diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index df2e018d..1a3b816e 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -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" diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py index 36e9e220..0342f389 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py @@ -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