fix(flomni,lamni): fovy/piezo-range bounds didn't match scan's strict "<"
CI for csaxs_bec / test (push) Failing after 2m12s

Real hardware failure: flomni.fovy=100.0 was accepted by the property, GUI,
and CLI (all capped at an inclusive 100.0), but FlomniFermatScan's own
ScanArgument requires fovy strictly less than 100 -- so the scan rejected it
at runtime with ScanInputValidationError, only surfacing once actually run.

- flomni.fovy property setter: now raises at >=100 (was >100), matching
  FlomniFermatScan's gt=0/lt=100. GUI/CLI max tightened 100.0 -> 99.9.
- lamni_piezo_range_x/y (become LamniFermatScan's fovx/fovy, gt=0/lt=80):
  found the same bug class at BOTH ends while writing the regression test --
  GUI/CLI min was an inclusive 0.0 (scan requires strictly >0) and max was
  an inclusive 80.0 (scan requires strictly <80). Tightened to 0.1-79.9 in
  both GUI and CLI. Property setter's large_range_scan bypass left
  untouched per earlier explicit instruction -- this fixes the GUI/CLI
  entry points, which is where the bound is actually enforced in practice.
- Added test_fov_bounds_match_scan_args.py: introspects the real
  ScanArgument gt/ge/lt/le from FlomniFermatScan/LamniFermatScan and asserts
  our configured GUI/CLI ranges are strictly inside them, so this class of
  bug (passes every check except the one that actually runs the scan)
  can't silently come back for these or future fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132KBoxsovfcMNRGJhS1Pbw
This commit is contained in:
x01dc
2026-09-19 21:30:22 +02:00
co-authored by Claude Sonnet 5
parent 1b802aaad6
commit d01af690a9
4 changed files with 107 additions and 8 deletions
@@ -2160,10 +2160,10 @@ class LamNI(TomoQueueMixin, LamNIAlignmentMixin, LamNIOpticsMixin, LamniGuiTools
"<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.0, max_=80.0
"<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.0, max_=80.0
"<piezo range Y (max 80)> um", self.lamni_piezo_range_y, float, min_=0.1, max_=79.9
)
self.lamni_stitch_x = self._get_val("<stitch X>", self.lamni_stitch_x, int)
self.lamni_stitch_y = self._get_val("<stitch Y>", self.lamni_stitch_y, int)
@@ -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("<FOV X (max 220)> um", self.fovx, float, min_=0.1, max_=220.0)
self.fovy = self._get_val("<FOV Y (max 100)> um", self.fovy, float, min_=0.1, max_=100.0)
self.fovy = self._get_val("<FOV Y (max 100)> 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"
@@ -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
@@ -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'"<FOV Y \(max 100\)> 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'"<piezo range {axis} \(max 80\)> 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)
)