feat(lamni): block tomo parameters when a stitch tile has too few points

With a circular FOV and stitching, corner tiles can hold far fewer Fermat
points than the center tile. A tile below the scan minimum aborts the
projection, and the retry then stops the tomogram. The point-count check
now uses the smallest tile (new lamni_smallest_tile_position_count()):
the GUI refuses submit / add-to-queue, and tomo_parameters on the command
line skips the confirm question and repeats the scan-geometry prompts
until the count is sufficient.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017NrH3poJsYBCTVq2Y9fuHZ
This commit is contained in:
x12saandClaude Opus 5.5 committed 2026-10-21 16:47:27 +02:00
1 parent f7c6b64435
commit ea74909ee0
7 files changed
+210 -112

No files matched your search

@@ -22,6 +22,7 @@ from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import
OMNYTools,
PtychoReconstructor,
TomoIDManager,
lamni_smallest_tile_position_count,
stitch_tile_offsets,
)
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.angle_file import (
@@ -2563,49 +2564,32 @@ class LamNI(
# ------------------------------------------------------------------
def _expected_fermat_position_count(self) -> int:
"""Predict the number of Fermat-spiral scan positions the current
settings would produce per projection tile, using the exact same
algorithm LamniFermatScan runs at scan time
(LamniFermatScan.get_lamni_fermat_spiral_pos()) -- so a
too-few-points configuration (which the scan server would only
catch by aborting with ScanAbortion once the scan actually starts)
can be caught here instead, while just looking at tomo_parameters().
"""Predict the number of Fermat-spiral scan positions of the stitch
tile with the fewest points, using the exact same algorithm
LamniFermatScan runs at scan time (see
lamni_smallest_tile_position_count()) -- so a too-few-points
configuration (which the scan server would only catch by aborting
with ScanAbortion once the scan actually starts) can be caught here
instead. With a circular FOV the corner tiles can hold far fewer
points than the center tile; any tile below the minimum aborts the
whole projection.
center_x/center_y/shift_x/shift_y are deliberately left at their
defaults (0): they only shift the final absolute position, applied
after the FOV/circular-FOV keep-or-discard check, so they can never
affect the resulting count. Computed for the center stitch tile
(stitch_x=stitch_y=0) at angle=0, as a representative estimate --
the real per-projection count can vary a bit with angle/stitch
tile, since the circular FOV crop (tomo_circfov) is checked against
the rotated stage position.
The real scan also draws a fresh random sub-step offset on every
call (see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a
further small amount of per-projection count variation on top of
the angle/stitch dependence above. A fixed seed is used here only so
that this estimate itself is reproducible from one call to the next.
center_x/center_y/shift_x/shift_y don't enter: they only shift the
final absolute position, applied after the FOV/circular-FOV
keep-or-discard check. The real scan also draws a fresh random
sub-step offset on every call, so the actual count can differ by a
few points per angle.
"""
from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan
positions = LamniFermatScan.get_lamni_fermat_spiral_pos(
-abs(self.lamni_piezo_range_x / 2),
abs(self.lamni_piezo_range_x / 2),
-abs(self.lamni_piezo_range_y / 2),
abs(self.lamni_piezo_range_y / 2),
step=self.tomo_shellstep,
spiral_type=0,
center=False,
angle=0.0,
stitch_x=0,
stitch_y=0,
stitch_overlap=self.tomo_stitch_overlap,
fovx=self.lamni_piezo_range_x,
fovy=self.lamni_piezo_range_y,
fov_circular=self.tomo_circfov,
seed=0,
count, _tile = lamni_smallest_tile_position_count(
self.lamni_piezo_range_x,
self.lamni_piezo_range_y,
self.tomo_shellstep,
self.lamni_stitch_x,
self.lamni_stitch_y,
self.tomo_stitch_overlap,
self.tomo_circfov,
)
return len(positions)
return count
@staticmethod
def _fermat_min_positions() -> int:
@@ -2615,6 +2599,50 @@ class LamNI(
return LamniFermatScan.MIN_POSITIONS
def _print_fermat_position_estimate(self) -> bool:
"""Print the estimated Fermat point count (smallest stitch tile);
True if it is below the scan's minimum, i.e. the scan would abort."""
expected_positions = self._expected_fermat_position_count()
min_positions = self._fermat_min_positions()
label = "Estimated Fermat scan points"
if self.lamni_stitch_x or self.lamni_stitch_y:
label += " (smallest stitch tile)"
if expected_positions < min_positions:
print(
f"{label}: {expected_positions} (WARNING: below the minimum of"
f" {min_positions} -- the scan will abort when run)"
)
return True
print(f"{label}: {expected_positions}")
return False
def _prompt_scan_geometry(self):
"""tomo_parameters_cli() prompts that determine the Fermat point count."""
self.tomo_shellstep = self._get_val(
"<step size> um", self.tomo_shellstep, float, min_=0.025, max_=20.0
)
self.lamni_piezo_range_x = self._get_val(
"<piezo range X (max 80)> um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9
)
self.lamni_piezo_range_y = self._get_val(
"<piezo range Y (max 80)> um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9
)
# min 0: a negative stitch makes stitch_tile_offsets() empty, so a
# projection would silently run zero scans. stitch n gives n+1 tiles.
# max 50 = the GUI's range.
self.lamni_stitch_x = self._get_val(
"<stitch X> (n+1 tiles)", self.lamni_stitch_x, int, min_=0, max_=50
)
self.lamni_stitch_y = self._get_val(
"<stitch Y> (n+1 tiles)", self.lamni_stitch_y, int, min_=0, max_=50
)
self.tomo_stitch_overlap = self._get_val(
"<stitch overlap> um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0
)
self.tomo_circfov = self._get_val(
"<circular FOV> um", self.tomo_circfov, float, min_=0.0, max_=500.0
)
def tomo_parameters_cli(self):
"""Print and interactively update the tomo parameters on the command line.
tomo_parameters() opens the GUI version."""
@@ -2631,15 +2659,7 @@ class LamNI(
)
print(f"Stitching overlap = {self.tomo_stitch_overlap}")
print(f"Circular FOV diam <microns> = {self.tomo_circfov}")
expected_positions = self._expected_fermat_position_count()
min_positions = self._fermat_min_positions()
if expected_positions < min_positions:
print(
f"Estimated Fermat scan points per projection: {expected_positions} "
f"(WARNING: below the minimum of {min_positions} -- the scan will abort when run)"
)
else:
print(f"Estimated Fermat scan points per projection: {expected_positions}")
too_few_positions = self._print_fermat_position_estimate()
print(f"Reconstruction queue name = {self.ptycho_reconstruct_foldername}")
print(f"Frames per trigger (burst) = {self.frames_per_trigger}")
print("FOV offset rotates to find the ROI; initial values determined in Xrayeye alignment.")
@@ -2688,36 +2708,23 @@ class LamNI(
self._print_angle_file_summary()
print(f"\nSample name: {self.sample_name}\n")
if self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
if too_few_positions:
print("Too few Fermat scan points -- the scan parameters have to be adjusted.")
elif self.OMNYTools.yesno("Are these parameters correctly set for your scan?", "y"):
print("OK. continue.")
return
self.tomo_countingtime = self._get_val(
"<ctime per frame> s", self.tomo_countingtime, float, min_=0.001, max_=100.0
)
self.tomo_shellstep = self._get_val(
"<step size> um", self.tomo_shellstep, float, min_=0.025, max_=20.0
)
self.lamni_piezo_range_x = self._get_val(
"<piezo range X (max 80)> um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9
)
self.lamni_piezo_range_y = self._get_val(
"<piezo range Y (max 80)> um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9
)
# min 0: a negative stitch makes stitch_tile_offsets() empty, so a
# projection would silently run zero scans. stitch n gives n+1 tiles. max 50 = the GUI's range.
self.lamni_stitch_x = self._get_val(
"<stitch X> (n+1 tiles)", self.lamni_stitch_x, int, min_=0, max_=50
)
self.lamni_stitch_y = self._get_val(
"<stitch Y> (n+1 tiles)", self.lamni_stitch_y, int, min_=0, max_=50
)
self.tomo_stitch_overlap = self._get_val(
"<stitch overlap> um", self.tomo_stitch_overlap, float, min_=0.0, max_=10.0
)
self.tomo_circfov = self._get_val(
"<circular FOV> um", self.tomo_circfov, float, min_=0.0, max_=500.0
)
while True:
self._prompt_scan_geometry()
if not self._print_fermat_position_estimate():
break
print(
"Too few Fermat scan points: reduce the step size or the piezo range,"
" or adjust stitching/circular FOV."
)
self.ptycho_reconstruct_foldername = self._get_val(
"Reconstruction queue", self.ptycho_reconstruct_foldername, str
)
@@ -42,6 +42,49 @@ def stitch_tile_offsets(n: int) -> list[float]:
return [i - n / 2 for i in range(n + 1)]
def lamni_smallest_tile_position_count(
piezo_range_x: float,
piezo_range_y: float,
step: float,
stitch_x: int,
stitch_y: int,
stitch_overlap: float,
fov_circular: float,
) -> tuple[int, tuple[float, float]]:
"""Fermat point count of the stitch tile with the fewest points, and that
tile's offsets. With a circular FOV the corner tiles can hold far fewer
points than the center one, and any tile below the scan's minimum aborts
the whole projection. Evaluated at angle 0: the per-tile count only
varies by a few points with angle (random sub-step offset), and seed=0
keeps the estimate reproducible."""
from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan
counts = {}
for tile_x in stitch_tile_offsets(stitch_x):
for tile_y in stitch_tile_offsets(stitch_y):
counts[(tile_x, tile_y)] = len(
LamniFermatScan.get_lamni_fermat_spiral_pos(
-abs(piezo_range_x / 2),
abs(piezo_range_x / 2),
-abs(piezo_range_y / 2),
abs(piezo_range_y / 2),
step=step,
spiral_type=0,
center=False,
angle=0.0,
stitch_x=tile_x,
stitch_y=tile_y,
stitch_overlap=stitch_overlap,
fovx=piezo_range_x,
fovy=piezo_range_y,
fov_circular=fov_circular,
seed=0,
)
)
tile = min(counts, key=counts.get)
return counts[tile], tile
class OMNYTools:
HEADER = "\033[95m"
@@ -590,7 +590,9 @@ class OMNY_TomoParams(BECWidget, QWidget):
# fov/step/stitch/piezo-range fields above change, flagged orange if
# below the scan server's own minimum (see _update_fermat_position_preview()).
self._lbl_fermat_positions = QLabel("---")
common_form.addRow("Estimated Fermat scan points:", self._lbl_fermat_positions)
common_form.addRow(
"Estimated Fermat scan points (smallest tile):", self._lbl_fermat_positions
)
for key in self._profile["fermat_position_fields"]:
widget = self._pw.get(key)
if widget is not None:
@@ -1126,7 +1128,8 @@ class OMNY_TomoParams(BECWidget, QWidget):
count, min_positions = self._profile["compute_fermat_positions"](params)
if count < min_positions:
return (
f"Estimated Fermat scan points ({count}) is below the minimum of "
f"Estimated Fermat scan points ({count}, smallest stitch tile) is below the "
f"minimum of "
f"{min_positions} required for the scan to run -- increase FOV, "
"reduce step size, or adjust stitch/piezo range before submitting."
)
@@ -2718,48 +2721,35 @@ def _compute_fermat_positions_flomni(params: dict[str, Any]) -> tuple[int, int]:
def _compute_fermat_positions_lamni(params: dict[str, Any]) -> tuple[int, int]:
"""Lamni sibling of _compute_fermat_positions_flomni(): calls
LamniFermatScan.get_lamni_fermat_spiral_pos() directly -- including its
rotated-stage and circular-FOV (tomo_circfov) cropping, which can matter
a lot and would be easy to get subtly wrong in a reimplementation.
Evaluated at the outermost tile of the currently edited stitch and angle=0 (lamni has
no angle field in this widget -- the real per-projection count can vary
a bit with angle, since the circular crop is angle-dependent).
The real scan also draws a fresh random sub-step offset on every call
(see get_lamni_fermat_spiral_pos()'s `seed` argument), adding a further
small amount of per-projection count variation on top of the
angle/stitch dependence above. A fixed seed is used here only so this
estimate is itself reproducible.
"""Lamni sibling of _compute_fermat_positions_flomni(), via
lamni_smallest_tile_position_count(): the real scan algorithm
(LamniFermatScan.get_lamni_fermat_spiral_pos(), including its
rotated-stage and circular-FOV cropping) run for every stitch tile, and
the smallest count returned. With a circular FOV the corner tiles can
fall below the minimum while the center tile is fine, and any such tile
aborts the projection, so submit is blocked on the smallest one.
Returns:
(estimated_count, minimum_required)
"""
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
lamni_smallest_tile_position_count,
)
from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan
piezo_x = params.get("lamni_piezo_range_x", 0.0)
piezo_y = params.get("lamni_piezo_range_y", 0.0)
step = params.get("tomo_shellstep", 0.0)
if step <= 0:
return 0, LamniFermatScan.MIN_POSITIONS
positions = LamniFermatScan.get_lamni_fermat_spiral_pos(
-abs(piezo_x / 2),
abs(piezo_x / 2),
-abs(piezo_y / 2),
abs(piezo_y / 2),
step=step,
spiral_type=0,
angle=0.0,
# outermost tile: stitch n gives n+1 tiles at offsets -n/2 .. +n/2
stitch_x=params.get("lamni_stitch_x", 0) / 2,
stitch_y=params.get("lamni_stitch_y", 0) / 2,
stitch_overlap=params.get("tomo_stitch_overlap", 1.0),
fovx=piezo_x,
fovy=piezo_y,
fov_circular=params.get("tomo_circfov", 0.0),
seed=0,
count, _tile = lamni_smallest_tile_position_count(
params.get("lamni_piezo_range_x", 0.0),
params.get("lamni_piezo_range_y", 0.0),
step,
params.get("lamni_stitch_x", 0),
params.get("lamni_stitch_y", 0),
params.get("tomo_stitch_overlap", 1.0),
params.get("tomo_circfov", 0.0),
)
return len(positions), LamniFermatScan.MIN_POSITIONS
return count, LamniFermatScan.MIN_POSITIONS
def _lamni_get_tomo_fov_offset(client, axis: str) -> float:
+1 -1
View File
@@ -208,7 +208,7 @@ which reads the last recorded progress and resumes automatically at the exact po
In case of eight equally spaced sub-tomograms, an individual sub-tomogram can be scanned by `lamni.sub_tomo_scan(subtomo_number, start_angle)`. If the start angle is not specified, it is computed depending on `subtomo_number`, ranging from 1 to 8.
`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(lamni_stitch_x+1) × (lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(lamni_stitch_x+1) × lamni_piezo_range_x` by `(lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`).
`lamni.tomo_parameters()` also offers `lamni_stitch_x`/`lamni_stitch_y` (integers, default 0, up to 50 each) for a field of view larger than a single scan can cover: when nonzero, every projection angle is acquired as a `(lamni_stitch_x+1) × (lamni_stitch_y+1)` grid of separate Fermat scans instead of a single one, placed symmetrically around the normal alignment-corrected center and spaced by `lamni_piezo_range_x`/`lamni_piezo_range_y` minus `tomo_stitch_overlap` (µm, default 0.2) per step in x/y — stitching together an effective field of view of roughly `(lamni_stitch_x+1) × lamni_piezo_range_x` by `(lamni_stitch_y+1) × lamni_piezo_range_y`, with `tomo_stitch_overlap` controlling the deliberate overlap between adjacent tiles used for aligning them together during reconstruction. For example, a value of 1 gives two tiles, each shifted by half a step from the center; 2 gives three tiles (one centered, one on each side). With a circular FOV (`tomo_circfov`) the corner tiles can contain far fewer scan points than the center one; the estimated point count shown by `tomo_parameters()` is that of the smallest tile, and the parameters cannot be submitted while it is below the scan minimum (20). Reduce the piezo range or the step size, or adjust the stitching or circular FOV. Same mechanism as flomni's `stitch_x`/`stitch_y`, just under lamni's own parameter names (see `lamni.tomo_scan_projection()`, which passes `stitch_x`/`stitch_y`/`stitch_overlap` straight through to `scans.lamni_fermat_scan()`).
#### Angles from a file (tomo_type 4)
@@ -7,6 +7,7 @@ range, same mechanism as Flomni's.
"""
import builtins
import types
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni import LamNI
@@ -166,3 +167,22 @@ def test_cli_type3_rejects_zero_projections_per_subtomogram(monkeypatch):
lamni.tomo_parameters_cli()
assert lamni.tomo_angle_stepsize == 10.0 # 360 / 36
def test_cli_too_few_points_skips_confirm_and_reprompts_geometry(monkeypatch, capsys):
"""Too few Fermat points (e.g. a starved corner stitch tile): the
parameters cannot be confirmed as they are, and the scan-geometry prompts
repeat until the estimate reaches the minimum."""
lamni = _make_cli_lamni(monkeypatch, {"<piezo range X": ["30", "10"]})
asked = []
lamni.OMNYTools = types.SimpleNamespace(yesno=lambda *a, **k: asked.append(a) or True)
# estimate follows the piezo range: >20 um -> too few points
lamni._expected_fermat_position_count = lambda: 5 if lamni.lamni_piezo_range_x > 20 else 50
lamni.lamni_piezo_range_x = 25.0
lamni.tomo_parameters_cli()
assert lamni.lamni_piezo_range_x == 10.0
assert not any("correctly set" in str(a) for a in asked)
out = capsys.readouterr().out
assert "Too few Fermat scan points" in out
@@ -219,10 +219,8 @@ def test_compute_fermat_positions_lamni_matches_scan_class_above_threshold():
def test_compute_fermat_positions_lamni_uses_live_stitch_tile():
"""Unlike the CLI's tomo_parameters() (which always estimates the
center tile as a quick summary), the GUI has the actual stitch_x/y
fields right there, so it should reflect whichever tile is currently
being edited, not always stitch=0."""
"""The estimate is the smallest stitch tile's count, so it depends on
the stitch_x/y being edited, not always the center tile."""
base_params = {
"lamni_piezo_range_x": 20.0,
"lamni_piezo_range_y": 20.0,
@@ -332,3 +330,25 @@ def test_flomni_manual_shift_fields_are_in_um():
assert "µm" in label
assert by_key["manual_shift_x"] == (-10.0, 10.0)
assert by_key["manual_shift_y"] == (-50.0, 50.0)
def test_compute_fermat_positions_lamni_blocks_on_starved_corner_tile():
"""Center tile fine, corner tiles below the minimum (3x3 tiles, 36 um
circular FOV): the estimate must be the corner count, so submit is
blocked as in the non-stitched case."""
params = {
"lamni_piezo_range_x": 20.0,
"lamni_piezo_range_y": 20.0,
"tomo_shellstep": 1.0,
"tomo_stitch_overlap": 0.2,
"tomo_circfov": 36.0,
"lamni_stitch_x": 2,
"lamni_stitch_y": 2,
}
count, min_positions = _compute_fermat_positions_lamni(params)
assert count < min_positions
count, min_positions = _compute_fermat_positions_lamni(
dict(params, lamni_piezo_range_x=10.0, lamni_piezo_range_y=10.0)
)
assert count >= min_positions
@@ -23,3 +23,21 @@ def test_stitch_tile_offsets(n, expected):
def test_stitch_tile_offsets_negative_is_empty():
assert stitch_tile_offsets(-1) == []
def test_smallest_tile_count_finds_starved_corner_tile():
"""3x3 tiles, 20 um piezo range, 36 um circular FOV: the center tile is
full, but a corner tile holds only a few points (below the scan's
minimum of 20). A smaller piezo range pulls the corners into the circle."""
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.omny_general_tools import (
lamni_smallest_tile_position_count,
)
center, _ = lamni_smallest_tile_position_count(20, 20, 1.0, 0, 0, 0.2, 36)
corner, tile = lamni_smallest_tile_position_count(20, 20, 1.0, 2, 2, 0.2, 36)
smaller_piezo, _ = lamni_smallest_tile_position_count(10, 10, 1.0, 2, 2, 0.2, 36)
assert center > 100
assert corner < 20
assert abs(tile[0]) == 1 and abs(tile[1]) == 1
assert smaller_piezo >= 20