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