Correcting Clock Skew in Multi-Agent Trajectory Streams

When trajectories from many synthetic agents are joined on time and the results show impossible near-misses or phantom gaps, the cause is per-agent clock skew — each agent stamps its samples with a slightly offset, slightly fast-or-slow clock, so identical real instants carry different timestamps.

Part of Temporal Synchronization for Moving Objects: that stage puts independently generated moving objects onto a shared timeline, and this page isolates the correction that must run before any time-based join — estimating each agent’s constant offset and linear drift against a common reference, then resampling every stream onto one clock so spatiotemporal proximity means what it claims to mean.

Root Cause: A Per-Agent Affine Clock Error

Each agent in a distributed generation run — a separate process, container, or simulated device — timestamps its samples from its own clock. Two error terms accumulate. A constant offset bib_i appears when agent ii’s clock starts at the wrong absolute time (an unsynchronized boot, a time-zone or epoch mismatch, a fixed pipeline latency). A drift or skew aia_i appears when the clock runs fast or slow, so the error grows linearly with elapsed time. The relationship between an agent’s local timestamp tilocalt^{\text{local}}_i and true reference time treft^{\text{ref}} is affine:

tilocal=(1+ai)tref+bi+εi,t^{\text{local}}_i = (1 + a_i)\, t^{\text{ref}} + b_i + \varepsilon_i,

Residual skew-estimation error against observation window, against the true skew The horizontal axis is the length of the observation window in seconds on a logarithmic scale, from ten seconds to an hour; the vertical axis is the error in the estimated skew, in parts per million. A shaded band spans the median to the ninetieth percentile over four hundred repeated fits, so the width of the band is how unlucky a single fit can be. A dashed horizontal line marks the true skew itself, which is the level at which the correction stops being worth applying: an estimate whose error exceeds the quantity being estimated makes the clock worse more often than better. At ten seconds the estimate is far above that line and applying it is actively harmful. The band crosses the line at a window of a few minutes, and by an hour the estimate is an order of magnitude tighter than the skew. Markers give the window at which the median and the ninetieth percentile each cross. The note underneath states the rule that follows: do not apply a skew correction fitted on a window shorter than the crossing point, and record the window length beside the correction, because a reviewer cannot tell a good estimate from a bad one without it. An estimate noisier than the thing it estimates makes the clock worse 10 s 32 s 2 min 5 min 17 min 53 min 0.5 1 5 20 100 501.2 observation window (s, log scale) skew estimation error (ppm, log scale) true skew, 42 ppm both fall below the skew by 5 min median residual error 90th percentile 400 fits per window against a 42 ppm skew with 12 ms of jitter per observation, fixed seed. Do not apply a correction fitted on a shorter window than the crossing, and record the window length beside the correction — a reviewer cannot tell a good estimate from a bad one without it.
An estimate noisier than the quantity it estimates makes the clock worse — so record the observation window beside the correction.

where aia_i is the fractional rate error (parts per million for a real oscillator, but arbitrarily large in a mis-seeded synthetic run), bib_i is the offset in seconds, and εi\varepsilon_i is small sampling jitter. Inverting it recovers reference time: t^ref=(tilocalbi)/(1+ai)\hat{t}^{\text{ref}} = (t^{\text{local}}_i - b_i)/(1 + a_i).

The damage is entirely in the join. A spatiotemporal join pairs samples whose timestamps fall within a tolerance window, then reasons about the spatial distance between the paired points. If agent A’s clock leads agent B’s by three seconds, a join at nominal equal time pairs A’s position with where B will be three seconds later — so two agents that were fifty metres apart appear to occupy nearly the same point, manufacturing a collision, or two agents that actually passed close appear never to have met. This is distinct from the design-time alignment covered in aligning multi-agent trajectory timestamps in distributed systems: there the concern is agreeing on a timeline convention up front, whereas here the streams already exist with baked-in per-agent affine error that must be measured from the data and removed. Because drift grows with time, a run that looks aligned at the start silently diverges over a long window, which is why a single offset correction is never enough.

