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,135 @@
1
+ """Real-time Shimmer wristband plot (PySide6 + pyqtgraph): IMU stack or single EDA/PPG."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ os.environ.setdefault("PYQTGRAPH_QT_LIB", "PySide6")
8
+
9
+ import numpy as np
10
+ import pyqtgraph as pg
11
+ from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
12
+
13
+ from nucleuskit_toolkit.shimmer.constants import ShimmerConstants
14
+ from nucleuskit_toolkit.ui.plot_style import (
15
+ add_legend,
16
+ lock_view,
17
+ pen,
18
+ plot_colors,
19
+ set_label,
20
+ set_title,
21
+ style_plot,
22
+ style_view,
23
+ )
24
+
25
+ # Combo box display string (user-requested exact label)
26
+ SHIMMER_COMBO_LABEL_IMU = "IMU (x,y,z)"
27
+
28
+
29
+ class RealtimeShimmerPlot(QWidget):
30
+ """
31
+ Scrolls Shimmer streams: combo selects IMU (x,y,z with vertical offset), EDA, or PPG.
32
+ Y-axis autoscales to visible data.
33
+ """
34
+
35
+ def __init__(self, parent: QWidget | None = None, window_seconds: float = 5.0) -> None:
36
+ super().__init__(parent)
37
+ self._fs = float(ShimmerConstants.SAMPLING_RATE)
38
+ self._buf_len = max(32, int(self._fs * window_seconds))
39
+ self._imu_offsets = np.array([0.0, 100.0, 200.0], dtype=np.float64)
40
+
41
+ self._combo = QComboBox()
42
+ self._combo.addItems([SHIMMER_COMBO_LABEL_IMU, "EDA", "PPG"])
43
+ self._combo.currentIndexChanged.connect(self._on_mode_changed)
44
+
45
+ top = QHBoxLayout()
46
+ top.addWidget(QLabel("Display:"))
47
+ top.addWidget(self._combo, stretch=1)
48
+
49
+ self._win = pg.GraphicsLayoutWidget(title="Real-time Shimmer")
50
+ self._plot = self._win.addPlot()
51
+ lock_view(self._plot)
52
+ set_label(self._plot, "bottom", "Time", units="s")
53
+
54
+ self._legend = add_legend(self._plot)
55
+ self._curves_imu = [self._plot.plot(name=axis, antialias=True) for axis in "XYZ"]
56
+ self._curve_scalar = self._plot.plot(antialias=True)
57
+
58
+ self._buf_imu = [np.zeros(self._buf_len) for _ in range(3)]
59
+ self._buf_scalar = np.zeros(self._buf_len)
60
+ # Seconds before now of each buffer sample (the newest sample is at 0).
61
+ self._time = (np.arange(self._buf_len) - (self._buf_len - 1)) / self._fs
62
+ self.refresh_theme()
63
+
64
+ self._plot.enableAutoRange(axis="y", enable=True)
65
+
66
+ layout = QVBoxLayout(self)
67
+ layout.setContentsMargins(0, 0, 0, 0)
68
+ layout.addLayout(top)
69
+ layout.addWidget(self._win, stretch=1)
70
+
71
+ self._on_mode_changed(self._combo.currentIndex())
72
+
73
+ def refresh_theme(self) -> None:
74
+ """Re-apply the plot colours of the current theme."""
75
+ c = plot_colors()
76
+ style_view(self._win)
77
+ style_plot(self._plot)
78
+ for curve, color in zip(self._curves_imu, c.xyz):
79
+ curve.setPen(pen(color, 2))
80
+ self._curve_scalar.setPen(pen(c.accent, 2))
81
+
82
+ def _on_mode_changed(self, index: int) -> None:
83
+ if index == 0:
84
+ set_title(self._plot, SHIMMER_COMBO_LABEL_IMU)
85
+ set_label(self._plot, "left", "IMU (offset stacked)")
86
+ for c in self._curves_imu:
87
+ c.setVisible(True)
88
+ self._curve_scalar.setVisible(False)
89
+ elif index == 1:
90
+ set_title(self._plot, "EDA")
91
+ set_label(self._plot, "left", "EDA")
92
+ for c in self._curves_imu:
93
+ c.setVisible(False)
94
+ self._curve_scalar.setVisible(True)
95
+ else:
96
+ set_title(self._plot, "PPG")
97
+ set_label(self._plot, "left", "PPG")
98
+ for c in self._curves_imu:
99
+ c.setVisible(False)
100
+ self._curve_scalar.setVisible(True)
101
+ self._legend.setVisible(index == 0)
102
+ self._autorange_y()
103
+
104
+ def _autorange_y(self) -> None:
105
+ visible = [c for c in self._plot.listDataItems() if c.isVisible()]
106
+ if visible:
107
+ self._plot.enableAutoRange(axis="y", enable=True)
108
+ self._plot.autoRange(items=visible)
109
+
110
+ def clear_buffers(self) -> None:
111
+ for j in range(3):
112
+ self._buf_imu[j].fill(0.0)
113
+ self._curves_imu[j].setData(self._time, self._buf_imu[j] + self._imu_offsets[j])
114
+ self._buf_scalar.fill(0.0)
115
+ self._curve_scalar.setData(self._time, self._buf_scalar)
116
+ self._autorange_y()
117
+
118
+ def add_sample(self, imu_x: float, imu_y: float, imu_z: float, eda: float, ppg: float) -> None:
119
+ """Append one sample row (decoded numeric values)."""
120
+ mode = self._combo.currentIndex()
121
+
122
+ if mode == 0:
123
+ vals = (float(imu_x), float(imu_y), float(imu_z))
124
+ for j in range(3):
125
+ buf = self._buf_imu[j]
126
+ buf[:] = np.roll(buf, -1)
127
+ buf[-1] = vals[j]
128
+ self._curves_imu[j].setData(self._time, buf + self._imu_offsets[j])
129
+ else:
130
+ buf = self._buf_scalar
131
+ buf[:] = np.roll(buf, -1)
132
+ buf[-1] = float(eda if mode == 1 else ppg)
133
+ self._curve_scalar.setData(self._time, buf)
134
+
135
+ self._autorange_y()
@@ -0,0 +1 @@
1
+ """Static UI resources (images, etc.)."""
@@ -0,0 +1 @@
1
+ """Branding assets shipped with the package."""
@@ -0,0 +1,226 @@
1
+ """Read-only inspection of a session or dataset folder for the Process page (no Qt imports).
2
+
3
+ The preflight functions of ``offline_job`` prepare the session folder (``ensure_rawdata_layout`` moves loose
4
+ recording files into ``rawData/``), so they only run when processing starts. The inspection here only reads: it looks
5
+ for the raw files in ``rawData/`` or, before the first run, at the top of the session folder, and reads the first and
6
+ last rows of the recordings to estimate the duration without loading them.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+
14
+ import numpy as np
15
+ from reak_core.constants import EDA_FILES, EXG_FILENAMES, PPG_FILES
16
+ from reak_core.io.session import RESERVED_DIRS
17
+ from reak_core.io.timestamps import normalise_timestamps_to_seconds
18
+
19
+ VIDEO_FILENAME = "video.mp4"
20
+ _HEAD_ROWS = 64
21
+ _TAIL_BYTES = 8192
22
+ _EDA_NAMES = tuple(name for name, _ in EDA_FILES)
23
+ _PPG_NAMES = tuple(name for name, _ in PPG_FILES)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class SessionInfo:
28
+ path: str
29
+ name: str
30
+ duration_s: float | None
31
+ eeg: bool
32
+ eda: bool
33
+ ppg: bool
34
+ video: bool
35
+
36
+ @property
37
+ def missing(self) -> list[str]:
38
+ """Parts of a complete Nucleus-Kit recording that are absent."""
39
+ out = []
40
+ if not self.eeg:
41
+ out.append("EEG/fEMG")
42
+ if not (self.eda or self.ppg):
43
+ out.append("EDA/PPG")
44
+ if not self.video:
45
+ out.append("video")
46
+ return out
47
+
48
+ def modalities_text(self) -> str:
49
+ parts = []
50
+ if self.eeg:
51
+ parts.append("EEG/fEMG")
52
+ if self.eda and self.ppg:
53
+ parts.append("EDA/PPG")
54
+ elif self.eda:
55
+ parts.append("EDA")
56
+ elif self.ppg:
57
+ parts.append("PPG")
58
+ text = "/".join(parts) if parts else "no signals"
59
+ return text + (" + video" if self.video else "")
60
+
61
+ def summary(self) -> str:
62
+ """For example ``Session P07, 23 min, EEG/fEMG/EDA/PPG + video``."""
63
+ parts = (f"Session {self.name}", format_duration(self.duration_s), self.modalities_text())
64
+ return ", ".join(p for p in parts if p)
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class DatasetInfo:
69
+ path: str
70
+ sessions: tuple[SessionInfo, ...]
71
+
72
+ @property
73
+ def incomplete(self) -> list[SessionInfo]:
74
+ return [s for s in self.sessions if s.missing]
75
+
76
+ def summary(self) -> str:
77
+ """For example ``14 sessions found, 2 incomplete (missing video)``."""
78
+ n = len(self.sessions)
79
+ text = f"{n} session{'s' if n != 1 else ''} found"
80
+ incomplete = self.incomplete
81
+ if incomplete:
82
+ reasons: list[str] = []
83
+ for session in incomplete:
84
+ for part in session.missing:
85
+ if part not in reasons:
86
+ reasons.append(part)
87
+ text += f", {len(incomplete)} incomplete (missing {', '.join(reasons)})"
88
+ return text
89
+
90
+
91
+ def format_duration(seconds: float | None) -> str:
92
+ if seconds is None or seconds <= 0:
93
+ return ""
94
+ if seconds < 60:
95
+ return f"{seconds:.0f} s"
96
+ minutes = int(round(seconds / 60))
97
+ if minutes < 60:
98
+ return f"{minutes} min"
99
+ return f"{minutes // 60} h {minutes % 60:02d} min"
100
+
101
+
102
+ def _raw_dir(folder: str) -> str:
103
+ raw = os.path.join(folder, "rawData")
104
+ return raw if os.path.isdir(raw) else folder
105
+
106
+
107
+ def _first_file(directory: str, names: tuple[str, ...]) -> str | None:
108
+ for name in names:
109
+ path = os.path.join(directory, name)
110
+ if os.path.isfile(path):
111
+ return path
112
+ return None
113
+
114
+
115
+ def _first_value(line: str) -> float | None:
116
+ try:
117
+ return float(line.split(",", 1)[0].strip().strip("'\""))
118
+ except ValueError:
119
+ return None
120
+
121
+
122
+ def file_duration_s(path: str) -> float | None:
123
+ """Duration of a recording from its first rows and its last row, without loading the whole file."""
124
+ try:
125
+ head: list[float] = []
126
+ with open(path, encoding="utf-8", errors="replace") as fh:
127
+ for line in fh:
128
+ value = _first_value(line)
129
+ if value is not None:
130
+ head.append(value)
131
+ if len(head) >= _HEAD_ROWS:
132
+ break
133
+ if len(head) < 2:
134
+ return None
135
+ with open(path, "rb") as fh:
136
+ fh.seek(0, os.SEEK_END)
137
+ size = fh.tell()
138
+ fh.seek(max(0, size - _TAIL_BYTES))
139
+ lines = fh.read().decode("utf-8", errors="replace").splitlines()
140
+ if size > _TAIL_BYTES:
141
+ lines = lines[1:] # the first line read may be cut
142
+ last = None
143
+ for line in reversed(lines):
144
+ last = _first_value(line)
145
+ if last is not None:
146
+ break
147
+ if last is None:
148
+ return None
149
+ seconds = normalise_timestamps_to_seconds(np.asarray(head + [last], dtype=float))
150
+ duration = float(seconds[-1])
151
+ return duration if np.isfinite(duration) and duration > 0 else None
152
+ except OSError:
153
+ return None
154
+
155
+
156
+ def inspect_session(folder: str, *, with_duration: bool = True) -> SessionInfo | None:
157
+ """What a session folder holds, or ``None`` when it has no Hermes or Shimmer recording. Moves nothing."""
158
+ folder = os.path.abspath(os.path.expanduser(folder))
159
+ if not os.path.isdir(folder):
160
+ return None
161
+ raw = _raw_dir(folder)
162
+ exg = _first_file(raw, EXG_FILENAMES)
163
+ eda = _first_file(raw, _EDA_NAMES)
164
+ ppg = _first_file(raw, _PPG_NAMES)
165
+ if not (exg or eda or ppg):
166
+ return None
167
+ duration = None
168
+ if with_duration:
169
+ durations = [d for d in (file_duration_s(p) for p in dict.fromkeys(p for p in (exg, eda, ppg) if p)) if d]
170
+ duration = max(durations) if durations else None
171
+ return SessionInfo(
172
+ path=folder,
173
+ name=os.path.basename(os.path.normpath(folder)),
174
+ duration_s=duration,
175
+ eeg=exg is not None,
176
+ eda=eda is not None,
177
+ ppg=ppg is not None,
178
+ video=os.path.isfile(os.path.join(raw, VIDEO_FILENAME)),
179
+ )
180
+
181
+
182
+ def inspect_dataset(folder: str) -> DatasetInfo | None:
183
+ """Sessions found in the direct subfolders of ``folder``, or ``None`` when there is none. Moves nothing."""
184
+ folder = os.path.abspath(os.path.expanduser(folder))
185
+ if not os.path.isdir(folder):
186
+ return None
187
+ sessions = []
188
+ try:
189
+ entries = sorted(os.listdir(folder))
190
+ except OSError:
191
+ return None
192
+ for entry in entries:
193
+ candidate = os.path.join(folder, entry)
194
+ if entry in RESERVED_DIRS or not os.path.isdir(candidate):
195
+ continue
196
+ info = inspect_session(candidate, with_duration=False)
197
+ if info is not None:
198
+ sessions.append(info)
199
+ return DatasetInfo(folder, tuple(sessions)) if sessions else None
200
+
201
+
202
+ def inspect_input(text: str, dataset: bool) -> tuple[SessionInfo | DatasetInfo | None, str | None]:
203
+ """``(result, error)`` for the Process page; ``(None, None)`` when no path is entered."""
204
+ text = text.strip()
205
+ if not text:
206
+ return None, None
207
+ path = os.path.abspath(os.path.expanduser(text))
208
+ if not os.path.isdir(path):
209
+ return None, "This folder does not exist."
210
+ if dataset:
211
+ info = inspect_dataset(path)
212
+ if info is not None:
213
+ return info, None
214
+ if inspect_session(path, with_duration=False) is not None:
215
+ return None, (
216
+ "This folder is a single session: choose Single session, or select the folder holding the sessions."
217
+ )
218
+ return None, "No sessions found: expected subfolders containing Hermes or Shimmer recordings."
219
+ session = inspect_session(path)
220
+ if session is not None:
221
+ return session, None
222
+ if inspect_dataset(path) is not None:
223
+ return None, "This folder holds several sessions: choose Dataset, or select one session folder."
224
+ return None, (
225
+ "No recording found: expected Hermes (EEG/fEMG) or Shimmer (EDA/PPG) files in the folder or its rawData/."
226
+ )