Skip to content

Windows PC test runbook (hand this to Claude on the acquisition PC)

Goal: verify the SSVEP toolbox on real display hardware — things that can't be checked in a headless environment. §1–5 need no OpenBCI board (this repo, conda, a 240 Hz monitor, and the flik-o-meter photodiode). §6 covers the OpenBCI Cyton + WiFi shield acquisition path.

Guardrails (do not violate): - Never commit participant data (no BIDS/ data, *.xdf, logs). Code/docs only. - The renderer preview + flik-o-meter involve no participant — fine to run now. - Actual data collection is blocked until the ethics amendments covering this hardware are approved — don't collect.

Report results back as a short summary (values + any errors + a screenshot or two).


Before any timing check or session: three things to look at

Each of these was found the hard way on the acquisition PC (2026-10-09, #171). The first two happen outside the toolbox, so nothing in it will tell you.

  • The graphics card's colour output. NVIDIA Control Panel → Display → Change resolution → Apply the following settings: Output color format: RGB, Output dynamic range: Full, 8 bpc, at 240 Hz. On a DisplayPort link that has negotiated down to the slower speed, the driver silently falls back to YCbCr 4:2:2, Limited at 2560 × 1440 @ 240 Hz: colour fringes on text, blurred fine colour edges in a pattern stimulus, and black and white that are no longer black and white. Unplugging and replugging the DP cable (direct to the card, a DP 1.4 / HBR3 cable) renegotiated it. It can come back after a reboot or a monitor power cycle, so look before each session.
  • Nothing moving on the other monitors. Measure refresh dropped 13 frames in 10 s while a chat window was updating on another screen of the same card, and 0 with every screen idle, at the same 239.972 Hz. The same applies to a recording: keep every other screen still, and nothing else in the foreground (§7 step 12: a keypress goes to whichever window has focus).
  • The graphics driver's sync settings (#544). NVIDIA Control Panel → Manage 3D settings: Vertical sync: Use the 3D application setting (or On; never Off, Fast or Adaptive), G-Sync off for the stimulus program (or Monitor technology: Fixed refresh), no frame-rate cap, and the panel at its fixed mode rate. A run with vsync off, or measuring more than 1 % faster than the panel, is refused before it records; Troubleshooting has why and the Windows 11 compositing caveat.

1. Get the repo + environment

git clone https://github.com/ssvep-toolbox/ssvep-toolbox.git   # or: cd existing; git pull
cd ssvep-toolbox
conda env create -f environment.yml   # first time; else: conda env update -f environment.yml
conda activate ssvep
pip install -e . --no-deps

2. Run the test suite

pytest -q
Expect all passed, 3 skipped: a pilot-data test skips unless BIDS/sourcedata/ is present, and the two audio-marked tests skip unless you ask for them with -m audio. Report the count and any failures verbatim — the count itself is deliberately not written down here (every copy of it in this repo has gone stale). The suite is silent by default; on the acquisition PC also run pytest -m audio -q once and listen for the beep. The test proves the playback call succeeds, not that sound reached the speakers; only your ears confirm that (the resting-cue audio path, #495).

3. Design GUI + measured refresh

ssvep-design
- The Stimulus monitor picker defaults to Auto — highest refresh. Report which screen it resolved to (the "← auto" entry); confirm it's the 240 Hz panel, not a 60 Hz one. Use Rescan if you plug/unplug a monitor. - Click Measure refresh (the stimulus monitor goes black fullscreen for about 10 s — that's required to measure the real vsync'd rate; Esc cancels). Report the measured Hz, its ± (the estimate's standard error), the frame jitter, the dropped count, and the screen index (expected ≈ 240). The frame jitter is how noisy the machine's timing is, not the uncertainty of the rate. This reads the true rate even when the 240 Hz panel is a secondary behind a 60 Hz primary — the window is native-resolution and per-monitor-DPI-aware, so Windows presents it at the panel's own rate instead of clamping to the primary's. A rate stuck at 60 on a 240 panel would signal that (DWM) clamp. - Viewing geometry: Coordinate units defaults to Degrees visual angle — set Viewing distance (default 75 cm) and Screen diagonal (default 27 in) so target x/y/w/h are read as degrees from central fixation (target centre). Switch to Screen proportion for the old normalized [-1,1] layout. - Note whether the live Validation panel warns about frames/cycle for 36–44 Hz. - Click Save… and save a manifest into run-manifests/ (don't commit it).

