Precomputing Travel-Time Matrices with OSRM

Chunk nodes under the OSRM /table cap and stitch sub-blocks into a cached int32 matrix.

This page solves one specific failure: a day’s collection matrix is a single /table call that OSRM refuses because it exceeds the coordinate cap, so the solver starts with no distances at all.

A waste solver reads travel time between every pair of stops as an N×N matrix, and for a full collection day N can run into the hundreds or low thousands. A self-hosted OSRM instance answers /table requests quickly, but only up to a configured coordinate cap (500 by default), so a naive one-shot request for 900 nodes is rejected before a single duration is computed. Precomputing travel-time matrices with OSRM means chunking the node list under that cap, calling /table for each sub-block, stitching the blocks back into the dense N×N, and caching the result keyed by a hash of the inputs so the same day is never recomputed. This is the concrete OSRM path in the Distance Matrix Construction stage; if you are still choosing a back end, weigh it against Valhalla and the in-process callback in OR-Tools vs. OSRM vs. Valhalla for matrix precomputation.

Chunk under the cap, fetch each source-target block from OSRM, stitch into the dense NxN, then hash and cache The full node list is split into row and column chunks below the OSRM coordinate cap. Each source-chunk-by-target-chunk block is fetched from the /table endpoint and written into its offset region of the dense N by N matrix. The stitched matrix is rounded to int32 seconds, hashed to a SHA-256 cache key, and stored; a repeat request for the same nodes returns the cached matrix without calling OSRM. node list N stops solver order chunk size < cap row × col blocks /table blocks 0,0 0,1 1,0 1,1 stitch offset write dense NxN cache SHA-256 key int32 s cache hit · same nodes → skip OSRM entirely each block is fetched once and written at its (row_offset, col_offset) into the dense matrix

Environment & Data Prerequisites

The /table service is reached over HTTP with requests; hashing and caching use the standard library (hashlib, json, pathlib). pytest is used only for verification. A running osrm-backend is assumed — extracted, contracted, and served with osrm-routed against your region’s .osrm data — reachable at http://localhost:5000, or a mock returning the documented /table shape.

python -m pip install \
  requests==2.32.3 \
  pytest==8.2.0
# osrm-backend runs separately, e.g.:
#   osrm-extract -p /opt/car.lua region.osm.pbf
#   osrm-contract region.osrm
#   osrm-routed --max-table-size 100 region.osrm

The input is one ordered list of collection nodes; the output is a cached dense matrix plus a manifest.

Field Unit / type Rule
nodes[] tuple(float, float) (lat, lon) WGS84 degrees, solver-index order
profile str OSRM profile served (driving); part of the cache key
TABLE_CAP int must be <= the server’s --max-table-size
output matrix list[list[int]] N×N, int32 seconds, matrix[i][j] = seconds i→j
cache_key str (hex) SHA-256 over nodes + profile + cap

Two rules prevent the two most common failures. OSRM /table takes coordinates as lon,lat in the URL path — transposed pairs return a plausible-looking but wrong matrix. And the chunk size must be at or below the server’s configured --max-table-size, or every block request is rejected.

Step-by-Step Implementation

Step 1 — Chunk the node list under the coordinate cap

Split the N node indices into contiguous blocks no larger than the cap. Because /table supports sources and destinations query parameters, one HTTP call can cover a row_chunk × col_chunk block, so the number of calls is ceil(N/cap)².

from __future__ import annotations

TABLE_CAP = 100  # must be <= osrm-routed --max-table-size


def chunk_indices(n: int, cap: int = TABLE_CAP) -> list[range]:
    return [range(start, min(start + cap, n)) for start in range(0, n, cap)]

Step 2 — Fetch one source-target block from /table

A single block sends all nodes in that row-and-column pair once, then selects rows with sources and columns with destinations (both are indices into the coordinates just sent). This keeps each request’s coordinate count at len(row) + len(col), well under the cap.

import requests

OSRM_BASE = "http://localhost:5000"


