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.
Files changed (49) hide show
  1. eemkit/__init__.py +51 -0
  2. eemkit/absorbance.py +216 -0
  3. eemkit/campaigns.py +276 -0
  4. eemkit/core/__init__.py +1 -0
  5. eemkit/core/dataset.py +91 -0
  6. eemkit/core/grid.py +100 -0
  7. eemkit/core/history.py +78 -0
  8. eemkit/corrections/__init__.py +44 -0
  9. eemkit/corrections/_common.py +58 -0
  10. eemkit/corrections/blank.py +66 -0
  11. eemkit/corrections/crop.py +33 -0
  12. eemkit/corrections/inner_filter.py +123 -0
  13. eemkit/corrections/raman.py +107 -0
  14. eemkit/corrections/scatter.py +191 -0
  15. eemkit/corrections/spectral.py +117 -0
  16. eemkit/errors.py +37 -0
  17. eemkit/gui/__init__.py +30 -0
  18. eemkit/gui/__main__.py +5 -0
  19. eemkit/gui/campaigns_tab.py +275 -0
  20. eemkit/gui/common.py +395 -0
  21. eemkit/gui/corrections_tab.py +334 -0
  22. eemkit/gui/export_tab.py +137 -0
  23. eemkit/gui/indices_tab.py +228 -0
  24. eemkit/gui/main_window.py +258 -0
  25. eemkit/gui/parafac_tab.py +399 -0
  26. eemkit/gui/project_tab.py +332 -0
  27. eemkit/gui/state.py +149 -0
  28. eemkit/gui/theme.py +125 -0
  29. eemkit/indices.py +256 -0
  30. eemkit/io/__init__.py +1 -0
  31. eemkit/io/_cary_csv.py +174 -0
  32. eemkit/io/cary60.py +120 -0
  33. eemkit/io/cary_eclipse.py +140 -0
  34. eemkit/io/discover.py +176 -0
  35. eemkit/io/loader.py +158 -0
  36. eemkit/io/pairing.py +115 -0
  37. eemkit/parafac/__init__.py +28 -0
  38. eemkit/parafac/_cp.py +125 -0
  39. eemkit/parafac/diagnostics.py +56 -0
  40. eemkit/parafac/export.py +54 -0
  41. eemkit/parafac/model.py +191 -0
  42. eemkit/parafac/validation.py +111 -0
  43. eemkit/pipeline.py +560 -0
  44. eemkit/plot.py +437 -0
  45. eemkit-0.1.0.dist-info/METADATA +195 -0
  46. eemkit-0.1.0.dist-info/RECORD +49 -0
  47. eemkit-0.1.0.dist-info/WHEEL +4 -0
  48. eemkit-0.1.0.dist-info/entry_points.txt +2 -0
  49. 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)