Routing Engine Integration for Fleet Telematics
A cleaned GPS trace is still just a list of coordinates floating near a road network. It does not know which street the vehicle drove, how far it travelled along that street, whether a turn was legal, or how long the segment should have taken. Answering those questions is the job of a routing engine — a service that holds a compiled representation of the road graph and exposes it through map-matching and route-planning APIs. Every operational metric a fleet platform reports downstream, from billed mileage to on-time-delivery rates to fuel-per-kilometre models, ultimately traces back to how faithfully raw pings were snapped onto real roads.
This guide covers the full integration surface for the three dominant open-source engines — OSRM, Valhalla, and GraphHopper — from a Python telematics perspective. It sets out where routing sits in the pipeline, the trade-offs that decide which engine you should run, the Python stack that glues pings to the HTTP API, and the production concerns — coordinate order, rate limits, caching, self-hosting, memory, and validation — that separate a demo from a system that survives a fleet of ten thousand vehicles. Each engine and the selection process itself are covered in depth on their own pages; this page is the map to all of them.
Why Fleets Need a Routing Engine
The failure modes of skipping map matching are concrete, not theoretical.
Snapping noisy GPS to roads. Even after Kalman filtering for GPS noise reduction, a fix can sit 5–15 metres from the true road centreline, and in urban canyons far worse. A routing engine’s map-matching service resolves each observation to the most probable edge in the road graph, producing a continuous driven path instead of a scattered point cloud. That path is what makes “the vehicle used the toll road” a defensible statement rather than a guess.
Accurate distances and ETAs. Straight-line (haversine) distance between consecutive pings systematically under-reports real driven distance because roads curve and pings are sparse. Matched geometry follows the actual carriageway, so segment distances reconcile with odometer readings. The same graph yields time estimates from stored or live edge speeds, which feed ETA prediction and schedule adherence.
Turn restrictions and legal routing. A road graph encodes one-way streets, banned turns, height and weight limits, and access restrictions. Without the engine, a naive nearest-edge snap will happily route a 40-tonne truck the wrong way down a one-way street or through a 3.5-tonne-limited bridge. Matching against a restriction-aware graph keeps reconstructed routes physically and legally plausible.
Mileage and compliance integrity. Billed mileage, driver pay, road-user charging, and tachograph reconciliation all depend on distance figures that survive an audit. Matched distance derived from a documented engine version and map extract is auditable; a sum of haversine hops is not.
Where Routing Sits in the Telematics Flow
Routing and map matching occupy the middle of the pipeline: after the signal has been cleaned and before analytics consume the result. Nothing downstream can recover road context that the matching stage failed to establish.
The routing stage is a strict consumer of preprocessing quality. If the trace still contains coordinate jumps, unsynchronised timestamps, or a mixed coordinate reference system, the engine will match confidently onto the wrong roads and report a plausible-looking distance that is quietly wrong. That is why GPS data preprocessing and cleaning is a hard prerequisite for anything on this page: the engine trusts the timestamps and positions you send it. Downstream, the matched geometry and its per-edge attributes feed stop detection and dwell-time analytics, speed profiling from raw GPS coordinates, and every mileage or compliance report the platform emits.
It is worth separating two related but distinct problems. The broader discipline of trajectory analysis and map matching covers the algorithms — geometric snapping, hidden Markov model map matching, and Viterbi decoding — that you might implement yourself. This section is about integrating an existing engine that already implements those algorithms behind an HTTP API, so your Python code sends a trace and consumes a result rather than decoding the graph by hand.
The Three Engines: A Survey
All three engines compile OpenStreetMap data into a routable graph and expose HTTP APIs, but they were designed around different priorities.
OSRM (Open Source Routing Machine) is a C++ engine optimised for raw speed. It precomputes Contraction Hierarchies (CH) or Multi-Level Dijkstra (MLD) so that route queries return in well under a millisecond on continental extracts. Its /match service implements map matching over the same graph. OSRM’s design point is high throughput on a fixed profile: you bake the vehicle profile into the preprocessing step, so changing costing means re-running the pipeline. It is the natural choice when you need to match millions of pings per night and your fleet is homogeneous. See OSRM integration for fleet map matching for the request format and self-hosting details.
Valhalla is a tiled, C++ engine built by Mapbox and now community-maintained. Its map-matching component, Meili, implements a hidden Markov model matcher with a Viterbi decoder. Valhalla’s distinguishing feature is dynamic costing: you pass costing options at request time, so the same running server can route a truck, a car, and a bicycle without re-preprocessing. Its tiled graph keeps the memory footprint modest and supports live traffic overlays. The /trace_attributes endpoint returns rich per-edge attributes — road class, speed, names, one-way flags — which is why it is favoured for detailed telematics reconstruction. See Valhalla Meili map matching for telematics.
GraphHopper is a Java engine with a mature map-matching module also built on a hidden Markov model. It offers both Contraction Hierarchies for speed and flexible mode for dynamic, per-request costing, and it has strong turn-restriction and turn-cost handling. Its Java stack integrates cleanly into JVM platforms, and the map-matching library is well documented. See GraphHopper map matching for fleet routing.
The table below summarises the trade-offs that actually drive the decision. The dedicated guide to choosing a routing engine works through them against a concrete fleet profile.
| Dimension | OSRM | Valhalla (Meili) | GraphHopper |
|---|---|---|---|
| Map-matching algorithm | Geometric + HMM via match plugin | HMM + Viterbi (Meili) | HMM + Viterbi |
| Route latency (continental) | Sub-millisecond (CH) | Low, tiled | Low (CH) to moderate (flexible) |
| Dynamic costing | No — profile baked at preprocess | Yes — per request | Yes — flexible mode |
| Turn restrictions | Yes | Yes | Yes, strong turn costs |
| Live traffic | Via custom speed updates | Native overlay support | Supported (flexible) |
| Memory (planet) | High (CH ~120 GB+) | Moderate (tiled) | Moderate to high |
| Per-edge attributes | Basic (annotations) | Rich (/trace_attributes) |
Rich (match details) |
| Auditability | High — deterministic | High | High |
| Self-host difficulty | Moderate (multi-step build) | Moderate | Moderate (JVM) |
| Language | C++ | C++ | Java |
No engine is universally best. OSRM wins on throughput for a fixed profile, Valhalla on attribute richness and multi-modal flexibility with a lean footprint, GraphHopper on turn-cost fidelity and JVM integration.
The Python Integration Stack
None of these engines is a Python library — each is a server you talk to over HTTP. The Python side is therefore a thin client-and-glue layer, and the library choices are consistent across all three engines.
| Library | Role in routing integration | When to reach for it |
|---|---|---|
httpx |
Async-capable HTTP client for the engine API | Concurrent batch matching; connection pooling |
requests |
Synchronous HTTP client | Simple scripts, one trace at a time |
polyline |
Decode/encode Google-encoded polyline geometry | OSRM and Valhalla return encoded polylines |
numpy |
Vectorised coordinate and distance arithmetic | Radius derivation, distance reconciliation |
shapely |
Geometry objects from matched paths | Building LineStrings, length, intersection |
pyproj |
CRS transforms for metric distance checks | Validating matched distance in metres |
osmnx |
Download and build OSM graphs for local prep | Small custom extracts, sanity graphs, testing |
docker |
Run and manage self-hosted engine containers | Reproducible self-host deployments |
tenacity |
Retry with backoff around API calls | Transient failures, rate-limit recovery |
Install the client stack with:
pip install httpx requests polyline numpy shapely pyproj osmnx tenacity
osmnx deserves a note: it does not replace a routing engine, but it is invaluable for preparing small custom extracts, building a reference graph to validate matches against, and prototyping before you commit to a full self-host build. For production matching, the compiled engine is always faster and restriction-aware in ways a raw NetworkX graph is not.
Architecture Blueprint
The integration follows the same shape regardless of engine: a cleaned trace goes in, one HTTP call fans out to the engine, and matched geometry plus attributes come back to be rejoined and stored.
The cache-and-rate-limit layer above the engine is not optional at fleet scale. Repeated routes (a depot loop driven daily) should be matched once and reused, and a token bucket in front of a hosted API prevents a nightly batch from tripping a provider’s throttle. Both concerns are developed in handling routing engine rate limits with circuit breakers.
Key Implementation Snippets
The three engines differ mostly in URL shape and payload keys. The following minimal calls show the coordinate-order convention and response decoding for each.
Calling OSRM /match
OSRM takes coordinates in the URL path as lon,lat pairs and returns matched geometry as an encoded polyline by default.
import httpx
import polyline
def osrm_match(trace, timestamps, radiuses, base_url="http://localhost:5000"):
"""
Snap an ordered GPS trace to roads with the OSRM /match service.
Parameters
----------
trace : list of (lon, lat) tuples — NOTE longitude first
timestamps : list of int UNIX seconds, one per coordinate, ascending
radiuses : list of float metres — per-point search radius (3-sigma
position uncertainty is a good source for this value)
base_url : OSRM HTTP server root
"""
coords = ";".join(f"{lon:.6f},{lat:.6f}" for lon, lat in trace)
params = {
"geometries": "polyline",
"overview": "full",
"timestamps": ";".join(str(t) for t in timestamps),
"radiuses": ";".join(f"{r:.1f}" for r in radiuses),
"annotations": "true", # return per-edge distance/duration/nodes
}
url = f"{base_url}/match/v1/driving/{coords}"
resp = httpx.get(url, params=params, timeout=30.0)
resp.raise_for_status()
data = resp.json()
matchings = data.get("matchings", [])
if not data.get("code") == "Ok" or not matchings:
raise RuntimeError(f"OSRM match failed: {data.get('code')}")
best = matchings[0]
geometry = polyline.decode(best["geometry"]) # [(lat, lon), ...]
return {
"geometry": geometry,
"distance_m": best["distance"],
"confidence": best["confidence"],
}
The confidence field (0–1) is OSRM’s own estimate of match quality and is the primary signal to alert on. The radiuses parameter is where preprocessing pays off: derive it from the Kalman posterior covariance so clean fixes snap tightly and uncertain ones are given slack.
Calling Valhalla /trace_attributes
Valhalla’s /trace_attributes returns per-edge attributes for a matched trace and, importantly, accepts costing options at request time.
import httpx
import polyline
def valhalla_trace_attributes(trace, base_url="http://localhost:8002", costing="truck"):
"""
Map-match a trace and return per-edge attributes via Valhalla Meili.
trace : list of (lat, lon) dicts are built below in lat/lon order,
which is Valhalla's JSON convention (note: JSON body, not URL)
costing : 'auto', 'truck', 'bicycle', 'pedestrian' — chosen per request
"""
shape = [{"lat": lat, "lon": lon} for lat, lon in trace]
body = {
"shape": shape,
"costing": costing,
"shape_match": "map_snap", # full HMM map matching
"filters": {
"attributes": ["edge.names", "edge.length", "edge.speed",
"edge.road_class", "edge.way_id"],
"action": "include",
},
}
resp = httpx.post(f"{base_url}/trace_attributes", json=body, timeout=30.0)
resp.raise_for_status()
data = resp.json()
edges = data.get("edges", [])
matched_geometry = polyline.decode(data["shape"], precision=6)
total_m = sum(e.get("length", 0.0) for e in edges) * 1000.0 # km -> m
return {"geometry": matched_geometry, "edges": edges, "distance_m": total_m}
Note the two coordinate-order conventions in play: Valhalla’s JSON body uses named lat/lon keys (order-safe), while its polyline output uses precision 6, not the default 5 that OSRM uses. Decoding with the wrong precision silently shifts every point by a factor of ten.
Calling GraphHopper /match
GraphHopper’s map-matching endpoint accepts a GPX-like point list and returns matched geometry plus per-edge details.
import httpx
def graphhopper_match(trace, base_url="http://localhost:8989", profile="truck"):
"""
Map-match a trace with the GraphHopper /match endpoint.
trace : list of (lon, lat, unix_millis) — GeoJSON-style lon/lat order
profile : must match a profile compiled into the GraphHopper config
"""
body = {
"points": [[lon, lat] for lon, lat, _ in trace],
"timestamps": [ts for _, _, ts in trace],
"profile": profile,
"points_encoded": False, # return raw coordinates, not encoded
}
params = {"profile": profile}
resp = httpx.post(f"{base_url}/match", params=params, json=body, timeout=30.0)
resp.raise_for_status()
data = resp.json()
path = data["paths"][0]
coords = path["points"]["coordinates"] # [[lon, lat], ...]
return {
"geometry": [(lat, lon) for lon, lat in coords],
"distance_m": path["distance"],
"map_matching_error_m": data.get("map_matching", {}).get("distance", None),
}
Setting points_encoded=False trades a larger response payload for the convenience of raw coordinates, which removes one decoding step and one class of precision bug. For high-volume batch jobs, leave encoding on and decode with polyline to cut bandwidth.
Production Considerations
Coordinate order is the number-one integration bug
Every engine on this page expects [longitude, latitude] for positional payloads, the reverse of the [latitude, longitude] order emitted by most GPS hardware and used by scikit-learn’s haversine metric. A swap produces a request at the transposed location with no error — the engine matches confidently onto whatever road happens to be there. Normalise to [lon, lat] at exactly one boundary in your code, assert it with a bounding-box check against your operational region, and never re-order again. This single discipline eliminates the most common and most expensive class of routing bug.
Rate limits and batching
Hosted APIs meter requests; self-hosted engines have finite CPU. Either way, one HTTP call per ping is the wrong granularity. Group pings into trips and send each trip as a single /match call — the engine’s HMM decoder is more accurate on a full trace than on fragments anyway. Split a trace only at genuine signal gaps (the same gaps flagged during timestamp synchronisation), so a tunnel does not corrupt an otherwise clean match. When calling a hosted API, put a token-bucket limiter and a circuit breaker in front of it, as developed in handling routing engine rate limits with circuit breakers.
Caching matched results
Fleets are repetitive: the same depot loops, trunk routes, and last-mile rounds recur daily. Hash the input trace (rounded coordinates plus ordered timestamps) and cache the matched geometry keyed by that hash. A daily batch for a fleet on fixed routes can see cache hit rates above 60%, cutting engine load and cost proportionally. Store the engine version and map-extract date alongside the cached result so a map update correctly invalidates stale matches.
Self-host vs hosted API
Run your own engine in Docker when volume is high, when vehicle locations cannot leave your infrastructure for privacy or contractual reasons, or when you need a custom map or profile. Use a hosted API when volume is low, when you are prototyping, or when you lack the operations capacity to maintain the tile-build pipeline. The break-even is usually a few hundred thousand requests per month; below it, hosted is cheaper once you price in engineering time. Engine-specific self-host recipes live on each engine’s page — for example self-hosting OSRM with Docker for fleet-scale map matching.
Performance and Scaling
Contraction Hierarchies vs Multi-Level Dijkstra
OSRM and GraphHopper both offer Contraction Hierarchies (CH), which precompute node shortcuts so that shortest-path queries return in microseconds — at the cost of a fixed profile and a long, memory-hungry preprocessing step. Multi-Level Dijkstra (MLD) in OSRM and flexible mode in GraphHopper preprocess a partition instead, giving slightly slower queries but supporting per-request costing changes and faster graph updates. For fleet map matching where the profile is stable (all trucks, say), CH maximises throughput. When you must route several vehicle classes from one server, MLD or flexible mode — or Valhalla’s inherently dynamic costing — is the right trade.
Spatial batching
Matching is embarrassingly parallel across independent trips. Partition the day’s traces by vehicle and trip, then fan them out with an async client (httpx.AsyncClient) against a pool of engine workers. Keep each worker’s request queue shallow so a single slow, degenerate trace does not head-of-line-block the batch. Spatial locality also helps the engine’s own caches: grouping trips by region keeps hot graph tiles resident.
import asyncio
import httpx
async def match_all(traces, base_url, concurrency=16):
"""
Map-match many independent trips concurrently against one engine.
traces : list of trip objects each exposing .id and .coord_path
("lon,lat;lon,lat;..." already in [lon, lat] order)
concurrency : bound on simultaneous in-flight requests; keep it near the
engine's core count so no single worker's queue grows deep
"""
sem = asyncio.Semaphore(concurrency)
limits = httpx.Limits(max_connections=concurrency)
async with httpx.AsyncClient(base_url=base_url, limits=limits, timeout=30.0) as client:
async def one(trace):
async with sem: # bound in-flight requests, avoid head-of-line blocking
r = await client.get(
f"/match/v1/driving/{trace.coord_path}",
params={"geometries": "polyline", "overview": "full"},
)
r.raise_for_status()
return trace.id, r.json()
return dict(await asyncio.gather(*(one(t) for t in traces)))
The semaphore, not the number of coroutines, sets the real concurrency: you can schedule ten thousand trips at once and still hold in-flight requests to the engine’s sustainable count. In production, wrap each client.get in the token-bucket limiter and circuit breaker so a saturated engine sheds load gracefully instead of timing out every coroutine at once.
Memory footprint of planet extracts
Memory is the dominant self-hosting cost. A country extract is comfortable on a modest instance, but a full-planet build is demanding: an OSRM CH planet graph needs on the order of 120 GB of RAM, MLD somewhat less, and Valhalla’s tiled design keeps resident memory far lower by paging tiles from disk. Right-size the extract to your operational area — most fleets never leave one country or a handful of border regions — and you avoid paying for planet-scale RAM. Extract clipping with osmium before the build is the single biggest lever on memory and build time.
Validation and Observability
A routing integration fails silently: a wrong match still returns a clean-looking distance. Instrument it so degradation surfaces before it reaches a report.
Matched-distance sanity
Compare matched distance against an independent estimate — OBD-II odometer delta where available, or the sum of Kalman-smoothed inter-ping distances — for every trip. A matched distance that is more than, say, 15% shorter than odometry usually means the matcher took a shortcut across a gap; more than 15% longer often means it detoured onto a parallel road. Route both extremes to a review queue rather than trusting them.
Confidence and per-edge checks
Persist the engine’s own confidence signal (OSRM confidence, Valhalla match scores, GraphHopper matching error) per trip and alert when it drops below a calibrated floor. Sudden fleet-wide confidence collapse is a strong indicator that a map extract update changed the graph under you, or that an upstream preprocessing change started sending degraded traces.
Alerting thresholds
Monitor these signals with daily rolling windows and route breaches to the data-engineering channel:
| Signal | Warning | Critical |
|---|---|---|
| Trips with match confidence below floor | > 5% | > 15% |
| Matched-vs-odometry distance error (95th pct) | > 12% | > 25% |
| Engine API error rate | > 1% | > 5% |
| Engine p95 response latency | > 400 ms | > 1.5 s |
| Cache hit rate (fixed-route fleet) | < 40% | < 20% |
When confidence or distance error breaches, fall back to a more conservative mode — for example, flagging affected trips as unmatched and reprocessing them once the root cause (a bad extract, a preprocessing regression) is fixed, rather than writing suspect distances to billing.
Parent topic: This section is the entry point for routing engine integration. Start with the engine survey above, then pick a path below.
Related
- Choosing a Routing Engine: OSRM vs Valhalla vs GraphHopper — the decision framework and a benchmark harness to test all three on your own fleet trace
- OSRM Integration for Fleet Map Matching — the /match service, radiuses, and self-hosting the fastest fixed-profile engine
- Valhalla Meili Map Matching for Telematics — HMM matching with rich per-edge attributes and dynamic costing
- GraphHopper Map Matching for Fleet Routing — HMM matching with strong turn-cost handling on the JVM
- Hidden Markov Model Map Matching in Python — the algorithm these engines implement, for when you need to understand or reproduce it
- Kalman Filtering for GPS Noise Reduction — upstream smoothing that supplies the per-point radiuses map matching depends on