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