feat(omny): add OMNY support to OMNY_TomoParams widget (Phase 2 of 2)
CI for csaxs_bec / test (push) Failing after 5s
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:
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"
|
||||
Reference in new issue
Block a user