diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 0099548..6e951b7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -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: diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index e360738..a8b1771 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -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), 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 b1178ce..7e19c67 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_angles.py @@ -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)]