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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-15 07:28:24 +02:00
co-authored by Claude Sonnet 5
parent 7e8e807adc
commit 900c810111
2 changed files with 77 additions and 51 deletions
@@ -2317,17 +2317,25 @@ class Flomni(
the original scheme). In 360 mode each sub-tomogram instead the original scheme). In 360 mode each sub-tomogram instead
covers only a 180-degree span - never the full circle - split by 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 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 "high" half [180,360) (n%4 in (2,3)). Without this split, every
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
sub-tomogram's own 360-degree sweep would contain angle pairs sub-tomogram's own 360-degree sweep would contain angle pairs
exactly 180 degrees apart, which is redundant tomographic exactly 180 degrees apart - redundant tomographic information,
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: Returns:
angles (np.ndarray): the N angles (degrees) for this sub-tomogram. 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] phase = step / 8.0 * phase_eighths[subtomo_number]
forward = bool(subtomo_number % 2) forward = bool(subtomo_number % 2)
else: 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 mod4 = subtomo_number % 4
base = 0.0 if mod4 in (1, 0) else 180.0 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) forward = mod4 in (1, 2)
if not explicit_start_angle: if not explicit_start_angle:
@@ -3635,6 +3647,20 @@ class Flomni(
"Angular step of the final (combined) tomogram:" "Angular step of the final (combined) tomogram:"
f" {self.tomo_angle_range / total_projections:.3f} degrees" 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( print(
"0-deg reference shots (odd sub-tomo start + end) =" "0-deg reference shots (odd sub-tomo start + end) ="
f" {self.zero_deg_reference_at_each_subtomo}" f" {self.zero_deg_reference_at_each_subtomo}"
@@ -3762,6 +3788,12 @@ class Flomni(
"The angular step of the final (combined) tomogram will be" "The angular step of the final (combined) tomogram will be"
f" {self.tomo_angle_range / actual_total:.3f} degrees" 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.zero_deg_reference_at_each_subtomo = bool(
self._get_val( self._get_val(
"Take 0-deg reference shots (start of each odd sub-tomo + end) for" "Take 0-deg reference shots (start of each odd sub-tomo + end) for"
@@ -33,8 +33,8 @@ def test_180_mode_regression(stepsize):
@pytest.mark.parametrize("stepsize", STEPSIZES) @pytest.mark.parametrize("stepsize", STEPSIZES)
def test_360_mode_no_duplicates_and_no_internal_180_pairs(stepsize): def test_360_mode_no_duplicates_and_no_internal_180_pairs(stepsize):
"""360-degree mode: no sub-tomogram may, by itself, contain two angles """360-degree mode: no sub-tomogram may, by itself, contain two angles
exactly 180 degrees apart (that redundancy was the bug), and the exactly 180 degrees apart (that redundancy was the original bug), and
combined 8-sub-tomogram set must contain no duplicate angles.""" the combined 8-sub-tomogram set must contain no duplicate angles."""
all_angles = [] all_angles = []
for n in range(1, 9): for n in range(1, 9):
angles, offset, N, step = plan(n, 360, stepsize) 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))) 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) @pytest.mark.parametrize("stepsize", STEPSIZES)
def test_total_projection_count_identical_180_vs_360(stepsize): def test_total_projection_count_identical_180_vs_360(stepsize):
"""Total projections for a given tomo_angle_stepsize must be the same """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) @pytest.mark.parametrize("stepsize", STEPSIZES)
def test_360_mode_full_grid_evenly_spaced(stepsize): def test_360_mode_mod_180_identical_to_180_mode(stepsize):
"""All 8 sub-tomograms combined must be a complete, evenly-spaced grid """The core requirement: 360-degree mode's combined angle set, folded
covering the full [0,360) range at spacing step/4, no gaps.""" mod 180, must be IDENTICAL (as a set, same count, no residue repeated)
_, step180, _ = _actual_grid(stepsize) to what 180-degree mode itself produces for the same stepsize -- zero
all_angles = np.sort(np.concatenate([plan(n, 360, stepsize)[0] for n in range(1, 9)])) redundant measurements, full information content, same total count.
diffs = np.diff(all_angles) This requires the low half (subtomos 1,4,5,8) and high half (2,3,6,7)
assert np.allclose(diffs, step180 / 4.0, atol=1e-6) to use COMPLEMENTARY phase positions, not the same ones shifted by
assert all_angles.min() >= 0 180 -- shared phases would make every low-half angle exactly 180
assert all_angles.max() < 360 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) assert len(angles360) == len(angles180)
@pytest.mark.parametrize("pair", [(1, 2), (3, 4), (5, 6), (7, 8)]) assert len(folded) == len(np.unique(folded)), "a mod-180 residue was hit more than once"
def test_360_mode_adjacent_pair_is_complete_coarse_tomogram(stepsize, pair): assert set_folded == set180
"""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
@pytest.mark.parametrize("tomo_angle_range", [180, 360]) @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 """Resuming with an explicit start_angle must recover the exact loop
index that originally produced that angle, for every sub-tomogram and index that originally produced that angle, for every sub-tomogram and
every position -- including the float tie-break cases (subtomo 2 in 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).""" 0.5)."""
for n in range(1, 9): for n in range(1, 9):
angles, _, N, _ = plan(n, tomo_angle_range, stepsize) angles, _, N, _ = plan(n, tomo_angle_range, stepsize)