Flagging impossible jumps and teleports in GPS feeds

A “teleport” in a GPS feed is one of three completely different faults wearing the same costume: a single reflected fix, a clock that stepped, or a payload whose fields were swapped or truncated. They need different responses — interpolate, split the segment, quarantine the device — and telling them apart takes three cheap tests applied in a fixed order. The one thing they share is that none of them should be silently deleted, because the rate of each is the best device-health signal a fleet has.

Why this happens

The implied speed between two fixes is distance / Δt, and a large value can come from a numerator that is wrong or a denominator that is wrong. A multipath reflection inflates the numerator for exactly one fix and then returns; a clock step shrinks the denominator, often to a fraction of a second, and everything after it is fine. Both produce a headline number in the hundreds of kilometres per hour, and a detector that only looks at speed cannot distinguish them.

The third case is different again. An axis-order swap or a truncated payload produces a position that is entirely plausible as a number — a valid latitude, a valid longitude — and entirely implausible as a location. Speed catches it only if the previous fix was nearby; a bounding-box test catches it always. This is the same failure discussed under CRS transformation best practices, arriving here as an operational alert rather than a projection bug.

Core pipeline

  1. Test the interval first. A non-positive or implausibly small Δt is a clock fault, and every speed derived from it is meaningless.
  2. Test the position against the envelope. A fix outside the operating bounding box is a payload fault regardless of what the speed says.
  3. Test the implied speed, then its persistence. A single excursion that returns is multipath; a sustained displacement is real movement.
  4. Record every rejection with its reason. The per-device rate of each class is the diagnostic; the individual fix is not.

Production-ready Python implementation

PYTHON
import numpy as np
import pandas as pd


def classify_jumps(
    df: pd.DataFrame,
    time_col: str = "t",
    x_col: str = "x",
    y_col: str = "y",
    v_max_ms: float = 70.0,
    min_dt_s: float = 0.25,
    bbox: tuple | None = None,
) -> pd.DataFrame:
    """
    Classify each fix as ok, clock_step, out_of_envelope, excursion, or move.

    Order matters: a clock step makes every derived speed meaningless, and a
    corrupted payload is plausible as a speed but not as a place. Both are
    decided BEFORE the speed test.

    Parameters
    ----------
    df : pd.DataFrame
        Fix-level frame, tz-aware time_col, PROJECTED metric x/y.
    min_dt_s : float
        Intervals below this are treated as a clock fault, not fast movement.
    bbox : tuple | None
        (minx, miny, maxx, maxy) operating envelope in the same CRS.

    Returns
    -------
    pd.DataFrame
        Input plus 'dt_s', 'step_m', 'implied_ms' and 'jump_class'.

    Raises
    ------
    ValueError
        On missing columns or an empty frame.
    """
    required = {time_col, x_col, y_col}
    missing = required - set(df.columns)
    if missing:
        raise ValueError(f"Missing required columns: {missing}")
    if df.empty:
        raise ValueError("Input DataFrame is empty.")

    out = df.sort_values(time_col).reset_index(drop=True).copy()
    dt = out[time_col].diff().dt.total_seconds().to_numpy()
    dx = out[x_col].diff().to_numpy()
    dy = out[y_col].diff().to_numpy()
    step = np.hypot(dx, dy)

    out["dt_s"] = dt
    out["step_m"] = step
    with np.errstate(divide="ignore", invalid="ignore"):
        out["implied_ms"] = np.where(dt > 0, step / dt, np.inf)

    cls = np.full(len(out), "ok", dtype=object)

    # ── 1. clock faults ───────────────────────────────────────────────
    # A non-positive interval is a duplicate or a backwards step; a very
    # small one usually means the clock jumped forward, not that the
    # vehicle moved 200 m in a fifth of a second.
    clock = (dt <= 0) | ((dt < min_dt_s) & (step > 5.0))
    cls[np.nan_to_num(clock, nan=False).astype(bool)] = "clock_step"

    # ── 2. envelope faults ────────────────────────────────────────────
    if bbox is not None:
        minx, miny, maxx, maxy = bbox
        outside = ~(out[x_col].between(minx, maxx) & out[y_col].between(miny, maxy))
        cls[outside.to_numpy()] = "out_of_envelope"

    # ── 3. speed, and whether it persists ─────────────────────────────
    fast = np.nan_to_num(out["implied_ms"].to_numpy(), nan=0.0, posinf=0.0) > v_max_ms
    # An excursion returns: the NEXT step is also large and roughly opposite.
    nxt = np.r_[fast[1:], False]
    excursion = fast & nxt
    sustained = fast & ~nxt

    still_ok = cls == "ok"
    cls[still_ok & excursion] = "excursion"
    cls[still_ok & sustained] = "move"     # fast, but consistent — investigate, don't drop

    out["jump_class"] = cls
    return out

