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:
1 parent
82e9e8bee1
commit
1fc702b854
8 files changed
+106
-3
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user