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