Skip to content

Worked example — three appearance contrasts over one grid

A complete, shipped session protocol you can load, run, and argue with: example-teaching-lab-appearance-contrasts-cyton. Three appearance manipulations — shape, colour, pattern — each a pair of runs over one shared 9-target grid, on an OpenBCI Cyton with an 8-channel Electro-Cap.

This is a second teaching-lab protocol, shipped alongside the original example-for-teaching-lab-cyton rather than in place of it: a class is already running recordings against that protocol's design, and replacing it in place would silently redefine what those recordings were run under. This page and this design are the reworked one — the frequency set and the checkerboard's spatial frequency both changed, see §2 — while the original stays on main, unchanged, for the class already using it.

It exists for two reasons. Every other shipped example manipulates frequency, so nothing demonstrated the appearance fields (color, pattern, pattern_reversal, spatial_freq_cpd) that have been in the run-manifest schema since the stimulus-expressiveness work. And a teaching lab needs a design a student can reason about: one that isolates something, and whose failures to isolate it are written down rather than discovered in the analysis.

⚠️ Read §7 before you run this on anyone. It flickers at 8–14.1 Hz, and the checkerboard run is further out than that: a pattern reversing at f presents at 2f, so six of its nine targets land inside the 15–25 Hz peak seizure-provocative band, and every checkerboard target is rated high. That is a safety property of the stimulus, not a preference, and it holds whoever is in the chair.


1. The files

File What it is
scripts/build_teaching_lab_protocol.py The source. Generates everything below.
run-manifests/example_teaching_contrasts_shape_square.json white square (shape group, A)
run-manifests/example_teaching_contrasts_shape_circle.json white circle (shape group, B)
run-manifests/example_teaching_contrasts_colour_amber.json amber square (colour group, A)
run-manifests/example_teaching_contrasts_colour_cyan.json cyan square (colour group, B)
run-manifests/example_teaching_contrasts_pattern_solid.json solid white square (pattern group, A)
run-manifests/example_teaching_contrasts_pattern_checkerboard.json contrast-reversing checkerboard (pattern group, B)
session-protocols/example_teaching_lab_appearance_contrasts_cyton.json the protocol that orders the six runs

The seven JSON files are generated, not hand-edited:

conda activate ssvep
python scripts/build_teaching_lab_protocol.py

Edit the script and re-run it; do not edit the JSON. The shape group's baseline and the pattern group's baseline have to be the same stimulus for either contrast to mean anything, and two JSON files kept identical by hand do not stay identical. (The protocol embeds its six manifests, which protocols have done since protocol_version 1.2, so re-running the script is what updates the copies inside it. This one is 1.3 because it also declares contrasts; see below. See Protocols and sessions.)

Its three A/B pairs — shape, colour, pattern — are also declared, not just run: the protocol carries a contrasts block naming each pair, its confirmatory window, and its primary dependent variable, which is what protocol_version 1.3 added. That turns "look at three HTML reports and squint" into an actual analysable contrast — the Compare tab, or BIDS/code/03_run_contrasts.py, will test all three once both runs of a pair are recorded and analysed. See Between-condition contrasts for what each test can and cannot say at this class's size, and a worked example on synthetic data.

2. The design