4. ⭐ Renderer + flik-o-meter (the key timing check)

Run the built-in 9-target 36–44 Hz example fullscreen on the 240 Hz monitor:

python -m ssvep.stim.renderer --fullscreen --duration 30
# auto-selects the highest-refresh monitor and prints which one; override with --screen N
# list detected monitors without rendering: python -m ssvep.stim.renderer --list-screens
# --hud overlays live refresh + dropped% on-screen (diagnostic only — adds luminance, not for collection)
# Esc quits early
While it runs, hold the flik-o-meter over one target and check the reading. On exit it prints frame stats. Report the startup line Presenting on screen N (refresh … Hz) via <GPU> — confirm it's the 240 Hz panel and a hardware GPU (e.g. NVIDIA…), not llvmpipe/software (see troubleshooting below) — plus: - The printed measured refresh and dropped-frame % — both reported over the steady-state window (the first ~0.5 s of fullscreen mode-switch is excluded as warmup; tune with --warmup), so they reflect what the experiment actually runs at. Measured Hz should sit at ≈ the panel rate; dropped% should be low. - Whether the flik-o-meter frequency matches the target's nominal Hz. - Subjectively, does 36–44 Hz sine look clean, or noticeably stepped? (240 Hz gives only ~5.5–6.7 frames/cycle — a known limit; we're gauging how it looks.)

Optional: preview a saved protocol → python -m ssvep.stim.renderer path\to\manifest.json --fullscreen.

4b. Trial presentation — cue + cycling through targets (NEW)

This is the mode the classification pipeline needs. Each trial shows a green cue on one target, then all targets flicker, then a blank ITI — cycling through the targets.

python -m ssvep.stim.renderer --session --max-trials 9 --fullscreen
# --markers also streams structured LSL markers (needs a recorder/consumer to observe them)
# Esc aborts at any time
Or from the GUI: Preview session. Report: does each trial begin with a cue on a single, different target, then all targets flicker, then blank? Any visible hitch at the cue→stim transition? (Frame-timing is still gauged by the flik-o-meter in §4.)

4c. ⭐ Measure-flicker mode — automated per-target QC via the photodiode (NEW)

The flik-o-meter (OPT101 + Arduino) now feeds the toolbox directly. Plug it in and note its serial port (Device Manager → Ports, e.g. COM3). Then:

python -m ssvep.stim.renderer --measure-flicker --photodiode COM3 --fullscreen --sync-square
# no board/photodiode handy? drop --photodiode to run visual-only (still saves a report)
Each target is cued in turn as a coloured outline around a black square; place the photodiode on it and press SPACE — all targets then flicker while it measures the cued one. It prints nominal / measured / Δ / drift / PASS|FAIL per target and writes a timestamped JSON under qc-reports\ (git-ignored; protocol name + manifest fingerprint in the filename). Report: the per-target Δ (Hz) and any FAILs, whether any target clipped (tune sensor gain / room light if so), and whether counters stayed in sync. --sync-square gives a fixed corner patch you can park the sensor on instead of repositioning over each small target.

If the firmware isn't flashed yet, see the build guide's Software and firmware section.

5. (Optional) Analysis on a real recording

If you copy a pilot XDF onto this PC (keep it out of git), this is the one case that needs the developer script — --pilot is the only reader for legacy pilot recordings' slash markers:

python scripts/analyze_recording.py PATH\to\recording.xdf --pilot
Report the per-band accuracy/ITR it prints (mid-band should decode well; report path is written under the git-ignored derivatives/). The script goes through the same data-use gate as the pipeline: it stops, saying why, on a recording with no *.session.json beside it or one this machine may not convert (no data-use stamp, or only the older consent record), which includes the legacy pilot recordings.

For anything recorded by this toolbox, analyse the session instead — that is the operator path and the only one that writes a run log and lands in the derivatives index (docs/BIDS.md):

python BIDS\code\02_run_offline_pipeline.py --sub 001 --ses 001

6. OpenBCI Cyton + WiFi shield — acquisition path (NEW; validated 2026-07-06/07)

The WiFi shield exists to run > 250 Hz so we can capture harmonics of high-frequency (80–100 Hz) stimuli. Power on the Cyton + shield; the shield makes its own AP (e.g. OpenBCI-6C6E, IP 192.168.4.1) — join that Wi-Fi first (the PC has no internet while on it). Then run the bounded connectivity/reliability check (no LSL, no recording, no participant):

python -m ssvep.runtime.openbci --board cyton-wifi --ip-address 192.168.4.1 --sample-rate 500 --check --duration 20
Expect CLEAN link (0 dropped, 0 gaps) and an effective rate ≈ the nominal you set.

Validated findings (Cyton + WiFi shield, this rig): - Reliability is clean at 250 / 500 / 1000 Hz. Any earlier PACKET LOSS was a one-time startup discontinuity in the shield's pre-roll buffer, now discarded automatically (both the bridge and --check flush the first read after start_stream). It was not ongoing loss — a single gap per run that didn't grow with duration. - Rate → what it buys you: 250 Hz (Nyquist 125) = fundamental only; 500 Hz (Nyquist 250) = + 2nd harmonic of 80–100 Hz — the recommended rate; 1000 Hz (Nyquist 500) = 3rd+ harmonics at double the data. Set it via Build Protocol → "Set up acquisition hardware" (or, as-run, Set up Session's Sampling rate field) — acquisition.sfreq_hz (250/500/1000) is protocol/session-level since #147, not a Design-tab field. - WiFi delivers in ~200 ms bursts regardless of rate (the shield's TCP batching). Data quality is unaffected, but the live sweep view refreshes ~5×/sec on WiFi — steady, not as fluid as a wired dongle (which delivers ~50 ms). This is a transport ceiling, not a plot bug. - Recorded timestamps are even — the bridge stamps on a sample-counter timeline (slow-locked to the LSL clock), so per-sample spacing is ~1/sfreq with no burst-boundary jitter.

Single-client gotcha: the shield accepts one TCP client at a time. If a session is killed ungracefully it can wedge (BOARD_WRITE_ERROR:4 unable to prepare streaming session) — the app now shuts the bridge down gracefully (CTRL-BREAK → releases the amp) so back-to-back runs work, but if you ever see that error, power-cycle the shield and rejoin its Wi-Fi. Also close the OpenBCI GUI (it would grab the single client). Report: the --check summary line (rate + CLEAN/PACKET LOSS) at 500 Hz, and whether two runs in a row both come up.


7. ⭐ Abort-then-re-run a protocol session — standing check (verified 2026-08-01)

The record loop is pragma: no cover: every guard in #86 is verified offscreen and by unit test, never against an amp. The failure it prevents only appears when a real run is aborted mid-flight, so re-run this on the acquisition PC after any change to the record path, and before a participant session. Synthetic board is fine — use a placeholder subject ID, purpose bench, no data that matters.

  1. Set up Session → set a throwaway Subject (+ Session/Operator), choose Purpose: bench → Load protocol… → 🔒 Lock protocol and session details. (Prep selected run on Run Session is disabled until you lock — before that the checklist is a preview, because the subject it resolved for is still editable. Load protocol…, Resolve / refresh, Suggest ses # and 🔒 Lock all live on Set up Session — #126 moved them to Run Session, #147 moved them back next to the identity/ purpose fields the lock actually freezes.)
  2. Switch to Run Session — the same loaded protocol and checklist are there (both tabs read one shared state; nothing needs re-loading).
  3. Prep selected run (on the checklist) → the QA-check pane expands → skip the impedance check for this test → Start recording (on the live-EEG pane below; it auto-starts Preview EEG first if you hadn't already, #126) → Abort a few seconds in.
  4. The checklist must read ⚠ INCOMPLETE (n/N trials) — re-run, not ✓ recorded, and the status line must say … · 1 incomplete.
  5. Press Start recording again without touching the checklist. It must refuse and point back at the checklist. This is the escape #86 (docs/PROTOCOLS.md) fixed; refusing is the whole fix.
  6. Click the incomplete run in the checklist (it becomes the highlighted row), then Start selected run → confirm the overwrite → let it finish.
  7. Its sidecar must show the full provenance.trials_recorded, aborted: false, and an intact session_position (protocol name, fingerprint, run_index).
  8. Unlock, then Clear protocol and confirm. There must now be no way to record anything: the lock refuses with "No protocol is loaded", and pressing Start is impossible because the checklist has nothing loaded. Recording is protocol-only since #115 — a one-off is a one-run protocol, not a lock on the Build run form.
  9. While still locked in step 6, check Build Protocol and Set up Session stay reachable, and Build run/Build Protocol stay editable (locking alone does not freeze them — only an actually-recording run does, see step 10) — and that loading a protocol in Build Protocol does not change what is loaded on Run Session.
  10. New in #126 — verify the tab-switch-mid-run guard is real, not just reactive. While a run is actually recording (mid-step 3, before Abort), confirm: (a) every tab except Run Session is visibly disabled in the tab bar — clicking them does nothing; (b) Abort stays enabled and clickable throughout (AGENTS.md §4 — the always-available abort must never be hidden by this), and since it now lives in the live-EEG pane's button row, that pane must refuse to collapse for as long as the run is recording — click its ▾ title row mid-run and confirm nothing happens and that the title text stays as bright as it was (a greyed title would dim the as-run device/rate line this pane's header carries, and make the whole pane read as dead right beside the Abort the pin exists to protect); (c) after the run ends (or you Abort), the other tabs become clickable again and the pane collapses normally. This is the structural half of the #124 fix — #124 itself stays open (root cause of the crash it originally guarded against was never found), so also confirm the app does not crash if you try switching tabs during a live run on real hardware (not just offscreen/unit-tested).
  11. New in #126 — the unified live-EEG pane. On real hardware (not synthetic): click Preview EEG before locking a run into position, confirm the same plot updates with live signal (PSD, railing, SNR — unaffected by the merge), then click it again to stop. Lock a run and press Start recording without clicking Preview EEG first — confirm it briefly shows live signal on its own before the recording bridge takes over the amp (the "auto-starts the preview" half of #126). Confirm the plot itself is visually the same widget/position before and during recording — there should be no flicker of a second plot appearing/disappearing. While previewing, switch the View selector next to PSD avg to SNR spectrum (#21) and confirm a comb of peaks appears at the montage's own occipital sites once contact is good, with dotted markers at the protocol's own target frequencies; switch back to PSD and confirm nothing lags or glitches on the way.
  12. Esc stops a run from either window. Participants are told they can stop the flicker with the Esc key on the keyboard in front of them. A key goes to whichever window has the keyboard, and the participant can't see which one that is, so check both: (a) Start recording, don't touch anything, and press Esc a few seconds in. The stimulus window takes the keyboard when the flicker starts, so Esc lands there and the run stops; (b) Start recording again, then click anywhere in the app's own window (the live-EEG plot, say) so that it has the keyboard, and press Esc. The run must stop just the same, and the status line must read aborting: Esc was pressed. Both times the result line must begin STOPPED EARLY, the checklist must read ⚠ INCOMPLETE, and the sidecar must show aborted: true. At a questionnaire's break, the break screen must end with the Esc reminder. During a real session, keep every other program out of the foreground: a third application with the keyboard (a browser, say) takes the participant's Esc, and nothing in the toolbox can see it. And don't press Esc yourself while a run records, not even to leave a text field or close a drop-down: the app can't tell your keyboard from the participant's, so it stops the run.

Failures to watch for:

Symptom What it means
The aborted run still reads ✓ recorded The completeness stamp is not reaching the sidecar.
Start on Run Session is allowed with no run prepared The refusal is not wired to the real button path.
The lock warns about an "unsaved run manifest" It is reading the Build run form again — it must only ever read the loaded protocol (#115).
A protocol appears on Run Session that you loaded in Build Protocol The cross-tab mirror is back (#115).
The recorded sidecar's acquisition lacks the skin prep you typed, or the electrode type it should have derived from the headset The as-run override is not reaching the protocol recorder (#115), or builder.headset_electrode_type is not being read (#147).
The confirmed re-run raises RunExistsError instead of replacing the run The overwrite grant is not reaching record_run.
A .rescued-NN file appears anywhere A clash is being discovered late that should have been caught before the amp was touched.
Another tab is clickable while a run is actually recording The structural #126 guard (_set_other_tabs_enabled) is not wired to the real start/stop path.
Abort is greyed out or hidden at any point during a run A hard failure — the always-available abort must be reachable from every state (AGENTS.md §4).
The live-EEG pane collapses mid-run, taking Abort with it The same hard failure by a different route — _pin_eeg_pane_open is not being driven from the real start/stop path.
Esc does nothing while the app's own window has the keyboard (step 12b) The Esc filter isn't installed on the real start path (_set_esc_abort), or something consumed the key first.
A run stopped with Esc shows ✓ recorded, or its sidecar has aborted: false The renderer's Esc exit code (display_report.EXIT_STOPPED_WITH_ESC) is not reaching the recorder.
"champOpen null" / "device not present" starting a run right after previewing The preview didn't actually release the amp before the bridge tried to open it (#126's _release_preview, unchanged logic, re-hosted target).

If the protocol declares questionnaires (#20), the checkpoint fires by itself after each completed run — an aborted run leaves it for you, so step 3 must not pop a questionnaire. Responses land in sourcedata/sub-XXX/ses-YYY/beh/; see QUESTIONNAIRES.md.


8. ⭐ Two rigs, one subnet: stream and instance isolation (#394)

LSL discovery reaches every machine on the subnet, and every copy of the toolbox advertises the same stream names. A rig must record only its own EEG and markers (docs/ARCHITECTURE.md, "LSL stream identity and instance isolation"). The unit tests prove the resolution rule against simulated outlets; this checks it with real bridges and renderers. Synthetic board is fine; use throwaway subject codes, no consent, nothing that matters. Re-run it after any change to the record path, and when a new acquisition PC joins a network another rig is on.

A. Two PCs on the same subnet (or the lab PC plus a laptop on the same network):

  1. Start a recording on PC B and leave it running (a long protocol, or a resting run).
  2. While B records, start a recording on PC A. It must record normally, with A's own live EEG in the plot.
  3. When both have finished, open each run's *.session.json. lsl_streams.eeg.hostname and lsl_streams.markers.hostname must both be the PC that saved the file, and the two source_ids must end in the same token. Then check that A's tokens differ from B's.
  4. Optional, and the strongest check: load each XDF with pyxdf.load_xdf and confirm each stream's info["hostname"] and info["source_id"] match its sidecar.

B. Two instances on one PC:

  1. Open the toolbox twice, both pointing at the same Output dir. Start a recording in the first, then press Start recording in the second. The second must refuse at once with "Another instance of the toolbox is already recording to this data root", naming the first's process, and must start no bridge and no stimulus window.
  2. Point the second instance's Output dir somewhere else (an empty folder) and start again. It must record, with its own stimulus window and its own markers, while the first keeps recording. Both sidecars must name this PC as the host, with different tokens.
  3. While both run, check %TEMP% for ssvep_run_manifest_*.json: each run has its own, and each is gone once its run ends. No file named exactly ssvep_run_manifest.json must appear.

Failures to watch for:

Symptom What it means
A sidecar's lsl_streams.*.hostname is the other PC A run bound another rig's stream: the source_id resolution is not on the real record path.
EEG and markers in one sidecar have different tokens One of the two outlets did not get the run's source_id (the bridge's --source-id or the renderer's --markers-source-id).
A run fails with "other stream(s) named … are visible on the network" while its own bridge was running The bridge did not publish the source_id it was given. Its log tail is in the same message.
The second instance on the same Output dir starts a bridge before it refuses The data-root lock is taken too late.
The second instance on a different Output dir is refused The lock is keyed too broadly.
.ssvep-toolbox.lock stays in the Output dir after both instances have closed A process died holding it. It clears itself when the same PC tries again, unless the holder was another machine; then the refusal says which file to delete.

What to send back

  1. pytest result (count + any failures).
  2. Measured refresh from the GUI (Hz ± SE, frame jitter, dropped) and from the renderer.
  3. Dropped-frame % and whether flik-o-meter freq matched nominal.
  4. Any errors, plus a screenshot of the Design GUI and (if possible) the flickering stimulus.

If anything errors on Windows (pyglet/PySide6/LSL import, GL context, etc.), paste the full traceback — that's exactly the kind of platform issue this run is meant to surface.

If the desktop icon shows the splash and then closes (seen on a fresh account on the lab PC,

70): the icon is a console-less pythonw launcher, so it has nowhere to print. Every crash is

appended, with a header describing the launch environment, to %LOCALAPPDATA%\ssvep-toolbox\startup-error.log — send that file. The header is usually the answer on its own:

Header line What it means if it looks wrong
CONDA_PREFIX : (unset — env not activated) + Library\bin : NOT on PATH The icon launches pythonw.exe directly, with no conda activate, so conda-built native deps (OpenBLAS, brainflow/liblsl) may not load. Launch via an activated shell to confirm.
editable inst: no Installed without -e; schemas/ won't resolve (ssvep.io.manifest.SCHEMA_DIR assumes a checkout). Reinstall with pip install -e . --no-deps.
console : no (pythonw) Expected for the icon — this is why the log exists.

To reproduce the icon's conditions with a console, run the console-script alias of the same app from a plain, non-activated shell: C:\path\to\envs\ssvep\Scripts\ssvep-design.exe.

If the renderer warns about a software OpenGL renderer (llvmpipe), or the via … line isn't a GPU: conda's mesalib ships a software opengl32.dll that shadows the GPU driver, which disables vsync and ruins timing. The toolbox now auto-prefers the system GPU driver — import ssvep pre-loads %WINDIR%\System32\opengl32.dll so pyglet/Qt bind the GPU's OpenGL, not Mesa's (src/ssvep/_glboot.py). Verified on the lab rig (GTX 1080 + 240 Hz Alienware): the context comes up as NVIDIA … 4.6 and Measure refresh now reads ≈ 240 Hz, where before the fix it bound llvmpipe and reported a bogus ~103 Hz. If a software renderer still appears, disable the Mesa DLL manually and re-run:

conda activate ssvep
Rename-Item "$env:CONDA_PREFIX\Library\bin\opengl32.dll" opengl32.dll.disabled
(Or drop the unused mesalib — nothing in the toolbox imports VTK, which is what pulled it in.)

Related symptom now fixed in code: a RuntimeError: Canvas has not been attached traceback (from pyglet's _event_dpi_change) when measuring refresh on a secondary, differently-scaled monitor. measure_refresh_hz (and the renderer) now reach fullscreen via a windowed→fullscreen handoff, which attaches the GL context before the monitor's DPI change fires and keeps per-monitor awareness — the latter also lets Windows present at the panel's true rate instead of DWM-clamping a composited window to the primary monitor's refresh (which had made a 240 Hz panel read 60 Hz). (Full explanation in docs/REQUIREMENTS.md → "Platform gotcha".)