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 <noreply@anthropic.com>
This commit is contained in:
x01dc
2026-07-21 13:22:01 +02:00
co-authored by Claude Sonnet 5
parent db18929742
commit 12b2538333
3 changed files with 232 additions and 5 deletions
@@ -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")
+3 -5
View File
@@ -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()`
@@ -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