Skip to content

Flik-o-meter — photodiode flicker QC

How the toolbox measures the monitor's flicker directly and validates it against the manifest. This doc owns the software subsystem + operator SOP + report interpretation. For the hardware (sensor, schematic + step-by-step assembly, Arduino firmware, BOM with Digi-Key SKUs) see hardware/flikometer/BUILD_GUIDE.md; for first bring-up on the acquisition PC see docs/WINDOWS_TESTING.md §4/§4c.

Why

The renderer's dropped-frame stats say the loop kept time; they cannot say the screen actually emitted the right frequency. The flik-o-meter is the ground-truth check: an OPT101 photodiode + Arduino streams the on-screen light to the PC, and the toolbox confirms each target flickers at its manifest frequency. Analysis never trusts nominal frequency without this on real hardware. (The old LDR unit was retired — usable only to ~1–3 Hz, hopeless for the 30–50 Hz band we run.)

How it works

 OPT101 → Arduino ──USB serial──▶ SerialPhotodiode ──▶ analysis ──▶ MeasureFlickerReport (JSON)
 (2 kHz fixed-rate, 6-byte           (handshake +        (freq +          (per-target measured
  counter+ADC samples)                gap check)          drift)           vs nominal, saved)
                                          ▲                                     ▲
                            manifest nominal freq constrains the search   qc-reports/ (git-ignored)
All in src/ssvep/stim/photodiode.py (pure array-API core + a thin, lazy-import serial shell): - estimate_frequency(sig, fs, expected_hz=…) — FFT + parabolic sub-bin interpolation, search band-constrained to the manifest nominal so mains-driven room light (100/120 Hz) or a square-wave harmonic can't win. - phase_drift(sig, fs, f) — Hilbert instantaneous phase vs. the best-fit constant tone (a steady but slightly off-nominal flicker reads ~0 drift; only wander/jitter shows). - check_counters(...) — the stream has no framing byte, so a dropped USB byte desyncs everything after it; the monotonic sample counter is the tell. - calibrate_timebase(window_s) / fit_sample_rate(...) — measures the Arduino's true sample rate against the PC clock (a ceramic-resonator clone is ±0.5 %, enough to blow ±0.1 Hz on its own) by least-squares fitting counter against arrival time over a long, continuously-read window. Delivery latency shifts that line's intercept, not its slope, so the slope is the device's clock. Repeatable to ±0.02 Hz over 10 s on hardware; run once per session before the first target (--calibrate-s, default 10 s). ⚠️ The older calibrate_fs(n, elapsed) — one block's samples ÷ its wall-clock time — is not used by the capture path and must not be: it measures USB delivery, not the device. FTDI's latency timer keeps a near-constant backlog, so a block's leading samples arrive free and the rate reads high. On hardware it returned 2016.9 Hz for a board running at ~2000, scaling every measured frequency by +0.8 % — 0.32 Hz at 40 Hz, against a 0.1 Hz tolerance. It sailed through the ±5 % guard, because 0.8 % is nowhere near 5 %.

The presentation lives in src/ssvep/stim/renderer.py (measure_flicker() / --measure-flicker).

Operator SOP — running a flicker QC

GUI: Design mode → Measure flicker…, at the foot of the centre pane beside Preview flicker / Preview session. It belongs with them, not up in Display: what it acts on is the target list — it measures the frequencies this design asks for. (Measure refresh is the one that lives in Display, because it characterises the monitor on its own.) Pick the flik-o-meter's serial port — Rescan re-enumerates if you plugged it in after opening the dialog, and leaving it blank runs visual-only — set the tolerance and timebase-calibration time if the defaults (0.1 Hz, 10 s) don't suit, then Start. Output streams into the dialog as it runs, so you get the same per-target lines as the CLI without leaving the GUI; Abort stops it, as does Esc in the stimulus window. The run is measured against the design currently in the form, so its report carries that design's fingerprint — you do not need to save first. CLI (equivalent):

