How to Calculate 15-Minute Drive-Time Polygons in Python

This page solves one exact task: turning a single store coordinate into a validated 15-minute drive-time polygon in Python, using network routing rather than a straight-line buffer, so the geometry is safe to feed into downstream catchment analysis.

A 15-minute drive-time isochrone is the canonical retail catchment unit: it answers “who can physically reach this site in a typical short trip” far more honestly than a radius ever can. Two locations the same distance from a candidate site can sit on opposite sides of a river, a highway interchange, or a one-way grid, and only network routing captures that asymmetry. The output of this task is a single reachability polygon that later joins against demographics and rolls up into a site score.

Prerequisites

Before running this task you need:

  • Python packages: requests for the HTTP call, plus geopandas, shapely, and pandas to parse and repair the returned geometry. Install with pip install requests geopandas shapely pandas.
  • An OpenRouteService API key. The free tier is enough to develop against; production batch runs need a higher quota or a self-hosted engine (see the scaling note below).
  • A clean input coordinate. The store location must already be a valid WGS84 longitude/latitude pair. Coordinate hygiene is its own task — see data validation rules for store coordinates — but at minimum the point must fall on the routable network, not in open water or outside the routing graph’s coverage.
  • The parent configuration context. This page assumes you have read Configuring OpenRouteService for Drive-Time Maps, which covers profile selection, header authentication, and the request lifecycle in depth.

Configuration and Execution Parameters

The task uses the ORS /v2/isochrones/driving-car endpoint, which computes reachable areas over an OpenStreetMap routing graph. A strict 15-minute polygon is defined entirely by four payload fields plus the profile embedded in the URL path.

Parameter Value for this task Notes
profile (URL path) driving-car Car routing; swap for cycling-regular or foot-walking for multi-modal urban analysis.
locations [[lon, lat]] ORS expects [longitude, latitude], not lat/lon.
range_type "time" Selects a time-based isochrone (the alternative is "distance").
range [900] 900 seconds = 15 minutes. Pass a list even for a single contour.
units "m" Units for the area attribute, not the range.
attributes ["area", "reachfactor"] Returns catchment area and the ratio of reachable to theoretical area.
smoothing 0.5 0–1; higher values simplify jagged contours at the cost of fidelity.

Two configuration facts cause most failures and deserve emphasis:

  • Coordinate order. ORS strictly expects [longitude, latitude]. Swapping the pair is the single most common source of empty or ocean-bound polygons, because the routing engine snaps the malformed point to the nearest graph node — often nowhere near the intended site.
  • Header authentication. The API key is passed as the raw Authorization header value, not as Bearer <key>. A Bearer prefix returns a 403 that looks like an invalid-key error.

Annotated Implementation

The function below constructs the payload, handles HTTP and network exceptions with exponential backoff, repairs the returned geometry, and hands back a GeoDataFrame in EPSG:4326. A shared requests.Session is accepted so callers batching many sites can reuse a connection pool.

python
import requests
import geopandas as gpd
import pandas as pd
from shapely.geometry import shape
from shapely.validation import make_valid
import time
import logging
from typing import Optional

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


def fetch_15min_isochrone(
    api_key: str,
    longitude: float,
    latitude: float,
    retries: int = 3,
    timeout: int = 30,
    session: Optional[requests.Session] = None
) -> gpd.GeoDataFrame:
    """
    Calculate a 15-minute drive-time polygon using the OpenRouteService API.

    ORS expects [longitude, latitude] order in the 'locations' array.
    The API key is passed in the 'Authorization' header (not as a Bearer token).

    Returns:
        GeoDataFrame with the isochrone polygon in EPSG:4326.
    Raises:
        RuntimeError: if all retry attempts are exhausted.
    """
    url = "https://api.openrouteservice.org/v2/isochrones/driving-car"
    headers = {
        "Authorization": api_key,          # raw key, NOT "Bearer <key>"
        "Content-Type": "application/json"
    }
    payload = {
        "locations": [[longitude, latitude]],   # ORS: [lon, lat]
        "range_type": "time",
        "range": [900],                          # 900 seconds = 15 minutes
        "units": "m",
        "attributes": ["area", "reachfactor"],
        "smoothing": 0.5
    }

    client = session or requests.Session()

    for attempt in range(retries):
        try:
            response = client.post(url, json=payload, headers=headers, timeout=timeout)
            response.raise_for_status()
            geojson = response.json()

            # An empty FeatureCollection means the point did not snap to the graph.
            if "features" not in geojson or not geojson["features"]:
                raise ValueError("API returned an empty feature collection.")

            feature = geojson["features"][0]
            raw_geom = shape(feature["geometry"])
            valid_geom = make_valid(raw_geom)        # repair self-intersections

            gdf = gpd.GeoDataFrame(
                pd.DataFrame([feature.get("properties", {})]),
                geometry=[valid_geom],
                crs="EPSG:4326"                      # ORS always returns WGS84
            )
            logging.info(
                "Generated 15-minute isochrone for (%.6f, %.6f)", longitude, latitude
            )
            return gdf

        except requests.exceptions.HTTPError:
            status = response.status_code
            if status == 429:                        # rate limited: back off hard
                wait_time = (2 ** attempt) * 5
                logging.warning("Rate limit hit. Retrying in %ds...", wait_time)
                time.sleep(wait_time)
            elif status >= 500:                      # transient server error
                wait_time = (2 ** attempt) * 2
                logging.warning("Server error (%d). Retrying in %ds...", status, wait_time)
                time.sleep(wait_time)
            else:                                    # 4xx other than 429: fail fast
                logging.error("HTTP %d: %s", status, response.text)
                raise
        except requests.exceptions.RequestException as e:
            wait_time = (2 ** attempt) * 3
            logging.warning("Network error: %s. Retrying in %ds...", str(e), wait_time)
            time.sleep(wait_time)

    raise RuntimeError(f"Failed to fetch isochrone after {retries} attempts.")

A single call is then trivial:

python
gdf = fetch_15min_isochrone(api_key="YOUR_KEY", longitude=-73.985428, latitude=40.748817)
print(gdf[["value", "area", "reachfactor"]])
15-minute isochrone request lifecycle with retry branches A store coordinate is serialized as [lon, lat] and POSTed to the ORS isochrones endpoint with a raw Authorization header. The response branches four ways: a 200 with features proceeds to geometry repair; a 429 rate limit and a 5xx server error each wait with exponential backoff and retry; an empty feature collection or exhausted retries raises an error. A valid 200 runs make_valid() and returns a GeoDataFrame in EPSG:4326. Build payload locations [[lon, lat]] · range [900] · time POST /v2/isochrones/driving-car Authorization: raw key (no "Bearer") Inspect response 429 / 5xx Wait + backoff sleep 2^attempt · k retry empty / 4xx Raise error no snap / fail fast 200 + features Repair geometry shape() → make_valid() Return GeoDataFrame EPSG:4326 · validated polygon

Failure Modes and Debugging

Symptom Cause Fix
Empty feature collection Point did not snap to the routing graph (offshore, outside coverage, or lat/lon swapped) Confirm [lon, lat] order; nudge the coordinate onto a road; widen the engine’s snap radius if self-hosting.
403 on a known-good key Bearer prefix on the Authorization header Pass the raw key string.
429 storm during batch runs Quota exceeded on the free tier The backoff above absorbs short bursts; for sustained volume add a caching layer (below) or self-host.
TopologyException downstream Self-intersecting ring returned by the router on dense or coastal grids Already handled here by shapely.validation.make_valid(), which splits invalid rings into a valid multi-polygon.
Distorted area / proximity results Measuring area in degrees on EPSG:4326 Reproject to a metric CRS before any area or buffer math (see Verification).

The geometry-repair step is not optional in production. Routing engines routinely emit topologically invalid polygons on complex urban grids and coastal routes; passing those straight into a spatial join raises TopologyException and aborts the run. make_valid() resolves them deterministically so the polygon is safe to consume.

Verification

Confirm correct output before trusting the polygon downstream:

  1. Geometry validity: assert gdf.geometry.is_valid.all() — should pass after make_valid.
  2. Bounding-box sanity: the polygon’s centroid should land within a few hundred meters of the input coordinate. gdf.geometry.centroid far from the request point signals a snap failure.
  3. Reach factor: reachfactor near 1.0 means the contour fills most of its theoretical area (open road network); a low value flags barriers like water or sparse roads and is expected, not an error.
  4. Area, measured correctly: reproject before measuring, because area in WGS84 degrees is meaningless.
python
metric = gdf.to_crs(gdf.estimate_utm_crs())   # auto-pick the right UTM zone
sq_km = metric.geometry.area.iloc[0] / 1_000_000
print(f"15-min catchment: {sq_km:.2f} km^2")

Projecting to the local UTM zone removes WGS84 distortion and gives an honest catchment size — the same projection step every later acreage or buffer calculation depends on.

Scaling beyond a single point

For one site this synchronous call is fine. Across hundreds of candidate locations, serial requests become the bottleneck and you will hit rate limits. Two levers apply: cache identical-coordinate responses so repeat runs never re-hit the network — covered in caching strategies for repeated network queries — and move high-volume work to a self-hosted engine, covered in optimizing batch isochrone generation with OSRM, which removes the external dependency and stabilizes latency.

← Back to Configuring OpenRouteService for Drive-Time Maps