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:
1 parent
8c6977428e
commit
533467775d
4 files changed
+171
-2
No files matched your search
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user