Validating GPS Coordinates in Python for Waste Fleet Telematics
Bounding-box and Haversine checks that catch drift and spoofed coordinates at ingest.
This page solves one specific production failure: a multipath GPS spike or a low-accuracy fix slips into the coordinate stream, and the routing solver silently rebuilds an impossible route from a location the truck was never at.
Municipal refuse vehicles report position under canopy cover, in dense urban corridors, and through intermittent satellite lock, so the raw stream carries multipath reflection, zero-velocity drift, and HDOP degradation. Validating GPS coordinates in Python means enforcing deterministic physical bounds — WGS84 range, positional accuracy, and kinematic plausibility — before any downstream engine trusts a fix. This validator is the kinematic pre-filter within the broader Schema Validation Pipelines contract: it rejects points a vehicle could not physically have reported, so the later Pydantic type-and-containment layer only ever sees candidates that already obey physics. It runs at the front of the Telematics & Sensor Data Ingestion framework, immediately after a fix arrives from the polling layer.
Environment & Data Prerequisites
The validator itself uses only the Python 3.10+ standard library — math, logging, json, dataclasses, and datetime — so nothing pins it to a vendor SDK. The only third-party dependency is pytest, and only for the verification step:
python -m pip install pytest==8.2.0
Each inbound fix is one flat record decoded at the edge from the telematics gateway. The validator reasons over exactly these fields:
| Field | Unit / type | Range / rule |
|---|---|---|
lat |
float, degrees | −90.0 … 90.0 (WGS84) |
lon |
float, degrees | −180.0 … 180.0 (WGS84) |
accuracy |
float, metres (CEP) | 0 ≤ a ≤ 50.0; larger is quarantined |
ts |
ISO 8601 string | tz-aware UTC, strictly increasing per vehicle |
Two rules matter before any code. Accuracy is a hard gate, not a hint: a fix reporting more than 50 m of circular error probable is unusable for curb-level service verification and is quarantined, never rounded. And timestamps must be timezone-aware UTC — a naive datetime will raise the moment you subtract two fixes to compute velocity, which is exactly the bug the validation pipeline’s temporal gate exists to catch downstream.
Step-by-Step Implementation
Step 1 — Structured audit logging
Every accept and reject decision must be machine-readable and replayable, so decisions are emitted as single-line JSON through a custom formatter rather than free text. This is the same JSONFormatter pattern used across the ingestion framework, so audit records from validation, sync, and polling all parse with one reader.
import math
import json
import logging
from dataclasses import dataclass, asdict
from typing import Optional
from datetime import datetime, timezone
class JSONFormatter(logging.Formatter):
"""Emit each log record as a single-line JSON object for audit ingestion."""
def format(self, record: logging.LogRecord) -> str:
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"event": record.getMessage(),
"payload": getattr(record, "payload", None),
}
return json.dumps(entry)
audit_logger = logging.getLogger("waste_ops.gps_validator")
if not audit_logger.handlers:
_handler = logging.StreamHandler()
_handler.setFormatter(JSONFormatter())
audit_logger.addHandler(_handler)
audit_logger.setLevel(logging.INFO)
Step 2 — The immutable result contract
Each decision returns a frozen dataclass carrying the outcome, the coordinate, and — critically — the validated timestamp. Storing the timestamp on the result lets the caller chain sequential validations without a separate prev_state dictionary: the previous accepted result is the state the velocity gate needs.
@dataclass(frozen=True)
class GPSValidationResult:
is_valid: bool
lat: float
lon: float
accuracy_m: float
timestamp: datetime # carried so callers can chain velocity checks
rejection_reason: Optional[str] = None
Step 3 — Bounds, accuracy, and the velocity gate
The validator runs three ordered checks. WGS84 range and accuracy are stateless. The velocity gate is the one that catches multipath: it computes the great-circle distance between this fix and the last accepted one, divides by elapsed time, and rejects anything faster than a heavy refuse vehicle can physically travel. The distance uses the Haversine formula,
where is latitude, is longitude in radians, and is the mean Earth radius.
class CoordinateValidator:
MIN_LAT, MAX_LAT = -90.0, 90.0
MIN_LON, MAX_LON = -180.0, 180.0
MAX_ACCURACY_M = 50.0 # CEP threshold for curb-level service verification
MAX_VELOCITY_KMH = 120.0 # hard kinematic cap for heavy refuse vehicles
def validate(
self,
lat: float,
lon: float,
accuracy: float,
timestamp: datetime,
prev_result: Optional[GPSValidationResult] = None,
) -> GPSValidationResult:
# 1. WGS84 bounds
if not (self.MIN_LAT <= lat <= self.MAX_LAT):
return self._reject(lat, lon, accuracy, timestamp, "latitude out of WGS84 bounds")
if not (self.MIN_LON <= lon <= self.MAX_LON):
return self._reject(lat, lon, accuracy, timestamp, "longitude out of WGS84 bounds")
# 2. Positional accuracy
if accuracy > self.MAX_ACCURACY_M:
return self._reject(lat, lon, accuracy, timestamp,
f"accuracy {accuracy:.1f} m exceeds {self.MAX_ACCURACY_M} m threshold")
# 3. Velocity spike (requires a previous accepted fix)
if prev_result is not None:
dt_seconds = (timestamp - prev_result.timestamp).total_seconds()
if dt_seconds <= 0:
return self._reject(lat, lon, accuracy, timestamp, "non-monotonic timestamp sequence")
dist_km = self._haversine(prev_result.lat, prev_result.lon, lat, lon)
velocity_kmh = (dist_km / dt_seconds) * 3600.0
if velocity_kmh > self.MAX_VELOCITY_KMH:
return self._reject(
lat, lon, accuracy, timestamp,
f"velocity spike {velocity_kmh:.1f} km/h exceeds {self.MAX_VELOCITY_KMH} km/h cap",
)
return GPSValidationResult(True, lat, lon, accuracy, timestamp)
def _reject(self, lat, lon, accuracy, timestamp, reason: str) -> GPSValidationResult:
audit_logger.warning(
"coordinate_rejected",
extra={"payload": {"lat": lat, "lon": lon, "accuracy_m": accuracy, "reason": reason}},
)
return GPSValidationResult(False, lat, lon, accuracy, timestamp, rejection_reason=reason)
@staticmethod
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
R = 6371.0
dlat, dlon = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
a = math.sin(dlat / 2) ** 2 + math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) * math.sin(dlon / 2) ** 2
return R * 2 * math.asin(math.sqrt(a))
Range-checking a coordinate only proves it is on Earth, not that it is inside a serviceable collection zone. That containment check — point-in-polygon against a municipal boundary — is deliberately not this validator’s job; it is delegated to the geospatial gate in the Schema Validation Pipelines layer that runs after physics has been proven.
Step 4 — Run it over a telemetry stream
The validator walks a stream in order, carrying the last accepted result forward as the velocity-gate state. Only accepted fixes advance that state, so a quarantined spike never becomes the baseline for the next comparison.
if __name__ == "__main__":
validator = CoordinateValidator()
telemetry_stream = [
{"lat": 40.7021, "lon": -74.0150, "accuracy": 8.2, "ts": "2024-05-12T08:00:00Z"},
{"lat": 40.7025, "lon": -74.0145, "accuracy": 9.1, "ts": "2024-05-12T08:00:15Z"},
{"lat": 40.7150, "lon": -74.0010, "accuracy": 12.5, "ts": "2024-05-12T08:00:30Z"}, # multipath jump
{"lat": 40.7028, "lon": -74.0140, "accuracy": 55.0, "ts": "2024-05-12T08:00:45Z"}, # HDOP degradation
]
prev_result: Optional[GPSValidationResult] = None
for pkt in telemetry_stream:
ts = datetime.fromisoformat(pkt["ts"].replace("Z", "+00:00"))
result = validator.validate(
lat=pkt["lat"], lon=pkt["lon"], accuracy=pkt["accuracy"],
timestamp=ts, prev_result=prev_result,
)
if result.is_valid:
prev_result = result # only accepted fixes advance the velocity baseline
audit_logger.info("coordinate_accepted", extra={"payload": asdict(result)})
# rejected fixes are held for dead-reckoning fallback; they never advance state
At sustained polling densities this loop runs in sub-millisecond time per packet, so it drops cleanly in front of the async batch processing layer that fans validated fixes out to the solver.
Compliance Output
The validator emits newline-delimited JSON — one object per decision — to an append-only audit stream. From the four-packet run above, the two accepts and two rejects serialize as:
{"ts": "2024-05-12T08:01:00.123456+00:00", "level": "INFO", "event": "coordinate_accepted", "payload": {"is_valid": true, "lat": 40.7021, "lon": -74.015, "accuracy_m": 8.2, "timestamp": "2024-05-12T08:00:00+00:00", "rejection_reason": null}}
{"ts": "2024-05-12T08:01:00.123789+00:00", "level": "INFO", "event": "coordinate_accepted", "payload": {"is_valid": true, "lat": 40.7025, "lon": -74.0145, "accuracy_m": 9.1, "timestamp": "2024-05-12T08:00:15+00:00", "rejection_reason": null}}
{"ts": "2024-05-12T08:01:00.124012+00:00", "level": "WARNING", "event": "coordinate_rejected", "payload": {"lat": 40.715, "lon": -74.001, "accuracy_m": 12.5, "reason": "velocity spike 142.3 km/h exceeds 120.0 km/h cap"}}
{"ts": "2024-05-12T08:01:00.124255+00:00", "level": "WARNING", "event": "coordinate_rejected", "payload": {"lat": 40.7028, "lon": -74.014, "accuracy_m": 55.0, "reason": "accuracy 55.0 m exceeds 50.0 m threshold"}}
Each field earns its place in the audit trail:
ts— wall-clock time the decision was made, distinct from the fix’s owntimestamp, so a replay can be ordered by processing time.event—coordinate_acceptedorcoordinate_rejected; a spike in the reject rate per reason is the primary health signal for the receiver fleet.payload.timestamp— the fix’s tz-aware UTC event time. Under the Hours-of-Service electronic-logging rule (49 CFR Part 395), position-time pairs on a driver log must be ordered and cannot be back- or future-dated; the non-monotonic reject enforces exactly that ordering.payload.accuracy_m— the reported CEP. Retaining it on every rejection substantiates why a curb-level service event was not recorded, which municipal collection ordinances require for franchise-boundary and diversion reporting.payload.reason— the machine-parseable rejection cause. Downstream, the DOT/FMCSA rule mapping reference resolves a velocity-spike or non-monotonic reason into the regulatory field an auditor reads, and a full malformed batch signals the fallback routing logic to hold the last-known-good route rather than treat “zero valid fixes” as “no work.”
Because the ledger is append-only and every rejection preserves the original coordinate, the trail stays tamper-evident and truthful — no clamped or fabricated positions ever enter it.
Verification
The tests below use pytest and prove each gate fires on the exact scenario it exists to catch, and that a clean fix passes untouched.
from datetime import timedelta
BASE = datetime(2024, 5, 12, 8, 0, 0, tzinfo=timezone.utc)
def test_clean_fix_is_accepted():
v = CoordinateValidator()
assert v.validate(40.7021, -74.0150, 8.2, BASE).is_valid
def test_low_accuracy_is_rejected():
v = CoordinateValidator()
result = v.validate(40.7021, -74.0150, 55.0, BASE)
assert not result.is_valid
assert "accuracy" in result.rejection_reason
def test_velocity_spike_is_rejected():
v = CoordinateValidator()
first = v.validate(40.7025, -74.0145, 9.1, BASE)
# ~1.4 km in 15 s ≈ 340 km/h — impossible for a refuse truck
jump = v.validate(40.7150, -74.0010, 12.5, BASE + timedelta(seconds=15), prev_result=first)
assert not jump.is_valid
assert "velocity spike" in jump.rejection_reason
def test_non_monotonic_timestamp_is_rejected():
v = CoordinateValidator()
first = v.validate(40.7025, -74.0145, 9.1, BASE)
stale = v.validate(40.7026, -74.0146, 9.0, BASE - timedelta(seconds=5), prev_result=first)
assert not stale.is_valid
assert "non-monotonic" in stale.rejection_reason
Common Errors
TypeError: can't subtract offset-naive and offset-aware datetimes — the velocity gate subtracts two datetime objects, and it raises the instant one of them is naive. Root cause: parsing an ISO string without preserving the zone, e.g. datetime.fromisoformat("2024-05-12T08:00:00"). Fix: normalize every fix to tz-aware UTC at ingestion, as Step 4 does with .replace("Z", "+00:00"), and reject naive timestamps outright rather than assuming a zone.
Every second fix is rejected as a velocity spike. Root cause: advancing prev_result with rejected fixes, so a single multipath jump becomes the baseline and the next legitimate fix looks like a spike back. Fix: only assign prev_result = result inside the if result.is_valid branch, exactly as Step 4 does — quarantined fixes must never advance the kinematic state.
ValueError: Invalid isoformat string on Python 3.10. Root cause: older 3.10 datetime.fromisoformat will not parse a trailing Z. Fix: replace Z with +00:00 before parsing (Step 4), or upgrade to Python 3.11+ where fromisoformat accepts Z directly. Do not paper over it with a naive parse, or you will resurface the first error above.
FAQ
Why quarantine a low-accuracy fix instead of using it anyway?
A fix with 55 m of circular error probable cannot confirm which side of a street a truck serviced, so accepting it writes an unverifiable location into the compliance record. Quarantine preserves the original reading for diagnosis and lets the fallback logic dead-reckon from the last trustworthy fix, keeping the audit trail defensible rather than optimistic.
Why does the velocity gate need the previous result at all?
Velocity is a property of two points and the time between them — a single fix cannot know whether it represents a plausible move. Carrying the last accepted GPSValidationResult gives the gate the baseline it needs without any external state store, and because only accepted fixes advance that baseline, one multipath jump cannot poison the comparison for the fix that follows it.
How does this validator relate to the Pydantic schema pipeline?
They compose in order. This validator is the kinematic pre-filter: it rejects impossible velocities, out-of-range coordinates, and low-accuracy fixes using physics alone. The Schema Validation Pipelines layer runs after, proving the surviving fix is well-typed, inside a serviceable polygon, and temporally ordered against the rest of the record. One filters physics; the other enforces schema and jurisdiction.
Up: Schema Validation Pipelines
Related
- Schema Validation Pipelines — the Pydantic contract and geospatial containment layer that runs after this kinematic pre-filter.
- Parsing IoT Fill-Level Sensor Payloads — decoding the vendor frames these coordinates arrive in.
- Polling GPS Telematics Without Rate Limiting — the ingestion layer that feeds fixes into this validator.
- Async Batch Processing — bounded fan-out of validated fixes to the routing solver.
- DOT/FMCSA Rule Mapping — the regulatory codes each rejection reason resolves to.