fix(lamni): tomo_scan_resume resumes at the interrupted angle, not a shifted sweep

_subtomo_angle_plan() used an explicit start_angle as the origin of a new
360-degree sweep. Resuming subtomo 2 at 85 deg generated 435..85; the >360
artifacts were silently skipped by _tomo_scan_at_angle's range gate, so the
resume began at 355 deg, re-measured done angles, never reached the rest and
reported wrong progress indices.

start_angle is now the literal resume point on the sub-tomogram's own grid:
the plan returns the remaining tail of the normal sweep plus a subtomo_offset
used for progress numbering (mirrors Flomni). Off-grid angles raise ValueError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:07:46 +02:00
committed by holler
co-authored by Claude Sonnet 5
parent 00ddc5f560
commit fd4e8a2de1
2 changed files with 97 additions and 21 deletions
@@ -1511,35 +1511,64 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
(no tomo_angle_range/forward-reverse split -- lamni always scans
the full 360 degrees).
Args:
start_angle (float, optional): resume point -- the literal angle
(on this sub-tomogram's own grid) to continue from, NOT the
origin of a new sweep. The returned angles are then the
remaining tail of the normal sweep, beginning at (and
including) this angle. Raises ValueError if it isn't on
the grid. Mirrors Flomni._subtomo_angle_plan().
Returns:
angles (np.ndarray): the N angles (degrees) for this sub-tomogram.
angles (np.ndarray): the N angles (degrees) for this sub-tomogram
(or, when resuming, the remaining ones from start_angle on).
subtomo_offset (int): index of angles[0] within the full
sub-tomogram sweep (0 unless resuming), for progress numbering.
N (int): number of projections in this (and every) sub-tomogram.
"""
N = int(360.0 / tomo_angle_stepsize)
achievable_step = 360.0 / N
if start_angle is None:
# Phase offset must be a fraction of the ACHIEVABLE step (after
# int() truncation), not the raw configured tomo_angle_stepsize --
# they only coincide when 360/tomo_angle_stepsize happens to
# already be a whole number. Using the raw value here breaks the
# interlacing scheme: sub-tomograms combined in pairs/quads/all-8
# land on unevenly-spaced angles instead of the intended
# progressively finer, evenly-spaced grid at every combination
# level. Mirrors Flomni._subtomo_angle_plan() exactly.
offsets = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7}
start_angle = achievable_step / 8.0 * offsets[subtomo_number]
# Phase offset must be a fraction of the ACHIEVABLE step (after
# int() truncation), not the raw configured tomo_angle_stepsize --
# they only coincide when 360/tomo_angle_stepsize happens to
# already be a whole number. Using the raw value here breaks the
# interlacing scheme: sub-tomograms combined in pairs/quads/all-8
# land on unevenly-spaced angles instead of the intended
# progressively finer, evenly-spaced grid at every combination
# level. Mirrors Flomni._subtomo_angle_plan() exactly.
offsets = {1: 0, 2: 4, 3: 2, 4: 6, 5: 1, 6: 5, 7: 3, 8: 7}
sweep_start = achievable_step / 8.0 * offsets[subtomo_number]
angle_end = start_angle + 360
# endpoint=False: a full 360-degree sweep must NOT re-measure both
# start_angle and start_angle+360 -- they're the same physical angle,
# sweep_start and sweep_start+360 -- they're the same physical angle,
# unlike a [0, 180) span's two distinct endpoints.
angles = np.linspace(start_angle, angle_end, num=N, endpoint=False)
angles = np.linspace(sweep_start, sweep_start + 360, num=N, endpoint=False)
if not (subtomo_number % 2):
angles = np.flip(angles)
return angles, N
if start_angle is None:
return angles, 0, N
# Resuming: locate start_angle on this sub-tomogram's own grid (with
# 360-degree wrap-around) and continue from there in the same
# direction. start_angle must NOT be used as the sweep origin -- that
# would generate angles past 360 (silently skipped by
# _tomo_scan_at_angle's range gate) and never reach the remaining
# part of the sweep.
diff = np.abs((angles - start_angle + 180.0) % 360.0 - 180.0)
subtomo_offset = int(np.argmin(diff))
# start_angle normally comes straight from progress["angle"] (taken
# from this same grid), so only float noise is tolerated here -- the
# nearest grid point is always within step/2, so a looser check
# would never catch a genuinely off-grid angle.
if diff[subtomo_offset] > 0.01:
raise ValueError(
f"start_angle {start_angle} is not on the angle grid of sub-tomogram "
f"{subtomo_number} (tomo_angle_stepsize={tomo_angle_stepsize})."
)
return angles[subtomo_offset:], subtomo_offset, N
def _resolve_type1_projection(self, projection_number: int) -> tuple[int, float]:
"""Map a flat 0-indexed tomo_type-1 projection number to
@@ -1551,7 +1580,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
tomography_scannumbers.txt log resolves to the same angle
sub_tomo_scan() would have produced at that position.
"""
_, N = self._subtomo_angle_plan(1, self.tomo_angle_stepsize)
_, _, N = self._subtomo_angle_plan(1, self.tomo_angle_stepsize)
total = N * 8
if not 0 <= projection_number < total:
raise ValueError(
@@ -1559,20 +1588,20 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
f"(tomo_angle_stepsize={self.tomo_angle_stepsize})."
)
subtomo_number = projection_number // N + 1
angles, _ = self._subtomo_angle_plan(subtomo_number, self.tomo_angle_stepsize)
angles, _, _ = self._subtomo_angle_plan(subtomo_number, self.tomo_angle_stepsize)
return subtomo_number, float(angles[projection_number % N])
def sub_tomo_scan(self, subtomo_number, start_angle=None):
"""Perform one sub-tomogram (tomo_type 1 only)."""
angles, N = self._subtomo_angle_plan(
angles, subtomo_offset, N = self._subtomo_angle_plan(
subtomo_number, self.tomo_angle_stepsize, start_angle=start_angle
)
total_projections = N * 8
for angle in angles:
for i, angle in enumerate(angles):
self.progress["tomo_type"] = "Equally spaced sub-tomograms"
self.progress["subtomo"] = subtomo_number
self.progress["subtomo_projection"] = np.where(angles == angle)[0][0]
self.progress["subtomo_projection"] = subtomo_offset + i
self.progress["subtomo_total_projections"] = N
self.progress["projection"] = (subtomo_number - 1) * N + self.progress[
"subtomo_projection"
@@ -129,6 +129,53 @@ def test_all_eight_subtomos_are_equally_spaced_when_combined(stepsize):
_assert_equally_spaced(combined, "full (all 8)")
# ── resuming mid-sub-tomogram (start_angle) ─────────────────────────────────
#
# start_angle used to be treated as the ORIGIN of a fresh 360-degree sweep,
# so resuming e.g. subtomo 2 at 85 deg generated 435..85 (flipped): the >360
# artifacts were silently skipped, the resume started at 355 deg instead of
# 85, re-measured done angles and never reached the remaining ones.
def _resume(stepsize: float, subtomo_number: int, start_angle: float):
lamni = make_lamni(stepsize)
recorded = []
def _record(angle, subtomo):
recorded.append((float(angle), int(lamni.progress["subtomo_projection"])))
lamni._tomo_scan_at_angle = _record
lamni.sub_tomo_scan(subtomo_number, start_angle=start_angle)
return recorded
@pytest.mark.parametrize("stepsize", STEPSIZES)
@pytest.mark.parametrize("subtomo_number", range(1, 9))
def test_resume_yields_tail_of_normal_sweep(stepsize, subtomo_number):
full = make_lamni(stepsize)
full_recorded = []
full._tomo_scan_at_angle = lambda angle, subtomo: full_recorded.append(float(angle))
full.sub_tomo_scan(subtomo_number)
for k, start in enumerate(full_recorded):
resumed = _resume(stepsize, subtomo_number, start)
assert [a for a, _ in resumed] == pytest.approx(full_recorded[k:])
assert [i for _, i in resumed] == list(range(k, len(full_recorded)))
assert all(0 <= a < 360.05 for a, _ in resumed)
def test_resume_reported_case_subtomo2_at_85_deg():
resumed = _resume(10.0, 2, 85.0)
assert resumed[0] == (pytest.approx(85.0), 27)
assert resumed[-1][0] == pytest.approx(5.0)
assert len(resumed) == 36 - 27
def test_resume_off_grid_start_angle_raises():
with pytest.raises(ValueError):
_resume(10.0, 2, 80.0)
# ── tomo_scan()'s new-scan account handling ─────────────────────────────────
#
# bec.active_account used to be passed through .decode() (as if it were