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
@@ -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
+ ]