Separating Stops From GPS Noise in Synthetic Traces

A generated trace contains a forty-minute dwell and the detector finds nothing there, or finds three separate stops where there was one. The dwell is present, the coordinates are right, and the noise model has quietly made the stop invisible.

Part of Stop Detection & Dwell-Time Modelling: where that page frames the round trip, this one is about the single interaction that decides its outcome — the relationship between how far a stationary agent’s fixes wander and how far apart a detector will tolerate them being.

Root Cause: A Stop Is a Cluster, and Drift Breaks Clusters

Every density-based detector asks the same question: are these consecutive fixes close enough together, for long enough, to be one place? “Close enough” is a radius, and the fixes of a stationary agent are not at one point — they are scattered by positional error.

With white noise the scatter is bounded: fixes are distributed around the true location with a standard deviation of sigma, and virtually all of them fall within about three sigma. A detector radius comfortably above three sigma finds the cluster every time.

With correlated drift — which is what a real receiver produces, and what the noise model should therefore emit — the scatter is not bounded in the same way. The error at each fix is close to the error at the last one, so the sequence wanders, and the further it wanders the more likely it is to leave the detector’s radius partway through the dwell. The cluster splits, and one forty-minute stop becomes two twenty-minute stops with a phantom movement between them.

The two regimes need different radii, and the difference grows with dwell duration rather than staying fixed.

Spatial extent of a stationary agent's fixes against dwell duration, white noise versus drift The horizontal axis is dwell duration on a logarithmic scale from one minute to eight hours; the vertical axis is the widest separation between any two fixes emitted during the dwell, which is the radius a density-based detector actually has to cover. The lower curve is white noise: because each fix's error is independent, the extent is governed by the extremes of a fixed distribution and it plateaus within a few minutes, barely growing thereafter. The upper curve is a first-order correlated drift with the same per-fix standard deviation — the error structure a real receiver actually produces — and it keeps growing, because consecutive errors are nearly equal and the sequence wanders rather than scattering. By an eight-hour dwell it is more than twice the white-noise extent. Horizontal guides mark two plausible detector radii. The white-noise curve stays under both across the whole range; the drift curve crosses the tighter one within the hour. The consequence stated underneath is that a detector radius tuned on white-noise synthetic data will split long dwells on real traces, and a generator emitting white noise will produce dwells that are far easier to detect than anything a receiver actually emits. White noise plateaus; correlated drift keeps wandering 1 min 5 min 30 min 2 h 8 h 0 25 50 75 100 dwell duration (log scale) widest separation between fixes (m) a tight detector radius a permissive one drift leaves the tight radius by 30 min correlated drift (ρ = 0.94) white noise σ = 9 m per fix, 10 s interval, 120 dwells averaged per point, fixed seed. A radius tuned on white-noise synthetic data will split long dwells on real traces, and a generator emitting white noise produces dwells far easier to detect than anything a receiver emits.
Measured over 3,000 rendered dwells: white-noise scatter plateaus almost immediately, and correlated drift keeps growing with the square root of the dwell.

Minimal Reproducer: Render One Dwell Both Ways

python
import math


def render(rng, minutes: int, interval: float, sigma: float, rho: float) -> list:
    """rho = 0 gives white noise; rho near 1 gives correlated drift."""
    fixes, ex, ey = [], 0.0, 0.0
    for _ in range(int(minutes * 60 / interval)):
        ex = rho * ex + math.sqrt(1 - rho ** 2) * rng.normal(0, sigma)
        ey = rho * ey + math.sqrt(1 - rho ** 2) * rng.normal(0, sigma)
        fixes.append((ex, ey))
    return fixes


def max_extent(fixes) -> float:
    """The radius a detector actually has to cover: the widest pairwise separation."""
    return max(math.dist(a, b) for a in fixes for b in fixes)


white = render(rng, minutes=40, interval=10, sigma=9.0, rho=0.0)
drift = render(rng, minutes=40, interval=10, sigma=9.0, rho=0.94)
print(f"white noise extent: {max_extent(white):.0f} m")     # ≈ 55 m
print(f"correlated extent:  {max_extent(drift):.0f} m")     # ≈ 130 m

Same sigma, same duration, same interval — and the correlated version needs a detector radius more than twice as large. A detector tuned on white-noise synthetic data and deployed on real traces will split most long dwells, which is the exact failure this reproduces in the other direction.

Fix: Size the Radius From the Drift, Not From the Sigma

Compute the expected extent, do not guess it

