From 12b2538333ec6fee0676c90d35054180a63cb7c6 Mon Sep 17 00:00:00 2001 From: x01dc Date: Tue, 21 Jul 2026 13:22:01 +0200 Subject: [PATCH] feat/add lamni.tomo_alignment_scan() -- dedicated fine-alignment scan Replaces the awkward documented workaround (configure a full tomo_type 1 setup with 96 projections, then launch just sub_tomo_scan(1, 0)) with a dedicated command, ported from flomni's tomo_alignment_scan(): adjust tomo_parameters() (FOV/step/counting time), then call lamni.tomo_alignment_scan() directly -- no tomo_type/sub-tomogram bookkeeping involved, matching flomni's clean two-step workflow. Runs 12 points evenly spaced across the full 360 degrees (lamni has no 180-degree symmetry the way flomni does, so unlike flomni's 5-point/ 180-degree scan, this covers the full circle -- point count matches what the old workaround's docs defaulted to, endpoint=False since 360==0 degrees). Aborts if x-ray-eye alignment hasn't been done yet (tomo_fit_xray_eye unset), mirroring flomni's equivalent guard. write_alignment_scan_numbers() writes the same 4-line scan-number/ angle/offset log flomni's version does, to ~/data/raw/logs/ptychotomoalign_scannum.txt for SPEC_ptycho_align.m, also printed at the console (flomni's own console-print equivalent is dead/commented-out code; lamni's actually prints). Scope note: flomni's version conditionally skips its eye-out/optics-in transition when already in measurement condition with feedback running, to avoid an unneeded interferometer reset -- lamni has no equivalent helpers for that check, so this calls leye_out() unconditionally instead. Left as a possible follow-up, not in scope here. Item 6 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/ FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md. Documented in docs/user/ptychography/lamni.md's "Fine alignment" section. Co-Authored-By: Claude Sonnet 5 --- .../bec_ipython_client/plugins/LamNI/lamni.py | 94 ++++++++++++ docs/user/ptychography/lamni.md | 8 +- .../test_lamni_tomo_alignment_scan.py | 135 ++++++++++++++++++ 3 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index e495ec3..24214bb 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -1068,6 +1068,100 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools optim_trajectory_corridor=corridor_size, ) + def write_alignment_scan_numbers(self, first_scan: int) -> None: + """Write the scan-number/angle/offset log consumed by SPEC_ptycho_align.m. + + Mirrors Flomni.write_alignment_scan_numbers() exactly (same 4-line + format), adapted to lamni's own alignment-scan angle set (12 points + across the full 360 degrees, see tomo_alignment_scan()) and its own + x-ray-eye-fit offset source (lamni_compute_additional_correction_xeye_mu() + instead of get_alignment_offset()). Assumes exactly one scan per + alignment point (no stitching) -- same assumption flomni's own + version makes. + """ + angles = list(np.linspace(0, 360, num=12, endpoint=False)) + scans = [first_scan + k for k in range(len(angles))] + + x_vals = [] + for angle in angles: + x, _y = self.lamni_compute_additional_correction_xeye_mu(angle) + x_vals.append(x) + + zeros = [0] * len(angles) + + file = os.path.expanduser("~/data/raw/logs/ptychotomoalign_scannum.txt") + os.makedirs(os.path.dirname(file), exist_ok=True) + with open(file, "w") as f: + f.write(" ".join(map(str, scans)) + "\n") + f.write(" ".join(map(str, angles)) + "\n") + f.write(" ".join(f"{x:.2f}" for x in x_vals) + "\n") + f.write(" ".join(map(str, x_vals)) + "\n") + + def tomo_alignment_scan(self) -> None: + """Perform a laminogram alignment scan: a quick ptychography scan at + 12 angles evenly spaced across the full 360 degrees, using whatever + tomo_parameters() are currently set (FOV/step/counting time -- + tomo_type and requested projection count are ignored, same as + Flomni.tomo_alignment_scan()). Collects all scan numbers acquired + during the alignment, writes them (with angles and the existing + x-ray-eye-fit offset at each angle) to + ~/data/raw/logs/ptychotomoalign_scannum.txt for SPEC_ptycho_align.m, + prints them, and creates a scilog entry summarising the alignment + scan numbers. + """ + if self.client.get_global_var("tomo_fit_xray_eye") is None: + print("It appears that the xrayeye alignment was not performed or loaded. Aborting.") + return + + bec = builtins.__dict__.get("bec") + dev = builtins.__dict__.get("dev") + + self.leye_out() + + self.write_alignment_scan_numbers(bec.queue.next_scan_number) + + angles = list(np.linspace(0, 360, num=12, endpoint=False)) + alignment_scan_numbers = [] + + for angle in angles: + successful = False + print(f"Starting LamNI scan for angle {angle}") + while not successful: + try: + start_scan_number = bec.queue.next_scan_number + self.tomo_scan_projection(angle) + except AlarmBase as exc: + if exc.alarm_type == "TimeoutError": + bec.queue.request_queue_reset() + time.sleep(2) + else: + raise exc + + end_scan_number = bec.queue.next_scan_number + for scan_nr in range(start_scan_number, end_scan_number): + alignment_scan_numbers.append(scan_nr) + + successful = True + + umv(dev.lsamrot, 0) + self.OMNYTools.printgreenbold( + "\n\nAlignment scan finished. Please run SPEC_ptycho_align and load the new fit" + " by lamni.read_additional_correction()." + ) + + scan_list_str = ", ".join(str(s) for s in alignment_scan_numbers) + print(f"\nAlignment scan numbers ({len(alignment_scan_numbers)} total): {scan_list_str}") + print(f"Angles: {', '.join(str(a) for a in angles)}") + + scilog_content = ( + f"Alignment scan finished.\n" + f"Sample: {self.sample_name}\n" + f"Number of alignment scans: {len(alignment_scan_numbers)}\n" + f"Alignment scan numbers: {scan_list_str}\n" + ) + print(scilog_content) + self.write_to_scilog(scilog_content, ["alignmentscan"]) + def tomo_reconstruct(self, base_path="~/data/raw/logs/reconstruction_queue"): """write the tomo reconstruct file for the reconstruction queue""" bec = builtins.__dict__.get("bec") diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index b8df89b..673e01d 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -62,11 +62,9 @@ or with the shutter left open: `lamni.xrayeye_update_frame(keep_shutter_open=Tru The sample fine alignment can be obtained using ptychography. For this a short laminogram has to be recorded. -* `lamni.tomo_parameters()` adjust the parameters for a coarse scan: A large step size and large FOV. Especially select **FOV offset = 0** and **number of projections = 96** (only one sub-laminogram will be recorded). -* `lamni.sub_tomo_scan(1,0)` record one sub-laminogram -* use the corresponding scan numbers in `SPEC_ptycho_align.m` -* Record a last projection for all scans to reconstruct `lamni.tomo_scan_projection(0)` and wait for the reconstructions to be complete -* Run `SPEC_ptycho_align.m` (in Matlab, **force ptycho=1**, and **correct scan numbers**) +* `lamni.tomo_parameters()` adjust the ptychographic scan parameters for the alignment scan (FOV/step size/counting time) — `tomo_type` and number of projections are ignored, since the alignment scan always runs its own fixed 12 points spread evenly across the full 360 degrees, independent of the main tomogram's settings. +* `lamni.tomo_alignment_scan()` perform the alignment scan. Requires x-ray-eye alignment to have already been done — it will abort with a message otherwise. Scan numbers, angles and the existing x-ray-eye-fit offset at each angle are written to `~/data/raw/logs/ptychotomoalign_scannum.txt` and also printed at the end. +* Run `SPEC_ptycho_align.m` (in Matlab, **force ptycho=1**, and **correct scan numbers**) using the printed/logged scan numbers. * Click the sample position in the Matlab GUI and then load the generated file by, for example `lamni.read_additional_correction('/sls/X12SA/data/e20632/Data10/cxs_software/ptycho/correction_lamni_um_S05389_lamni_fit.txt')` * With this alignment a second iteration could be performed. To read the second correction file use `lamni.read_additional_correction_2()` diff --git a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py new file mode 100644 index 0000000..2fff068 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py @@ -0,0 +1,135 @@ +"""Tests for lamni.tomo_alignment_scan()/write_alignment_scan_numbers(), +ported from flomni (see csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/ +FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 6). + +Uses a bare LamNI instance (bypassing __init__'s heavy side effects), same +pattern as test_lamni_tomo_angles.py's make_lamni_for_tomo_scan(). +""" + +import builtins +import types + +import numpy as np +import pytest + +import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni as lamni_module +from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI, _ProgressProxy + + +class FakeClient: + 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 FakeQueue: + def __init__(self, start=100): + self.next_scan_number = start + + def request_queue_reset(self): + pass + + +class FakeAxis: + value = 0 + + +class FakeDev: + def __init__(self): + self.lsamrot = FakeAxis() + + def __contains__(self, name): + return hasattr(self, name) + + def __getitem__(self, name): + return getattr(self, name) + + +def make_lamni(monkeypatch, xray_eye_fit=None): + obj = object.__new__(LamNI) + obj.client = FakeClient() + obj._progress_proxy = _ProgressProxy(obj.client) + obj.tomo_id = -1 + obj.sample_name = "test" + obj.OMNYTools = types.SimpleNamespace(printgreenbold=lambda msg: None) + obj._scilog_calls = [] + obj.write_to_scilog = lambda content, tags: obj._scilog_calls.append((content, tags)) + obj.leye_out = lambda: None + + if xray_eye_fit is not None: + obj.client.set_global_var("tomo_fit_xray_eye", xray_eye_fit) + + fake_bec = types.SimpleNamespace(queue=FakeQueue()) + fake_dev = FakeDev() + monkeypatch.setitem(builtins.__dict__, "bec", fake_bec) + monkeypatch.setitem(builtins.__dict__, "dev", fake_dev) + monkeypatch.setattr(lamni_module, "umv", lambda *a: None, raising=False) + + return obj + + +def test_tomo_alignment_scan_aborts_without_xray_eye_fit(monkeypatch): + lamni = make_lamni(monkeypatch, xray_eye_fit=None) + calls = [] + lamni.tomo_scan_projection = lambda angle: calls.append(angle) + + lamni.tomo_alignment_scan() + + assert calls == [] + assert lamni._scilog_calls == [] + + +def test_tomo_alignment_scan_runs_12_projections_across_360(monkeypatch): + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + calls = [] + + def _fake_projection(angle): + calls.append(angle) + builtins.__dict__["bec"].queue.next_scan_number += 1 + + lamni.tomo_scan_projection = _fake_projection + + lamni.tomo_alignment_scan() + + expected_angles = list(np.linspace(0, 360, num=12, endpoint=False)) + assert calls == expected_angles + assert len(lamni._scilog_calls) == 1 + content, tags = lamni._scilog_calls[0] + assert tags == ["alignmentscan"] + assert "Number of alignment scans: 12" in content, content + + +def test_tomo_alignment_scan_rotates_back_to_zero(monkeypatch): + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + lamni.tomo_scan_projection = lambda angle: None + umv_calls = [] + monkeypatch.setattr(lamni_module, "umv", lambda *a: umv_calls.append(a), raising=False) + + lamni.tomo_alignment_scan() + + assert umv_calls[-1][1] == 0 # umv(dev.lsamrot, 0) + + +def test_write_alignment_scan_numbers_format(tmp_path, monkeypatch): + lamni = make_lamni(monkeypatch, xray_eye_fit=[[1.0, 0.0, 0.5], [0.0, 0.0, 0.0]]) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + + lamni.write_alignment_scan_numbers(100) + + log_file = tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt" + lines = log_file.read_text().splitlines() + assert len(lines) == 4 + scans = [int(s) for s in lines[0].split()] + angles = [float(a) for a in lines[1].split()] + assert scans == list(range(100, 112)) + assert angles == list(np.linspace(0, 360, num=12, endpoint=False)) + assert len(lines[2].split()) == 12 + assert len(lines[3].split()) == 12