diff --git a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py index 19196a3..90c9806 100644 --- a/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py +++ b/csaxs_bec/bec_ipython_client/plugins/LamNI/lamni_optics_mixin.py @@ -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" + ) diff --git a/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py new file mode 100644 index 0000000..1a44121 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_lamni_osa_collision_info.py @@ -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