diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_rt_namespace_harmonization.md b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_rt_namespace_harmonization.md new file mode 100644 index 00000000..bc7398e1 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/AI_docs/TODO_rt_namespace_harmonization.md @@ -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: `.rt_` 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. diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py index 14dfedc8..dd454927 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/omny_general_tools.py @@ -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": diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py index 6fc8e641..829a0adc 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py @@ -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") diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md index 62e28572..a345ed9f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/AI_docs/SIMULATED_ENDSTATIONS.md @@ -138,10 +138,69 @@ Passed: sample storage + GUI, moves incl. live progress bars and Ctrl-C, rotatio angle coupling, feedback enable/disable/status, tracker, fermat scans incl. progress and abort, X-ray eye GUI with camera frames, storage bookkeeping. Pending: full transfer chain end-to-end, tomo_scan (unblocked by the scilog fix), tomo queue executor -(crash-resume checklist), then queue-reordering implementation. LamNI/OMNY: harness- -validated; a short live smoke test (config load, a move, one scan, one OMNY transfer) -recommended before relying on them, since flOMNI testing surfaced integration issues the -harness cannot see. +(crash-resume checklist), then queue-reordering implementation. LamNI: harness-validated; +live smoke test still pending. + +**OMNY (2026-08-26): live smoke test run, config load / move / transfer passed, scan +blocked by a real (pre-existing, non-sim) bug.** Fixes made along the way (separate from +this doc's sim-package scope, see the repo's git history for exact diffs): +- `bec_ipython_client/startup/post_startup.py`: `--session omny` imported `OMNY` from the + `flomni` plugin package (which doesn't export it) instead of `omny` — `ImportError` on + every OMNY session start. +- `omny/x_ray_eye_align.py`: `XrayEyeAlign.__init__` called `self.lamni.align.reset_*()` + on the OMNY instance mid-construction (`OMNY.__init__` does + `self.align = XrayEyeAlign(self.client, self)`, so `.align` doesn't exist yet) — + `AttributeError` on every `OMNY()` construction. This file is otherwise still an + unadapted copy of LamNI's `x_ray_eye_align.py` (docstrings, types, and `self.lamni` + throughout) — the same kind of OMNY-specific adaptation `LamNI`'s copy already got is + still open as future work. +- `omny/gui_tools.py`: `OMNYGuiTools.__init__` did `self.gui.windows["main"].widget` + unconditionally — `KeyError` in any session without an open BEC-widgets "main" window + (e.g. `--nogui`). Guarded to fall back to `None`, matching flomni's `GuiTools` pattern + (`self.flomni_window`, populated lazily by `flomnigui_show_gui()`). +- Two *environment* fixes, not code bugs, but required before any of the above could even + be exercised: `csaxs_bec` and `bec_widgets` were both pip-installed editable pointing at + stale pre-repo-reorganization paths (`~/bec/csaxs_bec`, `~/bec/bec_widgets`, neither + exists) instead of their current location under `~/bec/bec/...` — `bec-server` couldn't + import its own plugins, which is what "bec-server doesn't start" actually was. +- `omny_e2e_tests/sim_flomni_harness.py`: `FakeDeviceManager` had no `.connector` — + `rt_lamni_ophyd.py`'s `feedback_enable_with_reset()` gained a + `device_manager.connector.send_client_info(...)` call (commit `f736900`) the harness + mock never picked up. Added a no-op `FakeConnector`. +- `omny_e2e_tests/sim_lamni_omny_harness.py`: imported the pre-rename module path + `csaxs_bec.scans.LamNIFermatScan` (now `lamni_components.LamNIComponents`, a + `@staticmethod`) — `ModuleNotFoundError`. Both harnesses pass cleanly again (42 flOMNI + + 32 LamNI/OMNY checks) after these two fixes. + +Checklist result against a live local sim deployment (`simulated_omny.yaml`, +`bec --session omny`): +- **Config load**: pass — `bec.config.update_session_with_file(...)`, `OMNY(bec)` + constructs cleanly, all `simulated_omny.yaml` devices visible on `dev.*`. +- **A move**: pass — `umv(dev.osamx, ...)`/`umv(dev.osamy, ...)`, live progress bar, + correct readback. +- **One OMNY transfer**: pass — `omny.otransfer_park_slot(6)` drove `oparkz` to the + expected `-0.9` with progress reporting and no errors. +- **One scan**: **blocked**, real bug found (not fixed — needs Mirko's confirmation of + intended hardware semantics before touching it). `osamroy` (the rotation axis, axis `C` + on the port-8083 controller) has a permanent, documented `+25`° display offset applied + only on read (`OMNYGalilReadbackSignal._socket_get`, `ogalil_ophyd.py:56-60` — "we + introduce an offset of 25 to the rotation axis... that way we just do tomography from 0 + to 180 degrees"). Nothing subtracts that 25 back out on the write side + (`OMNYGalilMotor.move()`, same file) or in its own success check, so + `readback == raw + 25` is compared against the raw target passed to `.move()` — every + `.move()`/`umv()` call on `osamroy` fails its success check by exactly 25, unconditionally + (confirmed: readback stays fixed at the boot value 25.0 regardless of target; `osamx`/ + `osamy` on the same controller move correctly, since only this one axis has the offset). + This isn't sim-specific — `ogalil_ophyd.py` is real, unmodified device code, so the sim + is faithfully reproducing a genuine bug. It unconditionally blocks + `feedback_enable_with_reset()`'s "rotate to zero before enabling feedback" precondition + (`rt_omny_ophyd.py:526-533`), which blocks every scan needing interferometer feedback — + i.e. effectively all of them. A correct fix needs to decide, with domain input: should + `OMNYGalilMotor.move()` convert the caller's display-space target to raw before sending + it over the wire for this axis (so `.move(0)` means "go to displayed zero"), or should + callers already pass raw targets (in which case `feedback_enable_with_reset()`'s + `osamroy.obj.move(0, ...)` and its `np.fabs(readback) > 0.1` guard are the ones passing + the wrong space)? Either reading is plausible from the code alone. ## Operational notes diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.md b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.md new file mode 100644 index 00000000..908c998d --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.md @@ -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). diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.pdf b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.pdf index 81ce9c8a..b69bb94a 100644 Binary files a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.pdf and b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_calibrate_the_fzp_scanner.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.md b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.md new file mode 100644 index 00000000..a3c8829f --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.md @@ -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). diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.pdf b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.pdf index 66e18ac1..77b6089e 100644 Binary files a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.pdf and b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-howto_limit_the_fzp_scanner_range.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.md b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.md new file mode 100644 index 00000000..e3417883 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.md @@ -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("")` 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("")` + +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. diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.pdf b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.pdf new file mode 100644 index 00000000..f8f45098 Binary files /dev/null and b/csaxs_bec/bec_ipython_client/plugins/flomni/docs/local_contact_only-realtime_system.pdf differ diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index d73dc991..e561a3c7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -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::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 diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py index f31dd04f..0703a04a 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_optics_mixin.py @@ -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 diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_realtime_system.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_realtime_system.py new file mode 100644 index 00000000..598d262f --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_realtime_system.py @@ -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://: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(""), 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)) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py index 7538e26c..105fc6ef 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni_webpage_generator.py @@ -43,7 +43,6 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.webpage_generator_base imp _safe_get, _safe_float, ) -from csaxs_bec.devices.omny.sample_desc_codec import unpack_desc class FlomniWebpageGenerator(WebpageGeneratorBase): @@ -106,19 +105,16 @@ class FlomniWebpageGenerator(WebpageGeneratorBase): # ── Sample name ─────────────────────────────────────────────── # slot 0 of flomni_samples is the flOMNI measurement stage itself # (see FlomniSampleStorage.show_all()'s own "flOMNI stage:" label - # for slot 0 -- not an arbitrary tray position). The raw DESC value - # is packed as "name | owner" (sample_desc_codec.pack_desc, added - # 7e8e807) -- unpack it here too, the same way - # FlomniSampleStorage.get_sample_name_and_owner() and show_all() - # already do, so the page doesn't show the raw delimiter-joined - # string as the "sample name". Owner is appended in parentheses, - # mirroring show_all()'s own "(owner: ...)" formatting. - raw_sample = _safe_get(dm, "flomni_samples.sample_names.sample0") - if raw_sample is None: + # for slot 0 -- not an arbitrary tray position). Name and owner are + # separate IOC fields since 2026-10-17 (FLOMNI-SAMPLE:STAGE:NAME / + # :OWNER); owner is appended in parentheses, mirroring show_all()'s + # own "(owner: ...)" formatting. + name = _safe_get(dm, "flomni_samples.sample_names.sample0") + if name is None: sample_name = "N/A" else: - name, owner = unpack_desc(raw_sample) - sample_name = f"{name} (owner: {owner})" if owner else name + owner = _safe_get(dm, "flomni_samples.sample_owners.sample0") + sample_name = f"{name} (owner: {owner})" if owner else str(name) # ── Temperatures ────────────────────────────────────────────── temperatures = { diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py index 606394dd..5d4d6939 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/x_ray_eye_align.py @@ -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) diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md new file mode 100644 index 00000000..b73cb7a8 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/MACHINE_VISION_AUTOCONFIRM_PLAN.md @@ -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. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md new file mode 100644 index 00000000..651527d7 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md @@ -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()`) 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()` + 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: '' 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::...` since 2026-10-17, `OMNY-SAMPLE::USED/ +NAME/OWNER/STATUS/MEAS_DATE` on OMNY's caproto IOC `ioc_omny.py`); the status is an enum plus a +date, the status x account packing above and `_MeasuredSampleLog` are gone, `transfer_sample()` +moves the whole record, and `tomo_scan()` sets started/completed on the STAGE record (as flOMNI). + +## Reference images now saved at every manual confirm gate in otransfer_get_sample/put_sample -- flomni has no equivalent gate to save at + +`_otransfer_save_reference_images()` used to be called only from inside `_otransfer_confirm()`'s +"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 ` → `Editable project location` first. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md new file mode 100644 index 00000000..21f47040 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/SAMPLE_STORAGE_TEST_CHECKLIST.md @@ -0,0 +1,66 @@ +# OMNY sample-storage testing checklist (as of 2026-09-04) + +For live/sim testing of this branch's (`update-omny`) sample-storage work: the +`OMNY_SampleStorage` widget's OMNY support, the real owner + measured-status EPICS +channel, and the `omny.otransfer_*` CLI wrappers. Written so this can be pulled and +run from a separate session/machine. See `OPEN_ISSUES.md` for the broader OMNY +live-testing effort this is part of. + +## Widget — `OMNY_SampleStorage` + +- [ ] Open the widget in an OMNY session — header (stage+gripper) + fixed-O grid + 3 + shuttle group boxes render, no "no known setup" banner. +- [ ] Occupied slots show names in legible text (not near-invisible — this was a + dark-theme contrast bug, fixed). +- [ ] Right-click an empty slot → **New sample…** → name + owner (both required, + empty owner rejected) → slot fills in without an error (a `STATUS_NONE` + crash here was fixed — client-side device proxies don't expose plain class + attributes, only signals/USER_ACCESS methods). +- [ ] Duplicate name (matching another occupied slot) → rejected with a clear + message; renaming a slot to its own existing name is still fine. +- [ ] Right-click an occupied slot → menu shows only **Change name…** / **Clear + (set empty)** — no "Set measured status…" (removed; status is CLI-only now). +- [ ] **Clear (set empty)** works without error. +- [ ] Each shuttle's status line: right-click → **Set parking slot…** (1-6) / + **Clear parking assignment** — shows "Slot N" / "not parked". The + "● ACTIVE" badge needs `oparkz` sitting at a real target position (2-6); + may not be easy to trigger from sim unless you drive that motor there. +- [ ] **Print** button builds a PDF (expect a "printer not available" message + unless you have a `WSLA_X12SA` CUPS queue locally — not a bug). + +## CLI (`omny.otransfer_*`) + +- [ ] Tab-complete `omny.otransfer_` — lists ~22 commands (`otransfer_show_all`, + `otransfer_set_sample_status`, `otransfer_get_sample_owner`, etc.). +- [ ] `omny.otransfer_help()` — prints the transfer commands **and then the full + `dev.omny_samples.help()` reference** below it, in one call. +- [ ] `omny.otransfer_show_all()` — table shows `(owner)` and + `◐ started`/`✓ completed` suffixes on occupied slots. +- [ ] `dev.omny_samples.set_sample_slot('A', 4, 'testsample', 'yourname')` then + `dev.omny_samples.get_sample_name_and_owner('A', 4)` round-trips. +- [ ] `omny.otransfer_set_sample_status('A', 1, 'started')` / `'completed'` / + `'none'` — status is settable from the CLI (by design; not from the widget). +- [ ] `omny.otransfer_park_loadlock_slot(6)` → answer **y** to "in back position" + → should now proceed (drive to the slot) instead of raising + `OMNYTransferError` (was inverted — pre-existing bug, fixed); answer **n** + → should correctly abort with that error. + +## Transfer reference-image saving + +Depends on whether your session drives the Galil `MGconfirm`/`MGmntprgs` +handshake (`_otransfer_confirm()`) — may not fire in a lightweight sim. + +- [ ] Run `omny.otransfer_put_sample(...)` / `otransfer_get_sample(...)` through a + confirm checkpoint → check `~/data/raw/logs/omny_sample_transfer_images/` + for a new `.h5` file with both cameras of whichever pair was active + (parking `cam200`/`cam203` vs stage `cam201`/`cam202`). +- [ ] If the handshake doesn't fire: `omny._otransfer_save_reference_images(file_suffix='test')` + after opening a camera view (e.g. `omny.otransfer_park_slot(3)`) to set + `_omnycam_active_pair` — same effect, minus the physical move. + +## X-ray eye alignment — known incomplete, not this branch's scope + +The fit+image saving in `x_ray_eye_align.py` is shared code (also used by FlOMNI/LamNI) +and should already work in principle, but it has pre-existing OMNY/LamNI method-name +mismatches (e.g. `self.lfzp_in()`) that likely block the actual click-through run from +completing for OMNY. Not fixed this pass — flag if you hit it. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_flomni_review_2026-10_findings.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_flomni_review_2026-10_findings.md new file mode 100644 index 00000000..0b476053 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TODO_flomni_review_2026-10_findings.md @@ -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. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md new file mode 100644 index 00000000..10757976 --- /dev/null +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md @@ -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. diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py index 76d1757f..367248a2 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/gui_tools.py @@ -1,12 +1,16 @@ import builtins +import time -# from csaxs_bec.bec_ipython_client.plugins.cSAXS import epics_get, epics_put, fshopen, fshclose +from bec_lib import bec_logger + +logger = bec_logger.logger if builtins.__dict__.get("bec") is not None: bec = builtins.__dict__.get("bec") dev = builtins.__dict__.get("dev") scans = builtins.__dict__.get("scans") + def umv(*args): return scans.umv(*args, relative=False) @@ -16,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 = ( "
"
             + "██████╗ ███████╗ ██████╗      ██████╗ ███╗   ███╗███╗   ██╗██╗   ██╗\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}")
diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
index 34b18556..219d42b1 100644
--- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
+++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py
@@ -21,6 +21,7 @@ from csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin import
     OMNYSampleTransferMixin,
 )
 from csaxs_bec.bec_ipython_client.plugins.omny.x_ray_eye_align import XrayEyeAlign
+from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueMixin
 
 logger = bec_logger.logger
 
@@ -29,9 +30,11 @@ if builtins.__dict__.get("bec") is not None:
     dev = builtins.__dict__.get("dev")
     scans = builtins.__dict__.get("scans")
 
+
 def umv(*args):
     return scans.umv(*args, relative=False)
 
+
 class OMNYInitError(Exception):
     pass
 
@@ -40,6 +43,93 @@ class OMNYError(Exception):
     pass
 
 
+# BEC accounts (bec.active_account, e.g. "e12345") allowed to select advanced tomo
+# modes (golden ratio, 4/8 sub-tomogram) in OMNY.tomo_parameters(). Regular users only
+# ever see "1: 2 equally spaced sub-tomograms". active_account is a Redis-backed
+# pgroup value, set explicitly via `bec-set-account ` -- it is NOT derived
+# from the OS login, so testing as one of these accounts requires either running
+# `bec-set-account ` first, or (dev/test sessions where active_account is
+# unset) simply being logged in as that OS user, since _is_superuser_account()
+# falls back to os.getenv("USER") when active_account is blank. "holler" is
+# Mirko's account, used for live testing this branch.
+_SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({"gac-x01dc", "holler"})
+
+
+class _ProgressProxy:
+    """Dict-like proxy that persists the OMNY progress dict as a BEC global variable.
+
+    Every read (`proxy["key"]`) fetches the current dict from the global var store,
+    and every write (`proxy["key"] = val`) fetches, updates, and saves it back.
+    This makes the progress state visible to all BEC client sessions via
+    ``client.get_global_var("tomo_progress")`` -- same key and shape as
+    flomni's/lamni's own progress proxy, since only one tomography setup is
+    ever live against a given BEC session. Direct port of
+    ``flomni.py``'s/``lamni.py``'s identical class.
+    """
+
+    _GLOBAL_VAR_KEY = "tomo_progress"
+    _DEFAULTS: dict = {
+        "subtomo": 0,
+        "subtomo_projection": 0,
+        "subtomo_total_projections": 1,
+        "projection": 0,
+        "total_projections": 1,
+        "angle": 0,
+        "tomo_type": 0,
+        "tomo_start_time": None,
+        "estimated_remaining_time": None,
+        "estimated_finish_time": None,
+        "heartbeat": None,
+        "accumulated_idle_time": 0.0,
+    }
+
+    def __init__(self, client):
+        self._client = client
+
+    # ------------------------------------------------------------------
+    # Internal helpers
+    # ------------------------------------------------------------------
+    def _load(self) -> dict:
+        val = self._client.get_global_var(self._GLOBAL_VAR_KEY)
+        if val is None:
+            return dict(self._DEFAULTS)
+        return val
+
+    def _save(self, data: dict) -> None:
+        self._client.set_global_var(self._GLOBAL_VAR_KEY, data)
+
+    # ------------------------------------------------------------------
+    # Dict-like interface
+    # ------------------------------------------------------------------
+    def __getitem__(self, key):
+        return self._load()[key]
+
+    def __setitem__(self, key, value) -> None:
+        data = self._load()
+        data[key] = value
+        self._save(data)
+
+    def __repr__(self) -> str:
+        return f"{self.__class__.__name__}({self._load()!r})"
+
+    def get(self, key, default=None):
+        return self._load().get(key, default)
+
+    def update(self, *args, **kwargs) -> None:
+        """Update multiple fields in a single round-trip."""
+        data = self._load()
+        data.update(*args, **kwargs)
+        self._save(data)
+
+    def reset(self) -> None:
+        """Reset all progress fields to their default values."""
+        self._save(dict(self._DEFAULTS))
+
+    def as_dict(self) -> dict:
+        """Return a plain copy of the current progress state."""
+        return self._load()
+
+
 class OMNYInitStagesMixin:
 
     def enable_all_devices(self):
