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
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