feat(omny): add OMNY support to OMNY_TomoParams widget (Phase 2 of 2)
CI for csaxs_bec / test (push) Failing after 5s

Mirko, once Phase 1 (tomo-queue backend) was live-confirmed working: "is
the way to move to the widget phase" -- this adds OMNY as a third setup to
the tomo params GUI widget, alongside flomni/lamni.

Key design decision: replaced the module-level TOMO_TYPES dict (shared
across setups, couldn't express "type 1 means 2 sub-tomograms here but 8
there") with a per-profile "tomo_types" dict:
{tomo_type_int: {"label": str, "n_subtomos": int | None}}. n_subtomos is
not None became the single, uniform test for "is this an equally-spaced
type" everywhere (combo box, section visibility, validation, queue-table
projections display), since OMNY has three such flavors (types 1/4/5 --
2/4/8 sub-tomograms) instead of flomni's/lamni's one (always 8).

Critical bug caught by verifying the design against the live file, not by
the initial design itself: _validate() hardcoded
`tomo_type not in (1, 2, 3)` -- would have made the rest of Phase 2 look
like it worked (types 4/5 selectable, sections visible, math correct)
while silently rejecting every actual submit/queue attempt for an OMNY
type-4/5 job. Fixed to check profile membership instead. Also fixed three
UI strings that hardcoded "8 equal sub-tomograms" to read the active
type's real count.

New _compute_type1_omny/_requested_to_stepsize_omny (parameterized by
tomo_type via {1:2,4:4,5:8}, matching
OMNY._equally_spaced_subtomo_angles()'s own mapping exactly) and
_compute_fermat_positions_omny (calls OmnyFermatScan's real static
method, same pattern as flomni's). _format_projections() gained a 3-way
setup duck-type (OMNY's param set is a strict subset of flomni's -- no
positively-unique OMNY-only key exists, so a negative-then-positive check
is necessary) that resolves into SETUP_PROFILES rather than duplicating
the type-count mapping a third time.

Confirmed needing zero changes (verified, not assumed): _job_tooltip(),
_build_type23_section(), compute_fermat_positions call sites,
_detect_setup() (osamroy as OMNY's discriminator), the busy-banner
detection methods (already generic via the shared tomo_progress global
var from Phase 1), and the Qt Designer plugin registration files.

New test_omny_tomo_params_widget_math.py (29 pure-function tests, no
QApplication/QWidget, same style as the existing flomni/lamni test
files). Full suite: 761 passed. Not live-tested (needs a real display,
same as every GUI change this branch) -- next step is Mirko trying it
against a real OMNY session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
This commit is contained in:
x01dcandClaude Sonnet 5 committed 2026-09-03 03:28:08 +02:00
1 parent 127b35565b
commit 28eac31ab0
3 files changed
+532 -50

No files matched your search

@@ -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-
@@ -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):
@@ -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"