@@ -546,6 +636,7 @@ class OMNYInitStagesMixin:
 
 
 class OMNY(
+    TomoQueueMixin,
     OMNYInitStagesMixin,
     OMNYSampleTransferMixin,
     OMNYAlignmentMixin,
@@ -554,6 +645,76 @@ class OMNY(
     OMNY_rt_client,
     OMNYGuiTools,
 ):
+    # tomo_type values that behave like "type 1" for tomo_queue_reacquire()
+    # (TomoQueueMixin) -- OMNY has three "equally spaced sub-tomograms"
+    # flavors (1/4/5 -> 2/4/8 sub-tomograms), unlike flomni/lamni's single
+    # type-1 flavor (the mixin's default, frozenset({1})).
+    _TOMO_TYPE_1_LIKE: frozenset = frozenset({1, 4, 5})
+
+    # Property names a tomo_queue_add() job snapshots (TomoQueueMixin
+    # contract). Mirrors flomni's own tuple exactly, since OMNY's properties
+    # already mirror flomni's names 1:1 -- no tomo_angle_range/single_point_*/
+    # frames_per_trigger, since OMNY has no such properties.
+    # zero_deg_reference_at_each_subtomo *is* included (unlike the above) --
+    # it applies to any equally spaced tomo type, not just type 1, see
+    # tomo_scan()'s type-1-like loop and its own property docstring below.
+    _TOMO_SCAN_PARAM_NAMES = (
+        "tomo_countingtime",
+        "tomo_shellstep",
+        "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",
+    )
+
+    # Curated allow-list for the "move" queue action -- same shape as
+    # flomni's/lamni's own (TomoQueueMixin contract). "ccm_energy", not
+    # "mokev" -- OMNY has no mokev device in any config (real or
+    # simulated); ccm_energy (bl_optics_hutch.yaml) is always present
+    # regardless of active endstation.
+    _TOMO_QUEUE_MOVE_DEVICES = {
+        "ccm_energy": {"label": "Energy", "unit": "keV"},
+        "idgap": {"label": "Undulator gap", "unit": "mm"},
+    }
+
+    # Same move/optimize_idgap registry as flomni/lamni (TomoQueueMixin
+    # contract) -- optimize_idgap stays a stub (TomoQueueMixin.optimize_idgap
+    # is a no-op), matching both.
+    _TOMO_QUEUE_ACTIONS = {
+        "move": {
+            "func": "_queue_action_move",
+            "idempotent": True,  # absolute moves re-run harmlessly
+            "help": "Move device(s) to absolute position(s).",
+            "params": {"positions": {"type": "device_positions"}},
+        },
+        "optimize_idgap": {
+            "func": "optimize_idgap",
+            "idempotent": True,
+            "help": "Scan idgap over a search range and move to the peak. STUB: no-op.",
+            "params": {
+                "search_range": {
+                    "type": "float",
+                    "default": 0.5,
+                    "min": 0.0,
+                    "max": 2.0,
+                    "unit": "mm",
+                    "label": "Search range",
+                }
+            },
+        },
+    }
+
     def __init__(self, client):
         super().__init__()
         self.client = client
@@ -574,18 +735,68 @@ class OMNY(
         self.corr_angle_y = []
         self.corr_pos_y_2 = []
         self.corr_angle_y_2 = []
-        self.progress = {}
-        self.progress["subtomo"] = 0
-        self.progress["subtomo_projection"] = 0
-        self.progress["subtomo_total_projections"] = 1
-        self.progress["projection"] = 0
-        self.progress["total_projections"] = 1
-        self.progress["angle"] = 0
-        self.progress["tomo_type"] = 0
+        # Progress tracking, persisted via the BEC global variable
+        # "tomo_progress" so it survives a client restart -- deliberately NOT
+        # reset here: this proxy is constructed every time OMNY() is
+        # instantiated (i.e. every new client session), and resetting
+        # unconditionally on every instantiation would wipe tomo_start_time
+        # (and everything else) on exactly the restarts where recovering
+        # scan state matters most. A genuinely new tomo_scan() call resets
+        # the relevant fields itself (see the "new scan" branch in
+        # tomo_scan()).
+        self._progress_proxy = _ProgressProxy(self.client)
         self.OMNYTools = OMNYTools(self.client)
+        self._init_tomo_queue()
         OMNY_rt_client.__init__(self)
         self.align = XrayEyeAlign(self.client, self)
-        OMNYGuiTools.__init__(self, self.client)
+        OMNYGuiTools.__init__(self)
+        self.set_client(self.client)
+
+    @property
+    def progress(self) -> _ProgressProxy:
+        """Proxy dict backed by the BEC global variable ``tomo_progress``.
+
+        Readable from any BEC client session via::
+
+            client.get_global_var("tomo_progress")
+
+        Individual fields can be read and written just like a regular dict::
+
+            omny.progress["projection"]          # read
+            omny.progress["projection"] = 42     # write (persists immediately)
+
+        To update multiple fields atomically use :py:meth:`_ProgressProxy.update`::
+
+            omny.progress.update(projection=42, angle=90.0)
+
+        To reset all fields to their defaults::
+
+            omny.progress.reset()
+        """
+        return self._progress_proxy
+
+    @progress.setter
+    def progress(self, val: dict) -> None:
+        """Replace the entire progress dict.
+
+        Accepts a plain :class:`dict` and persists it to the global var store.
+        """
+        if not isinstance(val, dict):
+            raise TypeError(f"progress must be a dict, got {type(val).__name__!r}")
+        self._progress_proxy._save(val)
+
+    def tomo_progress_reset(self) -> None:
+        """Explicitly clear the persisted tomo progress (start time, ETA,
+        current angle/subtomo/projection, accumulated idle time, ...).
+
+        Not called automatically on OMNY() startup -- that would wipe an
+        in-progress scan's state on every kernel restart. Call this by hand
+        if you want a clean progress display without it being tied to
+        starting a new tomo_scan() (which already resets the relevant
+        fields itself).
+        """
+        self._progress_proxy.reset()
+        print("Tomo progress reset.")
 
     def start_x_ray_eye_alignment(self):
         if self.OMNYTools.yesno(
@@ -744,6 +955,12 @@ class OMNY(
         elif val == 3:
             # equally spaced tomography with starting angles shifted by golden ratio
             self.client.set_global_var("tomo_type", val)
+        elif val == 4:
+            # equally spaced tomography with 4 sub tomograms
+            self.client.set_global_var("tomo_type", val)
+        elif val == 5:
+            # equally spaced tomography with 8 sub tomograms
+            self.client.set_global_var("tomo_type", val)
         else:
             raise ValueError("Unknown tomo_type.")
 
@@ -837,6 +1054,30 @@ class OMNY(
     def golden_projections_at_0_deg_for_damage_estimation(self, val: float):
         self.client.set_global_var("golden_projections_at_0_deg_for_damage_estimation", val)
 
+    @property
+    def zero_deg_reference_at_each_subtomo(self):
+        """If True (tomo_type in _TOMO_TYPE_1_LIKE, i.e. 1/4/5 -- the equally
+        spaced sub-tomogram modes), an additional projection at exactly 0
+        degrees is acquired at the start of every odd (forward) sub-tomogram
+        -- i.e. every time the rotation passes back through 0 degrees,
+        including sub-tomogram 1 itself, which already lands on 0 degrees
+        naturally -- and once more after the final sub-tomogram completes.
+        Together with sub-tomogram 1's own first projection, this gives two
+        consecutive 0-degree shots right at the start (deliberate: useful as
+        an immediate damage/noise-floor baseline pair) and one more at every
+        later natural pass through 0, useful for tracking radiation damage
+        over time. Mirrors flomni's property of the same name, and
+        golden_projections_at_0_deg_for_damage_estimation, which provides
+        similar functionality for tomo_type 2/3."""
+        val = self.client.get_global_var("zero_deg_reference_at_each_subtomo")
+        if val is None:
+            return False
+        return val
+
+    @zero_deg_reference_at_each_subtomo.setter
+    def zero_deg_reference_at_each_subtomo(self, val: bool):
+        self.client.set_global_var("zero_deg_reference_at_each_subtomo", val)
+
     @property
     def golden_ratio_bunch_size(self):
         val = self.client.get_global_var("golden_ratio_bunch_size")
@@ -852,17 +1093,36 @@ class OMNY(
     def sample_name(self):
         return dev.omny_samples.get_sample_name_in_samplestage()
 
-    def write_to_scilog(self, content, tags: list = None):
+    def write_to_scilog(self, content, tags: list = None) -> bool:
+        """Write a text entry to scilog, tolerating scilog being disabled or
+        unreachable. Mirrors flomni's _scilog_write(): checks
+        scilog._enabled before attempting a send, rather than trying and
+        catching whatever exception (or hang) an unconfigured/unreachable
+        scilog produces -- the old bec.logbook/client.logbook.
+        send_logbook_message() API used here previously had no such check,
+        so a session without scilog credentials configured would block
+        indefinitely on send() instead of failing fast (confirmed live: a
+        real tomo_scan() hung inside _write_subtomo_to_scilog() on a
+        session with no /etc/bec/secrets/.scilog.env at all).
+
+        Returns True if the entry was sent, False if it was skipped/failed
+        (already logged as a warning either way -- callers that don't care
+        about the outcome can ignore the return value).
+        """
+        scilog = getattr(bec.messaging, "scilog", None)
+        if scilog is None or not getattr(scilog, "_enabled", False):
+            logger.warning("scilog is not enabled; skipping scilog entry.")
+            return False
+        if tags is not None:
+            tags.append("BEC")
+        else:
+            tags = ["BEC"]
         try:
-            if tags is not None:
-                tags.append("BEC")
-            else:
-                tags = ["BEC"]
-            msg = bec.logbook.LogbookMessage()
-            msg.add_text(content).add_tag(tags)
-            self.client.logbook.send_logbook_message(msg)
-        except Exception:
-            logger.warning("Failed to write to scilog.")
+            scilog.new().add_text(content.replace("\n", "
")).add_tags(tags).send() + return True + except Exception as exc: + logger.warning(f"Failed to write to scilog: {exc}") + return False def tomo_alignment_scan(self): """ @@ -925,41 +1185,71 @@ class OMNY( tags, ) - def sub_tomo_scan(self, subtomo_number, start_angle=None): + @staticmethod + def _equally_spaced_subtomo_angles( + subtomo_number, n_subtomos, tomo_angle_stepsize, start_angle=None + ): + """ + Compute the projection angles for one sub-tomogram of an N-way, bit-reversal + interleaved, equally spaced 180 degree tomogram (n_subtomos a power of two: + 2, 4, or 8). Ported from flomni's ``_subtomo_angle_plan()`` + (flomni/flomni.py), with the 180-vs-360-degree-range branch removed since + OMNY only ever scans 180 degrees. + + Odd sub-tomograms scan forward (ascending), even sub-tomograms scan in + reverse (descending, to minimize travel between sub-tomograms). Each + sub-tomogram's phase offset within a step is picked via a bit-reversal + permutation of 0..n_subtomos-1, so that successive sub-tomograms maximally + subdivide the angular gaps left by the previous ones. For n_subtomos=2 this + reduces to phases {0, step/2} -- the original OMNY scheme. + + Always returns exactly ``int(180 / tomo_angle_stepsize)`` angles (no + redundant 0/180 duplicate point), matching the convention already used by + flomni's and lamni's own sub_tomo_scan(). + + Args: + subtomo_number (int): 1-indexed sub-tomogram number. + n_subtomos (int): Total number of sub-tomograms (2, 4, or 8). + tomo_angle_stepsize (float): Angular step size in degrees. + start_angle (float, optional): Explicit start angle override. Defaults + to the bit-reversal phase offset described above. + """ + n_points = int(180.0 / tomo_angle_stepsize) + step = 180.0 / n_points + + bits = n_subtomos.bit_length() - 1 + subtomo_index = subtomo_number - 1 + phase_index = int(format(subtomo_index, f"0{bits}b")[::-1], 2) if bits else 0 + phase = step / n_subtomos * phase_index + + forward = bool(subtomo_number % 2) + if start_angle is None: + start_angle = phase if forward else (180.0 - step + phase) + + if forward: + return start_angle + np.arange(n_points) * step + return start_angle - np.arange(n_points) * step + + def sub_tomo_scan(self, subtomo_number, n_subtomos=2, start_angle=None): """ Performs a sub tomogram scan. Args: subtomo_number (int): The sub tomogram number. + n_subtomos (int): Total number of sub-tomograms in this scan (2, 4, or 8). start_angle (float, optional): The start angle of the scan. Defaults to None. """ self._write_subtomo_to_scilog(subtomo_number) - if start_angle is None: - if subtomo_number == 1: - start_angle = 0 - elif subtomo_number == 2: - start_angle = self.tomo_angle_stepsize / 2.0 - - # _tomo_shift_angles (potential global variable) - _tomo_shift_angles = 0 - angle_end = start_angle + 180 - angles = np.linspace( - start_angle + _tomo_shift_angles, - angle_end, - num=int(180 / self.tomo_angle_stepsize) + 1, - endpoint=True, + angles = self._equally_spaced_subtomo_angles( + subtomo_number, n_subtomos, self.tomo_angle_stepsize, start_angle ) - # reverse even sub-tomogram - if not (subtomo_number % 2): - angles = np.flip(angles) - for angle in angles: + n_points = len(angles) + for idx, angle in enumerate(angles): self.progress["subtomo"] = subtomo_number - self.progress["subtomo_projection"] = np.where(angles == angle)[0][0] - self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize - self.progress["projection"] = (subtomo_number - 1) * self.progress[ - "subtomo_total_projections" - ] + self.progress["subtomo_projection"] - self.progress["total_projections"] = 180 / self.tomo_angle_stepsize * 2 + self.progress["subtomo_projection"] = idx + self.progress["subtomo_total_projections"] = n_points + self.progress["projection"] = (subtomo_number - 1) * n_points + idx + self.progress["total_projections"] = n_points * n_subtomos self.progress["angle"] = angle self._tomo_scan_at_angle(angle, subtomo_number) @@ -967,9 +1257,42 @@ class OMNY( successful = False error_caught = False if 0 <= angle < 180.05: + now = datetime.datetime.now() + prev_heartbeat_str = self.progress.get("heartbeat") + if prev_heartbeat_str is not None: + gap = (now - datetime.datetime.fromisoformat(prev_heartbeat_str)).total_seconds() + # Normal cadence between consecutive projections is roughly the + # acquisition time plus motor/readout overhead. A gap well + # beyond that means something interrupted the scan in between + # (beamline-down interlock pause, a crash + manual restart, + # ...) -- attribute the excess to idle time so it doesn't drag + # down the apparent scan rate used for the ETA below. Mirrors + # flomni's identical heuristic (minus its frames_per_trigger + # factor -- OMNY has no such property). The 5x/60s margins + # are a heuristic, not a precise timing model -- tune if it + # over/under-triggers in practice. + normal_cadence = max(60.0, 5 * self.tomo_countingtime) + if gap > normal_cadence: + idle = gap - normal_cadence + self.progress["accumulated_idle_time"] = ( + self.progress.get("accumulated_idle_time", 0.0) + idle + ) + print( + f"Detected a {self._format_duration(gap)} gap since the last projection" + f" -- excluding {self._format_duration(idle)} from the ETA estimate." + ) + self.progress["heartbeat"] = now.isoformat() print(f"Starting OMNY scan for angle {angle} in subtomo {subtomo_number}") + # _print_progress() refreshes the progress GUI itself (via + # _omnygui_update_progress()) -- do NOT also call + # omnygui_show_progress() here: that re-raises the window to + # front on every single projection (confirmed live: on macOS + # this looks like the window closing and reopening every + # update), not just updates its values/text. tomo_scan()'s own + # unconditional call at scan start already shows/raises it + # once. Mirrors flomni's _tomo_scan_at_angle(), which likewise + # only ever calls _print_progress() per angle. self._print_progress() - self.omnygui_show_progress() while not successful: self._start_beam_check() if not self.special_angles: @@ -1002,24 +1325,38 @@ class OMNY( for scan_nr in range(start_scan_number, end_scan_number): self._write_tomo_scan_number(scan_nr, angle, subtomo_number) - def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None): - """start a tomo scan""" + def tomo_scan( + self, subtomo_start=1, start_angle=None, projection_number=None, interactive: bool = True + ): + """start a tomo scan + + Args: + interactive: accepted for signature compatibility with + TomoQueueMixin.tomo_queue_execute(), which always calls + self.tomo_scan(interactive=False) for unattended queued runs + (matching flomni's/lamni's call convention) -- unused here, + OMNY has no fine-alignment gate. + """ + self.omnygui_show_progress() + bec = builtins.__dict__.get("bec") scans = builtins.__dict__.get("scans") self._current_special_angles = self.special_angles.copy() # a new tomo scan was started if ( - (self.tomo_type == 1 and subtomo_start == 1 and start_angle is None) + (self.tomo_type in (1, 4, 5) and subtomo_start == 1 and start_angle is None) or (self.tomo_type == 2 and projection_number == None) or (self.tomo_type == 3 and projection_number == None) ): # pylint: disable=undefined-variable + # bec.active_account is a plain str, not bytes -- .decode() used + # to crash with AttributeError on every real account. if bec.active_account != "": self.tomo_id = self.add_sample_database( self.sample_name, str(datetime.date.today()), - bec.active_account.decode(), + bec.active_account, bec.queue.next_scan_number, "OMNY", "test additional info", @@ -1028,112 +1365,346 @@ class OMNY( self.write_pdf_report() else: self.tomo_id = 0 + # measurement status on the sample record (sample stage) in the IOC + dev.omny_samples.set_sample_measured( + "STAGE", + 0, + "started", + datetime.date.today().strftime("%Y-%m"), + account=bec.active_account or "", + ) + self.progress["tomo_start_time"] = datetime.datetime.now().isoformat() + # reset stale estimates from any previous scan, otherwise the GUI + # would keep showing a leftover ETA from before this scan has + # accumulated enough projections to compute a fresh one + self.progress["estimated_remaining_time"] = None + self.progress["estimated_finish_time"] = None + self.progress["accumulated_idle_time"] = 0.0 + self.progress["heartbeat"] = None + self.collect_empty_frames() - with scans.dataset_id_on_hold: - if self.tomo_type == 1: - # 2 equally spaced sub-tomograms - self.progress["tomo_type"] = "Equally spaced sub-tomograms" - for ii in range(subtomo_start, 3): - self.sub_tomo_scan(ii, start_angle=start_angle) - start_angle = None - - elif self.tomo_type == 2: - # Golden ratio tomography - previous_subtomo_number = -1 - if projection_number == None: - ii = 0 - else: - ii = projection_number - while True: - angle, subtomo_number = self._golden(ii, self.golden_ratio_bunch_size, 180, 1) - if previous_subtomo_number != subtomo_number: - self._write_subtomo_to_scilog(subtomo_number) + try: + with scans.dataset_id_on_hold: + if self.tomo_type in (1, 4, 5): + # equally spaced sub-tomograms (2, 4, or 8) + n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type] + n_points = int(180.0 / self.tomo_angle_stepsize) + self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})" + for ii in range(subtomo_start, n_subtomos + 1): if ( - subtomo_number % 2 == 1 - and ii > 10 - and self.golden_projections_at_0_deg_for_damage_estimation == 1 + start_angle is None + and ii % 2 == 1 + and self.zero_deg_reference_at_each_subtomo ): - self._tomo_scan_at_angle(0, subtomo_number) - previous_subtomo_number = subtomo_number - self.progress["tomo_type"] = "Golden ratio tomography" - self.progress["subtomo"] = subtomo_number - self.progress["projection"] = ii - self.progress["angle"] = angle - if self.golden_ratio_bunch_size > 0: - self.progress["subtomo_total_projections"] = self.golden_ratio_bunch_size + # Dedicated reference shot at exactly 0 degrees, taken + # every time the rotation passes back through 0 + # (every odd/forward sub-tomogram -- see + # _equally_spaced_subtomo_angles()), for tracking + # radiation damage over the full tomogram. Fires + # for sub-tomogram 1 too even though its own first + # projection already lands on 0 -- two consecutive + # 0-degree shots right at the start is deliberate + # (immediate baseline pair), not skipped as + # redundant. Skipped when resuming mid-sub-tomogram + # (start_angle given explicitly) since we're not + # actually passing through 0 deg at that moment. + # + # progress["projection"]/["subtomo_projection"]/etc. + # are otherwise only ever set inside sub_tomo_scan() + # -- set them explicitly here too, or the printed + # progress for this shot shows whatever was left + # over from the previous scan/subtomo instead of a + # value that makes sense for it (confirmed live: a + # stale "Projection: 9 / Total: 32" from a prior + # run, on the very first projection of a fresh + # scan). Treated as sub-tomogram ii's own + # projection 0, matching what sub_tomo_scan() is + # about to set for its real first point right + # after this. + self.progress["subtomo"] = ii + self.progress["subtomo_projection"] = 0 + self.progress["subtomo_total_projections"] = n_points + self.progress["projection"] = (ii - 1) * n_points + self.progress["total_projections"] = n_points * n_subtomos + self._tomo_scan_at_angle(0, ii) + self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle) + start_angle = None + + if self.zero_deg_reference_at_each_subtomo: + # Final reference shot at exactly 0 degrees once the whole + # tomogram is complete, giving a clean "before vs after" + # pair for radiation-damage comparison across the full + # acquisition. One past the last real point of the final + # sub-tomogram, same progress-freshness reasoning as above. + self.progress["subtomo"] = n_subtomos + self.progress["subtomo_projection"] = n_points + self.progress["subtomo_total_projections"] = n_points + self.progress["projection"] = n_points * n_subtomos + self.progress["total_projections"] = n_points * n_subtomos + self._tomo_scan_at_angle(0, n_subtomos) + + elif self.tomo_type == 2: + # Golden ratio tomography + previous_subtomo_number = -1 + if projection_number == None: + ii = 0 + else: + ii = projection_number + while True: + angle, subtomo_number = self._golden( + ii, self.golden_ratio_bunch_size, 180, 1 + ) + if previous_subtomo_number != subtomo_number: + self._write_subtomo_to_scilog(subtomo_number) + if ( + subtomo_number % 2 == 1 + and ii > 10 + and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ): + self._tomo_scan_at_angle(0, subtomo_number) + previous_subtomo_number = subtomo_number + self.progress["tomo_type"] = "Golden ratio tomography" + self.progress["subtomo"] = subtomo_number + self.progress["projection"] = ii + self.progress["angle"] = angle + if self.golden_ratio_bunch_size > 0: + self.progress["subtomo_total_projections"] = ( + self.golden_ratio_bunch_size + ) + self.progress["subtomo_projection"] = ( + ii - (subtomo_number - 1) * self.golden_ratio_bunch_size + ) + else: + self.progress["subtomo_total_projections"] = 0 + self.progress["subtomo_projection"] = 0 + + if self.golden_max_number_of_projections > 0: + self.progress["total_projections"] = ( + self.golden_max_number_of_projections + ) + else: + self.progress["total_projections"] = 0 + + self._tomo_scan_at_angle(angle, subtomo_number) + ii += 1 + if ( + ii > self.golden_max_number_of_projections + and self.golden_max_number_of_projections > 0 + ): + print( + f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" + ) + break + + elif self.tomo_type == 3: + # Equally spaced tomography, golden ratio starting angle + previous_subtomo_number = -1 + if projection_number == None: + ii = 0 + else: + ii = projection_number + while True: + angle, subtomo_number = self._golden_equally_spaced( + ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0 + ) + if previous_subtomo_number != subtomo_number: + self._write_subtomo_to_scilog(subtomo_number) + if ( + subtomo_number % 2 == 1 + and ii > 10 + and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ): + self._tomo_scan_at_angle(0, subtomo_number) + previous_subtomo_number = subtomo_number + self.progress["tomo_type"] = ( + "Equally spaced tomography, golden ratio starting angle" + ) + self.progress["subtomo"] = subtomo_number + self.progress["projection"] = ii + self.progress["angle"] = angle + + self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize self.progress["subtomo_projection"] = ( - ii - (subtomo_number - 1) * self.golden_ratio_bunch_size + ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"] ) - else: - self.progress["subtomo_total_projections"] = 0 - self.progress["subtomo_projection"] = 0 - if self.golden_max_number_of_projections > 0: - self.progress["total_projections"] = self.golden_max_number_of_projections - else: - self.progress["total_projections"] = 0 - - self._tomo_scan_at_angle(angle, subtomo_number) - ii += 1 - if ( - ii > self.golden_max_number_of_projections - and self.golden_max_number_of_projections > 0 - ): - print( - f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" - ) - break - - elif self.tomo_type == 3: - # Equally spaced tomography, golden ratio starting angle - previous_subtomo_number = -1 - if projection_number == None: - ii = 0 - else: - ii = projection_number - while True: - angle, subtomo_number = self._golden_equally_spaced( - ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0 - ) - if previous_subtomo_number != subtomo_number: - self._write_subtomo_to_scilog(subtomo_number) + if self.golden_max_number_of_projections > 0: + self.progress["total_projections"] = ( + self.golden_max_number_of_projections + ) + else: + self.progress["total_projections"] = 0 + self._tomo_scan_at_angle(angle, subtomo_number) + ii += 1 if ( - subtomo_number % 2 == 1 - and ii > 10 - and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ii > self.golden_max_number_of_projections + and self.golden_max_number_of_projections > 0 ): - self._tomo_scan_at_angle(0, subtomo_number) - previous_subtomo_number = subtomo_number - self.progress["tomo_type"] = ( - "Equally spaced tomography, golden ratio starting angle" - ) - self.progress["subtomo"] = subtomo_number - self.progress["projection"] = ii - self.progress["angle"] = angle + print( + f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" + ) + break + else: + raise OMNYError("undefined tomo type") + finally: + # Cleared on every exit path (normal completion, exception, or + # interrupt) -- not just at the next scan's start above -- so the + # GUI's busy-detectors see this immediately on their next poll + # instead of waiting out the heartbeat-staleness timeout after a + # tomogram that's actually already done. Mirrors flomni's/lamni's + # identical fix. + self.progress["heartbeat"] = None - self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize - self.progress["subtomo_projection"] = ( - ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"] - ) + self.progress["projection"] = self.progress["total_projections"] + self.progress["subtomo_projection"] = self.progress["subtomo_total_projections"] + self._print_progress() + self.OMNYTools.printgreenbold("Tomoscan finished") + # measurement status on the sample record (sample stage) in the IOC + dev.omny_samples.set_sample_measured( + "STAGE", + 0, + "completed", + datetime.date.today().strftime("%Y-%m"), + account=bec.active_account or "", + ) - if self.golden_max_number_of_projections > 0: - self.progress["total_projections"] = self.golden_max_number_of_projections - else: - self.progress["total_projections"] = 0 - self._tomo_scan_at_angle(angle, subtomo_number) - ii += 1 - if ( - ii > self.golden_max_number_of_projections - and self.golden_max_number_of_projections > 0 - ): - print( - f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" - ) - break - else: - raise OMNYError("undefined tomo type") + idle_s = self.progress.get("accumulated_idle_time", 0.0) + start_str = self.progress.get("tomo_start_time") + elapsed_s = None + if start_str is not None: + try: + elapsed_s = ( + datetime.datetime.now() - datetime.datetime.fromisoformat(start_str) + ).total_seconds() + except (ValueError, TypeError): + elapsed_s = None + timing_lines = [ + "Tomoscan finished.", + f"Measurement ID: {self.tomo_id}", + f"Sample: {self.sample_name}", + ] + hook_description = self._describe_active_hook() + if hook_description: + timing_lines.append(f"At-each-angle hook: {hook_description}") + if elapsed_s is not None: + timing_lines.append(f"Total measurement time: {self._format_duration(elapsed_s)}") + timing_lines.append( + f"Total measurement time excluding detected gaps: {self._format_duration(elapsed_s - idle_s)}" + ) + timing_lines.append( + f"Total measurement time lost to detected gaps: {self._format_duration(idle_s)}" + ) + for line in timing_lines[3:]: + print(line) + timing_content = "\n".join(timing_lines) + hook_source = self._active_hook_source() + if hook_source: + timing_content += ( + f"\n\nAt-each-angle hook source ('{self.at_each_angle_hook}'):\n{hook_source}" + ) + self.write_to_scilog(timing_content, ["tomoscan"]) + + def tomo_scan_resume(self) -> None: + """Resume a tomo_scan() that crashed or was interrupted, picking up + automatically from wherever ``progress`` last reported -- no need to + read the last subtomo/angle/projection off the progress printout by + hand and pass it to tomo_scan() yourself. + + Re-attempts the exact angle/projection that was in progress at the + moment of interruption, rather than skipping ahead to the next one: + from here we can't be sure whether that last point was actually + acquired before the crash, and re-acquiring one extra projection is + harmless. + + Works standalone for a normal tomo_scan(), independent of the tomo + queue. tomo_queue_execute() (see TomoQueueMixin) also calls this + internally when resuming a job that previously failed partway + through, so a failed queue job picks up mid-scan rather than + restarting from subtomo 1 / angle 0. Direct port of flomni's/lamni's + identical method, generalized to OMNY's three equally-spaced- + sub-tomogram flavors (tomo_type 1/4/5) via _TOMO_TYPE_1_LIKE. + """ + if self.progress.get("tomo_start_time") is None: + print("No tomo scan in progress to resume -- nothing to do.") + return + + if self.tomo_type in self._TOMO_TYPE_1_LIKE: + subtomo_start = self.progress["subtomo"] + start_angle = self.progress["angle"] + if subtomo_start < 1: + print("No tomo scan in progress to resume -- nothing to do.") + return + print(f"Resuming tomo scan at subtomo {subtomo_start}, angle {start_angle:.3f} deg.") + self.tomo_scan(subtomo_start=subtomo_start, start_angle=start_angle) + elif self.tomo_type in (2, 3): + projection_number = self.progress["projection"] + print(f"Resuming tomo scan at projection {projection_number}.") + self.tomo_scan(projection_number=projection_number) + else: + raise OMNYError("undefined tomo type") + + def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]: + """Map a flat 0-indexed projection number to (subtomo_start, + start_angle), for tomo_queue_reacquire() (TomoQueueMixin) on any + tomo_type in _TOMO_TYPE_1_LIKE (1/4/5). + + Reuses _equally_spaced_subtomo_angles() (the single, already-verified + source of truth for this angle grid -- see + omny/AI_docs/OPEN_ISSUES.md) rather than re-deriving the math here. + """ + n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type] + N = int(180.0 / self.tomo_angle_stepsize) + total = N * n_subtomos + if not 0 <= projection_number < total: + raise ValueError( + f"projection_number must be in [0, {total}) for tomo_type " + f"{self.tomo_type} ({n_subtomos} sub-tomograms, {N} projections each), " + f"got {projection_number}." + ) + subtomo_start = projection_number // N + 1 + projection_within_subtomo = projection_number % N + angles = self._equally_spaced_subtomo_angles( + subtomo_start, n_subtomos, self.tomo_angle_stepsize + ) + start_angle = float(angles[projection_within_subtomo]) + return subtomo_start, start_angle + + @staticmethod + def _format_duration(seconds: float) -> str: + """Format a duration in seconds as a human-readable string, e.g. '2h 03m 15s'.""" + seconds = int(seconds) + h, remainder = divmod(seconds, 3600) + m, s = divmod(remainder, 60) + if h > 0: + return f"{h}h {m:02d}m {s:02d}s" + if m > 0: + return f"{m}m {s:02d}s" + return f"{s}s" def _print_progress(self): + # --- compute and store estimated remaining time -------------------- + start_str = self.progress.get("tomo_start_time") + projection = self.progress["projection"] + total = self.progress["total_projections"] + if start_str is not None and total > 0 and projection > 9: + now = datetime.datetime.now() + elapsed = (now - datetime.datetime.fromisoformat(start_str)).total_seconds() + # Exclude detected idle time (beamline-down pauses, a crash + + # restart gap, ...) so it doesn't make the scan look slower than + # it actually is while it's running. + elapsed -= self.progress.get("accumulated_idle_time", 0.0) + elapsed = max(elapsed, 1.0) # guard against a degenerate/negative denominator + rate = projection / elapsed # projections per second + remaining_s = (total - projection) / rate + self.progress["estimated_remaining_time"] = remaining_s + eta_str = self._format_duration(remaining_s) + finish_dt = now + datetime.timedelta(seconds=remaining_s) + self.progress["estimated_finish_time"] = finish_dt.isoformat() + finish_str = finish_dt.strftime("%Y-%m-%d %H:%M:%S") + else: + eta_str = "N/A" + finish_str = "N/A" + # --------------------------------------------------------------------- print("\x1b[95mProgress report:") print(f"Tomo type: ....................... {self.progress['tomo_type']}") print(f"Projection: ...................... {self.progress['projection']:.0f}") @@ -1141,9 +1712,10 @@ class OMNY( print(f"Angle: ........................... {self.progress['angle']}") print(f"Current subtomo: ................. {self.progress['subtomo']}") print(f"Current projection within subtomo: {self.progress['subtomo_projection']}") - print( - f"Total projections per subtomo: ... {self.progress['subtomo_total_projections']}\x1b[0m" - ) + print(f"Total projections per subtomo: ... {self.progress['subtomo_total_projections']}") + print(f"Estimated remaining time: ........ {eta_str}") + print(f"Estimated finish time: ........... {finish_str}\x1b[0m") + self._omnygui_update_progress() def add_sample_database( self, samplename, date, eaccount, scan_number, setup, sample_additional_info, user @@ -1158,6 +1730,12 @@ class OMNY( return tomo_number def _at_each_angle(self, angle: float) -> None: + hook_name = self.at_each_angle_hook + if hook_name: + hook = self._resolve_at_each_angle_hook(hook_name, OMNYError) + hook(self, angle) + return + if "omny_at_each_angle" in builtins.__dict__: # pylint: disable=undefined-variable omny_at_each_angle(self, angle) @@ -1214,7 +1792,7 @@ class OMNY( return angle, subtomo_number - def tomo_reconstruct(self, base_path="~/Data10/specES1"): + def tomo_reconstruct(self, base_path="~/data/raw"): """write the tomo reconstruct file for the reconstruction queue""" bec = builtins.__dict__.get("bec") base_path = os.path.expanduser(base_path) @@ -1232,9 +1810,16 @@ class OMNY( queue_file.write("p.check_nextscan_started 1\n") def _write_tomo_scan_number(self, scan_number: int, angle: float, subtomo_number: int) -> None: - tomo_scan_numbers_file = os.path.expanduser( - "~/Data10/specES1/dat-files/tomography_scannumbers.txt" - ) + # ~/data/raw/logs/ matches flomni's own _write_tomo_scan_number() -- + # the old ~/Data10/specES1/dat-files/ layout doesn't exist in the + # current deployment (confirmed live: FileNotFoundError on a fresh + # session). Unlike flomni's version, explicitly create the + # directory here rather than assuming something else already has -- + # flomni's own identical open("a+") call has the same latent + # assumption, just usually gets away with it because the directory + # already exists on deployed systems. + tomo_scan_numbers_file = os.path.expanduser("~/data/raw/logs/tomography_scannumbers.txt") + os.makedirs(os.path.dirname(tomo_scan_numbers_file), exist_ok=True) sample_name = self.sample_name.replace(" ", "_") with open(tomo_scan_numbers_file, "a+") as out_file: # pylint: disable=undefined-variable @@ -1242,6 +1827,64 @@ class OMNY( f"{scan_number} {angle} {dev.osamroy.read()['osamroy']['value']:.3f} {self.tomo_id} {subtomo_number} {0} {sample_name}\n" ) + def collect_empty_frames(self): + """Acquire 10 empty-frame (flat-field) images at angle 0, with osamx/rtx + shifted out of the beam by half the field of view (fovx/2), in the + direction opposite the normal alignment x-offset at angle 0. + + Called once at the start of a new tomo_scan() (not on resume). Mirrors + flomni's collect_empty_frames() (same purpose, same 10-frame count, + same subtomo_number=0 logging convention), adapted for OMNY's own + device names: rtx is moved directly to the shifted-out x offset, then + dev.osamx.omny_osamx_to_scan_center() recenters osamx too if it has + drifted far enough from where it should sit for this offset -- the + same recenter check every real projection already goes through + (wired into omny_fermat_scan.py's prepare_setup_part2(), not + anything specific to this method), reused as-is rather than + duplicated. + + These frames are deliberately not passed through tomo_reconstruct() + -- they are flat fields, not ptycho projections, and must not be + added to the reconstruction queue. Logged to + tomography_scannumbers.txt with subtomo_number=0 so they remain + traceable but are clearly distinguished from the angular grid. + """ + scans = builtins.__dict__.get("scans") + + angle = 0 + + # --- rotation --- + osamroy_current_setpoint = dev.osamroy.user_setpoint.get() + if angle != osamroy_current_setpoint: + umv(dev.osamroy, angle) + else: + print("No rotation required") + + # --- alignment offset at angle 0, then push x out of the beam by + # fovx/2, in the direction opposite the normal offset --- + offsets = self.get_alignment_offset(angle) + normal_offset_x = offsets[0] - self.compute_additional_correction_x(angle) + normal_offset_y = ( + offsets[1] + - self.compute_additional_correction_y(angle) + - self.compute_additional_correction_y_2(angle) + ) + normal_offset_z = offsets[2] + direction = -1 if normal_offset_x >= 0 else 1 + sum_offset_x = normal_offset_x + direction * (self.fovx / 2) + direction * 20 + + self.laser_tracker_on() + umv(dev.rtx, sum_offset_x, dev.rty, normal_offset_y, dev.rtz, normal_offset_z) + self.laser_tracker_check_and_wait_for_signalstrength() + dev.osamx.omny_osamx_to_scan_center(sum_offset_x) + + print("Acquiring 10 empty frames at angle 0, samx/rtx shifted out of the beam.") + start_scan_number = bec.queue.next_scan_number + scans.acquire(exp_time=self.tomo_countingtime, frames_per_trigger=10) + end_scan_number = bec.queue.next_scan_number + for scan_nr in range(start_scan_number, end_scan_number): + self._write_tomo_scan_number(scan_nr, angle, subtomo_number=0) + def tomo_scan_projection(self, angle: float): scans = builtins.__dict__.get("scans") @@ -1290,6 +1933,58 @@ class OMNY( corridor_size=corridor_size, ) + def _is_superuser_account(self) -> bool: + """Whether the active BEC account is allowed to select advanced tomo modes. + + bec.active_account is a pgroup set explicitly via `bec-set-account`; when no + pgroup has been set yet (e.g. a fresh dev/test session), fall back to the OS + login (matches the account/system-user comparison convention already used in + OMNY_shared/omny_general_tools.py's _accounts_match()). + """ + bec = builtins.__dict__.get("bec") + try: + account = bec.active_account + except Exception: + return False + if isinstance(account, bytes): + account = account.decode() + if not account: + account = os.getenv("USER") or "" + return bool(account) and account in _SUPERUSER_ACCOUNTS + + def _expected_fermat_position_count(self) -> int: + """Estimate the number of Fermat scan points the current + fovx/fovy/tomo_shellstep settings would produce, so a + too-few-points configuration (which the scan server would only + catch by aborting with ScanAbortion once the scan actually starts) + can be caught here instead, while just looking at tomo_parameters(). + Direct port of Flomni._expected_fermat_position_count(). + + cenx/ceny/zshift are deliberately left at their defaults: they only + shift the final position, applied after the FOV keep-or-discard + check, so they can never affect the resulting count. + """ + from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan + + positions = OmnyFermatScan.get_omny_fermat_spiral_pos( + -abs(self.fovx / 2), + abs(self.fovx / 2), + -abs(self.fovy / 2), + abs(self.fovy / 2), + step=self.tomo_shellstep, + spiral_type=0, + center=False, + ) + return len(positions) + + @staticmethod + def _fermat_min_positions() -> int: + """OmnyFermatScan's own minimum-position threshold -- see + _expected_fermat_position_count().""" + from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan + + return OmnyFermatScan.MIN_POSITIONS + def tomo_parameters(self): """print and update the tomo parameters""" print("Current settings:") @@ -1298,6 +1993,16 @@ class OMNY( print(f"FOV (200/100) = {self.fovx}, {self.fovy}") print(f"Stitching number x,y = {self.stitch_x}, {self.stitch_y}") print(f"Stitching overlap = {self.tomo_stitch_overlap}") + expected_positions = self._expected_fermat_position_count() + min_positions = self._fermat_min_positions() + if expected_positions < min_positions: + print( + f"Estimated Fermat scan points per projection: {expected_positions} " + f"(WARNING: below the minimum of {min_positions} -- the scan will abort" + " when run)" + ) + else: + print(f"Estimated Fermat scan points per projection: {expected_positions}") print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}") print(f" _manual_shift_y = {self.manual_shift_y}") print("") @@ -1330,6 +2035,18 @@ class OMNY( print( "repeating prjections at 0 degrees at the beginning of every second subtomogram" ) + if self.tomo_type in (4, 5): + n_subtomos = {4: 4, 5: 8}[self.tomo_type] + print( + f"\x1b[1mTomo type {self.tomo_type}:\x1b[0m {n_subtomos} equally spaced sub-tomograms" + ) + print(f"Total number of projections: {180/self.tomo_angle_stepsize*n_subtomos}") + print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize} degrees") + if self.tomo_type in self._TOMO_TYPE_1_LIKE and self.zero_deg_reference_at_each_subtomo: + print( + "Repeating a reference projection at 0 degrees at the start of every forward" + " sub-tomogram, plus once more at the end." + ) print(f"\nSample name: {self.sample_name}\n") if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"): @@ -1345,12 +2062,23 @@ class OMNY( "Reconstruction queue ", self.ptycho_reconstruct_foldername, str ) + is_superuser = self._is_superuser_account() print("Tomography type:") print(" 1: 2 equally spaced sub-tomograms") - print(" 2: Golden ratio tomography") - print(" 3: Equally spaced tomography, golden ratio starting angle") + if is_superuser: + print(" 2: Golden ratio tomography") + print(" 3: Equally spaced tomography, golden ratio starting angle") + print(" 4: 4 equally spaced sub-tomograms") + print(" 5: 8 equally spaced sub-tomograms") self.tomo_type = self._get_val("Tomography type", self.tomo_type, int) + if self.tomo_type != 1 and not is_superuser: + print( + "Advanced tomo modes are only available to superuser accounts. Tomo type 1" + " selected: 2 sub-tomograms selected." + ) + self.tomo_type = 1 + if self.tomo_type == 1: tomo_numberofprojections = self._get_val( "Total number of projections", 180 / self.tomo_angle_stepsize * 2, int @@ -1360,63 +2088,76 @@ class OMNY( print(f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}") if self.tomo_type == 2: - code = self._get_val( - "This mode causes significant wear in OMNY. Enter authorization code.", 0, str + print( + "This mode causes significant wear in OMNY. You are a power user -- please" + " avoid unnecessarily many rotations and be gentle to the setup." + ) + self.golden_ratio_bunch_size = self._get_val( + "Number of projections sorted per bunch (minimum 100)", + self.golden_ratio_bunch_size, + int, + ) + if self.golden_ratio_bunch_size < 100: + print("Minimum of 100 selected.") + self.golden_ratio_bunch_size = 100 + self.golden_max_number_of_projections = self._get_val( + "Stop after number of projections (zero for endless)", + self.golden_max_number_of_projections, + int, + ) + self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( + "Repeat projections at 0 deg every second subtomo 1/0 ?", + self.golden_projections_at_0_deg_for_damage_estimation, + int, ) - if code == "x12sa": - self.golden_ratio_bunch_size = self._get_val( - "Number of projections sorted per bunch (minimum 100)", - self.golden_ratio_bunch_size, - int, - ) - if self.golden_ratio_bunch_size < 100: - print("Minimum of 100 selected.") - self.golden_ratio_bunch_size = 100 - self.golden_max_number_of_projections = self._get_val( - "Stop after number of projections (zero for endless)", - self.golden_max_number_of_projections, - int, - ) - self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( - "Repeat projections at 0 deg every second subtomo 1/0 ?", - self.golden_projections_at_0_deg_for_damage_estimation, - int, - ) - else: - print( - "Wrong authorization code. Tomo type 1 selected: 2 sub-tomograms selected." - ) - self.tomo_type = 1 if self.tomo_type == 3: - code = self._get_val( - "This mode causes significant wear in OMNY. Enter authorization code.", 0, str + print( + "This mode causes significant wear in OMNY. You are a power user -- please" + " avoid unnecessarily many rotations and be gentle to the setup." ) - if code == "x12sa": - numprj = self._get_val( - "Number of projections per sub-tomogram (minimum 100)", - int(180 / self.tomo_angle_stepsize), - int, - ) - if numprj < 100: - numprj = 100 - print("Minimum of 100 selected.") - self.tomo_angle_stepsize = 180 / numprj - self.golden_max_number_of_projections = self._get_val( - "Stop after number of projections (zero for endless)", - self.golden_max_number_of_projections, - int, - ) - self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( - "Repeat projections at 0 deg every second subtomo", - self.golden_projections_at_0_deg_for_damage_estimation, - int, - ) - else: + numprj = self._get_val( + "Number of projections per sub-tomogram", + int(180 / self.tomo_angle_stepsize), + int, + ) + self.tomo_angle_stepsize = 180 / numprj + if self.tomo_angle_stepsize > 2.5: + self.tomo_angle_stepsize = 2.5 print( - "Wrong authorization code. Tomo type 1 selected: 2 sub-tomograms selected." + "Angular step exceeded the maximum of 2.5 degrees; clamped to 2.5 degrees" + f" ({int(180 / 2.5)} projections per sub-tomogram)." ) - self.tomo_type = 1 + self.golden_max_number_of_projections = self._get_val( + "Stop after number of projections (zero for endless)", + self.golden_max_number_of_projections, + int, + ) + self.golden_projections_at_0_deg_for_damage_estimation = self._get_val( + "Repeat projections at 0 deg every second subtomo", + self.golden_projections_at_0_deg_for_damage_estimation, + int, + ) + + if self.tomo_type in (4, 5): + n_subtomos = {4: 4, 5: 8}[self.tomo_type] + print( + "This mode causes significant wear in OMNY. You are a power user -- please" + " avoid unnecessarily many rotations and be gentle to the setup." + ) + tomo_numberofprojections = self._get_val( + "Total number of projections", 180 / self.tomo_angle_stepsize * n_subtomos, int + ) + print(f"The angular step will be {180/tomo_numberofprojections}") + self.tomo_angle_stepsize = 180 / tomo_numberofprojections * n_subtomos + print(f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}") + + if self.tomo_type in self._TOMO_TYPE_1_LIKE: + self.zero_deg_reference_at_each_subtomo = self.OMNYTools.yesno( + "Repeat a reference projection at 0 degrees at the start of every forward" + " sub-tomogram (plus once more at the end)?", + "y" if self.zero_deg_reference_at_each_subtomo else "n", + ) @staticmethod def _get_val(msg: str, default_value, data_type): @@ -1461,6 +2202,15 @@ class OMNY( fzp_details = self._get_user_param_safe("ofzpx", "fzp_details") except Exception: fzp_details = "N/A" + # The energy readback device is "ccm_energy", not "mokev" -- OMNY has + # no mokev device in any config (real or simulated). Read it + # defensively so a missing/misbehaving device doesn't crash the + # whole report instead of just omitting one line. + try: + energy_kev = dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]["value"] + energy_str = f"{energy_kev:>{padding}.4f}" + except Exception: + energy_str = f"{'N/A':>{padding}}" content = [ f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n", f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n", @@ -1470,7 +2220,7 @@ class OMNY( f"{'Number of projections:':<{padding}}{int(180 / self.tomo_angle_stepsize * 8):>{padding}}\n", f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n", f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + int(180 / self.tomo_angle_stepsize * 8) + 10:>{padding}}\n", - f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:>{padding}.4f}\n", + f"{'Current photon energy:':<{padding}}{energy_str}\n", f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n", f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n", f"{'FOV:':<{padding}}{fovxy:>{padding}}\n", @@ -1481,7 +2231,14 @@ class OMNY( "Add current temperature and pressure status", ] content = "".join(content) - user_target = os.path.expanduser(f"~/Data10/documentation/tomo_scan_ID_{self.tomo_id}.pdf") + # ~/data/raw/documentation/ matches flomni's/lamni's own + # write_pdf_report() target -- the old ~/Data10/documentation/ + # layout doesn't exist in the current deployment. PDFWriter doesn't + # create its own parent directory, so do it explicitly here. + user_target = os.path.expanduser( + f"~/data/raw/documentation/tomo_scan_ID_{self.tomo_id}.pdf" + ) + os.makedirs(os.path.dirname(user_target), exist_ok=True) with PDFWriter(user_target) as file: file.write(header) file.write(content) @@ -1489,12 +2246,27 @@ class OMNY( "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True ) # status = subprocess.run(f"cp /tmp/spec-e20131-specES1.pdf {user_target}", shell=True) - msg = bec.logbook.LogbookMessage() - logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI_logo.png") - msg.add_file(logo_path).add_text("".join(content).replace("\n", "

")).add_tag( - ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name] - ) - self.client.logbook.send_logbook_message(msg) + # bec.logbook/client.logbook.send_logbook_message() was the old, + # pre-migration scilog API -- it has no "is scilog even configured" + # check, so on a session without scilog credentials (e.g. this dev + # sandbox has no /etc/bec/secrets/.scilog.env at all) the send() + # call blocks indefinitely instead of failing fast, hanging the + # whole tomo scan. flomni's write_pdf_report() uses + # bec.messaging.scilog instead, which is enabled/disabled based on + # the deployment's actual published scilog config -- mirrored here. + scilog = getattr(bec.messaging, "scilog", None) + if scilog is None or not getattr(scilog, "_enabled", False): + logger.warning("scilog is not enabled; skipping PDF report entry.") + else: + logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "OMNY.png") + try: + scilog.new().add_attachment(logo_path, width=200).add_text( + content.replace("\n", "
") + ).add_tags( + ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "OMNY", self.sample_name] + ).send() + except Exception as exc: + logger.warning(f"Failed to write PDF report to scilog: {exc}") def vcs_show_all(): dev.omny_vcs.show_all() @@ -1508,7 +2280,7 @@ class OMNY( def dewar_show_all(): dev.omny_dewar.show_all() - + if __name__ == "__main__": import builtins diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py index ae1f433b..df627113 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_alignment_mixin.py @@ -1,6 +1,7 @@ import time import numpy as np import os +from pathlib import Path from rich import box from rich.console import Console @@ -17,8 +18,87 @@ class OMNYAlignmentError(Exception): class OMNYAlignmentMixin: - default_correction_file = "correction_omny_202204.txt" - default_correction_file_x = "correction_omny_202204_x.txt" + # Resolved to an absolute path (mirrors Flomni's default_correction_file + # setup) -- a bare relative filename would only ever be found if bec were + # launched from this exact plugin directory, silently failing (caught by + # the FileNotFoundError handler below) everywhere else. + import csaxs_bec + + _csaxs_bec_basepath = Path(csaxs_bec.__file__).parent + default_correction_file = ( + _csaxs_bec_basepath + / "bec_ipython_client" + / "plugins" + / "omny" + / "correction_omny_202204.txt" + ).resolve() + default_correction_file_x = ( + _csaxs_bec_basepath + / "bec_ipython_client" + / "plugins" + / "omny" + / "correction_omny_202204_x.txt" + ).resolve() + del csaxs_bec + + # additional x/y-correction curves: global-var-backed so they survive a + # BEC client restart, same pattern as every other persistent setting in + # this class (and matching Flomni's corr_pos_y/corr_angle_y/etc.). NOTE: + # must NOT be pre-assigned in OMNY.__init__ -- doing so would overwrite + # the persisted value with [] on every restart. + @property + def corr_pos_x(self): + val = self.client.get_global_var("corr_pos_x") + return [] if val is None else val + + @corr_pos_x.setter + def corr_pos_x(self, val: list): + self.client.set_global_var("corr_pos_x", val) + + @property + def corr_angle_x(self): + val = self.client.get_global_var("corr_angle_x") + return [] if val is None else val + + @corr_angle_x.setter + def corr_angle_x(self, val: list): + self.client.set_global_var("corr_angle_x", val) + + @property + def corr_pos_y(self): + val = self.client.get_global_var("corr_pos_y") + return [] if val is None else val + + @corr_pos_y.setter + def corr_pos_y(self, val: list): + self.client.set_global_var("corr_pos_y", val) + + @property + def corr_angle_y(self): + val = self.client.get_global_var("corr_angle_y") + return [] if val is None else val + + @corr_angle_y.setter + def corr_angle_y(self, val: list): + self.client.set_global_var("corr_angle_y", val) + + @property + def corr_pos_y_2(self): + val = self.client.get_global_var("corr_pos_y_2") + return [] if val is None else val + + @corr_pos_y_2.setter + def corr_pos_y_2(self, val: list): + self.client.set_global_var("corr_pos_y_2", val) + + @property + def corr_angle_y_2(self): + val = self.client.get_global_var("corr_angle_y_2") + return [] if val is None else val + + @corr_angle_y_2.setter + def corr_angle_y_2(self, val: list): + self.client.set_global_var("corr_angle_y_2", val) def reset_correction(self, use_default_correction=True): """ @@ -215,6 +295,10 @@ class OMNYAlignmentMixin: return self._compute_additional_correction(angle, iteration="y2") def _compute_additional_correction(self, angle, iteration="y1"): + # corr_pos_x/corr_pos_y/corr_pos_y_2 etc. are global-var-backed + # properties that default to [] -- never AttributeError, even before + # reset_correction() has ever run (e.g. testing scan mechanics before + # any sample transfer). if iteration == "x1": corr_pos = self.corr_pos_x corr_angle = self.corr_angle_x diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py index 0023308e..3faf811f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_optics_mixin.py @@ -129,16 +129,44 @@ class OMNYOpticsMixin: if np.fabs(omny_oosa_currentx + 2) > 0.1: raise OMNYError("oosax did not reach target position. Not moving in z.\n") + def _oosa_is_at(self, x_pos, y_pos, z_pos, tol=0.05) -> bool: + """True if oosax/oosay/oosaz are all within tol of the given position.""" + dev.oosaz.read(cached=False) + return ( + np.fabs(dev.oosax.get().readback - x_pos) < tol + and np.fabs(dev.oosay.get().readback - y_pos) < tol + and np.fabs(dev.oosaz.get().readback - z_pos) < tol + ) + def oosa_in(self): + # Compute the target *before* touching anything: _oosa_check_y() below + # unconditionally parks oosay at 0.9, which is not the "in" position for + # either mode, so the "already there" check must happen first or it + # would never see the real (pre-park) oosay reading. + if self.near_field == False: + mode, x_in_pos, y_in_pos, z_in_pos = ( + "far-field", + self._get_user_param_safe("oosax", "far_field_in"), + self._get_user_param_safe("oosay", "far_field_in"), + self._get_user_param_safe("oosaz", "far_field_in"), + ) + else: + mode, x_in_pos, y_in_pos, z_in_pos = ( + "near-field", + self._get_user_param_safe("oosax", "near_field_in"), + self._get_user_param_safe("oosay", "near_field_in"), + self._get_user_param_safe("oosaz", "near_field_in"), + ) + if self._oosa_is_at(x_in_pos, y_in_pos, z_in_pos): + self.OMNYTools.printgreen(f"OSA is already at the IN position ({mode}).") + return + self._oosa_check_y() dev.oshield.read(cached=False) omny_oshield_current = dev.oshield.get().readback if omny_oshield_current < 15: self._oshield_ST_close() if self.near_field == False: - x_in_pos = self._get_user_param_safe("oosax", "far_field_in") - y_in_pos = self._get_user_param_safe("oosay", "far_field_in") - z_in_pos = self._get_user_param_safe("oosaz", "far_field_in") print("OSA movement in far-field mode.") dev.oosaz.read(cached=False) omny_oosa_currentz = dev.oosaz.get().readback @@ -154,9 +182,6 @@ class OMNYOpticsMixin: # umv oosay 0.386015 if self.near_field == True: - x_in_pos = self._get_user_param_safe("oosax", "near_field_in") - y_in_pos = self._get_user_param_safe("oosay", "near_field_in") - z_in_pos = self._get_user_param_safe("oosaz", "near_field_in") print("OSA movement in near-field mode.") dev.oosaz.read(cached=False) omny_oosa_currentz = dev.oosaz.get().readback @@ -178,14 +203,26 @@ class OMNYOpticsMixin: # rt_feedback_enable def oosa_out(self): + # oosa_out() doesn't move oosay, so "already out" only depends on x/z; + # read it before _oosa_check_y() parks oosay at 0.9 below (same reason + # as in oosa_in()). + omny_oosay_current = dev.oosay.get().readback + if self.near_field == False: + mode, z_out_pos = "far-field", 6.5 + else: + mode, z_out_pos = "near-field", -0.45 + if self._oosa_is_at(-2, omny_oosay_current, z_out_pos): + self.OMNYTools.printgreen(f"OSA is already at the OUT position ({mode}).") + return + self._oosa_check_y() dev.oshield.read(cached=False) omny_oshield_current = dev.oshield.get().readback if omny_oshield_current < 15: self._oshield_ST_close() - omny_oosaz_current = dev.oosaz.get().readback if self.near_field == False: print("OSA movement in far-field mode.") + omny_oosaz_current = dev.oosaz.get().readback if omny_oosaz_current < 6.4: self._oosa_to_move_corridor() dev.oosaz.limits = [6.4, 6.6] @@ -193,6 +230,7 @@ class OMNYOpticsMixin: umv(dev.oosax, -2) if self.near_field == True: print("OSA movement in near-field mode.") + omny_oosaz_current = dev.oosaz.get().readback if omny_oosaz_current > 0: self._oosa_to_move_corridor() dev.oosaz.limits = [-0.4, -0.6] @@ -217,18 +255,28 @@ class OMNYOpticsMixin: self.OMNYTools.printgreen("OSA is out of shield.") def ofzp_out(self): + y_out_pos = self._get_user_param_safe("ofzpy", "out") + needs_move = np.fabs(dev.ofzpy.get().readback - y_out_pos) > 0.02 + if not needs_move: + self.OMNYTools.printgreen("FZP is already at the out position") + return if "rtx" in dev and dev.rtx.enabled: dev.rtx.controller.feedback_disable() - y_out_pos = self._get_user_param_safe("ofzpy", "out") - if np.fabs(dev.ofzpy.get().readback - y_out_pos) > 0.02: - umv(dev.ofzpy, y_out_pos) + umv(dev.ofzpy, y_out_pos) self.OMNYTools.printgreen("FZP at out position") def ofzp_in(self): - if "rtx" in dev and dev.rtx.enabled: - dev.rtx.controller.feedback_disable() x_in_pos = self._get_user_param_safe("ofzpx", "in") y_in_pos = self._get_user_param_safe("ofzpy", "in") + needs_move = ( + np.fabs(dev.ofzpy.get().readback - y_in_pos) > 0.02 + or np.fabs(dev.ofzpx.get().readback - x_in_pos) > 0.02 + ) + if not needs_move: + self.OMNYTools.printgreen("FZP is already at the in position") + return + if "rtx" in dev and dev.rtx.enabled: + dev.rtx.controller.feedback_disable() if np.fabs(dev.ofzpy.get().readback - y_in_pos) > 0.02: umv(dev.ofzpy, y_in_pos) diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py index 3cb19d27..bc11eca3 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny_sample_transfer_mixin.py @@ -1,6 +1,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 ") print("omny.otransfer_put_sample(position) put with the gripper to ") - print("To modify the storage information see dev.omny_samples.help()") + print("omny.otransfer_show_all() show the full sample storage overview") + print( + "The other storage commands (set/unset slots, shuttles, gripper, sample stage," + ) + print("owner, measured status) are also available as omny.otransfer_* -- see below:") + print() + dev.omny_samples.help() diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py index ec15cff4..55a1ee29 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/x_ray_eye_align.py @@ -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}") \ No newline at end of file + print(f"Fit data submitted with shape {data.shape}:\n{data}") diff --git a/csaxs_bec/bec_ipython_client/startup/post_startup.py b/csaxs_bec/bec_ipython_client/startup/post_startup.py index 26564c71..16aff438 100644 --- a/csaxs_bec/bec_ipython_client/startup/post_startup.py +++ b/csaxs_bec/bec_ipython_client/startup/post_startup.py @@ -65,7 +65,7 @@ if _args.session.lower() == "lamni": """) elif _args.session.lower() == "omny": - from csaxs_bec.bec_ipython_client.plugins.flomni import OMNY + from csaxs_bec.bec_ipython_client.plugins.omny import OMNY _session_name = "OMNY" omny = OMNY(bec) diff --git a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py index 9348f07a..ad5a8a14 100644 --- a/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py +++ b/csaxs_bec/bec_widgets/widgets/sample_storage/sample_storage.py @@ -1,21 +1,49 @@ """ -OMNY_SampleStorage – BEC widget for viewing and correcting the FlOMNI -sample-storage bookkeeping. +OMNY_SampleStorage – BEC widget for viewing and correcting sample-storage +bookkeeping. -This widget is a GUI replacement for the ``ftransfer_show`` / -``ftransfer_modify`` CLI commands. It shows the sample magazine (slots -1–20), the sample stage (slot 0) and the gripper (slot 100), and lets the -user correct the stored records via a right-click context menu on each slot. +This widget is a GUI replacement for the CLI sample-storage bookkeeping +commands (FlOMNI's ``ftransfer_show``/``ftransfer_modify``, OMNY's +``dev.omny_samples.show_all()``/``help()``). It supports two structurally +different setups, detected at construction time (see ``SETUP_PROFILES``, +``_detect_setup()``) — a BEC session is always exactly one setup, never +both at once: + +Both setups require every sample slot to carry a name *and* an owner; both +keep one soft-IOC record per slot (USED/NAME/OWNER/STATUS/MEAS_DATE): + +FlOMNI (``dev.flomni_samples``) + A flat magazine: slots 1-20, the sample stage (slot 0) and the gripper + (slot 100), FLOMNI-SAMPLE::... since 2026-10-17 (before: name and + owner packed into one EPICS DESC field, ``sample_desc_codec``). + +OMNY (``dev.omny_samples``) + A richer structure: three shuttles (A/B/C) of 6 slots each, a sparse + set of "fixed O positions" ({10,11,12,13,14,32,33,34,100,101}), a + separate sample stage and gripper, and a 6-slot "parking" carousel + recording which shuttle letter is docked at each physical parking + position (parking slots are a shuttle-letter assignment, not a sample, + so they carry no name/owner/status). Since 2026-10-18 every OMNY slot is + one record on the OMNY soft IOC, like FlOMNI's + (OMNY-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE, see + ``OMNYSampleStorage``). Whichever shuttle's parking slot matches the + ``oparkz`` motor's current readback is "active", i.e. + at the transfer-ready position (see ``oparkz_active_parking_slot()``). + +A LamNI session has neither device; the widget shows a "no known setup +detected" message in that case. IMPORTANT – bookkeeping only ---------------------------- -This widget never moves hardware. It only reads and writes the sample -*records* held on the ``flomni_samples`` device — exactly the same records -that ``flomni_modify_storage_non_interactive()`` writes. It does NOT perform -sample transfers; those remain CLI-only (``flomni.ftransfer_sample_change`` -etc.), because they are blocking hardware sequences with interactive -confirmations. Editing a record here changes only what BEC *believes* is in -a slot, so entries must be kept in sync with physical reality by the operator. +This widget never moves hardware. It only reads and writes the sample +*records* held on the setup's samples device — for FlOMNI, exactly the same +records that ``flomni_modify_storage_non_interactive()`` writes. It does +NOT perform sample transfers; those remain CLI-only, because they are +blocking hardware sequences with interactive confirmations. Editing a +record here changes only what BEC *believes* is in a slot, so entries must +be kept in sync with physical reality by the operator. This also applies to +OMNY's parking-slot assignments, which (like the sample slots) are pure +bookkeeping never touched by the transfer code. Because a transfer and a manual record edit would both happen at the same workstation, there is deliberately no software interlock against editing @@ -24,20 +52,29 @@ while a transfer they started is running). Backend ------- -All state lives on ``dev.flomni_samples`` (not BEC global vars), which is the -single source of truth also consulted by the transfer state machine. The -widget reads/writes it directly, mirroring -``Flomni.flomni_modify_storage_non_interactive()``: +All state lives on the setup's samples device (not BEC global vars), which +is the single source of truth also consulted by the transfer state machine. +The widget reads/writes it directly: - slots 0–20 : sample_placed.sample{N}.set(used) - sample_names.sample{N}.set(name) - gripper : sample_in_gripper.set(used) - sample_in_gripper_name.set(name) +FlOMNI + writes : set_sample_slot(N, name, owner) / unset_sample_slot(N) + (N = 0–20, 100 = gripper; device methods, all record fields) + reads : one bulk read() of sample_placed / sample_names / + sample_owners / sample_status (gripper: sample_in_gripper*) + +Since 2026-10-17 every slot is one record on the flOMNI soft IOC +(FLOMNI-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE); the measurement +status travels with the sample and is no longer kept in a BEC global var. +OMNY, stage/gripper/fixed-O/shuttle slots: set_sample_slot(container, N, + name, owner) / unset_sample_slot(container, N) -- see + ``_write_slot_omny()``; the same one-record-per-slot layout + (OMNY-SAMPLE::..., 2026-10-18). +OMNY, parking : set_shuttle_slot(letter, N) / unset_shuttle_slot(N) Polling ------- BEC publishes no push notifications for these signals, so a ``QTimer`` polls -every 2 s to reflect CLI-driven changes. Writes are immediate (on the +every 2 s to reflect CLI-driven changes. Writes are immediate (on the affirmative button of each context-menu dialog); there is no batched edit/submit — every mutating action is individually confirmed instead. """ @@ -48,7 +85,7 @@ import datetime import os import subprocess import tempfile -from typing import Optional +from typing import Any, Optional from bec_lib import bec_logger from bec_lib.pdf_writer import PDFWriter @@ -57,6 +94,7 @@ from qtpy.QtCore import Qt, QTimer from qtpy.QtWidgets import ( QFrame, QGridLayout, + QGroupBox, QHBoxLayout, QInputDialog, QLabel, @@ -69,13 +107,12 @@ from qtpy.QtWidgets import ( ) from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.ptouch_printer import PTouchLabelPrinter -from csaxs_bec.devices.omny.sample_desc_codec import pack_desc, unpack_desc logger = bec_logger.logger # ── constants ──────────────────────────────────────────────────────────────── -# Magazine slot numbers, laid out to match the CLI printout: +# FlOMNI magazine slot numbers, laid out to match the CLI printout: # 1 2 3 4 5 # 6 7 8 9 10 # 11 12 13 14 15 @@ -84,14 +121,30 @@ STORAGE_ROWS = 4 STORAGE_COLS = 5 STORAGE_SLOTS = tuple(range(1, STORAGE_ROWS * STORAGE_COLS + 1)) # 1..20 -STAGE_SLOT = 0 # sample stage (in the beam) -GRIPPER_SLOT = 100 # gripper pseudo-slot +STAGE_SLOT = 0 # FlOMNI sample stage (in the beam) +GRIPPER_SLOT = 100 # FlOMNI gripper pseudo-slot -EMPTY_NAME = "-" # backend convention for an empty slot's name +EMPTY_NAME = "-" # backend convention for an empty sample slot's name (both setups) EMPTY_LABEL = "— empty —" # what an empty cell shows in the GUI +# OMNY-specific layout +OMNY_SHUTTLE_LETTERS = ("A", "B", "C") +OMNY_SHUTTLE_SLOTS = tuple(range(1, 7)) +# places as on the real shuttle: 3 rows x 2 columns, 1 2 / 3 4 / 5 6 (Mirko 2026-10-27; was 2 x 3) +OMNY_SHUTTLE_COLS = 2 +OMNY_FIXED_POSITIONS = (10, 11, 12, 13, 14, 32, 33, 34, 100, 101) +OMNY_PARKING_SLOTS = tuple(range(1, 7)) +# OMNYSampleStorage.unset_shuttle_slot()'s own empty sentinel -- unlike sample +# slots (which use EMPTY_NAME = "-"), parking slots are cleared to "none". +PARKING_EMPTY_SENTINEL = "none" + # colours -COLOR_OCCUPIED = "#1b1b1b" +# An occupied slot's name label intentionally has no fixed foreground colour +# here (see _SlotCell.set_state()) -- it was previously hardcoded to +# "#1b1b1b" (near-black), which is fine on a light theme but is effectively +# invisible against BEC's dark theme. Clearing the stylesheet instead lets +# the label fall back to the application palette's default text colour, +# which is correct in both themes. COLOR_EMPTY = "#9e9e9e" COLOR_STAGE_BORDER = "#2196F3" COLOR_GRIPPER_BORDER = "#FF9800" @@ -103,8 +156,13 @@ COLOR_SLOT_BORDER = "#c0c0c0" # already-transferred template (confirmed working) -- NOT the generic # Text1/Text2/Text6 names that OMNY_shared/flomni.lbx's own objectName # attributes happen to use for its (different) template source file. +# Same printer, same field names, but a *different* template number is +# pre-loaded on the printer for the OMNY label -- not yet bench-tested +# against a real printer the way FlOMNI's template 1 has been, so the +# field names are an assumption for now, not confirmed working. PTOUCH_PRINTER_HOST = "BRN94DDF8AAB8EC.psi.ch" PTOUCH_TEMPLATE_NUMBER = 1 +PTOUCH_TEMPLATE_NUMBER_OMNY = 3 PTOUCH_FIELD_ACCOUNT = "ACCOUNT" PTOUCH_FIELD_DATE = "DATE" PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME" @@ -114,53 +172,79 @@ PTOUCH_FIELD_SAMPLENAME = "SAMPLENAME" # call site handles the "queue not found" failure instead of pre-checking. PRINTER_NAME = "WSLA_X12SA" +# Which setup's samples device is used to detect that setup, mirroring the +# established multi-setup convention in +# bec_widgets/widgets/tomo_params/tomo_params.py's SETUP_PROFILES. Unlike +# tomo_params (which needs a separate proxy discriminator such as +# "fsamroy", since it doesn't itself depend on the samples device), this +# widget's whole purpose IS that device, so the discriminator is simply the +# device this widget will go on to read/write. +SETUP_PROFILES: dict[str, dict[str, str]] = { + "flomni": {"discriminator_attr": "flomni_samples"}, + "omny": {"discriminator_attr": "omny_samples"}, +} -# ── measured-sample log ────────────────────────────────────────────────────── +# oparkz motor target positions (mm) for each drivable OMNY parking slot, +# duplicated from omny_sample_transfer_mixin.py's _oparkz_slot_check() / +# otransfer_park_slot() rather than imported -- that mixin is a +# CLI-session-only plugin (module-level `dev`/`bec` builtins, OMNYTools +# dependency) that would be a heavier and more fragile coupling to pull into +# this widget's gui-server process. Slot 1 has no +# drivable transfer target and is never "active". +_OPARKZ_TARGET_POSITIONS: dict[int, float] = { + 2: -0.9 - 29 * 3 - 42 - 0.05, + 3: -0.9 - 29 * 3, + 4: -0.9 - 29 * 2, + 5: -0.9 - 29, + 6: -0.9, +} +# otransfer_park_loadlock_slot()'s targets (_oparkz_leica_slot() in the same +# mixin) -- the *same* slot numbers, reached via the loadlock/leica transfer +# path instead of the regular gripper path, at a different oparkz offset +# (base -6, not -0.9). Both genuinely mean "the parking stage is at that +# slot" -- "active" must recognize either, or a loadlock-driven park (like +# otransfer_park_loadlock_slot()) never shows as active in the GUI even +# though it physically is. +_OPARKZ_LEICA_TARGET_POSITIONS: dict[int, float] = { + 2: -6 - 29 * 3 - 42 - 0.05, + 3: -6 - 29 * 3, + 4: -6 - 29 * 2, + 5: -6 - 29, + 6: -6, +} +_OPARKZ_TOLERANCE = 0.05 -class _MeasuredSampleLog: - """Tracks which flomni samples have been measured, for the P-touch - label-printing feature. - - Deliberately duplicated here rather than imported from - ``flomni.py``'s ``_MeasuredSampleLog`` -- pulling in that whole plugin - module (CLI-only dependencies, module-level ``bec``/``dev`` builtins - access) into this widget's gui-server process is a heavier and more - fragile coupling than re-declaring this ~15-line global-var wrapper, - and this widget already duplicates ``flomni_modify_storage_non_interactive()`` - the same way (see module docstring). Both copies read/write the same - ``flomni_measured_samples`` global-var key, so they stay consistent in - effect despite being independent code, exactly like the existing - ``_write_slot``/``flomni_modify_storage_non_interactive`` duplication. +def oparkz_active_parking_slot(readback: float, tol: float = _OPARKZ_TOLERANCE) -> Optional[int]: + """Return which parking slot (2-6) the oparkz motor is currently sitting + at, i.e. ready for a sample transfer, or None if it's between positions + (or at slot 1, which has no drivable transfer target). Checks both the + regular-gripper and loadlock/leica target tables (see + ``_OPARKZ_LEICA_TARGET_POSITIONS``) since either represents the parking + stage genuinely being at that slot. Pure function -- see the + module-level docstring above ``_OPARKZ_TARGET_POSITIONS`` for why this + duplicates rather than imports omny_sample_transfer_mixin.py's + ``_oparkz_slot_check()``/``_oparkz_leica_slot()``. """ + for targets in (_OPARKZ_TARGET_POSITIONS, _OPARKZ_LEICA_TARGET_POSITIONS): + for slot, target in targets.items(): + if abs(readback - target) < tol: + return slot + return None - _GLOBAL_VAR_KEY = "flomni_measured_samples" - def __init__(self, client): - self._client = client +def shuttle_status( + letter: str, parking_assignment: dict[int, str], active_slot: Optional[int] +) -> dict[str, Any]: + """Where OMNY shuttle `letter` is parked (if anywhere), and whether that + parking slot is currently the "active" one (oparkz is sitting there, + ready for a transfer). Pure function of a snapshot of the parking + carousel and the current active slot -- see ``oparkz_active_parking_slot()``. - def _load(self) -> dict: - val = self._client.get_global_var(self._GLOBAL_VAR_KEY) - return val if val is not None else {} - - def _save(self, data: dict) -> None: - self._client.set_global_var(self._GLOBAL_VAR_KEY, data) - - def get(self, sample_key: str) -> Optional[dict]: - return self._load().get(sample_key) - - def snapshot(self) -> dict: - """One bulk fetch of the whole log, for callers (like the poll - loop below) that need to look up many sample keys at once without - one redis round-trip per key.""" - return self._load() - - def pop(self, sample_key: str) -> Optional[dict]: - data = self._load() - record = data.pop(sample_key, None) - if record is not None: - self._save(data) - return record + Returns {"parked_at": Optional[int], "active": bool}. + """ + parked_at = next((slot for slot, occupant in parking_assignment.items() if occupant == letter), None) + return {"parked_at": parked_at, "active": parked_at is not None and parked_at == active_slot} # ── slot cell widget ───────────────────────────────────────────────────────── @@ -168,14 +252,24 @@ class _MeasuredSampleLog: class _SlotCell(QFrame): """A single storage slot: shows its number and either the sample name or - an "empty" placeholder. Right-click opens a context menu whose actions + an "empty" placeholder. Right-click opens a context menu whose actions depend on whether the slot is currently occupied. The cell does not talk to the device itself; it calls back into the - parent widget's mutation methods, which own all backend access. + parent widget's mutation methods, which own all backend access. `slot` + is an opaque key: FlOMNI cells use a plain int (0-20, 100); OMNY cells + use a tuple identifying which underlying signal pair to hit (see + ``OMNY_SampleStorage._write_slot_omny()``). Only one setup's cells are + ever live on a given widget instance. """ - def __init__(self, slot: int, owner: "OMNY_SampleStorage", border_color: str): + def __init__( + self, + slot: int | tuple, + owner: "OMNY_SampleStorage", + border_color: str, + caption: Optional[str] = None, + ): super().__init__() self._slot = slot self._owner = owner @@ -193,7 +287,7 @@ class _SlotCell(QFrame): layout.setContentsMargins(6, 4, 6, 4) layout.setSpacing(2) - self._lbl_num = QLabel(self._slot_caption()) + self._lbl_num = QLabel(caption if caption is not None else self._slot_caption()) self._lbl_num.setStyleSheet("color: #888888; font-size: 10px;") self._lbl_num.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop) @@ -221,6 +315,11 @@ class _SlotCell(QFrame): # ── labels ──────────────────────────────────────────────────────────────── def _slot_caption(self) -> str: + """Fallback caption for FlOMNI's plain-int keys when no explicit + `caption` was given. OMNY cells always pass an explicit caption + (see OMNY_SampleStorage._build_ui_omny()), so this int-only logic + never runs for them. + """ if self._slot == STAGE_SLOT: return "Stage (0)" if self._slot == GRIPPER_SLOT: @@ -232,15 +331,20 @@ class _SlotCell(QFrame): ) -> None: """Update the cell's displayed state (called by the poll/refresh). - `measured_status` is "started"/"completed" (from `_MeasuredSampleLog`) - or None if this slot's current occupant has no measured record. + `measured_status` is "started"/"completed" (OMNY also "screened") (FLOMNI-SAMPLE::STATUS) + (OMNY: OMNY-SAMPLE::STATUS) or None if this slot's current + occupant has no measured record, which the measured label below + renders as blank. """ self._occupied = occupied self._name = name if name else EMPTY_NAME self._sample_owner = owner if occupied: self._lbl_name.setText(self._name) - self._lbl_name.setStyleSheet(f"color: {COLOR_OCCUPIED};") + # no fixed colour here -- inherit the application palette's + # default text colour, which is correct in both light and dark + # themes (see the COLOR_EMPTY comment above) + self._lbl_name.setStyleSheet("") self._lbl_owner.setText(f"owner: {owner}" if owner else "") if measured_status == "completed": self._lbl_measured.setText("✓ measured") @@ -252,6 +356,10 @@ class _SlotCell(QFrame): # wording that implies live progress. self._lbl_measured.setText("◐ started") self._lbl_measured.setStyleSheet("font-size: 10px; color: #ef6c00;") + elif measured_status == "screened": + # OMNY only (2026-10-27): was on the sample stage, no tomoscan started + self._lbl_measured.setText("○ screened") + self._lbl_measured.setStyleSheet("font-size: 10px; color: #1565c0;") else: self._lbl_measured.setText("") else: @@ -265,11 +373,11 @@ class _SlotCell(QFrame): def _show_menu(self, pos) -> None: menu = QMenu(self) if self._occupied: - act_rename = menu.addAction("Change name…") + act_rename = menu.addAction("Change sample…") act_clear = menu.addAction("Clear (set empty)") chosen = menu.exec_(self.mapToGlobal(pos)) if chosen == act_rename: - self._owner.action_change_name(self._slot, self._name, self._sample_owner) + self._owner.action_change_sample(self._slot, self._name, self._sample_owner) elif chosen == act_clear: self._owner.action_clear(self._slot) else: @@ -279,18 +387,75 @@ class _SlotCell(QFrame): self._owner.action_new_sample(self._slot) +# ── OMNY shuttle status label ──────────────────────────────────────────────── + + +class _ShuttleStatusLabel(QLabel): + """Shows where one OMNY shuttle is installed (which parking slot, if + any) and whether that slot is currently "active" (oparkz is sitting + there, ready for a transfer). Right-click lets the operator assign or + clear this shuttle's parking-slot bookkeeping -- a different action + pair from the sample-cell context menu, since a parking assignment is a + single enum-like field (a shuttle letter, or none), not an + occupied+name+owner record; see OMNY_SampleStorage.action_set_parking()/ + action_clear_parking(). + """ + + def __init__(self, letter: str, owner: "OMNY_SampleStorage"): + super().__init__("not parked") + self._letter = letter + self._owner = owner + self._parked_at: Optional[int] = None + self.setStyleSheet("color: #888888; font-size: 11px;") + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_menu) + + def set_status(self, parked_at: Optional[int], active: bool) -> None: + self._parked_at = parked_at + if parked_at is None: + self.setText("not parked") + self.setStyleSheet("color: #888888; font-size: 11px;") + elif active: + self.setText(f"Slot {parked_at} — ● ACTIVE (ready for transfer)") + self.setStyleSheet("color: #2e7d32; font-size: 11px; font-weight: bold;") + else: + self.setText(f"Slot {parked_at}") + self.setStyleSheet("color: #555555; font-size: 11px;") + + def _show_menu(self, pos) -> None: + menu = QMenu(self) + act_set = menu.addAction("Set parking slot…") + act_clear = menu.addAction("Clear parking assignment") + act_clear.setEnabled(self._parked_at is not None) + chosen = menu.exec_(self.mapToGlobal(pos)) + if chosen == act_set: + self._owner.action_set_parking(self._letter, self._parked_at) + elif chosen == act_clear: + self._owner.action_clear_parking(self._letter, self._parked_at) + + # ── main widget ────────────────────────────────────────────────────────────── class OMNY_SampleStorage(BECWidget, QWidget): """ - View and correct the FlOMNI sample-storage records. + View and correct the sample-storage records for whichever setup (FlOMNI + or OMNY -- see ``SETUP_PROFILES``) is active in this BEC session. Layout ------ + FlOMNI: - Header: Stage (slot 0) and Gripper (slot 100), right-click correctable - 4×5 grid: magazine slots 1–20, right-click correctable - Caveat line: records only, not hardware + OMNY: + - Header: Stage and Gripper, right-click correctable + - Grid: the 10 fixed O positions, right-click correctable + - Three shuttle sections (A/B/C): installed/active status line + (right-click to assign/clear a parking slot) + a 6-slot grid as on the + real shuttle (3 rows x 2 columns: 1 2 / 3 4 / 5 6), + right-click correctable + - Caveat line: records only, not hardware All mutations go through a right-click context menu; each mutating action is confirmed by its own dialog and written to the device immediately. @@ -305,49 +470,60 @@ class OMNY_SampleStorage(BECWidget, QWidget): def __init__(self, parent=None, **kwargs): super().__init__(parent=parent, **kwargs) self.get_bec_shortcuts() - self._cells: dict[int, _SlotCell] = {} + self._cells: dict[Any, _SlotCell] = {} # last-seen (occupied, name, owner, measured_status) per slot, for # change-detection in refresh() - self._last_state: dict[int, tuple[bool, str, str, Optional[str]]] = {} - self.measured_log = _MeasuredSampleLog(self.client) + self._last_state: dict[Any, tuple[bool, str, str, Optional[str]]] = {} + self._shuttle_status_labels: dict[str, _ShuttleStatusLabel] = {} self.label_printer = PTouchLabelPrinter( host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER ) - self._flomni_available = self._check_flomni_available() + self.label_printer_omny = PTouchLabelPrinter( + host=PTOUCH_PRINTER_HOST, template_number=PTOUCH_TEMPLATE_NUMBER_OMNY + ) + self._setup = self._detect_setup() self._build_ui() self._poll_timer = QTimer(self) self._poll_timer.setInterval(self._POLL_INTERVAL_MS) self._poll_timer.timeout.connect(self.refresh) - if self._flomni_available: + if self._setup is not None: self._poll_timer.start() self.refresh() # ── setup detection ────────────────────────────────────────────────────── - def _check_flomni_available(self) -> bool: + def _detect_setup(self) -> Optional[str]: """ - Return True if the FlOMNI setup is active in this BEC session. - Uses ``fsamroy`` (the FlOMNI-specific rotation stage) as the - discriminator — it is absent in OMNY and LamNI sessions. + Return the key of whichever known sample-storage setup (see + ``SETUP_PROFILES``) is active in this BEC session, or None if + neither is present (e.g. a LamNI session, which has no + sample-storage device at all). A BEC session is always exactly one + setup, never more than one at a time -- confirmed across every + device_configs/*.yaml, dev.flomni_samples and dev.omny_samples + never coexist. """ - try: - return getattr(self.dev, "fsamroy", None) is not None - except Exception: - return False + for setup, profile in SETUP_PROFILES.items(): + try: + if getattr(self.dev, profile["discriminator_attr"], None) is not None: + return setup + except Exception: + continue + return None # ── device access ──────────────────────────────────────────────────────── @property def _samples(self): - """The flomni_samples device (single source of truth).""" + """The active setup's sample-storage device (single source of truth).""" + if self._setup == "omny": + return self.dev.omny_samples return self.dev.flomni_samples - def _read_all_slots(self) -> dict[int, tuple[bool, str, str, Optional[str]]]: + def _read_all_slots_flomni(self) -> dict[int, tuple[bool, str, str, Optional[str]]]: """Return {slot: (occupied, name, owner, measured_status)} for every slot 0–20 and the gripper from a SINGLE bulk - ``dev.flomni_samples.read()`` round-trip plus a single bulk - ``_MeasuredSampleLog.snapshot()`` (one redis round-trip, not one - per occupied slot). + ``dev.flomni_samples.read()`` round-trip (name, owner and + measurement status are separate IOC fields since 2026-10-17). The per-slot accessors (``is_sample_slot_used`` + ``sample_names.sample{N}.get()``) would be ~44 blocking device @@ -358,8 +534,7 @@ class OMNY_SampleStorage(BECWidget, QWidget): ``flomni_samples_sample_names_sample{N}`` / ``flomni_samples_sample_in_gripper`` / ``flomni_samples_sample_in_gripper_name``), the same keys the CLI - iterated over in ftransfer_sample_change. The raw name signal packs - name+owner (see ``sample_desc_codec``), unpacked here. + iterated over in ftransfer_sample_change. Every underlying signal is an ``auto_monitor=True`` ``EpicsSignal`` (see ``FlomniSampleStorage``), so BEC already keeps a fresh cached @@ -375,11 +550,60 @@ class OMNY_SampleStorage(BECWidget, QWidget): # keep whatever is currently displayed rather than blanking out return {} + def _val(key, default=None): + entry = data.get(key) + if isinstance(entry, dict): + return entry.get("value", default) + return default + + def _status(used: bool, raw) -> Optional[str]: + status = str(raw) if raw is not None else "" + return status if used and status in ("started", "completed") else None + + for slot in (STAGE_SLOT, *STORAGE_SLOTS): + used = bool(_val(f"flomni_samples_sample_placed_sample{slot}", 0)) + name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME) or EMPTY_NAME + owner = _val(f"flomni_samples_sample_owners_sample{slot}", "") or "" + status = _status(used, _val(f"flomni_samples_sample_status_sample{slot}")) + result[slot] = (used, str(name), str(owner), status) + + g_used = bool(_val("flomni_samples_sample_in_gripper", 0)) + g_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME) or EMPTY_NAME + g_owner = _val("flomni_samples_sample_in_gripper_owner", "") or "" + result[GRIPPER_SLOT] = ( + g_used, + str(g_name), + str(g_owner), + _status(g_used, _val("flomni_samples_sample_in_gripper_status")), + ) + + return result + + def _bulk_read_omny(self) -> Optional[dict]: + """Single cached bulk read of dev.omny_samples, shared by + _refresh_omny() and _build_report_pdf_omny() so a report build + never issues a second round of signal access on top of the poll. + Every underlying signal is auto_monitor=True (see + ``OMNYSampleStorage``), so ``cached=True`` returns the monitored + values instead of a live EPICS round-trip per signal -- same + rationale as _read_all_slots_flomni(). + """ try: - measured = self.measured_log.snapshot() + return self._samples.read(cached=True) except Exception as exc: - logger.warning(f"OMNY_SampleStorage: measured-log read failed: {exc}") - measured = {} + logger.warning(f"OMNY_SampleStorage: bulk read failed: {exc}") + return None + + def _parse_sample_state_omny(self, data: dict) -> dict[tuple, tuple[bool, str, str, Optional[str]]]: + """Parse a dev.omny_samples.read(cached=True) dict into + {key: (occupied, name, owner, measured_status)} for every + sample-kind slot -- stage, gripper, the 10 fixed O positions, and + the 18 shuttle slots. One record per slot on the OMNY soft IOC + (OMNY-SAMPLE::USED/NAME/OWNER/STATUS/MEAS_DATE, see + ``OMNYSampleStorage``); the flat keys are + ``omny_samples_sample__`` with the IOC slot name + (A1..C6, P10..P101, STAGE, GRIPPER). + """ def _val(key, default=None): entry = data.get(key) @@ -387,35 +611,68 @@ class OMNY_SampleStorage(BECWidget, QWidget): return entry.get("value", default) return default - def _measured_status(used: bool, name: str, owner: str) -> Optional[str]: - if not used: - return None - record = measured.get(pack_desc(name, owner)) - return record["status"] if record else None + def _record(slot: str) -> tuple[bool, str, str, Optional[str]]: + used = bool(_val(f"omny_samples_sample_used_{slot}", 0)) + name = _val(f"omny_samples_sample_names_{slot}", EMPTY_NAME) or EMPTY_NAME + owner = _val(f"omny_samples_sample_owners_{slot}", "") or "" + raw = _val(f"omny_samples_sample_status_{slot}") + status = str(raw) if raw is not None else "" + return ( + used, + str(name), + str(owner), + status if used and status in ("started", "completed", "screened") else None, + ) - for slot in (STAGE_SLOT, *STORAGE_SLOTS): - used = _val(f"flomni_samples_sample_placed_sample{slot}", 0) - raw_name = _val(f"flomni_samples_sample_names_sample{slot}", EMPTY_NAME) - name, owner = unpack_desc(raw_name if raw_name else EMPTY_NAME) - name = name if name else EMPTY_NAME - result[slot] = (bool(used), name, owner, _measured_status(bool(used), name, owner)) + state: dict[tuple, tuple[bool, str, str, Optional[str]]] = {} + state[("stage",)] = _record("STAGE") + state[("gripper",)] = _record("GRIPPER") + for n in OMNY_FIXED_POSITIONS: + state[("O", n)] = _record(f"P{n}") + for letter in OMNY_SHUTTLE_LETTERS: + for n in OMNY_SHUTTLE_SLOTS: + state[("shuttle", letter, n)] = _record(f"{letter}{n}") + return state - g_used = _val("flomni_samples_sample_in_gripper", 0) - g_raw_name = _val("flomni_samples_sample_in_gripper_name", EMPTY_NAME) - g_name, g_owner = unpack_desc(g_raw_name if g_raw_name else EMPTY_NAME) - g_name = g_name if g_name else EMPTY_NAME - result[GRIPPER_SLOT] = ( - bool(g_used), - g_name, - g_owner, - _measured_status(bool(g_used), g_name, g_owner), - ) + def _parse_parking_assignment_omny(self, data: dict) -> dict[int, str]: + """{parking_slot_nr: shuttle_letter} for parking slots that + currently have a shuttle recorded, from the same bulk-read dict as + _parse_sample_state_omny(). Treats unset_shuttle_slot()'s "none" + sentinel (and, defensively, "-"/EMPTY_NAME) as "not parked". + """ - return result + def _val(key, default=None): + entry = data.get(key) + if isinstance(entry, dict): + return entry.get("value", default) + return default + + assignment: dict[int, str] = {} + for n in OMNY_PARKING_SLOTS: + raw = _val(f"omny_samples_parking_names_parking{n}", "") + letter = str(raw).strip() if raw else "" + if letter and letter not in (PARKING_EMPTY_SENTINEL, EMPTY_NAME, ""): + assignment[n] = letter + return assignment + + def _read_active_parking_slot(self) -> Optional[int]: + """Which parking slot (2-6) the oparkz motor is currently sitting + at, i.e. ready for a sample transfer -- see + ``oparkz_active_parking_slot()``. A separate cached device read + from ``_bulk_read_omny()`` since it lives on a different device + (``dev.oparkz``, not ``dev.omny_samples``). + """ + try: + readback = self.dev.oparkz.readback.get(cached=True) + except Exception as exc: + logger.warning(f"OMNY_SampleStorage: oparkz readback failed: {exc}") + return None + return oparkz_active_parking_slot(readback) def _offer_print_label_for_previous_occupant(self, slot: int) -> None: """If `slot` currently holds a measured sample, offer to print an account/date/name label for it before its record gets overwritten. + FlOMNI-only (see module docstring); never called for OMNY writes. Unlike flomni.py's equivalent check, this can run unconditionally for every slot ``_write_slot()`` touches (stage, gripper, and @@ -425,39 +682,33 @@ class OMNY_SampleStorage(BECWidget, QWidget): ``Flomni.flomni_modify_storage_non_interactive()``. """ try: - if slot == GRIPPER_SLOT: - was_used = bool(self._samples.sample_in_gripper.get()) - prev_name, prev_owner = unpack_desc(str(self._samples.sample_in_gripper_name.get())) - else: - was_used = self._samples.is_sample_slot_used(slot) - prev_name, prev_owner = self._samples.get_sample_name_and_owner(slot) + record = self._samples.get_sample_record(slot) except Exception as exc: logger.warning( f"OMNY_SampleStorage: could not read previous state of slot {slot} for the" f" measured-label check: {exc}" ) return - if not was_used: - return - record = self.measured_log.pop(pack_desc(prev_name, prev_owner)) - if record is None: + if not record["used"] or record["status"] not in ("started", "completed"): return + prev_name = record["name"] reply = QMessageBox.question( self, "Print label?", f"Sample '{prev_name}' was measured ({record['status']}) on" - f" {record['measured_date']}. Print a label?", + f" {record['meas_date']}. Print a label?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return fields = { - PTOUCH_FIELD_ACCOUNT: record["account"], - PTOUCH_FIELD_DATE: record["measured_date"], + # account of the measurement (MEAS_ACCOUNT, 2026-10-27); older records: the owner + PTOUCH_FIELD_ACCOUNT: record.get("meas_account") or record["owner"], + PTOUCH_FIELD_DATE: record["meas_date"], PTOUCH_FIELD_SAMPLENAME: prev_name, } - # Retry loop instead of a single attempt: the record was already - # popped from measured_log above, so if we gave up after one + # Retry loop instead of a single attempt: the slot record is + # overwritten right after this offer, so if we gave up after one # failure the label's account/date/name would be gone for good # even though the operator could just switch the printer on and # try again a moment later. Mirrors the CLI's ensure_ready() retry @@ -480,20 +731,86 @@ class OMNY_SampleStorage(BECWidget, QWidget): if reply != QMessageBox.StandardButton.Retry: break + def _offer_print_label_for_previous_occupant_omny(self, key: tuple) -> None: + """If the shuttle slot about to be cleared has a measured status + (started or completed), offer to print an account/date/name label + for it -- OMNY's counterpart to + ``_offer_print_label_for_previous_occupant()``, but only ever + called for shuttle-slot clears (see ``_write_slot_omny``): unlike + the fixed-O/stage/gripper positions, a shuttle's samples are the + only ones that can physically leave the vacuum chamber, so + printing a label only makes sense there. + + Uses ``self.label_printer_omny`` (``PTOUCH_TEMPLATE_NUMBER_OMNY``), + a different template number than FlOMNI's -- same field names + (ACCOUNT/DATE/SAMPLENAME) by assumption, not yet bench-tested + against a real printer the way FlOMNI's template 1 is. + + Status, owner and measurement date live on the slot record itself + (OMNY-SAMPLE::..., as FlOMNI), read fresh from the device right + before the clear; the account of the measurement (MEAS_ACCOUNT) is printed, the owner + only for records without one. + """ + assert key[0] == "shuttle" + _, letter, n = key + try: + record = self._samples.get_sample_record(letter, n) + except Exception as exc: + logger.warning( + f"OMNY_SampleStorage: could not read previous state of slot {key} for the" + f" measured-label check: {exc}" + ) + return + if not record["used"] or record["status"] not in ("started", "completed"): + return + prev_name = record["name"] + reply = QMessageBox.question( + self, + "Print label?", + f"Sample '{prev_name}' was measured ({record['status']}) on" + f" {record['meas_date']}. Print a label?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + fields = { + # account of the measurement (MEAS_ACCOUNT, 2026-10-27); older records: the owner + PTOUCH_FIELD_ACCOUNT: record.get("meas_account") or record["owner"], + PTOUCH_FIELD_DATE: record["meas_date"], + PTOUCH_FIELD_SAMPLENAME: prev_name, + } + # Retry loop, same reasoning as _offer_print_label_for_previous_occupant(): + # the slot is about to be cleared regardless of whether printing + # succeeds, so give the operator a real chance to fix the printer + # and retry rather than losing the label silently. + while not self.label_printer_omny.print_label(fields): + reply = QMessageBox.question( + self, + "Print failed", + f"Could not print label for '{prev_name}' — printer unreachable.\n\n" + "Power it on / check it has tape loaded, then Retry, or Ignore to" + " give up on printing this label.\n\nThe slot will be updated" + " regardless, once you close this dialog.", + QMessageBox.StandardButton.Retry | QMessageBox.StandardButton.Ignore, + QMessageBox.StandardButton.Retry, + ) + if reply != QMessageBox.StandardButton.Retry: + break + def _write_slot(self, slot: int, used: int, name: str, owner: str = "") -> bool: """ - Write (used, name, owner) to a storage slot 0–20 or the gripper, - mirroring ``Flomni.flomni_modify_storage_non_interactive()`` exactly. + Write (used, name, owner) to a FlOMNI storage slot 0–20 or the + gripper, mirroring ``Flomni.flomni_modify_storage_non_interactive()`` + exactly. """ self._offer_print_label_for_previous_occupant(slot) try: - packed_name = pack_desc(name, owner) - if slot == GRIPPER_SLOT: - self._samples.sample_in_gripper.set(used) - self._samples.sample_in_gripper_name.set(packed_name) + # device methods write the whole record (slot 100 = gripper); a new or + # renamed sample starts with measurement status "none" + if used: + self._samples.set_sample_slot(slot, name, owner=owner) else: - getattr(self._samples.sample_placed, f"sample{slot}").set(used) - getattr(self._samples.sample_names, f"sample{slot}").set(packed_name) + self._samples.unset_sample_slot(slot) # drop the cached state for this slot so the refresh() right after # a write always repaints it, without waiting for the value to # differ from a possibly-stale cache entry @@ -508,8 +825,99 @@ class OMNY_SampleStorage(BECWidget, QWidget): ) return False - @staticmethod - def _slot_title(slot: int) -> str: + def _write_slot_omny(self, key: tuple, used: int, name: str, owner: str = "") -> bool: + """Write (used, name, owner) to an OMNY sample-kind slot -- stage, + gripper, a fixed O position, or a shuttle slot -- through the device + methods, like ``_write_slot()``. Measured status is reset to "none" + on every write here (``OMNYSampleStorage.set_sample_slot()``/ + ``unset_sample_slot()`` write the whole record) -- a fresh name + (even a rename) or a cleared slot isn't mid-measurement. + + Offers a P-touch label print for the previous occupant only when + *clearing* a *shuttle* slot (see + ``_offer_print_label_for_previous_occupant_omny``) -- shuttles are + the only OMNY samples that can physically leave the vacuum + chamber, so that's the only "sample remove" this widget can + meaningfully print a label for; every other clear (fixed-O/stage/ + gripper) is a bookkeeping correction, not a physical removal. + """ + kind = key[0] + if used == 0 and kind == "shuttle": + self._offer_print_label_for_previous_occupant_omny(key) + try: + if kind == "stage": + container, n = "STAGE", 0 + elif kind == "gripper": + container, n = "GRIPPER", 0 + elif kind == "O": + container, n = "O", key[1] + elif kind == "shuttle": + container, n = key[1], key[2] + else: + raise ValueError(f"unknown OMNY slot key {key!r}") + if used: + self._samples.set_sample_slot(container, n, name, owner) + else: + self._samples.unset_sample_slot(container, n) + self._last_state.pop(key, None) + return True + except Exception as exc: + logger.warning(f"OMNY_SampleStorage: write of slot {key} failed: {exc}") + QMessageBox.critical( + self, + "Write failed", + f"Could not write slot {self._slot_title(key)} to the device:\n{exc}", + ) + return False + + def _write_parking_omny(self, slot_nr: int, letter: Optional[str]) -> bool: + """Assign (or clear) which shuttle letter is recorded as parked at + OMNY parking slot `slot_nr` (1-6), mirroring + ``OMNYSampleStorage.set_shuttle_slot()``/``unset_shuttle_slot()`` + field-for-field -- including its own empty sentinel, "none", which + differs from sample slots' "-" (see ``PARKING_EMPTY_SENTINEL``). + Pure operator bookkeeping: never touched by the transfer code, so + (like ``flomni_modify_storage_non_interactive()``) there's no + interlock against editing a slot that's also being changed by an + in-progress transfer. + """ + try: + if letter: + self._samples.set_shuttle_slot(letter, slot_nr) + else: + self._samples.unset_shuttle_slot(slot_nr) + return True + except Exception as exc: + logger.warning(f"OMNY_SampleStorage: write of parking slot {slot_nr} failed: {exc}") + QMessageBox.critical( + self, + "Write failed", + f"Could not write parking slot {slot_nr} to the device:\n{exc}", + ) + return False + + def _write_sample(self, slot, used: int, name: str, owner: str = "") -> bool: + """Shared write dispatcher used by the sample-cell mutation actions + (action_change_sample/action_new_sample/action_clear) for both + setups: OMNY keys are tuples (see _SlotCell), FlOMNI keys are + plain ints. + """ + if isinstance(slot, tuple): + return self._write_slot_omny(slot, used, name, owner) + return self._write_slot(slot, used, name, owner) + + def _slot_title(self, slot) -> str: + if isinstance(slot, tuple): + kind = slot[0] + if kind == "stage": + return "stage" + if kind == "gripper": + return "gripper" + if kind == "O": + return f"O:{slot[1]}" + if kind == "shuttle": + return f"shuttle {slot[1]}, slot {slot[2]}" + return str(slot) if slot == STAGE_SLOT: return "stage (0)" if slot == GRIPPER_SLOT: @@ -518,16 +926,15 @@ class OMNY_SampleStorage(BECWidget, QWidget): # ── UI construction ─────────────────────────────────────────────────────── - def _build_ui(self): + def _build_ui(self) -> None: root = QVBoxLayout(self) root.setSpacing(8) - if not self._flomni_available: + if self._setup is None: lbl = QLabel( - "⚠ FlOMNI setup not detected in this BEC session.\n\n" - "This widget is only meaningful when the FlOMNI\n" - "BEC session is active (fsamroy device present).\n\n" - "OMNY support is not implemented yet." + "⚠ No known sample-storage setup detected in this BEC session.\n\n" + "This widget needs dev.flomni_samples (FlOMNI) or\n" + "dev.omny_samples (OMNY) to be present." ) lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) lbl.setWordWrap(True) @@ -536,6 +943,12 @@ class OMNY_SampleStorage(BECWidget, QWidget): root.addStretch() return + if self._setup == "flomni": + self._build_ui_flomni(root) + else: + self._build_ui_omny(root) + + def _build_ui_flomni(self, root: QVBoxLayout) -> None: # header: stage + gripper header = QHBoxLayout() header.setSpacing(8) @@ -560,7 +973,61 @@ class OMNY_SampleStorage(BECWidget, QWidget): grid.addWidget(cell, r, c) root.addWidget(grid_box, stretch=1) - # caveat line + self._build_caveat(root) + self._build_button_row(root) + + def _build_ui_omny(self, root: QVBoxLayout) -> None: + # header: stage + gripper + header = QHBoxLayout() + header.setSpacing(8) + self._cells[("stage",)] = _SlotCell(("stage",), self, COLOR_STAGE_BORDER, caption="Stage") + self._cells[("gripper",)] = _SlotCell(("gripper",), self, COLOR_GRIPPER_BORDER, caption="Gripper") + header.addWidget(self._cells[("stage",)]) + header.addWidget(self._cells[("gripper",)]) + root.addLayout(header) + + # fixed O positions + fixed_box = QFrame() + fixed_grid = QGridLayout(fixed_box) + fixed_grid.setSpacing(6) + fixed_grid.setContentsMargins(0, 0, 0, 0) + for idx, n in enumerate(OMNY_FIXED_POSITIONS): + r, c = divmod(idx, 5) + key = ("O", n) + cell = _SlotCell(key, self, COLOR_SLOT_BORDER, caption=f"O:{n}") + self._cells[key] = cell + fixed_grid.addWidget(cell, r, c) + root.addWidget(fixed_box) + + # shuttles A/B/C: each a status line (installed/active, editable) + # plus a 6-slot grid + shuttle_row = QHBoxLayout() + shuttle_row.setSpacing(8) + for letter in OMNY_SHUTTLE_LETTERS: + group = QGroupBox(f"Shuttle {letter}") + group_layout = QVBoxLayout(group) + + status_label = _ShuttleStatusLabel(letter, self) + self._shuttle_status_labels[letter] = status_label + group_layout.addWidget(status_label) + + slot_grid = QGridLayout() + slot_grid.setSpacing(4) + for idx, n in enumerate(OMNY_SHUTTLE_SLOTS): + r, c = divmod(idx, OMNY_SHUTTLE_COLS) + key = ("shuttle", letter, n) + cell = _SlotCell(key, self, COLOR_SLOT_BORDER, caption=f"{letter}-{n}") + self._cells[key] = cell + slot_grid.addWidget(cell, r, c) + group_layout.addLayout(slot_grid) + + shuttle_row.addWidget(group) + root.addLayout(shuttle_row, stretch=1) + + self._build_caveat(root) + self._build_button_row(root) + + def _build_caveat(self, root: QVBoxLayout) -> None: caveat = QLabel( "Records only — this does not move any hardware. " "Ensure the entries here match physical reality." @@ -569,7 +1036,7 @@ class OMNY_SampleStorage(BECWidget, QWidget): caveat.setStyleSheet("color: #FF9800; font-size: 11px;") root.addWidget(caveat) - # button row + def _build_button_row(self, root: QVBoxLayout) -> None: button_row = QHBoxLayout() button_row.addStretch() self.print_button = QPushButton("Print") @@ -581,18 +1048,23 @@ class OMNY_SampleStorage(BECWidget, QWidget): @SafeSlot() def refresh(self) -> None: - """Re-read all slots (one bulk device round-trip) and update only the - cells whose state actually changed. + """Re-read all slots (one bulk device round-trip) and update only + the cells whose state actually changed. Dispatches to the active + setup's own refresh method. + """ + if self._setup == "flomni": + self._refresh_flomni() + elif self._setup == "omny": + self._refresh_omny() - Repainting every cell on every 2 s poll — even when nothing changed, + def _refresh_flomni(self) -> None: + """Repainting every cell on every 2 s poll — even when nothing changed, which is almost always the case for a sample magazine — was needless work on the GUI thread. We diff against the last-seen state and only call set_state() on cells that differ, so a steady-state poll does no UI work at all. """ - if not self._flomni_available: - return - state = self._read_all_slots() + state = self._read_all_slots_flomni() if not state: return # bulk read failed; leave the current display untouched for slot, (occupied, name, owner, measured_status) in state.items(): @@ -603,10 +1075,68 @@ class OMNY_SampleStorage(BECWidget, QWidget): cell.set_state(occupied, name, owner, measured_status) self._last_state[slot] = (occupied, name, owner, measured_status) + def _refresh_omny(self) -> None: + """Same diff-and-repaint-only-what-changed approach as + ``_refresh_flomni()``, for the sample-kind cells, plus an update of + each shuttle's installed/active status label. + """ + data = self._bulk_read_omny() + if data is None: + return # bulk read failed; leave the current display untouched + + state = self._parse_sample_state_omny(data) + for key, entry in state.items(): + if self._last_state.get(key) == entry: + continue + cell = self._cells.get(key) + if cell is not None: + cell.set_state(*entry) + self._last_state[key] = entry + + parking_assignment = self._parse_parking_assignment_omny(data) + active_slot = self._read_active_parking_slot() + for letter in OMNY_SHUTTLE_LETTERS: + status = shuttle_status(letter, parking_assignment, active_slot) + label = self._shuttle_status_labels.get(letter) + if label is not None: + label.set_status(status["parked_at"], status["active"]) + # ── mutation actions (called from _SlotCell context menu) ───────────────── - def action_change_name(self, slot: int, current_name: str, current_owner: str = "") -> None: - """Rename an occupied slot (stays occupied).""" + def _find_duplicate_name(self, slot, name: str): + """Return the slot key that already holds `name`, if any OTHER + occupied slot of the active setup currently has this exact name, or + None. Used by action_change_sample()/action_new_sample() to enforce + that sample names are unique -- otherwise operators reading the + storage overview (and the measurement records, scilog entries and + labels keyed by sample name) could no longer tell two same-named + samples apart. + + Reads from ``self._last_state``, the same poll-refreshed snapshot + the cells themselves are painted from, rather than issuing a fresh + device read -- consistent with the rest of this widget's + eventual-consistency model (a write immediately after a poll could + in principle race a concurrent CLI rename, same as any other + interaction here; see module docstring on operator discipline). + """ + for key, entry in self._last_state.items(): + if key == slot: + continue + occupied, existing_name = entry[0], entry[1] + if occupied and existing_name == name: + return key + return None + + def action_change_sample(self, slot, current_name: str, current_owner: str = "") -> None: + """Replace an occupied slot's sample identity (name + owner), stays + occupied. Named "change sample" rather than "rename" because + _write_sample() resets the slot's measured status/date back to + "none" -- appropriate since a new name+owner means a different + physical sample, not a correction to the existing one's record. + Shared by both setups (see ``_SlotCell``) -- both require a name and + an owner (see module docstring), and both require the name to be + unique across the active setup's slots (see ``_find_duplicate_name``). + """ name, ok = QInputDialog.getText( self, "Change sample name", @@ -624,6 +1154,15 @@ class OMNY_SampleStorage(BECWidget, QWidget): "Use “Clear (set empty)” to empty the slot instead.", ) return + dup = self._find_duplicate_name(slot, name) + if dup is not None: + QMessageBox.warning( + self, + "Duplicate name", + f"'{name}' is already used by slot {self._slot_title(dup)}.\n" + "Sample names must be unique.", + ) + return owner, ok = QInputDialog.getText( self, "Sample owner", @@ -634,15 +1173,15 @@ class OMNY_SampleStorage(BECWidget, QWidget): return owner = owner.strip() if not owner: - QMessageBox.warning( - self, "Invalid owner", "An occupied slot needs a non-empty owner." - ) + QMessageBox.warning(self, "Invalid owner", "An occupied slot needs a non-empty owner.") return - if self._write_slot(slot, 1, name, owner): + if self._write_sample(slot, 1, name, owner): self.refresh() - def action_new_sample(self, slot: int) -> None: - """Mark an empty slot occupied and give it a name.""" + def action_new_sample(self, slot) -> None: + """Mark an empty slot occupied and give it a name. Shared by both + setups (see ``action_change_sample``), including the uniqueness check. + """ name, ok = QInputDialog.getText( self, "New sample", f"Sample name for slot {self._slot_title(slot)}:" ) @@ -652,6 +1191,15 @@ class OMNY_SampleStorage(BECWidget, QWidget): if not name or name == EMPTY_NAME: QMessageBox.warning(self, "Invalid name", "Please enter a non-empty sample name.") return + dup = self._find_duplicate_name(slot, name) + if dup is not None: + QMessageBox.warning( + self, + "Duplicate name", + f"'{name}' is already used by slot {self._slot_title(dup)}.\n" + "Sample names must be unique.", + ) + return owner, ok = QInputDialog.getText( self, "Sample owner", f"Owner for slot {self._slot_title(slot)}:" ) @@ -659,15 +1207,15 @@ class OMNY_SampleStorage(BECWidget, QWidget): return owner = owner.strip() if not owner: - QMessageBox.warning( - self, "Invalid owner", "An occupied slot needs a non-empty owner." - ) + QMessageBox.warning(self, "Invalid owner", "An occupied slot needs a non-empty owner.") return - if self._write_slot(slot, 1, name, owner): + if self._write_sample(slot, 1, name, owner): self.refresh() - def action_clear(self, slot: int) -> None: - """Mark a slot empty (destructive; confirmed).""" + def action_clear(self, slot) -> None: + """Mark a slot empty (destructive; confirmed). Shared by both + setups (see ``action_change_sample``). + """ reply = QMessageBox.question( self, "Clear slot", @@ -678,7 +1226,68 @@ class OMNY_SampleStorage(BECWidget, QWidget): ) if reply != QMessageBox.StandardButton.Yes: return - if self._write_slot(slot, 0, EMPTY_NAME): + if self._write_sample(slot, 0, EMPTY_NAME): + self.refresh() + + def action_set_parking(self, letter: str, current_slot: Optional[int]) -> None: + """Assign OMNY shuttle `letter` to a parking slot (1-6), via + ``_ShuttleStatusLabel``'s context menu. Rejects the assignment if + that slot is already assigned to a *different* shuttle -- unlike + ``set_shuttle_slot()`` itself, which would silently overwrite it + (see module docstring on why writes elsewhere in this widget don't + add interlocks: parking is the one exception, since two different + shuttles both being "in" the same slot is a bookkeeping + impossibility, not a legitimate concurrent-edit race). Re-assigning + the *same* shuttle to a slot it's already in is a no-op-safe + confirm, not an error. + + Doesn't clear wherever `letter` was previously parked, if anywhere + else -- still the operator's job, same as everywhere else here. + """ + slot_nr, ok = QInputDialog.getInt( + self, + "Set parking slot", + f"Parking slot (1-6) for shuttle {letter}:", + current_slot or 1, + 1, + 6, + 1, + ) + if not ok: + return + data = self._bulk_read_omny() + parking_assignment = self._parse_parking_assignment_omny(data) if data is not None else {} + occupant = parking_assignment.get(slot_nr) + if occupant is not None and occupant != letter: + QMessageBox.warning( + self, + "Slot in use", + f"Parking slot {slot_nr} is already assigned to shuttle {occupant}.\n\n" + f"Clear shuttle {occupant}'s parking assignment first (right-click its " + f"status line → Clear parking assignment), then assign shuttle " + f"{letter} to slot {slot_nr}.", + ) + return + if self._write_parking_omny(slot_nr, letter): + self.refresh() + + def action_clear_parking(self, letter: str, current_slot: Optional[int]) -> None: + """Clear OMNY shuttle `letter`'s parking-slot assignment (destructive; + confirmed), via ``_ShuttleStatusLabel``'s context menu. + """ + if current_slot is None: + return + reply = QMessageBox.question( + self, + "Clear parking assignment", + f"Clear shuttle {letter}'s parking slot {current_slot}?\n\n" + "Confirm the shuttle has actually been removed from that slot — " + "this only changes the record, not the hardware.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, + ) + if reply != QMessageBox.StandardButton.Yes: + return + if self._write_parking_omny(current_slot, None): self.refresh() # ── print report ───────────────────────────────────────────────────────── @@ -727,14 +1336,20 @@ class OMNY_SampleStorage(BECWidget, QWidget): return False def _build_report_pdf(self) -> str: + """Dispatch to the active setup's own report builder.""" + if self._setup == "omny": + return self._build_report_pdf_omny() + return self._build_report_pdf_flomni() + + def _build_report_pdf_flomni(self) -> str: """Render a page mirroring the on-screen grid (stage + gripper - header, 4x5 magazine, same layout/orientation as ``_build_ui()``) - from a fresh device read, and return the path of the temp PDF file - it was written to. + header, 4x5 magazine, same layout/orientation as + ``_build_ui_flomni()``) from a fresh device read, and return the + path of the temp PDF file it was written to. """ from fpdf import XPos, YPos - state = self._read_all_slots() + state = self._read_all_slots_flomni() fd, path = tempfile.mkstemp(prefix="flomni_sample_storage_", suffix=".pdf") os.close(fd) with PDFWriter(path) as file: @@ -759,7 +1374,7 @@ class OMNY_SampleStorage(BECWidget, QWidget): ) # magazine grid, same row/col + right-to-left mirroring as the - # on-screen grid (sample_storage.py's _build_ui()) + # on-screen grid (sample_storage.py's _build_ui_flomni()) grid_y0 = y0 + cell_h + 4 for idx, slot in enumerate(STORAGE_SLOTS): r, c = divmod(idx, STORAGE_COLS) @@ -769,10 +1384,70 @@ class OMNY_SampleStorage(BECWidget, QWidget): self._draw_report_cell(pdf, x, y, cell_w, cell_h, slot, state.get(slot)) return path + def _build_report_pdf_omny(self) -> str: + """Render a page with just the 3 shuttles and their samples (status + line + slot grid) from a fresh device read, and return the path of + the temp PDF file it was written to. + + Deliberately excludes the stage/gripper/fixed-O positions that the + on-screen widget also shows -- unlike the shuttles, none of those + samples can physically leave the vacuum chamber, so a printed + report (meant to travel with a shuttle's physical samples) has + nothing useful to say about them. The widget itself still displays + and manages all of it; only this printed report is scoped down. + """ + from fpdf import XPos, YPos + + data = self._bulk_read_omny() + state = self._parse_sample_state_omny(data) if data else {} + parking_assignment = self._parse_parking_assignment_omny(data) if data else {} + active_slot = self._read_active_parking_slot() + + fd, path = tempfile.mkstemp(prefix="omny_sample_storage_", suffix=".pdf") + os.close(fd) + with PDFWriter(path) as file: + pdf = file._pdf + pdf.set_font("Helvetica", "B", 16) + pdf.cell(0, 10, "OMNY Shuttle Samples", new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C") + pdf.set_font("Helvetica", "", 10) + timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + pdf.cell(0, 6, timestamp, new_x=XPos.LMARGIN, new_y=YPos.NEXT, align="C") + pdf.ln(6) + + margin = pdf.l_margin + usable_w = pdf.w - 2 * margin + cell_h = 28 + + # shuttles A/B/C: status line + slot grid as on the shuttle (3 rows x 2), side by side + shuttle_y0 = pdf.get_y() + shuttle_w = usable_w / 3 + for si, letter in enumerate(OMNY_SHUTTLE_LETTERS): + x0 = margin + si * shuttle_w + status = shuttle_status(letter, parking_assignment, active_slot) + if status["parked_at"] is None: + status_text = f"Shuttle {letter}: not parked" + else: + status_text = f"Shuttle {letter}: slot {status['parked_at']}" + if status["active"]: + status_text += " (ACTIVE)" + pdf.set_xy(x0, shuttle_y0) + pdf.set_font("Helvetica", "B", 9) + pdf.cell(shuttle_w, 5, status_text, align="L") + + slot_w = shuttle_w / OMNY_SHUTTLE_COLS + for idx, n in enumerate(OMNY_SHUTTLE_SLOTS): + r, c = divmod(idx, OMNY_SHUTTLE_COLS) + x = x0 + c * slot_w + y = shuttle_y0 + 6 + r * cell_h + key = ("shuttle", letter, n) + self._draw_report_cell(pdf, x, y, slot_w, cell_h, key, state.get(key)) + return path + def _draw_report_cell(self, pdf, x, y, w, h, slot, entry) -> None: """Draw one slot's box, mirroring what ``_SlotCell.set_state()`` shows on screen: slot caption, name (or "empty"), owner and measured - marker. + marker. Shared by both setups' report builders (same entry tuple + for both, no setup-specific branch here). """ pdf.rect(x, y, w, h) occupied, name, owner, measured_status = entry if entry else (False, EMPTY_NAME, "", None) @@ -800,6 +1475,8 @@ class OMNY_SampleStorage(BECWidget, QWidget): marker = "measured" elif measured_status == "started": marker = "started" + elif measured_status == "screened": + marker = "screened" pdf.set_xy(x + 1, y + h - 6) pdf.set_font("Helvetica", "", 7) pdf.cell(w - 2, 4, marker, align="C") @@ -839,4 +1516,4 @@ class OMNY_SampleStorage(BECWidget, QWidget): def cleanup(self) -> None: self._poll_timer.stop() - super().cleanup() \ No newline at end of file + super().cleanup() diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 6044216e..db914045 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -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 diff --git a/csaxs_bec/device_configs/ptycho_flomni.yaml b/csaxs_bec/device_configs/ptycho_flomni.yaml index 9b3c278d..31bc56d7 100644 --- a/csaxs_bec/device_configs/ptycho_flomni.yaml +++ b/csaxs_bec/device_configs/ptycho_flomni.yaml @@ -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 diff --git a/csaxs_bec/device_configs/ptycho_omny.yaml b/csaxs_bec/device_configs/ptycho_omny.yaml index 9580b7b1..d68d9aac 100755 --- a/csaxs_bec/device_configs/ptycho_omny.yaml +++ b/csaxs_bec/device_configs/ptycho_omny.yaml @@ -12,7 +12,7 @@ cam200: enabled: true onFailure: buffer readOnly: false - readoutPriority: async + readoutPriority: on_request deviceTags: - ptycho_omny @@ -27,7 +27,7 @@ cam201: enabled: true onFailure: buffer readOnly: false - readoutPriority: async + readoutPriority: on_request deviceTags: - ptycho_omny @@ -42,7 +42,7 @@ cam202: enabled: true onFailure: buffer readOnly: false - readoutPriority: async + readoutPriority: on_request deviceTags: - ptycho_omny @@ -57,7 +57,7 @@ cam203: enabled: true onFailure: buffer readOnly: false - readoutPriority: async + readoutPriority: on_request deviceTags: - ptycho_omny diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_flomni.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_flomni.yaml index efb7ddb9..02b2f8de 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_flomni.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_bl_real_flomni.yaml @@ -24,5 +24,5 @@ fsh: flomni: - !include ../ptycho_flomni.yaml -machine: - - !include ../machine.yml +# machine: +# - !include ../machine.yml diff --git a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml index aaa710ab..4943cff2 100644 --- a/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml +++ b/csaxs_bec/device_configs/simulated_omny/simulated_omny.yaml @@ -15,7 +15,7 @@ cam200: enabled: true onFailure: buffer readOnly: false - readoutPriority: async + readoutPriority: on_request deviceTags: - simulated_omny cam201: @@ -29,7 +29,7 @@ cam201: enabled: true onFailure: buffer readOnly: false - readoutPriority: async + readoutPriority: on_request deviceTags: - simulated_omny cam202: @@ -43,7 +43,7 @@ cam202: enabled: true onFailure: buffer readOnly: false - readoutPriority: async + readoutPriority: on_request deviceTags: - simulated_omny cam203: @@ -57,7 +57,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 diff --git a/csaxs_bec/devices/omny/flomni_loop_settings.py b/csaxs_bec/devices/omny/flomni_loop_settings.py new file mode 100644 index 00000000..23bf5faf --- /dev/null +++ b/csaxs_bec/devices/omny/flomni_loop_settings.py @@ -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()) diff --git a/csaxs_bec/devices/omny/flomni_sample_storage.py b/csaxs_bec/devices/omny/flomni_sample_storage.py index 3588b261..493ddc61 100644 --- a/csaxs_bec/devices/omny/flomni_sample_storage.py +++ b/csaxs_bec/devices/omny/flomni_sample_storage.py @@ -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::USED 0 = free, 1 = sample present + FLOMNI-SAMPLE::NAME sample name (long string, up to 256 chars) + FLOMNI-SAMPLE::OWNER owner of the sample (free text, not necessarily an e-account) + FLOMNI-SAMPLE::STATUS none / started / completed (measurement status) + FLOMNI-SAMPLE::MEAS_DATE date of the measurement + FLOMNI-SAMPLE::MEAS_ACCOUNT e-account active when the measurement status was last set + (2026-10-27; before, it went into OWNER if that was empty) + + = STAGE (slot 0, the flOMNI sample stage), T01 ... T20 (tray slots 1-20), + GRIPPER (slot 100) + +Replaces (2026-10-17) the former ``XOMNY-SAMPLE_DB_flomni:GET`` / ``.DESC`` records, +where name and owner were packed into one 40-character DESC (``sample_desc_codec``) +and the measurement status lived in a separate BEC global variable keyed by that +packed string. The status now travels with the sample: ``transfer_sample()`` moves +all fields between slots, the gripper and the stage. + +An empty string cannot be written to the char-array NAME via Channel Access, so a +free slot carries the name ``EMPTY_NAME`` ("-"). +""" + import time -from typing import Optional from ophyd import Component as Cpt from ophyd import Device @@ -7,13 +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") diff --git a/csaxs_bec/devices/omny/omny_sample_storage.py b/csaxs_bec/devices/omny/omny_sample_storage.py index 4675176f..e5e284e0 100644 --- a/csaxs_bec/devices/omny/omny_sample_storage.py +++ b/csaxs_bec/devices/omny/omny_sample_storage.py @@ -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::USED 0 = free, 1 = sample present + OMNY-SAMPLE::NAME sample name (long string, up to 256 chars) + OMNY-SAMPLE::OWNER owner of the sample (free text, not necessarily an e-account) + OMNY-SAMPLE::STATUS none / screened / started / completed (measurement status; + screened = was on the sample stage, no tomoscan started) + OMNY-SAMPLE::MEAS_DATE date of the measurement (of the last status change) + OMNY-SAMPLE::MEAS_ACCOUNT e-account active when the status was last raised + (2026-10-27; before, the account went into OWNER if it was empty) + + (container, slot_nr of this device in brackets): + A1..A6, B1..B6, C1..C6 shuttles ("A".."C", 1-6) + P10..P14, P32..P34, P100, P101 fixed positions ("O", 10-14, 32-34, 100, 101) + STAGE sample stage (set/unset_sample_in_samplestage) + GRIPPER gripper (set/unset_sample_in_gripper) + PARK1..PARK6 parking slots: USED + NAME = shuttle letter (set_shuttle_slot) + FZP0, OSA0, OSA1 exist in the IOC, not used by the transfer yet + +Replaces (2026-10-18) the classic softIoc records ``XOMNY-SAMPLE_DB_:`` +(VAL = placed, DESC = name) and ``...::STATUS`` (VAL = status sign x account, +DESC = owner). The status is now a plain enum plus a date, as on flOMNI, and travels +with the sample: ``transfer_sample()`` moves all fields between slots, the gripper and +the stage. + +An empty string cannot be written to the char-array NAME via Channel Access, so a +free slot carries the name ``EMPTY_NAME`` ("-"). +""" + import time from 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()") diff --git a/csaxs_bec/devices/omny/omny_temperatures.py b/csaxs_bec/devices/omny/omny_temperatures.py index 0d4ecc74..34bd6817 100644 --- a/csaxs_bec/devices/omny/omny_temperatures.py +++ b/csaxs_bec/devices/omny/omny_temperatures.py @@ -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()) diff --git a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py index a437c134..61dc32fe 100644 --- a/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_flomni_ophyd.py @@ -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,,). - 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() diff --git a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py index 90cdbdb2..38f3a97b 100644 --- a/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_lamni_ophyd.py @@ -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]) diff --git a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py index f2566f25..28c35110 100644 --- a/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_omny_ophyd.py @@ -268,9 +268,15 @@ class RtOMNYController(Controller): return self.mirror_parameters[channel] def laser_tracker_check_and_wait_for_signalstrength(self): - self.device_manager.connector.send_client_info( - "Checking laser tracker...", scope="", show_asap=True - ) + # Plain print(), not send_client_info(): this runs on every RT setpoint move + # (RtOMNYSetpointSignal._socket_set()), so send_client_info() queued a "Checking + # laser tracker..."/"...completed." pair per move into the scan's client-message + # queue -- multiple times per scan setup -- which is both noisy for the user and + # triggers a bec_lib get_client_messages() bug that reprints leftover queued + # messages on a later, unrelated call (see omny/AI_docs/OPEN_ISSUES.md). Mirrors + # flomni's laser_tracker_check_signalstrength() (rt_flomni_ophyd.py), which has + # always used plain print() for this. + print("Checking laser tracker...") if not self.laser_tracker_check_enabled(): print( "laser_tracker_check_and_wait_for_signalstrength: The laser tracker is not even enabled." @@ -285,10 +291,8 @@ class RtOMNYController(Controller): low_signal = rtx.user_parameter.get("low_signal") wait_counter = 0 while signal < min_signal and wait_counter < 10: - self.device_manager.connector.send_client_info( - f"The signal of the tracker {signal} is below the minimum required signal of {min_signal}. Waiting...", - scope="laser_tracker_check_and_wait_for_signalstrength", - show_asap=True, + print( + f"The signal of the tracker {signal} is below the minimum required signal of {min_signal}. Waiting..." ) wait_counter += 1 @@ -296,16 +300,12 @@ class RtOMNYController(Controller): signal = self._omny_interferometer_get_signalsample("ssi_4", 0.1) if signal < low_signal: - self.device_manager.connector.send_client_info( - f"\x1b[91mThe signal of the tracker {signal} is below the low limit of {low_signal}. Auto readjustment...\x1b[0m", - scope="laser_tracker_check_and_wait_for_signalstrength", - show_asap=True, + print( + f"\x1b[91mThe signal of the tracker {signal} is below the low limit of {low_signal}. Auto readjustment...\x1b[0m" ) self.omny_interferometer_align_tracking() - self.device_manager.connector.send_client_info( - "Checking laser tracker completed.", scope="", show_asap=True - ) + print("Checking laser tracker completed.") def omny_interferometer_align_tracking(self): mirror_channel = 6 @@ -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 = ( diff --git a/csaxs_bec/devices/omny/rt/rt_ophyd.py b/csaxs_bec/devices/omny/rt/rt_ophyd.py index f456729c..839e7a50 100644 --- a/csaxs_bec/devices/omny/rt/rt_ophyd.py +++ b/csaxs_bec/devices/omny/rt/rt_ophyd.py @@ -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 diff --git a/csaxs_bec/devices/omny/shutter.py b/csaxs_bec/devices/omny/shutter.py index 6e5e4fe8..56e9b219 100644 --- a/csaxs_bec/devices/omny/shutter.py +++ b/csaxs_bec/devices/omny/shutter.py @@ -1,95 +1,53 @@ """ -Fast Shutter control for OMNY setup. If started with a config file in which the device_manager -has a 'fsh' device (cSAXSFastEpicsShutter), this device will be used as the shutter. -Otherwise, the device will create a dummy shutter device that will log warnings when shutter -methods are called, but will not raise exceptions. +Fast Shutter control for OMNY setup. Delegates directly to the 'fsh' device +(cSAXSFastEpicsShutter, or its simulated counterpart) via the device manager +-- 'fsh' must be present and enabled in the running config. """ from bec_lib.logger import bec_logger -from ophyd import Component as Cpt -from ophyd import Device, Signal +from ophyd import Device from ophyd_devices import PSIDeviceBase logger = bec_logger.logger -class MonitorSignal(Signal): - """A simple wrapper around ophyd Signal that automatically monitors the signal for changes.""" - - def __init__(self, *, name, auto_monitor=False, **kwargs): - super().__init__(name=name, **kwargs) - self.auto_monitor = auto_monitor - - class OMNYFastShutter(PSIDeviceBase, Device): """ - Fast Shutter control for OMNY setup. If started with at the beamline, it will expose - the shutter control methods (fshopen, fshclose, fshstatus, fshinfo) from the - cSAXSFastEpicsShutter device. The device is identified by the 'fsh' name in the device manager. - If the 'fsh' device is not found in the device manager, this device will create a dummy shutter - and log warnings when shutter methods are called, but will not raise exceptions. + Fast Shutter control for OMNY setup. Exposes the shutter control methods + (fshopen, fshclose, fshstatus, fshinfo, fshstatus_readback) by delegating + to the 'fsh' device (cSAXSFastEpicsShutter) found in the device manager. """ USER_ACCESS = ["fshopen", "fshclose", "fshstatus", "fshinfo", "help", "fshstatus_readback"] SUB_VALUE = "value" _default_sub = SUB_VALUE - shutter = Cpt(MonitorSignal, name="shutter", auto_monitor=True) - # ----------------------------------------------------- # User-facing shutter control functions # ----------------------------------------------------- - # pylint: disable=invalid-name - def _check_if_cSAXS_shutter_exists_in_config(self) -> bool: - """ - Check on the device manager if the shutter device exists. - - Returns: - bool: True if the 'fsh' device exists in the device manager, False otherwise - """ - if self.device_manager.devices.get("fsh", None) is None: - logger.warning(f"Fast shutter device not found for {self.name}.") - return False - return True - def fshopen(self): """Open the fast shutter.""" - if self._check_if_cSAXS_shutter_exists_in_config(): - return self.device_manager.devices["fsh"].fshopen() - else: - self.shutter.put(1) + return self.device_manager.devices["fsh"].fshopen() def fshclose(self): """Close the fast shutter.""" - if self._check_if_cSAXS_shutter_exists_in_config(): - return self.device_manager.devices["fsh"].fshclose() - else: - self.shutter.put(0) + return self.device_manager.devices["fsh"].fshclose() def fshstatus(self): """Return the fast shutter status (0=closed, 1=open).""" - if self._check_if_cSAXS_shutter_exists_in_config(): - return self.device_manager.devices["fsh"].fshstatus() - else: - return self.shutter.get() + return self.device_manager.devices["fsh"].fshstatus() def fshinfo(self): """Print information about which EPICS PV channel is being used.""" - if self._check_if_cSAXS_shutter_exists_in_config(): - return self.device_manager.devices["fsh"].fshinfo() - else: - print("Using dummy fast shutter device. No EPICS channel is connected.") + return self.device_manager.devices["fsh"].fshinfo() + + def fshstatus_readback(self): + """Return the fast shutter status (0=closed, 1=open) from the readback signal.""" + return self.device_manager.devices["fsh"].fshstatus_readback() def help(self): """Display available user methods.""" print("Available methods:") for method in self.USER_ACCESS: print(f" - {method}") - - def fshstatus_readback(self): - """Return the fast shutter status (0=closed, 1=open) from the readback signal.""" - if self._check_if_cSAXS_shutter_exists_in_config(): - return self.device_manager.devices["fsh"].fshstatus_readback() - else: - self.shutter.get() diff --git a/csaxs_bec/devices/omny/webhmi_client.py b/csaxs_bec/devices/omny/webhmi_client.py new file mode 100644 index 00000000..8a22fa16 --- /dev/null +++ b/csaxs_bec/devices/omny/webhmi_client.py @@ -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://:8080 with = `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") diff --git a/csaxs_bec/devices/sim/sim_galil.py b/csaxs_bec/devices/sim/sim_galil.py index f80e2a6b..3beedc2e 100644 --- a/csaxs_bec/devices/sim/sim_galil.py +++ b/csaxs_bec/devices/sim/sim_galil.py @@ -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, diff --git a/csaxs_bec/devices/sim/sim_omny.py b/csaxs_bec/devices/sim/sim_omny.py index 5aebf832..4f392eca 100644 --- a/csaxs_bec/devices/sim/sim_omny.py +++ b/csaxs_bec/devices/sim/sim_omny.py @@ -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 diff --git a/csaxs_bec/devices/sim/sim_sample_storage.py b/csaxs_bec/devices/sim/sim_sample_storage.py index 602740e7..f70c11d9 100644 --- a/csaxs_bec/devices/sim/sim_sample_storage.py +++ b/csaxs_bec/devices/sim/sim_sample_storage.py @@ -1,7 +1,7 @@ """ Simulated flOMNI sample storage. -`FlomniSampleStorage` is purely EPICS-backed (XOMNY-SAMPLE_DB_flomni* PVs). The simulated +`FlomniSampleStorage` is purely EPICS-backed (FLOMNI-SAMPLE:: PVs). The simulated version follows the patched-device pattern of `simulated_beamline_devices`: the real class is instantiated with all PVs replaced by in-memory mock PVs, so `set_sample_slot`, `show_all`, the gripper bookkeeping and the sample-transfer scripts run unchanged; the diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index f07cbca8..8d056a2a 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -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. diff --git a/csaxs_bec/scans/lamni_fermat_scan.py b/csaxs_bec/scans/lamni_fermat_scan.py index 743d8f39..817347eb 100644 --- a/csaxs_bec/scans/lamni_fermat_scan.py +++ b/csaxs_bec/scans/lamni_fermat_scan.py @@ -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) diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py index aee8c025..cada6751 100644 --- a/csaxs_bec/scans/omny_fermat_scan.py +++ b/csaxs_bec/scans/omny_fermat_scan.py @@ -20,6 +20,8 @@ import time from typing import Annotated import numpy as np +from scipy.spatial import cKDTree + from bec_lib import messages from bec_lib.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()) diff --git a/docs/user/ptychography/flomni.md b/docs/user/ptychography/flomni.md index 80bfc713..1e7fa45b 100644 --- a/docs/user/ptychography/flomni.md +++ b/docs/user/ptychography/flomni.md @@ -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_` 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 diff --git a/docs/user/ptychography/omny.md b/docs/user/ptychography/omny.md index e8a763aa..d1b19e19 100644 --- a/docs/user/ptychography/omny.md +++ b/docs/user/ptychography/omny.md @@ -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.
_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::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. diff --git a/omny_e2e_tests/_bootstrap.py b/omny_e2e_tests/_bootstrap.py index be7194c0..c6b3d15b 100644 --- a/omny_e2e_tests/_bootstrap.py +++ b/omny_e2e_tests/_bootstrap.py @@ -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 diff --git a/omny_e2e_tests/sim_flomni_harness.py b/omny_e2e_tests/sim_flomni_harness.py index 1b8778e4..0918b62c 100644 --- a/omny_e2e_tests/sim_flomni_harness.py +++ b/omny_e2e_tests/sim_flomni_harness.py @@ -46,9 +46,17 @@ class FakeDeviceContainer(dict): raise AttributeError(name) from exc +class FakeConnector: + """Stand-in for device_manager.connector; client-info notifications are a no-op here.""" + + def send_client_info(self, *args, **kwargs): + pass + + class FakeDeviceManager: def __init__(self): self.devices = FakeDeviceContainer() + self.connector = FakeConnector() def add(self, name, obj, user_parameter=None): self.devices[name] = FakeConfiguredDevice(obj, user_parameter) @@ -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 '- | '", + 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() diff --git a/omny_e2e_tests/sim_lamni_omny_harness.py b/omny_e2e_tests/sim_lamni_omny_harness.py index b388d5a6..1d6f38bd 100644 --- a/omny_e2e_tests/sim_lamni_omny_harness.py +++ b/omny_e2e_tests/sim_lamni_omny_harness.py @@ -178,10 +178,12 @@ def run_lamni(): # noqa: C901 # coarse-stage coupling: with feedback off, the interferometer follows lsamx/lsamy # through the LamNI geometry (this is what makes the scan-center routine converge) - from csaxs_bec.scans.LamNIFermatScan import lamni_from_stage_coordinates + from csaxs_bec.scans.lamni_components import LamNIComponents lsamx.move(87.55, wait=True) # +0.05 mm from center - expected_x, expected_y = (val * 1000 for val in lamni_from_stage_coordinates(0.05, 0.0)) + expected_x, expected_y = ( + val * 1000 for val in LamNIComponents.lamni_from_stage_coordinates(0.05, 0.0) + ) check( np.isclose(rtx.readback.get(), expected_x, atol=0.5) and np.isclose(rty.readback.get(), expected_y, atol=0.5), @@ -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") diff --git a/pyproject.toml b/pyproject.toml index 4110f2b6..72cc9652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/tests/tests_bec_ipython_client/test_flomni_gripper_check_images.py b/tests/tests_bec_ipython_client/test_flomni_gripper_check_images.py new file mode 100644 index 00000000..0cc06730 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_gripper_check_images.py @@ -0,0 +1,84 @@ +"""Tests that flomni's gripper-empty/gripper-occupied sample-transfer +checks capture a gripper camera reference image, not just the final +"All OK?" confirm (see csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/ +OPEN_ISSUES.md, "flomni has no equivalent gate to save at"). + +Uses a bare FlomniSampleTransferMixin instance (bypassing Flomni's heavy +__init__), same pattern as test_lamni_account_change_reset.py. +""" + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.flomni.flomni as flomni_module +from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import ( + FlomniError, + FlomniSampleTransferMixin, +) + + +class FakeConnector: + def __init__(self, image): + self.image = image + self.requested_endpoints = [] + + def get_last(self, endpoint): + self.requested_endpoints.append(endpoint) + return {"data": type("Msg", (), {"data": self.image})()} + + +class FakeClient: + def __init__(self, image="gripper-cam-frame"): + self.connector = FakeConnector(image) + + +class FakeFlomniSamples: + def __init__(self, sample_in_gripper): + self._sample_in_gripper = sample_in_gripper + + def is_sample_in_gripper(self): + return self._sample_in_gripper + + +class _FakeDev: + def __init__(self, sample_in_gripper): + self.flomni_samples = FakeFlomniSamples(sample_in_gripper) + + +def make_flomni(sample_in_gripper): + flomni_module.dev = _FakeDev(sample_in_gripper) + obj = object.__new__(FlomniSampleTransferMixin) + obj.client = FakeClient() + obj.check_tray_in = lambda: None + obj.check_sensor_connected = lambda: None + obj.saved_images = [] + obj.save_reference_image = lambda image, file_suffix="": obj.saved_images.append( + (image, file_suffix) + ) + return obj + + +def test_get_sample_captures_image_before_raising_when_gripper_occupied(): + flomni = make_flomni(sample_in_gripper=True) + + with pytest.raises(FlomniError, match="does carry a sample"): + flomni.ftransfer_get_sample(5) + + assert flomni.saved_images == [("gripper-cam-frame", "get_gripper_empty_check")] + + +def test_put_sample_captures_image_before_raising_when_gripper_empty(): + flomni = make_flomni(sample_in_gripper=False) + + with pytest.raises(FlomniError, match="does not carry a sample"): + flomni.ftransfer_put_sample(5) + + assert flomni.saved_images == [("gripper-cam-frame", "put_gripper_occupied_check")] + + +def test_capture_gripper_image_fetches_preview_and_saves_it(): + flomni = make_flomni(sample_in_gripper=False) + + flomni._ftransfer_capture_gripper_image(file_suffix="my_suffix") + + assert flomni.saved_images == [("gripper-cam-frame", "my_suffix")] + assert flomni.client.connector.requested_endpoints diff --git a/tests/tests_bec_ipython_client/test_flomni_realtime_system.py b/tests/tests_bec_ipython_client/test_flomni_realtime_system.py new file mode 100644 index 00000000..04921234 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_realtime_system.py @@ -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} diff --git a/tests/tests_bec_ipython_client/test_flomni_review_fixes.py b/tests/tests_bec_ipython_client/test_flomni_review_fixes.py new file mode 100644 index 00000000..bd521c27 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_flomni_review_fixes.py @@ -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 diff --git a/tests/tests_bec_ipython_client/test_flomni_special_angles.py b/tests/tests_bec_ipython_client/test_flomni_special_angles.py index 7cfab251..4a5781f5 100644 --- a/tests/tests_bec_ipython_client/test_flomni_special_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_special_angles.py @@ -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 == [] diff --git a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py index 5385c15d..414ff838 100644 --- a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py @@ -243,8 +243,6 @@ def make_flomni_for_golden_resume(monkeypatch, tomo_type: int) -> Flomni: obj.golden_max_number_of_projections = 21 obj.tomo_id = -1 obj.sample_get_name = lambda position: "test" # backs the read-only sample_name property - obj.sample_get_measured_log_key = lambda position=0: "test|" - obj.measured_log = types.SimpleNamespace(mark_measured=lambda *a, **k: None) obj.special_angles = [] obj._golden = _fake_golden obj._golden_equally_spaced = _fake_golden @@ -270,6 +268,11 @@ def make_flomni_for_golden_resume(monkeypatch, tomo_type: int) -> Flomni: ) monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans()) + # tomo_scan() records the measurement status on the sample record (stage slot 0) + fake_dev = types.SimpleNamespace( + flomni_samples=types.SimpleNamespace(set_sample_measured=lambda *a, **k: None) + ) + monkeypatch.setitem(builtins.__dict__, "dev", fake_dev) return obj @@ -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 diff --git a/tests/tests_bec_ipython_client/test_omny_alignment_mixin.py b/tests/tests_bec_ipython_client/test_omny_alignment_mixin.py new file mode 100644 index 00000000..9ef20684 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_alignment_mixin.py @@ -0,0 +1,143 @@ +"""Regression test for OMNYAlignmentMixin's correction data. + +corr_pos_x/corr_angle_x/corr_pos_y/etc. used to be plain instance attributes, +only ever assigned inside reset_correction() -- calling +_compute_additional_correction() before reset_correction() had ever run (e.g. +tomo_scan_projection() called to test scan mechanics without first doing a +sample transfer, the only place reset_correction() gets called, see +omny_sample_transfer_mixin.py's otransfer_put_sample()) raised +'OMNY' object has no attribute 'corr_pos_x'. + +They are now global-var-backed properties (mirroring Flomni's +corr_pos_y/corr_angle_y/etc.), defaulting to [] when unset -- matching the +graceful "no data available" handling _compute_additional_correction() +already had for an empty (but present) list, and persisting across BEC +client restarts like every other setting in this class. + +Also regression-guards default_correction_file/default_correction_file_x +being resolved to an absolute path (a bare relative filename would only ever +be found if bec were launched from this exact plugin directory). +""" + +import os + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.omny.omny_alignment_mixin import OMNYAlignmentMixin + + +class FakeClient: + """Minimal in-memory stand-in for BEC's global-var store.""" + + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +def make_alignment(): + # bare instance, deliberately never calling reset_correction() -- mirrors + # a fresh OMNY session where no sample has been transferred in yet + align = OMNYAlignmentMixin() + align.client = FakeClient() + return align + + +def test_default_correction_files_are_absolute_and_exist(): + assert os.path.isabs(OMNYAlignmentMixin.default_correction_file) + assert os.path.isabs(OMNYAlignmentMixin.default_correction_file_x) + assert os.path.exists(OMNYAlignmentMixin.default_correction_file) + assert os.path.exists(OMNYAlignmentMixin.default_correction_file_x) + + +@pytest.mark.parametrize( + "attr", + ["corr_pos_x", "corr_angle_x", "corr_pos_y", "corr_angle_y", "corr_pos_y_2", "corr_angle_y_2"], +) +def test_correction_properties_default_to_empty_list(attr): + align = make_alignment() + + assert getattr(align, attr) == [] + + +@pytest.mark.parametrize("iteration", ["x1", "y1", "y2"]) +def test_compute_additional_correction_returns_zero_when_never_reset(iteration, capsys): + align = make_alignment() + + result = align._compute_additional_correction(45.0, iteration=iteration) + + assert result == 0 + assert "No data available" in capsys.readouterr().out + + +def test_compute_additional_correction_x_does_not_raise(): + align = make_alignment() + + assert align.compute_additional_correction_x(0.0) == 0 + + +def test_compute_additional_correction_y_does_not_raise(): + align = make_alignment() + + assert align.compute_additional_correction_y(0.0) == 0 + + +def test_compute_additional_correction_y_2_does_not_raise(): + align = make_alignment() + + assert align.compute_additional_correction_y_2(0.0) == 0 + + +def test_compute_additional_correction_still_works_after_reset(): + """Once corr_pos_x/corr_angle_x etc. are actually set (mirrors what + reset_correction() -> read_additional_correction_x() would do), the + normal closest-angle lookup still works.""" + align = make_alignment() + align.corr_pos_x = [1.0, 2.0, 3.0] + align.corr_angle_x = [0.0, 90.0, 180.0] + + result = align._compute_additional_correction(85.0, iteration="x1") + + assert result == 2.0 + + +def test_correction_properties_persist_across_instances_via_shared_client(): + """Global-var-backed: two instances sharing the same client see the same + value, unlike a plain instance attribute.""" + client = FakeClient() + align_a = OMNYAlignmentMixin() + align_a.client = client + align_b = OMNYAlignmentMixin() + align_b.client = client + + align_a.corr_pos_x = [5.0] + + assert align_b.corr_pos_x == [5.0] + + +def test_reset_correction_with_default_loads_real_file_data(): + """End-to-end check of the path-resolution fix: reset_correction(True) + must actually find and load the real correction_omny_202204*.txt files + (previously silently failed everywhere except when bec happened to be + launched from this exact plugin directory).""" + align = make_alignment() + + align.reset_correction(use_default_correction=True) + + assert len(align.corr_pos_x) > 0 + assert len(align.corr_pos_y) > 0 + + +def test_reset_correction_without_default_clears_to_empty(): + align = make_alignment() + align.corr_pos_x = [1.0] + align.corr_angle_x = [0.0] + + align.reset_correction(use_default_correction=False) + + assert align.corr_pos_x == [] + assert align.corr_angle_x == [] diff --git a/tests/tests_bec_ipython_client/test_omny_gui_tools.py b/tests/tests_bec_ipython_client/test_omny_gui_tools.py new file mode 100644 index 00000000..a1e579f2 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_gui_tools.py @@ -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() diff --git a/tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py b/tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py new file mode 100644 index 00000000..bbbc5700 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_optics_skip_if_in_position.py @@ -0,0 +1,204 @@ +"""Tests for omny's "skip if already in position" optics optimization for +oosa_in()/oosa_out()/ofzp_in()/ofzp_out(), ported from flomni's +fosa_in()/ffzp_in() (same optimization already done for lamni, see +test_lamni_optics_skip_if_in_position.py). + +Methods under test (omny_optics_mixin.py) reference module-level `dev`/`umv` +globals (resolved from this module's own namespace, not an instance +attribute), so tests monkeypatch those module globals directly. +""" + +import types + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.omny.omny_optics_mixin as optics_mixin_module +from csaxs_bec.bec_ipython_client.plugins.omny.omny_optics_mixin import OMNYOpticsMixin + +# far-field in: oosax=3.022, oosay=0.4122, oosaz=6.5 (out) +# near-field in: oosax=3.2044, oosay=0.531, oosaz=-0.4452 +FAR_IN = {"oosax": 3.022, "oosay": 0.4122, "oosaz": 6.5} +NEAR_IN = {"oosax": 3.2044, "oosay": 0.531, "oosaz": -0.4452} + + +class FakeAxis: + def __init__(self, value, in_pos=None, out_pos=None): + self.value = value + self.readback = types.SimpleNamespace(get=lambda: self.value) + self.user_parameter = {} + if in_pos is not None: + self.user_parameter["in"] = in_pos + if out_pos is not None: + self.user_parameter["out"] = out_pos + + def read(self, cached=True): + return self.value + + def get(self): + return types.SimpleNamespace(readback=self.value) + + +class FakeController: + def __init__(self): + self.calls = [] + + def feedback_disable(self): + self.calls.append("disable") + + def feedback_enable_with_reset(self): + self.calls.append("enable_with_reset") + + +class FakeDev: + def __init__(self, oosay=0.9, oshield=20.0, osamy=0.0): + self.oosax = FakeAxis(0.0, in_pos=FAR_IN["oosax"]) + self.oosax.user_parameter = { + "far_field_in": FAR_IN["oosax"], + "near_field_in": NEAR_IN["oosax"], + } + self.oosay = FakeAxis(oosay) + self.oosay.user_parameter = { + "far_field_in": FAR_IN["oosay"], + "near_field_in": NEAR_IN["oosay"], + } + self.oosaz = FakeAxis(0.0) + self.oosaz.limits = None + self.oosaz.user_parameter = { + "far_field_in": FAR_IN["oosaz"], + "near_field_in": NEAR_IN["oosaz"], + } + self.oshield = FakeAxis(oshield) + self.osamy = FakeAxis(osamy) + self.ofzpx = FakeAxis(-0.5, in_pos=-0.5) + self.ofzpy = FakeAxis(0.3, in_pos=0.3, out_pos=0.0) + self.rtx = types.SimpleNamespace(enabled=True, controller=FakeController()) + + def __contains__(self, name): + return hasattr(self, name) + + def __getitem__(self, name): + return getattr(self, name) + + +@pytest.fixture +def fake_dev(monkeypatch): + # omny_optics_mixin.py has no module-level `dev`/`umv` names of its own -- + # it resolves them via the builtins fallback at call time, so the module + # attribute must be force-created (raising=False) rather than assumed to + # pre-exist. + dev = FakeDev() + monkeypatch.setattr(optics_mixin_module, "dev", dev, raising=False) + return dev + + +@pytest.fixture +def umv_calls(monkeypatch): + calls = [] + monkeypatch.setattr(optics_mixin_module, "umv", lambda *args: calls.append(args), raising=False) + return calls + + +def make_optics(near_field=False): + optics = OMNYOpticsMixin() + optics.near_field = near_field + optics.OMNYTools = types.SimpleNamespace(printgreen=print) + return optics + + +def test_oosa_in_skips_move_when_already_in_position_far_field(fake_dev, umv_calls, capsys): + fake_dev.oosax.value = FAR_IN["oosax"] + fake_dev.oosay.value = FAR_IN["oosay"] + fake_dev.oosaz.value = FAR_IN["oosaz"] + optics = make_optics(near_field=False) + + optics.oosa_in() + + assert umv_calls == [] + assert "already at the IN position" in capsys.readouterr().out + # _oosa_check_y()/shield-close must not run either -- oosay untouched + assert fake_dev.oosay.value == FAR_IN["oosay"] + + +def test_oosa_in_moves_when_out_of_position(fake_dev, umv_calls): + fake_dev.oosax.value = -2.0 # not at far-field "in" + optics = make_optics(near_field=False) + + optics.oosa_in() + + assert (fake_dev.oosaz, FAR_IN["oosaz"]) in umv_calls + assert (fake_dev.oosax, FAR_IN["oosax"]) in umv_calls + assert (fake_dev.oosay, FAR_IN["oosay"]) in umv_calls + + +def test_oosa_in_skips_move_when_already_in_position_near_field(fake_dev, umv_calls, capsys): + fake_dev.oosax.value = NEAR_IN["oosax"] + fake_dev.oosay.value = NEAR_IN["oosay"] + fake_dev.oosaz.value = NEAR_IN["oosaz"] + optics = make_optics(near_field=True) + + optics.oosa_in() + + assert umv_calls == [] + assert "already at the IN position" in capsys.readouterr().out + + +def test_oosa_out_skips_move_when_already_out(fake_dev, umv_calls, capsys): + fake_dev.oosax.value = -2.0 + fake_dev.oosaz.value = 6.5 + optics = make_optics(near_field=False) + + optics.oosa_out() + + assert umv_calls == [] + assert "already at the OUT position" in capsys.readouterr().out + + +def test_oosa_out_moves_when_not_out(fake_dev, umv_calls): + fake_dev.oosax.value = FAR_IN["oosax"] + fake_dev.oosaz.value = FAR_IN["oosaz"] + optics = make_optics(near_field=False) + + optics.oosa_out() + + assert (fake_dev.oosaz, 6.5) in umv_calls + assert (fake_dev.oosax, -2) in umv_calls + + +def test_ofzp_in_skips_move_and_feedback_disable_when_already_in(fake_dev, umv_calls, capsys): + optics = make_optics() + + optics.ofzp_in() + + assert umv_calls == [] + assert fake_dev.rtx.controller.calls == [] + assert "already at the in position" in capsys.readouterr().out + + +def test_ofzp_in_moves_and_disables_feedback_when_out_of_position(fake_dev, umv_calls): + fake_dev.ofzpx.value = -1.0 # not at "in" (-0.5) + optics = make_optics() + + optics.ofzp_in() + + assert (fake_dev.ofzpx, -0.5) in umv_calls + assert fake_dev.rtx.controller.calls == ["disable"] + + +def test_ofzp_out_skips_move_and_feedback_disable_when_already_out(fake_dev, umv_calls, capsys): + fake_dev.ofzpy.value = 0.0 # matches "out" + optics = make_optics() + + optics.ofzp_out() + + assert umv_calls == [] + assert fake_dev.rtx.controller.calls == [] + assert "already at the out position" in capsys.readouterr().out + + +def test_ofzp_out_moves_and_disables_feedback_when_not_out(fake_dev, umv_calls): + optics = make_optics() # ofzpy at 0.3, not "out" (0.0) + + optics.ofzp_out() + + assert (fake_dev.ofzpy, 0.0) in umv_calls + assert fake_dev.rtx.controller.calls == ["disable"] diff --git a/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py b/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py new file mode 100644 index 00000000..5f4deea3 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_sample_storage_widget_math.py @@ -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} diff --git a/tests/tests_bec_ipython_client/test_omny_sample_transfer_mixin.py b/tests/tests_bec_ipython_client/test_omny_sample_transfer_mixin.py new file mode 100644 index 00000000..54c18730 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_sample_transfer_mixin.py @@ -0,0 +1,54 @@ +from unittest import mock + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin import ( + _OPARKZ_LEICA_TARGET_POSITIONS, + _OPARKZ_TARGET_POSITIONS, + OMNYSampleTransferMixin, +) + +# pylint: disable=protected-access + +MIXIN = "csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin" + + +def _mixin_at(readback: float) -> OMNYSampleTransferMixin: + mixin = OMNYSampleTransferMixin() + dev_mock = mock.MagicMock() + dev_mock.oparkz.get.return_value.readback = readback + mixin._dev_patch = mock.patch(f"{MIXIN}.dev", dev_mock, create=True) + mixin._dev_patch.start() + return mixin + + +@pytest.mark.parametrize("slot,target", sorted(_OPARKZ_TARGET_POSITIONS.items())) +def test_oparkz_slot_check_matches_each_regular_gripper_target(slot, target): + mixin = _mixin_at(target) + try: + assert mixin._oparkz_slot_check() == slot + finally: + mixin._dev_patch.stop() + + +@pytest.mark.parametrize("slot,target", sorted(_OPARKZ_LEICA_TARGET_POSITIONS.items())) +def test_oparkz_slot_check_matches_each_leica_target(slot, target): + """otransfer_park_loadlock_slot() parks at the leica offset (base -6), + not the regular gripper offset (base -0.9) -- both must be recognized + as a valid active parking slot, or otransfer_put_sample()/ + otransfer_get_sample() wrongly raise "Parking stage is not at an + active parking position" right after a loadlock park even though the + GUI (and the operator) can see it parked there.""" + mixin = _mixin_at(target) + try: + assert mixin._oparkz_slot_check() == slot + finally: + mixin._dev_patch.stop() + + +def test_oparkz_slot_check_returns_10_between_positions(): + mixin = _mixin_at(-3.0) + try: + assert mixin._oparkz_slot_check() == 10 + finally: + mixin._dev_patch.stop() diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_angles.py b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py new file mode 100644 index 00000000..d295853e --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_tomo_angles.py @@ -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 diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py new file mode 100644 index 00000000..1f084cee --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py @@ -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] diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py new file mode 100644 index 00000000..94dce0dd --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py @@ -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 diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py new file mode 100644 index 00000000..380c0e9a --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py @@ -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, , dev.rty, , dev.rtz, ) + assert rtx_moves[0][1][3] == 1.0 # normal_offset_y, unshifted + + assert ("recenter", expected_offset_x) in calls + assert ("tracker_on",) in calls + assert ("tracker_check",) in calls + + acquire_calls = [c for c in calls if c[0] == "acquire"] + assert acquire_calls == [("acquire", {"exp_time": 0.2, "frames_per_trigger": 10})] + + # one acquire call bumped next_scan_number by 1 (100 -> 101) -- exactly + # one scan number logged, with subtomo_number=0 (flat field, not part + # of the angular grid) + assert written_scan_numbers == [(100, 0, 0)] diff --git a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py index 249dfed5..bd1af2ec 100644 --- a/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_tomo_params_widget_math.py @@ -10,6 +10,7 @@ import pytest from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( + SETUP_PROFILES, _compute_fermat_positions_flomni, _compute_type1, _requested_to_stepsize, @@ -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] diff --git a/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py index e07ece3b..b16ad85b 100644 --- a/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py +++ b/tests/tests_bec_widgets/test_x_ray_eye_ellipse_roi.py @@ -27,6 +27,22 @@ def _qapp(): return pg.mkQApp() +# All test ROIs go into one scene that lives for the whole module: Qt then owns the items, and the +# Python garbage collector cannot delete an unparented ROI while the next one is being built (that +# crashed PySide6 / pyqtgraph in CI with --random-order: "Fatal Python error: Segmentation fault" in +# GraphicsObject.itemChange during garbage collection). +_SCENE = None + + +@pytest.fixture(autouse=True, scope="module") +def _scene(_qapp): + global _SCENE + _SCENE = pg.QtWidgets.QGraphicsScene() + yield _SCENE + _SCENE.clear() + _SCENE = None + + class _FakeEllipticalROI(pg.EllipseROI): """pg ellipse standing in for bec_widgets' EllipticalROI in isinstance checks.""" @@ -39,6 +55,7 @@ def _patch_roi_class(): def _roi(pos=(10.0, 20.0), size=(40.0, 20.0), angle=0.0): roi = _FakeEllipticalROI(pos, size, angle=angle) + _SCENE.addItem(roi) return roi diff --git a/tests/tests_devices/test_flomni_loop_settings.py b/tests/tests_devices/test_flomni_loop_settings.py new file mode 100644 index 00000000..a5350e06 --- /dev/null +++ b/tests/tests_devices/test_flomni_loop_settings.py @@ -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 diff --git a/tests/tests_devices/test_omny_sample_storage.py b/tests/tests_devices/test_omny_sample_storage.py new file mode 100644 index 00000000..817da5a1 --- /dev/null +++ b/tests/tests_devices/test_omny_sample_storage.py @@ -0,0 +1,108 @@ +"""OMNYSampleStorage on the OMNY soft IOC records (OMNY-SAMPLE::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) diff --git a/tests/tests_devices/test_omny_shutter.py b/tests/tests_devices/test_omny_shutter.py index b2bc1c9d..85161b69 100644 --- a/tests/tests_devices/test_omny_shutter.py +++ b/tests/tests_devices/test_omny_shutter.py @@ -1,69 +1,51 @@ +from unittest import mock + import pytest from bec_server.device_server.tests.utils import DMMock -from csaxs_bec.devices.omny.shutter import MonitorSignal, OMNYFastShutter - - -@pytest.mark.parametrize("auto_monitor", [False, True]) -def test_monitor_signal_stores_auto_monitor(auto_monitor): - signal = MonitorSignal(name="signal", auto_monitor=auto_monitor) - - assert signal.auto_monitor is auto_monitor - - -def test_monitor_signal_put_propagates_value_to_readback_callback(): - signal = MonitorSignal(name="signal", auto_monitor=True) - initial_value = signal.read()[signal.name]["value"] - callback_values = [] - callback_reads = [] - - def _test_cb(value, old_value, **kwargs): - callback_values.append((value, old_value)) - callback_reads.append(kwargs["obj"].read()) - - signal.subscribe(_test_cb, event_type=signal.SUB_VALUE, run=False) - - signal.put(1) - - assert callback_values == [(1, initial_value)] - assert len(callback_reads) == 1 - assert callback_reads[0][signal.name]["value"] == 1 - assert signal.read()[signal.name]["value"] == 1 - - signal.put(0) - assert callback_values == [(1, initial_value), (0, 1)] - assert len(callback_reads) == 2 - assert callback_reads[1][signal.name]["value"] == 0 - assert signal.read()[signal.name]["value"] == 0 +from csaxs_bec.devices.omny.shutter import OMNYFastShutter @pytest.fixture def omny_fast_shutter(): - shutter = OMNYFastShutter(name="omny_fast_shutter", device_manager=DMMock()) + device_manager = DMMock() + fsh = mock.MagicMock() + device_manager.devices["fsh"] = fsh + shutter = OMNYFastShutter(name="omny_fast_shutter", device_manager=device_manager) try: - yield shutter + yield shutter, fsh finally: shutter.destroy() -def test_omny_fast_shutter_uses_monitor_signal_with_auto_monitor(omny_fast_shutter): - assert isinstance(omny_fast_shutter.shutter, MonitorSignal) - assert omny_fast_shutter.shutter.auto_monitor is True +def test_omny_fast_shutter_fshopen_delegates_to_fsh(omny_fast_shutter): + shutter, fsh = omny_fast_shutter + shutter.fshopen() + fsh.fshopen.assert_called_once() -def test_omny_fast_shutter_propagates_signal_changes_to_device_readback(omny_fast_shutter): - signal_name = omny_fast_shutter.shutter.name - callback_reads = [] +def test_omny_fast_shutter_fshclose_delegates_to_fsh(omny_fast_shutter): + shutter, fsh = omny_fast_shutter + shutter.fshclose() + fsh.fshclose.assert_called_once() - def _test_cb(**kwargs): - callback_reads.append(omny_fast_shutter.read()) - omny_fast_shutter.shutter.subscribe(_test_cb, event_type=omny_fast_shutter.shutter.SUB_VALUE, run=False) +def test_omny_fast_shutter_fshstatus_delegates_to_fsh(omny_fast_shutter): + shutter, fsh = omny_fast_shutter + fsh.fshstatus.return_value = 1 + assert shutter.fshstatus() == 1 + fsh.fshstatus.assert_called_once() - omny_fast_shutter.shutter.put(1) - assert len(callback_reads) == 1 - assert callback_reads[0][signal_name]["value"] == 1 - assert omny_fast_shutter.read()[signal_name]["value"] == 1 - assert omny_fast_shutter.fshstatus() == 1 +def test_omny_fast_shutter_fshstatus_readback_delegates_to_fsh(omny_fast_shutter): + shutter, fsh = omny_fast_shutter + fsh.fshstatus_readback.return_value = 0 + assert shutter.fshstatus_readback() == 0 + fsh.fshstatus_readback.assert_called_once() + + +def test_omny_fast_shutter_fshinfo_delegates_to_fsh(omny_fast_shutter): + shutter, fsh = omny_fast_shutter + shutter.fshinfo() + fsh.fshinfo.assert_called_once() diff --git a/tests/tests_devices/test_rt_flomni.py b/tests/tests_devices/test_rt_flomni.py index 5c38e22d..25b7b678 100644 --- a/tests/tests_devices/test_rt_flomni.py +++ b/tests/tests_devices/test_rt_flomni.py @@ -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 diff --git a/tests/tests_devices/test_rt_lamni_statistics.py b/tests/tests_devices/test_rt_lamni_statistics.py new file mode 100644 index 00000000..3db16603 --- /dev/null +++ b/tests/tests_devices/test_rt_lamni_statistics.py @@ -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() diff --git a/tests/tests_devices/test_rt_omny.py b/tests/tests_devices/test_rt_omny.py new file mode 100644 index 00000000..1cd48be4 --- /dev/null +++ b/tests/tests_devices/test_rt_omny.py @@ -0,0 +1,90 @@ +"""Regression tests for two RT-flyer bugs in rt_omny_ophyd.py, found by comparing +against flomni's (working) equivalents: + +1. RtOMNYFlyer.read_positions_from_sampler() called self.get_scan_status() instead + of self.controller.get_scan_status() -- get_scan_status() only exists on + RtOMNYController, never on RtOMNYFlyer itself. This raised an AttributeError + inside the background readout thread started by complete(); since an + uncaught exception in a thread target doesn't propagate, the thread died + silently before ever calling status.set_finished(), hanging the scan + forever at 0% progress (scan_core()'s `while not status.done` loop never + exits). flomni's and lamni's equivalents were already correct. + +2. RtOMNYSetpointSignal._socket_set() never checked the interferometer signal + strength before moving, unlike RtFlomniSetpointSignal._socket_set() (added + in commit 68320e1, "check tracker signal before rt move, which is + otherwise stuck forever") -- a real-hardware failure mode where a move + issued with a weak signal never converges. OMNY has its own duplicate + setpoint-signal class, so the fix never propagated. +""" + +from unittest import mock + +import pytest + +from csaxs_bec.devices.omny.rt.rt_omny_ophyd import RtOMNYFlyer, RtOMNYSetpointSignal + + +class _FakeFlyerSelf: + """Minimal stand-in for RtOMNYFlyer -- deliberately has no get_scan_status of + its own (matching the real class), so a regression to `self.get_scan_status()` + (instead of `self.controller.get_scan_status()`) raises AttributeError here too, + the same way it would on the real class. + """ + + def __init__(self): + self.controller = mock.MagicMock() + self.shutdown_event = mock.MagicMock() + self.average_stdeviations_x_st_fzp = 0 + self.average_stdeviations_y_st_fzp = 0 + self.progress = mock.MagicMock() + self.data = mock.MagicMock() + + +def test_read_positions_from_sampler_uses_controller_get_scan_status(): + fake_self = _FakeFlyerSelf() + # mode=0 -> the `while mode > 0` loop body never runs; falls straight to the + # shutdown-event check below it + fake_self.controller.get_scan_status.return_value = (0, 0, 0) + fake_self.shutdown_event.wait.return_value = True + status = mock.MagicMock() + + RtOMNYFlyer.read_positions_from_sampler(fake_self, status) + + fake_self.controller.get_scan_status.assert_called_once() + status.set_finished.assert_called_once() + + +def test_read_positions_from_sampler_would_have_raised_on_the_old_bug(): + """Sanity-check that _FakeFlyerSelf actually catches the regression: calling + the buggy `self.get_scan_status()` form directly must raise AttributeError.""" + fake_self = _FakeFlyerSelf() + with pytest.raises(AttributeError): + fake_self.get_scan_status() + + +def test_socket_set_checks_laser_tracker_before_moving(): + fake_self = mock.MagicMock() + fake_self.parent.controller.feedback_is_running.return_value = True + call_order = [] + fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.side_effect = ( + lambda: call_order.append("check") + ) + fake_self.set_with_feedback_disabled.side_effect = lambda val: call_order.append("move") + + RtOMNYSetpointSignal._socket_set(fake_self, 10.0) + + assert call_order == ["check", "move"] + fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.assert_called_once() + fake_self.set_with_feedback_disabled.assert_called_once_with(10.0) + + +def test_socket_set_raises_when_feedback_not_running(): + fake_self = mock.MagicMock() + fake_self.parent.controller.feedback_is_running.return_value = False + + with pytest.raises(Exception): + RtOMNYSetpointSignal._socket_set(fake_self, 10.0) + + fake_self.parent.controller.laser_tracker_check_and_wait_for_signalstrength.assert_not_called() + fake_self.set_with_feedback_disabled.assert_not_called() diff --git a/tests/tests_devices/test_sim_galil_omny_rotation_offset.py b/tests/tests_devices/test_sim_galil_omny_rotation_offset.py new file mode 100644 index 00000000..1d6338bf --- /dev/null +++ b/tests/tests_devices/test_sim_galil_omny_rotation_offset.py @@ -0,0 +1,77 @@ +import pytest +from bec_server.device_server.tests.utils import DMMock + +from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController +from csaxs_bec.devices.sim.sim_galil import SimGalilState +from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry + +# osamroy: axis_Id "C" (numeric 2) on port 8083 -- the OMNY rotation-stage controller +# (contrID=3 in controller1.dmc). This is the only axis/port combination on which the +# real controller applies the 25 deg offset when setting a position. +_ROTATION_AXIS_ID = "C" +_ROTATION_PORT = 8083 +_HOST = "test-omny-galil.psi.ch" +# fast enough that a ~200 deg simulated move settles well within the poll loop's +# resolution; OMNYGalilController is a per-(host, port) singleton so it must be reset +# between tests too, like test_galil_flomni.py does for FlomniGalilController. +_SIM_VELOCITY = 1000.0 + + +@pytest.fixture(autouse=True) +def _reset_sim_state(): + OMNYGalilController._reset_controller() + SimStateRegistry.reset() + yield + SimStateRegistry.reset() + OMNYGalilController._reset_controller() + + +def _make_axis(axis_Id, port): + motor = SimOMNYGalilMotor( + axis_Id, + name=f"axis_{axis_Id}_{port}", + host=_HOST, + port=port, + sign=1, + device_manager=DMMock(), + sim_initial_position=0, + sim_velocity=_SIM_VELOCITY, + ) + motor.wait_for_connection() + return motor + + +@pytest.mark.parametrize("target", [0.0, 90.0, 180.0]) +def test_osamroy_move_round_trips_through_25_deg_offset(target): + """osamroy must round-trip through the sim exactly like real hardware does via + controller1.dmc's #NEWPAR 'IF((contrID=3)&(axis=2)) ntarget=ntarget-25' handler: + a move to a display-space target reads back the same target, not target+25.""" + osamroy = _make_axis(_ROTATION_AXIS_ID, _ROTATION_PORT) + try: + osamroy.move(target, wait=True) + assert osamroy.readback.get() == pytest.approx(target, abs=1e-3) + + # the raw sim axis must actually be sitting 25 deg away from the logical target, + # matching what the real controller drives the motor to + state = SimStateRegistry.get(SimGalilState, _HOST, _ROTATION_PORT) + raw_axis = state.axis(osamroy.axis_Id_numeric) + assert raw_axis.position() / raw_axis.stppermm == pytest.approx(target - 25, abs=1e-3) + finally: + osamroy.destroy() + + +def test_offset_is_scoped_to_omny_rotation_axis_only(): + """A different axis on the same controller, or the same axis letter on a different + port, must NOT get the -25 offset -- only port 8083 / axis numeric 2 (osamroy).""" + other_axis_same_port = _make_axis("A", _ROTATION_PORT) # numeric 0, not the rotation axis + same_axis_other_port = _make_axis(_ROTATION_AXIS_ID, 8081) # not the rotation controller + try: + other_axis_same_port.move(10.0, wait=True) + assert other_axis_same_port.readback.get() == pytest.approx(10.0, abs=1e-3) + + same_axis_other_port.move(10.0, wait=True) + assert same_axis_other_port.readback.get() == pytest.approx(10.0, abs=1e-3) + finally: + other_axis_same_port.destroy() + same_axis_other_port.destroy() diff --git a/tests/tests_devices/test_sim_galil_oshield_switch.py b/tests/tests_devices/test_sim_galil_oshield_switch.py new file mode 100644 index 00000000..f33d1f07 --- /dev/null +++ b/tests/tests_devices/test_sim_galil_oshield_switch.py @@ -0,0 +1,118 @@ +import pytest +from bec_server.device_server.tests.utils import DMMock + +from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController +from csaxs_bec.devices.sim.sim_galil import ( + _OSHIELD_GALIL_HOST, + _OSHIELD_GALIL_PORT, + _OSHIELD_OPEN_POSITION_MM, + _OSHIELD_SMARACT_AXIS, + _OSHIELD_SMARACT_PORT, +) +from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor +from csaxs_bec.devices.sim.sim_smaract import SimSmaractMotor, SimSmaractState +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry + +# otransx's own axis letter doesn't matter here -- only its host/port (shared with +# oshield's "open" limit switch on real hardware, see sim_galil.py's +# _OSHIELD_GALIL_HOST/_OSHIELD_GALIL_PORT). +_OTRANSX_AXIS_ID = "D" + + +@pytest.fixture(autouse=True) +def _reset_sim_state(): + OMNYGalilController._reset_controller() + SimStateRegistry.reset() + yield + SimStateRegistry.reset() + OMNYGalilController._reset_controller() + + +def _make_devices(oshield_initial_position): + otransx = SimOMNYGalilMotor( + _OTRANSX_AXIS_ID, + name="otransx", + host=_OSHIELD_GALIL_HOST, + port=_OSHIELD_GALIL_PORT, + sign=1, + device_manager=DMMock(), + sim_initial_position=0, + sim_velocity=10.0, + ) + otransx.wait_for_connection() + oshield = SimSmaractMotor( + "C", + name="oshield", + host=_OSHIELD_GALIL_HOST, + port=_OSHIELD_SMARACT_PORT, + sign=1, + device_manager=DMMock(), + sim_initial_position=oshield_initial_position, + sim_velocity=10.0, + ) + oshield.wait_for_connection() + return otransx, oshield + + +def _switch_value(otransx) -> float: + return float(otransx.controller.socket_put_and_receive("MG @IN[8014]")) + + +def test_switch_reads_closed_when_oshield_is_closed(): + """oshield closed (15.5, _oshield_ST_close()'s own target) -> switch not + triggered, matching _oshield_is_ST_open()'s real-hardware semantics.""" + otransx, oshield = _make_devices(15.5) + try: + assert _switch_value(otransx) == 0.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_switch_reads_open_when_oshield_is_at_open_position(): + otransx, oshield = _make_devices(_OSHIELD_OPEN_POSITION_MM) + try: + assert _switch_value(otransx) == 1.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_switch_tracks_oshield_position_live_not_just_at_startup(): + """The switch must reflect oshield's *current* simulated position, not + just whatever it started at -- this is the actual bug being fixed: + the digital switch and oshield's position are simulated by two + different simulator classes (Galil vs Smaract) that don't otherwise + talk to each other.""" + otransx, oshield = _make_devices(15.5) + try: + assert _switch_value(otransx) == 0.0 + state = SimStateRegistry.get(SimSmaractState, _OSHIELD_GALIL_HOST, _OSHIELD_SMARACT_PORT) + state.axis(_OSHIELD_SMARACT_AXIS).pos_nm = _OSHIELD_OPEN_POSITION_MM * 1.0e6 + assert _switch_value(otransx) == 1.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_switch_reads_closed_at_an_intermediate_position(): + otransx, oshield = _make_devices(0.0) + try: + assert _switch_value(otransx) == 0.0 + finally: + otransx.destroy() + oshield.destroy() + + +def test_other_channels_unaffected_by_the_oshield_special_case(): + """A different digital-input channel on the same controller must still + use the plain seeded/default value, not get swept up in the oshield + special-case.""" + otransx, oshield = _make_devices(15.5) + try: + # channel 14 (light curtain) has its own module-level default (1.0), + # independent of oshield's position + assert float(otransx.controller.socket_put_and_receive("MG @IN[14]")) == 1.0 + finally: + otransx.destroy() + oshield.destroy() diff --git a/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py b/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py new file mode 100644 index 00000000..ccf9fbf3 --- /dev/null +++ b/tests/tests_devices/test_sim_galil_oshuttlealign_quirky_limit.py @@ -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() diff --git a/tests/tests_devices/test_sim_galil_oshuttleopen_high_limit.py b/tests/tests_devices/test_sim_galil_oshuttleopen_high_limit.py new file mode 100644 index 00000000..adb1eb67 --- /dev/null +++ b/tests/tests_devices/test_sim_galil_oshuttleopen_high_limit.py @@ -0,0 +1,80 @@ +import time + +import pytest +from bec_server.device_server.tests.utils import DMMock + +from csaxs_bec.devices.omny.galil.ogalil_ophyd import OMNYGalilController +from csaxs_bec.devices.sim.sim_omny import SimOMNYGalilMotor +from csaxs_bec.devices.sim.sim_socket import SimStateRegistry + +_HOST = "mpc3217.psi.ch" +_PORT = 8082 + + +@pytest.fixture(autouse=True) +def _reset_sim_state(): + OMNYGalilController._reset_controller() + SimStateRegistry.reset() + yield + SimStateRegistry.reset() + OMNYGalilController._reset_controller() + + +def _make_opener(high_limit_trip_position=None) -> SimOMNYGalilMotor: + opener = SimOMNYGalilMotor( + "G", + name="oshuttleopen", + host=_HOST, + port=_PORT, + sign=1, + device_manager=DMMock(), + sim_initial_position=0, + sim_velocity=100.0, + sim_high_limit_trip_position=high_limit_trip_position, + ) + opener.wait_for_connection() + return opener + + +def _drive_relative(opener: SimOMNYGalilMotor, steps: int): + opener.controller.socket_put_confirmed(f"PRG={steps}") + opener.controller.socket_put_confirmed("BGG") + while bool(float(opener.controller.socket_put_and_receive("MG_BGG"))): + time.sleep(0.01) + + +def test_high_limit_inactive_before_reaching_trip_position(): + opener = _make_opener(high_limit_trip_position=1.0) + try: + assert opener.get_motor_limit_switch()[1] is False + finally: + opener.destroy() + + +def test_high_limit_active_matches_real_shield_open_command_sequence(): + """Replays _otransfer_shield_open()'s exact oshuttleopen excursion + (omny_sample_transfer_mixin.py: PRG=17000 then PRG=37000, ~1.05 mm + total at this axis's resolution) -- the high/forward limit must read + active afterward, or _otransfer_is_shuttle_open() never sees the + shuttle as open and otransfer_put_sample()/otransfer_get_sample() + raise "Did not manage to open the lid" even though the move completed.""" + opener = _make_opener(high_limit_trip_position=1.0) + try: + _drive_relative(opener, 17000) + _drive_relative(opener, 37000) + assert opener.get_motor_limit_switch()[1] is True + finally: + opener.destroy() + + +def test_high_limit_trip_position_is_opt_in(): + """Without sim_high_limit_trip_position configured, plain PR/BG moves + must not spuriously report the high limit as active (matches every + other axis's existing behavior).""" + opener = _make_opener(high_limit_trip_position=None) + try: + _drive_relative(opener, 17000) + _drive_relative(opener, 37000) + assert opener.get_motor_limit_switch()[1] is False + finally: + opener.destroy() diff --git a/tests/tests_devices/test_sim_omny_sample_storage_defaults.py b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py new file mode 100644 index 00000000..48514498 --- /dev/null +++ b/tests/tests_devices/test_sim_omny_sample_storage_defaults.py @@ -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() diff --git a/tests/tests_scans/test_flomni_fermat_scan.py b/tests/tests_scans/test_flomni_fermat_scan.py index 2e2e4c0e..c68101a8 100644 --- a/tests/tests_scans/test_flomni_fermat_scan.py +++ b/tests/tests_scans/test_flomni_fermat_scan.py @@ -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() diff --git a/tests/tests_scans/test_omny_fermat_scan.py b/tests/tests_scans/test_omny_fermat_scan.py index 1ac42bc3..7080fe7b 100644 --- a/tests/tests_scans/test_omny_fermat_scan.py +++ b/tests/tests_scans/test_omny_fermat_scan.py @@ -1,6 +1,7 @@ from unittest import mock import numpy as np +import pytest from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan @@ -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)