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:
x01dc
2026-07-16 13:39:08 +02:00
co-authored by Claude Sonnet 5
parent 95d46c9d49
commit bac3fa8754
7 changed files with 1223 additions and 196 deletions
@@ -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",
]