Skip to content

Dejitter clock reconstruction for jittery NWB timestamps - #18

Merged
cboulay merged 1 commit into
devfrom
feature/dejitter-clock-reconstruction
Aug 13, 2026
Merged

Dejitter clock reconstruction for jittery NWB timestamps#18
cboulay merged 1 commit into
devfrom
feature/dejitter-clock-reconstruction

Conversation

@cboulay

@cboulay cboulay commented Aug 13, 2026

Copy link
Copy Markdown
Member

Problem

Some acquisition streams carry per-sample timestamps that are correct in aggregate (their median interval gives the true sample rate) but jittery sample-to-sample — the pipeline stamps each sample at arrival time rather than from the ADC clock. The iterator's gap-splitter then shatters such a stream into tens of thousands of tiny messages. On the reference file, an 8.2M-sample HUB1 stream fragments into ~285k messages (median run length 13 samples), which makes offline processing intractable.

The jitter is not data loss — the same structure appears in the device timestamps, and the two acquisition hubs share a PTP-synchronised device clock — so the fix is to replace the jittery timestamps with a smoothed, strictly-monotone version that preserves the true (slowly drifting, non-linear) clock while discarding the jitter, keeping the converted clock so cross-source alignment is retained.

Approach

New clockmodel.py (pure numpy, file-agnostic — usable both at read time and by a future offline NWB-corrector):

  • Monotone piecewise-linear map through binned-median knots, numpy-only (no scipy), pluggable method= for a future PCHIP. End segments are extrapolated to the true domain edges so the outer half-bins aren't clamped flat.
  • Self-fit reconstruction (index → dataset_time) for a lone stream.
  • Shared-clock model (the PTP idea): build the device → dataset conversion C from the cleanest group member, clean the target's device time from its sample index, push through C, and re-anchor to the target's own fixed latency via a robust median. A jittery stream is rescued by a clean sibling.
  • Auto clock-group detection from device-time endpoints (cheap scalar reads) + disk cache keyed by file signature and fit params for multi-pass workloads.
  • Real-gap guard: genuine data gaps are detected (threshold auto-derived above each stream's jitter envelope, verified as sustained level shifts) and preserved per-segment, so the iterator still splits a chunk there while jitter is smoothed.

Integrated into NWBSlicer: pairs converted ↔ *_device_ts streams by HDF5 object identity (hard-link), transparently loads and then hides partners under a stream_keys filter, and recomputes the stop bound in float64 while excluding the epoch-scale device partners. Forwarded through the iterator and clock-driven producer via new settings — dejitter (default on), clock_groups, dejitter_cache, real_gap_threshold.

Results on the reference file

  • HUB1: 284,159 gaps → 0, strictly monotone, within 0.58 ms std of the recorded timestamps (cross-hub shared model); HUB2: 27 → 0.
  • Iterator fragments collapse from ~900/chunk to 1/chunk, identical chunk grid, zero sample loss.
  • Cold open 1.13 s → warm (cached) 0.24 s.
  • Real-gap guard auto-threshold lands at 13.4 ms on HUB1 (above its 7.9 ms jitter tail) → 0 false gaps, while a genuine larger gap is preserved.

Notable subtleties handled

  • Endpoint clamping of binned-median knots (was mis-timing the outer half-bins by seconds).
  • float32/epoch bound blow-up: *_device_ts epoch timestamps + a float32 rate under NumPy weak promotion inflated stop_time to ~1.7e9; and a knife-edge ceil at epoch scale could drop a boundary sample — both fixed.
  • Real gaps vs clock-sync steps are indistinguishable from timestamps alone, so the guard is conservative by design (preserves only jumps well beyond the jitter tail); real_gap_threshold allows explicit control.

Tests

28 new tests (test_clockmodel.py unit + test_dejitter.py slicer/iterator integration, incl. a gapped NWB fixture verifying gap preservation and the disable path). Full suite: 198 passed, ruff clean.

🤖 Generated with Claude Code

Some acquisition streams (e.g. CereLink HUB1) carry per-sample timestamps
that are correct in aggregate but jittery sample-to-sample, so the iterator's
gap-splitter shatters them into hundreds of thousands of tiny messages (one
file fragments an 8.2M-sample stream into ~285k). Add a read-time dejitter
pass that replaces such timestamps with a smoothed, strictly-monotone version
that preserves the true non-linear clock drift while discarding the jitter.

New `clockmodel.py` (pure numpy, file-agnostic so an offline NWB-corrector can
reuse it):
- Monotone piecewise-linear map through binned-median knots, with end-segment
  extrapolation so the outer half-bins aren't clamped flat.
- Self-fit reconstruction (index -> dataset time) and a shared-clock model that
  builds the device->dataset conversion from the cleanest PTP-synchronised
  sibling and rescues a jittery stream through it, re-anchored to its own
  fixed latency.
- Auto clock-group detection from device-time endpoints; disk cache keyed by
  file signature + fit params for multi-pass workloads.
- Real-gap guard: genuine data gaps are detected (threshold auto-derived above
  each stream's jitter envelope, verified as sustained level shifts) and
  preserved per-segment so the iterator still splits there.

Integrated into NWBSlicer (pairs converted<->*_device_ts streams by HDF5
object identity, hides auto-loaded partners under a stream filter, recomputes
the stop bound in float64 excluding the epoch-scale device partners) and
forwarded through the iterator and clock-driven producer via `dejitter`,
`clock_groups`, `dejitter_cache`, and `real_gap_threshold` settings; on by
default.

On the reference file HUB1 goes from 284,159 gaps to 0 (monotone, within
0.58ms of raw via the cross-hub model), collapsing ~900 messages/chunk to 1
with no sample loss. Adds 28 tests (clockmodel unit + slicer/iterator
integration incl. real-gap preservation).
@cboulay
cboulay merged commit 6335771 into dev Aug 13, 2026
14 checks passed
@cboulay
cboulay deleted the feature/dejitter-clock-reconstruction branch August 13, 2026 16:12
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