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,181 @@
1
+ """ANDI chromatography (AIA) netCDF files: one detector trace.
2
+
3
+ The trace is `ordinate_values`, sampled every `actual_sampling_interval`
4
+ seconds from `actual_delay_time` on. A peak table (variables on the peak
5
+ dimension), if present, is kept in the signal metadata. See
6
+ `docs/formats/andi.md`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+
15
+ from ... import _netcdf3 as nc
16
+ from ...errors import CorruptFile, UnsupportedVariant, warn
17
+ from ...model import Injection, Signal
18
+ from .common import (
19
+ apply_scale,
20
+ check_variables,
21
+ has_variable,
22
+ injection_of,
23
+ json_value,
24
+ open_andi,
25
+ )
26
+
27
+ KEY = "ordinate_values"
28
+ SCALARS = (
29
+ "actual_sampling_interval",
30
+ "actual_delay_time",
31
+ "actual_run_time_length",
32
+ "detector_maximum_value",
33
+ "detector_minimum_value",
34
+ )
35
+ #: Global attributes that describe the signal.
36
+ SIGNAL_ATTRIBUTES = {"detector_unit", "detector_name", "retention_unit"}
37
+ SECONDS = ("Seconds", "seconds", "s")
38
+
39
+
40
+ def _scalar(f: nc.File, name: str) -> float | None:
41
+ if name not in f.variables:
42
+ return None
43
+ data = f.read(name).ravel()
44
+ if data.size != 1:
45
+ raise UnsupportedVariant(f"{f.path.name}: {name} holds {data.size} values.")
46
+ return float(data[0])
47
+
48
+
49
+ def _peak_table(f: nc.File, dimension: str) -> dict[str, list]:
50
+ table = {}
51
+ for variable in f.variables.values():
52
+ if not variable.dimensions or variable.dimensions[0] != dimension:
53
+ continue
54
+ data = f.read(variable.name)
55
+ if variable.nc_type == 2:
56
+ values = nc.strings(data) if data.size else []
57
+ else:
58
+ values = [json_value(v) for v in data.tolist()]
59
+ table[variable.name] = values
60
+ return table
61
+
62
+
63
+ def decode_andi_chrom(f: nc.File, file: str = "andi") -> tuple[Signal, Injection]:
64
+ """Decode an open ANDI chromatography file.
65
+
66
+ Parameters
67
+ ----------
68
+ f : chromparser._netcdf3.File
69
+ The open file.
70
+ file : str
71
+ File name for messages.
72
+
73
+ Returns
74
+ -------
75
+ Signal, Injection
76
+ The signal (channel ``detector_name``, or ``"detector"``) and its
77
+ injection (without the signal).
78
+
79
+ Raises
80
+ ------
81
+ UnsupportedVariant
82
+ For irregular sampling, times in other units than seconds, or
83
+ variables on the point dimension that the reader does not know.
84
+ CorruptFile
85
+ For unfinished files.
86
+ """
87
+ attributes = f.attributes
88
+ metadata: dict = {}
89
+ variable = f.variables[KEY]
90
+ if len(variable.dimensions) != 1:
91
+ raise UnsupportedVariant(f"{file}: {KEY} is not 1D.")
92
+ peak_dim = "peak_number"
93
+ known = {KEY, *SCALARS}
94
+ known |= {v.name for v in f.variables.values() if v.dimensions[:1] == (peak_dim,)}
95
+ check_variables(f, known, {variable.dimensions[0]}, metadata, file)
96
+
97
+ uniform = json_value(variable.attributes.get("uniform_sampling_flag"))
98
+ if uniform not in (None, "Y"):
99
+ raise UnsupportedVariant(
100
+ f"{file}: uniform_sampling_flag {uniform!r}; irregularly sampled "
101
+ "signals are not supported."
102
+ )
103
+ unit = json_value(attributes.get("retention_unit"))
104
+ if unit is None:
105
+ warn(metadata, f"{file}: no retention_unit; seconds assumed.")
106
+ elif unit not in SECONDS:
107
+ raise UnsupportedVariant(f"{file}: retention_unit {unit!r} (only seconds).")
108
+ interval = _scalar(f, "actual_sampling_interval")
109
+ if interval is None or not interval > 0:
110
+ raise UnsupportedVariant(f"{file}: no positive actual_sampling_interval.")
111
+ delay = _scalar(f, "actual_delay_time")
112
+ if delay is None:
113
+ warn(metadata, f"{file}: no actual_delay_time; 0 assumed.")
114
+ delay = 0.0
115
+ if variable.is_record and f.numrecs == 0 and f.records_in_file():
116
+ raise CorruptFile(
117
+ f"{file}: the header says 0 records but the file holds "
118
+ f"{f.records_in_file()}; the export did not finish."
119
+ )
120
+
121
+ values = apply_scale(f, KEY, f.read(KEY), metadata, "ordinate")
122
+ for name in SCALARS[2:]:
123
+ value = _scalar(f, name)
124
+ if value is not None:
125
+ metadata[f"andi_{name}"] = value
126
+ for key in ("uniform_sampling_flag", "autosampler_position"):
127
+ value = json_value(variable.attributes.get(key))
128
+ if value is not None:
129
+ metadata[f"andi_{key}"] = value
130
+ if peak_dim in f.dimensions:
131
+ table = _peak_table(f, peak_dim)
132
+ if table:
133
+ metadata["andi_peak_table"] = table
134
+ channel = json_value(attributes.get("detector_name")) or "detector"
135
+ signal = Signal(
136
+ channel=channel,
137
+ time=(delay + interval * np.arange(len(values))) / 60.0,
138
+ values=values,
139
+ unit=json_value(attributes.get("detector_unit")),
140
+ metadata=metadata,
141
+ sampling_interval=interval,
142
+ )
143
+ injection = injection_of(
144
+ f, file, "injection_date_time_stamp", SIGNAL_ATTRIBUTES.__contains__
145
+ )
146
+ return signal, injection
147
+
148
+
149
+ def read_andi_chrom(path: str | Path) -> list[Injection]:
150
+ """Read an ANDI chromatography (AIA) netCDF file.
151
+
152
+ Parameters
153
+ ----------
154
+ path : str or Path
155
+ The `.cdf` file.
156
+
157
+ Returns
158
+ -------
159
+ list of Injection
160
+ One injection with one signal. `inject_time` is the
161
+ ``injection_date_time_stamp`` (aware when it has an offset).
162
+
163
+ Raises
164
+ ------
165
+ UnsupportedFormat
166
+ If the file is not netCDF or has no ``ordinate_values``.
167
+ UnsupportedVariant
168
+ For netCDF-4 files and the variants `decode_andi_chrom` refuses.
169
+ CorruptFile
170
+ For unfinished files.
171
+ """
172
+ path = Path(path)
173
+ with open_andi(path, KEY, "ANDI chromatography") as f:
174
+ signal, injection = decode_andi_chrom(f, path.name)
175
+ injection.signals[signal.channel] = signal
176
+ return [injection]
177
+
178
+
179
+ def sniff_andi_chrom(path: str | Path) -> bool:
180
+ """Whether `path` is a netCDF file with an ``ordinate_values`` variable."""
181
+ return has_variable(path, KEY)
@@ -0,0 +1,232 @@
1
+ """Helpers shared by the ANDI-MS and ANDI chromatography (AIA) readers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Callable
7
+ from contextlib import contextmanager
8
+ from datetime import datetime, timedelta, timezone
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import numpy as np
13
+
14
+ from ... import _netcdf3 as nc
15
+ from ..._util import existing_file, head
16
+ from ...errors import UnsupportedFormat, UnsupportedVariant, warn
17
+ from ...hdf5 import HDF5_MAGIC
18
+ from ...model import Injection
19
+
20
+ _TIMESTAMP = re.compile(r"^(\d{14})(?:([+-])(\d{2})(\d{2}))?$")
21
+
22
+
23
+ def parse_timestamp(text: str) -> datetime | None:
24
+ """Parse an ANDI timestamp, ``YYYYMMDDHHMMSS`` with optional ``±ZZZZ``.
25
+
26
+ Returns
27
+ -------
28
+ datetime or None
29
+ Timezone-aware with an offset, naive without; `None` if `text` is not
30
+ such a timestamp.
31
+ """
32
+ match = _TIMESTAMP.match(text.strip())
33
+ if not match:
34
+ return None
35
+ digits, sign, hours, minutes = match.groups()
36
+ try:
37
+ value = datetime.strptime(digits, "%Y%m%d%H%M%S")
38
+ except ValueError:
39
+ return None
40
+ if sign is None:
41
+ return value
42
+ offset = timedelta(hours=int(hours), minutes=int(minutes))
43
+ return value.replace(tzinfo=timezone(-offset if sign == "-" else offset))
44
+
45
+
46
+ def format_timestamp(value: datetime) -> str:
47
+ """Format a datetime as an ANDI timestamp (offset only if it is aware)."""
48
+ text = value.strftime("%Y%m%d%H%M%S")
49
+ offset = value.utcoffset()
50
+ if offset is None:
51
+ return text
52
+ minutes = int(offset.total_seconds()) // 60
53
+ sign = "-" if minutes < 0 else "+"
54
+ return f"{text}{sign}{abs(minutes) // 60:02d}{abs(minutes) % 60:02d}"
55
+
56
+
57
+ def json_value(value: Any) -> Any:
58
+ """Return an attribute value as a JSON type (`None` for empty values)."""
59
+ if isinstance(value, str):
60
+ return value.strip() or None
61
+ if isinstance(value, np.ndarray):
62
+ return [json_value(v) for v in value.tolist()] or None
63
+ if isinstance(value, np.generic):
64
+ value = value.item()
65
+ if isinstance(value, float) and not np.isfinite(value):
66
+ return None
67
+ return value
68
+
69
+
70
+ def attribute_metadata(attributes: dict[str, Any]) -> dict[str, Any]:
71
+ """Global attributes as ``andi_<name>`` metadata.
72
+
73
+ Timestamps become ISO 8601 strings; empty values are left out.
74
+ """
75
+ metadata = {}
76
+ for key, value in attributes.items():
77
+ value = json_value(value)
78
+ if value is None:
79
+ continue
80
+ if key.endswith("date_time_stamp") and isinstance(value, str):
81
+ parsed = parse_timestamp(value)
82
+ value = parsed.isoformat() if parsed else value
83
+ metadata[f"andi_{key}"] = value
84
+ return metadata
85
+
86
+
87
+ def is_fill(data: np.ndarray, attributes: dict[str, Any], nc_type: int) -> np.ndarray:
88
+ """Mask of values equal to the variable's ``_FillValue`` or the default fill."""
89
+ fills = [nc.FILL[nc_type]]
90
+ if "_FillValue" in attributes:
91
+ fills.append(attributes["_FillValue"])
92
+ mask = np.zeros(data.shape, bool)
93
+ for fill in fills:
94
+ mask |= data == np.asarray(fill).astype(data.dtype)
95
+ return mask
96
+
97
+
98
+ def _netcdf4_variables(path: Path) -> set[str]:
99
+ """Root dataset names of an HDF5 (netCDF-4) file; empty for other files."""
100
+ if head(path, 8) != HDF5_MAGIC:
101
+ return set()
102
+ import h5py
103
+
104
+ try:
105
+ with h5py.File(path, "r") as hdf:
106
+ return set(hdf.keys())
107
+ except (OSError, ValueError):
108
+ return set()
109
+
110
+
111
+ def has_variable(path: str | Path, name: str) -> bool:
112
+ """Whether `path` is a netCDF file (3 or 4) with the variable `name`."""
113
+ path = Path(path)
114
+ if not path.is_file():
115
+ return False
116
+ try:
117
+ if nc.is_netcdf3(head(path, 4)):
118
+ with nc.File(path) as f:
119
+ return name in f.variables
120
+ return name in _netcdf4_variables(path)
121
+ except (ValueError, OSError):
122
+ return False
123
+
124
+
125
+ @contextmanager
126
+ def open_andi(path: str | Path, key: str, what: str):
127
+ """Open an ANDI file as a netCDF-3 `File`.
128
+
129
+ Raises
130
+ ------
131
+ UnsupportedVariant
132
+ For a netCDF-4 (HDF5) file with `key`, or a CDF-5 file.
133
+ UnsupportedFormat
134
+ If the file is not netCDF-3 or has no variable `key`.
135
+ """
136
+ path = existing_file(path)
137
+ if key in _netcdf4_variables(path):
138
+ raise UnsupportedVariant(
139
+ f"{path.name} is a netCDF-4 (HDF5) {what} file; only netCDF-3 "
140
+ "(classic and 64-bit offset) files are read."
141
+ )
142
+ with nc.File(path) as f:
143
+ if key not in f.variables:
144
+ raise UnsupportedFormat(f"{path.name} is not an {what} file (no {key}).")
145
+ yield f
146
+
147
+
148
+ def check_variables(
149
+ f: nc.File, known: set[str], data_dims: set[str], metadata: dict, name: str
150
+ ) -> None:
151
+ """Refuse unknown variables on the data dimensions, warn about others.
152
+
153
+ Raises
154
+ ------
155
+ UnsupportedVariant
156
+ If a variable outside `known` uses one of `data_dims`.
157
+ """
158
+ unknown = [v for v in f.variables.values() if v.name not in known]
159
+ on_data = [v.name for v in unknown if data_dims & set(v.dimensions)]
160
+ if on_data:
161
+ raise UnsupportedVariant(
162
+ f"{name}: variable(s) {', '.join(on_data)} on the data dimensions "
163
+ "are not understood by this reader."
164
+ )
165
+ if unknown:
166
+ warn(
167
+ metadata,
168
+ f"{name}: variable(s) not read: {', '.join(v.name for v in unknown)}.",
169
+ )
170
+
171
+
172
+ def injection_of(
173
+ f: nc.File, file: str, stamp_key: str, elsewhere: Callable[[str], bool]
174
+ ) -> Injection:
175
+ """Build the injection (without data) from the global attributes.
176
+
177
+ Parameters
178
+ ----------
179
+ f : chromparser._netcdf3.File
180
+ The open file.
181
+ file : str
182
+ File name for messages.
183
+ stamp_key : str
184
+ Attribute holding the injection time.
185
+ elsewhere : callable
186
+ Whether an attribute belongs to the signal or MS metadata instead.
187
+
188
+ Returns
189
+ -------
190
+ Injection
191
+ `name` from ``experiment_title`` or ``sample_name``; ``sample_name``
192
+ and ``operator`` (``operator_name``) metadata; the other attributes
193
+ as ``andi_<name>``.
194
+ """
195
+ attributes = f.attributes
196
+ metadata: dict = {}
197
+ sample = json_value(attributes.get("sample_name"))
198
+ title = json_value(attributes.get("experiment_title"))
199
+ operator = json_value(attributes.get("operator_name"))
200
+ if sample:
201
+ metadata["sample_name"] = sample
202
+ if operator:
203
+ metadata["operator"] = operator
204
+ stamp = json_value(attributes.get(stamp_key))
205
+ inject_time = parse_timestamp(stamp) if isinstance(stamp, str) else None
206
+ if stamp is not None and inject_time is None:
207
+ warn(metadata, f"{file}: {stamp_key} {stamp!r} is not an ANDI timestamp.")
208
+ metadata[f"andi_{stamp_key}"] = stamp
209
+ mapped = {"experiment_title", "sample_name", "operator_name", stamp_key}
210
+ rest = {k: v for k, v in attributes.items() if k not in mapped and not elsewhere(k)}
211
+ metadata.update(attribute_metadata(rest))
212
+ return Injection(
213
+ name=title or sample or "", inject_time=inject_time, metadata=metadata
214
+ )
215
+
216
+
217
+ def apply_scale(
218
+ f: nc.File, name: str, data: np.ndarray, metadata: dict, key: str
219
+ ) -> np.ndarray:
220
+ """Apply `scale_factor` / `add_offset` when they change the values."""
221
+ attributes = f.variables[name].attributes
222
+ scale = attributes.get("scale_factor")
223
+ offset = attributes.get("add_offset")
224
+ if scale is not None:
225
+ metadata[f"andi_{key}_scale_factor"] = json_value(scale)
226
+ if offset is not None:
227
+ metadata[f"andi_{key}_add_offset"] = json_value(offset)
228
+ scale = 1 if scale is None else float(np.asarray(scale).ravel()[0])
229
+ offset = 0 if offset is None else float(np.asarray(offset).ravel()[0])
230
+ if scale == 1 and offset == 0:
231
+ return data
232
+ return data.astype(np.float64) * scale + offset