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
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"
@@ -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)