fix(flomni,lamni): fovy/piezo-range bounds didn't match scan's strict "<"
CI for csaxs_bec / test (push) Failing after 2m12s
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:
@@ -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)
|
||||
)
|
||||
Reference in New Issue
Block a user