API reference¶
Generated from the docstrings in src/ssvep by
mkdocstrings, so it cannot drift from the code the way a
hand-written reference does. If something here is wrong, fix the docstring.
The decode, spectral and metrics layers take plain arrays plus sfreq — shape
(n_channels, n_times) — and nothing else. That boundary is deliberate: the same function runs
offline over a whole recording and online over a single sliding window, so an online result and an
offline one cannot disagree because they went through different code.
ssvep.classification — the decode engine¶
Decoding¶
ssvep.classification.decoding ¶
SSVEP frequency-recognition decoders (domain-generic, array-based).
All functions take plain NumPy arrays and a sampling rate, so the same code runs offline (batch) and online (per-window in an LSL loop). No MNE / BIDS / study dependencies.
- CCA, FBCCA: calibration-free (sinusoidal reference templates; no training).
- TRCA: template-trained; gate it with
trca_feasible()— it needs several phase-consistent trials per class and degrades to chance otherwise.
Trial array convention: (n_channels, n_times); batches: (n_trials, n_channels,
n_times). Reference frequencies are in Hz.
TRCA ¶
Ensemble Task-Related Component Analysis (Nakanishi et al. 2018).
Calibration-based: one spatial filter + template per class. Requires several
phase-consistent trials per class — check trca_feasible(y) first.
Epoch-length rules (every epoch must be time-locked to stimulus onset):
- Within a class, training epochs must be equal length — the cross-trial
covariance is sample-aligned, so ragged within-class input raises.
- Across classes, templates may differ in length: each class's filter and
template are learned independently, with no cross-frequency coupling. So a
whole-number-of-cycles (per-frequency) window is fine, and there is no need
for a common length across classes.
- predict compares a test window to each class over their shared
onset-locked interval (both cropped to the shorter), so the test window need
not match the training length either.
- Caveat: a correlation over more samples trends higher, so scoring classes at
different lengths biases the argmax toward the longer templates. Prefer a
common decision-window length unless you specifically want per-frequency
lengths.
Source code in src/ssvep/classification/decoding.py
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 | |
rendered_frequency ¶
Actual displayed flicker frequency given the render method.
sine: continuous-phase -> exact nominal frequency.square/even: integer frames per cycle ->refresh/round(refresh/f)(drifts from nominal at high freq / low frames-per-cycle).
Source code in src/ssvep/classification/decoding.py
expand_subepochs ¶
Split each trial into shorter sub-epochs; returns (Xs, ys, groups).
groups is the originating trial index so cross-validation can keep a
trial's sub-epochs together (no leakage). NOTE: sub-epochs cut at arbitrary
offsets have different SSVEP phase — fine for CCA, but they break TRCA's
time-domain template averaging unless aligned to the stimulus cycle.
Source code in src/ssvep/classification/decoding.py
reference_signals ¶
Sin/cos reference matrix (n_times, 2*n_harmonics) for one frequency.
Source code in src/ssvep/classification/decoding.py
cca_scores ¶
Max canonical correlation of one trial (n_ch, n_times) to each freq.
Source code in src/ssvep/classification/decoding.py
fbcca_scores ¶
Filter-bank CCA combined scores (Chen et al. 2015), one per candidate freq.
Source code in src/ssvep/classification/decoding.py
trca_feasible ¶
True if every class has >= min_trials_per_class trials (TRCA pre-check).
Source code in src/ssvep/classification/decoding.py
classify ¶
Predict the attended-frequency index for one trial. Calibration-free.
method='cca' or 'fbcca' (fbcca needs band=(low, high)).
Source code in src/ssvep/classification/decoding.py
Spectral analysis¶
ssvep.classification.spectral ¶
Spectral analysis and SSVEP SNR (array-based, scipy only — no MNE).
SNR follows the standard SSVEP definition (Norcia et al. 2015): power at the stimulus frequency divided by the mean power of neighbouring bins, excluding a small guard band, reported in dB. A harmonic-summed variant aggregates f,2f,3f.
Data convention: last axis is time. (n_times,), (n_ch, n_times) or
(n_trials, n_ch, n_times) all work; PSD/SNR keep the leading axes.
compute_psd ¶
Welch PSD. Returns (freqs, psd) with frequency on the last axis.
Source code in src/ssvep/classification/spectral.py
snr_spectrum ¶
SNR at every bin: power / mean(neighbouring bins, guard excluded). Same shape.
Source code in src/ssvep/classification/spectral.py
snr_at ¶
target_snr_db ¶
SNR (dB) at target f, optionally harmonic-summed over f,2f,3f...
Source code in src/ssvep/classification/spectral.py
target_snr_from_data ¶
target_snr_from_data(data, sfreq, f, n_harmonics=3, neighbor_hz=1.0, guard_hz=0.2, average_trials=True)
Convenience: per-channel SNR (dB) at f straight from a data array.
data (n_trials, n_ch, n_times) or (n_ch, n_times). With trials and
average_trials, the PSD is averaged across trials first.
Source code in src/ssvep/classification/spectral.py
Metrics¶
ssvep.classification.metrics ¶
Classification metrics for SSVEP BCI: accuracy, macro precision, Wolpaw ITR.
Pure NumPy + scikit-learn. Rigor helper: always pass chance = 1/n_classes
alongside any accuracy you report.
wolpaw_itr ¶
Information transfer rate in bits/min (Wolpaw et al. 1998).
selection_time_s = decision window + inter-selection overhead. B = log2(N) + Plog2(P) + (1-P)log2((1-P)/(N-1)); ITR = B*60/selection_time.
Source code in src/ssvep/classification/metrics.py
classification_metrics ¶
Accuracy, macro precision, ITR, and n for one set of predictions.
Source code in src/ssvep/classification/metrics.py
Visualisation¶
ssvep.classification.viz ¶
Domain-generic SSVEP plotting primitives (matplotlib only).
plot_channel_array: discrete per-channel map (NO interpolation) for sparse / single-region montages where interpolated topomaps mislead. Bichromatic diverging for signed values (SNR), monochromatic sequential for non-negative values (impedance).plot_window_sweep: mean ± SD vs decision-window length, with a chance line.plot_confusion: normalised confusion matrix.
plot_channel_array ¶
plot_channel_array(positions, ch_names, values, ax=None, title=None, vlim=None, cbar=True, label='value', diverging=True, cmap=None, show_values=None, norm=None, cbar_ticks=None)
One coloured disc per channel at its 2-D montage position; no interpolation.
positions: dict ch -> (x, y) (e.g. cm from a reference landmark). Discs are drawn in data
coordinates with a radius just under the nearest-neighbour spacing, so they never overlap
regardless of channel count (a 64-ch cap reads as cleanly as an 8-ch montage); the figure grows
with the montage. show_values prints the numeric value inside each disc — defaults to on for
sparse montages (≤20 ch) and off for dense ones (colour + colourbar carry the value there); the
label font shrinks with channel count so it fits the disc on a dense cap.
diverging=True -> bichromatic, centred at 0 (default RdBu_r) for signed values.
diverging=False -> sequential from 0 (default Greens) for non-negative values.
norm (+ a cmap that may be a Colormap object) overrides the auto scale — e.g. a
BoundaryNorm + ListedColormap for discrete pass/marginal/fail zones; cbar_ticks then
labels the boundaries.
Source code in src/ssvep/classification/viz.py
plot_impedance ¶
plot_impedance(positions, ch_names, kohm, ax=None, vmax=None, title='Electrode impedance', cmap=IMPEDANCE_CMAP, zones=None)
Discrete channel-array map of impedance (kΩ).
Thin wrapper over :func:plot_channel_array (sequential, non-negative). kohm aligns to
ch_names; NaN renders as an 'n/a' (unmeasured) marker. vmax is the top of the colour
scale; None auto-scales to at least 100 kΩ, expanding (rounded to the next 100) to cover
the worst channel — so gel montages sit on a 0–100 scale while dry electrodes (which read many
hundreds of kΩ) aren't all pinned to the top colour.
zones (e.g. :data:ACTICHAMP_IMPEDANCE_ZONES) switches the gradient for discrete
traffic-light bands — a (upper_kohm, colour) list, worst bound last. Each channel takes the
colour of the first band its value falls under; the colourbar shows the bands with ticks at the
boundaries. vmax is ignored when zones is given.
Source code in src/ssvep/classification/viz.py
plot_window_sweep ¶
series: dict label -> (mean_array, sd_array) over windows.
ylim fixes the y-axis to (lo, hi) so plots are comparable across reports; None keeps
matplotlib's autoscale. An auto-scaled axis makes every run's peak sit at the same height even
when the runs differ by tens of bits/min, so the reader can't eyeball which run was better — the
report layer passes a fixed range for exactly that reason.
Source code in src/ssvep/classification/viz.py
ssvep.stim — stimulus design¶
Flicker math and design-time validation¶
ssvep.stim.flicker ¶
Flicker math + design-time validation (pure; no pyglet, no GL).
Everything timing-critical about an SSVEP stimulus reduces to: what luminance should this
target show on frame N of a display refreshing at R Hz? Keeping that here — as plain,
deterministic functions of a frame counter — means the renderer is a thin shell and the
important behaviour is unit-testable without a display (see tests: an FFT of
frame_luminance_sequence must peak at the rendered frequency).
Conventions
- Luminance is normalised to
[0, 1](0 = black, 1 = max). The renderer maps this to pixel values with :func:gamma_encode. - Phase is in cycles (0..1), matching the manifest.
phase=0starts a sine at its mean rising through zero; a trial resets the frame counter so phase is reproducible. - Time is derived from the frame counter,
t = frame / refresh_hz— never wall-clock — so the waveform is deterministic and vsync-locked.
frames_per_cycle ¶
Display frames per flicker cycle, refresh / freq (non-integer allowed for sine).
rendered_frequency ¶
Actual displayed frequency given the render method.
sine— continuous phase → exact nominal frequency.square— integer frames per (half-)cycle → quantises torefresh / round(refresh/f)and drifts from nominal at high freq / low frames-per-cycle.
Source code in src/ssvep/stim/flicker.py
is_renderable ¶
A frequency is renderable only below Nyquist of the display (freq < refresh/2).
photosensitivity_risk ¶
Classify the seizure-provocation risk of a flicker frequency.
Frequency drives the classification; large area (>~25% of the field) and high contrast raise it. This is a design-time warning aid, not a medical guarantee.
Source code in src/ssvep/stim/flicker.py
sine_luminance ¶
Sinusoidal luminance in [0,1]: mean + (depth/2)·sin(2π(f·t + phase)). Vectorised.
Source code in src/ssvep/stim/flicker.py
square_luminance ¶
Two-level square luminance: high while within the duty fraction of the cycle.
Source code in src/ssvep/stim/flicker.py
gamma_encode ¶
Map linear luminance [0,1] → display pixel value [0,1] using the inverse gamma.
Displays are ~gamma-2.2; to make perceived/linear luminance sinusoidal we raise to
1/gamma. Set gamma=1.0 if the monitor/LUT is already linearised.
Source code in src/ssvep/stim/flicker.py
frame_luminance_sequence ¶
frame_luminance_sequence(freq_hz, refresh_hz, n_frames, *, kind='sine', phase=0.0, depth=1.0, mean=0.5, duty=0.5, gamma=1.0)
Per-frame luminance the renderer will show for n_frames (t = frame / refresh).
Returns a (n_frames,) array. This is exactly what the pyglet renderer samples, so a
spectral test on it verifies the timing-critical output. gamma defaults to 1.0 (raw
linear luminance) so tests see the pure waveform; the renderer passes the display gamma.
Source code in src/ssvep/stim/flicker.py
luminance_at_frame ¶
Scalar luminance in [0,1] for a manifest flicker block at a given frame.
Honours type (sine/square), freq_hz, phase, contrast (=modulation depth
about mean 0.5) and duty (square only). This is the single per-frame luminance the
renderer samples for a solid target — kept here so the contrast/duty maths is unit-tested
without a display. t = frame / refresh (frame-counted, never wall-clock).
Source code in src/ssvep/stim/flicker.py
target_rgb ¶
Combine a scalar luminance with a base colour → an 8-bit gamma-encoded (r,g,b).
The flicker modulates each channel linearly (in linear-light) between the background
(luminance=0) and the target color (luminance=1), then gamma-encodes to pixels::
rgb_linear = background + luminance · (color − background)
pixel = round(gamma_encode(rgb_linear) · 255)
So a white target on a black background reproduces the historic grayscale flicker, while a
coloured target flickers between the background and that colour. color may be RGB or
RGBA (alpha ignored). Pure + unit-tested; the renderer is a thin wrapper over this.
Source code in src/ssvep/stim/flicker.py
is_dropped_frame ¶
True if an inter-frame interval overshot the expected period by more than tol.
dt beyond (1+tol)/refresh means at least one refresh was missed — the renderer
logs these (an SSVEP killer if frequent).
Source code in src/ssvep/stim/flicker.py
Run builder¶
ssvep.stim.builder ¶
Run builder data-model: configure one SSVEP run and emit its manifest.
This is the UI-agnostic core of the stimulus builder (the PySide6 Design GUI will drive these
objects). It models targets, timing, conditions, acquisition, and markers; validates the
design at build time (renderability + photosensitivity — DESIGN_PRINCIPLES #4, COMPLIANCE R6);
and serialises to a schema-valid run manifest — the single source of truth analysis reads. The
central model is :class:RunSpec (one run). The session level — an ordered set of runs driven
by a protocol — is :mod:ssvep.runtime.session (#43); a session-protocol references the run
manifests a RunSpec emits.
Timing
dataclass
¶
Per-trial timing. stim_s is the usable (decodable) stimulation window.
onset_offset_s is a visual-latency lead-in that is added on top of stim_s: the
target flickers for stim_s + onset_offset_s and analysis discards the lead-in, so the
operator's "4 s stimulation" yields a full 4 s of usable steady-state data (flickering 4.14 s).
See :data:ssvep.DEFAULT_ONSET_OFFSET_S for why.
Source code in src/ssvep/stim/builder.py
presented_stim_s
property
¶
What actually flickers per trial: usable window + the discarded visual-latency lead-in.
MontageChannel
dataclass
¶
Source code in src/ssvep/stim/builder.py
position_pct
class-attribute
instance-attribute
¶
Head-relative 10-10 position (lateral_pct, height_pct), when the montage is defined
that way rather than in centimetres (see :func:occipital_ssvep_montage).
lateral_pct is the distance from the midline as a percentage of the left→right
preauricular arc, signed left(-)/right(+); height_pct is the distance up from the
inion as a percentage of the nasion→inion arc. Both are the 10-10 system's own units,
so they are head-size independent — position_2d is the same point in cm on the
:data:REFERENCE_HEAD_CIRCUMFERENCE_CM reference head, and is what the discrete channel-array
plots use. Fixed-geometry carriers (the Unicorn, the actiCAPs) leave this None: their
electrodes are moulded or held in a cap, not measured onto the scalp.
Run
dataclass
¶
Run-identity block (manifest run, was experiment pre-1.3). Names one run + its BIDS task.
Source code in src/ssvep/stim/builder.py
RunSpec
dataclass
¶
One run's design — emits a run manifest via :meth:to_manifest (was Protocol pre-#43).
A run is one continuous period of data collection (CLAUDE.md §2.1). A session composes an
ordered set of these by reference; see :mod:ssvep.runtime.session.
Source code in src/ssvep/stim/builder.py
197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 | |
is_resting
property
¶
True for the tone-cued eyes-open/eyes-closed resting paradigm (no flicker at all).
effective_refresh_hz ¶
Refresh used for validation: the MEASURED rate if present, else the nominal one.
validate ¶
Design-time checks: renderability (errors) + fidelity/photosensitivity (warnings).
Judged against the measured refresh when available (see effective_refresh_hz). A resting
protocol has no flicker, so the per-target renderability/photosensitivity checks don't apply
and are replaced by block-design checks (see :meth:_validate_resting).
Source code in src/ssvep/stim/builder.py
to_manifest ¶
Serialise to a manifest dict conforming to run_manifest.schema.json.
Source code in src/ssvep/stim/builder.py
save ¶
Validate (raise on any error) and write the manifest to path.
Source code in src/ssvep/stim/builder.py
acquisition_to_dict ¶
Serialise an :class:Acquisition to the manifest acquisition block (schema-conformant).
Pulled out of :meth:RunSpec.to_manifest so it has exactly one implementation: a run built on
Build run goes through it, and so does the Build Protocol "Set acquisition for all runs…" action
(#121), which writes this same shape straight into every embedded run's manifest.
Source code in src/ssvep/stim/builder.py
grid_positions ¶
Row-major normalised (x, y) centres for an n_cols × n_rows grid in [-spread, spread].
Source code in src/ssvep/stim/builder.py
montage_pct_to_cm ¶
montage_pct_to_cm(lateral_pct, height_pct, nasion_inion_cm=REFERENCE_NASION_INION_ARC_CM, preauricular_cm=REFERENCE_PREAURICULAR_ARC_CM)
A 10-10 percentage position as tape-measure centimetres on a head of the given arc lengths.
Returns (x_cm, y_cm) — x left(-)/right(+) of the midline, y up from the inion — which
is exactly what the operator measures: up the midline first, then out from that point. Defaults
are the :data:REFERENCE_HEAD_CIRCUMFERENCE_CM head the montage was originally drawn on.
Source code in src/ssvep/stim/builder.py
occipital_ssvep_montage ¶
Free electrode occipital SSVEP montage — the 8-channel occipital array for the OpenBCI Cyton with free (unmounted) electrodes. The lab's dry headband carries the same layout.
The montage is defined in 10-10 units (:data:OCCIPITAL_SSVEP_MONTAGE_PCT) so it transfers
across head sizes; position_pct carries that definition and position_2d is the same point
in cm on the reference head — x left(-)/right(+) of the midline, y up from the inion,
so the impedance map renders nose-up with the participant's left on the left.
E1..E8 are the Cyton's 8 EEG channels, labelled by their wire colour
(:data:CYTON_CHANNEL_WIRE_COLOURS) because on a free-electrode array the colour is the only
thing telling one electrode from another. Reference and ground are not EEG channels and so do not
appear here: white/SRB goes to the left earlobe and black/BIAS to the right
(:data:OCCIPITAL_SSVEP_REFERENCE, :data:OCCIPITAL_SSVEP_GROUND).
Source code in src/ssvep/stim/builder.py
occipital_1010_montage ¶
Occipital 10-10 montage — P1 PO3 O1 POz Oz P2 PO4 O2 on an OpenBCI Cyton, in board
order 1..8, with the same ear reference and ground as :func:occipital_ssvep_montage.
Unlike that montage, this one is defined by its site names: "PO3" already says exactly where the
electrode goes, in a system every EEG lab and every cap already implements — so there is no
position_pct to record, and nothing here needs a reference head. The gain is comparability
(results line up with the SSVEP literature, and the channels mean something to a reader who has
never seen this toolbox) and the ability to place it from a cap; the cost is less density right
over the occipital pole, because the array has to land on sites the 10-10 grid happens to provide.
label is the 10-10 site — the electrode's identity here — but the wire colours are the same
Cyton ribbon order (:data:CYTON_CHANNEL_WIRE_COLOURS), so channel 3 is still the blue lead.
position_2d reuses :func:acticap_64ch_montage's coordinates for every site, so this montage
and the 64-channel cap plot identically rather than drifting apart in two hand-typed tables.
Source code in src/ssvep/stim/builder.py
unicorn_hybrid_black_montage ¶
The g.tec Unicorn Hybrid Black's fixed 8-channel montage — Fz C3 Cz C4 Pz PO7 Oz PO8
(standard 10-20 positions) in the device's channel order 1..8 (see docs/UNICORN_INTEGRATION.md,
validated on hardware 2026-07-07).
Unlike the OpenBCI carriers, the Unicorn's electrodes are moulded into the headset, so this montage
is fixed (not editable). Positions are an approximate 2D scalp projection with the same convention
as :func:occipital_ssvep_montage — x = left(-)/right(+) of the midline, y =
anterior(+)/posterior(-), Cz at the origin — so the impedance map renders nose-up. label is the
electrode's 10-20 name (the Unicorn labels channels by site, not by wire colour).
Source code in src/ssvep/stim/builder.py
acticap_64ch_montage ¶
Brain Products actiCAP 64Ch Standard-2 layout, in the actiCHamp's channel order 1..64.
Positions are an approximate 2D scalp projection in the same convention as the other montages —
x = left(-)/right(+) of the midline, y = anterior(+)/posterior(-), Cz at the origin, in cm
on a ~9.5 cm scalp disc — so the impedance map renders nose-up with the correct hemispheres. Labels
are the 10-10 site names; channel is the amplifier channel index. Schematic (recognisable), not
digitised positions — a proper digitiser layout can replace this later. (Ground/reference are the
actiCHamp's dedicated GND/REF pins, not EEG channels, so they're not listed.)
Source code in src/ssvep/stim/builder.py
acticap_32ch_posterior_montage ¶
NCIL's 32-channel posterior actiCAP layout for the actiCHamp — every 10-10 site at or behind the TP/CP row, plus FCz and Cz as midline anchors. Not a stock actiCAP montage: the electrodes are plugged into the posterior holders of the 64-holder cap, concentrating coverage over visual cortex.
Channel order is the amplifier's, as wired at the cap (confirmed with Aaron 2026-07-15): ch1 FCz, ch2 Cz, then complete rows sweeping left→right, rows running anterior→posterior. PO9/PO10 belong to the O row (they sit at occipital height), not the PO row. That fills 1..32 exactly — the 64Ch Standard-2 cap has precisely 30 holders from the TP/CP row back, and 30 + FCz + Cz = 32.
Positions reuse :func:acticap_64ch_montage's coordinates for every shared site, so the two
montages plot identically; FCz is new (midline at the FC row's y). Same convention as the
other montages — x = left(-)/right(+), y = anterior(+)/posterior(-), Cz at the origin, cm.
The actiCHamp is reference-free, so this montage decodes with a common-average reference and an
occipital ROI automatically (32 > :data:ssvep.spatial.BIG_MONTAGE); it carries the whole validated
occ-8 (O1 Oz O2 POz PO3 PO4 PO7 PO8), so no position-based fallback is needed. ⚠️ The amp still
streams 64 channels with only module 1 populated (33-64 rail at +full-scale) — the LSL bridge
truncates to this montage's length; see :func:ssvep.runtime.actichamp.stream_to_lsl.
Source code in src/ssvep/stim/builder.py
device_for_board ¶
Reverse-lookup a device display name from a BrainFlow board id (for loading manifests).
device_electrode_types ¶
Electrode types this device can use, or None if it is not constrained.
A device whose electrodes are fixed in hardware (the actiCHamp's actiCAP, the Unicorn's moulded-in dry contacts) implies its electrode type; a flexible OpenBCI board does not.
Source code in src/ssvep/stim/builder.py
device_preps ¶
Skin/electrode preps this device allows, or None if it is not constrained.
device_headsets ¶
The electrode carriers this device can wear, or None if it accepts any of them.
None = the OpenBCI boards, which take any of the interchangeable OpenBCI carriers. A list =
the device constrains the choice: one entry means the montage is fixed in hardware (the Unicorn's
moulded-in electrodes) and the Design tab locks it; several means the operator must pick which cap
is on the participant (the actiCHamp's 64-ch vs 32-ch posterior actiCAP).
Source code in src/ssvep/stim/builder.py
device_fixed_headset ¶
The carrier for a device whose montage is fixed in hardware, else None.
Only devices with exactly one allowed carrier are "fixed" — a device offering a choice (the
actiCHamp) returns None here. Use :func:device_headsets to restrict a pick-list.
Source code in src/ssvep/stim/builder.py
canonical_headset ¶
The current name for a carrier, mapping any pre-rename alias forward. Unknown names pass through unchanged — an unrecognised carrier is data to preserve, not an error to raise.
Source code in src/ssvep/stim/builder.py
headset_electrode_type ¶
The electrode kind implied by a carrier, or None for a carrier we don't recognise.
The single place the derivation lives, so the Design tab, the protocol-level acquisition dialog and the per-session override all record the same string for the same headset (#147).
Source code in src/ssvep/stim/builder.py
headset_reference_ground ¶
(reference, ground) implied by a carrier, or (None, None) when it implies neither.
Source code in src/ssvep/stim/builder.py
example_protocol ¶
The worked 9-target, 36–44 Hz protocol (à la schemas/manifest_example.json), on the go-forward OpenBCI Cyton 8-ch occipital montage. Mirrors the pilot's operating band.
Source code in src/ssvep/stim/builder.py
jfpm_phase_cycles ¶
JFPM phase schedule for n targets, returned in cycles (the manifest unit).
Joint frequency–phase modulation (Chen et al. 2015) assigns target k the phase
k · step radians (canonical step 0.35π). The manifest stores phase in cycles, so this
converts: phase_cycles = (k · step_rad) / (2π) (wrapped to [0,1)).
Source code in src/ssvep/stim/builder.py
preset_high_freq_9class ¶
Preset: the worked high-frequency 9-class protocol (alias of :func:example_protocol).
preset_alpha_9class ¶
Preset: the 9-target grid of :func:example_protocol, but flickering in the alpha band
(8–12 Hz, 0.5 Hz steps) instead of 36–44 Hz.
Purpose: a fair test for the dry/prototype headsets. The high-frequency (36–44 Hz) response is tiny, and on 2026-07-14 both dry systems (Zeist, Unicorn) decoded that band at chance; alpha-band SSVEP is an order of magnitude larger, so it separates "electrode can't reach the cortex" from "the response is just small up here". Same layout/timing/montage as the high-freq preset so the only changed factor is stimulation frequency.
⚠️ Two caveats, both surfaced by validate() / worth stating to the operator:
* Photosensitivity (COMPLIANCE R6). 8–12 Hz sits in the provocative ~3–30 Hz band, so
validate() raises elevated/high-risk warnings. This is a template to adapt under an
appropriate REB amendment (docs/REB_AMENDMENTS.md), not to run as-is.
* Endogenous-alpha confound. These frequencies overlap the participant's own ~10 Hz alpha
rhythm (strongest eyes-open, occipital), which can inflate or contaminate the SSVEP — the
opposite trade-off from the high-freq band. Interpret decode accuracy with that in mind.
Source code in src/ssvep/stim/builder.py
preset_jfpm_speller ¶
Preset: a Chen et al. 2015-style JFPM speller (default 40 targets, 8–15.8 Hz, 0.2 Hz steps).
Frequencies step by df from f0; phases follow the JFPM schedule. NOTE: these low
frequencies fall in the ~3–30 Hz provocative band, so validate() will warn — for this REB
prefer the high-frequency band. Provided as a canonical template to adapt, not to run as-is.
Source code in src/ssvep/stim/builder.py
preset_checkerboard_reversal ¶
Preset: a 4-target contrast-reversing checkerboard example in the study-safe high band.
Pattern-reversal (the two phases swap at the flicker frequency) with no net luminance change — the classic pattern-reversal SSVEP/VEP stimulus. Uses degrees-of-visual-angle geometry.
Source code in src/ssvep/stim/builder.py
preset_resting_state ¶
Preset: the tone-cued eyes-open / eyes-closed resting run — the odd duck with no flicker.
Resting is a paradigm, not a stimulus layout, so it carries paradigm='resting' and an empty
stimuli list; its structure lives in design.resting (the manifest's authoritative record of
it). Everything else about a protocol still applies unchanged — the device/montage/sampling rate
chosen on the Design tab, the operator/consent/subject captured at Setup, the impedance check —
which is the whole reason it's expressed as a manifest rather than a special-case dialog: it locks,
records, and saves through exactly the same path as an SSVEP run.
Two jobs (see ssvep.stim.resting / ssvep.analysis.resting_paf):
* PAF calibration — the participant's individual peak alpha frequency, so SSVEP designs can
steer flicker away from PAF and its harmonics.
* Negative control + posterior QC — a well-behaved decoder must sit at chance on data with no
flicker in it, and eyes-closed alpha should exceed eyes-open (reactivity ratio > 1). A ratio
near 1 condemns the posterior montage before an SSVEP run is wasted on it (the sub-903/904
lesson).
Literature defaults (Klimesch 1999; Barry et al. 2007): 6 × 60 s alternating from eyes-open → 3 min eyes-closed. Grey background + central fixation cross during eyes-open blocks.
⚠️ Not in the approved REB protocol (V6 covers visual flicker only — no resting recording and
no auditory stimulation). Collection needs amendment A10; see docs/REB_AMENDMENTS.md.
Source code in src/ssvep/stim/builder.py
ssvep.runtime — sessions and acquisition¶
Run plan¶
ssvep.runtime.run_plan ¶
Run plan: expand a run manifest into a deterministic, ordered list of trials + markers.
The runtime runs ONLY built, saved, versioned protocols. Given a manifest, this derives the
condition × block × trial structure, assigns a cued target (frequency) per trial, and produces
the marker sequence — all reproducibly from a seed (recorded in provenance). The runner
attaches real LSL timestamps when each marker fires.
RunPlan
dataclass
¶
Source code in src/ssvep/runtime/run_plan.py
markers_for_trial ¶
cue → stim_on → stim_off markers for one trial (block_start/end added by the runner).
Source code in src/ssvep/runtime/run_plan.py
Session protocol and resolver¶
ssvep.runtime.session ¶
The session level: a protocol (template) → a concrete, ordered, frozen set of runs (#43).
Terminology (CLAUDE.md §2.1): a run is one continuous recording (one run manifest, planned by
:mod:ssvep.runtime.run_plan); a session is an ordered set of runs; a protocol is the
instructions for running a session on a participant. This module is the protocol layer that #42
unblocked — it reuses the module name freed when the old session.py (a run planner) became
run_plan.py.
A :class:SessionProtocol is a template: authored once, instantiated per participant. It
contains its run manifests and groups them; each group declares how its runs are ordered for
a given participant. :func:resolve_session turns (protocol, sub-XXX, ses-YYY) into an ordered
list of :class:ResolvedRun — each carrying the frozen run manifest, a derived run_index,
and the position block stamped into its recording's sidecar. The order is derived and recorded,
never inferred from files on disk (the manifest-spine rule, one level up). A protocol never redefines
a run's design: to run the same design on different hardware, add two run manifests — there are no
per-run overrides.
Protocol schema v1.2 (#115) embeds each run manifest in the protocol; before it, a protocol held
a path + pinned fingerprint and the resolver loaded the file, hard-stopping on drift. Embedding makes
a protocol one self-contained artifact — copy it to an acquisition PC and the whole design goes with
it — and means a saved protocol always runs, where before it could be silently broken by anyone
editing a run manifest it pointed at. What was lost with the resolve-time drift stop is regained by
:meth:SessionProtocol.source_report, which asks the same question at design time and can be
acted on. Pre-1.2 protocols still resolve exactly as they did, drift stop included.
The core (protocol model + ordering + resolver) is pure and unit-tested: it scans no sourcedata,
and for an embedded protocol it touches no filesystem at all. The helpers :func:next_session /
:func:session_exists are separate, so the resolver stays pure.
ProtocolDriftError ¶
Bases: RuntimeError
A referenced run manifest's on-disk fingerprint no longer matches the protocol's pin.
Raised by :func:resolve_session (unless allow_drift): the design a participant would run has
silently diverged from the design the protocol was authored against. A design change must be a
deliberate new protocol version, never a side effect of editing a referenced run manifest — so
this is a hard stop, not a warning (CLAUDE.md §2: declared, never inferred).
Source code in src/ssvep/runtime/session.py
RunRef
dataclass
¶
One run of a group: the run manifest itself, plus where it was copied from.
Since protocol schema v1.2 (#115) the manifest is embedded and authoritative — the resolver
runs :attr:manifest and never reads the store. That makes a protocol a single self-contained
file (copy it to an acquisition PC and the whole design travels with it) and makes a saved
protocol un-breakable by someone editing a run manifest it happens to point at.
:attr:run_manifest / :attr:fingerprint survive as the source pointer: where this copy came
from and what it fingerprinted at embed time. Nothing at record time reads them. They exist so
:meth:source_status can answer "has the file I copied this from moved on?" at design time,
which is where a design question belongs — the previous scheme could only answer it at resolve
time, i.e. by refusing to start a session with the participant already in the chair.
A pre-1.2 protocol has no :attr:manifest. Those still load, and :func:resolve_session still
resolves them the old way — store lookup plus a fingerprint check that hard-stops on drift.
Source code in src/ssvep/runtime/session.py
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 | |
pin
classmethod
¶
Embed the run manifest at store_dir / run_manifest, recording where it came from.
The manifest is loaded through :func:ssvep.io.manifest.load_manifest, so it is canonicalized
to v1.3 shape and schema-validated here — an unloadable or invalid run manifest fails while
someone is authoring, not while someone is recording.
Source code in src/ssvep/runtime/session.py
resolve_manifest ¶
This run's manifest and its fingerprint — embedded copy, or the legacy store lookup.
Embedded (v1.2+): returned as-is. There is nothing to verify, because there is nothing else it could have come from; the protocol is the design.
Legacy (v1.0/v1.1): loaded from store_dir and re-fingerprinted, raising
:class:ProtocolDriftError on a mismatch unless allow_drift.
Source code in src/ssvep/runtime/session.py
source_status ¶
Whether the file this run was embedded from still matches it — a design-time question.
One of :data:SOURCE_CURRENT / :data:SOURCE_DRIFTED / :data:SOURCE_MISSING /
:data:SOURCE_UNKNOWN. Never raises: an unreadable or invalid source file is missing,
because the answer this feeds is a report, and a report that throws is a report nobody sees.
Source code in src/ssvep/runtime/session.py
SessionProtocol
dataclass
¶
Source code in src/ssvep/runtime/session.py
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 | |
has_embedded_runs ¶
True once any run carries its manifest inline — i.e. this file needs a 1.2 reader (#115).
source_report ¶
Every run paired with whether its source file still matches it (:meth:RunRef.source_status).
The design-time replacement for the old resolve-time drift stop: authoring can ask "what have I fallen behind on?" and act on it, instead of finding out when a session refuses to start.
Source code in src/ssvep/runtime/session.py
duplicate_run_labels ¶
Runs in this protocol that share a run.name or run.description with another run.
A protocol's runs are meant to be distinct designs, so two sharing either field is either a genuine duplicate (the same run embedded twice) or a copy-paste that was never updated for the run it was pasted into — exactly what happened in the 2026-08-28 pilot, where six runs at three different frequency bands (31-39 Hz, 46-54 Hz) all carried the stale description "9-class high-frequency SSVEP, 36-44 Hz operating band" from the run they were cloned from. Neither is something Save should wave through unremarked.
Returns one entry per colliding (field, value) — {"field": "name"|"description",
"value": ..., "runs": [(group_label, run_label), ...]} — worst (most runs sharing a
value) first. A run whose manifest can't be resolved (a legacy reference with no store, or
a source file that has moved) is skipped rather than raising: this is a design-time nicety
layered on top of :meth:source_report, not a replacement for it.
Source code in src/ssvep/runtime/session.py
schema_version ¶
The schema version this protocol is, not the one it was loaded as.
Derived, not stored: a protocol carrying questionnaire blocks uses 1.1 features and says so, while one without them round-trips byte-identically as whatever it already was. The stamp then means "this file needs a 1.1 reader", never "a 1.1 build opened it" — so loading a pilot protocol to look at it does not rewrite its version.
Everything that reports a schema version must come through here. It did not, once: the file
said 1.1 and every recording made from it said 1.0, because to_dict derived the value and
the sidecar stamp read the stale protocol_version attribute off an in-memory object that
had been loaded as 1.0 and had questionnaires added since (caught on the sub-000 hardware
check, 2026-08-01). One protocol, two answers, depending on which artifact you read.
Highest feature in use wins: embedded runs (1.2) outrank questionnaires (1.1), since a 1.2 reader necessarily understands 1.1.
Source code in src/ssvep/runtime/session.py
fingerprint ¶
Semantic fingerprint of the protocol itself — its identity in each recording's sidecar.
ResolvedCheckpoint
dataclass
¶
One questionnaire administration in a resolved session (#20).
A checkpoint sits between runs — after a group's last run, or after the whole session — so
nothing is streaming while it happens and there are no markers to write, only a timestamp. It is
resolved rather than improvised for the same reason a run's run_index is: where in the session
a participant was asked is part of the record, not something to reconstruct from file times.
Source code in src/ssvep/runtime/session.py
ResolvedSession
dataclass
¶
Source code in src/ssvep/runtime/session.py
sequence ¶
Runs and checkpoints interleaved into the literal running order of the session.
A checkpoint lands immediately after the run whose index it follows, so this is what the operator's checklist shows and what "the next thing to do" means.
Source code in src/ssvep/runtime/session.py
RunStatus
dataclass
¶
Source code in src/ssvep/runtime/session.py
ItemStatus
dataclass
¶
One row of the session checklist — a run or a checkpoint, with its state derived from disk.
Source code in src/ssvep/runtime/session.py
williams_sequences ¶
Balanced Latin square (Williams design) over n items, as index orderings of range(n).
Each item is immediately preceded by every other item equally often (first-order carryover
balanced) — the right within-subject counterbalance where fatigue/adaptation carry over between
SSVEP runs. Even n needs n sequences; odd n needs 2n (the square plus its row
reverses). n<=1 ⇒ the single trivial ordering.
Source code in src/ssvep/runtime/session.py
order_indices ¶
This participant's ordering of n runs in a group, plus the counterbalance arm (or None).
Pure and deterministic in (order, n, subject, seed, label). fixed keeps authored order;
counterbalanced picks a Williams sequence by subject_number mod #sequences; randomized
shuffles with a per-group seed.
Source code in src/ssvep/runtime/session.py
resolve_session ¶
Instantiate protocol for one participant's session into an ordered, frozen run list.
Inputs are the template, the participant sub-XXX, and the visit ses-YYY. Groups run in
authored order; within each group the runs are reordered per its scheme (derived from the
participant), then flattened and stamped with run_index 1..N.
store_dir is needed only for a pre-1.2 protocol, whose runs are paths relative to the
run-manifest store: those are loaded and re-fingerprinted, raising :class:ProtocolDriftError on
a mismatch unless allow_drift. A v1.2 protocol carries its manifests, so store_dir is
unused and may be omitted — the whole resolution is then pure in-memory, which is the point: no
file outside the protocol can change what a participant runs.
The resolver never scans sourcedata and never assigns ses-YYY — that is the caller's, kept
out so this stays a pure function of its inputs.
Source code in src/ssvep/runtime/session.py
session_progress ¶
Which runs of a resolved session are already recorded — derived from disk, no progress file.
Three states, because two were not enough. A run is pending when its *.session.json
sidecar is absent, so a crash before saving is correctly offered again. It is done when the
sidecar shows every planned trial was recorded. In between it is partial: the sidecar exists,
but the run was aborted or lost its stream part-way.
That middle state is the sub-002 lesson. An 11-second abort still saves (deliberately — partial data is data), and when "the sidecar exists" was the whole test, the checklist called that run recorded and moved on. The operator, who could see perfectly well that it had not been, worked around the checklist by hand-numbering the next run — which took it out of the protocol entirely and cost it its fingerprint pin. Showing partial is what removes the reason to improvise.
The recordings remain the state; there is still no second artifact to desync (the ses-002 lesson, §CLAUDE.md) — completeness is read out of the sidecars themselves.
(An observer-only session persists nothing, so every run reads pending here — the record UI runs those forward-only rather than resuming, since there is nothing on disk to resume from.)
Source code in src/ssvep/runtime/session.py
next_pending ¶
The first unrecorded run (lowest run_index with no sidecar), or None if none is left.
Deliberately skips partial runs rather than re-offering them: something is on disk for those, and re-recording would overwrite it. They are shown as partial in the checklist and re-run through the confirmed path, so replacing collected data stays a decision rather than a default.
Source code in src/ssvep/runtime/session.py
questionnaire_mode ¶
Which stamp this session's runs carry. Declared-and-off is not the same as never-declared.
Absence of a response file has to mean one thing for the checklist to resume correctly ("not asked yet"), so the reason there will never be one gets written down instead of inferred. A rig test with no participant and a session whose responses were lost must not read the same way later — that ambiguity is the ses-002 lesson, one artifact over.
Source code in src/ssvep/runtime/session.py
recorder_for ¶
recorder_for(run, resolved, *, operator, consent, out_dir, device=None, notes=None, seed=0, questionnaires=None, acquisition_override=None)
Build a :class:RunRecorder for one resolved run — the only place resolver meets recorder.
run_index, session, and the position block all come from the resolved run/session, so
they are never hand-set at record time (the whole point of #43). Consent is passed per session by
the caller (a visit-level property). Returns an un-run recorder; the record path drives it.
questionnaires (see :func:questionnaire_mode) rides in the position block rather than in the
protocol, because whether they were administered is a fact about this session, decided at the
bench — not part of the design. The resolver stays pure and unaware of it.
acquisition_override is the same kind of fact: what headset / electrode type / skin prep
physically ran, which the protocol cannot know because electrodes get swapped at the bench.
It was threaded through the ad-hoc record path only, so until #115 a run recorded from a
protocol — the path the lab actually uses — silently dropped it, which is the sub-902 wet/dry
metadata gap reappearing one level up. Removing the ad-hoc path made fixing this compulsory.
Source code in src/ssvep/runtime/session.py
session_checklist ¶
The whole session as an ordered to-do list — runs and checkpoints interleaved.
Completion is derived from disk for both kinds, by the same rule and for the same reason: a run
is done when its *.session.json exists (and reports every planned trial), a checkpoint when
its _beh.json exists. There is still no separate progress file to desync from the data.
questionnaires=False (the operator's "don't run questionnaires" switch — a rig test with no
participant) marks checkpoints skipped rather than hiding them: they stay visible, so nobody
has to wonder later whether the protocol had any.
Source code in src/ssvep/runtime/session.py
next_pending_item ¶
The next thing to do: the first item that is neither done, partial, nor skipped.
Partial runs are stepped over for the same reason :func:next_pending steps over them — something
is on disk, so replacing it stays a deliberate re-run rather than the default.
Source code in src/ssvep/runtime/session.py
session_exists ¶
True if a recording session dir already exists — the GUI turns this into an overwrite warning.
next_session ¶
Suggest the next ses-NNN for a participant by scanning existing session dirs (ses-001 if none).
A convenience for prefilling the operator's session field — deliberately separate from the pure
resolver, which takes ses-YYY as an input rather than deriving it from disk.
Source code in src/ssvep/runtime/session.py
ssvep.io — manifests and data¶
Run manifest I/O¶
ssvep.io.manifest ¶
Run-manifest I/O and schema validation.
The manifest (schemas/run_manifest.schema.json) is the toolbox's single source of
truth: the builder emits it, acquisition stamps it into each recording, analysis reads it.
This module loads the JSON Schemas and validates manifests/markers against them, so every
producer (builder now; runtime later) can guarantee a conformant contract.
A manifest describes exactly one run (CLAUDE.md §2.1). Manifest v1.3 renamed the
run-identity block experiment → run; :func:canonicalize_manifest maps the old key on
read so every pre-1.3 protocol and recorded sidecar still loads, and :func:run_meta is the
tolerant accessor for that block regardless of which key a given manifest carries.
canonicalize_manifest ¶
Return manifest in the v1.3 shape, mapping a legacy experiment block to run.
Idempotent and non-mutating: if the manifest already has run (or has neither key) it is
returned unchanged; otherwise a shallow copy is made with experiment moved to run. Every
sidecar ever written embeds the manifest it recorded under (RunRecorder.sidecar), and the five
saved protocols predate the rename, so this is the single point that keeps all of them loadable.
Source code in src/ssvep/io/manifest.py
run_meta ¶
The run-identity block (name/task/description), tolerant of pre-1.3 manifests.
Reads run (v1.3+) or experiment (legacy). Use this everywhere a manifest — including one
lifted out of an on-disk sidecar, which may carry either key — is read for its name or task.
Source code in src/ssvep/io/manifest.py
manifest_fingerprint ¶
Short stable hash of a manifest's semantic content — the identity a session protocol pins.
Canonicalized first (§canonicalize_manifest), so the pre-1.3 experiment → run rename does
NOT change a run manifest's fingerprint: a protocol authored against the legacy shape still
resolves against the same file re-saved in v1.3 shape. Key order is irrelevant (sort_keys).
Ties a recording / QC report / protocol pin to the exact design it was built against.
Source code in src/ssvep/io/manifest.py
paradigm ¶
The manifest's paradigm: 'ssvep' (flickering targets) or 'resting' (eyes-open/closed).
Absent ⇒ 'ssvep', so every pre-1.2 manifest keeps reading correctly.
Source code in src/ssvep/io/manifest.py
is_resting ¶
resting_params ¶
design.resting for a resting manifest (the authoritative block structure); {} if absent.
validate_manifest ¶
Raise jsonschema.ValidationError if manifest violates the schema; else return.
Canonicalized first, so a legacy experiment-keyed manifest validates against the v1.3 schema.
Source code in src/ssvep/io/manifest.py
load_manifest ¶
Load, canonicalize, and validate a manifest JSON file.
Returns the v1.3 shape (run key), so a legacy experiment-keyed file on disk — a saved
protocol or a recorded sidecar's embedded manifest — is transparently upgraded for every caller.
Source code in src/ssvep/io/manifest.py
save_manifest ¶
Validate then write a manifest to path (pretty-printed). Returns the path.
Source code in src/ssvep/io/manifest.py
BIDS conversion¶
ssvep.io.bids ¶
Convert acquired XDF recordings (sourcedata/) into a BIDS-EEG dataset.
The runtime records each run as an XDF (durable, LabRecorder-compatible) plus a
*.session.json that embeds the full run manifest, provenance, consent and impedance
(see :mod:ssvep.io.xdf and the runtime). That XDF is the raw truth and belongs in
sourcedata/. This module derives a BIDS-compliant view of it: BrainVision
(.vhdr/.vmrk/.eeg) signals at the subject level, with *_eeg.json sidecars,
*_channels.tsv, *_events.tsv (every structured marker, sample-accurate), plus the
dataset-level dataset_description.json, README, CHANGES, participants.tsv/.json
and a task-<task>_events.json describing the event columns.
Design rules honoured here:
* The manifest is the source of truth. Channel names, sampling rate, montage, target
frequencies, and trial structure all come from the embedded manifest/markers — never inferred
from the signal.
* Sample-accurate events. Each marker is mapped to its nearest EEG sample via the recorded
LSL timestamps (not time × nominal_rate), so events stay aligned even when the effective
rate drifts a few tenths of a percent from nominal.
* Compliance. Only sub-XXX codes; no demographics are invented (unknown → n/a).
Everything written lives under the git-ignored BIDS/ tree.
Units: BrainFlow (Cyton / Unicorn) delivers EEG in microvolts; MNE works in volts, so we
scale µV → V on the way into :class:mne.io.RawArray and BrainVision stores true volts.
Conversion is not optional and not a separate errand. Analysis reads the XDF directly, so
nothing downstream needs the BIDS view — which is exactly how it came to be skipped: the GUI's
Analyze tab wrote derivatives/ for a dataset that had no BIDS-standard raw data to be a
derivative of. :func:ensure_converted (with :func:is_converted as the "is it already there?"
check) is that step, callable per selection, so every path that analyses data also derives the
dataset — 01_xdf_to_bids.py remains the way to (re)derive the whole tree at once.
ConversionReport
dataclass
¶
What :func:ensure_converted did — one entry per run, for a log line or a GUI status.
Source code in src/ssvep/io/bids.py
load_session ¶
Load a *.session.json (manifest + provenance + consent + plan + impedance).
markers_to_events ¶
Build a rich BIDS events table from the structured JSON markers.
Every marker becomes a row. Each is snapped to its nearest EEG sample (via the recorded
timestamps), so onset = sample / sfreq lands on that exact sample. stim_on rows get a
duration running to the matching stim_off; the extra columns carry the decoding label
(target_freq_hz) and trial structure.
Source code in src/ssvep/io/bids.py
build_raw ¶
Assemble an :class:mne.io.RawArray (volts) with montage, meas date, line freq, annotations.
Returns (raw, events_df). The events table is the authoritative one we later write to
*_events.tsv; annotations on the raw exist so MNE-based tools see the same events.
The mains frequency comes from the site profile, not from a constant: 60 Hz is a fact about
North America, and this toolbox is not only run there (#165). Unconfigured ⇒ None, which
BIDS records as "n/a" — unknown said out loud rather than a plausible wrong number.
Source code in src/ssvep/io/bids.py
task_of ¶
The BIDS task label for a run — from its manifest, never from a caller-wide default.
The run manifest's run.task (legacy experiment.task) is the design's own statement of what
ran ('ssvep', 'rest', …). A single task= argument applied to every run in a dataset silently
relabels any run that isn't the majority paradigm: sub-902/ses-002's resting run landed in BIDS as
task-ssvep despite its manifest saying rest, which is exactly the "analysis infers design"
failure the manifest spine exists to prevent (CLAUDE.md §2).
Source code in src/ssvep/io/bids.py
bids_path_for ¶
Where this run's BIDS view lives — the same path :func:convert_run would write.
Split out so a caller can ask whether a run has been converted without converting it
(:func:is_converted); the entities are derived in exactly one place.
Source code in src/ssvep/io/bids.py
is_converted ¶
Is this run's BrainVision view already on disk, no older than its XDF, and produced by the converter code currently running?
Existence alone is not enough: the BIDS tree is derived, so a source recording that is newer than the copy derived from it means the copy describes data that has since changed. Re-deriving is idempotent and cheap relative to being quietly wrong (CLAUDE.md §2.2).
The mtime check alone misses the other direction of that same rule: a converter code change
(a bug fix in the conversion logic) doesn't touch the source XDF at all, so a run converted before
the fix landed would report "up to date" forever even though re-running the fixed converter would
produce different output (#113) — a fix that never reaches what already happened. So conversion
also stamps a *.converter.json marker (:func:_stamp_converter_version) with a fingerprint of
this module's source (:func:_converter_fingerprint), and this check compares it against the
fingerprint of the code running now. A run with no marker at all — converted before this check
existed — is treated as needing reconversion too, which is the one-time cost of adopting it.
Source code in src/ssvep/io/bids.py
ensure_converted ¶
Derive the BIDS view of refs for any run that does not already have an up-to-date one.
This is the 01_xdf_to_bids.py step, scoped to the runs a caller cares about. It exists
because analysis reads the XDF directly, so a pipeline run would otherwise fill
derivatives/ while leaving the dataset without the BIDS-standard raw data it is a derivative
of — a tree that looks analysed but isn't a BIDS dataset.
Failure to convert one run is recorded, not raised: the XDF is the source of truth and the
analysis can still run from it. progress(i, n, stem) is called before each conversion.
Source code in src/ssvep/io/bids.py
convert_run ¶
Convert one XDF run into the BIDS tree and return its EEG :class:BIDSPath.
task overrides the manifest's own label; leave it None (the normal path) to use task_of.
Source code in src/ssvep/io/bids.py
discover_runs ¶
Find every *_eeg.xdf under sourcedata/ and pair it with its *.session.json.
Source code in src/ssvep/io/bids.py
migrate_toplevel_to_sourcedata ¶
Move raw *.xdf / *.session.json / *_impedance.json sitting at the subject level
(the pre-BIDS layout) down into sourcedata/. Idempotent — returns the files moved.
Source code in src/ssvep/io/bids.py
write_dataset_metadata ¶
(Re)write the dataset-level files with our richer, compliance-aware content.
tasks is every task label present in the dataset — each gets its own task-<x>_events.json,
because a dataset holds more than one paradigm (ssvep + rest). task is the single-label legacy
form.
Who made the dataset and under what approval comes from the site profile (#165). An
unconfigured deployment produces a dataset that names no lab and cites no ethics file — and
HowToAcknowledge is then omitted rather than invented, while License still defaults to
"not licensed for redistribution", because the restrictive reading is the safe one when nobody
has said (COMPLIANCE R5's principle applied to redistribution).
Source code in src/ssvep/io/bids.py
enrich_participants ¶
Add species / group columns to the mne-bids-generated participants.tsv.
No demographics are invented — unknown fields stay n/a (COMPLIANCE: coarse-only, no linkage).
group is inferred solely from the reserved sub-000 test code.
Source code in src/ssvep/io/bids.py
convert_dataset ¶
End-to-end: migrate legacy layout → convert every sourcedata run → write dataset metadata.
Each run is labelled from its own manifest (:func:task_of); task forces one label onto
every run and exists only for tests/legacy callers. A dataset can hold more than one paradigm, so
a task-<x>_events.json is written for every task actually present.
Source code in src/ssvep/io/bids.py
ssvep.analysis — offline pipelines¶
Batch pipeline¶
ssvep.analysis.batch ¶
Run the offline pipeline over a selected part of a BIDS tree (#67).
This is the one place the offline batch lives. BIDS/code/02_run_offline_pipeline.py is a thin
argparse wrapper over :func:run_batch, and the GUI's Analyze tab calls the same function on a
worker thread — so a session analysed from the command line and one analysed from the GUI are the
same code path, the same output location, and the same run log.
Selector. :func:select_runs filters what :func:ssvep.io.bids.discover_runs found, rather than
narrowing the glob: discovery keeps one definition of what a run is. sub/ses accept either the
bare number or the full entity (902 / sub-902) because operators type both. A selector that
matches nothing raises :class:NoRunsSelected listing what is available — a silent "0 analysed"
reads as success and is exactly how a typo'd ID becomes "my data is fine".
The BIDS view is derived first. Analysis reads sourcedata/ XDF, so nothing downstream needs
the BrainVision copy — and so it was skipped: analysing from the GUI filled derivatives/ for a
dataset that had no BIDS-standard raw data under it. :func:run_batch now runs the
01_xdf_to_bids.py step over its selection first (ensure_bids=True), converting only runs that
lack an up-to-date BIDS view. A run that fails to convert is logged and still analysed.
Two paradigms, one runner. Each recording is dispatched on its manifest paradigm field
(schema v1.2), never on the data: ssvep runs epoch → calibration-free decode → metrics → report,
while resting gets peak-alpha-frequency + eyes-closed/eyes-open reactivity via
:mod:ssvep.analysis.resting_paf, reported with the same *_report.html + *_metrics.json shape.
Spatial policy. Per run, the decode reference + channel set come from the manifest via
:func:ssvep.spatial.occipital_decode_plan: a large, reference-free cap (the 64-ch actiCHamp recorded
ground-only) decodes from the occipital ROI with a common-average reference; small/already-referenced
montages are left as recorded. Without this the 64-ch cap decodes at chance (DESIGN_PRINCIPLES #8).
Run log. Every invocation writes a timestamped log under derivatives/ssvep-analysis/logs/ with
the toolbox + dependency versions, the git commit, the selector used, and the per-run spatial
policy + results — so a log is never ambiguous about whether it covered the dataset or one session.
Output layout + index. Reports land in derivatives/ssvep-analysis/sub-XXX/ses-YYY/ and the
batch finishes by rebuilding the persistent index.html over the whole tree
(:mod:ssvep.analysis.derivatives, #80/#18). It no longer writes a summary_<stamp>.html per
invocation: that page described one batch, so a re-analysis left another one beside the reports and
none of them was the entry point. The index is derived from disk, so it covers every run analysed
so far — not just the ones this batch touched — and a crashed batch still leaves it truthful.
NoRunsSelected ¶
Bases: Exception
The selector matched no recordings. Carries what is on disk so the caller can say so.
Source code in src/ssvep/analysis/batch.py
RunResult
dataclass
¶
One recording's outcome — enough for a GUI row without re-reading the metrics.
Source code in src/ssvep/analysis/batch.py
normalize_entity ¶
'902'/'sub-902'/902 → '902'. None/blank → None.
Only the label is returned (no prefix), because that is what :class:~ssvep.io.bids.RunRef
carries. Operators type both forms and neither should be a miss.
Source code in src/ssvep/analysis/batch.py
available_entities ¶
{'902': ['001', '002'], …} — what the tree actually holds, for error messages and the GUI.
Source code in src/ssvep/analysis/batch.py
selector_label ¶
Human-readable description of a selection — recorded verbatim in the run log.
Source code in src/ssvep/analysis/batch.py
select_runs ¶
Filter discovered runs to a subject/session. Raises :class:NoRunsSelected on an empty match.
ses without sub is an error, not a cross-subject sweep: "session 002" is only meaningful
within a participant. No selector at all keeps the whole tree.
Source code in src/ssvep/analysis/batch.py
analyse_run ¶
Analyse one discovered run, dispatching on the manifest's paradigm. Never raises.
deriv is the derivatives root; the outputs go to this run's sub-XXX/ses-YYY/ under it
(:func:ssvep.analysis.derivatives.run_dir), mirroring the raw data's relative path.
Source code in src/ssvep/analysis/batch.py
discover ¶
Every run under <bids_root>/sourcedata (the GUI populates its tree from this).
run_batch ¶
run_batch(bids_root, *, sub=None, ses=None, method='fbcca', auto_spatial=True, refs=None, progress=None, to_stdout=True, ensure_bids=True, on_convert=None)
Analyse the selected recordings under bids_root, writing reports + a run log.
refs lets a caller (the GUI) pass an explicit run list it already discovered and let the
operator tick — the sub/ses selector is then not applied. progress is called as
progress(i, n, RunResult|None): before each run with None, and after it with the result,
so a GUI can show where it is without this module knowing about Qt.
ensure_bids (default on) derives the BIDS view of the selected runs first, for any that
lack an up-to-date one — the 01_xdf_to_bids.py step, scoped to the selection. Analysis reads
the XDF, so skipping it produced the failure this default exists to prevent: a derivatives/
tree over a dataset with no BIDS-standard raw data. on_convert(i, n, stem) reports its
progress (it is the slow part on a first analysis). A run that fails to convert is logged and
still analysed — the XDF is the source of truth.