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,228 @@
1
+ """Tab 3: fluorescence indices, Coble peaks and CDOM (absorbance) metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ import pandas as pd
8
+ from PySide6.QtCore import Qt
9
+ from PySide6.QtWidgets import (
10
+ QCheckBox,
11
+ QComboBox,
12
+ QFileDialog,
13
+ QFormLayout,
14
+ QGroupBox,
15
+ QHBoxLayout,
16
+ QLabel,
17
+ QMessageBox,
18
+ QPushButton,
19
+ QSplitter,
20
+ QTableView,
21
+ QVBoxLayout,
22
+ QWidget,
23
+ )
24
+
25
+ from eemkit import plot
26
+ from eemkit.errors import EEMKitWarning
27
+ from eemkit.gui.common import DataFrameModel, PlotPanel, TaskBar, note, pair, primary, spin
28
+ from eemkit.gui.state import Project
29
+ from eemkit.pipeline import compute_absorbance, compute_indices
30
+
31
+ INDEX_COLUMNS = ("FI", "HIX", "BIX", "peak_B", "peak_T", "peak_A", "peak_M", "peak_C")
32
+ CDOM_COLUMNS = ("a254", "a350", "S275_295", "S350_400", "SR", "E2_E3", "SUVA254")
33
+
34
+
35
+ class IndicesTab(QWidget):
36
+ def __init__(self, project: Project, tasks: TaskBar, parent=None):
37
+ super().__init__(parent)
38
+ self.project, self.tasks = project, tasks
39
+
40
+ self.ex_method = QComboBox()
41
+ self.ex_method.addItems(["linear", "nearest", "exact"])
42
+ self.bix_den = spin(430, 300, 600, 1, 0, " nm")
43
+ settings = QGroupBox("Settings")
44
+ f = QFormLayout(settings)
45
+ f.addRow("Excitations off the grid", self.ex_method)
46
+ f.addRow("BIX denominator emission", self.bix_den)
47
+ f.addRow(note("FI: Cory & McKnight 2005 (ex 370, em 470/520). HIX: Ohno 2002. BIX: "
48
+ "Huguet et al. 2009. Peaks: Coble 1996. Computed after Raman units, "
49
+ "before scatter removal."))
50
+
51
+ self.cdom = QCheckBox("Compute CDOM metrics from the absorbance spectra")
52
+ self.cdom.setChecked(True)
53
+ self.use_base = QCheckBox("Subtract the mean absorbance over")
54
+ self.base_lo = spin(680, 200, 1100, 1, 0, " nm")
55
+ self.base_hi = spin(700, 200, 1100, 1, 0, " nm")
56
+ self.min_r2 = spin(0.95, 0.0, 1.0, 0.01, 2)
57
+ self.doc = QComboBox()
58
+ cdom = QGroupBox("Absorbance (CDOM)")
59
+ f = QFormLayout(cdom)
60
+ f.addRow(self.cdom)
61
+ f.addRow("Baseline", pair(self.use_base, self.base_lo, QLabel("–"), self.base_hi))
62
+ f.addRow("Minimum R² of slope fits", self.min_r2)
63
+ f.addRow("DOC column (mg/L)", self.doc)
64
+ f.addRow(note("a254, a350: Napierian absorption coefficients (m⁻¹). S275–295, "
65
+ "S350–400, SR: log-linear fits (Helms et al. 2008). E2:E3 = A250/A365. "
66
+ "SUVA254 (L mg⁻¹ m⁻¹, Weishaar et al. 2003) needs a DOC column. Values "
67
+ "that need a non-positive absorbance or a poor fit are left empty, with "
68
+ "the reason in cdom_notes. The R² threshold is an eemkit quality "
69
+ "setting, not a literature value: report it."))
70
+ self.b_compute = primary(QPushButton("Compute indices"))
71
+ self.b_compute.clicked.connect(self.compute)
72
+ self.b_export = QPushButton("Save table…")
73
+ self.b_export.clicked.connect(self.export)
74
+
75
+ self.model = DataFrameModel()
76
+ view = QTableView()
77
+ view.setModel(self.model)
78
+ view.setSortingEnabled(False)
79
+ self.view = view
80
+
81
+ self.what = QComboBox()
82
+ self.x = QComboBox()
83
+ self.by = QComboBox()
84
+ for c in (self.what, self.x, self.by):
85
+ c.currentIndexChanged.connect(self.draw)
86
+ self.plot = PlotPanel()
87
+ controls = QHBoxLayout()
88
+ for lab, w in (("Show", self.what), ("along", self.x), ("grouped by", self.by)):
89
+ controls.addWidget(QLabel(lab))
90
+ controls.addWidget(w, 1)
91
+
92
+ left = QWidget()
93
+ ll = QVBoxLayout(left)
94
+ ll.addWidget(settings)
95
+ ll.addWidget(cdom)
96
+ buttons = QHBoxLayout()
97
+ buttons.addWidget(self.b_compute)
98
+ buttons.addWidget(self.b_export)
99
+ ll.addLayout(buttons)
100
+ ll.addWidget(view, 1)
101
+ right = QWidget()
102
+ rl = QVBoxLayout(right)
103
+ rl.addLayout(controls)
104
+ rl.addWidget(self.plot, 1)
105
+ split = QSplitter(Qt.Horizontal)
106
+ split.addWidget(left)
107
+ split.addWidget(right)
108
+ split.setStretchFactor(1, 1)
109
+ QVBoxLayout(self).addWidget(split)
110
+ project.changed.connect(self._on_changed)
111
+ tasks.busy_changed.connect(lambda b: self.b_compute.setEnabled(not b))
112
+ self.load_settings()
113
+
114
+ def load_settings(self) -> None:
115
+ s = self.project.settings["indices"]
116
+ self.ex_method.setCurrentText(s["ex_method"])
117
+ self.bix_den.setValue(float(s["bix_em_den"]))
118
+ a = self.project.settings["absorbance"]
119
+ self.use_base.setChecked(bool(a.get("baseline")))
120
+ if a.get("baseline"):
121
+ self.base_lo.setValue(float(a["baseline"][0]))
122
+ self.base_hi.setValue(float(a["baseline"][1]))
123
+ self.min_r2.setValue(float(a["min_r2"]))
124
+ self._fill_doc()
125
+
126
+ def _fill_doc(self) -> None:
127
+ ds = self.project.after_raman if self.project.after_raman is not None \
128
+ else self.project.raw
129
+ cols = [] if ds is None else [c for c in ds.coords if ds[c].dims == ("sample",)
130
+ and c != "sample"]
131
+ want = self.project.settings["absorbance"].get("doc_column", "")
132
+ self.doc.blockSignals(True)
133
+ self.doc.clear()
134
+ self.doc.addItems(["(none)"] + sorted(set(cols) | ({want} if want else set())))
135
+ self.doc.setCurrentText(want or "(none)")
136
+ self.doc.blockSignals(False)
137
+
138
+ def _on_changed(self, what: str) -> None:
139
+ if what == "settings":
140
+ self.load_settings()
141
+ if what in ("raw", "corrected"):
142
+ self._fill_doc()
143
+ if what in ("indices", "corrected") and self.project.indices is None:
144
+ self.model.set_frame(pd.DataFrame())
145
+ self.plot.clear("Compute the indices to plot them.")
146
+
147
+ def compute(self) -> None:
148
+ if self.project.after_raman is None:
149
+ QMessageBox.information(self, "eemkit", "Apply the corrections first.")
150
+ return
151
+ self.project.set_settings("indices", {"ex_method": self.ex_method.currentText(),
152
+ "bix_em_den": self.bix_den.value()}, "indices")
153
+ doc = self.doc.currentText()
154
+ self.project.set_settings("absorbance", {
155
+ "baseline": [self.base_lo.value(), self.base_hi.value()]
156
+ if self.use_base.isChecked() else [],
157
+ "min_r2": self.min_r2.value(),
158
+ "doc_column": "" if doc == "(none)" else doc}, "indices")
159
+ ds, settings = self.project.after_raman, self.project.settings
160
+ with_cdom = self.cdom.isChecked() and "absorbance" in ds
161
+
162
+ def work(progress, cancelled):
163
+ with warnings.catch_warnings(record=True) as caught:
164
+ warnings.simplefilter("always", EEMKitWarning)
165
+ df = compute_indices(ds, settings)
166
+ if with_cdom:
167
+ df = df.join(compute_absorbance(ds, settings))
168
+ return df, [str(w.message) for w in caught]
169
+
170
+ def done(result):
171
+ df, warns = result
172
+ self.project.indices = df
173
+ self.project.changed.emit("indices")
174
+ self.model.set_frame(df)
175
+ self.view.resizeColumnsToContents()
176
+ values = [c for c in INDEX_COLUMNS + CDOM_COLUMNS if c in df]
177
+ meta = [c for c in df.columns if not pd.api.types.is_numeric_dtype(df[c])
178
+ and c != "cdom_notes"]
179
+ # default axes: the campaign columns if chosen, else a column with few values
180
+ c = self.project.settings["campaigns"]
181
+ few = [m for m in meta if df[m].nunique() <= len(plot.CATEGORICAL)]
182
+ timeish = [m for m in meta if any(k in m.lower() for k in
183
+ ("campaign", "date", "month", "time"))]
184
+ x0 = c.get("time") if c.get("time") in meta else next(
185
+ (m for m in timeish + meta if 1 < df[m].nunique() <= 40),
186
+ meta[0] if meta else "")
187
+ by0 = c.get("group") if c.get("group") in meta else next(
188
+ (m for m in few if m != x0 and df[m].nunique() > 1), "(none)")
189
+ for box, items, cur in ((self.what, values, None), (self.x, meta, x0),
190
+ (self.by, ["(none)"] + meta, by0)):
191
+ box.blockSignals(True)
192
+ box.clear()
193
+ box.addItems(items)
194
+ if cur:
195
+ box.setCurrentText(cur)
196
+ box.blockSignals(False)
197
+ self.draw()
198
+ if warns:
199
+ QMessageBox.information(self, "eemkit", "\n".join(warns))
200
+
201
+ self.tasks.run(work, done, "Computing indices…")
202
+
203
+ def draw(self) -> None:
204
+ df = self.project.indices
205
+ if df is None or not self.what.currentText() or not self.x.currentText():
206
+ return
207
+ by = self.by.currentText()
208
+ data = df.reset_index()
209
+ if by == "(none)":
210
+ data["all samples"] = "all"
211
+ by = "all samples"
212
+ if data[by].nunique() > len(plot.CATEGORICAL):
213
+ self.plot.clear(f"'{by}' has more than {len(plot.CATEGORICAL)} groups; choose "
214
+ "another grouping.")
215
+ return
216
+ fig = plot.scores_over(data, [self.what.currentText()], x=self.x.currentText(), by=by,
217
+ width=6.5)
218
+ self.plot.show_figure(fig)
219
+
220
+ def export(self) -> None:
221
+ if self.project.indices is None:
222
+ return
223
+ path, _ = QFileDialog.getSaveFileName(self, "Save indices", "indices.csv", "CSV (*.csv)")
224
+ if path and self.project.in_data_root(path):
225
+ QMessageBox.warning(self, "eemkit", "The data folder is read only; choose another "
226
+ "folder.")
227
+ elif path:
228
+ self.project.indices.to_csv(path, float_format="%.6g")
@@ -0,0 +1,258 @@
1
+ """The main window: a sidebar of steps, one page per step, menus and the shared progress bar."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from PySide6.QtGui import QAction, QActionGroup, QKeySequence
8
+ from PySide6.QtWidgets import (
9
+ QApplication,
10
+ QFileDialog,
11
+ QHBoxLayout,
12
+ QLabel,
13
+ QListWidget,
14
+ QListWidgetItem,
15
+ QMainWindow,
16
+ QMessageBox,
17
+ QStackedWidget,
18
+ QVBoxLayout,
19
+ QWidget,
20
+ )
21
+
22
+ from eemkit import __version__
23
+ from eemkit.gui import theme
24
+ from eemkit.gui.campaigns_tab import CampaignsTab
25
+ from eemkit.gui.common import TaskBar
26
+ from eemkit.gui.corrections_tab import CorrectionsTab
27
+ from eemkit.gui.export_tab import ExportTab
28
+ from eemkit.gui.indices_tab import IndicesTab
29
+ from eemkit.gui.parafac_tab import ParafacTab
30
+ from eemkit.gui.project_tab import ProjectTab
31
+ from eemkit.gui.state import Project
32
+ from eemkit.pipeline import versions
33
+
34
+ #: Sidebar entries: (title, one-line description).
35
+ STEPS = (("Project and pairing", "Data folder, pairing table"),
36
+ ("Corrections", "Inner filter, Raman units, scatter"),
37
+ ("Indices and CDOM", "FI, HIX, BIX, peaks, absorbance"),
38
+ ("PARAFAC", "Models, validation, outliers"),
39
+ ("Rivers and dates", "Campaigns, sites, covariates"),
40
+ ("Export", "Tables, figures, OpenFluor"))
41
+ THEME_NAMES = {"system": "Follow the system", "light": "Light", "dark": "Dark"}
42
+
43
+ ABOUT = f"""<b>eemkit {__version__}</b><br>
44
+ Processing and PARAFAC modelling of fluorescence excitation-emission matrices
45
+ (EEMs) of dissolved organic matter.<br><br>
46
+ Every result can be reproduced from the saved project file with
47
+ <code>eemkit.pipeline</code>.<br><br>
48
+ Methods: inner filter, Kothawala et al. 2013; Raman units, Lawaetz &amp; Stedmon 2009;
49
+ PARAFAC and split-half validation, Murphy et al. 2013; core consistency,
50
+ Bro &amp; Kiers 2003."""
51
+
52
+ #: Choices of the View > EEM colours menu (names of eemkit.plot.COLORMAPS).
53
+ EEM_COLOURS = {"eclipse": "Cary Eclipse (violet, blue, green, yellow, red)",
54
+ "blues": "Single-hue blue (perceptually ordered, for print)"}
55
+
56
+
57
+ class MainWindow(QMainWindow):
58
+ def __init__(self, project_file: str | Path | None = None):
59
+ super().__init__()
60
+ self.project = Project()
61
+ self.tasks = TaskBar()
62
+ self.project_tab = ProjectTab(self.project, self.tasks)
63
+ self.corrections_tab = CorrectionsTab(self.project, self.tasks)
64
+ self.indices_tab = IndicesTab(self.project, self.tasks)
65
+ self.parafac_tab = ParafacTab(self.project, self.tasks)
66
+ self.campaigns_tab = CampaignsTab(self.project, self.tasks)
67
+ self.export_tab = ExportTab(self.project, self.tasks)
68
+ self.pages = QStackedWidget()
69
+ self.nav = QListWidget()
70
+ self.nav.setObjectName("nav")
71
+ for i, (page, (title, _)) in enumerate(zip(
72
+ (self.project_tab, self.corrections_tab, self.indices_tab, self.parafac_tab,
73
+ self.campaigns_tab, self.export_tab), STEPS, strict=True)):
74
+ self.pages.addWidget(page)
75
+ self.nav.addItem(QListWidgetItem(f"{i + 1} {title}"))
76
+ self.nav.currentRowChanged.connect(self.pages.setCurrentIndex)
77
+ self.nav.setCurrentRow(0)
78
+ self.summary = QLabel("")
79
+ self.summary.setObjectName("summary")
80
+ self.summary.setWordWrap(True)
81
+
82
+ sidebar = QWidget()
83
+ sidebar.setObjectName("sidebar")
84
+ sidebar.setFixedWidth(250)
85
+ sl = QVBoxLayout(sidebar)
86
+ sl.setContentsMargins(0, 0, 0, 0)
87
+ sl.setSpacing(0)
88
+ brand = QLabel("eemkit")
89
+ brand.setObjectName("brand")
90
+ tagline = QLabel("EEM processing and PARAFAC")
91
+ tagline.setObjectName("tagline")
92
+ for w in (brand, tagline):
93
+ sl.addWidget(w)
94
+ sl.addWidget(self.nav, 1)
95
+ sl.addWidget(self.summary)
96
+ central = QWidget()
97
+ cl = QHBoxLayout(central)
98
+ cl.setContentsMargins(0, 0, 0, 0)
99
+ cl.setSpacing(0)
100
+ cl.addWidget(sidebar)
101
+ cl.addWidget(self.pages, 1)
102
+ self.setCentralWidget(central)
103
+ self.statusBar().addPermanentWidget(self.tasks, 1)
104
+ self._menus()
105
+ self.project.changed.connect(lambda _: self._title())
106
+ self.project.changed.connect(lambda _: self._status())
107
+ self.resize(1440, 900)
108
+ if project_file:
109
+ self.open_project(project_file)
110
+ self._title()
111
+ self._status()
112
+
113
+ def _status(self) -> None:
114
+ """Mark finished steps in the sidebar and summarise the project under it."""
115
+ p = self.project
116
+ done = (p.raw is not None, p.corrected is not None, p.indices is not None,
117
+ p.model is not None, p.campaign is not None, False)
118
+ partial = (p.pairing is not None, p.after_raman is not None, False,
119
+ p.exploration is not None, False, False)
120
+ for i, (title, desc) in enumerate(STEPS):
121
+ mark = " ✓" if done[i] else (" •" if partial[i] else "")
122
+ item = self.nav.item(i)
123
+ item.setText(f"{i + 1} {title}{mark}")
124
+ item.setToolTip(desc)
125
+ lines = []
126
+ if p.pairing is not None and "include" in p.pairing:
127
+ inc = int((p.pairing["include"].astype(str).str.lower() == "yes").sum())
128
+ lines.append(f"{len(p.pairing)} rows in the pairing table, {inc} included")
129
+ if p.raw is not None:
130
+ lines.append(f"{p.raw.sizes['sample']} samples loaded")
131
+ if p.model is not None:
132
+ lines.append(f"Final model: {p.model.sizes['component']} components")
133
+ self.summary.setText("\n".join(lines) or "Open a project or choose a data folder.")
134
+
135
+ def _menus(self) -> None:
136
+ m = self.menuBar().addMenu("&File")
137
+ for text, key, slot in (("&New project", QKeySequence.New, self.new_project),
138
+ ("&Open project…", QKeySequence.Open, self.open_dialog),
139
+ ("&Save project", QKeySequence.Save, self.save_project),
140
+ ("Save project &as…", QKeySequence.SaveAs, self.save_as),
141
+ (None, None, None),
142
+ ("&Quit", QKeySequence.Quit, self.close)):
143
+ if text is None:
144
+ m.addSeparator()
145
+ continue
146
+ act = QAction(text, self)
147
+ act.setShortcut(key)
148
+ act.triggered.connect(slot)
149
+ m.addAction(act)
150
+ view = self.menuBar().addMenu("&View")
151
+ t = view.addMenu("&Theme")
152
+ tgroup = QActionGroup(self)
153
+ self.theme_actions = {}
154
+ current = theme.saved_theme()
155
+ for name, text in THEME_NAMES.items():
156
+ act = QAction(text, self, checkable=True)
157
+ act.setChecked(name == current)
158
+ act.triggered.connect(lambda _, n=name: self.set_theme(n))
159
+ tgroup.addAction(act)
160
+ t.addAction(act)
161
+ self.theme_actions[name] = act
162
+ v = view.addMenu("&EEM colours")
163
+ group = QActionGroup(self)
164
+ self.colour_actions = {}
165
+ for name, text in EEM_COLOURS.items():
166
+ act = QAction(text, self, checkable=True)
167
+ act.triggered.connect(lambda _, n=name: self.project.set_eem_colours(n))
168
+ group.addAction(act)
169
+ v.addAction(act)
170
+ self.colour_actions[name] = act
171
+ self.project.changed.connect(self._sync_colours)
172
+ self._sync_colours("display")
173
+ h = self.menuBar().addMenu("&Help")
174
+ act = QAction("&About eemkit", self)
175
+ act.triggered.connect(self.about)
176
+ h.addAction(act)
177
+
178
+ def set_theme(self, name: str) -> None:
179
+ """Switch the light/dark theme; a user preference, not part of the project."""
180
+ theme.save_theme(name)
181
+ app = QApplication.instance()
182
+ if app is not None:
183
+ theme.apply_theme(app, name)
184
+
185
+ def _sync_colours(self, what: str) -> None:
186
+ if what in ("settings", "display"):
187
+ act = self.colour_actions.get(self.project.eem_colours)
188
+ if act is not None:
189
+ act.setChecked(True)
190
+
191
+ def _title(self) -> None:
192
+ name = self.project.path.name if self.project.path else "untitled project"
193
+ self.setWindowTitle(f"eemkit - {name}{' *' if self.project.dirty else ''}")
194
+
195
+ # project files ------------------------------------------------------- #
196
+
197
+ def _confirm_discard(self) -> bool:
198
+ if not self.project.dirty:
199
+ return True
200
+ ans = QMessageBox.question(self, "eemkit", "Save the changes to the project first?",
201
+ QMessageBox.Save | QMessageBox.Discard | QMessageBox.Cancel)
202
+ if ans == QMessageBox.Save:
203
+ return self.save_project()
204
+ return ans == QMessageBox.Discard
205
+
206
+ def new_project(self) -> None:
207
+ if self._confirm_discard():
208
+ self.project.__init__() # reset in place: the tabs keep their reference
209
+ self.project.invalidate("pairing")
210
+ self.project.changed.emit("settings")
211
+ self.project.changed.emit("display")
212
+
213
+ def open_dialog(self) -> None:
214
+ if not self._confirm_discard():
215
+ return
216
+ path, _ = QFileDialog.getOpenFileName(self, "Open project", "", "eemkit project (*.toml)")
217
+ if path:
218
+ self.open_project(path)
219
+
220
+ def open_project(self, path: str | Path) -> None:
221
+ try:
222
+ self.project.open(path)
223
+ except Exception as exc: # noqa: BLE001 - shown to the user
224
+ QMessageBox.warning(self, "eemkit", f"Cannot open {path}:\n{exc}")
225
+ self._title()
226
+
227
+ def save_project(self) -> bool:
228
+ if self.project.path is None:
229
+ return self.save_as()
230
+ self.project.save()
231
+ self._title()
232
+ return True
233
+
234
+ def save_as(self) -> bool:
235
+ path, _ = QFileDialog.getSaveFileName(self, "Save project", "project.toml",
236
+ "eemkit project (*.toml)")
237
+ if not path:
238
+ return False
239
+ if self.project.in_data_root(path):
240
+ QMessageBox.warning(self, "eemkit", "The data folder is read only; save the project "
241
+ "elsewhere.")
242
+ return False
243
+ self.project.save(path)
244
+ self.project.changed.emit("settings")
245
+ self._title()
246
+ return True
247
+
248
+ def about(self) -> None:
249
+ v = ", ".join(f"{k} {x}" for k, x in versions().items())
250
+ QMessageBox.about(self, "About eemkit", f"{ABOUT}<br><br><small>{v}</small>")
251
+
252
+ def closeEvent(self, event) -> None: # noqa: N802 (Qt API)
253
+ if self.tasks.busy:
254
+ self.tasks.cancel()
255
+ if self._confirm_discard():
256
+ event.accept()
257
+ else:
258
+ event.ignore()