diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py index 7c093288..df2e018d 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py @@ -2160,10 +2160,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools " um", self.tomo_shellstep, float, min_=0.025, max_=20.0 ) self.lamni_piezo_range_x = self._get_val( - " um", self.lamni_piezo_range_x, float, min_=0.0, max_=80.0 + " um", self.lamni_piezo_range_x, float, min_=0.1, max_=79.9 ) self.lamni_piezo_range_y = self._get_val( - " um", self.lamni_piezo_range_y, float, min_=0.0, max_=80.0 + " um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9 ) self.lamni_stitch_x = self._get_val("", self.lamni_stitch_x, int) self.lamni_stitch_y = self._get_val("", self.lamni_stitch_y, int) diff --git a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py index 5255d260..06389265 100644 --- a/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py +++ b/csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py @@ -2065,8 +2065,14 @@ class Flomni( @fovy.setter def fovy(self, val: float): - if val > 100: - raise ValueError("FOV cannot be larger than 100 um.") + # Strict "<" to match FlomniFermatScan's own ScanArgument bound + # (gt=0, lt=100) -- fovy=100.0 was previously accepted here (and by + # the GUI/CLI, both capped at an inclusive 100.0) but rejected at + # scan time with ScanInputValidationError, only surfacing on real + # hardware. fovx has no such gap (property and FlomniFermatScan both + # use an inclusive <=220). + if val >= 100: + raise ValueError("FOV must be less than 100 um.") self.client.set_global_var("fovy", val) @property @@ -3834,7 +3840,7 @@ class Flomni( max_=FlomniFermatScan.MAX_FERMAT_ASYMMETRY, ) self.fovx = self._get_val(" um", self.fovx, float, min_=0.1, max_=220.0) - self.fovy = self._get_val(" um", self.fovy, float, min_=0.1, max_=100.0) + self.fovy = self._get_val(" um", self.fovy, float, min_=0.1, max_=99.9) if self.single_point_instead_of_fermat_scan: print( "Stitching is disabled while single point instead of fermat scan is" diff --git a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py index d95fe871..5a464656 100644 --- a/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py +++ b/csaxs_bec/bec_widgets/widgets/tomo_params/tomo_params.py @@ -2324,7 +2324,7 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { "has_zero_deg_reference": True, "fov_fields": [ ("fovx", "FOV x (µm)", 0.1, 220.0, 2), - ("fovy", "FOV y (µm)", 0.1, 100.0, 2), + ("fovy", "FOV y (µm)", 0.1, 99.9, 2), ], "stitch_fields": [("stitch_x", "Stitch x", 0, 10), ("stitch_y", "Stitch y", 0, 10)], "manual_shift_fields": [("manual_shift_y", "Manual shift y (µm)", -50.0, 50.0, 3)], @@ -2372,8 +2372,8 @@ SETUP_PROFILES: dict[str, dict[str, Any]] = { ("manual_shift_y", "Manual shift y (µm)", -1000.0, 1000.0, 3), ], "piezo_range_fields": [ - ("lamni_piezo_range_x", "Piezo range x (µm)", 0.0, 80.0, 2), - ("lamni_piezo_range_y", "Piezo range y (µm)", 0.0, 80.0, 2), + ("lamni_piezo_range_x", "Piezo range x (µm)", 0.1, 79.9, 2), + ("lamni_piezo_range_y", "Piezo range y (µm)", 0.1, 79.9, 2), ], # tomo_fovx_offset/tomo_fovy_offset: alignment values (LamNIAlignmentMixin), # not part of _TOMO_SCAN_PARAM_NAMES -- shown/editable here (mirroring diff --git a/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py new file mode 100644 index 00000000..f5074b60 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_fov_bounds_match_scan_args.py @@ -0,0 +1,93 @@ +"""Regression test for a real hardware bug (2026-09-19): flomni.fovy could be +set to exactly 100.0 via the property/GUI/CLI (all capped at an inclusive +100.0), but FlomniFermatScan's own ScanArgument declares fovy as gt=0, lt=100 +(strictly less than) -- so fovy=100.0 passed every check right up until the +scan itself rejected it with ScanInputValidationError on real hardware. + +lamni_piezo_range_x/y (which become LamniFermatScan's fovx/fovy) had the same +class of bug at both ends: GUI/CLI min was 0.0 but LamniFermatScan requires +gt=0, and max was an inclusive 80.0 but LamniFermatScan requires lt=80. + +This test introspects the actual ScanArgument bounds from the scan classes +themselves and checks our configured GUI/CLI ranges (tomo_params.py's +SETUP_PROFILES, and flomni.py/lamni.py's tomo_parameters() prompts) are +strictly inside them -- not just checked once by hand -- so this class of +"looks fine until you actually run the scan" bug can't silently return. +""" + +import re +from typing import get_type_hints + +from csaxs_bec.bec_widgets.widgets.tomo_params.tomo_params import SETUP_PROFILES +from csaxs_bec.scans.flomni_fermat_scan import FlomniFermatScan +from csaxs_bec.scans.lamni_fermat_scan import LamniFermatScan + + +def _scan_arg_bounds(scan_cls, param_name: str): + hints = get_type_hints(scan_cls.__init__, include_extras=True) + metadata = hints[param_name].__metadata__ + (scan_argument,) = [m for m in metadata if hasattr(m, "lt")] + return scan_argument.gt, scan_argument.ge, scan_argument.lt, scan_argument.le + + +def _assert_range_inside_bounds(our_min, our_max, gt, ge, lt, le): + if gt is not None: + assert our_min > gt, f"configured min {our_min} must be > scan's gt={gt}" + if ge is not None: + assert our_min >= ge, f"configured min {our_min} must be >= scan's ge={ge}" + if lt is not None: + assert our_max < lt, f"configured max {our_max} must be < scan's lt={lt}" + if le is not None: + assert our_max <= le, f"configured max {our_max} must be <= scan's le={le}" + + +def _gui_fields(profile_key: str) -> dict: + """Flatten a profile's fov_fields + piezo_range_fields into {name: (min, max)}.""" + profile = SETUP_PROFILES[profile_key] + fields = {} + for name, _label, min_, max_, *_ in profile["fov_fields"]: + fields[name] = (min_, max_) + for name, _label, min_, max_, *_ in profile.get("piezo_range_fields", []): + fields[name] = (min_, max_) + return fields + + +def test_flomni_gui_fovx_fovy_within_scan_bounds(): + fields = _gui_fields("flomni") + our_min, our_max = fields["fovx"] + _assert_range_inside_bounds(our_min, our_max, *_scan_arg_bounds(FlomniFermatScan, "fovx")) + + our_min, our_max = fields["fovy"] + _assert_range_inside_bounds(our_min, our_max, *_scan_arg_bounds(FlomniFermatScan, "fovy")) + + +def test_flomni_cli_fovy_bounds_within_scan_bounds(): + src = open( + "csaxs_bec/bec_ipython_client/plugins/flomni/flomni.py", encoding="utf-8" + ).read() + m = re.search(r'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL) + assert m, "could not find the fovy _get_val() call in flomni.py -- did it move?" + cli_min, cli_max = float(m.group(1)), float(m.group(2)) + _assert_range_inside_bounds(cli_min, cli_max, *_scan_arg_bounds(FlomniFermatScan, "fovy")) + + +def test_lamni_gui_piezo_range_within_scan_bounds(): + fields = _gui_fields("lamni") + for name, scan_param in (("lamni_piezo_range_x", "fovx"), ("lamni_piezo_range_y", "fovy")): + our_min, our_max = fields[name] + _assert_range_inside_bounds( + our_min, our_max, *_scan_arg_bounds(LamniFermatScan, scan_param) + ) + + +def test_lamni_cli_piezo_range_bounds_within_scan_bounds(): + src = open("csaxs_bec/bec_ipython_client/plugins/LamNI/lamni.py", encoding="utf-8").read() + for axis, scan_param in (("X", "fovx"), ("Y", "fovy")): + m = re.search( + rf'" um".*?min_=([\d.]+), max_=([\d.]+)', src, re.DOTALL + ) + assert m, f"could not find the piezo range {axis} _get_val() call in lamni.py -- did it move?" + cli_min, cli_max = float(m.group(1)), float(m.group(2)) + _assert_range_inside_bounds( + cli_min, cli_max, *_scan_arg_bounds(LamniFermatScan, scan_param) + )