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