Skip to content

Per-stream monotonic device→local time conversion#189

Merged
cboulay merged 2 commits into
masterfrom
cboulay/force_monotonic
Jun 30, 2026
Merged

Per-stream monotonic device→local time conversion#189
cboulay merged 2 commits into
masterfrom
cboulay/force_monotonic

Conversation

@cboulay

@cboulay cboulay commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds optional, per-stream monotonicity enforcement to device→host time conversion, applied after the device_ns → steady_clock mapping, 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 at device_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

  • ClockSync discontinuity 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 captured prev_committed guard keeps a stable peer offset from churning the epoch every sample.
  • Per-stream monotonizer (SdkSession::toLocalTimeBatch(stream_id, …)): each stream_id keeps 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.
  • CLIENT/shmem mode: no local 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

  • C++: SdkSession::toLocalTimeBatch(stream_id, device_ns[], out_steady_ns[], n), resetMonotonic(stream_id); ClockSync::syncEpoch() plumbed through IDeviceSession.
  • C API: cbsdk_session_to_local_time, cbsdk_session_reset_monotonic (offset/uncertainty getters retained).
  • pycbsdk: device_to_monotonic_batch(device_ns, stream_id=-1), reset_monotonic(stream_id). Scalar device_to_monotonic now delegates with stream_id=-1no behavior change for existing callers.

Testing — verified end-to-end against real nPlayServer

  • Unit: ClockSync epoch tests (cold-start, stable/no-churn, confirmed-step-once, external adopt/re-affirm/revert, device wrap). clock_sync_tests 27/27, multidevice 6/6, cbdev 40/40, cbshm 101/101.
  • C++ integration (file wrap): monotonic stream stays non-decreasing within every segment — 20949 batches, 10 real file wraps, 0 backsteps, 0.000000 ms max.
  • pycbsdk integration (LSL): strict non-decreasing — 18326 batches, 0 backsteps, 0.000000 ms max.
  • Existing raw-mapping stress test still passes (regression check on the TimelineCapture refactor).

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 combined getOffsetAndEpoch() snapshot would close it if desired.

Follow-up (not in this PR)

CereLink has no logging facility (only a lone fprintf for 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

cboulay and others added 2 commits June 26, 2026 16:37
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>
@cboulay cboulay merged commit cd00f9b into master Jun 30, 2026
23 of 26 checks passed
@cboulay cboulay deleted the cboulay/force_monotonic branch June 30, 2026 03:31
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