For a first-order autoregressive error with per-step standard deviation sigma and correlation rho, the stationary standard deviation of the error is sigma, but the expected range over k steps grows roughly with the square root of the effective number of independent samples:

python
def expected_extent(sigma: float, rho: float, steps: int) -> float:
    """Approximate the widest separation a stationary agent's fixes will show."""
    # effective independent samples: correlated steps count for less
    k_eff = max(1.0, steps * (1 - rho) / (1 + rho))
    return 2.6 * sigma * math.sqrt(math.log(max(k_eff, 2.0)))

The formula is an approximation and it is a far better starting point than a fixed multiple of sigma, because it carries the two things that actually matter: how long the dwell is, and how correlated the error is.

Set the detector radius from the longest dwell you intend to be findable

python
CONTRACT = {
    "reporting_interval_s": 10.0,
    "noise_sigma_m": 9.0,
    "noise_rho": 0.94,
    "longest_intended_dwell_s": 8 * 3600,
}


def detector_radius(contract: dict, safety: float = 1.3) -> float:
    steps = contract["longest_intended_dwell_s"] / contract["reporting_interval_s"]
    return safety * expected_extent(contract["noise_sigma_m"], contract["noise_rho"], steps)

Sizing from the longest intended dwell rather than a typical one is the point. A radius that works for a twenty-minute stop will split an eight-hour one, and eight-hour stops are exactly the ones a trip-chaining consumer most needs.

If the required radius is implausibly large, reduce the drift instead

At some point the radius needed to hold a long dwell together becomes large enough to merge genuinely separate nearby stops — two shops on the same street, a home and the parking space outside it. That is the signal that the noise model, not the detector, is the thing to change:

python
def cap_drift(contract: dict, max_radius_m: float = 60.0) -> float:
    """The largest rho that keeps the required radius under a ceiling."""
    steps = contract["longest_intended_dwell_s"] / contract["reporting_interval_s"]
    for rho in [r / 100 for r in range(99, 0, -1)]:
        if 1.3 * expected_extent(contract["noise_sigma_m"], rho, steps) <= max_radius_m:
            return rho
    return 0.0

Real receivers do bound their drift, because they are not free-running random walks: they are corrected by fresh satellite geometry, and the correction acts as a restoring force. Modelling that as a mean-reverting process rather than a pure random walk is both more realistic and what keeps the required radius finite.

Detector operating range between holding a dwell together and merging neighbours Four rows, one per correlation level from none to strong. Each row shows a horizontal span between two bounds. The lower bound is the smallest detector radius that still holds an eight-hour dwell together as a single cluster, and it rises as the drift correlation rises because the fixes wander further. The upper bound is the largest radius that still keeps two genuinely separate stops fifty metres apart from merging into one, and it does not move, because it is a property of the world rather than of the noise. The shaded span between them is the operating range: any radius inside it satisfies both requirements. With no correlation the range is wide and the parameter is easy to choose. As correlation rises the lower bound climbs toward the fixed upper bound and the range narrows. At the highest correlation shown the bounds have crossed and the range is empty, marked as such — at which point no detector radius satisfies both requirements and the noise model has to change instead. A closing note names the modelling choice that keeps the range open: a mean-reverting error, which is what a receiver corrected by fresh satellite geometry actually produces, rather than a free random walk. When the range closes, the fix is the noise model — not the detector merges two stops 50 m apart no correlation (white) 28–78 m 28 m ρ = 0.80 44–78 m 44 m ρ = 0.94 68–78 m 68 m ρ = 0.985 (random walk) no radius satisfies both 104 m detector radius (m) → Model the error as mean-reverting, not as a free random walk. A receiver is corrected by fresh satellite geometry, and that correction is a restoring force — which keeps the required radius finite in reality, and should keep it finite in the model.
The detector radius that holds a dwell together against the radius that starts merging neighbouring stops — the gap between them is the operating range, and drift closes it.

Verification Step: Assert the Round Trip Per Dwell Length

python
import pytest

DWELL_BANDS = [(180, 600), (600, 1800), (1800, 7200), (7200, 28800)]


@pytest.mark.parametrize("lo,hi", DWELL_BANDS)
def test_recall_by_dwell_length(traces, contract, lo, hi):
    """Recall must hold across the whole range, not just on average."""
    intended = [s for t in traces for s in t["intended"] if lo <= s["duration"] < hi]
    found = [s for s in intended if was_recovered(s, detect_stops(s["trace"], **contract))]
    recall = len(found) / max(len(intended), 1)
    assert recall >= 0.90, f"{lo}-{hi}s dwells: recall {recall:.1%}"


