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¶
Expect all passed, 1 skipped (a pilot-data test skips unlessBIDS/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¶
- 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 intorun-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
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
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)
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:
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):
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
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.
- 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.)
- Switch to Run Session — the same loaded protocol and checklist are there (both tabs read one shared state; nothing needs re-loading).
- 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.
- The checklist must read
⚠ INCOMPLETE (n/N trials) — re-run, not✓ recorded, and the status line must say… · 1 incomplete. - 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.
- Click the incomplete run in the checklist (it becomes the highlighted row), then Start selected run → confirm the overwrite → let it finish.
- Its sidecar must show the full
provenance.trials_recorded,aborted: false, and an intactsession_position(protocol name, fingerprint,run_index). - 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.
- 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.
- 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).
- 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¶
pytestresult (count + any failures).- Measured refresh (Hz ± std) from the GUI and from the renderer.
- Dropped-frame % and whether flik-o-meter freq matched nominal.
- 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:
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".)