python -m ssvep.stim.renderer --measure-flicker --photodiode COM3 --fullscreen
# COM3 → /dev/tty.usbmodemXXXX on macOS; omit --photodiode for a visual-only run
# --sync-square  flash a full-contrast patch in the BOTTOM-LEFT corner at the cued frequency — park
#                the sensor there for the whole run instead of repositioning over each small target
#                (it also removes any neighbour-crosstalk question)
# --calibrate-s  seconds spent measuring the board's true sample rate before the first target
#                (default 10; 0 disables). Every measured frequency scales with this rate.
# --button       start each trial with the on-device button instead of SPACE (needs the button build)
It steps through each target once, self-paced: 1. The target is cued as a coloured outline around a black square (so the sensor starts from black). All other targets are black. 2. Place the photodiode on the cued target and press SPACE — or the on-device button if the wand has one and you passed --button (this is your time to reposition). With --sync-square the sensor instead stays parked on the bottom-left patch for the whole run; don't chase the targets, and neighbour-crosstalk stops being a question at all. 3. All targets flicker for the trial while the photodiode captures the cued one; then the next target is cued. Esc aborts.

A JSON report is always written (even visual-only) — see below.

Reading the report

Saved to qc-reports/flikometer_<experiment>_<fingerprint>_<timestamp>.json (git-ignored — it is monitor-light QC, no participant data). The filename ties the report to the exact protocol (experiment name + manifest fingerprint) and the run time, so it can't be mismatched.

Per target (measurements[]): | field | meaning / what to watch for | |---|---| | nominal_hz | frequency the manifest asked for | | measured_hz | timebase-corrected FFT estimate (the number to trust) | | delta_hz | measured − nominal; pass if |delta| ≤ tolerance_hz (default 0.1 Hz) | | slope_freq_hz | independent estimate from the phase slope; should agree with measured_hz | | max_abs_drift_ms | worst timing wander over the trial; large values = unstable flicker | | clipped | ADC hit a rail — read adc_min/adc_max for which, because the fixes are opposite | | adc_min / adc_max | raw extremes of the capture (0–1023). adc_max at 1023 = too much light or gain → lower gain (BUILD_GUIDE §3.4) or screen brightness. adc_min at 0 = the dark phase bottomed out → let a little ambient light in (see below) | | counters_ok / dropped_samples | serial stream integrity; false/nonzero = desync, distrust it | | passed | |delta| ≤ tolerance and counters_ok and not clipped |

⚠️ A fully light-tight hood makes every target fail

The one that costs an afternoon if you don't know it. Shroud the sensor completely and the flicker's dark phase floors the ADC at 0 — there is no ambient light to hold the trough above ground, and the OPT101's single-supply output cannot go below it. Every target then reports clipped and fails, while measuring perfectly: the first hooded bring-up gave Δ −0.019 Hz with 0.12 ms drift on all nine targets, and scored 0/9.

The tell is adc 0-190-ish: bottomed out, with the top nowhere near 1023 — so there is no excess gain to remove, and turning the brightness down makes it worse. Crack a small gap at the edge of the hood so a little room light gets in. That adds a DC offset which lifts the trough clear of zero (adc 6-222 in the run that then passed 9/9), leaves the swing and the frequency untouched, and is a better operating point besides — not a workaround.

Do not "fix" a bottomed capture by adding gain: gain scales the trough at 0 by 0 and only pushes the peak toward the other rail.

all_passed (top level) is the go/no-go. A FAIL on a 240 Hz panel usually means either a genuine frequency the display can't render cleanly (too few frames per cycle at this refresh) or a measurement problem (clipped / counter desync) — the per-target fields disambiguate.

Design principles honoured

  • Manifest is authoritative — the nominal frequencies come from the manifest, never inferred.
  • Array-API core — the analysis is pure functions over (samples, fs), unit-tested on synthetic waveforms with no hardware (tests/test_photodiode.py).
  • Agent boundary — serial capture runs on a worker thread; the vsync'd render loop never blocks.
  • Compliance — the sensor reads screen light only; reports contain no participant data.

Roadmap

  • Now (Level 1–2, built): capture + manifest-constrained validation + saved report; the Measure flicker button.
  • Level 3 (planned): stream the photodiode into LSL alongside EEG so a recording carries a marker→light→EEG latency channel (absolute timing, not just frequency). Tracked as REB amendment A4 (docs/REB_AMENDMENTS.md).

Pointers

  • Hardware / schematic / assembly / firmware / BOM — hardware/flikometer/BUILD_GUIDE.md
  • On-hardware bring-up runbook — docs/WINDOWS_TESTING.md §4c
  • Code — src/ssvep/stim/photodiode.py, src/ssvep/stim/renderer.py (measure_flicker)