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