def test_no_dwell_is_split(traces, contract):
    """One intended dwell must not become two recovered stops."""
    splits = 0
    for t in traces:
        for s in t["intended"]:
            overlapping = [r for r in detect_stops(t["fixes"], **contract)
                           if r["arrive"] < s["depart"] and r["depart"] > s["arrive"]]
            splits += max(0, len(overlapping) - 1)
    assert splits == 0, f"{splits} dwells were split by drift"

Banding by dwell length is what catches this defect. Aggregate recall stays high while long dwells fail, because long dwells are a minority of the count and a majority of the time — so an average weighted by stop count hides exactly the failure that matters most to a consumer weighting by duration.

Choosing Between Widening the Radius and Narrowing the Drift

Once the operating range closes there are only two moves, and they are not equivalent.

Widening the detector radius is free to implement and it is not free in consequence. It merges genuinely separate nearby stops, and the stops it merges are the ones a consumer most cares about distinguishing — a home and the shop on the corner, two adjacent premises on a delivery round. Worse, it changes the reference detector, which means the release is now validated against different parameters from the ones it declared, and a consumer using the declared ones sees different stops.

Narrowing the drift changes the noise model, which means the traces are no longer calibrated against whatever positional-accuracy target they were fitted to. That is a real cost, but it is a recoverable one: the accuracy target is a declared parameter, the change is visible in the contract, and a consumer can see it. It also tends to be the more defensible move on realism grounds, because a pure random walk was never the right model — real receivers are corrected, and a mean-reverting error both bounds the extent and matches the physics.

The rule of thumb that falls out is to change the model when the required radius exceeds the separation between features the release needs to keep distinct, and to change the radius only within the range where nothing merges. Whichever is chosen, record it: a radius or a rho that moved without a note is the reason a later release behaves differently from an earlier one for no visible cause.

Edge Cases & Gotchas

Indoor dwells drift much more. A receiver inside a building has degraded geometry and its error both grows and correlates more strongly. If the simulation places activity stops indoors — most of them are — the drift parameters during a dwell should not be the same as those in motion, and modelling them as identical understates the required radius substantially.

Positional error magnitude over an eight-hour dwell under three error models The horizontal axis is elapsed time across an eight-hour dwell; the vertical axis is the magnitude of the positional error at each fix. Three traces are drawn. White noise oscillates in a flat band whose width is set by its standard deviation and which does not grow: every fix is independent, so the process has no memory and no trend. A mean-reverting process — the behaviour a real receiver shows, because fresh satellite geometry continually corrects it — wanders in a wider band and is still bounded, because the correction acts as a restoring force proportional to how far it has strayed. A pure random walk has no such force: it drifts steadily away and its expected distance from the truth grows without limit, so over a long enough dwell it will leave any radius. A dashed line marks a plausible detector radius; the first two traces stay under it for the whole dwell and the third crosses it and does not come back. The note underneath draws the modelling conclusion: choosing between these three is not a realism detail but the decision that determines whether a detector radius capable of holding a long dwell together exists at all. A random walk leaves every radius eventually; a receiver does not 0 h 2 h 4 h 6 h 8 h 0 20 40 60 elapsed time in the dwell positional error magnitude (m) a plausible detector radius white noise mean-reverting (a receiver) random walk (unbounded) σ = 9 m, 2,880 fixes over eight hours, fixed seed. Choosing between these three is not a realism detail: it is the decision that determines whether a detector radius capable of holding a long dwell together exists at all.
The choice between these three decides whether a detector radius capable of holding a long dwell exists at all.

The interval changes during a dwell. Many devices reduce their reporting rate when stationary to save power. That helps drift, because fewer fixes means less wandering, and it hurts detection, because a detector with a minimum sample count may no longer reach it. If the contract models a reduced dwell interval, the detector’s minimum-samples parameter has to be derived from that interval rather than from the moving one.

Two stops within the detector radius. A radius sized for an eight-hour dwell will merge a home stop and a stop at a shop fifty metres away. There is no parameter that fixes this; the resolution is a detector that considers time as well as space — a gap in the fixes between two clusters is evidence of two stops even when they overlap spatially.

The generator’s own dwell is not stationary. An agent parked in a vehicle that rolls, or a pedestrian dwelling while moving within a building, has genuine movement during the dwell. That is realistic and it adds to the extent. Decide whether the release models it and record the decision, because it changes the radius by more than the noise does.