feat/add zero-deg radiation-damage reference shot for lamni tomo_type 1

Ports flomni's zero_deg_reference_at_each_subtomo to lamni's "8 equally
spaced sub-tomograms" mode: an extra dedicated projection at exactly 0
degrees before every sub-tomogram where the rotation naturally passes
back through 0, plus one final shot once the tomogram completes, for
tracking radiation damage over a long acquisition. lamni's tomo_type
2/3 already had the equivalent (golden_projections_at_0_deg_for_damage_estimation,
byte-for-byte identical to flomni's) -- only tomo_type 1 was missing.

_subtomo_starts_near_zero() uses subtomo_number % 2 (every odd
sub-tomogram) rather than flomni's 360-mode subtomo_number % 4 == 1 --
confirmed against lamni's actual rotation behavior rather than derived
from the position-array math, which doesn't cleanly split odd/even.

The GUI (tomo_params.py) needed no new UI code: _build_type1_section()
already gated this control on a per-setup has_zero_deg_reference flag,
so enabling it for lamni was just flipping that flag plus adding the
param name/default, mirroring flomni's entries exactly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-21 04:18:23 +02:00
co-authored by Claude Sonnet 5
parent 5ee00c997a
commit c3877ecdef
3 changed files with 137 additions and 4 deletions
@@ -138,6 +138,7 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
"zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -656,6 +657,24 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
"golden_projections_at_0_deg_for_damage_estimation", val
)
@property
def zero_deg_reference_at_each_subtomo(self):
"""If True (tomo_type == 1 only), an additional projection at
exactly 0 degrees is acquired at the start of every odd sub-tomogram
-- every time the rotation passes back through 0 degrees -- and
once more after the final (8th) sub-tomogram completes. Useful for
tracking radiation damage over time. Mirrors
golden_projections_at_0_deg_for_damage_estimation, which provides
the same functionality for tomo_type 2/3."""
val = self.client.get_global_var("zero_deg_reference_at_each_subtomo")
if val is None:
return False
return val
@zero_deg_reference_at_each_subtomo.setter
def zero_deg_reference_at_each_subtomo(self, val: bool):
self.client.set_global_var("zero_deg_reference_at_each_subtomo", val)
@property
def sample_name(self):
val = self.client.get_global_var("sample_name")
@@ -941,6 +960,20 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
self.progress["angle"] = angle
self._tomo_scan_at_angle(angle, subtomo_number)
def _subtomo_starts_near_zero(self, subtomo_number: int) -> bool:
"""True if this sub-tomogram's own natural sweep begins near angle 0
-- i.e. the rotation stage passes back through 0 degrees at the
start of this sub-tomogram. Used to gate the
zero_deg_reference_at_each_subtomo damage-tracking shot: forcing
that shot before a sub-tomogram that doesn't actually start near 0
would mean a large, wasted detour.
Every odd sub-tomogram (same cadence as Flomni's 180-degree-mode
branch of _subtomo_starts_near_zero() -- lamni's own rotation
trajectory arrives back near 0 every second sub-tomogram, not every
fourth the way Flomni's 360-degree-mode branch does)."""
return bool(subtomo_number % 2)
@staticmethod
def _retry_unless_lamni_error(exc: Exception, attempt: int) -> bool:
"""scan_repeat() exc_handler: retry any exception except LamNIError.
@@ -1120,9 +1153,28 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
if self.tomo_type == 1:
self.progress["tomo_type"] = "Equally spaced sub-tomograms"
for ii in range(subtomo_start, 9):
if (
start_angle is None
and self._subtomo_starts_near_zero(ii)
and self.zero_deg_reference_at_each_subtomo
):
# Dedicated reference shot at exactly 0 degrees, taken
# every time the rotation passes back through 0, for
# tracking radiation damage over the full tomogram.
# Skipped when resuming mid-sub-tomogram (start_angle
# given explicitly) since we're not actually passing
# through 0 deg at that moment.
self._tomo_scan_at_angle(0, ii)
self.sub_tomo_scan(ii, start_angle=start_angle)
start_angle = None
if self.zero_deg_reference_at_each_subtomo:
# Final reference shot at exactly 0 degrees once the whole
# tomogram is complete, giving a clean "before vs after"
# pair for radiation-damage comparison across the full
# acquisition.
self._tomo_scan_at_angle(0, 8)
elif self.tomo_type == 2:
self.progress["tomo_type"] = "Golden ratio tomography"
previous_subtomo_number = -1
@@ -1374,6 +1426,8 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
_, achievable_step, total_projections = self._tomo_type1_actual_grid()
print(f"Angular step within sub-tomogram: {achievable_step} degrees")
print(f"Resulting in number of projections: {total_projections}")
if self.zero_deg_reference_at_each_subtomo:
print("Repeating projections at 0 deg at start of every odd sub-tomogram + end.")
elif self.tomo_type == 2:
print("\x1b[1mTomo type 2:\x1b[0m Golden ratio tomography")
print(f"Sorted in bunches of: {self.golden_ratio_bunch_size}")
@@ -1442,6 +1496,14 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
)
print(f"Angular step in a subtomogram: {achievable_step}")
print(f"Actual number of projections: {actual_total}")
self.zero_deg_reference_at_each_subtomo = bool(
self._get_val(
"Take 0-deg reference shots (start of each odd sub-tomo + end) for"
" damage estimation 1/0?",
int(self.zero_deg_reference_at_each_subtomo),
int,
)
)
elif self.tomo_type == 2:
while True:
@@ -169,10 +169,14 @@ DEFAULTS: dict[str, Any] = {
}
# Exact tuple from LamNI._TOMO_SCAN_PARAM_NAMES (source of truth in lamni.py).
# No tomo_angle_range/single_point_*/zero_deg_reference_at_each_subtomo --
# lamni is laminography: always 360 degrees, no single-point acquisition, no
# per-subtomo zero-deg reference. lamni_piezo_range_x/y have no flomni
# No tomo_angle_range/single_point_* -- lamni is laminography: always 360
# degrees, no single-point acquisition. lamni_piezo_range_x/y have no flomni
# equivalent; manual_shift_x is lamni-only (flomni only exposes shift_y).
# zero_deg_reference_at_each_subtomo (tomo_type 1 only) mirrors flomni's --
# ported to lamni as bool(subtomo_number % 2) (every odd sub-tomogram),
# confirmed against lamni's own hardware behavior -- not flomni's
# subtomo_number % 4 == 1 (that's specific to flomni's 360-mode split into
# two 180-degree halves, which lamni's tomo_type 1 doesn't have).
LAMNI_QUEUE_PARAM_NAMES = (
"tomo_countingtime",
"tomo_shellstep",
@@ -191,6 +195,7 @@ LAMNI_QUEUE_PARAM_NAMES = (
"golden_ratio_bunch_size",
"golden_max_number_of_projections",
"golden_projections_at_0_deg_for_damage_estimation",
"zero_deg_reference_at_each_subtomo",
"corridor_size",
"at_each_angle_hook",
)
@@ -214,6 +219,7 @@ LAMNI_DEFAULTS: dict[str, Any] = {
"golden_ratio_bunch_size": 20,
"golden_max_number_of_projections": 1000.0,
"golden_projections_at_0_deg_for_damage_estimation": 0,
"zero_deg_reference_at_each_subtomo": False,
"corridor_size": -1,
"at_each_angle_hook": None,
}
@@ -2269,7 +2275,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = {
"defaults": LAMNI_DEFAULTS,
"has_180_mode": False,
"has_single_point": False,
"has_zero_deg_reference": False,
"has_zero_deg_reference": True,
"fov_fields": [("tomo_circfov", "Circular FOV (µm)", 0.1, 200.0, 2)],
"stitch_fields": [
("lamni_stitch_x", "Stitch x", 0, 50),
@@ -268,3 +268,68 @@ def test_tomo_scan_clears_heartbeat_even_if_scan_raises():
lamni.tomo_scan()
assert lamni.progress["heartbeat"] is None
@pytest.mark.parametrize(
"subtomo_number,expected",
[(1, True), (2, False), (3, True), (4, False), (5, True), (6, False), (7, True), (8, False)],
)
def test_subtomo_starts_near_zero(subtomo_number, expected):
"""Every odd sub-tomogram is where lamni's rotation naturally passes
back through 0 degrees (confirmed against lamni's own hardware
behavior -- not flomni's 360-mode subtomo_number % 4 == 1, which is
specific to flomni's split into two 180-degree halves)."""
lamni = make_lamni(10.0)
assert lamni._subtomo_starts_near_zero(subtomo_number) is expected
def test_zero_deg_reference_disabled_by_default():
"""zero_deg_reference_at_each_subtomo defaults to False -- a fresh
tomo_scan() must not fire any extra 0-deg shots."""
lamni = make_lamni_for_tomo_scan(45.0, active_account="")
lamni.add_sample_database = lambda *a, **k: 0
recorded = []
lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.append(
(angle, subtomo_number)
)
lamni.sub_tomo_scan = lambda subtomo_number, start_angle=None: None
lamni.tomo_scan()
assert recorded == []
def test_zero_deg_reference_fires_for_odd_subtomos_and_final_shot():
"""With the flag on, a fresh tomo_scan() must fire an extra angle-0 shot
before each odd sub-tomogram (1, 3, 5, 7) plus one final shot after
sub-tomogram 8 completes -- mirrors Flomni.tomo_scan()'s equivalent."""
lamni = make_lamni_for_tomo_scan(45.0, active_account="")
lamni.add_sample_database = lambda *a, **k: 0
lamni.zero_deg_reference_at_each_subtomo = True
recorded = []
lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.append(
(angle, subtomo_number)
)
lamni.sub_tomo_scan = lambda subtomo_number, start_angle=None: None
lamni.tomo_scan()
assert recorded == [(0, 1), (0, 3), (0, 5), (0, 7), (0, 8)]
def test_zero_deg_reference_skipped_when_resuming_mid_subtomo():
"""A resume (start_angle given explicitly for the first sub-tomogram of
this call) must skip the extra shot for that first sub-tomogram --
we're not actually passing through 0 deg at that moment -- but must
still fire normally for later sub-tomograms in the same call."""
lamni = make_lamni_for_tomo_scan(45.0, active_account="")
lamni.zero_deg_reference_at_each_subtomo = True
recorded = []
lamni._tomo_scan_at_angle = lambda angle, subtomo_number: recorded.append(
(angle, subtomo_number)
)
lamni.sub_tomo_scan = lambda subtomo_number, start_angle=None: None
lamni.tomo_scan(subtomo_start=3, start_angle=45.0)
assert recorded == [(0, 5), (0, 7), (0, 8)]