Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ if(BUILD_EXAMPLES)
add_subdirectory(examples/mcap_record_replay)
add_subdirectory(examples/deviceio_live_view)
add_subdirectory(examples/haptic_feedback)
add_subdirectory(examples/synchronization)
if(BUILD_VIZ)
add_subdirectory(examples/mujoco_xr)
endif()
Expand Down
9 changes: 9 additions & 0 deletions examples/synchronization/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20)

add_subdirectory(cpp)

include(${CMAKE_SOURCE_DIR}/cmake/InstallPythonExample.cmake)
install_python_example(DESTINATION examples/synchronization/python)
69 changes: 69 additions & 0 deletions examples/synchronization/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<!--
SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
SPDX-License-Identifier: Apache-2.0
-->

# Device clock synchronization

Demonstrates `DeviceClockEstimator` without requiring hardware or a particular transport. A
simulated device clock runs with a known offset and skew while synthetic NTP-style probes calibrate
it against the host clock.

The goal is to estimate the relationship between the device clock and the host's common clock, then
convert capture times into that common clock domain. Multiple device streams can then be aligned by
their converted timestamps.

## Requirements

- The device must support timestamp echo: for each probe, it returns its device receive (`t2`) and
send (`t3`) timestamps plus an identifier that matches the reply to the request.
- Probe and sample timestamps must use the same free-running monotonic device clock.
- The host records send (`t1`) and receive (`t4`) times with one monotonic host clock, converts all
four timestamps to nanoseconds, and periodically feeds completed exchanges to the estimator.

## How Synchronization Works

Within a short sliding window, the monotonic device and host clocks are assumed to have an
approximately linear relationship. Probe and sample timestamps must use the same device clock.
Each probe records host send (`t1`), device receive (`t2`), device send (`t3`), and host receive
(`t4`). Assuming approximately symmetric link delay, `make_observation()` estimates the clock
offset and round-trip time (RTT).

In linear mode, the estimator rejects unusually slow exchanges and retains observations within a
sliding device-time window. Once the retained span reaches `min_span_s`, it fits the offset model

```text
offset(d) = a + b * (d - d0)
host_time = d - offset(d)
```

where `a` is the offset and `b` is the clock skew. Before enough time has elapsed to estimate skew,
`b` remains zero. Probe asymmetry contributes an offset error of at most roughly half the RTT.
`to_local_common_ns()` applies the map and returns `Synchronized`, `Uncalibrated`, or `Stale`.
A conversion becomes stale when it extrapolates beyond
`max_extrapolation_windows * window_s` from the newest observation supporting the fit. A backward
device timestamp is treated as a device restart and clears the old calibration.

The estimator performs no I/O and creates no thread. If probe I/O can block, run it on a background
thread and call `update()` there. The sample thread can call `to_local_common_ns()` directly; it
reads an immutable atomic snapshot and never waits for a fit. A separate thread is unnecessary when
the caller already performs probes without blocking its sampling loop.

Python:

```bash
PYTHONPATH=build/python_package/Release \
build/teleop_build_venv/bin/python \
examples/synchronization/python/synchronization_example.py
```

C++:

```bash
cmake --build build --target synchronization_example
./build/examples/synchronization/cpp/synchronization_example
```

Applications provide the four timestamps from each probe exchange and use the checked conversion
for every device sample. When conversion is uncalibrated or stale, use the sample's host arrival
time instead.
11 changes: 11 additions & 0 deletions examples/synchronization/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20)

add_executable(synchronization_example synchronization_example.cpp)
target_link_libraries(synchronization_example PRIVATE synchronization::synchronization)

install(TARGETS synchronization_example
RUNTIME DESTINATION examples/synchronization/cpp
)
105 changes: 105 additions & 0 deletions examples/synchronization/cpp/synchronization_example.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

#include <synchronization/device_clock_estimator.hpp>

#include <cmath>
#include <cstdint>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <string_view>

