The generated speed histogram overlays the observed one almost perfectly. Then somebody plots a
single trace and it oscillates between 12 and 48 km/h twice a second, which no vehicle does and
no physical model produced — the speeds were sampled, not driven.
Part of Physics-Based Path Generation: the physics gives plausible motion but the wrong speed distribution, and the obvious correction throws the physics away. Getting both is the actual problem.
Speed observed along a trajectory is a time series with strong autocorrelation: a vehicle at 50
km/h is at roughly 50 km/h a second later, because acceleration is bounded. The marginal
distribution of speed discards that entirely.
So a generator that draws each speed independently from the right marginal reproduces the
histogram exactly and produces motion that is physically impossible. The mismatch shows up in
three statistics, none of which anybody computes unless they have been caught by this before:
The acceleration distribution. Independent draws produce accelerations an order of
magnitude beyond what any vehicle achieves, and the tail is where it is most obvious.
The lag-1 autocorrelation of speed. Observed traces sit above 0.9 at one-second sampling.
Independent draws sit at zero, by construction.
The distribution of sustained-speed run lengths. Real driving holds a speed band for tens
of seconds; sampled speeds leave it every step.
Same speed histogram, two generators: the marginal match is exact in both and everything about the dynamics differs.
defspeed_targets(traces, dt:float)->dict:"""The two things a generated profile has to match, measured together."""
speeds, accels, lag1 =[],[],[]for tr in traces:
v =[step.speed for step in tr]
speeds.extend(v)
accels.extend((v[i +1]- v[i])/ dt for i inrange(len(v)-1))
lag1.append(autocorrelation(v, lag=1))return{"speed_quantiles": quantiles(speeds,[0.05,0.25,0.5,0.75,0.95]),"accel_p95": quantiles([abs(a)for a in accels],[0.95])[0],"accel_p999": quantiles([abs(a)for a in accels],[0.999])[0],"lag1_median": median(lag1),"dt": dt,}
Recording dt in the target is not bookkeeping. Autocorrelation and acceleration percentiles are
both functions of the sampling interval, so a target measured at 1 Hz and applied to a 5 Hz
generator is simply a different target — the same interval dependence that makes cross-release comparison of trajectory statistics awkward everywhere else.
defdrive(route, limits, rng, dt:float)->list[float]:"""A first-order speed process bounded by the acceleration budget."""
v =0.0
out =[]for seg in route:
target = seg.speed_limit * rng.normal(0.92,0.08)# drivers vary around the limitfor _ inrange(int(seg.duration / dt)):
desired =(target - v)/max(dt,1e-6)
a = clamp(desired,-limits.decel_max, limits.accel_max)
v =max(0.0, v + a * dt)
out.append(v)return out
This produces the right kind of trace — smooth, bounded, autocorrelated — and almost certainly
the wrong distribution, because the segment targets were guessed.
defquantile_map(generated:list[float], observed_quantiles, gen_quantiles)->list[float]:"""Monotone map from the generated distribution onto the observed one."""return[
interp(v, gen_quantiles, observed_quantiles)for v in generated
]
Monotonicity is the whole reason this works. A monotone transformation of a trace preserves its
ordering, so the autocorrelation survives, the run-length structure survives, and the marginal
becomes exactly the target. What it does not preserve is the acceleration budget: stretching
the upper tail multiplies the accelerations there.
defreconcile(generated, targets, limits, dt, rounds:int=6):"""Alternate quantile mapping with acceleration clipping until both hold."""
v =list(generated)for _ inrange(rounds):
v = quantile_map(v, targets["speed_quantiles"], quantiles(v,[.05,.25,.5,.75,.95]))
v = clip_accelerations(v, limits, dt)# slightly perturbs the marginal againif accel_p95(v, dt)<= targets["accel_p95"]*1.02:breakreturn v
The alternation converges quickly in practice because the two operations pull on nearly disjoint
parts of the trace: quantile mapping moves the values, clipping moves the differences, and after
three or four rounds neither is changing the other much. If it does not converge, the target
combination is infeasible — an observed distribution with a heavy high-speed tail cannot be
reached under an acceleration budget that never gets there, and the right response is to question
the budget rather than to raise the round count.
The alternation in progress: marginal error and acceleration excess across rounds, converging where they cross.
deftest_marginal_matches(generated, targets, tol=0.03):
got = quantiles(generated,[0.05,0.25,0.5,0.75,0.95])for g, want inzip(got, targets["speed_quantiles"]):assertabs(g - want)/max(want,1e-6)< tol,f"{g:.2f} vs {want:.2f}"deftest_accelerations_are_physical(generated, targets, dt, tol=1.05):
a =[abs((generated[i +1]- generated[i])/ dt)for i inrange(len(generated)-1)]assert quantiles(a,[0.95])[0]< targets["accel_p95"]* tol
assert quantiles(a,[0.999])[0]< targets["accel_p999"]* tol
deftest_autocorrelation_survives(generated, targets, tol=0.08):
got = autocorrelation(generated, lag=1)assertabs(got - targets["lag1_median"])< tol,f"lag-1 {got:.3f}"deftest_run_lengths_are_realistic(generated, observed_runs, tol=0.2):"""Time spent inside a 5 km/h band before leaving it."""
got = median(band_run_lengths(generated, band=5.0))assertabs(got - observed_runs)/ observed_runs < tol
The last two are the ones that fail on a marginal-only generator, and they are the reason the
suite is worth writing: a histogram overlay is a picture that any sampling approach passes, and
these are numbers that only a dynamically plausible trace passes.
Which statistic catches which defect: four generators against four checks, and the two checks that separate them.
Stops are a separate distribution. Observed speed distributions have a spike at zero that is
not part of the moving distribution at all. Fitting one distribution across both makes the map
wrong everywhere; separate the stopped and moving regimes, match the moving one, and let the
stop model supply the zeros.
Speed distributions are conditional. Free-flow motorway speeds and congested urban speeds are
different distributions, and a single global target reproduces neither. Conditioning the target
on road class costs a lookup and removes most of the residual error.
The observed distribution carries its own measurement error. Speeds derived by differencing
noisy positions are inflated at the low end, so a generator matched to them inherits the noise as
if it were signal. If the reference comes from position differencing rather than from a speed
sensor, the target should be de-noised first or the generator will be asked to reproduce GPS
error twice — once here and once in the noise injection stage.
Quantile mapping needs enough generated data. Estimating the generated quantiles from a
single short trace is noisy, so the map wobbles from trace to trace. Fit the map once on a large
pooled sample and apply it to every trace.
The reconciliation is only as good as the distribution it targets, and the choice of reference is
made carelessly more often than the fitting method is.
Prefer a speed sensor to differenced positions. A distribution derived by differencing GPS
positions is the true speed distribution convolved with the position noise, widened at both ends
and with an artificial floor above zero. Matching to it makes the generator reproduce measurement
error as though it were behaviour, and the noise injection stage then adds the same error a second time.
Condition on the thing that actually varies. Speed distributions differ far more between road
classes, times of day and vehicle types than they do between weeks. A global distribution fitted
across all of them is a mixture that describes no individual case, and a generator matched to it
produces motorway speeds in residential streets at exactly the rate the mixture implies.
Check the reference’s own sampling interval. A distribution built from 1 Hz samples and one
built from 10-second samples differ systematically: the coarser sample misses short excursions
and under-represents both tails. If the generator emits at a different rate than the reference
was measured at, the target has to be re-derived at the emission rate rather than reused.
Beware selection in the reference fleet. Speed distributions from commercial telematics come
from vehicles that are professionally driven, instrumented, and often speed-limited. Using that
as the target for a general population produces a synthetic fleet that is noticeably more
disciplined than reality — a bias that survives every check in this article, because every check
compares the output to the reference rather than the reference to the world.