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,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.")