Validation block

PYTHON
def validate_jump_classes(out: pd.DataFrame, device_id: str) -> dict:
    """Turn per-fix classes into the per-device rates that are actually useful."""
    n = len(out)
    rates = out["jump_class"].value_counts(normalize=True).to_dict()

    # Rates, not counts: a busy device produces more of everything.
    assert rates.get("clock_step", 0) < 0.01, (
        f"{device_id}: {rates.get('clock_step', 0):.1%} clock steps — "
        "the device is not disciplining its clock"
    )
    assert rates.get("out_of_envelope", 0) < 0.001, (
        f"{device_id}: payload corruption at {rates.get('out_of_envelope', 0):.2%}"
    )
    # An excursion rate of a few per cent is normal downtown and abnormal
    # on a motorway — compare against the device's own history, not a constant.
    print(f"{device_id}: {n} fixes, excursions {rates.get('excursion', 0):.2%}, "
          f"clock {rates.get('clock_step', 0):.3%}")
    return rates

Common mistakes and gotchas

  • Dividing by Δt before checking it. This is the bug that manufactures teleports. A 0.05-second interval turns a normal 3-metre step into 216 km/h, and the incident report then blames the driver.

  • Dropping rejected fixes. The rate per device per class is the entire diagnostic value. Flag and keep; let a downstream view exclude them.

  • Using one speed ceiling for the whole fleet. 70 m/s is right for cars and absurd for cargo bikes. Key the limit on the vehicle class, as in movement anomaly detection.

  • Treating a sustained fast run as an artefact. Two consecutive fast steps in the same direction is movement, possibly speeding, possibly a vehicle on a transporter. Only the excursion — out and immediately back — is a measurement error.

  • Interpolating across a clock step. The interval either side of a step has an unknown duration, so any interpolation invents elapsed time. Split the segment instead, exactly as gap filling in sparse trajectories prescribes for long gaps.

  • Running the envelope test in degrees. A bounding box in WGS84 with projected coordinates rejects everything, and one in projected units with WGS84 coordinates rejects nothing. Assert the CRS at the boundary.

FAQ

How do I tell a clock step from a genuine gap?

A gap has a large positive Δt with no data in between; a clock step has a Δt that is negative, zero, or implausibly small given the device’s configured cadence. Both end a segment, but for different reasons: a gap means the position is unknown, a step means the elapsed time is unknown. Recording which one occurred lets a later analysis decide whether the distance across the boundary is usable.

What speed ceiling should I use?

Set it well above the fastest thing the vehicle class can legally do, not at the speed limit. The ceiling exists to catch measurements that are impossible, not driving that is undesirable — 70 m/s for cars, 30 for HGVs, 22 for cycles. Speeding is a behavioural question and belongs in a different check with a different threshold.

Should the excursion test look forward as well as back?

Yes. An excursion is defined by returning, and you cannot see the return by looking only at the previous fix. The two-sided test in the code above is what separates a reflected fix from the first fix of a genuinely fast run, and it is why streaming implementations of this check need a one-fix buffer.

Back to Movement Anomaly Detection