GPS Polling Strategies
Adaptive polling cadence balancing bandwidth, accuracy, and urban-canyon drift.
A fixed polling interval is the wrong tool for a collection fleet, and it fails in two directions at once. Poll every truck every second and a mid-size municipal deployment saturates the vendor’s telematics API, earns a stream of 429 Too Many Requests responses, and starts losing coordinates during exactly the morning window an auditor will later ask about. Poll every truck every ninety seconds to stay under the rate limit and a truck can travel a full block between fixes — the distance matrix built by the VRP Route Optimization Algorithms sees a straight-line jump through buildings, and a proof-of-service timestamp lands after the vehicle has already left the stop. This page builds the middle path: a cadence that speeds up where routing and compliance need resolution and slows down where they do not, bounded by a rate budget the upstream API will actually tolerate.
It sits under the Telematics & Sensor Data Ingestion framework, which requires every fix to carry a UTC-normalized timestamp, a monotonic per-asset sequence check, and an immutable ledger reference before any downstream system reads it. Polling is the pull side of that boundary — the client controls the request cadence, so the engineering problem is rate-limiting and drift rather than the idempotency problem that Bin Sensor API Sync solves on the push side. The two views are joined downstream to prove a truck was within a geofence at the moment a bin reported empty.
Prerequisites
This reference targets Python 3.10+ and treats every fix returned by a telematics vendor as untrusted until a Pydantic v2 model proves it. Pin the toolchain before wiring anything into a dispatch pipeline:
python -m pip install \
aiohttp==3.9.5 \
aiolimiter==1.1.0 \
pydantic==2.7.1 \
tenacity==8.4.1
Every fix is normalized into one strict model. The model binds the plausibility rules from Validating GPS Coordinates in Python directly into the type, so an implausible payload cannot exist as a valid object:
from datetime import datetime, timezone
from pydantic import BaseModel, Field, field_validator
# Municipal refuse trucks are governed vehicles; a fix implying >120 km/h
# between samples is drift or a replayed packet, never a real displacement.
MAX_SPEED_KMH = 120.0
class GpsFix(BaseModel):
asset_id: str = Field(min_length=1)
sequence_id: int = Field(ge=0) # strictly increasing per asset
latitude: float = Field(ge=-90.0, le=90.0)
longitude: float = Field(ge=-180.0, le=180.0)
speed_kmh: float = Field(ge=0.0, le=MAX_SPEED_KMH)
device_ts: datetime # when the unit sampled the fix
ingested_at: datetime = Field(
default_factory=lambda: datetime.now(timezone.utc)
)
@field_validator("device_ts")
@classmethod
def require_utc(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("device_ts must be timezone-aware (UTC)")
return v.astimezone(timezone.utc)
Keeping both device_ts and ingested_at is deliberate: the first proves when the truck was at a coordinate, the second proves when the pipeline learned it, and the gap between them is the drift signal that the Async Batch Processing layer uses to decide whether a burst arrived late enough to need reordering.
Core Implementation
The polling loop has three cooperating parts: a cadence function that decides when each vehicle is next due, a shared limiter that enforces the vendor’s rate budget, and an async fetcher that turns a raw response into a validated GpsFix or a quarantine record. Build them in that order.
Step 1 — Adaptive cadence from velocity and geofence state
Resolution should follow information density. A truck idling at a transfer station needs a fix every couple of minutes; a truck moving through a dense residential grid, or crossing a geofence boundary that starts or ends a service window, needs one every few seconds. Express the interval as a function of speed with a hard clamp at both ends:
from dataclasses import dataclass
@dataclass(frozen=True)
class CadencePolicy:
min_interval_s: float = 3.0 # fastest we will ever poll one asset
max_interval_s: float = 120.0 # slowest, for stationary vehicles
target_gap_m: float = 25.0 # desired ground distance between fixes
def next_interval_s(self, speed_kmh: float, geofence_edge: bool) -> float:
"""Seconds until this asset should be polled again."""
if geofence_edge:
# A boundary crossing bounds a service window; capture it tightly.
return self.min_interval_s
speed_ms = max(speed_kmh, 0.0) * 1000.0 / 3600.0
if speed_ms <= 0.1:
return self.max_interval_s
interval = self.target_gap_m / speed_ms
return max(self.min_interval_s, min(self.max_interval_s, interval))
The target_gap_m term is what keeps the cadence tied to something physical: at a fixed 25 m target gap, a truck at 40 km/h is polled roughly every 2.3 s and a truck at 8 km/h roughly every 11 s, so ground resolution stays constant while request volume tracks how fast the fleet is actually moving.
Step 2 — A token-bucket budget the vendor will tolerate
Adaptive cadence lowers total volume but says nothing about instantaneous bursts — a whole fleet leaving the depot at 6:00 a.m. can still spike past the vendor’s ceiling. A single shared token bucket across all assets converts that spike into a smooth, bounded stream. aiolimiter implements exactly this:
from aiolimiter import AsyncLimiter
# Vendor allows 600 requests/minute across the account. Stay under it.
rate_limiter = AsyncLimiter(max_rate=550, time_period=60)
Sizing the bucket below the published ceiling (550 against 600) leaves headroom for the retries and reconciliation polls that a missed stop triggers, so ordinary operation never consumes the margin that error recovery depends on. When a workflow genuinely needs to exceed HTTP-poll throughput — hazardous-waste routing, live missed-stop reconciliation — that is a different transport problem, handled by Polling GPS telematics without rate limiting with persistent connections rather than by widening this bucket.
Step 3 — Async fetch with deterministic retry and quarantine
One pooled aiohttp.ClientSession serves the whole fleet. Each fetch passes through the limiter, retries only on transient network faults, and routes anything that fails validation to a quarantine list instead of failing the batch:
import asyncio
import aiohttp
from pydantic import ValidationError
from tenacity import (
retry, stop_after_attempt, wait_exponential,
retry_if_exception_type,
)
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=0.5, max=8),
retry=retry_if_exception_type(aiohttp.ClientConnectionError),
reraise=True,
)
async def _fetch_raw(session: aiohttp.ClientSession, url: str) -> dict:
async with rate_limiter:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
if resp.status == 429:
# Honour the vendor's own backoff instruction, not a guess.
delay = float(resp.headers.get("Retry-After", "5"))
await asyncio.sleep(delay)
raise aiohttp.ClientConnectionError("rate limited, retrying")
resp.raise_for_status()
return await resp.json()
async def poll_asset(session, asset_id: str, url: str,
accepted: list, quarantine: list) -> None:
raw = await _fetch_raw(session, url)
try:
accepted.append(GpsFix(asset_id=asset_id, **raw))
except ValidationError as exc:
quarantine.append({
"asset_id": asset_id,
"raw": raw,
"errors": exc.errors(),
"compliance_code": "GPS_FIX_REJECTED",
})
async def poll_fleet(asset_urls: dict[str, str]) -> tuple[list, list]:
accepted: list[GpsFix] = []
quarantine: list[dict] = []
connector = aiohttp.TCPConnector(limit=100, limit_per_host=10)
async with aiohttp.ClientSession(connector=connector) as session:
await asyncio.gather(*(
poll_asset(session, aid, url, accepted, quarantine)
for aid, url in asset_urls.items()
))
return accepted, quarantine
Retry is restricted to ClientConnectionError on purpose: a 400 or 404 is a permanent contract fault that must surface immediately, and retrying it only burns the rate budget. Explicit TCPConnector(limit=100, limit_per_host=10) bounds keep a large fleet from exhausting sockets on a municipal gateway.
Step 4 — Reject physically impossible displacement
Bounds-checking a single coordinate is not enough; drift shows up as a pair of individually-valid fixes that imply an impossible speed. Compute the great-circle distance between the last accepted fix and the new one, divide by the elapsed time, and reject anything above the governed ceiling. The displacement is the Haversine distance:
from math import radians, sin, cos, asin, sqrt
EARTH_RADIUS_M = 6_371_000.0
def haversine_m(lat1, lon1, lat2, lon2) -> float:
p1, p2 = radians(lat1), radians(lat2)
dphi = radians(lat2 - lat1)
dlam = radians(lon2 - lon1)
a = sin(dphi / 2) ** 2 + cos(p1) * cos(p2) * sin(dlam / 2) ** 2
return 2 * EARTH_RADIUS_M * asin(sqrt(a))
def implied_speed_kmh(prev: GpsFix, curr: GpsFix) -> float:
dt_s = (curr.device_ts - prev.device_ts).total_seconds()
if dt_s <= 0:
return float("inf") # non-monotonic time is itself a rejection
metres = haversine_m(prev.latitude, prev.longitude,
curr.latitude, curr.longitude)
return (metres / dt_s) * 3.6
def is_plausible(prev: GpsFix | None, curr: GpsFix) -> bool:
if prev is None:
return True
return implied_speed_kmh(prev, curr) <= MAX_SPEED_KMH
A fix that fails is_plausible goes to the same quarantine list as a schema failure, tagged with a distinct compliance code, so an urban-canyon multipath jump never poisons the routing graph or a chain-of-custody record.
Regulatory Mapping
Each polling parameter maps to a specific rule, and the mapping is what makes the audit trail defensible rather than merely plausible. Record these citations alongside the code so an operator can trace any threshold back to its source. The full driver-hours side is developed in the DOT / FMCSA Rule Mapping reference.
- ELD position accuracy and sampling — 49 CFR 395.26 and 49 CFR Part 395 Subpart B, Appendix A. The electronic logging device rule specifies the data elements an ELD records at each change of duty status and requires position captured to the nearest tenth of a mile during on-duty driving. The
min_interval_sclamp and the geofence-edge fast path exist to guarantee a fix is captured at every duty-status boundary, and the UTC-awaredevice_tsvalidator satisfies the rule’s timestamp-synchronization requirement. - Hours-of-service window — 49 CFR 395.3 and 395.8. Every accepted fix is cross-referenced against the driver’s shift schedule so on-duty driving time is reconstructable from position history. A fix whose
device_tsfalls outside the active shift is quarantined, because attributing driving time to an off-shift interval corrupts the HOS record. - Manifest chain of custody — 40 CFR 262.20–262.27 (Uniform Hazardous Waste Manifest) and the EPA e-Manifest system (40 CFR 264.71). Geofence entry and exit flags attached to each coordinate batch establish that a vehicle was physically present at a generator or disposal facility for the times an electronic manifest asserts, linking GPS evidence to EPA Form 8700-22.
- Record retention — 49 CFR 395.8(k). Supporting documents and ELD records must be retained for six months. Accepted fixes and their quarantine counterparts are written to append-only storage before acknowledgment, so a fix that was received but not durably logged is treated as a compliance gap, not a success.
Validation & Verification
Confirm the cadence and plausibility logic fire correctly before trusting either in production. The two properties worth asserting are that a stationary vehicle backs off to the ceiling while a moving one does not, and that a teleport is rejected:
from datetime import datetime, timedelta, timezone
def test_cadence_tracks_speed():
policy = CadencePolicy()
# Stationary -> slowest cadence.
assert policy.next_interval_s(0.0, geofence_edge=False) == policy.max_interval_s
# Moving -> tighter than a crawling truck, and clamped, never zero.
fast = policy.next_interval_s(40.0, geofence_edge=False)
slow = policy.next_interval_s(8.0, geofence_edge=False)
assert policy.min_interval_s <= fast < slow <= policy.max_interval_s
# A geofence edge always forces the tightest cadence.
assert policy.next_interval_s(60.0, geofence_edge=True) == policy.min_interval_s
def test_rejects_impossible_jump():
t0 = datetime(2026, 7, 2, 6, 0, 0, tzinfo=timezone.utc)
base = dict(asset_id="TRK-14", speed_kmh=30.0)
prev = GpsFix(sequence_id=1, latitude=40.7128, longitude=-74.0060,
device_ts=t0, **base)
# ~8 km away one second later -> ~28,000 km/h implied. Reject.
teleport = GpsFix(sequence_id=2, latitude=40.7840, longitude=-74.0060,
device_ts=t0 + timedelta(seconds=1), **base)
assert is_plausible(prev, prev) is True
assert is_plausible(prev, teleport) is False
On the observability side, assert on the structured log fields the pipeline emits — a GPS_FIX_REJECTED count that climbs for one asset_id is a failing telematics unit, not random noise. Emit those records with the site-wide JSONFormatter so every rejection lands as one append-only JSON object keyed by asset_id, sequence_id, and compliance_code.
Failure Modes & Edge Cases
- Sustained 429 despite the budget. If the vendor keeps rate-limiting even under the 550/minute bucket, the ceiling has moved or a second worker is sharing the account. The
Retry-Afterbackoff prevents a tight retry storm, but the durable fix is to widen the cadence floor (max_interval_s) fleet-wide and alert, so ingestion degrades gracefully instead of collapsing. When a unit trips its retry limit three times, isolate it and fall back to the last known good fix rather than blocking the wholeasyncio.gather. - A vehicle goes dark mid-window. A truck that stops reporting is not a truck that stopped working. Rather than interpolate a phantom position, hand the gap to the Fallback Routing Logic layer, which substitutes a deterministic last-known-good position and flags the interval so it is never mistaken for measured evidence.
- Sequence reset after a firmware update. A device that reboots its
sequence_idto zero looks like a flood of stale packets. Detect a reset (a large negative jump in sequence for an asset) and rebase, instead of quarantining every post-reboot fix as non-monotonic. - Clock skew masquerading as speeding. A unit whose clock runs fast produces a short
dt_sand an inflated implied speed, trippingis_plausibleon legitimate fixes. Cross-checkingested_at - device_ts; when that offset is large and stable, the problem is the clock, and the fix should be reconciled by the ingestion layer rather than discarded. - Morning-dispatch burst. The whole fleet keys on at once. Bound the intake queue and let Async Batch Processing absorb the spike with a disk-spill path, so a burst that outruns the writer never drops fixes on the floor.
Integration Checklist
FAQ
Why adapt the interval instead of just polling as fast as the limit allows?
Polling every asset at the maximum rate wastes the rate budget on stationary trucks and leaves no headroom for the retries and reconciliation polls that missed stops trigger. Tying cadence to target_gap_m keeps ground resolution constant where routing and compliance need it, then spends the freed capacity on the vehicles that are actually moving — the same number of requests buys far more useful coverage.
Should the token bucket be per-vehicle or shared?
Shared across the account. Vendor rate limits are almost always account-wide, so a per-vehicle bucket cannot see the fleet-level burst that a per-account bucket smooths. One AsyncLimiter in front of every request is what converts a 6:00 a.m. depot spike into a bounded stream instead of a wall of 429s.
How is GPS polling different from bin sensor sync?
They are mirror images. GPS polling is a pull the client paces, so the levers are cadence and rate-limiting and the enemy is drift. Bin Sensor API Sync mostly receives pushed or buffered fill events the client cannot pace, so the enemy is duplicate and out-of-order arrival, handled with content-hash idempotency. The two are joined downstream to prove a truck was near a bin at the moment it reported empty.
What stops GPS drift from corrupting the route?
A per-coordinate bounds check passes two individually-valid fixes that together imply an impossible speed. Computing the Haversine displacement between the previous accepted fix and the new one, then rejecting anything above the governed 120 km/h ceiling, catches the urban-canyon multipath jumps that bounds-checking alone lets through. Rejected fixes are quarantined, not silently smoothed.
Why keep two timestamps on every fix?
device_ts is when the unit sampled the position; ingested_at is when the pipeline received it. Keeping both lets an auditor prove ordering even when a device clock is wrong, and lets the pipeline distinguish real speeding from clock skew — a large, stable offset between the two points at a bad clock, not a fast truck.
Up: Telematics & Sensor Data Ingestion
Related
- Polling GPS telematics without rate limiting — persistent-connection transport for workflows that must exceed HTTP-poll throughput.
- Validating GPS Coordinates in Python — the bounds and displacement rules bound into the fix model here.
- Bin Sensor API Sync — the push-side mirror of polling, with content-hash idempotency and clock-skew reconciliation.
- Async Batch Processing — bounded queues and chunked writes that absorb the morning-dispatch burst.
- Fallback Routing Logic — deterministic degradation when a vehicle goes dark mid-window.