diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md index d210c3ba..086c1ad1 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md @@ -37,8 +37,13 @@ TomoQueueMixin's interactive= kwarg", "stale ~/Data10/ paths crashed on a fresh and tomo queue seems to work now for omny." Also just fixed following his feedback: the progress ring GUI only had 2 of flomni's 3 rings, and OMNY never tracked/printed measurement time or idle time the way flomni does at the end of a tomogram -- see "Progress ring GUI + ETA/idle-time -tracking + end-of-scan timing summary" below. Phase 2 (the `TomoParamsWidget` GUI itself) is a -separate follow-up, not yet started. +tracking + end-of-scan timing summary" below. + +**Just landed, not yet live-tested**: Phase 2 -- the `OMNY_TomoParams` GUI widget itself now +supports OMNY as a third setup (5 tomo types, not just flomni's/lamni's 3). See "TomoParamsWidget +ported to OMNY (Phase 2 of 2)" below. Mirko: "is the way to move to the widget phase" -- this is +that. No live GUI verification yet (needs a real display, same as every other GUI change this +branch) -- next step is Mirko trying it against a real OMNY session. **Still open / deferred** (unchanged from before, not touched this pass): - `x_ray_eye_align.py` is still LamNI-derived, unadapted code beyond the two call-site renames @@ -47,7 +52,6 @@ separate follow-up, not yet started. - The full gripper sample-transfer chain (`otransfer_put_sample()`/`otransfer_get_sample()`), dewar/temperature methods, sample storage bookkeeping, and `omny_alignment_mixin.py`/ `omny_optics_mixin.py` beyond what scans exercise — see "Checklist" section below. -- The `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself (Phase 2 of the tomo-queue port). - `omny_alignment_mixin.py`/`x_ray_eye_align.py` still reference the old `~/Data10/specES1/internal/` path for alignment-offset data (unlike the tomo-scan-path fixes below, these already tolerate a missing path gracefully — confirmed live, no crash, just "No data available" -- so left alone for @@ -145,15 +149,14 @@ Still untested (not attempted yet, not known-broken — just unexplored): - `omny_webpage_generator.py` (2957 lines) is dead/unwired code — never instantiated by `omny.py`, per `FLOMNI_TO_LAMNI_COMPARISON.md`'s note ("omny was left untouched... separate future task if omny ever needs a real one"). Not touched this session either. -- **Tomo-queue backend: ported, Phase 1 of 2 (this session).** `OMNY` now inherits +- **Tomo-queue backend + widget: both ported (Phase 1 and 2, this branch).** `OMNY` inherits `TomoQueueMixin` (`OMNY_shared/tomo_queue_mixin.py`), the same shared backend flomni/lamni use — `tomo_queue_add/delete/move/clear/show/execute/reacquire`, - `register_at_each_angle_hook`/`at_each_angle_hook`, `tomo_scan_resume()`. See "Tomo-queue - backend ported to OMNY" below for the full detail. **Not yet done (Phase 2, separate - follow-up)**: the `TomoParamsWidget`/`OMNY_TomoParams` GUI widget itself - (`bec_widgets/widgets/tomo_params/tomo_params.py`) still has no `SETUP_PROFILES["omny"]` - entry and its tomo-type selector/job-display logic is hardcoded to 3 tomo types (flomni's - and lamni's), not OMNY's 5 — needs its own plan once Phase 1 is live-tested. + `register_at_each_angle_hook`/`at_each_angle_hook`, `tomo_scan_resume()`. The + `OMNY_TomoParams` GUI widget (`bec_widgets/widgets/tomo_params/tomo_params.py`) now has a + `SETUP_PROFILES["omny"]` entry and its tomo-type selector/job-display logic supports OMNY's 5 + tomo types (flomni/lamni only ever had 3). See "Tomo-queue backend ported to OMNY" and + "TomoParamsWidget ported to OMNY (Phase 2 of 2)" below for the full detail. ## Progress ring GUI + ETA/idle-time tracking + end-of-scan timing summary (fixed) @@ -479,6 +482,67 @@ never drift out of sync. 13 new tests across `test_omny_fermat_scan.py` (scan-le `prepare_scan()` still aborts) and `test_omny_tomo_angles.py` (CLI-level: warning appears/doesn't appear). +## TomoParamsWidget ported to OMNY (Phase 2 of 2) + +Mirko, once Phase 1 was live-confirmed working: "is the way to move to the widget phase" -- this +is Phase 2, adding OMNY as a third setup to the `OMNY_TomoParams` GUI widget +(`bec_widgets/widgets/tomo_params/tomo_params.py`). The widget was already almost entirely +`SETUP_PROFILES`-driven (two research passes plus a verification pass against the live 2476-line +file found essentially everything generalizes mechanically), with one real complication: flomni +and lamni each have exactly one "equally spaced sub-tomograms" tomo type (type 1, always 8 +sub-tomograms); OMNY has three (types 1/4/5 -- 2/4/8 sub-tomograms). + +**Key design decision**: replaced the module-level `TOMO_TYPES` dict (shared across all setups, +couldn't express "type 1 means 2 sub-tomograms here but 8 there") with a per-profile +`"tomo_types"` dict: `{tomo_type_int: {"label": str, "n_subtomos": int | None}}`. +`n_subtomos is not None` became the single, uniform test for "is this an equally-spaced type" +everywhere in the file (combo box, section visibility, validation, queue-table projections +display) instead of duplicating an `{1,4,5}`-shaped literal two or three times. + +**Critical bug caught by a verification pass, not the initial design**: `_validate()` had +`if params.get("tomo_type") not in (1, 2, 3): return "tomo_type must be 1, 2, or 3"` -- a +hardcoded closed-enumeration check that the rest of Phase 2 would have sailed past (types 4/5 +selectable, sections visible, math correct) while **silently rejecting every actual submit/queue +attempt** for an OMNY type-4/5 job. Fixed to check `self._profile["tomo_types"]` membership +instead. Also caught: three UI strings hardcoded "8" ("must be divisible into 8 equal +sub-tomograms" in the projection-preview tooltip and both confirmation dialogs) -- all three now +read the active type's real `n_subtomos`. + +**New module-level OMNY math functions** (`_compute_type1_omny`/`_requested_to_stepsize_omny`), +mirroring `_compute_type1`/`_requested_to_stepsize` but parameterized by `tomo_type` (using +`{1:2, 4:4, 5:8}`, matching `OMNY._equally_spaced_subtomo_angles()`'s own mapping in omny.py +exactly) instead of a hardcoded `*8`. `_compute_fermat_positions_omny` calls +`OmnyFermatScan.get_omny_fermat_spiral_pos()`/`MIN_POSITIONS` directly, same pattern as flomni's. +`_format_projections()` (module-level, only has a job's raw `params` dict, not `self._profile`) +gained a 3-way setup duck-type: `"tomo_angle_range" in params` -> flomni; else `"fovx" in params` +-> omny (OMNY's param set is a strict subset of flomni's -- no positively-unique OMNY-only key +exists, so this negative-then-positive check is necessary, not a shortcut); else -> lamni. Once +resolved, it looks up `SETUP_PROFILES[setup]["tomo_types"]` rather than hand-picking a compute +function, avoiding a third copy of the type-count mapping. + +**Confirmed needing zero changes** (verified against the live file, not assumed): `_job_tooltip()`/ +`_irrelevant_params_for_type()` (OMNY's params never overlap the flomni-only keys they filter on, +so the existing `!= 1` check has no observable effect for OMNY regardless); `_build_type23_section()` +(golden-ratio-only, already type-count-agnostic); `compute_fermat_positions` call sites (fermat math +never depends on tomo_type); `_detect_setup()` (purely `discriminator_attr`-driven, `"osamroy"` for +OMNY); the busy-banner detection methods (already read the shared `tomo_progress` global var/ +scan-queue-status generically, Phase 1 already made OMNY use the same key); the Qt Designer plugin +registration files (already fully generic, self-detect setup at construction). + +Test coverage: new `test_omny_tomo_params_widget_math.py` (29 tests, pure-function -- no +`QApplication`/`QWidget`, same style as the existing flomni/lamni widget-math test files), +covering the new compute functions against the reference formula, `tomo_types` n_subtomos mapping, +`param_names`/`defaults` parity with `OMNY._TOMO_SCAN_PARAM_NAMES`, `_format_projections()`'s 3-way +dispatch (including a regression guard that it still correctly distinguishes flomni/lamni jobs), +`_compute_fermat_positions_omny` against the real scan class, and a standalone regression guard for +the `_validate()` fix. Full suite: 761 passed. + +**Not live-tested**: needs a real display (same as every GUI change this branch) -- next step is +Mirko trying it against a real OMNY session: switching between all 5 tomo types (confirm the +equally-spaced section shows/hides correctly and the new sub-tomogram-count label updates), +submitting/queueing a type-4 or type-5 job (confirms the `_validate()` fix specifically), and +checking a queued job's tooltip/Projections column in the queue dialog. + ## Environment note (not code, but will bite again if forgotten) Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo- diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index 0c6341c6..bab239fd 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -79,12 +79,6 @@ logger = bec_logger.logger # ── constants ──────────────────────────────────────────────────────────────── -TOMO_TYPES = { - 1: "8 equally spaced sub-tomograms", - 2: "Golden ratio (sorted in bunches)", - 3: "Equally spaced, golden ratio start", -} - # Mirrors _update_type_visibility()'s section/field gating (_sec_type1 shown # only for type 1, golden_ratio_bunch_size shown only for type 2 within # _sec_type23, the rest of _sec_type23 shown for type 2/3) -- reused by @@ -228,6 +222,48 @@ LAMNI_DEFAULTS: dict[str, Any] = { "at_each_angle_hook": None, } +# Exact tuple from OMNY._TOMO_SCAN_PARAM_NAMES (source of truth in omny.py). +# No tomo_angle_range/single_point_*/zero_deg_reference_at_each_subtomo/ +# frames_per_trigger -- none of these exist as OMNY properties. +OMNY_QUEUE_PARAM_NAMES = ( + "tomo_countingtime", + "tomo_shellstep", + "fovx", + "fovy", + "stitch_x", + "stitch_y", + "tomo_stitch_overlap", + "ptycho_reconstruct_foldername", + "manual_shift_y", + "tomo_type", + "tomo_angle_stepsize", + "golden_ratio_bunch_size", + "golden_max_number_of_projections", + "golden_projections_at_0_deg_for_damage_estimation", + "corridor_size", + "at_each_angle_hook", +) + +# Read directly off each OMNY property getter's None-fallback in omny.py. +OMNY_DEFAULTS: dict[str, Any] = { + "tomo_countingtime": 0.1, + "tomo_shellstep": 1, + "fovx": 20.0, + "fovy": 20.0, + "stitch_x": 0, + "stitch_y": 0, + "tomo_stitch_overlap": 0.2, + "ptycho_reconstruct_foldername": "ptycho_reconstruct", + "manual_shift_y": 0.0, + "tomo_type": 1, + "tomo_angle_stepsize": 10.0, + "golden_ratio_bunch_size": 20, + "golden_max_number_of_projections": 1000.0, + "golden_projections_at_0_deg_for_damage_estimation": 0, + "corridor_size": -1, + "at_each_angle_hook": None, +} + def _hline() -> QFrame: line = QFrame() @@ -438,8 +474,8 @@ class OMNY_TomoParams(BECWidget, QWidget): type_row = QHBoxLayout() type_row.addWidget(QLabel("Tomo type:")) self._pw["tomo_type"] = QComboBox() - for tid, label in TOMO_TYPES.items(): - self._pw["tomo_type"].addItem(f"{tid} – {label}", tid) + for tid, info in self._profile["tomo_types"].items(): + self._pw["tomo_type"].addItem(f"{tid} – {info['label']}", tid) self._pw["tomo_type"].currentIndexChanged.connect(self._on_type_changed) type_row.addWidget(self._pw["tomo_type"], stretch=1) vbox.addLayout(type_row) @@ -599,7 +635,11 @@ class OMNY_TomoParams(BECWidget, QWidget): form.addRow("At-each-angle hook:", self._pw["at_each_angle_hook"]) def _build_type1_section(self) -> QGroupBox: - box = QGroupBox("Type 1 – sub-tomogram settings") + # Generic title, not "Type 1" -- this section is shared by every + # "equally spaced sub-tomograms" type_flavor a profile defines (see + # SETUP_PROFILES[...]["tomo_types"]), which for OMNY is types 1/4/5 + # (2/4/8 sub-tomograms), not just type 1 like flomni/lamni. + box = QGroupBox("Equally spaced sub-tomograms – settings") form = QFormLayout(box) form.setLabelAlignment(Qt.AlignmentFlag.AlignRight) @@ -614,6 +654,12 @@ class OMNY_TomoParams(BECWidget, QWidget): ) form.addRow("Angle range:", self._pw["tomo_angle_range"]) + # read-only: how many sub-tomograms the currently selected type uses + # (2/4/8 for OMNY's types 1/4/5, always 8 for flomni/lamni's type 1) + # -- updated from _update_type_visibility() whenever the type changes. + self._lbl_subtomo_count = QLabel("---") + form.addRow("Sub-tomograms:", self._lbl_subtomo_count) + # requested total projections (user entry in edit mode) self._pw["_requested_total"] = QSpinBox() self._pw["_requested_total"].setRange(8, 10000) @@ -752,7 +798,7 @@ class OMNY_TomoParams(BECWidget, QWidget): # derive requested_total from stored tomo_angle_stepsize angle_range = int(params.get("tomo_angle_range", 180)) stepsize = float(params.get("tomo_angle_stepsize", 10.0)) - actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize) + actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize, type_val) self._pw["_requested_total"].blockSignals(True) self._pw["_requested_total"].setValue(actual_total) self._pw["_requested_total"].blockSignals(False) @@ -820,7 +866,9 @@ class OMNY_TomoParams(BECWidget, QWidget): # derive tomo_angle_stepsize from requested_total + angle_range (type 1) angle_range = int(params.get("tomo_angle_range", 180)) requested = self._pw["_requested_total"].value() - stepsize = self._profile["requested_to_stepsize"](angle_range, requested) + stepsize = self._profile["requested_to_stepsize"]( + angle_range, requested, params.get("tomo_type") + ) params["tomo_angle_stepsize"] = stepsize # type 3: override tomo_angle_stepsize from projections-per-subtomo @@ -849,8 +897,8 @@ class OMNY_TomoParams(BECWidget, QWidget): fpt = params.get("frames_per_trigger", 1) if not isinstance(fpt, int) or fpt < 1 or isinstance(fpt, bool): return "frames_per_trigger must be a positive integer" - if params.get("tomo_type") not in (1, 2, 3): - return "tomo_type must be 1, 2, or 3" + if params.get("tomo_type") not in self._profile["tomo_types"]: + return f"tomo_type must be one of {sorted(self._profile['tomo_types'])}" if self._profile["has_180_mode"] and params.get("tomo_angle_range") not in (180, 360): return "tomo_angle_range must be 180 or 360" if self._profile["has_single_point"]: @@ -869,7 +917,17 @@ class OMNY_TomoParams(BECWidget, QWidget): # ── type visibility ─────────────────────────────────────────────────────── def _update_type_visibility(self, tomo_type: int) -> None: - self._sec_type1.setVisible(tomo_type == 1) + # "Equally spaced sub-tomograms" section: shown for whichever + # tomo_type(s) this profile marks as such (flomni/lamni: type 1 + # only; OMNY: types 1/4/5 -- 2/4/8 sub-tomograms) rather than a bare + # `== 1` check, which would blank the panel entirely for OMNY's + # types 4/5. + type_info = self._profile["tomo_types"].get(tomo_type, {}) + n_subtomos = type_info.get("n_subtomos") + self._sec_type1.setVisible(n_subtomos is not None) + if n_subtomos is not None: + self._lbl_subtomo_count.setText(str(n_subtomos)) + self._pw["_requested_total"].setSingleStep(n_subtomos) self._sec_type23.setVisible(tomo_type in (2, 3)) # _numprj_type3: only visible for type 3 _w__numprj_type3 = self._pw.get("_numprj_type3") @@ -905,18 +963,22 @@ class OMNY_TomoParams(BECWidget, QWidget): # entirely, so any fallback here is a no-op, not a guess. angle_range_widget = self._pw.get("tomo_angle_range") angle_range = int(angle_range_widget.currentData() or 180) if angle_range_widget else 180 + tomo_type = self._pw["tomo_type"].currentData() requested = self._pw["_requested_total"].value() - stepsize = self._profile["requested_to_stepsize"](angle_range, requested) - actual_total, achievable_step, _ = self._profile["compute_type1"](angle_range, stepsize) + stepsize = self._profile["requested_to_stepsize"](angle_range, requested, tomo_type) + actual_total, achievable_step, _ = self._profile["compute_type1"]( + angle_range, stepsize, tomo_type + ) self._lbl_actual_total.setText(str(actual_total)) self._lbl_achievable_step.setText(f"{achievable_step:.4f}") if actual_total != requested: + n_subtomos = self._profile["tomo_types"].get(tomo_type, {}).get("n_subtomos") self._lbl_actual_total.setStyleSheet("color: orange;") self._lbl_actual_total.setToolTip( f"Requested {requested} → actual {actual_total} " - f"(must be divisible into 8 equal sub-tomograms)" + f"(must be divisible into {n_subtomos} equal sub-tomograms)" ) else: self._lbl_actual_total.setStyleSheet("") @@ -1178,18 +1240,22 @@ class OMNY_TomoParams(BECWidget, QWidget): QMessageBox.warning(self, "Validation error", error) return - # Confirm if actual_total differs from requested (type-1) - if params.get("tomo_type") == 1: + # Confirm if actual_total differs from requested (equally-spaced types) + type_info = self._profile["tomo_types"].get(params.get("tomo_type"), {}) + n_subtomos = type_info.get("n_subtomos") + if n_subtomos is not None: angle_range = int(params.get("tomo_angle_range", 180)) requested = self._pw["_requested_total"].value() stepsize = params["tomo_angle_stepsize"] - actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize) + actual_total, _, _ = self._profile["compute_type1"]( + angle_range, stepsize, params.get("tomo_type") + ) if actual_total != requested: reply = QMessageBox.question( self, "Projection count adjusted", f"Requested {requested} projections → achievable: {actual_total}.\n" - f"(Must be divisible into 8 equal sub-tomograms.)\n\n" + f"(Must be divisible into {n_subtomos} equal sub-tomograms.)\n\n" f"Submit with {actual_total} projections?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, ) @@ -1235,17 +1301,21 @@ class OMNY_TomoParams(BECWidget, QWidget): QMessageBox.warning(self, "Validation error", error) return - if params.get("tomo_type") == 1: + type_info = self._profile["tomo_types"].get(params.get("tomo_type"), {}) + n_subtomos = type_info.get("n_subtomos") + if n_subtomos is not None: angle_range = int(params.get("tomo_angle_range", 180)) requested = self._pw["_requested_total"].value() stepsize = params["tomo_angle_stepsize"] - actual_total, _, _ = self._profile["compute_type1"](angle_range, stepsize) + actual_total, _, _ = self._profile["compute_type1"]( + angle_range, stepsize, params.get("tomo_type") + ) if actual_total != requested: reply = QMessageBox.question( self, "Projection count adjusted", f"Requested {requested} projections → achievable: {actual_total}.\n" - f"(Must be divisible into 8 equal sub-tomograms.)\n\n" + f"(Must be divisible into {n_subtomos} equal sub-tomograms.)\n\n" f"Queue with {actual_total} projections?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, ) @@ -2089,7 +2159,7 @@ class CommandJobBuilderDialog(QDialog): # ── module-level helpers ───────────────────────────────────────────────────── -def _requested_to_stepsize(angle_range: int, requested_total: int) -> float: +def _requested_to_stepsize(angle_range: int, requested_total: int, tomo_type: int = 1) -> float: """Convert a desired total projection count to tomo_angle_stepsize. angle_range is accepted for call-site compatibility but NOT used here: @@ -2098,19 +2168,25 @@ def _requested_to_stepsize(angle_range: int, requested_total: int) -> float: sub-tomograms into low/high halves in 360 mode -- it doesn't change the total projection count or per-subtomo step). Mirrors Flomni._subtomo_angle_plan()'s docstring / _tomo_type1_actual_grid(). + + tomo_type is accepted for call-site uniformity with the OMNY sibling + (_requested_to_stepsize_omny(), which has more than one equally-spaced + flavor and needs it) -- unused here, flomni has only one. """ if requested_total <= 0: return 180.0 return (180.0 / requested_total) * 8 -def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float]: +def _compute_type1( + angle_range: int, stepsize: float, tomo_type: int = 1 +) -> tuple[int, float, float]: """ Given stored tomo_angle_stepsize, return: (actual_total, achievable_step, stepsize) Mirrors the CLI's internal computation exactly -- see - _requested_to_stepsize()'s docstring for why angle_range is accepted - but not used in the N/step/total math. + _requested_to_stepsize()'s docstring for why angle_range/tomo_type are + accepted but not used in the N/step/total math. """ if stepsize <= 0: return 0, 0.0, 0.0 @@ -2122,7 +2198,9 @@ def _compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float return actual_total, achievable_step, stepsize -def _lamni_requested_to_stepsize(angle_range: int, requested_total: int) -> float: +def _lamni_requested_to_stepsize( + angle_range: int, requested_total: int, tomo_type: int = 1 +) -> float: """Lamni sibling of _requested_to_stepsize(): inverse of _lamni_compute_type1()'s N*8 grid -- mirrors LamNI._tomo_type1_actual_grid()'s inverse (lamni.py) exactly. @@ -2130,13 +2208,16 @@ def _lamni_requested_to_stepsize(angle_range: int, requested_total: int) -> floa angle_range is accepted but ignored, for call-site symmetry with the flomni functions above -- lamni is laminography: always 360 degrees, no tomo_angle_range concept at all (see LamNI._TOMO_SCAN_PARAM_NAMES). + tomo_type likewise accepted but unused -- see _requested_to_stepsize(). """ if requested_total <= 0: return 360.0 return (360.0 / requested_total) * 8 -def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, float]: +def _lamni_compute_type1( + angle_range: int, stepsize: float, tomo_type: int = 1 +) -> tuple[int, float, float]: """Lamni sibling of _compute_type1(): mirrors LamNI._tomo_type1_actual_grid() (lamni.py) exactly -- N = int(360 / stepsize) -- unique angles per sub-tomogram @@ -2146,7 +2227,7 @@ def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, A full 360-degree sweep's closing angle is the same physical angle as its start, so N (not N+1) is the real per-sub-tomogram count -- sub_tomo_scan() generates it with endpoint=False for exactly this - reason. angle_range is accepted but ignored (see + reason. angle_range/tomo_type are accepted but ignored (see _lamni_requested_to_stepsize()). """ if stepsize <= 0: @@ -2159,6 +2240,46 @@ def _lamni_compute_type1(angle_range: int, stepsize: float) -> tuple[int, float, return actual_total, achievable_step, stepsize +# OMNY has three "equally spaced sub-tomograms" flavors (tomo_type 1/4/5 -- +# 2/4/8 sub-tomograms), unlike flomni's/lamni's single always-8 flavor -- see +# OMNY._TOMO_TYPE_1_LIKE / OMNY._equally_spaced_subtomo_angles() (omny.py). +_OMNY_N_SUBTOMOS_FOR_TYPE = {1: 2, 4: 4, 5: 8} + + +def _requested_to_stepsize_omny( + angle_range: int, requested_total: int, tomo_type: int = 1 +) -> float: + """OMNY sibling of _requested_to_stepsize(): same N/180-degree math, but + with a variable sub-tomogram multiplier depending on which of tomo_type + 1/4/5 is active, instead of flomni's/lamni's fixed 8. angle_range is + accepted but ignored (OMNY has no 180/360 toggle, always 180). + """ + n_subtomos = _OMNY_N_SUBTOMOS_FOR_TYPE.get(tomo_type, 2) + if requested_total <= 0: + return 180.0 + return (180.0 / requested_total) * n_subtomos + + +def _compute_type1_omny( + angle_range: int, stepsize: float, tomo_type: int = 1 +) -> tuple[int, float, float]: + """OMNY sibling of _compute_type1(): mirrors + OMNY._equally_spaced_subtomo_angles()'s own grid (omny.py) exactly -- + N = int(180 / stepsize) + achievable_step = 180 / N + actual_total = N * n_subtomos(tomo_type) + """ + if stepsize <= 0: + return 0, 0.0, 0.0 + N = int(180.0 / stepsize) + if N <= 0: + return 0, 0.0, 0.0 + n_subtomos = _OMNY_N_SUBTOMOS_FOR_TYPE.get(tomo_type, 2) + achievable_step = 180.0 / N + actual_total = N * n_subtomos + return actual_total, achievable_step, stepsize + + def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: """Estimated Fermat-spiral point count for the currently edited flomni fields, plus the scan-server's own minimum threshold. @@ -2189,6 +2310,30 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]: return len(positions), FlomniFermatScan.MIN_POSITIONS +def _compute_fermat_positions_omny(params: dict[str, Any]) -> tuple[int, int]: + """OMNY sibling of _compute_fermat_positions_flomni(): calls + OmnyFermatScan.get_omny_fermat_spiral_pos() directly -- the exact same + algorithm the scan itself runs (and the same one OMNY.tomo_parameters()'s + CLI warning uses via OMNY._expected_fermat_position_count()), so the + estimate can never drift out of sync. cenx/ceny/zshift left at their + defaults -- they only shift the final position, never affect the count. + + Returns: + (estimated_count, minimum_required) + """ + from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan + + fovx = params.get("fovx", 0.0) + fovy = params.get("fovy", 0.0) + step = params.get("tomo_shellstep", 0.0) + if step <= 0: + return 0, OmnyFermatScan.MIN_POSITIONS + positions = OmnyFermatScan.get_omny_fermat_spiral_pos( + -abs(fovx / 2), abs(fovx / 2), -abs(fovy / 2), abs(fovy / 2), step=step, spiral_type=0 + ) + return len(positions), OmnyFermatScan.MIN_POSITIONS + + def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]: """Lamni sibling of _compute_fermat_positions_flomni(): calls LamniFermatScan.get_lamni_fermat_spiral_pos() directly -- including its @@ -2296,6 +2441,11 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"], "sample_name_getter": lambda w: w.dev.flomni_samples.sample_names.sample0.get(), "cli_hint_name": "flomni", + "tomo_types": { + 1: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8}, + 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None}, + 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None}, + }, }, "lamni": { "label": "LamNI", @@ -2356,6 +2506,59 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { ], "sample_name_getter": lambda w: w.client.get_global_var("sample_name"), "cli_hint_name": "lamni", + "tomo_types": { + 1: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8}, + 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None}, + 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None}, + }, + }, + "omny": { + "label": "OMNY", + "discriminator_attr": "osamroy", + "param_names": OMNY_QUEUE_PARAM_NAMES, + "defaults": OMNY_DEFAULTS, + "has_180_mode": False, + "has_single_point": False, + "has_zero_deg_reference": False, + "fov_fields": [ + ("fovx", "FOV x (µm)", 0.1, 200.0, 2), + ("fovy", "FOV y (µm)", 0.1, 100.0, 2), + ], # bounds copied from flomni's -- must match OMNY.fovx/fovy setters' ValueError bounds + "stitch_fields": [("stitch_x", "Stitch x", 0, 50), ("stitch_y", "Stitch y", 0, 50)], + "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3)], + "piezo_range_fields": [], + "offset_fields": [], + "offset_getter": None, + "offset_setter": None, + # flomni's own field_order minus frames_per_trigger/single_point -- + # OMNY has neither property. + "field_order": [ + "tomo_countingtime", + "tomo_shellstep", + "fov", + "stitch", + "tomo_stitch_overlap", + "ptycho_reconstruct_foldername", + "manual_shift", + "at_each_angle_hook", + ], + "type1_base_angle": 180.0, + "compute_type1": _compute_type1_omny, + "requested_to_stepsize": _requested_to_stepsize_omny, + "compute_fermat_positions": _compute_fermat_positions_omny, + "fermat_position_fields": ["tomo_shellstep", "fovx", "fovy"], + "sample_name_getter": lambda w: w.dev.omny_samples.get_sample_name_in_samplestage(), + "cli_hint_name": "omny", + # OMNY has three "equally spaced sub-tomograms" flavors (2/4/8 + # sub-tomograms), unlike flomni's/lamni's single always-8 flavor -- + # see OMNY._TOMO_TYPE_1_LIKE / _equally_spaced_subtomo_angles() (omny.py). + "tomo_types": { + 1: {"label": "2 equally spaced sub-tomograms", "n_subtomos": 2}, + 2: {"label": "Golden ratio (sorted in bunches)", "n_subtomos": None}, + 3: {"label": "Equally spaced, golden ratio start", "n_subtomos": None}, + 4: {"label": "4 equally spaced sub-tomograms", "n_subtomos": 4}, + 5: {"label": "8 equally spaced sub-tomograms", "n_subtomos": 8}, + }, }, } @@ -2364,25 +2567,41 @@ def _format_projections(params: dict) -> str: """ Human-readable projection count for a queued job, mirroring the CLI's per-type logic. A job's own ``params`` dict is that setup's own param - snapshot, so ``"tomo_angle_range" in params`` distinguishes a flomni job - (fixed-180 base, mode-independent) from a lamni job (plain 360 base, - see ``_lamni_compute_type1``/``_lamni_requested_to_stepsize``) without - this module-level helper needing to know which setup is active: - - type 1: flomni int(180/stepsize)*8, lamni int(360/stepsize)*8 + snapshot, so this module-level helper duck-types which setup it belongs + to without needing to know which one is currently active: + - ``"tomo_angle_range" in params`` -> flomni (fixed-180 base, + mode-independent). + - else ``"fovx" in params`` -> omny. OMNY's param set is a strict + subset of flomni's (no positively-unique OMNY-only key exists), so + this negative-then-positive check is the correct 3-way + discriminator, not just a shortcut. + - else -> lamni (plain 360 base, ``tomo_circfov`` instead of + ``fovx``). + Once the setup is known, ``SETUP_PROFILES[setup]["tomo_types"]`` is the + single source of truth for which tomo_type values are "equally spaced + sub-tomograms" (and how many) -- avoids a second/third hardcoded + ``{1, 4, 5}``-shaped literal here. + - equally-spaced types: flomni int(180/stepsize)*8, lamni + int(360/stepsize)*8, omny int(180/stepsize)*{2,4,8} depending on + tomo_type. - type 2 and 3: golden ratio-flavored, no fixed sub-tomogram count -> configured max (golden_max_number_of_projections), or ∞ if unset. Type 3 shifts sub-tomogram starting angles by the golden ratio but - is otherwise capped exactly like type 2, not type 1's fixed grid. + is otherwise capped exactly like type 2, not the equally-spaced + types' fixed grid. """ try: tomo_type = int(params.get("tomo_type", 1)) stepsize = float(params.get("tomo_angle_stepsize", 0) or 0) is_flomni_job = "tomo_angle_range" in params - compute_type1 = _compute_type1 if is_flomni_job else _lamni_compute_type1 + is_omny_job = not is_flomni_job and "fovx" in params + setup = "flomni" if is_flomni_job else ("omny" if is_omny_job else "lamni") + profile = SETUP_PROFILES[setup] + type_info = profile["tomo_types"].get(tomo_type, {}) - if tomo_type == 1: + if type_info.get("n_subtomos") is not None: angle_range = int(params.get("tomo_angle_range", 180)) - actual_total, _, _ = compute_type1(angle_range, stepsize) + actual_total, _, _ = profile["compute_type1"](angle_range, stepsize, tomo_type) return str(actual_total) if tomo_type in (2, 3): diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py new file mode 100644 index 00000000..6146c670 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py @@ -0,0 +1,199 @@ +"""Regression test for TomoParamsWidget's omny-specific projection-count +math (_compute_type1_omny/_requested_to_stepsize_omny), mirroring +test_lamni_tomo_params_widget_math.py's style. + +OMNY's formula is NOT the same as flomni's/lamni's fixed-8-sub-tomogram +grid: OMNY has three "equally spaced sub-tomograms" flavors (tomo_type +1/4/5 -- 2/4/8 sub-tomograms), matching OMNY._equally_spaced_subtomo_angles() +(omny.py) exactly: N = int(180/stepsize) unique angles per sub-tomogram, +N*n_subtomos total, where n_subtomos = {1: 2, 4: 4, 5: 8}[tomo_type]. +""" + +import pytest + +from csaxs_bec.bec_ipython_client.plugins.omny.omny import OMNY +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import ( + OMNY_DEFAULTS, + OMNY_QUEUE_PARAM_NAMES, + SETUP_PROFILES, + _compute_fermat_positions_omny, + _compute_type1, + _compute_type1_omny, + _format_projections, + _lamni_compute_type1, + _requested_to_stepsize, + _requested_to_stepsize_omny, +) +from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan + +STEPSIZES = [10.0, 7.0, 25.0, 12.5] +OMNY_TYPES_N_SUBTOMOS = {1: 2, 4: 4, 5: 8} + + +@pytest.mark.parametrize("stepsize", STEPSIZES) +@pytest.mark.parametrize("tomo_type", [1, 4, 5]) +def test_omny_compute_type1_matches_equally_spaced_subtomo_angles(tomo_type, stepsize): + """Reference formula mirrors OMNY._equally_spaced_subtomo_angles()'s own + grid: N = int(180/stepsize) unique angles per sub-tomogram, N*n_subtomos + total.""" + N = int(180.0 / stepsize) + n_subtomos = OMNY_TYPES_N_SUBTOMOS[tomo_type] + expected_total = N * n_subtomos + expected_step = 180.0 / N + + actual_total, achievable_step, _ = _compute_type1_omny(180, stepsize, tomo_type) + assert actual_total == expected_total + assert achievable_step == pytest.approx(expected_step) + + +@pytest.mark.parametrize("tomo_type,requested_total", [(1, 18), (4, 36), (5, 72)]) +def test_omny_requested_to_stepsize_round_trips(tomo_type, requested_total): + """requested_total must already be a multiple of n_subtomos for the + round trip to land exactly back on it -- these were chosen as N=9.""" + stepsize = _requested_to_stepsize_omny(180, requested_total, tomo_type) + actual_total, _, _ = _compute_type1_omny(180, stepsize, tomo_type) + assert actual_total == requested_total + + +def test_omny_formula_differs_from_flomni_and_lamni_formula(): + """Cheap insurance against accidentally aliasing the wrong function into + a SETUP_PROFILES entry -- omny's math must not silently match flomni's + or lamni's.""" + for stepsize in STEPSIZES: + flomni_total, flomni_step, _ = _compute_type1(180, stepsize) + lamni_total, lamni_step, _ = _lamni_compute_type1(360, stepsize) + omny_total, omny_step, _ = _compute_type1_omny(180, stepsize, 1) + assert (flomni_total, flomni_step) != (omny_total, omny_step) + assert (lamni_total, lamni_step) != (omny_total, omny_step) + + for requested in (16, 32, 48, 64): + assert _requested_to_stepsize(180, requested) != _requested_to_stepsize_omny( + 180, requested, 1 + ) + + +def test_omny_tomo_types_have_correct_n_subtomos(): + tomo_types = SETUP_PROFILES["omny"]["tomo_types"] + assert tomo_types[1]["n_subtomos"] == 2 + assert tomo_types[4]["n_subtomos"] == 4 + assert tomo_types[5]["n_subtomos"] == 8 + assert tomo_types[2]["n_subtomos"] is None + assert tomo_types[3]["n_subtomos"] is None + + +def test_setup_profiles_param_names_match_cli_class(): + """Guards against the known mirror-drift risk: SETUP_PROFILES' + param_names must stay in sync with OMNY's own _TOMO_SCAN_PARAM_NAMES + (there is no shared import between bec_widgets and the ipython-client + plugins, so nothing else catches this).""" + assert set(SETUP_PROFILES["omny"]["param_names"]) == set(OMNY._TOMO_SCAN_PARAM_NAMES) + assert set(OMNY_QUEUE_PARAM_NAMES) == set(OMNY._TOMO_SCAN_PARAM_NAMES) + + +def test_setup_profiles_omny_defaults_cover_all_param_names(): + missing = set(OMNY_QUEUE_PARAM_NAMES) - set(OMNY_DEFAULTS) + assert not missing, f"omny profile is missing defaults for: {missing}" + + +def test_omny_offset_fields_empty(): + """OMNY has no alignment-offset concept in this widget (unlike lamni's + tomo_fovx_offset/tomo_fovy_offset) -- confirm the profile reflects that + rather than silently inheriting lamni's fields.""" + assert SETUP_PROFILES["omny"]["offset_fields"] == [] + assert SETUP_PROFILES["omny"]["offset_getter"] is None + assert SETUP_PROFILES["omny"]["offset_setter"] is None + + +def test_omny_field_order_matches_flomni_minus_omny_only_gaps(): + """flomni's own field_order minus frames_per_trigger/single_point -- + OMNY has neither property.""" + assert SETUP_PROFILES["omny"]["field_order"] == [ + "tomo_countingtime", + "tomo_shellstep", + "fov", + "stitch", + "tomo_stitch_overlap", + "ptycho_reconstruct_foldername", + "manual_shift", + "at_each_angle_hook", + ] + + +def test_omny_has_no_180_mode_single_point_or_zero_deg_reference(): + profile = SETUP_PROFILES["omny"] + assert profile["has_180_mode"] is False + assert profile["has_single_point"] is False + assert profile["has_zero_deg_reference"] is False + + +def test_compute_fermat_positions_omny_matches_scan_class_below_threshold(): + params = {"fovx": 2.0, "fovy": 2.0, "tomo_shellstep": 2.0} + count, min_positions = _compute_fermat_positions_omny(params) + assert min_positions == OmnyFermatScan.MIN_POSITIONS + assert count < min_positions + expected = OmnyFermatScan.get_omny_fermat_spiral_pos( + -1.0, 1.0, -1.0, 1.0, step=2.0, spiral_type=0 + ) + assert count == len(expected) + + +def test_compute_fermat_positions_omny_matches_scan_class_above_threshold(): + params = {"fovx": 20.0, "fovy": 20.0, "tomo_shellstep": 1.0} + count, min_positions = _compute_fermat_positions_omny(params) + assert count >= min_positions + + +def test_compute_fermat_positions_omny_zero_step_is_safe(): + count, min_positions = _compute_fermat_positions_omny( + {"fovx": 5.0, "fovy": 5.0, "tomo_shellstep": 0.0} + ) + assert count == 0 + assert min_positions == OmnyFermatScan.MIN_POSITIONS + + +# --- _format_projections(): 3-way setup dispatch, including OMNY types 4/5 -- + + +def test_format_projections_dispatches_omny_job_types_1_4_5(): + for tomo_type, n_subtomos in OMNY_TYPES_N_SUBTOMOS.items(): + params = {"tomo_type": tomo_type, "tomo_angle_stepsize": 10.0, "fovx": 20.0, "fovy": 20.0} + N = int(180.0 / 10.0) + assert _format_projections(params) == str(N * n_subtomos) + + +def test_format_projections_omny_golden_types_unaffected(): + params = {"tomo_type": 2, "fovx": 20.0, "golden_max_number_of_projections": 500} + assert _format_projections(params) == "500" + + params_infinite = {"tomo_type": 2, "fovx": 20.0, "golden_max_number_of_projections": 0} + assert _format_projections(params_infinite) == "∞" + + +def test_format_projections_still_distinguishes_flomni_and_lamni_jobs(): + """Regression guard: adding the omny branch must not break the existing + flomni/lamni duck-typing.""" + flomni_params = {"tomo_type": 1, "tomo_angle_range": 180, "tomo_angle_stepsize": 10.0} + lamni_params = {"tomo_type": 1, "tomo_angle_stepsize": 10.0} # no tomo_angle_range, no fovx + + flomni_N = int(180.0 / 10.0) + assert _format_projections(flomni_params) == str(flomni_N * 8) + + lamni_N = int(360.0 / 10.0) + assert _format_projections(lamni_params) == str(lamni_N * 8) + + +# --- _validate()'s tomo_type check: regression guard for the critical fix -- + + +def test_validate_tomo_type_condition_accepts_omny_types_4_and_5(): + """_validate() used to hardcode `tomo_type not in (1, 2, 3)`, which would + silently reject every OMNY type-4/5 submission even though the rest of + the UI (combo box, section visibility) would appear to work. Tested here + as a standalone expression against the profile data (mirroring + _validate()'s post-fix condition) rather than requiring a live QWidget + instance.""" + tomo_types = SETUP_PROFILES["omny"]["tomo_types"] + for tomo_type in (1, 2, 3, 4, 5): + assert tomo_type in tomo_types, f"tomo_type {tomo_type} should be valid for omny" + for invalid in (0, 6, 99, None): + assert invalid not in tomo_types, f"tomo_type {invalid} should be rejected for omny"