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