Skip to content

Commit 8ba45d6

Browse files
authored
Merge pull request #160 from CerebusOSS/cboulay/fix_sync_nplay
Fix timestamp bug when using nPlay
2 parents 4a44b55 + bc5e0e6 commit 8ba45d6

7 files changed

Lines changed: 391 additions & 22 deletions

File tree

examples/SimpleDevice/simple_device.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ void printPacket(const cbPKT_GENERIC& pkt) {
8080

8181
/// Map device type string to DeviceType enum
8282
DeviceType parseDeviceType(const std::string& type_str) {
83-
if (type_str == "NSP") return DeviceType::LEGACY_NSP;
84-
if (type_str == "GEMINI_NSP") return DeviceType::NSP;
83+
if (type_str == "LEGACY_NSP") return DeviceType::LEGACY_NSP;
84+
if (type_str == "NSP") return DeviceType::NSP;
8585
if (type_str == "HUB1") return DeviceType::HUB1;
8686
if (type_str == "HUB2") return DeviceType::HUB2;
8787
if (type_str == "HUB3") return DeviceType::HUB3;
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
"""Validate device-to-host clock conversion.
2+
3+
Receives live packets via callbacks, converts each ``header.time`` to
4+
``time.monotonic()`` via ``session.device_to_monotonic()``, and compares
5+
against the actual ``time.monotonic()`` at arrival. The difference is the
6+
one-way delivery latency — it should be small and stable when clock sync
7+
is working correctly.
8+
9+
Reports the detected protocol version. Protocol 4.0+ uses native PTP
10+
nanosecond timestamps; protocol 3.11 uses 30 kHz sample counters that the
11+
protocol wrapper upconverts to nanoseconds. Clock sync should work in both
12+
cases since the probe echo uses the same time base as data packets.
13+
14+
By default listens for event packets (spikes). Use ``--group`` to listen
15+
on a continuous sample group instead (useful when no spike channels are
16+
configured).
17+
18+
Usage::
19+
20+
python -m pycbsdk.cli.clock_check HUB1
21+
python -m pycbsdk.cli.clock_check HUB1 --interval 2.0
22+
python -m pycbsdk.cli.clock_check HUB1 --group 6
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import sys
29+
import time
30+
import threading
31+
from collections import deque
32+
33+
from pycbsdk import DeviceType, SampleRate, Session
34+
from pycbsdk.session import ProtocolVersion
35+
36+
37+
class ClockChecker:
38+
"""Accumulates packet arrival-vs-converted timestamps."""
39+
40+
def __init__(self, session: Session, window_sec: float):
41+
self._session = session
42+
self._window = window_sec
43+
self._lock = threading.Lock()
44+
# (arrival_mono, converted_mono, device_ns)
45+
self._samples: deque[tuple[float, float, int]] = deque()
46+
self._total = 0
47+
48+
def on_packet(self, header, data) -> None:
49+
arrival = time.monotonic()
50+
try:
51+
converted = self._session.device_to_monotonic(header.time)
52+
except Exception:
53+
return
54+
with self._lock:
55+
self._samples.append((arrival, converted, header.time))
56+
self._total += 1
57+
58+
def snapshot(self) -> dict | None:
59+
cutoff = time.monotonic() - self._window
60+
with self._lock:
61+
while self._samples and self._samples[0][0] < cutoff:
62+
self._samples.popleft()
63+
samples = list(self._samples)
64+
total = self._total
65+
if not samples:
66+
return None
67+
68+
# latency = arrival - converted (positive means packet arrived
69+
# after the converted timestamp, i.e. normal delivery delay)
70+
latencies = [(a - c) * 1000 for a, c, _ in samples]
71+
return {
72+
"n_window": len(samples),
73+
"n_total": total,
74+
"min_ms": min(latencies),
75+
"max_ms": max(latencies),
76+
"mean_ms": sum(latencies) / len(latencies),
77+
"last_latency_ms": latencies[-1],
78+
"last_device_ns": samples[-1][2],
79+
"last_arrival": samples[-1][0],
80+
}
81+
82+
83+
def clock_check(
84+
device_type: DeviceType,
85+
interval: float = 1.0,
86+
group: int | None = None,
87+
timeout: float = 10.0,
88+
) -> None:
89+
"""Connect to a device and print clock-conversion diagnostics.
90+
91+
Args:
92+
device_type: Device to connect to.
93+
interval: Seconds between reports.
94+
group: If set, listen on this sample group instead of events.
95+
timeout: Connection timeout in seconds.
96+
"""
97+
with Session(device_type=device_type) as session:
98+
deadline = time.monotonic() + timeout
99+
while not session.running:
100+
if time.monotonic() > deadline:
101+
raise TimeoutError(
102+
f"Session for {device_type.name} did not start within {timeout}s"
103+
)
104+
time.sleep(0.1)
105+
106+
proto = session.protocol_version
107+
ts_kind = ("30 kHz ticks (upconverted to ns)"
108+
if proto == ProtocolVersion.V3_11
109+
else "PTP nanoseconds")
110+
print(f"Connected to {device_type.name} protocol: {proto.name} "
111+
f"timestamps: {ts_kind}")
112+
113+
if proto == ProtocolVersion.UNKNOWN:
114+
print("WARNING: Protocol version unknown — results may be unreliable.",
115+
file=sys.stderr)
116+
117+
# Wait for at least one clock sync
118+
print("Waiting for clock sync ...")
119+
while session.clock_offset_ns is None:
120+
if time.monotonic() > deadline:
121+
raise TimeoutError("Clock sync did not arrive within timeout")
122+
session.send_clock_probe()
123+
time.sleep(0.25)
124+
125+
checker = ClockChecker(session, window_sec=interval)
126+
127+
if group is not None:
128+
rate = SampleRate(group)
129+
source = f"group {rate.name}"
130+
131+
@session.on_group(rate)
132+
def _on_group(header, data):
133+
checker.on_packet(header, data)
134+
else:
135+
source = "events (all channel types)"
136+
137+
@session.on_event(None)
138+
def _on_event(header, data):
139+
checker.on_packet(header, data)
140+
141+
print(f"Listening on {source} ...")
142+
143+
# Wait for at least one packet to arrive so we can sanity-check
144+
# that data-packet timestamps and clock-probe timestamps share
145+
# the same time base.
146+
pkt_deadline = time.monotonic() + timeout
147+
while True:
148+
snap = checker.snapshot()
149+
if snap is not None:
150+
break
151+
if time.monotonic() > pkt_deadline:
152+
raise TimeoutError(
153+
f"No packets received within {timeout}s — try --group"
154+
)
155+
time.sleep(0.1)
156+
157+
first_latency_ms = snap["mean_ms"]
158+
offset_ns = session.clock_offset_ns
159+
offset_s = offset_ns / 1e9 if offset_ns is not None else float("nan")
160+
print(
161+
f"\nClock sanity check (first packets):\n"
162+
f" Clock offset: {offset_s:+.6f} s "
163+
f"(device_ns - host_steady_ns)\n"
164+
f" First data pkt: {snap['last_device_ns']} ns "
165+
f"({snap['last_device_ns'] / 1e9:.6f} s)\n"
166+
f" Delivery latency: {first_latency_ms:+.3f} ms "
167+
f"(arrival_mono - device_to_monotonic(pkt.time))"
168+
)
169+
if abs(first_latency_ms) > 1000:
170+
print(
171+
f"\n WARNING: Latency magnitude > 1 s — data packet "
172+
f"timestamps and clock probe\n responses appear to use "
173+
f"different time bases. device_to_monotonic() results\n"
174+
f" will not be meaningful for this device.",
175+
file=sys.stderr,
176+
)
177+
178+
print(f"\nReporting every {interval}s ...\n")
179+
180+
hdr = (f"{'Pkts':>6s} {'Offset (ms)':>14s} {'Uncert (ms)':>11s} "
181+
f"{'Latency min':>11s} {'mean':>8s} {'max':>8s} {'last':>8s}")
182+
print(hdr)
183+
print("─" * len(hdr))
184+
185+
all_means: list[float] = []
186+
try:
187+
while True:
188+
time.sleep(interval)
189+
snap = checker.snapshot()
190+
191+
offset_ns = session.clock_offset_ns
192+
uncert_ns = session.clock_uncertainty_ns
193+
offset_ms = offset_ns / 1e6 if offset_ns is not None else float("nan")
194+
uncert_ms = uncert_ns / 1e6 if uncert_ns is not None else float("nan")
195+
196+
if snap is None:
197+
print(f"{'0':>6s} {offset_ms:>14.3f} {uncert_ms:>11.3f}"
198+
f" (no packets)")
199+
continue
200+
201+
all_means.append(snap["mean_ms"])
202+
print(
203+
f"{snap['n_window']:>6d} {offset_ms:>14.3f} {uncert_ms:>11.3f} "
204+
f"{snap['min_ms']:>+11.3f} {snap['mean_ms']:>+8.3f} "
205+
f"{snap['max_ms']:>+8.3f} {snap['last_latency_ms']:>+8.3f}"
206+
)
207+
208+
except KeyboardInterrupt:
209+
print()
210+
211+
if all_means:
212+
print(f"\nReports: {len(all_means)} "
213+
f"Mean latency min: {min(all_means):+.3f} ms "
214+
f"max: {max(all_means):+.3f} ms "
215+
f"overall: {sum(all_means) / len(all_means):+.3f} ms")
216+
217+
218+
def main(argv: list[str] | None = None) -> int:
219+
parser = argparse.ArgumentParser(
220+
prog="python -m pycbsdk.cli.clock_check",
221+
description="Validate device-to-host clock conversion.",
222+
)
223+
parser.add_argument(
224+
"device", nargs="?", default="NPLAY",
225+
help="Device type (default: NPLAY)",
226+
)
227+
parser.add_argument(
228+
"--interval", "-i", type=float, default=1.0,
229+
help="Seconds between reports (default: 1.0)",
230+
)
231+
parser.add_argument(
232+
"--group", "-g", type=int, default=None,
233+
help="Listen on sample group N instead of events. "
234+
f"Choices: {', '.join(f'{r.value}={r.name}' for r in SampleRate if r.value > 0)}",
235+
)
236+
parser.add_argument(
237+
"--timeout", type=float, default=10.0,
238+
help="Connection timeout in seconds (default: 10)",
239+
)
240+
args = parser.parse_args(argv)
241+
242+
try:
243+
device_type = DeviceType[args.device.upper()]
244+
except KeyError:
245+
names = ", ".join(dt.name for dt in DeviceType if dt != DeviceType.CUSTOM)
246+
parser.error(f"Unknown device: {args.device}. Choices: {names}")
247+
return 1
248+
249+
try:
250+
clock_check(device_type, args.interval, group=args.group, timeout=args.timeout)
251+
return 0
252+
except Exception as e:
253+
print(f"ERROR: {e}", file=sys.stderr)
254+
return 1
255+
256+
257+
if __name__ == "__main__":
258+
sys.exit(main())
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Load a CCF file to configure a Cerebus device.
2+
3+
Reads a CCF (XML) configuration file and sends its contents to the device.
4+
5+
Usage::
6+
7+
python -m pycbsdk.cli.load_ccf my_config.ccf
8+
python -m pycbsdk.cli.load_ccf my_config.ccf --device NPLAY
9+
python -m pycbsdk.cli.load_ccf my_config.ccf --timeout 15
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import sys
16+
import time
17+
18+
from pycbsdk import DeviceType, Session
19+
from pycbsdk.session import _coerce_enum
20+
21+
22+
def load_ccf(
23+
filename: str,
24+
device_type: DeviceType,
25+
timeout: float = 10.0,
26+
) -> None:
27+
"""Connect to a device and apply a CCF configuration file.
28+
29+
Args:
30+
filename: Path to the CCF file to load.
31+
device_type: Device to connect to.
32+
timeout: Connection timeout in seconds.
33+
"""
34+
with Session(device_type=device_type) as session:
35+
deadline = time.monotonic() + timeout
36+
while not session.running:
37+
if time.monotonic() > deadline:
38+
raise TimeoutError(
39+
f"Session for {device_type.name} did not start within {timeout}s"
40+
)
41+
time.sleep(0.1)
42+
# Let initial config settle
43+
time.sleep(0.5)
44+
45+
print(f"Loading CCF {filename!r} onto {device_type.name} ...")
46+
session.load_ccf(filename)
47+
print("CCF loaded successfully.")
48+
49+
50+
def main(argv: list[str] | None = None) -> int:
51+
parser = argparse.ArgumentParser(
52+
prog="python -m pycbsdk.cli.load_ccf",
53+
description="Load a CCF file to configure a Cerebus device.",
54+
)
55+
parser.add_argument(
56+
"filename",
57+
help="Path to the CCF file to load.",
58+
)
59+
parser.add_argument(
60+
"--device", default="NPLAY",
61+
help="Device type (default: NPLAY). "
62+
f"Choices: {', '.join(dt.name for dt in DeviceType)}",
63+
)
64+
parser.add_argument(
65+
"--timeout", type=float, default=10.0,
66+
help="Connection timeout in seconds (default: 10).",
67+
)
68+
args = parser.parse_args(argv)
69+
70+
try:
71+
device_type = _coerce_enum(DeviceType, args.device)
72+
except (ValueError, TypeError) as e:
73+
parser.error(str(e))
74+
return 1
75+
76+
try:
77+
load_ccf(args.filename, device_type, timeout=args.timeout)
78+
return 0
79+
except Exception as e:
80+
print(f"ERROR: {e}", file=sys.stderr)
81+
return 1
82+
83+
84+
if __name__ == "__main__":
85+
sys.exit(main())

pycbsdk/src/pycbsdk/session.py

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1181,30 +1181,30 @@ def set_channel_spike_sorting(
11811181
def _calibrate_monotonic_offset() -> int:
11821182
"""Compute offset between time.monotonic() and C++ steady_clock.
11831183
1184-
On Linux, macOS, and Windows with Python 3.12+, both clocks use the
1185-
same underlying source (CLOCK_MONOTONIC / mach_absolute_time /
1186-
QueryPerformanceCounter) so the offset is exactly 0.
1184+
On modern macOS, libc++ steady_clock uses mach_continuous_time()
1185+
(includes sleep) while Python time.monotonic() uses
1186+
mach_absolute_time() (excludes sleep), so the clocks can diverge
1187+
by the total accumulated sleep time.
11871188
11881189
On older Windows Python (<3.12), time.monotonic() may use
1189-
GetTickCount64 while steady_clock uses QueryPerformanceCounter,
1190-
so we measure the offset empirically.
1190+
GetTickCount64 while libc++ steady_clock uses QueryPerformanceCounter.
1191+
1192+
Always measure empirically.
11911193
11921194
Returns:
11931195
steady_clock_ns - monotonic_ns (int).
11941196
"""
1195-
import sys
1196-
import platform
1197-
1198-
if platform.system() != "Windows" or sys.version_info >= (3, 12):
1199-
return 0
1200-
1201-
# Windows < 3.12: clocks may differ, measure empirically
12021197
_lib = _get_lib()
12031198
t1 = _time.monotonic()
12041199
steady_ns = _lib.cbsdk_get_steady_clock_ns()
12051200
t2 = _time.monotonic()
12061201
mono_ns = int((t1 + t2) / 2 * 1_000_000_000)
1207-
return steady_ns - mono_ns
1202+
offset = steady_ns - mono_ns
1203+
# If the offset is small enough to be measurement noise, treat
1204+
# the clocks as identical (common case on Linux and Windows 3.12+).
1205+
if abs(offset) < 1_000_000: # < 1 ms
1206+
return 0
1207+
return offset
12081208

12091209
@property
12101210
def clock_offset_ns(self) -> Optional[int]:

0 commit comments

Comments
 (0)