def fetch_block(
    nodes: list[tuple[float, float]],
    rows: range,
    cols: range,
    base: str = OSRM_BASE,
    profile: str = "driving",
) -> list[list[float]]:
    idx = list(rows) + [c for c in cols if c not in rows]
    coords = ";".join(f"{nodes[i][1]},{nodes[i][0]}" for i in idx)  # lon,lat
    pos = {node_i: k for k, node_i in enumerate(idx)}
    src = ";".join(str(pos[r]) for r in rows)
    dst = ";".join(str(pos[c]) for c in cols)
    resp = requests.get(
        f"{base}/table/v1/{profile}/{coords}",
        params={"sources": src, "destinations": dst, "annotations": "duration"},
        timeout=30,
    )
    resp.raise_for_status()
    body = resp.json()
    if body.get("code") != "Ok":
        raise RuntimeError(f"OSRM /table error: {body.get('code')} {body.get('message', '')}")
    return body["durations"]  # len(rows) x len(cols), seconds (float | None)

Step 3 — Stitch sub-blocks into the full N×N

Each fetched block is written into its offset region of the dense matrix. The row offset is the block’s first row index, the column offset its first column index — get these wrong and the matrix is silently transposed at block boundaries, which is why Step verification below asserts the boundaries explicitly.

INT32_MAX = 2**31 - 1


def build_matrix(nodes: list[tuple[float, float]], cap: int = TABLE_CAP, **kw) -> list[list[int]]:
    n = len(nodes)
    matrix = [[INT32_MAX] * n for _ in range(n)]
    row_chunks = chunk_indices(n, cap)
    col_chunks = chunk_indices(n, cap)
    for rows in row_chunks:
        for cols in col_chunks:
            block = fetch_block(nodes, rows, cols, **kw)
            for bi, i in enumerate(rows):
                for bj, j in enumerate(cols):
                    val = block[bi][bj]
                    matrix[i][j] = 0 if i == j else (INT32_MAX if val is None else int(round(val)))
    return matrix

Step 4 — Hash to a SHA-256 cache key and store

The cache key is a SHA-256 over the rounded node coordinates, the profile, and the cap, so the same day’s nodes always resolve to the same key and any change to inputs produces a fresh one. The matrix is stored as JSON alongside a manifest.

import hashlib
import json
from pathlib import Path

CACHE_DIR = Path("./matrix_cache")


def cache_key(nodes: list[tuple[float, float]], profile: str, cap: int) -> str:
    canon = json.dumps(
        {"nodes": [[round(lat, 6), round(lon, 6)] for lat, lon in nodes], "profile": profile, "cap": cap},
        separators=(",", ":"), sort_keys=True,
    )
    return hashlib.sha256(canon.encode("utf-8")).hexdigest()


def get_or_build(nodes: list[tuple[float, float]], profile: str = "driving", cap: int = TABLE_CAP) -> dict:
    CACHE_DIR.mkdir(exist_ok=True)
    key = cache_key(nodes, profile, cap)
    path = CACHE_DIR / f"{key}.json"
    if path.exists():
        return json.loads(path.read_text())  # cache hit — OSRM never called
    matrix = build_matrix(nodes, cap=cap, profile=profile)
    manifest = {
        "cache_key": key,
        "profile": profile,
        "n_nodes": len(nodes),
        "table_cap": cap,
        "unit": "int32_seconds",
        "unreachable_sentinel": INT32_MAX,
        "matrix": matrix,
    }
    path.write_text(json.dumps(manifest))
    return manifest

Compliance Output

The cached artifact is a manifest, not a bare matrix, so the exact travel-time basis of a day’s routes is reproducible and auditable long after the OSRM server has moved on to newer map data.

{
  "cache_key": "9f2c4a…7e",
  "profile": "driving",
  "n_nodes": 320,
  "table_cap": 100,
  "unit": "int32_seconds",
  "unreachable_sentinel": 2147483647,
  "matrix": [[0, 214, 331], [208, 0, 142], [329, 140, 0]]
}
  • cache_key — the SHA-256 that pins these exact nodes, profile, and cap; the audit report generation stage cites it to prove a solved route was built on this matrix and not a silently re-run one.
  • profile — which OSRM cost profile produced the times, so an auditor knows the matrix reflects driving road times and not, say, a pedestrian profile misconfigured on the server.
  • table_cap — the chunk size used; recorded because a different cap produces the same numbers but a different call pattern, and reproducing an investigation means reproducing the cap.
  • unreachable_sentinel — the documented value for a no-arc pair, so INT32_MAX is never misread as a free 0-second connection between disconnected components.

