From 906e9f5318d0aec5bc0cb48635eca433f79931f6 Mon Sep 17 00:00:00 2001 From: x01dc Date: Wed, 2 Sep 2026 14:42:35 +0200 Subject: [PATCH] feat(omny): port tomo-queue backend from flomni/lamni (Phase 1 of 2) Mirko wants the tomo params GUI widget available for OMNY before running real tomo scans. That widget (Phase 2, separate follow-up) needs a working job queue and scan progress visible outside the CLI session that started it -- neither existed for OMNY. OMNY now inherits TomoQueueMixin (OMNY_shared/tomo_queue_mixin.py), the same shared backend flomni/lamni already use, giving it tomo_queue_add/delete/move/clear/show/execute/ reacquire, at_each_angle_hook registration, and tomo_scan_resume() for the first time. self.progress is now a global-var-backed proxy (_ProgressProxy, byte-for- byte port of flomni's/lamni's own), not a plain in-memory dict -- the load-bearing change the queue mixin and the eventual widget's busy-banner both depend on. tomo_queue_mixin.py's tomo_queue_reacquire() hardcoded tomo_type == 1; generalized via a new _TOMO_TYPE_1_LIKE class attribute (default frozenset({1}), flomni/lamni unaffected) since OMNY has three "equally spaced sub-tomograms" flavors (types 1/4/5), not just one. Along the way, fixed several real bugs in tomo_scan()/write_pdf_report() that predate this session and would have crashed on a real account/ real energy read (bec.active_account.decode() -- active_account is str not bytes; dev.mokev doesn't exist for OMNY, only dev.ccm_energy; an unguarded logbook send; a stale "LamNI" logo/tag copy-paste leftover) -- all direct ports of fixes flomni/lamni already needed and got. Also added the missing unconditional omnygui_show_progress() call and heartbeat- clearing try/finally in tomo_scan(), matching both. Verification: 23 new tests (test_omny_tomo_queue.py, test_omny_tomo_scan.py) covering the mixin wiring, hook dispatch/resolution, tomo_scan_resume()/ _resolve_type1_projection()/reacquire() across all three sub-tomogram flavors, account handling, and heartbeat clearing on both normal completion and a mid-scan exception. Full suite: 702 passed. Not yet live-tested against a running session (the local bec-server may be in active use by Mirko's own parallel session) -- next step is his own pull-and-test. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt --- .../plugins/OMNY_shared/tomo_queue_mixin.py | 29 +- .../plugins/omny/AI_docs/OPEN_ISSUES.md | 126 ++++- .../bec_ipython_client/plugins/omny/omny.py | 529 ++++++++++++++---- .../test_omny_tomo_queue.py | 317 +++++++++++ .../test_omny_tomo_scan.py | 183 ++++++ 5 files changed, 1051 insertions(+), 133 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_omny_tomo_queue.py create mode 100644 tests/tests_bec_ipython_client/test_omny_tomo_scan.py diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py index 927c546a..c26f7fa2 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py @@ -27,10 +27,15 @@ Consumers (e.g. ``Flomni``, ``LamNI``) must: ``flomni_at_each_angle`` builtins hook, single-point acquisition -- that it stays out of this mixin) - implement their own ``_resolve_type1_projection()`` (dispatched - generically by ``tomo_queue_reacquire()``), mapping a flat tomo_type-1 - projection number to a ``(subtomo_start, start_angle)`` pair -- the - angle math is setup-specific (see each setup's own - ``_subtomo_angle_plan()``), same reasoning as ``tomo_scan()`` above + generically by ``tomo_queue_reacquire()`` for any tomo_type in + ``_TOMO_TYPE_1_LIKE``), mapping a flat projection number to a + ``(subtomo_start, start_angle)`` pair -- the angle math is + setup-specific (see each setup's own ``_subtomo_angle_plan()``/ + ``_equally_spaced_subtomo_angles()``), same reasoning as + ``tomo_scan()`` above + - optionally override ``_TOMO_TYPE_1_LIKE`` (default ``frozenset({1})``) + if their setup has more than one "equally spaced sub-tomograms" + tomo_type flavor (e.g. OMNY's types 1/4/5 -- 2/4/8 sub-tomograms) """ from __future__ import annotations @@ -210,6 +215,13 @@ class TomoQueueMixin: _TOMO_QUEUE_ACTIONS: dict = {} _TOMO_QUEUE_MOVE_DEVICES: dict = {} + # tomo_type values that behave like "type 1" for tomo_queue_reacquire()'s + # purposes -- i.e. resolved via _resolve_type1_projection() rather than a + # flat projection_number. Default matches flomni/lamni (type 1 only, their + # only "equally spaced sub-tomograms" flavor); OMNY overrides this to + # {1, 4, 5} since it has three such flavors (2/4/8 sub-tomograms). + _TOMO_TYPE_1_LIKE: frozenset = frozenset({1}) + # Name of a registered at_each_angle hook (see register_at_each_angle_hook()), # or None for the default per-projection behaviour. at_each_angle_hook = _GlobalVarParam(None) @@ -1012,10 +1024,9 @@ class TomoQueueMixin: job_index: Position of the job to reopen, in the *current* queue order (same indexing as ``tomo_queue_show()``/ ``tomo_queue_delete()``). - projection_number: Flat 0-indexed projection to resume from, - for any tomo_type (1, 2, or 3) -- see - ``_resolve_type1_projection()`` for how this maps onto a - tomo_type-1 job's (subtomo, angle). + projection_number: Flat 0-indexed projection to resume from, for + any tomo_type -- see ``_resolve_type1_projection()`` for how + this maps onto a ``_TOMO_TYPE_1_LIKE`` job's (subtomo, angle). Raises: TomoQueueError: ``job_index`` is out of range, the job at that @@ -1049,7 +1060,7 @@ class TomoQueueMixin: for name, value in job["params"].items(): setattr(self, name, value) - if self.tomo_type == 1: + if self.tomo_type in self._TOMO_TYPE_1_LIKE: subtomo_start, start_angle = self._resolve_type1_projection(projection_number) self.progress["subtomo"] = subtomo_start self.progress["angle"] = start_angle diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md index 6f22ce7c..971f1d3f 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md @@ -26,6 +26,12 @@ removed" below. Tomo type 3 also gained a hard cap of 2.5 degrees on the angular only a minimum-100-projections floor, which happened to already satisfy this but wasn't expressed as a wear limit). +**Just landed**: the tomo-queue backend (Phase 1 of 2 of porting the tomo params GUI widget to +OMNY) — `OMNY` now has a real job queue, `at_each_angle_hook` registration, `tomo_scan_resume()`, +and scan progress visible outside the CLI session (previously a plain in-memory dict). See "Tomo- +queue backend ported to OMNY" below. Phase 2 (the widget itself) is a separate follow-up, not yet +started — not live-tested against a running session yet either; that's next, once Mirko pulls. + **Still open / deferred** (unchanged from before, not touched this pass): - `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames that unblocked the GUI-open crash — see its own section below. The GUI itself opens fine now; @@ -33,6 +39,7 @@ as a wear limit). - The full gripper sample-transfer chain (`otransfer_put_sample()`/`otransfer_get_sample()`), dewar/temperature methods, sample storage bookkeeping, and `omny_alignment_mixin.py`/ `omny_optics_mixin.py` beyond what scans exercise — see "Checklist" section below. +- The `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself (Phase 2 of the tomo-queue port). ## Standing question: OMNY should follow flOMNI, not LamNI @@ -126,24 +133,29 @@ Still untested (not attempted yet, not known-broken — just unexplored): - `omny_webpage_generator.py` (2957 lines) is dead/unwired code — never instantiated by `omny.py`, per `FLOMNI_TO_LAMNI_COMPARISON.md`'s note ("omny was left untouched... separate future task if omny ever needs a real one"). Not touched this session either. -- The tomo-queue / `TomoParamsWidget` generalization done for LamNI - (`LamNI/AI_docs/TOMO_QUEUE_PORT.md`, `TOMO_PARAMS_GUI_PORT.md`) has no OMNY counterpart - yet — out of scope for the sim-validation work, in scope for the eventual macro- - standardization pass. +- **Tomo-queue backend: ported, Phase 1 of 2 (this session).** `OMNY` now inherits + `TomoQueueMixin` (`OMNY_shared/tomo_queue_mixin.py`), the same shared backend flomni/lamni + use — `tomo_queue_add/delete/move/clear/show/execute/reacquire`, + `register_at_each_angle_hook`/`at_each_angle_hook`, `tomo_scan_resume()`. See "Tomo-queue + backend ported to OMNY" below for the full detail. **Not yet done (Phase 2, separate + follow-up)**: the `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself + (`bec_widgets/widgets/tomo_params/tomo_params.py`) still has no `SETUP_PROFILES["omny"]` + entry and its tomo-type selector/job-display logic is hardcoded to 3 tomo types (flomni's + and lamni's), not OMNY's 5 — needs its own plan once Phase 1 is live-tested. -## gui_tools.py progress fields (deferred on purpose) +## gui_tools.py progress ETA/heartbeat line (still not rendered, data now exists) `gui_tools.py` was modernized to flomni's current single-level `.new()` / `_is_deleted()`-guard pattern (window lifecycle, xray-eye-align GUI, cameras + hard-stop console, `RingProgressBar`). -One piece was deliberately **not** ported: flomni's `self.progress` dict carries -`tomo_start_time`/`heartbeat`/`estimated_remaining_time`/`estimated_finish_time` plus a -`_describe_active_hook()` method feeding an ETA/heartbeat line in `_flomnigui_update_progress()`. -omny's `self.progress` (set up in `OMNY.__init__`, `omny.py`) still only has the original 7 fields -(`subtomo`, `subtomo_projection`, `subtomo_total_projections`, `projection`, -`total_projections`, `angle`, `tomo_type`) and `_omnygui_update_progress()` was written against -just those. Mirko's call: keep it simple for this pass, revisit later. Not a crash risk — -`omny_webpage_generator.py` already reads the ETA-ish fields defensively via `.get()`, degrading -to "N/A" when absent, and that file is unwired/dead code anyway (see below). +As of the tomo-queue backend port (see below), `self.progress` now DOES carry +`tomo_start_time`/`heartbeat`/`estimated_remaining_time`/`estimated_finish_time` (global-var-backed, +same shape as flomni's/lamni's) — what's still not ported is purely a `gui_tools.py` rendering +detail: `_omnygui_update_progress()` was never updated to display an ETA/heartbeat line the way +flomni's `_flomnigui_update_progress()`/`_describe_active_hook()` do, and still only builds its +text from the original 7 fields. Not a crash risk either way — `_omnygui_update_progress()` +ignores fields it doesn't read, and `omny_webpage_generator.py` (unwired/dead code, see below) +already reads the ETA-ish fields defensively via `.get()`, degrading to "N/A" when absent. Revisit +whenever the progress ring GUI itself gets an ETA line, independent of the Phase 2 widget work. ## cam_xeye device (simulated only, real hardware config still needed) @@ -274,6 +286,92 @@ Test coverage: `tests/tests_bec_ipython_client/test_omny_tomo_angles.py`'s `"x12sa"` canned answer; new `test_tomo_parameters_clamps_type_3_step_size_to_max_2_5_degrees` covers the clamp. +## Tomo-queue backend ported to OMNY (Phase 1 of 2) + +Mirko wants the tomo params GUI widget available for OMNY before starting real tomo scans. That +widget (Phase 2, separate follow-up) depends on a working tomo-queue backend and on scan progress +being visible outside the CLI session that started it -- neither existed for OMNY before this pass. +Same shared infrastructure and pattern as the flomni -> lamni port +(`LamNI/AI_docs/TOMO_QUEUE_PORT.md`, `TOMO_PARAMS_GUI_PORT.md`), done as the same two-phase split +per Mirko's explicit choice. + +**`OMNY` now inherits `TomoQueueMixin`** (`OMNY_shared/tomo_queue_mixin.py`, first in the MRO, +matching flomni/lamni), giving it `tomo_queue_add/add_command/delete/move/clear/show/execute/ +resume/reacquire`, `register_at_each_angle_hook`/`unregister_at_each_angle_hook`/ +`list_at_each_angle_hooks`/`at_each_angle_hook` for the first time. New class attributes: +`_TOMO_SCAN_PARAM_NAMES` (mirrors flomni's names exactly -- OMNY's properties already mirror +flomni's 1:1, no `tomo_angle_range`/`single_point_*`/`zero_deg_reference_at_each_subtomo`/ +`frames_per_trigger` since none of these exist as OMNY properties), `_TOMO_QUEUE_MOVE_DEVICES` +(`ccm_energy`+`idgap`, **not** `mokev` -- OMNY has no `mokev` device in any config, real or +simulated), `_TOMO_QUEUE_ACTIONS` (same `move`/`optimize_idgap` registry as flomni/lamni, +`optimize_idgap` still a stub). + +**`self.progress` is now a global-var-backed proxy, not a plain dict.** Before this pass, OMNY's +`self.progress = {}` was a plain in-memory instance dict -- invisible to any other BEC client +session, a GUI process included. New `_ProgressProxy` class (direct, byte-for-byte port of +flomni's/lamni's own, backed by `client.get_global_var("tomo_progress")`, same global-var key -- +only one tomography setup is ever live per BEC session, no collision risk), with `progress` now a +`@property`. Carries the original 7 fields (`subtomo`, `subtomo_projection`, +`subtomo_total_projections`, `projection`, `total_projections`, `angle`, `tomo_type`) plus +`tomo_start_time`/`estimated_remaining_time`/`estimated_finish_time`/`heartbeat`/ +`accumulated_idle_time`, matching flomni's/lamni's `_DEFAULTS` shape. + +**`tomo_queue_mixin.py`'s `tomo_queue_reacquire()` generalized (small, additive, behavior- +preserving change to shared code)**: it hardcoded `if self.tomo_type == 1:` to decide when to +resolve via `_resolve_type1_projection()`. OMNY has three "equally spaced sub-tomograms" flavors +(types 1/4/5 -- 2/4/8 sub-tomograms), not just type 1. Added `_TOMO_TYPE_1_LIKE: frozenset` +(default `frozenset({1})`, so flomni/lamni are unaffected -- confirmed via their existing test +suites, unchanged and passing), which OMNY overrides to `frozenset({1, 4, 5})`. + +**New OMNY methods, direct ports generalized for 3 sub-tomogram counts**: `tomo_scan_resume()` +(dispatches subtomo/angle for tomo_type in `_TOMO_TYPE_1_LIKE`, projection_number for 2/3) and +`_resolve_type1_projection()` (reuses the already-verified `_equally_spaced_subtomo_angles()` -- +does not re-derive the angle grid a second way). + +**Real, pre-existing bugs fixed along the way** (same shapes flomni/lamni already needed and got, +found by reading `tomo_scan()`/`write_pdf_report()` against the mixin's needs, not newly +introduced): +- `bec.active_account.decode()` in `tomo_scan()` -- `active_account` is a plain `str`, not + `bytes`; crashed on every real account. Removed (OMNY's existing `if bec.active_account != "": + ... else: tomo_id = 0` branch structure kept as-is, unlike flomni's/lamni's "always attempt, + let add_sample_database() decide" pattern -- OMNY's own `add_sample_database()` has no + test-server/empty-account fallback to lean on). +- `dev.mokev.read()['mokev']['value']` in `write_pdf_report()` -- OMNY has no `mokev` device in + any config. Fixed to `dev.ccm_energy` (from `bl_optics_hutch.yaml`, always included regardless + of active endstation) with a try/except -> `"N/A"` fallback, matching lamni's identical fix. + Confirmed `ccm_energy` is *not* in the local simulated OMNY config either (`simulated_omny.yaml`/ + `simulated_bl_endstation.yaml` have no such device), so live sim testing will exercise the + fallback path, not a real energy read -- still correct/safe behavior, just not the success path. +- `bec.logbook.LogbookMessage()`/`send_logbook_message()` in `write_pdf_report()` was unguarded -- + wrapped in try/except, matching `write_to_scilog()`'s existing tolerance one method up. +- `write_pdf_report()`'s scilog logo/tags still said `"LamNI"`/pointed at a nonexistent + `LamNI_logo.png` (omny's plugin directory only has `OMNY.png`) -- `msg.add_file(logo_path)` + would have raised unguarded (now inside the same try/except above regardless). Fixed to + `OMNY.png`/`"OMNY"`. +- `tomo_scan()` never called `self.omnygui_show_progress()` unconditionally at scan start (only + per-angle inside `_tomo_scan_at_angle()`) and had no try/finally to clear `heartbeat` on exit -- + both fixed, matching flomni's/lamni's identical pattern. +- `_at_each_angle()` now checks `self.at_each_angle_hook` (mixin-provided) first, falling through + to the pre-existing `omny_at_each_angle` builtins-global hook, then the default + `tomo_scan_projection()` + `tomo_reconstruct()` -- same 3-tier structure as flomni's, minus its + single-point branch (OMNY has none). + +**Verification**: new `tests/tests_bec_ipython_client/test_omny_tomo_queue.py` (17 tests -- +param-name snapshotting, queue add/show/execute/resume/reacquire, add-command move validation, +`at_each_angle_hook` dispatch/default/legacy-builtins-fallback/unregistered-name, +`tomo_scan_resume()`/`_resolve_type1_projection()`/`tomo_queue_reacquire()` correctness across all +three sub-tomogram flavors) and `test_omny_tomo_scan.py` (6 tests -- account handling, progress-GUI +auto-show, heartbeat clearing on normal completion and mid-scan exception, new-scan timing-field +resets). `write_pdf_report()`'s `ccm_energy` fallback has no dedicated unit test, matching +flomni's/lamni's own precedent (neither has one either -- verified live only, real file I/O and +device reads make it impractical to unit test in isolation). Full suite: 702 passed. Confirmed via +plain Python (no live client needed) that `OMNY`'s MRO resolves cleanly with `TomoQueueMixin` +first, no conflicts. **Not yet live-verified against a real running session** (deliberately -- +the local bec-server may be in active use by Mirko's own parallel session; a full live +`tomo_scan()`/`tomo_queue_execute()` run moves real simulated hardware and consumes real scan +numbers) -- next step is Mirko pulling and testing live himself, same pattern as this whole +engagement. + ## Environment note (not code, but will bite again if forgotten) Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo- diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py index cc395088..c40e74b1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py @@ -21,6 +21,7 @@ from csaxs_bec.bec_ipython_client.plugins.omny.omny_sample_transfer_mixin import OMNYSampleTransferMixin, ) from csaxs_bec.bec_ipython_client.plugins.omny.x_ray_eye_align import XrayEyeAlign +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueMixin logger = bec_logger.logger @@ -54,6 +55,81 @@ class OMNYError(Exception): _SUPERUSER_ACCOUNTS: frozenset[str] = frozenset({"gac-x01dc", "holler"}) +class _ProgressProxy: + """Dict-like proxy that persists the OMNY progress dict as a BEC global variable. + + Every read (`proxy["key"]`) fetches the current dict from the global var store, + and every write (`proxy["key"] = val`) fetches, updates, and saves it back. + This makes the progress state visible to all BEC client sessions via + ``client.get_global_var("tomo_progress")`` -- same key and shape as + flomni's/lamni's own progress proxy, since only one tomography setup is + ever live against a given BEC session. Direct port of + ``flomni.py``'s/``lamni.py``'s identical class. + """ + + _GLOBAL_VAR_KEY = "tomo_progress" + _DEFAULTS: dict = { + "subtomo": 0, + "subtomo_projection": 0, + "subtomo_total_projections": 1, + "projection": 0, + "total_projections": 1, + "angle": 0, + "tomo_type": 0, + "tomo_start_time": None, + "estimated_remaining_time": None, + "estimated_finish_time": None, + "heartbeat": None, + "accumulated_idle_time": 0.0, + } + + def __init__(self, client): + self._client = client + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _load(self) -> dict: + val = self._client.get_global_var(self._GLOBAL_VAR_KEY) + if val is None: + return dict(self._DEFAULTS) + return val + + def _save(self, data: dict) -> None: + self._client.set_global_var(self._GLOBAL_VAR_KEY, data) + + # ------------------------------------------------------------------ + # Dict-like interface + # ------------------------------------------------------------------ + def __getitem__(self, key): + return self._load()[key] + + def __setitem__(self, key, value) -> None: + data = self._load() + data[key] = value + self._save(data) + + def __repr__(self) -> str: + return f"{self.__class__.__name__}({self._load()!r})" + + def get(self, key, default=None): + return self._load().get(key, default) + + def update(self, *args, **kwargs) -> None: + """Update multiple fields in a single round-trip.""" + data = self._load() + data.update(*args, **kwargs) + self._save(data) + + def reset(self) -> None: + """Reset all progress fields to their default values.""" + self._save(dict(self._DEFAULTS)) + + def as_dict(self) -> dict: + """Return a plain copy of the current progress state.""" + return self._load() + + class OMNYInitStagesMixin: def enable_all_devices(self): @@ -560,6 +636,7 @@ class OMNYInitStagesMixin: class OMNY( + TomoQueueMixin, OMNYInitStagesMixin, OMNYSampleTransferMixin, OMNYAlignmentMixin, @@ -568,6 +645,73 @@ class OMNY( OMNY_rt_client, OMNYGuiTools, ): + # tomo_type values that behave like "type 1" for tomo_queue_reacquire() + # (TomoQueueMixin) -- OMNY has three "equally spaced sub-tomograms" + # flavors (1/4/5 -> 2/4/8 sub-tomograms), unlike flomni/lamni's single + # type-1 flavor (the mixin's default, frozenset({1})). + _TOMO_TYPE_1_LIKE: frozenset = frozenset({1, 4, 5}) + + # Property names a tomo_queue_add() job snapshots (TomoQueueMixin + # contract). Mirrors flomni's own tuple exactly, since OMNY's properties + # already mirror flomni's names 1:1 -- no tomo_angle_range/single_point_*/ + # zero_deg_reference_at_each_subtomo/frames_per_trigger, since OMNY has + # no such properties. + _TOMO_SCAN_PARAM_NAMES = ( + "tomo_countingtime", + "tomo_shellstep", + "fovx", + "fovy", + "stitch_x", + "stitch_y", + "tomo_stitch_overlap", + "ptycho_reconstruct_foldername", + "manual_shift_y", + "tomo_type", + "tomo_angle_stepsize", + "golden_ratio_bunch_size", + "golden_max_number_of_projections", + "golden_projections_at_0_deg_for_damage_estimation", + "corridor_size", + "at_each_angle_hook", + ) + + # Curated allow-list for the "move" queue action -- same shape as + # flomni's/lamni's own (TomoQueueMixin contract). "ccm_energy", not + # "mokev" -- OMNY has no mokev device in any config (real or + # simulated); ccm_energy (bl_optics_hutch.yaml) is always present + # regardless of active endstation. + _TOMO_QUEUE_MOVE_DEVICES = { + "ccm_energy": {"label": "Energy", "unit": "keV"}, + "idgap": {"label": "Undulator gap", "unit": "mm"}, + } + + # Same move/optimize_idgap registry as flomni/lamni (TomoQueueMixin + # contract) -- optimize_idgap stays a stub (TomoQueueMixin.optimize_idgap + # is a no-op), matching both. + _TOMO_QUEUE_ACTIONS = { + "move": { + "func": "_queue_action_move", + "idempotent": True, # absolute moves re-run harmlessly + "help": "Move device(s) to absolute position(s).", + "params": {"positions": {"type": "device_positions"}}, + }, + "optimize_idgap": { + "func": "optimize_idgap", + "idempotent": True, + "help": "Scan idgap over a search range and move to the peak. STUB: no-op.", + "params": { + "search_range": { + "type": "float", + "default": 0.5, + "min": 0.0, + "max": 2.0, + "unit": "mm", + "label": "Search range", + } + }, + }, + } + def __init__(self, client): super().__init__() self.client = client @@ -588,20 +732,69 @@ class OMNY( self.corr_angle_y = [] self.corr_pos_y_2 = [] self.corr_angle_y_2 = [] - self.progress = {} - self.progress["subtomo"] = 0 - self.progress["subtomo_projection"] = 0 - self.progress["subtomo_total_projections"] = 1 - self.progress["projection"] = 0 - self.progress["total_projections"] = 1 - self.progress["angle"] = 0 - self.progress["tomo_type"] = 0 + # Progress tracking, persisted via the BEC global variable + # "tomo_progress" so it survives a client restart -- deliberately NOT + # reset here: this proxy is constructed every time OMNY() is + # instantiated (i.e. every new client session), and resetting + # unconditionally on every instantiation would wipe tomo_start_time + # (and everything else) on exactly the restarts where recovering + # scan state matters most. A genuinely new tomo_scan() call resets + # the relevant fields itself (see the "new scan" branch in + # tomo_scan()). + self._progress_proxy = _ProgressProxy(self.client) self.OMNYTools = OMNYTools(self.client) + self._init_tomo_queue() OMNY_rt_client.__init__(self) self.align = XrayEyeAlign(self.client, self) OMNYGuiTools.__init__(self) self.set_client(self.client) + @property + def progress(self) -> _ProgressProxy: + """Proxy dict backed by the BEC global variable ``tomo_progress``. + + Readable from any BEC client session via:: + + client.get_global_var("tomo_progress") + + Individual fields can be read and written just like a regular dict:: + + omny.progress["projection"] # read + omny.progress["projection"] = 42 # write (persists immediately) + + To update multiple fields atomically use :py:meth:`_ProgressProxy.update`:: + + omny.progress.update(projection=42, angle=90.0) + + To reset all fields to their defaults:: + + omny.progress.reset() + """ + return self._progress_proxy + + @progress.setter + def progress(self, val: dict) -> None: + """Replace the entire progress dict. + + Accepts a plain :class:`dict` and persists it to the global var store. + """ + if not isinstance(val, dict): + raise TypeError(f"progress must be a dict, got {type(val).__name__!r}") + self._progress_proxy._save(val) + + def tomo_progress_reset(self) -> None: + """Explicitly clear the persisted tomo progress (start time, ETA, + current angle/subtomo/projection, accumulated idle time, ...). + + Not called automatically on OMNY() startup -- that would wipe an + in-progress scan's state on every kernel restart. Call this by hand + if you want a clean progress display without it being tied to + starting a new tomo_scan() (which already resets the relevant + fields itself). + """ + self._progress_proxy.reset() + print("Tomo progress reset.") + def start_x_ray_eye_alignment(self): if self.OMNYTools.yesno( "Starting Xrayeye alignment. Deleting any potential existing alignment for this sample.", @@ -1055,6 +1248,8 @@ class OMNY( def tomo_scan(self, subtomo_start=1, start_angle=None, projection_number=None): """start a tomo scan""" + self.omnygui_show_progress() + bec = builtins.__dict__.get("bec") scans = builtins.__dict__.get("scans") self._current_special_angles = self.special_angles.copy() @@ -1066,11 +1261,13 @@ class OMNY( ): # pylint: disable=undefined-variable + # bec.active_account is a plain str, not bytes -- .decode() used + # to crash with AttributeError on every real account. if bec.active_account != "": self.tomo_id = self.add_sample_database( self.sample_name, str(datetime.date.today()), - bec.active_account.decode(), + bec.active_account, bec.queue.next_scan_number, "OMNY", "test additional info", @@ -1079,111 +1276,201 @@ class OMNY( self.write_pdf_report() else: self.tomo_id = 0 + self.progress["tomo_start_time"] = datetime.datetime.now().isoformat() + # reset stale estimates from any previous scan, otherwise the GUI + # would keep showing a leftover ETA from before this scan has + # accumulated enough projections to compute a fresh one + self.progress["estimated_remaining_time"] = None + self.progress["estimated_finish_time"] = None + self.progress["accumulated_idle_time"] = 0.0 + self.progress["heartbeat"] = None - with scans.dataset_id_on_hold: - if self.tomo_type in (1, 4, 5): - # equally spaced sub-tomograms (2, 4, or 8) - n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type] - self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})" - for ii in range(subtomo_start, n_subtomos + 1): - self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle) - start_angle = None + try: + with scans.dataset_id_on_hold: + if self.tomo_type in (1, 4, 5): + # equally spaced sub-tomograms (2, 4, or 8) + n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type] + self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})" + for ii in range(subtomo_start, n_subtomos + 1): + self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle) + start_angle = None - elif self.tomo_type == 2: - # Golden ratio tomography - previous_subtomo_number = -1 - if projection_number == None: - ii = 0 - else: - ii = projection_number - while True: - angle, subtomo_number = self._golden(ii, self.golden_ratio_bunch_size, 180, 1) - if previous_subtomo_number != subtomo_number: - self._write_subtomo_to_scilog(subtomo_number) + elif self.tomo_type == 2: + # Golden ratio tomography + previous_subtomo_number = -1 + if projection_number == None: + ii = 0 + else: + ii = projection_number + while True: + angle, subtomo_number = self._golden( + ii, self.golden_ratio_bunch_size, 180, 1 + ) + if previous_subtomo_number != subtomo_number: + self._write_subtomo_to_scilog(subtomo_number) + if ( + subtomo_number % 2 == 1 + and ii > 10 + and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ): + self._tomo_scan_at_angle(0, subtomo_number) + previous_subtomo_number = subtomo_number + self.progress["tomo_type"] = "Golden ratio tomography" + self.progress["subtomo"] = subtomo_number + self.progress["projection"] = ii + self.progress["angle"] = angle + if self.golden_ratio_bunch_size > 0: + self.progress["subtomo_total_projections"] = ( + self.golden_ratio_bunch_size + ) + self.progress["subtomo_projection"] = ( + ii - (subtomo_number - 1) * self.golden_ratio_bunch_size + ) + else: + self.progress["subtomo_total_projections"] = 0 + self.progress["subtomo_projection"] = 0 + + if self.golden_max_number_of_projections > 0: + self.progress["total_projections"] = ( + self.golden_max_number_of_projections + ) + else: + self.progress["total_projections"] = 0 + + self._tomo_scan_at_angle(angle, subtomo_number) + ii += 1 if ( - subtomo_number % 2 == 1 - and ii > 10 - and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ii > self.golden_max_number_of_projections + and self.golden_max_number_of_projections > 0 ): - self._tomo_scan_at_angle(0, subtomo_number) - previous_subtomo_number = subtomo_number - self.progress["tomo_type"] = "Golden ratio tomography" - self.progress["subtomo"] = subtomo_number - self.progress["projection"] = ii - self.progress["angle"] = angle - if self.golden_ratio_bunch_size > 0: - self.progress["subtomo_total_projections"] = self.golden_ratio_bunch_size + print( + f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" + ) + break + + elif self.tomo_type == 3: + # Equally spaced tomography, golden ratio starting angle + previous_subtomo_number = -1 + if projection_number == None: + ii = 0 + else: + ii = projection_number + while True: + angle, subtomo_number = self._golden_equally_spaced( + ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0 + ) + if previous_subtomo_number != subtomo_number: + self._write_subtomo_to_scilog(subtomo_number) + if ( + subtomo_number % 2 == 1 + and ii > 10 + and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ): + self._tomo_scan_at_angle(0, subtomo_number) + previous_subtomo_number = subtomo_number + self.progress["tomo_type"] = ( + "Equally spaced tomography, golden ratio starting angle" + ) + self.progress["subtomo"] = subtomo_number + self.progress["projection"] = ii + self.progress["angle"] = angle + + self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize self.progress["subtomo_projection"] = ( - ii - (subtomo_number - 1) * self.golden_ratio_bunch_size + ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"] ) - else: - self.progress["subtomo_total_projections"] = 0 - self.progress["subtomo_projection"] = 0 - if self.golden_max_number_of_projections > 0: - self.progress["total_projections"] = self.golden_max_number_of_projections - else: - self.progress["total_projections"] = 0 - - self._tomo_scan_at_angle(angle, subtomo_number) - ii += 1 - if ( - ii > self.golden_max_number_of_projections - and self.golden_max_number_of_projections > 0 - ): - print( - f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" - ) - break - - elif self.tomo_type == 3: - # Equally spaced tomography, golden ratio starting angle - previous_subtomo_number = -1 - if projection_number == None: - ii = 0 - else: - ii = projection_number - while True: - angle, subtomo_number = self._golden_equally_spaced( - ii, int(180 / self.tomo_angle_stepsize), 180, 1, 0 - ) - if previous_subtomo_number != subtomo_number: - self._write_subtomo_to_scilog(subtomo_number) + if self.golden_max_number_of_projections > 0: + self.progress["total_projections"] = ( + self.golden_max_number_of_projections + ) + else: + self.progress["total_projections"] = 0 + self._tomo_scan_at_angle(angle, subtomo_number) + ii += 1 if ( - subtomo_number % 2 == 1 - and ii > 10 - and self.golden_projections_at_0_deg_for_damage_estimation == 1 + ii > self.golden_max_number_of_projections + and self.golden_max_number_of_projections > 0 ): - self._tomo_scan_at_angle(0, subtomo_number) - previous_subtomo_number = subtomo_number - self.progress["tomo_type"] = ( - "Equally spaced tomography, golden ratio starting angle" - ) - self.progress["subtomo"] = subtomo_number - self.progress["projection"] = ii - self.progress["angle"] = angle + print( + f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" + ) + break + else: + raise OMNYError("undefined tomo type") + finally: + # Cleared on every exit path (normal completion, exception, or + # interrupt) -- not just at the next scan's start above -- so the + # GUI's busy-detectors see this immediately on their next poll + # instead of waiting out the heartbeat-staleness timeout after a + # tomogram that's actually already done. Mirrors flomni's/lamni's + # identical fix. + self.progress["heartbeat"] = None - self.progress["subtomo_total_projections"] = 180 / self.tomo_angle_stepsize - self.progress["subtomo_projection"] = ( - ii - (subtomo_number - 1) * self.progress["subtomo_total_projections"] - ) + def tomo_scan_resume(self) -> None: + """Resume a tomo_scan() that crashed or was interrupted, picking up + automatically from wherever ``progress`` last reported -- no need to + read the last subtomo/angle/projection off the progress printout by + hand and pass it to tomo_scan() yourself. - if self.golden_max_number_of_projections > 0: - self.progress["total_projections"] = self.golden_max_number_of_projections - else: - self.progress["total_projections"] = 0 - self._tomo_scan_at_angle(angle, subtomo_number) - ii += 1 - if ( - ii > self.golden_max_number_of_projections - and self.golden_max_number_of_projections > 0 - ): - print( - f"Golden ratio tomography stopped automatically after the requested {self.golden_max_number_of_projections} projections" - ) - break - else: - raise OMNYError("undefined tomo type") + Re-attempts the exact angle/projection that was in progress at the + moment of interruption, rather than skipping ahead to the next one: + from here we can't be sure whether that last point was actually + acquired before the crash, and re-acquiring one extra projection is + harmless. + + Works standalone for a normal tomo_scan(), independent of the tomo + queue. tomo_queue_execute() (see TomoQueueMixin) also calls this + internally when resuming a job that previously failed partway + through, so a failed queue job picks up mid-scan rather than + restarting from subtomo 1 / angle 0. Direct port of flomni's/lamni's + identical method, generalized to OMNY's three equally-spaced- + sub-tomogram flavors (tomo_type 1/4/5) via _TOMO_TYPE_1_LIKE. + """ + if self.progress.get("tomo_start_time") is None: + print("No tomo scan in progress to resume -- nothing to do.") + return + + if self.tomo_type in self._TOMO_TYPE_1_LIKE: + subtomo_start = self.progress["subtomo"] + start_angle = self.progress["angle"] + if subtomo_start < 1: + print("No tomo scan in progress to resume -- nothing to do.") + return + print(f"Resuming tomo scan at subtomo {subtomo_start}, angle {start_angle:.3f} deg.") + self.tomo_scan(subtomo_start=subtomo_start, start_angle=start_angle) + elif self.tomo_type in (2, 3): + projection_number = self.progress["projection"] + print(f"Resuming tomo scan at projection {projection_number}.") + self.tomo_scan(projection_number=projection_number) + else: + raise OMNYError("undefined tomo type") + + def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]: + """Map a flat 0-indexed projection number to (subtomo_start, + start_angle), for tomo_queue_reacquire() (TomoQueueMixin) on any + tomo_type in _TOMO_TYPE_1_LIKE (1/4/5). + + Reuses _equally_spaced_subtomo_angles() (the single, already-verified + source of truth for this angle grid -- see + omny/AI_docs/OPEN_ISSUES.md) rather than re-deriving the math here. + """ + n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type] + N = int(180.0 / self.tomo_angle_stepsize) + total = N * n_subtomos + if not 0 <= projection_number < total: + raise ValueError( + f"projection_number must be in [0, {total}) for tomo_type " + f"{self.tomo_type} ({n_subtomos} sub-tomograms, {N} projections each), " + f"got {projection_number}." + ) + subtomo_start = projection_number // N + 1 + projection_within_subtomo = projection_number % N + angles = self._equally_spaced_subtomo_angles( + subtomo_start, n_subtomos, self.tomo_angle_stepsize + ) + start_angle = float(angles[projection_within_subtomo]) + return subtomo_start, start_angle def _print_progress(self): print("\x1b[95mProgress report:") @@ -1210,6 +1497,12 @@ class OMNY( return tomo_number def _at_each_angle(self, angle: float) -> None: + hook_name = self.at_each_angle_hook + if hook_name: + hook = self._resolve_at_each_angle_hook(hook_name, OMNYError) + hook(self, angle) + return + if "omny_at_each_angle" in builtins.__dict__: # pylint: disable=undefined-variable omny_at_each_angle(self, angle) @@ -1555,6 +1848,15 @@ class OMNY( fzp_details = self._get_user_param_safe("ofzpx", "fzp_details") except Exception: fzp_details = "N/A" + # The energy readback device is "ccm_energy", not "mokev" -- OMNY has + # no mokev device in any config (real or simulated). Read it + # defensively so a missing/misbehaving device doesn't crash the + # whole report instead of just omitting one line. + try: + energy_kev = dev.ccm_energy.read(cached=True)[dev.ccm_energy.name]["value"] + energy_str = f"{energy_kev:>{padding}.4f}" + except Exception: + energy_str = f"{'N/A':>{padding}}" content = [ f"{'Sample Name:':<{padding}}{self.sample_name:>{padding}}\n", f"{'Measurement ID:':<{padding}}{str(self.tomo_id):>{padding}}\n", @@ -1564,7 +1866,7 @@ class OMNY( f"{'Number of projections:':<{padding}}{int(180 / self.tomo_angle_stepsize * 8):>{padding}}\n", f"{'First scan number:':<{padding}}{self.client.queue.next_scan_number:>{padding}}\n", f"{'Last scan number approx.:':<{padding}}{self.client.queue.next_scan_number + int(180 / self.tomo_angle_stepsize * 8) + 10:>{padding}}\n", - f"{'Current photon energy:':<{padding}}{dev.mokev.read()['mokev']['value']:>{padding}.4f}\n", + f"{'Current photon energy:':<{padding}}{energy_str}\n", f"{'Exposure time:':<{padding}}{self.tomo_countingtime:>{padding}.2f}\n", f"{'Fermat spiral step size:':<{padding}}{self.tomo_shellstep:>{padding}.2f}\n", f"{'FOV:':<{padding}}{fovxy:>{padding}}\n", @@ -1583,12 +1885,19 @@ class OMNY( "xterm /work/sls/spec/local/XOMNY/bin/upload/upload_last_pon.sh &", shell=True ) # status = subprocess.run(f"cp /tmp/spec-e20131-specES1.pdf {user_target}", shell=True) - msg = bec.logbook.LogbookMessage() - logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "LamNI_logo.png") - msg.add_file(logo_path).add_text("".join(content).replace("\n", "

")).add_tag( - ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "LamNI", self.sample_name] - ) - self.client.logbook.send_logbook_message(msg) + # Same tolerance as write_to_scilog(): a session without scilog/ + # logbook configured (e.g. a dev/sim session) must not crash report + # generation over the logbook upload -- the PDF itself is already + # written above. + try: + msg = bec.logbook.LogbookMessage() + logo_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "OMNY.png") + msg.add_file(logo_path).add_text("".join(content).replace("\n", "

")).add_tag( + ["BEC", "tomo_parameters", f"dataset_id_{dataset_id}", "OMNY", self.sample_name] + ) + self.client.logbook.send_logbook_message(msg) + except Exception: + logger.warning("Failed to write PDF report to scilog.") def vcs_show_all(): dev.omny_vcs.show_all() diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py new file mode 100644 index 00000000..6ad9cca9 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py @@ -0,0 +1,317 @@ +"""Unit tests for the tomo-queue / at-each-angle-hook backend ported to +OMNY from flomni/lamni (see TomoQueueMixin in +csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py). + +Builds an OMNY instance without running __init__'s heavy side effects +(device init, gui_tools, x-ray-eye alignment, ...) -- only what the +tomo-queue backend actually needs: self.client + _init_tomo_queue(). +Mirrors test_lamni_tomo_queue.py's structure exactly, plus OMNY-specific +coverage for its three "equally spaced sub-tomograms" tomo_type flavors +(1/4/5, via _TOMO_TYPE_1_LIKE) that flomni/lamni don't have. +""" + +import types + +import numpy as np + +import csaxs_bec.bec_ipython_client.plugins.omny.omny as omny_module +import csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin as tomo_queue_mixin +from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY, OMNYError, _ProgressProxy + + +class FakeClient: + """Minimal in-memory stand-in for BEC's global-var store.""" + + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +class FakeOMNYTools: + def __init__(self, answer=True): + self.answer = answer + + def yesno(self, *args, **kwargs): + return self.answer + + +def make_omny(fake_client=None): + # sample_name reads the module-level `dev` (set at import time inside a + # real BEC session only) -- stand in a fake so tomo_queue_execute()'s + # confirmation prompt (which reads self.sample_name) doesn't NameError. + omny_module.dev = types.SimpleNamespace( + omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample") + ) + + obj = object.__new__(OMNY) + obj.client = fake_client or FakeClient() + obj.OMNYTools = FakeOMNYTools() + obj._progress_proxy = _ProgressProxy(obj.client) + obj._init_tomo_queue() + return obj + + +def test_tomo_scan_param_names_have_no_flomni_only_concepts(): + """OMNY mirrors flomni's names for shared concepts, but has no + 180/360 toggle, no single-point acquisition, no zero-deg reference, + and no frames_per_trigger -- none of these exist as OMNY properties.""" + names = OMNY._TOMO_SCAN_PARAM_NAMES + assert "tomo_angle_range" not in names + assert "single_point_instead_of_fermat_scan" not in names + assert "single_point_random_shift_max" not in names + assert "zero_deg_reference_at_each_subtomo" not in names + assert "frames_per_trigger" not in names + # shared-with-flomni names are present, using OMNY's own property names + assert "fovx" in names + assert "stitch_x" in names + assert "tomo_angle_stepsize" in names + + +def test_tomo_queue_add_snapshots_all_param_names(): + omny = make_omny() + omny.tomo_countingtime = 0.2 + omny.fovx = 42.0 + + index = omny.tomo_queue_add(label="my job") + + assert index == 0 + jobs = omny._tomo_queue_proxy.as_list() + assert len(jobs) == 1 + job = jobs[0] + assert job["label"] == "my job" + assert job["kind"] == "tomo" + assert job["status"] == "pending" + assert set(job["params"]) == set(OMNY._TOMO_SCAN_PARAM_NAMES) + assert job["params"]["tomo_countingtime"] == 0.2 + assert job["params"]["fovx"] == 42.0 + + +def test_tomo_queue_show_survives_missing_param_key(capsys): + """A job snapshotted before some key existed in _TOMO_SCAN_PARAM_NAMES + won't carry it in its persisted params dict -- tomo_queue_show() must + still print a line for it instead of raising KeyError.""" + omny = make_omny() + omny.fovx = 15.0 + omny.tomo_queue_add(label="job1") + jobs = omny._tomo_queue_proxy.as_list() + del jobs[0]["params"]["fovx"] + omny.client.set_global_var("tomo_queue", jobs) + + result = omny.tomo_queue_show() + + assert result[0]["label"] == "job1" + capsys.readouterr() # just confirm no crash while printing + + +def test_tomo_queue_execute_runs_fresh_job_then_marks_done(): + omny = make_omny() + calls = [] + omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k)) + omny.tomo_scan_resume = lambda *a, **k: calls.append(("resume", a, k)) + + omny.tomo_queue_add(label="job1") + omny.tomo_queue_execute() + + assert calls == [("scan", (), {"interactive": False})] + job = omny._tomo_queue_proxy.as_list()[0] + assert job["status"] == "done" + + +def test_tomo_queue_execute_resumes_incomplete_job(): + omny = make_omny() + calls = [] + omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k)) + omny.tomo_scan_resume = lambda *a, **k: calls.append(("resume", a, k)) + + index = omny.tomo_queue_add(label="job1") + omny._tomo_queue_proxy.update(index, status="incomplete") + + omny.tomo_queue_execute() + + assert calls == [("resume", (), {})] + job = omny._tomo_queue_proxy.as_list()[0] + assert job["status"] == "done" + + +def test_tomo_queue_add_command_move_action(monkeypatch): + omny = make_omny() + fake_dev = {"ccm_energy": "ccm-energy-device"} + moved = [] + monkeypatch.setattr(tomo_queue_mixin, "dev", fake_dev, raising=False) + monkeypatch.setattr(tomo_queue_mixin, "umv", lambda *a: moved.append(a), raising=False) + + omny.tomo_queue_add_command( + {"action": "move", "kwargs": {"positions": {"ccm_energy": 6.2}}}, label="reconfigure" + ) + omny.tomo_queue_execute() + + assert moved == [("ccm-energy-device", 6.2)] + job = omny._tomo_queue_proxy.as_list()[0] + assert job["kind"] == "command" + assert job["status"] == "done" + + +def test_tomo_queue_add_command_rejects_non_movable_device(): + omny = make_omny() + try: + omny.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"fzp": 1.0}}}) + assert False, "expected ValueError for a non-allow-listed device" + except ValueError as exc: + assert "not queue-movable" in str(exc) + + +def test_register_at_each_angle_hook_dispatches_from_at_each_angle(): + omny = make_omny() + seen = [] + omny.register_at_each_angle_hook("record", lambda self, angle: seen.append(angle)) + omny.at_each_angle_hook = "record" + + omny._at_each_angle(12.5) + + assert seen == [12.5] + assert omny.client.get_global_var("tomo_at_each_angle_hooks") == ["record"] + + +def test_at_each_angle_default_path_unchanged_without_hook(): + """No hook registered -- must fall through to the exact same default + behaviour omny had before this port: tomo_scan_projection + tomo_reconstruct.""" + omny = make_omny() + calls = [] + omny.tomo_scan_projection = lambda angle: calls.append(("projection", angle)) + omny.tomo_reconstruct = lambda: calls.append(("reconstruct",)) + + omny._at_each_angle(30.0) + + assert calls == [("projection", 30.0), ("reconstruct",)] + + +def test_at_each_angle_falls_through_to_legacy_builtins_hook(monkeypatch): + """The pre-existing omny_at_each_angle builtins hook must still work as + the second-tier fallback, when no at_each_angle_hook is registered.""" + import builtins + + omny = make_omny() + calls = [] + monkeypatch.setitem( + builtins.__dict__, "omny_at_each_angle", lambda setup, angle: calls.append(angle) + ) + + omny._at_each_angle(7.0) + + assert calls == [7.0] + + +def test_at_each_angle_raises_for_unregistered_hook_name(): + omny = make_omny() + omny.at_each_angle_hook = "missing_hook" + try: + omny._at_each_angle(0.0) + assert False, "expected OMNYError for an unregistered hook name" + except OMNYError as exc: + assert "missing_hook" in str(exc) + assert "not registered" in str(exc) + + +# --- OMNY-specific: tomo_scan_resume() over three sub-tomogram flavors ------- + + +def test_tomo_scan_resume_dispatches_subtomo_angle_for_type_1_4_5(): + for tomo_type, expected_subtomo, expected_angle in [(1, 2, 55.0), (4, 3, 20.0), (5, 7, 90.0)]: + omny = make_omny() + omny.tomo_type = tomo_type + omny.progress["tomo_start_time"] = "2026-01-01T00:00:00" + omny.progress["subtomo"] = expected_subtomo + omny.progress["angle"] = expected_angle + calls = [] + omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k)) + + omny.tomo_scan_resume() + + assert calls == [ + ("scan", (), {"subtomo_start": expected_subtomo, "start_angle": expected_angle}) + ], tomo_type + + +def test_tomo_scan_resume_dispatches_projection_number_for_type_2_3(): + for tomo_type in (2, 3): + omny = make_omny() + omny.tomo_type = tomo_type + omny.progress["tomo_start_time"] = "2026-01-01T00:00:00" + omny.progress["projection"] = 42 + calls = [] + omny.tomo_scan = lambda *a, **k: calls.append(("scan", a, k)) + + omny.tomo_scan_resume() + + assert calls == [("scan", (), {"projection_number": 42})], tomo_type + + +def test_tomo_scan_resume_noop_when_nothing_in_progress(capsys): + omny = make_omny() + calls = [] + omny.tomo_scan = lambda *a, **k: calls.append(a) + + omny.tomo_scan_resume() + + assert calls == [] + assert "nothing to do" in capsys.readouterr().out + + +# --- OMNY-specific: _resolve_type1_projection() for n_subtomos in {2,4,8} --- + + +def test_resolve_type1_projection_matches_equally_spaced_subtomo_angles(): + """Cross-check against _equally_spaced_subtomo_angles() itself -- the + single source of truth for this angle grid -- rather than + re-deriving/duplicating the math in the test.""" + for tomo_type, n_subtomos in [(1, 2), (4, 4), (5, 8)]: + omny = make_omny() + omny.tomo_type = tomo_type + omny.tomo_angle_stepsize = 10.0 + N = int(180.0 / omny.tomo_angle_stepsize) + + for projection_number in range(N * n_subtomos): + subtomo_start, start_angle = omny._resolve_type1_projection(projection_number) + expected_subtomo = projection_number // N + 1 + expected_angle = OMNY._equally_spaced_subtomo_angles( + expected_subtomo, n_subtomos, omny.tomo_angle_stepsize + )[projection_number % N] + assert subtomo_start == expected_subtomo + assert np.isclose(start_angle, expected_angle) + + +def test_resolve_type1_projection_rejects_out_of_range(): + omny = make_omny() + omny.tomo_type = 1 + omny.tomo_angle_stepsize = 10.0 + N = int(180.0 / omny.tomo_angle_stepsize) + total = N * 2 # n_subtomos=2 for tomo_type 1 + + try: + omny._resolve_type1_projection(total) + assert False, "expected ValueError for an out-of-range projection number" + except ValueError: + pass + + +def test_tomo_queue_reacquire_dispatches_type1_like_for_types_1_4_5(): + """tomo_queue_reacquire() (TomoQueueMixin) hardcodes tomo_type == 1 by + default; OMNY overrides _TOMO_TYPE_1_LIKE to {1, 4, 5} so reacquiring a + type-4/5 job also resolves via _resolve_type1_projection() instead of + hitting the mixin's "unknown tomo_type" branch.""" + for tomo_type, n_subtomos in [(1, 2), (4, 4), (5, 8)]: + omny = make_omny() + omny.tomo_type = tomo_type + omny.tomo_angle_stepsize = 10.0 + omny.tomo_queue_add(label="job1") + + omny.tomo_queue_reacquire(0, projection_number=1) + + job = omny._tomo_queue_proxy.as_list()[0] + assert job["status"] == "incomplete", tomo_type + assert omny.progress["subtomo"] in range(1, n_subtomos + 1), tomo_type diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py new file mode 100644 index 00000000..ade871ba --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py @@ -0,0 +1,183 @@ +"""Unit tests for tomo_scan()'s new-scan account handling, progress-GUI +auto-show, and heartbeat-clearing -- all part of porting the tomo-queue +backend (TomoQueueMixin) to OMNY. Mirrors test_lamni_tomo_angles.py's +equivalent tests exactly (same bug shapes, same fixes, direct ports of +flomni's/lamni's already-fixed code). + +bec.active_account used to be passed through .decode() (as if it were +bytes) -- it's a plain str, so this raised AttributeError on every real +tomo_scan() call with a real account. Unlike flomni/lamni (which always +attempt sample-database registration, letting it decide the empty-account +case), OMNY's existing "if bec.active_account != '': ... else: tomo_id = 0" +branch structure is preserved as-is here -- only the .decode() bug itself +is fixed, not the branch structure (no TomoIDManager/test-server fallback +exists in OMNY's own add_sample_database()). +""" + +import builtins +import types + +import pytest + +import csaxs_bec.bec_ipython_client.plugins.omny.omny as omny_module +from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY, _ProgressProxy + + +class FakeGlobalVarClient: + """Minimal in-memory stand-in for BEC's global-var store.""" + + def __init__(self): + self._vars = {} + + def get_global_var(self, key): + return self._vars.get(key) + + def set_global_var(self, key, value): + self._vars[key] = value + + +class _FakeContextManager: + def __enter__(self): + return self + + def __exit__(self, *exc_info): + return False + + +class _FakeScans: + dataset_id_on_hold = _FakeContextManager() + + +def make_omny_for_tomo_scan(monkeypatch, tomo_angle_stepsize: float, active_account: str) -> OMNY: + """Bare OMNY instance with tomo_scan()'s "new scan" branch reachable, + everything downstream of it stubbed out (sample database, PDF report, + per-sub-tomogram scanning) so only the logic under test actually runs.""" + # sample_name reads the module-level `dev` (set at import time inside a + # real BEC session only) -- stand in a fake so tomo_scan()'s sample- + # database registration (which reads self.sample_name) doesn't NameError. + omny_module.dev = types.SimpleNamespace( + omny_samples=types.SimpleNamespace(get_sample_name_in_samplestage=lambda: "test_sample") + ) + + obj = object.__new__(OMNY) + obj.client = FakeGlobalVarClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.tomo_type = 1 + obj.tomo_angle_stepsize = tomo_angle_stepsize + obj.special_angles = [] + obj._current_special_angles = [] + obj.write_pdf_report = lambda: None + obj.sub_tomo_scan = lambda subtomo_number, n_subtomos=2, start_angle=None: None + obj.omnygui_show_progress = lambda: None + monkeypatch.setitem( + builtins.__dict__, + "bec", + types.SimpleNamespace( + active_account=active_account, queue=types.SimpleNamespace(next_scan_number=1) + ), + ) + monkeypatch.setitem(builtins.__dict__, "scans", _FakeScans()) + return obj + + +def test_tomo_scan_skips_sample_database_without_active_account(monkeypatch): + """Empty active_account (e.g. a dev/sim session) must not crash and must + not attempt sample-database registration -- OMNY's own add_sample_database() + has no test-server/empty-account fallback, unlike flomni's/lamni's.""" + omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="") + called = [] + omny.add_sample_database = lambda *a, **k: called.append(a) or 99 + + omny.tomo_scan() + + assert called == [] + assert omny.tomo_id == 0 + + +def test_tomo_scan_registers_sample_with_plain_string_account(monkeypatch): + """A real active_account must be passed through as-is -- calling + .decode() on it (a plain str, not bytes) raises AttributeError.""" + omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="e12345") + recorded = {} + + def _fake_add_sample_database(samplename, date, eaccount, scan_number, setup, info, user): + recorded["eaccount"] = eaccount + return 42 + + omny.add_sample_database = _fake_add_sample_database + + omny.tomo_scan() + + assert recorded["eaccount"] == "e12345" + assert omny.tomo_id == 42 + + +def test_tomo_scan_shows_progress_gui(monkeypatch): + """tomo_scan() must open/show the progress GUI unconditionally at the + start of every scan -- previously only shown per-angle inside + _tomo_scan_at_angle(), never at scan start.""" + omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="") + calls = [] + omny.omnygui_show_progress = lambda: calls.append("shown") + + omny.tomo_scan() + + assert calls == ["shown"] + + +def test_tomo_scan_clears_heartbeat_on_normal_completion(monkeypatch): + """The busy-detector heartbeat must be cleared as soon as the scan + finishes, not left for the next poll to time out before the GUI stops + showing "beamline busy" for an already-finished scan. + + sub_tomo_scan() is stubbed to set a fresh heartbeat before returning, + the same way the real _tomo_scan_at_angle() does at the start of every + projection -- without that, this test would pass even without the fix, + since tomo_scan()'s own "new scan" branch already resets the heartbeat + to None *before* the scan body runs. + """ + omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="") + + def _fake_sub_tomo_scan(subtomo_number, n_subtomos=2, start_angle=None): + omny.progress["heartbeat"] = "2026-01-01T00:00:00" + + omny.sub_tomo_scan = _fake_sub_tomo_scan + + omny.tomo_scan() + + assert omny.progress["heartbeat"] is None + + +def test_tomo_scan_clears_heartbeat_even_if_scan_raises(monkeypatch): + """The heartbeat must be cleared on every exit path, including a + mid-scan exception -- otherwise a crashed scan looks permanently "busy" + to the GUI until the staleness timeout, or forever if polled more often + than that.""" + omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="") + + def _boom(subtomo_number, n_subtomos=2, start_angle=None): + omny.progress["heartbeat"] = "2026-01-01T00:00:00" + raise RuntimeError("simulated crash mid-scan") + + omny.sub_tomo_scan = _boom + + with pytest.raises(RuntimeError): + omny.tomo_scan() + + assert omny.progress["heartbeat"] is None + + +def test_tomo_scan_sets_timing_fields_on_new_scan(monkeypatch): + """A fresh tomo_scan() must stamp tomo_start_time and reset stale + ETA/idle-time estimates from any previous scan.""" + omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="") + omny.progress["estimated_remaining_time"] = "stale" + omny.progress["estimated_finish_time"] = "stale" + omny.progress["accumulated_idle_time"] = 123.0 + + omny.tomo_scan() + + assert omny.progress["tomo_start_time"] is not None + assert omny.progress["estimated_remaining_time"] is None + assert omny.progress["estimated_finish_time"] is None + assert omny.progress["accumulated_idle_time"] == 0.0