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/model.py ADDED
@@ -0,0 +1,254 @@
1
+ """Data model returned by all readers: `Injection`, `Signal` and `MSData`.
2
+
3
+ See `docs/model.md` for the reader contract. Metadata values are JSON types
4
+ (str, int, float, bool, lists and dicts of them); timestamps in metadata are
5
+ ISO 8601 strings. Unknown values are left out rather than stored as `None` or
6
+ `""`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field, fields
12
+ from datetime import datetime
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ SPECTRUM_TYPES = ("centroid", "profile")
18
+
19
+ #: Bit of `MSData.flags` set for peaks recorded with a saturated detector.
20
+ SATURATED = 1
21
+
22
+
23
+ def _equal(a: Any, b: Any) -> bool:
24
+ """Compare values; arrays must agree in dtype, shape and content."""
25
+ if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
26
+ if not (isinstance(a, np.ndarray) and isinstance(b, np.ndarray)):
27
+ return False
28
+ return (
29
+ a.dtype == b.dtype
30
+ and a.shape == b.shape
31
+ and bool(np.array_equal(a, b, equal_nan=a.dtype.kind in "fc"))
32
+ )
33
+ if isinstance(a, dict) and isinstance(b, dict):
34
+ return a.keys() == b.keys() and all(_equal(a[k], b[k]) for k in a)
35
+ if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)):
36
+ return len(a) == len(b) and all(map(_equal, a, b))
37
+ return bool(a == b)
38
+
39
+
40
+ class _ArrayEq:
41
+ """Dataclass mixin: `==` compares all fields, arrays with `_equal`."""
42
+
43
+ def __eq__(self, other: object) -> bool:
44
+ if type(other) is not type(self):
45
+ return NotImplemented
46
+ return all(
47
+ _equal(getattr(self, f.name), getattr(other, f.name))
48
+ for f in fields(self) # type: ignore[arg-type]
49
+ )
50
+
51
+ __hash__ = None # type: ignore[assignment]
52
+
53
+
54
+ @dataclass(eq=False)
55
+ class Signal(_ArrayEq):
56
+ """One detector trace of one injection (FID, TCD, single-wavelength UV, ...).
57
+
58
+ Attributes
59
+ ----------
60
+ channel : str
61
+ Channel name, e.g. ``"FID_left"``.
62
+ time : numpy.ndarray
63
+ Retention time in minutes (float64).
64
+ values : numpy.ndarray
65
+ Signal values in `unit`, with the dtype the file stores.
66
+ unit : str or None
67
+ Signal unit, e.g. ``"pA"``; `None` when the file does not say.
68
+ acquisition_start : datetime or None
69
+ Start of the data acquisition (timezone-aware, UTC).
70
+ metadata : dict
71
+ Further metadata, e.g. ``detector``, ``driver``.
72
+ sampling_interval : float or None
73
+ Time between two points in seconds, for regularly sampled signals.
74
+ """
75
+
76
+ channel: str
77
+ time: np.ndarray
78
+ values: np.ndarray
79
+ unit: str | None = None
80
+ acquisition_start: datetime | None = None
81
+ metadata: dict[str, Any] = field(default_factory=dict)
82
+ sampling_interval: float | None = None
83
+
84
+ def __len__(self) -> int:
85
+ return len(self.time)
86
+
87
+
88
+ @dataclass(eq=False)
89
+ class MSData(_ArrayEq):
90
+ """Mass spectra of one injection, stored back to back (CSR layout).
91
+
92
+ The peaks of the scan at index ``i`` are
93
+ ``mz[peak_offset[i]:peak_offset[i + 1]]`` (likewise `intensity` and
94
+ `flags`). Use `spectrum` for one scan and `xic` for extracted-ion
95
+ chromatograms.
96
+
97
+ Attributes
98
+ ----------
99
+ name : str
100
+ Name of the MS data, e.g. ``"MSDevice"``.
101
+ scan_number : numpy.ndarray
102
+ Scan numbers (int32) as the file stores them (Thermo counts from 1,
103
+ Shimadzu from 0).
104
+ retention_time : numpy.ndarray
105
+ Retention time of each scan in minutes (float64).
106
+ tic : numpy.ndarray
107
+ Total ion current of each scan (float64).
108
+ base_peak_mz, base_peak_intensity : numpy.ndarray or None
109
+ m/z and intensity of the most intense peak of each scan; `None` when
110
+ the file has no such field. When computed by the reader, their names
111
+ are listed in ``metadata["computed"]``.
112
+ filters : list of str or None
113
+ Scan filter of each scan, e.g. ``"+ c EI Full ms [33.00-500.00]"``.
114
+ mz : numpy.ndarray
115
+ m/z of all peaks, as stored (float32 for Thermo, float64 for Shimadzu).
116
+ intensity : numpy.ndarray
117
+ Intensity of all peaks, as stored (float32 for Thermo, uint32 for
118
+ Shimadzu).
119
+ peak_offset : numpy.ndarray
120
+ Start of each scan's peaks, plus the total number of peaks (int64,
121
+ one more entry than scans).
122
+ acquisition_start : datetime or None
123
+ Start of the MS acquisition (timezone-aware, UTC).
124
+ metadata : dict
125
+ Further metadata, e.g. ``instrument_model``, ``ionisation``.
126
+ ms_level : numpy.ndarray or None
127
+ MS level of each scan (uint8).
128
+ flags : numpy.ndarray or None
129
+ Per-peak flags (uint8); bit 0 (`SATURATED`) marks a saturated detector.
130
+ spectrum_type : str
131
+ ``"centroid"`` or ``"profile"``.
132
+ """
133
+
134
+ name: str
135
+ scan_number: np.ndarray
136
+ retention_time: np.ndarray
137
+ tic: np.ndarray
138
+ base_peak_mz: np.ndarray | None
139
+ base_peak_intensity: np.ndarray | None
140
+ filters: list[str] | None
141
+ mz: np.ndarray
142
+ intensity: np.ndarray
143
+ peak_offset: np.ndarray
144
+ acquisition_start: datetime | None = None
145
+ metadata: dict[str, Any] = field(default_factory=dict)
146
+ ms_level: np.ndarray | None = None
147
+ flags: np.ndarray | None = None
148
+ spectrum_type: str = "centroid"
149
+
150
+ def __post_init__(self) -> None:
151
+ n = len(self.scan_number)
152
+ per_scan = {
153
+ "retention_time": self.retention_time,
154
+ "tic": self.tic,
155
+ "base_peak_mz": self.base_peak_mz,
156
+ "base_peak_intensity": self.base_peak_intensity,
157
+ "filters": self.filters,
158
+ "ms_level": self.ms_level,
159
+ }
160
+ for name, values in per_scan.items():
161
+ if values is not None and len(values) != n:
162
+ raise ValueError(f"MSData.{name} has {len(values)} entries, not {n}.")
163
+ if len(self.peak_offset) != n + 1:
164
+ raise ValueError("MSData.peak_offset must have one entry more than scans.")
165
+ n_peaks = int(self.peak_offset[-1])
166
+ for name in ("mz", "intensity", "flags"):
167
+ values = getattr(self, name)
168
+ if values is not None and len(values) != n_peaks:
169
+ raise ValueError(
170
+ f"MSData.{name} has {len(values)} peaks, peak_offset says {n_peaks}."
171
+ )
172
+ if self.spectrum_type not in SPECTRUM_TYPES:
173
+ raise ValueError(f"Unknown spectrum type {self.spectrum_type!r}.")
174
+
175
+ def __len__(self) -> int:
176
+ return len(self.scan_number)
177
+
178
+ def spectrum(self, scan_number: int) -> tuple[np.ndarray, np.ndarray]:
179
+ """Return the m/z and intensity arrays of one scan.
180
+
181
+ Parameters
182
+ ----------
183
+ scan_number : int
184
+ Scan number as stored in `scan_number`.
185
+
186
+ Returns
187
+ -------
188
+ mz, intensity : numpy.ndarray
189
+ Views into `mz` and `intensity`.
190
+
191
+ Raises
192
+ ------
193
+ KeyError
194
+ If there is no scan with this number.
195
+ """
196
+ index = int(np.searchsorted(self.scan_number, scan_number))
197
+ if index == len(self) or self.scan_number[index] != scan_number:
198
+ raise KeyError(f"No scan {scan_number}.")
199
+ peaks = slice(self.peak_offset[index], self.peak_offset[index + 1])
200
+ return self.mz[peaks], self.intensity[peaks]
201
+
202
+ def xic(self, mz: float, tolerance: float = 0.5) -> np.ndarray:
203
+ """Extracted-ion chromatogram.
204
+
205
+ Sums the intensity of each scan within
206
+ ``mz - tolerance <= m/z <= mz + tolerance``.
207
+
208
+ Parameters
209
+ ----------
210
+ mz : float
211
+ Centre of the m/z window.
212
+ tolerance : float
213
+ Half width of the window.
214
+
215
+ Returns
216
+ -------
217
+ numpy.ndarray
218
+ One intensity (float64) per scan, to plot against `retention_time`.
219
+ """
220
+ selected = (self.mz >= mz - tolerance) & (self.mz <= mz + tolerance)
221
+ scan_index = np.repeat(np.arange(len(self)), np.diff(self.peak_offset))
222
+ return np.bincount(
223
+ scan_index[selected],
224
+ weights=self.intensity[selected].astype(np.float64),
225
+ minlength=len(self),
226
+ )
227
+
228
+
229
+ @dataclass(eq=False)
230
+ class Injection(_ArrayEq):
231
+ """One injection with its signals and MS data.
232
+
233
+ Attributes
234
+ ----------
235
+ name : str
236
+ Injection name (``""`` when unnamed).
237
+ inject_time : datetime or None
238
+ Injection time as local time of the instrument: timezone-aware when the
239
+ file stores the UTC offset, naive when it stores only the wall time
240
+ (Agilent ``.ch``, some Chromeleon text exports), `None` when unknown.
241
+ signals : dict of str to Signal
242
+ Signals by channel name.
243
+ ms : dict of str to MSData
244
+ MS data by name.
245
+ metadata : dict
246
+ Further metadata, e.g. ``sample_name``, ``sequence``,
247
+ ``instrument_method``.
248
+ """
249
+
250
+ name: str
251
+ inject_time: datetime | None = None
252
+ signals: dict[str, Signal] = field(default_factory=dict)
253
+ ms: dict[str, MSData] = field(default_factory=dict)
254
+ metadata: dict[str, Any] = field(default_factory=dict)
chromparser/py.typed ADDED
File without changes
chromparser/sniff.py ADDED
@@ -0,0 +1,80 @@
1
+ """Detect the format of a file from its content.
2
+
3
+ Magic bytes, OLE2 storages, zip members and the content of `.D` folders
4
+ decide; the file extension is only used in error messages.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from ._util import head
12
+ from .errors import UnsupportedFormat
13
+
14
+ HDF5 = "hdf5"
15
+
16
+ _HINTS = {
17
+ ".cmbx": "chromeleon-cmbx",
18
+ ".dx": "agilent-dx",
19
+ ".raw": "chromeleon-raw or thermo-raw",
20
+ ".qgd": "shimadzu-qgd",
21
+ ".gcd": "shimadzu-gcd",
22
+ ".d": "agilent-d",
23
+ ".ch": "agilent-ch",
24
+ ".txt": "chromeleon-txt",
25
+ ".cdf": "andi-ms or andi-chrom",
26
+ ".h5": HDF5,
27
+ ".hdf5": HDF5,
28
+ }
29
+
30
+ _RECOGNISED_UNSUPPORTED = {
31
+ b"CpAu": "a Chromeleon audit trail (CpAu), which holds no chromatogram",
32
+ b"CDF\x01": "a netCDF file without ANDI MS or chromatography data",
33
+ b"CDF\x02": "a netCDF file without ANDI MS or chromatography data",
34
+ b"CDF\x05": "a CDF-5 netCDF file, which chromparser does not read",
35
+ }
36
+
37
+
38
+ def sniff(path: str | Path) -> str:
39
+ """Return the format id of a file or folder.
40
+
41
+ Parameters
42
+ ----------
43
+ path : str or Path
44
+ File or folder (Agilent `.D`).
45
+
46
+ Returns
47
+ -------
48
+ str
49
+ One of the ids in `chromparser.vendors.FORMATS` (e.g.
50
+ ``"shimadzu-qgd"``), or ``"hdf5"`` for files written by chromparser
51
+ or cmbx-reader.
52
+
53
+ Raises
54
+ ------
55
+ FileNotFoundError
56
+ If `path` does not exist.
57
+ UnsupportedFormat
58
+ If the content is not a supported format.
59
+ """
60
+ from .hdf5 import sniff_hdf5
61
+ from .vendors import FORMATS
62
+
63
+ path = Path(path)
64
+ if not path.exists():
65
+ raise FileNotFoundError(f"File not found: {path}")
66
+ if sniff_hdf5(path):
67
+ return HDF5
68
+ for fmt in FORMATS:
69
+ if fmt.sniff(path):
70
+ return fmt.id
71
+ if path.is_file():
72
+ start = head(path, 8)
73
+ for magic, what in _RECOGNISED_UNSUPPORTED.items():
74
+ if start.startswith(magic):
75
+ raise UnsupportedFormat(f"{path.name} is {what}.")
76
+ hint = _HINTS.get(path.suffix.lower())
77
+ detail = f" (the extension suggests {hint})" if hint else ""
78
+ raise UnsupportedFormat(
79
+ f"{path.name} is not a chromatography file chromparser can read{detail}."
80
+ )
@@ -0,0 +1,55 @@
1
+ """Readers of the vendor formats and the registry used by `chromparser.read`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from ..model import Injection
10
+ from .agilent import (
11
+ read_agilent_ch,
12
+ read_agilent_d,
13
+ read_agilent_dx,
14
+ sniff_agilent_ch,
15
+ sniff_agilent_d,
16
+ sniff_agilent_dx,
17
+ )
18
+ from .andi import read_andi_chrom, read_andi_ms, sniff_andi_chrom, sniff_andi_ms
19
+ from .chromeleon import (
20
+ read_chromeleon_raw,
21
+ read_chromeleon_txt,
22
+ read_cmbx,
23
+ sniff_chromeleon_raw,
24
+ sniff_chromeleon_txt,
25
+ sniff_cmbx,
26
+ )
27
+ from .shimadzu import read_gcd, read_qgd, sniff_gcd, sniff_qgd
28
+ from .thermo import read_thermo_raw, sniff_thermo_raw
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Format:
33
+ """A file format: id, description, content sniffer and reader."""
34
+
35
+ id: str
36
+ description: str
37
+ sniff: Callable[[str | Path], bool]
38
+ read: Callable[..., list[Injection]]
39
+
40
+
41
+ #: Vendor formats in the order they are tried by `sniff`: formats with a
42
+ #: specific signature first, the heuristic ones (Agilent `.ch`, text) last.
43
+ FORMATS = [
44
+ Format("chromeleon-cmbx", "Chromeleon 7 archive (.cmbx)", sniff_cmbx, read_cmbx),
45
+ Format("agilent-dx", "Agilent OpenLab archive (.dx)", sniff_agilent_dx, read_agilent_dx),
46
+ Format("chromeleon-raw", "Chromeleon 7 raw signal file (.raw)", sniff_chromeleon_raw, read_chromeleon_raw),
47
+ Format("thermo-raw", "Thermo Finnigan MS file (.raw)", sniff_thermo_raw, read_thermo_raw),
48
+ Format("shimadzu-qgd", "Shimadzu GCMSsolution GC-MS file (.qgd)", sniff_qgd, read_qgd),
49
+ Format("shimadzu-gcd", "Shimadzu GC file (.gcd)", sniff_gcd, read_gcd),
50
+ Format("andi-ms", "ANDI-MS netCDF GC-MS file (.cdf)", sniff_andi_ms, read_andi_ms),
51
+ Format("andi-chrom", "ANDI chromatography (AIA) netCDF file (.cdf)", sniff_andi_chrom, read_andi_chrom),
52
+ Format("agilent-d", "Agilent data folder (.D)", sniff_agilent_d, read_agilent_d),
53
+ Format("agilent-ch", "Agilent chromatogram (.ch)", sniff_agilent_ch, read_agilent_ch),
54
+ Format("chromeleon-txt", "Chromeleon text export (.txt)", sniff_chromeleon_txt, read_chromeleon_txt),
55
+ ] # fmt: skip
@@ -0,0 +1,17 @@
1
+ """Agilent ChemStation / OpenLab: `.ch` files, `.D` folders and `.dx` archives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .ch import decode_ch, read_agilent_ch, sniff_agilent_ch
6
+ from .dot_d import read_agilent_d, sniff_agilent_d
7
+ from .dx import read_agilent_dx, sniff_agilent_dx
8
+
9
+ __all__ = [
10
+ "decode_ch",
11
+ "read_agilent_ch",
12
+ "read_agilent_d",
13
+ "read_agilent_dx",
14
+ "sniff_agilent_ch",
15
+ "sniff_agilent_d",
16
+ "sniff_agilent_dx",
17
+ ]