Skip to content

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 — see docs/REB_AMENDMENTS.md. No collection until it's approved in writing.


1. Why bother — it has no flicker

Two jobs, both about other recordings:

  1. 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.
  2. 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. (sub-903/904: dry systems decoded at chance in the high band and the posterior contact failure was only found in analysis, afterwards.)

2. Operator SOP

Identical to any other protocol — that's deliberate:

  1. 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 acquisition block, exactly like a flicker run.
  2. 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.
  3. Save… the protocol (Run mode wants a saved, versioned protocol).
  4. 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.
  5. 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).
  6. 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 Start selected runStart 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" → BIDS task-rest, cleanly separate from task-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 sub-904 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_tone imported AudioFormat from pyglet.media, which pyglet 2.1 moved to pyglet.media.codecs; a bare except swallowed the ImportError and every cue tone no-oped on every machine. The test only asserted callable(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.pyLiveAlphaMonitor, the live QC.
  • docs/REB_AMENDMENTS.mdA10, required before collection.

Lesson from the first real resting run (sub-902/ses-002 run-008, 2026-07-15)

It produced a textbook result — PAF 9.5 Hz, eyes-closed/eyes-open ratio 4.89x — 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 5 of the 11 occipital channels the PAF averaged over (PO7/PO3/POz/PO4/PO8).

The number survived only because a constant channel contributes no variance to a band-power average. That is luck, not correctness — rerun it before trusting it.

Three things to take from this:

  1. 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-eeg in the bridge does exactly what the manifest says.
  2. Run mode now blocks this (signal_quality.check_montage + the record_run pre-roll gate), with an explicit override that is stamped into provenance.
  3. A disconnected actiCHamp channel sits at ~+410 mV DC, not at zero — and np.std on such a channel in float32 returns plausible garbage. Detect by DC offset, in float64. See docs/HANDOFF_2026-07-15_ses-002-review.md §3.3.