Skip to content

Architecture

Guiding shape: "A now, C-shaped boundary"

We start as one native desktop application (fastest path to increment 1, the stimulus builder + runtime), but we adopt the boundaries of a more distributed design from day one so nothing becomes a dead end:

  • The timing-critical stimulus + LSL engine is an isolated "agent" — a component with a narrow, message-based control surface, so UI work can never stall the render loop, and so it can later run as a separate process or be reimplemented in another engine.
  • All decoding/IO logic lives in a UI-agnostic core (plain functions over arrays + manifests). No GUI import ever reaches into it.

Consequence: adding a browser-based analysis frontend later (served from the Canadian compute server for remote users) is additive — it consumes the same core + manifest. So is swapping the renderer for a game engine (see "Engine portability").

Why not fuller web-first or two-frontends now: both add real infra (web server, container, protocol) before the first timing-critical deliverable exists. We buy their optionality cheaply via the boundary instead of paying their cost up front. Revisit when remote browser analysis becomes a priority.

The manifest is the spine

 ┌─────────────┐   emits    ┌──────────────────────┐
 │ stim builder│──manifest─▶│ run manifest         │◀─ single source of truth
 └─────────────┘            │ (schemas/*.json)     │
                            └──────────┬───────────┘
        stamps into each recording     │        reads directly
                 ┌─────────────────────┼─────────────────────┐
                 ▼                                            ▼
        ┌────────────────┐  LSL (EEG + markers)      ┌──────────────────┐
        │ runtime/runner │─────────────────────────▶ │ analysis + report│
        │ record → XDF   │                            │ metrics.json →   │
        └────────────────┘                            │ figures/report   │
                 │ save/push (Canada only)            └──────────────────┘
         local disk → NCIL NAS → compute server

Every value analysis would otherwise reverse-engineer (frequencies, montage, trial/block structure, render method, marker schema) is a manifest lookup. This deletes the back-filling that dominated the pilot.

Components (src/ssvep/)

Package Responsibility Depends on Notes
classification/ Validated engine: CCA/FBCCA/TRCA, PSD/SNR, metrics, viz. Pure (n_ch, n_times) array API. numpy, scipy, scikit-learn, matplotlib This is ssvep_core from the handoff, lifted as-is (renamed from the too-generic core). Same code offline + online.
io/ manifest ⇄ (arrays, labels) adapter; XDF read (pyxdf/mne-lsl); BIDS conversion (mne-bids); provenance stamping; consent-flag model. mne, mne-bids, pyxdf, jsonschema The thin adapter that replaces the pilot's study-specific glue.
stim/ pyglet flicker renderer (vsync-locked, gamma/LUT-aware, dropped-frame detection); builder data-model that validates renderability against display.refresh_hz; emits manifest. Runs as the stimulus agent. Integrates the flik-o-meter photodiode for timing validation (USB capture + analysis in stim/photodiode.py; --measure-flicker presentation; LSL-sync link still to come). pyglet, pyserial Timing-critical. Validate every build; photosensitivity warnings here.
runtime/ Session runner: resolve/verify LSL streams (OpenBCI Cyton 8-ch via BrainFlow/OpenBCI→LSL), push structured markers, record EEG+markers → XDF, stamp manifest, save + push to the NCIL server (push.py, SFTP via the system OpenSSH client — opt-in, consent-gated, copy-never-move; docs/PUSH.md). Always-available abort; break-time adverse-symptom checkpoints. pylsl, mne-lsl Only runs built, saved, versioned protocols.
analysis/ Offline pipelines (epoch → decode → sweep windows), canonical metrics.json, report generation — including a reproducible stimulus description (screen-layout figure + trial-timeline diagrams, derived from the manifest) — and the derivatives layer (derivatives.py): where reports live (sub-XXX/ses-YYY/), the subject→session→run index rebuilt from disk, and the derivatives dataset_description.json. classification, io One metrics.json feeds every table/figure/number (report principle). The index is derived, never accumulated — see docs/BIDS.md.
ui/ PySide6 unified GUI with Design / Run / Analyze modes; pyqtgraph real-time plots. Identical behaviour for live vs file input. PySide6, pyqtgraph Talks to the stim agent over its control surface; never imports render internals.

Real-time path

classification.decoding.classify / cca_scores / fbcca_scores already take a single (n_ch, n_times) window — the online loop calls them per LSL buffer; calibration-free methods need no warm-up. TRCA stays gated behind trca_feasible(). The online harness (buffering, windowing, marker sync) is new; the math is the validated handoff code.

Auth / access model (remote-only)

  • Local acquisition app: no live auth; operator selects their name from the admin whitelist (config file, not GUI-editable). That operator id is stamped into provenance so we track who collected each dataset. Works fully offline.
  • Remote analysis (future web frontend): GitHub-ID OAuth checked against the whitelist, served only from the Canadian compute server behind TLS + CA/US IP allowlist. This sits on top of the lab's existing server security, never weakening it — the general layering principle is in docs/DATA_GOVERNANCE.md.

Engine portability (Unity / Godot — future, optional)

The stim/ agent is the only engine-specific layer. Its contract to the rest of the system is exactly: consume a manifest, render the specified flicker with correct timing, emit the structured LSL markers. Any future renderer that honours that contract drops in without touching core/io/runtime/analysis.

  • Unity: not used here — proprietary/paid-tier licensing conflicts with the commercial-free requirement, it offers no SSVEP timing advantage over a tight pyglet loop, and no one on the build team knows it.
  • Godot (MIT): the license-clean candidate if a rich game engine ever becomes core to Gambit. A Godot stimulus front-end would implement the same manifest+marker contract.

Data flow & residency (summary; mechanism in docs/DATA_GOVERNANCE.md, Dalhousie-specific bindings in docs/COMPLIANCE.md)

Acquire on the Windows PC → write XDF locally → (optionally) BIDS-convert → push to NCIL NAS (Canada) → available to the compute server via NFS for remote offline analysis. No step touches a foreign/US cloud, and git never holds data.