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.
@@ -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