nucleuskit-toolkit 2.2.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 (59) hide show
  1. nucleuskit_toolkit/__init__.py +3 -0
  2. nucleuskit_toolkit/__main__.py +60 -0
  3. nucleuskit_toolkit/app.py +34 -0
  4. nucleuskit_toolkit/camera/__init__.py +9 -0
  5. nucleuskit_toolkit/camera/processing.py +374 -0
  6. nucleuskit_toolkit/config.py +86 -0
  7. nucleuskit_toolkit/hermes/__init__.py +0 -0
  8. nucleuskit_toolkit/hermes/constants.py +77 -0
  9. nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
  10. nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
  11. nucleuskit_toolkit/logging_utils.py +68 -0
  12. nucleuskit_toolkit/mqtt/__init__.py +5 -0
  13. nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
  14. nucleuskit_toolkit/pipeline.py +194 -0
  15. nucleuskit_toolkit/position/__init__.py +5 -0
  16. nucleuskit_toolkit/position/gps_processor.py +91 -0
  17. nucleuskit_toolkit/session_job.py +28 -0
  18. nucleuskit_toolkit/shimmer/__init__.py +5 -0
  19. nucleuskit_toolkit/shimmer/constants.py +32 -0
  20. nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
  21. nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
  22. nucleuskit_toolkit/tools/__init__.py +1 -0
  23. nucleuskit_toolkit/tools/eeg_regression.py +91 -0
  24. nucleuskit_toolkit/tools/recording_merger.py +437 -0
  25. nucleuskit_toolkit/ui/__init__.py +1 -0
  26. nucleuskit_toolkit/ui/icons.py +165 -0
  27. nucleuskit_toolkit/ui/main_window.py +465 -0
  28. nucleuskit_toolkit/ui/offline_job.py +144 -0
  29. nucleuskit_toolkit/ui/pages/__init__.py +1 -0
  30. nucleuskit_toolkit/ui/pages/about_page.py +88 -0
  31. nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
  32. nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
  33. nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
  34. nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
  35. nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
  36. nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
  37. nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
  38. nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
  39. nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
  40. nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
  41. nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
  42. nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
  43. nucleuskit_toolkit/ui/plot_style.py +155 -0
  44. nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
  45. nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
  46. nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
  47. nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
  48. nucleuskit_toolkit/ui/resources/__init__.py +1 -0
  49. nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
  50. nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
  51. nucleuskit_toolkit/ui/session_inspect.py +226 -0
  52. nucleuskit_toolkit/ui/theme.py +330 -0
  53. nucleuskit_toolkit/ui/widgets.py +190 -0
  54. nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
  55. nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
  56. nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
  57. nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
  58. nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
  59. nucleuskit_toolkit-2.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,395 @@
