Skip to content

Commit 85ddc25

Browse files
committed
Add DetectorData API
1 parent 7567a9e commit 85ddc25

7 files changed

Lines changed: 2149 additions & 1 deletion

docs/calibration.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Calibration constants
22

33
The `extra.calibration` module helps you to find & load detector calibration
4-
constants stored in the [Calibration Catalog](https://in.xfel.eu/calibration/).
4+
data stored in the [Calibration Catalog](https://in.xfel.eu/calibration/).
5+
This is used for most imaging detectors in use at European XFEL to record
6+
and track datasets called calibration constants derived from characterization
7+
measurements and used for data corrections.
58

69
To find constants by conditions for a particular point in time, create a
710
condition object for the relevant detector type and use
@@ -69,6 +72,20 @@ agipd_cd.display_markdown_table()
6972

7073
::: extra.calibration.ShimadzuHPVX2Conditions
7174

75+
## Detectors and module mapping
76+
77+
Calibration data is associated with physical detector units (PDUs), with an actual
78+
detector consisting of one or more of these PDUs then generally called modules. PDUs
79+
can freely move between detectors of the same type in a process called mapping, and
80+
take their calibration data alongside with them.
81+
82+
This is automatically taken into account when using the `CalibrationData` APIs described
83+
here, but you can also obtain the detector metadata and mapping information explicitly:
84+
85+
::: extra.calibration.DetectorData
86+
87+
::: extra.calibration.PhysicalDetectorUnit
88+
7289
## Bad pixel values
7390

7491
The calibration pipeline produces masks along with corrected data, in keys

src/extra/calibration.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from dataclasses import dataclass, field, fields, replace
55
from datetime import date, datetime, time, timezone
66
from enum import IntFlag
7+
from fnmatch import fnmatch
78
from functools import lru_cache
89
from pathlib import Path
910
from typing import Dict, List, Optional, Union
@@ -29,6 +30,8 @@
2930
"DSSCConditions",
3031
"JUNGFRAUConditions",
3132
"ShimadzuHPVX2Conditions",
33+
"DetectorData",
34+
"PhysicalDetectorUnit"
3235
]
3336

3437
# Default address to connect to, only available internally
@@ -191,6 +194,9 @@ def detector_by_identifier(self, identifier):
191194
return self._get_by_name(
192195
"detectors", identifier, name_key="identifier")
193196

197+
def instrument_by_name(self, name):
198+
return self._get_by_name("instruments", name, name_key='identifier')
199+
194200
def calibration_by_name(self, name):
195201
return self._get_by_name("calibrations", name)
196202

@@ -824,6 +830,9 @@ def pdu_names(self):
824830
May include missing modules."""
825831
return [m["physical_name"] for m in self.module_details]
826832

833+
def detector(self):
834+
return DetectorData.from_identifier(self.detector_name)
835+
827836
def require_calibrations(self, calibrations) -> "CalibrationData":
828837
"""Drop any modules missing the specified constant types"""
829838
mods = set(self.aggregator_names)
@@ -1203,6 +1212,199 @@ class ShimadzuHPVX2Conditions(ConditionsBase):
12031212
}
12041213

12051214

1215+
@dataclass
1216+
class PhysicalDetectorUnit:
1217+
"""Physical detector unit (PDU).
1218+
1219+
PDUs describe the physical modules independent from the detector
1220+
they may be installed in, or the detector's logical modules they are
1221+
mapped to.
1222+
1223+
Calibration data is always associated with a PDU rather than a
1224+
detector or detector module. This allows a PDU to be moved to a
1225+
different detector installation or place within the detector and
1226+
carry all its calibration data alongside with it.
1227+
"""
1228+
1229+
pdu_id: int
1230+
physical_name: str # PDU identifier independent of detector
1231+
uuid: int # Universally unique ID used for mapping
1232+
aggregator: str # Data aggregator the PDU is currently mapped to
1233+
detector: str # Detector identifier the PDU is currently mapped to
1234+
virtual_device_name: str # Identifier within the detector, e.g. Q1M2
1235+
module_index: int # Enumerated module index within the detector, contiguous and always starts at 0
1236+
module_number: int # Module number within the detector, may start at any number and have gaps
1237+
detector_type: str
1238+
1239+
@property
1240+
def ccv_params(self):
1241+
"""PDU arguments as needed for write_ccv()."""
1242+
return self.physical_name, self.uuid, self.detector_type
1243+
1244+
1245+
class DetectorData(Mapping):
1246+
"""Detector consisting of one or more modules
1247+
1248+
A detector can house one or more physical detector units, which are
1249+
mapped to the logical modules of the detector. Calibration data is
1250+
associated with a PDU rather than a detector.
1251+
1252+
This object exposes the module mapping in a dict-like interface
1253+
mapping data aggregators to physical detector units. Alternatively,
1254+
the module index may also be used as a key.
1255+
"""
1256+
1257+
def __init__(self, detector_row, module_rows):
1258+
# Result rows as returned by CalCat.
1259+
self.detector_row = detector_row
1260+
self.pdus = [PhysicalDetectorUnit(
1261+
p['id'], p['physical_name'], p['uuid'], p['karabo_da'],
1262+
p['detector']['identifier'], p['virtual_device_name'],
1263+
i, p['module_number'], p['detector_type']['name'])
1264+
for i, p in enumerate(module_rows)]
1265+
1266+
@classmethod
1267+
def from_identifier(cls, identifier, pdu_snapshot_at=None, client=None):
1268+
"""Look up a detector and its modules by identifier.
1269+
1270+
`pdu_snapshot_at` should either be an ISO 8601 compatible string
1271+
or a datetime-like object. It may also be a DataCollection
1272+
object from EXtra-data to use the beginning of the run as a
1273+
point in time.
1274+
"""
1275+
1276+
client = client or get_client()
1277+
detector_row = client.detector_by_identifier(identifier)
1278+
1279+
try:
1280+
module_rows = client.get(
1281+
'physical_detector_units/get_all_by_detector',
1282+
{'detector_id': detector_row['id'],
1283+
'pdu_snapshot_at': client.format_time(pdu_snapshot_at)})
1284+
except CalCatAPIError as e:
1285+
if e.status_code == 404:
1286+
module_rows = []
1287+
else:
1288+
raise e
1289+
1290+
return cls(detector_row, module_rows)
1291+
1292+
@classmethod
1293+
def from_instrument(cls, instrument, identifier=None, pdu_snapshot_at=None,
1294+
client=None):
1295+
"""Look up a detector and its modules by instrument.
1296+
1297+
`identifier` may be a string restricting the result using Unix
1298+
shell-style glob patterns.
1299+
1300+
`pdu_snapshot_at` should either be an ISO 8601 compatible string
1301+
or a datetime-like object. It may also be a DataCollection
1302+
object from EXtra-data to use the beginning of the run as a
1303+
point in time.
1304+
"""
1305+
1306+
client = client or get_client()
1307+
instrument_id = client.instrument_by_name(instrument)['id']
1308+
1309+
rows = [det for det in client.get(
1310+
'detectors/get_all_by_instrument', {'instrument_id': instrument_id}
1311+
) if identifier is None or fnmatch(det['identifier'], identifier)]
1312+
1313+
if not rows:
1314+
raise ValueError(f'No such detector found for {instrument}')
1315+
elif len(rows) > 1:
1316+
raise ValueError(
1317+
f'Multiple such detectors found for {instrument}: ' +
1318+
', '.join([detector['identifier'] for detector in rows]))
1319+
1320+
return cls.from_identifier(rows[0]['identifier'])
1321+
1322+
@classmethod
1323+
def list_by_instrument(cls, instrument, client=None):
1324+
"""List all detectors by instrument."""
1325+
1326+
client = client or get_client()
1327+
instrument_id = client.instrument_by_name(instrument)['id']
1328+
1329+
return [det['identifier'] for det in
1330+
client.get('detectors/get_all_by_instrument',
1331+
{'instrument_id': instrument_id})]
1332+
1333+
def __getitem__(self, key):
1334+
if isinstance(key, int):
1335+
return self.pdus[key]
1336+
elif isinstance(key, str):
1337+
for pdu in self.pdus:
1338+
if pdu.aggregator == key:
1339+
return pdu
1340+
1341+
raise KeyError(key)
1342+
1343+
def __iter__(self):
1344+
return (pdu.aggregator for pdu in self.pdus)
1345+
1346+
def __len__(self):
1347+
return len(self.pdus)
1348+
1349+
def __repr__(self):
1350+
return f'<DetectorData: {len(self.pdus)}/{self.number_of_modules} ' \
1351+
f'modules of {self.identifier}>'
1352+
1353+
@property
1354+
def id(self) -> int:
1355+
"""Detector ID in CalCat."""
1356+
return self.detector_row['id']
1357+
1358+
@property
1359+
def identifier(self) -> str:
1360+
"""Detector identifier."""
1361+
return self.detector_row['identifier']
1362+
1363+
@property
1364+
def source_name_pattern(self) -> str:
1365+
"""Source name pattern."""
1366+
assert self.detector_row['source_name_pattern'] is not None, \
1367+
'incomplete detector entry in CalCat'
1368+
return self.detector_row['source_name_pattern']
1369+
1370+
@property
1371+
def source_names(self) -> list[str]:
1372+
"""Source names of currently mapped PDUs."""
1373+
return [self.source_name_pattern.format(
1374+
modno=pdu.module_number or i + self.first_module_index
1375+
) for i, pdu in enumerate(self.pdus)]
1376+
1377+
@property
1378+
def number_of_modules(self) -> int:
1379+
"""Number of modules for the full detector."""
1380+
return self.detector_row['number_of_modules']
1381+
1382+
@property
1383+
def first_module_index(self) -> int:
1384+
"""Module index of the first module."""
1385+
assert self.detector_row['first_module_index'] is not None, \
1386+
'incomplete detector entry in CalCat'
1387+
return self.detector_row['first_module_index']
1388+
1389+
@property
1390+
def pdu_detector_types(self) -> set[str]:
1391+
"""Detector types of currently installed PDUs."""
1392+
return {pdu.detector_type for pdu in self.pdus}
1393+
1394+
@property
1395+
def detector_type(self) -> str:
1396+
"""Detector type of all PDUs if unique."""
1397+
pdu_types = self.pdu_detector_types
1398+
1399+
if len(pdu_types) > 1:
1400+
raise ValueError('more than one type of PDU: ' +
1401+
', '.join(pdu_types))
1402+
elif len(pdu_types) == 0:
1403+
raise ValueError('no mapped PDUs')
1404+
1405+
return pdu_types.pop()
1406+
1407+
12061408
class BadPixels(IntFlag):
12071409
"""Bad pixel reasons, as used in masks in corrected detector data"""
12081410
OFFSET_OUT_OF_THRESHOLD = 1 << 0

0 commit comments

Comments
 (0)