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,5 @@
1
+ """Shimmer3 real-time hardware interface (Bluetooth serial streaming)."""
2
+
3
+ from nucleuskit_toolkit.shimmer.realtime.proxy import ShimmerSerialProxy
4
+
5
+ __all__ = ["ShimmerSerialProxy"]
@@ -0,0 +1,279 @@
1
+ """
2
+ Shimmer3 LogAndStream client over Bluetooth Classic.
3
+
4
+ Supports two connection modes:
5
+ - COM port (e.g. "COM7") — uses pyserial, classic approach.
6
+ - MAC address (e.g. "00:06:66:B7:00:00") — opens a direct Bluetooth RFCOMM
7
+ socket, bypassing COM-port selection entirely. This is more reliable on
8
+ Windows where two "Standard Serial over Bluetooth link" ports are created
9
+ per device (incoming / outgoing) and only one can be opened by applications.
10
+
11
+ Configures streaming at ~51.2 Hz and invokes a callback with each decoded row:
12
+ [epoch_ms, imu_x, imu_y, imu_z, eda_kohm, ppg_mv] (host epoch milliseconds).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import re
19
+ import struct
20
+ import sys
21
+ import threading
22
+ from collections.abc import Callable
23
+ from time import sleep, time
24
+ from typing import Any
25
+
26
+ from nucleuskit_toolkit.shimmer.constants import ShimmerConstants
27
+
28
+ ShimmerSampleCallback = Callable[[list[float]], None]
29
+
30
+ ACK_BYTE = b"\xff"
31
+ FRAME_SIZE = 14 # 1 type + 3 ts + 5x uint16 payload (accel xyz, PPG, GSR packed)
32
+
33
+ _MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}[:\-]){5}[0-9A-Fa-f]{2}$")
34
+
35
+
36
+ class _BtSocketAdapter:
37
+ """Wraps a Bluetooth RFCOMM socket with a serial-like read/write interface.
38
+
39
+ ``is_open`` starts True and is set to False on ``close()``. ``read(n)``
40
+ accumulates bytes until *n* bytes arrive or the per-call socket timeout
41
+ elapses, matching the behaviour of ``serial.Serial.read(n, timeout=…)``.
42
+ """
43
+
44
+ def __init__(self, sock: Any) -> None:
45
+ self._sock = sock
46
+ self.is_open = True
47
+
48
+ def reset_input_buffer(self) -> None:
49
+ """Discard any data already in the receive buffer."""
50
+ self._sock.settimeout(0.05)
51
+ try:
52
+ while True:
53
+ data = self._sock.recv(4096)
54
+ if not data:
55
+ break
56
+ except OSError:
57
+ pass
58
+ self._sock.settimeout(0.2)
59
+
60
+ def read(self, n: int) -> bytes:
61
+ """Return up to *n* bytes; returns fewer (or b'') on timeout / error."""
62
+ result = b""
63
+ while len(result) < n:
64
+ try:
65
+ chunk = self._sock.recv(n - len(result))
66
+ if not chunk:
67
+ break
68
+ result += chunk
69
+ except OSError:
70
+ break
71
+ return result
72
+
73
+ def write(self, data: bytes) -> None:
74
+ self._sock.sendall(data)
75
+
76
+ def close(self) -> None:
77
+ self.is_open = False
78
+ try:
79
+ self._sock.close()
80
+ except OSError:
81
+ pass
82
+
83
+
84
+ class ShimmerSerialProxy:
85
+ """Background thread: connect to Shimmer3, send setup, stream packets until disconnect.
86
+
87
+ ``port_or_mac`` may be:
88
+
89
+ * A serial port name — ``"COM7"``, ``"/dev/rfcomm0"`` etc.
90
+ * A Bluetooth MAC address — ``"00:06:66:B7:00:00"`` (colons or hyphens).
91
+ A direct RFCOMM socket is opened; no virtual COM port is needed.
92
+
93
+ Pass the result of :meth:`is_mac_address` to determine which mode will be
94
+ used before constructing the proxy.
95
+ """
96
+
97
+ def __init__(self, port_or_mac: str, sample_callback: ShimmerSampleCallback) -> None:
98
+ self._target = port_or_mac
99
+ self._sample_callback = sample_callback
100
+ self.is_connected = False
101
+ self._conn: Any = None # serial.Serial or _BtSocketAdapter
102
+ self._stop = threading.Event()
103
+ self._connection_error: str | None = None
104
+ self._worker = threading.Thread(target=self._run, name="ShimmerSerial", daemon=True)
105
+ self._worker.start()
106
+
107
+ # ------------------------------------------------------------------
108
+ # Public helpers
109
+ # ------------------------------------------------------------------
110
+
111
+ @staticmethod
112
+ def is_mac_address(s: str) -> bool:
113
+ """Return True if *s* looks like a Bluetooth MAC address."""
114
+ return bool(_MAC_RE.match(s.strip()))
115
+
116
+ def wait_until_connected(self, timeout: float = 90.0) -> None:
117
+ deadline = time() + timeout
118
+ while time() < deadline:
119
+ if self.is_connected:
120
+ return
121
+ if self._connection_error is not None:
122
+ raise RuntimeError(self._connection_error)
123
+ sleep(0.05)
124
+ raise TimeoutError("Shimmer connection timed out")
125
+
126
+ def disconnect(self) -> None:
127
+ self._stop.set()
128
+ conn = self._conn
129
+ if conn is not None and conn.is_open:
130
+ try:
131
+ conn.write(struct.pack("B", 0x20))
132
+ except OSError:
133
+ pass
134
+ try:
135
+ conn.close()
136
+ except OSError:
137
+ pass
138
+ self._worker.join(timeout=5.0)
139
+ self.is_connected = False
140
+ self._conn = None
141
+
142
+ # ------------------------------------------------------------------
143
+ # Internal: connection
144
+ # ------------------------------------------------------------------
145
+
146
+ @staticmethod
147
+ def _open_bt_socket(mac: str) -> _BtSocketAdapter:
148
+ """Open a direct Bluetooth RFCOMM socket to *mac* (channel 1)."""
149
+ import socket as _socket
150
+
151
+ mac = mac.upper().replace("-", ":")
152
+
153
+ if sys.platform == "win32":
154
+ # AF_BTH = 32, BTPROTO_RFCOMM = 3 (Winsock2 constants)
155
+ AF_BTH = getattr(_socket, "AF_BTH", 32)
156
+ BTPROTO_RFCOMM = getattr(_socket, "BTPROTO_RFCOMM", 3)
157
+ sock = _socket.socket(AF_BTH, _socket.SOCK_STREAM, BTPROTO_RFCOMM)
158
+ else:
159
+ sock = _socket.socket(
160
+ _socket.AF_BLUETOOTH, _socket.SOCK_STREAM, _socket.BTPROTO_RFCOMM
161
+ )
162
+
163
+ sock.settimeout(12.0)
164
+ sock.connect((mac, 1)) # RFCOMM channel 1
165
+ sock.settimeout(0.2)
166
+ return _BtSocketAdapter(sock)
167
+
168
+ # ------------------------------------------------------------------
169
+ # Internal: Shimmer protocol
170
+ # ------------------------------------------------------------------
171
+
172
+ def _wait_for_ack(self, conn: Any, timeout_s: float = 8.0) -> None:
173
+ deadline = time() + timeout_s
174
+ while time() < deadline:
175
+ if self._stop.is_set():
176
+ raise InterruptedError("Shimmer disconnect requested")
177
+ b = conn.read(1)
178
+ if b == ACK_BYTE:
179
+ return
180
+ raise TimeoutError("Timed out waiting for Shimmer ACK (0xFF)")
181
+
182
+ def _configure_streaming(self, conn: Any) -> None:
183
+ # SET_SENSORS: accel + GSR + PPG (same bitmask as legacy stack)
184
+ conn.write(struct.pack("BBBB", 0x08, 0x84, 0x01, 0x00))
185
+ self._wait_for_ack(conn)
186
+
187
+ conn.write(struct.pack("BB", 0x5E, 0x01))
188
+ self._wait_for_ack(conn)
189
+
190
+ conn.write(struct.pack("B", 0x20))
191
+ self._wait_for_ack(conn)
192
+
193
+ clock_wait = int(math.ceil((2 << 14) / ShimmerConstants.SAMPLING_RATE))
194
+ conn.write(struct.pack("<BH", 0x05, clock_wait))
195
+ self._wait_for_ack(conn)
196
+
197
+ conn.write(struct.pack("B", 0x01))
198
+ self._wait_for_ack(conn)
199
+
200
+ response_size = 9
201
+ inquiry = conn.read(response_size)
202
+ if len(inquiry) < response_size:
203
+ raise TimeoutError("Shimmer inquiry response too short")
204
+
205
+ num_channels = inquiry[7]
206
+ channels = conn.read(num_channels)
207
+ if len(channels) < num_channels:
208
+ raise TimeoutError("Shimmer channel map too short")
209
+
210
+ conn.write(struct.pack("B", 0x07))
211
+ self._wait_for_ack(conn)
212
+
213
+ def _decode_frame(self, data: bytes) -> list[float] | None:
214
+ if len(data) != FRAME_SIZE:
215
+ return None
216
+ # Native uint16 order matches legacy Shimmer streaming clients on Windows/Linux.
217
+ x, y, z, ppg_raw, gsr_raw = struct.unpack("HHHHH", data[4:FRAME_SIZE])
218
+
219
+ data_range = (gsr_raw >> 14) & 0x03
220
+ rf = (40.2, 287.0, 1000.0, 3300.0)[data_range]
221
+ gsr_to_volts = (gsr_raw & 0x3FFF) * (3.0 / 4095.0)
222
+ den = (gsr_to_volts / 0.5) - 1.0
223
+ if abs(den) < 1e-9:
224
+ gsr_kohm = float("nan")
225
+ else:
226
+ gsr_kohm = rf / den
227
+
228
+ ppg_mv = ppg_raw * (3000.0 / 4095.0)
229
+ epoch_ms = round(time() * 1000.0)
230
+ return [float(epoch_ms), float(x), float(y), float(z), float(gsr_kohm), float(ppg_mv)]
231
+
232
+ # ------------------------------------------------------------------
233
+ # Worker thread
234
+ # ------------------------------------------------------------------
235
+
236
+ def _run(self) -> None:
237
+ try:
238
+ target = self._target.strip()
239
+ if self.is_mac_address(target):
240
+ conn: Any = self._open_bt_socket(target)
241
+ else:
242
+ import serial
243
+ conn = serial.Serial(target, baudrate=115200, timeout=0.2)
244
+
245
+ self._conn = conn
246
+ conn.reset_input_buffer()
247
+ self._configure_streaming(conn)
248
+ self.is_connected = True
249
+
250
+ buf = b""
251
+ while not self._stop.is_set():
252
+ chunk = conn.read(FRAME_SIZE)
253
+ if not chunk:
254
+ continue
255
+ buf += chunk
256
+ while len(buf) >= FRAME_SIZE and not self._stop.is_set():
257
+ frame = buf[:FRAME_SIZE]
258
+ buf = buf[FRAME_SIZE:]
259
+ row = self._decode_frame(frame)
260
+ if row is not None:
261
+ self._sample_callback(row)
262
+
263
+ except Exception as e:
264
+ self._connection_error = str(e)
265
+ finally:
266
+ self.is_connected = False
267
+ conn = self._conn
268
+ if conn is not None:
269
+ if conn.is_open:
270
+ try:
271
+ conn.write(struct.pack("B", 0x20))
272
+ self._wait_for_ack(conn, timeout_s=2.0)
273
+ except Exception:
274
+ pass
275
+ try:
276
+ conn.close()
277
+ except OSError:
278
+ pass
279
+ self._conn = None
@@ -0,0 +1 @@
1
+ """Standalone processing tools (non-pipeline utilities)."""
@@ -0,0 +1,91 @@
1
+ """
2
+ EMG-regression variant of the cognition pipeline (Tools > EEG regression page).
3
+
4
+ Exploratory Nucleus-Kit tool, not part of re-ak-core: it removes the EMG noise shared with the
5
+ reference channels (CHEEK_R, CHEEK_L, BROW_L, NOSE) from the EEG by ridge regression before
6
+ re-ak-core's cognition steps (filtering, artefact rejection, power bands, Engagement).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+ import pandas as pd
13
+ from reak_core.io.exg import CHANNEL_NAMES
14
+ from sklearn.linear_model import Ridge
15
+
16
+ from nucleuskit_toolkit.logging_utils import printInfo, printWarning
17
+
18
+ REFERENCE_CHANNELS = ("CHEEK_R", "CHEEK_L", "BROW_L", "NOSE")
19
+ """EMG reference channels. AFz is left out: it is an EEG channel and would remove brain signal."""
20
+
21
+
22
+ def reference_channels(exg: np.ndarray) -> pd.DataFrame | None:
23
+ """The reference channels of a marked EXG matrix (hardware order, invalid samples already NaN)."""
24
+ if exg is None or exg.ndim != 2 or exg.shape[1] < len(CHANNEL_NAMES):
25
+ printWarning(f"[eegRegressionTool] Expected {len(CHANNEL_NAMES)} EXG channels to extract the references")
26
+ return None
27
+ return pd.DataFrame({name: exg[:, CHANNEL_NAMES.index(name)] for name in REFERENCE_CHANNELS})
28
+
29
+
30
+ def regress_noise_channels(eeg_df, ref_df):
31
+ """
32
+ Remove shared EMG noise from EEG channels via ridge regression.
33
+
34
+ For each EEG channel, a Ridge regressor is fitted against all reference
35
+ channels using only the samples where both the target and every reference
36
+ are finite (non-NaN). The fitted model's prediction is then subtracted
37
+ from the full signal; samples that are NaN in the reference matrix are
38
+ replaced with zeros before prediction (those samples are already flagged
39
+ by ``hardware_invalid`` and will be excluded downstream).
40
+
41
+ Reference channels are expected to be filtered with the same bandpass as
42
+ ``eeg_df`` so that the regression operates on the same frequency content.
43
+
44
+ Args:
45
+ eeg_df: Bandpass-filtered EEG DataFrame (samples × EEG channels).
46
+ ref_df: Bandpass-filtered reference DataFrame (samples × reference
47
+ channels, e.g. CHEEK_R, CHEEK_L, BROW_L, NOSE).
48
+
49
+ Returns:
50
+ Cleaned EEG DataFrame of the same shape as ``eeg_df``.
51
+ """
52
+ printInfo("[cognitionProcessor] Applying regression-based noise removal")
53
+
54
+ cleaned = eeg_df.copy()
55
+ X_raw = ref_df.to_numpy(dtype=float)
56
+ X_safe = np.nan_to_num(X_raw, nan=0.0) # for prediction on gap samples
57
+
58
+ for ch in eeg_df.columns:
59
+ y = eeg_df[ch].to_numpy(dtype=float)
60
+
61
+ # Use only rows where target AND all references are finite
62
+ valid = np.isfinite(y) & np.all(np.isfinite(X_raw), axis=1)
63
+ n_valid = int(valid.sum())
64
+
65
+ if n_valid < X_raw.shape[1] + 10:
66
+ printWarning(
67
+ f"[cognitionProcessor] regress_noise_channels: channel '{ch}' "
68
+ f"has only {n_valid} valid samples (need > {X_raw.shape[1] + 9}) "
69
+ f"— skipping regression for this channel"
70
+ )
71
+ continue
72
+
73
+ reg = Ridge(alpha=1.0, fit_intercept=True)
74
+ reg.fit(X_raw[valid], y[valid])
75
+
76
+ predicted = reg.predict(X_safe)
77
+ residual = y - predicted
78
+
79
+ # Variance reduction as a quality indicator
80
+ var_orig = float(np.nanvar(y[valid]))
81
+ var_res = float(np.nanvar(residual[valid]))
82
+ pct_reduction = round(100.0 * (1.0 - var_res / (var_orig + 1e-12)), 1)
83
+
84
+ printInfo(
85
+ f"[cognitionProcessor] regress_noise_channels: '{ch}' fitted on "
86
+ f"{n_valid} samples — variance reduction {pct_reduction}%"
87
+ )
88
+
89
+ cleaned[ch] = residual
90
+
91
+ return cleaned