Synthetic urban trajectories perturbed with independent white Gaussian jitter look nothing like real downtown GPS, because the dominant error source between tall buildings is multipath — a slowly wandering, position-dependent bias that white noise cannot reproduce.
Part of Noise Injection & Stochastic Drift: that stage degrades clean generated paths into sensor-realistic ones, and this page addresses the specific failure that appears the moment a trajectory enters a dense urban core — a noise model calibrated on open-sky data undershoots the magnitude, the correlation, and the spatial structure of the error a receiver actually produces in a canyon.
Open-sky GNSS error is close to zero-mean and nearly white: each fix is a fresh, roughly independent draw around the true position, so adding independent Gaussian noise per sample is a fair approximation. In an urban canyon the geometry breaks both assumptions. Tall buildings block the direct line of sight to low-elevation satellites and reflect their signals off glass and stone, so the receiver locks onto a path that travelled farther than the straight-line distance. That extra path length enters the range measurement as a positive bias, and because the reflecting surfaces and blocked satellites change slowly as the vehicle moves down a street, the resulting position error is temporally correlated and position-dependent — it drifts smoothly over seconds and tens of metres rather than resampling every fix.
White Gaussian noise εt∼N(0,σ2) with E[εtεt−1]=0 therefore gets three things wrong at once: it is zero-mean (multipath is biased), it is uncorrelated (multipath persists across fixes), and it is spatially uniform (multipath is severe on a narrow street and mild in an open plaza). A first-order Gauss-Markov process captures the first two:
bt=ϕbt−1+ηt,ηt∼N(0,ση2),0<ϕ<1,
where the bias state bt decays toward zero with correlation coefficient ϕ (larger ϕ means longer-lived drift) and is driven by process noise ηt. The stationary variance is σb2=ση2/(1−ϕ2), and the correlation time is τ=−Δt/lnϕ. Making the driving variance and correlation depend on where the vehicle is — high on a narrow canyon segment, low in the open — adds the spatial structure. This is a strict generalization of the open-sky model that adding realistic GPS noise to synthetic vehicle trajectories establishes: set ϕ→0 and the spatial term to a constant and the Gauss-Markov process collapses back to white noise.
Reflected low-elevation signals add a slowly drifting, position-dependent bias. Its autocorrelation decays as a power of the lag, whereas white noise is uncorrelated after lag zero.
Confirm the gap by measuring the autocorrelation of a real urban error series and comparing it to what white Gaussian noise produces. Pin the toolchain so the RNG stream is reproducible.
import numpy as np
deflag1_autocorr(err: np.ndarray)->float:"""Lag-1 autocorrelation of a scalar error series (e.g. cross-track offset)."""
e = err - err.mean()
denom = np.sum(e * e)returnfloat(np.sum(e[1:]* e[:-1])/ denom)if denom >0else0.0
rng = np.random.default_rng(7)
white = rng.normal(0.0,8.0, size=600)# open-sky-style jitter, sigma 8 m# Real downtown cross-track error is biased and sticky:
real_urban_lag1 =0.93# measured from a canyon drive logprint(round(lag1_autocorr(white),3))# ~ 0.0 -> uncorrelatedprint(real_urban_lag1)# ~ 0.93 -> strongly correlatedprint(round(white.mean(),2))# ~ 0.0 -> zero-mean, but urban error is biased
White noise reports a lag-1 autocorrelation near zero and a mean near zero; a canyon drive log reports autocorrelation above 0.9 and a non-zero mean. Any model whose synthetic error autocorrelation does not approach the measured value will not fool a detector that inspects error dynamics.
Model the urban error as the sum of a slowly drifting Gauss-Markov bias whose intensity depends on local canyon severity, plus a small white measurement term, plus rare discrete jumps when the satellite set changes. Work in a metric CRS so the noise is in metres, never in EPSG:4326 degrees.
Drive the noise from a per-position severity in [0,1]: high on narrow streets flanked by tall buildings, low in open space. In production this comes from a building-height raster or sky-view factor; here it is any callable over the path.
python
import numpy as np
defseverity_along(path_xy: np.ndarray, sky_view: np.ndarray)-> np.ndarray:"""Map a per-sample sky-view factor (1=open, 0=fully enclosed) to canyon severity."""
sky_view = np.clip(sky_view,0.0,1.0)return1.0- sky_view # 0 open plaza ... 1 deep canyon
Integrate a first-order Gauss-Markov bias whose correlation ϕ and driving standard deviation scale with severity, so the error is stickier and larger in the canyon and relaxes back to open-sky behaviour in the open.
python
import numpy as np
defmultipath_bias(severity: np.ndarray, dt:float,*, seed:int,
phi_open:float=0.6, phi_canyon:float=0.97,
sigma_open:float=1.5, sigma_canyon:float=12.0)-> np.ndarray:"""Return an (N, 2) correlated bias in metres, driven by local canyon severity."""
rng = np.random.default_rng(seed)
n =len(severity)
b = np.zeros((n,2))for t inrange(1, n):
s = severity[t]
phi = phi_open +(phi_canyon - phi_open)* s # longer memory in canyon
sigma_eta = sigma_open +(sigma_canyon - sigma_open)* s
eta = rng.normal(0.0, sigma_eta, size=2)
b[t]= phi * b[t -1]+ eta # Gauss-Markov update# Bias points preferentially away from the reflecting facade: add a mild along-severity# offset so deep-canyon segments carry a persistent positive component, not just zero-mean drift.
b +=0.4* severity[:,None]* sigma_canyon * np.array([1.0,0.0])return b
Add rare discrete jumps where the visible constellation changes (turning a corner, passing a gap), then combine bias, jumps, and a small white term into the observed position. Keep the timestamps untouched — resampling and clock alignment belong to the temporal synchronization for moving objects stage, not here.
python
import numpy as np
defapply_urban_gps(clean_xy: np.ndarray, severity: np.ndarray, dt:float,*,
seed:int, jump_rate:float=0.01, sigma_white:float=1.0)-> np.ndarray:"""clean_xy: (N,2) metric truth from the physics stage -> observed urban positions."""
rng = np.random.default_rng(seed)
bias = multipath_bias(severity, dt, seed=seed)
jumps = np.zeros_like(clean_xy)
active = np.zeros(2)for t inrange(len(clean_xy)):if rng.random()< jump_rate *(0.5+ severity[t]):# more re-locks in canyon
active = rng.normal(0.0,6.0+10.0* severity[t], size=2)
jumps[t]= active # step persists until next re-lock
white = rng.normal(0.0, sigma_white, size=clean_xy.shape)return clean_xy + bias + jumps + white
Because the bias is driven off the clean geometry from the physics-based path generation stage, run this perturbation after the kinematic envelope is enforced — the correlated drift will inflate finite-difference derivatives, so a path that sat on its jerk limit before noise will breach it after.
Assert that synthetic urban error reproduces both the temporal correlation and the position dependence measured from real drives, within tolerance.
python
import numpy as np
deftest_urban_error_is_correlated_and_position_dependent(
clean_xy, observed_xy, severity,*, target_lag1=0.90, tol=0.08):
err = observed_xy - clean_xy
# 1. Temporal correlation must approach the measured urban value, not ~0.
e = err[:,0]- err[:,0].mean()
lag1 =float(np.sum(e[1:]* e[:-1])/ np.sum(e * e))assert lag1 >= target_lag1 - tol,f"error not sticky enough: lag1={lag1:.2f}"# 2. Error magnitude must be larger in canyon segments than open ones.
mag = np.linalg.norm(err, axis=1)
hi = mag[severity >0.7].mean()
lo = mag[severity <0.3].mean()assert hi >1.8* lo,f"no position dependence: canyon {hi:.1f} vs open {lo:.1f}"# 3. Sanity: not degenerate / not exploding.assert np.isfinite(mag).all()and mag.max()<150.0,"implausible error magnitude"
The autocorrelation assertion is the load-bearing one: white Gaussian noise cannot pass it at any variance, so a green gate proves the model captures error dynamics and not merely error magnitude. The position-dependence assertion prevents a well-tuned but spatially uniform model from sneaking through.
Degrees are not metres — project first. Applying a metre-valued bias to EPSG:4326 coordinates adds roughly 8 metres of longitude error at the equator but wildly more near the poles, because a degree of longitude shrinks with latitude. Always transform the clean path into a UTM zone or EPSG:6933 before adding noise, then transform back; a bias generated in degrees produces error that is anisotropic and latitude-dependent for entirely the wrong reason.
Correlation time versus sample rate. The correlation coefficient ϕ is tied to the sampling interval through τ=−Δt/lnϕ. A ϕ=0.97 calibrated at 1 Hz implies a very different physical memory at 10 Hz, so if you resample the trajectory you must recompute ϕ from the target correlation time, not carry the raw coefficient across. Carrying it unchanged makes the drift look implausibly persistent or implausibly brief after a rate change.
Antimeridian and UTM-zone seams. A vehicle crossing a UTM zone boundary or the antimeridian while a bias state is active will have that bias applied in two different projected frames, injecting a spurious jump at the seam. Keep the whole canyon segment in a single local projected CRS whose central meridian sits inside the region, and only stitch frames at points where the bias state has relaxed near zero.
Why is white Gaussian noise inadequate for urban GPS?
White Gaussian noise is zero-mean, uncorrelated, and spatially uniform, but urban multipath is biased, temporally correlated over many fixes, and far worse on narrow streets than in open space. A detector or model that inspects error dynamics sees the difference immediately: real urban error drifts smoothly and sticks, while white noise resamples independently every fix. White noise is only a fair approximation of open-sky conditions.
What does the correlation coefficient phi actually control?
Phi sets how long the bias state remembers its previous value in a first-order Gauss-Markov process. A phi near zero decays instantly and reproduces white noise, while a phi near one drifts slowly and persists across many fixes, which is the urban-canyon regime. It maps to a physical correlation time through tau equals minus delta-t divided by the natural log of phi, so it must be recomputed whenever the sampling rate changes.
How do I obtain a canyon-severity field without ray-tracing every satellite?
A sky-view factor derived from a building-height raster is a good, cheap proxy: it measures the fraction of the sky hemisphere visible from a point, which correlates strongly with multipath severity. You can approximate it from open building footprint and height data with a simple horizon scan, then map one minus sky-view to severity. Full satellite ray-tracing is more accurate but rarely necessary for generating realistic-looking synthetic error.
Should multipath noise be added before or after enforcing kinematic limits?
After. The clean, physically feasible path is produced first, then noise is layered on, because the correlated bias and the discrete jumps both inflate the finite-difference acceleration and jerk reconstructed from the noisy positions. A path that sat exactly on its jerk limit will breach it once noise is applied, so enforce the kinematic envelope on the clean path and give any downstream jerk gate a slack factor to absorb the perturbation.