From 78f0c8fa3bce14ff37185ad4c0f3939b8da162dc Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 3 Sep 2026 16:25:07 +0200 Subject: [PATCH] feat(omny): port zero_deg_reference_at_each_subtomo to types 1/4/5 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 Claude-Session: https://claude.ai/code/session_01QLrD7sVYGLAzsQjLVJpCgt --- .../plugins/omny/AI_docs/OPEN_ISSUES.md | 69 +++++++++++++++++++ .../omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md | 11 +++ .../bec_ipython_client/plugins/omny/omny.py | 68 +++++++++++++++++- .../widgets/tomo_params/tomo_params.py | 25 +++++-- .../test_omny_tomo_params_widget_math.py | 23 ++++++- .../test_omny_tomo_queue.py | 9 ++- .../test_omny_tomo_scan.py | 61 ++++++++++++++++ 7 files changed, 252 insertions(+), 14 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md index 5b1ed802..ec4fca15 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/OPEN_ISSUES.md @@ -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- diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md index 1ec26493..69de97aa 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md +++ b/csaxs_bec/bec_ipython_client/plugins/omny/AI_docs/TOMO_SCAN_TEST_CHECKLIST.md @@ -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 diff --git a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py index fbc4f402..10ef4a1d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/omny/omny.py +++ b/csaxs_bec/bec_ipython_client/plugins/omny/omny.py @@ -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) diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index df9cc913..4df22863 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -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), diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py index f39de1bd..f5b2796e 100644 --- a/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py +++ b/tests/tests_bec_ipython_client/test_omny_tomo_params_widget_math.py @@ -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 -- diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py index 7627cd53..736a54b2 100644 --- a/tests/tests_bec_ipython_client/test_omny_tomo_queue.py +++ b/tests/tests_bec_ipython_client/test_omny_tomo_queue.py @@ -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 diff --git a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py index 80a11719..fd3b7638 100644 --- a/tests/tests_bec_ipython_client/test_omny_tomo_scan.py +++ b/tests/tests_bec_ipython_client/test_omny_tomo_scan.py @@ -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