Estimating and removing per-agent clock offset and drift before a spatiotemporal join Top half plots three agent timelines against a common reference axis. Agent A's samples are shifted right by a constant offset. Agent B's samples start aligned but spread apart increasingly to the right, showing linear drift. Agent C is nearly aligned. A shared event, a moment all three observe together, is marked by a vertical reference line; the horizontal distance from each agent's stamp of that event to the reference line gives the offset and, combined with a second shared event, the drift. Bottom half shows the same three streams after applying the affine correction and resampling onto a uniform common-clock grid of evenly spaced tick marks, with all agents' samples now landing on the shared grid so a time join pairs truly simultaneous positions. Before: per-agent offset + drift ref shared event A offset b (constant) B drift a (grows) C nearly aligned apply affine correction + resample After: common-clock grid joins pair simultaneous positions
Each agent carries a constant offset and a linear drift. Estimating both from shared events and resampling onto one clock grid makes a time-based join pair positions that were truly simultaneous.

Minimal Reproducer: Detect Skew from a Shared Event

Confirm skew exists by finding an event every agent observes — a shared checkpoint crossing, a common trigger, a rendezvous — and comparing the timestamps each assigned to it. Pin the toolchain so interpolation is deterministic.

# requirements.txt — pin majors, let patch releases float
numpy==1.26.*
scipy==1.11.*
pandas==2.*
python
import numpy as np

def skew_from_shared_events(local_stamps: dict[str, np.ndarray],
                            ref_times: np.ndarray) -> dict[str, float]:
    """local_stamps[agent] = the agent's own timestamps for the SAME ordered events.
    ref_times = the true reference time of each shared event.
    Returns the max residual offset per agent under a zero-correction assumption.
    """
    out = {}
    for agent, ts in local_stamps.items():
        residual = ts - ref_times          # if this is non-constant, there is drift too
        out[agent] = float(np.abs(residual - residual.mean()).max())
    return out

# Two shared checkpoints observed by three agents; reference at t=0 and t=600 s.
ref = np.array([0.0, 600.0])
local = {"A": np.array([2.9, 603.1]),      # ~3 s constant offset, little drift
         "B": np.array([0.1, 612.4]),       # grows to +12 s -> strong drift
         "C": np.array([0.2, 600.3])}       # nearly clean
print(skew_from_shared_events(local, ref))
# {'A': ~0.1, 'B': ~6.1, 'C': ~0.05}  -> B's non-constant residual flags drift, not just offset

A residual that is constant across events is pure offset; a residual that grows with reference time is drift. Any agent whose residual spread exceeds the join tolerance will corrupt spatiotemporal pairing and must be corrected.

Fix: Fit the Affine Model, Then Resample onto a Common Clock

Correction is two moves: fit each agent’s offset and drift by least squares against reference times of shared events, then resample every stream onto one uniform reference grid so all agents share identical timestamps before any join.

