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,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,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
|
+
)
|