chromparser 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- chromparser/__init__.py +183 -0
- chromparser/__main__.py +7 -0
- chromparser/_netcdf3.py +548 -0
- chromparser/_util.py +98 -0
- chromparser/cli.py +192 -0
- chromparser/errors.py +49 -0
- chromparser/hdf5.py +428 -0
- chromparser/model.py +254 -0
- chromparser/py.typed +0 -0
- chromparser/sniff.py +80 -0
- chromparser/vendors/__init__.py +55 -0
- chromparser/vendors/agilent/__init__.py +17 -0
- chromparser/vendors/agilent/ch.py +344 -0
- chromparser/vendors/agilent/dot_d.py +69 -0
- chromparser/vendors/agilent/dx.py +147 -0
- chromparser/vendors/andi/__init__.py +15 -0
- chromparser/vendors/andi/chrom.py +181 -0
- chromparser/vendors/andi/common.py +232 -0
- chromparser/vendors/andi/ms.py +358 -0
- chromparser/vendors/andi/write.py +320 -0
- chromparser/vendors/chromeleon/__init__.py +19 -0
- chromparser/vendors/chromeleon/archive.py +310 -0
- chromparser/vendors/chromeleon/raw.py +407 -0
- chromparser/vendors/chromeleon/sequence.py +177 -0
- chromparser/vendors/chromeleon/txt.py +319 -0
- chromparser/vendors/shimadzu/__init__.py +8 -0
- chromparser/vendors/shimadzu/gcd.py +146 -0
- chromparser/vendors/shimadzu/ole.py +84 -0
- chromparser/vendors/shimadzu/qgd.py +196 -0
- chromparser/vendors/thermo/__init__.py +7 -0
- chromparser/vendors/thermo/raw.py +377 -0
- chromparser-0.1.0.dist-info/METADATA +162 -0
- chromparser-0.1.0.dist-info/RECORD +36 -0
- chromparser-0.1.0.dist-info/WHEEL +4 -0
- chromparser-0.1.0.dist-info/entry_points.txt +3 -0
- chromparser-0.1.0.dist-info/licenses/LICENSE +51 -0
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
"""Chromeleon 7 raw signal files (`SignHdr` / `PtsLL2Df` / `PtsDDCmp` chunks).
|
|
2
|
+
|
|
3
|
+
These are the `.raw` files starting with `SignHdr`, found inside `.cmbx`
|
|
4
|
+
archives and in the `RawData` folder of a data vault. See
|
|
5
|
+
`docs/formats/chromeleon.md` for the binary layout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import lzma
|
|
11
|
+
import struct
|
|
12
|
+
import uuid
|
|
13
|
+
import warnings
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from ..._util import Source, existing_file, filetime, head, read_bytes
|
|
19
|
+
from ...errors import (
|
|
20
|
+
ChromparserWarning,
|
|
21
|
+
CorruptFile,
|
|
22
|
+
UnsupportedFormat,
|
|
23
|
+
UnsupportedVariant,
|
|
24
|
+
)
|
|
25
|
+
from ...model import Injection, Signal
|
|
26
|
+
|
|
27
|
+
SIGNAL_MAGIC = b"SignHdr\x00"
|
|
28
|
+
POINTS_TAG = b"PtsLL2Df"
|
|
29
|
+
COMPRESSED_POINTS_TAG = b"PtsDDCmp"
|
|
30
|
+
AUDIT_TRAIL_MAGIC = b"CpAu"
|
|
31
|
+
THERMO_RAW_MAGIC = b"\x01\xa1" + "Finnigan".encode("utf-16-le")
|
|
32
|
+
|
|
33
|
+
_CHUNK_HEADER = struct.Struct("<8sQ")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_svarint(buf: bytes, pos: int) -> tuple[int, int]:
|
|
37
|
+
"""Read one signed varint.
|
|
38
|
+
|
|
39
|
+
The first byte is ``[continue][sign][6 value bits]``, every following byte
|
|
40
|
+
``[continue][7 value bits]``, least significant group first.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
buf : bytes
|
|
45
|
+
Buffer to read from.
|
|
46
|
+
pos : int
|
|
47
|
+
Position of the varint.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
value, position : int
|
|
52
|
+
The value and the position after it.
|
|
53
|
+
"""
|
|
54
|
+
byte = buf[pos]
|
|
55
|
+
pos += 1
|
|
56
|
+
negative = byte & 0x40
|
|
57
|
+
value = byte & 0x3F
|
|
58
|
+
shift = 6
|
|
59
|
+
while byte & 0x80:
|
|
60
|
+
byte = buf[pos]
|
|
61
|
+
pos += 1
|
|
62
|
+
value |= (byte & 0x7F) << shift
|
|
63
|
+
shift += 7
|
|
64
|
+
return (-value if negative else value), pos
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decode_svarints(buf: bytes) -> tuple[np.ndarray, np.ndarray]:
|
|
68
|
+
"""Decode a buffer of signed varints (vectorised).
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
buf : bytes
|
|
73
|
+
Consecutive signed varints.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
values : numpy.ndarray
|
|
78
|
+
The values (int64).
|
|
79
|
+
end_markers : numpy.ndarray
|
|
80
|
+
Boolean mask of "negative zero" values, which end a block of points.
|
|
81
|
+
|
|
82
|
+
Raises
|
|
83
|
+
------
|
|
84
|
+
CorruptFile
|
|
85
|
+
If the buffer ends inside a varint or a varint is too long.
|
|
86
|
+
"""
|
|
87
|
+
data = np.frombuffer(buf, dtype=np.uint8)
|
|
88
|
+
if data.size == 0:
|
|
89
|
+
return np.empty(0, dtype=np.int64), np.empty(0, dtype=bool)
|
|
90
|
+
is_last = data < 0x80
|
|
91
|
+
if not is_last[-1]:
|
|
92
|
+
raise CorruptFile("Truncated varint at the end of a data block.")
|
|
93
|
+
ends = np.flatnonzero(is_last)
|
|
94
|
+
starts = np.concatenate(([0], ends[:-1] + 1))
|
|
95
|
+
lengths = ends - starts + 1
|
|
96
|
+
if lengths.max() > 9:
|
|
97
|
+
raise CorruptFile("Varint longer than 63 bits in data block.")
|
|
98
|
+
# the first byte of a varint carries 6 value bits, every following byte 7
|
|
99
|
+
pos_in_varint = np.arange(data.size) - np.repeat(starts, lengths)
|
|
100
|
+
shifts = np.where(pos_in_varint == 0, 0, 7 * pos_in_varint - 1)
|
|
101
|
+
bits = np.where(pos_in_varint == 0, data & 0x3F, data & 0x7F).astype(np.int64)
|
|
102
|
+
magnitude = np.add.reduceat(bits << shifts, starts)
|
|
103
|
+
negative = (data[starts] & 0x40) != 0
|
|
104
|
+
return np.where(negative, -magnitude, magnitude), negative & (magnitude == 0)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _iter_chunks(data: bytes) -> list[tuple[bytes, bytes]]:
|
|
108
|
+
"""Split a raw signal file into (tag, payload) chunks."""
|
|
109
|
+
chunks = []
|
|
110
|
+
offset = 0
|
|
111
|
+
while offset < len(data):
|
|
112
|
+
if offset + _CHUNK_HEADER.size > len(data):
|
|
113
|
+
raise CorruptFile(f"Truncated chunk header at offset {offset}.")
|
|
114
|
+
tag, length = _CHUNK_HEADER.unpack_from(data, offset)
|
|
115
|
+
if length < _CHUNK_HEADER.size or offset + length > len(data):
|
|
116
|
+
raise CorruptFile(
|
|
117
|
+
f"Invalid length {length} for chunk {tag!r} at offset {offset}."
|
|
118
|
+
)
|
|
119
|
+
chunks.append((tag, data[offset + _CHUNK_HEADER.size : offset + length]))
|
|
120
|
+
offset += length
|
|
121
|
+
return chunks
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _parse_signal_header(payload: bytes) -> dict:
|
|
125
|
+
"""Parse the `SignHdr` chunk.
|
|
126
|
+
|
|
127
|
+
Fields with unknown meaning are skipped. A header that cannot be parsed
|
|
128
|
+
only costs metadata, never signal data.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
pos = 0
|
|
132
|
+
values = []
|
|
133
|
+
for _ in range(5): # format version, unknown, major, minor, revision
|
|
134
|
+
value, pos = read_svarint(payload, pos)
|
|
135
|
+
values.append(value)
|
|
136
|
+
acquisition_start = filetime(struct.unpack_from("<q", payload, pos)[0])
|
|
137
|
+
pos += 8
|
|
138
|
+
_, pos = read_svarint(payload, pos)
|
|
139
|
+
name_length, pos = read_svarint(payload, pos)
|
|
140
|
+
channel = payload[pos : pos + name_length].decode("utf-8")
|
|
141
|
+
pos += name_length
|
|
142
|
+
injection_guid = uuid.UUID(bytes_le=payload[pos : pos + 16])
|
|
143
|
+
pos += 16
|
|
144
|
+
build, pos = read_svarint(payload, pos)
|
|
145
|
+
except (IndexError, OverflowError, struct.error, ValueError) as e:
|
|
146
|
+
warnings.warn(
|
|
147
|
+
f"Could not parse signal header: {e}", ChromparserWarning, stacklevel=3
|
|
148
|
+
)
|
|
149
|
+
return {}
|
|
150
|
+
|
|
151
|
+
major, minor, revision = values[2:5]
|
|
152
|
+
return {
|
|
153
|
+
"channel": channel,
|
|
154
|
+
"acquisition_start": acquisition_start,
|
|
155
|
+
"injection_guid": str(injection_guid),
|
|
156
|
+
"generating_data_system": f"Chromeleon {major}.{minor}.{revision}.{build}",
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _decode_points_chunk(payload: bytes) -> tuple[np.ndarray, float, np.ndarray]:
|
|
161
|
+
"""Decode a `PtsLL2Df` chunk.
|
|
162
|
+
|
|
163
|
+
Layout: two float64 scale factors (time ticks per minute, signal counts
|
|
164
|
+
per unit), then signed varints ``t0, v0, dt, dv, (ddt, ddv)...`` and a
|
|
165
|
+
"negative zero" end marker: a second-order delta encoding of the integer
|
|
166
|
+
time and signal.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
ticks : numpy.ndarray
|
|
171
|
+
Integer time (int64).
|
|
172
|
+
ticks_per_minute : float
|
|
173
|
+
Scale of `ticks`.
|
|
174
|
+
values : numpy.ndarray
|
|
175
|
+
Signal (float64) in the signal unit.
|
|
176
|
+
"""
|
|
177
|
+
if len(payload) < 16:
|
|
178
|
+
raise CorruptFile("Truncated PtsLL2Df data block.")
|
|
179
|
+
ticks_per_minute, counts_per_unit = struct.unpack_from("<dd", payload, 0)
|
|
180
|
+
if not (ticks_per_minute > 0 and counts_per_unit > 0):
|
|
181
|
+
raise CorruptFile(
|
|
182
|
+
f"Invalid scale factors ({ticks_per_minute}, {counts_per_unit}) "
|
|
183
|
+
"in PtsLL2Df data block."
|
|
184
|
+
)
|
|
185
|
+
values, end_markers = decode_svarints(payload[16:])
|
|
186
|
+
end = np.flatnonzero(end_markers)
|
|
187
|
+
if end.size == 0:
|
|
188
|
+
raise CorruptFile("PtsLL2Df data block has no end marker.")
|
|
189
|
+
if end[0] != values.size - 1:
|
|
190
|
+
raise CorruptFile("Unexpected data after the end of a PtsLL2Df data block.")
|
|
191
|
+
values = values[: end[0]]
|
|
192
|
+
|
|
193
|
+
if values.size == 0:
|
|
194
|
+
return np.empty(0, dtype=np.int64), ticks_per_minute, np.empty(0)
|
|
195
|
+
if values.size % 2:
|
|
196
|
+
raise CorruptFile("PtsLL2Df data block has an incomplete data point.")
|
|
197
|
+
# rows: (t0, v0), (dt, dv), then (ddt, ddv) for every further point
|
|
198
|
+
pairs = values.reshape(-1, 2)
|
|
199
|
+
steps = np.cumsum(pairs[1:], axis=0)
|
|
200
|
+
points = np.vstack((pairs[:1], pairs[0] + np.cumsum(steps, axis=0)))
|
|
201
|
+
return points[:, 0], ticks_per_minute, points[:, 1] / counts_per_unit
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def decompress_lzma2(payload: bytes) -> bytes:
|
|
205
|
+
"""Decompress a raw LZMA2 stream preceded by its dictionary-size byte.
|
|
206
|
+
|
|
207
|
+
Parameters
|
|
208
|
+
----------
|
|
209
|
+
payload : bytes
|
|
210
|
+
Property byte, then the LZMA2 chunks.
|
|
211
|
+
|
|
212
|
+
Returns
|
|
213
|
+
-------
|
|
214
|
+
bytes
|
|
215
|
+
The decompressed data.
|
|
216
|
+
|
|
217
|
+
Raises
|
|
218
|
+
------
|
|
219
|
+
CorruptFile
|
|
220
|
+
If the stream is empty or corrupt.
|
|
221
|
+
"""
|
|
222
|
+
if not payload:
|
|
223
|
+
raise CorruptFile("Empty LZMA2 stream.")
|
|
224
|
+
prop = payload[0]
|
|
225
|
+
if prop > 40:
|
|
226
|
+
raise CorruptFile(f"Invalid LZMA2 dictionary size property {prop}.")
|
|
227
|
+
dict_size = 0xFFFFFFFF if prop == 40 else (2 | (prop & 1)) << (prop // 2 + 11)
|
|
228
|
+
filters = [{"id": lzma.FILTER_LZMA2, "dict_size": dict_size}]
|
|
229
|
+
try:
|
|
230
|
+
return lzma.LZMADecompressor(lzma.FORMAT_RAW, filters=filters).decompress(
|
|
231
|
+
payload[1:]
|
|
232
|
+
)
|
|
233
|
+
except lzma.LZMAError as e:
|
|
234
|
+
raise CorruptFile(f"Corrupt LZMA2 stream: {e}") from e
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _decode_compressed_points_chunk(payload: bytes) -> tuple[np.ndarray, np.ndarray]:
|
|
238
|
+
"""Decode a `PtsDDCmp` chunk into time (min) and signal arrays.
|
|
239
|
+
|
|
240
|
+
Layout: signed varints ``1, 0, uncompressed size, compressed size``, then
|
|
241
|
+
an LZMA2 stream of float64 ``(time, value)`` pairs.
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
pos = 0
|
|
245
|
+
for _ in range(2): # 1 and 0 in all known files, meaning unknown
|
|
246
|
+
_, pos = read_svarint(payload, pos)
|
|
247
|
+
size, pos = read_svarint(payload, pos)
|
|
248
|
+
compressed_size, pos = read_svarint(payload, pos)
|
|
249
|
+
except IndexError:
|
|
250
|
+
raise CorruptFile("Truncated PtsDDCmp data block.") from None
|
|
251
|
+
if compressed_size != len(payload) - pos:
|
|
252
|
+
raise CorruptFile(
|
|
253
|
+
f"PtsDDCmp data block has {len(payload) - pos} compressed bytes, "
|
|
254
|
+
f"its header says {compressed_size}."
|
|
255
|
+
)
|
|
256
|
+
data = decompress_lzma2(payload[pos:])
|
|
257
|
+
if len(data) != size or size % 16:
|
|
258
|
+
raise CorruptFile(
|
|
259
|
+
f"PtsDDCmp data block decompressed to {len(data)} bytes, "
|
|
260
|
+
f"expected {size} bytes of (time, value) pairs."
|
|
261
|
+
)
|
|
262
|
+
points = np.frombuffer(data, dtype="<f8").reshape(-1, 2)
|
|
263
|
+
return points[:, 0].copy(), points[:, 1].copy()
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _regular_interval(ticks: list[np.ndarray], scales: list[float]) -> float | None:
|
|
267
|
+
"""Return the sampling interval (s) of evenly spaced integer time ticks."""
|
|
268
|
+
if not ticks or len(set(scales)) != 1:
|
|
269
|
+
return None
|
|
270
|
+
steps = np.diff(np.concatenate(ticks))
|
|
271
|
+
if steps.size == 0 or np.any(steps != steps[0]) or steps[0] <= 0:
|
|
272
|
+
return None
|
|
273
|
+
return round(float(steps[0]) / scales[0] * 60, 12)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _float_interval(time: np.ndarray) -> float | None:
|
|
277
|
+
"""Return the sampling interval (s) of an evenly spaced float time axis."""
|
|
278
|
+
steps = np.diff(time)
|
|
279
|
+
if steps.size == 0 or steps[0] <= 0 or np.ptp(steps) > 1e-6 * steps[0]:
|
|
280
|
+
return None
|
|
281
|
+
return round(float(time[-1] - time[0]) / (time.size - 1) * 60, 9)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def decode_signal(data: bytes) -> Signal:
|
|
285
|
+
"""Decode the bytes of a raw signal file.
|
|
286
|
+
|
|
287
|
+
Parameters
|
|
288
|
+
----------
|
|
289
|
+
data : bytes
|
|
290
|
+
Content of the file.
|
|
291
|
+
|
|
292
|
+
Returns
|
|
293
|
+
-------
|
|
294
|
+
Signal
|
|
295
|
+
Without unit, which raw files do not store.
|
|
296
|
+
|
|
297
|
+
Raises
|
|
298
|
+
------
|
|
299
|
+
UnsupportedFormat
|
|
300
|
+
If the data is not a raw signal file.
|
|
301
|
+
UnsupportedVariant
|
|
302
|
+
If it has an unknown chunk type.
|
|
303
|
+
CorruptFile
|
|
304
|
+
If it is malformed.
|
|
305
|
+
"""
|
|
306
|
+
if not data.startswith(SIGNAL_MAGIC):
|
|
307
|
+
if data.startswith(AUDIT_TRAIL_MAGIC):
|
|
308
|
+
raise UnsupportedFormat(
|
|
309
|
+
"File is a Chromeleon audit trail (CpAu), not a signal. Signal "
|
|
310
|
+
"data is stored in the raw files starting with 'SignHdr'."
|
|
311
|
+
)
|
|
312
|
+
if data.startswith(THERMO_RAW_MAGIC):
|
|
313
|
+
raise UnsupportedFormat(
|
|
314
|
+
"File is a Thermo MS raw file, not a signal. Use read_thermo_raw "
|
|
315
|
+
"to read it."
|
|
316
|
+
)
|
|
317
|
+
raise UnsupportedFormat("Not a raw signal file (missing 'SignHdr' header).")
|
|
318
|
+
|
|
319
|
+
header: dict = {}
|
|
320
|
+
times, values = [], []
|
|
321
|
+
ticks, scales, compressed = [], [], False
|
|
322
|
+
for tag, payload in _iter_chunks(data):
|
|
323
|
+
if tag == SIGNAL_MAGIC:
|
|
324
|
+
header = _parse_signal_header(payload)
|
|
325
|
+
elif tag == POINTS_TAG:
|
|
326
|
+
tick, scale, value = _decode_points_chunk(payload)
|
|
327
|
+
ticks.append(tick)
|
|
328
|
+
scales.append(scale)
|
|
329
|
+
times.append(tick / scale)
|
|
330
|
+
values.append(value)
|
|
331
|
+
elif tag == COMPRESSED_POINTS_TAG:
|
|
332
|
+
time, value = _decode_compressed_points_chunk(payload)
|
|
333
|
+
compressed = True
|
|
334
|
+
times.append(time)
|
|
335
|
+
values.append(value)
|
|
336
|
+
else:
|
|
337
|
+
raise UnsupportedVariant(f"Unsupported chunk {tag!r} in raw signal file.")
|
|
338
|
+
|
|
339
|
+
time = np.concatenate(times) if times else np.empty(0)
|
|
340
|
+
if compressed:
|
|
341
|
+
interval = None if ticks else _float_interval(time)
|
|
342
|
+
else:
|
|
343
|
+
interval = _regular_interval(ticks, scales)
|
|
344
|
+
return Signal(
|
|
345
|
+
channel=header.pop("channel", ""),
|
|
346
|
+
time=time,
|
|
347
|
+
values=np.concatenate(values) if values else np.empty(0),
|
|
348
|
+
acquisition_start=header.pop("acquisition_start", None),
|
|
349
|
+
metadata=header,
|
|
350
|
+
sampling_interval=interval,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def read_signal(source: Source, signal_unit: str | None = None) -> Signal:
|
|
355
|
+
"""Read a raw signal file into a `Signal`.
|
|
356
|
+
|
|
357
|
+
This is cmbx-reader's ``read_raw``. A raw file stores neither the signal
|
|
358
|
+
unit nor the instrument's time zone; `read_cmbx` gets both from an archive.
|
|
359
|
+
|
|
360
|
+
Parameters
|
|
361
|
+
----------
|
|
362
|
+
source : str, Path, bytes or file object
|
|
363
|
+
The raw signal file.
|
|
364
|
+
signal_unit : str, optional
|
|
365
|
+
Signal unit to attach, e.g. ``"pA"``.
|
|
366
|
+
|
|
367
|
+
Returns
|
|
368
|
+
-------
|
|
369
|
+
Signal
|
|
370
|
+
With `acquisition_start` in UTC.
|
|
371
|
+
"""
|
|
372
|
+
signal = decode_signal(read_bytes(source))
|
|
373
|
+
signal.unit = signal_unit
|
|
374
|
+
return signal
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def read_chromeleon_raw(
|
|
378
|
+
path: str | Path, *, signal_unit: str | None = None
|
|
379
|
+
) -> list[Injection]:
|
|
380
|
+
"""Read a Chromeleon raw signal file.
|
|
381
|
+
|
|
382
|
+
Parameters
|
|
383
|
+
----------
|
|
384
|
+
path : str or Path
|
|
385
|
+
The raw signal file.
|
|
386
|
+
signal_unit : str, optional
|
|
387
|
+
Signal unit, which raw files do not store.
|
|
388
|
+
|
|
389
|
+
Returns
|
|
390
|
+
-------
|
|
391
|
+
list of Injection
|
|
392
|
+
One injection without name and inject time, holding the signal.
|
|
393
|
+
"""
|
|
394
|
+
signal = read_signal(existing_file(path), signal_unit)
|
|
395
|
+
guid = signal.metadata.get("injection_guid")
|
|
396
|
+
return [
|
|
397
|
+
Injection(
|
|
398
|
+
name="",
|
|
399
|
+
signals={signal.channel: signal},
|
|
400
|
+
metadata={"guid": guid} if guid else {},
|
|
401
|
+
)
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def sniff_chromeleon_raw(path: str | Path) -> bool:
|
|
406
|
+
"""Whether `path` is a Chromeleon raw signal file (starts with `SignHdr`)."""
|
|
407
|
+
return Path(path).is_file() and head(path, 8) == SIGNAL_MAGIC
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Metadata from the sequence file (`*.cmd`) of a `.cmbx` archive.
|
|
2
|
+
|
|
3
|
+
The sequence is serialized with protobuf-net. Top-level field 18 holds item
|
|
4
|
+
records (type name, own GUID, parent GUID), field 19 the data of each item,
|
|
5
|
+
linked by GUID. Only the fields listed in `docs/formats/chromeleon.md` are read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from ..._util import clean as _clean
|
|
16
|
+
from ..._util import parse_iso_datetime
|
|
17
|
+
from ...errors import CorruptFile
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class SignalInfo:
|
|
22
|
+
"""Signal metadata from the sequence, with the metadata of its injection."""
|
|
23
|
+
|
|
24
|
+
unit: str | None = None
|
|
25
|
+
expected_points: int | None = None
|
|
26
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
27
|
+
injection_name: str | None = None
|
|
28
|
+
inject_time: datetime | None = None
|
|
29
|
+
injection_metadata: dict[str, Any] = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_uvarint(buf: bytes, pos: int) -> tuple[int, int]:
|
|
33
|
+
result = shift = 0
|
|
34
|
+
while True:
|
|
35
|
+
byte = buf[pos]
|
|
36
|
+
pos += 1
|
|
37
|
+
result |= (byte & 0x7F) << shift
|
|
38
|
+
shift += 7
|
|
39
|
+
if not byte & 0x80:
|
|
40
|
+
return result, pos
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decode(buf: bytes) -> dict[int, list]:
|
|
44
|
+
"""Decode a protobuf message into {field number: [raw values]}."""
|
|
45
|
+
fields: dict[int, list] = {}
|
|
46
|
+
pos = 0
|
|
47
|
+
while pos < len(buf):
|
|
48
|
+
key, pos = _read_uvarint(buf, pos)
|
|
49
|
+
wire_type = key & 7
|
|
50
|
+
if wire_type == 0:
|
|
51
|
+
value, pos = _read_uvarint(buf, pos)
|
|
52
|
+
elif wire_type == 1:
|
|
53
|
+
value, pos = buf[pos : pos + 8], pos + 8
|
|
54
|
+
elif wire_type == 2:
|
|
55
|
+
length, pos = _read_uvarint(buf, pos)
|
|
56
|
+
value, pos = buf[pos : pos + length], pos + length
|
|
57
|
+
elif wire_type == 5:
|
|
58
|
+
value, pos = buf[pos : pos + 4], pos + 4
|
|
59
|
+
else:
|
|
60
|
+
raise CorruptFile(f"Unsupported protobuf wire type {wire_type}.")
|
|
61
|
+
if pos > len(buf):
|
|
62
|
+
raise CorruptFile("Truncated protobuf message.")
|
|
63
|
+
fields.setdefault(key >> 3, []).append(value)
|
|
64
|
+
return fields
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _get(message: dict, *path: int):
|
|
68
|
+
"""Return the first value at a path of nested field numbers, or None."""
|
|
69
|
+
for depth, field_number in enumerate(path):
|
|
70
|
+
values = message.get(field_number)
|
|
71
|
+
if not values:
|
|
72
|
+
return None
|
|
73
|
+
if depth == len(path) - 1:
|
|
74
|
+
return values[0]
|
|
75
|
+
message = _decode(values[0])
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _str(message: dict, *path: int) -> str | None:
|
|
80
|
+
value = _get(message, *path)
|
|
81
|
+
return value.decode("utf-8") if isinstance(value, bytes) else None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _guid(message: dict, field_number: int) -> uuid.UUID | None:
|
|
85
|
+
"""Decode a protobuf-net `bcl.Guid` (two fixed64 halves)."""
|
|
86
|
+
value = _get(message, field_number)
|
|
87
|
+
if not isinstance(value, bytes):
|
|
88
|
+
return None
|
|
89
|
+
halves = _decode(value)
|
|
90
|
+
low = halves.get(1, [b"\x00" * 8])[0]
|
|
91
|
+
high = halves.get(2, [b"\x00" * 8])[0]
|
|
92
|
+
return uuid.UUID(bytes_le=low + high)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_PROCESSING_METHOD = 0
|
|
96
|
+
_INSTRUMENT_METHOD = 1
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _method_names(injection: dict) -> dict[int, str]:
|
|
100
|
+
"""Names of the methods linked in field 40, by kind (field 40.1)."""
|
|
101
|
+
names = {}
|
|
102
|
+
for raw in injection.get(40, []):
|
|
103
|
+
link = _decode(raw)
|
|
104
|
+
kind = _get(link, 1)
|
|
105
|
+
name = _str(link, 2, 5)
|
|
106
|
+
if name:
|
|
107
|
+
names.setdefault(kind if isinstance(kind, int) else 0, name)
|
|
108
|
+
return names
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _signal_info(record: dict, injection: dict | None) -> SignalInfo:
|
|
112
|
+
signal = _decode(_get(record, 5, 3) or b"")
|
|
113
|
+
expected_points = _get(signal, 4)
|
|
114
|
+
info = SignalInfo(
|
|
115
|
+
unit=_str(signal, 6, 3) or None,
|
|
116
|
+
expected_points=expected_points if isinstance(expected_points, int) else None,
|
|
117
|
+
metadata=_clean(
|
|
118
|
+
{
|
|
119
|
+
"signal_quantity": _str(signal, 6, 4),
|
|
120
|
+
"detector": _str(signal, 11),
|
|
121
|
+
"driver": _str(signal, 12),
|
|
122
|
+
}
|
|
123
|
+
),
|
|
124
|
+
)
|
|
125
|
+
if injection is not None:
|
|
126
|
+
info.injection_name = _str(injection, 19, 2)
|
|
127
|
+
inject_time = _str(injection, 19, 7, 1)
|
|
128
|
+
info.inject_time = parse_iso_datetime(inject_time) if inject_time else None
|
|
129
|
+
methods = _method_names(injection)
|
|
130
|
+
info.injection_metadata = _clean(
|
|
131
|
+
{
|
|
132
|
+
"type": _str(injection, 19, 3, 3),
|
|
133
|
+
"status": _str(injection, 19, 4, 3),
|
|
134
|
+
"position": _str(injection, 19, 5),
|
|
135
|
+
"instrument_method": methods.get(_INSTRUMENT_METHOD),
|
|
136
|
+
"processing_method": methods.get(_PROCESSING_METHOD),
|
|
137
|
+
}
|
|
138
|
+
)
|
|
139
|
+
return info
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def parse_sequence(data: bytes) -> dict[str, SignalInfo]:
|
|
143
|
+
r"""Extract signal and injection metadata from a sequence file.
|
|
144
|
+
|
|
145
|
+
Parameters
|
|
146
|
+
----------
|
|
147
|
+
data : bytes
|
|
148
|
+
Content of the `*.cmd` file.
|
|
149
|
+
|
|
150
|
+
Returns
|
|
151
|
+
-------
|
|
152
|
+
dict of str to SignalInfo
|
|
153
|
+
Mapping of raw data file id (lower case, e.g.
|
|
154
|
+
``2023\\09\\07\\152612018.raw``) to the metadata of that signal.
|
|
155
|
+
"""
|
|
156
|
+
top = _decode(data)
|
|
157
|
+
items: dict[uuid.UUID, tuple[str | None, uuid.UUID | None]] = {}
|
|
158
|
+
for raw in top.get(18, []):
|
|
159
|
+
record = _decode(raw)
|
|
160
|
+
guid = _guid(record, 5)
|
|
161
|
+
if guid is not None:
|
|
162
|
+
items[guid] = (_str(record, 3, 3), _guid(record, 6))
|
|
163
|
+
payloads = {}
|
|
164
|
+
for raw in top.get(19, []):
|
|
165
|
+
record = _decode(raw)
|
|
166
|
+
guid = _guid(record, 25)
|
|
167
|
+
if guid is not None:
|
|
168
|
+
payloads[guid] = record
|
|
169
|
+
|
|
170
|
+
signals = {}
|
|
171
|
+
for guid, (item_type, parent) in items.items():
|
|
172
|
+
if item_type != "Signal" or guid not in payloads:
|
|
173
|
+
continue
|
|
174
|
+
raw_id = _str(payloads[guid], 5, 6)
|
|
175
|
+
if raw_id:
|
|
176
|
+
signals[raw_id.lower()] = _signal_info(payloads[guid], payloads.get(parent))
|
|
177
|
+
return signals
|