Configuring OpenRouteService for Drive-Time Maps

Turning a single store coordinate into a defensible drive-time catchment depends on how precisely you configure the OpenRouteService (ORS) isochrone API, serialize requests, and validate the geometry it returns before anything downstream consumes it.

Retail site selection has moved from heuristic Euclidean buffers to network-constrained spatial modeling, and ORS is the most accessible entry point into that shift. This stage of the Isochrone Generation & Network Analysis workflow takes candidate site coordinates and produces validated reachability polygons that later feed demographic attribution and suitability scoring. Getting the parameterization, retry behavior, and topology checks right here prevents silent errors from propagating into market-sizing decisions worth millions in lease commitments.

Concept: how network isochrones differ from buffers

A radial buffer assumes a customer travels in a straight line at uniform speed in every direction — an assumption that collapses the moment a river, motorway, or one-way grid enters the picture. A network isochrone instead runs a shortest-path expansion over the routing graph from the origin, accumulating travel cost along real edges until the time or distance budget is exhausted, then contours the reachable node set into a polygon. The result is an anisotropic shape that follows arterials, bends around barriers, and shrinks across congested or low-connectivity areas.

ORS computes this expansion against an OpenStreetMap-derived graph, applying a routing profile that encodes mode-specific speed and access rules. The driving-car profile uses standard passenger-vehicle speed assumptions; driving-hgv enforces weight, height, and commercial routing restrictions that matter for distribution-node modeling. Two origins with identical 15-minute budgets can yield catchments that differ by an order of magnitude in area purely because of underlying network density — which is exactly why the area-versus-speed sanity check later in this page is non-negotiable.

Radial buffer versus network-constrained isochrone A radial buffer is a perfect circle of fixed radius around the origin, ignoring barriers. A network isochrone is an anisotropic shape that follows arterial roads, bends around a river, and shrinks across low-connectivity areas for the same travel-time budget. Radial buffer straight-line, uniform speed Network isochrone shortest-path over real edges radius = t·v Same area in every direction river arterial Follows roads, bends around barriers

Architecture overview

The request pipeline is a linear stage sequence: build a typed request from candidate coordinates, dispatch it with retry and backoff, parse and repair the returned geometry, reproject and clip it to the analysis boundary, then validate area before persisting. Each stage fails loudly and in isolation so a single malformed payload never corrupts a batch.

OpenRouteService isochrone request pipeline Candidate coordinates are serialized into ORS isochrone requests, dispatched with retry and backoff, parsed and topology-repaired, then clipped and validated before storage. OpenRouteService request pipeline Build request profile · range smoothing · avoid Dispatch POST + API key retry / backoff Parse read isochrone make_valid() Clip reproject to UTM overlay boundaries Validate area vs limits flag deviations

The /v2/isochrones/{profile} endpoint accepts an HTTP POST with a JSON body, and the API key travels in the Authorization header as a plain string — not a Bearer token. The profile is embedded in the URL path, so the request object and the endpoint string must always agree on the mode. A mismatch between a driving-hgv payload and a driving-car URL produces a valid-looking polygon computed with the wrong access rules, which no HTTP error will catch.

Configuration parameters

The table below lists the request fields that materially change catchment geometry, with the data type, valid range, and the default that suits most retail catchment work. Treat anything outside these ranges as a deliberate, documented exception rather than a convenience.

Parameter Type Valid range / values Retail default Effect on output
profile (URL) string driving-car, driving-hgv, cycling-regular, foot-walking driving-car Selects speed and access rules for the graph traversal
locations array of [lon, lat] WGS84 bounds; lon first Origin point(s); coordinate order is the top failure source
range_type string time, distance time Whether range is interpreted as seconds or meters
range array of numbers seconds: 603600+; ascending [600, 1200, 1800] Each value yields one nested contour (10/20/30 min)
location_type string start, destination start Outbound customer reach vs. inbound logistics reach
smoothing float 0.01.0 0.25 Below 0.3 preserves frontage detail; above 0.6 generalizes
attributes array area, reachfactor, total_pop ["area"] Returns area in m² so you can validate without reprojecting twice
avoid_polygons GeoJSON valid Polygon/MultiPolygon none Excludes seasonal closures or restricted zones
avoid_features array highways, tollways, ferries none Excludes feature classes from the traversal

