fix(scans): correct undersized fermat spiral iteration cap for elongated FOVs

get_flomni_fermat_spiral_pos/get_omny_fermat_spiral_pos/get_lamni_fermat_spiral_pos
sized their spiral loop's iteration cap (n_max) from the FOV's area alone,
but the spiral radius only grows as step*0.57*sqrt(ii). For an elongated
(non-square) FOV -- e.g. flomni's fovx up to 220 vs fovy capped at 100 --
this under-estimated the iterations needed, so the loop ran out before
reaching the long axis's outer region. The two explicitly-appended
bounding-box corner points masked this: they showed up regardless, while
genuine spiral fill points near the true edge silently went missing
(observed as the pattern being "cut" well short of the requested FOV).

Add a shared compute_fermat_spiral_n_max() helper that takes the max of
the old area-based estimate and a corner-radius-based estimate, so
square/near-square FOVs are numerically unaffected (verified
byte-for-byte identical) while elongated ones now reach the requested
edge (verified: flomni fovx=220,fovy=30 now reaches 109.98um vs the
82.78um it reached before, against a 110.0um target).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD
This commit is contained in:
x12saandClaude Sonnet 5 committed 2026-09-16 11:30:46 +02:00
1 parent 82e9e8bee1
commit 1fc702b854
8 files changed
+106 -3

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
"""Shared helpers for Fermat-spiral scan position generation (flomni/omny/lamni)."""
from __future__ import annotations
import numpy as np
def compute_fermat_spiral_n_max(
length_axis1: float, length_axis2: float, step: float, safety_factor: float = 1.1
) -> int:
"""
Size the Fermat-spiral loop's iteration cap.
The spiral radius at iteration ``ii`` grows as ``step * 0.57 * sqrt(ii)``, so sizing
the cap from the box's area alone (``length_axis1 * length_axis2 * 3.2 / step**2``)
under-estimates the iterations needed for elongated (non-square) boxes: the maximum
radius that formula reaches is a function of area, not of the box's actual corner
distance, so the spiral can exhaust its iterations before reaching the far corners of
a long, narrow box. Taking the max with a cap derived from the corner distance itself
fixes that while leaving square/near-square boxes unaffected (the area-based term
already dominates there).
Args:
length_axis1 (float): Full extent of the scan box along the first axis.
length_axis2 (float): Full extent of the scan box along the second axis.
step (float): Spiral step size.
safety_factor (float): Margin applied to the corner-distance-based term.
Returns:
int: Iteration cap for the Fermat-spiral generation loop.
"""
n_max_area = int(length_axis1 * length_axis2 * 3.2 / step / step)
corner_radius = np.sqrt((length_axis1 / 2) ** 2 + (length_axis2 / 2) ** 2)
n_max_corner = int((corner_radius / (0.57 * step)) ** 2 * safety_factor)
return max(n_max_area, n_max_corner)
+2 -1
View File
@@ -32,6 +32,7 @@ from bec_server.scan_server.scans.scan_base import ScanBase, ScanType
from bec_server.scan_server.scans.scan_modifier import scan_hook
from csaxs_bec.devices.epics.delay_generator_csaxs.delay_generator_csaxs import TRIGGERSOURCE
from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max
logger = bec_logger.logger
@@ -441,7 +442,7 @@ class FlomniFermatScan(ScanBase):
length_axis1 = np.abs(m1_stop - m1_start)
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step)
z_pos = zshift
+2 -1
View File
@@ -26,6 +26,7 @@ from bec_server.scan_server.errors import ScanAbortion
from bec_server.scan_server.scans.scan_base import ScanBase, ScanType
from bec_server.scan_server.scans.scan_modifier import scan_hook
from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max
from csaxs_bec.scans.lamni_components import LamNIComponents
logger = bec_logger.logger
@@ -309,7 +310,7 @@ class LamniFermatScan(ScanBase):
length_axis1 = np.abs(m1_stop - m1_start)
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step)
total_shift_x, total_shift_y = LamniFermatScan._compute_total_shift(
center_x=center_x,
+3 -1
View File
@@ -28,6 +28,8 @@ from bec_server.scan_server.scans import MessageEndpoints
from bec_server.scan_server.scans.scan_base import ScanBase, ScanType
from bec_server.scan_server.scans.scan_modifier import scan_hook
from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max
logger = bec_logger.logger
@@ -302,7 +304,7 @@ class OmnyFermatScan(ScanBase):
length_axis1 = np.abs(m1_stop - m1_start)
length_axis2 = np.abs(m2_stop - m2_start)
n_max = int(length_axis1 * length_axis2 * 3.2 / step / step)
n_max = compute_fermat_spiral_n_max(length_axis1, length_axis2, step)
z_pos = self.zshift
@@ -0,0 +1,14 @@
from csaxs_bec.scans.fermat_spiral_utils import compute_fermat_spiral_n_max
def test_n_max_unchanged_for_square_box():
# the old area-based term already dominates for a square/near-square box,
# so the corner-radius-based term must not change the result there
assert compute_fermat_spiral_n_max(50, 50, 1) == int(50 * 50 * 3.2)
def test_n_max_boosted_for_elongated_box():
# for a long, narrow box the old area-based term under-estimates the
# iterations needed to reach the far corners, so the fixed result must
# exceed it
assert compute_fermat_spiral_n_max(220, 30, 1) > int(220 * 30 * 3.2)
@@ -1,5 +1,7 @@
from unittest import mock
import numpy as np
from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan
@@ -43,3 +45,17 @@ def test_flomni_rotation_moves_when_setpoint_matches_but_readback_far_off():
FlomniFermatScan.flomni_rotation(scan, 10.0)
scan.actions.set.assert_called_once()
def test_flomni_fermat_spiral_reaches_elongated_fov_corners():
# Regression test: for an elongated (non-square) FOV, the spiral's iteration
# cap used to be sized from FOV area alone, which under-estimated the
# iterations needed and left the loop-generated points well short of the
# long axis's outer region (only the two explicit corner points reached it).
fovx, fovy = 220.0, 30.0
pos = FlomniFermatScan.get_flomni_fermat_spiral_pos(
-fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1
)
loop_points = pos[:-2] # exclude the two corner points appended after the loop
assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2)
assert len(pos) > FlomniFermatScan.MIN_POSITIONS
@@ -44,3 +44,16 @@ def test_lamni_rotation_moves_when_setpoint_matches_but_not_settled():
scan.dev.lsamrot.set.assert_called_once_with(10.0)
scan.dev.lsamrot.set.return_value.wait.assert_called_once_with()
def test_lamni_fermat_spiral_reaches_elongated_fov_corners():
# Regression test: for an elongated (non-square) FOV, the spiral's iteration
# cap used to be sized from FOV area alone, which under-estimated the
# iterations needed and left coverage well short of the long axis's outer
# region. lamni's returned x/y are post-transform-shift, so assert on point
# count growth (46 -> 73 for this case) rather than raw coordinate magnitude.
fovx, fovy = 79.0, 2.0
pos = LamniFermatScan.get_lamni_fermat_spiral_pos(
-fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1, fovx=fovx, fovy=fovy
)
assert len(pos) >= 60
@@ -1,5 +1,7 @@
from unittest import mock
import numpy as np
from csaxs_bec.scans.omny_fermat_scan import OmnyFermatScan
@@ -42,3 +44,22 @@ def test_omny_rotation_moves_when_setpoint_matches_but_readback_far_off():
OmnyFermatScan.omny_rotation(scan, 10.0)
scan.actions.set.assert_called_once()
class _PosStub:
cenx = 0.0
ceny = 0.0
zshift = 0.0
def test_omny_fermat_spiral_reaches_elongated_fov_corners():
# Regression test: for an elongated (non-square) FOV, the spiral's iteration
# cap used to be sized from FOV area alone, which under-estimated the
# iterations needed and left the loop-generated points well short of the
# long axis's outer region (only the two explicit corner points reached it).
fovx, fovy = 190.0, 20.0 # stay under omny's fovx<200 / fovy<100 argument bounds
pos = OmnyFermatScan.get_omny_fermat_spiral_pos(
_PosStub(), -fovx / 2, fovx / 2, -fovy / 2, fovy / 2, step=1
)
loop_points = pos[:-2] # exclude the two corner points appended after the loop
assert np.max(np.abs(loop_points[:, 0])) > 0.95 * (fovx / 2)