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
eemkit/gui/common.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Widgets and helpers shared by the tabs: table model, plot panel, background tasks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import traceback
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import Callable, Sequence
|
|
9
|
+
|
|
10
|
+
import matplotlib
|
|
11
|
+
|
|
12
|
+
matplotlib.use("Agg") # eemkit.plot builds figures off-screen; panels embed them
|
|
13
|
+
import matplotlib.pyplot as plt
|
|
14
|
+
import pandas as pd
|
|
15
|
+
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
|
|
16
|
+
from PySide6.QtCore import (
|
|
17
|
+
QAbstractTableModel,
|
|
18
|
+
QModelIndex,
|
|
19
|
+
QObject,
|
|
20
|
+
QRunnable,
|
|
21
|
+
Qt,
|
|
22
|
+
QThreadPool,
|
|
23
|
+
Signal,
|
|
24
|
+
)
|
|
25
|
+
from PySide6.QtGui import QColor
|
|
26
|
+
from PySide6.QtWidgets import (
|
|
27
|
+
QComboBox,
|
|
28
|
+
QDoubleSpinBox,
|
|
29
|
+
QHBoxLayout,
|
|
30
|
+
QLabel,
|
|
31
|
+
QLineEdit,
|
|
32
|
+
QMessageBox,
|
|
33
|
+
QProgressBar,
|
|
34
|
+
QPushButton,
|
|
35
|
+
QStyledItemDelegate,
|
|
36
|
+
QVBoxLayout,
|
|
37
|
+
QWidget,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from eemkit.errors import Cancelled, EEMKitError, EEMKitWarning
|
|
41
|
+
|
|
42
|
+
SUGGESTED = QColor("#fff4d6")
|
|
43
|
+
EXCLUDED = QColor("#f0efec")
|
|
44
|
+
ROW_TEXT = QColor("#1d232b")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
# Tables
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
|
|
51
|
+
class DataFrameModel(QAbstractTableModel):
|
|
52
|
+
"""A pandas DataFrame in a QTableView; optionally editable column by column."""
|
|
53
|
+
|
|
54
|
+
edited = Signal()
|
|
55
|
+
|
|
56
|
+
def __init__(self, df: pd.DataFrame | None = None, editable: Sequence[str] = (),
|
|
57
|
+
row_colour: Callable[[pd.Series], QColor | None] | None = None,
|
|
58
|
+
float_format: str = "{:.4g}", parent=None):
|
|
59
|
+
super().__init__(parent)
|
|
60
|
+
self._df = pd.DataFrame() if df is None else df
|
|
61
|
+
self.editable = set(editable)
|
|
62
|
+
self.row_colour = row_colour
|
|
63
|
+
self.float_format = float_format
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def df(self) -> pd.DataFrame:
|
|
67
|
+
return self._df
|
|
68
|
+
|
|
69
|
+
def set_frame(self, df: pd.DataFrame) -> None:
|
|
70
|
+
self.beginResetModel()
|
|
71
|
+
self._df = df
|
|
72
|
+
self.endResetModel()
|
|
73
|
+
|
|
74
|
+
def rowCount(self, parent: QModelIndex | None = None): # noqa: N802 (Qt API)
|
|
75
|
+
return 0 if parent is not None and parent.isValid() else len(self._df)
|
|
76
|
+
|
|
77
|
+
def columnCount(self, parent: QModelIndex | None = None): # noqa: N802
|
|
78
|
+
return 0 if parent is not None and parent.isValid() else len(self._df.columns)
|
|
79
|
+
|
|
80
|
+
def data(self, index, role=Qt.DisplayRole):
|
|
81
|
+
if not index.isValid():
|
|
82
|
+
return None
|
|
83
|
+
v = self._df.iat[index.row(), index.column()]
|
|
84
|
+
if role in (Qt.DisplayRole, Qt.EditRole, Qt.ToolTipRole):
|
|
85
|
+
if isinstance(v, float):
|
|
86
|
+
return "" if pd.isna(v) else self.float_format.format(v)
|
|
87
|
+
return "" if v is None else str(v)
|
|
88
|
+
if role == Qt.BackgroundRole and self.row_colour is not None:
|
|
89
|
+
return self.row_colour(self._df.iloc[index.row()])
|
|
90
|
+
if role == Qt.ForegroundRole and self.row_colour is not None \
|
|
91
|
+
and self.row_colour(self._df.iloc[index.row()]) is not None:
|
|
92
|
+
return ROW_TEXT # tinted rows are light in both themes
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def headerData(self, section, orientation, role=Qt.DisplayRole): # noqa: N802
|
|
96
|
+
if role != Qt.DisplayRole:
|
|
97
|
+
return None
|
|
98
|
+
if orientation == Qt.Horizontal:
|
|
99
|
+
return str(self._df.columns[section])
|
|
100
|
+
idx = self._df.index
|
|
101
|
+
plain = isinstance(idx, pd.RangeIndex) and idx.start == 0 and idx.step == 1 \
|
|
102
|
+
and idx.name is None
|
|
103
|
+
return str(section + 1) if plain else str(idx[section]) # e.g. ranks 2, 3, 4
|
|
104
|
+
|
|
105
|
+
def flags(self, index):
|
|
106
|
+
f = super().flags(index)
|
|
107
|
+
if self._df.columns[index.column()] in self.editable:
|
|
108
|
+
f |= Qt.ItemIsEditable
|
|
109
|
+
return f
|
|
110
|
+
|
|
111
|
+
def setData(self, index, value, role=Qt.EditRole): # noqa: N802
|
|
112
|
+
if role != Qt.EditRole or not index.isValid():
|
|
113
|
+
return False
|
|
114
|
+
self._df.iat[index.row(), index.column()] = str(value).strip()
|
|
115
|
+
self.dataChanged.emit(self.index(index.row(), 0),
|
|
116
|
+
self.index(index.row(), self.columnCount() - 1))
|
|
117
|
+
self.edited.emit()
|
|
118
|
+
return True
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ChoiceDelegate(QStyledItemDelegate):
|
|
122
|
+
"""Cell editor offering a fixed list of values (and the empty string)."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, choices: Callable[[], Sequence[str]], parent=None):
|
|
125
|
+
super().__init__(parent)
|
|
126
|
+
self.choices = choices
|
|
127
|
+
|
|
128
|
+
def createEditor(self, parent, option, index): # noqa: N802
|
|
129
|
+
box = QComboBox(parent)
|
|
130
|
+
box.addItems([""] + list(self.choices()))
|
|
131
|
+
box.setEditable(False)
|
|
132
|
+
return box
|
|
133
|
+
|
|
134
|
+
def setEditorData(self, editor, index): # noqa: N802
|
|
135
|
+
editor.setCurrentText(index.data(Qt.EditRole) or "")
|
|
136
|
+
|
|
137
|
+
def setModelData(self, editor, model, index): # noqa: N802
|
|
138
|
+
model.setData(index, editor.currentText())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --------------------------------------------------------------------------- #
|
|
142
|
+
# Plots
|
|
143
|
+
# --------------------------------------------------------------------------- #
|
|
144
|
+
|
|
145
|
+
class PlotPanel(QWidget):
|
|
146
|
+
"""Shows one matplotlib figure at a time, with the navigation toolbar."""
|
|
147
|
+
|
|
148
|
+
def __init__(self, parent=None):
|
|
149
|
+
super().__init__(parent)
|
|
150
|
+
self._layout = QVBoxLayout(self)
|
|
151
|
+
self._layout.setContentsMargins(0, 0, 0, 0)
|
|
152
|
+
self._canvas = self._toolbar = None
|
|
153
|
+
self.placeholder = QLabel("Nothing to show yet.")
|
|
154
|
+
self.placeholder.setAlignment(Qt.AlignCenter)
|
|
155
|
+
self._layout.addWidget(self.placeholder)
|
|
156
|
+
|
|
157
|
+
def show_figure(self, fig) -> None:
|
|
158
|
+
self.clear()
|
|
159
|
+
self.placeholder.hide()
|
|
160
|
+
self._canvas = FigureCanvasQTAgg(fig)
|
|
161
|
+
self._toolbar = NavigationToolbar2QT(self._canvas, self)
|
|
162
|
+
self._layout.addWidget(self._toolbar)
|
|
163
|
+
self._layout.addWidget(self._canvas, 1)
|
|
164
|
+
self._canvas.draw_idle()
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def figure(self):
|
|
168
|
+
return self._canvas.figure if self._canvas is not None else None
|
|
169
|
+
|
|
170
|
+
def clear(self, message: str | None = None) -> None:
|
|
171
|
+
for w in (self._toolbar, self._canvas):
|
|
172
|
+
if w is not None:
|
|
173
|
+
self._layout.removeWidget(w)
|
|
174
|
+
w.hide() # deleteLater waits for the event loop; hide at once
|
|
175
|
+
w.setParent(None)
|
|
176
|
+
w.deleteLater()
|
|
177
|
+
if self._canvas is not None:
|
|
178
|
+
plt.close(self._canvas.figure)
|
|
179
|
+
self._canvas = self._toolbar = None
|
|
180
|
+
if message is not None:
|
|
181
|
+
self.placeholder.setText(message)
|
|
182
|
+
self.placeholder.show()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# --------------------------------------------------------------------------- #
|
|
186
|
+
# Background tasks
|
|
187
|
+
# --------------------------------------------------------------------------- #
|
|
188
|
+
|
|
189
|
+
class _Signals(QObject):
|
|
190
|
+
done = Signal(object)
|
|
191
|
+
failed = Signal(str, str) # short message, traceback
|
|
192
|
+
progress = Signal(str, int, int)
|
|
193
|
+
warned = Signal(list)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class _Task(QRunnable):
|
|
197
|
+
def __init__(self, fn, signals: _Signals, cancel: threading.Event):
|
|
198
|
+
super().__init__()
|
|
199
|
+
self.fn, self.signals, self.cancel = fn, signals, cancel
|
|
200
|
+
|
|
201
|
+
def run(self):
|
|
202
|
+
# eemkit warnings (e.g. a model that did not converge) are collected and shown,
|
|
203
|
+
# never turned into errors or lost; only one task runs at a time
|
|
204
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
205
|
+
warnings.simplefilter("always", EEMKitWarning)
|
|
206
|
+
try:
|
|
207
|
+
result = self.fn(lambda msg, i, n: self.signals.progress.emit(msg, i, n),
|
|
208
|
+
self.cancel.is_set)
|
|
209
|
+
except Cancelled:
|
|
210
|
+
self.signals.failed.emit("Cancelled.", "")
|
|
211
|
+
return
|
|
212
|
+
except EEMKitError as exc:
|
|
213
|
+
self.signals.failed.emit(str(exc), "")
|
|
214
|
+
return
|
|
215
|
+
except Exception as exc: # noqa: BLE001 - report anything to the user
|
|
216
|
+
self.signals.failed.emit(f"{type(exc).__name__}: {exc}", traceback.format_exc())
|
|
217
|
+
return
|
|
218
|
+
self.signals.warned.emit([str(w.message) for w in caught
|
|
219
|
+
if issubclass(w.category, EEMKitWarning)])
|
|
220
|
+
self.signals.done.emit(result)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class TaskBar(QWidget):
|
|
224
|
+
"""Progress bar and Cancel button; runs one background task at a time.
|
|
225
|
+
|
|
226
|
+
``run(fn, on_done)`` calls ``fn(progress, cancelled)`` in a worker thread;
|
|
227
|
+
``on_done(result)`` runs in the GUI thread. Errors are shown in a dialog.
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
busy_changed = Signal(bool)
|
|
231
|
+
|
|
232
|
+
def __init__(self, parent=None):
|
|
233
|
+
super().__init__(parent)
|
|
234
|
+
lay = QHBoxLayout(self)
|
|
235
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
236
|
+
self.label = QLabel("")
|
|
237
|
+
self.bar = QProgressBar()
|
|
238
|
+
self.bar.setMaximumWidth(260)
|
|
239
|
+
self.cancel_button = QPushButton("Cancel")
|
|
240
|
+
self.cancel_button.clicked.connect(self.cancel)
|
|
241
|
+
self.warnings_button = QPushButton("")
|
|
242
|
+
self.warnings_button.setFlat(True)
|
|
243
|
+
self.warnings_button.setProperty("role", "warning")
|
|
244
|
+
self.warnings_button.clicked.connect(self.show_warnings)
|
|
245
|
+
lay.addWidget(self.label, 1)
|
|
246
|
+
lay.addWidget(self.warnings_button)
|
|
247
|
+
lay.addWidget(self.bar)
|
|
248
|
+
lay.addWidget(self.cancel_button)
|
|
249
|
+
self._cancel = threading.Event()
|
|
250
|
+
self._signals: _Signals | None = None
|
|
251
|
+
self.busy = False
|
|
252
|
+
self.warnings: list[str] = []
|
|
253
|
+
self._idle()
|
|
254
|
+
|
|
255
|
+
def _idle(self) -> None:
|
|
256
|
+
self.bar.hide()
|
|
257
|
+
self.cancel_button.hide()
|
|
258
|
+
self.warnings_button.setVisible(bool(self.warnings))
|
|
259
|
+
|
|
260
|
+
def _warned(self, messages: list[str]) -> None:
|
|
261
|
+
self.warnings = list(dict.fromkeys(messages))
|
|
262
|
+
n = len(self.warnings)
|
|
263
|
+
self.warnings_button.setText(f"⚠ {n} warning{'s' if n > 1 else ''}")
|
|
264
|
+
|
|
265
|
+
def show_warnings(self) -> None:
|
|
266
|
+
box = QMessageBox(QMessageBox.Information, "eemkit",
|
|
267
|
+
"The last computation finished with warnings. Review them before "
|
|
268
|
+
"using the results.", parent=self)
|
|
269
|
+
box.setDetailedText("\n".join(self.warnings))
|
|
270
|
+
box.exec()
|
|
271
|
+
|
|
272
|
+
def run(self, fn, on_done: Callable[[object], None], label: str = "Working…") -> bool:
|
|
273
|
+
if self.busy:
|
|
274
|
+
QMessageBox.information(self, "Busy", "Another computation is still running.")
|
|
275
|
+
return False
|
|
276
|
+
self.busy = True
|
|
277
|
+
self.busy_changed.emit(True)
|
|
278
|
+
self._cancel = threading.Event()
|
|
279
|
+
self._signals = sig = _Signals()
|
|
280
|
+
self.warnings = []
|
|
281
|
+
self.warnings_button.hide()
|
|
282
|
+
self.label.setText(label)
|
|
283
|
+
self.bar.setRange(0, 0)
|
|
284
|
+
self.bar.show()
|
|
285
|
+
self.cancel_button.show()
|
|
286
|
+
sig.progress.connect(self._progress)
|
|
287
|
+
sig.warned.connect(self._warned)
|
|
288
|
+
sig.done.connect(lambda r: self._finish(on_done, r))
|
|
289
|
+
sig.failed.connect(self._failed)
|
|
290
|
+
QThreadPool.globalInstance().start(_Task(fn, sig, self._cancel))
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
def cancel(self) -> None:
|
|
294
|
+
self._cancel.set()
|
|
295
|
+
self.label.setText("Cancelling after the current step…")
|
|
296
|
+
|
|
297
|
+
def _progress(self, msg: str, i: int, n: int) -> None:
|
|
298
|
+
self.label.setText(msg)
|
|
299
|
+
if n:
|
|
300
|
+
self.bar.setRange(0, n)
|
|
301
|
+
self.bar.setValue(i)
|
|
302
|
+
|
|
303
|
+
def _end(self, text: str) -> None:
|
|
304
|
+
self.busy = False
|
|
305
|
+
self._idle()
|
|
306
|
+
self.label.setText(text)
|
|
307
|
+
self.busy_changed.emit(False)
|
|
308
|
+
|
|
309
|
+
def _finish(self, on_done, result) -> None:
|
|
310
|
+
self._end("Done.")
|
|
311
|
+
on_done(result)
|
|
312
|
+
|
|
313
|
+
def _failed(self, msg: str, tb: str) -> None:
|
|
314
|
+
self._end(msg.splitlines()[0] if msg else "Failed.")
|
|
315
|
+
if msg != "Cancelled.":
|
|
316
|
+
box = QMessageBox(QMessageBox.Warning, "eemkit", msg, parent=self)
|
|
317
|
+
if tb:
|
|
318
|
+
box.setDetailedText(tb)
|
|
319
|
+
box.exec()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# --------------------------------------------------------------------------- #
|
|
323
|
+
# Form fields
|
|
324
|
+
# --------------------------------------------------------------------------- #
|
|
325
|
+
|
|
326
|
+
class OptionalFloat(QLineEdit):
|
|
327
|
+
"""A number field that may be left empty (= not set)."""
|
|
328
|
+
|
|
329
|
+
def __init__(self, value=None, placeholder: str = "not set", required: bool = False,
|
|
330
|
+
parent=None):
|
|
331
|
+
super().__init__(parent)
|
|
332
|
+
self.required = required
|
|
333
|
+
self.setPlaceholderText(placeholder)
|
|
334
|
+
self.set_value(value)
|
|
335
|
+
self.textChanged.connect(self._validate)
|
|
336
|
+
self._validate()
|
|
337
|
+
|
|
338
|
+
def value(self) -> float | None:
|
|
339
|
+
t = self.text().strip().replace(",", ".")
|
|
340
|
+
if not t:
|
|
341
|
+
return None
|
|
342
|
+
try:
|
|
343
|
+
return float(t)
|
|
344
|
+
except ValueError:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
def set_value(self, v) -> None:
|
|
348
|
+
self.setText("" if v is None else f"{float(v):g}")
|
|
349
|
+
|
|
350
|
+
def is_valid(self) -> bool:
|
|
351
|
+
t = self.text().strip()
|
|
352
|
+
if not t:
|
|
353
|
+
return not self.required
|
|
354
|
+
return self.value() is not None
|
|
355
|
+
|
|
356
|
+
def _validate(self) -> None:
|
|
357
|
+
invalid = not self.is_valid()
|
|
358
|
+
if self.property("invalid") != invalid:
|
|
359
|
+
self.setProperty("invalid", invalid)
|
|
360
|
+
self.style().unpolish(self)
|
|
361
|
+
self.style().polish(self)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def spin(value: float, lo: float, hi: float, step: float = 1.0, decimals: int = 1,
|
|
365
|
+
suffix: str = "") -> QDoubleSpinBox:
|
|
366
|
+
s = QDoubleSpinBox()
|
|
367
|
+
s.setRange(lo, hi)
|
|
368
|
+
s.setDecimals(decimals)
|
|
369
|
+
s.setSingleStep(step)
|
|
370
|
+
s.setValue(float(value))
|
|
371
|
+
if suffix:
|
|
372
|
+
s.setSuffix(suffix)
|
|
373
|
+
return s
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def pair(*widgets: QWidget) -> QWidget:
|
|
377
|
+
w = QWidget()
|
|
378
|
+
lay = QHBoxLayout(w)
|
|
379
|
+
lay.setContentsMargins(0, 0, 0, 0)
|
|
380
|
+
for x in widgets:
|
|
381
|
+
lay.addWidget(x)
|
|
382
|
+
return w
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def note(text: str) -> QLabel:
|
|
386
|
+
lab = QLabel(text)
|
|
387
|
+
lab.setWordWrap(True)
|
|
388
|
+
lab.setProperty("role", "note")
|
|
389
|
+
return lab
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def primary(button: QPushButton) -> QPushButton:
|
|
393
|
+
"""Mark ``button`` as the main action of its step (accent colour in the theme)."""
|
|
394
|
+
button.setProperty("primary", True)
|
|
395
|
+
return button
|