Use ascending range arrays: a single request returning [600, 1200, 1800] produces three nested polygons in one network call, which is dramatically cheaper than three separate requests. Set location_type to destination only when the question is reverse-commute or inbound delivery reach — the asymmetry matters on networks with one-way arterials or directional motorway ramps.

Step-by-step Python implementation

The dispatcher below builds a typed request, enforces coordinate order, retries transient failures with exponential backoff, and returns a CRS-aware GeoDataFrame. It uses the requests library for HTTP and shapely for geometry sanitization. Every geometry is constructed with an explicit CRS — no bare lat/lon arithmetic happens anywhere in the pipeline.

python
import requests
import time
import logging
from typing import Sequence

import geopandas as gpd
from shapely.geometry import shape
from shapely.validation import make_valid

logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")

ORS_URL = "https://api.openrouteservice.org/v2/isochrones/{profile}"
RETRYABLE = {429, 500, 502, 503, 504}
WGS84 = "EPSG:4326"


def _validate_coords(lon: float, lat: float) -> None:
    # Guard against the single most common ORS error: swapped lon/lat.
    if not (-180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0):
        raise ValueError(f"Out-of-range coordinate: lon={lon}, lat={lat}")
    if abs(lon) <= 90 and abs(lat) <= 90 and abs(lat) > abs(lon):
        logging.warning("lon (%s) < lat (%s): verify [lon, lat] order", lon, lat)


def fetch_isochrone(
    api_key: str,
    lon: float,
    lat: float,
    ranges_s: Sequence[int] = (600, 1200, 1800),
    profile: str = "driving-car",
    smoothing: float = 0.25,
    max_retries: int = 4,
) -> gpd.GeoDataFrame:
    """Return nested drive-time contours as a GeoDataFrame in EPSG:4326."""
    _validate_coords(lon, lat)

    payload = {
        "locations": [[lon, lat]],          # ORS demands [longitude, latitude]
        "range_type": "time",
        "range": sorted(ranges_s),          # ascending -> nested contours
        "location_type": "start",
        "smoothing": smoothing,
        "attributes": ["area"],
    }
    headers = {"Authorization": api_key, "Content-Type": "application/json"}
    url = ORS_URL.format(profile=profile)

    for attempt in range(max_retries):
        resp = requests.post(url, json=payload, headers=headers, timeout=30)
        if resp.status_code == 200:
            break
        if resp.status_code in RETRYABLE:
            wait = 2 ** attempt                       # 1s, 2s, 4s, 8s
            logging.warning("ORS %s; retry in %ss", resp.status_code, wait)
            time.sleep(wait)
            continue
        # 4xx (bad payload, exhausted quota) is not transient: fail fast.
        resp.raise_for_status()
    else:
        raise RuntimeError(f"ORS unavailable after {max_retries} attempts")

    features = resp.json()["features"]
    rows = []
    for feat in features:
        geom = make_valid(shape(feat["geometry"]))    # repair self-intersections
        rows.append({
            "range_s": int(feat["properties"]["value"]),
            "area_m2": feat["properties"].get("area"),
            "geometry": geom,
        })
    return gpd.GeoDataFrame(rows, crs=WGS84)

Connection pooling is worth wiring in for any portfolio of more than a few sites: reuse a single requests.Session() across calls so the TLS handshake is amortized. The retry loop distinguishes transient status codes (429, 5xx) from terminal 4xx responses — retrying a 400 Bad Request only burns quota, since a malformed range array or missing profile will fail identically every time.

For the complete single-site walkthrough — including coordinate validation against WGS84 bounds and GeoJSON-to-GeoDataFrame conversion — see How to calculate 15-minute drive time polygons in Python.

Edge cases and failure modes

Most ORS failures are quiet: the request succeeds, but the geometry is subtly wrong. Watch for these patterns.

  • Swapped coordinates. [lat, lon] instead of [lon, lat] produces a polygon in the ocean or a 2010 empty-result error. The _validate_coords guard above flags suspicious ordering before dispatch.
  • Origin off the network. A coordinate that snaps to no routable edge (a rooftop centroid, a parking-lot interior) returns an empty or truncated isochrone. Snap candidate points to the nearest road before submission, or widen the ORS snapping tolerance.
  • Quota exhaustion. The free tier caps daily isochrone requests; a 403 with a quota message is not retryable. When a portfolio outgrows the hosted tier, move topology-heavy batches to a self-hosted engine via Optimizing Batch Isochrone Generation with OSRM.
  • Topological artifacts. Raw responses can contain sliver polygons, self-intersections, or coordinate drift at high smoothing values. make_valid() resolves invalid rings before any area math runs.
  • Profile/URL mismatch. A driving-hgv payload sent to a driving-car URL returns a 200 with the wrong access rules applied — caught only by the area sanity check, never by HTTP status.

Performance and scaling

Each ORS call is a network round trip, so the dominant cost at scale is request count, not local CPU. Three tactics keep batch runs fast and within quota:

  1. Nest contours per request. Returning [600, 1200, 1800] in one call rather than three separate requests cuts network volume by two-thirds for the same coverage.
  2. Cache idempotent requests. Hash the canonicalized request payload with SHA-256 and key a cache on the digest, so re-running a portfolio skips coordinates whose parameters have not changed. The dedicated patterns live in Caching Strategies for Repeated Network Queries.
  3. Bound concurrency to the rate limit. Parallel dispatch helps only up to the per-minute quota; beyond it, concurrency just generates 429 storms. Size the worker pool to the documented limit and let the backoff loop absorb the rest.

For portfolios in the tens of thousands of sites, the hosted API stops being economical. Self-hosting the routing graph removes per-request quotas entirely and shifts the constraint to memory and graph-build time — covered in the OSRM optimization workflow linked above.

Validation and QA gates

No isochrone leaves this stage until it clears these checks. Run them as hard gates in the pipeline, not as advisory logging.

  1. CRS alignment. Convert WGS84 (EPSG:4326) to a local projected system (a UTM zone or EPSG:5070 Albers) before any area calculation or spatial join — geographic coordinates make planar area meaningless.
  2. Topology repair. Apply shapely.make_valid() so self-intersections never reach downstream aggregation.
  3. Boundary clipping. Clip contours against municipal limits, zoning districts, or competitor exclusion zones with geopandas.overlay() so catchments respect real market edges.
  4. Area sanity check. Compare the projected polygon area against the theoretical maximum implied by the profile’s posted speeds and local road density. Flag any catchment exceeding 15% deviation for manual review — this is the gate that catches profile/URL mismatches and off-network origins.
python
def validate_isochrone(gdf: gpd.GeoDataFrame, utm_crs: str, max_area_m2: float):
    """Hard gate: reproject, repair, and bound-check before persisting."""
    proj = gdf.to_crs(utm_crs)
    proj["geometry"] = proj.geometry.apply(make_valid)
    proj["area_m2"] = proj.geometry.area
    if not proj.geometry.is_valid.all():
        raise ValueError("Invalid geometry survived make_valid()")
    overscale = proj[proj["area_m2"] > max_area_m2]
    if not overscale.empty:
        logging.warning("%d contour(s) exceed area ceiling; review", len(overscale))
    return proj

Integration notes

Validated contours are the handoff point to the rest of the location intelligence stack. Persist them to a PostGIS table keyed by site and range so downstream consumers query by catchment rather than recompute it. Version the routing-graph snapshot alongside each batch — quarterly site selection cycles depend on reproducible baselines, and an undocumented graph refresh silently shifts every catchment boundary.

From storage, the polygons drive demographic attribution: joining each contour to population and income layers produces the trade area profiles that feed footfall and lease-optimization models. Urban sites usually need a multi-modal layer on top of the drive-time base — fold pedestrian, cycling, and transit reach into the catchment via Implementing Multi-Modal Routing for Urban Retail before demographic attribution, then union the modes into a single accessibility surface.

← Back to Isochrone Generation & Network Analysis