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/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