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,310 @@
|
|
|
1
|
+
"""Chromeleon 7 `.cmbx` archives ("Export to archive").
|
|
2
|
+
|
|
3
|
+
A `.cmbx` archive is a zip file with a `header.xml` manifest describing the
|
|
4
|
+
exported items (sequence, injections, signals, ...), the serialized sequence
|
|
5
|
+
(`*.cmd`) and one raw data file per signal, audit trail and MS data item. MS
|
|
6
|
+
data items are complete Thermo `.raw` files.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
import warnings
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
import zipfile
|
|
15
|
+
from collections.abc import Iterator
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from ..._util import existing_file
|
|
19
|
+
from ...errors import ChromparserWarning, UnsupportedFormat
|
|
20
|
+
from ...model import Injection
|
|
21
|
+
from ..thermo.raw import decode_thermo_raw
|
|
22
|
+
from .raw import decode_signal
|
|
23
|
+
from .sequence import SignalInfo, parse_sequence
|
|
24
|
+
|
|
25
|
+
_SIGNAL = "Dionex.Chromeleon.Data.Signal"
|
|
26
|
+
_MS_RAW = "Dionex.Chromeleon.Data.MSRawItem"
|
|
27
|
+
_INJECTION = "Dionex.Chromeleon.Data.Injection"
|
|
28
|
+
_SEQUENCE = "Dionex.Chromeleon.Data.Sequence"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _data_vault(url: str) -> str | None:
|
|
32
|
+
# chrom://<computer>/<data vault>/<folders...>
|
|
33
|
+
parts = url.split("/")
|
|
34
|
+
return parts[3] if url.startswith("chrom://") and len(parts) > 3 else None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _iter_injections(
|
|
38
|
+
root: ET.Element,
|
|
39
|
+
) -> Iterator[tuple[ET.Element, ET.Element | None]]:
|
|
40
|
+
"""Yield (injection, sequence) elements of `header.xml`."""
|
|
41
|
+
|
|
42
|
+
def walk(element, sequence):
|
|
43
|
+
for child in element:
|
|
44
|
+
item_type = child.get("ItemType")
|
|
45
|
+
if item_type == _INJECTION:
|
|
46
|
+
yield child, sequence
|
|
47
|
+
else:
|
|
48
|
+
yield from walk(child, child if item_type == _SEQUENCE else sequence)
|
|
49
|
+
|
|
50
|
+
yield from walk(root, None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _raw_items(injection: ET.Element, item_type: str) -> list[ET.Element]:
|
|
54
|
+
return [e for e in injection.iter() if e.get("ItemType") == item_type]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _open(path: str | Path) -> tuple[zipfile.ZipFile, dict[str, str], ET.Element]:
|
|
58
|
+
"""Open an archive: zip file, member names by lower case name, `header.xml`."""
|
|
59
|
+
path = existing_file(path)
|
|
60
|
+
try:
|
|
61
|
+
archive = zipfile.ZipFile(path)
|
|
62
|
+
except zipfile.BadZipFile as e:
|
|
63
|
+
raise UnsupportedFormat(f"'{path}' is not a .cmbx archive: {e}") from e
|
|
64
|
+
members = {name.lower(): name for name in archive.namelist()}
|
|
65
|
+
if "header.xml" not in members:
|
|
66
|
+
archive.close()
|
|
67
|
+
raise UnsupportedFormat(f"'{path}' is not a .cmbx archive (no header.xml).")
|
|
68
|
+
return archive, members, ET.fromstring(archive.read(members["header.xml"]))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _injections_with_data(root: ET.Element, members: dict[str, str]):
|
|
72
|
+
"""Yield (injection, sequence, signal items, MS items) of injections with data.
|
|
73
|
+
|
|
74
|
+
Only injections whose signals or MS data are in the archive are yielded,
|
|
75
|
+
in archive order. The position in this sequence is the injection number,
|
|
76
|
+
as used by `read_cmbx`, the HDF5 layout and `extract_ms_raw`.
|
|
77
|
+
"""
|
|
78
|
+
for element, sequence in _iter_injections(root):
|
|
79
|
+
signals = _raw_items(element, _SIGNAL)
|
|
80
|
+
ms_items = [
|
|
81
|
+
item
|
|
82
|
+
for item in _raw_items(element, _MS_RAW)
|
|
83
|
+
if (item.get("RawDataFilename") or "").lower() in members
|
|
84
|
+
]
|
|
85
|
+
present = [
|
|
86
|
+
item
|
|
87
|
+
for item in signals
|
|
88
|
+
if (item.get("RawDataFilename") or "").lower() in members
|
|
89
|
+
]
|
|
90
|
+
if present or ms_items:
|
|
91
|
+
yield element, sequence, signals, ms_items
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def read_cmbx(path: str | Path, *, ms: bool = True) -> list[Injection]:
|
|
95
|
+
"""Read all injections with signals or MS data from a `.cmbx` archive.
|
|
96
|
+
|
|
97
|
+
Metadata (signal unit, inject time, injection type and status, instrument
|
|
98
|
+
method, detector, ...) is read from the sequence file in the archive. Audit
|
|
99
|
+
trails are skipped, and so are injections without signals and MS data.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
path : str or Path
|
|
104
|
+
The `.cmbx` file.
|
|
105
|
+
ms : bool
|
|
106
|
+
Read MS data (Thermo `.raw` files). Unsupported MS data raises; pass
|
|
107
|
+
``ms=False`` to read only the signals.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
list of Injection
|
|
112
|
+
Injections in archive order.
|
|
113
|
+
|
|
114
|
+
Raises
|
|
115
|
+
------
|
|
116
|
+
FileNotFoundError
|
|
117
|
+
If the file does not exist.
|
|
118
|
+
UnsupportedFormat
|
|
119
|
+
If the file is not a `.cmbx` archive.
|
|
120
|
+
UnsupportedVariant, CorruptFile
|
|
121
|
+
If a signal or MS data item is unsupported or malformed.
|
|
122
|
+
"""
|
|
123
|
+
path = Path(path)
|
|
124
|
+
archive, members, root = _open(path)
|
|
125
|
+
with archive:
|
|
126
|
+
sequences: dict[str, dict[str, SignalInfo]] = {}
|
|
127
|
+
|
|
128
|
+
def sequence_info(sequence) -> dict[str, SignalInfo]:
|
|
129
|
+
name = sequence.get("Filename") if sequence is not None else None
|
|
130
|
+
if not name or name.lower() not in members:
|
|
131
|
+
return {}
|
|
132
|
+
if name not in sequences:
|
|
133
|
+
try:
|
|
134
|
+
sequences[name] = parse_sequence(
|
|
135
|
+
archive.read(members[name.lower()])
|
|
136
|
+
)
|
|
137
|
+
except Exception as e:
|
|
138
|
+
warnings.warn(
|
|
139
|
+
f"Could not read metadata from sequence {name}: {e}",
|
|
140
|
+
ChromparserWarning,
|
|
141
|
+
stacklevel=3,
|
|
142
|
+
)
|
|
143
|
+
sequences[name] = {}
|
|
144
|
+
return sequences[name]
|
|
145
|
+
|
|
146
|
+
injections = []
|
|
147
|
+
for element, sequence, signal_elements, ms_elements in _injections_with_data(
|
|
148
|
+
root, members
|
|
149
|
+
):
|
|
150
|
+
injection = Injection(
|
|
151
|
+
name=element.get("Name", ""),
|
|
152
|
+
metadata={
|
|
153
|
+
"url": element.get("Url"),
|
|
154
|
+
"data_vault": _data_vault(element.get("Url", "")),
|
|
155
|
+
"sequence": sequence.get("Name") if sequence is not None else None,
|
|
156
|
+
},
|
|
157
|
+
)
|
|
158
|
+
for signal_element in signal_elements:
|
|
159
|
+
raw_name = signal_element.get("RawDataFilename")
|
|
160
|
+
if not raw_name or raw_name.lower() not in members:
|
|
161
|
+
warnings.warn(
|
|
162
|
+
f"Raw data of signal {signal_element.get('Name')!r} "
|
|
163
|
+
f"is missing in {path}.",
|
|
164
|
+
ChromparserWarning,
|
|
165
|
+
stacklevel=2,
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
signal = decode_signal(archive.read(members[raw_name.lower()]))
|
|
169
|
+
signal.channel = signal_element.get("Name") or signal.channel
|
|
170
|
+
raw_id = signal_element.get("RawDataFileId", "")
|
|
171
|
+
signal.metadata.update(
|
|
172
|
+
url=signal_element.get("Url"),
|
|
173
|
+
raw_data_file=raw_name,
|
|
174
|
+
raw_data_file_id=raw_id or None,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
info = sequence_info(sequence).get(raw_id.lower())
|
|
178
|
+
if info is not None:
|
|
179
|
+
signal.unit = info.unit
|
|
180
|
+
signal.metadata.update(info.metadata)
|
|
181
|
+
if info.expected_points not in (None, len(signal)):
|
|
182
|
+
warnings.warn(
|
|
183
|
+
f"Signal {signal.channel!r} in {path} has {len(signal)} "
|
|
184
|
+
f"data points, the sequence lists {info.expected_points}.",
|
|
185
|
+
ChromparserWarning,
|
|
186
|
+
stacklevel=2,
|
|
187
|
+
)
|
|
188
|
+
if info.injection_name:
|
|
189
|
+
injection.name = info.injection_name
|
|
190
|
+
injection.inject_time = injection.inject_time or info.inject_time
|
|
191
|
+
injection.metadata.update(info.injection_metadata)
|
|
192
|
+
|
|
193
|
+
signal.metadata = {
|
|
194
|
+
k: v for k, v in signal.metadata.items() if v is not None
|
|
195
|
+
}
|
|
196
|
+
injection.signals[signal.channel] = signal
|
|
197
|
+
|
|
198
|
+
for ms_element in ms_elements if ms else []:
|
|
199
|
+
raw_name = ms_element.get("RawDataFilename")
|
|
200
|
+
name = ms_element.get("Name") or "MS"
|
|
201
|
+
try:
|
|
202
|
+
ms_data = decode_thermo_raw(
|
|
203
|
+
archive.read(members[raw_name.lower()]), name
|
|
204
|
+
)
|
|
205
|
+
except ValueError as e:
|
|
206
|
+
raise type(e)(
|
|
207
|
+
f"MS data {name!r} ({raw_name}) in {path}: {e} "
|
|
208
|
+
"Use ms=False (--no-ms) to read only the signals, or "
|
|
209
|
+
"extract the Thermo .raw file with extract_ms_raw "
|
|
210
|
+
"(chromparser extract-ms)."
|
|
211
|
+
) from e
|
|
212
|
+
ms_data.metadata.update(
|
|
213
|
+
url=ms_element.get("Url"),
|
|
214
|
+
raw_data_file=raw_name,
|
|
215
|
+
raw_data_file_id=ms_element.get("RawDataFileId") or None,
|
|
216
|
+
)
|
|
217
|
+
ms_data.metadata = {
|
|
218
|
+
k: v for k, v in ms_data.metadata.items() if v is not None
|
|
219
|
+
}
|
|
220
|
+
injection.ms[name] = ms_data
|
|
221
|
+
|
|
222
|
+
if injection.signals or injection.ms:
|
|
223
|
+
if injection.signals:
|
|
224
|
+
first = next(iter(injection.signals.values()))
|
|
225
|
+
injection.metadata["guid"] = first.metadata.get("injection_guid")
|
|
226
|
+
injection.metadata = {
|
|
227
|
+
k: v for k, v in injection.metadata.items() if v is not None
|
|
228
|
+
}
|
|
229
|
+
injections.append(injection)
|
|
230
|
+
|
|
231
|
+
if not injections:
|
|
232
|
+
warnings.warn(f"No signals found in {path}.", ChromparserWarning, stacklevel=2)
|
|
233
|
+
return injections
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def extract_ms_raw(
|
|
237
|
+
path: str | Path,
|
|
238
|
+
directory: str | Path | None = None,
|
|
239
|
+
*,
|
|
240
|
+
overwrite: bool = False,
|
|
241
|
+
) -> list[Path]:
|
|
242
|
+
"""Write the Thermo `.raw` files embedded in a `.cmbx` archive to a directory.
|
|
243
|
+
|
|
244
|
+
The files are written unchanged and can be opened with any tool for Thermo
|
|
245
|
+
raw files. They are named ``<archive>_injection_<n>_<item>.raw``, where
|
|
246
|
+
``n`` is the injection number as in `read_cmbx` and the HDF5 layout (from
|
|
247
|
+
1) and ``item`` the name of the MS data item, e.g.
|
|
248
|
+
``run_injection_0001_MSDevice.raw``.
|
|
249
|
+
|
|
250
|
+
Parameters
|
|
251
|
+
----------
|
|
252
|
+
path : str or Path
|
|
253
|
+
The `.cmbx` file.
|
|
254
|
+
directory : str or Path, optional
|
|
255
|
+
Output directory, created if needed. Defaults to the directory of the
|
|
256
|
+
archive.
|
|
257
|
+
overwrite : bool
|
|
258
|
+
Replace existing files.
|
|
259
|
+
|
|
260
|
+
Returns
|
|
261
|
+
-------
|
|
262
|
+
list of Path
|
|
263
|
+
Paths of the written files (empty if the archive has no MS data).
|
|
264
|
+
|
|
265
|
+
Raises
|
|
266
|
+
------
|
|
267
|
+
FileExistsError
|
|
268
|
+
If an output file exists and `overwrite` is False.
|
|
269
|
+
UnsupportedFormat
|
|
270
|
+
If the file is not a `.cmbx` archive.
|
|
271
|
+
"""
|
|
272
|
+
path = Path(path)
|
|
273
|
+
directory = Path(directory) if directory is not None else path.parent
|
|
274
|
+
archive, members, root = _open(path)
|
|
275
|
+
written = []
|
|
276
|
+
with archive:
|
|
277
|
+
targets = []
|
|
278
|
+
for number, (_, _, _, ms_elements) in enumerate(
|
|
279
|
+
_injections_with_data(root, members), start=1
|
|
280
|
+
):
|
|
281
|
+
for item in ms_elements:
|
|
282
|
+
name = (item.get("Name") or "MS").replace("/", "_")
|
|
283
|
+
target = directory / f"{path.stem}_injection_{number:04d}_{name}.raw"
|
|
284
|
+
if target.exists() and not overwrite:
|
|
285
|
+
raise FileExistsError(
|
|
286
|
+
f"{target} exists, use overwrite=True to replace it."
|
|
287
|
+
)
|
|
288
|
+
targets.append((members[item.get("RawDataFilename").lower()], target))
|
|
289
|
+
if targets:
|
|
290
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
291
|
+
for member, target in targets:
|
|
292
|
+
with archive.open(member) as source, target.open("wb") as output:
|
|
293
|
+
shutil.copyfileobj(source, output, 1 << 20)
|
|
294
|
+
written.append(target)
|
|
295
|
+
return written
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def sniff_cmbx(path: str | Path) -> bool:
|
|
299
|
+
"""Whether `path` is a zip archive with a Chromeleon `header.xml`."""
|
|
300
|
+
if not Path(path).is_file() or not zipfile.is_zipfile(path):
|
|
301
|
+
return False
|
|
302
|
+
try:
|
|
303
|
+
with zipfile.ZipFile(path) as archive:
|
|
304
|
+
names = {name.lower(): name for name in archive.namelist()}
|
|
305
|
+
if "header.xml" not in names:
|
|
306
|
+
return False
|
|
307
|
+
with archive.open(names["header.xml"]) as f:
|
|
308
|
+
return b"Dionex.Chromeleon" in f.read(1 << 16)
|
|
309
|
+
except (zipfile.BadZipFile, OSError):
|
|
310
|
+
return False
|