SSVEP toolbox — design principles (carried from the GamBit pilot pipeline)¶
These are the hard-won lessons from building and validating the offline pipeline. They are the reason to redesign the I/O boundary rather than port the study glue.
The one that matters most¶
Co-design the stimulus output and the analysis input. In the pilot we
reverse-engineered the experiment from data: inferring trial duration per
recording, parsing a slash-delimited marker string, discovering a "method" was
secretly something else, pulling impedances from a side spreadsheet, reading the
montage off a PDF. In the toolbox the GUI owns both ends — so emit one
run manifest (schemas/run_manifest.schema.json) that analysis
reads directly. Every value we hand-solved becomes a lookup.
Principles¶
- Self-describing recordings. Each recording carries (or links) its manifest: frequencies, montage, condition/trial structure, render method, marker schema. Analysis never infers design from data heuristics.
- Never hard-code design params. The pilot mixed 15 s and 10 s trials, 1 and 2 blocks, and a truncated session — all silently. Parse per-recording from the manifest/markers; treat duration/blocks/reps as data, not constants.
- Authoritative structured markers, not free text. Put
target_freq_hz,condition,trial,segmentas fields (schemas/markers.schema.json). Keep a human string only for debugging. Epoch onstim_on→stim_off. - Validate the stimulus at design time. Knowing
display.refresh_hzlets the builder check renderability and warn in the GUI: sine fidelity drops asframes/cycle = refresh/ffalls; square (even-duty) frequencies arerefresh/(2k)and get sparse at high freq (none between 60–120 Hz at 240 Hz). Catch this before collecting data, not after. - Calibration-free by default, trained-gated. CCA/FBCCA need no per-user
training — make them the default. TRCA/TDCA are calibration-based: gate them
behind a data-sufficiency check (
decoding.trca_feasible) that refuses and flags rather than emitting chance-level garbage. (TRCA failed in the pilot from too few reps + phase-misaligned sub-epochs; both are detectable up front.) - Rigor guards in the analysis layer. Always state chance (
1/n_classes) and the decision window next to any accuracy; report per-subject values, never a bare mean; flag windows/conditions not present for all subjects. - Single source of truth. Compute canonical metrics once →
metrics.json; every table, figure, chip, and prose paragraph derives from it. Numbers stay identical across deliverables by construction. - For calibration-free CCA, the best case is an occipital ROI + a reference —
not the most channels. CCA/FBCCA have no channel-selection step, so every
extra non-occipital channel is another degree of freedom to overfit the
sinusoid templates to noise. Validated on sub-904 (2026-07-14): a 64-ch actiCAP
recorded reference-free (actiCHamp ground-only → ~24 mV common mode) decoded at
near chance across all 64 channels, and recovered to ~68% @4s once
restricted to ~8 occipital channels with a common-average reference — while a
trained spatial filter (TRCA) could not rescue it (trial-onset jitter breaks
its cross-trial phase alignment at 40 Hz). So: re-reference a ground-only amp
(CAR ≈ Fz ≈ mastoid; CAR best), decode from a posterior ROI on high-density
caps, and treat "more electrodes" as a liability for the calibration-free
decoder, not an asset. This policy is shared by the live and offline decoders
(
ssvep.spatial.occipital_decode_plan), so a streamed run and its report agree (Principle: identical live/offline behaviour). To genuinely exploit 64 channels you need per-subject calibration — and a phase-robust one, since TRCA fails here. Don't per-recording-tune the montage.ssvep.analysis.channel_search(auto-run for >16-ch montages) greedily searches the posterior channels for the best subset and reports an honest split-half number beside the fixed ROI. On sub-904 the fixed occipital ROI won (68.1% vs 65.3% split-half for the selected set), while the naive full-data pick looked better than it was — the classic selection-bias trap. Per-recording selection fits one head's idiosyncrasies (sub-904's response is right-lateralised); a montage you deploy must be validated across subjects. - Band-pass before TRCA, and gate it on measured phase-locking. TRCA/TDCA build templates by averaging trials, so they need (a) the epochs phase-aligned to stimulus onset and (b) a covariance that reflects the response, not the drift.
- Always band-pass first. On raw reference-free data TRCA sits at chance (sub-904: 12.5%) because the covariance is dominated by the mV-scale common-mode drift, so the spatial filter locks onto drift. Band-passed to the stimulus band it jumps to 77.8% — beating FBCCA (68.1%), and 77.8% vs 41.7% at a 2 s window, a large ITR win. A "TRCA fails" result on unfiltered data means nothing.
- Phase-locking is a property of the acquisition path — measure it, don't assume.
The renderer does reset flicker phase at each trial (
frame_local = 0at stim onset; time from the frame counter, never wall-clock), so trials are phase-locked by design. Whether that survives to the file depends on marker↔EEG timestamp alignment: coherent-vs-incoherent trial averaging (pipeline.phase_locking_gain_db;+10·log10(N)dB = perfectly locked, ~0 dB = scrambled) measured +5.7 dB on the actiCHamp (direct ctypes driver; PLV≈0.68 ⇒ ~3.5 ms jitter ≈ one 250 Hz sample) but only +1.4 dB on the Cyton (chunked BrainFlow→LSL; PLV≈0.35) — same PC, monitor and renderer, so the jitter is in the amplifier's timestamps, not the display. TRCA accordingly works on actiCHamp recordings and is at chance on Cyton ones, while phase-invariant FBCCA still reaches 86% there. - …but that Cyton claim is frequency-scoped, not absolute (revised 2026-07-15, sub-902/ses-002). Same Cyton, same headset, 9 min apart, only the band differs: −0.58 dB at 36–44 Hz (TRCA 21%, chance) vs +7.14 dB at 8–12 Hz (TRCA 96%, 74 bpm). Scrambled timestamps would kill alpha too; what fits is a fixed ~5–6 ms timing error, since phase error = 2π·f·Δt — ≈85° at 40 Hz (fatal), ≈21° at 10 Hz (harmless). Rough rule: an amp is TRCA-capable below about 30/Δt_ms Hz. So do not write off Cyton+TRCA; scope the claim by frequency. Estimate Δt from the high-frequency loss only — the alpha-band estimate is inflated by endogenous (non-phase-locked) alpha and understates locking, returning ~10 ms even for the actiCHamp, which its own 40 Hz TRCA result disproves.
- So: report TRCA beside FBCCA (never instead of), band-passed, with leave-one-block-out CV (it is calibration-based — an uncross-validated number is leakage), and carry the phase-locking gain next to it so a chance-level TRCA reads as "this acquisition path isn't phase-aligned", not as "the data is bad".
- Present the visual-latency lead-in; discard it in analysis; still charge it to the
ITR. The SSVEP does not start at stimulus onset — it needs ~100–150 ms to reach
visual cortex, and the onset transient VEP that arrives first is broadband and
not frequency-specific, so it cannot discriminate targets and only adds variance.
The design therefore adds
onset_offset_s(defaultssvep.DEFAULT_ONSET_OFFSET_S= 0.14 s, the Tsinghua/Nakanishi convention) on top of the operator's stimulation duration: "4 s stimulation" flickers for 4.14 s, analysis drops the lead-in, and the operator still gets a full 4 s of usable steady state. Verified on our data at a matched decision time from onset (the only fair test — a shifted window answers later): dropping 0.14–0.2 s gained ~+4 to +8 points, largest at short (1–2 s) windows and shrinking as the window grows; 0.3 s overshoots and starves a short window. Two rigor rules: the discarded lead-in is charged to the ITR (selection_time = offset + window + ITI) so dropping it can never flatter the numbers, and the window sweep steps in 0.5 s increments up to the usable stimulation, so the accuracy/speed trade-off is legible where a BCI actually operates. - Surface what was NOT measured/computed. Make missing-data an explicit output (e.g. "15 s window not computed", "setup time not measured"), so reports can't silently imply coverage that doesn't exist.
- Montage-appropriate visualisation. For dense single-region arrays, use
discrete per-channel plots (
viz.plot_channel_array), not interpolated topomaps. Diverging colormap for signed (SNR), sequential for non-negative (impedance). Montage positions are data (from the manifest), so plots adapt to any layout. - BIDS as the storage layer. Convert to BIDS for durable, tool-friendly storage — but drive it from the manifest, not from heuristics.
- The delivered refresh is the truth, not the advertised one — and the render
path decides it. A "240 Hz" rig can silently deliver invalid timing two ways on
Windows: software GL (conda Mesa's
llvmpipeshadowing the GPU driver) has no vsync at all, and a composited fullscreen window on a mixed-refresh multi-monitor desktop is clamped by DWM to the primary monitor's rate (a 240 Hz secondary measured 60). Both are invisible unless you measure. So: force hardware GL, render fullscreen at native resolution with per-monitor DPI awareness (which lets Windows use independent-flip and present at the panel's own rate), and measure the real vsync'd refresh on the actual rig (stim.display.measure_refresh_hz, which also returns the GL renderer) — feed that into the manifest, never the nominal spec. Seedocs/REQUIREMENTS.md"Platform gotcha" +docs/WINDOWS_TESTING.md.
The decoder engine transfers directly¶
ssvep/classification/decoding.py already takes (n_ch, n_times) arrays + sfreq, so the
same code runs offline (batch over epochs) and online (per-window in an LSL
loop). The online harness is new; the math is not.
What we deliberately did NOT port¶
The pilot's bids.py, provenance.py, XDF marker parsing, montage-from-cm, and
PsychoPy-CSV behavioural parsing were all band-aids for missing/confusing source
metadata. The manifest removes the need for them. Rebuild a thin
manifest -> arrays adapter instead.