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
chromparser/cli.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Command line interface: ``chromparser convert | info | extract-ms``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from . import __version__, convert, read, sniff, write_andi
|
|
12
|
+
from .errors import ChromparserError
|
|
13
|
+
from .model import Injection, MSData, Signal
|
|
14
|
+
from .vendors.chromeleon import extract_ms_raw
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _interval(signal: Signal) -> str:
|
|
18
|
+
if signal.sampling_interval is None:
|
|
19
|
+
return "irregular" if len(signal) > 1 else ""
|
|
20
|
+
return f"every {signal.sampling_interval:g} s"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _signal_line(signal: Signal) -> str:
|
|
24
|
+
parts = [f"{len(signal)} points", signal.unit or "unit unknown"]
|
|
25
|
+
if len(signal):
|
|
26
|
+
parts.append(f"{signal.time[0]:g}-{signal.time[-1]:g} min")
|
|
27
|
+
parts.append(_interval(signal))
|
|
28
|
+
parts.append(str(signal.values.dtype))
|
|
29
|
+
return f" signal {signal.channel!r}: " + ", ".join(p for p in parts if p)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _ms_line(ms: MSData) -> str:
|
|
33
|
+
parts = [f"{len(ms)} scans", f"{int(ms.peak_offset[-1])} peaks", ms.spectrum_type]
|
|
34
|
+
if len(ms):
|
|
35
|
+
parts.append(f"{ms.retention_time[0]:g}-{ms.retention_time[-1]:g} min")
|
|
36
|
+
if len(ms.mz):
|
|
37
|
+
parts.append(f"m/z {float(np.min(ms.mz)):g}-{float(np.max(ms.mz)):g}")
|
|
38
|
+
parts.append(f"m/z {ms.mz.dtype}, intensity {ms.intensity.dtype}")
|
|
39
|
+
if ms.flags is not None:
|
|
40
|
+
parts.append(f"{int(np.count_nonzero(ms.flags))} flagged peaks")
|
|
41
|
+
return f" ms {ms.name!r}: " + ", ".join(parts)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def describe(path: Path, fmt: str, injections: list[Injection]) -> str:
|
|
45
|
+
"""Return the text printed by ``chromparser info``."""
|
|
46
|
+
lines = [f"{path} ({fmt}): {len(injections)} injection(s)"]
|
|
47
|
+
for number, injection in enumerate(injections, start=1):
|
|
48
|
+
head = f"injection {number}"
|
|
49
|
+
if injection.name:
|
|
50
|
+
head += f" {injection.name!r}"
|
|
51
|
+
if injection.inject_time is not None:
|
|
52
|
+
head += f", injected {injection.inject_time.isoformat()}"
|
|
53
|
+
lines.append(head)
|
|
54
|
+
lines.extend(_signal_line(s) for s in injection.signals.values())
|
|
55
|
+
lines.extend(_ms_line(ms) for ms in injection.ms.values())
|
|
56
|
+
for warning in injection.metadata.get("warnings", []):
|
|
57
|
+
lines.append(f" warning: {warning}")
|
|
58
|
+
return "\n".join(lines)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _convert_one(args, source: Path, options: dict) -> list[Path]:
|
|
62
|
+
if args.to == "andi":
|
|
63
|
+
folder = args.output or source.parent
|
|
64
|
+
return write_andi(read(source, **options), folder, overwrite=args.overwrite)
|
|
65
|
+
output = convert(
|
|
66
|
+
source,
|
|
67
|
+
args.output,
|
|
68
|
+
overwrite=args.overwrite,
|
|
69
|
+
compression=not args.no_compression,
|
|
70
|
+
**options,
|
|
71
|
+
)
|
|
72
|
+
return [output]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _convert(args) -> int:
|
|
76
|
+
if args.to == "hdf5" and args.output and len(args.inputs) > 1:
|
|
77
|
+
print("chromparser: --output needs a single input", file=sys.stderr)
|
|
78
|
+
return 2
|
|
79
|
+
options = {"ms": not args.no_ms}
|
|
80
|
+
if args.signal_unit:
|
|
81
|
+
options["signal_unit"] = args.signal_unit
|
|
82
|
+
status = 0
|
|
83
|
+
for source in args.inputs:
|
|
84
|
+
try:
|
|
85
|
+
outputs = _convert_one(args, source, options)
|
|
86
|
+
except (OSError, ChromparserError, ValueError) as e:
|
|
87
|
+
print(f"chromparser: {source}: {e}", file=sys.stderr)
|
|
88
|
+
status = 1
|
|
89
|
+
continue
|
|
90
|
+
if not outputs:
|
|
91
|
+
print(f"chromparser: {source}: no signals or MS data", file=sys.stderr)
|
|
92
|
+
for output in outputs:
|
|
93
|
+
print(f"{source} -> {output}")
|
|
94
|
+
return status
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _info(args) -> int:
|
|
98
|
+
status = 0
|
|
99
|
+
for source in args.inputs:
|
|
100
|
+
try:
|
|
101
|
+
fmt = sniff(source)
|
|
102
|
+
injections = read(source, ms=not args.no_ms)
|
|
103
|
+
except (OSError, ChromparserError, ValueError) as e:
|
|
104
|
+
print(f"chromparser: {source}: {e}", file=sys.stderr)
|
|
105
|
+
status = 1
|
|
106
|
+
continue
|
|
107
|
+
print(describe(source, fmt, injections))
|
|
108
|
+
return status
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _extract(args) -> int:
|
|
112
|
+
status = 0
|
|
113
|
+
for source in args.inputs:
|
|
114
|
+
try:
|
|
115
|
+
written = extract_ms_raw(source, args.directory, overwrite=args.overwrite)
|
|
116
|
+
except (OSError, ChromparserError) as e:
|
|
117
|
+
print(f"chromparser: {source}: {e}", file=sys.stderr)
|
|
118
|
+
status = 1
|
|
119
|
+
continue
|
|
120
|
+
if not written:
|
|
121
|
+
print(f"chromparser: {source}: no MS data", file=sys.stderr)
|
|
122
|
+
for path in written:
|
|
123
|
+
print(f"{source} -> {path}")
|
|
124
|
+
return status
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: list[str] | None = None) -> int:
|
|
128
|
+
"""Run the command line interface.
|
|
129
|
+
|
|
130
|
+
Parameters
|
|
131
|
+
----------
|
|
132
|
+
argv : list of str, optional
|
|
133
|
+
Arguments; defaults to `sys.argv`.
|
|
134
|
+
|
|
135
|
+
Returns
|
|
136
|
+
-------
|
|
137
|
+
int
|
|
138
|
+
Exit status.
|
|
139
|
+
"""
|
|
140
|
+
parser = argparse.ArgumentParser(
|
|
141
|
+
prog="chromparser",
|
|
142
|
+
description="Read chromatography raw files and convert them to HDF5.",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
146
|
+
)
|
|
147
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
148
|
+
|
|
149
|
+
p = commands.add_parser(
|
|
150
|
+
"convert", help="convert files to HDF5 (layout v3) or ANDI netCDF"
|
|
151
|
+
)
|
|
152
|
+
p.add_argument("inputs", nargs="+", type=Path, help="raw files, .D folders or .h5")
|
|
153
|
+
p.add_argument(
|
|
154
|
+
"--to",
|
|
155
|
+
choices=("hdf5", "andi"),
|
|
156
|
+
default="hdf5",
|
|
157
|
+
help="hdf5 (default): one .h5 file; andi: one .cdf per signal and MS run",
|
|
158
|
+
)
|
|
159
|
+
p.add_argument(
|
|
160
|
+
"-o",
|
|
161
|
+
"--output",
|
|
162
|
+
type=Path,
|
|
163
|
+
help="output file (hdf5, single input) or folder (andi; default: next to the input)",
|
|
164
|
+
)
|
|
165
|
+
p.add_argument("--overwrite", action="store_true", help="replace existing output")
|
|
166
|
+
p.add_argument(
|
|
167
|
+
"--no-compression", action="store_true", help="do not compress (hdf5)"
|
|
168
|
+
)
|
|
169
|
+
p.add_argument("--no-ms", action="store_true", help="skip MS data of .cmbx files")
|
|
170
|
+
p.add_argument("--signal-unit", help="unit of Chromeleon raw signal files, e.g. pA")
|
|
171
|
+
p.set_defaults(run=_convert)
|
|
172
|
+
|
|
173
|
+
p = commands.add_parser("info", help="print injections, channels and MS runs")
|
|
174
|
+
p.add_argument("inputs", nargs="+", type=Path, help="raw files, .D folders or .h5")
|
|
175
|
+
p.add_argument("--no-ms", action="store_true", help="skip MS data of .cmbx files")
|
|
176
|
+
p.set_defaults(run=_info)
|
|
177
|
+
|
|
178
|
+
p = commands.add_parser(
|
|
179
|
+
"extract-ms", help="write the Thermo .raw files embedded in .cmbx archives"
|
|
180
|
+
)
|
|
181
|
+
p.add_argument("inputs", nargs="+", type=Path, help=".cmbx files")
|
|
182
|
+
p.add_argument(
|
|
183
|
+
"-d",
|
|
184
|
+
"--directory",
|
|
185
|
+
type=Path,
|
|
186
|
+
help="output folder (default: next to the input)",
|
|
187
|
+
)
|
|
188
|
+
p.add_argument("--overwrite", action="store_true", help="replace existing files")
|
|
189
|
+
p.set_defaults(run=_extract)
|
|
190
|
+
|
|
191
|
+
args = parser.parse_args(argv)
|
|
192
|
+
return args.run(args)
|
chromparser/errors.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Exceptions and warnings raised by the readers.
|
|
2
|
+
|
|
3
|
+
All exceptions derive from `ValueError`, so code written for cmbx-reader, which
|
|
4
|
+
raised `ValueError`, keeps working.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import warnings
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ChromparserError(ValueError):
|
|
14
|
+
"""Base class of the errors raised by chromparser."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class UnsupportedFormat(ChromparserError):
|
|
18
|
+
"""The file is not chromatography data in any format chromparser reads."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnsupportedVariant(ChromparserError):
|
|
22
|
+
"""The format is recognised, but this variant is not supported.
|
|
23
|
+
|
|
24
|
+
The message names what was found (version, chunk type, format code, ...).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CorruptFile(ChromparserError):
|
|
29
|
+
"""The file is truncated or inconsistent and cannot be read completely."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ChromparserWarning(UserWarning):
|
|
33
|
+
"""A recoverable oddity in a file, such as a TIC mismatch."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def warn(metadata: dict[str, Any], message: str, stacklevel: int = 3) -> None:
|
|
37
|
+
"""Issue a `ChromparserWarning` and record it in `metadata["warnings"]`.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
metadata : dict
|
|
42
|
+
Metadata of the object the warning is about.
|
|
43
|
+
message : str
|
|
44
|
+
The warning.
|
|
45
|
+
stacklevel : int
|
|
46
|
+
Passed to `warnings.warn`.
|
|
47
|
+
"""
|
|
48
|
+
warnings.warn(message, ChromparserWarning, stacklevel=stacklevel)
|
|
49
|
+
metadata.setdefault("warnings", []).append(message)
|
chromparser/hdf5.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
"""HDF5 files: write layout v3, read layouts 1-3.
|
|
2
|
+
|
|
3
|
+
Layout v3 (the normative description is `docs/hdf5.md`)::
|
|
4
|
+
|
|
5
|
+
/ attrs: format = "chromparser", layout_version = 3,
|
|
6
|
+
created_by, created, source, source_format,
|
|
7
|
+
source_sha256, source_size
|
|
8
|
+
/injection_0001 attrs: name, inject_time, <metadata>, metadata_json
|
|
9
|
+
/injection_0001/<signal> attrs: kind = "signal", channel, unit, acquisition_start,
|
|
10
|
+
sampling_interval, <metadata>, metadata_json
|
|
11
|
+
time float64, minutes (attr unit = "min")
|
|
12
|
+
signal dtype as stored (attr unit, if known)
|
|
13
|
+
/injection_0001/<ms> attrs: kind = "ms", name, spectrum_type,
|
|
14
|
+
acquisition_start, <metadata>, metadata_json
|
|
15
|
+
scan_number int32
|
|
16
|
+
retention_time float64, minutes
|
|
17
|
+
tic float64
|
|
18
|
+
peak_offset int64, n_scans + 1
|
|
19
|
+
mz, intensity as stored
|
|
20
|
+
base_peak_mz, base_peak_intensity, filter, ms_level, flags optional
|
|
21
|
+
|
|
22
|
+
Layout 2 is cmbx-reader's (the same without `metadata_json` and the optional
|
|
23
|
+
MS fields), layout 1 that of cmbx-reader 0.1.0 (signals only, no `kind`).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import hashlib
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
from collections.abc import Iterable
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
import h5py
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
from ._util import head
|
|
40
|
+
from .errors import UnsupportedFormat, UnsupportedVariant
|
|
41
|
+
from .model import Injection, MSData, Signal
|
|
42
|
+
|
|
43
|
+
FORMAT = "chromparser"
|
|
44
|
+
LAYOUT_VERSION = 3
|
|
45
|
+
READABLE_LAYOUTS = (1, 2, 3)
|
|
46
|
+
HDF5_MAGIC = b"\x89HDF\r\n\x1a\n"
|
|
47
|
+
|
|
48
|
+
PEAK_CHUNK = 1 << 20 # elements per chunk of the peak datasets
|
|
49
|
+
|
|
50
|
+
_INJECTION_ATTRS = {"name", "inject_time", "metadata_json"}
|
|
51
|
+
_SIGNAL_ATTRS = {
|
|
52
|
+
"kind",
|
|
53
|
+
"channel",
|
|
54
|
+
"unit",
|
|
55
|
+
"acquisition_start",
|
|
56
|
+
"sampling_interval",
|
|
57
|
+
"metadata_json",
|
|
58
|
+
}
|
|
59
|
+
_MS_ATTRS = {"kind", "name", "spectrum_type", "acquisition_start", "metadata_json"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------ writing
|
|
63
|
+
def _is_scalar(value: Any) -> bool:
|
|
64
|
+
if isinstance(value, (bool, np.bool_, str, float, np.floating)):
|
|
65
|
+
return True
|
|
66
|
+
if isinstance(value, (int, np.integer)):
|
|
67
|
+
return -(2**63) <= int(value) < 2**63
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _json_default(value: Any) -> Any:
|
|
72
|
+
if isinstance(value, datetime):
|
|
73
|
+
return value.isoformat()
|
|
74
|
+
if isinstance(value, np.generic):
|
|
75
|
+
return value.item()
|
|
76
|
+
if isinstance(value, np.ndarray):
|
|
77
|
+
return value.tolist()
|
|
78
|
+
return str(value)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _set_attrs(obj: h5py.HLObject, attrs: dict, metadata: dict, reserved: set) -> None:
|
|
82
|
+
"""Write fixed attributes, scalar metadata and `metadata_json`.
|
|
83
|
+
|
|
84
|
+
Metadata that is not scalar, or whose key is a reserved attribute name,
|
|
85
|
+
goes to `metadata_json`. `None` values are omitted.
|
|
86
|
+
"""
|
|
87
|
+
for key, value in attrs.items():
|
|
88
|
+
if value is None:
|
|
89
|
+
continue
|
|
90
|
+
obj.attrs[key] = value.isoformat() if isinstance(value, datetime) else value
|
|
91
|
+
nested = {}
|
|
92
|
+
for key, value in metadata.items():
|
|
93
|
+
if value is None:
|
|
94
|
+
continue
|
|
95
|
+
if key in reserved or not _is_scalar(value):
|
|
96
|
+
nested[key] = value
|
|
97
|
+
else:
|
|
98
|
+
obj.attrs[key] = value
|
|
99
|
+
if nested:
|
|
100
|
+
obj.attrs["metadata_json"] = json.dumps(
|
|
101
|
+
nested, default=_json_default, ensure_ascii=False
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _group_name(name: str, taken: set[str]) -> str:
|
|
106
|
+
"""Sanitise a channel or MS name as an HDF5 group name, unique in `taken`."""
|
|
107
|
+
base = name.replace("/", "_")
|
|
108
|
+
if base in ("", ".", ".."):
|
|
109
|
+
base = "unnamed"
|
|
110
|
+
candidate, n = base, 1
|
|
111
|
+
while candidate in taken:
|
|
112
|
+
n += 1
|
|
113
|
+
candidate = f"{base}_{n}"
|
|
114
|
+
taken.add(candidate)
|
|
115
|
+
return candidate
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _dataset(
|
|
119
|
+
group: h5py.Group, name: str, data: np.ndarray, compress: bool, chunk: int | None
|
|
120
|
+
) -> h5py.Dataset:
|
|
121
|
+
kwargs: dict[str, Any] = {}
|
|
122
|
+
if compress and data.size:
|
|
123
|
+
kwargs = {"compression": "gzip", "compression_opts": 4}
|
|
124
|
+
if data.dtype.kind != "O":
|
|
125
|
+
kwargs["shuffle"] = True
|
|
126
|
+
kwargs["chunks"] = (min(data.size, chunk or PEAK_CHUNK),)
|
|
127
|
+
return group.create_dataset(name, data=data, **kwargs)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _write_signal(parent: h5py.Group, name: str, signal: Signal, compress: bool):
|
|
131
|
+
group = parent.create_group(name)
|
|
132
|
+
_set_attrs(
|
|
133
|
+
group,
|
|
134
|
+
{
|
|
135
|
+
"kind": "signal",
|
|
136
|
+
"channel": signal.channel,
|
|
137
|
+
"unit": signal.unit,
|
|
138
|
+
"acquisition_start": signal.acquisition_start,
|
|
139
|
+
"sampling_interval": signal.sampling_interval,
|
|
140
|
+
},
|
|
141
|
+
signal.metadata,
|
|
142
|
+
_SIGNAL_ATTRS,
|
|
143
|
+
)
|
|
144
|
+
time = _dataset(group, "time", np.asarray(signal.time), compress, None)
|
|
145
|
+
time.attrs["unit"] = "min"
|
|
146
|
+
values = _dataset(group, "signal", np.asarray(signal.values), compress, None)
|
|
147
|
+
if signal.unit:
|
|
148
|
+
values.attrs["unit"] = signal.unit
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _write_ms(parent: h5py.Group, name: str, ms: MSData, compress: bool) -> None:
|
|
152
|
+
group = parent.create_group(name)
|
|
153
|
+
_set_attrs(
|
|
154
|
+
group,
|
|
155
|
+
{
|
|
156
|
+
"kind": "ms",
|
|
157
|
+
"name": ms.name,
|
|
158
|
+
"spectrum_type": ms.spectrum_type,
|
|
159
|
+
"acquisition_start": ms.acquisition_start,
|
|
160
|
+
},
|
|
161
|
+
ms.metadata,
|
|
162
|
+
_MS_ATTRS,
|
|
163
|
+
)
|
|
164
|
+
per_scan = {
|
|
165
|
+
"scan_number": ms.scan_number,
|
|
166
|
+
"retention_time": ms.retention_time,
|
|
167
|
+
"tic": ms.tic,
|
|
168
|
+
"peak_offset": ms.peak_offset,
|
|
169
|
+
"base_peak_mz": ms.base_peak_mz,
|
|
170
|
+
"base_peak_intensity": ms.base_peak_intensity,
|
|
171
|
+
"ms_level": ms.ms_level,
|
|
172
|
+
}
|
|
173
|
+
for key, data in per_scan.items():
|
|
174
|
+
if data is not None:
|
|
175
|
+
_dataset(group, key, np.asarray(data), compress, None)
|
|
176
|
+
if ms.filters is not None:
|
|
177
|
+
filters = np.array(ms.filters, dtype=h5py.string_dtype())
|
|
178
|
+
_dataset(group, "filter", filters, compress, None)
|
|
179
|
+
for key in ("mz", "intensity", "flags"):
|
|
180
|
+
data = getattr(ms, key)
|
|
181
|
+
if data is not None:
|
|
182
|
+
_dataset(group, key, np.asarray(data), compress, PEAK_CHUNK)
|
|
183
|
+
group["retention_time"].attrs["unit"] = "min"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _sha256(path: Path) -> str:
|
|
187
|
+
digest = hashlib.sha256()
|
|
188
|
+
with path.open("rb") as f:
|
|
189
|
+
for block in iter(lambda: f.read(1 << 20), b""):
|
|
190
|
+
digest.update(block)
|
|
191
|
+
return digest.hexdigest()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def write_hdf5(
|
|
195
|
+
injections: Injection | Iterable[Injection],
|
|
196
|
+
path: str | Path,
|
|
197
|
+
*,
|
|
198
|
+
overwrite: bool = False,
|
|
199
|
+
compression: bool = True,
|
|
200
|
+
source: str | Path | None = None,
|
|
201
|
+
source_format: str | None = None,
|
|
202
|
+
) -> Path:
|
|
203
|
+
"""Write injections to an HDF5 file in layout v3.
|
|
204
|
+
|
|
205
|
+
Parameters
|
|
206
|
+
----------
|
|
207
|
+
injections : Injection or iterable of Injection
|
|
208
|
+
What to write.
|
|
209
|
+
path : str or Path
|
|
210
|
+
Output file.
|
|
211
|
+
overwrite : bool
|
|
212
|
+
Replace an existing file.
|
|
213
|
+
compression : bool
|
|
214
|
+
Compress datasets (gzip level 4 with shuffle).
|
|
215
|
+
source : str or Path, optional
|
|
216
|
+
The input file. Its name is stored, and for a file also its size and
|
|
217
|
+
SHA-256.
|
|
218
|
+
source_format : str, optional
|
|
219
|
+
Format id of the input, e.g. ``"shimadzu-qgd"`` (see `sniff`).
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
Path
|
|
224
|
+
The written file.
|
|
225
|
+
|
|
226
|
+
Raises
|
|
227
|
+
------
|
|
228
|
+
FileExistsError
|
|
229
|
+
If the file exists and `overwrite` is False.
|
|
230
|
+
"""
|
|
231
|
+
from . import __version__
|
|
232
|
+
|
|
233
|
+
if isinstance(injections, Injection):
|
|
234
|
+
injections = [injections]
|
|
235
|
+
path = Path(path)
|
|
236
|
+
if path.exists() and not overwrite:
|
|
237
|
+
raise FileExistsError(f"{path} exists, use overwrite=True to replace it.")
|
|
238
|
+
|
|
239
|
+
root = {
|
|
240
|
+
"format": FORMAT,
|
|
241
|
+
"layout_version": LAYOUT_VERSION,
|
|
242
|
+
"created_by": f"chromparser {__version__}",
|
|
243
|
+
"created": datetime.now(timezone.utc).replace(microsecond=0),
|
|
244
|
+
"source_format": source_format,
|
|
245
|
+
}
|
|
246
|
+
if source is not None:
|
|
247
|
+
source = Path(source)
|
|
248
|
+
root["source"] = source.name
|
|
249
|
+
if source.is_file():
|
|
250
|
+
root["source_sha256"] = _sha256(source)
|
|
251
|
+
root["source_size"] = source.stat().st_size
|
|
252
|
+
|
|
253
|
+
with h5py.File(path, "w") as hdf:
|
|
254
|
+
_set_attrs(hdf, root, {}, set())
|
|
255
|
+
for number, injection in enumerate(injections, start=1):
|
|
256
|
+
group = hdf.create_group(f"injection_{number:04d}")
|
|
257
|
+
_set_attrs(
|
|
258
|
+
group,
|
|
259
|
+
{"name": injection.name or None, "inject_time": injection.inject_time},
|
|
260
|
+
injection.metadata,
|
|
261
|
+
_INJECTION_ATTRS,
|
|
262
|
+
)
|
|
263
|
+
taken: set[str] = set()
|
|
264
|
+
for signal in injection.signals.values():
|
|
265
|
+
name = _group_name(signal.channel, taken)
|
|
266
|
+
_write_signal(group, name, signal, compression)
|
|
267
|
+
for ms in injection.ms.values():
|
|
268
|
+
_write_ms(group, _group_name(ms.name, taken), ms, compression)
|
|
269
|
+
return path
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ------------------------------------------------------------------ reading
|
|
273
|
+
def _value(value: Any) -> Any:
|
|
274
|
+
if isinstance(value, np.generic):
|
|
275
|
+
return value.item()
|
|
276
|
+
if isinstance(value, bytes):
|
|
277
|
+
return value.decode("utf-8")
|
|
278
|
+
if isinstance(value, np.ndarray):
|
|
279
|
+
return [_value(v) for v in value.tolist()]
|
|
280
|
+
return value
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _attrs(obj: h5py.HLObject, reserved: set) -> tuple[dict, dict]:
|
|
284
|
+
"""Split attributes into (reserved attributes, metadata)."""
|
|
285
|
+
fixed, metadata = {}, {}
|
|
286
|
+
for key, value in obj.attrs.items():
|
|
287
|
+
value = _value(value)
|
|
288
|
+
(fixed if key in reserved else metadata)[key] = value
|
|
289
|
+
if "metadata_json" in fixed:
|
|
290
|
+
metadata.update(json.loads(fixed.pop("metadata_json")))
|
|
291
|
+
return fixed, metadata
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _time(text: str | None) -> datetime | None:
|
|
295
|
+
return datetime.fromisoformat(text) if text else None
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _read_signal(group: h5py.Group) -> Signal:
|
|
299
|
+
fixed, metadata = _attrs(group, _SIGNAL_ATTRS)
|
|
300
|
+
return Signal(
|
|
301
|
+
channel=fixed.get("channel", group.name.rsplit("/", 1)[-1]),
|
|
302
|
+
time=group["time"][()],
|
|
303
|
+
values=group["signal"][()],
|
|
304
|
+
unit=fixed.get("unit"),
|
|
305
|
+
acquisition_start=_time(fixed.get("acquisition_start")),
|
|
306
|
+
metadata=metadata,
|
|
307
|
+
sampling_interval=fixed.get("sampling_interval"),
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _read_ms(group: h5py.Group) -> MSData:
|
|
312
|
+
fixed, metadata = _attrs(group, _MS_ATTRS)
|
|
313
|
+
|
|
314
|
+
def optional(key):
|
|
315
|
+
return group[key][()] if key in group else None
|
|
316
|
+
|
|
317
|
+
return MSData(
|
|
318
|
+
name=fixed.get("name", group.name.rsplit("/", 1)[-1]),
|
|
319
|
+
scan_number=group["scan_number"][()],
|
|
320
|
+
retention_time=group["retention_time"][()],
|
|
321
|
+
tic=group["tic"][()],
|
|
322
|
+
base_peak_mz=optional("base_peak_mz"),
|
|
323
|
+
base_peak_intensity=optional("base_peak_intensity"),
|
|
324
|
+
filters=group["filter"].asstr()[()].tolist() if "filter" in group else None,
|
|
325
|
+
mz=group["mz"][()],
|
|
326
|
+
intensity=group["intensity"][()],
|
|
327
|
+
peak_offset=group["peak_offset"][()],
|
|
328
|
+
acquisition_start=_time(fixed.get("acquisition_start")),
|
|
329
|
+
metadata=metadata,
|
|
330
|
+
ms_level=optional("ms_level"),
|
|
331
|
+
flags=optional("flags"),
|
|
332
|
+
spectrum_type=fixed.get("spectrum_type", "centroid"),
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def layout_version(path: str | Path) -> int:
|
|
337
|
+
"""Return the layout version of a chromparser or cmbx-reader HDF5 file.
|
|
338
|
+
|
|
339
|
+
Raises
|
|
340
|
+
------
|
|
341
|
+
UnsupportedFormat
|
|
342
|
+
If the file is not such an HDF5 file.
|
|
343
|
+
"""
|
|
344
|
+
if head(path, 8) != HDF5_MAGIC:
|
|
345
|
+
raise UnsupportedFormat(f"{path} is not an HDF5 file.")
|
|
346
|
+
with h5py.File(path, "r") as hdf:
|
|
347
|
+
version = _value(hdf.attrs.get("layout_version"))
|
|
348
|
+
created_by = str(_value(hdf.attrs.get("created_by", "")))
|
|
349
|
+
known = hdf.attrs.get("format") == FORMAT or created_by.startswith(
|
|
350
|
+
"cmbx-reader"
|
|
351
|
+
)
|
|
352
|
+
if not known or not isinstance(version, int):
|
|
353
|
+
raise UnsupportedFormat(
|
|
354
|
+
f"{path} is not a chromparser or cmbx-reader HDF5 file."
|
|
355
|
+
)
|
|
356
|
+
return version
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def read_hdf5(path: str | Path) -> list[Injection]:
|
|
360
|
+
"""Read an HDF5 file written by chromparser (layout 3) or cmbx-reader (1, 2).
|
|
361
|
+
|
|
362
|
+
Parameters
|
|
363
|
+
----------
|
|
364
|
+
path : str or Path
|
|
365
|
+
The HDF5 file.
|
|
366
|
+
|
|
367
|
+
Returns
|
|
368
|
+
-------
|
|
369
|
+
list of Injection
|
|
370
|
+
In file order. Groups are recognised by their `kind` attribute
|
|
371
|
+
(layout 1: by their `time` and `signal` datasets); unknown groups,
|
|
372
|
+
datasets and attributes are ignored.
|
|
373
|
+
|
|
374
|
+
Raises
|
|
375
|
+
------
|
|
376
|
+
UnsupportedFormat
|
|
377
|
+
If the file is not a chromparser or cmbx-reader HDF5 file.
|
|
378
|
+
UnsupportedVariant
|
|
379
|
+
If its layout version is newer than this reader.
|
|
380
|
+
"""
|
|
381
|
+
path = Path(path)
|
|
382
|
+
if not path.is_file():
|
|
383
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
384
|
+
version = layout_version(path)
|
|
385
|
+
if version not in READABLE_LAYOUTS:
|
|
386
|
+
raise UnsupportedVariant(
|
|
387
|
+
f"{path.name} has HDF5 layout version {version}; this chromparser "
|
|
388
|
+
f"reads {', '.join(map(str, READABLE_LAYOUTS))}. Update chromparser."
|
|
389
|
+
)
|
|
390
|
+
injections = []
|
|
391
|
+
with h5py.File(path, "r") as hdf:
|
|
392
|
+
names = sorted(
|
|
393
|
+
(n for n in hdf if re.fullmatch(r"injection_\d+", n)),
|
|
394
|
+
key=lambda n: int(n[10:]),
|
|
395
|
+
)
|
|
396
|
+
for name in names:
|
|
397
|
+
group = hdf[name]
|
|
398
|
+
fixed, metadata = _attrs(group, _INJECTION_ATTRS)
|
|
399
|
+
injection = Injection(
|
|
400
|
+
name=fixed.get("name", ""),
|
|
401
|
+
inject_time=_time(fixed.get("inject_time")),
|
|
402
|
+
metadata=metadata,
|
|
403
|
+
)
|
|
404
|
+
for child in group.values():
|
|
405
|
+
if not isinstance(child, h5py.Group):
|
|
406
|
+
continue
|
|
407
|
+
kind = _value(child.attrs.get("kind"))
|
|
408
|
+
if kind is None and {"time", "signal"} <= child.keys():
|
|
409
|
+
kind = "signal"
|
|
410
|
+
if kind == "signal":
|
|
411
|
+
signal = _read_signal(child)
|
|
412
|
+
injection.signals[signal.channel] = signal
|
|
413
|
+
elif kind == "ms":
|
|
414
|
+
ms = _read_ms(child)
|
|
415
|
+
injection.ms[ms.name] = ms
|
|
416
|
+
injections.append(injection)
|
|
417
|
+
return injections
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def sniff_hdf5(path: str | Path) -> bool:
|
|
421
|
+
"""Whether `path` is an HDF5 file written by chromparser or cmbx-reader."""
|
|
422
|
+
if not Path(path).is_file():
|
|
423
|
+
return False
|
|
424
|
+
try:
|
|
425
|
+
layout_version(path)
|
|
426
|
+
except (UnsupportedFormat, OSError):
|
|
427
|
+
return False
|
|
428
|
+
return True
|