CI for csaxs_bec / test (push) Failing after 3m15s
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
1108 lines
43 KiB
Python
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]
|