Detecting Stuck GPS Receivers with Run-Length Checks

Of the defects catalogued in outlier removal in raw telematics streams, the frozen receiver is the one no displacement-based test can find. A spike is far from its neighbours; a teleport implies an impossible speed; a stuck receiver produces the cleanest data in the file. Every inter-fix displacement is exactly zero, every implied speed is exactly zero, and any statistic built on those quantities reports perfect quality.

The signal that does exist is the run length of bit-identical coordinates. A stationary receiver with a live solution still jitters by a few metres from one epoch to the next; a receiver that has stopped recomputing emits the same floating-point values indefinitely. Distinguishing the two is a one-pass operation, and the interesting part is what to do with the answer — because a long run of identical coordinates is sometimes exactly what a vehicle parked in an underground bay looks like.


Compatibility and Configuration Requirements

Requirement Value Notes
Coordinate precision As stored by the device, unrounded Rounding to five decimals manufactures runs that were not there
Sampling interval Known per device The trigger is expressed in seconds, not in rows
Helpful extras ignition, fuel_rate, sats_used, hdop Any one of them converts a suspicion into a verdict
Polars ≥ 1.0 rle_id used below
Position in pipeline After deduplication, before smoothing A retransmitted fix creates a false run of length two

Running this before deduplication produces false positives: a retransmitted fix is bit-identical to its original by construction.


The Detector

from __future__ import annotations

import polars as pl


def flag_stuck_runs(
    fixes: pl.LazyFrame,
    min_run_seconds: float = 300.0,
    min_run_fixes: int = 5,
) -> pl.LazyFrame:
    """Flag runs of bit-identical coordinates that look like a frozen receiver.

    A run qualifies when it is both long enough in wall-clock time and holds
    enough fixes for the repeat to be implausible at the device's own rate.
    Nothing is deleted — the caller decides.
    """
    with_runs = (
        fixes.sort(["vehicle_id", "ts_utc"])
        .with_columns(
            pl.struct(["lat", "lon"]).rle_id().over("vehicle_id").alias("_run_id")
        )
        .with_columns(
            pl.len().over(["vehicle_id", "_run_id"]).alias("run_fixes"),
            (
                pl.col("ts_utc").max().over(["vehicle_id", "_run_id"])
                - pl.col("ts_utc").min().over(["vehicle_id", "_run_id"])
            ).dt.total_seconds().alias("run_seconds"),
        )
    )

    return with_runs.with_columns(
        (
            (pl.col("run_fixes") >= min_run_fixes)
            & (pl.col("run_seconds") >= min_run_seconds)
        ).alias("repeat_run")
    ).drop("_run_id")


def classify_runs(flagged: pl.LazyFrame) -> pl.LazyFrame:
    """Turn a suspicion into a verdict using non-positional evidence."""
    return flagged.with_columns(
        pl.when(~pl.col("repeat_run"))
        .then(pl.lit("normal"))
        # Engine off across the whole run: a genuine park.
        .when(pl.col("ignition").not_().all().over(["vehicle_id", "run_seconds"]))
        .then(pl.lit("parked"))
        # Engine on and moving fuel: the vehicle is running, so a frozen
        # position is a receiver fault, not a stop.
        .when(pl.col("fuel_rate").mean().over(["vehicle_id", "run_seconds"]) > 0.4)
        .then(pl.lit("receiver_stuck"))
        .otherwise(pl.lit("unknown"))
        .alias("run_class")
    )

rle_id does the work: it assigns a new identifier every time the (lat, lon) struct changes, so a run of identical coordinates shares one value and its length is a window count. The two thresholds are deliberately combined with and — a 30-second device produces five identical fixes in 150 seconds, which is well inside a normal traffic-light stop, so the fix count alone is not enough.

A live stationary receiver still jitters Latitude at full precision over sixty fixes. In the first block the vehicle is parked and the receiver still moves the last decimal places by a few metres per epoch. In the second block the values are bit-identical across every fix, which no live position solution produces. Latitude, last four decimal places, 60 consecutive fixes parked — live solution, ±3 m jitter stuck — 46 bit-identical values Both blocks report zero displacement, zero speed and zero variance. Only the second is a fault. The distinguishing feature is not how far the vehicle moved — it is whether the receiver kept computing. Rounding coordinates to five decimals before this test destroys the jitter and makes both blocks look identical. Some devices quantise their output, which produces short false runs — raise the seconds threshold rather than the fix count. A stuck run that ends with a large jump is the classic signature: the receiver resumed and caught up in one step.

Execution and Tuning Guidelines

Set min_run_seconds from the longest stop you are willing to misclassify. At 300 seconds the detector will flag any five-minute park that also happens to be bit-identical — rare with a live receiver, but not impossible in a deep underground bay where the solution genuinely freezes. Raising it to 1800 seconds makes false positives nearly impossible and lets a half-hour receiver fault pass unnoticed.

