diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 57d7e93..c17c785 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2317,25 +2317,35 @@ 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)). Without this split, every - sub-tomogram's own 360-degree sweep would contain angle pairs - exactly 180 degrees apart - redundant tomographic information, - since a measurement 180 degrees from one already taken - contributes no new information. + "high" half [180,360) (n%4 in (2,3)), each sub-tomogram in the + high half literally its low-half counterpart's own 180-degree + scan, shifted by +180 (per subtomo_number, using the SAME + phase_eighths value as 180 mode - not a different phase table). + Without this split, every sub-tomogram's own 360-degree sweep + would contain angle pairs 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. + Reusing the same per-subtomo-number phase_eighths value in both + modes (rather than reassigning phases by acquisition order within + each half) is what makes subtomo N carry the same + interlacing/refinement role in both modes - e.g. subtomo 2 always + represents the phase_eighths[2] tier, whether that's within + [0,180) (180 mode) or shifted to [180,360) (360 mode, since + subtomo 2 % 4 == 2, a "high" sub-tomogram) - instead of an + unrelated phase suddenly appearing under the same subtomo number + depending on tomo_angle_range. Since the low half uses + subtomo_numbers {1,4,5,8} (phase_eighths values {0,6,1,7}) and the + high half uses {2,3,6,7} (phase_eighths values {4,2,5,3}), these + two phase sets are complementary (their union is all 8 eighths, + with no overlap) purely as a property of the original + phase_eighths table's structure - not something this branch has + to separately re-derive. Once the high half is folded mod 180, + low and high together 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. @@ -2357,20 +2367,19 @@ class Flomni( N = int(180.0 / tomo_angle_stepsize) step = 180.0 / N + # Same phase_eighths table in both modes - see docstring above for + # why reusing it (rather than a separately-derived phase table for + # 360 mode) is what keeps subtomo N's role consistent between + # modes. + phase_eighths = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7} + phase = step / 8.0 * phase_eighths[subtomo_number] + if tomo_angle_range == 180: base = 0.0 - phase_eighths = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7} - phase = step / 8.0 * phase_eighths[subtomo_number] forward = bool(subtomo_number % 2) else: - # 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 / 8.0 * eighths[subtomo_number] forward = mod4 in (1, 2) if not explicit_start_angle: 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 1c26ead..a9e4ca7 100644 --- a/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_flomni_tomo_angles.py @@ -94,6 +94,27 @@ def test_360_mode_mod_180_identical_to_180_mode(stepsize): assert set_folded == set180 +@pytest.mark.parametrize("stepsize", STEPSIZES) +@pytest.mark.parametrize("n", range(1, 9)) +def test_360_mode_subtomo_keeps_same_phase_tier_as_180_mode(stepsize, n): + """Subtomo N must carry the same phase_eighths tier in 360 mode as in + 180 mode - e.g. subtomo 2 always represents the phase_eighths[2] tier, + whether that's within [0,180) (180 mode) or shifted +180 into + [180,360) (360 mode). A previous implementation reassigned phases by + acquisition-order-within-half instead of reusing the per-subtomo-number + value, so e.g. the angle physically corresponding to subtomo 2's tier + in 180 mode ended up under subtomo 4 in 360 mode - confusing, and + breaks any expectation that "subtomo N" means the same thing across + modes.""" + angles180, _, N, step = plan(n, 180, stepsize) + angles360, _, _, _ = plan(n, 360, stepsize) + + base = 0.0 if n % 4 in (1, 0) else 180.0 + offset180 = set(np.round(np.mod(angles180, step), 6)) + offset360 = set(np.round(np.mod(angles360 - base, step), 6)) + assert offset360 == offset180 + + @pytest.mark.parametrize("tomo_angle_range", [180, 360]) @pytest.mark.parametrize("stepsize", STEPSIZES) def test_resume_round_trip(tomo_angle_range, stepsize):