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.
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 offsetbi appears when agent i’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 ai appears when the clock runs fast or slow, so the error grows linearly with elapsed time. The relationship between an agent’s local timestamp tilocal and true reference time tref is affine:
tilocal=(1+ai)tref+bi+εi,
where ai is the fractional rate error (parts per million for a real oscillator, but arbitrarily large in a mis-seeded synthetic run), bi is the offset in seconds, and εi is small sampling jitter. Inverting it recovers reference time: t^ref=(tilocal−bi)/(1+ai).
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.
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.
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.
import numpy as np
defskew_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 cleanprint(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.
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.
With at least two shared events, fit the affine map tlocal=(1+a)tref+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
deffit_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)."""iflen(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)returnfloat(slope -1.0),float(intercept)# a = slope-1, b = interceptdeflocal_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)
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
defresample_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 k 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.
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
deftest_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"deftest_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)assertabs(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.
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) 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.
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.