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,334 @@
|
|
|
1
|
+
"""Tab 2: correction settings, applying them, and a preview of any sample."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from PySide6.QtCore import Qt
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QCheckBox,
|
|
9
|
+
QComboBox,
|
|
10
|
+
QFileDialog,
|
|
11
|
+
QFormLayout,
|
|
12
|
+
QGroupBox,
|
|
13
|
+
QHBoxLayout,
|
|
14
|
+
QLabel,
|
|
15
|
+
QLineEdit,
|
|
16
|
+
QMessageBox,
|
|
17
|
+
QPushButton,
|
|
18
|
+
QScrollArea,
|
|
19
|
+
QSplitter,
|
|
20
|
+
QVBoxLayout,
|
|
21
|
+
QWidget,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from eemkit import corrections as C
|
|
25
|
+
from eemkit import pipeline, plot
|
|
26
|
+
from eemkit.gui.common import OptionalFloat, PlotPanel, TaskBar, note, pair, primary, spin
|
|
27
|
+
from eemkit.gui.state import Project
|
|
28
|
+
|
|
29
|
+
BANDS = {"rayleigh1": "Rayleigh 1st order", "raman1": "Raman 1st order",
|
|
30
|
+
"rayleigh2": "Rayleigh 2nd order", "raman2": "Raman 2nd order"}
|
|
31
|
+
STAGES = {"Raw sample": ("raw", "intensity"), "Raw blank": ("raw", "blank_intensity"),
|
|
32
|
+
"After inner filter, blank, Raman units": ("after_raman", "intensity"),
|
|
33
|
+
"Corrected (scatter removed, cropped)": ("corrected", "intensity"),
|
|
34
|
+
"Spectral correction factors": ("after_raman", "spectral_correction_ex")}
|
|
35
|
+
CONVENTIONS = {"multiply": "correction factors (multiply)",
|
|
36
|
+
"divide": "instrument response (divide)"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CorrectionsTab(QWidget):
|
|
40
|
+
def __init__(self, project: Project, tasks: TaskBar, parent=None):
|
|
41
|
+
super().__init__(parent)
|
|
42
|
+
self.project, self.tasks = project, tasks
|
|
43
|
+
s = pipeline.DEFAULTS
|
|
44
|
+
|
|
45
|
+
self.factor_files = {k: QLineEdit() for k in ("ex_factors", "em_factors")}
|
|
46
|
+
self.factor_info = {k: QLabel("") for k in self.factor_files}
|
|
47
|
+
self.convention = QComboBox()
|
|
48
|
+
self.convention.addItems(list(CONVENTIONS.values()))
|
|
49
|
+
g_sp = QGroupBox("Instrument spectral correction (optional)")
|
|
50
|
+
f = QFormLayout(g_sp)
|
|
51
|
+
for key, label in (("ex_factors", "Excitation file"), ("em_factors", "Emission file")):
|
|
52
|
+
edit = self.factor_files[key]
|
|
53
|
+
edit.setReadOnly(True)
|
|
54
|
+
edit.setPlaceholderText("not applied")
|
|
55
|
+
b_choose, b_clear = QPushButton("Choose…"), QPushButton("Clear")
|
|
56
|
+
b_choose.clicked.connect(lambda _, k=key: self.choose_factors(k))
|
|
57
|
+
b_clear.clicked.connect(lambda _, k=key: self.set_factor_file(k, ""))
|
|
58
|
+
f.addRow(label, pair(edit, b_choose, b_clear))
|
|
59
|
+
self.factor_info[key].setStyleSheet("color: gray")
|
|
60
|
+
f.addRow("", self.factor_info[key])
|
|
61
|
+
f.addRow("Files contain", self.convention)
|
|
62
|
+
f.addRow(note("Applied first, to sample and blank EEMs. Two columns: wavelength (nm), "
|
|
63
|
+
"factor. Factors are specific to your instrument and must be measured "
|
|
64
|
+
"(e.g. with certified reference standards); eemkit provides none. Leave "
|
|
65
|
+
"empty if you have no factors: indices then depend on the instrument "
|
|
66
|
+
"(Cory et al. 2010)."))
|
|
67
|
+
|
|
68
|
+
self.l_abs = OptionalFloat(required=True, placeholder="required, e.g. 1")
|
|
69
|
+
self.l_fl = OptionalFloat(required=True, placeholder="required, e.g. 1")
|
|
70
|
+
g_ife = QGroupBox("Inner filter (Kothawala et al. 2013)")
|
|
71
|
+
f = QFormLayout(g_ife)
|
|
72
|
+
f.addRow("Absorbance cuvette (cm)", self.l_abs)
|
|
73
|
+
f.addRow("Fluorescence cuvette (cm)", self.l_fl)
|
|
74
|
+
f.addRow(note("Path lengths depend on your cuvettes and have no default."))
|
|
75
|
+
|
|
76
|
+
r = s["raman"]
|
|
77
|
+
self.r_ex = spin(r["ex"], 200, 600, 1, 0, " nm")
|
|
78
|
+
self.r_lo = spin(r["em_range"][0], 200, 800, 1, 0, " nm")
|
|
79
|
+
self.r_hi = spin(r["em_range"][1], 200, 800, 1, 0, " nm")
|
|
80
|
+
self.r_base = QCheckBox("Subtract a linear baseline under the peak")
|
|
81
|
+
g_ram = QGroupBox("Raman units (Lawaetz && Stedmon 2009)") # && shows one &
|
|
82
|
+
f = QFormLayout(g_ram)
|
|
83
|
+
f.addRow("Excitation", self.r_ex)
|
|
84
|
+
f.addRow("Emission range", pair(self.r_lo, QLabel("–"), self.r_hi))
|
|
85
|
+
f.addRow(self.r_base)
|
|
86
|
+
|
|
87
|
+
sc = s["scatter"]
|
|
88
|
+
self.shift = spin(sc["raman_shift_cm1"], 2500, 4500, 10, 0, " cm⁻¹")
|
|
89
|
+
self.widths = {}
|
|
90
|
+
g_sc = QGroupBox("Scatter removal")
|
|
91
|
+
f = QFormLayout(g_sc)
|
|
92
|
+
f.addRow("Water Raman shift", self.shift)
|
|
93
|
+
for band, label in BANDS.items():
|
|
94
|
+
lo = spin(sc[band][0], 0, 100, 1, 1, " nm")
|
|
95
|
+
hi = spin(sc[band][1], 0, 100, 1, 1, " nm")
|
|
96
|
+
self.widths[band] = (lo, hi)
|
|
97
|
+
f.addRow(label, pair(QLabel("below"), lo, QLabel("above"), hi))
|
|
98
|
+
self.zero_below = OptionalFloat(placeholder="off")
|
|
99
|
+
f.addRow("Zeros below Rayleigh, offset (nm)", self.zero_below)
|
|
100
|
+
self.interp = QComboBox()
|
|
101
|
+
self.interp.addItems(["no (keep as missing)", "em", "ex", "both"])
|
|
102
|
+
f.addRow("Interpolate removed bands", self.interp)
|
|
103
|
+
f.addRow(note("Defaults are generic (10 nm bands); check them against the blanks in the "
|
|
104
|
+
"preview. Zeros below Rayleigh: Thygesen et al. 2004."))
|
|
105
|
+
|
|
106
|
+
self.crop = {k: OptionalFloat(placeholder="no limit")
|
|
107
|
+
for k in ("ex_min", "ex_max", "em_min", "em_max")}
|
|
108
|
+
g_crop = QGroupBox("Crop")
|
|
109
|
+
f = QFormLayout(g_crop)
|
|
110
|
+
f.addRow("Excitation (nm)", pair(self.crop["ex_min"], QLabel("–"), self.crop["ex_max"]))
|
|
111
|
+
f.addRow("Emission (nm)", pair(self.crop["em_min"], QLabel("–"), self.crop["em_max"]))
|
|
112
|
+
|
|
113
|
+
self.missing = QComboBox()
|
|
114
|
+
self.missing.addItems(["error", "drop", "keep"])
|
|
115
|
+
g_miss = QGroupBox("Samples without blank or absorbance")
|
|
116
|
+
f = QFormLayout(g_miss)
|
|
117
|
+
f.addRow("Policy", self.missing)
|
|
118
|
+
|
|
119
|
+
self.b_apply = primary(QPushButton("Apply corrections"))
|
|
120
|
+
self.b_apply.clicked.connect(self.apply)
|
|
121
|
+
form = QWidget()
|
|
122
|
+
fl = QVBoxLayout(form)
|
|
123
|
+
for g in (g_sp, g_ife, g_ram, g_sc, g_crop, g_miss):
|
|
124
|
+
fl.addWidget(g)
|
|
125
|
+
fl.addWidget(self.b_apply)
|
|
126
|
+
fl.addStretch(1)
|
|
127
|
+
scroll = QScrollArea()
|
|
128
|
+
scroll.setWidget(form)
|
|
129
|
+
scroll.setWidgetResizable(True)
|
|
130
|
+
scroll.setMinimumWidth(380)
|
|
131
|
+
|
|
132
|
+
self.sample = QComboBox()
|
|
133
|
+
self.stage = QComboBox()
|
|
134
|
+
self.stage.addItems(list(STAGES))
|
|
135
|
+
self.sample.currentIndexChanged.connect(self.preview)
|
|
136
|
+
self.stage.currentIndexChanged.connect(self.preview)
|
|
137
|
+
self.plot = PlotPanel()
|
|
138
|
+
self.summary = QLabel("")
|
|
139
|
+
self.summary.setWordWrap(True)
|
|
140
|
+
self.summary.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
141
|
+
right = QWidget()
|
|
142
|
+
rl = QVBoxLayout(right)
|
|
143
|
+
top = QHBoxLayout()
|
|
144
|
+
top.addWidget(QLabel("Sample"))
|
|
145
|
+
top.addWidget(self.sample, 1)
|
|
146
|
+
top.addWidget(QLabel("Step"))
|
|
147
|
+
top.addWidget(self.stage, 1)
|
|
148
|
+
rl.addLayout(top)
|
|
149
|
+
rl.addWidget(self.plot, 1)
|
|
150
|
+
rl.addWidget(self.summary)
|
|
151
|
+
|
|
152
|
+
split = QSplitter()
|
|
153
|
+
split.addWidget(scroll)
|
|
154
|
+
split.addWidget(right)
|
|
155
|
+
split.setStretchFactor(1, 1)
|
|
156
|
+
QVBoxLayout(self).addWidget(split)
|
|
157
|
+
project.changed.connect(self._on_changed)
|
|
158
|
+
tasks.busy_changed.connect(lambda b: self.b_apply.setEnabled(not b))
|
|
159
|
+
self.load_settings()
|
|
160
|
+
|
|
161
|
+
# settings <-> form -------------------------------------------------- #
|
|
162
|
+
|
|
163
|
+
def load_settings(self) -> None:
|
|
164
|
+
s = self.project.settings
|
|
165
|
+
for key in self.factor_files:
|
|
166
|
+
p = self.project.resolve(key, "spectral")
|
|
167
|
+
self.set_factor_file(key, str(p) if p is not None else "")
|
|
168
|
+
self.convention.setCurrentText(CONVENTIONS[s["spectral"].get("convention", "multiply")])
|
|
169
|
+
self.l_abs.set_value(s["inner_filter"].get("absorbance_path_length_cm"))
|
|
170
|
+
self.l_fl.set_value(s["inner_filter"].get("fluorescence_path_length_cm"))
|
|
171
|
+
r = s["raman"]
|
|
172
|
+
self.r_ex.setValue(float(r["ex"]))
|
|
173
|
+
self.r_lo.setValue(float(r["em_range"][0]))
|
|
174
|
+
self.r_hi.setValue(float(r["em_range"][1]))
|
|
175
|
+
self.r_base.setChecked(bool(r["baseline"]))
|
|
176
|
+
sc = s["scatter"]
|
|
177
|
+
self.shift.setValue(float(sc["raman_shift_cm1"]))
|
|
178
|
+
for band, (lo, hi) in self.widths.items():
|
|
179
|
+
lo.setValue(float(sc[band][0]))
|
|
180
|
+
hi.setValue(float(sc[band][1]))
|
|
181
|
+
self.zero_below.set_value(sc.get("zero_below_rayleigh"))
|
|
182
|
+
self.interp.setCurrentIndex(["em", "ex", "both"].index(sc["interpolate"]) + 1
|
|
183
|
+
if sc.get("interpolate") else 0)
|
|
184
|
+
for k, w in self.crop.items():
|
|
185
|
+
w.set_value(s["crop"].get(k))
|
|
186
|
+
self.missing.setCurrentText(s["missing"]["policy"])
|
|
187
|
+
|
|
188
|
+
def store_settings(self) -> bool:
|
|
189
|
+
if not (self.l_abs.is_valid() and self.l_fl.is_valid()):
|
|
190
|
+
QMessageBox.information(self, "eemkit", "Enter both cuvette path lengths (cm). "
|
|
191
|
+
"They depend on your measurement and have no default.")
|
|
192
|
+
return False
|
|
193
|
+
files = {k: e.text() for k, e in self.factor_files.items()}
|
|
194
|
+
if bool(files["ex_factors"]) != bool(files["em_factors"]):
|
|
195
|
+
QMessageBox.information(self, "eemkit", "Spectral correction needs both the "
|
|
196
|
+
"excitation and the emission file (or neither).")
|
|
197
|
+
return False
|
|
198
|
+
convention = next(k for k, v in CONVENTIONS.items()
|
|
199
|
+
if v == self.convention.currentText())
|
|
200
|
+
sections = {
|
|
201
|
+
"spectral": {**{k: self.project.relative(v) for k, v in files.items()},
|
|
202
|
+
"convention": convention},
|
|
203
|
+
"inner_filter": {"absorbance_path_length_cm": self.l_abs.value(),
|
|
204
|
+
"fluorescence_path_length_cm": self.l_fl.value()},
|
|
205
|
+
"raman": {"ex": self.r_ex.value(), "em_range": [self.r_lo.value(), self.r_hi.value()],
|
|
206
|
+
"baseline": self.r_base.isChecked()},
|
|
207
|
+
"scatter": {"raman_shift_cm1": self.shift.value(),
|
|
208
|
+
**{b: [lo.value(), hi.value()] for b, (lo, hi) in self.widths.items()},
|
|
209
|
+
"zero_below_rayleigh": self.zero_below.value(),
|
|
210
|
+
"interpolate": False if self.interp.currentIndex() == 0
|
|
211
|
+
else self.interp.currentText()},
|
|
212
|
+
"crop": {k: w.value() for k, w in self.crop.items()},
|
|
213
|
+
"missing": {"policy": self.missing.currentText()},
|
|
214
|
+
}
|
|
215
|
+
# read the whole form before storing: storing emits signals that reload it
|
|
216
|
+
for name, values in sections.items():
|
|
217
|
+
self.project.set_settings(name, values, "corrected")
|
|
218
|
+
return True
|
|
219
|
+
|
|
220
|
+
def set_factor_file(self, key: str, path: str) -> None:
|
|
221
|
+
"""Show ``path`` and the range it covers (the file is read to check it)."""
|
|
222
|
+
self.factor_files[key].setText(path)
|
|
223
|
+
info = self.factor_info[key]
|
|
224
|
+
if not path:
|
|
225
|
+
info.setText("")
|
|
226
|
+
return
|
|
227
|
+
try:
|
|
228
|
+
wl, f = C.read_correction_file(path)
|
|
229
|
+
except (OSError, ValueError) as exc: # FileFormatError is a ValueError
|
|
230
|
+
info.setText(f"cannot read: {exc}")
|
|
231
|
+
return
|
|
232
|
+
info.setText(f"{wl.size} values, {wl.min():g}-{wl.max():g} nm, factors "
|
|
233
|
+
f"{f.min():.4g}-{f.max():.4g}")
|
|
234
|
+
|
|
235
|
+
def choose_factors(self, key: str) -> None:
|
|
236
|
+
start = self.factor_files[key].text() or str(self.project.base)
|
|
237
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
238
|
+
self, "Spectral correction factors", start,
|
|
239
|
+
"Text or CSV (*.csv *.txt *.dat);;All files (*)")
|
|
240
|
+
if path:
|
|
241
|
+
self.set_factor_file(key, path)
|
|
242
|
+
|
|
243
|
+
# actions ------------------------------------------------------------ #
|
|
244
|
+
|
|
245
|
+
def apply(self) -> None:
|
|
246
|
+
if self.project.raw is None:
|
|
247
|
+
QMessageBox.information(self, "eemkit", "Load the data first (Project tab).")
|
|
248
|
+
return
|
|
249
|
+
if not self.store_settings():
|
|
250
|
+
return
|
|
251
|
+
raw, settings, base = self.project.raw, self.project.settings, self.project.base
|
|
252
|
+
|
|
253
|
+
def work(progress, cancelled):
|
|
254
|
+
progress("Spectral correction, inner filter, blank, Raman units", 0, 2)
|
|
255
|
+
after = pipeline.correct_until_raman(raw, settings, base=base)
|
|
256
|
+
progress("Scatter and crop", 1, 2)
|
|
257
|
+
return after, pipeline.finish_corrections(after, settings)
|
|
258
|
+
|
|
259
|
+
def done(result):
|
|
260
|
+
self.project.after_raman, self.project.corrected = result
|
|
261
|
+
self.project.changed.emit("corrected")
|
|
262
|
+
self.stage.setCurrentIndex(3)
|
|
263
|
+
|
|
264
|
+
self.tasks.run(work, done, "Applying corrections…")
|
|
265
|
+
|
|
266
|
+
def _on_changed(self, what: str) -> None:
|
|
267
|
+
if what == "settings":
|
|
268
|
+
self.load_settings()
|
|
269
|
+
if what == "display":
|
|
270
|
+
self.preview()
|
|
271
|
+
if what in ("raw", "corrected", "pairing"):
|
|
272
|
+
ds = self.project.raw
|
|
273
|
+
current = self.sample.currentText()
|
|
274
|
+
self.sample.blockSignals(True)
|
|
275
|
+
self.sample.clear()
|
|
276
|
+
if ds is not None:
|
|
277
|
+
self.sample.addItems([str(x) for x in ds["sample"].values])
|
|
278
|
+
if current:
|
|
279
|
+
self.sample.setCurrentText(current)
|
|
280
|
+
self.sample.blockSignals(False)
|
|
281
|
+
self.update_summary()
|
|
282
|
+
self.preview()
|
|
283
|
+
|
|
284
|
+
def update_summary(self) -> None:
|
|
285
|
+
p = self.project
|
|
286
|
+
lines = []
|
|
287
|
+
if p.raw is not None:
|
|
288
|
+
done = p.after_raman if p.after_raman is not None else p.raw
|
|
289
|
+
lines.append(f"{p.raw.sizes['sample']} samples loaded. Spectral correction: "
|
|
290
|
+
f"{C.spectral_correction_status(done)}.")
|
|
291
|
+
if p.corrected is not None:
|
|
292
|
+
ife = p.corrected["ife_factor"]
|
|
293
|
+
ra = p.corrected["raman_area"].values
|
|
294
|
+
miss = float(np.isnan(p.corrected["intensity"]).mean()) * 100
|
|
295
|
+
lines.append(f"Inner-filter factor over all cells: median {float(ife.median()):.3f}, "
|
|
296
|
+
f"max {float(ife.max()):.3f}. Raman area of the blanks: "
|
|
297
|
+
f"{np.nanmin(ra):.4g}-{np.nanmax(ra):.4g} (CV "
|
|
298
|
+
f"{np.nanstd(ra) / np.nanmean(ra) * 100:.1f} %). Corrected grid: ex "
|
|
299
|
+
f"{p.corrected.ex.min().item():g}-{p.corrected.ex.max().item():g}, em "
|
|
300
|
+
f"{p.corrected.em.min().item():g}-{p.corrected.em.max().item():g} nm; "
|
|
301
|
+
f"{miss:.1f} % missing cells.")
|
|
302
|
+
self.summary.setText("\n".join(lines))
|
|
303
|
+
|
|
304
|
+
def preview(self) -> None:
|
|
305
|
+
name = self.sample.currentText()
|
|
306
|
+
where, var = STAGES[self.stage.currentText()]
|
|
307
|
+
ds = getattr(self.project, where)
|
|
308
|
+
if var == "spectral_correction_ex":
|
|
309
|
+
if ds is None or var not in ds:
|
|
310
|
+
self.plot.clear("No spectral correction applied." if ds is not None else
|
|
311
|
+
"Apply the corrections to see this step.")
|
|
312
|
+
else:
|
|
313
|
+
self.plot.show_figure(plot.spectral_factors(ds, width=6.0))
|
|
314
|
+
return
|
|
315
|
+
if not name or ds is None or var not in ds or name not in ds["sample"].values:
|
|
316
|
+
self.plot.clear("Apply the corrections to see this step." if name else
|
|
317
|
+
"Load the data to preview samples.")
|
|
318
|
+
return
|
|
319
|
+
da = ds[var].sel(sample=name)
|
|
320
|
+
if da.isnull().all():
|
|
321
|
+
self.plot.clear("No data for this sample at this step.")
|
|
322
|
+
return
|
|
323
|
+
title = f"{name}: {self.stage.currentText()}"
|
|
324
|
+
vmax = None
|
|
325
|
+
cor = self.project.corrected
|
|
326
|
+
if where != "corrected" and cor is not None and name in cor["sample"].values:
|
|
327
|
+
# scatter outshines fluorescence: cap the colour scale at the maximum of the
|
|
328
|
+
# cells kept after scatter removal
|
|
329
|
+
kept = cor["intensity"].sel(sample=name).notnull()
|
|
330
|
+
vmax = float(da.sel(ex=kept.ex, em=kept.em).where(kept).max())
|
|
331
|
+
title += " (colour scale capped at the fluorescence maximum)"
|
|
332
|
+
fig = plot.eems({title: da}, ncols=1, width=6.0, vmax=vmax,
|
|
333
|
+
cmap=self.project.eem_colours)
|
|
334
|
+
self.plot.show_figure(fig)
|
eemkit/gui/export_tab.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Tab 6: write results, OpenFluor file, figures and the processing record."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PySide6.QtCore import QUrl
|
|
6
|
+
from PySide6.QtGui import QDesktopServices
|
|
7
|
+
from PySide6.QtWidgets import (
|
|
8
|
+
QCheckBox,
|
|
9
|
+
QFormLayout,
|
|
10
|
+
QGroupBox,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLineEdit,
|
|
13
|
+
QMessageBox,
|
|
14
|
+
QPlainTextEdit,
|
|
15
|
+
QPushButton,
|
|
16
|
+
QVBoxLayout,
|
|
17
|
+
QWidget,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from eemkit import pipeline
|
|
21
|
+
from eemkit.gui.common import TaskBar, note, primary
|
|
22
|
+
from eemkit.gui.state import Project
|
|
23
|
+
from eemkit.parafac.export import METADATA_KEYS
|
|
24
|
+
|
|
25
|
+
#: OpenFluor fields the user can fill; the others are derived from the model.
|
|
26
|
+
USER_FIELDS = ("name", "creator", "email", "doi", "reference", "fluorometer", "sources",
|
|
27
|
+
"ecozones", "methods", "preprocess", "validation", "description")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ExportTab(QWidget):
|
|
31
|
+
def __init__(self, project: Project, tasks: TaskBar, parent=None):
|
|
32
|
+
super().__init__(parent)
|
|
33
|
+
self.project, self.tasks = project, tasks
|
|
34
|
+
|
|
35
|
+
self.what = {k: QCheckBox(label) for k, label in (
|
|
36
|
+
("corrected", "Corrected EEMs (netCDF, with processing history)"),
|
|
37
|
+
("indices", "Fluorescence indices and CDOM metrics (CSV)"),
|
|
38
|
+
("exploration", "Exploratory PARAFAC summary (CSV)"),
|
|
39
|
+
("model", "Final model: Fmax, loadings, OpenFluor file, model (netCDF)"),
|
|
40
|
+
("figures", "Figures (PDF and PNG)"))}
|
|
41
|
+
g_what = QGroupBox("Write")
|
|
42
|
+
v = QVBoxLayout(g_what)
|
|
43
|
+
for box in self.what.values():
|
|
44
|
+
box.setChecked(True)
|
|
45
|
+
v.addWidget(box)
|
|
46
|
+
v.addWidget(note("settings.toml and processing_record.json (software versions, every "
|
|
47
|
+
"processing step and its parameters) are always written, so the "
|
|
48
|
+
"analysis can be repeated with eemkit.pipeline."))
|
|
49
|
+
|
|
50
|
+
self.meta = {k: QLineEdit() for k in USER_FIELDS}
|
|
51
|
+
for e in self.meta.values():
|
|
52
|
+
e.setPlaceholderText("?")
|
|
53
|
+
e.editingFinished.connect(self.store_metadata)
|
|
54
|
+
g_of = QGroupBox("OpenFluor metadata (Murphy et al. 2014)")
|
|
55
|
+
f = QFormLayout(g_of)
|
|
56
|
+
for k, e in self.meta.items():
|
|
57
|
+
f.addRow(k, e)
|
|
58
|
+
f.addRow(note("Empty fields are written as '?'. Check the file against the current "
|
|
59
|
+
"OpenFluor template before uploading."))
|
|
60
|
+
|
|
61
|
+
self.b_export = primary(QPushButton("Export to the output folder"))
|
|
62
|
+
self.b_export.clicked.connect(self.export)
|
|
63
|
+
self.b_open = QPushButton("Open output folder")
|
|
64
|
+
self.b_open.clicked.connect(self.open_folder)
|
|
65
|
+
self.log = QPlainTextEdit()
|
|
66
|
+
self.log.setReadOnly(True)
|
|
67
|
+
|
|
68
|
+
buttons = QHBoxLayout()
|
|
69
|
+
buttons.addWidget(self.b_export)
|
|
70
|
+
buttons.addWidget(self.b_open)
|
|
71
|
+
buttons.addStretch(1)
|
|
72
|
+
lay = QVBoxLayout(self)
|
|
73
|
+
top = QHBoxLayout()
|
|
74
|
+
top.addWidget(g_what, 1)
|
|
75
|
+
top.addWidget(g_of, 1)
|
|
76
|
+
lay.addLayout(top)
|
|
77
|
+
lay.addLayout(buttons)
|
|
78
|
+
lay.addWidget(self.log, 1)
|
|
79
|
+
project.changed.connect(lambda w: self.load_settings() if w == "settings" else None)
|
|
80
|
+
tasks.busy_changed.connect(lambda b: self.b_export.setEnabled(not b))
|
|
81
|
+
self.load_settings()
|
|
82
|
+
|
|
83
|
+
def load_settings(self) -> None:
|
|
84
|
+
of = self.project.settings["parafac"]["final"].get("openfluor", {})
|
|
85
|
+
for k, e in self.meta.items():
|
|
86
|
+
e.setText(str(of.get(k, "")))
|
|
87
|
+
|
|
88
|
+
def store_metadata(self) -> None:
|
|
89
|
+
final = dict(self.project.settings["parafac"]["final"])
|
|
90
|
+
final["openfluor"] = {k: e.text().strip() for k, e in self.meta.items()
|
|
91
|
+
if e.text().strip()}
|
|
92
|
+
if final != self.project.settings["parafac"]["final"]:
|
|
93
|
+
self.project.settings["parafac"]["final"] = final
|
|
94
|
+
self.project.dirty = True
|
|
95
|
+
|
|
96
|
+
def open_folder(self) -> None:
|
|
97
|
+
out = self.project.resolve("output_dir")
|
|
98
|
+
if out is not None and out.is_dir():
|
|
99
|
+
QDesktopServices.openUrl(QUrl.fromLocalFile(str(out)))
|
|
100
|
+
|
|
101
|
+
def export(self) -> None:
|
|
102
|
+
out = self.project.resolve("output_dir")
|
|
103
|
+
if out is None:
|
|
104
|
+
QMessageBox.information(self, "eemkit", "Choose an output folder (Project tab).")
|
|
105
|
+
return
|
|
106
|
+
if self.project.in_data_root(out):
|
|
107
|
+
QMessageBox.warning(self, "eemkit", "The output folder is inside the read-only data "
|
|
108
|
+
"folder; choose another one.")
|
|
109
|
+
return
|
|
110
|
+
self.store_metadata()
|
|
111
|
+
p, w = self.project, {k: b.isChecked() for k, b in self.what.items()}
|
|
112
|
+
meta = {k: v for k, v in p.settings["parafac"]["final"].get("openfluor", {}).items()
|
|
113
|
+
if k in METADATA_KEYS}
|
|
114
|
+
args = dict(corrected=p.corrected if w["corrected"] else None,
|
|
115
|
+
indices=p.indices if w["indices"] else None,
|
|
116
|
+
exploration=p.exploration if w["exploration"] else None,
|
|
117
|
+
model=p.model if w["model"] else None,
|
|
118
|
+
split=p.split if w["model"] else None, openfluor=meta)
|
|
119
|
+
missing = [k for k in ("corrected", "indices", "exploration", "model")
|
|
120
|
+
if w[k] and getattr(p, k) is None]
|
|
121
|
+
settings = p.settings
|
|
122
|
+
|
|
123
|
+
def work(progress, cancelled):
|
|
124
|
+
progress("Writing files", 0, 0)
|
|
125
|
+
return pipeline.export_results(out, settings, figures=False, **args)
|
|
126
|
+
|
|
127
|
+
def done(paths):
|
|
128
|
+
if w["figures"]: # matplotlib stays in the GUI thread
|
|
129
|
+
paths += pipeline.export_figures(out / "figures", settings, args["exploration"],
|
|
130
|
+
args["model"], args["split"])
|
|
131
|
+
self.log.appendPlainText(f"Wrote {len(paths)} files to {out}:")
|
|
132
|
+
for path in paths:
|
|
133
|
+
self.log.appendPlainText(f" {path.relative_to(out)}")
|
|
134
|
+
if missing:
|
|
135
|
+
self.log.appendPlainText(f"Not computed yet, so not written: {', '.join(missing)}")
|
|
136
|
+
|
|
137
|
+
self.tasks.run(work, done, "Exporting…")
|