From 0830a7175985e7622a18bb4e395c55b35d3aaa77 Mon Sep 17 00:00:00 2001 From: x12sa Date: Tue, 20 Oct 2026 15:08:12 +0200 Subject: [PATCH] feat(lamni): number_of_projections argument for tomo_alignment_scan Optional, default 13 (every 30 deg, 0 and 360 inclusive). The angles actually used are written to ptychotomoalign_scannum.txt. Values that are not an integer >= 2 raise ValueError before anything moves. Co-Authored-By: Claude Opus 5.5 --- .../bec_ipython_client/plugins/LamNI/lamni.py | 44 +++++++++++++------ docs/user/ptychography/lamni.md | 4 +- .../test_lamni_tomo_alignment_scan.py | 30 +++++++++++++ 3 files changed, 63 insertions(+), 15 deletions(-) diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 3338699e..d8af53b6 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -1636,14 +1636,18 @@ class LamNI( self.tomo_reconstruct() @staticmethod - def alignment_scan_angles() -> list[float]: - """Angles of tomo_alignment_scan(): every 30 degrees from 0 to 360 - inclusive. 360 is the same orientation as 0; it is measured anyway as + def alignment_scan_angles(number_of_projections: int = 13) -> list[float]: + """Angles of tomo_alignment_scan(): number_of_projections equally + spaced angles from 0 to 360 inclusive (default 13, i.e. every 30 + degrees). 360 is the same orientation as 0; it is measured anyway as a last data point confirming the sample did not drift during the run.""" - return list(np.linspace(0, 360, num=13)) + return list(np.linspace(0, 360, num=number_of_projections)) def write_alignment_scan_numbers( - self, first_scan: int, scans: list[int] | None = None + self, + first_scan: int, + scans: list[int] | None = None, + angles: list[float] | None = None, ) -> None: """Write the scan-number/angle/offset log consumed by BEC_ptycho_align. @@ -1652,12 +1656,15 @@ class LamNI( (2*lamni_stitch_x+1)*(2*lamni_stitch_y+1) scans). ``scans`` (optional): the actual first scan number of the angles - acquired so far (possibly fewer than all 13), used to rewrite the + acquired so far (possibly fewer than all angles), used to rewrite the file during/after the scan when a retried angle consumed extra scan numbers. Angles not yet acquired are predicted from the last known one, stepping by the number of stitch tiles per projection. Without ``scans`` the whole list is predicted from ``first_scan``. + ``angles`` (optional): the alignment angles; defaults to + alignment_scan_angles(). + File format, as read by BEC_ptycho_align (MATLAB): line 1 scans first scan number per angle line 2 theta angle [deg] @@ -1667,7 +1674,8 @@ class LamNI( the LaMNI ellipse fit uses just the clicks, scans and theta. lamni_compute_additional_correction_xeye_mu() returns mm, hence *1000. """ - angles = self.alignment_scan_angles() + if angles is None: + angles = self.alignment_scan_angles() scans_per_angle = (2 * self.lamni_stitch_x + 1) * (2 * self.lamni_stitch_y + 1) scans = list(scans) if scans else [first_scan] while len(scans) < len(angles): @@ -1687,10 +1695,10 @@ class LamNI( f.write(" ".join(f"{x:.4f}" for x in x_um) + "\n") f.write(" ".join(f"{y:.4f}" for y in y_um) + "\n") - def tomo_alignment_scan(self, force: bool = False) -> None: + def tomo_alignment_scan(self, number_of_projections: int = 13, force: bool = False) -> None: """Perform a laminogram alignment scan: a quick ptychography scan at - 13 angles, every 30 degrees from 0 to 360 inclusive (see - alignment_scan_angles()), using whatever + number_of_projections equally spaced angles from 0 to 360 inclusive + (default 13, every 30 degrees; see alignment_scan_angles()), 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 @@ -1701,8 +1709,16 @@ class LamNI( scan numbers. Args: + number_of_projections: number of angles, 0 and 360 included + (at least 2). force: skip the X-ray-eye-alignment check below without prompting. """ + if int(number_of_projections) != number_of_projections or number_of_projections < 2: + raise ValueError( + f"number_of_projections must be an integer >= 2, got {number_of_projections}." + ) + number_of_projections = int(number_of_projections) + if self.client.get_global_var("tomo_fit_xray_eye") is None: if not self._confirm_sequence_override( "No X-ray-eye alignment fit is loaded (or it was invalidated " @@ -1728,9 +1744,9 @@ class LamNI( print("Aborting alignment scan.") return - self.write_alignment_scan_numbers(bec.queue.next_scan_number) + angles = self.alignment_scan_angles(number_of_projections) + self.write_alignment_scan_numbers(bec.queue.next_scan_number, angles=angles) - angles = self.alignment_scan_angles() alignment_scan_numbers = [] self.alignment_scan_progress.reset() @@ -1765,7 +1781,9 @@ class LamNI( # Keep the file current after every angle, so an interrupted run # still leaves correct first-of-stitch-series numbers behind. - self.write_alignment_scan_numbers(first_scan_per_angle[0], scans=first_scan_per_angle) + self.write_alignment_scan_numbers( + first_scan_per_angle[0], scans=first_scan_per_angle, angles=angles + ) self.alignment_scan_progress.update(angle_index=idx + 1, angle=angle) self._lamnigui_update_alignment_progress() diff --git a/docs/user/ptychography/lamni.md b/docs/user/ptychography/lamni.md index bee8275f..7c618e20 100644 --- a/docs/user/ptychography/lamni.md +++ b/docs/user/ptychography/lamni.md @@ -169,8 +169,8 @@ The correction tables are read from the files written by `BEC_ptycho_align`, wit The alignment scan itself already uses the x-ray eye correction and any correction tables that are loaded. So the ptychographic alignment measures what is left over after those corrections, which is why a second iteration with `read_additional_correction_2()` is useful. -* `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 13 points, every 30 degrees from 0 to 360 inclusive (360 repeats 0 as a drift check), 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. +* `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 runs its own set of equally spaced angles from 0 to 360 inclusive (360 repeats 0 as a drift check), independent of the main tomogram's settings. By default this is 13 points, every 30 degrees; see `number_of_projections` below. +* `lamni.tomo_alignment_scan()` perform the alignment scan. Optional argument `number_of_projections` (default 13) sets the number of angles, 0 and 360 both counted, e.g. `lamni.tomo_alignment_scan(number_of_projections=25)` for every 15 degrees. It must be at least 2. 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 `BEC_ptycho_align` (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')` 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 index d81ab1ce..9a029e03 100644 --- a/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py +++ b/tests/tests_bec_ipython_client/test_lamni_tomo_alignment_scan.py @@ -264,3 +264,33 @@ def test_write_alignment_scan_numbers_lines_3_4_are_x_and_y_in_um(tmp_path, monk lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines() assert [float(v) for v in lines[2].split()] == pytest.approx([12.5] * 13) assert [float(v) for v in lines[3].split()] == pytest.approx([-3.25] * 13) + + +def test_tomo_alignment_scan_number_of_projections(tmp_path, monkeypatch): + lamni = make_lamni(monkeypatch, xray_eye_fit=[[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) + monkeypatch.setattr( + "os.path.expanduser", + lambda p: p.replace("~", str(tmp_path)) if p.startswith("~") else p, + ) + calls = [] + + def _fake_projection(angle, _internal=False, **kw): + calls.append(angle) + builtins.__dict__["bec"].queue.next_scan_number += 1 + + lamni.tomo_scan_projection = _fake_projection + + lamni.tomo_alignment_scan(number_of_projections=5) + + assert calls == [0.0, 90.0, 180.0, 270.0, 360.0] + lines = (tmp_path / "data/raw/logs/ptychotomoalign_scannum.txt").read_text().splitlines() + assert [int(s) for s in lines[0].split()] == [100, 101, 102, 103, 104] + assert [float(a) for a in lines[1].split()] == [0.0, 90.0, 180.0, 270.0, 360.0] + + +@pytest.mark.parametrize("n", [0, 1, 2.5]) +def test_tomo_alignment_scan_rejects_invalid_number_of_projections(monkeypatch, n): + 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, **kw: pytest.fail("must not scan") + with pytest.raises(ValueError): + lamni.tomo_alignment_scan(number_of_projections=n)