feat(omny): port zero_deg_reference_at_each_subtomo to types 1/4/5
CI for csaxs_bec / test (push) Successful in 2m35s

flomni already has this (type-1-only: a dedicated projection at exactly
0 degrees before every odd/forward sub-tomogram, plus once more at the
end, for tracking radiation damage over a tomogram). Deliberately
excluded from OMNY during the Phase 1 port as flomni-specific; Mirko
asked for it to be extended to OMNY's generalized equally-spaced types
(1/4/5, 2/4/8 sub-tomograms), and confirmed the pre-shot should fire
even for sub-tomogram 1 (whose own first projection already lands on 0)
to get two consecutive 0-degree shots as an immediate baseline pair.

Also fixes a second hardcoded `tomo_type != 1` bug in the widget's
_irrelevant_params_for_type() (same class as the earlier _validate()
fix) that would have hidden this setting from OMNY type-4/5 job
tooltips even when enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt
This commit is contained in:
x01dc
2026-09-03 16:25:07 +02:00
co-authored by Claude Sonnet 5
parent 70696756f1
commit 78f0c8fa3b
7 changed files with 252 additions and 14 deletions
@@ -636,6 +636,75 @@ used it here too (there's existing precedent for this exact pattern in GUI code,
records its `cached` kwarg -- confirms both that the getter only touches the signal and that it
passes `cached=True`).
## zero_deg_reference_at_each_subtomo ported to OMNY's equally spaced types (1/4/5)
Mirko: "one thing flomni has and we could implement here as well is the repeated projection when
the stage is close to 0 anyways... for the tomo types 1,4 and 5 it could also be useful. in tomo
type 1 it would of course only mean that at the end of the measurement one additional projection
will be recorded." This flag was deliberately excluded from OMNY's `_TOMO_SCAN_PARAM_NAMES` during
the Phase 1 port as a "flomni-only concept" (flomni's version is type-1-only, tied to its fixed
8-sub-tomogram/180-vs-360-mode scheme) -- this reverses that call for OMNY's generalized 1/4/5
equally-spaced types specifically, it's not a blind re-add.
Verified against `_equally_spaced_subtomo_angles()`'s bit-reversal interleaving: odd (forward)
sub-tomograms always start near angle 0 for any `n_subtomos` (2/4/8) -- exactly flomni's 180-mode
rule (`subtomo_number % 2 == 1`), no 360-mode branch needed since OMNY only ever scans 180deg.
Sub-tomogram 1 specifically always starts at *exactly* 0.0 (phase offset 0 by construction,
regardless of tomo_type); the other odd sub-tomograms (3/5/7, types 4/5 only) start at a small but
*nonzero* phase offset -- genuinely new angles near 0, not exact duplicates.
One design question flomni's own code doesn't answer for OMNY: firing the pre-shot before
sub-tomogram 1 too would duplicate its own first projection (both exactly angle 0, back to back).
Asked Mirko directly -- answer: "it will be very helpful to start with two projections at zero
degrees" -- so the pre-shot fires before every odd sub-tomogram including 1, deliberately
producing two consecutive 0-degree shots at the very start (useful as an immediate damage/noise-
floor baseline pair), matching flomni's own ported logic exactly rather than diverging from it.
**Implementation** (`omny.py`): new `zero_deg_reference_at_each_subtomo` property (mirrors
flomni's of the same name), added to `_TOMO_SCAN_PARAM_NAMES` (comment above the tuple updated --
this is now the one flomni-adjacent concept OMNY *does* carry, unlike `tomo_angle_range`/
`single_point_*`/`frames_per_trigger`, which still don't apply). `tomo_scan()`'s type-1-like loop:
before each odd sub-tomogram (`ii % 2 == 1`), fires `self._tomo_scan_at_angle(0, ii)` -- guarded by
`start_angle is None` so a resume mid-sub-tomogram doesn't force a detour through 0 it isn't
actually passing through -- and once more after the whole loop finishes, for the final before/after
bookend shot. `tomo_parameters()` gained a matching print (current-settings summary) and a
`self.OMNYTools.yesno(...)` prompt (edit mode), gated by `self.tomo_type in self._TOMO_TYPE_1_LIKE`.
**Widget** (`tomo_params.py`): `SETUP_PROFILES["omny"]["has_zero_deg_reference"]` flipped
`False` -> `True` -- the checkbox itself (`_build_type1_section()`'s `if self._profile[
"has_zero_deg_reference"]: self._add_bool(...)`) was already fully generic, needing no new UI
code. Added `zero_deg_reference_at_each_subtomo` to `OMNY_QUEUE_PARAM_NAMES`/`OMNY_DEFAULTS`
(default `False`). **Second hardcoded-`!= 1` bug caught while wiring this up** (same class of bug
as `_validate()`'s Phase 2 fix): `_irrelevant_params_for_type()` (used by `_job_tooltip()` to hide
inapplicable fields) had `if tomo_type != 1: irrelevant |= _TYPE1_ONLY_PARAMS` -- would have hidden
this setting from every OMNY type-4/5 job's tooltip even when genuinely enabled. Fixed to
`tomo_type not in (1, 4, 5)` (safe without profile context: 4/5 are OMNY-only tomo_type values,
flomni/lamni jobs never have them).
Regression tests: `test_tomo_scan_zero_deg_reference_disabled_by_default_no_extra_shots`,
`..._type1_fires_before_subtomo1_and_after_subtomo2`, `..._type5_fires_before_every_odd_subtomo`,
`..._skipped_only_for_the_resumed_subtomo` (`test_omny_tomo_scan.py`, spy on `_tomo_scan_at_angle`
with `sub_tomo_scan` stubbed out); `test_irrelevant_params_for_type_keeps_zero_deg_reference_for_omny_types_4_and_5`
(`test_omny_tomo_params_widget_math.py`); updated the pre-existing "no flomni-only concepts" test
(`test_omny_tomo_queue.py`) and the pre-existing `has_zero_deg_reference is False` test (now
`True`), both of which would otherwise have failed against this deliberate reversal. Full suite:
770 passed.
**Deferred, needs Mirko's hardware knowledge, not implemented**: Mirko also asked for flomni's
`collect_empty_frames()` (acquires 10 flat-field/empty frames at angle 0 with the sample shifted
out of the beam by fovx/2, called once at the start of every new tomo_scan()) to be ported to
OMNY. Checked flomni's implementation (`flomni.py:2570`) -- it's built entirely on flomni's fine-
piezo/laser-tracker stack (`dev.rtx`/`rty`/`rtz`, `laser_tracker_check_signalstrength()`,
`move_samx_to_scan_region()`), none of which OMNY has. OMNY's own alignment offsets
(`omny_alignment_mixin.py`'s `get_alignment_offset()`) are a pure sinusoidal-fit computation
folded directly into `cenx`/`ceny` passed to `scans.omny_fermat_scan()` -- there's no separate
fine-stage motor to move at all, and no `scans.acquire()` (or equivalent stationary-multi-frame-
burst call) used anywhere in OMNY's plugin code to crib from. A literal port isn't possible without
knowing: (1) what OMNY-side call performs a stationary multi-frame acquisition (does one exist?),
and (2) whether "shift out of the beam" for OMNY means moving `osamx` directly (it's a real,
movable coarse stage, confirmed) or just passing a deliberately offset `cenx` into
`omny_fermat_scan()`. Not guessed at -- next step is asking Mirko for these two specifics.
## Environment note (not code, but will bite again if forgotten)
Both `csaxs_bec` and `bec_widgets` were pip-installed editable pointing at pre-repo-
@@ -45,6 +45,17 @@ the broader OMNY live-testing effort this is part of.
setting parameters via `tomo_parameters()`.
- [ ] A real full scan for type 4 and type 5 (4/8 sub-tomograms).
## zero_deg_reference_at_each_subtomo (new, never tested live)
- [ ] `tomo_parameters()`: enable it for tomo_type 1 -- confirm the prompt/summary text and that a
real `tomo_scan()` records two projections at angle 0 right at the start (subtomo 1's own
first projection plus the deliberate duplicate) and one more at the very end.
- [ ] Same for tomo_type 4 or 5 -- confirm extra 0-degree shots also appear before sub-tomograms
3/5/7 (not just 1), each at a distinct (nonzero) angle from 0, not a duplicate.
- [ ] Widget: the "0° reference each sub-tomo" checkbox now appears for OMNY (previously
flomni/lamni only) -- toggle it for a type-4/5 job specifically and check the queued job's
tooltip shows it (regression guard for a hardcoded `!= 1` bug caught while wiring this up).
## Smaller fixes
- [ ] `tomography_scannumbers.txt` and the PDF report land under `~/data/raw/...` (not the old
@@ -654,8 +654,10 @@ class OMNY(
# Property names a tomo_queue_add() job snapshots (TomoQueueMixin
# contract). Mirrors flomni's own tuple exactly, since OMNY's properties
# already mirror flomni's names 1:1 -- no tomo_angle_range/single_point_*/
# zero_deg_reference_at_each_subtomo/frames_per_trigger, since OMNY has
# no such properties.
# frames_per_trigger, since OMNY has no such properties.
# zero_deg_reference_at_each_subtomo *is* included (unlike the above) --
# it applies to any equally spaced tomo type, not just type 1, see
# tomo_scan()'s type-1-like loop and its own property docstring below.
_TOMO_SCAN_PARAM_NAMES = (
"tomo_countingtime",
"tomo_shellstep",
@@ -671,6 +673,7 @@ class OMNY(
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
"zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -1051,6 +1054,30 @@ class OMNY(
def golden_projections_at_0_deg_for_damage_estimation(self, val: float):
self.client.set_global_var("golden_projections_at_0_deg_for_damage_estimation", val)
@property
def zero_deg_reference_at_each_subtomo(self):
"""If True (tomo_type in _TOMO_TYPE_1_LIKE, i.e. 1/4/5 -- the equally
spaced sub-tomogram modes), an additional projection at exactly 0
degrees is acquired at the start of every odd (forward) sub-tomogram
-- i.e. every time the rotation passes back through 0 degrees,
including sub-tomogram 1 itself, which already lands on 0 degrees
naturally -- and once more after the final sub-tomogram completes.
Together with sub-tomogram 1's own first projection, this gives two
consecutive 0-degree shots right at the start (deliberate: useful as
an immediate damage/noise-floor baseline pair) and one more at every
later natural pass through 0, useful for tracking radiation damage
over time. Mirrors flomni's property of the same name, and
golden_projections_at_0_deg_for_damage_estimation, which provides
similar functionality for tomo_type 2/3."""
val = self.client.get_global_var("zero_deg_reference_at_each_subtomo")
if val is None:
return False
return val
@zero_deg_reference_at_each_subtomo.setter
def zero_deg_reference_at_each_subtomo(self, val: bool):
self.client.set_global_var("zero_deg_reference_at_each_subtomo", val)
@property
def golden_ratio_bunch_size(self):
val = self.client.get_global_var("golden_ratio_bunch_size")
@@ -1354,9 +1381,34 @@ class OMNY(
n_subtomos = {1: 2, 4: 4, 5: 8}[self.tomo_type]
self.progress["tomo_type"] = f"Equally spaced sub-tomograms ({n_subtomos})"
for ii in range(subtomo_start, n_subtomos + 1):
if (
start_angle is None
and ii % 2 == 1
and self.zero_deg_reference_at_each_subtomo
):
# Dedicated reference shot at exactly 0 degrees, taken
# every time the rotation passes back through 0
# (every odd/forward sub-tomogram -- see
# _equally_spaced_subtomo_angles()), for tracking
# radiation damage over the full tomogram. Fires
# for sub-tomogram 1 too even though its own first
# projection already lands on 0 -- two consecutive
# 0-degree shots right at the start is deliberate
# (immediate baseline pair), not skipped as
# redundant. Skipped when resuming mid-sub-tomogram
# (start_angle given explicitly) since we're not
# actually passing through 0 deg at that moment.
self._tomo_scan_at_angle(0, ii)
self.sub_tomo_scan(ii, n_subtomos=n_subtomos, start_angle=start_angle)
start_angle = None
if self.zero_deg_reference_at_each_subtomo:
# Final reference shot at exactly 0 degrees once the whole
# tomogram is complete, giving a clean "before vs after"
# pair for radiation-damage comparison across the full
# acquisition.
self._tomo_scan_at_angle(0, n_subtomos)
elif self.tomo_type == 2:
# Golden ratio tomography
previous_subtomo_number = -1
@@ -1889,6 +1941,11 @@ class OMNY(
)
print(f"Total number of projections: {180/self.tomo_angle_stepsize*n_subtomos}")
print(f"Angular step within sub-tomogram: {self.tomo_angle_stepsize} degrees")
if self.tomo_type in self._TOMO_TYPE_1_LIKE and self.zero_deg_reference_at_each_subtomo:
print(
"Repeating a reference projection at 0 degrees at the start of every forward"
" sub-tomogram, plus once more at the end."
)
print(f"\nSample name: {self.sample_name}\n")
if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
@@ -1994,6 +2051,13 @@ class OMNY(
self.tomo_angle_stepsize = 180 / tomo_numberofprojections * n_subtomos
print(f"The angular step in a subtomogram it will be {self.tomo_angle_stepsize}")
if self.tomo_type in self._TOMO_TYPE_1_LIKE:
self.zero_deg_reference_at_each_subtomo = self.OMNYTools.yesno(
"Repeat a reference projection at 0 degrees at the start of every forward"
" sub-tomogram (plus once more at the end)?",
"y" if self.zero_deg_reference_at_each_subtomo else "n",
)
@staticmethod
def _get_val(msg: str, default_value, data_type):
return data_type(input(f"{msg} ({default_value}): ") or default_value)
@@ -80,11 +80,13 @@ logger = bec_logger.logger
# ── constants ────────────────────────────────────────────────────────────────
# 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
# _job_tooltip() so a queued job's hover details don't show fields that are
# meaningless for its own tomo_type (e.g. golden-ratio settings on a type-1
# job), on both flomni and lamni.
# for every equally spaced sub-tomogram type -- 1 for flomni/lamni, 1/4/5 for
# OMNY --, golden_ratio_bunch_size shown only for type 2 within _sec_type23,
# the rest of _sec_type23 shown for type 2/3) -- reused by _job_tooltip() so
# a queued job's hover details don't show fields that are meaningless for
# its own tomo_type (e.g. golden-ratio settings on a type-1 job).
# tomo_angle_range is flomni-only regardless (OMNY has no such property, so
# it's simply never present in an OMNY job's params to begin with).
_TYPE1_ONLY_PARAMS = {"tomo_angle_range", "zero_deg_reference_at_each_subtomo"}
_TYPE2_ONLY_PARAMS = {"golden_ratio_bunch_size"}
_TYPE23_PARAMS = {
@@ -97,7 +99,11 @@ def _irrelevant_params_for_type(tomo_type: Any) -> set[str]:
"""Param names that don't apply to a job's own tomo_type and should be
hidden from its tooltip."""
irrelevant: set[str] = set()
if tomo_type != 1:
# tomo_type 1 for flomni/lamni; 1/4/5 (all equally spaced sub-tomogram
# flavors) for OMNY -- 4/5 are OMNY-only tomo_type values (flomni/lamni
# jobs never have them), so this is safe without knowing which setup a
# given job's params belong to.
if tomo_type not in (1, 4, 5):
irrelevant |= _TYPE1_ONLY_PARAMS
if tomo_type not in (2, 3):
irrelevant |= _TYPE23_PARAMS
@@ -240,6 +246,7 @@ OMNY_QUEUE_PARAM_NAMES = (
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
"zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -260,6 +267,7 @@ OMNY_DEFAULTS: dict[str, Any] = {
"golden_ratio_bunch_size": 20,
"golden_max_number_of_projections": 1000.0,
"golden_projections_at_0_deg_for_damage_estimation": 0,
"zero_deg_reference_at_each_subtomo": False,
"corridor_size": -1,
"at_each_angle_hook": None,
}
@@ -2525,7 +2533,10 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"defaults": OMNY_DEFAULTS,
"has_180_mode": False,
"has_single_point": False,
"has_zero_deg_reference": False,
# Applies to any equally spaced tomo type here (1/4/5), not just
# type 1 -- see omny.py's zero_deg_reference_at_each_subtomo
# property and tomo_scan()'s type-1-like loop.
"has_zero_deg_reference": True,
"fov_fields": [
("fovx", "FOV x (µm)", 0.1, 200.0, 2),
("fovy", "FOV y (µm)", 0.1, 100.0, 2),
@@ -20,6 +20,7 @@ from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
_compute_type1,
_compute_type1_omny,
_format_projections,
_irrelevant_params_for_type,
_lamni_compute_type1,
_requested_to_stepsize,
_requested_to_stepsize_omny,
@@ -119,11 +120,15 @@ def test_omny_field_order_matches_flomni_minus_omny_only_gaps():
]
def test_omny_has_no_180_mode_single_point_or_zero_deg_reference():
def test_omny_has_no_180_mode_or_single_point():
"""has_zero_deg_reference IS True for OMNY (unlike 180-mode/single-point,
which genuinely don't exist here): zero_deg_reference_at_each_subtomo
applies to any equally spaced tomo type, not just type 1 -- see
omny.py's own property docstring and tomo_scan()'s type-1-like loop."""
profile = SETUP_PROFILES["omny"]
assert profile["has_180_mode"] is False
assert profile["has_single_point"] is False
assert profile["has_zero_deg_reference"] is False
assert profile["has_zero_deg_reference"] is True
def test_compute_fermat_positions_omny_matches_scan_class_below_threshold():
@@ -182,6 +187,20 @@ def test_format_projections_still_distinguishes_flomni_and_lamni_jobs():
assert _format_projections(lamni_params) == str(lamni_N * 8)
# --- _irrelevant_params_for_type(): regression guard for the type-4/5 gap --
def test_irrelevant_params_for_type_keeps_zero_deg_reference_for_omny_types_4_and_5():
"""_irrelevant_params_for_type() used to hardcode `tomo_type != 1`, which
would hide zero_deg_reference_at_each_subtomo from an OMNY type-4/5 job's
tooltip even though it's a real, applicable setting for those types too
(only flomni/lamni are limited to type 1) -- fixed to `not in (1, 4, 5)`."""
for tomo_type in (1, 4, 5):
assert "zero_deg_reference_at_each_subtomo" not in _irrelevant_params_for_type(tomo_type)
for tomo_type in (2, 3):
assert "zero_deg_reference_at_each_subtomo" in _irrelevant_params_for_type(tomo_type)
# --- _validate()'s tomo_type check: regression guard for the critical fix --
@@ -58,14 +58,17 @@ def make_omny(fake_client=None):
def test_tomo_scan_param_names_have_no_flomni_only_concepts():
"""OMNY mirrors flomni's names for shared concepts, but has no
180/360 toggle, no single-point acquisition, no zero-deg reference,
and no frames_per_trigger -- none of these exist as OMNY properties."""
180/360 toggle, no single-point acquisition, and no frames_per_trigger --
none of these exist as OMNY properties. zero_deg_reference_at_each_subtomo
is a deliberate exception: it applies to any equally spaced tomo type
(1/4/5 for OMNY, not just type 1), so unlike the others it *is* present --
see tomo_scan()'s type-1-like loop and the property's own docstring."""
names = OMNY._TOMO_SCAN_PARAM_NAMES
assert "tomo_angle_range" not in names
assert "single_point_instead_of_fermat_scan" not in names
assert "single_point_random_shift_max" not in names
assert "zero_deg_reference_at_each_subtomo" not in names
assert "frames_per_trigger" not in names
assert "zero_deg_reference_at_each_subtomo" in names
# shared-with-flomni names are present, using OMNY's own property names
assert "fovx" in names
assert "stitch_x" in names
@@ -147,6 +147,67 @@ def test_tomo_scan_accepts_interactive_kwarg(monkeypatch):
assert omny.progress["tomo_start_time"] is not None
def test_tomo_scan_zero_deg_reference_disabled_by_default_no_extra_shots(monkeypatch):
"""zero_deg_reference_at_each_subtomo defaults to False -- no extra
_tomo_scan_at_angle(0, ...) calls beyond whatever sub_tomo_scan() does
on its own (stubbed to a no-op here, so none at all)."""
omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
calls = []
omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
omny.tomo_scan()
assert calls == []
def test_tomo_scan_zero_deg_reference_type1_fires_before_subtomo1_and_after_subtomo2(monkeypatch):
"""Type 1 (n_subtomos=2): a pre-shot before sub-tomogram 1 (even though
its own first projection already lands on 0 -- two consecutive 0-degree
shots right at the start is deliberate) and a final shot after
sub-tomogram 2, giving exactly one extra projection at the end plus one
at the very start for this type."""
omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
omny.tomo_type = 1
omny.zero_deg_reference_at_each_subtomo = True
calls = []
omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
omny.tomo_scan()
assert calls == [(0, 1), (0, 2)]
def test_tomo_scan_zero_deg_reference_type5_fires_before_every_odd_subtomo(monkeypatch):
"""Type 5 (n_subtomos=8): a pre-shot before every odd/forward
sub-tomogram (1, 3, 5, 7), plus the final shot after sub-tomogram 8."""
omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
omny.tomo_type = 5
omny.zero_deg_reference_at_each_subtomo = True
calls = []
omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
omny.tomo_scan()
assert calls == [(0, 1), (0, 3), (0, 5), (0, 7), (0, 8)]
def test_tomo_scan_zero_deg_reference_skipped_only_for_the_resumed_subtomo(monkeypatch):
"""Resuming mid-sub-tomogram (start_angle given explicitly) must skip the
pre-shot only for that specific sub-tomogram -- we aren't actually
passing through 0 deg at that moment -- but later odd sub-tomograms in
the same tomo_scan() call still get theirs, and the final shot is
unaffected either way."""
omny = make_omny_for_tomo_scan(monkeypatch, 45.0, active_account="")
omny.tomo_type = 5
omny.zero_deg_reference_at_each_subtomo = True
calls = []
omny._tomo_scan_at_angle = lambda angle, subtomo_number: calls.append((angle, subtomo_number))
omny.tomo_scan(subtomo_start=3, start_angle=12.5)
assert calls == [(0, 5), (0, 7), (0, 8)]
def test_tomo_scan_clears_heartbeat_on_normal_completion(monkeypatch):
"""The busy-detector heartbeat must be cleared as soon as the scan
finishes, not left for the next poll to time out before the GUI stops