eemkit 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.
- eemkit/__init__.py +51 -0
- eemkit/absorbance.py +216 -0
- eemkit/campaigns.py +276 -0
- eemkit/core/__init__.py +1 -0
- eemkit/core/dataset.py +91 -0
- eemkit/core/grid.py +100 -0
- eemkit/core/history.py +78 -0
- eemkit/corrections/__init__.py +44 -0
- eemkit/corrections/_common.py +58 -0
- eemkit/corrections/blank.py +66 -0
- eemkit/corrections/crop.py +33 -0
- eemkit/corrections/inner_filter.py +123 -0
- eemkit/corrections/raman.py +107 -0
- eemkit/corrections/scatter.py +191 -0
- eemkit/corrections/spectral.py +117 -0
- eemkit/errors.py +37 -0
- eemkit/gui/__init__.py +30 -0
- eemkit/gui/__main__.py +5 -0
- eemkit/gui/campaigns_tab.py +275 -0
- eemkit/gui/common.py +395 -0
- eemkit/gui/corrections_tab.py +334 -0
- eemkit/gui/export_tab.py +137 -0
- eemkit/gui/indices_tab.py +228 -0
- eemkit/gui/main_window.py +258 -0
- eemkit/gui/parafac_tab.py +399 -0
- eemkit/gui/project_tab.py +332 -0
- eemkit/gui/state.py +149 -0
- eemkit/gui/theme.py +125 -0
- eemkit/indices.py +256 -0
- eemkit/io/__init__.py +1 -0
- eemkit/io/_cary_csv.py +174 -0
- eemkit/io/cary60.py +120 -0
- eemkit/io/cary_eclipse.py +140 -0
- eemkit/io/discover.py +176 -0
- eemkit/io/loader.py +158 -0
- eemkit/io/pairing.py +115 -0
- eemkit/parafac/__init__.py +28 -0
- eemkit/parafac/_cp.py +125 -0
- eemkit/parafac/diagnostics.py +56 -0
- eemkit/parafac/export.py +54 -0
- eemkit/parafac/model.py +191 -0
- eemkit/parafac/validation.py +111 -0
- eemkit/pipeline.py +560 -0
- eemkit/plot.py +437 -0
- eemkit-0.1.0.dist-info/METADATA +195 -0
- eemkit-0.1.0.dist-info/RECORD +49 -0
- eemkit-0.1.0.dist-info/WHEEL +4 -0
- eemkit-0.1.0.dist-info/entry_points.txt +2 -0
- eemkit-0.1.0.dist-info/licenses/LICENSE +674 -0
eemkit/indices.py
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Fluorescence indices and peak picking.
|
|
2
|
+
|
|
3
|
+
==================== =============================================== =========================
|
|
4
|
+
index definition (defaults) reference
|
|
5
|
+
==================== =============================================== =========================
|
|
6
|
+
``FI`` I(ex 370, em 470) / I(ex 370, em 520) Cory & McKnight 2005
|
|
7
|
+
``HIX`` H / (H + L) at ex 254; H = area em 435-480, Ohno 2002
|
|
8
|
+
L = area em 300-345
|
|
9
|
+
``BIX`` I(ex 310, em 380) / I(ex 310, em 430) Huguet et al. 2009
|
|
10
|
+
Coble peaks B 275/310, T 275/340, A 260/380-460, Coble 1996
|
|
11
|
+
M 312/380-420, C 350/420-480 (ex/em, nm);
|
|
12
|
+
maximum over the emission range
|
|
13
|
+
==================== =============================================== =========================
|
|
14
|
+
|
|
15
|
+
Excitation wavelengths that are not on the measured grid (e.g. 254, 275 or
|
|
16
|
+
312 nm on a 10 nm grid) are handled by ``ex_method``:
|
|
17
|
+
|
|
18
|
+
* ``"linear"``: linear interpolation between the two neighbouring scans;
|
|
19
|
+
* ``"nearest"``: the closest measured excitation;
|
|
20
|
+
* ``"exact"``: refuse (raise) unless the excitation was measured.
|
|
21
|
+
|
|
22
|
+
Emission values off the grid are linearly interpolated; emission areas are
|
|
23
|
+
trapezoidal integrals of the piecewise-linear spectrum between the exact
|
|
24
|
+
limits. A value that needs a NaN point (e.g. removed scatter) is NaN and
|
|
25
|
+
triggers a warning. Ratios are unit-free; peaks are in the units of
|
|
26
|
+
``intensity``. Indices depend on instrument spectral response (Cory et al.
|
|
27
|
+
2010), so the spectral-correction status travels with every result.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import warnings
|
|
34
|
+
from collections.abc import Mapping
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import pandas as pd
|
|
39
|
+
import xarray as xr
|
|
40
|
+
|
|
41
|
+
from eemkit.core.history import get_history
|
|
42
|
+
from eemkit.errors import EEMKitWarning, IndexComputationError
|
|
43
|
+
|
|
44
|
+
EX_METHODS = ("linear", "nearest", "exact")
|
|
45
|
+
|
|
46
|
+
#: Coble (1996) peaks: name -> (ex nm, (em_min, em_max) nm)
|
|
47
|
+
COBLE_1996: dict[str, tuple[float, tuple[float, float]]] = {
|
|
48
|
+
"B": (275.0, (310.0, 310.0)),
|
|
49
|
+
"T": (275.0, (340.0, 340.0)),
|
|
50
|
+
"A": (260.0, (380.0, 460.0)),
|
|
51
|
+
"M": (312.0, (380.0, 420.0)),
|
|
52
|
+
"C": (350.0, (420.0, 480.0)),
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------------------------- #
|
|
57
|
+
# Building blocks
|
|
58
|
+
# --------------------------------------------------------------------------- #
|
|
59
|
+
|
|
60
|
+
def spectrum_at_ex(da: xr.DataArray, ex: float, ex_method: str) -> xr.DataArray:
|
|
61
|
+
"""Emission spectrum (dims ``..., em``) at excitation ``ex``."""
|
|
62
|
+
if ex_method not in EX_METHODS:
|
|
63
|
+
raise ValueError(f"ex_method must be one of {EX_METHODS}")
|
|
64
|
+
# per-scan coordinates (e.g. scan_time(sample, ex)) have no meaning off the scans
|
|
65
|
+
da = da.drop_vars([c for c in da.coords if c != "ex" and "ex" in da[c].dims])
|
|
66
|
+
exs = da["ex"].values
|
|
67
|
+
hit = np.isclose(exs, ex)
|
|
68
|
+
if hit.any():
|
|
69
|
+
return da.sel(ex=exs[hit][0], drop=True)
|
|
70
|
+
if ex_method == "exact":
|
|
71
|
+
raise IndexComputationError(f"excitation {ex} nm was not measured; use ex_method='linear' "
|
|
72
|
+
"or 'nearest'")
|
|
73
|
+
if ex < exs.min() or ex > exs.max():
|
|
74
|
+
raise IndexComputationError(f"excitation {ex} nm is outside the measured {exs.min()}-"
|
|
75
|
+
f"{exs.max()} nm")
|
|
76
|
+
if ex_method == "nearest":
|
|
77
|
+
return da.sel(ex=exs[np.argmin(np.abs(exs - ex))], drop=True)
|
|
78
|
+
hi = exs[exs > ex].min()
|
|
79
|
+
lo = exs[exs < ex].max()
|
|
80
|
+
w = (ex - lo) / (hi - lo)
|
|
81
|
+
return (1 - w) * da.sel(ex=lo, drop=True) + w * da.sel(ex=hi, drop=True)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _interp_em(spec: xr.DataArray, em: float) -> xr.DataArray:
|
|
85
|
+
grid = spec["em"].values
|
|
86
|
+
if em < grid.min() or em > grid.max():
|
|
87
|
+
raise IndexComputationError(f"emission {em} nm is outside {grid.min()}-{grid.max()} nm")
|
|
88
|
+
hit = np.isclose(grid, em)
|
|
89
|
+
if hit.any():
|
|
90
|
+
return spec.sel(em=grid[hit][0], drop=True)
|
|
91
|
+
hi = grid[grid > em].min()
|
|
92
|
+
lo = grid[grid < em].max()
|
|
93
|
+
w = (em - lo) / (hi - lo)
|
|
94
|
+
return (1 - w) * spec.sel(em=lo, drop=True) + w * spec.sel(em=hi, drop=True)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _em_window(spec: xr.DataArray, lo: float, hi: float) -> xr.DataArray:
|
|
98
|
+
"""Spectrum on [lo, hi] including the interpolated end points."""
|
|
99
|
+
grid = spec["em"].values
|
|
100
|
+
inner = grid[(grid > lo) & (grid < hi)]
|
|
101
|
+
parts = [_interp_em(spec, lo).expand_dims(em=[lo]),
|
|
102
|
+
spec.sel(em=inner),
|
|
103
|
+
_interp_em(spec, hi).expand_dims(em=[hi])] if hi > lo else \
|
|
104
|
+
[_interp_em(spec, lo).expand_dims(em=[lo])]
|
|
105
|
+
return xr.concat(parts, dim="em")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def emission_area(spec: xr.DataArray, lo: float, hi: float) -> xr.DataArray:
|
|
109
|
+
"""Trapezoidal integral of ``spec`` over emission ``lo``-``hi`` (exact limits)."""
|
|
110
|
+
win = _em_window(spec, lo, hi)
|
|
111
|
+
vals = np.trapezoid(win.values, win["em"].values, axis=win.get_axis_num("em"))
|
|
112
|
+
dims = [d for d in win.dims if d != "em"]
|
|
113
|
+
return xr.DataArray(vals, dims=dims,
|
|
114
|
+
coords={d: win[d].values for d in dims if d in win.coords})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def emission_max(spec: xr.DataArray, lo: float, hi: float) -> xr.DataArray:
|
|
118
|
+
"""Maximum of ``spec`` over emission ``lo``-``hi``; NaN if any point there is NaN."""
|
|
119
|
+
win = _em_window(spec, lo, hi)
|
|
120
|
+
return win.max("em", skipna=False)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _warn_nan(result: xr.DataArray, name: str) -> None:
|
|
124
|
+
if "sample" in result.dims and result.isnull().any():
|
|
125
|
+
bad = list(result["sample"].values[result.isnull().values])
|
|
126
|
+
warnings.warn(f"{name}: NaN for {len(bad)} sample(s) (missing data in the window, "
|
|
127
|
+
f"e.g. removed scatter): {bad[:5]}", EEMKitWarning, stacklevel=3)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
# Indices
|
|
132
|
+
# --------------------------------------------------------------------------- #
|
|
133
|
+
|
|
134
|
+
def fluorescence_index(ds: xr.Dataset, *, ex: float = 370.0,
|
|
135
|
+
em: tuple[float, float] = (470.0, 520.0),
|
|
136
|
+
ex_method: str = "exact") -> xr.DataArray:
|
|
137
|
+
"""FI = I(ex, em[0]) / I(ex, em[1]) (Cory & McKnight 2005)."""
|
|
138
|
+
spec = spectrum_at_ex(ds["intensity"], ex, ex_method)
|
|
139
|
+
fi = _interp_em(spec, em[0]) / _interp_em(spec, em[1])
|
|
140
|
+
_warn_nan(fi, "FI")
|
|
141
|
+
return fi.rename("FI")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def humification_index(ds: xr.Dataset, *, ex: float = 254.0,
|
|
145
|
+
high: tuple[float, float] = (435.0, 480.0),
|
|
146
|
+
low: tuple[float, float] = (300.0, 345.0),
|
|
147
|
+
ex_method: str) -> xr.DataArray:
|
|
148
|
+
"""HIX = H / (H + L) with emission areas H and L at ``ex`` (Ohno 2002).
|
|
149
|
+
|
|
150
|
+
``ex_method`` has no default because the standard 254 nm is rarely on the
|
|
151
|
+
measured grid and the choice must be explicit.
|
|
152
|
+
"""
|
|
153
|
+
spec = spectrum_at_ex(ds["intensity"], ex, ex_method)
|
|
154
|
+
h = emission_area(spec, *high)
|
|
155
|
+
lo_ = emission_area(spec, *low)
|
|
156
|
+
hix = h / (h + lo_)
|
|
157
|
+
_warn_nan(hix, "HIX")
|
|
158
|
+
return hix.rename("HIX")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def biological_index(ds: xr.Dataset, *, ex: float = 310.0, em_num: float = 380.0,
|
|
162
|
+
em_den: float | tuple[float, float] = 430.0,
|
|
163
|
+
ex_method: str = "exact") -> xr.DataArray:
|
|
164
|
+
"""BIX = I(ex, em_num) / I(ex, em_den) (Huguet et al. 2009).
|
|
165
|
+
|
|
166
|
+
If ``em_den`` is a ``(min, max)`` range, the denominator is the maximum
|
|
167
|
+
over that range (as in the beta:alpha 'freshness' index).
|
|
168
|
+
"""
|
|
169
|
+
spec = spectrum_at_ex(ds["intensity"], ex, ex_method)
|
|
170
|
+
den = emission_max(spec, *em_den) if isinstance(em_den, tuple) else _interp_em(spec, em_den)
|
|
171
|
+
bix = _interp_em(spec, em_num) / den
|
|
172
|
+
_warn_nan(bix, "BIX")
|
|
173
|
+
return bix.rename("BIX")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def peaks(ds: xr.Dataset, definitions: Mapping[str, tuple[float, tuple[float, float]]],
|
|
177
|
+
*, ex_method: str) -> xr.Dataset:
|
|
178
|
+
"""Peak intensities: for each name, max over the emission range at the given excitation."""
|
|
179
|
+
out = {}
|
|
180
|
+
for name, (ex, (lo, hi)) in definitions.items():
|
|
181
|
+
spec = spectrum_at_ex(ds["intensity"], ex, ex_method)
|
|
182
|
+
v = emission_max(spec, lo, hi)
|
|
183
|
+
_warn_nan(v, f"peak {name}")
|
|
184
|
+
out[name] = v.assign_attrs(ex_nm=ex, em_nm=[lo, hi],
|
|
185
|
+
units=ds["intensity"].attrs.get("units", ""))
|
|
186
|
+
return xr.Dataset(out)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def coble_peaks(ds: xr.Dataset, *, ex_method: str) -> xr.Dataset:
|
|
190
|
+
"""The five peaks of Coble (1996); see :data:`COBLE_1996`."""
|
|
191
|
+
return peaks(ds, COBLE_1996, ex_method=ex_method)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# --------------------------------------------------------------------------- #
|
|
195
|
+
# Table and export
|
|
196
|
+
# --------------------------------------------------------------------------- #
|
|
197
|
+
|
|
198
|
+
def compute_indices(
|
|
199
|
+
ds: xr.Dataset,
|
|
200
|
+
*,
|
|
201
|
+
ex_method: str,
|
|
202
|
+
fi: Mapping | None = None,
|
|
203
|
+
hix: Mapping | None = None,
|
|
204
|
+
bix: Mapping | None = None,
|
|
205
|
+
peak_definitions: Mapping[str, tuple[float, tuple[float, float]]] | None = None,
|
|
206
|
+
) -> pd.DataFrame:
|
|
207
|
+
"""All indices in one table (one row per sample) with the settings in ``df.attrs``.
|
|
208
|
+
|
|
209
|
+
``fi``, ``hix``, ``bix`` are keyword overrides for the respective
|
|
210
|
+
functions; ``peak_definitions`` defaults to :data:`COBLE_1996`.
|
|
211
|
+
``ex_method`` applies to every index and peak.
|
|
212
|
+
"""
|
|
213
|
+
fi, hix, bix = dict(fi or {}), dict(hix or {}), dict(bix or {})
|
|
214
|
+
peak_definitions = dict(peak_definitions or COBLE_1996)
|
|
215
|
+
cols = {
|
|
216
|
+
"FI": fluorescence_index(ds, ex_method=ex_method, **fi),
|
|
217
|
+
"HIX": humification_index(ds, ex_method=ex_method, **hix),
|
|
218
|
+
"BIX": biological_index(ds, ex_method=ex_method, **bix),
|
|
219
|
+
}
|
|
220
|
+
pk = peaks(ds, peak_definitions, ex_method=ex_method)
|
|
221
|
+
cols.update({f"peak_{k}": pk[k] for k in pk.data_vars})
|
|
222
|
+
df = pd.DataFrame({k: v.to_series() for k, v in cols.items()})
|
|
223
|
+
df.index.name = "sample"
|
|
224
|
+
df.attrs["settings"] = {
|
|
225
|
+
"ex_method": ex_method,
|
|
226
|
+
"FI": {"reference": "Cory & McKnight 2005", "ex": 370.0, "em": [470.0, 520.0], **fi},
|
|
227
|
+
"HIX": {"reference": "Ohno 2002", "ex": 254.0, "high": [435.0, 480.0],
|
|
228
|
+
"low": [300.0, 345.0], "integration": "trapezoidal", **hix},
|
|
229
|
+
"BIX": {"reference": "Huguet et al. 2009", "ex": 310.0, "em_num": 380.0,
|
|
230
|
+
"em_den": 430.0, **bix},
|
|
231
|
+
"peaks": {"reference": "Coble 1996" if peak_definitions == COBLE_1996 else "custom",
|
|
232
|
+
"definitions": {k: [v[0], list(v[1])] for k, v in peak_definitions.items()},
|
|
233
|
+
"units": ds["intensity"].attrs.get("units", "")},
|
|
234
|
+
"spectral_correction": ds.attrs.get("spectral_correction", "none"),
|
|
235
|
+
"processing_history": get_history(ds),
|
|
236
|
+
}
|
|
237
|
+
return df
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def export_indices(df: pd.DataFrame, path: str | Path, ds: xr.Dataset | None = None,
|
|
241
|
+
metadata: tuple[str, ...] = ()) -> tuple[Path, Path]:
|
|
242
|
+
"""Write the table as CSV plus a JSON sidecar (``<name>.json``) with all settings.
|
|
243
|
+
|
|
244
|
+
``metadata`` lists ``sample`` coordinates of ``ds`` (e.g. river, site) to
|
|
245
|
+
prepend as columns.
|
|
246
|
+
"""
|
|
247
|
+
path = Path(path)
|
|
248
|
+
out = df.copy()
|
|
249
|
+
if ds is not None:
|
|
250
|
+
for name in reversed(metadata):
|
|
251
|
+
out.insert(0, name, ds[name].to_series().reindex(out.index).values)
|
|
252
|
+
out.to_csv(path, float_format="%.6g")
|
|
253
|
+
side = path.with_suffix(".json")
|
|
254
|
+
side.write_text(json.dumps(df.attrs.get("settings", {}), indent=2, default=str),
|
|
255
|
+
encoding="utf-8")
|
|
256
|
+
return path, side
|
eemkit/io/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Instrument readers and the pairing table."""
|
eemkit/io/_cary_csv.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Low-level parser shared by the Cary Eclipse and Cary 60 CSV readers.
|
|
2
|
+
|
|
3
|
+
Both Agilent/Varian Cary programs export "ASCII with log" files laid out as::
|
|
4
|
+
|
|
5
|
+
row 1 one label per scan, each followed by an empty cell
|
|
6
|
+
row 2 column titles, two per scan (x, y)
|
|
7
|
+
rows data, until the first empty line
|
|
8
|
+
rest one log block per scan: the scan label, 'Collection Time: ...',
|
|
9
|
+
a 'Parameter List :' section of 'key<2+ spaces>value' lines,
|
|
10
|
+
then 'Method Log :' with the method history
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import warnings
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
from eemkit.errors import EEMKitWarning, FileFormatError
|
|
24
|
+
|
|
25
|
+
ENCODINGS = ("utf-8-sig", "cp1252", "latin-1")
|
|
26
|
+
TIME_FORMATS = ("%m/%d/%Y %I:%M:%S %p", "%m/%d/%Y %H:%M:%S", "%d/%m/%Y %H:%M:%S")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class ScanLog:
|
|
31
|
+
label: str
|
|
32
|
+
collection_time: datetime | None
|
|
33
|
+
params: dict[str, str] = field(default_factory=dict)
|
|
34
|
+
method_name: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class CaryCSV:
|
|
39
|
+
path: Path
|
|
40
|
+
labels: list[str]
|
|
41
|
+
titles: list[str]
|
|
42
|
+
x: np.ndarray # (n_rows, n_scans)
|
|
43
|
+
y: np.ndarray # (n_rows, n_scans)
|
|
44
|
+
logs: list[ScanLog]
|
|
45
|
+
encoding: str
|
|
46
|
+
|
|
47
|
+
def log_for(self, label: str) -> ScanLog | None:
|
|
48
|
+
return next((lg for lg in self.logs if lg.label == label), None)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_text(path: Path) -> tuple[list[str], str]:
|
|
52
|
+
raw = Path(path).read_bytes()
|
|
53
|
+
for enc in ENCODINGS:
|
|
54
|
+
try:
|
|
55
|
+
return raw.decode(enc).splitlines(), enc
|
|
56
|
+
except UnicodeDecodeError:
|
|
57
|
+
continue
|
|
58
|
+
raise AssertionError("latin-1 decodes any byte string")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_cary_csv(path: str | Path) -> CaryCSV:
|
|
62
|
+
"""Parse a Cary 'ASCII with log' CSV into labels, x/y arrays and scan logs.
|
|
63
|
+
|
|
64
|
+
Empty cells become NaN; any other non-numeric cell is an error.
|
|
65
|
+
"""
|
|
66
|
+
path = Path(path)
|
|
67
|
+
lines, enc = read_text(path)
|
|
68
|
+
if len(lines) < 3:
|
|
69
|
+
raise FileFormatError(f"{path}: too short to be a Cary CSV export")
|
|
70
|
+
labels = [t.strip() for t in lines[0].split(",") if t.strip()]
|
|
71
|
+
if not labels:
|
|
72
|
+
raise FileFormatError(f"{path}: first row has no scan labels")
|
|
73
|
+
n = len(labels)
|
|
74
|
+
titles = [t.strip() for t in lines[1].split(",")][: 2 * n]
|
|
75
|
+
|
|
76
|
+
rows = []
|
|
77
|
+
i = 2
|
|
78
|
+
while i < len(lines) and lines[i].strip():
|
|
79
|
+
rows.append(lines[i])
|
|
80
|
+
i += 1
|
|
81
|
+
if not rows:
|
|
82
|
+
raise FileFormatError(f"{path}: no data rows")
|
|
83
|
+
|
|
84
|
+
data = np.full((len(rows), 2 * n), np.nan)
|
|
85
|
+
for r, row in enumerate(rows):
|
|
86
|
+
toks = row.split(",")
|
|
87
|
+
if any(t.strip() for t in toks[2 * n:]):
|
|
88
|
+
raise FileFormatError(
|
|
89
|
+
f"{path}, line {r + 3}: more values than the {n} labelled scans"
|
|
90
|
+
)
|
|
91
|
+
for c, tok in enumerate(toks[: 2 * n]):
|
|
92
|
+
tok = tok.strip()
|
|
93
|
+
if not tok:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
data[r, c] = float(tok)
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise FileFormatError(
|
|
99
|
+
f"{path}, line {r + 3}, column {c + 1}: not a number: {tok!r}"
|
|
100
|
+
) from None
|
|
101
|
+
|
|
102
|
+
return CaryCSV(path, labels, titles, data[:, 0::2], data[:, 1::2],
|
|
103
|
+
parse_log(lines[i + 1:]), enc)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parse_log(lines: list[str]) -> list[ScanLog]:
|
|
107
|
+
logs: list[ScanLog] = []
|
|
108
|
+
current: ScanLog | None = None
|
|
109
|
+
in_params = False
|
|
110
|
+
prev = ""
|
|
111
|
+
for line in lines:
|
|
112
|
+
s = line.rstrip()
|
|
113
|
+
if s.startswith("Collection Time:"):
|
|
114
|
+
current = ScanLog(prev.strip().rstrip(","), parse_time(s.split(":", 1)[1].strip()))
|
|
115
|
+
logs.append(current)
|
|
116
|
+
in_params = False
|
|
117
|
+
elif current is not None:
|
|
118
|
+
if s.startswith("Parameter List"):
|
|
119
|
+
in_params = True
|
|
120
|
+
elif s.startswith("Method Log"):
|
|
121
|
+
in_params = False
|
|
122
|
+
elif s.startswith("Method Name"):
|
|
123
|
+
current.method_name = s.split(":", 1)[1].strip()
|
|
124
|
+
elif in_params:
|
|
125
|
+
parts = re.split(r"\s{2,}", s.strip())
|
|
126
|
+
if len(parts) >= 2:
|
|
127
|
+
current.params[" ".join(parts[:-1])] = parts[-1].strip()
|
|
128
|
+
if s.strip():
|
|
129
|
+
prev = s
|
|
130
|
+
return logs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_time(text: str) -> datetime | None:
|
|
134
|
+
for fmt in TIME_FORMATS:
|
|
135
|
+
try:
|
|
136
|
+
return datetime.strptime(text, fmt)
|
|
137
|
+
except ValueError:
|
|
138
|
+
continue
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def common_params(
|
|
143
|
+
path: Path, logs: list[ScanLog], keys: dict[str, str]
|
|
144
|
+
) -> dict[str, str]:
|
|
145
|
+
"""Map attribute names to log values that are shared by all scans.
|
|
146
|
+
|
|
147
|
+
A parameter that differs between scans is kept as 'v1|v2' with a warning;
|
|
148
|
+
one that is absent from every log is omitted.
|
|
149
|
+
"""
|
|
150
|
+
out = {}
|
|
151
|
+
for attr, key in keys.items():
|
|
152
|
+
vals = [lg.params[key] for lg in logs if key in lg.params]
|
|
153
|
+
if not vals:
|
|
154
|
+
continue
|
|
155
|
+
uniq = list(dict.fromkeys(vals))
|
|
156
|
+
if len(uniq) > 1:
|
|
157
|
+
warnings.warn(f"{path.name}: '{key}' differs between scans: {uniq}",
|
|
158
|
+
EEMKitWarning, stacklevel=3)
|
|
159
|
+
out[attr] = "|".join(uniq)
|
|
160
|
+
return out
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def scan_times(logs: list[ScanLog | None]) -> np.ndarray:
|
|
164
|
+
return np.array(
|
|
165
|
+
[np.datetime64(lg.collection_time, "s") if lg and lg.collection_time
|
|
166
|
+
else np.datetime64("NaT", "s") for lg in logs]
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def time_attrs(times: np.ndarray) -> dict[str, str]:
|
|
171
|
+
valid = times[~np.isnat(times)]
|
|
172
|
+
if valid.size == 0:
|
|
173
|
+
return {}
|
|
174
|
+
return {"collection_start": str(valid.min()), "collection_end": str(valid.max())}
|
eemkit/io/cary60.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Reader for Agilent Cary 60 UV-Vis absorbance CSV exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import xarray as xr
|
|
11
|
+
|
|
12
|
+
from eemkit.core.history import with_history
|
|
13
|
+
from eemkit.errors import EEMKitWarning, FileFormatError
|
|
14
|
+
from eemkit.io._cary_csv import common_params, parse_cary_csv, scan_times, time_attrs
|
|
15
|
+
|
|
16
|
+
LABEL_RE = re.compile(r"^(?P<name>.*)_(?P<cycle>[0-9]+)$")
|
|
17
|
+
|
|
18
|
+
LOG_PARAMS = {
|
|
19
|
+
"instrument": "Instrument",
|
|
20
|
+
"start_nm": "Start (nm)",
|
|
21
|
+
"stop_nm": "Stop (nm)",
|
|
22
|
+
"y_mode": "Y Mode",
|
|
23
|
+
"scan_rate_nm_per_min": "UV-Vis Scan Rate (nm/min)",
|
|
24
|
+
"data_interval_nm": "UV-Vis Data Interval (nm)",
|
|
25
|
+
"averaging_time_s": "UV-Vis Ave. Time (sec)",
|
|
26
|
+
"beam_mode": "Beam Mode",
|
|
27
|
+
"baseline_correction": "Baseline Correction",
|
|
28
|
+
"cycle_mode": "Cycle Mode",
|
|
29
|
+
"cycle_count": "Cycle Count",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def read_cary60(path: str | Path) -> xr.Dataset:
|
|
34
|
+
"""Read one absorbance measurement (one or more cycles) from a Cary 60 CSV.
|
|
35
|
+
|
|
36
|
+
Returns
|
|
37
|
+
-------
|
|
38
|
+
xarray.Dataset
|
|
39
|
+
``absorbance`` with dims ``(cycle, wavelength)``, wavelength ascending
|
|
40
|
+
(the instrument scans downwards), coordinate ``scan_time(cycle)`` and
|
|
41
|
+
the instrument settings as attributes. Values are as exported: no
|
|
42
|
+
blank subtraction, baseline or path-length normalisation.
|
|
43
|
+
"""
|
|
44
|
+
path = Path(path)
|
|
45
|
+
raw = parse_cary_csv(path)
|
|
46
|
+
|
|
47
|
+
names, cycles = [], []
|
|
48
|
+
for lab in raw.labels:
|
|
49
|
+
m = LABEL_RE.match(lab)
|
|
50
|
+
if not m:
|
|
51
|
+
raise FileFormatError(f"{path}: column label {lab!r} is not '<sample>_<cycle>'")
|
|
52
|
+
names.append(m.group("name"))
|
|
53
|
+
cycles.append(int(m.group("cycle")))
|
|
54
|
+
if len(set(names)) > 1:
|
|
55
|
+
raise FileFormatError(f"{path}: several sample names in one file: {sorted(set(names))}")
|
|
56
|
+
if len(set(cycles)) != len(cycles):
|
|
57
|
+
raise FileFormatError(f"{path}: duplicated cycle numbers")
|
|
58
|
+
if raw.titles[:2] != ["Wavelength (nm)", "Abs"]:
|
|
59
|
+
raise FileFormatError(f"{path}: unexpected column titles {raw.titles[:2]} "
|
|
60
|
+
"(expected absorbance mode)")
|
|
61
|
+
|
|
62
|
+
x, y = raw.x, raw.y
|
|
63
|
+
keep = ~np.all(np.isnan(x), axis=1)
|
|
64
|
+
x, y = x[keep], y[keep]
|
|
65
|
+
wl = x[:, 0]
|
|
66
|
+
if np.isnan(wl).any() or not np.allclose(x, wl[:, None], atol=1e-6, equal_nan=True):
|
|
67
|
+
raise FileFormatError(f"{path}: the wavelength axis differs between cycles")
|
|
68
|
+
order = np.argsort(wl)
|
|
69
|
+
wl, absorb = wl[order], y[order].T
|
|
70
|
+
if np.any(np.diff(wl) <= 0):
|
|
71
|
+
raise FileFormatError(f"{path}: repeated wavelengths")
|
|
72
|
+
|
|
73
|
+
logs = [raw.log_for(lab) for lab in raw.labels]
|
|
74
|
+
present = [lg for lg in logs if lg is not None]
|
|
75
|
+
if len(present) < len(logs):
|
|
76
|
+
warnings.warn(f"{path.name}: no log block for {len(logs) - len(present)} cycle(s)",
|
|
77
|
+
EEMKitWarning, stacklevel=2)
|
|
78
|
+
times = scan_times(logs)
|
|
79
|
+
attrs = {
|
|
80
|
+
"sample_name": names[0],
|
|
81
|
+
"source_file": str(path),
|
|
82
|
+
"file_format": "Cary 60 CSV (ASCII with log)",
|
|
83
|
+
**common_params(path, present, LOG_PARAMS),
|
|
84
|
+
**time_attrs(times),
|
|
85
|
+
"method_name": "|".join(dict.fromkeys(lg.method_name for lg in present)),
|
|
86
|
+
}
|
|
87
|
+
if "cycle_count" in attrs and attrs["cycle_count"] != str(len(cycles)):
|
|
88
|
+
warnings.warn(f"{path.name}: log says {attrs['cycle_count']} cycles, "
|
|
89
|
+
f"file contains {len(cycles)}", EEMKitWarning, stacklevel=2)
|
|
90
|
+
|
|
91
|
+
ds = xr.Dataset(
|
|
92
|
+
{"absorbance": (("cycle", "wavelength"), absorb,
|
|
93
|
+
{"units": "1", "long_name": "absorbance (decadic)"})},
|
|
94
|
+
coords={
|
|
95
|
+
"cycle": ("cycle", np.asarray(cycles)),
|
|
96
|
+
"wavelength": ("wavelength", wl, {"units": "nm", "long_name": "wavelength"}),
|
|
97
|
+
"scan_time": ("cycle", times),
|
|
98
|
+
},
|
|
99
|
+
attrs=attrs,
|
|
100
|
+
)
|
|
101
|
+
return with_history(ds, "read_cary60")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def mean_cycles(ds: xr.Dataset) -> xr.Dataset:
|
|
105
|
+
"""Average repeated cycles: returns ``absorbance`` and ``absorbance_sd`` (wavelength).
|
|
106
|
+
|
|
107
|
+
The standard deviation uses ``ddof=1`` and is NaN for a single cycle.
|
|
108
|
+
"""
|
|
109
|
+
a = ds["absorbance"]
|
|
110
|
+
n = a.sizes["cycle"]
|
|
111
|
+
out = xr.Dataset(
|
|
112
|
+
{
|
|
113
|
+
"absorbance": a.mean("cycle").assign_attrs(a.attrs),
|
|
114
|
+
"absorbance_sd": (a.std("cycle", ddof=1) if n > 1
|
|
115
|
+
else xr.full_like(a.isel(cycle=0, drop=True), np.nan))
|
|
116
|
+
.assign_attrs(units="1", long_name="standard deviation between cycles"),
|
|
117
|
+
},
|
|
118
|
+
attrs=dict(ds.attrs, n_cycles=n),
|
|
119
|
+
)
|
|
120
|
+
return with_history(out, "mean_cycles", n_cycles=n)
|