Three shared-event anchors for skew estimation, by frequency and timestamp precision The horizontal axis is how often the anchor event occurs, from once an hour to many times a minute, on a logarithmic scale; the vertical axis is how precisely an agent can say when the event happened, in milliseconds, also logarithmic and inverted so that better precision is higher. Three anchors are plotted. A broadcast reference tick — a network time signal, or a shared message on a bus — is frequent and precise, and sits in the top right: it is the anchor to use when the system has one. A spatial co-location, where two agents are observed at the same place, is rare and imprecise, because the position fix itself has error and the agents were only approximately co-located; it sits in the bottom left. A common upstream trigger — both agents reacting to the same dispatch, the same signal change — is moderately frequent and moderately precise, because the reaction delay varies. A shaded region marks where a skew fit converges within a useful observation window; only the first anchor is comfortably inside it and the third is marginal. The note underneath states the consequence plainly: if a system has no anchor in the shaded region, its per-agent clocks cannot be aligned after the fact at all, and the correct response is to add a broadcast tick to the generator rather than to fit a skew model to an anchor that cannot support one. No shared event, no skew estimate — and two of the three barely qualify 0.01 0.03 0.10 0.32 1 3 10 5 20 100 501 1,995 how often the anchor occurs (events per minute, log scale) timestamp precision (ms, log scale) a skew fit converges here broadcast reference tick a time signal or a shared bus message — use it when it exists common upstream trigger both agents react to the same dispatch; the reaction delay varies spatial co-location the fix has error and the agents were only approximately together If a system has no anchor in the shaded region, its per-agent clocks cannot be aligned after the fact at all. The correct response is to add a broadcast tick to the generator — which costs one field per emitter — rather than to fit a skew model to an anchor that cannot support one and then trust the number it returns.
Only a broadcast tick is comfortably inside the region where a skew fit converges — the other two anchors barely qualify.

Step 1 — Fit offset and drift per agent

With at least two shared events, fit the affine map tlocal=(1+a)tref+bt^{\text{local}} = (1+a)\,t^{\text{ref}} + b by least squares and invert it to map local time back to reference time. More shared events make the drift estimate robust to jitter.

python
import numpy as np

def fit_clock(local_event_ts: np.ndarray, ref_event_ts: np.ndarray) -> tuple[float, float]:
    """Least-squares fit of local = (1+a)*ref + b. Returns (a, b)."""
    if len(ref_event_ts) < 2:
        raise ValueError("need >= 2 shared events to separate drift from offset")
    slope, intercept = np.polyfit(ref_event_ts, local_event_ts, deg=1)
    return float(slope - 1.0), float(intercept)     # a = slope-1, b = intercept

def local_to_ref(local_ts: np.ndarray, a: float, b: float) -> np.ndarray:
    """Invert the affine clock: recover reference time from local timestamps."""
    return (local_ts - b) / (1.0 + a)

Step 2 — Resample every stream onto the common reference grid

Convert each agent’s timestamps to reference time, then interpolate positions onto one shared, uniformly spaced grid. Interpolate in a metric CRS so the intermediate positions are geometrically sound, and only within each agent’s observed interval — never extrapolate a position beyond where the agent actually reported.

python
import numpy as np

def resample_to_common_clock(streams: dict, clocks: dict, grid: np.ndarray) -> dict:
    """streams[agent] = (local_ts (N,), xy (N,2) metric). clocks[agent] = (a, b).
    grid = uniform reference timestamps shared by all agents.
    Returns positions for every agent on `grid`, NaN outside the agent's coverage.
    """
    out = {}
    for agent, (local_ts, xy) in streams.items():
        a, b = clocks[agent]
        ref_ts = local_to_ref(local_ts, a, b)
        order = np.argsort(ref_ts)                  # ensure monotonic for interp
        ref_ts, xy = ref_ts[order], xy[order]
        lo, hi = ref_ts[0], ref_ts[-1]
        x = np.interp(grid, ref_ts, xy[:, 0])
        y = np.interp(grid, ref_ts, xy[:, 1])
        mask = (grid < lo) | (grid > hi)            # do not extrapolate
        x[mask] = np.nan; y[mask] = np.nan
        out[agent] = np.column_stack([x, y])
    return out

Only after this resampling is a spatiotemporal join valid: every agent’s row at grid index kk refers to the same reference instant, so a proximity query returns genuine simultaneity. This is the temporal counterpart to the spatial degradation in adding realistic GPS noise to synthetic vehicle trajectories — correct the clock first, then any position noise you add is attributed to the right instant. Downstream consumers such as agent-based mobility simulation depend on this alignment to reason about interactions between agents.

Verification Step: Gate Alignment at Shared Events

Assert that after correction the residual at held-out shared events is within tolerance and that a known-simultaneous pair actually resolves as simultaneous.

python
import numpy as np

