From 900c81011103299084ef49faaec3295065e0e251 Mon Sep 17 00:00:00 2001 From: x01dc Date: Wed, 15 Jul 2026 07:28:24 +0200 Subject: [PATCH] fix(flomni): use complementary phases for 360deg tomo, not adjacent pairs The previous commit's 360-degree phase assignment gave the low and high halves the SAME phase per pair (1,2)/(3,4)/(5,6)/(7,8), so each pair concatenated into one continuous evenly-spaced range -- but that means every low-half angle is exactly 180deg from a high-half angle, which is exactly the redundant-measurement bug this was supposed to fix, just reintroduced between subtomos instead of within one. Corrected: the low half (subtomos 1,4,5,8) now uses the even eighths of the original 8-way phase_eighths table, and the high half (2,3,6,7) uses the odd eighths -- complementary, not shared. Folded mod 180, the two halves interleave into exactly the same 8-way, step/8 grid that 180-mode itself produces: every position is measured exactly once, using its full 0-360 physical range, with zero redundant measurements anywhere. Total projection count is unchanged (still identical to 180 mode for a given tomo_angle_stepsize). Verified: pure-math unit tests confirm the 360-mode combined set, folded mod 180, is an exact set match (same count, no repeated residue) against 180-mode's own set. Live-verified against the running flomni sim: real motor motion for both modes, folding the observed 360-mode angles mod 180 exactly reproduces the observed 180-mode angles. Also updated the two "angular step of the final combined tomogram" CLI/parameter-wizard print statements to additionally report the effective mod-180 reconstruction resolution for 360 mode, since it's now finer than the raw per-half spacing they already printed. Co-Authored-By: Claude Sonnet 5 --- .../plugins/flomni/flomni.py | 56 +++++++++++---- .../test_flomni_tomo_angles.py | 72 +++++++++---------- 2 files changed, 77 insertions(+), 51 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 3ffa694..2db5a09 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2317,17 +2317,25 @@ class Flomni( the original scheme). In 360 mode each sub-tomogram instead covers only a 180-degree span - never the full circle - split by subtomo_number % 4 into a "low" half [0,180) (n%4 in (1,0)) and a - "high" half [180,360) (n%4 in (2,3)), with the 4 sub-tomograms - serving each half interlaced via a bit-reversal of {0,1,2,3} - (not sequential order): this specific permutation is what makes - adjacent pairs (1,2)/(3,4)/(5,6)/(7,8), either quartet (1-4 or - 5-8), and all 8 combined each independently form a complete, - evenly-spaced 360-degree tomogram at successively finer spacing - (step, step/2, step/4) - a sequential assignment would leave gaps - in the quartet-level grids. Without this split, every + "high" half [180,360) (n%4 in (2,3)). Without this split, every sub-tomogram's own 360-degree sweep would contain angle pairs - exactly 180 degrees apart, which is redundant tomographic - information. + exactly 180 degrees apart - redundant tomographic information, + since a measurement 180 degrees from one already taken + contributes no new information. + + Critically, the low half and high half use COMPLEMENTARY halves + of the original 8-way phase_eighths table (low: the even eighths + {0,2,4,6}; high: the odd eighths {1,3,5,7}), not the same phases + shifted by 180 - using the same phases would make every low-half + angle exactly 180 degrees from a high-half angle, reintroducing + the redundancy this scheme exists to remove. With complementary + phases, low and high each independently interlace their own half + at spacing step/4, but together - once the high half is folded + mod 180 - they reconstruct the full 8-way, step/8 grid with every + position hit EXACTLY once: the combined 360-degree angle set is + mod-180-identical to what an equivalent 180-degree scan would + produce, at the same total projection count, with zero redundant + measurements anywhere. Returns: angles (np.ndarray): the N angles (degrees) for this sub-tomogram. @@ -2355,10 +2363,14 @@ class Flomni( phase = step / 8.0 * phase_eighths[subtomo_number] forward = bool(subtomo_number % 2) else: - quarter = {1: 0, 4: 2, 5: 1, 8: 3, 2: 0, 3: 2, 6: 1, 7: 3} + # Low half (subtomos 1,4,5,8): even eighths. High half + # (subtomos 2,3,6,7): odd eighths. Complementary, not shared - + # see docstring above for why that's required to avoid + # redundant 180-degrees-apart measurements. + eighths = {1: 0, 4: 4, 5: 2, 8: 6, 2: 1, 3: 5, 6: 3, 7: 7} mod4 = subtomo_number % 4 base = 0.0 if mod4 in (1, 0) else 180.0 - phase = step / 4.0 * quarter[subtomo_number] + phase = step / 8.0 * eighths[subtomo_number] forward = mod4 in (1, 2) if not explicit_start_angle: @@ -3635,6 +3647,20 @@ class Flomni( "Angular step of the final (combined) tomogram:" f" {self.tomo_angle_range / total_projections:.3f} degrees" ) + if self.tomo_angle_range == 360: + # The line above is the raw physical spacing within each + # 180-degree half (each half is only 4-way interlaced on + # its own). The two halves use complementary, not shared, + # phases (see _subtomo_angle_plan()'s docstring), so mod + # 180 they combine into a grid twice as fine, with zero + # redundant measurements - identical to what an equivalent + # 180-degree scan at the same total projection count would + # produce. + print( + "Effective (mod-180) reconstruction resolution:" + f" {180.0 / total_projections:.3f} degrees" + " (halves are complementary, not redundant)" + ) print( "0-deg reference shots (odd sub-tomo start + end) =" f" {self.zero_deg_reference_at_each_subtomo}" @@ -3762,6 +3788,12 @@ class Flomni( "The angular step of the final (combined) tomogram will be" f" {self.tomo_angle_range / actual_total:.3f} degrees" ) + if self.tomo_angle_range == 360: + print( + "The effective (mod-180) reconstruction resolution will be" + f" {180.0 / actual_total:.3f} degrees" + " (halves are complementary, not redundant)" + ) self.zero_deg_reference_at_each_subtomo = bool( self._get_val( "Take 0-deg reference shots (start of each odd sub-tomo + end) for" diff --git a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py index c21fc06..1c26ead 100644 --- a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py @@ -33,8 +33,8 @@ def test_180_mode_regression(stepsize): @pytest.mark.parametrize("stepsize", STEPSIZES) def test_360_mode_no_duplicates_and_no_internal_180_pairs(stepsize): """360-degree mode: no sub-tomogram may, by itself, contain two angles - exactly 180 degrees apart (that redundancy was the bug), and the - combined 8-sub-tomogram set must contain no duplicate angles.""" + exactly 180 degrees apart (that redundancy was the original bug), and + the combined 8-sub-tomogram set must contain no duplicate angles.""" all_angles = [] for n in range(1, 9): angles, offset, N, step = plan(n, 360, stepsize) @@ -50,6 +50,19 @@ def test_360_mode_no_duplicates_and_no_internal_180_pairs(stepsize): assert len(combined) == len(np.unique(np.round(combined, 6))) +@pytest.mark.parametrize("stepsize", STEPSIZES) +def test_no_angle_pair_360_degrees_apart_anywhere(stepsize): + """No two of the 8 sub-tomograms' combined 360-degree angles may be + exactly 180 degrees apart from each other either -- a measurement + 180 degrees from one already taken carries no new information, and + this must hold across the WHOLE combined set, not just within a + single sub-tomogram.""" + combined = np.concatenate([plan(n, 360, stepsize)[0] for n in range(1, 9)]) + setc = set(np.round(combined, 6)) + for a in setc: + assert round((a + 180) % 360, 6) not in setc + + @pytest.mark.parametrize("stepsize", STEPSIZES) def test_total_projection_count_identical_180_vs_360(stepsize): """Total projections for a given tomo_angle_stepsize must be the same @@ -60,44 +73,25 @@ def test_total_projection_count_identical_180_vs_360(stepsize): @pytest.mark.parametrize("stepsize", STEPSIZES) -def test_360_mode_full_grid_evenly_spaced(stepsize): - """All 8 sub-tomograms combined must be a complete, evenly-spaced grid - covering the full [0,360) range at spacing step/4, no gaps.""" - _, step180, _ = _actual_grid(stepsize) - all_angles = np.sort(np.concatenate([plan(n, 360, stepsize)[0] for n in range(1, 9)])) - diffs = np.diff(all_angles) - assert np.allclose(diffs, step180 / 4.0, atol=1e-6) - assert all_angles.min() >= 0 - assert all_angles.max() < 360 +def test_360_mode_mod_180_identical_to_180_mode(stepsize): + """The core requirement: 360-degree mode's combined angle set, folded + mod 180, must be IDENTICAL (as a set, same count, no residue repeated) + to what 180-degree mode itself produces for the same stepsize -- zero + redundant measurements, full information content, same total count. + This requires the low half (subtomos 1,4,5,8) and high half (2,3,6,7) + to use COMPLEMENTARY phase positions, not the same ones shifted by + 180 -- shared phases would make every low-half angle exactly 180 + degrees from a high-half angle (the original bug).""" + angles180 = np.concatenate([plan(n, 180, stepsize)[0] for n in range(1, 9)]) + angles360 = np.concatenate([plan(n, 360, stepsize)[0] for n in range(1, 9)]) + folded = np.mod(np.round(angles360, 6), 180) + set180 = set(np.round(angles180, 6)) + set_folded = set(np.round(folded, 6)) -@pytest.mark.parametrize("stepsize", STEPSIZES) -@pytest.mark.parametrize("pair", [(1, 2), (3, 4), (5, 6), (7, 8)]) -def test_360_mode_adjacent_pair_is_complete_coarse_tomogram(stepsize, pair): - """Any adjacent pair (1,2)/(3,4)/(5,6)/(7,8) must independently - reconstruct a complete, evenly-spaced 360-degree tomogram at the - coarse (unrefined) step spacing.""" - _, step180, _ = _actual_grid(stepsize) - a, b = pair - combined = np.sort(np.concatenate([plan(a, 360, stepsize)[0], plan(b, 360, stepsize)[0]])) - diffs = np.diff(combined) - assert np.allclose(diffs, step180, atol=1e-6) - assert combined.min() >= 0 - assert combined.max() < 360 - - -@pytest.mark.parametrize("stepsize", STEPSIZES) -@pytest.mark.parametrize("quartet", [(1, 2, 3, 4), (5, 6, 7, 8)]) -def test_360_mode_quartet_is_complete_half_sampled_tomogram(stepsize, quartet): - """Either quartet (1-4 or 5-8) must independently reconstruct a - complete, evenly-spaced 360-degree tomogram at half the finest - (8-subtomo) spacing.""" - _, step180, _ = _actual_grid(stepsize) - combined = np.sort(np.concatenate([plan(n, 360, stepsize)[0] for n in quartet])) - diffs = np.diff(combined) - assert np.allclose(diffs, step180 / 2.0, atol=1e-6) - assert combined.min() >= 0 - assert combined.max() < 360 + assert len(angles360) == len(angles180) + assert len(folded) == len(np.unique(folded)), "a mod-180 residue was hit more than once" + assert set_folded == set180 @pytest.mark.parametrize("tomo_angle_range", [180, 360]) @@ -106,7 +100,7 @@ def test_resume_round_trip(tomo_angle_range, stepsize): """Resuming with an explicit start_angle must recover the exact loop index that originally produced that angle, for every sub-tomogram and every position -- including the float tie-break cases (subtomo 2 in - 180 mode; subtomos 3 and 4 in 360 mode, both landing on phase/step == + 180 mode; subtomos 3 and 6 in 360 mode, both landing on phase/step == 0.5).""" for n in range(1, 9): angles, _, N, _ = plan(n, tomo_angle_range, stepsize)