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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G1MYT2ByPys8XL6BdfApAF
This commit is contained in:
x12saandClaude Sonnet 5.5 committed 2026-10-08 11:47:24 +02:00
1 parent 8c6977428e
commit 533467775d
4 files changed
+171 -2

No files matched your search

+4 -1
View File
@@ -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)}"
@@ -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,
@@ -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/<key>
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:
"""
+85
View File
@@ -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()