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,332 @@
|
|
|
1
|
+
"""Tab 1: data folder, file inventory and the pairing table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
from PySide6.QtCore import QSortFilterProxyModel, Qt
|
|
10
|
+
from PySide6.QtWidgets import (
|
|
11
|
+
QAbstractItemView,
|
|
12
|
+
QFileDialog,
|
|
13
|
+
QFormLayout,
|
|
14
|
+
QGroupBox,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QLineEdit,
|
|
17
|
+
QMessageBox,
|
|
18
|
+
QPlainTextEdit,
|
|
19
|
+
QPushButton,
|
|
20
|
+
QSplitter,
|
|
21
|
+
QTableView,
|
|
22
|
+
QTabWidget,
|
|
23
|
+
QVBoxLayout,
|
|
24
|
+
QWidget,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from eemkit import pipeline
|
|
28
|
+
from eemkit.errors import EEMKitWarning
|
|
29
|
+
from eemkit.gui.common import (
|
|
30
|
+
EXCLUDED,
|
|
31
|
+
SUGGESTED,
|
|
32
|
+
ChoiceDelegate,
|
|
33
|
+
DataFrameModel,
|
|
34
|
+
TaskBar,
|
|
35
|
+
note,
|
|
36
|
+
pair,
|
|
37
|
+
primary,
|
|
38
|
+
)
|
|
39
|
+
from eemkit.gui.state import Project
|
|
40
|
+
from eemkit.io.discover import scan_folder, suggest_pairing
|
|
41
|
+
from eemkit.io.pairing import read_pairing
|
|
42
|
+
|
|
43
|
+
FILE_KINDS = {"eem_file": "eem", "eem_blank_file": "eem", "abs_file": "absorbance",
|
|
44
|
+
"abs_blank_file": "absorbance"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _pairing_colour(row: pd.Series):
|
|
48
|
+
if str(row.get("include", "")).lower() not in ("yes", "y", "true", "1", "si", "sì"):
|
|
49
|
+
return EXCLUDED
|
|
50
|
+
if row.get("pairing_status", "") == "suggested":
|
|
51
|
+
return SUGGESTED
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ProjectTab(QWidget):
|
|
56
|
+
def __init__(self, project: Project, tasks: TaskBar, parent=None):
|
|
57
|
+
super().__init__(parent)
|
|
58
|
+
self.project, self.tasks = project, tasks
|
|
59
|
+
|
|
60
|
+
self.data_root = QLineEdit()
|
|
61
|
+
self.pairing_path = QLineEdit()
|
|
62
|
+
self.output_dir = QLineEdit()
|
|
63
|
+
for edit in (self.data_root, self.pairing_path, self.output_dir):
|
|
64
|
+
edit.setReadOnly(True)
|
|
65
|
+
b_root = QPushButton("Choose…")
|
|
66
|
+
b_root.clicked.connect(self.choose_data_root)
|
|
67
|
+
b_pair = QPushButton("Open…")
|
|
68
|
+
b_pair.clicked.connect(self.open_pairing)
|
|
69
|
+
b_out = QPushButton("Choose…")
|
|
70
|
+
b_out.clicked.connect(self.choose_output)
|
|
71
|
+
paths = QGroupBox("Project")
|
|
72
|
+
form = QFormLayout(paths)
|
|
73
|
+
form.addRow("Data folder", pair(self.data_root, b_root))
|
|
74
|
+
form.addRow("", note("Read only: eemkit never writes, renames or moves anything in the "
|
|
75
|
+
"data folder."))
|
|
76
|
+
form.addRow("Pairing table", pair(self.pairing_path, b_pair))
|
|
77
|
+
form.addRow("Output folder", pair(self.output_dir, b_out))
|
|
78
|
+
|
|
79
|
+
self.b_scan = QPushButton("1. Scan data folder")
|
|
80
|
+
self.b_scan.clicked.connect(self.scan)
|
|
81
|
+
self.b_suggest = QPushButton("2. Draft pairing from scan")
|
|
82
|
+
self.b_suggest.clicked.connect(self.suggest)
|
|
83
|
+
self.b_include = QPushButton("Include selected rows")
|
|
84
|
+
self.b_include.clicked.connect(lambda: self.set_include(True))
|
|
85
|
+
self.b_exclude = QPushButton("Exclude selected rows")
|
|
86
|
+
self.b_exclude.clicked.connect(lambda: self.set_include(False))
|
|
87
|
+
self.b_save = QPushButton("Save pairing table")
|
|
88
|
+
self.b_save.clicked.connect(self.save_pairing)
|
|
89
|
+
self.b_load = primary(QPushButton("3. Check and load data"))
|
|
90
|
+
self.b_load.clicked.connect(self.load_data)
|
|
91
|
+
self.b_load.setDefault(True)
|
|
92
|
+
buttons = QHBoxLayout()
|
|
93
|
+
for b in (self.b_scan, self.b_suggest, self.b_include, self.b_exclude, self.b_save,
|
|
94
|
+
self.b_load):
|
|
95
|
+
buttons.addWidget(b)
|
|
96
|
+
buttons.addStretch(1)
|
|
97
|
+
|
|
98
|
+
self.pairing_model = DataFrameModel(row_colour=_pairing_colour)
|
|
99
|
+
self.pairing_model.edited.connect(self._pairing_edited)
|
|
100
|
+
self.proxy = QSortFilterProxyModel()
|
|
101
|
+
self.proxy.setSourceModel(self.pairing_model)
|
|
102
|
+
self.proxy.setFilterCaseSensitivity(Qt.CaseInsensitive)
|
|
103
|
+
self.proxy.setFilterKeyColumn(-1)
|
|
104
|
+
self.filter = QLineEdit()
|
|
105
|
+
self.filter.setPlaceholderText("Filter rows…")
|
|
106
|
+
self.filter.textChanged.connect(self.proxy.setFilterFixedString)
|
|
107
|
+
self.pairing_view = QTableView()
|
|
108
|
+
self.pairing_view.setModel(self.proxy)
|
|
109
|
+
self.pairing_view.setSelectionBehavior(QAbstractItemView.SelectRows)
|
|
110
|
+
self.pairing_view.setSortingEnabled(True)
|
|
111
|
+
self.pairing_view.sortByColumn(-1, Qt.AscendingOrder) # file order until a click
|
|
112
|
+
pairing_box = QWidget()
|
|
113
|
+
pl = QVBoxLayout(pairing_box)
|
|
114
|
+
pl.setContentsMargins(0, 0, 0, 0)
|
|
115
|
+
pl.addWidget(note("One row per sample. Rows drafted from the scan are yellow and start "
|
|
116
|
+
"excluded: check each pairing, then include it. Grey rows are not "
|
|
117
|
+
"used. Double-click a cell to edit it."))
|
|
118
|
+
pl.addWidget(self.filter)
|
|
119
|
+
pl.addWidget(self.pairing_view)
|
|
120
|
+
|
|
121
|
+
self.files_model = DataFrameModel()
|
|
122
|
+
files_view = QTableView()
|
|
123
|
+
files_view.setModel(self.files_model)
|
|
124
|
+
files_view.setSortingEnabled(True)
|
|
125
|
+
|
|
126
|
+
inner = QTabWidget()
|
|
127
|
+
inner.addTab(pairing_box, "Pairing table")
|
|
128
|
+
inner.addTab(files_view, "Files found")
|
|
129
|
+
self.messages = QPlainTextEdit()
|
|
130
|
+
self.messages.setReadOnly(True)
|
|
131
|
+
self.messages.setPlaceholderText("Problems and warnings appear here.")
|
|
132
|
+
split = QSplitter(Qt.Vertical)
|
|
133
|
+
split.addWidget(inner)
|
|
134
|
+
split.addWidget(self.messages)
|
|
135
|
+
split.setStretchFactor(0, 4)
|
|
136
|
+
split.setStretchFactor(1, 1)
|
|
137
|
+
|
|
138
|
+
lay = QVBoxLayout(self)
|
|
139
|
+
lay.addWidget(paths)
|
|
140
|
+
lay.addLayout(buttons)
|
|
141
|
+
lay.addWidget(split, 1)
|
|
142
|
+
project.changed.connect(self._on_changed)
|
|
143
|
+
tasks.busy_changed.connect(self._busy)
|
|
144
|
+
self.refresh_paths()
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
|
|
148
|
+
def _busy(self, busy: bool) -> None:
|
|
149
|
+
for b in (self.b_scan, self.b_suggest, self.b_load):
|
|
150
|
+
b.setEnabled(not busy)
|
|
151
|
+
|
|
152
|
+
def _on_changed(self, what: str) -> None:
|
|
153
|
+
if what == "settings":
|
|
154
|
+
self.refresh_paths()
|
|
155
|
+
path = self.project.resolve("pairing")
|
|
156
|
+
if self.project.pairing is None and path is not None and path.is_file():
|
|
157
|
+
self._read_pairing(path)
|
|
158
|
+
if what == "pairing" and self.project.pairing is None:
|
|
159
|
+
self.pairing_model.set_frame(pd.DataFrame())
|
|
160
|
+
|
|
161
|
+
def refresh_paths(self) -> None:
|
|
162
|
+
for key, edit in (("data_root", self.data_root), ("pairing", self.pairing_path),
|
|
163
|
+
("output_dir", self.output_dir)):
|
|
164
|
+
p = self.project.resolve(key)
|
|
165
|
+
edit.setText(str(p) if p else "")
|
|
166
|
+
|
|
167
|
+
def say(self, text: str) -> None:
|
|
168
|
+
self.messages.appendPlainText(text)
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------ #
|
|
171
|
+
|
|
172
|
+
def choose_data_root(self) -> None:
|
|
173
|
+
d = QFileDialog.getExistingDirectory(self, "Data folder (read only)",
|
|
174
|
+
self.data_root.text())
|
|
175
|
+
if d:
|
|
176
|
+
self.project.set_path_setting("data_root", d)
|
|
177
|
+
self.project.invalidate("pairing")
|
|
178
|
+
self.project.changed.emit("settings")
|
|
179
|
+
|
|
180
|
+
def choose_output(self) -> None:
|
|
181
|
+
d = QFileDialog.getExistingDirectory(self, "Output folder", self.output_dir.text())
|
|
182
|
+
if d:
|
|
183
|
+
if self.project.in_data_root(d):
|
|
184
|
+
QMessageBox.warning(self, "eemkit", "The output folder must not be inside the "
|
|
185
|
+
"data folder, which is read only.")
|
|
186
|
+
return
|
|
187
|
+
self.project.set_path_setting("output_dir", d)
|
|
188
|
+
self.project.changed.emit("settings")
|
|
189
|
+
|
|
190
|
+
def open_pairing(self) -> None:
|
|
191
|
+
path, _ = QFileDialog.getOpenFileName(self, "Pairing table", self.pairing_path.text(),
|
|
192
|
+
"CSV (*.csv)")
|
|
193
|
+
if path and self._read_pairing(Path(path)):
|
|
194
|
+
self.project.set_path_setting("pairing", path)
|
|
195
|
+
self.project.changed.emit("settings")
|
|
196
|
+
|
|
197
|
+
def _read_pairing(self, path: Path) -> bool:
|
|
198
|
+
try:
|
|
199
|
+
df = read_pairing(path, check_files=False)
|
|
200
|
+
except Exception as exc: # noqa: BLE001 - shown to the user
|
|
201
|
+
QMessageBox.warning(self, "eemkit", str(exc))
|
|
202
|
+
return False
|
|
203
|
+
df["include"] = df["include"].map({True: "yes", False: "no"})
|
|
204
|
+
self._set_pairing(df)
|
|
205
|
+
self.say(f"Opened {path}: {len(df)} rows, {int((df['include'] == 'yes').sum())} "
|
|
206
|
+
"included.")
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def _set_pairing(self, df: pd.DataFrame) -> None:
|
|
210
|
+
self.project.pairing = df
|
|
211
|
+
self.pairing_model.set_frame(df)
|
|
212
|
+
self.pairing_model.editable = set(df.columns)
|
|
213
|
+
self.pairing_view.setItemDelegateForColumn(
|
|
214
|
+
list(df.columns).index("include"), ChoiceDelegate(lambda: ["yes", "no"], self))
|
|
215
|
+
for col, kind in FILE_KINDS.items():
|
|
216
|
+
if col in df.columns:
|
|
217
|
+
self.pairing_view.setItemDelegateForColumn(
|
|
218
|
+
list(df.columns).index(col),
|
|
219
|
+
ChoiceDelegate(lambda k=kind: self._files_of(k), self))
|
|
220
|
+
self.pairing_view.resizeColumnsToContents()
|
|
221
|
+
self.project.invalidate("raw")
|
|
222
|
+
|
|
223
|
+
def _files_of(self, kind: str) -> list[str]:
|
|
224
|
+
inv = self.project.inventory
|
|
225
|
+
if inv is None:
|
|
226
|
+
return []
|
|
227
|
+
return list(inv.loc[inv["kind"] == kind, "file"])
|
|
228
|
+
|
|
229
|
+
def _pairing_edited(self) -> None:
|
|
230
|
+
self.project.dirty = True
|
|
231
|
+
self.project.invalidate("raw")
|
|
232
|
+
|
|
233
|
+
def scan(self) -> None:
|
|
234
|
+
root = self.project.resolve("data_root")
|
|
235
|
+
if root is None or not root.is_dir():
|
|
236
|
+
QMessageBox.information(self, "eemkit", "Choose the data folder first.")
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
def work(progress, cancelled):
|
|
240
|
+
return scan_folder(root, progress=lambda i, n: progress("Scanning files", i, n))
|
|
241
|
+
|
|
242
|
+
def done(inv):
|
|
243
|
+
self.project.inventory = inv
|
|
244
|
+
self.files_model.set_frame(inv)
|
|
245
|
+
counts = inv["kind"].value_counts().to_dict()
|
|
246
|
+
self.say(f"Scanned {root}: {len(inv)} CSV files, {counts.get('eem', 0)} EEM, "
|
|
247
|
+
f"{counts.get('absorbance', 0)} absorbance, {counts.get('unknown', 0)} not "
|
|
248
|
+
f"recognised; {int(inv['is_blank'].sum())} look like blanks.")
|
|
249
|
+
for _, r in inv[inv["kind"] == "unknown"].iterrows():
|
|
250
|
+
self.say(f" not used: {r['file']}: {r['problem']}")
|
|
251
|
+
|
|
252
|
+
self.tasks.run(work, done, "Scanning data folder…")
|
|
253
|
+
|
|
254
|
+
def suggest(self) -> None:
|
|
255
|
+
if self.project.inventory is None:
|
|
256
|
+
QMessageBox.information(self, "eemkit", "Scan the data folder first.")
|
|
257
|
+
return
|
|
258
|
+
if self.project.pairing is not None and len(self.project.pairing):
|
|
259
|
+
ok = QMessageBox.question(self, "eemkit", "Replace the current pairing table with a "
|
|
260
|
+
"new draft?")
|
|
261
|
+
if ok != QMessageBox.Yes:
|
|
262
|
+
return
|
|
263
|
+
draft = suggest_pairing(self.project.inventory)
|
|
264
|
+
self._set_pairing(draft)
|
|
265
|
+
self.project.dirty = True
|
|
266
|
+
n_abs = int((draft["abs_file"] != "").sum())
|
|
267
|
+
self.say(f"Drafted {len(draft)} rows ({n_abs} with an absorbance match). All rows are "
|
|
268
|
+
"excluded until you check and include them.")
|
|
269
|
+
|
|
270
|
+
def set_include(self, include: bool) -> None:
|
|
271
|
+
df = self.project.pairing
|
|
272
|
+
rows = {self.proxy.mapToSource(i).row() for i in
|
|
273
|
+
self.pairing_view.selectionModel().selectedRows()}
|
|
274
|
+
if df is None or not rows:
|
|
275
|
+
return
|
|
276
|
+
idx = df.index[sorted(rows)]
|
|
277
|
+
df.loc[idx, "include"] = "yes" if include else "no"
|
|
278
|
+
if include and "pairing_status" in df.columns:
|
|
279
|
+
df.loc[idx, "pairing_status"] = "checked"
|
|
280
|
+
self.pairing_model.set_frame(df)
|
|
281
|
+
self._pairing_edited()
|
|
282
|
+
|
|
283
|
+
def save_pairing(self) -> bool:
|
|
284
|
+
df = self.project.pairing
|
|
285
|
+
if df is None:
|
|
286
|
+
return False
|
|
287
|
+
path = self.project.resolve("pairing")
|
|
288
|
+
if path is None:
|
|
289
|
+
name, _ = QFileDialog.getSaveFileName(self, "Save pairing table", "pairing.csv",
|
|
290
|
+
"CSV (*.csv)")
|
|
291
|
+
if not name:
|
|
292
|
+
return False
|
|
293
|
+
path = Path(name)
|
|
294
|
+
self.project.set_path_setting("pairing", path)
|
|
295
|
+
self.project.changed.emit("settings")
|
|
296
|
+
if self.project.in_data_root(path):
|
|
297
|
+
QMessageBox.warning(self, "eemkit", "The pairing table cannot be saved inside the "
|
|
298
|
+
"read-only data folder.")
|
|
299
|
+
return False
|
|
300
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
301
|
+
df.to_csv(path, index=False)
|
|
302
|
+
self.say(f"Saved {path}")
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
def load_data(self) -> None:
|
|
306
|
+
if self.project.pairing is not None and not self.save_pairing():
|
|
307
|
+
return
|
|
308
|
+
settings, base = self.project.settings, self.project.base
|
|
309
|
+
|
|
310
|
+
def work(progress, cancelled):
|
|
311
|
+
progress("Reading files", 0, 0)
|
|
312
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
313
|
+
warnings.simplefilter("always", EEMKitWarning)
|
|
314
|
+
ds = pipeline.load(settings, base)
|
|
315
|
+
return ds, [str(w.message) for w in caught]
|
|
316
|
+
|
|
317
|
+
def done(result):
|
|
318
|
+
ds, warns = result
|
|
319
|
+
self.project.raw = ds
|
|
320
|
+
self.project.changed.emit("raw")
|
|
321
|
+
n = ds.sizes["sample"]
|
|
322
|
+
nb = int(ds["blank_intensity"].notnull().any(("ex", "em")).sum()) \
|
|
323
|
+
if "blank_intensity" in ds else 0
|
|
324
|
+
na = int(ds["absorbance"].notnull().any("wavelength").sum()) \
|
|
325
|
+
if "absorbance" in ds else 0
|
|
326
|
+
self.say(f"Loaded {n} samples (ex {ds.ex.min().item():g}-{ds.ex.max().item():g} nm, "
|
|
327
|
+
f"em {ds.em.min().item():g}-{ds.em.max().item():g} nm); {nb} with a blank "
|
|
328
|
+
f"EEM, {na} with absorbance.")
|
|
329
|
+
for w in warns:
|
|
330
|
+
self.say(f" warning: {w}")
|
|
331
|
+
|
|
332
|
+
self.tasks.run(work, done, "Loading data…")
|
eemkit/gui/state.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The open project: settings, pairing table and the results of each step."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import xarray as xr
|
|
10
|
+
from PySide6.QtCore import QObject, Signal
|
|
11
|
+
|
|
12
|
+
from eemkit import pipeline
|
|
13
|
+
|
|
14
|
+
STAGES = ("pairing", "raw", "corrected", "indices", "exploration", "final", "campaigns")
|
|
15
|
+
#: Settings that hold file paths, as (section, key).
|
|
16
|
+
PATH_SETTINGS = (("project", "data_root"), ("project", "pairing"), ("project", "output_dir"),
|
|
17
|
+
("spectral", "ex_factors"), ("spectral", "em_factors"),
|
|
18
|
+
("campaigns", "sites_table"), ("campaigns", "covariates_table"))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Project(QObject):
|
|
22
|
+
"""Holds everything the tabs share and announces what changed.
|
|
23
|
+
|
|
24
|
+
A result is dropped (and ``changed`` emitted) whenever something it
|
|
25
|
+
depends on changes, so a tab never shows results computed from settings
|
|
26
|
+
or data that are no longer current.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
changed = Signal(str) # one of STAGES, "settings", or "display" (redraw only)
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.path: Path | None = None # the project's settings TOML
|
|
34
|
+
self.settings: dict = pipeline.default_settings()
|
|
35
|
+
self.inventory: pd.DataFrame | None = None
|
|
36
|
+
self.pairing: pd.DataFrame | None = None
|
|
37
|
+
self.raw: xr.Dataset | None = None
|
|
38
|
+
self.after_raman: xr.Dataset | None = None
|
|
39
|
+
self.corrected: xr.Dataset | None = None
|
|
40
|
+
self.indices: pd.DataFrame | None = None
|
|
41
|
+
self.exploration: pipeline.Exploration | None = None
|
|
42
|
+
self.model: xr.Dataset | None = None
|
|
43
|
+
self.split: xr.Dataset | None = None
|
|
44
|
+
self.campaign: pipeline.CampaignAnalysis | None = None
|
|
45
|
+
self.dirty = False
|
|
46
|
+
|
|
47
|
+
# paths -------------------------------------------------------------- #
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def base(self) -> Path:
|
|
51
|
+
"""Folder relative paths in the settings are resolved against."""
|
|
52
|
+
return self.path.parent if self.path else Path.cwd()
|
|
53
|
+
|
|
54
|
+
def resolve(self, key: str, section: str = "project") -> Path | None:
|
|
55
|
+
v = self.settings[section].get(key, "")
|
|
56
|
+
if not v:
|
|
57
|
+
return None
|
|
58
|
+
p = Path(v)
|
|
59
|
+
# normpath, not resolve(): folds "config/../data" without following drive mappings
|
|
60
|
+
return p if p.is_absolute() else Path(os.path.normpath(self.base / p))
|
|
61
|
+
|
|
62
|
+
def in_data_root(self, path: str | Path) -> bool:
|
|
63
|
+
"""True if ``path`` lies in the (read-only) data folder."""
|
|
64
|
+
root = self.resolve("data_root")
|
|
65
|
+
return root is not None and Path(path).resolve().is_relative_to(root.resolve())
|
|
66
|
+
|
|
67
|
+
def relative(self, path: str | Path) -> str:
|
|
68
|
+
"""``path`` relative to the project file when it lies below it; '' stays ''."""
|
|
69
|
+
if not str(path):
|
|
70
|
+
return ""
|
|
71
|
+
p = Path(path)
|
|
72
|
+
if self.path is not None:
|
|
73
|
+
try:
|
|
74
|
+
p = p.resolve().relative_to(self.base.resolve())
|
|
75
|
+
except ValueError:
|
|
76
|
+
pass
|
|
77
|
+
return p.as_posix()
|
|
78
|
+
|
|
79
|
+
def set_path_setting(self, key: str, path: str | Path, section: str = "project") -> None:
|
|
80
|
+
"""Store ``path`` relative to the project file when it lies below it."""
|
|
81
|
+
self.settings[section][key] = self.relative(path)
|
|
82
|
+
self.dirty = True
|
|
83
|
+
|
|
84
|
+
# invalidation ------------------------------------------------------- #
|
|
85
|
+
|
|
86
|
+
def invalidate(self, stage: str) -> None:
|
|
87
|
+
"""Drop the results of ``stage`` and of every later stage."""
|
|
88
|
+
later = STAGES[STAGES.index(stage):]
|
|
89
|
+
if "raw" in later:
|
|
90
|
+
self.raw = None
|
|
91
|
+
if "corrected" in later:
|
|
92
|
+
self.after_raman = self.corrected = None
|
|
93
|
+
if "indices" in later:
|
|
94
|
+
self.indices = None
|
|
95
|
+
if "exploration" in later:
|
|
96
|
+
self.exploration = None
|
|
97
|
+
if "final" in later:
|
|
98
|
+
self.model = self.split = None
|
|
99
|
+
self.campaign = None # last stage: depends on everything before it
|
|
100
|
+
for s in later:
|
|
101
|
+
self.changed.emit(s)
|
|
102
|
+
|
|
103
|
+
def set_settings(self, section: str, values: dict, invalidates: str | None) -> None:
|
|
104
|
+
"""Update one settings section; drop results that depended on it."""
|
|
105
|
+
old = self.settings.get(section, {})
|
|
106
|
+
new = {**old, **values}
|
|
107
|
+
if new == old:
|
|
108
|
+
return
|
|
109
|
+
self.settings[section] = new
|
|
110
|
+
self.dirty = True
|
|
111
|
+
self.changed.emit("settings")
|
|
112
|
+
if invalidates:
|
|
113
|
+
self.invalidate(invalidates)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def eem_colours(self) -> str:
|
|
117
|
+
"""Name of the EEM colour map (a key of :data:`eemkit.plot.COLORMAPS`)."""
|
|
118
|
+
return self.settings["display"]["eem_colours"]
|
|
119
|
+
|
|
120
|
+
def set_eem_colours(self, name: str) -> None:
|
|
121
|
+
if name != self.eem_colours:
|
|
122
|
+
self.set_settings("display", {"eem_colours": name}, None)
|
|
123
|
+
self.changed.emit("display")
|
|
124
|
+
|
|
125
|
+
# files -------------------------------------------------------------- #
|
|
126
|
+
|
|
127
|
+
def save(self, path: str | Path | None = None) -> Path:
|
|
128
|
+
if path is not None:
|
|
129
|
+
old_base = self.base
|
|
130
|
+
self.path = Path(path)
|
|
131
|
+
for section, key in PATH_SETTINGS: # re-express paths against the new folder
|
|
132
|
+
v = self.settings[section].get(key, "")
|
|
133
|
+
if v:
|
|
134
|
+
self.set_path_setting(key, v if Path(v).is_absolute() else old_base / v,
|
|
135
|
+
section)
|
|
136
|
+
if self.path is None:
|
|
137
|
+
raise ValueError("no project file chosen")
|
|
138
|
+
pipeline.save_settings(self.settings, self.path)
|
|
139
|
+
self.dirty = False
|
|
140
|
+
return self.path
|
|
141
|
+
|
|
142
|
+
def open(self, path: str | Path) -> None:
|
|
143
|
+
self.settings = pipeline.load_settings(path)
|
|
144
|
+
self.path = Path(path)
|
|
145
|
+
self.inventory = self.pairing = None
|
|
146
|
+
self.invalidate("pairing")
|
|
147
|
+
self.dirty = False
|
|
148
|
+
self.changed.emit("settings")
|
|
149
|
+
self.changed.emit("display")
|
eemkit/gui/theme.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Light and dark themes of the application (Qt Fusion style, palette and style sheet).
|
|
2
|
+
|
|
3
|
+
The theme is a preference of the user, not of the project: it is kept in
|
|
4
|
+
``QSettings`` and never written to the project file. Widgets opt into
|
|
5
|
+
special looks through dynamic properties: ``primary`` for the main action of
|
|
6
|
+
a step, ``invalid`` for a field that needs a value, ``role="note"`` for help
|
|
7
|
+
text and ``role="warning"`` for warnings.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from PySide6.QtCore import QSettings, Qt
|
|
13
|
+
from PySide6.QtGui import QColor, QGuiApplication, QPalette
|
|
14
|
+
from PySide6.QtWidgets import QApplication, QStyleFactory, QWidget
|
|
15
|
+
|
|
16
|
+
THEMES = ("system", "light", "dark")
|
|
17
|
+
|
|
18
|
+
COLOURS = {
|
|
19
|
+
"light": {"bg": "#f4f6f8", "surface": "#ffffff", "border": "#d6dbe1", "text": "#1d232b",
|
|
20
|
+
"muted": "#5b6573", "accent": "#1f5fa8", "accent_hover": "#184c86",
|
|
21
|
+
"accent_soft": "#dce8f7", "nav": "#e9edf2", "warn": "#8a5a00",
|
|
22
|
+
"warn_bg": "#fff4d6", "bad_bg": "#fbe3e3", "on_accent": "#ffffff"},
|
|
23
|
+
"dark": {"bg": "#16191d", "surface": "#1f2328", "border": "#353c45", "text": "#e6e9ed",
|
|
24
|
+
"muted": "#9aa4b0", "accent": "#4c8fdb", "accent_hover": "#6aa3e3",
|
|
25
|
+
"accent_soft": "#22364d", "nav": "#1b1e23", "warn": "#f0b54a",
|
|
26
|
+
"warn_bg": "#3a2f17", "bad_bg": "#4a2326", "on_accent": "#0b1220"},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
STYLE = """
|
|
30
|
+
QWidget {{ color: {text}; font-size: 10pt; }}
|
|
31
|
+
QMainWindow, QDialog {{ background: {bg}; }}
|
|
32
|
+
QMenuBar {{ background: {bg}; }}
|
|
33
|
+
QGroupBox {{ background: {surface}; border: 1px solid {border}; border-radius: 8px;
|
|
34
|
+
margin-top: 14px; padding: 10px 8px 8px 8px; }}
|
|
35
|
+
QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 4px;
|
|
36
|
+
color: {muted}; font-weight: 600; }}
|
|
37
|
+
QPushButton {{ background: {surface}; border: 1px solid {border}; border-radius: 6px;
|
|
38
|
+
padding: 6px 14px; }}
|
|
39
|
+
QPushButton:hover {{ border-color: {accent}; }}
|
|
40
|
+
QPushButton:pressed {{ background: {accent_soft}; }}
|
|
41
|
+
QPushButton:disabled {{ color: {muted}; }}
|
|
42
|
+
QPushButton[primary="true"] {{ background: {accent}; color: {on_accent}; border: none;
|
|
43
|
+
font-weight: 600; padding: 8px 18px; }}
|
|
44
|
+
QPushButton[primary="true"]:hover {{ background: {accent_hover}; }}
|
|
45
|
+
QPushButton[primary="true"]:disabled {{ background: {border}; color: {muted}; }}
|
|
46
|
+
QPushButton[role="warning"] {{ color: {warn}; border: none; background: transparent; }}
|
|
47
|
+
QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox, QPlainTextEdit {{
|
|
48
|
+
background: {surface}; border: 1px solid {border}; border-radius: 6px; padding: 4px 6px; }}
|
|
49
|
+
QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, QComboBox:focus {{
|
|
50
|
+
border-color: {accent}; }}
|
|
51
|
+
QLineEdit[invalid="true"] {{ background: {bad_bg}; }}
|
|
52
|
+
QLabel[role="note"] {{ color: {muted}; }}
|
|
53
|
+
QLabel[role="warning"] {{ color: {warn}; background: {warn_bg}; border-radius: 6px;
|
|
54
|
+
padding: 8px; }}
|
|
55
|
+
QTableView, QListWidget {{ background: {surface}; border: 1px solid {border};
|
|
56
|
+
border-radius: 6px; gridline-color: {border};
|
|
57
|
+
selection-background-color: {accent_soft}; selection-color: {text}; }}
|
|
58
|
+
QHeaderView::section {{ background: {bg}; color: {muted}; border: none;
|
|
59
|
+
border-bottom: 1px solid {border}; padding: 4px 6px; font-weight: 600; }}
|
|
60
|
+
QTabWidget::pane {{ border: 1px solid {border}; border-radius: 6px; background: {surface}; }}
|
|
61
|
+
QTabBar::tab {{ background: transparent; padding: 6px 12px; color: {muted};
|
|
62
|
+
border-bottom: 2px solid transparent; }}
|
|
63
|
+
QTabBar::tab:selected {{ color: {text}; border-bottom: 2px solid {accent}; }}
|
|
64
|
+
QProgressBar {{ border: 1px solid {border}; border-radius: 6px; background: {surface};
|
|
65
|
+
text-align: center; height: 14px; }}
|
|
66
|
+
QProgressBar::chunk {{ background: {accent}; border-radius: 5px; }}
|
|
67
|
+
QSplitter::handle {{ background: {bg}; }}
|
|
68
|
+
QListWidget#nav {{ background: {nav}; border: none; border-radius: 0; padding: 10px 6px;
|
|
69
|
+
font-size: 10.5pt; outline: 0; }}
|
|
70
|
+
QListWidget#nav::item {{ padding: 10px 10px; border-radius: 6px; margin: 1px 0; }}
|
|
71
|
+
QListWidget#nav::item:selected {{ background: {accent_soft}; color: {text};
|
|
72
|
+
border-left: 3px solid {accent}; }}
|
|
73
|
+
QListWidget#nav::item:hover:!selected {{ background: {bg}; }}
|
|
74
|
+
QLabel#brand {{ font-size: 15pt; font-weight: 700; padding: 14px 14px 2px 14px; }}
|
|
75
|
+
QLabel#tagline {{ color: {muted}; padding: 0 14px 8px 14px; }}
|
|
76
|
+
QLabel#summary {{ color: {muted}; padding: 10px 14px; }}
|
|
77
|
+
QWidget#sidebar {{ background: {nav}; border-right: 1px solid {border}; }}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def saved_theme() -> str:
|
|
82
|
+
t = QSettings("eemkit", "eemkit").value("theme", "system")
|
|
83
|
+
return t if t in THEMES else "system"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def save_theme(name: str) -> None:
|
|
87
|
+
QSettings("eemkit", "eemkit").setValue("theme", name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolved(name: str) -> str:
|
|
91
|
+
"""'light' or 'dark' ('system' follows the operating system where Qt can tell)."""
|
|
92
|
+
if name != "system":
|
|
93
|
+
return name
|
|
94
|
+
hints = QGuiApplication.styleHints()
|
|
95
|
+
scheme = getattr(hints, "colorScheme", lambda: None)()
|
|
96
|
+
return "dark" if scheme == Qt.ColorScheme.Dark else "light"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def apply_theme(app: QApplication, name: str = "system") -> str:
|
|
100
|
+
"""Apply a theme to the whole application; returns 'light' or 'dark'."""
|
|
101
|
+
mode = resolved(name)
|
|
102
|
+
c = COLOURS[mode]
|
|
103
|
+
app.setStyle(QStyleFactory.create("Fusion"))
|
|
104
|
+
pal = QPalette()
|
|
105
|
+
for role, key in ((QPalette.Window, "bg"), (QPalette.Base, "surface"),
|
|
106
|
+
(QPalette.AlternateBase, "bg"), (QPalette.Button, "surface"),
|
|
107
|
+
(QPalette.Text, "text"), (QPalette.WindowText, "text"),
|
|
108
|
+
(QPalette.ButtonText, "text"), (QPalette.ToolTipBase, "surface"),
|
|
109
|
+
(QPalette.ToolTipText, "text"), (QPalette.Highlight, "accent"),
|
|
110
|
+
(QPalette.HighlightedText, "on_accent"), (QPalette.PlaceholderText, "muted"),
|
|
111
|
+
(QPalette.Link, "accent")):
|
|
112
|
+
pal.setColor(role, QColor(c[key]))
|
|
113
|
+
app.setPalette(pal)
|
|
114
|
+
app.setStyleSheet(STYLE.format(**c))
|
|
115
|
+
app.setProperty("eemkit_theme", mode)
|
|
116
|
+
return mode
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def mark(widget: QWidget, **props) -> QWidget:
|
|
120
|
+
"""Set dynamic properties (e.g. ``primary=True``) and refresh the widget's style."""
|
|
121
|
+
for k, v in props.items():
|
|
122
|
+
widget.setProperty(k, v)
|
|
123
|
+
widget.style().unpolish(widget)
|
|
124
|
+
widget.style().polish(widget)
|
|
125
|
+
return widget
|