Skip to content

Technical reference and troubleshooting

How the flik-o-meter software works, for anyone changing it or needing to know exactly what a number means. Operators need the using and results pages, not this one.

Where the code is

What Where
Capture, analysis and the report src/ssvep/stim/photodiode.py
The Measure flicker presentation src/ssvep/stim/renderer.py (measure_flicker(), --measure-flicker)
The Measure flicker dialog src/ssvep/ui/design.py
Dark reference and contrast figures src/ssvep/stim/photodiode.py (DarkReference, dark_relative)
Latency from a recording src/ssvep/analysis/light_latency.py, scripts/extract_light_latency.py
Test designs scripts/build_latency_qc_manifests.py, scripts/build_reversal_qc_manifests.py
Firmware and wire format hardware/flikometer/firmware/; the build guide's Firmware and wire format
Tests (synthetic signals, no hardware) tests/test_photodiode.py, tests/test_light_latency.py

How a measurement works

The pipeline is a thin input/output shell around pure functions. SerialPhotodiode is the only part that touches hardware, and it imports pyserial only when used, so analysis machines need no serial library. Everything else is a pure function of (samples, sample rate), tested on synthetic waveforms.

  1. Handshake. SerialPhotodiode.open() waits for the firmware's START line and reads the FS_HZ line that follows (reading it as data would leave every later record misaligned).
  2. Timebase calibration. calibrate_timebase(), before any stimulus; see below.
  3. Capture. While all targets flicker, a worker thread reads the cued target's samples, so the vsync-locked render loop never waits on the serial port.
  4. Integrity. check_counters() checks that the sample index rises by exactly one each time. The stream has no framing byte, so a single lost USB byte would shift every later record; the index is what reveals it, and record_offset is how the reader finds a record boundary again.
  5. Frequency. estimate_frequency(sig, fs, expected_hz=…): an FFT with parabolic interpolation between bins, searching only a band around the design's frequency, so a peak from room lights flickering at 100 or 120 Hz, or a square wave's harmonic, cannot win.
  6. Phase drift. phase_drift(sig, fs, f): the Hilbert-transform phase of the band-passed signal, compared with the best-fitting steady tone. A steady flicker that is slightly off frequency reads near zero drift; only wander and jitter show. The fitted slope is the independent frequency estimate reported as slope_freq_hz.
  7. Verdict. capture_refusals() and capture_flags() judge the capture; passed combines tolerance, integrity, clipping and refusals.
  8. Report. MeasureFlickerReport saves the JSON report, named after the design and its manifest fingerprint.

Phase drift in detail

The band-pass that phase is taken from is ±5 Hz around the flicker frequency from 20 Hz up, and ±¼ of the frequency below that (3–5 Hz at 4 Hz). It is built as second-order sections (butter(..., output="sos") with sosfiltfilt); the transfer-function form (b, a) is numerically unstable at a few hertz with a 2 kHz sample rate and returns a confident wrong answer. The filter's settling time is discarded at each end of the capture. Below 20 Hz that is about 8.4 cycles, so a capture needs about 19 cycles of the flicker before drift can be measured at all: 4.7 s at 4 Hz, 3.8 s at 5 Hz. Shorter captures report drift and slope as null rather than guessing.

Timebase calibration

Every measured frequency scales with the sample rate the analysis believes, and the board's resonator is only good to ±0.5 %. SerialPhotodiode.calibrate_timebase(window_s) reads the stream continuously for the window (default DEFAULT_CALIBRATE_S, 60 s, shared by the command line, the dialog and measure_flicker()) and fit_sample_rate() fits a straight line of sample index against each chunk's arrival time on the PC's clock. The slope is the board's sample rate; USB delivery delay moves only the intercept. The fit is rejected, and the declared 2000 Hz used instead, if there are too few chunks or the rate is more than 2 % from the declared one, which indicates a stall or a stream fault rather than a resonator. It returns a TimebaseFit that says whether it was adopted, and the report's notes record the outcome.

Why the window is 60 s rather than 10 s is explained, with the numbers, under Calibration.

⚠️ Never use calibrate_fs(n, elapsed). This older function divides one block's sample count by its wall-clock time. It measures USB delivery, not the board: the FTDI chip's latency timer keeps a nearly constant backlog, so a block's first samples arrive "free" and the rate reads high. On hardware it returned 2016.9 Hz for a board running at about 2000, which scales every frequency up by 0.8 %: 0.32 Hz at 40 Hz, more than three times the tolerance, and it still passed its own ±5 % sanity check. It is kept only for offline callers and is not used by the capture path.

Thresholds

The refusal and flag thresholds are constants in src/ssvep/stim/photodiode.py, and every report records the values it was judged with (refusal_thresholds, flag_thresholds):

