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