Files
csaxs_bec/tests/tests_devices/test_falcon_csaxs.py
menzelandClaude Opus 5 af5603d9b8
Read the Docs Deploy Trigger / trigger-rtd-webhook (push) Successful in 1s
CI for csaxs_bec / test (push) Successful in 1m48s
fix(falcon): name prefix in __init__ so the device server stops dropping it
The __init__ added in the previous commit took (*args, **kwargs). The device
server builds a device's init kwargs by intersecting the deviceConfig keys
with the NAMED parameters of the class signature
(bec_server/device_server/devices/devicemanager.py:469-475), so 'prefix' was
silently discarded and the Falcon was constructed with an empty prefix.

Every signal then pointed at a bare suffix -- HDF1:FilePath_RBV instead of
X12SA-SITORO:HDF1:FilePath_RBV -- and instantiation failed with
"TimeoutError: Failed to connect to all signals" listing several hundred PVs.
That reads like an unreachable IOC, which is how it was diagnosed at the
beamline for two hours, while caget from the same host worked perfectly.

The signature now names name, prefix, scan_info, device_manager and
xml_file_name explicitly, matching DDG1. Two tests guard it: one asserts every
deviceConfig key is a named parameter, the other that a configured prefix
reaches the signal PV names.

Reported-by: Klaus Wakonig <klaus.wakonig@psi.ch>

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLnmUurqcNd1FiDY5M2uZr
2026-09-09 12:29:26 +02:00

393 lines
14 KiB
Python

