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.
- nucleuskit_toolkit/__init__.py +3 -0
- nucleuskit_toolkit/__main__.py +60 -0
- nucleuskit_toolkit/app.py +34 -0
- nucleuskit_toolkit/camera/__init__.py +9 -0
- nucleuskit_toolkit/camera/processing.py +374 -0
- nucleuskit_toolkit/config.py +86 -0
- nucleuskit_toolkit/hermes/__init__.py +0 -0
- nucleuskit_toolkit/hermes/constants.py +77 -0
- nucleuskit_toolkit/hermes/realtime/__init__.py +8 -0
- nucleuskit_toolkit/hermes/realtime/proxy.py +652 -0
- nucleuskit_toolkit/logging_utils.py +68 -0
- nucleuskit_toolkit/mqtt/__init__.py +5 -0
- nucleuskit_toolkit/mqtt/mqtt_controller.py +256 -0
- nucleuskit_toolkit/pipeline.py +194 -0
- nucleuskit_toolkit/position/__init__.py +5 -0
- nucleuskit_toolkit/position/gps_processor.py +91 -0
- nucleuskit_toolkit/session_job.py +28 -0
- nucleuskit_toolkit/shimmer/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/constants.py +32 -0
- nucleuskit_toolkit/shimmer/realtime/__init__.py +5 -0
- nucleuskit_toolkit/shimmer/realtime/proxy.py +279 -0
- nucleuskit_toolkit/tools/__init__.py +1 -0
- nucleuskit_toolkit/tools/eeg_regression.py +91 -0
- nucleuskit_toolkit/tools/recording_merger.py +437 -0
- nucleuskit_toolkit/ui/__init__.py +1 -0
- nucleuskit_toolkit/ui/icons.py +165 -0
- nucleuskit_toolkit/ui/main_window.py +465 -0
- nucleuskit_toolkit/ui/offline_job.py +144 -0
- nucleuskit_toolkit/ui/pages/__init__.py +1 -0
- nucleuskit_toolkit/ui/pages/about_page.py +88 -0
- nucleuskit_toolkit/ui/pages/channel_fixer_page.py +205 -0
- nucleuskit_toolkit/ui/pages/channel_gain_page.py +362 -0
- nucleuskit_toolkit/ui/pages/eeg_regression_page.py +385 -0
- nucleuskit_toolkit/ui/pages/mqtt_controller_page.py +394 -0
- nucleuskit_toolkit/ui/pages/offline_page.py +754 -0
- nucleuskit_toolkit/ui/pages/playback_page.py +1524 -0
- nucleuskit_toolkit/ui/pages/ppg_fixer_page.py +199 -0
- nucleuskit_toolkit/ui/pages/realtime_viewer_page.py +954 -0
- nucleuskit_toolkit/ui/pages/recording_merge_page.py +395 -0
- nucleuskit_toolkit/ui/pages/revert_original_page.py +128 -0
- nucleuskit_toolkit/ui/pages/settings_page.py +69 -0
- nucleuskit_toolkit/ui/pages/tools_menu_page.py +42 -0
- nucleuskit_toolkit/ui/plot_style.py +155 -0
- nucleuskit_toolkit/ui/realtime_eeg_plot.py +244 -0
- nucleuskit_toolkit/ui/realtime_hermes_motion_plot.py +162 -0
- nucleuskit_toolkit/ui/realtime_nlms_config_dialog.py +101 -0
- nucleuskit_toolkit/ui/realtime_shimmer_plot.py +135 -0
- nucleuskit_toolkit/ui/resources/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/__init__.py +1 -0
- nucleuskit_toolkit/ui/resources/branding/logo.png +0 -0
- nucleuskit_toolkit/ui/session_inspect.py +226 -0
- nucleuskit_toolkit/ui/theme.py +330 -0
- nucleuskit_toolkit/ui/widgets.py +190 -0
- nucleuskit_toolkit-2.2.0.dist-info/METADATA +351 -0
- nucleuskit_toolkit-2.2.0.dist-info/RECORD +59 -0
- nucleuskit_toolkit-2.2.0.dist-info/WHEEL +5 -0
- nucleuskit_toolkit-2.2.0.dist-info/entry_points.txt +2 -0
- nucleuskit_toolkit-2.2.0.dist-info/licenses/LICENSE +675 -0
- nucleuskit_toolkit-2.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""About: application and processing-core versions, emotion models, license and links."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
from PySide6.QtCore import Qt, QUrl
|
|
8
|
+
from PySide6.QtGui import QDesktopServices
|
|
9
|
+
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QPushButton, QVBoxLayout, QWidget
|
|
10
|
+
|
|
11
|
+
import nucleuskit_toolkit
|
|
12
|
+
from nucleuskit_toolkit.ui.icons import icon
|
|
13
|
+
from nucleuskit_toolkit.ui.theme import current_palette
|
|
14
|
+
from nucleuskit_toolkit.ui.widgets import Card, Divider, SectionHeader, label
|
|
15
|
+
|
|
16
|
+
LINKS = (
|
|
17
|
+
("Nucleus-Kit on GitHub", "https://github.com/fs-re-ak/NucleusKit-ProcessingPipeline"),
|
|
18
|
+
("re-ak-core on GitHub", "https://github.com/fs-re-ak/re-ak-core"),
|
|
19
|
+
("Changelog", "https://github.com/fs-re-ak/NucleusKit-ProcessingPipeline/blob/main/CHANGELOG.md"),
|
|
20
|
+
("License (GPL-3.0-or-later)", "https://github.com/fs-re-ak/NucleusKit-ProcessingPipeline/blob/main/LICENSE"),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _package_version(name: str) -> str:
|
|
25
|
+
try:
|
|
26
|
+
return version(name)
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
return "not installed"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AboutPage(QWidget):
|
|
32
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
33
|
+
super().__init__(parent)
|
|
34
|
+
from reak_core.models import DEFAULT_MODEL, builtin_releases, resolve_key
|
|
35
|
+
|
|
36
|
+
card = Card()
|
|
37
|
+
card.setMaximumWidth(960)
|
|
38
|
+
card.body.addWidget(
|
|
39
|
+
SectionHeader(
|
|
40
|
+
"Nucleus-Kit Processing Toolkit", "Copyright (C) 2026 RE-AK Technologies Inc.", icon="activity"
|
|
41
|
+
)
|
|
42
|
+
)
|
|
43
|
+
card.body.addWidget(Divider())
|
|
44
|
+
|
|
45
|
+
grid = QGridLayout()
|
|
46
|
+
grid.setHorizontalSpacing(24)
|
|
47
|
+
grid.setVerticalSpacing(10)
|
|
48
|
+
default = resolve_key(DEFAULT_MODEL)
|
|
49
|
+
models = ", ".join(
|
|
50
|
+
f"{r.name} {r.version}" + (" (recommended)" if key == default else "")
|
|
51
|
+
for key, r in builtin_releases().items()
|
|
52
|
+
)
|
|
53
|
+
rows = (
|
|
54
|
+
("Version", nucleuskit_toolkit.__version__),
|
|
55
|
+
("Processing core", f"re-ak-core {_package_version('re-ak-core')}"),
|
|
56
|
+
("Emotion models", models),
|
|
57
|
+
("Qt", f"PySide6 {_package_version('PySide6')}"),
|
|
58
|
+
("License", "GNU General Public License v3.0 or later"),
|
|
59
|
+
)
|
|
60
|
+
for i, (name, value) in enumerate(rows):
|
|
61
|
+
grid.addWidget(label(name, "hint"), i, 0, Qt.AlignmentFlag.AlignTop)
|
|
62
|
+
grid.addWidget(label(value, "strong", wrap=True), i, 1)
|
|
63
|
+
grid.setColumnStretch(1, 1)
|
|
64
|
+
card.body.addLayout(grid)
|
|
65
|
+
card.body.addWidget(Divider())
|
|
66
|
+
|
|
67
|
+
links = QHBoxLayout()
|
|
68
|
+
links.setSpacing(8)
|
|
69
|
+
self._link_buttons: list[QPushButton] = []
|
|
70
|
+
for text, url in LINKS:
|
|
71
|
+
btn = QPushButton(" " + text)
|
|
72
|
+
btn.setProperty("variant", "secondary")
|
|
73
|
+
btn.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
74
|
+
btn.clicked.connect(lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u)))
|
|
75
|
+
links.addWidget(btn)
|
|
76
|
+
self._link_buttons.append(btn)
|
|
77
|
+
links.addStretch(1)
|
|
78
|
+
card.body.addLayout(links)
|
|
79
|
+
self.refresh_theme()
|
|
80
|
+
|
|
81
|
+
col = QVBoxLayout(self)
|
|
82
|
+
col.setContentsMargins(0, 0, 0, 0)
|
|
83
|
+
col.addWidget(card)
|
|
84
|
+
col.addStretch(1)
|
|
85
|
+
|
|
86
|
+
def refresh_theme(self) -> None:
|
|
87
|
+
for btn in self._link_buttons:
|
|
88
|
+
btn.setIcon(icon("external", current_palette().text, 16))
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Channel Fixer: repair one RMS channel in a processed session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import queue
|
|
6
|
+
import sys
|
|
7
|
+
import traceback
|
|
8
|
+
|
|
9
|
+
from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot
|
|
10
|
+
from PySide6.QtGui import QTextCursor
|
|
11
|
+
from PySide6.QtWidgets import (
|
|
12
|
+
QComboBox,
|
|
13
|
+
QFileDialog,
|
|
14
|
+
QGroupBox,
|
|
15
|
+
QHBoxLayout,
|
|
16
|
+
QLineEdit,
|
|
17
|
+
QMessageBox,
|
|
18
|
+
QPlainTextEdit,
|
|
19
|
+
QProgressBar,
|
|
20
|
+
QPushButton,
|
|
21
|
+
QVBoxLayout,
|
|
22
|
+
QWidget,
|
|
23
|
+
)
|
|
24
|
+
from reak_core.constants import CHANNEL_NAMES
|
|
25
|
+
from reak_core.tools import fix_session
|
|
26
|
+
|
|
27
|
+
from nucleuskit_toolkit.ui.offline_job import QueueTextWriter, channel_fixer_preflight
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ChannelFixerWorker(QObject):
|
|
31
|
+
finished_ok = Signal()
|
|
32
|
+
finished_err = Signal(str)
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
folder: str,
|
|
37
|
+
channel_idx: int,
|
|
38
|
+
log_queue: queue.SimpleQueue[str],
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__()
|
|
41
|
+
self._folder = folder
|
|
42
|
+
self._channel_idx = channel_idx
|
|
43
|
+
self._log_queue = log_queue
|
|
44
|
+
|
|
45
|
+
@Slot()
|
|
46
|
+
def run_fix(self) -> None:
|
|
47
|
+
err: str | None = None
|
|
48
|
+
writer = QueueTextWriter(self._log_queue)
|
|
49
|
+
old_out, old_err = sys.stdout, sys.stderr
|
|
50
|
+
try:
|
|
51
|
+
sys.stdout = writer
|
|
52
|
+
sys.stderr = writer
|
|
53
|
+
out = fix_session(self._folder, self._channel_idx)
|
|
54
|
+
print(f"Report written: {out}")
|
|
55
|
+
except BaseException:
|
|
56
|
+
err = traceback.format_exc()
|
|
57
|
+
self._log_queue.put(err)
|
|
58
|
+
finally:
|
|
59
|
+
sys.stdout = old_out
|
|
60
|
+
sys.stderr = old_err
|
|
61
|
+
if err:
|
|
62
|
+
self.finished_err.emit("Run finished with errors (see log).")
|
|
63
|
+
else:
|
|
64
|
+
self.finished_ok.emit()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ChannelFixerPage(QWidget):
|
|
68
|
+
go_tools_menu = Signal()
|
|
69
|
+
processing_changed = Signal(bool)
|
|
70
|
+
|
|
71
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
72
|
+
super().__init__(parent)
|
|
73
|
+
self._log_queue: queue.SimpleQueue[str] = queue.SimpleQueue()
|
|
74
|
+
self._thread: QThread | None = None
|
|
75
|
+
self._worker: ChannelFixerWorker | None = None
|
|
76
|
+
|
|
77
|
+
self._back = QPushButton("Back")
|
|
78
|
+
self._back.setProperty("secondary", True)
|
|
79
|
+
self._back.clicked.connect(self.go_tools_menu.emit)
|
|
80
|
+
|
|
81
|
+
top = QHBoxLayout()
|
|
82
|
+
top.addStretch(1)
|
|
83
|
+
top.addWidget(self._back)
|
|
84
|
+
|
|
85
|
+
self._session = QLineEdit()
|
|
86
|
+
self._session.setPlaceholderText("Session folder path…")
|
|
87
|
+
browse_s = QPushButton("Browse…")
|
|
88
|
+
browse_s.clicked.connect(self._browse_session)
|
|
89
|
+
|
|
90
|
+
sess_row = QHBoxLayout()
|
|
91
|
+
sess_row.addWidget(self._session, 1)
|
|
92
|
+
sess_row.addWidget(browse_s)
|
|
93
|
+
|
|
94
|
+
self._channel = QComboBox()
|
|
95
|
+
for name in CHANNEL_NAMES:
|
|
96
|
+
self._channel.addItem(name)
|
|
97
|
+
|
|
98
|
+
ch_row = QHBoxLayout()
|
|
99
|
+
ch_row.addWidget(self._channel, 1)
|
|
100
|
+
|
|
101
|
+
session_box = QGroupBox("Session (must contain features/emotions/rmsSignals.csv)")
|
|
102
|
+
sg = QVBoxLayout(session_box)
|
|
103
|
+
sg.addLayout(sess_row)
|
|
104
|
+
|
|
105
|
+
channel_box = QGroupBox("Channel to fix (canonical name)")
|
|
106
|
+
cg = QVBoxLayout(channel_box)
|
|
107
|
+
cg.addLayout(ch_row)
|
|
108
|
+
|
|
109
|
+
self._run = QPushButton("Run channel fixer")
|
|
110
|
+
self._run.clicked.connect(self._run_clicked)
|
|
111
|
+
|
|
112
|
+
self._progress = QProgressBar()
|
|
113
|
+
self._progress.setRange(0, 0)
|
|
114
|
+
self._progress.setVisible(False)
|
|
115
|
+
|
|
116
|
+
actions = QHBoxLayout()
|
|
117
|
+
actions.addWidget(self._run)
|
|
118
|
+
actions.addWidget(self._progress, 1)
|
|
119
|
+
|
|
120
|
+
self._log = QPlainTextEdit()
|
|
121
|
+
self._log.setReadOnly(True)
|
|
122
|
+
|
|
123
|
+
log_box = QGroupBox("Log")
|
|
124
|
+
lg = QVBoxLayout(log_box)
|
|
125
|
+
lg.addWidget(self._log)
|
|
126
|
+
|
|
127
|
+
layout = QVBoxLayout(self)
|
|
128
|
+
layout.addLayout(top)
|
|
129
|
+
layout.addWidget(session_box)
|
|
130
|
+
layout.addWidget(channel_box)
|
|
131
|
+
layout.addLayout(actions)
|
|
132
|
+
layout.addWidget(log_box, 1)
|
|
133
|
+
|
|
134
|
+
self._poll_timer = QTimer(self)
|
|
135
|
+
self._poll_timer.timeout.connect(self._drain_log_queue)
|
|
136
|
+
self._poll_timer.start(120)
|
|
137
|
+
|
|
138
|
+
def _browse_session(self) -> None:
|
|
139
|
+
path = QFileDialog.getExistingDirectory(self, "Select session folder")
|
|
140
|
+
if path:
|
|
141
|
+
self._session.setText(path)
|
|
142
|
+
|
|
143
|
+
def _set_processing(self, running: bool) -> None:
|
|
144
|
+
self._back.setEnabled(not running)
|
|
145
|
+
self._run.setEnabled(not running)
|
|
146
|
+
self._session.setEnabled(not running)
|
|
147
|
+
self._channel.setEnabled(not running)
|
|
148
|
+
self._progress.setVisible(running)
|
|
149
|
+
self.processing_changed.emit(running)
|
|
150
|
+
|
|
151
|
+
def _run_clicked(self) -> None:
|
|
152
|
+
folder = self._session.text().strip()
|
|
153
|
+
err = channel_fixer_preflight(folder)
|
|
154
|
+
if err:
|
|
155
|
+
QMessageBox.warning(self, "Session", err)
|
|
156
|
+
return
|
|
157
|
+
if self._thread is not None and self._thread.isRunning():
|
|
158
|
+
QMessageBox.information(self, "Busy", "A run is already in progress.")
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
channel_idx = self._channel.currentIndex()
|
|
162
|
+
self._set_processing(True)
|
|
163
|
+
self._insert_log(f"\n--- Starting channel fixer: {folder!r} channel index {channel_idx} ---\n")
|
|
164
|
+
|
|
165
|
+
thread = QThread()
|
|
166
|
+
worker = ChannelFixerWorker(folder, channel_idx, self._log_queue)
|
|
167
|
+
worker.moveToThread(thread)
|
|
168
|
+
thread.started.connect(worker.run_fix)
|
|
169
|
+
worker.finished_ok.connect(self._on_finished_ok)
|
|
170
|
+
worker.finished_err.connect(self._on_finished_err)
|
|
171
|
+
worker.finished_ok.connect(thread.quit)
|
|
172
|
+
worker.finished_err.connect(thread.quit)
|
|
173
|
+
thread.finished.connect(worker.deleteLater)
|
|
174
|
+
thread.finished.connect(thread.deleteLater)
|
|
175
|
+
thread.finished.connect(self._clear_thread_ref)
|
|
176
|
+
|
|
177
|
+
self._thread = thread
|
|
178
|
+
self._worker = worker
|
|
179
|
+
thread.start()
|
|
180
|
+
|
|
181
|
+
def _clear_thread_ref(self) -> None:
|
|
182
|
+
self._thread = None
|
|
183
|
+
self._worker = None
|
|
184
|
+
|
|
185
|
+
def _on_finished_ok(self) -> None:
|
|
186
|
+
self._set_processing(False)
|
|
187
|
+
QMessageBox.information(self, "Channel fixer", "Run finished.")
|
|
188
|
+
|
|
189
|
+
def _on_finished_err(self, msg: str) -> None:
|
|
190
|
+
self._set_processing(False)
|
|
191
|
+
QMessageBox.critical(self, "Channel fixer", msg)
|
|
192
|
+
|
|
193
|
+
def _insert_log(self, text: str) -> None:
|
|
194
|
+
self._log.moveCursor(QTextCursor.End)
|
|
195
|
+
self._log.insertPlainText(text)
|
|
196
|
+
sb = self._log.verticalScrollBar()
|
|
197
|
+
sb.setValue(sb.maximum())
|
|
198
|
+
|
|
199
|
+
def _drain_log_queue(self) -> None:
|
|
200
|
+
try:
|
|
201
|
+
while True:
|
|
202
|
+
chunk = self._log_queue.get_nowait()
|
|
203
|
+
self._insert_log(chunk)
|
|
204
|
+
except queue.Empty:
|
|
205
|
+
pass
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Per-channel RMS gain and zero adjustment with blue vs red distribution plots."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pyqtgraph as pg
|
|
8
|
+
from PySide6.QtCore import Qt, Signal
|
|
9
|
+
from PySide6.QtWidgets import (
|
|
10
|
+
QAbstractItemView,
|
|
11
|
+
QComboBox,
|
|
12
|
+
QDoubleSpinBox,
|
|
13
|
+
QFileDialog,
|
|
14
|
+
QFormLayout,
|
|
15
|
+
QGroupBox,
|
|
16
|
+
QHBoxLayout,
|
|
17
|
+
QHeaderView,
|
|
18
|
+
QLabel,
|
|
19
|
+
QMessageBox,
|
|
20
|
+
QPushButton,
|
|
21
|
+
QScrollArea,
|
|
22
|
+
QSizePolicy,
|
|
23
|
+
QSplitter,
|
|
24
|
+
QTableWidget,
|
|
25
|
+
QTableWidgetItem,
|
|
26
|
+
QVBoxLayout,
|
|
27
|
+
QWidget,
|
|
28
|
+
)
|
|
29
|
+
from reak_core.constants import CHANNEL_NAMES
|
|
30
|
+
from reak_core.tools.rms_columns import as_output_rms_frame, normalize_rms_dataframe
|
|
31
|
+
from reak_core.tools.rms_ops import (
|
|
32
|
+
append_operation,
|
|
33
|
+
ensure_baseline_snapshot,
|
|
34
|
+
original_rms_csv,
|
|
35
|
+
working_rms_csv,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
from nucleuskit_toolkit.ui.offline_job import rms_features_preflight
|
|
39
|
+
from nucleuskit_toolkit.ui.plot_style import plot_colors, set_label, set_title, style_plot, style_view, translucent
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _quartile_stats(values: np.ndarray) -> tuple[float, float, float, float, float]:
|
|
43
|
+
a = np.asarray(values, dtype=float)
|
|
44
|
+
a = a[np.isfinite(a)]
|
|
45
|
+
if a.size == 0:
|
|
46
|
+
return (float("nan"),) * 5
|
|
47
|
+
qs = np.quantile(a, (0.0, 0.25, 0.5, 0.75, 1.0))
|
|
48
|
+
return tuple(float(x) for x in qs)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _apply_gain_zero(df: pd.DataFrame, gains: list[float], zeros: list[float]) -> pd.DataFrame:
|
|
52
|
+
out = df.copy()
|
|
53
|
+
for i, name in enumerate(CHANNEL_NAMES):
|
|
54
|
+
out[name] = out[name].astype(float) * gains[i] + zeros[i]
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ChannelGainPage(QWidget):
|
|
59
|
+
go_tools_menu = Signal()
|
|
60
|
+
|
|
61
|
+
_MAX_SCATTER = 1800
|
|
62
|
+
_X_LEFT = -0.35
|
|
63
|
+
_X_RIGHT = 0.75
|
|
64
|
+
|
|
65
|
+
def __init__(self, parent: QWidget | None = None) -> None:
|
|
66
|
+
super().__init__(parent)
|
|
67
|
+
|
|
68
|
+
self._back = QPushButton("Back")
|
|
69
|
+
self._back.setProperty("secondary", True)
|
|
70
|
+
self._back.clicked.connect(self.go_tools_menu.emit)
|
|
71
|
+
|
|
72
|
+
top = QHBoxLayout()
|
|
73
|
+
top.addStretch(1)
|
|
74
|
+
top.addWidget(self._back)
|
|
75
|
+
|
|
76
|
+
self._folder: str | None = None
|
|
77
|
+
self._work_df: pd.DataFrame | None = None
|
|
78
|
+
self._blue_df: pd.DataFrame | None = None
|
|
79
|
+
|
|
80
|
+
self._gains = [1.0] * 8
|
|
81
|
+
self._zeros = [0.0] * 8
|
|
82
|
+
self._spin_gains: list[QDoubleSpinBox] = []
|
|
83
|
+
self._spin_zeros: list[QDoubleSpinBox] = []
|
|
84
|
+
|
|
85
|
+
self._browse = QPushButton("Select session folder…")
|
|
86
|
+
self._browse.clicked.connect(self._browse_session)
|
|
87
|
+
|
|
88
|
+
self._reload = QPushButton("Reload RMS")
|
|
89
|
+
self._reload.clicked.connect(self._reload_data)
|
|
90
|
+
|
|
91
|
+
self._apply = QPushButton("Apply to rmsSignals.csv")
|
|
92
|
+
self._apply.clicked.connect(self._apply_clicked)
|
|
93
|
+
|
|
94
|
+
self._hint = QLabel(
|
|
95
|
+
"Blue = frozen baseline (original/rmsSignals.csv if present, else current file on load). "
|
|
96
|
+
"Red = preview after gain/zero. Zoom and pan on the plot affect the vertical axis only."
|
|
97
|
+
)
|
|
98
|
+
self._hint.setWordWrap(True)
|
|
99
|
+
|
|
100
|
+
self._channel_combo = QComboBox()
|
|
101
|
+
for name in CHANNEL_NAMES:
|
|
102
|
+
self._channel_combo.addItem(name)
|
|
103
|
+
self._channel_combo.currentIndexChanged.connect(lambda _i: self._refresh_preview())
|
|
104
|
+
|
|
105
|
+
ctrl_col = QVBoxLayout()
|
|
106
|
+
ctrl_col.addWidget(self._browse)
|
|
107
|
+
ctrl_col.addWidget(self._reload)
|
|
108
|
+
ctrl_col.addWidget(self._apply)
|
|
109
|
+
ctrl_col.addWidget(self._hint)
|
|
110
|
+
ctrl_col.addWidget(QLabel("Plot channel:"))
|
|
111
|
+
ctrl_col.addWidget(self._channel_combo)
|
|
112
|
+
|
|
113
|
+
form_scroll = QScrollArea()
|
|
114
|
+
form_scroll.setWidgetResizable(True)
|
|
115
|
+
form_inner = QWidget()
|
|
116
|
+
form_layout = QVBoxLayout(form_inner)
|
|
117
|
+
for i, name in enumerate(CHANNEL_NAMES):
|
|
118
|
+
box = QGroupBox(name)
|
|
119
|
+
fl = QFormLayout(box)
|
|
120
|
+
sg = QDoubleSpinBox()
|
|
121
|
+
sg.setRange(0.001, 1000.0)
|
|
122
|
+
sg.setDecimals(4)
|
|
123
|
+
sg.setValue(1.0)
|
|
124
|
+
sg.setSingleStep(0.05)
|
|
125
|
+
sz = QDoubleSpinBox()
|
|
126
|
+
sz.setRange(-1e6, 1e6)
|
|
127
|
+
sz.setDecimals(4)
|
|
128
|
+
sz.setValue(0.0)
|
|
129
|
+
|
|
130
|
+
def _mk_gain_handler(ii: int):
|
|
131
|
+
def _h(v: float) -> None:
|
|
132
|
+
self._gains[ii] = v
|
|
133
|
+
self._refresh_preview()
|
|
134
|
+
|
|
135
|
+
return _h
|
|
136
|
+
|
|
137
|
+
def _mk_zero_handler(ii: int):
|
|
138
|
+
def _h(v: float) -> None:
|
|
139
|
+
self._zeros[ii] = v
|
|
140
|
+
self._refresh_preview()
|
|
141
|
+
|
|
142
|
+
return _h
|
|
143
|
+
|
|
144
|
+
sg.valueChanged.connect(_mk_gain_handler(i))
|
|
145
|
+
sz.valueChanged.connect(_mk_zero_handler(i))
|
|
146
|
+
fl.addRow("Gain", sg)
|
|
147
|
+
fl.addRow("Zero", sz)
|
|
148
|
+
self._spin_gains.append(sg)
|
|
149
|
+
self._spin_zeros.append(sz)
|
|
150
|
+
form_layout.addWidget(box)
|
|
151
|
+
form_layout.addStretch(1)
|
|
152
|
+
form_scroll.setWidget(form_inner)
|
|
153
|
+
|
|
154
|
+
self._table = QTableWidget()
|
|
155
|
+
self._table.setColumnCount(11)
|
|
156
|
+
self._table.setHorizontalHeaderLabels(
|
|
157
|
+
[
|
|
158
|
+
"Channel",
|
|
159
|
+
"min B",
|
|
160
|
+
"min R",
|
|
161
|
+
"Q1 B",
|
|
162
|
+
"Q1 R",
|
|
163
|
+
"Med B",
|
|
164
|
+
"Med R",
|
|
165
|
+
"Q3 B",
|
|
166
|
+
"Q3 R",
|
|
167
|
+
"max B",
|
|
168
|
+
"max R",
|
|
169
|
+
]
|
|
170
|
+
)
|
|
171
|
+
self._table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
172
|
+
self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
173
|
+
self._table.setRowCount(8)
|
|
174
|
+
|
|
175
|
+
self._plot = pg.PlotWidget()
|
|
176
|
+
set_label(self._plot.getPlotItem(), "left", "RMS")
|
|
177
|
+
self.refresh_theme()
|
|
178
|
+
self._plot.setMinimumHeight(280)
|
|
179
|
+
self._plot.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|
180
|
+
vb = self._plot.getViewBox()
|
|
181
|
+
vb.setMouseEnabled(x=False, y=True)
|
|
182
|
+
vb.sigRangeChanged.connect(self._on_plot_range_changed)
|
|
183
|
+
|
|
184
|
+
right_split = QSplitter(Qt.Orientation.Vertical)
|
|
185
|
+
right_split.addWidget(self._table)
|
|
186
|
+
right_split.addWidget(self._plot)
|
|
187
|
+
right_split.setStretchFactor(0, 0)
|
|
188
|
+
right_split.setStretchFactor(1, 1)
|
|
189
|
+
|
|
190
|
+
main_split = QSplitter(Qt.Orientation.Horizontal)
|
|
191
|
+
left_w = QWidget()
|
|
192
|
+
left_l = QVBoxLayout(left_w)
|
|
193
|
+
left_l.addLayout(ctrl_col)
|
|
194
|
+
left_l.addWidget(form_scroll, 1)
|
|
195
|
+
main_split.addWidget(left_w)
|
|
196
|
+
main_split.addWidget(right_split)
|
|
197
|
+
main_split.setStretchFactor(0, 0)
|
|
198
|
+
main_split.setStretchFactor(1, 1)
|
|
199
|
+
|
|
200
|
+
layout = QVBoxLayout(self)
|
|
201
|
+
layout.addLayout(top)
|
|
202
|
+
layout.addWidget(main_split, 1)
|
|
203
|
+
|
|
204
|
+
def refresh_theme(self) -> None:
|
|
205
|
+
"""Re-apply the plot colours of the current theme (redraws the distributions)."""
|
|
206
|
+
style_view(self._plot)
|
|
207
|
+
style_plot(self._plot.getPlotItem(), grid=False)
|
|
208
|
+
self._plot.showGrid(x=False, y=True, alpha=plot_colors().grid_alpha)
|
|
209
|
+
if getattr(self, "_blue_df", None) is not None:
|
|
210
|
+
self._refresh_preview()
|
|
211
|
+
|
|
212
|
+
def _on_plot_range_changed(self, *_args) -> None:
|
|
213
|
+
"""Keep horizontal range fixed; only Y may change (zoom/pan)."""
|
|
214
|
+
vb = self._plot.getViewBox()
|
|
215
|
+
x0, x1 = self._X_LEFT, self._X_RIGHT
|
|
216
|
+
try:
|
|
217
|
+
xr = vb.viewRange()[0]
|
|
218
|
+
except Exception:
|
|
219
|
+
return
|
|
220
|
+
if abs(xr[0] - x0) < 1e-6 and abs(xr[1] - x1) < 1e-6:
|
|
221
|
+
return
|
|
222
|
+
vb.blockSignals(True)
|
|
223
|
+
try:
|
|
224
|
+
vb.setXRange(x0, x1, padding=0)
|
|
225
|
+
finally:
|
|
226
|
+
vb.blockSignals(False)
|
|
227
|
+
|
|
228
|
+
def _browse_session(self) -> None:
|
|
229
|
+
path = QFileDialog.getExistingDirectory(self, "Select session folder")
|
|
230
|
+
if path:
|
|
231
|
+
self._folder = path
|
|
232
|
+
self._reload_data()
|
|
233
|
+
|
|
234
|
+
def _reload_data(self) -> None:
|
|
235
|
+
if not self._folder:
|
|
236
|
+
QMessageBox.warning(self, "Session", "Select a session folder first.")
|
|
237
|
+
return
|
|
238
|
+
err = rms_features_preflight(self._folder)
|
|
239
|
+
if err:
|
|
240
|
+
QMessageBox.warning(self, "Session", err)
|
|
241
|
+
return
|
|
242
|
+
work_path = working_rms_csv(self._folder)
|
|
243
|
+
self._work_df = normalize_rms_dataframe(pd.read_csv(str(work_path)))
|
|
244
|
+
orig_path = original_rms_csv(self._folder)
|
|
245
|
+
if orig_path.is_file():
|
|
246
|
+
self._blue_df = normalize_rms_dataframe(pd.read_csv(str(orig_path)))
|
|
247
|
+
else:
|
|
248
|
+
self._blue_df = self._work_df.copy()
|
|
249
|
+
for i in range(8):
|
|
250
|
+
self._spin_gains[i].blockSignals(True)
|
|
251
|
+
self._spin_zeros[i].blockSignals(True)
|
|
252
|
+
self._spin_gains[i].setValue(1.0)
|
|
253
|
+
self._spin_zeros[i].setValue(0.0)
|
|
254
|
+
self._spin_gains[i].blockSignals(False)
|
|
255
|
+
self._spin_zeros[i].blockSignals(False)
|
|
256
|
+
self._gains[i] = 1.0
|
|
257
|
+
self._zeros[i] = 0.0
|
|
258
|
+
self._refresh_preview()
|
|
259
|
+
|
|
260
|
+
def _preview_df(self) -> pd.DataFrame | None:
|
|
261
|
+
if self._work_df is None:
|
|
262
|
+
return None
|
|
263
|
+
return _apply_gain_zero(self._work_df, self._gains, self._zeros)
|
|
264
|
+
|
|
265
|
+
def _refresh_preview(self) -> None:
|
|
266
|
+
if self._work_df is None or self._blue_df is None:
|
|
267
|
+
self._table.setRowCount(0)
|
|
268
|
+
self._plot.clear()
|
|
269
|
+
return
|
|
270
|
+
red_df = self._preview_df()
|
|
271
|
+
if red_df is None:
|
|
272
|
+
return
|
|
273
|
+
|
|
274
|
+
self._table.setRowCount(8)
|
|
275
|
+
for i, ch_name in enumerate(CHANNEL_NAMES):
|
|
276
|
+
bcol = self._blue_df[ch_name].to_numpy(dtype=float)
|
|
277
|
+
rcol = red_df[ch_name].to_numpy(dtype=float)
|
|
278
|
+
bstats = _quartile_stats(bcol)
|
|
279
|
+
rstats = _quartile_stats(rcol)
|
|
280
|
+
self._table.setItem(i, 0, QTableWidgetItem(ch_name))
|
|
281
|
+
for j in range(5):
|
|
282
|
+
self._table.setItem(i, 1 + 2 * j, QTableWidgetItem(f"{bstats[j]:.5g}"))
|
|
283
|
+
self._table.setItem(i, 2 + 2 * j, QTableWidgetItem(f"{rstats[j]:.5g}"))
|
|
284
|
+
|
|
285
|
+
idx = self._channel_combo.currentIndex()
|
|
286
|
+
if idx < 0:
|
|
287
|
+
idx = 0
|
|
288
|
+
name = CHANNEL_NAMES[idx]
|
|
289
|
+
|
|
290
|
+
bcol = self._blue_df[name].to_numpy(dtype=float)
|
|
291
|
+
rcol = red_df[name].to_numpy(dtype=float)
|
|
292
|
+
|
|
293
|
+
self._plot.clear()
|
|
294
|
+
set_title(self._plot.getPlotItem(), name)
|
|
295
|
+
n = len(bcol)
|
|
296
|
+
k = min(n, self._MAX_SCATTER)
|
|
297
|
+
if k < 1:
|
|
298
|
+
self._plot.setXRange(self._X_LEFT, self._X_RIGHT, padding=0.02)
|
|
299
|
+
return
|
|
300
|
+
|
|
301
|
+
rng = np.random.default_rng(42)
|
|
302
|
+
idx_s = rng.choice(n, size=k, replace=False)
|
|
303
|
+
vb = bcol[idx_s]
|
|
304
|
+
vr = rcol[idx_s]
|
|
305
|
+
xb = rng.uniform(-0.2, 0.2, k)
|
|
306
|
+
xr = 0.45 + rng.uniform(-0.2, 0.2, k)
|
|
307
|
+
sp_b = pg.ScatterPlotItem(
|
|
308
|
+
x=xb,
|
|
309
|
+
y=vb,
|
|
310
|
+
pen=None,
|
|
311
|
+
brush=pg.mkBrush(translucent(plot_colors().pair[0], 110)),
|
|
312
|
+
size=5,
|
|
313
|
+
)
|
|
314
|
+
sp_r = pg.ScatterPlotItem(
|
|
315
|
+
x=xr,
|
|
316
|
+
y=vr,
|
|
317
|
+
pen=None,
|
|
318
|
+
brush=pg.mkBrush(translucent(plot_colors().pair[1], 110)),
|
|
319
|
+
size=5,
|
|
320
|
+
)
|
|
321
|
+
self._plot.addItem(sp_b)
|
|
322
|
+
self._plot.addItem(sp_r)
|
|
323
|
+
vb_plot = self._plot.getViewBox()
|
|
324
|
+
vb_plot.blockSignals(True)
|
|
325
|
+
try:
|
|
326
|
+
vb_plot.setXRange(self._X_LEFT, self._X_RIGHT, padding=0.02)
|
|
327
|
+
y_all = np.concatenate([vb, vr])
|
|
328
|
+
y_all = y_all[np.isfinite(y_all)]
|
|
329
|
+
if y_all.size > 0:
|
|
330
|
+
y_min = float(np.min(y_all))
|
|
331
|
+
y_max = float(np.max(y_all))
|
|
332
|
+
pad = max((y_max - y_min) * 0.05, 1e-9)
|
|
333
|
+
vb_plot.setYRange(y_min - pad, y_max + pad, padding=0.02)
|
|
334
|
+
finally:
|
|
335
|
+
vb_plot.blockSignals(False)
|
|
336
|
+
|
|
337
|
+
def _apply_clicked(self) -> None:
|
|
338
|
+
if not self._folder or self._work_df is None:
|
|
339
|
+
QMessageBox.warning(self, "Session", "Load a session first.")
|
|
340
|
+
return
|
|
341
|
+
err = rms_features_preflight(self._folder)
|
|
342
|
+
if err:
|
|
343
|
+
QMessageBox.warning(self, "Session", err)
|
|
344
|
+
return
|
|
345
|
+
out = _apply_gain_zero(self._work_df, self._gains, self._zeros)
|
|
346
|
+
work_path = working_rms_csv(self._folder)
|
|
347
|
+
try:
|
|
348
|
+
ensure_baseline_snapshot(self._folder)
|
|
349
|
+
as_output_rms_frame(out).to_csv(work_path, index=False)
|
|
350
|
+
parts = [
|
|
351
|
+
f"{CHANNEL_NAMES[i]}:g={self._gains[i]:.4g},z={self._zeros[i]:.4g}"
|
|
352
|
+
for i in range(8)
|
|
353
|
+
]
|
|
354
|
+
msg = "gain_adjust " + ";".join(parts)
|
|
355
|
+
if len(msg) > 500:
|
|
356
|
+
msg = msg[:497] + "..."
|
|
357
|
+
append_operation(self._folder, msg)
|
|
358
|
+
except OSError as e:
|
|
359
|
+
QMessageBox.critical(self, "Apply", str(e))
|
|
360
|
+
return
|
|
361
|
+
self._reload_data()
|
|
362
|
+
QMessageBox.information(self, "Gain", "Applied to rmsSignals.csv.")
|