namespace
{

constexpr int64_t kNsPerSecond = 1'000'000'000;

struct SimulatedClock
{
int64_t epoch_host_ns;
int64_t offset_ns;
double skew_ppm;

int64_t device_ns(int64_t host_ns) const
{
const double rate = 1.0 + skew_ppm * 1e-6;
return offset_ns + static_cast<int64_t>(std::llround(static_cast<double>(host_ns - epoch_host_ns) * rate));
}
};

core::ClockObservation probe(const SimulatedClock& clock, int64_t host_send_ns)
{
const int64_t device_receive_host_ns = host_send_ns + 80'000;
const int64_t device_send_host_ns = device_receive_host_ns + 20'000;
const int64_t host_receive_ns = device_send_host_ns + 120'000;
return core::make_observation(
host_send_ns, clock.device_ns(device_receive_host_ns), clock.device_ns(device_send_host_ns), host_receive_ns);
}

void add_probes(core::DeviceClockEstimator& estimator, const SimulatedClock& clock, int64_t start_ns)
{
for (int index = 0; index < 12; ++index)
{
estimator.update(probe(clock, start_ns + index * kNsPerSecond));
}
}

std::string_view status_name(core::ClockStatus status)
{
switch (status)
{
case core::ClockStatus::Synchronized:
return "synchronized";
case core::ClockStatus::Uncalibrated:
return "uncalibrated";
case core::ClockStatus::Stale:
return "stale";
}
return "unknown";
}

double conversion_error_us(const core::DeviceClockEstimator& estimator, const SimulatedClock& clock, int64_t host_ns)
{
const auto converted = estimator.to_local_common_ns(clock.device_ns(host_ns));
if (converted.status != core::ClockStatus::Synchronized)
{
throw std::runtime_error("expected a synchronized clock");
}
return static_cast<double>(converted.local_common_ns - host_ns) / 1'000.0;
}

} // namespace

int main()
{
const int64_t start_ns = 1'000 * kNsPerSecond;
const SimulatedClock clock{ start_ns, 5 * kNsPerSecond, 20.0 };
core::DeviceClockEstimator estimator(core::DeviceClockEstimator::Mode::Linear, 30.0, 5.0, 2.0);

add_probes(estimator, clock, start_ns);
auto stats = estimator.stats();
std::cout << std::fixed << std::setprecision(2) << "calibrated clock\n"
<< " estimated skew: " << std::showpos << stats.skew_ppm << std::noshowpos << " ppm\n"
<< " median RTT: " << stats.rtt_ns / 1'000.0 << " us\n"
<< " conversion error: " << std::showpos
<< conversion_error_us(estimator, clock, start_ns + 11 * kNsPerSecond) << std::noshowpos << " us\n";

const int64_t stale_host_ns = start_ns + 72 * kNsPerSecond;
const auto stale = estimator.to_local_common_ns(clock.device_ns(stale_host_ns));
std::cout << "after probe silence: " << status_name(stale.status) << '\n';

const SimulatedClock restarted{ stale_host_ns, 100'000'000, -15.0 };
estimator.update(probe(restarted, stale_host_ns));
const auto restarting = estimator.to_local_common_ns(restarted.device_ns(stale_host_ns));
std::cout << "after restart: " << status_name(restarting.status) << '\n';

add_probes(estimator, restarted, stale_host_ns + kNsPerSecond);
stats = estimator.stats();
std::cout << "recalibrated clock\n"
<< " resets: " << stats.resets << '\n'
<< " estimated skew: " << std::showpos << stats.skew_ppm << std::noshowpos << " ppm\n"
<< " conversion error: " << std::showpos
<< conversion_error_us(estimator, restarted, stale_host_ns + 12 * kNsPerSecond) << std::noshowpos
<< " us\n";
}
9 changes: 9 additions & 0 deletions examples/synchronization/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

[project]
name = "synchronization-example"
version = "0.0.0"
description = "Isaac Teleop device-clock synchronization example"
requires-python = ">=3.11,<3.14"
dependencies = ["isaacteleop"]
94 changes: 94 additions & 0 deletions examples/synchronization/python/synchronization_example.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

from dataclasses import dataclass

from isaacteleop.synchronization import (
ClockStatus,
DeviceClockEstimator,
make_observation,
)

NS_PER_SECOND = 1_000_000_000


@dataclass(frozen=True)
class SimulatedClock:
epoch_host_ns: int
offset_ns: int
skew_ppm: float

def device_ns(self, host_ns: int) -> int:
rate = 1.0 + self.skew_ppm * 1e-6
return self.offset_ns + round((host_ns - self.epoch_host_ns) * rate)


def probe(clock: SimulatedClock, host_send_ns: int):
device_receive_host_ns = host_send_ns + 80_000
device_send_host_ns = device_receive_host_ns + 20_000
host_receive_ns = device_send_host_ns + 120_000
return make_observation(
host_send_ns,
clock.device_ns(device_receive_host_ns),
clock.device_ns(device_send_host_ns),
host_receive_ns,
)


