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,344 @@
|
|
|
1
|
+
"""Agilent ChemStation / OpenLab chromatogram files (`.ch`).
|
|
2
|
+
|
|
3
|
+
Supported file versions: 8 (and 80), 81, 179 and 181. The decoders are
|
|
4
|
+
adapted from ``ImportAgilent.m`` in chemplexity/chromatography
|
|
5
|
+
(https://github.com/chemplexity/chromatography, MIT License, Copyright (c)
|
|
6
|
+
2014 James Dillon; see LICENSE), by way of chromstream's ``parsers/agilent.py``.
|
|
7
|
+
See `docs/formats/agilent.md` for the layout.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
import struct
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from ..._util import clean, existing_file, head
|
|
21
|
+
from ...errors import CorruptFile, UnsupportedFormat, UnsupportedVariant, warn
|
|
22
|
+
from ...model import Injection, Signal
|
|
23
|
+
|
|
24
|
+
SUPPORTED_VERSIONS = ("8", "80", "81", "179", "181")
|
|
25
|
+
|
|
26
|
+
# version -> byte offset of each Pascal string
|
|
27
|
+
_STRINGS_V8 = {
|
|
28
|
+
"sample_name": 24,
|
|
29
|
+
"sample_description": 86,
|
|
30
|
+
"operator": 148,
|
|
31
|
+
"date": 178,
|
|
32
|
+
"inlet": 208,
|
|
33
|
+
"instrument": 218,
|
|
34
|
+
"method": 228,
|
|
35
|
+
"unit": 580,
|
|
36
|
+
}
|
|
37
|
+
_STRINGS_V179 = {
|
|
38
|
+
"sample_name": 858,
|
|
39
|
+
"sample_description": 1369,
|
|
40
|
+
"operator": 1880,
|
|
41
|
+
"date": 2391,
|
|
42
|
+
"inlet": 2492,
|
|
43
|
+
"instrument": 2533,
|
|
44
|
+
"method": 2574,
|
|
45
|
+
"unit": 4172,
|
|
46
|
+
}
|
|
47
|
+
_DATE_FORMATS = (
|
|
48
|
+
"%d %b %y %I:%M %p",
|
|
49
|
+
"%m/%d/%y %I:%M:%S %p",
|
|
50
|
+
"%d-%b-%y, %H:%M:%S",
|
|
51
|
+
"%d %b %y %H:%M:%S",
|
|
52
|
+
)
|
|
53
|
+
# version 8 files with header code 1-3 at byte 542 are scaled by this factor
|
|
54
|
+
# instead of slope and intercept (as in ImportAgilent.m)
|
|
55
|
+
_CODE_FACTOR = 1.33321110047553
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _Buffer:
|
|
59
|
+
"""Bounds-checked big-endian reads."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, data: bytes, name: str):
|
|
62
|
+
self.data = data
|
|
63
|
+
self.name = name
|
|
64
|
+
|
|
65
|
+
def unpack(self, fmt: str, offset: int) -> tuple:
|
|
66
|
+
size = struct.calcsize(">" + fmt)
|
|
67
|
+
if offset + size > len(self.data):
|
|
68
|
+
raise CorruptFile(f"{self.name}: truncated header at offset {offset}.")
|
|
69
|
+
return struct.unpack_from(">" + fmt, self.data, offset)
|
|
70
|
+
|
|
71
|
+
def pascal(self, offset: int, utf16: bool) -> str:
|
|
72
|
+
"""Read a Pascal string: length byte, then latin-1 or UTF-16 characters."""
|
|
73
|
+
if offset >= len(self.data):
|
|
74
|
+
raise CorruptFile(f"{self.name}: truncated header at offset {offset}.")
|
|
75
|
+
length = self.data[offset]
|
|
76
|
+
raw = self.data[offset + 1 : offset + 1 + length * (2 if utf16 else 1)]
|
|
77
|
+
try:
|
|
78
|
+
return raw.decode("utf-16-le" if utf16 else "latin-1").strip()
|
|
79
|
+
except UnicodeDecodeError:
|
|
80
|
+
return raw.decode("latin-1", errors="replace").strip()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def decode_delta(data: bytes, offset: int) -> np.ndarray:
|
|
84
|
+
"""Decode delta-compressed values (version 8).
|
|
85
|
+
|
|
86
|
+
Blocks of a big-endian int16 header (count in the low 12 bits, 0 in the
|
|
87
|
+
high 4 bits ends the data), then `count` int16 deltas. A delta of -32768
|
|
88
|
+
is followed by an absolute int32 value.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
data : bytes
|
|
93
|
+
The file.
|
|
94
|
+
offset : int
|
|
95
|
+
Start of the data.
|
|
96
|
+
|
|
97
|
+
Returns
|
|
98
|
+
-------
|
|
99
|
+
numpy.ndarray
|
|
100
|
+
The integer values as float64.
|
|
101
|
+
"""
|
|
102
|
+
values = []
|
|
103
|
+
value = 0
|
|
104
|
+
pos, end = offset, len(data)
|
|
105
|
+
while pos + 2 <= end:
|
|
106
|
+
(header,) = struct.unpack_from(">h", data, pos)
|
|
107
|
+
pos += 2
|
|
108
|
+
if header >> 12 == 0:
|
|
109
|
+
break
|
|
110
|
+
for _ in range(header & 0x0FFF):
|
|
111
|
+
if pos + 2 > end:
|
|
112
|
+
raise CorruptFile("Agilent .ch: delta-compressed data is truncated.")
|
|
113
|
+
(delta,) = struct.unpack_from(">h", data, pos)
|
|
114
|
+
pos += 2
|
|
115
|
+
if delta != -32768:
|
|
116
|
+
value += delta
|
|
117
|
+
else:
|
|
118
|
+
if pos + 4 > end:
|
|
119
|
+
raise CorruptFile(
|
|
120
|
+
"Agilent .ch: delta-compressed data is truncated."
|
|
121
|
+
)
|
|
122
|
+
(value,) = struct.unpack_from(">i", data, pos)
|
|
123
|
+
pos += 4
|
|
124
|
+
values.append(value)
|
|
125
|
+
return np.array(values, dtype=np.float64)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def decode_double_delta(data: bytes, offset: int) -> np.ndarray:
|
|
129
|
+
"""Decode double-delta-compressed values (versions 81 and 181).
|
|
130
|
+
|
|
131
|
+
Big-endian int16 second differences; 32767 is followed by an absolute
|
|
132
|
+
48-bit value (int16 high part, uint32 low part), which resets the delta.
|
|
133
|
+
|
|
134
|
+
Parameters
|
|
135
|
+
----------
|
|
136
|
+
data : bytes
|
|
137
|
+
The file.
|
|
138
|
+
offset : int
|
|
139
|
+
Start of the data.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
numpy.ndarray
|
|
144
|
+
The integer values as float64.
|
|
145
|
+
"""
|
|
146
|
+
values = []
|
|
147
|
+
signal = delta = 0
|
|
148
|
+
pos, end = offset, len(data)
|
|
149
|
+
while pos + 2 <= end:
|
|
150
|
+
(step,) = struct.unpack_from(">h", data, pos)
|
|
151
|
+
pos += 2
|
|
152
|
+
if step != 32767:
|
|
153
|
+
delta += step
|
|
154
|
+
signal += delta
|
|
155
|
+
else:
|
|
156
|
+
if pos + 6 > end:
|
|
157
|
+
raise CorruptFile("Agilent .ch: double-delta data is truncated.")
|
|
158
|
+
high, low = struct.unpack_from(">hI", data, pos)
|
|
159
|
+
pos += 6
|
|
160
|
+
signal = high * 4294967296 + low
|
|
161
|
+
delta = 0
|
|
162
|
+
values.append(signal)
|
|
163
|
+
return np.array(values, dtype=np.float64)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def decode_double_array(data: bytes, offset: int) -> np.ndarray:
|
|
167
|
+
"""Decode uncompressed little-endian float64 values (version 179)."""
|
|
168
|
+
count = max(len(data) - offset, 0) // 8
|
|
169
|
+
return np.frombuffer(data, dtype="<f8", count=count, offset=offset).astype(
|
|
170
|
+
np.float64
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def parse_date(text: str) -> datetime | None:
|
|
175
|
+
"""Parse the date string of a `.ch` header (local time, no time zone).
|
|
176
|
+
|
|
177
|
+
Parameters
|
|
178
|
+
----------
|
|
179
|
+
text : str
|
|
180
|
+
E.g. ``14-Jan-26, 11:49:50``.
|
|
181
|
+
|
|
182
|
+
Returns
|
|
183
|
+
-------
|
|
184
|
+
datetime or None
|
|
185
|
+
Naive datetime, `None` if the format is unknown.
|
|
186
|
+
"""
|
|
187
|
+
for fmt in _DATE_FORMATS:
|
|
188
|
+
try:
|
|
189
|
+
return datetime.strptime(text, fmt)
|
|
190
|
+
except ValueError:
|
|
191
|
+
continue
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def version_of(data: bytes) -> str | None:
|
|
196
|
+
"""Return the version string at the start of a `.ch` file, if any."""
|
|
197
|
+
if len(data) < 4 or not 1 <= data[0] <= 3:
|
|
198
|
+
return None
|
|
199
|
+
version = data[1 : 1 + data[0]]
|
|
200
|
+
return version.decode("ascii") if re.fullmatch(rb"\d+", version) else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def decode_ch(
|
|
204
|
+
data: bytes, name: str = ".ch"
|
|
205
|
+
) -> tuple[Signal, datetime | None, dict[str, Any]]:
|
|
206
|
+
"""Decode an Agilent `.ch` file.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
data : bytes
|
|
211
|
+
Content of the file.
|
|
212
|
+
name : str
|
|
213
|
+
File name, used as channel name (its stem) and in messages.
|
|
214
|
+
|
|
215
|
+
Returns
|
|
216
|
+
-------
|
|
217
|
+
signal : Signal
|
|
218
|
+
The chromatogram, with the unit from the header.
|
|
219
|
+
inject_time : datetime or None
|
|
220
|
+
Naive local time from the header.
|
|
221
|
+
metadata : dict
|
|
222
|
+
Injection metadata (``sample_name``, ``operator``, ...).
|
|
223
|
+
|
|
224
|
+
Raises
|
|
225
|
+
------
|
|
226
|
+
UnsupportedFormat
|
|
227
|
+
If the data is not an Agilent `.ch` file.
|
|
228
|
+
UnsupportedVariant
|
|
229
|
+
If the file version is not supported.
|
|
230
|
+
CorruptFile
|
|
231
|
+
If the file is truncated.
|
|
232
|
+
"""
|
|
233
|
+
version = version_of(data)
|
|
234
|
+
if version is None:
|
|
235
|
+
raise UnsupportedFormat(f"{name} is not an Agilent .ch file.")
|
|
236
|
+
if version not in SUPPORTED_VERSIONS:
|
|
237
|
+
raise UnsupportedVariant(
|
|
238
|
+
f"{name}: Agilent .ch file version {version} is not supported "
|
|
239
|
+
f"(supported: {', '.join(SUPPORTED_VERSIONS)})."
|
|
240
|
+
)
|
|
241
|
+
buffer = _Buffer(data, name)
|
|
242
|
+
utf16 = version in ("179", "181")
|
|
243
|
+
offsets = _STRINGS_V179 if utf16 else _STRINGS_V8
|
|
244
|
+
strings = {key: buffer.pascal(offset, utf16) for key, offset in offsets.items()}
|
|
245
|
+
(block,) = buffer.unpack("i", 264)
|
|
246
|
+
data_offset = (block - 1) * 512
|
|
247
|
+
if not 0 < data_offset <= len(data):
|
|
248
|
+
raise CorruptFile(f"{name}: data offset {data_offset} is outside the file.")
|
|
249
|
+
|
|
250
|
+
if version in ("8", "80"):
|
|
251
|
+
values = decode_delta(data, data_offset)
|
|
252
|
+
start, end = buffer.unpack("ii", 282)
|
|
253
|
+
(code,) = buffer.unpack("i", 542)
|
|
254
|
+
if code in (1, 2, 3):
|
|
255
|
+
values = values * _CODE_FACTOR
|
|
256
|
+
else:
|
|
257
|
+
intercept, slope = buffer.unpack("dd", 636)
|
|
258
|
+
values = values * slope + intercept
|
|
259
|
+
else:
|
|
260
|
+
start, end = buffer.unpack("ff", 282)
|
|
261
|
+
intercept, slope = buffer.unpack("dd", 4724 if utf16 else 636)
|
|
262
|
+
if version == "179":
|
|
263
|
+
values = decode_double_array(data, data_offset)
|
|
264
|
+
else:
|
|
265
|
+
values = decode_double_delta(data, data_offset)
|
|
266
|
+
values = values * slope + intercept
|
|
267
|
+
|
|
268
|
+
# start and end of the run in ms
|
|
269
|
+
if len(values) > 1:
|
|
270
|
+
time = np.linspace(start / 60000.0, end / 60000.0, len(values))
|
|
271
|
+
interval = round((end - start) / (len(values) - 1) / 1000.0, 12)
|
|
272
|
+
else:
|
|
273
|
+
time = np.zeros(len(values))
|
|
274
|
+
interval = None
|
|
275
|
+
|
|
276
|
+
metadata = clean(
|
|
277
|
+
{
|
|
278
|
+
"sample_name": strings["sample_name"],
|
|
279
|
+
"agilent_sample_description": strings["sample_description"],
|
|
280
|
+
"operator": strings["operator"],
|
|
281
|
+
"instrument": strings["instrument"],
|
|
282
|
+
"agilent_inlet": strings["inlet"],
|
|
283
|
+
"instrument_method": strings["method"],
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
signal_metadata: dict[str, Any] = {"agilent_file_version": version}
|
|
287
|
+
if version in ("81", "181") and (len(data) - data_offset) % 2:
|
|
288
|
+
warn(signal_metadata, f"{name}: odd trailing byte after the data, ignored.")
|
|
289
|
+
inject_time = parse_date(strings["date"]) if strings["date"] else None
|
|
290
|
+
if strings["date"]:
|
|
291
|
+
metadata["agilent_date"] = strings["date"]
|
|
292
|
+
if inject_time is None:
|
|
293
|
+
warn(metadata, f"{name}: unknown date format {strings['date']!r}.")
|
|
294
|
+
signal = Signal(
|
|
295
|
+
channel=Path(name).stem,
|
|
296
|
+
time=time,
|
|
297
|
+
values=values,
|
|
298
|
+
unit=strings["unit"] or None,
|
|
299
|
+
metadata=signal_metadata,
|
|
300
|
+
sampling_interval=interval,
|
|
301
|
+
)
|
|
302
|
+
return signal, inject_time, metadata
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def read_agilent_ch(path: str | Path, *, channel: str | None = None) -> list[Injection]:
|
|
306
|
+
"""Read an Agilent `.ch` file.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
path : str or Path
|
|
311
|
+
The `.ch` file.
|
|
312
|
+
channel : str, optional
|
|
313
|
+
Channel name; defaults to the file name without extension.
|
|
314
|
+
|
|
315
|
+
Returns
|
|
316
|
+
-------
|
|
317
|
+
list of Injection
|
|
318
|
+
One injection with one signal. `inject_time` is naive local time:
|
|
319
|
+
the file does not store the time zone.
|
|
320
|
+
"""
|
|
321
|
+
path = existing_file(path)
|
|
322
|
+
signal, inject_time, metadata = decode_ch(path.read_bytes(), path.name)
|
|
323
|
+
if channel:
|
|
324
|
+
signal.channel = channel
|
|
325
|
+
return [
|
|
326
|
+
Injection(
|
|
327
|
+
name=metadata.get("sample_name", ""),
|
|
328
|
+
inject_time=inject_time,
|
|
329
|
+
signals={signal.channel: signal},
|
|
330
|
+
metadata=metadata,
|
|
331
|
+
)
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def sniff_agilent_ch(path: str | Path) -> bool:
|
|
336
|
+
"""Whether `path` starts like an Agilent `.ch` file (any version)."""
|
|
337
|
+
path = Path(path)
|
|
338
|
+
if not path.is_file() or path.stat().st_size < 512:
|
|
339
|
+
return False
|
|
340
|
+
data = head(path, 268)
|
|
341
|
+
if version_of(data) is None:
|
|
342
|
+
return False
|
|
343
|
+
(block,) = struct.unpack_from(">i", data, 264)
|
|
344
|
+
return 0 < (block - 1) * 512 <= path.stat().st_size
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Agilent data folders (`.D`): one injection, one `.ch` file per signal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ...errors import UnsupportedFormat, warn
|
|
8
|
+
from ...model import Injection
|
|
9
|
+
from .ch import decode_ch, sniff_agilent_ch
|
|
10
|
+
|
|
11
|
+
# files of an Agilent folder that hold data this reader does not decode
|
|
12
|
+
_UNREAD = {".ms": "MS data", ".uv": "UV spectra"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ch_files(path: Path) -> list[Path]:
|
|
16
|
+
return sorted(
|
|
17
|
+
(p for p in path.iterdir() if p.is_file() and p.suffix.lower() == ".ch"),
|
|
18
|
+
key=lambda p: p.name.lower(),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def read_agilent_d(path: str | Path) -> list[Injection]:
|
|
23
|
+
"""Read the chromatograms (`.ch` files) of an Agilent `.D` folder.
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
path : str or Path
|
|
28
|
+
The `.D` folder.
|
|
29
|
+
|
|
30
|
+
Returns
|
|
31
|
+
-------
|
|
32
|
+
list of Injection
|
|
33
|
+
One injection with one signal per `.ch` file, in file name order.
|
|
34
|
+
Injection metadata comes from the first file.
|
|
35
|
+
|
|
36
|
+
Raises
|
|
37
|
+
------
|
|
38
|
+
FileNotFoundError
|
|
39
|
+
If the folder does not exist.
|
|
40
|
+
UnsupportedFormat
|
|
41
|
+
If it holds no `.ch` files.
|
|
42
|
+
"""
|
|
43
|
+
path = Path(path)
|
|
44
|
+
if not path.is_dir():
|
|
45
|
+
raise FileNotFoundError(f"Folder not found: {path}")
|
|
46
|
+
files = _ch_files(path)
|
|
47
|
+
if not files:
|
|
48
|
+
raise UnsupportedFormat(f"{path} holds no Agilent .ch files.")
|
|
49
|
+
injection = None
|
|
50
|
+
for file in files:
|
|
51
|
+
signal, inject_time, metadata = decode_ch(file.read_bytes(), file.name)
|
|
52
|
+
if injection is None:
|
|
53
|
+
injection = Injection(
|
|
54
|
+
name=metadata.get("sample_name", ""),
|
|
55
|
+
inject_time=inject_time,
|
|
56
|
+
metadata=metadata,
|
|
57
|
+
)
|
|
58
|
+
injection.signals[signal.channel] = signal
|
|
59
|
+
for p in sorted(path.iterdir()):
|
|
60
|
+
what = _UNREAD.get(p.suffix.lower())
|
|
61
|
+
if what:
|
|
62
|
+
warn(injection.metadata, f"{p.name}: {what} is not read (not supported).")
|
|
63
|
+
return [injection]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sniff_agilent_d(path: str | Path) -> bool:
|
|
67
|
+
"""Whether `path` is a folder with at least one Agilent `.ch` file."""
|
|
68
|
+
path = Path(path)
|
|
69
|
+
return path.is_dir() and any(sniff_agilent_ch(p) for p in _ch_files(path))
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Agilent OpenLab CDS `.dx` files: a zip archive with `.ch` files and `.acmd`.
|
|
2
|
+
|
|
3
|
+
The `injection.acmd` XML names the channel of each `.ch` member (by trace id)
|
|
4
|
+
and holds the injection information, including the run time with its UTC
|
|
5
|
+
offset. Instrument traces (`.IT` members: pressures, temperatures) are not
|
|
6
|
+
read.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import xml.etree.ElementTree as ET
|
|
12
|
+
import zipfile
|
|
13
|
+
from pathlib import Path, PurePosixPath
|
|
14
|
+
|
|
15
|
+
from ..._util import clean, existing_file, parse_iso_datetime
|
|
16
|
+
from ...errors import UnsupportedFormat, warn
|
|
17
|
+
from ...model import Injection
|
|
18
|
+
from .ch import decode_ch, version_of
|
|
19
|
+
|
|
20
|
+
_NS = {"acmd": "urn:schemas-agilent-com:acmd20"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _acmd(archive: zipfile.ZipFile) -> tuple[dict, dict[str, dict]]:
|
|
24
|
+
"""Parse the `.acmd` member.
|
|
25
|
+
|
|
26
|
+
Returns
|
|
27
|
+
-------
|
|
28
|
+
injection : dict
|
|
29
|
+
Texts of the ``InjectionInfo`` elements.
|
|
30
|
+
signals : dict of str to dict
|
|
31
|
+
Texts of each ``Signal`` element, by ``TraceId``.
|
|
32
|
+
"""
|
|
33
|
+
name = next((n for n in archive.namelist() if n.lower().endswith(".acmd")), None)
|
|
34
|
+
if name is None:
|
|
35
|
+
return {}, {}
|
|
36
|
+
root = ET.fromstring(archive.read(name))
|
|
37
|
+
info = root.find("acmd:InjectionInfo", _NS)
|
|
38
|
+
injection: dict = {}
|
|
39
|
+
signals: dict[str, dict] = {}
|
|
40
|
+
if info is None:
|
|
41
|
+
return injection, signals
|
|
42
|
+
for child in info:
|
|
43
|
+
tag = child.tag.split("}")[-1]
|
|
44
|
+
if tag != "Signals" and child.text and child.text.strip():
|
|
45
|
+
injection[tag] = child.text.strip()
|
|
46
|
+
for signal in info.iterfind("acmd:Signals/acmd:Signal", _NS):
|
|
47
|
+
fields = {
|
|
48
|
+
child.tag.split("}")[-1]: (child.text or "").strip() for child in signal
|
|
49
|
+
}
|
|
50
|
+
if fields.get("TraceId"):
|
|
51
|
+
signals[fields["TraceId"].lower()] = fields
|
|
52
|
+
return injection, signals
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_agilent_dx(path: str | Path) -> list[Injection]:
|
|
56
|
+
"""Read the chromatograms of an Agilent OpenLab `.dx` file.
|
|
57
|
+
|
|
58
|
+
Parameters
|
|
59
|
+
----------
|
|
60
|
+
path : str or Path
|
|
61
|
+
The `.dx` file.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
list of Injection
|
|
66
|
+
One injection with one signal per `.ch` member, named after the
|
|
67
|
+
channel in the `.acmd` file. `inject_time` is timezone-aware.
|
|
68
|
+
|
|
69
|
+
Raises
|
|
70
|
+
------
|
|
71
|
+
UnsupportedFormat
|
|
72
|
+
If the file is not a `.dx` archive or holds no `.ch` members.
|
|
73
|
+
"""
|
|
74
|
+
path = existing_file(path)
|
|
75
|
+
try:
|
|
76
|
+
archive = zipfile.ZipFile(path)
|
|
77
|
+
except zipfile.BadZipFile as e:
|
|
78
|
+
raise UnsupportedFormat(f"'{path}' is not a .dx archive: {e}") from e
|
|
79
|
+
with archive:
|
|
80
|
+
info, trace_info = _acmd(archive)
|
|
81
|
+
members = sorted(
|
|
82
|
+
(n for n in archive.namelist() if n.lower().endswith(".ch")),
|
|
83
|
+
key=str.lower,
|
|
84
|
+
)
|
|
85
|
+
if not members:
|
|
86
|
+
raise UnsupportedFormat(f"{path.name} holds no .ch chromatograms.")
|
|
87
|
+
injection = None
|
|
88
|
+
for member in members:
|
|
89
|
+
signal, inject_time, metadata = decode_ch(archive.read(member), member)
|
|
90
|
+
trace = trace_info.get(PurePosixPath(member).stem.lower(), {})
|
|
91
|
+
signal.channel = trace.get("ChannelName") or signal.channel
|
|
92
|
+
signal.metadata.update(
|
|
93
|
+
clean(
|
|
94
|
+
{
|
|
95
|
+
"agilent_trace_id": trace.get("TraceId"),
|
|
96
|
+
"detector": trace.get("DeviceName"),
|
|
97
|
+
"agilent_description": trace.get("Description"),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
if injection is None:
|
|
102
|
+
run_time = parse_iso_datetime(info.get("RunDateTime", ""))
|
|
103
|
+
metadata.update(
|
|
104
|
+
clean(
|
|
105
|
+
{
|
|
106
|
+
"sample_name": info.get("SampleName"),
|
|
107
|
+
"operator": info.get("RunOperator"),
|
|
108
|
+
"instrument_method": info.get("AcquisitionMethod"),
|
|
109
|
+
"agilent_location": info.get("Location"),
|
|
110
|
+
"agilent_injection_volume": info.get("InjectionVolume"),
|
|
111
|
+
"agilent_injection_volume_unit": info.get(
|
|
112
|
+
"InjectionVolumeUnits"
|
|
113
|
+
),
|
|
114
|
+
"agilent_sequence_line": info.get("SequenceLine"),
|
|
115
|
+
"agilent_run_date_time": info.get("RunDateTime"),
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
injection = Injection(
|
|
120
|
+
name=metadata.get("sample_name", ""),
|
|
121
|
+
inject_time=run_time or inject_time,
|
|
122
|
+
metadata=metadata,
|
|
123
|
+
)
|
|
124
|
+
if signal.channel in injection.signals:
|
|
125
|
+
warn(
|
|
126
|
+
injection.metadata,
|
|
127
|
+
f"{path.name}: two signals named {signal.channel!r}; "
|
|
128
|
+
f"{member} is stored under its member name.",
|
|
129
|
+
)
|
|
130
|
+
signal.channel = PurePosixPath(member).stem
|
|
131
|
+
injection.signals[signal.channel] = signal
|
|
132
|
+
return [injection]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sniff_agilent_dx(path: str | Path) -> bool:
|
|
136
|
+
"""Whether `path` is a zip archive with an `.acmd` member or `.ch` members."""
|
|
137
|
+
if not Path(path).is_file() or not zipfile.is_zipfile(path):
|
|
138
|
+
return False
|
|
139
|
+
try:
|
|
140
|
+
with zipfile.ZipFile(path) as archive:
|
|
141
|
+
names = archive.namelist()
|
|
142
|
+
if any(n.lower().endswith(".acmd") for n in names):
|
|
143
|
+
return True
|
|
144
|
+
members = [n for n in names if n.lower().endswith(".ch")]
|
|
145
|
+
return bool(members) and version_of(archive.read(members[0])) is not None
|
|
146
|
+
except (zipfile.BadZipFile, OSError):
|
|
147
|
+
return False
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""ANDI netCDF files: ANDI-MS (GC-MS) and ANDI chromatography (AIA)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .chrom import read_andi_chrom, sniff_andi_chrom
|
|
6
|
+
from .ms import read_andi_ms, sniff_andi_ms
|
|
7
|
+
from .write import write_andi
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"read_andi_chrom",
|
|
11
|
+
"read_andi_ms",
|
|
12
|
+
"sniff_andi_chrom",
|
|
13
|
+
"sniff_andi_ms",
|
|
14
|
+
"write_andi",
|
|
15
|
+
]
|