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,319 @@
|
|
|
1
|
+
"""Chromeleon text exports of one chromatogram (`.txt`).
|
|
2
|
+
|
|
3
|
+
The export is a tab-separated text file: ``key<TAB>value`` lines in sections
|
|
4
|
+
(``Injection Information:``, ``Chromatogram Data Information:``, ...), then
|
|
5
|
+
``Chromatogram Data:``, a column header and one ``time<TAB>step<TAB>value``
|
|
6
|
+
row per point. Labels, number and date formats depend on the Chromeleon
|
|
7
|
+
version and on the locale of the exporting computer; see
|
|
8
|
+
`docs/formats/chromeleon.md`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime, timedelta, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from ..._util import clean, existing_file, head, parse_iso_datetime
|
|
20
|
+
from ...errors import CorruptFile, UnsupportedFormat, warn
|
|
21
|
+
from ...model import Injection, Signal
|
|
22
|
+
|
|
23
|
+
_DATA_MARKER = "Chromatogram Data:"
|
|
24
|
+
|
|
25
|
+
# labels of different Chromeleon versions -> metadata keys
|
|
26
|
+
_INJECTION_KEYS = {
|
|
27
|
+
"URL": "url",
|
|
28
|
+
"File Path": "url",
|
|
29
|
+
"Data Vault": "data_vault",
|
|
30
|
+
"Sequence": "sequence",
|
|
31
|
+
"Number": "number",
|
|
32
|
+
"Injection Number": "number",
|
|
33
|
+
"Position": "position",
|
|
34
|
+
"Comment": "comment",
|
|
35
|
+
"Processing Method": "processing_method",
|
|
36
|
+
"Instrument Method": "instrument_method",
|
|
37
|
+
"Type": "type",
|
|
38
|
+
"Status": "status",
|
|
39
|
+
"Operator": "operator",
|
|
40
|
+
}
|
|
41
|
+
_SIGNAL_KEYS = {
|
|
42
|
+
"Detector": "detector",
|
|
43
|
+
"Driver Name": "driver",
|
|
44
|
+
"Generating Data System": "generating_data_system",
|
|
45
|
+
"Exporting Data System": "exporting_data_system",
|
|
46
|
+
"Signal Quantity": "signal_quantity",
|
|
47
|
+
"Channel Type": "channel_type",
|
|
48
|
+
"Signal Info": "signal_info",
|
|
49
|
+
}
|
|
50
|
+
_NAME_KEYS = ("Name", "Injection")
|
|
51
|
+
_TIME_KEYS = ("Inject Time", "Injection Time")
|
|
52
|
+
# statistics of the data, checked or recomputable, not kept as metadata
|
|
53
|
+
_DERIVED_KEYS = {
|
|
54
|
+
"Time Min. (min)",
|
|
55
|
+
"Time Max. (min)",
|
|
56
|
+
"Data Points",
|
|
57
|
+
"Signal Min.",
|
|
58
|
+
"Signal Max.",
|
|
59
|
+
"Min. Step (s)",
|
|
60
|
+
"Max. Step (s)",
|
|
61
|
+
"Average Step (s)",
|
|
62
|
+
"Channel",
|
|
63
|
+
"Signal Unit",
|
|
64
|
+
"Injection Date",
|
|
65
|
+
}
|
|
66
|
+
_SIGNAL_SECTIONS = ("Chromatogram Data Information", "Signal Parameter Information")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _snake(label: str) -> str:
|
|
70
|
+
label = label.replace("µ", "u").replace("μ", "u").lower()
|
|
71
|
+
return re.sub(r"[^a-z0-9]+", "_", label).strip("_")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _read_text(path: Path) -> list[str]:
|
|
75
|
+
data = path.read_bytes()
|
|
76
|
+
try:
|
|
77
|
+
text = data.decode("utf-8-sig")
|
|
78
|
+
except UnicodeDecodeError:
|
|
79
|
+
text = data.decode("cp1252")
|
|
80
|
+
return text.splitlines()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _header(lines: list[str]) -> tuple[list[tuple[str, str, str]], int | None]:
|
|
84
|
+
"""Parse the ``key<TAB>value`` lines.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
entries : list of tuple
|
|
89
|
+
``(section, key, value)`` with stripped strings.
|
|
90
|
+
data_start : int or None
|
|
91
|
+
Index of the first data row, `None` without ``Chromatogram Data:``.
|
|
92
|
+
"""
|
|
93
|
+
entries = []
|
|
94
|
+
section = ""
|
|
95
|
+
for i, line in enumerate(lines):
|
|
96
|
+
stripped = line.strip()
|
|
97
|
+
if not stripped:
|
|
98
|
+
continue
|
|
99
|
+
if stripped.startswith(_DATA_MARKER):
|
|
100
|
+
return entries, i + 2 # skip the column header
|
|
101
|
+
if "\t" in stripped:
|
|
102
|
+
key, value = stripped.split("\t", 1)
|
|
103
|
+
entries.append((section, key.strip().rstrip(":"), value.strip()))
|
|
104
|
+
elif stripped.endswith(":"):
|
|
105
|
+
section = stripped[:-1]
|
|
106
|
+
return entries, None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _to_float(column: np.ndarray, comma_decimal: bool) -> np.ndarray:
|
|
110
|
+
"""Convert number strings (``1,234.5`` or ``1.234,5``, ``n.a.``) to float64."""
|
|
111
|
+
column = np.char.strip(column)
|
|
112
|
+
column = np.where(np.isin(column, ["n.a.", ""]), "nan", column)
|
|
113
|
+
if comma_decimal:
|
|
114
|
+
column = np.char.replace(np.char.replace(column, ".", ""), ",", ".")
|
|
115
|
+
else:
|
|
116
|
+
column = np.char.replace(column, ",", "")
|
|
117
|
+
return column.astype(np.float64)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _number(text: str | None, comma_decimal: bool) -> float | None:
|
|
121
|
+
if not text:
|
|
122
|
+
return None
|
|
123
|
+
try:
|
|
124
|
+
return float(_to_float(np.array([text]), comma_decimal)[0])
|
|
125
|
+
except ValueError:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
_DATE_TIME = re.compile(
|
|
130
|
+
r"^(\d{1,2})[/.-](\d{1,2})[/.-](\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})"
|
|
131
|
+
r"(?:\s*([AaPp][Mm]))?(?:\s*([+-])(\d{2}):?(\d{2}))?$"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _hour(hour: int, am_pm: str | None) -> int:
|
|
136
|
+
if not am_pm:
|
|
137
|
+
return hour
|
|
138
|
+
return hour % 12 + (12 if am_pm.upper() == "PM" else 0)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_inject_time(
|
|
142
|
+
text: str, date: str | None = None, dayfirst: bool = True
|
|
143
|
+
) -> datetime:
|
|
144
|
+
"""Parse the inject time of a Chromeleon text export.
|
|
145
|
+
|
|
146
|
+
The format follows the locale of the exporting computer. Day and month
|
|
147
|
+
order is read from the data when one of them is larger than 12; dates
|
|
148
|
+
with an AM/PM time are month first (US locale); otherwise `dayfirst`
|
|
149
|
+
decides. Exports of Chromeleon 7.2 store the date separately
|
|
150
|
+
(``Injection Date``).
|
|
151
|
+
|
|
152
|
+
Parameters
|
|
153
|
+
----------
|
|
154
|
+
text : str
|
|
155
|
+
``Inject Time`` (e.g. ``7/17/2023 3:35:22 PM +02:00``) or
|
|
156
|
+
``Injection Time`` (e.g. ``1:43:35 PM`` or ``10:30:07``).
|
|
157
|
+
date : str, optional
|
|
158
|
+
``Injection Date`` (``12/19/2023`` or ``22-Aug-25``), required when
|
|
159
|
+
`text` is only a time.
|
|
160
|
+
dayfirst : bool
|
|
161
|
+
Order for ambiguous dates such as ``7/1/2023``.
|
|
162
|
+
|
|
163
|
+
Returns
|
|
164
|
+
-------
|
|
165
|
+
datetime
|
|
166
|
+
Timezone-aware when the export has a UTC offset, naive otherwise.
|
|
167
|
+
|
|
168
|
+
Raises
|
|
169
|
+
------
|
|
170
|
+
ValueError
|
|
171
|
+
If the time cannot be parsed.
|
|
172
|
+
"""
|
|
173
|
+
text = text.strip()
|
|
174
|
+
match = _DATE_TIME.match(text)
|
|
175
|
+
if match:
|
|
176
|
+
first, second, year, hour, minute, sec, am_pm, sign, oh, om = match.groups()
|
|
177
|
+
first, second = int(first), int(second)
|
|
178
|
+
if first > 12:
|
|
179
|
+
day_first = True
|
|
180
|
+
elif second > 12 or am_pm:
|
|
181
|
+
# a 12-hour clock is the US locale, which writes the month first
|
|
182
|
+
day_first = False
|
|
183
|
+
else:
|
|
184
|
+
day_first = dayfirst
|
|
185
|
+
day, month = (first, second) if day_first else (second, first)
|
|
186
|
+
tz = None
|
|
187
|
+
if sign:
|
|
188
|
+
offset = timedelta(hours=int(oh), minutes=int(om))
|
|
189
|
+
tz = timezone(-offset if sign == "-" else offset)
|
|
190
|
+
return datetime(
|
|
191
|
+
int(year), month, day, _hour(int(hour), am_pm), int(minute), int(sec),
|
|
192
|
+
tzinfo=tz,
|
|
193
|
+
) # fmt: skip
|
|
194
|
+
|
|
195
|
+
if re.match(r"\d{1,2}:\d{2}:\d{2} (AM|PM)", text):
|
|
196
|
+
if not date:
|
|
197
|
+
raise ValueError("Injection Date is missing for an AM/PM time.")
|
|
198
|
+
return datetime.strptime(f"{date} {text}", "%m/%d/%Y %I:%M:%S %p")
|
|
199
|
+
if re.match(r"\d{2}:\d{2}:\d{2}$", text):
|
|
200
|
+
if not date:
|
|
201
|
+
raise ValueError("Injection Date is missing for a time without date.")
|
|
202
|
+
if re.match(r"\d{1,2}-[A-Za-z]{3}-\d{2}$", date):
|
|
203
|
+
return datetime.strptime(f"{date} {text}", "%d-%b-%y %H:%M:%S")
|
|
204
|
+
if re.match(r"\d{1,2}/\d{1,2}/\d{4}$", date):
|
|
205
|
+
return datetime.strptime(f"{date} {text}", "%m/%d/%Y %H:%M:%S")
|
|
206
|
+
raise ValueError(f"Unrecognised Injection Date format: {date}")
|
|
207
|
+
parsed = parse_iso_datetime(text)
|
|
208
|
+
if parsed is None:
|
|
209
|
+
raise ValueError(f"Unrecognised Inject Time format: {text}")
|
|
210
|
+
return parsed
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def read_chromeleon_txt(path: str | Path, *, dayfirst: bool = True) -> list[Injection]:
|
|
214
|
+
"""Read a Chromeleon text export of one chromatogram.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
path : str or Path
|
|
219
|
+
The `.txt` export.
|
|
220
|
+
dayfirst : bool
|
|
221
|
+
Day/month order for ambiguous dates (see `parse_inject_time`).
|
|
222
|
+
|
|
223
|
+
Returns
|
|
224
|
+
-------
|
|
225
|
+
list of Injection
|
|
226
|
+
One injection with one signal.
|
|
227
|
+
|
|
228
|
+
Raises
|
|
229
|
+
------
|
|
230
|
+
UnsupportedFormat
|
|
231
|
+
If the file is not a Chromeleon chromatogram export.
|
|
232
|
+
CorruptFile
|
|
233
|
+
If the data rows cannot be read.
|
|
234
|
+
"""
|
|
235
|
+
path = existing_file(path)
|
|
236
|
+
lines = _read_text(path)
|
|
237
|
+
entries, data_start = _header(lines)
|
|
238
|
+
if data_start is None:
|
|
239
|
+
raise UnsupportedFormat(f"{path.name}: no 'Chromatogram Data:' section.")
|
|
240
|
+
raw = {key: value for _, key, value in entries}
|
|
241
|
+
|
|
242
|
+
rows = [line.split("\t") for line in lines[data_start:] if line.strip()]
|
|
243
|
+
if any(len(row) < 3 for row in rows):
|
|
244
|
+
raise CorruptFile(f"{path.name}: data row with fewer than three columns.")
|
|
245
|
+
table = np.array([row[:3] for row in rows], dtype=str).reshape(-1, 3)
|
|
246
|
+
comma_decimal = bool(len(table)) and "," in table[0, 0] and "." not in table[0, 0]
|
|
247
|
+
try:
|
|
248
|
+
time = _to_float(table[:, 0], comma_decimal)
|
|
249
|
+
values = _to_float(table[:, 2], comma_decimal)
|
|
250
|
+
except ValueError as e:
|
|
251
|
+
raise CorruptFile(f"{path.name}: unreadable data row: {e}") from None
|
|
252
|
+
|
|
253
|
+
injection_metadata: dict = {}
|
|
254
|
+
signal_metadata: dict = {}
|
|
255
|
+
for section, key, value in entries:
|
|
256
|
+
if key in _DERIVED_KEYS or key in _NAME_KEYS or key in _TIME_KEYS:
|
|
257
|
+
continue
|
|
258
|
+
if key in _INJECTION_KEYS:
|
|
259
|
+
injection_metadata[_INJECTION_KEYS[key]] = value
|
|
260
|
+
elif key in _SIGNAL_KEYS or section in _SIGNAL_SECTIONS:
|
|
261
|
+
signal_metadata[_SIGNAL_KEYS.get(key, _snake(key))] = value
|
|
262
|
+
else:
|
|
263
|
+
injection_metadata[_snake(key)] = value
|
|
264
|
+
url = injection_metadata.get("url", "")
|
|
265
|
+
if url.startswith("chrom://"):
|
|
266
|
+
signal_metadata["url"] = url
|
|
267
|
+
|
|
268
|
+
inject_time = None
|
|
269
|
+
time_key = next((key for key in _TIME_KEYS if raw.get(key)), None)
|
|
270
|
+
if time_key is None:
|
|
271
|
+
warn(injection_metadata, f"{path.name}: no Inject Time.")
|
|
272
|
+
else:
|
|
273
|
+
try:
|
|
274
|
+
inject_time = parse_inject_time(
|
|
275
|
+
raw[time_key], raw.get("Injection Date"), dayfirst
|
|
276
|
+
)
|
|
277
|
+
except ValueError as e:
|
|
278
|
+
injection_metadata["chromeleon_inject_time"] = raw[time_key]
|
|
279
|
+
warn(injection_metadata, f"{path.name}: {e}")
|
|
280
|
+
|
|
281
|
+
expected = _number(raw.get("Data Points"), False)
|
|
282
|
+
if expected is not None and expected != len(time):
|
|
283
|
+
warn(
|
|
284
|
+
signal_metadata,
|
|
285
|
+
f"{path.name}: {len(time)} data rows, the header lists {int(expected)}.",
|
|
286
|
+
)
|
|
287
|
+
steps = [
|
|
288
|
+
_number(raw.get(key), comma_decimal)
|
|
289
|
+
for key in ("Min. Step (s)", "Max. Step (s)", "Average Step (s)")
|
|
290
|
+
]
|
|
291
|
+
interval = steps[2] if steps[0] is not None and len(set(steps)) == 1 else None
|
|
292
|
+
|
|
293
|
+
signal = Signal(
|
|
294
|
+
channel=raw.get("Channel", ""),
|
|
295
|
+
time=time,
|
|
296
|
+
values=values,
|
|
297
|
+
unit=raw.get("Signal Unit") or None,
|
|
298
|
+
metadata=clean(signal_metadata),
|
|
299
|
+
sampling_interval=interval,
|
|
300
|
+
)
|
|
301
|
+
name = next((raw[key] for key in _NAME_KEYS if key in raw), "")
|
|
302
|
+
return [
|
|
303
|
+
Injection(
|
|
304
|
+
name=name,
|
|
305
|
+
inject_time=inject_time,
|
|
306
|
+
signals={signal.channel: signal},
|
|
307
|
+
metadata=clean(injection_metadata),
|
|
308
|
+
)
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def sniff_chromeleon_txt(path: str | Path) -> bool:
|
|
313
|
+
"""Whether `path` looks like a Chromeleon chromatogram text export."""
|
|
314
|
+
if not Path(path).is_file():
|
|
315
|
+
return False
|
|
316
|
+
text = head(path, 1 << 16).decode("utf-8", errors="replace")
|
|
317
|
+
return (
|
|
318
|
+
"\nChromatogram Data:" in text or "\nChromatogram Data Information:" in text
|
|
319
|
+
) and "Injection Information:" in text
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Shimadzu GCMSsolution: `.qgd` (GC-MS) and `.gcd` (GC) files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .gcd import decode_gcd, read_gcd, sniff_gcd
|
|
6
|
+
from .qgd import decode_qgd, read_qgd, sniff_qgd
|
|
7
|
+
|
|
8
|
+
__all__ = ["decode_gcd", "decode_qgd", "read_gcd", "read_qgd", "sniff_gcd", "sniff_qgd"]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Shimadzu GCMSsolution GC files (`.gcd`, storages ``GC Raw Data-<n>``).
|
|
2
|
+
|
|
3
|
+
Each storage holds one detector channel: ``Intensity Data`` (float64 per
|
|
4
|
+
sample), ``Intensity Data Flag`` (one byte per sample) and ``Status``
|
|
5
|
+
(sampling interval, start, number of samples). LabSolutions GC files store
|
|
6
|
+
``LSS Raw Data`` instead; they are recognised and raise `UnsupportedVariant`.
|
|
7
|
+
See `docs/formats/shimadzu.md`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import struct
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
from ...errors import CorruptFile, UnsupportedFormat, UnsupportedVariant, warn
|
|
18
|
+
from ...model import Injection, Signal
|
|
19
|
+
from .ole import (
|
|
20
|
+
GCD_PREFIX,
|
|
21
|
+
LSS_STORAGE,
|
|
22
|
+
file_properties,
|
|
23
|
+
open_ole,
|
|
24
|
+
read_stream,
|
|
25
|
+
storages,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
FLAG_VALUE = 0x80 # the only flag value seen; its meaning is unknown
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def decode_gcd(
|
|
32
|
+
streams: dict[str, bytes], channel: str = "1", name: str = "gcd"
|
|
33
|
+
) -> Signal:
|
|
34
|
+
"""Decode the streams of one ``GC Raw Data-<n>`` storage.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
streams : dict of str to bytes
|
|
39
|
+
``"Intensity Data"``, ``"Status"`` and optionally
|
|
40
|
+
``"Intensity Data Flag"``.
|
|
41
|
+
channel : str
|
|
42
|
+
Channel name.
|
|
43
|
+
name : str
|
|
44
|
+
File name for messages.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
Signal
|
|
49
|
+
Values as stored (float64), unit unknown.
|
|
50
|
+
|
|
51
|
+
Raises
|
|
52
|
+
------
|
|
53
|
+
CorruptFile
|
|
54
|
+
If the status is invalid.
|
|
55
|
+
"""
|
|
56
|
+
status = streams["Status"]
|
|
57
|
+
if len(status) < 12:
|
|
58
|
+
raise CorruptFile(f"{name}: Status of channel {channel} is truncated.")
|
|
59
|
+
interval_ms, start_ms, n = struct.unpack_from("<iii", status)
|
|
60
|
+
if interval_ms <= 0:
|
|
61
|
+
raise CorruptFile(f"{name}: invalid sampling interval {interval_ms} ms.")
|
|
62
|
+
data = streams["Intensity Data"]
|
|
63
|
+
if len(data) % 8:
|
|
64
|
+
raise CorruptFile(f"{name}: Intensity Data is not a float64 array.")
|
|
65
|
+
values = np.frombuffer(data, "<f8").astype(np.float64)
|
|
66
|
+
metadata: dict = {"shimadzu_storage": f"{GCD_PREFIX}{channel}"}
|
|
67
|
+
if n != len(values):
|
|
68
|
+
warn(metadata, f"{name}: {len(values)} samples stored, {n} expected.")
|
|
69
|
+
flags = streams.get("Intensity Data Flag")
|
|
70
|
+
if flags is not None and np.any(np.frombuffer(flags, np.uint8) != FLAG_VALUE):
|
|
71
|
+
warn(metadata, f"{name}: intensity flags of unknown meaning are not read.")
|
|
72
|
+
if start_ms:
|
|
73
|
+
metadata["shimadzu_start_ms"] = start_ms
|
|
74
|
+
time = (start_ms + interval_ms * np.arange(len(values), dtype=np.float64)) / 60000.0
|
|
75
|
+
return Signal(
|
|
76
|
+
channel=channel,
|
|
77
|
+
time=time,
|
|
78
|
+
values=values,
|
|
79
|
+
metadata=metadata,
|
|
80
|
+
sampling_interval=interval_ms / 1000.0,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def read_gcd(path: str | Path) -> list[Injection]:
|
|
85
|
+
"""Read the chromatograms of a Shimadzu GCMSsolution `.gcd` file.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
path : str or Path
|
|
90
|
+
The `.gcd` file.
|
|
91
|
+
|
|
92
|
+
Returns
|
|
93
|
+
-------
|
|
94
|
+
list of Injection
|
|
95
|
+
One injection with one signal per ``GC Raw Data-<n>`` storage, named
|
|
96
|
+
``<n>``; empty storages (channels without data) are skipped. `inject_time` is `None`; the acquisition start (UTC) is in
|
|
97
|
+
`Signal.acquisition_start`.
|
|
98
|
+
|
|
99
|
+
Raises
|
|
100
|
+
------
|
|
101
|
+
UnsupportedVariant
|
|
102
|
+
For LabSolutions files (``LSS Raw Data``).
|
|
103
|
+
UnsupportedFormat
|
|
104
|
+
If the file holds no GC raw data.
|
|
105
|
+
"""
|
|
106
|
+
path = Path(path)
|
|
107
|
+
with open_ole(path) as ole:
|
|
108
|
+
names = sorted(
|
|
109
|
+
{e[0] for e in ole.listdir(storages=True) if e[0].startswith(GCD_PREFIX)}
|
|
110
|
+
)
|
|
111
|
+
if not names:
|
|
112
|
+
if ole.exists(LSS_STORAGE):
|
|
113
|
+
raise UnsupportedVariant(
|
|
114
|
+
f"{path.name} is a LabSolutions GC file ({LSS_STORAGE}), which "
|
|
115
|
+
"is not supported yet. Please share a sample file."
|
|
116
|
+
)
|
|
117
|
+
raise UnsupportedFormat(f"{path.name} contains no GC raw data.")
|
|
118
|
+
start, metadata = file_properties(ole)
|
|
119
|
+
signals = {}
|
|
120
|
+
for storage in names:
|
|
121
|
+
streams = {
|
|
122
|
+
stream: read_stream(ole, f"{storage}/{stream}")
|
|
123
|
+
for stream in ("Intensity Data", "Status", "Intensity Data Flag")
|
|
124
|
+
if ole.exists(f"{storage}/{stream}")
|
|
125
|
+
}
|
|
126
|
+
channel = storage[len(GCD_PREFIX) :]
|
|
127
|
+
if not streams:
|
|
128
|
+
continue # an empty storage: a configured channel without data
|
|
129
|
+
if {"Intensity Data", "Status"} - streams.keys():
|
|
130
|
+
raise CorruptFile(f"{path.name}: {storage} is incomplete.")
|
|
131
|
+
signal = decode_gcd(streams, channel, path.name)
|
|
132
|
+
signal.acquisition_start = start
|
|
133
|
+
signals[channel] = signal
|
|
134
|
+
if not signals:
|
|
135
|
+
raise UnsupportedFormat(f"{path.name}: all GC raw data storages are empty.")
|
|
136
|
+
return [
|
|
137
|
+
Injection(
|
|
138
|
+
name=metadata.get("sample_name", ""), signals=signals, metadata=metadata
|
|
139
|
+
)
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def sniff_gcd(path: str | Path) -> bool:
|
|
144
|
+
"""Whether `path` is an OLE2 file with GC raw data (GCMSsolution or LabSolutions)."""
|
|
145
|
+
names = storages(path)
|
|
146
|
+
return LSS_STORAGE in names or any(n.startswith(GCD_PREFIX) for n in names)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""OLE2 compound documents written by Shimadzu GCMSsolution.
|
|
2
|
+
|
|
3
|
+
Both `.qgd` (GC-MS) and `.gcd` (GC) files are OLE2 compound documents
|
|
4
|
+
("structured storage"), read with `olefile`. The ``File Property`` stream
|
|
5
|
+
holds the acquisition start and the sample name.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import struct
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import olefile
|
|
17
|
+
|
|
18
|
+
from ..._util import existing_file, filetime, head
|
|
19
|
+
from ...errors import UnsupportedFormat
|
|
20
|
+
|
|
21
|
+
OLE_MAGIC = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
|
22
|
+
QGD_STORAGE = "GCMS Raw Data"
|
|
23
|
+
GCD_PREFIX = "GC Raw Data-"
|
|
24
|
+
LSS_STORAGE = "LSS Raw Data"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_ole_file(path: str | Path) -> bool:
|
|
28
|
+
"""Whether `path` is an OLE2 compound document."""
|
|
29
|
+
return Path(path).is_file() and head(path, 8) == OLE_MAGIC
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@contextmanager
|
|
33
|
+
def open_ole(path: str | Path):
|
|
34
|
+
"""Open an OLE2 file, raising `UnsupportedFormat` if it is none."""
|
|
35
|
+
path = existing_file(path)
|
|
36
|
+
try:
|
|
37
|
+
ole = olefile.OleFileIO(str(path))
|
|
38
|
+
except OSError as e: # olefile raises OSError for non-OLE files
|
|
39
|
+
raise UnsupportedFormat(f"{path.name} is not an OLE2 file: {e}") from e
|
|
40
|
+
with ole:
|
|
41
|
+
yield ole
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def storages(path: str | Path) -> set[str]:
|
|
45
|
+
"""Names of the top-level storages and streams; empty for non-OLE files."""
|
|
46
|
+
if not is_ole_file(path):
|
|
47
|
+
return set()
|
|
48
|
+
try:
|
|
49
|
+
with open_ole(path) as ole:
|
|
50
|
+
return {entry[0] for entry in ole.listdir(streams=True, storages=True)}
|
|
51
|
+
except (UnsupportedFormat, OSError):
|
|
52
|
+
return set()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def read_stream(ole: olefile.OleFileIO, name: str) -> bytes:
|
|
56
|
+
"""Read a stream, given as ``storage/stream``."""
|
|
57
|
+
return ole.openstream(name).read()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def file_properties(ole: olefile.OleFileIO) -> tuple[datetime | None, dict[str, Any]]:
|
|
61
|
+
"""Read the ``File Property`` stream.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
acquisition_start : datetime or None
|
|
66
|
+
Windows FILETIME (UTC) at byte 52, equal to the creation time of the
|
|
67
|
+
raw data storage.
|
|
68
|
+
metadata : dict
|
|
69
|
+
``sample_name`` (null-terminated, cp1252, at byte 204), if present.
|
|
70
|
+
"""
|
|
71
|
+
if not ole.exists("File Property"):
|
|
72
|
+
return None, {}
|
|
73
|
+
data = read_stream(ole, "File Property")
|
|
74
|
+
start = None
|
|
75
|
+
metadata = {}
|
|
76
|
+
if len(data) >= 60:
|
|
77
|
+
(ticks,) = struct.unpack_from("<Q", data, 52)
|
|
78
|
+
if ticks:
|
|
79
|
+
start = filetime(ticks)
|
|
80
|
+
if len(data) > 204:
|
|
81
|
+
sample = data[204:300].split(b"\0", 1)[0].decode("cp1252", "replace").strip()
|
|
82
|
+
if sample:
|
|
83
|
+
metadata["sample_name"] = sample
|
|
84
|
+
return start, metadata
|