From d23735701227188240752ccdadcb938874597c7a Mon Sep 17 00:00:00 2001 From: x01dc Date: Thu, 17 Sep 2026 14:22:29 +0200 Subject: [PATCH] fix(omny-shared): filters_out_of_beam tolerates a missing/None dev Surfaced live on a simulated flomni session: tomo_scan_projection() crashed with "DeviceConfigError: Device filter_array_1_x does not exist" -- the real beamline's four filter_array_*_x devices (bl_endstation.yaml) aren't part of any endstation simulation config, by design (out of scope for an isolated endstation sim, same as frontend/optics-hutch/detector devices). filters_out_of_beam() already tried to skip a missing device via getattr(dev, axis_name, None), but BEC's device container raises DeviceConfigError -- a plain Exception, not AttributeError -- for a missing device, so getattr's default never applied. Wrap the lookup in try/except instead, which also preserves the pre-existing (and apparently relied-upon, per several LamNI tests) behavior of `dev` itself being None -- the usual state of this codebase's `dev = builtins.__dict__.get("dev")` idiom before a real session sets it. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Lx3KffiFyyDMKT8vvPENUW --- .../plugins/OMNY_shared/filter_check.py | 14 +++- .../test_filter_check.py | 81 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/tests_bec_ipython_client/test_filter_check.py diff --git a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py index 1503433a..6d5db9d7 100644 --- a/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py +++ b/csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py @@ -9,12 +9,24 @@ def filters_out_of_beam(dev, tol: float = 0.1) -> tuple[bool, list[str]]: Reuses cSAXSFilterTransmission's own axis list/position table (index 0 of each row is the "out" position) so there is one source of truth for what "out" means, matching csaxs.fil_trans()/_fil_trans_report()'s own check. + + Tolerates a deployment missing one or more filter axes (e.g. a simulated + session without the real beamline's filter_array_* devices), and `dev` + itself being None (the usual state of the module-level `dev = + builtins.__dict__.get("dev")` idiom used throughout this codebase before + a real BEC session has set it). BEC's device container raises + DeviceConfigError -- not AttributeError -- for a missing device, so a + plain `getattr(dev, axis_name, None)` does NOT safely skip it; wrapping + the lookup itself in try/except does, for both cases. """ offending = [] for axis_name, positions in zip( cSAXSFilterTransmission._AXES, cSAXSFilterTransmission._POSITIONS_USER ): - axis_obj = getattr(dev, axis_name, None) + try: + axis_obj = getattr(dev, axis_name, None) + except Exception: + axis_obj = None out_position = positions[0] if axis_obj is None or out_position is None: continue diff --git a/tests/tests_bec_ipython_client/test_filter_check.py b/tests/tests_bec_ipython_client/test_filter_check.py new file mode 100644 index 00000000..a45d6894 --- /dev/null +++ b/tests/tests_bec_ipython_client/test_filter_check.py @@ -0,0 +1,81 @@ +"""Tests for filters_out_of_beam() (csaxs_bec/bec_ipython_client/plugins/OMNY_shared/filter_check.py). + +Regression coverage for a bug surfaced on a simulated flomni session: +tomo_scan_projection() -> filters_out_of_beam() crashed with +DeviceConfigError: Device filter_array_1_x does not exist, because the sim +config (unlike the real beamline's bl_endstation.yaml) doesn't define the +filter_array_*_x devices at all. The lookup used `getattr(dev, axis_name, +None)`, but BEC's device container raises DeviceConfigError (a plain +Exception, not AttributeError) for a missing device, so getattr's default +never applied. +""" + +from unittest import mock + +from bec_lib.bec_errors import DeviceConfigError + +from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.filter_check import filters_out_of_beam + + +class _FakeDeviceContainer(dict): + """Stands in for BEC's real device-manager container: attribute access + for a present device returns it, for a missing one raises + DeviceConfigError (not AttributeError) -- see devicemanager.py's + __getattr__.""" + + def __getattr__(self, attr): + dev = self.get(attr) + if not dev: + raise DeviceConfigError(f"Device {attr} does not exist.") + return dev + + +def _axis_at(position: float): + axis = mock.MagicMock() + axis.readback.get.return_value = position + return axis + + +def test_filters_out_of_beam_dev_is_none(): + # The usual state of the module-level `dev` idiom before a real BEC + # session has set it (`dev = builtins.__dict__.get("dev")`) -- several + # LamNI/Flomni call sites pass this straight through. Must not raise. + all_out, offending = filters_out_of_beam(None) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_all_devices_missing_from_container(): + # Mirrors a simulated deployment: none of the filter_array_*_x devices + # exist at all. Must not raise, and must report "all out" (nothing to + # check -> vacuously true), matching the existing axis_obj-is-None skip. + dev = _FakeDeviceContainer() + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_some_devices_missing_from_container(): + # A mixed deployment: filter_array_1_x exists and is out of the beam, + # the other three axes don't exist at all. + dev = _FakeDeviceContainer(filter_array_1_x=_axis_at(25.0)) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is True + assert offending == [] + + +def test_filters_out_of_beam_flags_axis_in_beam(): + dev = _FakeDeviceContainer( + filter_array_1_x=_axis_at(25.0), # out + filter_array_2_x=_axis_at(17.6), # NOT out (out position is 25.5) + ) + + all_out, offending = filters_out_of_beam(dev) + + assert all_out is False + assert offending == ["filter_array_2_x"]