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:
requestsfor the HTTP call, plusgeopandas,shapely, andpandasto parse and repair the returned geometry. Install withpip 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
Authorizationheader value, not asBearer <key>. ABearerprefix 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.
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:
gdf = fetch_15min_isochrone(api_key="YOUR_KEY", longitude=-73.985428, latitude=40.748817)
print(gdf[["value", "area", "reachfactor"]])
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:
- Geometry validity:
assert gdf.geometry.is_valid.all()— should pass aftermake_valid. - Bounding-box sanity: the polygon’s centroid should land within a few hundred meters of the input coordinate.
gdf.geometry.centroidfar from the request point signals a snap failure. - Reach factor:
reachfactornear 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. - Area, measured correctly: reproject before measuring, because area in WGS84 degrees is meaningless.
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.
Related
- Optimizing Batch Isochrone Generation with OSRM — self-host routing for high-volume runs.
- Caching Strategies for Repeated Network Queries — avoid redundant API calls.
- Performing Point-in-Polygon Joins for Store Catchments — the next pipeline stage that consumes this polygon.