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/__init__.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""eemkit: EEM processing and PARAFAC modelling of dissolved organic matter fluorescence."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from eemkit import corrections, indices, parafac, pipeline
|
|
6
|
+
from eemkit.core.dataset import stack
|
|
7
|
+
from eemkit.core.grid import infer_common_grid, regular_grid, resample
|
|
8
|
+
from eemkit.core.history import get_history
|
|
9
|
+
from eemkit.errors import (
|
|
10
|
+
Cancelled,
|
|
11
|
+
CorrectionError,
|
|
12
|
+
EEMKitError,
|
|
13
|
+
EEMKitWarning,
|
|
14
|
+
FileFormatError,
|
|
15
|
+
GridError,
|
|
16
|
+
PairingError,
|
|
17
|
+
SettingsError,
|
|
18
|
+
)
|
|
19
|
+
from eemkit.io.cary60 import mean_cycles, read_cary60
|
|
20
|
+
from eemkit.io.cary_eclipse import read_cary_eclipse
|
|
21
|
+
from eemkit.io.discover import scan_folder, suggest_pairing
|
|
22
|
+
from eemkit.io.loader import load_dataset
|
|
23
|
+
from eemkit.io.pairing import read_pairing
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Cancelled",
|
|
27
|
+
"CorrectionError",
|
|
28
|
+
"EEMKitError",
|
|
29
|
+
"EEMKitWarning",
|
|
30
|
+
"FileFormatError",
|
|
31
|
+
"GridError",
|
|
32
|
+
"PairingError",
|
|
33
|
+
"SettingsError",
|
|
34
|
+
"__version__",
|
|
35
|
+
"corrections",
|
|
36
|
+
"get_history",
|
|
37
|
+
"indices",
|
|
38
|
+
"infer_common_grid",
|
|
39
|
+
"load_dataset",
|
|
40
|
+
"mean_cycles",
|
|
41
|
+
"parafac",
|
|
42
|
+
"pipeline",
|
|
43
|
+
"read_cary60",
|
|
44
|
+
"read_cary_eclipse",
|
|
45
|
+
"read_pairing",
|
|
46
|
+
"regular_grid",
|
|
47
|
+
"resample",
|
|
48
|
+
"scan_folder",
|
|
49
|
+
"stack",
|
|
50
|
+
"suggest_pairing",
|
|
51
|
+
]
|
eemkit/absorbance.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Chromophoric DOM (CDOM) metrics from absorbance spectra.
|
|
2
|
+
|
|
3
|
+
================= =================================================== ==========================
|
|
4
|
+
metric definition (defaults) reference
|
|
5
|
+
================= =================================================== ==========================
|
|
6
|
+
``a254``, ``a350`` Napierian absorption coefficient a = ln(10) A / l Green & Blough 1994
|
|
7
|
+
(m^-1, path length l in m)
|
|
8
|
+
``S275_295`` slope of ln a(lambda) against lambda, 275-295 nm Helms et al. 2008
|
|
9
|
+
``S350_400`` the same over 350-400 nm (nm^-1, reported positive) Helms et al. 2008
|
|
10
|
+
``SR`` S275_295 / S350_400 Helms et al. 2008
|
|
11
|
+
``E2_E3`` A(250) / A(365) De Haan & De Boer 1987
|
|
12
|
+
``SUVA254`` A(254) / l (m^-1, decadic) / DOC (mg L^-1) Weishaar et al. 2003
|
|
13
|
+
================= =================================================== ==========================
|
|
14
|
+
|
|
15
|
+
Before any metric, each spectrum is corrected for its baseline offset by
|
|
16
|
+
subtracting its mean absorbance over ``baseline`` (default 680-700 nm, where
|
|
17
|
+
CDOM does not absorb; Green & Blough 1994, Helms et al. 2008). The absorbance
|
|
18
|
+
must already be blank-corrected
|
|
19
|
+
(:func:`~eemkit.corrections.subtract_absorbance_blank`).
|
|
20
|
+
|
|
21
|
+
Low signal is never turned into a number: a metric that would need a
|
|
22
|
+
non-positive absorbance, or a slope whose log-linear fit has R^2 below
|
|
23
|
+
``min_r2``, is NaN, and the reason is written in the ``cdom_notes`` column.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import warnings
|
|
29
|
+
from collections.abc import Sequence
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import pandas as pd
|
|
33
|
+
import xarray as xr
|
|
34
|
+
|
|
35
|
+
from eemkit.core.history import get_history, has_step
|
|
36
|
+
from eemkit.errors import EEMKitWarning, IndexComputationError
|
|
37
|
+
|
|
38
|
+
LN10 = float(np.log(10.0))
|
|
39
|
+
|
|
40
|
+
#: Default slope ranges in nm (Helms et al. 2008).
|
|
41
|
+
SLOPE_RANGES: dict[str, tuple[float, float]] = {"S275_295": (275.0, 295.0),
|
|
42
|
+
"S350_400": (350.0, 400.0)}
|
|
43
|
+
|
|
44
|
+
#: Default wavelengths in nm.
|
|
45
|
+
COEFFICIENTS: tuple[float, ...] = (254.0, 350.0)
|
|
46
|
+
E2_E3: tuple[float, float] = (250.0, 365.0)
|
|
47
|
+
|
|
48
|
+
COLUMNS = ("a254", "a350", "S275_295", "S350_400", "SR", "E2_E3", "SUVA254")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def baseline_corrected(absorbance: xr.DataArray,
|
|
52
|
+
baseline: tuple[float, float] | None) -> xr.DataArray:
|
|
53
|
+
"""Each spectrum minus its mean over ``baseline`` (nm); unchanged if ``None``."""
|
|
54
|
+
if baseline is None:
|
|
55
|
+
return absorbance
|
|
56
|
+
lo, hi = baseline
|
|
57
|
+
wl = absorbance["wavelength"].values
|
|
58
|
+
inside = (wl >= lo) & (wl <= hi)
|
|
59
|
+
if not inside.any():
|
|
60
|
+
raise IndexComputationError(f"baseline range {lo}-{hi} nm is outside the measured "
|
|
61
|
+
f"{wl.min()}-{wl.max()} nm")
|
|
62
|
+
offset = absorbance.isel(wavelength=np.flatnonzero(inside)).mean("wavelength")
|
|
63
|
+
return absorbance - offset
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _at(spec: np.ndarray, wl: np.ndarray, lam: float) -> float:
|
|
67
|
+
"""Linear interpolation of one spectrum at ``lam`` nm (no extrapolation)."""
|
|
68
|
+
if lam < wl.min() or lam > wl.max():
|
|
69
|
+
raise IndexComputationError(f"{lam} nm is outside the measured {wl.min()}-{wl.max()} nm")
|
|
70
|
+
order = np.argsort(wl)
|
|
71
|
+
return float(np.interp(lam, wl[order], spec[order]))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def spectral_slope(a: np.ndarray, wl: np.ndarray, lo: float, hi: float,
|
|
75
|
+
min_r2: float) -> tuple[float, float, str]:
|
|
76
|
+
"""Slope S (nm^-1, positive for decreasing a) of ln a against wavelength on [lo, hi].
|
|
77
|
+
|
|
78
|
+
Linear least squares on the measured points in the range (Helms et al.
|
|
79
|
+
2008). Returns ``(S, R2, note)``; ``S`` is NaN with a note when a value in
|
|
80
|
+
the range is not positive, when there are fewer than 3 points, or when
|
|
81
|
+
``R2 < min_r2``.
|
|
82
|
+
"""
|
|
83
|
+
sel = (wl >= lo) & (wl <= hi)
|
|
84
|
+
x, y = wl[sel], a[sel]
|
|
85
|
+
name = f"{lo:g}-{hi:g} nm"
|
|
86
|
+
if x.size < 3:
|
|
87
|
+
return np.nan, np.nan, f"fewer than 3 points in {name}"
|
|
88
|
+
if not np.all(np.isfinite(y)):
|
|
89
|
+
return np.nan, np.nan, f"missing absorbance in {name}"
|
|
90
|
+
if np.any(y <= 0):
|
|
91
|
+
return np.nan, np.nan, f"a <= 0 in {name}"
|
|
92
|
+
ly = np.log(y)
|
|
93
|
+
slope, icept = np.polyfit(x, ly, 1)
|
|
94
|
+
resid = ly - (slope * x + icept)
|
|
95
|
+
ss_tot = float(np.sum((ly - ly.mean()) ** 2))
|
|
96
|
+
r2 = 1.0 - float(np.sum(resid ** 2)) / ss_tot if ss_tot > 0 else np.nan
|
|
97
|
+
if not np.isfinite(r2) or r2 < min_r2:
|
|
98
|
+
return np.nan, r2, f"R2 {r2:.3f} < {min_r2:g} in {name}"
|
|
99
|
+
return float(-slope), r2, ""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def compute_absorbance_metrics(
|
|
103
|
+
ds: xr.Dataset,
|
|
104
|
+
*,
|
|
105
|
+
absorbance_path_length_cm: float,
|
|
106
|
+
baseline: tuple[float, float] | None = (680.0, 700.0),
|
|
107
|
+
min_r2: float = 0.95,
|
|
108
|
+
coefficients: Sequence[float] = COEFFICIENTS,
|
|
109
|
+
slopes: dict[str, tuple[float, float]] | None = None,
|
|
110
|
+
e2_e3: tuple[float, float] = E2_E3,
|
|
111
|
+
doc: str | None = None,
|
|
112
|
+
) -> pd.DataFrame:
|
|
113
|
+
"""CDOM metrics, one row per sample, with the settings in ``df.attrs``.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
ds
|
|
118
|
+
Dataset with ``absorbance(sample, wavelength)``, blank-corrected.
|
|
119
|
+
absorbance_path_length_cm
|
|
120
|
+
Path length of the absorbance cuvette. Required: eemkit never assumes
|
|
121
|
+
a cuvette.
|
|
122
|
+
baseline
|
|
123
|
+
Wavelength range (nm) whose mean is subtracted from each spectrum;
|
|
124
|
+
``None`` for no baseline correction.
|
|
125
|
+
min_r2
|
|
126
|
+
Smallest R^2 of the log-linear fit for a slope to be reported. This is
|
|
127
|
+
an eemkit quality threshold, not a value from the literature; report
|
|
128
|
+
the value used.
|
|
129
|
+
coefficients
|
|
130
|
+
Wavelengths (nm) of the Napierian absorption coefficients ``a<nm>``.
|
|
131
|
+
slopes
|
|
132
|
+
``{name: (lo, hi)}``; default :data:`SLOPE_RANGES`. ``SR`` is computed
|
|
133
|
+
when both default ranges are present.
|
|
134
|
+
e2_e3
|
|
135
|
+
The two wavelengths of the E2:E3 ratio.
|
|
136
|
+
doc
|
|
137
|
+
Name of a ``sample`` coordinate holding DOC in mg L^-1; ``SUVA254``
|
|
138
|
+
is computed only when it is given.
|
|
139
|
+
|
|
140
|
+
Notes
|
|
141
|
+
-----
|
|
142
|
+
Values that cannot be computed are NaN and the reasons are joined in the
|
|
143
|
+
``cdom_notes`` column, so low-signal spectra are never reported as
|
|
144
|
+
numbers.
|
|
145
|
+
"""
|
|
146
|
+
if "absorbance" not in ds:
|
|
147
|
+
raise IndexComputationError("no absorbance in the dataset")
|
|
148
|
+
if not has_step(ds, "subtract_absorbance_blank"):
|
|
149
|
+
raise IndexComputationError("subtract the absorbance blank first "
|
|
150
|
+
"(eemkit.corrections.subtract_absorbance_blank)")
|
|
151
|
+
if not np.isfinite(absorbance_path_length_cm) or absorbance_path_length_cm <= 0:
|
|
152
|
+
raise IndexComputationError("absorbance_path_length_cm must be a positive number")
|
|
153
|
+
if doc is not None and doc not in ds.coords:
|
|
154
|
+
raise IndexComputationError(f"no sample column {doc!r} for DOC")
|
|
155
|
+
slopes = dict(SLOPE_RANGES if slopes is None else slopes)
|
|
156
|
+
path_m = absorbance_path_length_cm / 100.0
|
|
157
|
+
|
|
158
|
+
A = baseline_corrected(ds["absorbance"].transpose("sample", "wavelength"), baseline)
|
|
159
|
+
wl = A["wavelength"].values
|
|
160
|
+
rows, notes = [], []
|
|
161
|
+
for i in range(A.sizes["sample"]):
|
|
162
|
+
spec = A.values[i]
|
|
163
|
+
row: dict[str, float] = {}
|
|
164
|
+
why: list[str] = []
|
|
165
|
+
if not np.any(np.isfinite(spec)):
|
|
166
|
+
notes.append("no absorbance")
|
|
167
|
+
rows.append({})
|
|
168
|
+
continue
|
|
169
|
+
for lam in coefficients:
|
|
170
|
+
v = _at(spec, wl, lam)
|
|
171
|
+
ok = v > 0
|
|
172
|
+
row[f"a{lam:g}"] = LN10 * v / path_m if ok else np.nan
|
|
173
|
+
if not ok:
|
|
174
|
+
why.append(f"A({lam:g}) <= 0")
|
|
175
|
+
a = LN10 * spec / path_m
|
|
176
|
+
for name, (lo, hi) in slopes.items():
|
|
177
|
+
s, r2, note = spectral_slope(a, wl, lo, hi, min_r2)
|
|
178
|
+
row[name], row[f"{name}_R2"] = s, r2
|
|
179
|
+
if note:
|
|
180
|
+
why.append(f"{name}: {note}")
|
|
181
|
+
if {"S275_295", "S350_400"} <= set(slopes):
|
|
182
|
+
row["SR"] = row["S275_295"] / row["S350_400"]
|
|
183
|
+
num, den = _at(spec, wl, e2_e3[0]), _at(spec, wl, e2_e3[1])
|
|
184
|
+
row["E2_E3"] = num / den if num > 0 and den > 0 else np.nan
|
|
185
|
+
if not (num > 0 and den > 0):
|
|
186
|
+
why.append(f"E2_E3: A({e2_e3[0]:g}) or A({e2_e3[1]:g}) <= 0")
|
|
187
|
+
if doc is not None:
|
|
188
|
+
c = pd.to_numeric(str(ds[doc].values[i]).strip().replace(",", "."),
|
|
189
|
+
errors="coerce")
|
|
190
|
+
a254_dec = _at(spec, wl, 254.0) / path_m
|
|
191
|
+
ok = np.isfinite(c) and c > 0 and a254_dec > 0
|
|
192
|
+
row["SUVA254"] = a254_dec / c if ok else np.nan
|
|
193
|
+
if not ok:
|
|
194
|
+
why.append("SUVA254: DOC missing or not positive, or A(254) <= 0")
|
|
195
|
+
rows.append(row)
|
|
196
|
+
notes.append("; ".join(why))
|
|
197
|
+
|
|
198
|
+
df = pd.DataFrame(rows, index=pd.Index(A["sample"].values, name="sample"))
|
|
199
|
+
df["cdom_notes"] = notes
|
|
200
|
+
n_bad = int((df["cdom_notes"] != "").sum())
|
|
201
|
+
if n_bad:
|
|
202
|
+
warnings.warn(f"CDOM metrics: {n_bad} sample(s) with values left empty (low signal); "
|
|
203
|
+
"see the cdom_notes column", EEMKitWarning, stacklevel=2)
|
|
204
|
+
df.attrs["settings"] = {
|
|
205
|
+
"absorption_coefficient": "Napierian, a = ln(10) A / l, m^-1 (Green & Blough 1994)",
|
|
206
|
+
"absorbance_path_length_cm": absorbance_path_length_cm,
|
|
207
|
+
"baseline_nm": list(baseline) if baseline is not None else None,
|
|
208
|
+
"slopes": {"ranges_nm": {k: list(v) for k, v in slopes.items()},
|
|
209
|
+
"fit": "linear least squares of ln a (Helms et al. 2008)",
|
|
210
|
+
"min_r2": min_r2},
|
|
211
|
+
"E2_E3_nm": list(e2_e3),
|
|
212
|
+
"SUVA254": {"doc_column": doc, "units": "L mg^-1 m^-1",
|
|
213
|
+
"reference": "Weishaar et al. 2003"} if doc else None,
|
|
214
|
+
"processing_history": get_history(ds),
|
|
215
|
+
}
|
|
216
|
+
return df
|
eemkit/campaigns.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Comparing sampling campaigns, sites and rivers.
|
|
2
|
+
|
|
3
|
+
Works on any per-sample table (indices, CDOM metrics, PARAFAC Fmax) with
|
|
4
|
+
columns that name the *group* (e.g. river), the *site* and the *time* (e.g.
|
|
5
|
+
campaign). The statistical unit is the mean of the replicates of one site at
|
|
6
|
+
one time (:func:`site_means`), so replicates are never counted as independent
|
|
7
|
+
samples.
|
|
8
|
+
|
|
9
|
+
* :func:`design_table` and :func:`confounding` show which groups were sampled
|
|
10
|
+
at which times, and say when group and time cannot be separated (for
|
|
11
|
+
example rivers that were sampled in different months);
|
|
12
|
+
* :func:`compare_times`: within each group, Kruskal-Wallis test between times
|
|
13
|
+
(Kruskal & Wallis 1952) and Dunn's pairwise test (Dunn 1964) with Holm's
|
|
14
|
+
correction (Holm 1979);
|
|
15
|
+
* :func:`spatial_trend`: Spearman correlation with a site position (e.g.
|
|
16
|
+
distance from the source), per group;
|
|
17
|
+
* :func:`covariate_correlations`: Spearman correlation with covariates per
|
|
18
|
+
group and time (e.g. discharge, rainfall);
|
|
19
|
+
* :func:`composition`: the share of each PARAFAC component in total Fmax.
|
|
20
|
+
|
|
21
|
+
Optional tables are joined explicitly on their key columns
|
|
22
|
+
(:func:`join_sites`, :func:`join_covariates`); rows that do not match are
|
|
23
|
+
reported, never guessed.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from itertools import combinations
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import pandas as pd
|
|
33
|
+
from scipy import stats
|
|
34
|
+
|
|
35
|
+
from eemkit.errors import SettingsError
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _check_columns(df: pd.DataFrame, cols: Sequence[str]) -> None:
|
|
39
|
+
missing = [c for c in cols if c and c not in df.columns]
|
|
40
|
+
if missing:
|
|
41
|
+
raise SettingsError(f"no column {missing} in the table (columns: {list(df.columns)})")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _time_order(values) -> list:
|
|
45
|
+
return sorted(pd.unique(pd.Series(values).astype(str)))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
# Units and composition
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
|
|
52
|
+
def site_means(df: pd.DataFrame, values: Sequence[str], *, group: str, time: str,
|
|
53
|
+
site: str | None = None) -> pd.DataFrame:
|
|
54
|
+
"""Mean, standard deviation and coefficient of variation of the replicates.
|
|
55
|
+
|
|
56
|
+
One row per (group, site, time); ``n`` is the number of replicates. Without
|
|
57
|
+
``site`` all samples of a group at one time are pooled.
|
|
58
|
+
"""
|
|
59
|
+
keys = [group] + ([site] if site else []) + [time]
|
|
60
|
+
_check_columns(df, list(keys) + list(values))
|
|
61
|
+
data = df[keys + list(values)].copy()
|
|
62
|
+
for k in keys:
|
|
63
|
+
data[k] = data[k].astype(str)
|
|
64
|
+
g = data.groupby(keys, sort=True)
|
|
65
|
+
mean = g[list(values)].mean()
|
|
66
|
+
sd = g[list(values)].std(ddof=1).add_suffix("_sd")
|
|
67
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
68
|
+
cv = (100 * g[list(values)].std(ddof=1) / mean.abs()).add_suffix("_cv_pct")
|
|
69
|
+
out = pd.concat([mean, sd, cv], axis=1)
|
|
70
|
+
out.insert(0, "n", g.size())
|
|
71
|
+
return out.reset_index()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def composition(df: pd.DataFrame, fmax_columns: Sequence[str]) -> pd.DataFrame:
|
|
75
|
+
"""Percentage of each component in the summed Fmax of each sample."""
|
|
76
|
+
_check_columns(df, fmax_columns)
|
|
77
|
+
f = df[list(fmax_columns)].astype(float)
|
|
78
|
+
total = f.sum(axis=1, min_count=len(fmax_columns))
|
|
79
|
+
out = f.div(total, axis=0) * 100
|
|
80
|
+
out.columns = [f"{c.removesuffix('_Fmax')}_pct" for c in fmax_columns]
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --------------------------------------------------------------------------- #
|
|
85
|
+
# Design
|
|
86
|
+
# --------------------------------------------------------------------------- #
|
|
87
|
+
|
|
88
|
+
def design_table(units: pd.DataFrame, *, group: str, time: str) -> pd.DataFrame:
|
|
89
|
+
"""Number of units for every (time, group) combination."""
|
|
90
|
+
_check_columns(units, [group, time])
|
|
91
|
+
t = pd.crosstab(units[time].astype(str), units[group].astype(str))
|
|
92
|
+
return t.reindex(_time_order(units[time]))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def confounding(units: pd.DataFrame, *, group: str, time: str) -> list[str]:
|
|
96
|
+
"""Plain-language warnings when group and time effects cannot be separated."""
|
|
97
|
+
t = design_table(units, group=group, time=time) > 0
|
|
98
|
+
msgs = []
|
|
99
|
+
groups = list(t.columns)
|
|
100
|
+
for a, b in combinations(groups, 2):
|
|
101
|
+
shared = t.index[t[a] & t[b]].tolist()
|
|
102
|
+
if not shared:
|
|
103
|
+
msgs.append(f"{a} and {b} were never sampled at the same {time}: differences "
|
|
104
|
+
f"between them cannot be separated from differences between "
|
|
105
|
+
f"{time} values.")
|
|
106
|
+
if len(groups) > 1 and (t.sum(axis=1) <= 1).all():
|
|
107
|
+
msgs.insert(0, f"Every {time} contains a single {group}: {group} and {time} are "
|
|
108
|
+
"completely confounded; compare times within each group only.")
|
|
109
|
+
return msgs
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --------------------------------------------------------------------------- #
|
|
113
|
+
# Tests
|
|
114
|
+
# --------------------------------------------------------------------------- #
|
|
115
|
+
|
|
116
|
+
def _holm(p: np.ndarray) -> np.ndarray:
|
|
117
|
+
p = np.asarray(p, dtype=float)
|
|
118
|
+
out = np.full_like(p, np.nan)
|
|
119
|
+
ok = np.isfinite(p)
|
|
120
|
+
if not ok.any():
|
|
121
|
+
return out
|
|
122
|
+
pv = p[ok]
|
|
123
|
+
order = np.argsort(pv)
|
|
124
|
+
m = pv.size
|
|
125
|
+
adj = np.minimum(1.0, np.maximum.accumulate((m - np.arange(m)) * pv[order]))
|
|
126
|
+
res = np.empty(m)
|
|
127
|
+
res[order] = adj
|
|
128
|
+
out[ok] = res
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def dunn(values: Sequence[float], labels: Sequence[str]) -> pd.DataFrame:
|
|
133
|
+
"""Dunn's (1964) pairwise test after Kruskal-Wallis, with tie correction and Holm.
|
|
134
|
+
|
|
135
|
+
Returns one row per pair: mean ranks, z, two-sided p and Holm-adjusted p.
|
|
136
|
+
"""
|
|
137
|
+
v = np.asarray(values, dtype=float)
|
|
138
|
+
lab = np.asarray(labels).astype(str)
|
|
139
|
+
ok = np.isfinite(v)
|
|
140
|
+
v, lab = v[ok], lab[ok]
|
|
141
|
+
n = v.size
|
|
142
|
+
ranks = stats.rankdata(v)
|
|
143
|
+
_, counts = np.unique(v, return_counts=True)
|
|
144
|
+
tie = np.sum(counts ** 3 - counts) / (12.0 * (n - 1)) if n > 1 else 0.0
|
|
145
|
+
levels = _time_order(lab)
|
|
146
|
+
rows = []
|
|
147
|
+
for a, b in combinations(levels, 2):
|
|
148
|
+
ra, rb = ranks[lab == a], ranks[lab == b]
|
|
149
|
+
se = np.sqrt((n * (n + 1) / 12.0 - tie) * (1 / ra.size + 1 / rb.size))
|
|
150
|
+
z = (ra.mean() - rb.mean()) / se if se > 0 else np.nan
|
|
151
|
+
p = 2 * stats.norm.sf(abs(z)) if np.isfinite(z) else np.nan
|
|
152
|
+
rows.append({"a": a, "b": b, "n_a": ra.size, "n_b": rb.size, "mean_rank_a": ra.mean(),
|
|
153
|
+
"mean_rank_b": rb.mean(), "z": z, "p": p})
|
|
154
|
+
out = pd.DataFrame(rows)
|
|
155
|
+
if not out.empty:
|
|
156
|
+
out["p_holm"] = _holm(out["p"].to_numpy())
|
|
157
|
+
return out
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def compare_times(units: pd.DataFrame, value: str, *, group: str,
|
|
161
|
+
time: str) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
162
|
+
"""Within each group, differences of ``value`` between times.
|
|
163
|
+
|
|
164
|
+
Returns ``(kruskal, pairwise)``: one Kruskal-Wallis row per group (H,
|
|
165
|
+
degrees of freedom, p, number of units and times, or a note when the test
|
|
166
|
+
cannot be run) and Dunn's pairwise comparisons with Holm-adjusted p.
|
|
167
|
+
"""
|
|
168
|
+
_check_columns(units, [group, time, value])
|
|
169
|
+
kw_rows, pair_rows = [], []
|
|
170
|
+
for g, sub in units.groupby(units[group].astype(str), sort=True):
|
|
171
|
+
sub = sub[np.isfinite(sub[value].astype(float))]
|
|
172
|
+
samples = [s[value].astype(float).to_numpy()
|
|
173
|
+
for _, s in sub.groupby(sub[time].astype(str), sort=True)]
|
|
174
|
+
row = {group: g, "n_units": len(sub), "n_times": len(samples),
|
|
175
|
+
"H": np.nan, "df": np.nan, "p": np.nan, "note": ""}
|
|
176
|
+
if len(samples) < 2:
|
|
177
|
+
row["note"] = "fewer than 2 times with data"
|
|
178
|
+
elif len(sub) - len(samples) < 1:
|
|
179
|
+
row["note"] = "one unit per time: no replication between sites"
|
|
180
|
+
else:
|
|
181
|
+
try:
|
|
182
|
+
h, p = stats.kruskal(*samples)
|
|
183
|
+
row.update(H=float(h), df=len(samples) - 1, p=float(p))
|
|
184
|
+
except ValueError as exc: # e.g. all values identical
|
|
185
|
+
row["note"] = str(exc)
|
|
186
|
+
kw_rows.append(row)
|
|
187
|
+
if np.isfinite(row["p"]):
|
|
188
|
+
d = dunn(sub[value].astype(float), sub[time].astype(str))
|
|
189
|
+
d.insert(0, group, g)
|
|
190
|
+
pair_rows.append(d)
|
|
191
|
+
kw = pd.DataFrame(kw_rows)
|
|
192
|
+
pairs = pd.concat(pair_rows, ignore_index=True) if pair_rows else pd.DataFrame()
|
|
193
|
+
kw.attrs["method"] = ("Kruskal-Wallis between times within each group (Kruskal & Wallis "
|
|
194
|
+
"1952); Dunn's pairwise test (Dunn 1964), Holm-adjusted (Holm 1979); "
|
|
195
|
+
"unit = mean of the replicates of one site at one time")
|
|
196
|
+
return kw, pairs
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _spearman(x, y) -> dict:
|
|
200
|
+
x, y = np.asarray(x, dtype=float), np.asarray(y, dtype=float)
|
|
201
|
+
ok = np.isfinite(x) & np.isfinite(y)
|
|
202
|
+
n = int(ok.sum())
|
|
203
|
+
if n < 3 or np.ptp(x[ok]) == 0 or np.ptp(y[ok]) == 0:
|
|
204
|
+
return {"n": n, "rho": np.nan, "p": np.nan,
|
|
205
|
+
"note": "fewer than 3 pairs or no variation"}
|
|
206
|
+
rho, p = stats.spearmanr(x[ok], y[ok])
|
|
207
|
+
return {"n": n, "rho": float(rho), "p": float(p), "note": ""}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def spatial_trend(units: pd.DataFrame, value: str, position: str, *, group: str,
|
|
211
|
+
time: str | None = None) -> pd.DataFrame:
|
|
212
|
+
"""Spearman correlation between ``value`` and a site ``position`` within each group.
|
|
213
|
+
|
|
214
|
+
With ``time``, also one row per (group, time). ``position`` is any number
|
|
215
|
+
that orders the sites, e.g. distance from the source in km.
|
|
216
|
+
"""
|
|
217
|
+
_check_columns(units, [group, value, position] + ([time] if time else []))
|
|
218
|
+
rows = []
|
|
219
|
+
for g, sub in units.groupby(units[group].astype(str), sort=True):
|
|
220
|
+
rows.append({group: g, **({time: "all"} if time else {}),
|
|
221
|
+
**_spearman(sub[position], sub[value])})
|
|
222
|
+
if time:
|
|
223
|
+
for t, s2 in sub.groupby(sub[time].astype(str), sort=True):
|
|
224
|
+
rows.append({group: g, time: t, **_spearman(s2[position], s2[value])})
|
|
225
|
+
return pd.DataFrame(rows)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def covariate_correlations(units: pd.DataFrame, value: str, covariates: Sequence[str], *,
|
|
229
|
+
group: str) -> pd.DataFrame:
|
|
230
|
+
"""Spearman correlation between ``value`` and each covariate, per group and overall."""
|
|
231
|
+
_check_columns(units, [group, value] + list(covariates))
|
|
232
|
+
rows = []
|
|
233
|
+
for c in covariates:
|
|
234
|
+
rows.append({group: "all", "covariate": c, **_spearman(units[c], units[value])})
|
|
235
|
+
for g, sub in units.groupby(units[group].astype(str), sort=True):
|
|
236
|
+
rows.append({group: g, "covariate": c, **_spearman(sub[c], sub[value])})
|
|
237
|
+
return pd.DataFrame(rows)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# --------------------------------------------------------------------------- #
|
|
241
|
+
# Optional tables
|
|
242
|
+
# --------------------------------------------------------------------------- #
|
|
243
|
+
|
|
244
|
+
def _join(units: pd.DataFrame, table: pd.DataFrame, keys: Sequence[str],
|
|
245
|
+
what: str) -> tuple[pd.DataFrame, list[str]]:
|
|
246
|
+
_check_columns(table, keys)
|
|
247
|
+
t = table.copy()
|
|
248
|
+
for k in keys:
|
|
249
|
+
t[k] = t[k].astype(str).str.strip()
|
|
250
|
+
dup = t.duplicated(list(keys))
|
|
251
|
+
if dup.any():
|
|
252
|
+
raise SettingsError(f"{what}: duplicate rows for {t.loc[dup, list(keys)].values.tolist()}")
|
|
253
|
+
left = units.copy()
|
|
254
|
+
for k in keys:
|
|
255
|
+
left[k] = left[k].astype(str).str.strip()
|
|
256
|
+
merged = left.merge(t, on=list(keys), how="left", indicator=True)
|
|
257
|
+
unmatched = merged.loc[merged["_merge"] == "left_only", list(keys)].drop_duplicates()
|
|
258
|
+
notes = [f"{what}: no row for " + ", ".join(f"{k}={r[k]}" for k in keys)
|
|
259
|
+
for _, r in unmatched.iterrows()]
|
|
260
|
+
return merged.drop(columns="_merge"), notes
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def join_sites(units: pd.DataFrame, sites: pd.DataFrame, *, group: str,
|
|
264
|
+
site: str) -> tuple[pd.DataFrame, list[str]]:
|
|
265
|
+
"""Add the columns of a site table (e.g. latitude, longitude, distance) by (group, site)."""
|
|
266
|
+
return _join(units, sites, [group, site], "site table")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def join_covariates(units: pd.DataFrame, covariates: pd.DataFrame, *, group: str,
|
|
270
|
+
time: str) -> tuple[pd.DataFrame, list[str]]:
|
|
271
|
+
"""Add the columns of a covariate table (e.g. discharge, rainfall) by (group, time).
|
|
272
|
+
|
|
273
|
+
The time values must be written exactly as in the sample table (e.g.
|
|
274
|
+
``2024-05`` for a campaign); no date matching is attempted.
|
|
275
|
+
"""
|
|
276
|
+
return _join(units, covariates, [group, time], "covariate table")
|
eemkit/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Data model: grids, multi-sample datasets and processing history."""
|
eemkit/core/dataset.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Combining single measurements into a multi-sample dataset.
|
|
2
|
+
|
|
3
|
+
A single measurement (one EEM, one absorbance spectrum) is an
|
|
4
|
+
``xarray.Dataset`` whose attributes describe the file and the instrument.
|
|
5
|
+
:func:`stack` puts many of them along a new ``sample`` dimension:
|
|
6
|
+
|
|
7
|
+
* data variables gain the ``sample`` dimension; a missing measurement
|
|
8
|
+
(``None``) becomes all-NaN;
|
|
9
|
+
* coordinates such as ``scan_time(ex)`` become ``(sample, ex)``;
|
|
10
|
+
* attributes equal for every sample stay dataset attributes, the others
|
|
11
|
+
become ``sample`` coordinates, so nothing is lost;
|
|
12
|
+
* the processing history is kept once if it is the same for every sample.
|
|
13
|
+
|
|
14
|
+
A ``prefix`` keeps EEM and absorbance metadata apart in the same dataset
|
|
15
|
+
(both have e.g. an ``instrument`` attribute).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import xarray as xr
|
|
25
|
+
|
|
26
|
+
from eemkit.core.history import HISTORY_ATTR, get_history, same_history
|
|
27
|
+
from eemkit.errors import GridError
|
|
28
|
+
|
|
29
|
+
SAMPLE_DIM = "sample"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def stack(
|
|
33
|
+
items: Sequence[xr.Dataset | None],
|
|
34
|
+
sample_ids: Sequence[str],
|
|
35
|
+
prefix: str = "",
|
|
36
|
+
) -> xr.Dataset:
|
|
37
|
+
"""Stack single measurements that share the same grid along ``sample``."""
|
|
38
|
+
if len(items) != len(sample_ids):
|
|
39
|
+
raise ValueError("items and sample_ids differ in length")
|
|
40
|
+
if len(set(sample_ids)) != len(sample_ids):
|
|
41
|
+
raise ValueError("sample_ids are not unique")
|
|
42
|
+
present = [it for it in items if it is not None]
|
|
43
|
+
if not present:
|
|
44
|
+
raise ValueError("nothing to stack: every item is None")
|
|
45
|
+
ref = present[0]
|
|
46
|
+
|
|
47
|
+
for it in present[1:]:
|
|
48
|
+
for dim in ref.dims:
|
|
49
|
+
if dim not in it.dims or it.sizes[dim] != ref.sizes[dim] or \
|
|
50
|
+
not np.allclose(it[dim].values, ref[dim].values):
|
|
51
|
+
raise GridError(
|
|
52
|
+
f"'{it.attrs.get('source_file', '?')}' is not on the same '{dim}' grid as "
|
|
53
|
+
f"'{ref.attrs.get('source_file', '?')}'; resample to a common grid first"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# index values only: ref[d] would also drag along coordinates such as scan_time(ex)
|
|
57
|
+
out = xr.Dataset(coords={d: (d, ref[d].values, ref[d].attrs) for d in ref.dims})
|
|
58
|
+
out = out.assign_coords({SAMPLE_DIM: (SAMPLE_DIM, np.asarray(sample_ids, dtype=object))})
|
|
59
|
+
|
|
60
|
+
def stacked(name: str, template: xr.DataArray) -> np.ndarray:
|
|
61
|
+
fill = np.array("NaT", dtype=template.dtype) if template.dtype.kind == "M" else np.nan
|
|
62
|
+
return np.stack([it[name].values if it is not None
|
|
63
|
+
else np.full(template.shape, fill, dtype=template.dtype)
|
|
64
|
+
for it in items])
|
|
65
|
+
|
|
66
|
+
for name, var in ref.data_vars.items():
|
|
67
|
+
out[name] = ((SAMPLE_DIM, *var.dims), stacked(name, var), var.attrs)
|
|
68
|
+
for name, coord in ref.coords.items():
|
|
69
|
+
if name in ref.dims:
|
|
70
|
+
continue
|
|
71
|
+
out = out.assign_coords(
|
|
72
|
+
{prefix + name: ((SAMPLE_DIM, *coord.dims), stacked(name, coord), coord.attrs)}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
keys = [k for k in dict.fromkeys(k for it in present for k in it.attrs) if k != HISTORY_ATTR]
|
|
76
|
+
for key in keys:
|
|
77
|
+
vals = [str(it.attrs.get(key, "")) if it is not None else "" for it in items]
|
|
78
|
+
present_vals = {str(it.attrs.get(key, "")) for it in present}
|
|
79
|
+
if len(present_vals) == 1 and all(it.attrs.get(key) is not None for it in present):
|
|
80
|
+
out.attrs[prefix + key] = present[0].attrs[key]
|
|
81
|
+
else:
|
|
82
|
+
out = out.assign_coords({prefix + key: (SAMPLE_DIM, np.asarray(vals, dtype=object))})
|
|
83
|
+
|
|
84
|
+
histories = [get_history(it) for it in present]
|
|
85
|
+
if all(same_history(histories[0], h) for h in histories[1:]):
|
|
86
|
+
out.attrs[HISTORY_ATTR] = json.dumps(histories[0])
|
|
87
|
+
else:
|
|
88
|
+
out = out.assign_coords({prefix + "history": (SAMPLE_DIM, np.asarray(
|
|
89
|
+
[json.dumps(get_history(it)) if it is not None else "" for it in items],
|
|
90
|
+
dtype=object))})
|
|
91
|
+
return out
|