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,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)
@@ -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…")