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,385 @@
1
+ """EEG Regression Denoising tool page.
2
+
3
+ Applies EMG reference-channel regression to remove shared muscle noise from EEG
4
+ before recomputing all cognition outputs for a session. The standard pipeline is
5
+ not modified; this tool clears the existing cognition cache and regenerates it.
6
+
7
+ Reference channels used as regressors (CHEEK_R, CHEEK_L, BROW_L, NOSE) are
8
+ recorded by the Hermes hardware but are not used in the normal EEG derivation.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import queue
15
+ import shutil
16
+ import sys
17
+ import traceback
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
22
+ from PySide6.QtGui import QTextCursor
23
+ from PySide6.QtWidgets import (
24
+ QFileDialog,
25
+ QGroupBox,
26
+ QHBoxLayout,
27
+ QLineEdit,
28
+ QMessageBox,
29
+ QPlainTextEdit,
30
+ QProgressBar,
31
+ QPushButton,
32
+ QVBoxLayout,
33
+ QWidget,
34
+ )
35
+ from reak_core.constants import EXG_SAMPLING_RATE
36
+ from reak_core.eeg.cognition import (
37
+ GAP_THRESHOLD_S,
38
+ bandpass_filter,
39
+ compute_cognitive_indexes,
40
+ compute_eeg_power_bands,
41
+ reject_temporal_artefacts,
42
+ save_eeg_artefact_plot,
43
+ save_filtered_eeg_csv,
44
+ )
45
+ from reak_core.io.exg import derive_eeg_channels, load_exg, mark_invalid
46
+ from reak_core.sync.resample import resample_to_timebase
47
+
48
+ from nucleuskit_toolkit.logging_utils import printError, printInfo, printWarning
49
+ from nucleuskit_toolkit.tools.eeg_regression import reference_channels, regress_noise_channels
50
+ from nucleuskit_toolkit.ui.offline_job import QueueTextWriter, eeg_regression_preflight
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Background worker
54
+ # ---------------------------------------------------------------------------
55
+
56
+ class _EegRegressionWorker(QObject):
57
+ finished_ok = Signal()
58
+ finished_err = Signal(str)
59
+
60
+ def __init__(self, folder: str, log_queue: queue.SimpleQueue[str]) -> None:
61
+ super().__init__()
62
+ self._folder = folder
63
+ self._log_queue = log_queue
64
+
65
+ @Slot()
66
+ def run(self) -> None:
67
+ err: str | None = None
68
+ writer = QueueTextWriter(self._log_queue)
69
+ old_out, old_err = sys.stdout, sys.stderr
70
+ try:
71
+ sys.stdout = writer
72
+ sys.stderr = writer
73
+ self._process()
74
+ except BaseException:
75
+ err = traceback.format_exc()
76
+ self._log_queue.put(err)
77
+ finally:
78
+ sys.stdout = old_out
79
+ sys.stderr = old_err
80
+
81
+ if err:
82
+ self.finished_err.emit("Run finished with errors (see log).")
83
+ else:
84
+ self.finished_ok.emit()
85
+
86
+ def _process(self) -> None:
87
+ recpath = self._folder
88
+ sf = EXG_SAMPLING_RATE
89
+ features_dir = os.path.join(recpath, "features", "cognition")
90
+ cognitive_path = os.path.join(recpath, "results", "Cognition.csv")
91
+ powerbands_path = os.path.join(features_dir, "powerBands.csv")
92
+ artefact_path = os.path.join(features_dir, "artefactStats.csv")
93
+ temporal_bp_path = os.path.join(features_dir, "temporalBandPowers.csv")
94
+ filtered_eeg_path = os.path.join(features_dir, "filteredEEG.csv")
95
+ artefact_plot_path = os.path.join(features_dir, "eegArtefactPlot.png")
96
+
97
+ # -- Clear existing cognition cache so outputs are fully regenerated --
98
+ printInfo("[eegRegressionTool] Clearing existing cognition outputs")
99
+ if os.path.isfile(cognitive_path):
100
+ os.remove(cognitive_path)
101
+ if os.path.isdir(features_dir):
102
+ shutil.rmtree(features_dir)
103
+
104
+ # -- Load EEG ------------------------------------------------------------
105
+ printInfo("[eegRegressionTool] Loading EEG data")
106
+ recording = load_exg(recpath)
107
+ if recording is None or recording.data.shape[1] < 5:
108
+ printError("[eegRegressionTool] No usable EXG data — aborting")
109
+ raise RuntimeError("No EEG data available.")
110
+ # Invalid raw samples (non-finite or full scale) become NaN, as in re-ak-core's cognition.
111
+ raw = mark_invalid(recording.data)
112
+ original_timestamps = recording.timestamps
113
+ eeg_data = derive_eeg_channels(raw)
114
+
115
+ printInfo(f"[eegRegressionTool] EEG loaded: {len(eeg_data)} samples, "
116
+ f"columns: {list(eeg_data.columns)}")
117
+
118
+ # -- Timestamp gap detection + hardware invalid mask ---------------------
119
+ if original_timestamps is None:
120
+ printWarning(
121
+ "[eegRegressionTool] Hardware timestamps unavailable — "
122
+ "falling back to synthetic timestamps. Gap detection disabled."
123
+ )
124
+ original_timestamps = np.arange(len(eeg_data), dtype=float) / sf
125
+ gap_sample_mask = np.zeros(len(eeg_data), dtype=bool)
126
+ else:
127
+ dt = np.diff(original_timestamps)
128
+ gap_indices = np.where(dt >= GAP_THRESHOLD_S)[0]
129
+ gap_sample_mask = np.zeros(len(original_timestamps), dtype=bool)
130
+ if len(gap_indices):
131
+ printWarning(
132
+ f"[eegRegressionTool] {len(gap_indices)} hardware timestamp gap(s) "
133
+ f">= {GAP_THRESHOLD_S:.0f} s detected — affected windows will be NaN."
134
+ )
135
+ gap_sample_mask[gap_indices + 1] = True
136
+
137
+ hardware_invalid = eeg_data.isna().any(axis=1).to_numpy() | gap_sample_mask
138
+
139
+ # -- Bandpass filter EEG -------------------------------------------------
140
+ printInfo("[eegRegressionTool] Bandpass filtering EEG")
141
+ filtered_eeg = bandpass_filter(eeg_data, sf)
142
+
143
+ # -- Load + filter reference channels ------------------------------------
144
+ printInfo("[eegRegressionTool] Loading EMG reference channels")
145
+ ref_data = reference_channels(raw)
146
+
147
+ if ref_data is not None:
148
+ printInfo("[eegRegressionTool] Bandpass filtering reference channels")
149
+ filtered_refs = bandpass_filter(ref_data, sf)
150
+
151
+ # First pass: denoise all EEG channels with the EMG reference set
152
+ # (CHEEK_R, CHEEK_L, BROW_L, NOSE).
153
+ printInfo("[eegRegressionTool] Applying EMG regression denoising (all channels)")
154
+ filtered_eeg = regress_noise_channels(filtered_eeg, filtered_refs)
155
+
156
+ # Second pass: denoise T9/T10 with an extended reference set that
157
+ # additionally includes AF7 and AF8. These frontal channels capture
158
+ # EOG (eye blinks, lateral movements) and frontal movement artefacts
159
+ # that contaminate the temporal electrodes via the shared EAR_R
160
+ # midpoint reference. Only T9/T10 are targeted to avoid regressing
161
+ # frontal brain activity out of the frontal channels themselves.
162
+ if {'AF7', 'AF8', 'T9', 'T10'}.issubset(filtered_eeg.columns):
163
+ printInfo(
164
+ "[eegRegressionTool] Applying extended regression on T9/T10 "
165
+ "using AF7+AF8 as additional EOG/movement references"
166
+ )
167
+ extended_refs = pd.concat(
168
+ [filtered_refs, filtered_eeg[['AF7', 'AF8']]],
169
+ axis=1,
170
+ )
171
+ cleaned_temporal = regress_noise_channels(
172
+ filtered_eeg[['T9', 'T10']],
173
+ extended_refs,
174
+ )
175
+ filtered_eeg = filtered_eeg.copy()
176
+ filtered_eeg[['T9', 'T10']] = cleaned_temporal[['T9', 'T10']]
177
+ else:
178
+ printWarning(
179
+ "[eegRegressionTool] AF7/AF8 or T9/T10 missing from filtered EEG "
180
+ "— skipping extended T9/T10 regression"
181
+ )
182
+ else:
183
+ printWarning(
184
+ "[eegRegressionTool] Reference channels unavailable — "
185
+ "proceeding without regression denoising"
186
+ )
187
+
188
+ # -- Artefact rejection --------------------------------------------------
189
+ printInfo("[eegRegressionTool] Running temporal artefact rejection")
190
+ artefact_mask, artefact_stats, epoch_metrics = reject_temporal_artefacts(
191
+ filtered_eeg, hardware_invalid, sf
192
+ )
193
+ combined_invalid = hardware_invalid | artefact_mask
194
+
195
+ # -- Save filtered EEG + artefact plot -----------------------------------
196
+ os.makedirs(features_dir, exist_ok=True)
197
+ save_filtered_eeg_csv(filtered_eeg, original_timestamps, filtered_eeg_path)
198
+ save_eeg_artefact_plot(filtered_eeg, original_timestamps, combined_invalid, artefact_plot_path)
199
+
200
+ if artefact_stats is not None:
201
+ artefact_stats.to_csv(artefact_path, index=False)
202
+ printInfo(f"[eegRegressionTool] Artefact stats saved to {artefact_path}")
203
+
204
+ epoch_metrics_path = os.path.join(features_dir, "epochMetrics.csv")
205
+ if epoch_metrics is not None:
206
+ epoch_metrics.to_csv(epoch_metrics_path, index=False)
207
+ printInfo(f"[eegRegressionTool] Epoch metrics saved to {epoch_metrics_path}")
208
+
209
+ # -- Power bands ---------------------------------------------------------
210
+ printInfo("[eegRegressionTool] Computing power bands")
211
+ powerbands = compute_eeg_power_bands(
212
+ filtered_eeg[['T9', 'T10']],
213
+ timestamps=original_timestamps,
214
+ hardware_invalid=combined_invalid,
215
+ )
216
+ if powerbands is None:
217
+ raise RuntimeError("compute_eeg_power_bands returned None — recording may be too short.")
218
+
219
+ powerbands.to_csv(powerbands_path, index=False)
220
+ printInfo(f"[eegRegressionTool] Power bands saved to {powerbands_path}")
221
+
222
+ # -- Cognitive indexes ---------------------------------------------------
223
+ printInfo("[eegRegressionTool] Computing cognitive indexes")
224
+ result, temporal_bands = compute_cognitive_indexes(powerbands)
225
+ if result is None:
226
+ raise RuntimeError("compute_cognitive_indexes returned None.")
227
+
228
+ if temporal_bands is not None:
229
+ temporal_bands.to_csv(temporal_bp_path, index=False)
230
+ printInfo(f"[eegRegressionTool] Temporal band powers saved to {temporal_bp_path}")
231
+
232
+ result = resample_to_timebase(result, target_interval=0.5)
233
+
234
+ os.makedirs(os.path.join(recpath, "results"), exist_ok=True)
235
+ result.to_csv(cognitive_path, index=False)
236
+ printInfo(f"[eegRegressionTool] Cognition results saved to {cognitive_path}")
237
+
238
+ printInfo("[eegRegressionTool] Done.")
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # UI page
243
+ # ---------------------------------------------------------------------------
244
+
245
+ class EegRegressionPage(QWidget):
246
+ go_tools_menu = Signal()
247
+ processing_changed = Signal(bool)
248
+
249
+ def __init__(self, parent: QWidget | None = None) -> None:
250
+ super().__init__(parent)
251
+ self._log_queue: queue.SimpleQueue[str] = queue.SimpleQueue()
252
+ self._thread: QThread | None = None
253
+ self._worker: _EegRegressionWorker | None = None
254
+
255
+ # ── top bar ──────────────────────────────────────────────────
256
+ self._back = QPushButton("Back")
257
+ self._back.setProperty("secondary", True)
258
+ self._back.clicked.connect(self.go_tools_menu.emit)
259
+
260
+ top = QHBoxLayout()
261
+ top.addStretch(1)
262
+ top.addWidget(self._back)
263
+
264
+ # ── session folder selector ───────────────────────────────────
265
+ self._session = QLineEdit()
266
+ self._session.setPlaceholderText("Session folder path…")
267
+ browse = QPushButton("Browse…")
268
+ browse.clicked.connect(self._browse_session)
269
+
270
+ sess_row = QHBoxLayout()
271
+ sess_row.addWidget(self._session, 1)
272
+ sess_row.addWidget(browse)
273
+
274
+ session_box = QGroupBox(
275
+ "Session (must contain rawData/ with a raw EEG file)"
276
+ )
277
+ sg = QVBoxLayout(session_box)
278
+ sg.addLayout(sess_row)
279
+
280
+ # ── actions ───────────────────────────────────────────────────
281
+ self._run = QPushButton("Run EEG Regression Denoising")
282
+ self._run.clicked.connect(self._run_clicked)
283
+
284
+ self._progress = QProgressBar()
285
+ self._progress.setRange(0, 0)
286
+ self._progress.setVisible(False)
287
+
288
+ actions = QHBoxLayout()
289
+ actions.addWidget(self._run)
290
+ actions.addWidget(self._progress, 1)
291
+
292
+ # ── log ───────────────────────────────────────────────────────
293
+ self._log = QPlainTextEdit()
294
+ self._log.setReadOnly(True)
295
+
296
+ log_box = QGroupBox("Log")
297
+ lg = QVBoxLayout(log_box)
298
+ lg.addWidget(self._log)
299
+
300
+ # ── assemble ─────────────────────────────────────────────────
301
+ layout = QVBoxLayout(self)
302
+ layout.addLayout(top)
303
+ layout.addWidget(session_box)
304
+ layout.addLayout(actions)
305
+ layout.addWidget(log_box, 1)
306
+
307
+ self._poll_timer = QTimer(self)
308
+ self._poll_timer.timeout.connect(self._drain_log_queue)
309
+ self._poll_timer.start(120)
310
+
311
+ # ── private helpers ───────────────────────────────────────────────
312
+
313
+ def _browse_session(self) -> None:
314
+ path = QFileDialog.getExistingDirectory(self, "Select session folder")
315
+ if path:
316
+ self._session.setText(path)
317
+
318
+ def _set_processing(self, running: bool) -> None:
319
+ self._back.setEnabled(not running)
320
+ self._run.setEnabled(not running)
321
+ self._session.setEnabled(not running)
322
+ self._progress.setVisible(running)
323
+ self.processing_changed.emit(running)
324
+
325
+ def _run_clicked(self) -> None:
326
+ folder = self._session.text().strip()
327
+ err = eeg_regression_preflight(folder)
328
+ if err:
329
+ QMessageBox.warning(self, "Session", err)
330
+ return
331
+ if self._thread is not None and self._thread.isRunning():
332
+ QMessageBox.information(self, "Busy", "A run is already in progress.")
333
+ return
334
+
335
+ self._set_processing(True)
336
+ self._insert_log(f"\n--- Starting EEG Regression Denoising: {folder!r} ---\n")
337
+
338
+ thread = QThread()
339
+ worker = _EegRegressionWorker(folder, self._log_queue)
340
+ worker.moveToThread(thread)
341
+
342
+ thread.started.connect(worker.run)
343
+ worker.finished_ok.connect(self._on_finished_ok)
344
+ worker.finished_err.connect(self._on_finished_err)
345
+ worker.finished_ok.connect(thread.quit)
346
+ worker.finished_err.connect(thread.quit)
347
+ thread.finished.connect(worker.deleteLater)
348
+ thread.finished.connect(thread.deleteLater)
349
+ thread.finished.connect(self._clear_thread_ref)
350
+
351
+ self._thread = thread
352
+ self._worker = worker
353
+ thread.start()
354
+
355
+ def _clear_thread_ref(self) -> None:
356
+ self._thread = None
357
+ self._worker = None
358
+
359
+ def _on_finished_ok(self) -> None:
360
+ self._set_processing(False)
361
+ QMessageBox.information(
362
+ self,
363
+ "EEG Regression Denoising",
364
+ "Done.\n\n"
365
+ "Cognition outputs regenerated with EMG regression applied.\n"
366
+ "results/Cognition.csv and features/cognition/ have been updated.",
367
+ )
368
+
369
+ def _on_finished_err(self, msg: str) -> None:
370
+ self._set_processing(False)
371
+ QMessageBox.critical(self, "EEG Regression Denoising", msg)
372
+
373
+ def _insert_log(self, text: str) -> None:
374
+ self._log.moveCursor(QTextCursor.End)
375
+ self._log.insertPlainText(text)
376
+ sb = self._log.verticalScrollBar()
377
+ sb.setValue(sb.maximum())
378
+
379
+ def _drain_log_queue(self) -> None:
380
+ try:
381
+ while True:
382
+ chunk = self._log_queue.get_nowait()
383
+ self._insert_log(chunk)
384
+ except queue.Empty:
385
+ pass