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.

Where routing and map matching sit in the telematics data flow Five sequential pipeline stages from device ingestion through preprocessing to routing and map matching, then post-processing and analytical storage, with the routing stage highlighted. Ingestion raw pings Preprocessing clean + project Routing / Map matching this section Post-process stops · speed Storage analytics

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.

Routing engine integration architecture blueprint A cleaned GPS trace is serialised into an HTTP request to the routing engine's match or route endpoint. The engine returns encoded geometry, matched distance, confidence, and per-edge attributes, which the Python client decodes and forwards to downstream analytics and storage. Cleaned trace WGS84 pings + timestamps, HDOP Routing engine /match or /route OSRM · Valhalla · GH Matched result geometry · distance confidence · edges Stops · speed mileage · storage Cache + rate limit hash key · token bucket

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.