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,68 @@
1
+ """Console logging helpers used across the pipeline (GUI-safe, no ANSI when not a TTY)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import sys
7
+
8
+ _LOGGER = logging.getLogger("nucleuskit_toolkit")
9
+ _CORE_LOGGER = logging.getLogger("reak_core") # processing messages of re-ak-core
10
+
11
+
12
+ class _AnsiAwareHandler(logging.StreamHandler):
13
+ """Write to the current ``sys.stdout``.
14
+
15
+ The stream is resolved on every record so a GUI worker can redirect
16
+ stdout into the on-screen log after this handler was created.
17
+ """
18
+
19
+ def emit(self, record: logging.LogRecord) -> None:
20
+ try:
21
+ msg = self.format(record)
22
+ stream = sys.stdout
23
+ if stream is None:
24
+ return
25
+ self.stream = stream
26
+ if hasattr(stream, "isatty") and stream.isatty():
27
+ if record.levelno >= logging.ERROR:
28
+ msg = f"\033[91m{msg}\033[0m"
29
+ elif record.levelno >= logging.WARNING:
30
+ msg = f"\033[93m{msg}\033[0m"
31
+ stream.write(msg + self.terminator)
32
+ self.flush()
33
+ except Exception:
34
+ self.handleError(record)
35
+
36
+
37
+ def configure_logging(level: int = logging.INFO) -> None:
38
+ """Attach a single stream handler to the toolkit and re-ak-core loggers if none exist (idempotent)."""
39
+ for logger in (_LOGGER, _CORE_LOGGER):
40
+ if logger.handlers:
41
+ logger.setLevel(level)
42
+ continue
43
+ handler = _AnsiAwareHandler(sys.stdout)
44
+ handler.setFormatter(logging.Formatter("%(message)s"))
45
+ logger.addHandler(handler)
46
+ logger.setLevel(level)
47
+ logger.propagate = False
48
+
49
+
50
+ def printInfo(message: object) -> None:
51
+ configure_logging()
52
+ _LOGGER.info("%s", message)
53
+
54
+
55
+ def printWarning(message: object) -> None:
56
+ configure_logging()
57
+ _LOGGER.warning("%s", message)
58
+
59
+
60
+ def printError(message: object) -> None:
61
+ configure_logging()
62
+ _LOGGER.error("%s", message)
63
+
64
+
65
+ class bcolors:
66
+ WARNING = "\033[93m"
67
+ FAIL = "\033[91m"
68
+ ENDC = "\033[0m"
@@ -0,0 +1,5 @@
1
+ """MQTT controller for Vizia Mobile devices."""
2
+
3
+ from nucleuskit_toolkit.mqtt.mqtt_controller import MqttController
4
+
5
+ __all__ = ["MqttController"]
@@ -0,0 +1,256 @@
1
+ """
2
+ MQTT controller for Vizia Mobile Android devices.
3
+
4
+ Connects to a user-configured MQTT broker and drives recording sessions via
5
+ the Vizia Mobile MQTT interface (see doc/mqtt_interface.md for the full contract).
6
+
7
+ Thread-safety: paho-mqtt runs its own network loop in a background thread.
8
+ All public methods are safe to call from any thread. GUI consumers should
9
+ connect to the Qt signals defined in MqttBridge instead of calling these
10
+ methods directly from UI callbacks.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import Callable
20
+
21
+ DEFAULT_BROKER_PORT = 1883
22
+ DEFAULT_STATUS_TOPIC = "vizia/status"
23
+ DEFAULT_COMMAND_TOPIC_SUFFIX = "control/"
24
+
25
+ TARGET_ALL = "__ALL__"
26
+
27
+
28
+ @dataclass
29
+ class DeviceStatus:
30
+ device_id: str
31
+ recording: bool = False
32
+ battery: int = -1
33
+ last_seen: float = field(default_factory=time.time)
34
+
35
+
36
+ class MqttController:
37
+ """
38
+ Manages a single MQTT connection to a Vizia Mobile broker.
39
+
40
+ Parameters
41
+ ----------
42
+ broker_host : str
43
+ IP address or hostname of the MQTT broker.
44
+ broker_port : int
45
+ TCP port of the broker (default 1883).
46
+ status_topic : str
47
+ Topic on which devices publish status packets (default "vizia/status").
48
+ command_topic_suffix : str
49
+ Suffix appended after ``{recorderId}/`` when publishing commands
50
+ (default "control/").
51
+ on_log : Callable[[str], None] | None
52
+ Called from the paho network thread with human-readable log lines.
53
+ on_device_update : Callable[[DeviceStatus], None] | None
54
+ Called whenever a device status packet is received or updated.
55
+ on_connected : Callable[[], None] | None
56
+ Called once the broker connection is established.
57
+ on_disconnected : Callable[[], None] | None
58
+ Called when the connection drops.
59
+ """
60
+
61
+ def __init__(
62
+ self,
63
+ broker_host: str,
64
+ broker_port: int = DEFAULT_BROKER_PORT,
65
+ status_topic: str = DEFAULT_STATUS_TOPIC,
66
+ command_topic_suffix: str = DEFAULT_COMMAND_TOPIC_SUFFIX,
67
+ on_log: Callable[[str], None] | None = None,
68
+ on_device_update: Callable[[DeviceStatus], None] | None = None,
69
+ on_connected: Callable[[], None] | None = None,
70
+ on_disconnected: Callable[[], None] | None = None,
71
+ ) -> None:
72
+ self._broker_host = broker_host.strip()
73
+ self._broker_port = broker_port
74
+ self._status_topic = status_topic
75
+ self._command_topic_suffix = command_topic_suffix
76
+ self._on_log = on_log
77
+ self._on_device_update = on_device_update
78
+ self._on_connected = on_connected
79
+ self._on_disconnected = on_disconnected
80
+
81
+ self._devices: dict[str, DeviceStatus] = {}
82
+ self._devices_lock = threading.Lock()
83
+ self._connected = False
84
+ self._client = None
85
+
86
+ # ── Public properties ────────────────────────────────────────────────
87
+
88
+ @property
89
+ def is_connected(self) -> bool:
90
+ return self._connected
91
+
92
+ @property
93
+ def discovered_devices(self) -> list[DeviceStatus]:
94
+ with self._devices_lock:
95
+ return list(self._devices.values())
96
+
97
+ # ── Connection management ────────────────────────────────────────────
98
+
99
+ def connect(self) -> None:
100
+ """Connect to the broker and start the background network loop."""
101
+ try:
102
+ import paho.mqtt.client as mqtt
103
+ except ImportError as exc:
104
+ raise RuntimeError(
105
+ "paho-mqtt is required for the MQTT controller. "
106
+ "Install with: pip install 'nucleuskit-toolkit[gui]'"
107
+ ) from exc
108
+
109
+ self._log(f"Connecting to broker {self._broker_host}:{self._broker_port} …")
110
+
111
+ client = mqtt.Client(
112
+ client_id=f"nucleuskit-{int(time.time())}",
113
+ protocol=mqtt.MQTTv311,
114
+ clean_session=True,
115
+ )
116
+ client.on_connect = self._on_mqtt_connect
117
+ client.on_disconnect = self._on_mqtt_disconnect
118
+ client.on_message = self._on_mqtt_message
119
+ client.on_log = self._on_mqtt_log
120
+
121
+ client.connect_async(self._broker_host, self._broker_port, keepalive=60)
122
+ client.loop_start()
123
+ self._client = client
124
+
125
+ def disconnect(self) -> None:
126
+ """Gracefully disconnect from the broker."""
127
+ if self._client is None:
128
+ return
129
+ self._log("Disconnecting from broker …")
130
+ try:
131
+ self._client.loop_stop()
132
+ self._client.disconnect()
133
+ except Exception as exc:
134
+ self._log(f"[WARN] Error during disconnect: {exc}")
135
+ self._client = None
136
+ self._connected = False
137
+
138
+ # ── Command publishing ───────────────────────────────────────────────
139
+
140
+ def send_start_recording(self, device_id: str = TARGET_ALL) -> None:
141
+ """Send START_RECORDING to one device or all discovered devices."""
142
+ payload = json.dumps({"Type": "COMMAND", "Content": {"Code": "START_RECORDING"}})
143
+ self._publish_to(device_id, payload, label="START_RECORDING")
144
+
145
+ def send_stop_recording(self, device_id: str = TARGET_ALL) -> None:
146
+ """Send STOP_RECORDING to one device or all discovered devices."""
147
+ payload = json.dumps({"Type": "COMMAND", "Content": {"Code": "STOP_RECORDING"}})
148
+ self._publish_to(device_id, payload, label="STOP_RECORDING")
149
+
150
+ def send_tag(self, code: str, values: str = "", device_id: str = TARGET_ALL) -> None:
151
+ """Inject an event marker into the active recording session."""
152
+ payload = json.dumps({"Type": "TAG", "Content": {"Code": code, "Values": values}})
153
+ self._publish_to(device_id, payload, label=f"TAG:{code}")
154
+
155
+ # ── Internal helpers ─────────────────────────────────────────────────
156
+
157
+ def _publish_to(self, device_id: str, payload: str, label: str) -> None:
158
+ if not self._connected or self._client is None:
159
+ self._log("[WARN] Not connected — command not sent.")
160
+ return
161
+
162
+ if device_id == TARGET_ALL:
163
+ with self._devices_lock:
164
+ targets = list(self._devices.keys())
165
+ if not targets:
166
+ self._log("[WARN] No devices discovered yet — command not sent.")
167
+ return
168
+ for did in targets:
169
+ topic = f"{did}/{self._command_topic_suffix}"
170
+ self._client.publish(topic, payload, qos=0)
171
+ self._log(f"→ [{label}] → {topic}")
172
+ else:
173
+ topic = f"{device_id}/{self._command_topic_suffix}"
174
+ self._client.publish(topic, payload, qos=0)
175
+ self._log(f"→ [{label}] → {topic}")
176
+
177
+ def _log(self, msg: str) -> None:
178
+ if self._on_log is not None:
179
+ self._on_log(msg)
180
+
181
+ # ── paho callbacks (run on the paho network thread) ──────────────────
182
+
183
+ def _on_mqtt_connect(self, client, userdata, flags, rc) -> None: # type: ignore[no-untyped-def]
184
+ import paho.mqtt.client as mqtt
185
+
186
+ if rc == mqtt.CONNACK_ACCEPTED:
187
+ self._connected = True
188
+ self._log(f"✓ Connected to {self._broker_host}:{self._broker_port}")
189
+ self._log(f" Subscribing to status topic: {self._status_topic}")
190
+ client.subscribe(self._status_topic, qos=0)
191
+ self._log(" Ready — waiting for device status packets …")
192
+ if self._on_connected:
193
+ self._on_connected()
194
+ else:
195
+ reason = {
196
+ 1: "unacceptable protocol version",
197
+ 2: "identifier rejected",
198
+ 3: "server unavailable",
199
+ 4: "bad username or password",
200
+ 5: "not authorised",
201
+ }.get(rc, f"unknown reason code {rc}")
202
+ self._log(f"✗ Connection refused: {reason}")
203
+
204
+ def _on_mqtt_disconnect(self, client, userdata, rc) -> None: # type: ignore[no-untyped-def]
205
+ was_connected = self._connected
206
+ self._connected = False
207
+ if rc == 0:
208
+ self._log("Disconnected cleanly.")
209
+ else:
210
+ self._log(f"[WARN] Unexpected disconnect (rc={rc}) — paho will attempt reconnect …")
211
+ if was_connected and self._on_disconnected:
212
+ self._on_disconnected()
213
+
214
+ def _on_mqtt_message(self, client, userdata, msg) -> None: # type: ignore[no-untyped-def]
215
+ try:
216
+ payload = msg.payload.decode("utf-8")
217
+ data = json.loads(payload)
218
+ device_id = str(data.get("id", "device"))
219
+ recording = bool(data.get("recording", False))
220
+ battery = int(data.get("battery", -1))
221
+
222
+ with self._devices_lock:
223
+ existing = self._devices.get(device_id)
224
+ is_new = existing is None
225
+ status = DeviceStatus(
226
+ device_id=device_id,
227
+ recording=recording,
228
+ battery=battery,
229
+ last_seen=time.time(),
230
+ )
231
+ self._devices[device_id] = status
232
+
233
+ if is_new:
234
+ self._log(
235
+ f" ● New device discovered: '{device_id}' "
236
+ f"(recording={recording}, battery={battery}%)"
237
+ )
238
+
239
+ if self._on_device_update:
240
+ self._on_device_update(status)
241
+
242
+ except (json.JSONDecodeError, KeyError, ValueError) as exc:
243
+ self._log(f"[WARN] Malformed status packet on {msg.topic}: {exc}")
244
+
245
+ def _on_mqtt_log(self, client, userdata, level, buf) -> None: # type: ignore[no-untyped-def]
246
+ import paho.mqtt.client as mqtt
247
+
248
+ if level in (mqtt.MQTT_LOG_DEBUG,):
249
+ return
250
+ prefix = {
251
+ mqtt.MQTT_LOG_INFO: "[paho:INFO]",
252
+ mqtt.MQTT_LOG_NOTICE: "[paho:NOTICE]",
253
+ mqtt.MQTT_LOG_WARNING: "[paho:WARN]",
254
+ mqtt.MQTT_LOG_ERR: "[paho:ERR]",
255
+ }.get(level, "[paho]")
256
+ self._log(f"{prefix} {buf}")
@@ -0,0 +1,194 @@
1
+ """
2
+ Nucleus-Kit analytics toolkit (offline session processing).
3
+
4
+ The processing itself is re-ak-core's default pipeline (``reak_core.pipeline``): heart dynamics,
5
+ arousal, cognition, emotions, events and the emotions report. Nucleus-Kit adds its local steps
6
+ around it: POV movie conversion, session video rotation and GPS.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ from abc import ABC, abstractmethod
13
+
14
+ from reak_core.io.session import Session
15
+ from reak_core.pipeline import FunctionStep, Pipeline, RunContext, StepResult
16
+
17
+ from nucleuskit_toolkit.camera import convertPOVMovieClip, ensure_session_video_rotated_180
18
+ from nucleuskit_toolkit.config import resolve_pov_settings
19
+ from nucleuskit_toolkit.logging_utils import printError, printInfo, printWarning
20
+ from nucleuskit_toolkit.position import processGPS
21
+
22
+
23
+ class BasePipeline(ABC):
24
+ """Minimal base from analyticsEngine.pipelines.BasePipeline."""
25
+
26
+ def __init__(self, configuration=None):
27
+ self.configured = False
28
+ self.pipeName = "Unknown"
29
+
30
+ @abstractmethod
31
+ def processSession(self, jobDetails):
32
+ pass
33
+
34
+ def updateSession(self, jobDetails):
35
+ return False
36
+
37
+ def configure(self, config_name=None):
38
+ pass
39
+
40
+ def get_pipeline_name(self):
41
+ return self.pipeName
42
+
43
+
44
+ _STEP_LOG = {
45
+ "prepare": "[pipeline] Preparing session folders",
46
+ "video_rotation": "[pipeline] Checking session video orientation",
47
+ "metainfo": "[pipeline] Reading recording length",
48
+ "heart_dynamics": "[pipeline] Computing heart dynamics",
49
+ "arousal": "[pipeline] Computing arousal",
50
+ "cognition": "[pipeline] Computing cognitive indexes",
51
+ "emotions": "[pipeline] Computing emotions",
52
+ "feature_events": "[pipeline] Processing events",
53
+ "playback_annotations": "[pipeline] Seeding playback annotations",
54
+ "gps": "[pipeline] Processing GPS",
55
+ }
56
+
57
+
58
+ def _local_step(name: str, fn) -> FunctionStep:
59
+ """A Nucleus-Kit step backed by ``fn(session_path)``."""
60
+
61
+ def run(session: Session, context: RunContext) -> dict:
62
+ fn(session.root)
63
+ return {}
64
+
65
+ return FunctionStep(name, run)
66
+
67
+
68
+ class _LoggedStep:
69
+ """Prints the Nucleus-Kit progress line before running the wrapped step.
70
+
71
+ When ``cancel`` is set, the step is skipped instead: a cancelled run finishes the step in progress, then
72
+ skips the remaining ones (recorded as ``skipped`` / ``cancelled`` in ``results/processing.json``).
73
+ """
74
+
75
+ def __init__(self, step, cancel: threading.Event | None = None):
76
+ self._step = step
77
+ self._cancel = cancel
78
+ self.name = step.name
79
+
80
+ def run(self, session: Session, context: RunContext):
81
+ if self._cancel is not None and self._cancel.is_set():
82
+ return StepResult(self.name, "skipped", detail={"reason": "cancelled"})
83
+ printInfo(_STEP_LOG.get(self.name, f"[pipeline] {self.name}"))
84
+ return self._step.run(session, context)
85
+
86
+
87
+ class _CancellableExporter:
88
+ """Runs the wrapped exporter unless the run was cancelled (a cancelled run exports nothing)."""
89
+
90
+ def __init__(self, exporter, cancel: threading.Event):
91
+ self._exporter = exporter
92
+ self._cancel = cancel
93
+ self.name = exporter.name
94
+
95
+ def export(self, session: Session, context: RunContext, results) -> None:
96
+ if not self._cancel.is_set():
97
+ self._exporter.export(session, context, results)
98
+
99
+
100
+ def build_pipeline(
101
+ *,
102
+ skip_video_rotation: bool = False,
103
+ apply_nlms: bool = True,
104
+ emotion_model: str | None = None,
105
+ cancel: threading.Event | None = None,
106
+ ) -> Pipeline:
107
+ """re-ak-core's default pipeline with the Nucleus-Kit local steps, in Nucleus-Kit's order."""
108
+ pipeline = Pipeline.default(emotion_model=emotion_model, apply_nlms=apply_nlms)
109
+ if not skip_video_rotation:
110
+ pipeline.with_step(_local_step("video_rotation", ensure_session_video_rotated_180), after="prepare")
111
+ pipeline.with_step(_local_step("gps", processGPS))
112
+ pipeline.steps = [_LoggedStep(step, cancel) for step in pipeline.steps]
113
+ if cancel is not None:
114
+ pipeline.exporters = [_CancellableExporter(exporter, cancel) for exporter in pipeline.exporters]
115
+ return pipeline
116
+
117
+
118
+ class NucleusKitProcessingToolkit(BasePipeline):
119
+ """
120
+ Offline analytics pipeline for processing session data from a local folder.
121
+ """
122
+
123
+ def __init__(
124
+ self,
125
+ configuration=None,
126
+ *,
127
+ skip_video_rotation: bool = False,
128
+ apply_nlms: bool = True,
129
+ emotion_model: str | None = None,
130
+ cancel: threading.Event | None = None,
131
+ ):
132
+ super().__init__(configuration)
133
+ self.plot_data = False
134
+ self.pipeline: Pipeline | None = None
135
+ self._skip_video_rotation = skip_video_rotation
136
+ self._apply_nlms = apply_nlms
137
+ self._emotion_model = emotion_model
138
+ self._cancel = cancel
139
+
140
+ if configuration is None:
141
+ self.configureAsDefault()
142
+ self.configured = True
143
+ else:
144
+ printError("[NucleusKitProcessingToolkit] ERROR: Invalid configuration")
145
+
146
+ @property
147
+ def processingSteps(self) -> list[str]:
148
+ """Names of the steps run, in order."""
149
+ return [step.name for step in self.pipeline.steps] if self.pipeline else []
150
+
151
+ def processSession(self, jobDetails):
152
+ if not self.configured:
153
+ printError("[NucleusKitProcessingToolkit] ERROR: Pipeline not configured")
154
+ return False
155
+
156
+ cachepath = jobDetails.path
157
+ session = Session.open(cachepath)
158
+
159
+ pov = resolve_pov_settings(getattr(jobDetails, "pov_config_json", None))
160
+ screen_id = jobDetails.screenID or (pov.screen_id if pov else None)
161
+
162
+ printInfo(f"[pipeline] Starting {self.pipeName}")
163
+ printInfo("[pipeline] Converting POV movie")
164
+ try:
165
+ if screen_id and pov and pov.data_root and pov.ffmpeg_bin_dir:
166
+ convertPOVMovieClip(screen_id, pov.data_root, pov.ffmpeg_bin_dir)
167
+ elif screen_id:
168
+ printWarning(
169
+ "[NucleusKitProcessingToolkit] POV screen id set but data_root/ffmpeg not configured; "
170
+ "skipping conversion"
171
+ )
172
+ except Exception as e:
173
+ printWarning(f"[NucleusKitProcessingToolkit] WARNING: Converting movie failed: {e}")
174
+
175
+ result = self.pipeline.run(session)
176
+ if self._cancel is not None and self._cancel.is_set():
177
+ printWarning("[pipeline] Run cancelled: the remaining steps were skipped")
178
+ for step in result.steps:
179
+ if step.status == "error":
180
+ printError(f"[NucleusKitProcessingToolkit] ERROR in {step.name}: {step.error}")
181
+ else:
182
+ printInfo(f"[pipeline] {step.name}: {step.status} ({step.seconds:.1f} s)")
183
+
184
+ printInfo("[pipeline] Session processing finished")
185
+ return True
186
+
187
+ def configureAsDefault(self):
188
+ self.pipeName = "HermesDevPipe"
189
+ self.pipeline = build_pipeline(
190
+ skip_video_rotation=self._skip_video_rotation,
191
+ apply_nlms=self._apply_nlms,
192
+ emotion_model=self._emotion_model,
193
+ cancel=self._cancel,
194
+ )
@@ -0,0 +1,5 @@
1
+ """Outdoor position processing (GPS)."""
2
+
3
+ from nucleuskit_toolkit.position.gps_processor import processGPS
4
+
5
+ __all__ = ["processGPS"]
@@ -0,0 +1,91 @@
1
+ """
2
+ GPS Processor
3
+
4
+ Processes GPS tracking data from sessions.
5
+
6
+ Author(s):
7
+ Fred Simard (fs@re-ak.com), ©RE-AK Technologies Inc.
8
+ Winter 2026
9
+ """
10
+
11
+ import os
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ from reak_core.io.csvfiles import has_text_header
16
+
17
+ from nucleuskit_toolkit.logging_utils import printInfo, printWarning
18
+
19
+ GPS_HDR = "[GPSProcessor] "
20
+
21
+ _GPS_FILENAMES = ["gps.csv", "gps.tmp"]
22
+ _GRID_STEP_S = 0.5 # shared 2 Hz / 0.5 s pipeline timebase
23
+
24
+
25
+ def processGPS(recpath):
26
+ """
27
+ Process GPS tracking data.
28
+
29
+ Loads the raw GPS CSV (no header: unix_timestamp_s, latitude, longitude),
30
+ normalises timestamps to session-relative seconds, resamples to the shared
31
+ 2 Hz / 0.5 s grid via linear interpolation, and writes results/gpsDf.csv.
32
+
33
+ Args:
34
+ recpath: Path to the recording directory
35
+ """
36
+ printInfo(f"{GPS_HDR}Processing GPS data")
37
+
38
+ out_path = os.path.join(recpath, "results", "gpsDf.csv")
39
+ if os.path.exists(out_path):
40
+ printInfo(f"{GPS_HDR}GPS already processed, using cached results")
41
+ return
42
+
43
+ gps_file = None
44
+ for filename in _GPS_FILENAMES:
45
+ candidate = os.path.join(recpath, "rawData", filename)
46
+ if os.path.isfile(candidate):
47
+ gps_file = candidate
48
+ break
49
+
50
+ if gps_file is None:
51
+ printWarning(f"{GPS_HDR}No GPS file found, tested: {_GPS_FILENAMES}")
52
+ return
53
+
54
+ printInfo(f"{GPS_HDR}Loading {os.path.basename(gps_file)}")
55
+ try:
56
+ skiprows = 1 if has_text_header(gps_file) else 0
57
+ raw = pd.read_csv(
58
+ gps_file,
59
+ header=None,
60
+ names=["unix_ts", "Latitude", "Longitude"],
61
+ skiprows=skiprows,
62
+ )
63
+ except Exception as exc:
64
+ printWarning(f"{GPS_HDR}Failed to read GPS file: {exc}")
65
+ return
66
+
67
+ if len(raw) < 2:
68
+ printWarning(f"{GPS_HDR}GPS file contains fewer than 2 rows — skipping")
69
+ return
70
+
71
+ t_raw = raw["unix_ts"].to_numpy(dtype=float)
72
+ t_rel = t_raw - t_raw[0]
73
+ lat = raw["Latitude"].to_numpy(dtype=float)
74
+ lon = raw["Longitude"].to_numpy(dtype=float)
75
+
76
+ grid = np.arange(0.0, t_rel[-1] + _GRID_STEP_S, _GRID_STEP_S)
77
+
78
+ lat_grid = np.interp(grid, t_rel, lat, left=np.nan, right=np.nan)
79
+ lon_grid = np.interp(grid, t_rel, lon, left=np.nan, right=np.nan)
80
+
81
+ result = pd.DataFrame({
82
+ "Timestamp": grid,
83
+ "Latitude": lat_grid,
84
+ "Longitude": lon_grid,
85
+ })
86
+
87
+ result.to_csv(out_path, index=False, float_format="%.7f", na_rep="NULL")
88
+ printInfo(
89
+ f"{GPS_HDR}GPS processing completed — "
90
+ f"{len(result)} rows @ 2 Hz written to {out_path}"
91
+ )
@@ -0,0 +1,28 @@
1
+ """
2
+ Minimal session job DTO for local Nucleus-Kit pipeline processing (no REST).
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ from dataclasses import dataclass
9
+ from typing import Optional
10
+
11
+
12
+ @dataclass
13
+ class SessionJob:
14
+ sessionID: Optional[str] = None
15
+ path: Optional[str] = None
16
+ jobType: str = "PROCESS"
17
+ pipeline: str = "hermes"
18
+ screenID: Optional[str] = None
19
+ pov_config_json: Optional[str] = None # optional path merged after cwd default JSON
20
+
21
+
22
+ def session_job_from_folder(folder: str, pov_config_json: Optional[str] = None) -> SessionJob:
23
+ folder = os.path.abspath(os.path.expanduser(folder))
24
+ norm = os.path.normpath(folder)
25
+ session_id = os.path.basename(norm)
26
+ sep = os.path.sep
27
+ path = norm if norm.endswith(sep) else norm + sep
28
+ return SessionJob(sessionID=session_id, path=path, pov_config_json=pov_config_json)
@@ -0,0 +1,5 @@
1
+ """Shimmer wristband physiological processing (PPG, EDA)."""
2
+
3
+ from nucleuskit_toolkit.shimmer.realtime.proxy import ShimmerSerialProxy
4
+
5
+ __all__ = ["ShimmerSerialProxy"]
@@ -0,0 +1,32 @@
1
+ """Constants for the Shimmer3 wristband hardware."""
2
+
3
+
4
+ class ShimmerConstants(object):
5
+
6
+ # tmp signal definition
7
+ SAMPLING_RATE = 51.2
8
+ GSR_INDEX = 4
9
+ NYQUIST_FREQ = SAMPLING_RATE / 2
10
+
11
+ # ppg conditioning
12
+ FILTER_ORDER = 4 # applied twice
13
+ AVG_FILTER_FC = 0.5
14
+
15
+ # HRV/BPM
16
+ THRESHOLD = 20
17
+ HRV_WINDOW_WIDTH = 11 # used for smoothing
18
+ BPM_WINDOW_WIDTH = 11 # used for smoothing used to be 101
19
+ MIN_BPM = 40
20
+ MAX_BPM = 150
21
+
22
+ # gsr conditioning
23
+ GSR_BANDWIDTH = [0.01, 5]
24
+ TONIC_FC = 5.0
25
+ GSR_THRESHOLD_TIGHT = 10
26
+
27
+ # arousal conditioning
28
+ FILTER_ORDER = 4
29
+ FIRST_STAGE_BANDWIDTH = [0.03, 3]
30
+ SMOOTHING_CUTOFF = 0.03
31
+
32
+ THRESHOLD = 2