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.
One point set, three boundaries The same reachable nodes are wrapped by a concave hull, which produces a single simple boundary; an alpha shape, which additionally carves a hole around an interior gap; and a grid contour, which smooths across the sparse filaments at the edge. Same reachable nodes, three polygonisations concave hull simple, no holes, follows the outer points alpha shape park carves genuine interior gaps as holes grid contour smooth, follows the cost gradient All three are defensible on this point set. On a sparse rural one, the contour would bridge gaps the vehicle cannot cross. On a set with no genuine interior gap, the alpha shape's holes would be sampling artefacts rather than geography.

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.

Method choice changes the answer, not just the picture On a dense urban network the three methods report areas within about eight percent of each other. On a sparse rural network the grid contour reports nearly twice the area of the concave hull, because it interpolates across gaps between radial roads. Reported 15-minute area by method dense urban concave hull 24.1 km² alpha shape 22.9 km² grid contour 25.3 km² — all within 8 % sparse rural concave hull 88 km² grid contour 171 km² — bridges the gaps between radial roads What each polygonisation costs to compute A concave hull takes under a tenth of a second, an alpha shape about a third, and grid contouring at fifty metre cells 1.4 seconds — rising to 5.8 at twenty-five metres because cell count scales with the square of resolution. Polygonisation cost for one 15-minute isochrone concave hull 0.09 s alpha shape 0.31 s grid contour, 50 m 1.4 s grid contour, 25 m 5.8 s — four times the cells For a single isochrone none of this matters. For a city-wide sweep across thousands of origins it decides whether the job finishes overnight. The quadratic cell scaling is the reason to check that a finer grid actually changes the reported area before paying for it.

Validation checklist

  • The polygon is valid. Assert geom.is_valid before 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.