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.
- Handshake.
SerialPhotodiode.open()waits for the firmware'sSTARTline and reads theFS_HZline that follows (reading it as data would leave every later record misaligned). - Timebase calibration.
calibrate_timebase(), before any stimulus; see below. - 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.
- 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, andrecord_offsetis how the reader finds a record boundary again. - 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. - 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 asslope_freq_hz. - Verdict.
capture_refusals()andcapture_flags()judge the capture;passedcombines tolerance, integrity, clipping and refusals. - Report.
MeasureFlickerReportsaves 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_amplitudefalls 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 recordsn_samples.- The floor is set below a 100 Hz target on a 240 Hz panel (2.4 frames per cycle,
rel_amplitude0.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-dejitterkeeps 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) |