def add_probes(
estimator: DeviceClockEstimator, clock: SimulatedClock, start_ns: int
) -> None:
for index in range(12):
estimator.update(probe(clock, start_ns + index * NS_PER_SECOND))


def conversion_error_us(
estimator: DeviceClockEstimator, clock: SimulatedClock, host_ns: int
) -> float:
converted = estimator.to_local_common_ns(clock.device_ns(host_ns))
if converted.status != ClockStatus.Synchronized:
raise RuntimeError(f"expected synchronized clock, got {converted.status}")
return (converted.local_common_ns - host_ns) / 1_000.0


def main() -> None:
start_ns = 1_000 * NS_PER_SECOND
clock = SimulatedClock(start_ns, offset_ns=5 * NS_PER_SECOND, skew_ppm=20.0)
estimator = DeviceClockEstimator(
window_s=30.0,
min_span_s=5.0,
max_extrapolation_windows=2.0,
)

add_probes(estimator, clock, start_ns)
error_us = conversion_error_us(estimator, clock, start_ns + 11 * NS_PER_SECOND)
stats = estimator.stats()
print("calibrated clock")
print(f" estimated skew: {stats.skew_ppm:+.2f} ppm")
print(f" median RTT: {stats.rtt_ns / 1_000:.1f} us")
print(f" conversion error: {error_us:+.1f} us")

stale_host_ns = start_ns + 72 * NS_PER_SECOND
stale = estimator.to_local_common_ns(clock.device_ns(stale_host_ns))
print(f"after probe silence: {stale.status}")

restarted = SimulatedClock(stale_host_ns, offset_ns=100_000_000, skew_ppm=-15.0)
estimator.update(probe(restarted, stale_host_ns))
restarting = estimator.to_local_common_ns(restarted.device_ns(stale_host_ns))
print(f"after restart: {restarting.status}")

add_probes(estimator, restarted, stale_host_ns + NS_PER_SECOND)
error_us = conversion_error_us(
estimator,
restarted,
stale_host_ns + 12 * NS_PER_SECOND,
)
stats = estimator.stats()
print("recalibrated clock")
print(f" resets: {stats.resets}")
print(f" estimated skew: {stats.skew_ppm:+.2f} ppm")
print(f" conversion error: {error_us:+.1f} us")


if __name__ == "__main__":
main()
3 changes: 3 additions & 0 deletions src/core/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ add_subdirectory(schema)
# Grades one binary schema against another; used by the conform test and by replay.
add_subdirectory(schema_compat)

# Device-clock synchronization (no dependencies; used by plugins whose device keeps its own clock)
add_subdirectory(synchronization)

# Build OXR Utils first - header-only utilities (no dependencies)
add_subdirectory(oxr_utils)

Expand Down
2 changes: 1 addition & 1 deletion src/core/python/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ add_custom_target(python_package ALL
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/requirements-retargeters-lite.txt" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/"
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/requirements-grounding.txt" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/"
COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/requirements-wuji.txt" "${CMAKE_BINARY_DIR}/python_package/$<CONFIG>/"
DEPENDS deviceio_trackers_py deviceio_session_py oxr_py plugin_manager_py schema_py isaacteleop_python cloudxr_python stage_generated_tracker_exports
DEPENDS deviceio_trackers_py deviceio_session_py oxr_py plugin_manager_py schema_py synchronization_py isaacteleop_python cloudxr_python stage_generated_tracker_exports
COMMENT "Preparing Python package structure"
)

Expand Down
11 changes: 11 additions & 0 deletions src/core/synchronization/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20)

# Build the device-clock synchronization C++ library
add_subdirectory(cpp)

if(BUILD_PYTHON_BINDINGS)
add_subdirectory(python)
endif()
22 changes: 22 additions & 0 deletions src/core/synchronization/cpp/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

cmake_minimum_required(VERSION 3.20)

find_package(Threads REQUIRED)

# Synchronization - maps a device's own free-running clock onto the local common clock.
# Pure arithmetic over round-trip observations: no transport, no threads, no device knowledge,
# and therefore no dependencies beyond the standard library.
add_library(synchronization
device_clock_estimator.cpp
)

target_link_libraries(synchronization PRIVATE Threads::Threads)

target_include_directories(synchronization
INTERFACE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/inc>
)

add_library(synchronization::synchronization ALIAS synchronization)
Loading
Loading