feat/tomo queue reacquire from an earlier projection

Add tomo_queue_reacquire(job_index, projection_number) to the shared
TomoQueueMixin: reopens any queue job (even an already-"done" one) at
an earlier projection than its own recorded progress, and resets every
job queued after it -- tomo and command jobs alike -- to "pending", so
a following tomo_queue_resume() (a new alias for tomo_queue_execute(),
added for naming symmetry with tomo_scan_resume()) re-runs everything
from there forward in order. Reuses the existing tomo_scan_resume()
machinery by writing the requested resume point into the shared
progress global var, rather than adding new parameters to
tomo_scan()/tomo_queue_execute(). Guards against clobbering another
job's progress if one is already "incomplete"/"running" elsewhere in
the queue.

A flat projection number is used as the resume-point unit for all
three tomo_types, including type 1 (8 equally-spaced sub-tomograms),
via a new per-setup _resolve_type1_projection() that maps it to
(subtomo_start, start_angle). flomni's version reuses its existing
_subtomo_angle_plan(); lamni's inline angle math in sub_tomo_scan() was
extracted into an analogous _subtomo_angle_plan() static method first
(behavior-preserving -- verified against the existing angle-math test
suite) so both setups share the same pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-20 22:00:22 +02:00
co-authored by Claude Sonnet 5
parent 80f6972a31
commit abc2a3d041
4 changed files with 460 additions and 4 deletions
@@ -0,0 +1,244 @@
"""Tests for TomoQueueMixin.tomo_queue_reacquire()/tomo_queue_resume() and
the per-setup _resolve_type1_projection() resolvers it depends on (see
csaxs_bec/bec_ipython_client/plugins/OMNY_shared/tomo_queue_mixin.py).
Round-trip tests build a bare LamNI/Flomni instance (bypassing __init__'s
heavy side effects, same pattern as test_lamni_tomo_queue.py) and confirm
_resolve_type1_projection() agrees with what sub_tomo_scan() itself would
actually produce at a given flat projection number -- not a re-derivation
of the same formula, but a comparison against the real scan-execution path.
"""
import types
import pytest
import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import Flomni
from csaxs_bec.bec_ipython_client.plugins.flomni.flomni import _ProgressProxy as FlomniProgressProxy
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.tomo_queue_mixin import TomoQueueError
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
class FakeOMNYTools:
def yesno(self, *args, **kwargs):
return True
class _FakeRtx:
user_parameter = {"large_range_scan": True}
class _FakeDev:
rtx = _FakeRtx()
def __contains__(self, name):
return hasattr(self, name)
def __getitem__(self, name):
return getattr(self, name)
def make_lamni():
lamni_module.dev = _FakeDev()
obj = object.__new__(LamNI)
obj.client = FakeClient()
obj.OMNYTools = FakeOMNYTools()
obj.reconstructor = types.SimpleNamespace(folder_name=None)
obj.tomo_id = -1
obj._progress_proxy = _ProgressProxy(obj.client)
obj._init_tomo_queue()
return obj
def make_flomni():
obj = object.__new__(Flomni)
obj.client = FakeClient()
obj.OMNYTools = FakeOMNYTools()
obj._progress_proxy = FlomniProgressProxy(obj.client)
obj._init_tomo_queue()
return obj
# ── _resolve_type1_projection round-trip: lamni ─────────────────────────────
@pytest.mark.parametrize("stepsize", STEPSIZES)
def test_lamni_resolve_type1_projection_matches_sub_tomo_scan(stepsize):
lamni = make_lamni()
lamni.tomo_angle_stepsize = stepsize
lamni._write_subtomo_to_scilog = lambda subtomo_number: None
recorded = {}
lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.__setitem__(
lamni.progress["projection"], (subtomo_number, angle)
)
for subtomo in range(1, 9):
lamni.sub_tomo_scan(subtomo)
assert len(recorded) > 0
for projection_number, (expected_subtomo, expected_angle) in recorded.items():
subtomo_start, start_angle = lamni._resolve_type1_projection(projection_number)
assert subtomo_start == expected_subtomo
assert start_angle == pytest.approx(expected_angle)
def test_lamni_resolve_type1_projection_out_of_range():
lamni = make_lamni()
lamni.tomo_angle_stepsize = 10.0
with pytest.raises(ValueError):
lamni._resolve_type1_projection(-1)
with pytest.raises(ValueError):
lamni._resolve_type1_projection(10_000)
# ── _resolve_type1_projection round-trip: flomni ────────────────────────────
@pytest.mark.parametrize("stepsize", STEPSIZES)
@pytest.mark.parametrize("angle_range", [180, 360])
def test_flomni_resolve_type1_projection_matches_sub_tomo_scan(stepsize, angle_range):
flomni = make_flomni()
flomni.tomo_angle_range = angle_range
flomni.tomo_angle_stepsize = stepsize
recorded = {}
flomni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.__setitem__(
flomni.progress["projection"], (subtomo_number, angle)
)
for subtomo in range(1, 9):
flomni.sub_tomo_scan(subtomo)
assert len(recorded) > 0
for projection_number, (expected_subtomo, expected_angle) in recorded.items():
subtomo_start, start_angle = flomni._resolve_type1_projection(projection_number)
assert subtomo_start == expected_subtomo
assert start_angle == pytest.approx(expected_angle)
def test_flomni_resolve_type1_projection_out_of_range():
flomni = make_flomni()
flomni.tomo_angle_range = 180
flomni.tomo_angle_stepsize = 10.0
with pytest.raises(ValueError):
flomni._resolve_type1_projection(-1)
with pytest.raises(ValueError):
flomni._resolve_type1_projection(10_000)
# ── tomo_queue_reacquire() / tomo_queue_resume() mechanics (lamni) ──────────
def test_tomo_queue_resume_is_an_alias_for_execute():
lamni = make_lamni()
calls = []
lamni.tomo_queue_execute = lambda start_index=0: calls.append(start_index)
lamni.tomo_queue_resume()
lamni.tomo_queue_resume(start_index=2)
assert calls == [0, 2]
def test_tomo_queue_reacquire_type1_sets_progress_and_status():
lamni = make_lamni()
lamni.tomo_type = 1
lamni.tomo_angle_stepsize = 10.0
index = lamni.tomo_queue_add(label="job0")
lamni._tomo_queue_proxy.update(index, status="done")
lamni.tomo_queue_reacquire(index, projection_number=5)
job = lamni._tomo_queue_proxy.as_list()[index]
assert job["status"] == "incomplete"
subtomo_start, start_angle = lamni._resolve_type1_projection(5)
assert lamni.progress["subtomo"] == subtomo_start
assert lamni.progress["angle"] == pytest.approx(start_angle)
assert lamni.progress["tomo_start_time"] is not None
assert lamni.progress["accumulated_idle_time"] == 0.0
def test_tomo_queue_reacquire_type23_sets_projection_directly():
lamni = make_lamni()
lamni.tomo_type = 2
lamni.golden_max_number_of_projections = 0
index = lamni.tomo_queue_add(label="job0")
lamni._tomo_queue_proxy.update(index, status="done")
lamni.tomo_queue_reacquire(index, projection_number=17)
assert lamni.progress["projection"] == 17
job = lamni._tomo_queue_proxy.as_list()[index]
assert job["status"] == "incomplete"
def test_tomo_queue_reacquire_resets_later_jobs_to_pending_tomo_and_command():
lamni = make_lamni()
lamni.tomo_type = 1
lamni.tomo_angle_stepsize = 10.0
i0 = lamni.tomo_queue_add(label="job0")
i1 = lamni.tomo_queue_add(label="job1")
lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"mokev": 6.2}}})
i3 = lamni.tomo_queue_add(label="job3")
for i in (i0, i1, i3):
lamni._tomo_queue_proxy.update(i, status="done")
lamni.tomo_queue_reacquire(i0, projection_number=0)
jobs = lamni._tomo_queue_proxy.as_list()
assert jobs[i0]["status"] == "incomplete"
for later in jobs[i0 + 1 :]:
assert later["status"] == "pending"
def test_tomo_queue_reacquire_rejects_out_of_range_job_index():
lamni = make_lamni()
with pytest.raises(TomoQueueError):
lamni.tomo_queue_reacquire(0, projection_number=0)
def test_tomo_queue_reacquire_rejects_command_job():
lamni = make_lamni()
lamni.tomo_queue_add_command({"action": "move", "kwargs": {"positions": {"mokev": 6.2}}})
with pytest.raises(TomoQueueError):
lamni.tomo_queue_reacquire(0, projection_number=0)
def test_tomo_queue_reacquire_rejects_when_another_job_is_incomplete():
lamni = make_lamni()
lamni.tomo_type = 1
lamni.tomo_angle_stepsize = 10.0
i0 = lamni.tomo_queue_add(label="job0")
i1 = lamni.tomo_queue_add(label="job1")
lamni._tomo_queue_proxy.update(i1, status="incomplete")
with pytest.raises(TomoQueueError):
lamni.tomo_queue_reacquire(i0, projection_number=0)
def test_tomo_queue_reacquire_allows_targeting_the_incomplete_job_itself():
lamni = make_lamni()
lamni.tomo_type = 1
lamni.tomo_angle_stepsize = 10.0
index = lamni.tomo_queue_add(label="job0")
lamni._tomo_queue_proxy.update(index, status="incomplete")
lamni.tomo_queue_reacquire(index, projection_number=0)
job = lamni._tomo_queue_proxy.as_list()[index]
assert job["status"] == "incomplete"