Self-hosted isochrone service for OSRM and Valhalla. It turns a routing engine's travel-time matrix into multi-band reachability polygons, serves them as GeoJSON over HTTP, and ships a zero-build interactive map to look at them.
No geopandas, no shapely, no scipy, no pysal. The computational geometry
— Delaunay triangulation, alpha shapes, ring assembly, simplification, geodesic
area — is implemented here in pure Python, which is why it installs in seconds
on Python 3.11, 3.12 and 3.13 and why the test suite runs in under two seconds
without a routing server anywhere in sight.
"How far can I get in 15 minutes?" is a question every routing engine can answer
for a single destination and none of them answer as an area. OSRM has no
isochrone endpoint at all. Valhalla has /isochrone, but it is tied to
Valhalla, its concavity is not tunable, and it will not tell you the area of
what it returned.
The usual workaround is a notebook: sample some points, call /table, hand the
reachable ones to shapely and alphashape, and hope the resulting polygon is
valid. That notebook is fine until you need it as a service — with input
validation, caching, batch origins, bounded concurrency, a stable GeoJSON
contract, and something a colleague can click on.
This is that service.
For one origin, a list of time budgets and a travel mode, you get an RFC 7946
FeatureCollection — largest band first — where each feature is a Polygon or
MultiPolygon with correct winding, interior rings for genuine holes, and
properties carrying area, perimeter, compactness and sampling statistics.
Two output shapes:
output |
Geometry | Use it for |
|---|---|---|
cumulative |
Each band contains all shorter bands | Filled maps, "everything within 15 minutes", spatial joins |
bands |
Disjoint rings (band n with band n−1 punched out as a hole) | Choropleths where overlapping fills would double-count |
Because every band comes from one shared triangulation, bands output needs no
polygon boolean operations at all — see How it works.
origin (lon, lat)
│
▼
┌─────────────────────┐ azimuthal equidistant projection centred on the origin,
│ 1. project │ so every later step works in metres, not degrees
└─────────────────────┘
▼
┌─────────────────────┐ hexagonal lattice over the disc the largest budget
│ 2. sample │ could possibly reach (free-flow speed × time)
└─────────────────────┘
▼
┌─────────────────────┐ one chunked /table or /sources_to_targets call per
│ 3. query the engine │ chunk; the only I/O in the pipeline
└─────────────────────┘
▼
┌─────────────────────┐ Bowyer–Watson, once, over all samples
│ 4. triangulate │
└─────────────────────┘
▼
┌─────────────────────┐ per budget: keep samples within it, keep triangles
│ 5. alpha shape │ whose circumradius ≤ alpha, walk the boundary
└─────────────────────┘
▼
┌─────────────────────┐ Douglas–Peucker, unproject, geodesic area,
│ 6. simplify, measure│ shape statistics → GeoJSON
└─────────────────────┘
Delaunay triangulation (Bowyer–Watson) — geometry/delaunay.py.
Points are inserted one at a time; each insertion deletes every triangle whose
circumcircle contains the new point and re-triangulates the resulting cavity.
Naively that scan is O(n) per insertion and O(n²) overall, which is painful
in Python past a few hundred samples, so triangles are indexed in a uniform
bucket grid keyed on their circumcircle bounding boxes. The lookup is exact
rather than heuristic — a triangle whose circumcircle contains a point is always
registered in that point's own cell — and gives the classic expected
O(n log n). A 2000-point grid triangulates in about 100 ms.
Exact predicates — geometry/predicates.py.
Orientation and in-circle tests are evaluated in floating point and, when the
result is too close to zero to trust, recomputed with fractions.Fraction,
which is exact for binary floats. A single wrong sign turns Bowyer–Watson into
a non-triangulation, and a regular sampling lattice produces exactly cocircular
quadruples by the thousand, so this matters more here than the usual "floats are
fine" intuition suggests.
Alpha shape — geometry/alphashape.py.
Keep every Delaunay triangle whose circumradius is at most alpha; the boundary
is the set of edges belonging to one kept triangle instead of two. Large alpha
approaches the convex hull, small alpha erodes the shape and eventually
fragments it. The default alpha is twice the sample spacing (alpha_factor).
Complexity is O(t) in triangles.
Ring assembly and holes — geometry/polygons.py.
Boundary edges are oriented with the region on their left, so chaining them
traces shells counter-clockwise and holes clockwise for free. Holes are attached
to the smallest shell that contains them, and everything is emitted following
the RFC 7946 right-hand rule.
Simplification — geometry/simplify.py.
Ramer–Douglas–Peucker, iterative rather than recursive. Rings are cut at two
anchors before simplifying so that no arbitrary "first vertex" is privileged.
Typically removes 70–90% of vertices with no visible change. O(n log n)
typical, O(n²) worst case, and idempotent.
Geodesic area — geometry/spherical.py.
Spherical excess via the discrete Green's-theorem sum, exact for graticule
polygons and well under a percent for the short edges an isochrone is made of.
Holes are subtracted from their shell.
Reachable sample sets are nested: anything reachable in 10 minutes is reachable
in 20. The alpha filter is a fixed circumradius threshold. So the triangles kept
for a short budget are a strict subset of those kept for a longer one, and
therefore each band is geometrically contained in the next by construction,
not by luck. That is what makes bands output a matter of adding the previous
shell as a hole rather than running a polygon difference.
The package is not published to PyPI. Install it from a clone:
git clone https://github.com/geospatialrouting/isochrone-service.git
cd isochrone-service
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]" # drop [dev] if you do not need the testsRequires Python 3.11 or newer. Dependencies: fastapi, uvicorn, httpx,
pydantic, pydantic-settings, click. Development adds pytest,
pytest-asyncio and ruff.
You also need a routing engine, unless you are using the built-in fake engine.
For OSRM, the OSRM HTTP API documentation
describes the /table service this tool uses; the sibling repository
osrm-quickstart gets you
from an OSM extract to a running /table endpoint.
Everything below runs with no routing server, using the deterministic offline
engine. Swap ISOCHRONE_ENGINE=fake for osrm and point ISOCHRONE_ROUTER_URL
at your server to do it for real.
export ISOCHRONE_ENGINE=fake
python -m isochrone_service.cli demo -o examples/amsterdam-bike-demo.geojsonengine=fake mode=bike output=cumulative
samples: 2395/2455 routed (spacing 228.5 m, alpha 456.9 m)
15.0 min -> 22.058 km² components=1 holes=1 compactness=0.7181
10.0 min -> 9.312 km² components=1 holes=1 compactness=0.7688
5.0 min -> 1.921 km² components=1 holes=0 compactness=0.74
written to examples/amsterdam-bike-demo.geojson
The hole in the 10- and 15-minute bands is a synthetic "lake" the demo engine
places near the origin: it is crossable but not enterable, exactly like a body
of water with bridges. compactness is the Polsby–Popper ratio — 1.0 for a
perfect circle — and is a quick sanity check that the shape is obstacle-shaped
rather than sampling-artefact-shaped.
export ISOCHRONE_ENGINE=osrm
export ISOCHRONE_ROUTER_URL=http://localhost:5000
python -m isochrone_service.cli generate \
--origin 52.3702,4.8952 \
--minutes 5 --minutes 10 --minutes 15 \
--mode bike --resolution 24 \
-o amsterdam.geojsonThe GeoJSON goes to the file (or stdout without -o, so it pipes into jq or
ogr2ogr); the summary always goes to stderr and never contaminates the data
stream. Note the lat,lon order of --origin — that is the order you copy out
of a map application, and it is converted to GeoJSON's lon,lat in exactly one
place.
export ISOCHRONE_ENGINE=fake
python -m isochrone_service.cli serve --port 8000
# or: uvicorn isochrone_service.app:build --factory --port 8000Then open http://127.0.0.1:8000/ for the map, or call the API:
curl -s localhost:8000/isochrone -H 'content-type: application/json' \
-d '{"lon":4.8952,"lat":52.3702,"budgets":[300,600,900],"mode":"bike"}' \
| jq '.features[].properties | {min: .budget_minutes, km2: .area_km2, holes}'{ "min": 15.0, "km2": 23.1465, "holes": 0 }
{ "min": 10.0, "km2": 9.8545, "holes": 0 }
{ "min": 5.0, "km2": 2.0626, "holes": 0 }(The fake engine has no obstacles unless you add them, so these bands are
discs. The demo command above adds a lake and a slow crossing.)
GET /version and GET /healthz report what you are talking to:
{
"name": "isochrone-service",
"version": "0.1.0",
"engine": "fake",
"router_url": "http://localhost:5000",
"modes": ["car", "bike", "foot"]
}{
"status": "ok",
"engine": "fake",
"uptime_s": 41.2,
"cache": { "size": 3, "hits": 12, "misses": 3, "evictions": 0, "expirations": 0, "hit_rate": 0.8 }
}Logs are one JSON object per line, with the request ID attached to everything a request touches:
{"ts":"2026-07-19T22:30:37+0200","level":"INFO","logger":"isochrone_service","message":"request completed","request_id":"7633be455c51","path":"/isochrone","method":"POST","status":200,"duration_ms":98.4}Body (examples/single-request.json):
| Field | Type | Default | Meaning |
|---|---|---|---|
lon |
float, −180…180 | required | Origin longitude (WGS84) |
lat |
float, −90…90 | required | Origin latitude (WGS84) |
budgets |
list of seconds | required | One polygon per budget; sorted and de-duplicated |
mode |
car | bike | foot |
car |
Mapped to the engine's profile or costing model |
output |
cumulative | bands |
cumulative |
Nested polygons or disjoint rings |
resolution |
int, 4…80 | ISOCHRONE_DEFAULT_RESOLUTION |
Lattice rings from origin to rim; sample count ≈ 3.63 × resolution² |
alpha_factor |
float, 0…20 | ISOCHRONE_DEFAULT_ALPHA_FACTOR |
Alpha radius as a multiple of sample spacing. Lower hugs tighter |
simplify_tolerance_m |
float, 0…5000 | spacing ÷ 4 | Douglas–Peucker tolerance; 0 disables simplification |
max_speed_kmh |
float, 0…400 | per mode | Overrides the free-flow speed used to bound the sampling radius |
min_area_m2 |
float ≥ 0 | 0 |
Drop rings smaller than this |
Unknown fields are rejected (422) rather than silently ignored.
Response: a FeatureCollection with a metadata foreign member. Per-feature
properties are band_index, budget_seconds, budget_minutes, mode,
output, area_m2, area_km2, perimeter_m, equivalent_radius_m,
compactness, components, holes, reachable_samples and sample_share.
Responses carry X-Cache: HIT|MISS and X-Request-ID.
Same parameters plus origins (each with lon, lat and an optional id) and
concurrency (capped by ISOCHRONE_BATCH_CONCURRENCY). See
examples/batch-request.json.
A batch is partially failable: one unroutable origin does not discard the
work already done for the others. The HTTP status stays 200 and each result
carries its own ok flag and error string.
Response shape, with the successful geojson bodies elided for brevity:
{
"count": 3,
"succeeded": 2,
"failed": 1,
"elapsed_ms": 412.7,
"results": [
{ "index": 0, "id": "amsterdam-centraal", "lon": 4.9003, "lat": 52.3791, "ok": true, "geojson": { "type": "FeatureCollection" } },
{ "index": 1, "id": "rotterdam-centraal", "lon": 4.4694, "lat": 51.9244, "ok": false, "geojson": null, "error": "osrm: table response code 'NoSegment'" },
{ "index": 2, "id": "utrecht-centraal", "lon": 5.11, "lat": 52.0894, "ok": true, "geojson": { "type": "FeatureCollection" } }
]
}/healthz never touches the routing engine, so it stays fast and honest as a
liveness probe. / is the map UI. The OpenAPI schema is at /openapi.json and
interactive docs at /docs.
GET / serves one self-contained HTML document: a hand-written slippy map
(tile layer plus SVG overlay, about 200 lines of Web Mercator arithmetic) with
an origin picker, mode and budget controls, a legend showing per-band area and
hole counts, and copy/download actions for the GeoJSON. It is theme-aware,
responsive, and has no build step, no npm, and no third-party JavaScript to
audit — the only external request it makes is for map tiles, from whichever
ISOCHRONE_TILE_URL you configure.
Click the map to move the origin; drag to pan; scroll to zoom.
Run as python -m isochrone_service.cli or via the isochrone-service console
script installed by pip install -e ..
| Flag | Default | Meaning |
|---|---|---|
--origin LAT,LON |
required | Origin, in the order map applications show it |
--minutes FLOAT |
5 10 15 |
Time budget in minutes; repeat for several bands |
--seconds FLOAT |
— | Time budget in seconds; combines with --minutes |
--mode car|bike|foot |
car |
Travel mode |
--output cumulative|bands |
cumulative |
Nested polygons or disjoint rings |
--resolution 4…80 |
from settings | Lattice rings from origin to rim |
--alpha 0.1…20 |
from settings | Alpha radius as a multiple of sample spacing |
--simplify 0…5000 |
spacing ÷ 4 | Douglas–Peucker tolerance in metres |
--max-speed-kmh |
per mode | Override the free-flow speed bound |
--engine osrm|valhalla|fake |
from settings | Routing backend |
--router-url URL |
from settings | Base URL of the routing engine |
--barrier MINLON,MINLAT,MAXLON,MAXLAT |
— | Only with --engine fake: an impassable box. Repeatable |
-o, --out PATH |
stdout | Where to write the GeoJSON |
--pretty / --compact |
--pretty |
Indent the JSON |
--quiet |
off | Suppress the stderr summary |
--host (default 127.0.0.1), --port (default 8000), --reload
(development only).
-o, --out PATH (default isochrone-demo.geojson). Produces a concave, holed
isochrone with the offline engine — useful for checking your rendering pipeline
before a routing server exists.
Every key is an environment variable prefixed ISOCHRONE_, and can also live in
a .env file next to the process. See .env.example.
| Variable | Default | Meaning |
|---|---|---|
ISOCHRONE_ENGINE |
osrm |
osrm, valhalla or fake |
ISOCHRONE_ROUTER_URL |
http://localhost:5000 |
Base URL of the routing engine |
ISOCHRONE_REQUEST_TIMEOUT_S |
30 |
Per-HTTP-request timeout |
ISOCHRONE_MAX_TABLE_SIZE |
100 |
Destinations per matrix call; must not exceed the engine's own cap |
ISOCHRONE_MAX_BUDGETS |
8 |
Time budgets allowed per request |
ISOCHRONE_MAX_BUDGET_SECONDS |
7200 |
Largest single time budget accepted |
ISOCHRONE_MAX_SAMPLES |
4000 |
Upper bound on probe points per isochrone |
ISOCHRONE_DEFAULT_RESOLUTION |
20 |
Default lattice rings |
ISOCHRONE_DEFAULT_ALPHA_FACTOR |
2.0 |
Default concavity |
ISOCHRONE_BATCH_CONCURRENCY |
4 |
Origins in flight at once |
ISOCHRONE_MAX_BATCH_ORIGINS |
100 |
Origins accepted per batch request |
ISOCHRONE_CACHE_SIZE |
256 |
Cached responses (LRU) |
ISOCHRONE_CACHE_TTL_S |
300 |
Cache entry lifetime |
ISOCHRONE_CORS_ORIGINS |
* |
Comma-separated list or a JSON array |
ISOCHRONE_LOG_LEVEL |
INFO |
Root log level |
ISOCHRONE_TILE_URL |
OpenStreetMap | Slippy tile template for the map UI |
ISOCHRONE_TILE_ATTRIBUTION |
© OpenStreetMap contributors |
Attribution shown over the map |
ISOCHRONE_FAKE_SPEED_KMH |
40 |
Car speed for the fake engine |
ISOCHRONE_FAKE_DETOUR_FACTOR |
1.3 |
Distance multiplier for the fake engine |
The limits exist because they are the operator's business: they are what stops
one API caller from turning a shared routing engine into their personal batch
job. Requests that exceed them get a 422 naming the limit, not a timeout.
import asyncio
from isochrone_service.clients import OSRMClient
from isochrone_service.pipeline import IsochroneOptions, compute_isochrone
async def main():
client = OSRMClient("http://localhost:5000", max_table_size=100)
try:
result = await compute_isochrone(
client,
origin=(4.8952, 52.3702), # lon, lat
options=IsochroneOptions(budgets=(300, 600, 900), mode="bike"),
)
finally:
await client.aclose()
print(result.stats["sampling"])
return result.to_geojson()
asyncio.run(main())The geometry package stands alone and imports nothing outside the standard library, so it is usable for any concave-hull problem:
from isochrone_service.geometry import alpha_shape, delaunay, suggest_alpha
points = [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0), (0.5, 0.5)]
polygons = alpha_shape(delaunay(points), suggest_alpha(spacing=1.0))Implement two methods and pass an instance to create_app or
compute_isochrone:
class MyClient:
name = "my-engine"
async def durations(self, origin, destinations, mode) -> list[float | None]:
"""Same length and order as `destinations`; None means unroutable."""
async def aclose(self) -> None:
"""Release transport resources."""None must mean unreachable, never zero — the distinction is what produces
holes instead of spikes.
Worth knowing before you trust a number:
- It is a sampled approximation. The polygon is reconstructed from a finite
lattice, so its boundary is accurate to roughly half a sample spacing. Raise
resolutionfor detail; the matrix cost grows with its square. - Snapping matters. Sample points snap to the nearest routable edge. In sparse rural networks a sample can snap hundreds of metres away, which inflates the shape slightly; in dense grids it is negligible.
- Alpha is a judgement call. Too small and the isochrone grows holes that
are sampling artefacts; too large and real concavity disappears. The default
of twice the sample spacing is conservative. Check
compactnessandholesin the response before believing a surprising shape. - Free-flow speed bounds the search. If your routing profile can exceed the
assumed free-flow speed (
MODE_MAX_SPEED_KMH), the sampling disc clips the isochrone. Passmax_speed_kmhfor unusual profiles. - Spherical, not ellipsoidal. Areas and distances use a sphere of mean Earth radius: about 0.3% off WGS84, an order of magnitude below the sampling error.
- One-way asymmetry. These are from-origin isochrones. Reverse isochrones ("who can reach me") need the transposed matrix and are not implemented.
- The cache is per process. Multiple workers each hold their own. That is fine for a cache, and avoids requiring Redis.
- No population weighting. The statistics are area-based; joining to a population grid is left to you (see the further reading below).
pip install -e ".[dev]"
ruff check . && ruff format --check . && pytest -q249 tests, no network access, under two seconds. CI runs the same commands on Python 3.11, 3.12 and 3.13. The suite covers the empty-circumcircle property on random and degenerate point sets, alpha shapes against known hulls, ring winding and GeoJSON validity, hole detection, Douglas–Peucker fidelity and idempotence, geodesic area against closed-form values, sampling density and coverage, band containment, matrix chunking, and the full HTTP contract including validation errors, batch partial failure and cache hit/miss/expiry.
See CONTRIBUTING.md.
- osrm-quickstart — get an OSRM instance running from an OSM extract.
- batch-route-optimizer — large-scale route and matrix jobs against the same engines.
Background on the techniques this tool implements, and on what to do with the polygons once you have them:
- Generating isochrones with PySAL and GeoPandas — the notebook-shaped version of this pipeline, useful for understanding the trade-offs the service makes on your behalf.
- Creating 15-minute city isochrones in Python — practical guidance on budgets, modes and what a defensible 15-minute analysis actually requires.
- Population-weighted accessibility scoring — how to turn these area-based polygons into people-based numbers.
- Valhalla cost matrix generation for urban planners — tuning the matrix endpoint this service depends on, including the service limits that govern chunk size.
- geospatialrouting.com — the rest of the routing and accessibility material.
MIT. Copyright (c) 2026 geospatialrouting.com.
Maintained by geospatialrouting.com.