Set min_run_fixes from the device rate. Five fixes is 5 seconds at 1 Hz and 150 seconds at 30-second reporting. Expressing the trigger only in fixes gives two device families wildly different sensitivities from one configuration value.

Classify, do not delete. The three-way outcome — parked, receiver_stuck, unknown — is what makes the detector safe to run. Deleting every flagged run removes genuine depot stops; stop detection needs those, and it is better equipped than this stage to decide what they mean.

Feed receiver_stuck runs to the device-health pipeline, not just to the cleaner. A vehicle whose receiver freezes for twenty minutes every shift has a hardware problem that a data pipeline can detect long before anyone reports it.

What the flagged runs turn out to be Of 4 180 flagged runs in one fleet week, 3 020 resolve to genuine parks once ignition is checked, 640 are receiver faults concentrated in one device family, and 520 remain unknown because those vehicles report no ignition or fuel channel at all. 4 180 flagged runs, one fleet week parked 3 020 receiver_stuck 640 — 91 % from one device family unknown 520 — no ignition or fuel channel Deleting every flagged run would have removed three thousand genuine depot and delivery stops. The concentration of faults in one device family is the actionable finding, and it only appears because the runs were classified. Publish the unknown share too: it measures how many vehicles cannot be diagnosed at all with the channels currently ingested.

Common Pitfalls Specific to This Technique

Rounding before comparing. A pipeline that stores coordinates as numeric(9,6) has already destroyed the jitter this detector relies on, and every stationary period becomes a bit-identical run. Keep full precision at least until this stage has run.

Treating the run’s timestamps as trustworthy. A frozen receiver sometimes freezes the timestamp too, in which case the run has zero duration and the seconds threshold never fires. Guard the duration test with a check that the timestamps are advancing, and treat a frozen clock as its own fault class.

Ignoring the fix at the end of the run. The last fix before a stuck receiver resumes is often a large jump, because the device has been stationary in the data while the vehicle moved. That jump will be caught by the spike detector and removed, hiding the evidence that the run was a fault. Order the stages so run-length classification happens before spike rejection, or carry the flag forward.


What to Do With a Confirmed Stuck Run

Detection is the easy half. The harder question is what a downstream consumer should see when a receiver has been frozen for twenty minutes, and the answer differs by consumer in a way that a single cleaning rule cannot express.

Positional consumers should see a gap. Map matching, geofencing and any distance calculation should treat a stuck run as missing data, not as a stationary vehicle. Emitting it as a stop creates a dwell event at a position the vehicle may have left long before, and the resulting record is indistinguishable from a real one.

Availability metrics should see a fault. A vehicle whose receiver was frozen was not reporting, whatever the row count says. Counting stuck fixes toward data coverage makes a fleet with failing hardware look better instrumented than one with honest gaps.

Device health should see an event. The run’s start time, duration and the vehicle’s activity during it are the whole diagnostic. Aggregated by device family and firmware version, they usually point at one batch of hardware or one release.

def apply_stuck_policy(df: pl.LazyFrame) -> pl.LazyFrame:
    """Null the positions in a confirmed stuck run, keep the rows, keep the reason."""
    stuck = pl.col("run_class") == "receiver_stuck"
    return df.with_columns(
        pl.when(stuck).then(None).otherwise(pl.col("lat")).alias("lat"),
        pl.when(stuck).then(None).otherwise(pl.col("lon")).alias("lon"),
        pl.when(stuck).then(pl.lit("receiver_stuck")).otherwise(pl.col("quality_flag")).alias("quality_flag"),
    )

Nulling the coordinates while keeping the rows is deliberate. Dropping the rows entirely would make the trace look like a coverage gap, which is a different fault with a different remedy; keeping them with a reason attached lets the availability metric and the device-health report both find what they need in the same table.

The recovery jump

The fix immediately after a stuck run deserves its own handling. The receiver resumes with the vehicle’s true current position, which may be several kilometres from the frozen one, and every displacement-based check downstream will read that single step as a teleport. Suppress the displacement test across the boundary of a stuck run rather than letting the spike detector remove the first honest fix the device has produced in twenty minutes.

One stuck run, three correct interpretations A stuck run of eighteen minutes ends with a recovery jump of 4.2 kilometres. Positional consumers should see a gap across the run and no teleport at its end; availability metrics should count the run as not reporting; device health should record one fault event with a duration. 18-minute stuck run ending in a 4.2 km recovery jump raw rows bit-identical coordinates positional view null — and no teleport flag at the seam availability 18 minutes counted as not reporting Three consumers, three answers, one set of rows — which is why the detector flags rather than deletes. Device health gets the fourth answer: one event, one device, one duration, aggregated by firmware version.