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.
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.
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.
Related
- Outlier removal in raw telematics streams — parent topic and the full defect taxonomy
- Deduplicating repeated GPS fixes from buffered trackers — the other source of repeated rows, which must be removed first
- Detecting engine idle versus true stops with variance windows — the same non-positional evidence used for a different question
- Automating outlier detection in high-frequency telematics data — where this detector fits in the automated pass
- GPS Data Preprocessing & Cleaning Fundamentals — parent section