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/pipeline.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""The full processing chain, driven by one settings file.
|
|
2
|
+
|
|
3
|
+
The settings are a nested dictionary, stored as TOML, with the sections
|
|
4
|
+
``project``, ``spectral``, ``inner_filter``, ``raman``, ``scatter``, ``crop``,
|
|
5
|
+
``indices``, ``absorbance``, ``campaigns``, ``parafac`` (with ``split_half``
|
|
6
|
+
and ``final``), ``missing`` and ``display``. The same file drives scripts and
|
|
7
|
+
the desktop application, so every analysis can be rerun from the command line.
|
|
8
|
+
|
|
9
|
+
Steps, in the enforced order of the corrections:
|
|
10
|
+
|
|
11
|
+
1. :func:`load` - read the samples included in the pairing table;
|
|
12
|
+
2. :func:`correct_until_raman` - optional instrument spectral correction,
|
|
13
|
+
absorbance blank, inner filter, EEM blank, Raman units;
|
|
14
|
+
:func:`finish_corrections` - scatter, optional interpolation, crop;
|
|
15
|
+
3. :func:`compute_indices` - on the Raman-normalised EEMs, before scatter
|
|
16
|
+
removal; :func:`compute_absorbance` - CDOM metrics;
|
|
17
|
+
4. :func:`explore` - PARAFAC for a range of ranks with split-half
|
|
18
|
+
validation; :func:`fit_final` - the chosen rank with tighter convergence;
|
|
19
|
+
5. :func:`campaign_analysis` - differences between campaigns, along sites
|
|
20
|
+
and with covariates, on any column of :func:`sample_table`;
|
|
21
|
+
6. :func:`export_results` - tables, OpenFluor file, figures, settings and
|
|
22
|
+
processing history; :func:`export_campaigns` for campaign analyses.
|
|
23
|
+
|
|
24
|
+
Defaults that are scientific choices are listed in :data:`DEFAULTS` with
|
|
25
|
+
their source; values that depend on the measurement (cuvette path lengths)
|
|
26
|
+
have no default and must be set.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import copy
|
|
32
|
+
import json
|
|
33
|
+
import platform
|
|
34
|
+
import re
|
|
35
|
+
import tomllib
|
|
36
|
+
from collections.abc import Callable
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
import pandas as pd
|
|
42
|
+
import xarray as xr
|
|
43
|
+
|
|
44
|
+
from eemkit import absorbance as A
|
|
45
|
+
from eemkit import campaigns as K
|
|
46
|
+
from eemkit import corrections as C
|
|
47
|
+
from eemkit import indices as I
|
|
48
|
+
from eemkit import parafac as P
|
|
49
|
+
from eemkit.core.history import get_history
|
|
50
|
+
from eemkit.errors import Cancelled, SettingsError
|
|
51
|
+
from eemkit.io.loader import load_dataset
|
|
52
|
+
from eemkit.io.pairing import read_pairing
|
|
53
|
+
|
|
54
|
+
Progress = Callable[[str, int, int], None]
|
|
55
|
+
|
|
56
|
+
#: Default settings. ``None`` means "no default: must be provided".
|
|
57
|
+
DEFAULTS: dict = {
|
|
58
|
+
"project": {"data_root": "", "pairing": "", "output_dir": ""},
|
|
59
|
+
# Instrument-specific factor files (wavelength, factor); empty = not applied.
|
|
60
|
+
# convention: "multiply" (correction factors) or "divide" (response spectra).
|
|
61
|
+
"spectral": {"ex_factors": "", "em_factors": "", "convention": "multiply"},
|
|
62
|
+
# Kothawala et al. (2013): absorbance-based inner-filter correction
|
|
63
|
+
"inner_filter": {"absorbance_path_length_cm": None, "fluorescence_path_length_cm": None},
|
|
64
|
+
# Lawaetz & Stedmon (2009): Raman area of the blank at ex 350, em 381-426 nm
|
|
65
|
+
"raman": {"ex": 350.0, "em_range": [381.0, 426.0], "baseline": False},
|
|
66
|
+
# Band widths (below, above) in nm. 10 nm is the eemR/staRdom default width;
|
|
67
|
+
# check them against the blanks. Water Raman shift ~3400 cm-1 (Lakowicz 2006).
|
|
68
|
+
"scatter": {"raman_shift_cm1": 3400.0, "rayleigh1": [10.0, 10.0], "raman1": [10.0, 10.0],
|
|
69
|
+
"rayleigh2": [10.0, 10.0], "raman2": [10.0, 10.0],
|
|
70
|
+
"zero_below_rayleigh": None, "interpolate": False},
|
|
71
|
+
"crop": {"ex_min": None, "ex_max": None, "em_min": None, "em_max": None},
|
|
72
|
+
"indices": {"ex_method": "linear", "bix_em_den": 430.0},
|
|
73
|
+
# CDOM metrics. Baseline: mean absorbance 680-700 nm subtracted (Green & Blough 1994;
|
|
74
|
+
# Helms et al. 2008). min_r2: eemkit quality threshold for the log-linear slope fits,
|
|
75
|
+
# not a literature value. doc_column: sample column with DOC (mg/L), for SUVA254.
|
|
76
|
+
"absorbance": {"baseline": [680.0, 700.0], "min_r2": 0.95, "doc_column": ""},
|
|
77
|
+
# Comparison of campaigns, sites and rivers: names of sample columns, and optional
|
|
78
|
+
# site table (by group and site) and covariate table (by group and time).
|
|
79
|
+
"campaigns": {"group": "", "site": "", "time": "", "position": "", "sites_table": "",
|
|
80
|
+
"covariates_table": ""},
|
|
81
|
+
# Murphy et al. (2013)
|
|
82
|
+
"parafac": {
|
|
83
|
+
"ranks": [2, 3, 4, 5, 6, 7], "n_init": 10, "normalization": "norm", "tol": 1e-6,
|
|
84
|
+
"max_iter": 5000, "random_state": 0, "exclude": [],
|
|
85
|
+
"split_half": {"n_init": 5, "stratify": [], "random_state": 0, "threshold": 0.95},
|
|
86
|
+
"final": {"rank": None, "tol": 1e-8, "max_iter": 20000, "n_init": 20},
|
|
87
|
+
},
|
|
88
|
+
"missing": {"policy": "error"},
|
|
89
|
+
# EEM colour map of the figures: "eclipse" (Cary Eclipse rainbow) or "blues"
|
|
90
|
+
"display": {"eem_colours": "eclipse"},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --------------------------------------------------------------------------- #
|
|
95
|
+
# Settings
|
|
96
|
+
# --------------------------------------------------------------------------- #
|
|
97
|
+
|
|
98
|
+
def _merge(base: dict, over: dict) -> dict:
|
|
99
|
+
out = copy.deepcopy(base)
|
|
100
|
+
for k, v in over.items():
|
|
101
|
+
out[k] = _merge(out[k], v) if isinstance(v, dict) and isinstance(out.get(k), dict) \
|
|
102
|
+
else copy.deepcopy(v)
|
|
103
|
+
return out
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def default_settings() -> dict:
|
|
107
|
+
"""A fresh copy of :data:`DEFAULTS`."""
|
|
108
|
+
return copy.deepcopy(DEFAULTS)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_settings(path: str | Path) -> dict:
|
|
112
|
+
"""Read a settings TOML; keys it does not set take :data:`DEFAULTS`."""
|
|
113
|
+
return _merge(DEFAULTS, tomllib.loads(Path(path).read_text(encoding="utf-8")))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _toml_value(v) -> str:
|
|
117
|
+
if isinstance(v, bool):
|
|
118
|
+
return "true" if v else "false"
|
|
119
|
+
if isinstance(v, int | float | np.integer | np.floating):
|
|
120
|
+
return repr(float(v)) if isinstance(v, float | np.floating) else str(int(v))
|
|
121
|
+
if isinstance(v, str):
|
|
122
|
+
return json.dumps(v, ensure_ascii=False)
|
|
123
|
+
if isinstance(v, list | tuple):
|
|
124
|
+
return "[" + ", ".join(_toml_value(x) for x in v) + "]"
|
|
125
|
+
raise TypeError(f"cannot write {type(v).__name__} to TOML")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def dumps_settings(settings: dict) -> str:
|
|
129
|
+
"""Settings as TOML text. ``None`` values (unset) are written as comments."""
|
|
130
|
+
lines: list[str] = ["# eemkit settings", ""]
|
|
131
|
+
|
|
132
|
+
def table(name: str, d: dict) -> None:
|
|
133
|
+
scalars = {k: v for k, v in d.items() if not isinstance(v, dict)}
|
|
134
|
+
if name:
|
|
135
|
+
lines.append(f"[{name}]")
|
|
136
|
+
for k, v in scalars.items():
|
|
137
|
+
lines.append(f"# {k} = (not set)" if v is None else f"{k} = {_toml_value(v)}")
|
|
138
|
+
if name:
|
|
139
|
+
lines.append("")
|
|
140
|
+
for k, v in d.items():
|
|
141
|
+
if isinstance(v, dict):
|
|
142
|
+
table(f"{name}.{k}" if name else k, v)
|
|
143
|
+
|
|
144
|
+
table("", settings)
|
|
145
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def save_settings(settings: dict, path: str | Path) -> Path:
|
|
149
|
+
path = Path(path)
|
|
150
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
path.write_text(dumps_settings(settings), encoding="utf-8")
|
|
152
|
+
return path
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _required(settings: dict, section: str, key: str) -> float:
|
|
156
|
+
v = settings.get(section, {}).get(key)
|
|
157
|
+
if v in (None, ""):
|
|
158
|
+
raise SettingsError(f"{section}.{key} is not set; it depends on your measurement and "
|
|
159
|
+
"has no default")
|
|
160
|
+
return float(v)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _resolve(settings: dict, key: str, base: Path | None, section: str = "project") -> Path:
|
|
164
|
+
v = settings[section].get(key, "")
|
|
165
|
+
if not v:
|
|
166
|
+
raise SettingsError(f"{section}.{key} is not set")
|
|
167
|
+
p = Path(v)
|
|
168
|
+
return p if p.is_absolute() or base is None else base / p
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --------------------------------------------------------------------------- #
|
|
172
|
+
# Steps
|
|
173
|
+
# --------------------------------------------------------------------------- #
|
|
174
|
+
|
|
175
|
+
def _check(cancelled: Callable[[], bool] | None) -> None:
|
|
176
|
+
if cancelled is not None and cancelled():
|
|
177
|
+
raise Cancelled("cancelled")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def load(settings: dict, base: str | Path | None = None) -> xr.Dataset:
|
|
181
|
+
"""Read the included rows of ``project.pairing`` (paths relative to ``base``)."""
|
|
182
|
+
base = Path(base) if base is not None else None
|
|
183
|
+
root = _resolve(settings, "data_root", base)
|
|
184
|
+
table = read_pairing(_resolve(settings, "pairing", base), data_root=root)
|
|
185
|
+
return load_dataset(table, data_root=root)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def correct_until_raman(ds: xr.Dataset, settings: dict,
|
|
189
|
+
base: str | Path | None = None) -> xr.Dataset:
|
|
190
|
+
"""Spectral correction (if factor files are set), absorbance blank, inner
|
|
191
|
+
filter, EEM blank and Raman units. Factor files are relative to ``base``."""
|
|
192
|
+
l_abs = _required(settings, "inner_filter", "absorbance_path_length_cm")
|
|
193
|
+
l_fl = _required(settings, "inner_filter", "fluorescence_path_length_cm")
|
|
194
|
+
missing = settings["missing"]["policy"]
|
|
195
|
+
sp = settings["spectral"]
|
|
196
|
+
files = [bool(sp.get(k)) for k in ("ex_factors", "em_factors")]
|
|
197
|
+
if any(files):
|
|
198
|
+
if not all(files):
|
|
199
|
+
raise SettingsError("spectral: set both ex_factors and em_factors, or neither")
|
|
200
|
+
base = Path(base) if base is not None else None
|
|
201
|
+
ds = C.apply_spectral_correction(
|
|
202
|
+
ds, ex_factors=_resolve(settings, "ex_factors", base, "spectral"),
|
|
203
|
+
em_factors=_resolve(settings, "em_factors", base, "spectral"),
|
|
204
|
+
convention=sp.get("convention", "multiply"))
|
|
205
|
+
ds = C.subtract_absorbance_blank(ds, missing=missing)
|
|
206
|
+
ds = C.correct_inner_filter(ds, absorbance_path_length_cm=l_abs,
|
|
207
|
+
fluorescence_path_length_cm=l_fl, missing=missing)
|
|
208
|
+
ds = C.subtract_blank(ds, missing=missing)
|
|
209
|
+
r = settings["raman"]
|
|
210
|
+
return C.normalize_raman(ds, ex=float(r["ex"]), em_range=tuple(r["em_range"]),
|
|
211
|
+
baseline=bool(r["baseline"]), missing=missing)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def finish_corrections(ds: xr.Dataset, settings: dict) -> xr.Dataset:
|
|
215
|
+
"""Scatter removal, optional interpolation of the gaps, crop."""
|
|
216
|
+
s = settings["scatter"]
|
|
217
|
+
widths = {b: tuple(s[b]) for b in C.scatter.BANDS if s.get(b)}
|
|
218
|
+
ds = C.remove_scatter(ds, widths=widths, raman_shift_cm1=float(s["raman_shift_cm1"]),
|
|
219
|
+
zero_below_rayleigh=s.get("zero_below_rayleigh"))
|
|
220
|
+
if s.get("interpolate"):
|
|
221
|
+
ds = C.interpolate_missing(ds, method=s["interpolate"])
|
|
222
|
+
c = settings["crop"]
|
|
223
|
+
ex = (c.get("ex_min"), c.get("ex_max"))
|
|
224
|
+
em = (c.get("em_min"), c.get("em_max"))
|
|
225
|
+
if any(v is not None for v in ex + em):
|
|
226
|
+
ds = C.crop(ds, ex=ex if any(v is not None for v in ex) else None,
|
|
227
|
+
em=em if any(v is not None for v in em) else None)
|
|
228
|
+
return ds
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def compute_indices(after_raman: xr.Dataset, settings: dict) -> pd.DataFrame:
|
|
232
|
+
"""FI, HIX, BIX and Coble peaks, with the sample metadata in front."""
|
|
233
|
+
s = settings["indices"]
|
|
234
|
+
df = I.compute_indices(after_raman, ex_method=s["ex_method"],
|
|
235
|
+
bix={"em_den": float(s["bix_em_den"])})
|
|
236
|
+
meta = [c for c in after_raman.coords
|
|
237
|
+
if after_raman[c].dims == ("sample",) and c != "sample"
|
|
238
|
+
and after_raman[c].dtype == object and not c.startswith(("eem_", "abs_"))
|
|
239
|
+
and not c.endswith("_file")]
|
|
240
|
+
for c in reversed(meta):
|
|
241
|
+
df.insert(0, c, after_raman[c].to_series().reindex(df.index).values)
|
|
242
|
+
return df
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def compute_absorbance(after_raman: xr.Dataset, settings: dict) -> pd.DataFrame:
|
|
246
|
+
"""CDOM metrics (a254, a350, spectral slopes, SR, E2:E3, SUVA254 with DOC)."""
|
|
247
|
+
s = settings["absorbance"]
|
|
248
|
+
baseline = tuple(s["baseline"]) if s.get("baseline") else None
|
|
249
|
+
return A.compute_absorbance_metrics(
|
|
250
|
+
after_raman,
|
|
251
|
+
absorbance_path_length_cm=_required(settings, "inner_filter",
|
|
252
|
+
"absorbance_path_length_cm"),
|
|
253
|
+
baseline=baseline, min_r2=float(s["min_r2"]), doc=s.get("doc_column") or None)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def parafac_input(corrected: xr.Dataset, settings: dict) -> xr.Dataset:
|
|
257
|
+
"""Drop excluded samples; add the split-half stratum coordinate."""
|
|
258
|
+
p = settings["parafac"]
|
|
259
|
+
unknown = sorted(set(p["exclude"]) - set(corrected["sample"].values))
|
|
260
|
+
if unknown:
|
|
261
|
+
raise SettingsError(f"parafac.exclude lists unknown samples: {unknown}")
|
|
262
|
+
ds = corrected.drop_sel(sample=p["exclude"]) if p["exclude"] else corrected
|
|
263
|
+
strat = p["split_half"]["stratify"]
|
|
264
|
+
if strat:
|
|
265
|
+
bad = [c for c in strat if c not in ds.coords]
|
|
266
|
+
if bad:
|
|
267
|
+
raise SettingsError(f"parafac.split_half.stratify: no sample column {bad}")
|
|
268
|
+
ds = ds.assign_coords(stratum=("sample", ["|".join(str(ds[c].values[i]) for c in strat)
|
|
269
|
+
for i in range(ds.sizes["sample"])]))
|
|
270
|
+
return ds
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _fit_kwargs(p: dict, final: dict | None = None) -> dict:
|
|
274
|
+
f = final or {}
|
|
275
|
+
return dict(normalization=p["normalization"] or None, tol=float(f.get("tol", p["tol"])),
|
|
276
|
+
max_iter=int(f.get("max_iter", p["max_iter"])),
|
|
277
|
+
random_state=int(p["random_state"]))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _split(ds: xr.Dataset, rank: int, p: dict, kw: dict) -> xr.Dataset:
|
|
281
|
+
sh = p["split_half"]
|
|
282
|
+
return P.split_half(ds, rank, stratify="stratum" if "stratum" in ds.coords else None,
|
|
283
|
+
split_random_state=int(sh["random_state"]),
|
|
284
|
+
threshold=float(sh["threshold"]), n_init=int(sh["n_init"]), **kw)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class Exploration:
|
|
289
|
+
models: dict[int, xr.Dataset]
|
|
290
|
+
splits: dict[int, xr.Dataset]
|
|
291
|
+
table: pd.DataFrame
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def explore(ds: xr.Dataset, settings: dict, *, progress: Progress | None = None,
|
|
295
|
+
cancelled: Callable[[], bool] | None = None) -> Exploration:
|
|
296
|
+
"""PARAFAC and split-half validation for every rank in ``parafac.ranks``.
|
|
297
|
+
|
|
298
|
+
``cancelled`` is polled between fits; when it returns True,
|
|
299
|
+
:class:`~eemkit.errors.Cancelled` is raised.
|
|
300
|
+
"""
|
|
301
|
+
p = settings["parafac"]
|
|
302
|
+
kw = _fit_kwargs(p)
|
|
303
|
+
ranks = [int(r) for r in p["ranks"]]
|
|
304
|
+
models, splits = {}, {}
|
|
305
|
+
for i, r in enumerate(ranks):
|
|
306
|
+
_check(cancelled)
|
|
307
|
+
if progress:
|
|
308
|
+
progress(f"{r} components: fitting", 2 * i, 2 * len(ranks))
|
|
309
|
+
models[r] = P.fit_parafac(ds, r, n_init=int(p["n_init"]), **kw)
|
|
310
|
+
_check(cancelled)
|
|
311
|
+
if progress:
|
|
312
|
+
progress(f"{r} components: split-half", 2 * i + 1, 2 * len(ranks))
|
|
313
|
+
splits[r] = _split(ds, r, p, kw)
|
|
314
|
+
if progress:
|
|
315
|
+
progress("done", 2 * len(ranks), 2 * len(ranks))
|
|
316
|
+
table = P.summary_table(models)
|
|
317
|
+
table["split_half_min_tcc"] = [splits[r].attrs["min_tcc"] for r in table.index]
|
|
318
|
+
table["split_half_validated"] = [bool(splits[r].attrs["validated"]) for r in table.index]
|
|
319
|
+
return Exploration(models, splits, table)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def fit_final(ds: xr.Dataset, settings: dict, *, progress: Progress | None = None,
|
|
323
|
+
cancelled: Callable[[], bool] | None = None) -> tuple[xr.Dataset, xr.Dataset]:
|
|
324
|
+
"""The rank in ``parafac.final.rank`` with its own convergence settings."""
|
|
325
|
+
p = settings["parafac"]
|
|
326
|
+
final = p["final"]
|
|
327
|
+
if not final.get("rank"):
|
|
328
|
+
raise SettingsError("parafac.final.rank is not set: choose the number of components "
|
|
329
|
+
"after the exploratory models")
|
|
330
|
+
r = int(final["rank"])
|
|
331
|
+
kw = _fit_kwargs(p, final)
|
|
332
|
+
if progress:
|
|
333
|
+
progress(f"final {r}-component model", 0, 2)
|
|
334
|
+
model = P.fit_parafac(ds, r, n_init=int(final["n_init"]), **kw)
|
|
335
|
+
_check(cancelled)
|
|
336
|
+
if progress:
|
|
337
|
+
progress("split-half validation", 1, 2)
|
|
338
|
+
split = _split(ds, r, p, kw)
|
|
339
|
+
if progress:
|
|
340
|
+
progress("done", 2, 2)
|
|
341
|
+
return model, split
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
# --------------------------------------------------------------------------- #
|
|
345
|
+
# Campaigns, sites and rivers
|
|
346
|
+
# --------------------------------------------------------------------------- #
|
|
347
|
+
|
|
348
|
+
def sample_table(indices: pd.DataFrame | None, model: xr.Dataset | None = None) -> pd.DataFrame:
|
|
349
|
+
"""Indices and CDOM metrics, plus Fmax and the share of each component if a model is given.
|
|
350
|
+
|
|
351
|
+
Samples left out of the model have no Fmax (empty cells).
|
|
352
|
+
"""
|
|
353
|
+
if indices is None and model is None:
|
|
354
|
+
raise SettingsError("compute the indices or fit a PARAFAC model first")
|
|
355
|
+
df = indices.copy() if indices is not None else pd.DataFrame(index=pd.Index(
|
|
356
|
+
model["sample"].values, name="sample"))
|
|
357
|
+
if model is not None:
|
|
358
|
+
fmax = model["fmax"].to_pandas()
|
|
359
|
+
fmax.columns = [f"C{c}_Fmax" for c in fmax.columns]
|
|
360
|
+
df = df.drop(columns=[c for c in fmax.columns if c in df]).join(fmax, how="left")
|
|
361
|
+
if indices is None:
|
|
362
|
+
for c in reversed([c for c in model.coords if model[c].dims == ("sample",)
|
|
363
|
+
and c != "sample" and model[c].dtype == object]):
|
|
364
|
+
df.insert(0, c, model[c].to_series().reindex(df.index).values)
|
|
365
|
+
fmax_cols = [c for c in df.columns if re.fullmatch(r"C\d+_Fmax", str(c))]
|
|
366
|
+
if fmax_cols:
|
|
367
|
+
comp = K.composition(df, fmax_cols)
|
|
368
|
+
df = df.drop(columns=[c for c in comp.columns if c in df]).join(comp)
|
|
369
|
+
return df
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@dataclass
|
|
373
|
+
class CampaignAnalysis:
|
|
374
|
+
value: str
|
|
375
|
+
units: pd.DataFrame
|
|
376
|
+
design: pd.DataFrame
|
|
377
|
+
warnings: list[str]
|
|
378
|
+
kruskal: pd.DataFrame
|
|
379
|
+
pairwise: pd.DataFrame
|
|
380
|
+
spatial: pd.DataFrame | None
|
|
381
|
+
covariates: pd.DataFrame | None
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def read_table(path: str | Path) -> pd.DataFrame:
|
|
385
|
+
return pd.read_csv(path, sep=None, engine="python", encoding="utf-8-sig")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def campaign_analysis(table: pd.DataFrame, value: str, settings: dict,
|
|
389
|
+
base: str | Path | None = None) -> CampaignAnalysis:
|
|
390
|
+
"""Differences of ``value`` between times, along sites and with covariates.
|
|
391
|
+
|
|
392
|
+
Uses ``settings["campaigns"]``: ``group``, ``time`` and optionally
|
|
393
|
+
``site``, ``position`` (a column of the site table or of ``table``) and
|
|
394
|
+
the two optional tables (paths relative to ``base``).
|
|
395
|
+
"""
|
|
396
|
+
c = settings["campaigns"]
|
|
397
|
+
group, time, site = c.get("group"), c.get("time"), c.get("site") or None
|
|
398
|
+
if not group or not time:
|
|
399
|
+
raise SettingsError("campaigns: choose the group column (e.g. river) and the time "
|
|
400
|
+
"column (e.g. campaign)")
|
|
401
|
+
base = Path(base) if base is not None else None
|
|
402
|
+
keys = {group, time} | ({site} if site else set())
|
|
403
|
+
numeric = [k for k in table.columns if k not in keys
|
|
404
|
+
and pd.api.types.is_numeric_dtype(table[k])]
|
|
405
|
+
if value not in numeric:
|
|
406
|
+
raise SettingsError(f"campaigns: {value!r} is not a numeric column of the table")
|
|
407
|
+
units = K.site_means(table.reset_index(), numeric, group=group, time=time, site=site)
|
|
408
|
+
notes: list[str] = []
|
|
409
|
+
if c.get("sites_table"):
|
|
410
|
+
if not site:
|
|
411
|
+
raise SettingsError("campaigns: a site table needs the site column")
|
|
412
|
+
units, n = K.join_sites(units, read_table(_resolve(settings, "sites_table", base,
|
|
413
|
+
"campaigns")), group=group, site=site)
|
|
414
|
+
notes += n
|
|
415
|
+
cov_cols: list[str] = []
|
|
416
|
+
if c.get("covariates_table"):
|
|
417
|
+
cov = read_table(_resolve(settings, "covariates_table", base, "campaigns"))
|
|
418
|
+
cov_cols = [k for k in cov.columns if k not in (group, time)
|
|
419
|
+
and pd.api.types.is_numeric_dtype(cov[k])]
|
|
420
|
+
units, n = K.join_covariates(units, cov, group=group, time=time)
|
|
421
|
+
notes += n
|
|
422
|
+
kw, pairs = K.compare_times(units, value, group=group, time=time)
|
|
423
|
+
position = c.get("position")
|
|
424
|
+
spatial = (K.spatial_trend(units, value, position, group=group, time=time)
|
|
425
|
+
if position and position in units.columns else None)
|
|
426
|
+
if position and position not in units.columns:
|
|
427
|
+
notes.append(f"position column {position!r} not found; no spatial trend")
|
|
428
|
+
covs = K.covariate_correlations(units, value, cov_cols, group=group) if cov_cols else None
|
|
429
|
+
return CampaignAnalysis(value, units, K.design_table(units, group=group, time=time),
|
|
430
|
+
K.confounding(units, group=group, time=time) + notes, kw, pairs,
|
|
431
|
+
spatial, covs)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def export_campaigns(out_dir: str | Path, analysis: CampaignAnalysis, settings: dict,
|
|
435
|
+
figures: bool = True) -> list[Path]:
|
|
436
|
+
"""Tables (and figures) of one campaign analysis in ``out_dir``."""
|
|
437
|
+
out = Path(out_dir)
|
|
438
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
439
|
+
v = analysis.value
|
|
440
|
+
written = []
|
|
441
|
+
for name, df in (("site_means", analysis.units), ("design", analysis.design),
|
|
442
|
+
(f"{v}_kruskal", analysis.kruskal), (f"{v}_dunn", analysis.pairwise),
|
|
443
|
+
(f"{v}_spatial", analysis.spatial), (f"{v}_covariates", analysis.covariates)):
|
|
444
|
+
if df is not None and not df.empty:
|
|
445
|
+
written.append(out / f"{name}.csv")
|
|
446
|
+
df.to_csv(written[-1], index=name == "design", float_format="%.6g")
|
|
447
|
+
written.append(out / f"{v}_notes.txt")
|
|
448
|
+
written[-1].write_text("\n".join([analysis.kruskal.attrs.get("method", "")]
|
|
449
|
+
+ analysis.warnings) + "\n", encoding="utf-8")
|
|
450
|
+
if figures:
|
|
451
|
+
import matplotlib.pyplot as plt
|
|
452
|
+
|
|
453
|
+
from eemkit import plot
|
|
454
|
+
c = settings["campaigns"]
|
|
455
|
+
fig = plot.campaign_series(analysis.units, v, group=c["group"], time=c["time"])
|
|
456
|
+
written += plot.save(fig, out / f"{v}_by_{c['time']}")
|
|
457
|
+
plt.close(fig)
|
|
458
|
+
if analysis.spatial is not None:
|
|
459
|
+
fig = plot.along_sites(analysis.units, v, c["position"], group=c["group"])
|
|
460
|
+
written += plot.save(fig, out / f"{v}_along_sites")
|
|
461
|
+
plt.close(fig)
|
|
462
|
+
return written
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# --------------------------------------------------------------------------- #
|
|
466
|
+
# Export
|
|
467
|
+
# --------------------------------------------------------------------------- #
|
|
468
|
+
|
|
469
|
+
def versions() -> dict[str, str]:
|
|
470
|
+
import scipy
|
|
471
|
+
|
|
472
|
+
from eemkit import __version__
|
|
473
|
+
return {"eemkit": __version__, "python": platform.python_version(),
|
|
474
|
+
"numpy": np.__version__, "scipy": scipy.__version__, "pandas": pd.__version__,
|
|
475
|
+
"xarray": xr.__version__}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def export_results(
|
|
479
|
+
out_dir: str | Path,
|
|
480
|
+
settings: dict,
|
|
481
|
+
*,
|
|
482
|
+
corrected: xr.Dataset | None = None,
|
|
483
|
+
indices: pd.DataFrame | None = None,
|
|
484
|
+
exploration: Exploration | None = None,
|
|
485
|
+
model: xr.Dataset | None = None,
|
|
486
|
+
split: xr.Dataset | None = None,
|
|
487
|
+
openfluor: dict | None = None,
|
|
488
|
+
figures: bool = True,
|
|
489
|
+
) -> list[Path]:
|
|
490
|
+
"""Write whatever results are given; returns the paths written.
|
|
491
|
+
|
|
492
|
+
Always writes ``settings.toml`` and ``processing_record.json`` (versions,
|
|
493
|
+
processing history, sample list) so the run can be repeated.
|
|
494
|
+
"""
|
|
495
|
+
out = Path(out_dir)
|
|
496
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
497
|
+
written = [save_settings(settings, out / "settings.toml")]
|
|
498
|
+
record: dict = {"versions": versions()}
|
|
499
|
+
|
|
500
|
+
if corrected is not None:
|
|
501
|
+
written.append(out / "corrected_eems.nc")
|
|
502
|
+
corrected.to_netcdf(written[-1], engine="scipy")
|
|
503
|
+
record["processing_history"] = get_history(corrected)
|
|
504
|
+
record["samples"] = [str(s) for s in corrected["sample"].values]
|
|
505
|
+
record["spectral_correction"] = C.spectral_correction_status(corrected)
|
|
506
|
+
if indices is not None:
|
|
507
|
+
written.append(out / "indices.csv")
|
|
508
|
+
indices.to_csv(written[-1], float_format="%.6g")
|
|
509
|
+
if exploration is not None:
|
|
510
|
+
written.append(out / "parafac_exploration.csv")
|
|
511
|
+
exploration.table.to_csv(written[-1])
|
|
512
|
+
if model is not None:
|
|
513
|
+
r = model.sizes["component"]
|
|
514
|
+
fmax = model["fmax"].to_pandas()
|
|
515
|
+
fmax.columns = [f"C{c}_Fmax" for c in fmax.columns]
|
|
516
|
+
written.append(out / f"parafac_{r}comp_fmax.csv")
|
|
517
|
+
fmax.to_csv(written[-1], float_format="%.6g")
|
|
518
|
+
for mode in ("ex", "em"):
|
|
519
|
+
written.append(out / f"parafac_{r}comp_{mode}_loadings.csv")
|
|
520
|
+
pd.DataFrame(model[f"{mode}_loadings"].values, index=model[mode].values,
|
|
521
|
+
columns=[f"C{c}" for c in model["component"].values]
|
|
522
|
+
).rename_axis(mode).to_csv(written[-1])
|
|
523
|
+
written.append(P.to_openfluor(model, out / f"parafac_{r}comp_openfluor.txt",
|
|
524
|
+
**(openfluor or {})))
|
|
525
|
+
written.append(out / f"parafac_{r}comp_model.nc")
|
|
526
|
+
model.to_netcdf(written[-1], engine="scipy")
|
|
527
|
+
record["parafac"] = {k: model.attrs[k] for k in (
|
|
528
|
+
"explained_variance_pct", "core_consistency_pct", "n_init_at_best_minimum")}
|
|
529
|
+
if split is not None:
|
|
530
|
+
record["parafac"]["split_half_min_tcc"] = split.attrs["min_tcc"]
|
|
531
|
+
record["parafac"]["split_half_validated"] = bool(split.attrs["validated"])
|
|
532
|
+
if figures:
|
|
533
|
+
written += export_figures(out / "figures", settings, exploration, model, split)
|
|
534
|
+
|
|
535
|
+
written.append(out / "processing_record.json")
|
|
536
|
+
written[-1].write_text(json.dumps(record, indent=2, default=str), encoding="utf-8")
|
|
537
|
+
return written
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def export_figures(out: Path, settings: dict, exploration: Exploration | None,
|
|
541
|
+
model: xr.Dataset | None, split: xr.Dataset | None) -> list[Path]:
|
|
542
|
+
"""Rank diagnostics, components and leverage figures (needs matplotlib)."""
|
|
543
|
+
import matplotlib.pyplot as plt
|
|
544
|
+
|
|
545
|
+
from eemkit import plot
|
|
546
|
+
|
|
547
|
+
written: list[Path] = []
|
|
548
|
+
if exploration is not None:
|
|
549
|
+
fig = plot.rank_diagnostics(exploration.table,
|
|
550
|
+
threshold=settings["parafac"]["split_half"]["threshold"],
|
|
551
|
+
chosen=settings["parafac"]["final"].get("rank"))
|
|
552
|
+
written += plot.save(fig, out / "rank_diagnostics")
|
|
553
|
+
plt.close(fig)
|
|
554
|
+
if model is not None:
|
|
555
|
+
cmap = settings.get("display", {}).get("eem_colours", "blues")
|
|
556
|
+
for name, fig in (("components", plot.components(model, split, cmap=cmap)),
|
|
557
|
+
("leverage", plot.leverage(model))):
|
|
558
|
+
written += plot.save(fig, out / name)
|
|
559
|
+
plt.close(fig)
|
|
560
|
+
return written
|