Skip to content

Timestamp compensation: Physio16 realignment, filter recalibration, system delay, mock fidelity#15

Open
cboulay wants to merge 6 commits into
masterfrom
cboulay/fix_decimation_skew
Open

Timestamp compensation: Physio16 realignment, filter recalibration, system delay, mock fidelity#15
cboulay wants to merge 6 commits into
masterfrom
cboulay/fix_decimation_skew

Conversation

@cboulay

@cboulay cboulay commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes the LSL timestamps the app assigns reflect when a signal actually occurred, not when its bytes arrived — so EEG, Physio16, and DIN line up with each other and with external event markers. Adds three timestamp corrections (all measured on hardware) plus mock-server fidelity so the behaviour can be validated locally.

Changes

Physio16 realignment (decimated mode). The PIB/PNS acquisition path leads the FPGA-filtered EEG by a fixed ~33 ms, so the same event lands at different sample indices in the EEG vs physio channels of the combined stream. A new header-only PhysioDelayLine buffers the physio channels by floor(33 ms × rate) samples (config physioAlignDelayMs, default 33), emitting zeros during the warm-up window. Operates on raw int32 PIB counts so it serves both the native and float output paths.

Filter-delay recalibration. getFilterDelaySeconds updated to the measured DIN→EEG group delays (250 → 111, 500 → 61 ms; 1000 unchanged at 36) from a 300 s DIN/EEG/Physio16 sweep — repeatable across machines and device versions.

Removed the --align-timestamps toggle. Alignment now follows the configured mode automatically: decimated ⇒ filter + physio corrections; native ⇒ neither. The app trusts the user's configured mode at the ambiguous 500/1000 Hz rates (where native/decimated are indistinguishable in the stream). GUI checkbox, CLI flag, and config field all removed; the _aligned LSL source-id suffix dropped.

System-delay compensation (always applied). New systemDelayMs (double, default 4.5 ms) subtracted from every pushed timestamp — EEG, Physio16, and DIN — to compensate the digitization→client pipeline (firmware + network + read path). Measured ~5 ms in native mode; the default deliberately under-compensates so a sample is never back-dated before its event.

Mock-server fidelity. The mock now injects a common reference pulse into DIN pin-1 / EEG ch1 / physio ch1 with the device-accurate skews (EEG lagged by the filter delay, physio leading EEG by 33 ms; native = 0), distinguishes native vs decimated mode, and paces streaming to an accurate sample rate (was running ~0.8× nominal).

Docs. New README "Timestamp Compensation" section covering all three corrections; values persisted in ampserver_config.cfg.

Validation

  • Skews verified end-to-end against the mock: DIN→EEG 27/30/36 samples and EEG−physio 8/16/33 samples at 250/500/1000.
  • Absolute latency measured on real hardware (audio tone split to an MCC DAQ ruler + EGI Physio Ch1, recorded to XDF): EGI native pipeline ≈ 5 ms; decimated compensation holds the delivered latency near native instead of the 5–85 ms raw group delays. (Measurement harness not included in this PR.)

cboulay and others added 6 commits June 23, 2026 20:24
…delays

The PIB/Physio16 acquisition path leads the FPGA-filtered EEG by a fixed
~33 ms in decimated mode, so the same physical event lands at different
sample indices in the EEG vs physio channels of the combined outlet. The
existing timestamp offset can't fix this (EEG and physio share one
timestamp), so add a per-channel delay line that buffers physio on the raw
int32 ADC counts and emits zeros during warm-up.

- PhysioDelayLine: header-only ring buffer applied in readPacketFormat2,
  serving both the native (int32) and float output paths.
- The 33 ms skew is a physical delay the device quantizes to whole samples,
  so the applied delay is floor(33 ms x rate): 8 @ 250 Hz, 16 @ 500 Hz
  (both 32 ms), 33 @ 1000 Hz. Computed with integer division, not round().
- Store the delay in ampserver_config.cfg (<physioaligndelayms>, default 33).

Remove the "Align Timestamps" control (GUI checkbox, CLI --align-timestamps,
config field). Alignment now follows the user's attempted config: shouldAlign()
returns true iff the configured mode is decimated, and gates both the timestamp
offset and the physio delay. An already-running stream at an ambiguous rate
(500/1000 Hz) is assumed to match the configured mode. Drops the "_aligned"
LSL source-id suffix (decimated now always implies aligned).

Recalibrate getFilterDelaySeconds DIN->EEG delays to measured, repeatable
values (confirmed across machines/device versions): 250 Hz 112->111 ms,
500 Hz 66->61 ms. 1000 Hz unchanged at 36 ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make MockAmplifier reproduce the relative timing the real device exhibits so
the app's PhysioDelayLine and timestamp alignment can be validated against it.

- Inject a common reference pulse (jig emulation) into DIN pin-1 (active-low),
  EEG ch1, and physio ch1. DIN reflects the event immediately; EEG lags it by
  the FPGA anti-alias filter delay (111/61/36 ms decimated) and physio leads
  EEG by a fixed 33 ms (floor-quantized to samples), matching the device.
- Add a decimated/native mode flag (set by setNativeRate/setDecimatedRate) and
  an activeRate() helper; native mode applies no skew. This also fixes a latent
  bug where the stream always ran at decimatedRate, ignoring native rates.
- Replace the old free-running DIN counter with the active-low pin-1 reference.

Verified end to end: MockAmpServer -p 1 + delay_capture_sweep against
127.0.0.1 yields DIN->EEG 27/30/36 samples and EEG-physio 8/16/33 samples at
250/500/1000 decimated, and 0 in native.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The streaming loop slept a fixed 5 ms *after* generating/sending each packet, so
the effective rate sagged well below nominal (~816 Hz at 1000). The fixed 5 ms
interval also can't represent non-1000 rates: 250 Hz -> 1 sample/5 ms = 200 Hz.

Pace to a steady deadline with sleep_until, and set the interval to
samplesPerPacket/rate so every rate is exact. Measured rates are now
250.0/500.1/1000.5 Hz (was 163/322/816), so the timestamp-derived delays line
up with the sample-domain skews. A resync guard avoids bursting after a stall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The EEG anti-alias delay rounds to the nearest sample instead of flooring, so
DIN->EEG lands at 112/62/36 ms (was 108/60/36) -- closer to the device's
111/61/36, which the app applies as a continuous (non-quantized) timestamp
offset anyway. The physio lead keeps flooring to match the device's own
whole-sample quantization, so EEG-physio is unchanged at 32/32/33 ms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Subtract a configurable system/pipeline latency (systemDelayMs, default 4.5 ms)
from every pushed LSL timestamp -- EEG, Physio16, and DIN alike -- to compensate
for the time between digitization and the sample being available to time-stamp
in this client (device firmware + network transmission + read path). Measured
~5 ms in native mode via the audio latency test; the default deliberately
under-compensates so a sample is never back-dated before its event. Applied
unconditionally at the point the batch timestamp is captured, on top of the
decimated-only FPGA filter offset.

Document the full timestamp-compensation behaviour in the README (system delay,
FPGA filter offset, Physio16 realignment), replacing the stale --align-timestamps
section and correcting the filter-delay table to the measured 111/61/36 ms.

Also gitignore the audio-latency-test recordings and Python caches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant