Movement Anomaly Detection

Movement anomaly detection separates measurements that no vehicle could have produced from movement that is physically possible but behaviourally unexpected, and scores the second kind against an explicit definition of what normal means for that entity.

The distinction is the whole discipline. Conflating the two produces the characteristic failure of mobility anomaly systems: an alert queue dominated by urban-canyon multipath, in which the genuinely interesting events — the vehicle that left its corridor, the asset that moved when it should have been parked — are buried under noise that a kinematic bound would have removed for free.

Prerequisites

  • A cleaned, projected trace. Every threshold below is metric and assumes multipath has already been filtered; see GPS precision and error handling and coordinate reference system mapping.
  • A monotone UTC timeline. Clock steps produce infinite speeds and backwards intervals that read exactly like teleports; time-series synchronization has to come first.
  • Segment boundaries. A gap is not an anomaly, but movement across a gap looks like one; trajectory segmentation supplies the boundaries that stop the detector inventing events.
  • Python stack. pandas >= 2.0, numpy >= 1.25, shapely >= 2.0, and scikit-learn >= 1.4 only if you reach the behavioural layer.

The Three Layers, and Why Order Matters

Layer one is physics. It asks whether the measurement could have happened at all: implied speed above a hard ceiling, acceleration beyond what the vehicle class can produce, a position outside the operating region, a timestamp that goes backwards. These are sensor artefacts. They are rejected, counted, and never surfaced as alerts, because an alert a human cannot act on is worse than a silent counter. The one thing worth monitoring is the rate — a device whose artefact rate triples has a hardware fault.

Layer two is rules against an explicit reference. Here “normal” is something you can write down: a planned route, a delivery window, a geofence, a permitted operating envelope. The output is a graded score — metres off corridor, minutes late, seconds dwelling where dwelling is not expected — not a boolean. Grading matters because the threshold is a business decision that will change, and re-deriving history under a new threshold has to be a query rather than a reprocessing job.

Layer three is behaviour. Nothing is violated; the movement simply does not resemble what this entity, or entities like it, usually do. This is where unsupervised methods earn their place, and where they should stay: their alerts are the hardest to explain and the least likely to be actionable, so they belong at the top of the funnel where the volume is already small.

Failure-Mode Taxonomy

Signature Usual cause Layer What to do
Single fix hundreds of metres off, returns immediately Multipath reflection 1 Reject the fix; interpolate; count it
Implied speed 400+ km/h across two fixes Clock step or duplicate transmission 1 Split the segment at the step; do not smooth across it
Position in the sea, or 200 km from any depot Axis-order swap or corrupted payload 1 Reject and alert on the device, not the trip
Sustained 60 m offset from the corridor for 4 minutes Genuine diversion, or roadworks 2 Score and rank; enrich with incident data
Dwell of 40 minutes at an unplanned location Break, breakdown, or unrecorded stop 2 Score; correlate with driver records
Trip pattern unlike this vehicle’s last 90 days Reassignment, theft, or a new contract 3 Review; never auto-action
Every vehicle deviating in the same place The reference route is wrong 2 Fix the reference, not the detector

The last row is the most common cause of a detector losing credibility. When deviation clusters spatially across many entities, the reference is stale — a closed road, a changed depot entrance, a route planned on an old network. A detector that cannot distinguish “this vehicle is wrong” from “the plan is wrong” will be switched off within a month.

Deterministic Pipeline Overview

Implementation Walkthrough

The physics gate is the highest-value twenty lines in the whole pipeline, and it belongs in a function that can be unit-tested against known-bad fixtures.

PYTHON
import numpy as np
import pandas as pd

# Hard ceilings by vehicle class. These are physics, not tuning knobs:
# exceeding them means the MEASUREMENT is wrong, not the driver.
LIMITS = {
    "pedestrian": {"v_max_ms": 8.0,  "a_max_ms2": 4.0},
    "cycle":      {"v_max_ms": 22.0, "a_max_ms2": 6.0},
    "car":        {"v_max_ms": 70.0, "a_max_ms2": 12.0},
    "hgv":        {"v_max_ms": 30.0, "a_max_ms2": 6.0},
    "rail":       {"v_max_ms": 90.0, "a_max_ms2": 4.0},
}


def physics_gate(
    df: pd.DataFrame,
    vehicle_class: str = "car",
    time_col: str = "t",
    x_col: str = "x",
    y_col: str = "y",
    bbox: tuple | None = None,
) -> pd.DataFrame:
    """
    Flag fixes that no vehicle of this class could have produced.

    Adds boolean columns 'bad_speed', 'bad_accel', 'bad_bbox', 'bad_clock'
    and a combined 'artefact'. Nothing is dropped — the caller decides,
    and the counts are a device-health signal worth keeping.

    Parameters
    ----------
    df : pd.DataFrame
        Fix-level frame with tz-aware time_col and PROJECTED metric x/y.
    bbox : tuple | None
        (minx, miny, maxx, maxy) operating envelope in the same CRS.

    Raises
    ------
    ValueError
        On missing columns, an empty frame, or an unknown vehicle class.
    """
    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.")
    if vehicle_class not in LIMITS:
        raise ValueError(f"Unknown vehicle class {vehicle_class!r}; "
                         f"expected one of {sorted(LIMITS)}")

    lim = LIMITS[vehicle_class]
    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)

    # A non-positive interval is a clock step or a duplicate, never movement.
    out["bad_clock"] = np.r_[False, dt[1:] <= 0]

    with np.errstate(divide="ignore", invalid="ignore"):
        speed = np.where(dt > 0, step / dt, np.nan)
        accel = np.r_[np.nan, np.diff(speed) / np.where(dt[1:] > 0, dt[1:], np.nan)]

    out["speed_ms"] = speed
    out["bad_speed"] = np.nan_to_num(speed, nan=0.0) > lim["v_max_ms"]
    out["bad_accel"] = np.abs(np.nan_to_num(accel, nan=0.0)) > lim["a_max_ms2"]

    if bbox is not None:
        minx, miny, maxx, maxy = bbox
        out["bad_bbox"] = ~(
            out[x_col].between(minx, maxx) & out[y_col].between(miny, maxy)
        )
    else:
        out["bad_bbox"] = False

    out["artefact"] = (
        out["bad_speed"] | out["bad_accel"] | out["bad_bbox"] | out["bad_clock"]
    )
    return out

