From 533467775dfd597cf716f72a619cb03d8ba922f1 Mon Sep 17 00:00:00 2001 From: x12sa Date: Thu, 8 Oct 2026 11:47:24 +0200 Subject: [PATCH] feat(eiger): log Dectris detector server status on detector error Log the read-only Simplon status endpoints (detector, stream, monitor, filewriter) of the Eiger2 Si 500K detector server as one warning line, right before the EigerError is raised in on_complete and before the auto re-init wipes the error state. Only enabled when dectris_url is set, which is the case for the Eiger2 Si 500K only. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01G1MYT2ByPys8XL6BdfApAF --- csaxs_bec/devices/jungfraujoch/eiger.py | 5 +- .../devices/jungfraujoch/eiger2_si_500k.py | 4 + .../jungfraujoch/jungfrau_joch_client.py | 79 ++++++++++++++++- tests/tests_devices/test_eiger.py | 85 +++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/csaxs_bec/devices/jungfraujoch/eiger.py b/csaxs_bec/devices/jungfraujoch/eiger.py index d7b183ec..4053b888 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger.py +++ b/csaxs_bec/devices/jungfraujoch/eiger.py @@ -158,12 +158,14 @@ class Eiger(PSIDeviceBase): readout_time: float = EIGER_READOUT_TIME, raise_on_missing_packets: bool = RAISE_ON_MISSING_PACKETS, auto_reinitialize: bool = False, + dectris_url: str | None = None, device_manager=None, **kwargs, ): super().__init__(name=name, scan_info=scan_info, device_manager=device_manager, **kwargs) self._host = f"{host}:{port}" - self.jfj_client = JungfrauJochClient(host=self._host, parent=self) + # dectris_url: base URL of the Dectris detector server, its status is logged on errors. + self.jfj_client = JungfrauJochClient(host=self._host, parent=self, dectris_url=dectris_url) # NOTE: fetch this information from JungfrauJochClient during on_connected! self.jfj_preview_client = JungfrauJochPreview( url="tcp://129.129.95.114:5400", cb=self._preview_callback @@ -565,6 +567,7 @@ class Eiger(PSIDeviceBase): timeout = self._wait_for_on_complete while time.time() - start_time < timeout: if self.jfj_client.detector_state == DetectorState.ERROR: + self.jfj_client.log_dectris_status("ERROR state during acquisition") raise EigerError( f"Device {self.name} is in ERROR state during acquisition. Broker status: " f"{yaml.dump(self.jfj_client.jfj_status.to_dict(), indent=4)}" diff --git a/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py b/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py index a7a898f9..de4af5c4 100644 --- a/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py +++ b/csaxs_bec/devices/jungfraujoch/eiger2_si_500k.py @@ -18,6 +18,8 @@ from csaxs_bec.devices.jungfraujoch.eiger import RAISE_ON_MISSING_PACKETS, Eiger # the largest value among the detectors in a scan, or the slowest one overruns. EIGER2_SI_500K_READOUT_TIME = 2e-4 # s -- placeholder until a measured value exists DETECTOR_NAME = "Dectris EIGER2 Si 500K" +# Detector server (Simplon API). Its status is logged when the detector goes into error state. +DECTRIS_URL = "http://129.129.122.159" if TYPE_CHECKING: # pragma no cover @@ -47,11 +49,13 @@ class Eiger2Si500K(Eiger): device_manager: DeviceManagerDS = None, readout_time: float = EIGER2_SI_500K_READOUT_TIME, raise_on_missing_packets: bool = RAISE_ON_MISSING_PACKETS, + dectris_url: str | None = DECTRIS_URL, **kwargs, ) -> None: super().__init__( name=name, detector_name=DETECTOR_NAME, + dectris_url=dectris_url, readout_time=readout_time, raise_on_missing_packets=raise_on_missing_packets, detector_distance=detector_distance, diff --git a/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py b/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py index 2d514dce..42837676 100644 --- a/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py +++ b/csaxs_bec/devices/jungfraujoch/jungfrau_joch_client.py @@ -7,6 +7,7 @@ import json import threading import time import traceback +from concurrent.futures import ThreadPoolExecutor from typing import TYPE_CHECKING import requests @@ -25,6 +26,33 @@ logger = bec_logger.logger if TYPE_CHECKING: # pragma: no cover from ophyd import Device +# Keys of the Dectris detector server (Simplon API 1.8.0) endpoint detector/api/1.8.0/status/ +DECTRIS_DETECTOR_STATUS_KEYS = ( + "state", + "series_unique_id", + "time", + "temperature", + "humidity", + "board_000/th0_temp", + "board_000/th0_humidity", + "link_0", + "link_1", + "link_2", + "link_3", +) + + +def _simplify_dectris_status(data): + """ + Reduce a Simplon status response to its values. A single entry {"value": x, ...} becomes x, + a collection {"name": {"value": x, ...}, ...} becomes {"name": x, ...}. + """ + if isinstance(data, dict): + if "value" in data: + return data["value"] + return {key: _simplify_dectris_status(val) for key, val in data.items()} + return data + # pylint: disable=raise-missing-from # pylint: disable=broad-except @@ -56,11 +84,18 @@ class JungfrauJochClient: host (str): Hostname of the Jungfrau Joch broker service. Default is "http://sls-jfjoch-001:8080" parent (Device, optional): Parent ophyd device, used for logging purposes. + dectris_url (str, optional): Base URL of the Dectris detector server (Simplon API), e.g. + "http://129.129.122.159". If set, its read-only status endpoints are logged + by log_dectris_status for debugging detector errors. Default is None (off). """ def __init__( - self, host: str = "http://sls-jfjoch-001:8080", parent: Device | None = None + self, + host: str = "http://sls-jfjoch-001:8080", + parent: Device | None = None, + dectris_url: str | None = None, ) -> None: + self._dectris_url = dectris_url.rstrip("/") if dectris_url else None self._initialised = False self._recover_lock = threading.Lock() self._recoveries = 0 @@ -139,6 +174,8 @@ class JungfrauJochClient: f"JungfrauJoch backend for device {self._parent_name} is in ERROR state, " f"re-initialising the detector (recovery #{self._recoveries} since startup)." ) + # Capture the detector server state before the re-initialisation wipes it. + self.log_dectris_status(f"before re-init (recovery #{self._recoveries})") try: self._initialize(timeout) except Exception as exc: @@ -155,6 +192,46 @@ class JungfrauJochClient: logger.info(f"Device {self._parent_name} re-initialised and back in IDLE state.") return True + def log_dectris_status(self, reason: str, timeout: float = 2) -> None: + """ + Log the status endpoints of the Dectris detector server (Simplon API) as a single warning + line of compact JSON. Only read-only GET requests are made. Does nothing if no dectris_url + was configured. This never raises and takes at most roughly the timeout, it is meant to be + called while handling a detector error. + + Args: + reason (str): Why the status is logged, included in the log line. + timeout (float): Timeout in seconds of each HTTP request. Requests run in parallel. + """ + if self._dectris_url is None: + return + try: + base = self._dectris_url + urls = { + f"detector/{key}": f"{base}/detector/api/1.8.0/status/{key}" + for key in DECTRIS_DETECTOR_STATUS_KEYS + } + urls["detector/error"] = f"{base}/detector/api/1.8.0/status/error" + for component in ("stream", "monitor", "filewriter"): + urls[component] = f"{base}/{component}/api/1.8.0/status/" + + def _get(url: str): + try: + response = requests.get(url, timeout=timeout) + response.raise_for_status() + return _simplify_dectris_status(response.json()) + except Exception as exc: + return f"<{type(exc).__name__}: {str(exc)[:120]}>" + + with ThreadPoolExecutor(max_workers=len(urls)) as pool: + results = dict(zip(urls, pool.map(_get, urls.values()))) + logger.warning( + f"Dectris detector server status for device {self._parent_name} ({reason}): " + f"{json.dumps(results, separators=(',', ':'), default=str)}" + ) + except Exception as exc: + logger.warning(f"Could not log Dectris detector server status ({reason}): {exc}") + @staticmethod def _api_error_message(exc: Exception) -> str | None: """ diff --git a/tests/tests_devices/test_eiger.py b/tests/tests_devices/test_eiger.py index 29dfeef3..afc3c192 100644 --- a/tests/tests_devices/test_eiger.py +++ b/tests/tests_devices/test_eiger.py @@ -912,3 +912,88 @@ def test_eiger_auto_reinitialize_is_a_config_attribute(eiger_1_5m): 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()