feat/add OSA collision-clearance warning to lamni lfzp_info()
Ports flomni's ffzp_info() OSA collision-clearance section (compares live losaz against nominal "in" position and a collision-boundary constant, warns if closer to collision than nominal or reports clearance at IN) -- same formula shape and sign convention as flomni (motion directions confirmed identical between the two setups). lamni's collision-boundary constant has never been measured, so it's read from loptz's device config (userParameter.collision_offset) via a new _get_user_param_optional() helper -- unlike the existing _get_user_param_safe(), it returns None instead of raising when the parameter is undefined (as it currently is for every lamni device config, including the simulated one), since absence here means "not yet commissioned" rather than a config error. lfzp_info() prints a clear warning and skips the numeric estimate entirely until someone adds the real measured value to the config -- deliberately not a CLI-settable/global-var parameter, since this is deployment-level hardware calibration. Item 7 of csaxs_bec/bec_ipython_client/plugins/LamNI/AI_docs/ FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -205,6 +205,16 @@ class LamNIOpticsMixin:
|
||||
raise ValueError(f"Device {device} has no user parameter definition for {var}.")
|
||||
return param.get(var)
|
||||
|
||||
@staticmethod
|
||||
def _get_user_param_optional(device, var):
|
||||
"""Like _get_user_param_safe(), but returns None instead of raising
|
||||
when the parameter isn't defined in the device config -- for
|
||||
genuinely optional, not-yet-commissioned calibration values."""
|
||||
param = dev[device].user_parameter
|
||||
if not param:
|
||||
return None
|
||||
return param.get(var)
|
||||
|
||||
def leye_out(self):
|
||||
self.loptics_in()
|
||||
dev.fsh.fshopen()
|
||||
@@ -415,3 +425,53 @@ class LamNIOpticsMixin:
|
||||
print(
|
||||
"The numbers presented here are for a sample in the plane of the lamni sample holder.\n"
|
||||
)
|
||||
|
||||
collision_offset = self._get_user_param_optional("loptz", "collision_offset")
|
||||
|
||||
print("\nOSA Information:")
|
||||
if collision_offset is None:
|
||||
print(
|
||||
" \033[93mWarning: OSA collision-clearance calibration has not been "
|
||||
"commissioned for LamNI yet -- set 'collision_offset' under loptz's "
|
||||
"userParameter in the device config once measured (mirrors flomni's "
|
||||
"ffzp_info(), whose equivalent constant is already commissioned). "
|
||||
"Skipping the collision-clearance estimate below.\033[0m"
|
||||
)
|
||||
else:
|
||||
losaz_val = dev.losaz.readback.get()
|
||||
losaz_in = self._get_user_param_safe("losaz", "in")
|
||||
|
||||
tol_osa = 0.010 # mm, 10 microns
|
||||
|
||||
remaining_current = -losaz_val + (collision_offset - loptz_val)
|
||||
remaining_at_in = -losaz_in + (collision_offset - loptz_val)
|
||||
|
||||
print(f" Current losaz {losaz_val:.1f}")
|
||||
print(
|
||||
" The OSA will collide with a normal sample holder at losaz "
|
||||
f"\033[1m{(collision_offset - losaz_val):.1f}\033[0m"
|
||||
)
|
||||
print(f" Remaining space (right now): \033[1m{remaining_current:.1f}\033[0m")
|
||||
|
||||
diff = losaz_val - losaz_in # >0: OSA is currently more "in" than nominal -> worse
|
||||
|
||||
if abs(diff) > tol_osa:
|
||||
if diff > 0:
|
||||
# current position is already more collision-prone than the defined IN position
|
||||
print(
|
||||
f" \033[91mWarning: current losaz ({losaz_val:.4f}) is "
|
||||
f"{diff*1000:.1f} um further IN than the defined IN position "
|
||||
f"({losaz_in:.4f}) -- closer to collision than normal "
|
||||
"operation.\033[0m"
|
||||
)
|
||||
else:
|
||||
# OSA is out (parked further away than its nominal in-position) -- not urgent
|
||||
# now, but flag what clearance will be once it's moved to IN
|
||||
print(
|
||||
f" Note: OSA is {(-diff)*1000:.1f} um away from its IN position "
|
||||
"(likely parked OUT)."
|
||||
)
|
||||
print(
|
||||
" Remaining space if OSA is moved to its IN position: "
|
||||
f"\033[1m{remaining_at_in:.1f}\033[0m"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Tests for lamni's OSA collision-clearance section in lfzp_info(), ported
|
||||
from flomni's ffzp_info() (see csaxs_bec/bec_ipython_client/plugins/LamNI/
|
||||
AI_docs/FLOMNI_LAMNI_FEATURE_GAPS_2026-07.md, item 7).
|
||||
|
||||
Methods under test (lamni_optics_mixin.py) reference module-level `dev`
|
||||
(resolved from this module's own namespace, not an instance attribute), so
|
||||
tests monkeypatch that module global directly.
|
||||
"""
|
||||
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
import csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin as optics_mixin_module
|
||||
from csaxs_bec.bec_ipython_client.plugins.LamNI.lamni_optics_mixin import LamNIOpticsMixin
|
||||
|
||||
|
||||
class FakeAxis:
|
||||
def __init__(self, value, name=None, user_parameter=None):
|
||||
self.value = value
|
||||
self._name = name
|
||||
self.readback = types.SimpleNamespace(get=lambda: self.value)
|
||||
self.user_parameter = user_parameter or {}
|
||||
|
||||
def read(self):
|
||||
return {self._name: {"value": self.value}}
|
||||
|
||||
|
||||
class FakeDev:
|
||||
def __init__(self, collision_offset=None):
|
||||
self.mokev = FakeAxis(7.0)
|
||||
self.loptz = FakeAxis(
|
||||
80.0,
|
||||
name="loptz",
|
||||
user_parameter=(
|
||||
{"collision_offset": collision_offset} if collision_offset is not None else {}
|
||||
),
|
||||
)
|
||||
self.losaz = FakeAxis(0.0, user_parameter={"in": 0.0})
|
||||
|
||||
def __contains__(self, name):
|
||||
return hasattr(self, name)
|
||||
|
||||
def __getitem__(self, name):
|
||||
return getattr(self, name)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_dev(monkeypatch):
|
||||
def _make(collision_offset=None):
|
||||
dev = FakeDev(collision_offset=collision_offset)
|
||||
monkeypatch.setattr(optics_mixin_module, "dev", dev)
|
||||
return dev
|
||||
|
||||
return _make
|
||||
|
||||
|
||||
def make_optics():
|
||||
return LamNIOpticsMixin()
|
||||
|
||||
|
||||
def test_get_user_param_optional_missing_param_returns_none(fake_dev):
|
||||
fake_dev()
|
||||
optics = make_optics()
|
||||
assert optics._get_user_param_optional("loptz", "collision_offset") is None
|
||||
|
||||
|
||||
def test_get_user_param_optional_missing_user_parameter_dict_returns_none(monkeypatch):
|
||||
class NoParamAxis:
|
||||
user_parameter = None
|
||||
|
||||
class Dev:
|
||||
loptz = NoParamAxis()
|
||||
|
||||
def __getitem__(self, name):
|
||||
return getattr(self, name)
|
||||
|
||||
monkeypatch.setattr(optics_mixin_module, "dev", Dev())
|
||||
optics = make_optics()
|
||||
assert optics._get_user_param_optional("loptz", "collision_offset") is None
|
||||
|
||||
|
||||
def test_get_user_param_optional_returns_value_when_present(fake_dev):
|
||||
fake_dev(collision_offset=33.0)
|
||||
optics = make_optics()
|
||||
assert optics._get_user_param_optional("loptz", "collision_offset") == 33.0
|
||||
|
||||
|
||||
def test_get_user_param_safe_still_raises_when_missing(fake_dev):
|
||||
"""Regression guard: the existing raising helper must stay untouched --
|
||||
only the new _get_user_param_optional() should tolerate an absent param."""
|
||||
fake_dev()
|
||||
optics = make_optics()
|
||||
with pytest.raises(ValueError):
|
||||
optics._get_user_param_safe("loptz", "collision_offset")
|
||||
|
||||
|
||||
def test_lfzp_info_warns_and_skips_numbers_when_uncommissioned(fake_dev, capsys):
|
||||
fake_dev(collision_offset=None)
|
||||
optics = make_optics()
|
||||
|
||||
optics.lfzp_info(mokev_val=7.0)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "has not been commissioned" in out
|
||||
assert "Remaining space" not in out
|
||||
assert "collide with a normal sample holder" not in out
|
||||
|
||||
|
||||
def test_lfzp_info_prints_collision_estimate_when_commissioned(fake_dev, capsys):
|
||||
dev = fake_dev(collision_offset=113.0)
|
||||
dev.losaz.value = 0.0
|
||||
dev.losaz.user_parameter["in"] = 0.0
|
||||
|
||||
optics = make_optics()
|
||||
optics.lfzp_info(mokev_val=7.0)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "has not been commissioned" not in out
|
||||
assert "Current losaz 0.0" in out
|
||||
assert "Remaining space (right now)" in out
|
||||
|
||||
|
||||
def test_lfzp_info_warns_when_closer_to_collision_than_nominal(fake_dev, capsys):
|
||||
dev = fake_dev(collision_offset=113.0)
|
||||
dev.losaz.user_parameter["in"] = 0.0
|
||||
dev.losaz.value = 0.02 # further IN than nominal "in" (0.0), beyond the 0.010 tolerance
|
||||
|
||||
optics = make_optics()
|
||||
optics.lfzp_info(mokev_val=7.0)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "further IN than the defined IN position" in out
|
||||
|
||||
|
||||
def test_lfzp_info_notes_parked_out(fake_dev, capsys):
|
||||
dev = fake_dev(collision_offset=113.0)
|
||||
dev.losaz.user_parameter["in"] = 0.0
|
||||
dev.losaz.value = -0.5 # parked further OUT than nominal "in"
|
||||
|
||||
optics = make_optics()
|
||||
optics.lfzp_info(mokev_val=7.0)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "likely parked OUT" in out
|
||||
assert "Remaining space if OSA is moved to its IN position" in out
|
||||
Reference in New Issue
Block a user