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 REB amendments A1–A3 are approved — don't collect.

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


1. Get the repo + environment

git clone https://github.com/NeuroCognitiveImagingLab/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, 1 skipped (a pilot-data test skips unless BIDS/sourcedata/ is present). 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; see docs/ROADMAP.md, "Why the split").

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 (it briefly flashes fullscreen on the stimulus monitor — that's required to measure the real vsync'd rate). Report the measured Hz, ±std, and screen index (expected ≈ 240). 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 hardware/flikometer/BUILD_GUIDE.md (§5).

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 the sub-005–008 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/).

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 902 --ses 002

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, sub-000)

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 — sub-000 or a 900-series code, no consent, no data that matters.

  1. Set up Session → set a throwaway Subject (+ Session/Operator/consent) → Load protocol…🔒 Lock protocol and session details. (Start 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/ consent 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. Start 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 sub-002 escape; 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 (CLAUDE.md §4 — the always-available abort must never be hidden by this); (c) after the run ends (or you Abort), the other tabs become clickable again. 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.

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 CLAUDE.md §4 hard failure — the always-available abort must be reachable from every state.
"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.


What to send back

  1. pytest result (count + any failures).
  2. Measured refresh (Hz ± std) from the GUI 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 driverimport 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".)