Per-stream monotonic device→local time conversion#189
Merged
Conversation
C++ integration (tests/integration/): - test_nplay_sync_stress.cpp: wrap-segmented host-timeline monotonicity for file-playback nPlay, plus an opt-in nPlay+live-Hub2 cross-device variant (CERELINK_HW_MULTIDEVICE). - nplay_fixture.h: unique per-launch lock name + remove the real $TMPDIR/<name>.lock file, fixing the alternating "multiple instances" failures caused by a leftover O_EXCL lock. - download test assets from the v9.11.0 release. pycbsdk (pycbsdk/tests/): - test_lsl_sync.py + _lsl_generator.py: stamp every batch with device_to_monotonic and assert a monotonic host timeline on the --device=LSL path, single-nPlay and nPlay+live-Hub2. - conftest.py: pylsl-driven LSL nPlayServer fixtures; a collection hook that runs LSL tests only in isolation (one NPLAY shmem per device type); EPERM-tolerant chmod for freshly-downloaded signed binaries; lock-file cleanup; and a guard that fails when the dev-built libcbsdk is older than the C++ sources. - pyproject.toml: pylsl/numpy test deps and the `lsl` marker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Expose toLocalTime as a batch API to pycbsdk and add optional, per-stream monotonicity enforcement applied AFTER device->steady_clock conversion, so downstream consumers (LSL ingest, RESAMPLE/MERGE) get a non-decreasing host timeline without each client re-deriving the offset. - ClockSync: add a discontinuity epoch (syncEpoch) bumped only when the committed offset steps to a new regime (cold-start / confirmed step / stepout / external adopt / source change / wrap), never on a smooth slew or sub-deadband converge. A captured prev_committed guard keeps a stable peer offset from churning the epoch every sample. Plumbed through the IDeviceSession interface, impl, and wrapper. - SdkSession: toLocalTimeBatch(stream_id, ...) + resetMonotonic(stream_id). Each stream keeps its own non-decreasing floor and last-seen epoch; on an epoch change the floor resets (follow the new regime), else a backward step is clamped (non-decreasing). One consistent (offset, epoch) snapshot per batch. stream_id < 0 is a stateless conversion. CLIENT/shmem mode derives the epoch best-effort from offset jumps > ~slew_max. - C API: cbsdk_session_to_local_time (batch) + cbsdk_session_reset_monotonic; offset/uncertainty getters retained. - pycbsdk: device_to_monotonic_batch(device_ns, stream_id=-1) and reset_monotonic; scalar device_to_monotonic now delegates with stream_id=-1 (no behavior change for existing callers). Tests: ClockSync epoch unit tests; nPlay file-wrap integration test asserting the monotonic stream never steps back within a segment (verified across 10 real wraps); pycbsdk LSL strict-non-decreasing test (verified on hardware). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Summary
Adds optional, per-stream monotonicity enforcement to device→host time conversion, applied after the
device_ns → steady_clockmapping, and exposes the conversion as a batch API to pycbsdk. Downstream consumers (LSL ingest, ezmsg-blackrock RESAMPLE/MERGE) get a non-decreasing host timeline without each client re-deriving the clock offset.The branch also lands the hardware nPlay/Hub2 clock-sync stress-test infrastructure the feature is validated against (commit
a0833c4).Motivation
Source device timestamps are almost always monotonic, and the
steady_clock → time.monotonic()hop is stable. The remaining non-monotonicity enters atdevice_ns → steady_clock: whenever the committed clock offset moves (re-sync, nPlay file wrap, step). Consumers that assume monotonic time (RESAMPLE/MERGE) stall on the resulting backward jumps. This makes the conversion the single chokepoint and clamps backward steps there — except across a genuine clock re-sync, where clamping would stall the timeline indefinitely.How it works
ClockSyncdiscontinuity epoch (syncEpoch()): a counter bumped only when the committed offset steps to a new regime — cold-start, confirmed step, stepout, external/peer adopt, source change, device wrap — and never on a smooth slew or sub-deadband converge. This is the structural "the timeline moved to a new regime" signal, with no magnitude threshold to tune. A capturedprev_committedguard keeps a stable peer offset from churning the epoch every sample.SdkSession::toLocalTimeBatch(stream_id, …)): eachstream_idkeeps its own non-decreasing floor + last-seen epoch. On an epoch change the floor resets (follow the new regime); otherwise a backward step is clamped (non-decreasing). One consistent(offset, epoch)snapshot per batch.stream_id < 0= stateless. Independent floors per stream (e.g. 30 kHz raw vs 1 kHz) sharing one clock correction; deferred/sparse conversion works because the floor advances on the conversion-call sequence, not on data arrival.ClockSync, so the epoch is derived best-effort from offset jumps larger than a smooth slew (~slew_max). Documented as best-effort until the shmem layout carries a real epoch field.API surface
SdkSession::toLocalTimeBatch(stream_id, device_ns[], out_steady_ns[], n),resetMonotonic(stream_id);ClockSync::syncEpoch()plumbed throughIDeviceSession.cbsdk_session_to_local_time,cbsdk_session_reset_monotonic(offset/uncertainty getters retained).device_to_monotonic_batch(device_ns, stream_id=-1),reset_monotonic(stream_id). Scalardevice_to_monotonicnow delegates withstream_id=-1— no behavior change for existing callers.Testing — verified end-to-end against real nPlayServer
ClockSyncepoch tests (cold-start, stable/no-churn, confirmed-step-once, external adopt/re-affirm/revert, device wrap).clock_sync_tests27/27, multidevice 6/6, cbdev 40/40, cbshm 101/101.TimelineCapturerefactor).Known minor
STANDALONE reads offset and epoch as two separate locked calls on the device's
ClockSync; a discontinuous commit landing exactly between them yields at most a one-batch forward clamp transient at a regime boundary — never a backstep. The 10-wrap hardware run was clean. A combinedgetOffsetAndEpoch()snapshot would close it if desired.Follow-up (not in this PR)
CereLink has no logging facility (only a lone
fprintffor dropped packets). Rather than log epoch bumps from inside the library (wrong layer; the bump sites run under the clock-sync mutex), a small reason enum alongside the epoch — exposed via the C API / pycbsdk — would let the application log "clock regime change: confirmed step, offset X→Y" with proper context. Happy to add as a follow-up.🤖 Generated with Claude Code