Constant Value Used for
MIN_FLICKER_AMPLITUDE_COUNTS 16 counts flat_trace. Measured on the Hann-windowed in-band peak, so the effective floor is 16–19 counts depending on where the tone falls between FFT bins
REL_AMPLITUDE_FLOOR 0.02 weak_signal
MIN_CYCLES 5 too_few_cycles
MIN_PEAK_TO_SECOND 2 ambiguous_peak (a rival peak above 1 Hz, outside the target peak's own main lobe, more than half as strong)
MAX_OFFBAND_TO_PEAK 3 off_target_dominant (a peak above 1 Hz outside the search band)
MAX_DRIFT_CYCLES 0.25 cycle frequency_changed
MAX_SLOPE_DISAGREEMENT 5 % frequency_changed
SLOPE_AGREEMENT_FRAC 1 % the slope_disagrees flag

⚠️ These are provisional. They come from one bench session: one monitor, one sensor, one gain setting, one level of room light and one bad capture. They are loose on purpose, to refuse only the plainly worthless, and they should be refitted from captures across more units and displays. Known limits:

  • rel_amplitude falls as a capture gets longer or the sample rate rises (both put more FFT bins under the noise floor), so the floor assumes captures of a few seconds at about 2 kHz; each target records n_samples.
  • The floor is set below a 100 Hz target on a 240 Hz panel (2.4 frames per cycle, rel_amplitude 0.036 against 0.077–0.115 for its neighbours), so a frequency the display renders poorly but at the right rate is not refused. Whether it should be is for the refit to decide.

The reversal test designs

flik-o-meter-checkerboard-reversal.json and flik-o-meter-grating-reversal.json in qc-fixtures/run-manifests/ are generated by scripts/build_reversal_qc_manifests.py so that the centre of every target reverses fully, since the sensor is placed while the targets are black. The grating needs a 4° target: the renderer draws a sinusoidal grating with a crest at the target's left edge, so the centre is a crest only when a whole number of cycles spans the target, and at 3° it would be a zero crossing that does not change on reversal. A sinusoidal grating is also at full swing only on the crest itself, so placement matters much more than for a checkerboard.

How precisely to place the sensor, on the declared display (27 in, 2560 × 1440, viewed from 75 cm, where one degree is 13.1 mm), with the OPT101's 2.29 mm window:

  • Checkerboard: the centre check is 13.1 mm across, so the window stays inside it anywhere within about ±5.4 mm of the centre.
  • Grating: the band with at least 90 % of full swing is about 3.7 mm wide (±0.07 of the 26 mm period), so the whole window stays inside it only within about ±0.7 mm of the crest.

Latency extraction in detail

scripts/extract_light_latency.py reads each trial's marker against the light in the named channel.

  • Edge latency is the first crossing of halfway from the dark level to the lit level, interpolated between samples. Phase lag is the flicker fundamental's phase at the marker against the phase the declared waveform has at onset, unwrapped onto the edge's cycle. A trial whose step is present but whose edge falls outside the search window keeps its onset phase and gets no phase lag, because without the edge a delay is known only to within one period.
  • Segments. A segment boundary is a timestamp gap of at least --gap-s (default 1 s), a second stream of the same name in one file, or a separate recording. An acquisition restart that left no gap and simply stepped the timestamps is not detected; record each run separately, as the latency protocol does, so every restart is its own file.
  • Timestamps. By default each gap-free piece of each stream is dejittered with its own linear fit, as the analysis does; --no-dejitter keeps the recorded timestamps. The file is read raw first and split on the recorded timestamps, because a fit across a gap moves every sample near it and turns a short dropout into invented jitter (pyxdf's own dejitter breaks only at gaps over the larger of 1 s and 500 samples).
  • What the latency includes. The wait for the first stimulus frame to be shown, the panel's scan-out to the target and its pixel response, and the amplifier's own filter delay. The difference between two amplifiers on the same display belongs to the acquisition path, which includes each front end's digital filter delay (on the Cyton, the ADS1299's decimation filter), a fixed and correctable delay, as well as how the bridge timestamps samples.
  • Channel labels. A bench recording made through the toolbox carries the electrode carrier's labels, one of which is the light's channel; a BIDS conversion of it will name that channel after an electrode that was not there.
  • Polarity is worked out per segment from the level between trials, and recorded, since it depends only on how the sensor was wired.

Design principles

  • The manifest is authoritative. Nominal frequencies come from the design, never inferred from the light.
  • Pure analysis core. The analysis is pure functions of samples and sample rate, tested without hardware.
  • The render loop never blocks. Serial capture runs on a worker thread.
  • No participant data. The sensor reads screen light only, and reports carry none.

Troubleshooting

Problems building the device, uploading firmware or getting a first reading are in the build guide's troubleshooting table. This table covers running the tests.

Symptom Likely cause What to do
No stimulus window for the first minute after Start Timebase calibration is running Expected; wait
The dialog doesn't preselect a port The board's USB chip is not an FTDI FT232R (a clone or another board), or it was plugged in after the dialog opened Click Rescan, or pick the port by hand
Handshake times out Another program holds the port, the wrong port, or the board runs the check sketch rather than the firmware Close serial monitors, pick the right port, upload the streaming firmware
timebase: calibration fit rejected The stream stalled or fell out of step during calibration Check the cable, avoid hubs, re-run. Results from that run may be up to 0.5 % out
Every target clipped at 0, Δ tiny Light-tight hood Let a little room light in (Calibration)
desync on some captures Bytes lost on the USB link Better cable, direct port; re-run those targets
flat_trace / weak_signal Sensor off the target or shaded Re-seat it, or use the sync square
too_few_cycles from the command line Capture too short for a slow flicker Lengthen --stim-s
off_target_dominant Room lights flickering into the sensor, or the wrong stimulus Shield the screen from direct room light; check the design
Δ consistently off by the same fraction on every target The timebase calibration was skipped or rejected Check the report's notes; re-run with the default 60 s
Δ fails on one high frequency only The display cannot render that frequency cleanly at its refresh rate Check with Measure refresh; choose a frequency with a whole number of frames per cycle
Pattern-reversal target shows no flicker The sensor sees more than one check, or the sync square was on Turn the sync square off; narrow the aperture; place the sensor on the centre
trough_below_dark flag The dark reference no longer matches (sensor moved, light changed) Start a fresh run with a new dark reference
A coloured target's counts don't match its luminance The sensor is not eye-weighted Expected; compare counts only between targets of the same colour (Calibration)