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/__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")
@@ -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