Modelling Signal Outages and Reacquisition

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.

Root Cause: Gaps Are Structured, and Absence Is Not Neutral

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.

Gap-duration distribution for observed traces and two generators Seven gap-duration bands run along the horizontal axis, from one to two seconds up to three to ten minutes, spaced evenly rather than proportionally so the long bands remain legible. The vertical axis is the percentage of all gaps falling in each band. Three bars appear in each band. The observed distribution has most of its mass in the shortest band but a substantial tail: more than a quarter of gaps last longer than ten seconds and a few percent last minutes. A per-sample dropout model puts essentially everything in the first band and produces nothing at all beyond two seconds, because independent per-sample losses almost never occur consecutively more than once or twice. A burst model with a log-normal outage duration tracks the observed distribution across every band including the tail. The note underneath explains why the tail is the part that matters — it is the region where a consumer's interpolation logic changes behaviour, and a generator that produces none of it provides no test coverage for that logic at all. The tail is the part consumers break on — and it is the part that is missing 0% 25% 50% 75% 100% share of gaps (%) 1–2 s 2–5 s 5–10 s 10–30 s 30–60 s 1–3 min 3–10 min per-sample dropout produces nothing out here observed per-sample dropout burst model The tail is where a consumer's interpolation logic changes behaviour — a two-second gap is filled without comment, a four-minute gap should not be. A generator that produces none of the tail gives that code path no coverage, so the thresholds in it stay untested until a real trace arrives.
Observed outage durations against two models: per-sample dropout produces nothing past a second, while a burst model reproduces the tail that breaks consumers.

Prerequisite Check: Characterise the Gaps in the Reference Traces

python
def gap_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 in zip(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.

Fix: A Two-State Process with a Heavy-Tailed Off Duration

1 — Model availability as an alternating renewal process

python
def outage_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.

2 — Attach outages to places, not only to time

python
def geographic_outage_risk(position, canyon_index, tunnels) -> float:
    """Outage hazard from where the agent is, not just from a clock."""
    if inside_any(position, tunnels):
        return 1.0                                  # a tunnel is not probabilistic
    return min(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.

3 — Model the reacquisition ramp

python
def reacquire(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 in range(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 after reacquisition, by outage length The horizontal axis is the sample index after signal is regained, from the first fix through the ninth. The vertical axis is position error in metres. A horizontal line marks the receiver's steady-state accuracy. Three curves show what happens after outages of ten seconds, forty-five seconds and four minutes. All three start above the steady-state line and converge down to it within about four samples, but they start at very different heights: a ten-second gap barely degrades the first fix, while a four-minute gap produces a first fix several times worse than steady state. The longer the outage, the further the receiver's filter has drifted on dead reckoning and the worse its first reacquisition. Alongside each curve the reported accuracy value is shown tracking the actual error rather than staying at its nominal figure. The note underneath identifies the specific consumer bug this exists to test: a trace whose positions are bad and whose accuracy field claims they are good, which is common in real data and impossible to encounter in a synthetic release that resumes with a perfect fix. A first fix after four minutes is not a first fix after four seconds 0 2 4 6 8 0 5 10 15 20 samples since reacquisition position error (m) steady state 4.5 m 10 s outage 45 s outage 240 s outage 10 s outage 45 s outage 240 s outage The bug this exists to test is specific: a trace whose positions are bad and whose accuracy field says they are good. It is common in real data, it breaks consumers that filter on the accuracy field, and it cannot occur in a synthetic release that resumes from an outage with a perfect fix — so the code that should handle it is never exercised.
Position error across the samples following an outage, by outage length, with the reported accuracy field tracking it.

Verification Step: Check the Gaps Are There, and Structured

python
def test_gap_fraction_matches(generated, reference, tol=0.2):
    got = gap_profile(generated, expected_dt=1.0)["gap_fraction"]
    want = reference["gap_fraction"]
    assert abs(got - want) / want < tol, f"gap fraction {got:.4f} vs {want:.4f}"


def test_gap_durations_have_the_right_tail(generated, reference, tol=0.3):
    got = gap_profile(generated, 1.0)["duration_quantiles"]
    for g, w in zip(got, reference["duration_quantiles"]):
        assert abs(g - w) / w < tol, f"{g:.1f}s vs {w:.1f}s"


def test_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)]
    assert len(lost) / len(passes) > min_hit_rate


def test_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


def test_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 A matrix with three generator behaviours as rows and four downstream consumer code paths as columns. A gapless release exercises none of them: interpolation across a gap, the long-gap threshold that should refuse to interpolate, dead-zone recognition, and accuracy-field filtering all sit unused, and every threshold in them stays at whatever value it was first written with. Randomly placed bursts exercise interpolation and the long-gap threshold, because gaps of realistic duration now occur, but leave dead-zone recognition untested since the gaps never recur in the same place, and leave accuracy filtering untested because reacquisition is modelled as perfect. Geographic bursts with a reacquisition ramp exercise all four. The note underneath draws the distinction the table is built around: realistic gap statistics and useful gap placement are different properties, and a generator can have the first without the second while passing every distributional check. Right statistics, wrong placement — still no test coverage gap model interpolation long-gap refusal dead-zone logic accuracy filter gapless release untested untested untested untested random bursts exercised exercised untested untested geographic bursts + ramp exercised exercised exercised exercised Realistic gap statistics and useful gap placement are different properties. A generator can reproduce the observed duration distribution exactly while placing every gap at random, pass every distributional check in the suite, and leave the consumer logic that recognises a known dead zone with no coverage at all.
What each gap model exercises in a consumer: four downstream behaviours against three generators.

Edge Cases & Gotchas

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.

Deciding How Much Gap to Ship

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.