From 1fc702b854d306f9f55955e0db926d4bd910f2da Mon Sep 17 00:00:00 2001 From: x12sa Date: Wed, 16 Sep 2026 11:30:46 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_0197yD3hTxwvrgmYr7aaYiCD --- csaxs_bec/scans/fermat_spiral_utils.py | 35 +++++++++++++++++++ csaxs_bec/scans/flomni_fermat_scan.py | 3 +- csaxs_bec/scans/lamni_fermat_scan.py | 3 +- csaxs_bec/scans/omny_fermat_scan.py | 4 ++- tests/tests_scans/test_fermat_spiral_utils.py | 14 ++++++++ tests/tests_scans/test_flomni_fermat_scan.py | 16 +++++++++ tests/tests_scans/test_lamni_fermat_scan.py | 13 +++++++ tests/tests_scans/test_omny_fermat_scan.py | 21 +++++++++++ 8 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 csaxs_bec/scans/fermat_spiral_utils.py create mode 100644 tests/tests_scans/test_fermat_spiral_utils.py diff --git a/csaxs_bec/scans/fermat_spiral_utils.py b/csaxs_bec/scans/fermat_spiral_utils.py new file mode 100644 index 00000000..b42272b7 --- /dev/null +++ b/csaxs_bec/scans/fermat_spiral_utils.py @@ -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) diff --git a/csaxs_bec/scans/flomni_fermat_scan.py b/csaxs_bec/scans/flomni_fermat_scan.py index 6ca6ae00..0fd80dde 100644 --- a/csaxs_bec/scans/flomni_fermat_scan.py +++ b/csaxs_bec/scans/flomni_fermat_scan.py @@ -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 diff --git a/csaxs_bec/scans/lamni_fermat_scan.py b/csaxs_bec/scans/lamni_fermat_scan.py index 993c4c51..862c407e 100644 --- a/csaxs_bec/scans/lamni_fermat_scan.py +++ b/csaxs_bec/scans/lamni_fermat_scan.py @@ -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, diff --git a/csaxs_bec/scans/omny_fermat_scan.py b/csaxs_bec/scans/omny_fermat_scan.py index d1a1689b..34b2c5f6 100644 --- a/csaxs_bec/scans/omny_fermat_scan.py +++ b/csaxs_bec/scans/omny_fermat_scan.py @@ -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 diff --git a/tests/tests_scans/test_fermat_spiral_utils.py b/tests/tests_scans/test_fermat_spiral_utils.py new file mode 100644 index 00000000..249c457f --- /dev/null +++ b/tests/tests_scans/test_fermat_spiral_utils.py @@ -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) diff --git a/tests/tests_scans/test_flomni_fermat_scan.py b/tests/tests_scans/test_flomni_fermat_scan.py index a0aacb80..83da7b9b 100644 --- a/tests/tests_scans/test_flomni_fermat_scan.py +++ b/tests/tests_scans/test_flomni_fermat_scan.py @@ -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 diff --git a/tests/tests_scans/test_lamni_fermat_scan.py b/tests/tests_scans/test_lamni_fermat_scan.py index c013275e..d12595eb 100644 --- a/tests/tests_scans/test_lamni_fermat_scan.py +++ b/tests/tests_scans/test_lamni_fermat_scan.py @@ -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 diff --git a/tests/tests_scans/test_omny_fermat_scan.py b/tests/tests_scans/test_omny_fermat_scan.py index 089afd71..1ac42bc3 100644 --- a/tests/tests_scans/test_omny_fermat_scan.py +++ b/tests/tests_scans/test_omny_fermat_scan.py @@ -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)