Batch Map Matching with GraphHopper in Python

Matching one trace against a GraphHopper /match server is a single HTTP call, covered in GraphHopper map matching for fleet routing. The problem this page solves is the next one: reprocessing an entire day of fleet telemetry — tens of thousands of per-trip traces — through that same endpoint without melting the server, losing partial progress, or turning a handful of broken sequences into a failed batch. The answer is a bounded thread pool with retry that separates transient failures from deterministic ones, plus aggregation into a pandas DataFrame so the output is a report, not a pile of JSON. The class below is self-contained and copy-paste ready; every knob is explained inline and in the tuning section that follows.

Compatibility and Configuration Requirements

Requirement Version / value Notes
GraphHopper server 8.x or 9.x /match endpoint enabled; profiles declared and graph imported before the batch runs
Java (server side) 17+ Required by GraphHopper 8/9; a mismatched JVM fails at import, not at request time
Python 3.10+ concurrent.futures, structural typing, and f-strings used below
requests 2.31+ Uses a shared Session with a mounted HTTPAdapter for connection pooling
pandas 2.0+ pd.DataFrame aggregation of one row per trace
Profile config must match request Any profile you submit (e.g. truck) must exist in the server config.yml at import time
Coordinate format [lat, lon] degrees in, [lon, lat] out GPX trackpoints take lat/lon; GraphHopper returns [lon, lat]

Tables scroll horizontally on narrow viewports. See the GraphHopper documentation for the authoritative endpoint contract and the GraphHopper repository for the map-matching module.

A Self-Contained Batch Matcher

import time
import logging
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from datetime import timezone
from concurrent.futures import ThreadPoolExecutor, as_completed


import requests
import pandas as pd
from requests.adapters import HTTPAdapter

logger = logging.getLogger("gh_batch")


@dataclass
class Trace:
    """One ordered vehicle trace destined for a single /match call."""
    trace_id: str                 # unique key, e.g. f"{vehicle_id}:{trip_id}"
    points: list                  # [{"lat": float, "lon": float, "time": datetime}, ...]
    profile: str = "car"          # must be declared in the server config.yml


class GraphHopperBatchMatcher:
    """
    Batch map matcher for a self-hosted GraphHopper /match server.

    Parameters
    ----------
    base_url : str
        Root of the GraphHopper server, e.g. "http://localhost:8989".
    gps_accuracy : float
        Emission std-dev in metres. Set to the receiver's true ~1-sigma error:
        8-12 for clean automotive GPS, 25-40 for sparse or urban-canyon traces.
        Too small breaks sequences; too large lets the match wander.
    max_visited_nodes : int
        Transition search budget. Raise (5000-10000) for sparse traces; capping
        it protects p99 latency from a single pathological trace.
    workers : int
        Thread-pool size. Scale to the SERVER core count (2-4 per core), not the
        client's -- matching is CPU-bound on one shared graph.
    chunk_size : int
        Traces submitted per wave. Bounds client memory and gives checkpointable
        progress on large overnight batches.
    max_retries : int
        Retry attempts for TRANSIENT failures only (timeout, 5xx, conn reset).
        Deterministic 400 broken-sequence errors are never retried.
    backoff_base : float
        Seconds for exponential backoff: sleep = backoff_base * 2 ** attempt.
    request_timeout : float
        Per-request timeout in seconds. Must exceed the slowest legitimate trace.
    """

    def __init__(
        self,
        base_url: str,
        gps_accuracy: float = 12.0,
        max_visited_nodes: int = 5_000,
        workers: int = 8,
        chunk_size: int = 500,
        max_retries: int = 3,
        backoff_base: float = 0.5,
        request_timeout: float = 60.0,
    ):
        self.base_url = base_url.rstrip("/")
        self.gps_accuracy = gps_accuracy
        self.max_visited_nodes = max_visited_nodes
        self.workers = workers
        self.chunk_size = chunk_size
        self.max_retries = max_retries
        self.backoff_base = backoff_base
        self.request_timeout = request_timeout

        # One pooled Session shared across threads; sized to the worker count so
        # connections are reused instead of re-opened per request.
        self.session = requests.Session()
        adapter = HTTPAdapter(pool_connections=workers, pool_maxsize=workers)
        self.session.mount("http://", adapter)
        self.session.mount("https://", adapter)

    # -- GPX serialisation -------------------------------------------------
    @staticmethod
    def _to_gpx(points: list) -> bytes:
        gpx = ET.Element("gpx", version="1.1", creator="gh-batch")
        seg = ET.SubElement(ET.SubElement(gpx, "trk"), "trkseg")
        for p in points:
            pt = ET.SubElement(
                seg, "trkpt", lat=f"{p['lat']:.6f}", lon=f"{p['lon']:.6f}"
            )
            t = p["time"].astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
            ET.SubElement(pt, "time").text = t
        return ET.tostring(gpx, encoding="utf-8", xml_declaration=True)

    # -- single request with retry ----------------------------------------
    def _match_one(self, trace: Trace) -> dict:
        """Match one trace. Retries transient errors; surfaces 400s as failures."""
        params = {
            "profile": trace.profile,
            "gps_accuracy": self.gps_accuracy,
            "max_visited_nodes": self.max_visited_nodes,
            "points_encoded": "false",
            "details": ["road_class"],
        }
        body = self._to_gpx(trace.points)
        headers = {"Content-Type": "application/gpx+xml"}

        for attempt in range(self.max_retries + 1):
            try:
                resp = self.session.post(
                    f"{self.base_url}/match",
                    params=params,
                    data=body,
                    headers=headers,
                    timeout=self.request_timeout,
                )
            except (requests.Timeout, requests.ConnectionError) as exc:
                if attempt < self.max_retries:
                    time.sleep(self.backoff_base * 2 ** attempt)
                    continue
                return self._fail(trace, f"transport: {exc}")

            if resp.status_code == 200:
                return self._parse(trace, resp.json())

            # 4xx (e.g. broken sequence) is deterministic -- do not retry.
            if 400 <= resp.status_code < 500:
                return self._fail(trace, f"client {resp.status_code}: {resp.text[:120]}")

            # 5xx is transient -- back off and retry.
            if attempt < self.max_retries:
                time.sleep(self.backoff_base * 2 ** attempt)
                continue
            return self._fail(trace, f"server {resp.status_code}")

        return self._fail(trace, "exhausted retries")

    # -- result shaping ----------------------------------------------------
    @staticmethod
    def _parse(trace: Trace, payload: dict) -> dict:
        path = payload["paths"][0]
        return {
            "trace_id": trace.trace_id,
            "profile": trace.profile,
            "status": "ok",
            "matched_distance_m": path["distance"],
            "matched_time_s": path["time"] / 1000.0,
            "point_count": len(trace.points),
            "error": None,
        }

    @staticmethod
    def _fail(trace: Trace, error: str) -> dict:
        return {
            "trace_id": trace.trace_id,
            "profile": trace.profile,
            "status": "failed",
            "matched_distance_m": None,
            "matched_time_s": None,
            "point_count": len(trace.points),
            "error": error,
        }

    # -- public entry point ------------------------------------------------
    def run(self, traces: list) -> pd.DataFrame:
        """Match every trace and return one aggregated row per trace."""
        rows: list = []
        for start in range(0, len(traces), self.chunk_size):
            chunk = traces[start:start + self.chunk_size]
            with ThreadPoolExecutor(max_workers=self.workers) as pool:
                futures = {pool.submit(self._match_one, t): t for t in chunk}
                for fut in as_completed(futures):
                    rows.append(fut.result())
            logger.info("matched %d/%d traces", len(rows), len(traces))
        return pd.DataFrame(rows)


# ---------------------------------------------------------------------------
# Usage
# ---------------------------------------------------------------------------
# matcher = GraphHopperBatchMatcher(
#     base_url="http://localhost:8989",
#     gps_accuracy=12.0,
#     workers=8,           # server has 4 physical cores -> 2 workers/core
#     chunk_size=500,
# )
# traces = [Trace(trace_id=f"{v}:{trip}", points=pts, profile="truck")
#           for (v, trip, pts) in grouped_trips]
# results = matcher.run(traces)
# print(results["status"].value_counts())
# results.to_parquet("match_results.parquet")
Overlapping chunks and how they are stitched A twelve-hundred point trace split into three chunks of five hundred points with a fifty-point overlap. Each chunk is matched independently. When stitching, the overlap region from every chunk except the first is dropped, so the seam never falls in the middle of a junction decision. 1 200 points, chunk 500, overlap 50 point 0 point 1200 chunk 1 — 0…500 chunk 2 — 450…950 chunk 3 — 900…1200 overlap overlap Without the overlap, the first and last points of every chunk have no neighbours on one side, so the matcher guesses their direction. Fifty points at 1 Hz is under a minute of driving — enough context to settle a junction, cheap enough to throw away. Split on time gaps first, then on length: a chunk boundary that lands inside a tunnel gap costs nothing to place there.

Execution and Tuning Guidelines

workers — size to the server, not the client

Picking a chunk size Two curves against chunk size. The number of requests for a fixed corpus falls steeply and then flattens. The per-chunk failure rate climbs once a chunk holds enough points to exceed the engine's visited-node budget. The workable band sits between three hundred and eight hundred points. Requests issued and chunks rejected, 2 M-point corpus workable band requests rejected 50 300 500 800 1500 3000 points per chunk Past eight hundred points the request saving is a few percent and the rejection rate starts doubling per step. A rejected chunk costs a retry at half the size, so it is roughly three times the price of never having sent it.

The most common mistake is setting workers from the client’s core count. GraphHopper matches against one shared in-memory graph and is CPU-bound, so throughput is capped by the server’s physical cores. Two to four workers per server core saturates it; more just queues requests inside the JVM and lengthens the latency tail. Raise workers while watching p99 latency and stop as soon as the tail degrades — that inflection is your ceiling.

gps_accuracy — the emission window

gps_accuracy is the standard deviation of the measurement model in metres, identical to the parameter on the single-trace endpoint. Raise it for sparse or noisy fleets so legitimate edges are not penalised out of the emission window; lower it for clean automotive GPS so the match does not wander onto parallel roads. Because a batch usually mixes receiver types, carry the value per trace when your fleets differ rather than forcing one global constant.

max_visited_nodes — the transition budget

This bounds the shortest-path search that connects consecutive candidates. Sparse traces need a larger budget to bridge the gap between fixes; raising it lifts recall on 30–60 s sampling but lets a single pathological trace dominate latency. Set it high enough to clear your normal sparse traces (5,000–10,000) and cap it there, then handle genuine signal-loss gaps by splitting the trace upstream via interpolating GPS gaps during tunnel signal loss.

chunk_size — memory and checkpointing

Chunking submits traces in waves instead of holding one future per trace in memory. It bounds client RSS on large batches, and — because each chunk completes before the next starts — it gives you a natural checkpoint boundary. Persist results per chunk (append to Parquet) so a crash three hours into an overnight run resumes from the last completed chunk rather than re-matching everything.

Retry policy — separate transient from deterministic

The retry logic deliberately treats a 400 differently from a 5xx. A broken-sequence 400 is deterministic: the identical trace and parameters will fail identically, so retrying only burns server capacity. Route those to a fallback that cleans outliers with Kalman filtering, splits at gaps, and re-submits with a wider gps_accuracy. Reserve exponential backoff for timeouts, connection resets, and 5xx — the failures that a moment later may succeed.

Common Pitfalls

Thread pool overwhelms a single-graph server Retrying a rejected chunk unchanged just fails again A 900-point chunk exceeds the engine's visited-node budget and is rejected. Retrying it identically fails identically. Splitting it in half and retrying each part succeeds for the first half and needs one more split for the second, which contained a dense city centre. Three extra calls recover the whole chunk. Split-on-reject, not retry-on-reject 900 points — rejected 450 points — matched 450 points — rejected 225 — matched 225 — matched Cap the recursion depth: a chunk that still fails at 50 points is a bad trace, not a large one, and belongs in a dead-letter queue.

Sizing workers to the client machine floods a CPU-bound server that serves every request from one shared graph. Requests queue inside the JVM, p99 latency climbs, and occasional timeouts trigger retries that add still more load — a feedback loop that collapses throughput. Size the pool to the server’s physical cores (2–4 workers per core), and put a circuit breaker in front of the server as described in handling routing engine rate limits with circuit breakers so a saturated server sheds load instead of cascading.

Retrying deterministic broken-sequence errors

A naive try/except that retries every non-200 will hammer the server three times with a trace that can never match — an off-road outlier or a tunnel-sized gap fails identically on every attempt. The matcher above inspects the status code and retries only transient classes (timeout, connection error, 5xx), returning 4xx failures immediately with the server’s message preserved in the error column so you can triage them in the DataFrame rather than in the logs.

Unsorted or duplicated points inflate matched distance

The /match endpoint assumes the trace is time-ordered. Feeding buffered pings that arrive out of order, or leaving exact-timestamp duplicates in place, makes the HMM see impossible back-and-forth motion and either breaks the sequence or produces a matched path far longer than the real trip. Sort each trace by timestamp and drop duplicate fixes before constructing the Trace — the same preprocessing discipline that timestamp synchronization for multi-device GPS logs establishes upstream — and reconcile matched_distance_m against the raw trace length as a batch-wide quality gate.


Up: GraphHopper Map Matching for Fleet Routing | Routing Engine Integration for Fleet Telematics