Group A B What varies
shape white square white circle outline geometry
colour amber square (#FFA800) cyan square (#00CAFF) hue, at matched luminance
pattern solid white square white/black checkerboard, 2.0 cyc/deg, contrast-reversing spatial structure

Everything else is held constant across all six runs:

  • Grid — 3×3, target centres at ±6°, targets 2×2° of visual angle (builder.GRID_SPREAD_DEG / TARGET_SIZE_DEG, the same grid the other 9-class presets use, so it cannot drift from what Generate grid produces on the Build run tab). Nearest-neighbour separation is 6.0° centre-to-centre; §8 says why that number is reported here rather than in pixels, and what it does not control for.
  • Frequencies — nine exact divisors of the 240 Hz refresh, from 8.000 Hz up to a top target of 14.118 Hz:
Hz frames/cycle reversal rate (2f)
8.000 30 16.000
8.571 28 17.143
9.231 26 18.462
10.000 24 20.000
10.909 22 21.818
12.000 20 24.000
12.632 19 25.263 one-frame jitter
13.333 18 26.667
14.118 17 28.235 one-frame jitter

Minimum separation 0.571 Hz — far above the 0.2 Hz Chen resolved with 40 targets. No target's second harmonic lands on another's fundamental, because 2 × 8.0 = 16.0 clears the top of the band; that property holds only while the set stays inside one octave, so keep it there. The manifests store the exact divisors (240/28, not 8.571), because the exactness is the point.

Why these and not round numbers. A reversing target swaps textures every refresh/(2f) frames. When that is not a whole number the reversals alternate between the two bracketing frame counts — a one-frame jitter of 4.17 ms. The mean rate stays exactly 2f (the renderer samples the square each frame rather than quantising the frequency), so this smears the spectrum rather than moving the peak; but it is free to avoid, and it needs even frames/cycle. From 8 Hz up to any ceiling below 15 Hz there are exactly seven such frequencies at 240 Hz (the eighth is 15 Hz itself, where the peak band starts) — seven, not nine — so the top two targets carry the jitter and the builder emits a design-time warning saying so. Sinusoidal flicker is unaffected either way: a sine renders at exactly its nominal frequency at any divisor. - Timing — 1.5 s cue, 4.0 s of usable stimulation plus a 0.14 s onset lead-in that analysis discards, 0.75 s ITI. 9 trials/block × 3 blocks = 27 trials per run, ~2 min 53 s of run time, ~1 min 52 s of flicker. Six runs ≈ 17 min of run time (≈ 11 min of it flicker, counting the lead-in), before prep, breaks and impedance checks. - Hardware — OpenBCI Cyton over the USB dongle at 250 Hz, on the Electro-Cap 8-channel 10-20 carrier (Cz Pz P7 P3 P4 P8 O1 O2). Reference, ground and electrode type are derived from the carrier, not typed in, so they cannot disagree with MONTAGE_ELECTROCAP.md. - Display — 40 cm diagonal at 70 cm viewing distance, black background, gamma 2.2. Black is not incidental: Ming 2021 found a black-background checkerboard matched plain flicker on accuracy and ITR (p > 0.05) while the white-background version was significantly worse (p < 0.05). See §6.

No resting run, and only the default checkpoints plus the end questionnaire — this is a one-hour class, not a study session. For what a resting run would add (individual peak alpha, a negative control, a montage check before the SSVEP runs are spent), see Resting state; in this band the PAF question is more than academic, because a target flickering at a participant's own alpha peak is measuring their alpha rhythm as much as a driven response. §8 argues for adding one questionnaire item despite all that.

Order. Group order is fixed — shape, then colour, then pattern. Within each group the two runs are counterbalanced across participants. §8 has the catch in that.

3. Running it

  1. Set up Session tab → enter the participant code (sub-XXX — a numeric code only, never a name, initials or student number; see Data governance), the session and consent, and check the as-run hardware, which is pre-filled from the protocol. Then Load protocol… → session-protocols/example_teaching_lab_appearance_contrasts_cyton.json. The resolver expands the protocol into six ordered runs for that participant.
  2. 🔒 Lock protocol and session details, still on Set up Session. The lock freezes which design and whose session. If anything is missing, it refuses and names what.
  3. Run Session tab → record the runs in the order its checklist gives you; Prep selected run picks the next pending one. The order is a property of the participant code, not of the operator's memory.

Build Protocol is where this protocol was composed. You don't need it to run one.

Before the first class on a new rig, verify that the flicker actually reaches the screen at the frequencies the manifest claims — a photodiode against the panel, measured rather than assumed. A 240 Hz display that silently drops to 60 Hz on battery power turns 12 Hz into something else, and nothing in the recording will say so. Measure refresh on the Build run tab is the software half of the same check.

4. What each contrast isolates — and what it doesn't

This is the part worth teaching from. None of the three manipulations is clean, and the reasons are different in each case.

4.1 Shape — confounded with light, and it cannot not be

The circle is inscribed in the same 2×2° bounding box as the square. So it covers π/4 ≈ 78.5% of the square's area and emits about 21% less light. The shape contrast is therefore shape and luminous flux, and flux is a strong driver of SSVEP amplitude. If the circle run decodes worse, the honest first hypothesis is "less light", not "circles are harder".

The tempting fix does not work. Matching the illuminated pixel count means growing the circle to a 2.26° diameter — and then it subtends a larger retinal area, extends further into the periphery, and samples a different population of receptive fields at a different cortical magnification. Area is not a nuisance variable you can quietly compensate; it is a second manipulation.

There are three things you could hold constant and you can only pick one:

Match Circle size What is then equal What is then unequal
Bounding box (chosen) 2.00° diameter extent, centre eccentricity, inter-target spacing, the grid area (78.5%), flux, contour length
Area / flux 2.26° diameter illuminated pixels, total light extent, eccentricity reached, target–target gap
Maximum extent 2.83° diameter corner-to-corner reach area (157%), flux

The bounding box was chosen because it is what the builder produces, and because it keeps the grid identical — same centres, same eccentricities, same gaps between neighbouring targets. The alternative moves geometry the rest of the design depends on.

Two smaller ones while you are here. Contour length falls by exactly the same π/4 factor (square perimeter 8°, circle circumference 6.28°), and SSVEP is substantially an edge response, so "less light" and "less edge" are themselves confounded with each other in this pair — these two runs cannot tell them apart. And the circle is drawn as a polygon approximation (a pyglet.shapes.Ellipse), so its boundary is not antialiased the way a rectangle's axis-aligned edge is; at 184 px per target that is a sub-percent effect on total light, but it is not zero.

The teachable point: "same size" is not one thing. Ask which sense of same a paper means, every time.

4.2 Colour — matched luminance, for a standard observer, on an assumed panel

The pair is amber (linear-light RGB 1.00 / 0.40 / 0.00, sRGB #FFA800) and cyan (0.00 / 0.60 / 1.00, sRGB #00CAFF). Both sit at Rec.709 relative luminance 0.50 — half of white, and equal to each other to well within one display code value. That match is derived in the build script from the target luminance rather than typed in, and asserted at build time, so the two cannot drift apart when someone edits one of them. The hues are far apart on both the L–M and S-cone axes, so they also stay distinguishable to the common colour-vision deficiencies.

This replaced a full-saturation red/blue pair. By those same weights, full blue sits at luminance 0.0722 and full red at 0.2126 — blue is about a third the luminance of red, so that version manipulated colour and a three-to-one luminance difference, which is not a colour experiment.

Four limits on how far "isoluminant" goes here:

  1. It is photometric, not measured. The weights (0.2126 / 0.7152 / 0.0722) assume sRGB primaries and the gamma 2.2 declared in the manifest. A wide-gamut or uncalibrated laptop panel has different primaries and a different transfer curve, so the two patches will not be exactly equal on that screen. Measuring them with a photometer at the viewing position is a genuinely good undergraduate exercise, and is the only way to claim more than "nominally matched".
  2. It is a standard observer. Individual isoluminance varies with cone ratios and macular pigment; the real procedure is heterochromatic flicker photometry, per person, and the match moves noticeably between individuals.
  3. It is a foveal standard, used at 6° eccentricity. Relative spectral sensitivity is not constant across the visual field, and every target in this design is in the periphery. A pair matched for the fovea is only approximately matched where these targets actually are.
  4. Neither run is a purely chromatic stimulus. Each target flickers between its colour and the black background, so both runs modulate luminance from 0 to 0.5 — what the matching equalises is how much luminance modulation each run has, not that there is none. A chromatic-only stimulus would swap between two isoluminant colours at constant mean luminance, which the current flicker model (background ↔ colour) does not express. That is a limitation of the toolbox, not of the example.

Expect both colour runs to decode worse than white, because both carry half the luminance modulation of the white runs. The comparison that means something is amber vs cyan, not either against the shape group.

The teachable point: "isoluminant" is a claim about a measurement, an observer, and a location in the visual field. Ask which.

4.3 Pattern — a different response, at a different frequency

The checkerboard is 2.0 cycles/degree across a 2° target — 4 cycles, an 8×8 board of 0.25° checks, about 23 px each on the class laptop — and it contrast-reverses at the target's frequency instead of dimming.

The 2f rule, and the trap in it

One flicker cycle contains two reversals, and the visual system responds to each, so the response appears at twice the target's declared frequency. A 10 Hz checkerboard target evokes its strongest response at 20 Hz. This is not an inference: Chailloux Peguero, running this exact comparison, found "the maximum average power peaks in the CBR and CBS paradigms were obtained for frequency values corresponding to twice the stimulation frequency f", and Di Russo shows it physically — a reversal every 83.3 ms is "a reversal rate of 12 Hz" with a 6 Hz fundamental.

Build your reference signals at the reversal rate. If the decoder correlates against the frequency used to drive the reversal, every reference sits an octave below the response. The run then decodes badly, and it looks like "checkerboards are bad" rather than "the references were wrong". The toolbox now knows the difference: flicker.response_freq_hz(f, pattern_reversal=True) is the single definition, and both decoders take a pattern_reversal flag that moves their references, filter band and spectral measurements to 2f while leaving the class labels as the targets' own frequencies.

run_analysis(epochs, pattern_reversal=True)        # offline
LiveDecoder(freqs, sfreq, pattern_reversal=True)   # live

Have the class run it both ways. That is the exercise: same recording, same decoder, one flag, and the difference is the whole lesson. Be careful how you describe the result, though — the mis-referenced condition does not collapse to chance. A three-harmonic CCA reference at 8 Hz contains 16 Hz, so it still matches the response through its harmonic term, with the fundamental contributing nothing. On clean synthetic data the penalty is visible but partial; on eight tin electrodes it will be larger. The honest claim is that mis-referencing costs you accuracy for no reason, not that it produces a null.

What else this contrast is not controlling

  • The two arms are not decoded through identical machinery. For the checkerboard run the decoder is aimed at 2f: its references are 2f, 4f and 6f, and FBCCA lays its sub-bands out at multiples of the 2f band, down-weighted by k^-1.25 + 0.25. So the checkerboard's first sub-band spans 14–30.2 Hz, where the solid runs' spans 6–16.1 Hz. That is right, but it means the two arms are read through filter banks an octave apart. It also means the checkerboard decode uses nothing at the declared f or its odd harmonics: every reference is an even multiple of f, and the lowest sub-band starts at 14 Hz, above all but the top declared frequency. Any response energy there is invisible to accuracy.
  • One confusion is built into the frequency set, in both arms. 8 and 12 Hz are in a 3:2 ratio, so the 8 Hz candidate's third reference harmonic lands on the 12 Hz candidate's second. That is 24 Hz for the solid runs and 48 Hz for the checkerboard, whose references sit at 2f. If 8 and 12 Hz are confused with each other more often than chance explains, look there first. It is sharper in the no-flag half of the exercise above. Decoded at f, the checkerboard's main 24 Hz response itself sits on the 8 Hz candidate's third harmonic.
  • The 12.632 Hz target's checkerboard reversal rate sits near 50 Hz mains, at short windows only. 2 × 25.263 Hz = 50.526 Hz, within one FFT bin of a 50 Hz mains line whenever the decision window is 1 s or shorter (bin width 1/T). At an unconfigured site the decoder guards both 50 and 60 Hz by default (ssvep.classification.decoding.mains_safe_harmonics, #280), so that harmonic order is correctly dropped from every candidate at T ≤ 1 s — the guard doing its job, not a defect — and it clears at the protocol's own confirmatory window of 2.0 s. tests/test_mains_harmonic_collision.py records this as a documented exception rather than a re-tuned frequency, since 12.632 Hz is already one of the two targets this design accepts a one-frame reversal jitter on (§2).
  • Mean luminance is constant during stimulation — a reversing checkerboard swaps light and dark checks, so the patch's average output does not change, while the solid target swings from black to white. The two runs therefore differ in mean luminance, in luminance modulation depth, and in spatial structure, all at once. Pattern-reversal versus solid is a paradigm comparison, not a parameter comparison.

A brief written for a dedicated experiment will tell you to fix this by alternating the solid field between the checkerboard's light and dark check luminances instead of between stimulus and background. Here that instruction is a no-op, and it is worth seeing why: the background is black and the dark checks are black, so "between the check luminances" and "between stimulus and background" are the same thing. The instruction assumes the standard VEP mean-grey field, where dark checks sit above the background. Time-averaged mean luminance across a trial is already 0.5 in both arms. What differs is the modulation of the space-averaged luminance — full depth in the solid arm, zero in the reversal arm — and that is not a nuisance to be matched, it is the manipulation. - The two arms do not share a temporal waveform. The solid runs are sinusoidal; a reversal cannot be, because it selects between two textures and there is no partial state to interpolate — so the reversal arm is a hard square selection whatever the manifest declares. Square and sine differ in harmonic content, so the pattern contrast is sine-versus-square as well as solid-versus-checkerboard. This is a limitation of the toolbox's flicker model rather than of the design, and it is the one respect in which this example is further from the literature it draws on than it looks: Zerafa, Ming, Waytowich and Chailloux Peguero all use square on/off baselines.

Why 2.0 cycles/degree

Two published sweeps converge on ~2.1–2.4 c/°. Waytowich 2016 swept nine spatial frequencies and found a bimodal accuracy profile peaking at 0 and 2.4 c/° with a trough at 0.15–0.6 c/°; Ming 2021 swept a nine-target design and chose 2.13 c/°. Ming also found flicker perception lowest between 0.166 and 2.656 c/°, so the accurate band is the comfortable one too. A checkerboard tested in the trough is a straw man — Choi's VR study ran at 0.25 and 0.5 c/° and attributed part of its performance drop to exactly that.

This design uses 2.0, just below both optima, for a reason that only shows up when you reverse. Checks per side is 2 × cpd × size_deg, which for a 2° target is 4 × cpd. Unless that is an even whole number, the two reversal phases do not carry equal light and dark area, so the patch mean flips slightly on every reversal — adding a luminance modulation at exactly 2f. That is the frequency the pattern response itself lives at, so the contaminant is coherent with the signal and cannot be separated from it. Measured white-fraction swing between the two phases:

c/° checks/side swing @2560×1600 @1920×1080
2.00 8 0.00% 0.01% (one pixel)
2.13 (Ming's choice) 8.52 0.19% 0.27%
2.25 9 0.76% 1.23%
2.40 (Waytowich's peak) 9.6 0.11% 0.14%

The midpoint of the published range is the worst available option, because an odd check count maximises the imbalance. 2.0 c/° is 6% below Ming's optimum — well inside the plateau both sweeps report, more than a factor of three clear of Waytowich's trough — and neutral to within one pixel at both resolutions. The previous value was 1.0 c/°: also neutral to within one pixel, but at half the optimum spatial frequency.

Neutrality is a property of the live pixel extent, not only of the design. The renderer sizes each target from the window it actually opens and rounds the target and each check to whole pixels. So the same 2.0 c/° is exact at 184 px and one pixel off at 135 px, where 17 px checks overrun the target by a pixel (9113 vs 9112 white pixels). _assert_reversal_is_luminance_neutral re-checks it at build time at both extents. It builds the two phases through the renderer's own texture function, so what it checks is what gets drawn.

The teachable point: changing the stimulus can change which frequency you should be looking at. Check that the analysis followed.

5. What to expect in the data

Eight tin electrodes, a 4-second window, calibration-free decoding, nine classes. Chance is 11%. The alpha band is chosen precisely because it gives the largest SSVEP on this hardware, and because a student should be able to see the response in the live decoder rather than take it on faith.

Rough expectations, in descending order of confidence: the two white runs (shape A, pattern A) decode best; the circle a little worse than the square; the two colour runs clearly worse than white but comparable to each other. All of this is a prediction, not a result — the point of running it is that it might be wrong.

The pattern contrast deserves a warning about how to read it. The literature is consistent on a dissociation, not a winner, so design the analysis to detect a split rather than to crown a champion:

  • Signal quality favours plain flicker. Chailloux Peguero found on/off gave greater power than checkerboard at all five frequencies (wideband SNR −11.03 dB vs −14.68 dB); Ming found a higher fundamental SNR for flickering than for the black-background checkerboard (19.28 vs 18.15 dB); Zerafa reports plain ahead by 9.26% classification accuracy — though every subject in that study ran plain first and checkerboard second, so its headline is confounded with session order. Waytowich's solid condition reaches the highest absolute accuracy (97.7%).
  • Comfort favours the checkerboard, three studies to one. 51.9% of Chailloux Peguero's participants preferred it "in terms of comfort and focus"; Ming found significantly lower flicker perception across 0.166–2.656 c/°; Waytowich found 2.4 c/° reduced visual irritation. Zerafa's subjects disagreed — but Zerafa's check size is not reported in units that place it against the 2.1–2.4 c/° optimum.
  • ITR can invert the accuracy ordering. Waytowich measured 45.3 bits/min for the 2.4 c/° checkerboard against 35.7 for solid (p = 0.02), because the checkerboard peaks at a shorter window. Accuracy and ITR are both worth reporting here precisely because they can disagree.

Record long, analyse short. The morphology effect is window-dependent and the studies disagree on its direction: Waytowich found checkerboard accuracy peaks at 2.5 s and then declines (spatial-frequency adaptation) while solid rises monotonically; Ming found flicker ahead only at 0.2–0.5 s, with the difference gone by 0.6 s; Chailloux Peguero found peak performance after the first second in all four paradigms. A single fixed window would let you conclude almost anything. The pipeline already sweeps decision windows in 0.5 s steps and reports accuracy and ITR at each — read the sweep, not just the operating point.

For analysis, run the dataset's own reproducible pipeline (BIDS/code/01_xdf_to_bids.py, then 02_run_offline_pipeline.py) or the Analyze tab, which is the same code path — not an ad-hoc script. See BIDS conversion. The checkerboard run needs nothing extra there: the pipeline reads pattern_reversal from the recording's manifest and decodes at 2f by itself. The flag only matters when you call run_analysis directly, and that is how to run §4.3's exercise: once with pattern_reversal=True, and once without.

6. Display, resolution and refresh

Does it matter what resolution the manifest was authored at? Almost never — and the reason is worth knowing.

The manifest's display.resolution_px is design-time metadata, not a render setting. It feeds the off-screen check, the area-weighted photosensitivity warning, the Build run tab's preview, and the BIDS ScreenSize field. At run time the renderer sizes every target from the live window resolution on the monitor it actually opens, which the Build/Run tabs auto-detect (the picker defaults to the highest-refresh panel; you can pin a specific one).

Because this design is authored in deg units, the physical mapping is re-derived on the day, from the live resolution and the screen diagonal you declared:

px_per_cm = hypot(res_w, res_h) / screen_diagonal_cm

So a 2° target stays 2° of visual angle on any 40 cm panel at 70 cm, whatever its pixel count. Only the pixel count changes:

Resolution (40 cm diagonal, 70 cm distance) px/cm 2° target Visible field
1920×1080 55.1 135 px ±14.0° × ±8.0°
2560×1600 (the class laptop) 75.5 184 px ±13.6° × ±8.6°

What does move is the aspect ratio. Going 16:9 → 16:10 on the same diagonal trades about 0.4° of width for 0.6° of height. The ±6° grid plus a 1° target half-width reaches ±7.0°, so it fits either way — with 1.0° to spare at 16:9 and 1.6° at 16:10. This example declares 2560×1600 because that is the class laptop's native resolution; had it stayed at 1920×1080 nothing would have rendered differently, but the design-time checks and the BIDS screen-size metadata would have described a display nobody used.

Two corollaries:

  • A norm-units design is a different story. There, sizes are fractions of the screen, so the same manifest on a different aspect ratio subtends a different visual angle. This is why deg is the canonical unit.
  • refresh_hz is nominal in the same way. The renderer takes the panel's advertised mode rate, and Measure refresh on the Build run tab records what the rig actually did. On a laptop, check it on battery as well as on mains — power management changes refresh rate silently.

Set the screen diagonal and viewing distance honestly; those two, not the resolution, are what make the degrees real. Fix the viewing distance physically — a mark on the desk — rather than trusting it.

Three things specific to running at 240 Hz

Log dropped frames per trial, not just the mean refresh. Kelly reports a nominal-60 Hz CRT measuring 60.2 Hz — nominal values are not trustworthy at any rate. But at 240 Hz a single dropped frame is a 4.17 ms timing error rather than 16.7 ms, so the rate of drops matters more than their size, and a mean refresh figure will hide them. flicker.is_dropped_frame is the per-interval test. Software can only report what it believes it drew, so pair it with a photodiode against the panel — an independent measurement of the light that actually reached the room. The toolbox has a photodiode QC subsystem (the flik-o-meter) for exactly that; its operator SOP is not part of this page's distribution.

High refresh rates may cost amplitude, and this is untested here. Vialatte notes that "CRT refreshing rates can be high (>200 Hz), but SSVEP amplitude drops for high refresh rates". That observation is about CRTs and may not transfer to a modern 240 Hz panel, but it is the only statement in the corpus about this range and it points the wrong way. Worth one pilot check: record a subject at 240 Hz and again with the panel set to 120 Hz, and compare SNR.

But the fix is not free, and a plausible-sounding shortcut is wrong here. Halving the refresh does not halve the frames/cycle counts into an equally good set — it makes this one worse. At 120 Hz, 12.632 and 14.118 Hz stop being exact divisors at all (9.5 and 8.5 frames/cycle), and of the nine only three — 8.571, 10.000 and 12.000 Hz — keep the even frames/cycle a jitter-free reversal needs, down from seven. A 120 Hz fallback therefore needs its own frequency set, chosen the same way, not this one transplanted. It is worth checking that arithmetic in code rather than trusting this paragraph; the draft of it claimed the opposite.

But 240 Hz also removes an artefact. Herrmann found SSVEPs can be driven by "the monitor refresh flicker at frequencies up to at least 75 Hz when the flickering is no longer consciously perceived". On a 60 Hz panel the refresh itself can contribute a 60 Hz component; at 240 Hz that sits far outside the analysis band. Net benefit, and worth stating in a write-up.

Panel response time matters more than refresh rate. Husain measured pattern-reversal P100 at 107.7 ms on CRT, 115.7 ms on a 2 ms LCD and 156.8 ms on a 30 ms LCD (all p < 0.0001), with luminance, contrast and ambient light controlled. A 240 Hz gaming panel is typically ~1 ms so the penalty should be small — but it is not zero, and it shifts the onset lead-in the analysis discards. Record the monitor model and its response-time spec.

7. Photosensitivity and safety

This example is written for a teaching lab — a class, not a study. That changes which rules apply and which do not, and it is worth being exact about the difference, because an earlier version of this page was not.

  • Photosensitivity applies in full. It is a property of the light leaving the screen, and it does not care whether the person in the chair is a participant or a student. Everything below holds.
  • Research ethics approval is a different question, and it is not this page's. Running a demonstration in a class is not human-subjects research, so a research protocol number is not what authorises it. If class recordings are ever reused as research data, or if this design is run as a study, that is a new question and it has to be asked before the recording, not after — data collected outside a study cannot usually be retro-fitted into one.

What the class owes its students is a different and more immediate thing: informed, genuinely optional participation, and a stimulus whose risk has been weighed and lowered as far as the lesson allows.

The solid runs

They flicker at 8.0–14.1 Hz:

  • It is below 15–25 Hz, the peak band of Fisher et al. (2005), but from 10 Hz up it is inside the 10–25 Hz peak the toolbox grades on, which also covers the 10–20 Hz maximum of Fisher et al. (2026).
  • So the builder grades every solid target high. From 10 Hz up the frequency alone does that (at full contrast). Below 10 Hz the frequency alone gives elevated, but these manifests do not declare the display's luminance, so the builder presumes 20 cd/m² or more, as on typical displays, and that is one of the Fisher et al. (2026, Table 4) conditions that raise the grade a step. Only a measured luminance below 20 cd/m², declared for the display, removes that step. All of it is inside the 3–60 Hz band Table 4 advises designers to avoid; the grade says how far inside, relative to other designs.
  • Screen participants for personal and family history of epilepsy, and for migraine, before anyone sits down.
  • The band gives by far the largest SSVEP on eight tin electrodes, which is what makes the live decoder legible in a class.

The two colour-group runs raise a second warning on every target, for chromatic flicker: an amber or cyan square flashing against black is a repetitive colour flash, inside the 3–60 Hz range where Fisher et al. (2026, Table 4) advise avoiding them. Neither colour is saturated red, and they do not alternate red with blue, so the warning is the plain one rather than the stronger wording those get. The photosensitivity bibliography says how it is counted.

The checkerboard run

A contrast reversal at f presents at 2f. So the declared 8.0–14.1 Hz is a presented 16.0–28.2 Hz, and six of the nine targets land inside the 15–25 Hz peak provocative band. The builder rates all nine high:

target S0: photosensitivity high — 8 Hz contrast-reversing presents a reversal rate of
16 Hz and a local luminance alternation at 8 Hz, and is rated on the worse of the two.

The top three (2f ≥ 25.3 Hz) clear the peak band at their reversal rate, but their 12.6–14.1 Hz local luminance alternation is inside the 10–25 Hz peak the toolbox grades on, so they are high too. And the checks themselves raise the grade: at 2.0 cyc/deg they are stripes inside the 1–8 cycles per degree that Fisher et al. (2026, Table 4) name, which lifts even an off-peak component one step. No 9-target alpha-band reversing set avoids this, because an alpha-band reversing stimulus simply is a 16–28 Hz stimulus; it is a property of the paradigm, not a flaw in this frequency set. There is a counter-intuitive corollary worth teaching: a reversing target is rated on the worse of its declared frequency and its double, so "higher is lower risk" holds only in steps. For a full-contrast reversing target with no raising factor — which needs a declared luminance below 20 cd/m², since an undeclared one is presumed to be 20 or more (the band bounds are inclusive):

  • Declared 5–12.5 Hz is high, because the declared frequency or the reversal rate is in the 10–25 Hz peak.
  • Above 12.5 and below 25 Hz is still high, because the declared frequency itself is in the peak.
  • Above 25 and below 30 Hz is elevated; 30–60 Hz declared is moderate. A reversing target reaches low only above 60 Hz declared, where both components are outside the 3–60 Hz band.
  • Each raising factor lifts the grade one step from there, up to high: stripes of 1–8 cyc/deg; saturated red, or two alternating colours at least 60% as far apart as red and blue in the CIE 1976 UCS chromaticity diagram, which is the widest pair an sRGB display can show (Fisher et al. 2026, Fig. 2; one step, not two); 25% or more of the screen covered by the target, or by all the targets flickering together; and a luminance of 20 cd/m² or more, presumed when the display does not declare one.

This is the most provocative stimulus in the repository, and its risk was invisible until the guard learned about 2f. If you inherited a class that is already running, that is the sentence to act on.

The checkerboard also raises a pattern-density warning on every target, on top of the frequency rating above: at 2.0 cyc/deg across a 2° target it draws four light–dark pairs across its width and 7.5 across its diagonal (Harding et al.'s criterion is "in any orientation", and the checks being square makes the diagonal the worst of the three) — over the five that Harding et al. (2005) treat as the limit for a reversing pattern. The photosensitivity bibliography says how it is counted.

⚠️ Screening is a mitigation, not a licence. It lowers the chance that someone susceptible sits down; it does not lower the risk for a susceptible person who does sit down — including someone who does not yet know they are photosensitive. If you are not prepared to defend running 16–28 Hz reversal at full contrast in front of a room, change the stimulus rather than the consent form. The abort path is always available at runtime, and the photosensitivity bibliography is the evidence behind the bands.

Students cannot freely decline the way a recruited volunteer can — the power relation is real whatever the wording on the form, and it is the reason a class needs more care here than a study does, not less. Ladouce's comfort gradient puts this band at the uncomfortable end (8 Hz rated 3.3/10 against 60 Hz at 8.6/10), so the discomfort is not hypothetical either.

Build in an explicit, repeated, no-cost withdrawal option and generous breaks, and make observing rather than participating a visible, ordinary choice that costs nothing and requires no explanation — not something a student has to ask for in front of their classmates.

A note on the wording you will see

The builder's warnings cite no approval. They grade relative risk — moderate, elevated, high — and cite the literature behind the grade: the high message for a target below 30 Hz says "Avoid: choose a frequency at or above 30 Hz, and remove what raises the grade" (for one at 30–60 Hz it says above 60 Hz instead), and the elevated and moderate messages say to prefer a higher frequency and reduce contrast or size if it must be used. Every grade is a warning; none stops you building the design. If your deployment's ethics approval limits stimulation frequencies, that limit governs through the site profile, not through this wording. Read the warning as what it is: a higher grade carries greater risk for anyone photosensitive, including undiagnosed cases, and a lower one carries less — never none.

8. Things you might reasonably change

  • Collect a one-item comfort rating per run. This example ships only the default checkpoint after each group and the end questionnaire, on the grounds that a one-hour class has no time for more — but one extra Likert item after each run is close to free and may be the most interesting thing you get. Choi found per-subject comfort ratings predicted which stimulus decoded better for that subject: everyone who rated the checkerboard more comfortable also decoded better with it. At n ≈ 12 that individual-differences result is more likely to survive than the group difference the design is nominally about. See Questionnaires.
  • The counterbalancing moves in lockstep. All three groups use the same scheme, which over two runs assigns the arm by sub-XXX parity — so there are two distinct session orders, not 2³ = 8. sub-001 gets circle-first, cyan-first, checkerboard-first; sub-002 gets the exact mirror. Order is balanced within each contrast, which is what the design asks for, but it is perfectly correlated between contrasts, so any fatigue or drift effect lands on the same arm of all three. Switching two of the three groups to randomized decouples them (the resolver seeds each randomized group independently, from the protocol seed, the participant and the group label). Zerafa is the cautionary case: every subject ran plain first and checkerboard second, so its 9.26% advantage for plain cannot be separated from session order.
  • The frequency band. The design is agnostic to it — the grid, timing and counterbalancing are unchanged by moving it. Moving to 30–50 Hz costs amplitude, and costs the class its visible response; it would want more trials or a longer window to compensate. Note that the checkerboard run would move twice as far, and would end up the only run whose reversal rate is comfortably outside the provocative band.
  • Blocks and trials. 3 blocks × 9 trials is about as short as nine classes can be while still giving each target three trials. More blocks buys a better accuracy estimate at the cost of class time.
  • Whether the two baselines stay separate. The shape group's baseline and the pattern group's baseline are the same stimulus, recorded twice, so that each contrast is self-contained and internally counterbalanced. They are separate runs with separate names so the two recordings are distinguishable without reading run_index. Merging them would save three minutes and cost each contrast its own baseline.
  • Make the solid arm square if the rendered_freq_hz bug is fixed. As shipped the solid runs are sinusoidal and the reversal arm is necessarily square, so the pattern contrast carries a waveform difference as well as a spatial one (§4.3). Converting the baselines to square would remove it and bring the example closer to the literature it draws on; it is not done here only because square manifests currently get a rendered_freq_hz stamp the renderer never produces.

What holding the layout constant does not buy you

Inter-stimulus spacing is not a neutral background variable. Gao found a significant stimulus-type × inter-stimulus-distance interaction: accuracy varied by 0.13 across spacings for flicker stimuli but only 0.03 for pattern stimuli. Holding the layout constant across conditions — which this design does, deliberately — equates the two conditions on spacing but does not neutralise it. It fixes one point on two differently-shaped curves.

This design uses a single spacing (6.0° centre-to-centre, a mid-range separation rather than an extreme one, which makes that one sampled point as representative as one point can be) and discloses the limitation rather than doubling the trial count to cross spacing with morphology. Report it in degrees of visual angle, never in pixels or centimetres, so it is comparable to Gao and to anyone replicating.

Draft text for a write-up:

Stimulus layout, including inter-stimulus spacing, was held constant across the checkerboard and flicker conditions. This equates the two conditions on spacing but does not neutralise it: Gao et al. report a significant stimulus-type × inter-stimulus-distance interaction, with flicker stimuli showing substantially greater variation in decoding accuracy across spacings (0.13) than pattern stimuli (0.03). A single spacing therefore samples one point on two differently-shaped functions, and the magnitude — though not necessarily the direction — of the morphology difference reported here may not generalise to other target separations. Because the flicker condition is the more spacing-sensitive of the two, this limitation bears asymmetrically on the flicker estimate. Resolving it requires crossing spatial frequency with inter-stimulus distance, which no published study has done.

One more layout constraint worth stating, since this grid satisfies it by luck rather than design: Di Russo's V1 source inverts phase by roughly 180° between the upper and lower visual field. An array should be symmetric about the horizontal meridian, or entirely on one side of it. The 3×3 grid is symmetric about it — but a 2×3 or an off-centre variant would not be, and that is the kind of change that looks cosmetic and is not.