def test_clock_correction_aligns_streams(clocks, holdout_local, holdout_ref, *, tol_s=0.25):
    # 1. Held-out shared events must map back to reference within tolerance.
    for agent, local_ts in holdout_local.items():
        a, b = clocks[agent]
        recovered = local_to_ref(local_ts, a, b)
        err = np.abs(recovered - holdout_ref).max()
        assert err <= tol_s, f"{agent}: residual {err:.3f}s exceeds {tol_s}s after correction"

def test_no_phantom_simultaneity(resampled, grid, truth_gap_m, *, k, tol_m=5.0):
    # 2. At grid index k, two agents known to be truth_gap_m apart must not collapse.
    a_xy, b_xy = resampled["A"][k], resampled["B"][k]
    if np.isnan(a_xy).any() or np.isnan(b_xy).any():
        return  # outside coverage; nothing to assert
    d = np.linalg.norm(a_xy - b_xy)
    assert abs(d - truth_gap_m) <= tol_m, f"skew reintroduced: {d:.1f}m vs truth {truth_gap_m}m"

The held-out residual gate is the load-bearing one: fitting on all shared events and testing on the same events would pass a model that merely memorized them, so reserve at least one event for verification. The phantom-simultaneity check guards the actual downstream consequence — a join that manufactures or erases a near-miss.

Edge Cases & Gotchas

Only one shared event cannot separate offset from drift. With a single common observation you can estimate a constant offset but the drift term is unidentifiable — infinitely many (a,b)(a, b) pairs fit one point. The fit must raise rather than silently return drift zero, because assuming zero drift on a fast-running clock leaves a linearly growing error that a start-of-run check will not catch. Require at least two well-separated shared events, and prefer several spanning the full window.

Never extrapolate positions past an agent’s coverage. Resampling onto a common grid tempts you to fill an agent’s rows before its first or after its last real sample, but interpolation there is invention: the agent reported no position, so the honest value is NaN. Extrapolating a moving object’s location produces confident-looking coordinates that never existed and will trigger false joins. Mask everything outside each agent’s observed reference interval.

Leap seconds and epoch boundaries in the reference clock. If the reference timeline is wall-clock UTC, a leap second inserted mid-run adds a one-second step that the affine model will misattribute to offset or drift, warping the whole fit. Use a monotonic reference such as TAI or elapsed seconds from a fixed epoch for the correction, and only convert to civil time for display. The same caution applies at day boundaries where a naive string-parsed timestamp can wrap.

Frequently Asked Questions

Why isn't a single constant offset correction enough?
A constant offset only fixes clocks that start at the wrong time but tick at the correct rate. Real and mis-seeded synthetic clocks also drift, running fast or slow, so their error grows linearly with elapsed time. A run corrected for offset alone looks aligned at the start and diverges over a long window, silently corrupting joins late in the stream. You must fit and remove both the offset and the drift.
What counts as a shared event for estimating skew?
Any instant that multiple agents observe or participate in and that has a known reference time: a common checkpoint crossing, a broadcast trigger, a rendezvous where two agents are known to be co-located, or a synchronization pulse. Each agent's own timestamp for that instant, compared against the reference, gives one equation. Two or more well-separated shared events let you separate the constant offset from the linear drift by least squares.
Should I correct clocks before or after adding position noise?
Correct clocks first. Clock correction is about attributing each position to the right instant, and any position noise you add afterward is then anchored to the correct reference time. If you add noise first and correct time later, the interpolation during resampling mixes noisy samples across slightly wrong instants, smearing the error. Align the timeline, then degrade positions, then join.
How do I choose the common-clock grid spacing?
Match the grid to the finest native sampling interval you need to preserve, or slightly coarser, so interpolation interpolates rather than invents. A grid far finer than the source data fabricates intermediate positions with no support, while a grid far coarser throws away resolution the join may need. Keep the spacing constant across all agents so every row index refers to the same reference instant.