Dejitter clock reconstruction for jittery NWB timestamps - #18
Merged
Conversation
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).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):method=for a future PCHIP. End segments are extrapolated to the true domain edges so the outer half-bins aren't clamped flat.index → dataset_time) for a lone stream.device → datasetconversionCfrom the cleanest group member, clean the target's device time from its sample index, push throughC, and re-anchor to the target's own fixed latency via a robust median. A jittery stream is rescued by a clean sibling.Integrated into NWBSlicer: pairs converted ↔
*_device_tsstreams by HDF5 object identity (hard-link), transparently loads and then hides partners under astream_keysfilter, 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
Notable subtleties handled
*_device_tsepoch timestamps + a float32rateunder NumPy weak promotion inflatedstop_timeto ~1.7e9; and a knife-edgeceilat epoch scale could drop a boundary sample — both fixed.real_gap_thresholdallows explicit control.Tests
28 new tests (
test_clockmodel.pyunit +test_dejitter.pyslicer/iterator integration, incl. a gapped NWB fixture verifying gap preservation and the disable path). Full suite: 198 passed, ruff clean.🤖 Generated with Claude Code