Files
csaxs_bec/tests/tests_devices/test_eiger.py
T
x12saandClaude Opus 5.5 d56a2a6d47
CI for csaxs_bec / test (push) Failing after 3m15s
fix(eiger): log JungfrauJoch cancel and flag a Dectris server left armed
On 2026-10-10 an interlock abort left the Eiger2 Si 500K Dectris server
armed (state ready, stream acquire), and every re-init then failed with
"Detector busy" until the fibres were re-seated. The cancel path logged
nothing on success, so it was not possible to tell what happened.

- stop() logs a successful cancel with its reason, then the Dectris status
- recover_from_error() adds a hint to the error when Dectris is still armed
- on_stop logs that it was called
- tests: block cancel to the production JungfrauJoch host from fixtures

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1MYT2ByPys8XL6BdfApAF
2026-10-12 11:33:23 +02:00

1108 lines
43 KiB
Python

# pylint: skip-file
import inspect
import os
import threading
import time
from typing import TYPE_CHECKING, Generator
from unittest import mock
import numpy as np
import pytest
from bec_lib.messages import FileMessage, ScanStatusMessage
from bec_server.scan_server.scans.scan_base import ScanInfo as ScanServerScanInfo
from jfjoch_client.exceptions import ServiceException
from jfjoch_client.models.broker_status import BrokerStatus
from jfjoch_client.models.dataset_settings import DatasetSettings
from jfjoch_client.models.detector_list import DetectorList
from jfjoch_client.models.detector_list_element import DetectorListElement
from jfjoch_client.models.detector_settings import DetectorSettings
from jfjoch_client.models.detector_timing import DetectorTiming
from jfjoch_client.models.file_writer_format import FileWriterFormat
from jfjoch_client.models.file_writer_settings import FileWriterSettings
from jfjoch_client.models.measurement_statistics import MeasurementStatistics
from ophyd import Staged
from ophyd_devices.utils.psi_device_base_utils import DeviceStatus
from csaxs_bec.devices.jungfraujoch.eiger import EigerError
from csaxs_bec.devices.jungfraujoch.eiger import (
EIGER_READOUT_TIME,
MIN_EXP_TIME,
MISSING_PACKETS_MESSAGE,
RAISE_ON_MISSING_PACKETS,
)
from csaxs_bec.devices.jungfraujoch.jungfrau_joch_client import (
DetectorState,
JungfrauJochClient,
JungfrauJochClientError,
)
from csaxs_bec.devices.jungfraujoch.eiger_1_5m import EIGER1_5M_READOUT_TIME, Eiger1_5M
from csaxs_bec.devices.jungfraujoch.eiger_9m import EIGER9M_READOUT_TIME, Eiger9M
from csaxs_bec.devices.jungfraujoch.eiger2_si_500k import (
EIGER2_SI_500K_READOUT_TIME,
Eiger2Si500K,
)
if TYPE_CHECKING: # pragma no cover
from bec_lib.messages import FileMessage
# @pytest.fixture(scope="function")
# def scan_worker_mock(scan_server_mock):
# scan_server_mock.device_manager.connector = mock.MagicMock()
# scan_worker = ScanWorker(parent=scan_server_mock)
# yield scan_worker
@pytest.fixture(
scope="function",
params=[(0.1, 1, 1, "line_scan"), (0.2, 2, 2, "time_scan"), (0.5, 5, 5, "acquire")],
)
def mock_scan_info(request, tmpdir):
exp_time, frames_per_trigger, num_points, scan_name = request.param
scan_info = ScanStatusMessage(
scan_id="test_id",
status="open",
scan_number=1,
scan_parameters={
"exp_time": exp_time,
"frames_per_trigger": frames_per_trigger,
"system_config": {},
},
info=ScanServerScanInfo(
scan_name=scan_name,
scan_id="test_id",
positions=np.array([[0, 0], [1, 1], [2, 2]]),
exp_time=exp_time,
frames_per_trigger=frames_per_trigger,
system_config={},
num_points=num_points,
).model_dump(),
num_points=num_points,
scan_name=scan_name,
)
scan_info.info.update({"file_components": (f"{tmpdir}/data/S00000/S000001", "h5")})
yield scan_info
@pytest.fixture(scope="function", params=[(1,), (2,), (3,)])
def detector_list(request) -> Generator[DetectorList, None, None]:
"""Fixture for the detector list."""
current_id = request.param[0]
detector_list = DetectorList(
detectors=[
DetectorListElement(
id=1,
description="EIGER 1.5M",
serial_number="123456",
base_ipv4_addr="192.168.0.1",
udp_interface_count=1,
nmodules=1,
width=512,
height=512,
pixel_size_mm=0.1,
readout_time_ns=100000,
min_frame_time_ns=1000000,
min_count_time_ns=100000,
type="EIGER",
),
DetectorListElement(
id=2,
description="EIGER 9M",
serial_number="123456",
base_ipv4_addr="192.168.0.1",
udp_interface_count=1,
nmodules=1,
width=512,
height=512,
pixel_size_mm=0.1,
readout_time_ns=100000,
min_frame_time_ns=1000000,
min_count_time_ns=100000,
type="EIGER",
),
DetectorListElement(
id=3,
description="Dectris EIGER2 Si 500K",
serial_number="123456",
base_ipv4_addr="192.168.0.1",
udp_interface_count=1,
nmodules=1,
width=512,
height=512,
pixel_size_mm=0.1,
readout_time_ns=100000,
min_frame_time_ns=1000000,
min_count_time_ns=100000,
type="EIGER",
),
],
current_id=current_id,
)
yield detector_list
@pytest.fixture(scope="function")
def eiger_1_5m(mock_scan_info) -> Generator[Eiger1_5M, None, None]:
"""Fixture for the Eiger 1.5M device."""
name = "eiger_1_5m"
dev = Eiger1_5M(name=name, beam_center=(256, 256), detector_distance=100.0)
dev.scan_info.msg = mock_scan_info
try:
yield dev
finally:
if dev._destroyed is False:
dev.destroy()
@pytest.fixture(scope="function")
def eiger_9m(mock_scan_info) -> Generator[Eiger9M, None, None]:
"""Fixture for the Eiger 9M device.
Currently only on_connected is different for both devices, all other methods are the same."""
name = "eiger_9m"
dev = Eiger9M(name=name)
dev.scan_info.msg = mock_scan_info
try:
yield dev
finally:
if dev._destroyed is False:
dev.destroy()
@pytest.fixture(autouse=True)
def no_live_jfjoch_cancel():
"""
The devices point to the production JungfrauJoch host, and destroy() -> on_stop() sends a
cancel from a background thread. Block it so tests never cancel a live acquisition, nor read
the Dectris detector server afterwards. Tests patching the client instance still override this.
"""
with mock.patch(
"jfjoch_client.api.default_api.DefaultApi.cancel_post_with_http_info",
side_effect=ConnectionError("tests must not reach JungfrauJoch"),
):
yield
# Stop threads started by device teardown must finish while the patch is active.
for thread in threading.enumerate():
if thread.name == "stop_jungfraujoch_thread":
thread.join(timeout=5)
@pytest.fixture(scope="function")
def eiger2_si_500k(mock_scan_info) -> Generator[Eiger2Si500K, None, None]:
"""Fixture for the Eiger 2 Si 500K device.
Currently only on_connected is different for both devices, all other methods are the same."""
name = "eiger2_si_500k"
dev = Eiger2Si500K(name=name)
dev.scan_info.msg = mock_scan_info
try:
yield dev
finally:
if dev._destroyed is False:
dev.destroy()
def test_eiger_wait_for_connection(eiger_1_5m, eiger_9m, eiger2_si_500k):
"""Test the wait_for_connection metho is calling status_get on the JFJ API client."""
for eiger in (eiger_1_5m, eiger_9m, eiger2_si_500k):
with mock.patch.object(eiger.jfj_client.api, "status_get") as mock_status_get:
eiger.wait_for_connection(timeout=1)
mock_status_get.assert_called_once_with(_request_timeout=1)
@pytest.mark.parametrize("detector_state", ["Idle", "Inactive"])
def test_eiger_1_5m_on_connected(eiger_1_5m, detector_list, detector_state):
"""Test the on_connected logic of the Eiger detector."""
eiger = eiger_1_5m
detector_id = 1
with (
mock.patch.object(eiger.jfj_client, "stop") as mock_jfj_client_stop,
mock.patch.object(
eiger.jfj_client.api, "config_select_detector_get", return_value=detector_list
),
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state=detector_state)
),
mock.patch.object(eiger.jfj_client, "set_detector_settings") as mock_set_det,
mock.patch.object(eiger.jfj_client.api, "config_file_writer_put") as mock_file_writer,
mock.patch.object(eiger, "jfj_preview_client") as mock_jfj_preview_client,
):
if detector_state != "Idle" or detector_list.current_id != detector_id:
with pytest.raises(RuntimeError):
eiger.on_connected()
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.call_count == 0
else:
eiger.on_connected()
assert mock_set_det.call_args == mock.call(
DetectorSettings(frame_time_us=500, timing=DetectorTiming.TRIGGER), timeout=5
)
assert mock_file_writer.call_args == mock.call(
file_writer_settings=FileWriterSettings(
overwrite=True, format=FileWriterFormat.NXMXVDS
),
_request_timeout=10,
)
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.connect.call_count == 1
assert mock_jfj_preview_client.start.call_count == 1
@pytest.mark.parametrize("detector_state", ["Idle", "Inactive"])
def test_eiger2_si_500k_on_connected(eiger2_si_500k, detector_list, detector_state):
"""Test the on_connected logic of the Eiger detector."""
eiger = eiger2_si_500k
detector_id = 3
with (
mock.patch.object(eiger.jfj_client, "stop") as mock_jfj_client_stop,
mock.patch.object(
eiger.jfj_client.api, "config_select_detector_get", return_value=detector_list
),
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state=detector_state)
),
mock.patch.object(eiger.jfj_client, "set_detector_settings") as mock_set_det,
mock.patch.object(eiger.jfj_client.api, "config_file_writer_put") as mock_file_writer,
mock.patch.object(eiger, "jfj_preview_client") as mock_jfj_preview_client,
):
if detector_state != "Idle" or detector_list.current_id != detector_id:
with pytest.raises(RuntimeError):
eiger.on_connected()
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.call_count == 0
else:
eiger.on_connected()
assert mock_set_det.call_args == mock.call(
DetectorSettings(frame_time_us=500, timing=DetectorTiming.TRIGGER), timeout=5
)
assert mock_file_writer.call_args == mock.call(
file_writer_settings=FileWriterSettings(
overwrite=True, format=FileWriterFormat.NXMXVDS
),
_request_timeout=10,
)
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.connect.call_count == 1
assert mock_jfj_preview_client.start.call_count == 1
@pytest.mark.skip(
reason="This test is currently disabled because the Eiger 9M config is replaced by the 8_5M config."
)
@pytest.mark.parametrize("detector_state", ["Idle", "Inactive"])
def test_eiger_9m_on_connected(eiger_9m, detector_list, detector_state):
"""Test the on_connected logic of the Eiger detector."""
eiger = eiger_9m
detector_id = 2
with (
mock.patch.object(eiger.jfj_client, "stop") as mock_jfj_client_stop,
mock.patch.object(
eiger.jfj_client.api, "config_select_detector_get", return_value=detector_list
),
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state=detector_state)
),
mock.patch.object(eiger.jfj_client, "set_detector_settings") as mock_set_det,
mock.patch.object(eiger.jfj_client.api, "config_file_writer_put") as mock_file_writer,
mock.patch.object(eiger, "jfj_preview_client") as mock_jfj_preview_client,
):
if detector_state != "Idle" or detector_list.current_id != detector_id:
with pytest.raises(RuntimeError):
eiger.on_connected()
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.call_count == 0
else:
eiger.on_connected()
assert mock_set_det.call_args == mock.call(
DetectorSettings(frame_time_us=500, timing=DetectorTiming.TRIGGER), timeout=5
)
assert mock_file_writer.call_args == mock.call(
file_writer_settings=FileWriterSettings(
overwrite=True, format=FileWriterFormat.NXMXVDS
),
_request_timeout=10,
)
mock_jfj_client_stop.assert_called_once()
assert mock_jfj_preview_client.connect.call_count == 1
assert mock_jfj_preview_client.start.call_count == 1
@pytest.mark.timeout(20)
def test_eiger_on_stop(eiger_1_5m):
"""Test the on_stop logic of the Eiger detector. This is equivalent for 9M and 1_5M."""
eiger = eiger_1_5m
start_event = threading.Event()
stop_event = threading.Event()
def tmp_task():
start_event.set()
try:
while True:
time.sleep(0.1)
finally:
stop_event.set()
eiger.task_handler.submit_task(tmp_task, run=True)
start_event.wait(timeout=5) # Wait for thread to start
with mock.patch.object(eiger.jfj_client, "stop") as mock_jfj_client_stop:
eiger.on_stop()
mock_jfj_client_stop.assert_called_once()
stop_event.wait(timeout=5) # Thread should be killed from task_handler
def test_eiger_on_destroy(eiger_1_5m):
"""Test the on_destroy logic of the Eiger detector. This is equivalent for 9M and 1_5M."""
eiger = eiger_1_5m
start_event = threading.Event()
stop_event = threading.Event()
def tmp_task():
start_event.set()
try:
while True:
time.sleep(0.1)
finally:
stop_event.set()
eiger.task_handler.submit_task(tmp_task)
start_event.wait(timeout=5)
with (
mock.patch.object(eiger.jfj_preview_client, "stop") as mock_jfj_preview_client_stop,
mock.patch.object(eiger.jfj_client, "stop") as mock_jfj_client_stop,
):
eiger.on_destroy()
mock_jfj_preview_client_stop.assert_called_once()
mock_jfj_client_stop.assert_called_once()
stop_event.wait(timeout=5)
@pytest.mark.timeout(25)
@pytest.mark.parametrize("raise_timeout", [True, False])
def test_eiger_on_complete(eiger_1_5m, raise_timeout):
"""Test the on_complete logic of the Eiger detector. This is equivalent for 9M and 1_5M."""
eiger = eiger_1_5m
eiger._wait_for_on_complete = 1 # reduce wait time for testing
callback_completed_event = threading.Event()
def _callback_complete(status: DeviceStatus):
if status.done:
callback_completed_event.set()
unblock_wait_for_idle = threading.Event()
def mock_wait_for_idle(timeout: float, raise_on_timeout: bool) -> bool:
if unblock_wait_for_idle.wait(timeout):
if raise_timeout:
return False
return True
return False
with (
mock.patch.object(
eiger.jfj_client.api,
"status_get",
return_value=BrokerStatus(state="Idle", message_severity="info"),
),
mock.patch.object(eiger.jfj_client, "wait_for_idle", side_effect=mock_wait_for_idle),
mock.patch.object(
eiger.jfj_client.api,
"statistics_data_collection_get",
return_value=MeasurementStatistics(
run_number=1,
images_collected=eiger.scan_info.msg.num_points
* eiger.scan_info.msg.scan_parameters["frames_per_trigger"],
),
),
):
status = eiger.complete()
status.add_callback(_callback_complete)
assert status.done == False
assert status.success == False
assert eiger.file_event.get() is None
unblock_wait_for_idle.set()
if raise_timeout:
with pytest.raises(TimeoutError):
status.wait(timeout=10)
else:
status.wait(timeout=10)
assert status.done == True
assert status.success == False if raise_timeout else True
def test_eiger_on_complete_error_message(eiger_1_5m):
"""Test that on_complete raises if the message severity is error.
Unrelated to the missing-packet tolerance: this message is a different error, so it
raises whatever raise_on_missing_packets is set to.
"""
# Test that on_complete raises if message_severity is error
eiger = eiger_1_5m
unblock_wait_for_idle = threading.Event()
raise_timeout = False
def mock_wait_for_idle(timeout: float, raise_on_timeout: bool) -> bool:
if unblock_wait_for_idle.wait(timeout):
if raise_timeout:
return False
return True
return False
with (
mock.patch.object(
eiger.jfj_client.api,
"status_get",
return_value=BrokerStatus(state="Idle", message_severity="error", message="Test error"),
),
mock.patch.object(eiger.jfj_client, "wait_for_idle", side_effect=mock_wait_for_idle),
mock.patch.object(
eiger.jfj_client.api,
"statistics_data_collection_get",
return_value=MeasurementStatistics(
run_number=1,
images_collected=eiger.scan_info.msg.num_points
* eiger.scan_info.msg.scan_parameters["frames_per_trigger"],
),
),
):
with pytest.raises(EigerError):
status = eiger.complete()
unblock_wait_for_idle.set()
status.wait(timeout=2)
def test_eiger_file_event_callback(eiger_1_5m, tmp_path):
"""Test the file_event callback of the Eiger detector. This is equivalent for 9M and 1_5M."""
eiger = eiger_1_5m
test_file = tmp_path / "test_file.h5"
eiger._full_path = str(test_file)
assert eiger.file_event.get() is None
status = DeviceStatus(device=eiger, done=True, success=True)
eiger._file_event_callback(status)
file_msg: FileMessage = eiger.file_event.get()
assert file_msg.device_name == eiger.name
assert file_msg.file_path == str(test_file)
assert file_msg.done is True
assert file_msg.successful is True
assert file_msg.hinted_h5_entries == {"data": "entry/data/data"}
status = DeviceStatus(device=eiger, done=False, success=False)
eiger._file_event_callback(status)
file_msg: FileMessage = eiger.file_event.get()
assert file_msg.device_name == eiger.name
assert file_msg.file_path == str(test_file)
assert file_msg.done is False
assert file_msg.successful is False
assert file_msg.hinted_h5_entries == {"data": "entry/data/data"}
def test_eiger_on_stage(eiger_1_5m):
"""Test the on_stage and on_unstage logic of the Eiger detector. This is equivalent for 9M and 1_5M."""
eiger = eiger_1_5m
scan_msg = eiger.scan_info.msg
with (
mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True),
mock.patch.object(eiger.jfj_client, "start") as mock_start,
mock.patch.object(eiger.jfj_client, "stop") as mock_stop,
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle")
),
):
eiger.stage()
mock_stop.assert_not_called()
assert (
eiger._full_path
== f"{scan_msg.info['file_components'][0]}_{eiger.name}_master.{scan_msg.info['file_components'][1]}"
)
file_msg: FileMessage = eiger.file_event.get()
assert file_msg.file_path == eiger._full_path
assert file_msg.done is False
assert file_msg.successful is False
assert file_msg.hinted_h5_entries == {"data": "entry/data/data"}
data_settings = DatasetSettings(
# exp_time is the trigger period; the detector acquires for the gated window
image_time_us=int((scan_msg.scan_parameters["exp_time"] - EIGER_READOUT_TIME) * 1e6),
ntrigger=int(scan_msg.num_points * scan_msg.scan_parameters["frames_per_trigger"]),
file_prefix=os.path.relpath(eiger._full_path, start="/sls/x12sa/data").removesuffix(
"_master.h5"
),
beam_x_pxl=eiger.beam_center[0],
beam_y_pxl=eiger.beam_center[1],
detector_distance_mm=eiger.detector_distance,
incident_energy_ke_v=12.0, # hardcoded at this moment as it is hardcoded in the Eiger implementation
)
assert mock_start.call_args == mock.call(settings=data_settings)
assert eiger.staged is Staged.yes
def test_eiger_set_det_distance_test_beam_center(eiger_1_5m):
"""Test the set_detector_distance and set_beam_center methods. Equivalent for 9M and 1_5M."""
eiger = eiger_1_5m
old_distance = eiger.detector_distance
new_distance = old_distance + 100
old_beam_center = eiger.beam_center
new_beam_center = (old_beam_center[0] + 20, old_beam_center[1] + 50)
eiger.set_detector_distance(new_distance)
assert eiger.detector_distance == new_distance
eiger.set_beam_center(x=new_beam_center[0], y=new_beam_center[1])
assert eiger.beam_center == new_beam_center
with pytest.raises(ValueError):
eiger.set_beam_center(x=-10, y=100) # Cannot set negative beam center
with pytest.raises(ValueError):
eiger.detector_distance = -50 # Cannot set negative detector distance
def test_eiger_preview_callback(eiger_1_5m):
"""Preview callback test for the Eiger detector. This is equivalent for 9M and 1_5M."""
eiger = eiger_1_5m
# NOTE: I don't find models for the CBOR messages used by JFJ, currently using a dummay dict.
# Please adjust once the proper model is found.
for msg_type in ["start", "end", "image", "calibration", "metadata"]:
msg = {"type": msg_type, "data": {"default": np.array([[1, 2], [3, 4]])}}
with mock.patch.object(eiger.preview_image, "put") as mock_preview_put:
eiger._preview_callback(msg)
if msg_type == "image":
mock_preview_put.assert_called_once_with(msg["data"]["default"])
else:
mock_preview_put.assert_not_called()
def test_eiger_preview_callback_falls_back_to_non_default_channel(eiger2_si_500k):
"""The Eiger2 Si 500K's multi-threshold preview data has no "default" key (it's
named after the threshold, e.g. "threshold_1" -- confirmed against a live capture
of the JFJ preview stream); the callback must still forward that single channel.
"""
eiger = eiger2_si_500k
frame = np.array([[1, 2], [3, 4]])
msg = {"type": "image", "data": {"threshold_1": frame}}
with mock.patch.object(eiger.preview_image, "put") as mock_preview_put:
eiger._preview_callback(msg)
mock_preview_put.assert_called_once_with(frame)
def test_eiger_preview_callback_logs_when_data_is_truly_empty(eiger_1_5m):
"""No channel at all (empty or missing data dict) must still be treated as missing,
not silently pass through some other unrelated dict key."""
eiger = eiger_1_5m
for msg in [{"type": "image", "data": {}}, {"type": "image"}]:
with mock.patch.object(eiger.preview_image, "put") as mock_preview_put:
eiger._preview_callback(msg)
mock_preview_put.assert_not_called()
def test_eiger_acquisition_matches_the_gated_window(eiger_1_5m):
"""The detector must acquire for exp_time - readout, not for the whole period.
exp_time is the trigger period. The delay generator gates for exp_time - readout, so
sending the full period leaves the detector still acquiring when the next trigger
arrives -- 20 us of margin, which is what JungfrauJoch subtracts internally.
"""
eiger = eiger_1_5m
exp_time = eiger.scan_info.msg.scan_parameters["exp_time"]
with (
mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True),
mock.patch.object(eiger.jfj_client, "start") as mock_start,
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle")
),
):
eiger.stage()
sent = mock_start.call_args.kwargs["settings"].image_time_us
assert sent == int((exp_time - EIGER_READOUT_TIME) * 1e6)
assert sent < int(exp_time * 1e6)
def test_eiger_scan_readout_time_wins_when_larger(eiger_1_5m):
"""A scan asking for a wider gap must widen the acquisition window too."""
eiger = eiger_1_5m
exp_time = eiger.scan_info.msg.scan_parameters["exp_time"]
eiger.scan_info.msg.info["readout_time"] = 3e-3 # > EIGER_READOUT_TIME
with (
mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True),
mock.patch.object(eiger.jfj_client, "start") as mock_start,
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle")
),
):
eiger.stage()
assert mock_start.call_args.kwargs["settings"].image_time_us == int((exp_time - 3e-3) * 1e6)
def test_eiger_rejects_a_readout_that_fills_the_period(eiger_1_5m):
"""A gap wider than the period leaves nothing to acquire; fail rather than send <= 0."""
eiger = eiger_1_5m
exp_time = eiger.scan_info.msg.scan_parameters["exp_time"]
eiger.scan_info.msg.info["readout_time"] = exp_time + 1e-3
with (
mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True),
mock.patch.object(eiger.jfj_client, "start"),
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle")
),
):
with pytest.raises(ValueError):
eiger.stage()
def test_eiger_minimum_exposure_check_is_unchanged(eiger_1_5m):
"""Splitting readout from the validation floor must not relax the minimum exposure."""
eiger = eiger_1_5m
eiger.scan_info.msg.info["exp_time"] = MIN_EXP_TIME / 2
with (
mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True),
mock.patch.object(eiger.jfj_client, "start"),
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle")
),
):
with pytest.raises(ValueError):
eiger.stage()
def test_eiger_models_may_declare_their_own_readout_time(eiger_1_5m, eiger_9m, eiger2_si_500k):
"""Readout is a detector property, so each model states its own value.
They coincide today, but nothing should force them to: a 9M has more modules
than a 1.5M, and the Falcon needs 3 ms.
"""
assert eiger_1_5m._readout_time == EIGER1_5M_READOUT_TIME
assert eiger_9m._readout_time == EIGER9M_READOUT_TIME
assert eiger2_si_500k._readout_time == EIGER2_SI_500K_READOUT_TIME
def test_eiger_readout_time_is_overridable_per_deployment(mock_scan_info):
"""deviceConfig's readout_time wins over the model's default."""
dev = Eiger1_5M(
name="eiger_1_5m", beam_center=(256, 256), detector_distance=100.0, readout_time=1.5e-3
)
try:
assert dev._readout_time == 1.5e-3
finally:
dev.destroy()
def test_eiger_subclasses_name_readout_time_for_the_device_server():
"""deviceConfig keys reach a device only if the class signature names them.
bec_server intersects the config keys with the named parameters of the class
(device_server/devices/devicemanager.py), so a subclass that only forwards
**kwargs silently drops readout_time -- and passing it explicitly used to raise
'got multiple values for keyword argument'.
"""
for cls in (Eiger1_5M, Eiger9M, Eiger2Si500K):
assert "readout_time" in inspect.signature(cls).parameters
def _run_on_complete(eiger, broker_status, images_collected=0, timeout=5):
"""Drive on_complete to a verdict against a given broker status.
Returns the status once it resolves; raises whatever on_complete raised.
"""
eiger._wait_for_on_complete = 1 # keep the loop short
unblock_wait_for_idle = threading.Event()
def mock_wait_for_idle(timeout: float, raise_on_timeout: bool) -> bool:
return unblock_wait_for_idle.wait(timeout)
with (
mock.patch.object(eiger.jfj_client.api, "status_get", return_value=broker_status),
mock.patch.object(eiger.jfj_client, "wait_for_idle", side_effect=mock_wait_for_idle),
mock.patch.object(
eiger.jfj_client.api,
"statistics_data_collection_get",
return_value=MeasurementStatistics(run_number=1, images_collected=images_collected),
),
):
status = eiger.complete()
unblock_wait_for_idle.set()
status.wait(timeout=timeout)
return status
def _missing_packets_status():
"""A broker status carrying the packet-loss error JungfrauJoch actually emits."""
return BrokerStatus(
state="Idle", message_severity="error", message=f"{MISSING_PACKETS_MESSAGE} (run 42)"
)
def test_eiger_tolerates_missing_packets_when_configured_to(eiger_1_5m):
"""With the tolerance on, a missing-packet error completes the scan and is counted."""
eiger = eiger_1_5m
eiger.set_raise_on_missing_packets(False)
status = _run_on_complete(eiger, _missing_packets_status())
assert status.done is True
assert status.success is True
assert eiger.get_missing_packet_events() == 1
def test_eiger_raises_on_missing_packets_when_asked_to(eiger_1_5m):
"""With the tolerance off, the same error fails the scan and is not counted."""
eiger = eiger_1_5m
eiger.set_raise_on_missing_packets(True)
with pytest.raises(EigerError):
_run_on_complete(eiger, _missing_packets_status())
assert eiger.get_missing_packet_events() == 0
def test_eiger_tolerance_does_not_extend_to_other_broker_errors(eiger_1_5m):
"""Tolerating packet loss must not tolerate every error the broker reports."""
eiger = eiger_1_5m
eiger.set_raise_on_missing_packets(False)
with pytest.raises(EigerError):
_run_on_complete(
eiger, BrokerStatus(state="Idle", message_severity="error", message="Detector on fire")
)
assert eiger.get_missing_packet_events() == 0
def test_eiger_tolerance_does_not_hide_a_short_acquisition(eiger_1_5m):
"""The frame-count check still fires: only flagged-but-complete data is let through.
This is what makes the tolerance defensible -- a scan that actually lost frames still
fails, whatever raise_on_missing_packets says.
"""
eiger = eiger_1_5m
eiger.set_raise_on_missing_packets(False)
eiger._num_triggers = 10
with pytest.raises(EigerError, match="acquisition incomplete"):
_run_on_complete(eiger, _missing_packets_status(), images_collected=9)
def test_eiger_missing_packet_tolerance_is_switchable_at_runtime(eiger_1_5m):
"""The flag is reachable from the client, so a beamtime can change its mind."""
eiger = eiger_1_5m
assert eiger.get_raise_on_missing_packets() is RAISE_ON_MISSING_PACKETS
eiger.set_raise_on_missing_packets(True)
assert eiger.get_raise_on_missing_packets() is True
eiger.set_raise_on_missing_packets(False)
assert eiger.get_raise_on_missing_packets() is False
for name in (
"get_raise_on_missing_packets",
"set_raise_on_missing_packets",
"get_missing_packet_events",
):
assert name in type(eiger).USER_ACCESS
def test_eiger_missing_packet_tolerance_is_overridable_per_deployment(mock_scan_info):
"""deviceConfig's raise_on_missing_packets wins over the default, per detector."""
dev = Eiger1_5M(
name="eiger_1_5m",
beam_center=(256, 256),
detector_distance=100.0,
raise_on_missing_packets=True,
)
try:
assert dev.get_raise_on_missing_packets() is True
finally:
dev.destroy()
def test_eiger_subclasses_name_raise_on_missing_packets_for_the_device_server():
"""Same rule as readout_time: a deviceConfig key only reaches a device if the
class signature names it, so **kwargs alone would silently drop it."""
for cls in (Eiger1_5M, Eiger9M, Eiger2Si500K):
assert "raise_on_missing_packets" in inspect.signature(cls).parameters
def test_eiger_on_stage_auto_reinitialize(eiger_1_5m):
"""With auto_reinitialize the detector is recovered on staging, otherwise it is not."""
eiger = eiger_1_5m
for flag in (True, False):
eiger._auto_reinitialize = flag
with (
mock.patch.object(eiger.jfj_client, "wait_for_idle", return_value=True),
mock.patch.object(eiger.jfj_client, "start"),
mock.patch.object(eiger.jfj_client, "stop"),
mock.patch.object(eiger.jfj_client, "recover_from_error") as mock_recover,
mock.patch.object(
eiger.jfj_client.api, "status_get", return_value=BrokerStatus(state="Idle")
),
):
eiger.stage()
assert mock_recover.call_count == (1 if flag else 0)
eiger.unstage()
def test_eiger_on_stage_auto_reinitialize_failure(eiger_1_5m):
"""A failed recovery raises a single EigerError pointing to the WEB UI."""
eiger = eiger_1_5m
eiger._auto_reinitialize = True
with mock.patch.object(
eiger.jfj_client, "recover_from_error", side_effect=JungfrauJochClientError("still bad")
):
with pytest.raises(EigerError, match="could not be re-initialised"):
eiger.on_stage()
def test_eiger_on_complete_fails_fast_in_error_state(eiger_1_5m):
"""on_complete raises right away if the detector is in ERROR state, not after the timeout."""
eiger = eiger_1_5m
with mock.patch.object(
eiger.jfj_client.api,
"status_get",
return_value=BrokerStatus(state="Error", message_severity="error", message="boom"),
):
start = time.time()
status = eiger.complete()
with pytest.raises(EigerError):
status.wait(timeout=5)
assert time.time() - start < 5
def _jfj_client() -> JungfrauJochClient:
return JungfrauJochClient(host="http://localhost:1")
@pytest.mark.parametrize(
"states, expected, n_init",
[
(["Idle"], False, 0),
(["Error", "Idle"], True, 1),
],
)
def test_jfj_client_recover_from_error(states, expected, n_init):
client = _jfj_client()
with (
mock.patch.object(
JungfrauJochClient,
"detector_state",
new_callable=mock.PropertyMock,
side_effect=[DetectorState(s) for s in states],
),
mock.patch.object(client.api, "initialize_post") as mock_init,
mock.patch.object(client, "wait_for_idle", return_value=True) as mock_wait,
):
assert client.recover_from_error() is expected
assert mock_init.call_count == n_init
assert mock_wait.call_count == n_init
def test_jfj_client_recover_from_error_still_in_error():
client = _jfj_client()
with (
mock.patch.object(
JungfrauJochClient,
"detector_state",
new_callable=mock.PropertyMock,
return_value=DetectorState.ERROR,
),
mock.patch.object(client.api, "initialize_post"),
mock.patch.object(client, "wait_for_idle", return_value=True),
):
with pytest.raises(JungfrauJochClientError, match="after re-initialisation"):
client.recover_from_error()
def test_jfj_client_wait_for_idle_reports_broker_message():
"""The HTTP 500 body is reported instead of the pydantic validation traceback."""
client = _jfj_client()
body = (
'{"msg":"DAQ state error (Detector error (Error in detector operation))",'
'"reason":"WrongDAQState"}'
)
exc = ServiceException(status=500, reason="Internal Server Error", body=body)
with mock.patch.object(client.api, "wait_till_done_post", side_effect=exc):
with pytest.raises(JungfrauJochClientError, match="WrongDAQState"):
client.wait_for_idle(timeout=1)
assert client.wait_for_idle(timeout=1, raise_on_timeout=False) is False
def test_eiger_auto_reinitialize_is_a_config_attribute(eiger_1_5m):
"""The device server rejects deviceConfig keys that are not attributes of the device."""
eiger = eiger_1_5m
assert hasattr(eiger, "auto_reinitialize")
assert eiger.auto_reinitialize is False
eiger.auto_reinitialize = True
assert eiger._auto_reinitialize is True
def test_dectris_url_only_set_for_eiger2_si_500k(eiger_1_5m, eiger_9m, eiger2_si_500k):
"""Only the Eiger2 Si 500K logs the Dectris detector server status, the others are unchanged."""
assert eiger_1_5m.jfj_client._dectris_url is None
assert eiger_9m.jfj_client._dectris_url is None
assert eiger2_si_500k.jfj_client._dectris_url == "http://129.129.122.159"
def test_jfj_client_log_dectris_status_disabled():
"""Without dectris_url no request is made and nothing is logged."""
client = _jfj_client()
with (
mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.requests.get") as mock_get,
mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger") as mock_logger,
):
client.log_dectris_status("test")
mock_get.assert_not_called()
mock_logger.warning.assert_not_called()
def test_jfj_client_log_dectris_status():
"""All endpoints are read and logged in a single line, failed requests do not raise."""
client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu/")
def fake_get(url, timeout):
response = mock.Mock()
if url.endswith("/status/link_2"):
raise TimeoutError("too slow")
if url.endswith("/status/state"):
response.json.return_value = {"value": "error", "value_type": "string"}
elif "/monitor/" in url:
response.json.return_value = {"state": {"value": "overflow"}, "dropped": {"value": 5}}
else:
response.json.return_value = {"value": 1}
return response
with (
mock.patch(
"csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.requests.get", side_effect=fake_get
) as mock_get,
mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger") as mock_logger,
):
client.log_dectris_status("test reason")
assert mock_get.call_count == 15
mock_logger.warning.assert_called_once()
line = mock_logger.warning.call_args[0][0]
assert "test reason" in line
assert '"detector/state":"error"' in line
assert '"monitor":{"state":"overflow","dropped":5}' in line
assert "TimeoutError" in line # link_2 failed, the rest is still logged
def test_jfj_client_recover_from_error_logs_dectris_status_before_init():
client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu")
calls = mock.Mock()
with (
mock.patch.object(
JungfrauJochClient,
"detector_state",
new_callable=mock.PropertyMock,
side_effect=[DetectorState.ERROR, DetectorState.IDLE],
),
mock.patch.object(client, "log_dectris_status", calls.log),
mock.patch.object(client.api, "initialize_post", calls.init),
mock.patch.object(client, "wait_for_idle", return_value=True),
):
assert client.recover_from_error() is True
assert [c[0] for c in calls.mock_calls] == ["log", "init"]
def test_eiger_on_complete_logs_dectris_status_in_error_state(eiger2_si_500k):
eiger = eiger2_si_500k
with (
mock.patch.object(
eiger.jfj_client.api,
"status_get",
return_value=BrokerStatus(state="Error", message_severity="error", message="boom"),
),
mock.patch.object(eiger.jfj_client, "log_dectris_status") as mock_log,
):
status = eiger.complete()
with pytest.raises(EigerError):
status.wait(timeout=5)
mock_log.assert_called_once()
def test_jfj_client_log_dectris_status_returns_results():
disabled = _jfj_client()
assert disabled.log_dectris_status("test") is None
client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu")
response = mock.Mock()
response.json.return_value = {"value": "idle"}
with (
mock.patch(
"csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.requests.get",
return_value=response,
),
mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger"),
):
results = client.log_dectris_status("test")
assert results["detector/state"] == "idle"
@pytest.mark.parametrize(
"results, expected",
[
({"detector/state": "ready", "detector/series_unique_id": "S1"}, "S1"),
({"detector/state": "idle", "stream": {"state": "acquire"}}, "unknown"),
({"detector/state": "ready", "detector/series_unique_id": "<TimeoutError: x>"}, "unknown"),
({"detector/state": "idle", "stream": {"state": "ready"}}, None),
({"detector/state": "<TimeoutError: x>", "stream": "<TimeoutError: x>"}, None),
({}, None),
(None, None),
],
)
def test_jfj_client_dectris_still_armed(results, expected):
assert JungfrauJochClient._dectris_still_armed(results) == expected
@pytest.mark.parametrize(
"dectris_status, hint_expected",
[
(
{
"detector/state": "ready",
"detector/series_unique_id": "01M4J4FPRPZ2J7M6MW2YSA27VG",
"stream": {"state": "acquire"},
},
True,
),
({"detector/state": "idle", "stream": {"state": "ready"}}, False),
(None, False),
],
)
def test_jfj_client_recover_from_error_hints_armed_dectris(dectris_status, hint_expected):
"""A failed re-init tells the operator when the Dectris server is still armed."""
client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu")
with (
mock.patch.object(
JungfrauJochClient,
"detector_state",
new_callable=mock.PropertyMock,
return_value=DetectorState.ERROR,
),
mock.patch.object(client, "log_dectris_status", return_value=dectris_status),
mock.patch.object(client.api, "initialize_post"),
mock.patch.object(
client, "wait_for_idle", side_effect=JungfrauJochClientError("Detector busy")
),
):
with pytest.raises(JungfrauJochClientError, match="Detector busy") as exc_info:
client.recover_from_error()
message = str(exc_info.value)
assert ("still armed" in message) is hint_expected
assert ("01M4J4FPRPZ2J7M6MW2YSA27VG" in message) is hint_expected
def test_jfj_client_stop_logs_cancel_and_dectris_status():
client = JungfrauJochClient(host="http://localhost:1", dectris_url="http://dcu")
calls = mock.Mock()
with (
mock.patch.object(client.api, "cancel_post_with_http_info", calls.cancel),
mock.patch.object(client, "log_dectris_status", calls.log),
mock.patch("csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.logger") as mock_logger,
mock.patch(
"csaxs_bec.devices.jungfraujoch.jungfrau_joch_client.threading.Thread",
side_effect=lambda target, args, **kwargs: mock.Mock(start=lambda: target(*args)),
),
):
client.stop(reason="on_stop")
assert [c[0] for c in calls.mock_calls] == ["cancel", "log"]
calls.log.assert_called_once_with("after cancel (on_stop)")
assert "on_stop" in mock_logger.info.call_args[0][0]