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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Reader for Agilent Cary Eclipse 3-D (EEM) CSV exports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import warnings
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import xarray as xr
|
|
11
|
+
|
|
12
|
+
from eemkit.core.grid import DEFAULT_TOLERANCE, interp_on_grid
|
|
13
|
+
from eemkit.core.history import with_history
|
|
14
|
+
from eemkit.errors import EEMKitWarning, FileFormatError
|
|
15
|
+
from eemkit.io._cary_csv import common_params, parse_cary_csv, scan_times, time_attrs
|
|
16
|
+
|
|
17
|
+
LABEL_RE = re.compile(r"^(?P<name>.*)_EX_(?P<ex>[0-9]+(?:\.[0-9]+)?)$")
|
|
18
|
+
|
|
19
|
+
#: dataset attribute -> key in the 'Parameter List' of the scan log
|
|
20
|
+
LOG_PARAMS = {
|
|
21
|
+
"instrument": "Instrument",
|
|
22
|
+
"instrument_serial": "Instrument Serial Number",
|
|
23
|
+
"data_mode": "Data mode",
|
|
24
|
+
"scan_mode": "Scan mode",
|
|
25
|
+
"em_start_nm": "Start (nm)",
|
|
26
|
+
"em_stop_nm": "Stop (nm)",
|
|
27
|
+
"ex_slit_nm": "Ex. Slit (nm)",
|
|
28
|
+
"em_slit_nm": "Em. Slit (nm)",
|
|
29
|
+
"scan_rate_nm_per_min": "Scan rate (nm/min)",
|
|
30
|
+
"data_interval_nm": "Data interval (nm)",
|
|
31
|
+
"averaging_time_s": "Averaging Time (s)",
|
|
32
|
+
"ex_filter": "Excitation filter",
|
|
33
|
+
"em_filter": "Emission filter",
|
|
34
|
+
"pmt_voltage": "PMT voltage (V)",
|
|
35
|
+
"corrected_spectra": "Corrected spectra",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_cary_eclipse(
|
|
40
|
+
path: str | Path,
|
|
41
|
+
em_grid: np.ndarray | None = None,
|
|
42
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
43
|
+
) -> xr.Dataset:
|
|
44
|
+
"""Read one EEM exported by the Cary Eclipse software as CSV with log.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
path
|
|
49
|
+
CSV file with one emission scan per excitation wavelength.
|
|
50
|
+
em_grid
|
|
51
|
+
If given, each emission scan is linearly interpolated onto this grid
|
|
52
|
+
(nm). If omitted, the native emission axis is kept, which requires all
|
|
53
|
+
scans of the file to share it.
|
|
54
|
+
tolerance
|
|
55
|
+
See :func:`eemkit.core.grid.interp_on_grid`; used only with ``em_grid``.
|
|
56
|
+
|
|
57
|
+
Returns
|
|
58
|
+
-------
|
|
59
|
+
xarray.Dataset
|
|
60
|
+
``intensity`` with dims ``(ex, em)`` in instrument units, coordinate
|
|
61
|
+
``scan_time(ex)`` and the instrument settings as attributes. Values
|
|
62
|
+
are exactly as exported: no correction of any kind is applied.
|
|
63
|
+
"""
|
|
64
|
+
path = Path(path)
|
|
65
|
+
raw = parse_cary_csv(path)
|
|
66
|
+
|
|
67
|
+
names, ex = [], []
|
|
68
|
+
for lab in raw.labels:
|
|
69
|
+
m = LABEL_RE.match(lab)
|
|
70
|
+
if not m:
|
|
71
|
+
raise FileFormatError(
|
|
72
|
+
f"{path}: column label {lab!r} is not '<sample>_EX_<nm>'; "
|
|
73
|
+
"is this a Cary Eclipse 3-D scan?"
|
|
74
|
+
)
|
|
75
|
+
names.append(m.group("name"))
|
|
76
|
+
ex.append(float(m.group("ex")))
|
|
77
|
+
if len(set(names)) > 1:
|
|
78
|
+
raise FileFormatError(f"{path}: several sample names in one file: {sorted(set(names))}")
|
|
79
|
+
if len(set(ex)) != len(ex):
|
|
80
|
+
raise FileFormatError(f"{path}: duplicated excitation wavelengths")
|
|
81
|
+
if raw.titles[:2] != ["Wavelength (nm)", "Intensity (a.u.)"]:
|
|
82
|
+
raise FileFormatError(f"{path}: unexpected column titles {raw.titles[:2]}")
|
|
83
|
+
|
|
84
|
+
order = np.argsort(ex)
|
|
85
|
+
ex_arr = np.asarray(ex)[order]
|
|
86
|
+
x, y = raw.x[:, order], raw.y[:, order]
|
|
87
|
+
labels = [raw.labels[i] for i in order]
|
|
88
|
+
|
|
89
|
+
if em_grid is None:
|
|
90
|
+
keep = ~np.all(np.isnan(x), axis=1)
|
|
91
|
+
x, y = x[keep], y[keep]
|
|
92
|
+
em = x[:, 0]
|
|
93
|
+
if not np.allclose(x, em[:, None], atol=1e-6, equal_nan=True) or np.isnan(em).any():
|
|
94
|
+
raise FileFormatError(
|
|
95
|
+
f"{path}: the emission axis differs between excitation scans; "
|
|
96
|
+
"pass em_grid= to resample each scan onto a common grid"
|
|
97
|
+
)
|
|
98
|
+
em_order = np.argsort(em)
|
|
99
|
+
em, intensity = em[em_order], y[em_order].T
|
|
100
|
+
if np.any(np.diff(em) <= 0):
|
|
101
|
+
raise FileFormatError(f"{path}: repeated emission wavelengths")
|
|
102
|
+
else:
|
|
103
|
+
em = np.asarray(em_grid, dtype=float)
|
|
104
|
+
intensity = np.vstack(
|
|
105
|
+
[interp_on_grid(x[:, j], y[:, j], em, tolerance) for j in range(len(ex_arr))]
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
logs = [raw.log_for(lab) for lab in labels]
|
|
109
|
+
missing = [lab for lab, lg in zip(labels, logs, strict=True) if lg is None]
|
|
110
|
+
if missing:
|
|
111
|
+
warnings.warn(f"{path.name}: no log block for {len(missing)} scan(s), e.g. {missing[0]}",
|
|
112
|
+
EEMKitWarning, stacklevel=2)
|
|
113
|
+
present = [lg for lg in logs if lg is not None]
|
|
114
|
+
times = scan_times(logs)
|
|
115
|
+
|
|
116
|
+
attrs = {
|
|
117
|
+
"sample_name": names[0],
|
|
118
|
+
"source_file": str(path),
|
|
119
|
+
"file_format": "Cary Eclipse CSV (ASCII with log)",
|
|
120
|
+
**common_params(path, present, LOG_PARAMS),
|
|
121
|
+
**time_attrs(times),
|
|
122
|
+
"method_name": "|".join(dict.fromkeys(lg.method_name for lg in present)),
|
|
123
|
+
}
|
|
124
|
+
if attrs.get("data_mode", "Fluorescence") != "Fluorescence" or \
|
|
125
|
+
attrs.get("scan_mode", "Emission") != "Emission":
|
|
126
|
+
raise FileFormatError(f"{path}: not a fluorescence emission scan")
|
|
127
|
+
|
|
128
|
+
ds = xr.Dataset(
|
|
129
|
+
{"intensity": (("ex", "em"), intensity,
|
|
130
|
+
{"units": "a.u.", "long_name": "fluorescence intensity"})},
|
|
131
|
+
coords={
|
|
132
|
+
"ex": ("ex", ex_arr, {"units": "nm", "long_name": "excitation wavelength"}),
|
|
133
|
+
"em": ("em", em, {"units": "nm", "long_name": "emission wavelength"}),
|
|
134
|
+
"scan_time": ("ex", times),
|
|
135
|
+
},
|
|
136
|
+
attrs=attrs,
|
|
137
|
+
)
|
|
138
|
+
return with_history(ds, "read_cary_eclipse",
|
|
139
|
+
em_grid=em if em_grid is not None else "native",
|
|
140
|
+
tolerance_nm=tolerance if em_grid is not None else None)
|
eemkit/io/discover.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Find measurement files in a data folder and draft a pairing table from them.
|
|
2
|
+
|
|
3
|
+
:func:`scan_folder` classifies every CSV from its first two lines (scan labels
|
|
4
|
+
and column titles), without reading the data, and never writes anything.
|
|
5
|
+
|
|
6
|
+
:func:`suggest_pairing` drafts a pairing table from that inventory. The draft
|
|
7
|
+
is a starting point for a person to check: every row is written with
|
|
8
|
+
``include = no`` and ``pairing_status = suggested``, and each choice it made
|
|
9
|
+
(or could not make) is explained in ``pairing_notes``. :func:`load_dataset`
|
|
10
|
+
reads only rows that someone has switched to ``include = yes``.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import pandas as pd
|
|
20
|
+
|
|
21
|
+
from eemkit.io import cary60, cary_eclipse
|
|
22
|
+
|
|
23
|
+
#: Sample or file names matching this (case-insensitive) are treated as blanks.
|
|
24
|
+
DEFAULT_BLANK_PATTERN = r"blank|bianc|milli-?q|\bmq\b|ultrapure"
|
|
25
|
+
|
|
26
|
+
INVENTORY_COLUMNS = ("file", "kind", "sample_name", "n_scans", "first_x", "last_x",
|
|
27
|
+
"is_blank", "problem")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _head(path: Path, n: int = 2) -> list[str]:
|
|
31
|
+
raw = path.open("rb").read(65536)
|
|
32
|
+
for enc in ("utf-8-sig", "cp1252", "latin-1"):
|
|
33
|
+
try:
|
|
34
|
+
return raw.decode(enc).splitlines()[:n]
|
|
35
|
+
except UnicodeDecodeError:
|
|
36
|
+
continue
|
|
37
|
+
return [] # pragma: no cover - latin-1 decodes anything
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def classify(path: str | Path) -> dict:
|
|
41
|
+
"""Kind of one CSV ('eem', 'absorbance' or 'unknown') from its header lines."""
|
|
42
|
+
path = Path(path)
|
|
43
|
+
out = {"kind": "unknown", "sample_name": "", "n_scans": 0, "first_x": None,
|
|
44
|
+
"last_x": None, "problem": ""}
|
|
45
|
+
try:
|
|
46
|
+
lines = _head(path)
|
|
47
|
+
except OSError as exc:
|
|
48
|
+
out["problem"] = f"cannot read: {exc}"
|
|
49
|
+
return out
|
|
50
|
+
if len(lines) < 2:
|
|
51
|
+
out["problem"] = "fewer than two lines"
|
|
52
|
+
return out
|
|
53
|
+
labels = [t.strip() for t in lines[0].split(",") if t.strip()]
|
|
54
|
+
titles = [t.strip() for t in lines[1].split(",")][:2]
|
|
55
|
+
out["n_scans"] = len(labels)
|
|
56
|
+
for kind, regex, title, xgroup in (
|
|
57
|
+
("eem", cary_eclipse.LABEL_RE, "Intensity (a.u.)", "ex"),
|
|
58
|
+
("absorbance", cary60.LABEL_RE, "Abs", "cycle"),
|
|
59
|
+
):
|
|
60
|
+
matches = [regex.match(lab) for lab in labels]
|
|
61
|
+
if labels and all(matches) and titles == ["Wavelength (nm)", title]:
|
|
62
|
+
names = {m.group("name") for m in matches}
|
|
63
|
+
if len(names) > 1:
|
|
64
|
+
out["problem"] = f"several sample names: {sorted(names)}"
|
|
65
|
+
return out
|
|
66
|
+
xs = sorted(float(m.group(xgroup)) for m in matches)
|
|
67
|
+
out.update(kind=kind, sample_name=names.pop(), first_x=xs[0], last_x=xs[-1])
|
|
68
|
+
return out
|
|
69
|
+
out["problem"] = f"not a Cary Eclipse 3-D or Cary 60 absorbance export (titles {titles})"
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def scan_folder(
|
|
74
|
+
root: str | Path,
|
|
75
|
+
*,
|
|
76
|
+
blank_pattern: str = DEFAULT_BLANK_PATTERN,
|
|
77
|
+
progress: Callable[[int, int], None] | None = None,
|
|
78
|
+
) -> pd.DataFrame:
|
|
79
|
+
"""Classify every ``*.csv`` below ``root`` (recursively); read-only.
|
|
80
|
+
|
|
81
|
+
Returns one row per file with :data:`INVENTORY_COLUMNS`; ``file`` is
|
|
82
|
+
relative to ``root`` with forward slashes. ``first_x``/``last_x`` are
|
|
83
|
+
the excitation range (EEM) or cycle numbers (absorbance).
|
|
84
|
+
"""
|
|
85
|
+
root = Path(root)
|
|
86
|
+
files = sorted(p for p in root.rglob("*") if p.is_file() and p.suffix.lower() == ".csv")
|
|
87
|
+
blank = re.compile(blank_pattern, re.IGNORECASE)
|
|
88
|
+
rows = []
|
|
89
|
+
for i, p in enumerate(files):
|
|
90
|
+
info = classify(p)
|
|
91
|
+
rel = p.relative_to(root).as_posix()
|
|
92
|
+
info["file"] = rel
|
|
93
|
+
info["is_blank"] = bool(blank.search(info["sample_name"]) or blank.search(p.name))
|
|
94
|
+
rows.append(info)
|
|
95
|
+
if progress:
|
|
96
|
+
progress(i + 1, len(files))
|
|
97
|
+
return pd.DataFrame(rows, columns=list(INVENTORY_COLUMNS))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _key(name: str) -> str:
|
|
101
|
+
return re.sub(r"[\s_\-.]+", "", name).lower()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _folder(file: str) -> str:
|
|
105
|
+
parent = Path(file).parent.as_posix()
|
|
106
|
+
return "" if parent == "." else parent
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _unique_in_folder(candidates: pd.DataFrame, folder: str) -> tuple[str, str]:
|
|
110
|
+
"""(file, note) for the single candidate in ``folder``, else ('', reason)."""
|
|
111
|
+
here = candidates[candidates["folder"] == folder]
|
|
112
|
+
if len(here) == 1:
|
|
113
|
+
return here["file"].iloc[0], ""
|
|
114
|
+
if len(here) > 1:
|
|
115
|
+
return "", f"{len(here)} candidates in {folder or '.'}"
|
|
116
|
+
return "", f"none in {folder or '.'}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def suggest_pairing(inventory: pd.DataFrame) -> pd.DataFrame:
|
|
120
|
+
"""Draft a pairing table from :func:`scan_folder` output, for a person to check.
|
|
121
|
+
|
|
122
|
+
One row per non-blank EEM. Suggestions, all marked as such:
|
|
123
|
+
|
|
124
|
+
* ``abs_file``: the non-blank absorbance file with the same sample name
|
|
125
|
+
(ignoring case, spaces, ``_``, ``-`` and ``.``), preferring the EEM's
|
|
126
|
+
folder; left empty if there is no single match;
|
|
127
|
+
* ``eem_blank_file``: the only blank EEM in the EEM's folder;
|
|
128
|
+
* ``abs_blank_file``: the only blank absorbance file in the absorbance
|
|
129
|
+
file's folder.
|
|
130
|
+
|
|
131
|
+
Every row has ``include = no``.
|
|
132
|
+
"""
|
|
133
|
+
inv = inventory.copy()
|
|
134
|
+
inv["folder"] = inv["file"].map(_folder)
|
|
135
|
+
eems = inv[(inv["kind"] == "eem") & ~inv["is_blank"]]
|
|
136
|
+
absb = inv[(inv["kind"] == "absorbance") & ~inv["is_blank"]]
|
|
137
|
+
eem_blanks = inv[(inv["kind"] == "eem") & inv["is_blank"]]
|
|
138
|
+
abs_blanks = inv[(inv["kind"] == "absorbance") & inv["is_blank"]]
|
|
139
|
+
abs_keys = absb["sample_name"].map(_key)
|
|
140
|
+
|
|
141
|
+
rows, used = [], {}
|
|
142
|
+
for _, e in eems.iterrows():
|
|
143
|
+
notes = []
|
|
144
|
+
sid = e["sample_name"] or Path(e["file"]).stem
|
|
145
|
+
if sid in used:
|
|
146
|
+
used[sid] += 1
|
|
147
|
+
notes.append(f"sample name also used by another file; renamed to {sid}_{used[sid]}")
|
|
148
|
+
sid = f"{sid}_{used[sid]}"
|
|
149
|
+
else:
|
|
150
|
+
used[sid] = 1
|
|
151
|
+
|
|
152
|
+
cands = absb[abs_keys == _key(e["sample_name"])]
|
|
153
|
+
same = cands[cands["folder"] == e["folder"]]
|
|
154
|
+
pick = same if len(same) == 1 else cands
|
|
155
|
+
if len(pick) == 1:
|
|
156
|
+
abs_file = pick["file"].iloc[0]
|
|
157
|
+
notes.append("absorbance matched by sample name")
|
|
158
|
+
else:
|
|
159
|
+
abs_file = ""
|
|
160
|
+
notes.append("no absorbance with this sample name" if cands.empty
|
|
161
|
+
else f"{len(cands)} absorbance files with this sample name")
|
|
162
|
+
|
|
163
|
+
eem_blank, why = _unique_in_folder(eem_blanks, e["folder"])
|
|
164
|
+
notes.append(f"EEM blank: {'only blank in folder' if eem_blank else why}")
|
|
165
|
+
abs_blank = ""
|
|
166
|
+
if abs_file:
|
|
167
|
+
abs_blank, why = _unique_in_folder(abs_blanks, _folder(abs_file))
|
|
168
|
+
notes.append(f"absorbance blank: {'only blank in folder' if abs_blank else why}")
|
|
169
|
+
|
|
170
|
+
rows.append({"sample_id": sid, "include": "no", "eem_file": e["file"],
|
|
171
|
+
"eem_blank_file": eem_blank, "abs_file": abs_file,
|
|
172
|
+
"abs_blank_file": abs_blank, "pairing_status": "suggested",
|
|
173
|
+
"pairing_notes": "; ".join(notes)})
|
|
174
|
+
return pd.DataFrame(rows, columns=["sample_id", "include", "eem_file", "eem_blank_file",
|
|
175
|
+
"abs_file", "abs_blank_file", "pairing_status",
|
|
176
|
+
"pairing_notes"])
|
eemkit/io/loader.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Build one multi-sample dataset from a pairing table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import xarray as xr
|
|
11
|
+
|
|
12
|
+
from eemkit.core.dataset import SAMPLE_DIM, stack
|
|
13
|
+
from eemkit.core.grid import DEFAULT_TOLERANCE, infer_common_grid, resample
|
|
14
|
+
from eemkit.core.history import HISTORY_ATTR, describe_grid, get_history, make_entry
|
|
15
|
+
from eemkit.errors import GridError
|
|
16
|
+
from eemkit.io.cary60 import mean_cycles, read_cary60
|
|
17
|
+
from eemkit.io.cary_eclipse import read_cary_eclipse
|
|
18
|
+
from eemkit.io.pairing import read_pairing
|
|
19
|
+
|
|
20
|
+
READERS = {"cary_eclipse": read_cary_eclipse}
|
|
21
|
+
ABS_READERS = {"cary60": read_cary60}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_dataset(
|
|
25
|
+
pairing: str | Path | pd.DataFrame,
|
|
26
|
+
data_root: str | Path | None = None,
|
|
27
|
+
em_grid: np.ndarray | None = None,
|
|
28
|
+
wavelength_grid: np.ndarray | None = None,
|
|
29
|
+
tolerance: float = DEFAULT_TOLERANCE,
|
|
30
|
+
eem_format: str = "cary_eclipse",
|
|
31
|
+
abs_format: str = "cary60",
|
|
32
|
+
) -> xr.Dataset:
|
|
33
|
+
"""Read every included row of a pairing table into one dataset.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
pairing
|
|
38
|
+
Path to the pairing CSV, or a table already returned by
|
|
39
|
+
:func:`eemkit.io.pairing.read_pairing`.
|
|
40
|
+
data_root
|
|
41
|
+
Folder the file paths are relative to (see ``read_pairing``).
|
|
42
|
+
em_grid, wavelength_grid
|
|
43
|
+
Common emission grid for EEMs and wavelength grid for absorbance (nm).
|
|
44
|
+
If omitted they are inferred with
|
|
45
|
+
:func:`eemkit.core.grid.infer_common_grid` from all files read, and
|
|
46
|
+
the choice is recorded in the history.
|
|
47
|
+
tolerance
|
|
48
|
+
Edge tolerance of the linear resampling (nm).
|
|
49
|
+
|
|
50
|
+
Returns
|
|
51
|
+
-------
|
|
52
|
+
xarray.Dataset
|
|
53
|
+
Dimensions ``sample``, ``ex``, ``em`` and, when any absorbance is
|
|
54
|
+
paired, ``wavelength``. Variables:
|
|
55
|
+
|
|
56
|
+
* ``intensity`` (sample, ex, em): raw sample EEMs on ``em_grid``
|
|
57
|
+
* ``blank_intensity`` (sample, ex, em): the paired blank, NaN if none
|
|
58
|
+
* ``absorbance``, ``absorbance_sd`` (sample, wavelength): mean and SD
|
|
59
|
+
of the instrument cycles, NaN if none
|
|
60
|
+
* ``blank_absorbance`` (sample, wavelength)
|
|
61
|
+
|
|
62
|
+
All pairing-table columns become ``sample`` coordinates. File and
|
|
63
|
+
instrument metadata are prefixed ``eem_``, ``eem_blank_``, ``abs_``
|
|
64
|
+
and ``abs_blank_``.
|
|
65
|
+
"""
|
|
66
|
+
table = pairing if isinstance(pairing, pd.DataFrame) else read_pairing(pairing, data_root)
|
|
67
|
+
root = Path(data_root if data_root is not None else table.attrs.get("data_root", "."))
|
|
68
|
+
rows = table[table["include"]].reset_index(drop=True)
|
|
69
|
+
if rows.empty:
|
|
70
|
+
raise ValueError("no included rows in the pairing table")
|
|
71
|
+
ids = list(rows["sample_id"])
|
|
72
|
+
|
|
73
|
+
read_eem = READERS[eem_format]
|
|
74
|
+
read_abs = ABS_READERS[abs_format]
|
|
75
|
+
cache: dict[str, xr.Dataset] = {}
|
|
76
|
+
|
|
77
|
+
def load(col: str, reader) -> list[xr.Dataset | None]:
|
|
78
|
+
out = []
|
|
79
|
+
for rel in rows[col]:
|
|
80
|
+
if not rel:
|
|
81
|
+
out.append(None)
|
|
82
|
+
continue
|
|
83
|
+
if rel not in cache:
|
|
84
|
+
cache[rel] = reader(root / rel)
|
|
85
|
+
out.append(cache[rel])
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
eems = load("eem_file", read_eem)
|
|
89
|
+
eem_blanks = load("eem_blank_file", read_eem)
|
|
90
|
+
abss = [mean_cycles(a) if a is not None else None for a in load("abs_file", read_abs)]
|
|
91
|
+
abs_blanks = [mean_cycles(a) if a is not None else None
|
|
92
|
+
for a in load("abs_blank_file", read_abs)]
|
|
93
|
+
|
|
94
|
+
all_eems = [e for e in eems + eem_blanks if e is not None]
|
|
95
|
+
ex0 = all_eems[0]["ex"].values
|
|
96
|
+
for e in all_eems:
|
|
97
|
+
if e.sizes["ex"] != ex0.size or not np.allclose(e["ex"].values, ex0):
|
|
98
|
+
raise GridError(f"{e.attrs['source_file']}: excitation wavelengths differ from "
|
|
99
|
+
f"{all_eems[0].attrs['source_file']}")
|
|
100
|
+
em_inferred = em_grid is None
|
|
101
|
+
if em_inferred:
|
|
102
|
+
em_grid = infer_common_grid(e["em"].values for e in all_eems)
|
|
103
|
+
|
|
104
|
+
def on_grid(items, dim, grid):
|
|
105
|
+
return [resample(it, dim, grid, tolerance) if it is not None else None for it in items]
|
|
106
|
+
|
|
107
|
+
ds = stack(on_grid(eems, "em", em_grid), ids, prefix="eem_")
|
|
108
|
+
history = get_history(ds)
|
|
109
|
+
ds.attrs.pop(HISTORY_ATTR, None)
|
|
110
|
+
if any(b is not None for b in eem_blanks):
|
|
111
|
+
blanks = stack(on_grid(eem_blanks, "em", em_grid), ids, prefix="eem_blank_")
|
|
112
|
+
ds["blank_intensity"] = blanks["intensity"]
|
|
113
|
+
ds = _merge_meta(ds, blanks, "eem_blank_")
|
|
114
|
+
|
|
115
|
+
params = {"em_grid": describe_grid(em_grid), "em_grid_inferred": em_inferred,
|
|
116
|
+
"tolerance_nm": tolerance, "n_samples": len(ids),
|
|
117
|
+
"n_with_eem_blank": sum(b is not None for b in eem_blanks),
|
|
118
|
+
"n_with_absorbance": sum(a is not None for a in abss)}
|
|
119
|
+
|
|
120
|
+
all_abs = [a for a in abss + abs_blanks if a is not None]
|
|
121
|
+
if all_abs:
|
|
122
|
+
wl_inferred = wavelength_grid is None
|
|
123
|
+
if wl_inferred:
|
|
124
|
+
wavelength_grid = infer_common_grid(a["wavelength"].values for a in all_abs)
|
|
125
|
+
params.update(wavelength_grid=describe_grid(wavelength_grid),
|
|
126
|
+
wavelength_grid_inferred=wl_inferred)
|
|
127
|
+
for items, prefix, names in (
|
|
128
|
+
(abss, "abs_", {"absorbance": "absorbance", "absorbance_sd": "absorbance_sd"}),
|
|
129
|
+
(abs_blanks, "abs_blank_", {"absorbance": "blank_absorbance"}),
|
|
130
|
+
):
|
|
131
|
+
if all(it is None for it in items):
|
|
132
|
+
continue
|
|
133
|
+
part = stack(on_grid(items, "wavelength", wavelength_grid), ids, prefix=prefix)
|
|
134
|
+
for src, dst in names.items():
|
|
135
|
+
ds[dst] = part[src]
|
|
136
|
+
ds = _merge_meta(ds, part, prefix)
|
|
137
|
+
|
|
138
|
+
meta = {c: (SAMPLE_DIM, rows[c].to_numpy(dtype=object))
|
|
139
|
+
for c in rows.columns if c not in ("sample_id", "include")}
|
|
140
|
+
ds = ds.assign_coords(meta)
|
|
141
|
+
ds.attrs["pairing_source"] = table.attrs.get("source", "")
|
|
142
|
+
ds.attrs["data_root"] = str(root)
|
|
143
|
+
ds.attrs["spectral_correction"] = (
|
|
144
|
+
"instrument ('Corrected spectra' ON)"
|
|
145
|
+
if str(ds.attrs.get("eem_corrected_spectra", "")).upper() == "ON" else "none")
|
|
146
|
+
ds.attrs[HISTORY_ATTR] = json.dumps(history + [make_entry("load_dataset", **params)])
|
|
147
|
+
return ds
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _merge_meta(ds: xr.Dataset, part: xr.Dataset, prefix: str) -> xr.Dataset:
|
|
151
|
+
"""Copy the prefixed coordinates and attributes of ``part`` into ``ds``.
|
|
152
|
+
|
|
153
|
+
The history of ``part`` is kept as ``<prefix>history``.
|
|
154
|
+
"""
|
|
155
|
+
ds = ds.assign_coords({k: v for k, v in part.coords.items() if k not in part.dims})
|
|
156
|
+
for k, v in part.attrs.items():
|
|
157
|
+
ds.attrs[f"{prefix}history" if k == HISTORY_ATTR else k] = v
|
|
158
|
+
return ds
|
eemkit/io/pairing.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""The pairing table: which EEM, absorbance and blank files belong together.
|
|
2
|
+
|
|
3
|
+
Pairing is never guessed by eemkit. A CSV table, written or checked by a
|
|
4
|
+
person, lists one sample per row:
|
|
5
|
+
|
|
6
|
+
================= ======== ==================================================
|
|
7
|
+
column required content
|
|
8
|
+
================= ======== ==================================================
|
|
9
|
+
``sample_id`` yes unique identifier used throughout the analysis
|
|
10
|
+
``eem_file`` yes fluorescence file, relative to ``data_root``
|
|
11
|
+
``include`` yes yes/no (also true/false, 1/0, si/sì)
|
|
12
|
+
``eem_blank_file`` no blank EEM (e.g. Milli-Q) measured with the sample
|
|
13
|
+
``abs_file`` no absorbance file of the same sample
|
|
14
|
+
``abs_blank_file`` no absorbance blank
|
|
15
|
+
any other column no kept as sample metadata (river, site, date, ...)
|
|
16
|
+
================= ======== ==================================================
|
|
17
|
+
|
|
18
|
+
Comma or semicolon separators are accepted, so the table can be edited in a
|
|
19
|
+
spreadsheet with an Italian or other comma-decimal locale.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
from eemkit.errors import PairingError
|
|
29
|
+
|
|
30
|
+
REQUIRED_COLUMNS = ("sample_id", "eem_file", "include")
|
|
31
|
+
FILE_COLUMNS = ("eem_file", "eem_blank_file", "abs_file", "abs_blank_file")
|
|
32
|
+
_TRUE = {"yes", "y", "true", "1", "si", "sì"}
|
|
33
|
+
_FALSE = {"no", "n", "false", "0", ""}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_pairing(
|
|
37
|
+
path: str | Path,
|
|
38
|
+
data_root: str | Path | None = None,
|
|
39
|
+
check_files: bool = True,
|
|
40
|
+
) -> pd.DataFrame:
|
|
41
|
+
"""Read and validate a pairing table.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
path
|
|
46
|
+
CSV file.
|
|
47
|
+
data_root
|
|
48
|
+
Folder the file paths are relative to; defaults to the table's folder.
|
|
49
|
+
check_files
|
|
50
|
+
Verify that every file referenced by an included row exists.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
pandas.DataFrame
|
|
55
|
+
All rows (included or not), every column as text except ``include``
|
|
56
|
+
(bool). ``df.attrs`` holds ``source`` and ``data_root``.
|
|
57
|
+
|
|
58
|
+
Raises
|
|
59
|
+
------
|
|
60
|
+
PairingError
|
|
61
|
+
Listing every problem found, not only the first.
|
|
62
|
+
"""
|
|
63
|
+
path = Path(path)
|
|
64
|
+
root = Path(data_root) if data_root is not None else path.parent
|
|
65
|
+
df = _read_csv(path)
|
|
66
|
+
df.columns = [str(c).strip() for c in df.columns]
|
|
67
|
+
|
|
68
|
+
missing = [c for c in REQUIRED_COLUMNS if c not in df.columns]
|
|
69
|
+
if missing:
|
|
70
|
+
raise PairingError(f"{path}: missing required column(s) {missing}")
|
|
71
|
+
for col in FILE_COLUMNS:
|
|
72
|
+
if col not in df.columns:
|
|
73
|
+
df[col] = ""
|
|
74
|
+
df = df.apply(lambda s: s.str.strip())
|
|
75
|
+
|
|
76
|
+
problems = []
|
|
77
|
+
flags = df["include"].str.lower()
|
|
78
|
+
bad = ~flags.isin(_TRUE | _FALSE)
|
|
79
|
+
for i in df.index[bad]:
|
|
80
|
+
problems.append(f"row {i + 2}: include={df.at[i, 'include']!r} is not yes/no")
|
|
81
|
+
df["include"] = flags.isin(_TRUE)
|
|
82
|
+
|
|
83
|
+
inc = df[df["include"]]
|
|
84
|
+
for i in inc.index[inc["sample_id"] == ""]:
|
|
85
|
+
problems.append(f"row {i + 2}: empty sample_id")
|
|
86
|
+
for i in inc.index[inc["eem_file"] == ""]:
|
|
87
|
+
problems.append(f"row {i + 2}: included but eem_file is empty")
|
|
88
|
+
dup = inc["sample_id"][inc["sample_id"].duplicated(keep=False) & (inc["sample_id"] != "")]
|
|
89
|
+
for sid in sorted(set(dup)):
|
|
90
|
+
rows = [str(i + 2) for i in dup.index[dup == sid]]
|
|
91
|
+
problems.append(f"sample_id {sid!r} used by rows {', '.join(rows)}")
|
|
92
|
+
|
|
93
|
+
if check_files:
|
|
94
|
+
for i, row in inc.iterrows():
|
|
95
|
+
for col in FILE_COLUMNS:
|
|
96
|
+
if row[col] and not (root / row[col]).is_file():
|
|
97
|
+
problems.append(f"row {i + 2}: {col} not found: {row[col]}")
|
|
98
|
+
|
|
99
|
+
if problems:
|
|
100
|
+
raise PairingError(f"{path}: {len(problems)} problem(s)\n " + "\n ".join(problems))
|
|
101
|
+
df.attrs["source"] = str(path)
|
|
102
|
+
df.attrs["data_root"] = str(root)
|
|
103
|
+
return df
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _read_csv(path: Path) -> pd.DataFrame:
|
|
107
|
+
for enc in ("utf-8-sig", "cp1252"):
|
|
108
|
+
try:
|
|
109
|
+
with open(path, encoding=enc) as fh:
|
|
110
|
+
header = fh.readline()
|
|
111
|
+
sep = ";" if header.count(";") > header.count(",") else ","
|
|
112
|
+
return pd.read_csv(path, sep=sep, dtype=str, keep_default_na=False, encoding=enc)
|
|
113
|
+
except UnicodeDecodeError:
|
|
114
|
+
continue
|
|
115
|
+
raise PairingError(f"{path}: cannot decode as UTF-8 or Windows-1252")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Non-negative PARAFAC with the diagnostics of Murphy et al. (2013)."""
|
|
2
|
+
|
|
3
|
+
from eemkit.parafac._cp import nn_parafac
|
|
4
|
+
from eemkit.parafac.diagnostics import (
|
|
5
|
+
core_consistency,
|
|
6
|
+
leverage,
|
|
7
|
+
match_components,
|
|
8
|
+
tucker_congruence,
|
|
9
|
+
)
|
|
10
|
+
from eemkit.parafac.export import to_openfluor
|
|
11
|
+
from eemkit.parafac.model import fit_parafac, fit_ranks, model_eem, residuals, summary_table
|
|
12
|
+
from eemkit.parafac.validation import split_groups, split_half
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"core_consistency",
|
|
16
|
+
"fit_parafac",
|
|
17
|
+
"fit_ranks",
|
|
18
|
+
"leverage",
|
|
19
|
+
"match_components",
|
|
20
|
+
"model_eem",
|
|
21
|
+
"nn_parafac",
|
|
22
|
+
"residuals",
|
|
23
|
+
"split_groups",
|
|
24
|
+
"split_half",
|
|
25
|
+
"summary_table",
|
|
26
|
+
"to_openfluor",
|
|
27
|
+
"tucker_congruence",
|
|
28
|
+
]
|