Because the manifest is content-addressed by its own inputs, a re-run with identical nodes is a pure cache hit — the compliance record proves not only what the matrix was but that it was not recomputed against drifted map data mid-investigation.

Verification

The tests use a deterministic fake /table (no live OSRM) to prove stitching writes each block at the correct offset, the diagonal is zero, and a second call for the same nodes is a cache hit rather than a rebuild.

import pytest
from unittest.mock import patch


def _fake_fetch_block(nodes, rows, cols, **kw):
    # deterministic: duration i->j = 10*i + j seconds, so offsets are checkable
    return [[float(10 * i + j) for j in cols] for i in rows]


def test_stitch_offsets_and_diagonal():
    nodes = [(40.70 + k * 0.001, -74.00) for k in range(5)]
    with patch(f"{__name__}.fetch_block", side_effect=_fake_fetch_block):
        m = build_matrix(nodes, cap=2)  # forces 3x3 = 9 blocks with boundaries
    assert len(m) == 5 and all(len(r) == 5 for r in m)
    assert all(m[i][i] == 0 for i in range(5))
    # off-diagonal cells reflect the 10*i + j rule → offsets were honoured
    assert m[3][1] == 31
    assert m[1][4] == 14


def test_second_call_is_cache_hit(tmp_path, monkeypatch):
    monkeypatch.setattr(f"{__name__}.CACHE_DIR", tmp_path)
    nodes = [(40.70, -74.00), (40.71, -74.01)]
    with patch(f"{__name__}.fetch_block", side_effect=_fake_fetch_block) as mocked:
        first = get_or_build(nodes, cap=2)
        calls_after_first = mocked.call_count
        second = get_or_build(nodes, cap=2)  # must not call OSRM again
    assert mocked.call_count == calls_after_first
    assert first["cache_key"] == second["cache_key"]

Common Errors

OSRM /table error: TooBig — Number of table coordinates 640 exceeds limit of 100 — a block, or the whole node list, was sent in one request above the cap. Root cause: TABLE_CAP set higher than the server’s --max-table-size, or a one-shot request bypassing chunk_indices. Fix: set TABLE_CAP at or below the server’s limit and route every request through build_matrix, which never sends more than len(rows) + len(cols) coordinates per call.

Travel times are symmetric-looking nonsense and a known short hop reads as hundreds of kilometres. Root cause: coordinates sent as lat,lon instead of lon,lat in the /table path. Fix: emit each coordinate as f"{nodes[i][1]},{nodes[i][0]}" (longitude first), exactly as Step 2 does, and assert one known pair’s duration is within a plausible band before caching the matrix.

RuntimeError: OSRM /table error: InvalidUrl or an empty durations. Root cause: the requested profile is not the one the server was extracted and contracted with (for example asking for driving when only a foot profile was built), so OSRM cannot answer. Fix: match the profile string to the Lua profile OSRM was built against, and record it in the manifest so a later reader can tell which network the times describe. This matrix then flows into the OR-Tools implementation as arc costs.

FAQ

Why chunk into row-and-column blocks instead of one row at a time?

A single-row strategy still sends the full column set with every request, so a large day either exceeds the cap anyway or fans out into far more calls than needed. Blocking by both axes keeps every request’s coordinate count at len(rows) + len(cols) and reduces total calls to ceil(N/cap)², which is the practical minimum under the sources/destinations model.

Should the cache key include the map data version?

For strict reproducibility, yes — append the OSRM data timestamp or the .osrm file’s hash to the canonical key input. This guarantees that re-extracting against newer map data invalidates the cache rather than silently returning stale times, which matters when a compliance record must name the exact network a route was optimized on.

Up: Distance Matrix Construction