An isochrone is computed as a set of reachable nodes and delivered as a polygon, and the step between the two is a modelling choice rather than a formality. Three methods dominate — alpha shapes, concave hulls and grid contouring — and each is right for a different network. This page compares them, as part of generating isochrones with PySAL and GeoPandas within Python Routing Engines & Isochrone Mapping.
The choice is usually made once, by whoever wrote the first version, and then inherited. It is worth revisiting because the failure modes differ sharply and each looks acceptable on the network it was tuned against.
When to use this approach
The decision follows from the reachable point set rather than from taste:
- Dense, evenly distributed points — an urban drive or walk isochrone — suit grid contouring. Interpolation between closely spaced points is well constrained, and the resulting boundary follows the travel-time gradient rather than the outermost points.
- Sparse or filamentary points — a rural network, or any isochrone dominated by a few long radial roads — suit a concave hull. Contouring across the gaps between filaments invents reachability that does not exist.
- Point sets with genuine interior gaps — a city with a large park, a port, an airfield — suit an alpha shape, which is the only one of the three that produces holes without being asked.
Implementation
Shapely 2.0 ships a concave hull, which removes the main historical reason to reach for a third-party alpha-shape implementation.
# requires: shapely, geopandas (pip install shapely geopandas)
import geopandas as gpd
from shapely import concave_hull
from shapely.geometry import MultiPoint
def hull_isochrone(points: gpd.GeoSeries, *, ratio: float = 0.85,
buffer_m: float = 60.0):
"""Concave hull with a buffer to close gaps between adjacent streets."""
if points.crs is None or points.crs.is_geographic:
raise ValueError("project to a metric CRS before polygonising")
cloud = MultiPoint(list(points.geometry))
poly = concave_hull(cloud, ratio=ratio, allow_holes=False)
# Buffer out and back to smooth the outline without changing its extent much
return poly.buffer(buffer_m).buffer(-buffer_m * 0.6).buffer(0)
The buffer-out-and-back is doing real work. A raw hull over street-network nodes traces a jagged outline that follows individual junction positions; a modest buffer closes the gaps between parallel streets and produces a boundary a reader recognises as a neighbourhood. Buffering back by slightly less than the outward amount keeps the extent honest while retaining the smoothing.
allow_holes=False is the deliberate difference from an alpha shape. Where holes are wanted, set it True — but only where the gaps are geographic rather than a consequence of sampling.
The .buffer(0) at the end is not superstition: it repairs self-intersections that the double buffer can introduce on concave outlines, and an invalid polygon will fail on write or on any subsequent spatial join.
For grid contouring the boundary comes from the interpolated surface rather than the points:
# requires: numpy, scipy, scikit-image, shapely (pip install numpy scipy scikit-image shapely)
import numpy as np
from scipy.interpolate import griddata
from skimage.measure import find_contours
from shapely.geometry import Polygon
def contour_isochrone(xy: np.ndarray, minutes: np.ndarray, cutoff_min: float,
*, cell_m: float = 50.0) -> Polygon | None:
"""Contour an interpolated travel-time surface at the cutoff."""
x0, y0 = xy.min(axis=0) - cell_m
x1, y1 = xy.max(axis=0) + cell_m
gx = np.arange(x0, x1, cell_m)
gy = np.arange(y0, y1, cell_m)
mesh_x, mesh_y = np.meshgrid(gx, gy)
# Fill beyond the hull with a value above the cutoff so it never encloses
grid = griddata(xy, minutes, (mesh_x, mesh_y),
method="linear", fill_value=cutoff_min + 1.0)
contours = find_contours(grid, level=cutoff_min)
if not contours:
return None
biggest = max(contours, key=len)
coords = [(x0 + c * cell_m, y0 + r * cell_m) for r, c in biggest]
return Polygon(coords).buffer(0)
fill_value above the cutoff is the line that stops the contour wrapping territory the network never reached. Leaving it at the default of NaN produces a surface whose edges are undefined, and the contour then follows the convex hull of the sample points rather than the travel-time boundary.
There is also a practical reason to prefer whichever method is stable under small perturbations of the node set. Reachable nodes change slightly with every graph rebuild, and a method whose output area swings by ten percent in response to a handful of boundary nodes will produce alarming diffs that reflect nothing but sampling noise. Running the candidate methods against two consecutive graph builds of the same area, and comparing the areas they report, is a cheap way to find out which one is telling you about the city and which one is telling you about the rebuild.
Key parameters and tuning
| Parameter | Recommended value | Notes |
|---|---|---|
| Method, dense urban | grid contour | Interpolation is well constrained; boundary follows the gradient |
| Method, sparse rural | concave hull | Contouring bridges gaps the vehicle cannot cross |
| Method, real interior gaps | alpha shape | The only one that produces holes unasked |
ratio |
0.8–0.9 | Below 0.7, fragmentation into multiple polygons becomes common |
| Buffer out / back | 60 m / 36 m | Smooths between parallel streets without inflating extent |
Grid cell_m |
50 m | Halving it quadruples memory for little boundary change |
fill_value |
cutoff + 1 | Stops the contour enclosing unreached territory |
| Final repair | .buffer(0) |
Self-intersections fail on write and on spatial joins |
Integration points
Into accessibility scoring. The polygon is the mask for the zonal statistics in population-weighted accessibility scoring, and the method choice propagates directly into the score. A contour that bridges a river inflates reachable population by everything on the far bank.
Against engine isochrones. Valhalla returns its own polygons, computed from its internal expansion rather than from a node set you control — see Valhalla isochrone endpoint for service areas. Where both are available, comparing them is a good sanity check on whichever local parameters you have chosen.
Into storage. Whichever method is used, store the method and its parameters alongside the geometry. An isochrone without them cannot be reproduced or compared against a later one, and the differences between methods are large enough that comparing across them is meaningless.
Validation checklist
- The polygon is valid. Assert
geom.is_validbefore storing. An invalid polygon fails on write and silently misbehaves in spatial joins. - It is a single polygon where expected. Count parts. A concave hull fragmenting into several means the ratio is too low for the point set.
- The origin is inside. A trivial check that catches a CRS mix-up or a transposed coordinate immediately.
- Holes are geographic. For every hole, confirm a real barrier — park, water, rail yard. A hole over ordinary streets is a sampling artefact and the parameters need loosening.
- Area is stable across cell sizes. For contouring, halve the cell size and confirm the area moves by only a few percent. A large move means the surface is under-sampled.
Related
- Generating isochrones with PySAL and GeoPandas — the expansion and interpolation stages that produce the point set
- Creating 15-minute city isochrones in Python — walking parameters that change the point set before any polygonisation
- Population-weighted accessibility scoring — where the polygon becomes a number and the method choice propagates
- Valhalla isochrone endpoint for service areas — the engine-side alternative and a useful cross-check