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.

⚠️ Ethics approval. Resting-state recording and auditory cueing may be outside an approved visual high-frequency VEP protocol. If yours is, they need an amendment, tracked in your deployment's own amendment record. 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. (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:

  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 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. When the run is over you'll hear two notes going up, and the screen will slowly brighten to say the run is complete — then you can open your eyes and relax."

The run ends with a cue of its own (#485). A run can finish on an eyes-closed block, and a participant with their eyes shut cannot tell it is over. So the end is marked by (1) a rising two-note tone (C5 then G5), which cannot be confused with either single-note cue; (2) one end marker in the marker stream, after the final block_end; and (3) a single gentle fade-up, about 1 s, to a "Run complete — you can open your eyes" message, held for a few seconds before the window closes. It is one slow brightness ramp, not a flash or a flicker, so the resting run is still graded as carrying no flicker. Esc gives the same cue — tone, end marker, fade — because an Esc-stopped run can end in the middle of an eyes-closed block; press Esc a second time to close the window at once. (Esc is still an abort: the summary reports stopped_with_esc.) This applies to Esc pressed in the stimulus window. The operator's Abort button, or Esc in the app's own window, stops the run without the end cue, so after an operator abort, tell the participant to open their eyes. The eyes-open flash described next is unchanged.

The window holds the screen before anything is recorded (#521), as the flicker and FPVS windows do: it is made topmost when fullscreen, Esc or closing it stops the run from the moment it opens, its refresh measurement takes at most about 6 s, and its marker stream appears only after that. A fullscreen window measured far below the panel's rate is refused with the reason shown in the Run tab, because a window that is not getting the display is not presenting the run. The lead-in and every block end on the clock (#457), on the first frame at or after their start plus their declared length, so a block is never shorter than declared whatever the panel's real rate, and what each lasted is saved in the sidecar's provenance.presentation_timing. On a single screen the topmost window hides the Abort button, so Esc is the stop; if Esc ever does nothing, Alt+Tab to the toolbox and press Esc there; the toolbox may stay hidden behind the stimulus, press Esc anyway, and it stops the run.

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 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_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.py — LiveAlphaMonitor, the live QC.
  • A deployment whose approved protocol predates resting-state collection must have it approved before collecting.

Lesson from an early resting recording

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 a partly-populated 32-electrode 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:

  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.