Skip to content

Repository files navigation

isochrone-service

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.


The problem

"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.

What it produces

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.

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
└─────────────────────┘

The algorithms, and why they are here

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.

Why one triangulation serves every band

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.

Install

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 tests

Requires 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.

Worked example

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.

One command, one file

export ISOCHRONE_ENGINE=fake
python -m isochrone_service.cli demo -o examples/amsterdam-bike-demo.geojson
engine=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.

Against a real engine

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.geojson

The 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.

As a service

export ISOCHRONE_ENGINE=fake
python -m isochrone_service.cli serve --port 8000
# or: uvicorn isochrone_service.app:build --factory --port 8000

Then 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}

HTTP API

POST /isochrone

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.

POST /isochrones/batch

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" } }
  ]
}

GET /healthz, GET /version, GET /

/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.

The map UI

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.

CLI reference

Run as python -m isochrone_service.cli or via the isochrone-service console script installed by pip install -e ..

generate

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

serve

--host (default 127.0.0.1), --port (default 8000), --reload (development only).

demo

-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.

Configuration

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.

Using it as a library

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))

Adding a routing engine

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.

Limitations

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 resolution for 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 compactness and holes in 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. Pass max_speed_kmh for 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).

Development

pip install -e ".[dev]"
ruff check . && ruff format --check . && pytest -q

249 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.

Related tools

Further reading

Background on the techniques this tool implements, and on what to do with the polygons once you have them:

License

MIT. Copyright (c) 2026 geospatialrouting.com.


Maintained by geospatialrouting.com.

About

Self-hosted isochrone service for OSRM and Valhalla — multi-band reachability polygons via pure-Python alpha shapes, a GeoJSON API, and a zero-build map UI.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages