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; a square target presents its exact frequency on average, but unlessrefresh/fis a whole number of frames its cycle lengths alternate between the two bracketing frame counts (one-frame jitter, which costs more the fewer frames a cycle has), and a contrast reversal needs an even whole number. All of this is frame arithmetic, done atdisplay.refresh_hz(the renderer's frame clock), never at a measured rate. 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 in bench testing: 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. In that same test 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 (a lateralised response, in that case); 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 (~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). Two bands recorded on the same Cyton and headset, 9 minutes 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) — record that in the manifest (display.measured_refresh_hz) rather than trusting the nominal spec. It is what shows the panel really runs the mode the design assumes (the builder refuses a mismatch); frame counts stay at the mode rate, which is what the renderer clocks on. Seedocs/REQUIREMENTS.md"Platform gotcha". - A reference harmonic on the mains line wins every trial — so no candidate may have one. Unfiltered EEG carries a mains line (and its harmonics) far larger than any SSVEP in the band it sits in, and it is the same on every trial. A calibration-free decoder whose reference bank for one candidate contains a sinusoid at that frequency matches the hum whatever the participant looked at, and outputs that candidate for every trial (#280: a declared-10 Hz reversing target decoded at 2f = 20 Hz has its third harmonic at exactly 60 Hz). Wrong references scatter predictions; a constant predicted class means something constant across trials is in the scores, and mains is the first suspect. The guard (
decoding.mains_safe_harmonics) drops a colliding harmonic order from every candidate — dropping it from only the one that has it leaves that candidate with fewer reference terms, which lowers its score on the same noise and swaps a bias toward it for a bias against it. A reference harmonic above Nyquist counts at its alias, because that is what it is once sampled. The mains frequency is a site fact (acquisition.line_frequency_hz), read by live and offline decoding through one function; where the site has not said, both 50 and 60 Hz are guarded, because guarding an absent line costs a harmonic and missing the present one costs the decode. -
A reference harmonic at or above Nyquist is an alias, and — like a mains collision — it is dropped from every decodable candidate, not just the one whose own frequency aliases.
reference_signalssamplessin(2*pi*h*f*t)atsfreq; any harmonic withh*f >= sfreq/2is not a sinusoid at that frequency once sampled — it is its alias, a sinusoid at a frequency the design never declared (#310, found reviewing #302's mains guard, which folds a harmonic about Nyquist only to compare it against a mains line, not to decide whether to use it at all). A first version of this guard read as the mirror image of principle 12 — mains is one external interferer shared by the whole bank, so it drops globally, while aliasing is a property of each candidate's own frequency, so it dropped per candidate — but that is wrong for exactly the reason principle 12 gives: a candidate left with fewer reference columns than the rest of the bank runs a lower canonical correlation on the same noise, so it is under-picked on null data and mis-decoded at low SNR, whichever guard put it there. Measured on the shipped high-band preset (review of #346): the per-candidate version's bias against the affected candidates was larger than the alias bias #310 set out to remove, and larger than the unguarded code's own bias, at exactly the SNRs where decoding is marginal. So the rule is principle 12's: an order that aliases for any decodable candidate is dropped from every decodable candidate, and every decodable candidate keeps the same reference dimensionality (decoding.nyquist_safe_harmonics,decoding.usable_harmonics; recorded per candidate innyquist_guard/metrics.jsononly because an undecodable candidate still needs naming — every decodable one shares one order set). It has noline_hz-style on/off switch (an alias is never a legitimate reference, mains-guarded or not), and a candidate whose own fundamental already aliases is refused (NaN, never picked) rather than decoded on nothing — the one case where "per candidate" is still the right description, because there is no reference left for it to share. Composing this with the mains guard needs one more care: a mains-collision test must only be asked of an order that is alias-safe for that candidate to begin with, or a candidate's aliased harmonic (never built) can fold onto a mains line and take a real harmonic away from every other candidate over a collision nobody's decoder actually has.This is a real boundary, not the only one that matters (design note).
h*f >= sfreq/2is where a reference aliases — where it stops being the frequency it claims to be. It is not where a reference stops being usable on every amplifier: the OpenBCI Cyton's ADS1299 front end decimates with a roll-off centred around 0.26·sfreq (roughly 65 Hz at 250 SPS) and is far attenuated by 118–124 Hz, so a harmonic just below Nyquist can be mostly noise floor on this hardware despite not aliasing at all — the same mechanism as this guard, in miniature, on every shipped high-band preset's 3rd harmonic. Not fixed here: the honest boundary would be an amplifier-declared usable bandwidth in the site profile, not a margin quietly subtracted from Nyquist, and it belongs with the D7 pooling follow-up (below) rather than folded into #310's alias fix.
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.