# pylint: skip-file
import inspect
import os
import threading
from typing import Generator
from unittest import mock
import ophyd
import pytest
from bec_lib import messages
from bec_lib.endpoints import MessageEndpoints
from bec_lib.file_utils import get_full_path
from bec_server.device_server.tests.utils import DMMock
from ophyd_devices.interfaces.base_classes.psi_device_base import DeviceStoppedError
from ophyd_devices.tests.utils import patched_device
from csaxs_bec.devices.epics.falcon_csaxs import (
ACQUIRESTATUS,
DEFAULT_XML_FILE_NAME,
FalconcSAXS,
FalconError,
MappingSource,
TriggerSource,
)
from csaxs_bec.devices.utils.utils import fetch_scan_info
@pytest.fixture(scope="function")
def mock_det() -> Generator[FalconcSAXS, None, None]:
"""Fixture to mock the FalconcSAXS device."""
name = "mcs_csaxs"
prefix = "X12SA-MCS-CSAXS:"
dm = DMMock()
with patched_device(
FalconcSAXS,
name="falcon",
prefix="X12SA-SITORO:",
device_manager=dm,
_mock_pv_initial_value=1,
) as dev:
try:
for dotted_name, device in dev.walk_subdevices(include_lazy=True):
device.stage_sigs = {} # Remove stage signals
device.trigger_sigs = {} # Remove trigger signals
if hasattr(device, "plugin_type"):
device.plugin_type._read_pv.mock_data = device._plugin_type
yield dev
finally:
dev.destroy()
def test_falcon_init(mock_det: FalconcSAXS):
"""Test the initialization of the FalconcSAXS device."""
assert mock_det._readout_time == mock_det.MIN_READOUT
assert mock_det._value_pixel_per_buffer == 20
assert mock_det._queue_size == 2000
assert mock_det._full_path == ""
def test_falcon_on_connected(mock_det: FalconcSAXS):
"""Test the on_connected method of the FalconcSAXS device."""
falcon = mock_det
# Set known default values
falcon.preset_mode.put(-1)
falcon.input_logic_polarity.put(-1)
falcon.auto_pixels_per_buffer.put(-1)
falcon.hdf5.enable.put(-1)
with (
mock.patch.object(falcon, "on_stop") as mock_on_stop,
mock.patch.object(falcon, "set_trigger") as mock_set_trigger,
):
falcon.on_connected()
mock_on_stop.assert_called_once()
mock_set_trigger.assert_called_once_with(
mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0
)
# Detector default PV values
assert falcon.preset_mode.get() == "1" # Real Time
assert falcon.input_logic_polarity.get() == 0
assert falcon.auto_pixels_per_buffer.get() == 0
assert falcon.pixels_per_buffer.get() == falcon._value_pixel_per_buffer
# Backend default PV values
assert falcon.hdf5.enable.get() == "1" # Enabled
assert falcon.hdf5.xml_file_name.get() == DEFAULT_XML_FILE_NAME
assert falcon.hdf5.lazy_open.get() == "1" # Enabled
assert falcon.hdf5.temp_suffix.get() == ""
assert falcon.hdf5.queue_size.get() == falcon._queue_size
assert falcon.nd_array_mode.get() == 1
assert falcon.hdf5.file_template.get() == "%s%s"
assert falcon.hdf5.file_write_mode.get() == 2
def test_falcon_on_stage(mock_det: FalconcSAXS):
"""
Test the on_stage method of the FalconcSAXS device.
All relevant information is available in the scan_info attribute and used
to bootstrap the detector for the upcoming acquisition. Two scenarios are tested:
I. Normal case with exposure time larger than readout time
II. Case where exposure time is smaller than readout time, which should raise an exception.
"""
falcon = mock_det
num_points = 10
exp_time = 0.2
frames_per_trigger = 5
falcon.scan_info.msg.info["num_points"] = num_points
falcon.scan_info.msg.info["frames_per_trigger"] = frames_per_trigger
falcon.scan_info.msg.info["exp_time"] = exp_time
falcon.hdf5.array_counter.put(5) # Set to non-zero to check reset
# I. Normal case
falcon.stage()
assert falcon.staged is ophyd.Staged.yes
assert falcon._full_path == get_full_path(falcon.scan_info.msg, falcon.name)
file_path = falcon.hdf5.file_path.get()
file_name = falcon.hdf5.file_name.get()
assert os.path.join(file_path, file_name) == falcon._full_path
assert falcon.preset_real_time.get() == exp_time
assert falcon.pixels_per_run.get() == num_points * frames_per_trigger
assert falcon.hdf5.num_capture.get() == num_points * frames_per_trigger
assert falcon.hdf5.array_counter.get() == 0
assert falcon.hdf5.capture.get() == 1
assert falcon.start_all.get() == 1
# II. Unstage device first
falcon.unstage()
exp_time = 1e-3 # Smaller than readout time
falcon.scan_info.msg.info["exp_time"] = exp_time
with pytest.raises(ValueError):
falcon.stage()
assert falcon.staged is not ophyd.Staged.no
def test_falcon_on_pre_scan(mock_det: FalconcSAXS):
"""Test the on_pre_scan method of the FalconcSAXS device."""
falcon = mock_det
# I. Test normal case with success
falcon.acquire_busy._read_pv.mock_data = ACQUIRESTATUS.DONE
falcon.hdf5.capture._read_pv.mock_data = ACQUIRESTATUS.DONE
falcon = mock_det
st = falcon.on_pre_scan()
assert st.done is False
assert st.success is False
falcon.acquire_busy._read_pv.mock_data = ACQUIRESTATUS.ACQUIRING
assert st.done is False
assert st.success is False
falcon.hdf5.capture._read_pv.mock_data = ACQUIRESTATUS.ACQUIRING
st.wait(3)
assert st.done is True
assert st.success is True
# II. Test abort case with stop called
falcon.acquire_busy._read_pv.mock_data = ACQUIRESTATUS.DONE
falcon.hdf5.capture._read_pv.mock_data = ACQUIRESTATUS.DONE
st = falcon.on_pre_scan()
assert st.done is False
assert st.success is False
falcon.stop()
with pytest.raises(DeviceStoppedError):
st.wait(3)
assert st.done is True
assert st.success is False
def test_falcon_stop(mock_det: FalconcSAXS):
"""Test the stop method of the FalconcSAXS device."""
falcon = mock_det
falcon.stop_all.put(0)
falcon.hdf5.capture.put(1)
falcon.erase_all.put(0)
falcon.stop()
assert falcon.stop_all.get() == 1
assert falcon.hdf5.capture.get() == 0
assert falcon.erase_all.get() == 1
def test_falcon_complete(mock_det: FalconcSAXS):
"""Test the complete method of the FalconcSAXS device."""
falcon = mock_det
num_points = 10
frames_per_trigger = 5
falcon.scan_info.msg.info["num_points"] = num_points
falcon.scan_info.msg.info["frames_per_trigger"] = frames_per_trigger
falcon.scan_parameters = fetch_scan_info(falcon.scan_info)
# I. Test normal case with success
falcon.dxp.current_pixel._read_pv.mock_data = num_points * frames_per_trigger - 1
falcon.hdf5.array_counter._read_pv.mock_data = num_points * frames_per_trigger - 1
falcon._full_path = "/tmp/fake_path/test.h5"
st = falcon.on_complete()
assert st.done is False
assert st.success is False
falcon.dxp.current_pixel._read_pv.mock_data = num_points * frames_per_trigger
assert st.done is False
assert st.success is False
falcon.hdf5.array_counter._read_pv.mock_data = num_points * frames_per_trigger
st.wait(3)
assert st.done is True
assert st.success is True
assert falcon.file_event.get() == messages.FileMessage(
file_path="/tmp/fake_path/test.h5",
done=True,
successful=True,
device_name=falcon.name,
file_type="h5",
hinted_h5_entries=None,
metadata={},
)
# II. Test case where acquisition fails due to interruption
falcon.dxp.current_pixel._read_pv.mock_data = num_points * frames_per_trigger - 1
st = falcon.on_complete()
assert st.done is False
assert st.success is False
falcon.stop()
with pytest.raises(DeviceStoppedError):
st.wait(3)
assert falcon.file_event.get() == messages.FileMessage(
file_path="/tmp/fake_path/test.h5",
done=True,
successful=False,
device_name=falcon.name,
file_type="h5",
hinted_h5_entries=None,
metadata={},
)
def test_falcon_is_primed(mock_det: FalconcSAXS):
"""The plugin counts as primed as soon as any array dimension is non-zero."""
falcon = mock_det
with mock.patch.object(falcon.hdf5.array_size, "get", return_value=(0, 0, 0)):
assert falcon.is_primed() is False
with mock.patch.object(falcon.hdf5.array_size, "get", return_value=(0, 1, 3000)):
assert falcon.is_primed() is True
def test_falcon_prime_skips_when_already_primed(mock_det: FalconcSAXS):
"""Priming an already primed plugin must not touch the detector."""
falcon = mock_det
with (
mock.patch.object(falcon, "is_primed", return_value=True),
mock.patch.object(falcon, "set_trigger") as mock_set_trigger,
mock.patch.object(falcon, "on_stop") as mock_on_stop,
):
falcon.prime()
mock_set_trigger.assert_not_called()
mock_on_stop.assert_not_called()
def test_falcon_prime(mock_det: FalconcSAXS):
"""Priming advances a pixel by hand and restores the trigger configuration."""
falcon = mock_det
falcon.set_trigger(
mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0
)
falcon.pixels_per_buffer.put(20)
with (
mock.patch.object(falcon, "is_primed", side_effect=[False, True]),
mock.patch.object(falcon.next_pixel, "put") as mock_next_pixel,
):
falcon.prime()
# A gate signal must not be required, so the pixel is advanced from software.
assert mock_next_pixel.call_count > 0
assert falcon.hdf5.capture.get() == 0
# Trigger configuration restored, otherwise the next scan silently starves.
assert falcon.collect_mode.get() == MappingSource.MAPPING
assert falcon.pixel_advance_mode.get() == TriggerSource.GATE
assert falcon.ignore_gate.get() == 0
assert falcon.pixels_per_buffer.get() == 20
def test_falcon_prime_restores_config_on_failure(mock_det: FalconcSAXS):
"""A failed priming must still leave the detector in its original trigger config."""
falcon = mock_det
falcon.set_trigger(
mapping_mode=MappingSource.MAPPING, trigger_source=TriggerSource.GATE, ignore_gate=0
)
falcon.pixels_per_buffer.put(20)
with (
mock.patch.object(falcon, "is_primed", return_value=False),
mock.patch.object(falcon, "_wait_for_primed", return_value=False),
):
with pytest.raises(FalconError):
falcon.prime()
assert falcon.pixel_advance_mode.get() == TriggerSource.GATE
assert falcon.ignore_gate.get() == 0
assert falcon.pixels_per_buffer.get() == 20
def test_falcon_sets_the_ioc_resolvable_layout(mock_det: FalconcSAXS):
"""A bare 'layout.xml' does not resolve for the IOC; cfg/layout.xml does."""
falcon = mock_det
falcon.hdf5.xml_file_name.put("something-else")
with mock.patch.object(falcon, "_check_xml_layout", return_value=True):
falcon._initialize_detector_backend()
assert falcon.hdf5.xml_file_name.get() == DEFAULT_XML_FILE_NAME
assert DEFAULT_XML_FILE_NAME == "cfg/layout.xml"
def test_falcon_xml_file_name_from_device_config():
"""deviceConfig can point at a different layout, or disable it with ''."""
with patched_device(
FalconcSAXS,
name="falcon",
prefix="X12SA-SITORO:",
device_manager=DMMock(),
xml_file_name="",
_mock_pv_initial_value=1,
) as dev:
try:
assert dev._xml_file_name == ""
# Nothing to validate when the built-in layout is requested.
assert dev._check_xml_layout(timeout=0.01) is True
finally:
dev.destroy()
def test_falcon_warns_when_the_ioc_rejects_the_layout(mock_det: FalconcSAXS):
"""An unreadable layout is reported at connect, not as a write error much later."""
falcon = mock_det
with (
mock.patch.object(falcon.hdf5.xml_valid, "get", return_value="No"),
mock.patch.object(falcon.hdf5.xml_error_msg, "get", return_value="cannot be opened"),
mock.patch("csaxs_bec.devices.epics.falcon_csaxs.logger") as mock_logger,
):
assert falcon._check_xml_layout(timeout=0.05) is False
assert mock_logger.error.call_count == 1
assert "layout" in mock_logger.error.call_args[0][0].lower()
def test_falcon_accepts_a_valid_layout_silently(mock_det: FalconcSAXS):
"""A layout the IOC accepts produces no error."""
falcon = mock_det
with (
mock.patch.object(falcon.hdf5.xml_valid, "get", return_value="Yes"),
mock.patch("csaxs_bec.devices.epics.falcon_csaxs.logger") as mock_logger,
):
assert falcon._check_xml_layout(timeout=0.05) is True
mock_logger.error.assert_not_called()
def test_falcon_init_names_every_device_config_key():
"""The device server drops config keys that are not named parameters.
bec_server's devicemanager builds the init kwargs by intersecting the deviceConfig
keys with the class signature's named parameters. A *args/**kwargs signature
therefore silently discards 'prefix', and every PV is built without it -- which
presents as 'failed to connect to all signals' rather than as a config error.
"""
params = inspect.signature(FalconcSAXS).parameters
for key in ("name", "prefix", "scan_info", "device_manager", "xml_file_name"):
assert key in params, f"{key} must be a named parameter or the device server drops it"
def test_falcon_keeps_the_configured_prefix():
"""A prefix passed as the device server passes it must reach the signals."""
with patched_device(
FalconcSAXS,
name="falcon",
prefix="X12SA-SITORO:",
device_manager=DMMock(),
_mock_pv_initial_value=1,
) as dev:
try:
assert dev.prefix == "X12SA-SITORO:"
assert dev.hdf5.file_path.pvname.startswith("X12SA-SITORO:")
finally:
dev.destroy()