1
+ """Recording Merger: join up to 5 recordings with null-padded CSV gaps and black-screen video gaps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import queue
6
+ import sys
7
+ import traceback
8
+ from pathlib import Path
9
+
10
+ from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
11
+ from PySide6.QtGui import QTextCursor
12
+ from PySide6.QtWidgets import (
13
+ QCheckBox,
14
+ QDoubleSpinBox,
15
+ QFileDialog,
16
+ QGroupBox,
17
+ QHBoxLayout,
18
+ QLabel,
19
+ QLineEdit,
20
+ QMessageBox,
21
+ QPlainTextEdit,
22
+ QProgressBar,
23
+ QPushButton,
24
+ QSizePolicy,
25
+ QVBoxLayout,
26
+ QWidget,
27
+ )
28
+
29
+ from nucleuskit_toolkit.tools.recording_merger import merge_recordings
30
+ from nucleuskit_toolkit.ui.offline_job import QueueTextWriter
31
+
32
+ _MAX_RECORDINGS = 5
33
+
34
+
35
+ # ── worker ─────────────────────────────────────────────────────────────────────
36
+
37
+ class _MergeWorker(QObject):
38
+ finished_ok = Signal()
39
+ finished_err = Signal(str)
40
+
41
+ def __init__(
42
+ self,
43
+ folders: list[str],
44
+ offsets: list[float],
45
+ output_dir: str,
46
+ log_queue: queue.SimpleQueue[str],
47
+ skip_video: bool = False,
48
+ ) -> None:
49
+ super().__init__()
50
+ self._folders = folders
51
+ self._offsets = offsets
52
+ self._output_dir = output_dir
53
+ self._log_queue = log_queue
54
+ self._skip_video = skip_video
55
+
56
+ @Slot()
57
+ def run(self) -> None:
58
+ err: str | None = None
59
+ writer = QueueTextWriter(self._log_queue)
60
+ old_out, old_err = sys.stdout, sys.stderr
61
+ try:
62
+ sys.stdout = writer
63
+ sys.stderr = writer
64
+ merge_recordings(
65
+ self._folders, self._offsets, self._output_dir,
66
+ self._log_queue, skip_video=self._skip_video,
67
+ )
68
+ except BaseException:
69
+ err = traceback.format_exc()
70
+ self._log_queue.put(err)
71
+ finally:
72
+ sys.stdout = old_out
73
+ sys.stderr = old_err
74
+
75
+ if err:
76
+ self.finished_err.emit("Run finished with errors (see log).")
77
+ else:
78
+ self.finished_ok.emit()
79
+
80
+
81
+ # ── recording row widget ────────────────────────────────────────────────────────
82
+
83
+ class _RecordingRow(QWidget):
84
+ """One row: index label | path field | Browse | [Offset label | spin] | [Remove]."""
85
+
86
+ path_changed = Signal()
87
+ remove_requested = Signal(object) # emits self
88
+
89
+ def __init__(self, index: int, parent: QWidget | None = None) -> None:
90
+ super().__init__(parent)
91
+ self._index = index
92
+
93
+ self._label = QLabel(f"Recording {index + 1}:")
94
+ self._label.setFixedWidth(100)
95
+
96
+ self._path = QLineEdit()
97
+ self._path.setPlaceholderText("Recording folder path…")
98
+ self._path.textChanged.connect(lambda _: self.path_changed.emit())
99
+
100
+ self._browse = QPushButton("Browse…")
101
+ self._browse.clicked.connect(self._browse_clicked)
102
+
103
+ self._offset_label = QLabel("Offset:")
104
+ self._offset = QDoubleSpinBox()
105
+ self._offset.setRange(0.0, 86_400.0) # up to 24 h
106
+ self._offset.setSingleStep(1.0)
107
+ self._offset.setSuffix(" s")
108
+ self._offset.setValue(0.0)
109
+ self._offset.setFixedWidth(110)
110
+ self._offset.setToolTip("Gap in seconds from the end of the previous recording to the start of this one.")
111
+
112
+ self._remove = QPushButton("Remove")
113
+ self._remove.setProperty("secondary", True)
114
+ self._remove.clicked.connect(lambda: self.remove_requested.emit(self))
115
+
116
+ row = QHBoxLayout(self)
117
+ row.setContentsMargins(0, 2, 0, 2)
118
+ row.addWidget(self._label)
119
+ row.addWidget(self._path, 1)
120
+ row.addWidget(self._browse)
121
+ row.addWidget(self._offset_label)
122
+ row.addWidget(self._offset)
123
+ row.addWidget(self._remove)
124
+
125
+ self._refresh_visibility()
126
+
127
+ # ── public ────────────────────────────────────────────────────────────────
128
+
129
+ def folder(self) -> str:
130
+ return self._path.text().strip()
131
+
132
+ def offset(self) -> float:
133
+ return self._offset.value()
134
+
135
+ def set_index(self, new_index: int) -> None:
136
+ self._index = new_index
137
+ self._label.setText(f"Recording {new_index + 1}:")
138
+ self._refresh_visibility()
139
+
140
+ def set_controls_enabled(self, enabled: bool) -> None:
141
+ self._path.setEnabled(enabled)
142
+ self._browse.setEnabled(enabled)
143
+ self._offset.setEnabled(enabled)
144
+ self._remove.setEnabled(enabled)
145
+
146
+ # ── private ───────────────────────────────────────────────────────────────
147
+
148
+ def _refresh_visibility(self) -> None:
149
+ is_first = self._index == 0
150
+ self._offset_label.setVisible(not is_first)
151
+ self._offset.setVisible(not is_first)
152
+ self._remove.setVisible(not is_first)
153
+
154
+ def _browse_clicked(self) -> None:
155
+ path = QFileDialog.getExistingDirectory(self, "Select recording folder")
156
+ if path:
157
+ self._path.setText(path)
158
+
159
+
160
+ # ── main page ──────────────────────────────────────────────────────────────────
161
+
162
+ class RecordingMergePage(QWidget):
163
+ go_tools_menu = Signal()
164
+ processing_changed = Signal(bool)
165
+
166
+ def __init__(self, parent: QWidget | None = None) -> None:
167
+ super().__init__(parent)
168
+
169
+ self._log_queue: queue.SimpleQueue[str] = queue.SimpleQueue()
170
+ self._thread: QThread | None = None
171
+ self._worker: _MergeWorker | None = None
172
+ self._rows: list[_RecordingRow] = []
173
+
174
+ # ── top bar ───────────────────────────────────────────────────────────
175
+ self._back = QPushButton("Back")
176
+ self._back.setProperty("secondary", True)
177
+ self._back.clicked.connect(self.go_tools_menu.emit)
178
+
179
+ top = QHBoxLayout()
180
+ top.addStretch(1)
181
+ top.addWidget(self._back)
182
+
183
+ # ── recordings group ──────────────────────────────────────────────────
184
+ self._rows_layout = QVBoxLayout()
185
+ self._rows_layout.setSpacing(4)
186
+
187
+ self._add_btn = QPushButton("+ Add recording")
188
+ self._add_btn.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
189
+ self._add_btn.clicked.connect(self._add_row)
190
+
191
+ rows_inner = QVBoxLayout()
192
+ rows_inner.addLayout(self._rows_layout)
193
+ rows_inner.addWidget(self._add_btn)
194
+
195
+ recordings_box = QGroupBox("Recordings (2–5, in order)")
196
+ recordings_box.setLayout(rows_inner)
197
+
198
+ # ── output folder ─────────────────────────────────────────────────────
199
+ self._output = QLineEdit()
200
+ self._output.setReadOnly(True)
201
+ self._output.setPlaceholderText("Set Recording 1 path to auto-generate…")
202
+
203
+ out_row = QHBoxLayout()
204
+ out_row.addWidget(QLabel("Output folder (auto):"))
205
+ out_row.addWidget(self._output, 1)
206
+
207
+ # ── actions ───────────────────────────────────────────────────────────
208
+ self._skip_video = QCheckBox("Skip video merge")
209
+ self._skip_video.setToolTip(
210
+ "When checked, only result CSVs are merged; video files are ignored."
211
+ )
212
+
213
+ self._run = QPushButton("Start Merge")
214
+ self._run.clicked.connect(self._run_clicked)
215
+
216
+ self._progress = QProgressBar()
217
+ self._progress.setRange(0, 0)
218
+ self._progress.setVisible(False)
219
+
220
+ actions = QHBoxLayout()
221
+ actions.addWidget(self._skip_video)
222
+ actions.addStretch(1)
223
+ actions.addWidget(self._run)
224
+ actions.addWidget(self._progress, 1)
225
+
226
+ # ── log ───────────────────────────────────────────────────────────────
227
+ self._log = QPlainTextEdit()
228
+ self._log.setReadOnly(True)
229
+
230
+ log_box = QGroupBox("Log")
231
+ lg = QVBoxLayout(log_box)
232
+ lg.addWidget(self._log)
233
+
234
+ # ── assemble ──────────────────────────────────────────────────────────
235
+ layout = QVBoxLayout(self)
236
+ layout.addLayout(top)
237
+ layout.addWidget(recordings_box)
238
+ layout.addLayout(out_row)
239
+ layout.addLayout(actions)
240
+ layout.addWidget(log_box, 1)
241
+
242
+ # seed with two rows so the tool is immediately usable
243
+ self._add_row()
244
+ self._add_row()
245
+
246
+ self._poll_timer = QTimer(self)
247
+ self._poll_timer.timeout.connect(self._drain_log_queue)
248
+ self._poll_timer.start(120)
249
+
250
+ # ── row management ────────────────────────────────────────────────────────
251
+
252
+ def _add_row(self) -> None:
253
+ if len(self._rows) >= _MAX_RECORDINGS:
254
+ return
255
+ row = _RecordingRow(len(self._rows), self)
256
+ row.path_changed.connect(self._update_output_folder)
257
+ row.remove_requested.connect(self._remove_row)
258
+ self._rows_layout.addWidget(row)
259
+ self._rows.append(row)
260
+ self._add_btn.setEnabled(len(self._rows) < _MAX_RECORDINGS)
261
+
262
+ def _remove_row(self, row: _RecordingRow) -> None:
263
+ if row not in self._rows:
264
+ return
265
+ self._rows.remove(row)
266
+ self._rows_layout.removeWidget(row)
267
+ row.deleteLater()
268
+ for i, r in enumerate(self._rows):
269
+ r.set_index(i)
270
+ self._add_btn.setEnabled(len(self._rows) < _MAX_RECORDINGS)
271
+ self._update_output_folder()
272
+
273
+ def _update_output_folder(self) -> None:
274
+ if not self._rows:
275
+ self._output.setText("")
276
+ return
277
+ first_path = self._rows[0].folder()
278
+ if not first_path:
279
+ self._output.setText("")
280
+ return
281
+ names = [Path(r.folder()).name for r in self._rows if r.folder()]
282
+ merged_name = "merged_" + "_".join(names) if names else "merged_recording"
283
+ self._output.setText(str(Path(first_path).parent / merged_name))
284
+
285
+ # ── validation ────────────────────────────────────────────────────────────
286
+
287
+ def _validate(self) -> str | None:
288
+ if len(self._rows) < 2:
289
+ return "Add at least 2 recordings to merge."
290
+
291
+ for i, row in enumerate(self._rows):
292
+ f = row.folder()
293
+ if not f:
294
+ return f"Recording {i + 1} has no folder selected."
295
+ p = Path(f)
296
+ if not p.is_dir():
297
+ return f"Recording {i + 1}: folder does not exist:\n{f}"
298
+ if not (p / "results").is_dir() and not (p / "rawData").is_dir():
299
+ return (
300
+ f"Recording {i + 1}: folder does not look like a recording\n"
301
+ f"(no 'results/' or 'rawData/' subfolder found):\n{f}"
302
+ )
303
+
304
+ output = self._output.text().strip()
305
+ if not output:
306
+ return "Cannot determine output folder. Set Recording 1 path first."
307
+ if Path(output).exists():
308
+ return (
309
+ f"Output folder already exists:\n{output}\n\n"
310
+ "Remove or rename it before merging."
311
+ )
312
+
313
+ return None
314
+
315
+ # ── run ───────────────────────────────────────────────────────────────────
316
+
317
+ def _run_clicked(self) -> None:
318
+ err = self._validate()
319
+ if err:
320
+ QMessageBox.warning(self, "Recording Merger", err)
321
+ return
322
+ if self._thread is not None and self._thread.isRunning():
323
+ QMessageBox.information(self, "Busy", "A merge is already in progress.")
324
+ return
325
+
326
+ folders = [row.folder() for row in self._rows]
327
+ offsets = [row.offset() for row in self._rows[1:]]
328
+ output_dir = self._output.text().strip()
329
+ skip_video = self._skip_video.isChecked()
330
+
331
+ self._set_processing(True)
332
+ self._insert_log(
333
+ f"\n--- Starting merge of {len(folders)} recordings ---\n"
334
+ + "".join(f" [{i + 1}] {f}\n" for i, f in enumerate(folders))
335
+ + (" (video merge skipped)\n" if skip_video else "")
336
+ + "\n"
337
+ )
338
+
339
+ thread = QThread()
340
+ worker = _MergeWorker(folders, offsets, output_dir, self._log_queue, skip_video)
341
+ worker.moveToThread(thread)
342
+
343
+ thread.started.connect(worker.run)
344
+ worker.finished_ok.connect(self._on_finished_ok)
345
+ worker.finished_err.connect(self._on_finished_err)
346
+ worker.finished_ok.connect(thread.quit)
347
+ worker.finished_err.connect(thread.quit)
348
+ thread.finished.connect(worker.deleteLater)
349
+ thread.finished.connect(thread.deleteLater)
350
+ thread.finished.connect(self._clear_thread_ref)
351
+
352
+ self._thread = thread
353
+ self._worker = worker
354
+ thread.start()
355
+
356
+ def _set_processing(self, running: bool) -> None:
357
+ self._back.setEnabled(not running)
358
+ self._run.setEnabled(not running)
359
+ self._add_btn.setEnabled(not running)
360
+ self._skip_video.setEnabled(not running)
361
+ self._progress.setVisible(running)
362
+ for row in self._rows:
363
+ row.set_controls_enabled(not running)
364
+ self.processing_changed.emit(running)
365
+
366
+ def _clear_thread_ref(self) -> None:
367
+ self._thread = None
368
+ self._worker = None
369
+
370
+ def _on_finished_ok(self) -> None:
371
+ self._set_processing(False)
372
+ QMessageBox.information(
373
+ self,
374
+ "Recording Merger",
375
+ f"Done.\n\nMerged recording saved to:\n{self._output.text()}",
376
+ )
377
+
378
+ def _on_finished_err(self, msg: str) -> None:
379
+ self._set_processing(False)
380
+ QMessageBox.critical(self, "Recording Merger", msg)
381
+
382
+ # ── log helpers ───────────────────────────────────────────────────────────
383
+
384
+ def _insert_log(self, text: str) -> None:
385
+ self._log.moveCursor(QTextCursor.MoveOperation.End)
386
+ self._log.insertPlainText(text)
387
+ sb = self._log.verticalScrollBar()
388
+ sb.setValue(sb.maximum())
389
+
390
+ def _drain_log_queue(self) -> None:
391
+ try:
392
+ while True:
393
+ self._insert_log(self._log_queue.get_nowait())
394
+ except queue.Empty:
395
+ pass
@@ -0,0 +1,128 @@
1
+ """Revert working RMS CSV to the frozen baseline in ``features/emotions/original/``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ from PySide6.QtCore import Signal
8
+ from PySide6.QtWidgets import (
9
+ QFileDialog,
10
+ QGroupBox,
11
+ QHBoxLayout,
12
+ QLabel,
13
+ QMessageBox,
14
+ QPlainTextEdit,
15
+ QPushButton,
16
+ QVBoxLayout,
17
+ QWidget,
18
+ )
19
+ from reak_core.tools.rms_ops import (
20
+ operations_log_path,
21
+ original_rms_csv,
22
+ revert_working_to_original,
23
+ )
24
+
25
+ from nucleuskit_toolkit.ui.offline_job import rms_features_preflight
26
+
27
+
28
+ class RevertOriginalPage(QWidget):
29
+ go_tools_menu = Signal()
30
+
31
+ def __init__(self, parent: QWidget | None = None) -> None:
32
+ super().__init__(parent)
33
+
34
+ self._back = QPushButton("Back")
35
+ self._back.setProperty("secondary", True)
36
+ self._back.clicked.connect(self.go_tools_menu.emit)
37
+
38
+ top = QHBoxLayout()
39
+ top.addStretch(1)
40
+ top.addWidget(self._back)
41
+
42
+ self._session = QLabel()
43
+ self._session.setWordWrap(True)
44
+ self._session.setText("No session selected.")
45
+
46
+ self._browse = QPushButton("Select session folder…")
47
+ self._browse.clicked.connect(self._browse_session)
48
+
49
+ self._revert = QPushButton("Revert to original")
50
+ self._revert.clicked.connect(self._revert_clicked)
51
+
52
+ sess_box = QGroupBox("Session")
53
+ sg = QVBoxLayout(sess_box)
54
+ sg.addWidget(self._browse)
55
+ sg.addWidget(self._session)
56
+
57
+ self._log = QPlainTextEdit()
58
+ self._log.setReadOnly(True)
59
+ self._log.setPlaceholderText("operations.log tail (after revert)…")
60
+
61
+ log_box = QGroupBox("Recent operations log")
62
+ lg = QVBoxLayout(log_box)
63
+ lg.addWidget(self._log)
64
+
65
+ layout = QVBoxLayout(self)
66
+ layout.addLayout(top)
67
+ layout.addWidget(sess_box)
68
+ layout.addWidget(self._revert)
69
+ layout.addWidget(log_box, 1)
70
+
71
+ self._folder: str | None = None
72
+
73
+ def _browse_session(self) -> None:
74
+ path = QFileDialog.getExistingDirectory(self, "Select session folder")
75
+ if path:
76
+ self._folder = path
77
+ self._session.setText(path)
78
+ self._refresh_log_tail()
79
+
80
+ def _refresh_log_tail(self) -> None:
81
+ if not self._folder:
82
+ self._log.clear()
83
+ return
84
+ log_path = operations_log_path(self._folder)
85
+ if not os.path.isfile(log_path):
86
+ self._log.clear()
87
+ return
88
+ try:
89
+ with open(log_path, encoding="utf-8") as fh:
90
+ lines = fh.readlines()
91
+ tail = "".join(lines[-80:])
92
+ self._log.setPlainText(tail)
93
+ except OSError:
94
+ self._log.setPlainText("(Could not read log.)")
95
+
96
+ def _revert_clicked(self) -> None:
97
+ if not self._folder:
98
+ QMessageBox.warning(self, "Session", "Please select a session folder.")
99
+ return
100
+ err = rms_features_preflight(self._folder)
101
+ if err:
102
+ QMessageBox.warning(self, "Session", err)
103
+ return
104
+ ori = original_rms_csv(self._folder)
105
+ if not ori.is_file():
106
+ QMessageBox.warning(
107
+ self,
108
+ "Revert",
109
+ f"No frozen baseline found:\n{ori}\n\n"
110
+ "Run a tool that writes RMS (channel fixer or gain adjustment) first.",
111
+ )
112
+ return
113
+ confirm = QMessageBox.question(
114
+ self,
115
+ "Revert to original",
116
+ "Overwrite features/emotions/rmsSignals.csv with the frozen baseline?",
117
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
118
+ QMessageBox.StandardButton.No,
119
+ )
120
+ if confirm != QMessageBox.StandardButton.Yes:
121
+ return
122
+ try:
123
+ revert_working_to_original(self._folder)
124
+ except OSError as e:
125
+ QMessageBox.critical(self, "Revert", str(e))
126
+ return
127
+ self._refresh_log_tail()
128
+ QMessageBox.information(self, "Revert", "Working RMS file restored from original.")
@@ -0,0 +1,69 @@
1
+ """Appearance and other settings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import QSettings, Signal
6
+ from PySide6.QtWidgets import QComboBox, QHBoxLayout, QVBoxLayout, QWidget
7
+
8
+ from nucleuskit_toolkit.ui.theme import ThemeMode
9
+ from nucleuskit_toolkit.ui.widgets import Card, SectionHeader, label
10
+
11
+ ORGANIZATION = "REAK"
12
+ APPLICATION = "NucleusKitToolkit"
13
+ SETTINGS_THEME_KEY = "appearance/theme"
14
+
15
+
16
+ def load_theme_setting() -> ThemeMode:
17
+ s = QSettings(ORGANIZATION, APPLICATION)
18
+ raw = s.value(SETTINGS_THEME_KEY, "system")
19
+ if raw in ("light", "dark", "system"):
20
+ return raw # type: ignore[return-value]
21
+ return "system"
22
+
23
+
24
+ def save_theme_setting(mode: ThemeMode) -> None:
25
+ s = QSettings(ORGANIZATION, APPLICATION)
26
+ s.setValue(SETTINGS_THEME_KEY, mode)
27
+
28
+
29
+ class SettingsPage(QWidget):
30
+ """Theme selection persisted with QSettings."""
31
+
32
+ theme_changed = Signal(str)
33
+
34
+ def __init__(self, parent: QWidget | None = None) -> None:
35
+ super().__init__(parent)
36
+ self._theme_combo = QComboBox()
37
+ self._theme_combo.addItem("Light", "light")
38
+ self._theme_combo.addItem("Dark", "dark")
39
+ self._theme_combo.addItem("System", "system")
40
+ self._theme_combo.setMinimumWidth(220)
41
+
42
+ current = load_theme_setting()
43
+ idx = max(0, self._theme_combo.findData(current))
44
+ self._theme_combo.setCurrentIndex(idx)
45
+
46
+ appearance = Card()
47
+ appearance.setMaximumWidth(760)
48
+ appearance.body.addWidget(SectionHeader("Appearance", "Colours of the application.", icon="settings"))
49
+ row = QHBoxLayout()
50
+ text = QVBoxLayout()
51
+ text.setSpacing(2)
52
+ text.addWidget(label("Theme", "strong"))
53
+ text.addWidget(label("System follows the light or dark mode of the operating system.", "hint"))
54
+ row.addLayout(text, 1)
55
+ row.addWidget(self._theme_combo)
56
+ appearance.body.addLayout(row)
57
+
58
+ layout = QVBoxLayout(self)
59
+ layout.setContentsMargins(0, 0, 0, 0)
60
+ layout.addWidget(appearance)
61
+ layout.addStretch(1)
62
+
63
+ self._theme_combo.currentIndexChanged.connect(self._on_theme_changed)
64
+
65
+ def _on_theme_changed(self, _index: int) -> None:
66
+ mode = self._theme_combo.currentData()
67
+ if mode:
68
+ save_theme_setting(mode)
69
+ self.theme_changed.emit(mode)
@@ -0,0 +1,42 @@
1
+ """Auxiliary tools for processed sessions (Channel Fixer, …), shown as a grid of cards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from PySide6.QtCore import Signal
6
+ from PySide6.QtWidgets import QGridLayout, QVBoxLayout, QWidget
7
+
8
+ from nucleuskit_toolkit.ui.widgets import ChoiceCard
9
+
10
+ TOOLS = (
11
+ ("open_channel_fixer", "Channel Fixer", "Repair a single bad RMS channel with the channel-fix model."),
12
+ ("open_channel_gain", "Channel gain adjustment", "Adjust per-channel RMS gain and zero offset."),
13
+ ("open_revert_original", "Revert to original", "Restore the RMS features from the frozen baseline."),
14
+ ("open_ppg_fixer", "PPG Fixer", "Re-run PPG artifact rejection and heart dynamics."),
15
+ ("open_eeg_regression", "EEG Regression Denoising", "Remove shared muscle noise from the EEG."),
16
+ ("open_recording_merge", "Recording Merger", "Join up to 5 recordings into one session."),
17
+ )
18
+
19
+
20
+ class ToolsMenuPage(QWidget):
21
+ open_channel_fixer = Signal()
22
+ open_channel_gain = Signal()
23
+ open_revert_original = Signal()
24
+ open_ppg_fixer = Signal()
25
+ open_eeg_regression = Signal()
26
+ open_recording_merge = Signal()
27
+
28
+ def __init__(self, parent: QWidget | None = None) -> None:
29
+ super().__init__(parent)
30
+ grid = QGridLayout()
31
+ grid.setHorizontalSpacing(14)
32
+ grid.setVerticalSpacing(14)
33
+ for i, (signal_name, title, description) in enumerate(TOOLS):
34
+ card = ChoiceCard("wrench", title, description)
35
+ card.set_indicator_visible(False)
36
+ card.clicked.connect(getattr(self, signal_name).emit)
37
+ grid.addWidget(card, i // 2, i % 2)
38
+
39
+ outer = QVBoxLayout(self)
40
+ outer.setContentsMargins(0, 0, 0, 0)
41
+ outer.addLayout(grid)
42
+ outer.addStretch(1)