Three choices in that function are deliberate. Nothing is dropped, because the artefact rate per device is the cheapest hardware-fault signal available and dropping rows destroys it. The bounding-box check is separate from the speed check, because an axis-order swap produces a plausible speed and an implausible location — the two failures need different responses. And a non-positive dt is treated as a clock problem rather than a speed problem, because dividing by it is how a clock step becomes a 400 km/h “teleport” in the incident report.

Mathematical Grounding

Route deviation is a point-to-line distance, and the arithmetic that matters is the error budget around it. If positional error has standard deviation $\sigma_p$ and the reference geometry itself has uncertainty $\sigma_r$ — lane width, digitising error, a corridor that legitimately splits around an island — then the deviation of an on-route vehicle is distributed with standard deviation $\sqrt{\sigma_p^2 + \sigma_r^2}$. A threshold set at three of those combined standard deviations gives roughly one false event per thousand independent fixes.

The word independent is where naive thresholds fail. Consecutive GPS errors are strongly autocorrelated — multipath persists for tens of seconds — so the effective number of independent observations in a four-minute window at 1 Hz is closer to eight than to 240. That is precisely why the persistence gate is expressed in seconds and metres rather than in a count of fixes: requiring 30 seconds of sustained deviation is requiring several independent errors to agree, which noise rarely does and a genuine diversion always does.

Calibration and Parameter Tuning

Parameter Motorway corridor Urban street Rationale
Deviation threshold 60–80 m 25–40 m Covers positional error plus legitimate corridor spread
Persistence 30 s / 400 m 30 s / 200 m Long enough to outlast correlated multipath
Clear threshold 0.6 × enter 0.6 × enter Hysteresis, so an event does not chatter at the boundary
Unexpected-dwell 8 min 4 min Below typical traffic-signal and queue durations
Max artefact rate 2% 5% Above this, investigate the device, not the trip

Set the deviation threshold from a measured distribution rather than a guess: take a week of trips known to have followed their route, compute the deviation distribution, and place the threshold at its 99.9th percentile. That number is specific to your fleet, your network data and your cleaning pipeline, and it will not match anybody else’s.

Integration and Compatibility

Anomaly detection consumes almost everything upstream and feeds almost nothing downstream, which makes it unusually sensitive to changes elsewhere. A change to the smoother alters the deviation distribution; a change to segmentation alters what counts as a dwell; a network update alters the reference geometry for every corridor at once. Version the detector’s inputs and record the versions on each event, or the first question about an old alert — “would this still fire today?” — becomes unanswerable.

The natural consumers are operational rather than analytical: dispatch, compliance, asset security. That has one important consequence for the interface. Every event needs the check that fired, the observed and expected values, the margin, and the fixes involved, because someone will have to decide within minutes whether it is real. An event record containing only a flag and a timestamp is not actionable, and a detector whose events are not actionable gets muted rather than fixed.

In This Section

FAQ

What is the difference between a GPS artefact and a movement anomaly?

An artefact is a measurement no vehicle could have produced — a 900 km/h step, a position 40 km offshore, a timestamp that goes backwards. An anomaly is physically possible but behaviourally unexpected, such as a van taking a route it has never taken. Artefacts are rejected by physics and never reach a human; anomalies are scored and ranked, because whether they matter is a judgement the detector cannot make.

Why does my anomaly detector fire constantly in urban areas?

Almost always because it is measuring positional noise rather than behaviour. Urban multipath routinely produces 15–30 m excursions, which any distance-from-reference score will flag. Fix it upstream by cleaning the trace and comparing against a map-matched path rather than raw fixes, and require deviation to persist across several consecutive fixes before it counts.

Should I use a machine-learning model for anomaly detection?

Not before the deterministic checks are in place. Physics-based rejection and reference-path deviation catch the overwhelming majority of what teams want to find, need no training data, and produce explainable alerts — which matters because someone has to act on each one. Unsupervised models are useful for the residual: behaviour that breaks no rule but does not resemble the entity’s history.

How do I set a route-deviation threshold?

From the corridor width, not from a round number. The threshold must exceed positional error plus the legitimate spread of the route — service roads, lane differences, permitted diversions. In practice 50–80 m for motorway corridors and 25–40 m for urban streets, combined with a persistence requirement of at least 30 seconds or 200 metres.

What should an anomaly record contain?

The entity and time window, the check that fired, the observed and expected values, the margin by which the threshold was crossed, and the raw fixes involved. Recording only a boolean makes the alert untriageable and makes retuning impossible, because there is no way to ask what would have happened at a different threshold without reprocessing the archive.

Back to Movement Pattern Extraction & Trajectory Analysis