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:
x01dc
2026-07-21 12:58:24 +02:00
co-authored by Claude Sonnet 5
parent 7863b8fd37
commit db18929742
2 changed files with 206 additions and 0 deletions
@@ -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