feat(lamni,bec_widgets): generalize tomo-params GUI to lamni, fix two sub_tomo_scan angle bugs
Generalize TomoParamsWidget to lamni via a SETUP_PROFILES mechanism (setup detection, per-setup field lists/order, a new Offsets section, a setup-agnostic "Duplicate job" queue button), fixing two latent TomoQueueDialog bugs that silently mishandled lamni jobs along the way. While verifying the GUI's projection-count preview against the CLI, found two real, pre-existing bugs in LamNI.sub_tomo_scan() unrelated to the GUI itself: a duplicate closing angle every sub-tomogram (360=0 degrees), and a phase offset computed from the raw stepsize instead of the achievable one, breaking the equally-spaced-when- combined guarantee for sub-tomogram pairs/quads/the full set. Both fixed to mirror Flomni's existing, correct equivalents. Also fills in lamni's user documentation with the queue/command-job/ at-each-angle-hook system, which it previously lacked entirely, mirroring flomni.md's coverage. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""Regression test for LamNI.sub_tomo_scan()'s actual angle generation.
|
||||
|
||||
Mirrors test_flomni_tomo_angles.py's style of testing angle math directly,
|
||||
without a live BEC session. Guards against two bugs fixed this session:
|
||||
|
||||
1. A full 360-degree sweep's closing angle (start_angle + 360) is the same
|
||||
physical angle as its start, so sub_tomo_scan() must generate exactly N
|
||||
unique angles per sub-tomogram (endpoint=False), not N+1.
|
||||
2. The inter-sub-tomogram phase offset must be a fraction of the ACHIEVABLE
|
||||
step (post int() truncation), not the raw configured tomo_angle_stepsize
|
||||
-- otherwise sub-tomograms combined in pairs/quads/all-8 land on
|
||||
unevenly-spaced angles instead of the intended progressively finer, even
|
||||
grid at every combination level (mirrors Flomni._subtomo_angle_plan()'s
|
||||
same requirement).
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
|
||||
|
||||
STEPSIZES = [10.0, 7.0, 25.0, 12.5]
|
||||
|
||||
|
||||
class FakeClient:
|
||||
"""Minimal in-memory stand-in for BEC's global-var store."""
|
||||
|
||||
def __init__(self):
|
||||
self._vars = {}
|
||||
|
||||
def get_global_var(self, key):
|
||||
return self._vars.get(key)
|
||||
|
||||
def set_global_var(self, key, value):
|
||||
self._vars[key] = value
|
||||
|
||||
|
||||
def make_lamni(tomo_angle_stepsize: float) -> LamNI:
|
||||
"""Bare LamNI instance with only what sub_tomo_scan() needs to run its
|
||||
angle-generation logic, bypassing __init__'s heavy side effects."""
|
||||
obj = object.__new__(LamNI)
|
||||
obj.client = FakeClient()
|
||||
obj._progress_proxy = _ProgressProxy(obj.client)
|
||||
obj.tomo_angle_stepsize = tomo_angle_stepsize
|
||||
obj.tomo_id = -1
|
||||
obj._write_subtomo_to_scilog = lambda subtomo_number: None
|
||||
return obj
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stepsize", STEPSIZES)
|
||||
@pytest.mark.parametrize("subtomo_number", range(1, 9))
|
||||
def test_sub_tomo_scan_generates_no_duplicate_angles(stepsize, subtomo_number):
|
||||
lamni = make_lamni(stepsize)
|
||||
recorded = []
|
||||
lamni._tomo_scan_at_angle = lambda angle, subtomo: recorded.append(float(angle))
|
||||
|
||||
lamni.sub_tomo_scan(subtomo_number)
|
||||
|
||||
N, _achievable_step, _total = lamni._tomo_type1_actual_grid()
|
||||
assert len(recorded) == N, "sub_tomo_scan() must generate exactly N angles, not N+1"
|
||||
unique_mod_360 = {round(a % 360, 6) for a in recorded}
|
||||
assert len(unique_mod_360) == N, "sub_tomo_scan() must not re-measure its own start angle"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stepsize", STEPSIZES)
|
||||
def test_sub_tomo_scan_projection_count_matches_actual_grid(stepsize):
|
||||
"""progress['subtomo_total_projections']/['total_projections'] must
|
||||
match what actually ran, not a pre-truncation float estimate."""
|
||||
lamni = make_lamni(stepsize)
|
||||
lamni._tomo_scan_at_angle = lambda angle, subtomo: None
|
||||
|
||||
lamni.sub_tomo_scan(1)
|
||||
|
||||
N, _achievable_step, total = lamni._tomo_type1_actual_grid()
|
||||
assert lamni.progress["subtomo_total_projections"] == N
|
||||
assert lamni.progress["total_projections"] == total
|
||||
|
||||
|
||||
def _all_subtomo_angles(stepsize: float) -> dict[int, list[float]]:
|
||||
"""Run sub_tomo_scan() for all 8 sub-tomograms (real code path, not a
|
||||
reimplementation) and return each one's acquired angles, in order."""
|
||||
result = {}
|
||||
for n in range(1, 9):
|
||||
lamni = make_lamni(stepsize)
|
||||
recorded = []
|
||||
lamni._tomo_scan_at_angle = lambda angle, subtomo, _r=recorded: _r.append(float(angle))
|
||||
lamni.sub_tomo_scan(n)
|
||||
result[n] = recorded
|
||||
return result
|
||||
|
||||
|
||||
def _assert_equally_spaced(angles, label: str) -> None:
|
||||
sorted_angles = np.sort(np.asarray(angles) % 360)
|
||||
diffs = np.diff(np.concatenate([sorted_angles, [sorted_angles[0] + 360]]))
|
||||
assert np.allclose(
|
||||
diffs, diffs[0], atol=1e-6
|
||||
), f"{label}: angles are not equally spaced when combined -- got spacings {diffs}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stepsize", STEPSIZES)
|
||||
def test_subtomo_pairs_are_equally_spaced_when_combined(stepsize):
|
||||
"""(1,2), (3,4), (5,6), (7,8) each combine into one evenly-spaced,
|
||||
doubled-resolution grid -- this is the whole point of the phase-offset
|
||||
interlacing scheme (bit-reversal table), and silently breaks if the
|
||||
phase is computed from the wrong step (see module docstring)."""
|
||||
angles = _all_subtomo_angles(stepsize)
|
||||
for a, b in [(1, 2), (3, 4), (5, 6), (7, 8)]:
|
||||
_assert_equally_spaced(angles[a] + angles[b], f"pair ({a},{b})")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stepsize", STEPSIZES)
|
||||
def test_subtomo_quads_are_equally_spaced_when_combined(stepsize):
|
||||
"""(1,2,3,4) and (5,6,7,8) each combine into one evenly-spaced,
|
||||
quadrupled-resolution grid."""
|
||||
angles = _all_subtomo_angles(stepsize)
|
||||
for quad in [(1, 2, 3, 4), (5, 6, 7, 8)]:
|
||||
combined = sum((angles[n] for n in quad), [])
|
||||
_assert_equally_spaced(combined, f"quad {quad}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stepsize", STEPSIZES)
|
||||
def test_all_eight_subtomos_are_equally_spaced_when_combined(stepsize):
|
||||
"""The full combined set of all 8 sub-tomograms is the finest,
|
||||
evenly-spaced grid -- the end goal of the interlacing scheme."""
|
||||
angles = _all_subtomo_angles(stepsize)
|
||||
combined = sum((angles[n] for n in range(1, 9)), [])
|
||||
_assert_equally_spaced(combined, "full (all 8)")
|
||||
@@ -0,0 +1,167 @@
|
||||
"""Regression test for TomoParamsWidget's lamni-specific projection-count
|
||||
math (_lamni_compute_type1/_lamni_requested_to_stepsize), mirroring
|
||||
test_tomo_params_widget_math.py's style for flomni.
|
||||
|
||||
Lamni's formula is NOT the same as flomni's: LamNI.sub_tomo_scan() sweeps a
|
||||
plain 360 degrees per sub-tomogram (vs. flomni's fixed-180-base,
|
||||
mode-independent _compute_type1/_requested_to_stepsize, which these tests
|
||||
must not touch -- see that file's own docstring on why they're pinned by
|
||||
name/signature) -- but is otherwise the same shape: N = int(360/stepsize)
|
||||
unique angles per sub-tomogram, N*8 total. (sub_tomo_scan() used to also
|
||||
acquire a duplicate closing angle per sub-tomogram -- start_angle+360 is the
|
||||
same physical angle as start_angle -- fixed via endpoint=False; see
|
||||
lamni.py's _tomo_type1_actual_grid().)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
|
||||
from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import (
|
||||
DEFAULTS,
|
||||
LAMNI_QUEUE_PARAM_NAMES,
|
||||
QUEUE_PARAM_NAMES,
|
||||
SETUP_PROFILES,
|
||||
_compute_type1,
|
||||
_lamni_compute_type1,
|
||||
_lamni_get_tomo_fov_offset,
|
||||
_lamni_requested_to_stepsize,
|
||||
_lamni_set_tomo_fov_offset,
|
||||
_requested_to_stepsize,
|
||||
)
|
||||
|
||||
STEPSIZES = [10.0, 7.0, 25.0, 12.5]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stepsize", STEPSIZES)
|
||||
def test_lamni_compute_type1_matches_lamni_subtomo_scan(stepsize):
|
||||
"""Reference formula mirrors LamNI.sub_tomo_scan()'s exact (fixed) grid:
|
||||
N = int(360/stepsize) unique angles per sub-tomogram, N*8 total."""
|
||||
N = int(360.0 / stepsize)
|
||||
expected_total = N * 8
|
||||
expected_step = 360.0 / N
|
||||
|
||||
actual_total, achievable_step, _ = _lamni_compute_type1(360, stepsize)
|
||||
assert actual_total == expected_total
|
||||
assert achievable_step == pytest.approx(expected_step)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("requested_total", [32, 64, 160, 320])
|
||||
def test_lamni_requested_to_stepsize_round_trips(requested_total):
|
||||
"""requested_total must already be a multiple of 8 for the round trip
|
||||
to land exactly back on it -- these were chosen as N=4,8,20,40."""
|
||||
stepsize = _lamni_requested_to_stepsize(360, requested_total)
|
||||
actual_total, _, _ = _lamni_compute_type1(360, stepsize)
|
||||
assert actual_total == requested_total
|
||||
|
||||
|
||||
def test_lamni_formula_differs_from_flomni_formula():
|
||||
"""Cheap insurance against accidentally aliasing the wrong function into
|
||||
a SETUP_PROFILES entry -- lamni's math must not silently match flomni's."""
|
||||
for stepsize in STEPSIZES:
|
||||
flomni_total, flomni_step, _ = _compute_type1(180, stepsize)
|
||||
lamni_total, lamni_step, _ = _lamni_compute_type1(360, stepsize)
|
||||
assert (flomni_total, flomni_step) != (lamni_total, lamni_step)
|
||||
|
||||
for requested in (24, 48, 96, 144):
|
||||
assert _requested_to_stepsize(180, requested) != _lamni_requested_to_stepsize(
|
||||
360, requested
|
||||
)
|
||||
|
||||
|
||||
def test_setup_profiles_param_names_match_cli_classes():
|
||||
"""Guards against the known mirror-drift risk: SETUP_PROFILES'
|
||||
param_names must stay in sync with each CLI class's own
|
||||
_TOMO_SCAN_PARAM_NAMES (there is no shared import between bec_widgets
|
||||
and the ipython-client plugins, so nothing else catches this)."""
|
||||
assert set(SETUP_PROFILES["flomni"]["param_names"]) == set(Flomni._TOMO_SCAN_PARAM_NAMES)
|
||||
assert set(SETUP_PROFILES["lamni"]["param_names"]) == set(LamNI._TOMO_SCAN_PARAM_NAMES)
|
||||
assert set(QUEUE_PARAM_NAMES) == set(Flomni._TOMO_SCAN_PARAM_NAMES)
|
||||
assert set(LAMNI_QUEUE_PARAM_NAMES) == set(LamNI._TOMO_SCAN_PARAM_NAMES)
|
||||
|
||||
|
||||
def test_setup_profiles_defaults_cover_all_param_names():
|
||||
for setup, profile in SETUP_PROFILES.items():
|
||||
missing = set(profile["param_names"]) - set(profile["defaults"])
|
||||
assert not missing, f"{setup} profile is missing defaults for: {missing}"
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Minimal in-memory stand-in for BEC's global-var store."""
|
||||
|
||||
def __init__(self):
|
||||
self._vars = {}
|
||||
|
||||
def get_global_var(self, key):
|
||||
return self._vars.get(key)
|
||||
|
||||
def set_global_var(self, key, value):
|
||||
self._vars[key] = value
|
||||
|
||||
|
||||
def test_lamni_fov_offset_round_trip_does_not_clobber_other_axis():
|
||||
"""tomo_fov_offset packs both axes into one global var ([x_um, y_um]) --
|
||||
writing one axis must read-modify-write, not silently reset the other
|
||||
(this is exactly the kind of bug a naive per-key implementation would
|
||||
introduce)."""
|
||||
client = _FakeClient()
|
||||
|
||||
_lamni_set_tomo_fov_offset(client, "x", 1.5)
|
||||
assert _lamni_get_tomo_fov_offset(client, "x") == pytest.approx(1.5)
|
||||
assert _lamni_get_tomo_fov_offset(client, "y") == pytest.approx(0.0)
|
||||
|
||||
_lamni_set_tomo_fov_offset(client, "y", -2.25)
|
||||
assert _lamni_get_tomo_fov_offset(client, "x") == pytest.approx(1.5)
|
||||
assert _lamni_get_tomo_fov_offset(client, "y") == pytest.approx(-2.25)
|
||||
|
||||
assert client.get_global_var("tomo_fov_offset") == [1500.0, -2250.0]
|
||||
|
||||
|
||||
def test_lamni_fov_offset_defaults_to_zero_when_unset():
|
||||
client = _FakeClient()
|
||||
assert _lamni_get_tomo_fov_offset(client, "x") == 0.0
|
||||
assert _lamni_get_tomo_fov_offset(client, "y") == 0.0
|
||||
|
||||
|
||||
def test_lamni_offset_fields_excluded_from_param_names():
|
||||
"""tomo_fovx_offset/tomo_fovy_offset are alignment values, shown in the
|
||||
GUI but deliberately excluded from queue job snapshots -- must never
|
||||
appear in param_names."""
|
||||
offset_keys = {key for key, *_ in SETUP_PROFILES["lamni"]["offset_fields"]}
|
||||
assert offset_keys == {"tomo_fovx_offset", "tomo_fovy_offset"}
|
||||
assert offset_keys.isdisjoint(SETUP_PROFILES["lamni"]["param_names"])
|
||||
assert not SETUP_PROFILES["flomni"]["offset_fields"]
|
||||
|
||||
|
||||
def test_flomni_field_order_matches_pre_reorder_layout():
|
||||
"""Regression guard: the field_order dispatch must reproduce flomni's
|
||||
exact, unchanged field build order (this task only reordered lamni)."""
|
||||
assert SETUP_PROFILES["flomni"]["field_order"] == [
|
||||
"tomo_countingtime",
|
||||
"tomo_shellstep",
|
||||
"fov",
|
||||
"stitch",
|
||||
"tomo_stitch_overlap",
|
||||
"ptycho_reconstruct_foldername",
|
||||
"manual_shift",
|
||||
"frames_per_trigger",
|
||||
"single_point",
|
||||
"at_each_angle_hook",
|
||||
]
|
||||
|
||||
|
||||
def test_lamni_field_order_matches_tomo_parameters_cli():
|
||||
"""Mirrors lamni.tomo_parameters()'s exact print/edit order
|
||||
(lamni.py:1136-1153)."""
|
||||
assert SETUP_PROFILES["lamni"]["field_order"] == [
|
||||
"tomo_countingtime",
|
||||
"tomo_shellstep",
|
||||
"piezo_range",
|
||||
"stitch",
|
||||
"tomo_stitch_overlap",
|
||||
"fov",
|
||||
"ptycho_reconstruct_foldername",
|
||||
"frames_per_trigger",
|
||||
"offsets",
|
||||
"at_each_angle_hook",
|
||||
]
|
||||
Reference in New Issue
Block a user