The synthetic traces are gapless. Every consumer’s gap-filling code is therefore untested against
them, and the first real trace with a four-minute tunnel outage produces an interpolated path
straight through a hillside.
Part of Noise Injection & Stochastic Drift: position error is the noise everybody models, and missing positions are the noise everybody forgets — even though the second breaks more downstream code than the first.
Two mistakes, and the second is the expensive one.
Uniformly random dropouts are the wrong model. Real outages are not independent per sample.
They are bursts caused by a physical condition — a tunnel, an urban canyon, a parking garage,
a device sleeping — that persists for a duration drawn from a heavy-tailed distribution, and
they recur at the same places. A per-sample dropout probability produces isolated missing
samples, which almost no gap-filling code notices and no real receiver produces.
Reacquisition is not instantaneous or accurate. A receiver emerging from an outage takes
seconds to re-fix, and its first fixes are noticeably worse than its steady-state accuracy —
often several times worse, sometimes with a jump because the filter was propagating dead
reckoning. Modelling the outage but resuming with a perfect fix removes the failure mode
consumers actually hit.
The consequence of shipping gapless traces is that consumers calibrate their interpolation
thresholds against synthetic data with no gaps, and every threshold ends up untested.
Observed outage durations against two models: per-sample dropout produces nothing past a second, while a burst model reproduces the tail that breaks consumers.
defgap_profile(traces, expected_dt:float, tol:float=1.5)->dict:"""Every interval materially longer than the reporting interval is a gap."""
gaps =[]for tr in traces:for a, b inzip(tr, tr[1:]):
dt = b.t - a.t
if dt > expected_dt * tol:
gaps.append({"seconds": dt,"distance_m": haversine(a, b),"start_speed": a.speed,"reacquire_accuracy": b.hdop,})
total =sum(len(t)for t in traces)* expected_dt
return{"count":len(gaps),"gap_fraction":sum(g["seconds"]for g in gaps)/ total,"duration_quantiles": quantiles([g["seconds"]for g in gaps],[.5,.9,.99]),"accuracy_ratio": median([g["reacquire_accuracy"]for g in gaps])/ median_hdop(traces),}
The accuracy_ratio is the number most often missing from a specification and most often
surprising when measured — a first fix after a long outage is commonly two to four times worse
than steady state, and consumers that trust the reported accuracy field handle that badly.
defoutage_schedule(duration_s:float, rng, mean_up_s=900.0, gap_quantiles=None):"""Alternate available and unavailable intervals for the length of the trace."""
t, schedule =0.0,[]while t < duration_s:
up = rng.exponential(1/ mean_up_s)
down = sample_lognormal_from_quantiles(gap_quantiles, rng)
schedule.append(("up", t,min(t + up, duration_s)))
t += up
if t >= duration_s:break
schedule.append(("down", t,min(t + down, duration_s)))
t += down
return schedule
Log-normal down-durations, fitted from the observed quantiles rather than assumed, because the
tail is the part that matters and the tail is what a fitted exponential gets wrong by an order of
magnitude.
defgeographic_outage_risk(position, canyon_index, tunnels)->float:"""Outage hazard from where the agent is, not just from a clock."""if inside_any(position, tunnels):return1.0# a tunnel is not probabilisticreturnmin(0.9,0.02+0.55* canyon_index(position))
This is what makes the synthetic gaps useful for testing. Randomly placed gaps exercise a
consumer’s code path; gaps that recur at the same tunnel exercise the logic that is supposed to
recognise a known dead zone — and that logic is where the interesting bugs live.
defreacquire(true_pos, gap_seconds:float, base_sigma:float, rng):"""First fixes after an outage are degraded and converge over a few samples."""
inflation =1.0+2.4*min(1.0, gap_seconds /120.0)
out =[]for k inrange(4):
sigma = base_sigma *(1+(inflation -1)* math.exp(-k /1.4))
out.append(jitter(true_pos[k], sigma, rng))
yield_accuracy = sigma # report it, honestly
out[-1].hdop = yield_accuracy / base_sigma
return out
Reporting the degraded accuracy in the accuracy field rather than only in the position is the
part that matters for consumers. A trace whose positions are bad and whose accuracy field says
they are good is a specific, common, and very testable failure — and a synthetic release that
never produces it lets that bug reach production.
Position error across the samples following an outage, by outage length, with the reported accuracy field tracking it.
deftest_gap_fraction_matches(generated, reference, tol=0.2):
got = gap_profile(generated, expected_dt=1.0)["gap_fraction"]
want = reference["gap_fraction"]assertabs(got - want)/ want < tol,f"gap fraction {got:.4f} vs {want:.4f}"deftest_gap_durations_have_the_right_tail(generated, reference, tol=0.3):
got = gap_profile(generated,1.0)["duration_quantiles"]for g, w inzip(got, reference["duration_quantiles"]):assertabs(g - w)/ w < tol,f"{g:.1f}s vs {w:.1f}s"deftest_gaps_recur_at_known_locations(generated, dead_zones, min_hit_rate=0.6):"""A trace through a tunnel should almost always lose signal there."""
passes = traces_through(generated, dead_zones)
lost =[p for p in passes if has_gap_within(p, dead_zones)]assertlen(lost)/len(passes)> min_hit_rate
deftest_reacquisition_is_degraded(generated, ratio_min=1.6):
first =[p.error for p in first_fixes_after_gaps(generated)]
steady =[p.error for p in steady_state_fixes(generated)]assert median(first)/ median(steady)> ratio_min
deftest_accuracy_field_reflects_the_error(generated, tol=0.35):"""The reported accuracy must move with the actual error, not stay nominal."""assert rank_correlation([p.hdop for p in generated],[p.error for p in generated])>1- tol
The third test is the one worth writing carefully, because it is the property that makes the
synthetic data useful rather than merely realistic: a consumer testing dead-zone handling needs
gaps that recur, and a generator with the right gap statistics but random placement will pass
every other check here while providing no test coverage at all.
What each gap model exercises in a consumer: four downstream behaviours against three generators.
A gap and a stop look identical in the raw stream. A device that sleeps while parked produces
the same absence as a tunnel. If the release carries ground-truth stop labels, the distinction is recoverable and should be published; if not, consumers will treat some stops as outages and some outages as stops, and the release should say which is which.
Buffered replay reorders rather than drops. Many devices store fixes during an outage and
send them on reconnect, so the consumer sees a burst of late points rather than a gap. That is a
different failure, it stresses timestamp alignment rather than interpolation, and both should appear in a release that claims to be realistic.
Do not interpolate across the gap in the emitted data. The temptation to fill gaps in the
synthetic output defeats the entire purpose. If a filled variant is needed, publish it as a
separate derived layer with the gaps still marked.
Gap-aware metrics. Distance travelled, mean speed and dwell time all change depending on how
gaps are handled, so a release with gaps needs its summary statistics defined with respect to
them — otherwise two consumers computing “total distance” get different answers and both are
right.
A synthetic release with realistic gaps is more useful and more annoying than one without, and
the tension is real enough to be worth resolving explicitly rather than by default.
The default should match the reference. If the observed fleet loses signal for two percent of
its operating time, a release claiming to represent that fleet should too. Shipping less because
gaps are inconvenient produces data that quietly overstates coverage, and every downstream
estimate of distance, dwell time and availability inherits the overstatement.
Ship a gapless variant only as a labelled derivative. There is a legitimate use for a
gap-free layer — training a model that cannot handle missingness, prototyping a pipeline before
its error handling exists. That layer should be derived from the gapped release, named so nobody
mistakes it for the primary artifact, and carry a flag on every interpolated point.
Make the gap severity a declared parameter, not a hidden constant. A consumer testing their
gap handling wants to turn it up; a consumer benchmarking throughput wants to turn it down. A
release family generated at two or three declared severities serves both without anybody editing
the generator.
Never let the gap fraction vary silently between releases. This is the same comparability
problem as an adaptive density scale: if release n has three percent gaps and release n+1 has
one percent, every coverage statistic moves and nothing in the data says why. Fix it, record it,
and change it by version.
The underlying principle is that a gap is data. It records that the receiver was somewhere it
could not see the sky, and a release that suppresses it has thrown away an observation rather
than cleaned one up.