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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user