Resting state (eyes-open / eyes-closed) — operator SOP + how it works¶
A ~6-minute, tone-cued eyes-open / eyes-closed recording with no flicker at all. It is the odd duck of the toolbox — every other protocol is a flickering-target design — but it runs through exactly the same path as any other protocol, and that is the point of how it's built.
⚠️ REB. Resting-state recording and auditory cueing are not in the approved protocol (V6 approves visual high-frequency VEP only). Amendment A10 covers it in this deployment's amendment record. No collection until it's approved in writing.
1. Why bother — it has no flicker¶
Two jobs, both about other recordings:
- PAF calibration. The participant's individual peak alpha frequency. Alpha (~8–13 Hz) and its 2nd harmonic sit right in the low-frequency SSVEP band; a design that puts flicker on a participant's own PAF is measuring their alpha rhythm as much as a driven response. Knowing PAF lets a design steer around it.
- Negative control + posterior QC. There is nothing to decode in a resting run, so a well-behaved SSVEP decoder must sit at chance on it. And because eyes-closed alpha reliably exceeds eyes-open (alpha reactivity), the eyes-closed/eyes-open alpha ratio is a direct test of whether the occipital electrodes are reading cortex at all — a ratio near 1 condemns the montage before an SSVEP session is spent on it. (Dry systems have decoded at chance in the high band with the posterior contact failure only found in analysis, afterwards — exactly what this check is meant to catch early.)
2. Operator SOP¶
Identical to any other protocol — that's deliberate:
- Build run tab — Preset → "Resting state (eyes open/closed, PAF — no flicker)" → Load
preset. The target table is replaced by the resting block parameters; the preview pane shows the
fixation cross. Build run has no hardware fields (#147) — a resting run is saved with no
acquisitionblock, exactly like a flicker run. - Hit Preview resting to see the cross and hear both cue tones (2 short blocks, records nothing). Do this once per rig — it is the cheapest way to catch dead audio.
- Save… the protocol (Run mode wants a saved, versioned protocol).
- Build Protocol tab — add the saved resting run to a protocol, then Set up acquisition hardware to set the amplifier / sampling rate / headset for the whole protocol (#147) — it applies to a resting run exactly as it does to a flicker protocol. Save the protocol.
- Set up Session tab — subject / session / run / operator / consent; Load protocol…, Resolve / refresh and the as-run hardware fields (correctable at the bench) all live here, next to 🔒 Lock protocol and session details (#147 — #126 had put load/lock on Run Session; #147 moved them back beside the fields the lock freezes).
- Run Session tab — the checklist is already there once locked. Expand the collapsible QA check pane and run the impedance check as usual; optionally click Preview EEG on the live-EEG pane below it to check signal quality first, then Prep selected run → Start recording (auto-starts the preview too if you skipped it). If the machine can't play the cue tones you get a warning before anything starts; you can cue verbally instead, but fix the audio.
Tell the participant: "You'll hear a high beep, or the screen will flash white — open your eyes and look at the cross. A low beep, and the cross disappears — close your eyes and rest. Don't fall asleep."
The eyes-open cue is a white screen flash as well as a tone (2026-07-15). The lab PC has no
speakers, and an eyes-closed participant cannot see the fixation cross reappear — so on a mute rig
they had no cue to open at all, for the whole run. A bright full-screen flash is visible through
closed eyelids, so it works with or without audio. The tones stay for rigs that have sound; the
audio warning above still fires, because a tone is the better cue when it is available.
Configurable via design.resting.eyes_open_flash_ms (400 ms default; 0 disables).
What you're watching during the run¶
The Run Session tab's decode panel is replaced by live alpha reactivity (there are no frequencies to decode). You should see the eyes-closed blocks push the ratio above ~1.5×. If it sits near 1.0 the occipital electrodes are not reading cortex — stop and fix contact rather than finishing a run that can't answer anything.
3. Reading the report¶
BIDS/code/02_run_offline_pipeline.py routes task-rest runs to the PAF pipeline automatically
(no flag needed) and writes the usual *_report.html + *_metrics.json to
derivatives/ssvep-analysis/sub-XXX/ses-YYY/, linked from the derivatives index (docs/BIDS.md).
| Field | Meaning |
|---|---|
peak_alpha_hz |
PAF — the eyes-closed occipital spectral peak. Steer SSVEP flicker away from this and its 2nd harmonic. |
cog_alpha_hz |
Power-weighted mean frequency in band; steadier than the raw peak when alpha is broad/bimodal. |
ec_eo_alpha_ratio |
The QC. ≥1.5 healthy · 1.15–1.5 marginal · ~1.0 the montage isn't reading cortex. |
occipital_channels |
What the estimate averaged over. A montage with no 10-20 names (the colour-coded occipital array) falls back to all channels — already occipital there, but check. |
4. How it works (and why it's a manifest paradigm)¶
Resting is expressed as a manifest paradigm, not a special-case dialog:
paradigm: "resting"(schema v1.2; absent ⇒"ssvep", so every older manifest still reads).stimuli: []— literally no flicker. That absence is the design.design.resting— block length, count, start state, cue-tone frequencies, eyes-open flash, lead-in, settle, fixation cross, alpha band. The authoritative record of the structure.run.task: "rest"→ BIDStask-rest, cleanly separate fromtask-ssvep.
Everything else falls out of that: the runtime picks ssvep.stim.resting_renderer instead of
ssvep.stim.renderer (orchestrator.renderer_command) and the rest of the chain — EEG bridge, LSL
recorder, XDF, session sidecar, consent gating, provenance, impedance snapshots — is unchanged.
Analysis dispatches on the same field. Eye-state is read from each marker's condition field, never
inferred from the signal, so the alpha contrast is an honest test rather than a circular one.
History: why it's built this way (2026-07-15)¶
The first version put a "Resting-state…" button on the Design tab that ran the paradigm from a dialog. Three things were wrong with it, and they're worth remembering because they were all the same mistake:
- It saved nothing. It launched a bare renderer subprocess — no EEG bridge, no recorder, no XDF writer. The "Stream LSL block markers" checkbox opened a marker outlet that nothing subscribed to. A whole resting run was presented and no data was ever written (nor could it have been).
- It bypassed everything. No subject, no consent, no impedance check, no provenance, no lock — and it ignored the Design tab's own device/montage/rate selections.
- It was silent.
tones.play_toneimportedAudioFormatfrompyglet.media, which pyglet 2.1 moved topyglet.media.codecs; a bareexceptswallowed theImportErrorand every cue tone no-oped on every machine. The test only assertedcallable(play_tone), so it passed throughout.
The root cause of the first two is that resting wasn't a protocol — it was a dialog that
side-stepped the architecture. Making it a manifest paradigm fixes them by construction: there is no
resting-specific run path left to forget to wire a recorder into. Design previews still render (that
has always been fine — Preview session does it too); what Design never does is collect.
5. Pointers¶
src/ssvep/stim/resting.py— protocol + frame-indexed schedule + markers (pure).src/ssvep/stim/resting_renderer.py— the pyglet shell (manifest-driven).src/ssvep/stim/tones.py— cue-tone synthesis + guarded playback +probe_audio().src/ssvep/analysis/resting_paf.py— PAF + alpha reactivity (array API: offline == online).src/ssvep/runtime/online.py—LiveAlphaMonitor, the live QC.- This deployment's amendment record — A10, required before collection.
Lesson from an early resting recording (2026-07-15)¶
It produced a plausible-looking PAF and eyes-closed/eyes-open alpha-reactivity ratio — on half a cap. The protocol declared the 64-ch actiCAP while the 32-ch posterior cap was physically on the head, so the bridge faithfully streamed 64 channels of which 32-63 were floating, including several of the occipital channels the PAF estimate averaged over.
The result survived only because a constant channel contributes no variance to a band-power average. That is luck, not correctness — rerun any such session before trusting it.
Three things to take from this:
- The montage is baked into the protocol at design time. The resting preset ships with the 64-ch
actiCAP; if a different cap is on the head, the protocol is wrong, not the code.
--n-eegin the bridge does exactly what the manifest says. - Run mode now blocks this (
signal_quality.check_montage+ therecord_runpre-roll gate), with an explicit override that is stamped into provenance. - A disconnected actiCHamp channel sits at ~+410 mV DC, not at zero — and
np.stdon such a channel in float32 returns plausible garbage. Detect by DC offset, in float64 (docs/DATA_REVIEW_LESSONS.md§3.3).