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,196 @@
1
+ """Shimadzu GCMSsolution GC-MS files (`.qgd`, storage ``GCMS Raw Data``).
2
+
3
+ Centroid spectra in full scan mode. Masses are stored in units of 1/20 u and
4
+ returned as float64 m/z; intensities are 2, 3 or 4 bytes per scan and are
5
+ returned as uint32, with bit 31 (saturated detector) moved to `MSData.flags`.
6
+ SIM/MRM scans (format code 0) raise `UnsupportedVariant`. See
7
+ `docs/formats/shimadzu.md` for the layout.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import numpy as np
15
+
16
+ from ...errors import CorruptFile, UnsupportedFormat, UnsupportedVariant, warn
17
+ from ...model import SATURATED, Injection, MSData
18
+ from .ole import QGD_STORAGE, file_properties, open_ole, read_stream, storages
19
+
20
+ MZ_SCALE = 20 # raw masses are m/z * 20
21
+ INTENSITY_BYTES = {1: 4, 2: 2, 3: 3} # format code -> bytes per intensity
22
+ SATURATED_BIT = 1 << 31
23
+ SCAN_HEADER = 32
24
+ CHUNK = 4096 # scans decoded at a time
25
+ STREAMS = ("Retention Time", "Spectrum Index", "TIC Data", "MS Raw Data")
26
+
27
+
28
+ def _spectrum_index(data: bytes, n_scans: int, name: str) -> np.ndarray:
29
+ if len(data) == 4 * n_scans:
30
+ return np.frombuffer(data, "<u4").astype(np.int64)
31
+ if len(data) == 2 + 8 * n_scans:
32
+ return np.frombuffer(data, "<u8", offset=2).astype(np.int64)
33
+ raise CorruptFile(
34
+ f"{name}: spectrum index ({len(data)} bytes) does not match "
35
+ f"{n_scans} retention times."
36
+ )
37
+
38
+
39
+ def decode_qgd(streams: dict[str, bytes], name: str = "qgd") -> MSData:
40
+ """Decode the ``GCMS Raw Data`` streams of a `.qgd` file.
41
+
42
+ Parameters
43
+ ----------
44
+ streams : dict of str to bytes
45
+ ``"Retention Time"``, ``"Spectrum Index"``, ``"MS Raw Data"`` and
46
+ optionally ``"TIC Data"``, which every scan is checked against.
47
+ name : str
48
+ Name of the MS data and file name for messages.
49
+
50
+ Returns
51
+ -------
52
+ MSData
53
+ Without acquisition start and file metadata (see `read_qgd`).
54
+
55
+ Raises
56
+ ------
57
+ UnsupportedVariant
58
+ For SIM/MRM or unknown scan formats.
59
+ CorruptFile
60
+ If the streams are inconsistent or truncated.
61
+ """
62
+ rt = np.frombuffer(streams["Retention Time"], "<i4")
63
+ raw = np.frombuffer(streams["MS Raw Data"], np.uint8)
64
+ starts = _spectrum_index(streams["Spectrum Index"], len(rt), name)
65
+ n_scans = len(starts)
66
+ metadata: dict = {}
67
+ if n_scans and np.any(starts + SCAN_HEADER > len(raw)):
68
+ raise CorruptFile(f"{name}: spectrum index points past the end of the data.")
69
+
70
+ header = raw[starts[:, None] + np.arange(SCAN_HEADER)]
71
+ scan_no = header[:, 0:4].copy().view("<u4")[:, 0]
72
+ header_rt = header[:, 4:8].copy().view("<u4")[:, 0]
73
+ codes = header[:, 20:22].copy().view("<u2")[:, 0]
74
+ counts = header[:, 22:24].copy().view("<u2")[:, 0].astype(np.int64)
75
+ if n_scans and not np.array_equal(
76
+ scan_no.astype(np.int64) - int(scan_no[0]), np.arange(n_scans)
77
+ ):
78
+ raise CorruptFile(f"{name}: scan headers are out of order.")
79
+ unknown = sorted(set(np.unique(codes).tolist()) - set(INTENSITY_BYTES))
80
+ if unknown:
81
+ raise UnsupportedVariant(
82
+ f"{name}: unsupported scan format code(s) {unknown} "
83
+ "(0 is SIM/MRM, which is not supported)."
84
+ )
85
+ if not np.array_equal(header_rt.astype(np.int64), rt.astype(np.int64)):
86
+ warn(metadata, f"{name}: scan headers and Retention Time disagree.")
87
+ width_of_code = np.zeros(max(INTENSITY_BYTES) + 1, dtype=np.int64)
88
+ for code, width in INTENSITY_BYTES.items():
89
+ width_of_code[code] = width
90
+ widths = width_of_code[codes]
91
+ ends = starts + SCAN_HEADER + counts * (2 + widths)
92
+ if n_scans and ends.max() > len(raw):
93
+ raise CorruptFile(f"{name}: the MS data is truncated.")
94
+ if np.any(starts[1:] < ends[:-1]):
95
+ raise CorruptFile(f"{name}: scans overlap or are out of order.")
96
+
97
+ peak_offset = np.zeros(n_scans + 1, dtype=np.int64)
98
+ np.cumsum(counts, out=peak_offset[1:])
99
+ mz_raw = np.empty(peak_offset[-1], dtype=np.uint16)
100
+ intensity = np.empty(peak_offset[-1], dtype=np.uint32)
101
+ for first in range(0, n_scans, CHUNK):
102
+ chunk = slice(first, min(first + CHUNK, n_scans))
103
+ n = counts[chunk]
104
+ peaks = slice(peak_offset[chunk.start], peak_offset[chunk.stop])
105
+ within = np.arange(peaks.stop - peaks.start) - np.repeat(
106
+ peak_offset[chunk] - peaks.start, n
107
+ )
108
+ width = np.repeat(widths[chunk], n)
109
+ pos = np.repeat(starts[chunk] + SCAN_HEADER, n) + within * (2 + width)
110
+ mz_raw[peaks] = raw[pos] | (raw[pos + 1].astype(np.uint16) << 8)
111
+ value = np.zeros(len(pos), dtype=np.uint32)
112
+ for byte in range(4):
113
+ has = width > byte
114
+ value[has] |= raw[pos[has] + 2 + byte].astype(np.uint32) << (8 * byte)
115
+ intensity[peaks] = value
116
+
117
+ flags = (intensity >> 31).astype(np.uint8) * np.uint8(SATURATED)
118
+ intensity &= np.uint32(SATURATED_BIT - 1)
119
+ computed = np.bincount(
120
+ np.repeat(np.arange(n_scans), counts),
121
+ weights=intensity.astype(np.float64),
122
+ minlength=n_scans,
123
+ )
124
+ stored = streams.get("TIC Data")
125
+ if stored is None:
126
+ tic = computed
127
+ metadata["computed"] = ["tic"]
128
+ else:
129
+ tic = np.frombuffer(stored, "<i8").astype(np.float64)
130
+ if len(tic) != n_scans:
131
+ raise CorruptFile(f"{name}: TIC Data has {len(tic)} scans, not {n_scans}.")
132
+ bad = int(np.count_nonzero(tic != computed))
133
+ if bad:
134
+ warn(metadata, f"{name}: {bad} scans do not add up to the stored TIC.")
135
+ return MSData(
136
+ name=name,
137
+ scan_number=scan_no.astype(np.int32),
138
+ retention_time=rt.astype(np.float64) / 60000.0,
139
+ tic=tic,
140
+ base_peak_mz=None,
141
+ base_peak_intensity=None,
142
+ filters=None,
143
+ mz=mz_raw.astype(np.float64) / MZ_SCALE,
144
+ intensity=intensity,
145
+ peak_offset=peak_offset,
146
+ metadata=metadata,
147
+ flags=flags,
148
+ spectrum_type="centroid",
149
+ )
150
+
151
+
152
+ def read_qgd(path: str | Path) -> list[Injection]:
153
+ """Read a Shimadzu GCMSsolution `.qgd` file.
154
+
155
+ Parameters
156
+ ----------
157
+ path : str or Path
158
+ The `.qgd` file.
159
+
160
+ Returns
161
+ -------
162
+ list of Injection
163
+ One injection with one MS run named ``"MS"``. `inject_time` is
164
+ `None`; the acquisition start (UTC) is in `MSData.acquisition_start`.
165
+
166
+ Raises
167
+ ------
168
+ UnsupportedFormat
169
+ If the file holds no GC-MS raw data.
170
+ """
171
+ path = Path(path)
172
+ with open_ole(path) as ole:
173
+ if not ole.exists(f"{QGD_STORAGE}/MS Raw Data"):
174
+ raise UnsupportedFormat(f"{path.name} contains no GCMS raw data.")
175
+ streams = {
176
+ name: read_stream(ole, f"{QGD_STORAGE}/{name}")
177
+ for name in STREAMS
178
+ if ole.exists(f"{QGD_STORAGE}/{name}")
179
+ }
180
+ start, metadata = file_properties(ole)
181
+ missing = [n for n in STREAMS[:2] if n not in streams]
182
+ if missing:
183
+ raise CorruptFile(f"{path.name}: stream(s) {missing} are missing.")
184
+ ms = decode_qgd(streams, path.name)
185
+ ms.name = "MS"
186
+ ms.acquisition_start = start
187
+ return [
188
+ Injection(
189
+ name=metadata.get("sample_name", ""), ms={"MS": ms}, metadata=metadata
190
+ )
191
+ ]
192
+
193
+
194
+ def sniff_qgd(path: str | Path) -> bool:
195
+ """Whether `path` is an OLE2 file with a ``GCMS Raw Data`` storage."""
196
+ return QGD_STORAGE in storages(path)
@@ -0,0 +1,7 @@
1
+ """Thermo Finnigan `.raw` MS files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .raw import decode_thermo_raw, read_thermo_ms, read_thermo_raw, sniff_thermo_raw
6
+
7
+ __all__ = ["decode_thermo_raw", "read_thermo_ms", "read_thermo_raw", "sniff_thermo_raw"]
@@ -0,0 +1,377 @@
1
+ """Thermo `.raw` MS files (Finnigan format), as embedded in `.cmbx` archives.
2
+
3
+ Only format version 66 with centroid data from a single quadrupole (packet
4
+ type 24, e.g. ISQ GC-MS) is supported; everything else raises
5
+ `UnsupportedVariant`. See `docs/formats/thermo-raw.md` for the binary layout.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import struct
11
+ import warnings
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ from ..._util import Source, existing_file, head, read_bytes
18
+ from ...errors import (
19
+ ChromparserWarning,
20
+ CorruptFile,
21
+ UnsupportedFormat,
22
+ UnsupportedVariant,
23
+ warn,
24
+ )
25
+ from ...model import Injection, MSData
26
+
27
+ THERMO_RAW_MAGIC = b"\x01\xa1" + "Finnigan".encode("utf-16-le")
28
+ SUPPORTED_VERSIONS = (66,)
29
+
30
+ _FILE_HEADER_SIZE = 1356
31
+ _VERSION_OFFSET = 36
32
+
33
+ # RawFileInfo: 44 byte preamble, 764 unknown bytes, then 64-bit addresses
34
+ _RAW_FILE_INFO_ADDRESSES = 808
35
+ _RUN_HEADER_SIZE = 0x1D98
36
+ _RUN_HEADER_ADDRESSES = 0x1CF0
37
+ _SCAN_INDEX_ENTRY = struct.Struct("<IIHHIII6dQII")
38
+ _PACKET_DESCRIPTOR = struct.Struct("<IffdQ20sQ")
39
+ _SCAN_EVENT_PREAMBLE_SIZE = 132
40
+
41
+ # packet type of centroid data from single quadrupoles (`LowResolutionSpectrumType4`)
42
+ _CENTROID_PACKET = 24
43
+
44
+ # scan event preamble bytes (byte index: {value: meaning})
45
+ _POLARITY = 4, {0: "-", 1: "+"}
46
+ _SCAN_DATA = 5, {0: "c", 1: "p"}
47
+ _MS_ORDER = 6, {1: "ms"}
48
+ _SCAN_TYPE = 7, {0: "Full"}
49
+ _IONIZATION = 11, {0: "EI", 1: "CI"}
50
+
51
+
52
+ class _Reader:
53
+ """Bounds-checked little-endian reads from a buffer."""
54
+
55
+ def __init__(self, data: bytes):
56
+ self.data = data
57
+
58
+ def unpack(self, fmt: str | struct.Struct, offset: int) -> tuple:
59
+ layout = fmt if isinstance(fmt, struct.Struct) else struct.Struct("<" + fmt)
60
+ if offset < 0 or offset + layout.size > len(self.data):
61
+ raise CorruptFile(
62
+ f"Truncated Thermo raw file: cannot read {layout.size} bytes "
63
+ f"at offset {offset}."
64
+ )
65
+ return layout.unpack_from(self.data, offset)
66
+
67
+ def string(self, offset: int) -> tuple[str, int]:
68
+ """Read a UTF-16 string preceded by its length in characters (uint32)."""
69
+ (length,) = self.unpack("I", offset)
70
+ end = offset + 4 + 2 * length
71
+ if end > len(self.data):
72
+ raise CorruptFile(f"Truncated Thermo raw file: string at offset {offset}.")
73
+ return self.data[offset + 4 : end].decode("utf-16-le"), end
74
+
75
+ def skip_strings(self, offset: int, count: int) -> int:
76
+ for _ in range(count):
77
+ _, offset = self.string(offset)
78
+ return offset
79
+
80
+
81
+ def _run_header_address(reader: _Reader) -> tuple[int, int, datetime | None]:
82
+ """Walk the file header to RawFileInfo.
83
+
84
+ Returns
85
+ -------
86
+ data_address, run_header : int
87
+ Addresses of the scan data and the run header.
88
+ acquisition_start : datetime or None
89
+ In UTC.
90
+ """
91
+ pos = _FILE_HEADER_SIZE
92
+ # SeqRow: 64 bytes injection data, 13 strings, 3 strings + uint32, 15 strings
93
+ pos = reader.skip_strings(pos + 64, 13)
94
+ pos = reader.skip_strings(pos, 3) + 4
95
+ pos = reader.skip_strings(pos, 15)
96
+ # ASInfo: 24 bytes, 1 string
97
+ _, pos = reader.string(pos + 24)
98
+ # RawFileInfo
99
+ year, month, _, day, hour, minute, second, ms = reader.unpack("8H", pos + 4)
100
+ (controllers,) = reader.unpack("I", pos + 28)
101
+ if controllers != 1:
102
+ raise UnsupportedVariant(
103
+ f"Thermo raw file with {controllers} instruments: only files with one "
104
+ "MS instrument are supported."
105
+ )
106
+ data_address, _, run_header = reader.unpack("3Q", pos + _RAW_FILE_INFO_ADDRESSES)
107
+ try:
108
+ start = datetime(
109
+ year, month, day, hour, minute, second, ms * 1000, timezone.utc
110
+ )
111
+ except ValueError:
112
+ start = None
113
+ return data_address, run_header, start
114
+
115
+
116
+ def _scan_event(reader: _Reader, error_log: int) -> tuple[bytes, list[tuple]]:
117
+ """Read the scan event that follows the error log.
118
+
119
+ Returns
120
+ -------
121
+ preamble : bytes
122
+ The preamble bytes of the event.
123
+ ranges : list of tuple
124
+ The mass ranges of the event.
125
+ """
126
+ (errors,) = reader.unpack("I", error_log)
127
+ pos = error_log + 4
128
+ for _ in range(errors): # float32 retention time, message
129
+ _, pos = reader.string(pos + 4)
130
+ segments, events = reader.unpack("II", pos)
131
+ if (segments, events) != (1, 1):
132
+ raise UnsupportedVariant(
133
+ f"Thermo raw file with {segments} scan segment(s) and {events} scan "
134
+ "event(s) in the first segment: only one full scan event is supported."
135
+ )
136
+ pos += 8
137
+ preamble = reader.data[pos : pos + _SCAN_EVENT_PREAMBLE_SIZE]
138
+ pos += _SCAN_EVENT_PREAMBLE_SIZE
139
+ precursors, _, n_ranges = reader.unpack("III", pos)
140
+ if precursors:
141
+ raise UnsupportedVariant("Thermo raw file with MSn scans is not supported.")
142
+ ranges = [reader.unpack("dd", pos + 12 + 16 * i) for i in range(n_ranges)]
143
+ return preamble, ranges
144
+
145
+
146
+ def _scan_filter(preamble: bytes, ranges: list[tuple]) -> tuple[str, dict]:
147
+ """Build the scan filter, e.g. ``+ c EI Full ms [33.00-500.00]``.
148
+
149
+ Returns
150
+ -------
151
+ filter : str
152
+ The scan filter.
153
+ metadata : dict
154
+ ``polarity``, ``ionisation`` and the mass range.
155
+ """
156
+ parts = []
157
+ for (index, meanings), what in [
158
+ (_POLARITY, "polarity"),
159
+ (_SCAN_DATA, "scan data type"),
160
+ (_IONIZATION, "ionization"),
161
+ (_SCAN_TYPE, "scan type"),
162
+ (_MS_ORDER, "MS order"),
163
+ ]:
164
+ value = preamble[index]
165
+ if value not in meanings:
166
+ raise UnsupportedVariant(
167
+ f"Thermo raw file with unsupported {what} ({value})."
168
+ )
169
+ parts.append(meanings[value])
170
+ if parts[1] != "c":
171
+ raise UnsupportedVariant("Thermo raw file with profile data is not supported.")
172
+ if not ranges:
173
+ raise UnsupportedVariant(
174
+ "Thermo raw file without a mass range in its scan event."
175
+ )
176
+ masses = ", ".join(f"{low:.2f}-{high:.2f}" for low, high in ranges)
177
+ metadata = {
178
+ "polarity": "positive" if parts[0] == "+" else "negative",
179
+ "ionisation": parts[2],
180
+ "mass_range_min": min(low for low, _ in ranges),
181
+ "mass_range_max": max(high for _, high in ranges),
182
+ }
183
+ return " ".join(parts) + f" [{masses}]", metadata
184
+
185
+
186
+ def _instrument(reader: _Reader, offset: int) -> dict:
187
+ """Instrument id after the run header: 12 bytes, then strings."""
188
+ metadata = {}
189
+ try:
190
+ pos = offset + 12
191
+ for key in ["instrument", "instrument_model", "serial_number"]:
192
+ metadata[key], pos = reader.string(pos)
193
+ metadata["instrument_software_version"], pos = reader.string(pos)
194
+ except (CorruptFile, UnicodeDecodeError) as e:
195
+ warnings.warn(
196
+ f"Could not read the instrument id: {e}", ChromparserWarning, stacklevel=4
197
+ )
198
+ return {key: value for key, value in metadata.items() if value}
199
+
200
+
201
+ def decode_thermo_raw(data: bytes, name: str = "MS") -> MSData:
202
+ """Decode the bytes of a Thermo `.raw` file.
203
+
204
+ Parameters
205
+ ----------
206
+ data : bytes
207
+ Content of the file.
208
+ name : str
209
+ Name of the MS data.
210
+
211
+ Returns
212
+ -------
213
+ MSData
214
+ Centroid spectra; m/z and intensity are float32 as stored.
215
+
216
+ Raises
217
+ ------
218
+ UnsupportedFormat
219
+ If the data is not a Thermo raw file.
220
+ UnsupportedVariant
221
+ If the file is not a supported variant (see the module docstring).
222
+ CorruptFile
223
+ If the file is truncated or inconsistent.
224
+ """
225
+ if not data.startswith(THERMO_RAW_MAGIC):
226
+ raise UnsupportedFormat("Not a Thermo raw file (missing 'Finnigan' header).")
227
+ reader = _Reader(data)
228
+ (version,) = reader.unpack("I", _VERSION_OFFSET)
229
+ if version not in SUPPORTED_VERSIONS:
230
+ raise UnsupportedVariant(
231
+ f"Thermo raw file format version {version} is not supported "
232
+ f"(supported: {', '.join(map(str, SUPPORTED_VERSIONS))})."
233
+ )
234
+
235
+ data_address, run_header, acquisition_start = _run_header_address(reader)
236
+ first, last = reader.unpack("II", run_header + 8)
237
+ (index, data_check, _, error_log, _, trailer, _, _, run_header_check) = (
238
+ reader.unpack("9Q", run_header + _RUN_HEADER_ADDRESSES)
239
+ )
240
+ if (data_check, run_header_check) != (data_address, run_header):
241
+ raise CorruptFile("Thermo raw file with inconsistent run header addresses.")
242
+ n_scans = last - first + 1
243
+ if first < 1 or n_scans < 0 or index + n_scans * _SCAN_INDEX_ENTRY.size > trailer:
244
+ raise CorruptFile(
245
+ f"Thermo raw file with an invalid scan range {first}-{last} or scan index."
246
+ )
247
+
248
+ preamble, ranges = _scan_event(reader, error_log)
249
+ scan_filter, event_metadata = _scan_filter(preamble, ranges)
250
+
251
+ metadata: dict = {"format_version": version}
252
+ scans = np.empty(n_scans, dtype=np.int32)
253
+ stats = np.empty((n_scans, 6))
254
+ counts = np.empty(n_scans, dtype=np.int64)
255
+ chunks = []
256
+ peaks_end = 0 # packets are contiguous: peaks, peak flags, descriptor
257
+ flagged = []
258
+ for i in range(n_scans):
259
+ entry = reader.unpack(_SCAN_INDEX_ENTRY, index + i * _SCAN_INDEX_ENTRY.size)
260
+ scan, packet_type, *_ = entry[4:7]
261
+ if scan != first + i:
262
+ raise CorruptFile(f"Thermo raw file: scan {scan} found at index {i}.")
263
+ if packet_type != _CENTROID_PACKET:
264
+ raise UnsupportedVariant(
265
+ f"Thermo raw file: scan {scan} has packet type {packet_type}. Only "
266
+ f"centroid data from single quadrupoles (type {_CENTROID_PACKET}) "
267
+ "is supported."
268
+ )
269
+ if entry[2:4] != (0, 0):
270
+ raise UnsupportedVariant(
271
+ f"Thermo raw file: scan {scan} has an unknown scan event."
272
+ )
273
+ stats[i] = entry[7:13]
274
+ scans[i] = scan
275
+
276
+ descriptor = entry[13]
277
+ _, _, _, _, offset, reserved, size = reader.unpack(
278
+ _PACKET_DESCRIPTOR, data_address + descriptor
279
+ )
280
+ count = size // 8
281
+ if (
282
+ reserved.strip(b"\0")
283
+ or size % 8
284
+ or offset != peaks_end
285
+ or offset + size + count != descriptor
286
+ ):
287
+ raise UnsupportedVariant(
288
+ f"Thermo raw file: unsupported data packet of scan {scan}."
289
+ )
290
+ peaks_end = descriptor + _PACKET_DESCRIPTOR.size
291
+ start = data_address + offset
292
+ chunks.append(data[start : start + size])
293
+ counts[i] = count
294
+ if data[start + size : start + size + count].strip(b"\0"):
295
+ flagged.append(int(scan))
296
+ if data_address + peaks_end != run_header:
297
+ raise CorruptFile("Thermo raw file: unexpected data between the scans.")
298
+ if flagged:
299
+ warn(
300
+ metadata,
301
+ f"Thermo raw file: {len(flagged)} scan(s) have peak flags of unknown "
302
+ f"meaning, first scan {flagged[0]}; the flags are not read.",
303
+ )
304
+
305
+ peaks = np.frombuffer(b"".join(chunks), dtype="<f4").reshape(-1, 2)
306
+ peak_offset = np.zeros(n_scans + 1, dtype=np.int64)
307
+ np.cumsum(counts, out=peak_offset[1:])
308
+
309
+ metadata.update(event_metadata)
310
+ metadata.update(_instrument(reader, run_header + _RUN_HEADER_SIZE))
311
+ return MSData(
312
+ name=name,
313
+ scan_number=scans,
314
+ retention_time=stats[:, 0].copy(),
315
+ tic=stats[:, 1].copy(),
316
+ base_peak_mz=stats[:, 3].copy(),
317
+ base_peak_intensity=stats[:, 2].copy(),
318
+ filters=[scan_filter] * n_scans,
319
+ mz=peaks[:, 0].astype(np.float32),
320
+ intensity=peaks[:, 1].astype(np.float32),
321
+ peak_offset=peak_offset,
322
+ acquisition_start=acquisition_start,
323
+ metadata=metadata,
324
+ ms_level=np.ones(n_scans, dtype=np.uint8),
325
+ spectrum_type="centroid",
326
+ )
327
+
328
+
329
+ def read_thermo_ms(source: Source, name: str = "MS") -> MSData:
330
+ """Read a Thermo `.raw` MS file into `MSData`.
331
+
332
+ This is cmbx-reader's ``read_thermo_raw``.
333
+
334
+ Parameters
335
+ ----------
336
+ source : str, Path, bytes or file object
337
+ The Thermo raw file.
338
+ name : str
339
+ Name of the MS data.
340
+
341
+ Returns
342
+ -------
343
+ MSData
344
+ With `acquisition_start` in UTC.
345
+ """
346
+ return decode_thermo_raw(read_bytes(source), name)
347
+
348
+
349
+ def read_thermo_raw(path: str | Path, *, name: str = "MS") -> list[Injection]:
350
+ """Read a Thermo `.raw` MS file, e.g. one extracted from a `.cmbx` archive.
351
+
352
+ Parameters
353
+ ----------
354
+ path : str or Path
355
+ The Thermo raw file.
356
+ name : str
357
+ Name of the MS data.
358
+
359
+ Returns
360
+ -------
361
+ list of Injection
362
+ One injection holding the MS data.
363
+ """
364
+ ms = read_thermo_ms(existing_file(path), name)
365
+ metadata = {
366
+ key: ms.metadata[key]
367
+ for key in ("instrument", "instrument_model")
368
+ if key in ms.metadata
369
+ }
370
+ return [Injection(name="", ms={name: ms}, metadata=metadata)]
371
+
372
+
373
+ def sniff_thermo_raw(path: str | Path) -> bool:
374
+ """Whether `path` is a Thermo raw file (starts with ``01 A1 "Finnigan"``)."""
375
+ return (
376
+ Path(path).is_file() and head(path, len(THERMO_RAW_MAGIC)) == THERMO_RAW_MAGIC
377
+ )