How to draw a transit isochrone from a GTFS feed

On this page

Problem statement

A transit isochrone — everywhere reachable within 30 minutes by public transport — looks like a street-network isochrone with holes in it: blobs of walkable area around every stop the rider can reach in time, strung along the lines. It is also much less stable. Shift the departure by five minutes and the shape changes, because a different set of buses is caught.

From a point in Portland's Hollywood district on TriMet's Tuesday timetable, the 30-minute isochrone covers anything from 28 to 50 square kilometres depending on which five-minute departure you draw. A single isochrone map shows one draw from that range and presents it as the answer.

Quick answer

Route from the origin with the connection scan, then around every stop reached draw a circle whose radius is the walking distance left in the budget:

import numpy as np
import geopandas as gpd
from shapely.geometry import Point
from transit_csa import build, access, csa

net = build("trimet_gtfs", "2027-01-26")
ORIGIN = (-122.6280, 45.5350)
origin = access(net, *ORIGIN)
stops = net["stops"].set_index("stop_id")

def transit_isochrone(t0, budget_s, max_walk_m=800):
    reached = csa(net, origin, t0, max_time=budget_s)
    speed = net["walk_speed"] / net["detour"]            # effective metres per second
    discs = [Point(stops.x[s], stops.y[s]).buffer(min((budget_s - t) * speed, max_walk_m))
             for s, t in reached.items() if t < budget_s]
    ox_, oy_ = net["to_m"].transform(*ORIGIN)
    discs.append(Point(ox_, oy_).buffer(min(budget_s * speed, 2000)))   # walking only
    return gpd.GeoSeries(discs, crs=32610).union_all()

iso = transit_isochrone(8 * 3600, 1800)
print(f"30-min isochrone leaving 08:00: {iso.area / 1e6:.1f} km²")
30-min isochrone leaving 08:00: 35.5 km²

The walking discs are capped at 800 metres: nobody gets off a bus and walks for twenty minutes, and without the cap the isochrone fills with huge circles around stops reached early.

Building a transit isochrone: reached stops, walking discs sized by time left, and their union. stops reached, time left1281062minutes left of 30a disc per stopradius = time left × speedunion = isochroneholes between stops are realThe dashed disc is the walk-only area from the origin, which fills the centre spatialworkflow.io
A transit isochrone is a union of walking discs, each sized by the time left when that stop was reached.

Route, size discs, cap the walk, union

1. Route from the origin

Run the connection scan with max_time equal to the budget. The result is the earliest arrival at every stop reachable in time.

2. Size a disc around each reached stop

The walking time left is the budget minus the arrival time. Multiply by walking speed and divide by the detour factor to get a straight-line radius.

3. Cap the walk

Cap the radius at a realistic walking distance. 800 metres is common; use less for older riders.

4. Add the walk-only area

Include the area reachable on foot from the origin without transit, or the isochrone has a hole at its own centre.

5. Union

Dissolve the discs into one polygon. For a smoother boundary, walk the street network from each stop instead of drawing circles, as in the catchment guide.

6. Draw several departures, not one

Compute the isochrone for each departure minute across a window and either show the range or show how often each place is reachable.

30-minute transit isochrone area from Hollywood for six departures five minutes apart. 30-minute isochrone area, Hollywood, Tuesday08:0035.5 km²08:0534.1 km²08:1050.4 km² — largest08:1531.6 km²08:2030.6 km²08:2527.6 km² — smallest030 km²60 km²Leave at 08:10 instead of 08:25 and the area reachable nearly doubles spatialworkflow.io
The same origin and budget, six departures, and areas that differ by almost a factor of two.

Isochrones at six departures and two budgets

The data used on this page

TriMet's GTFS feed for Portland, Oregon, downloaded from developer.trimet.org and unzipped to trimet_gtfs/. TriMet republishes it every few weeks, so counts of trips and stop times move between versions; the setup prints the version the numbers on this page came from.

import io
import os
import zipfile

import pandas as pd
import requests

if not os.path.exists("trimet_gtfs"):
    feed = requests.get("https://developer.trimet.org/schedule/gtfs.zip", timeout=600)
    zipfile.ZipFile(io.BytesIO(feed.content)).extractall("trimet_gtfs")
info = pd.read_csv("trimet_gtfs/feed_info.txt", dtype=str).iloc[0]
print(f"TriMet feed {info.feed_version}, valid {info.feed_start_date} to {info.feed_end_date}")
TriMet feed 20260927-20261007-0900, valid 20260927 to 20270227

transit_csa.py is the connection-scan router from the travel-time guide, saved as a module.

%%writefile transit_csa.py
"""A small connection-scan router over one service day of a GTFS feed."""
import numpy as np
import pandas as pd
from pyproj import Transformer
from scipy.spatial import cKDTree

DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]


def services_on(feed, date):
    cal = pd.read_csv(f"{feed}/calendar.txt", dtype=str)
    cd = pd.read_csv(f"{feed}/calendar_dates.txt", dtype=str)
    d = pd.Timestamp(date)
    ds, dow = d.strftime("%Y%m%d"), DAYS[d.dayofweek]
    weekly = set(cal.loc[(cal[dow] == "1") & (cal.start_date <= ds) &
                         (cal.end_date >= ds), "service_id"])
    ex = cd[cd.date == ds]
    return (weekly | set(ex.loc[ex.exception_type == "1", "service_id"])) \
        - set(ex.loc[ex.exception_type == "2", "service_id"])


def to_secs(s):
    p = s.str.split(":", expand=True).astype(int)
    return p[0] * 3600 + p[1] * 60 + p[2]


def build(feed, date, crs=32610, walk_speed=1.25, detour=1.3, max_transfer_m=400):
    """Connections for one service day, plus walking links between nearby stops."""
    trips = pd.read_csv(f"{feed}/trips.txt", dtype=str)
    trips = trips[trips.service_id.isin(services_on(feed, date))]
    st = pd.read_csv(f"{feed}/stop_times.txt",
                     usecols=["trip_id", "arrival_time", "departure_time", "stop_id",
                              "stop_sequence", "pickup_type", "drop_off_type"],
                     dtype={"trip_id": str, "stop_id": str})
    st = st[st.trip_id.isin(trips.trip_id)].sort_values(["trip_id", "stop_sequence"])
    st["dep"], st["arr"] = to_secs(st.departure_time), to_secs(st.arrival_time)
    nxt = st.groupby("trip_id").shift(-1)
    c = pd.DataFrame({"from": st.stop_id, "to": nxt.stop_id, "dep": st.dep,
                      "arr": nxt.arr, "trip": st.trip_id,
                      "board": st.pickup_type != 1, "alight": nxt.drop_off_type != 1})
    c = c.dropna(subset=["to"]).sort_values("dep").reset_index(drop=True)

    stops = pd.read_csv(f"{feed}/stops.txt", dtype=str)
    stops = stops[stops.location_type.fillna("0").isin(["0", ""])].copy()
    to_m = Transformer.from_crs(4326, crs, always_xy=True)
    stops["x"], stops["y"] = to_m.transform(stops.stop_lon.astype(float).values,
                                            stops.stop_lat.astype(float).values)
    tree = cKDTree(stops[["x", "y"]].values)
    pairs = tree.query_pairs(max_transfer_m, output_type="ndarray")
    ids, xy = stops.stop_id.values, stops[["x", "y"]].values
    secs = np.hypot(*(xy[pairs[:, 0]] - xy[pairs[:, 1]]).T) * detour / walk_speed
    walk = {}
    for (i, j), w in zip(pairs, secs):
        walk.setdefault(ids[i], []).append((ids[j], w))
        walk.setdefault(ids[j], []).append((ids[i], w))
    return {"conn": c, "walk": walk, "stops": stops, "tree": tree, "to_m": to_m,
            "walk_speed": walk_speed, "detour": detour}


def access(net, lon, lat, max_walk_m=800):
    """Stops within walking distance of a point, with walking time in seconds."""
    x, y = net["to_m"].transform(lon, lat)
    s = net["stops"].iloc[net["tree"].query_ball_point([x, y], max_walk_m)]
    secs = np.hypot(s.x - x, s.y - y) * net["detour"] / net["walk_speed"]
    return dict(zip(s.stop_id, secs))


def csa(net, sources, t0, max_time=5400, transfer_slack=0):
    """Earliest arrival (seconds after t0) at every stop reachable within max_time."""
    c = net["conn"]
    fr, to, dep, arr = c["from"].values, c["to"].values, c.dep.values, c.arr.values
    trip, board, alight = c.trip.values, c.board.values, c.alight.values
    walk, INF = net["walk"], float("inf")
    best = {}

    def reach(stop, t):
        if t < best.get(stop, INF):
            best[stop] = t
            for other, w in walk.get(stop, ()):
                if t + w < best.get(other, INF):
                    best[other] = t + w

    for stop, w in sources.items():
        reach(stop, t0 + w)
    on_board = set()
    i, end = np.searchsorted(dep, t0), t0 + max_time
    while i < len(dep) and dep[i] <= end:
        if trip[i] in on_board or (board[i] and best.get(fr[i], INF) + transfer_slack <= dep[i]):
            on_board.add(trip[i])
            if alight[i]:
                reach(to[i], arr[i])
        i += 1
    return {s: t - t0 for s, t in best.items() if t - t0 <= max_time}


def travel_time(net, result, lon, lat, max_walk_m=800):
    """Door-to-door minutes from a csa() result to a destination point."""
    tail = access(net, lon, lat, max_walk_m)
    t = min((result[s] + w for s, w in tail.items() if s in result), default=float("inf"))
    return t / 60
Writing transit_csa.py

Example 1 — six departures, two budgets

for budget in (1800, 2700):
    areas = [transit_isochrone(8 * 3600 + m * 60, budget).area / 1e6 for m in range(0, 30, 5)]
    print(f"{budget // 60} min:", " ".join(f"{a:5.1f}" for a in areas), "km²")
30 min:  35.5  34.1  50.4  31.6  30.6  27.6 km²
45 min: 139.8 111.2 150.1 122.3 119.6 126.2 km²

Example 2 — how often each place is reachable

from transit_csa import travel_time

xs = np.arange(-122.74, -122.52, 0.005)
ys = np.arange(45.46, 45.60, 0.0035)
grid = [(x, y) for x in xs for y in ys]
runs = [csa(net, origin, 8 * 3600 + m * 60, max_time=1800) for m in range(0, 60, 2)]
share = np.array([np.mean([travel_time(net, r, x, y) <= 30 for r in runs]) for x, y in grid])
cell_km2 = (0.005 * 78.0) * (0.0035 * 111.2)          # degrees to km at 45.5° N
print(f"cells: {len(grid)}; reachable in any departure: {(share > 0).sum() * cell_km2:.0f} km²; "
      f"in half: {(share >= 0.5).sum() * cell_km2:.0f} km²; in all: {(share == 1).sum() * cell_km2:.0f} km²")
cells: 1804; reachable in any departure: 73 km²; in half: 32 km²; in all: 13 km²

The area reachable at least half the time is the honest single map. The "any departure" area is what a rider who plans perfectly around the timetable gets, and it is much larger.

Example 3 — save the isochrones with their parameters

out = gpd.GeoDataFrame(
    {"minutes": [30, 45], "departure": "08:00", "service_date": "2027-01-26"},
    geometry=[transit_isochrone(8 * 3600, 1800), transit_isochrone(8 * 3600, 2700)],
    crs=32610)
out.to_crs(4326).to_file("hollywood_transit_isochrones.gpkg")
print(out.assign(km2=out.area / 1e6)[["minutes", "departure", "km2"]].round(1).to_string(index=False))
 minutes departure   km2
      30     08:00  35.5
      45     08:00 139.8

Holes, minutes and reach shares

Why transit isochrones have holes

Between stops, the only way to reach a place is to walk from a stop. A stop reached with little time left contributes a small disc, and places between widely spaced stops on an express route are not reachable at all even though the route runs past them. The holes are real and are what distinguishes transit access from driving access.

Why the shape depends on the minute

Catching a bus two minutes after it leaves costs a whole headway. Near the edge of the budget, that decides whether whole branches of the network are reached. The effect is largest for budgets close to the typical journey time and for origins served by infrequent routes.

Why the reach-share map is better than one isochrone

A reader looking at an isochrone assumes it describes the area they can reach. With transit, that is only true for one departure time. Showing the share of departures that reach each place turns the uncertainty into part of the map, and the 50% contour is a defensible "typical" isochrone.

Area reachable within 30 minutes in any, half, or all of thirty departures from Hollywood. reachable within 30 min, 30 departures 08:00–08:58in any departure73 km²÷ 2.3in at least half32 km²÷ 2.5in every one13 km²The middle band is the defensible single map; the top one needs perfect timing spatialworkflow.io
"Reachable in 30 minutes" is three different areas, depending on how often it has to be true.

Circles versus network walking and late departures

  • Circles vs network walking. Circles overstate the walkable area; network catchments are better near rivers and freeways.
  • Budget near journey times gives the most unstable shapes.
  • Late-night departures have longer headways and much smaller isochrones.
  • Grid resolution in the reach-share map trades speed for detail; 400–500 m cells suit a city.
  • Edge of the feed. An isochrone that reaches the feed's boundary is truncated there.
  • Store the departure time in the file; an isochrone without it is not reproducible.

FAQ

How do I make a public transport isochrone in Python?

Route with a connection scan from the origin, then union a walking disc around every reached stop, sized by the time left and capped at a realistic walk.

Why does my transit isochrone change with departure time?

Because departures are discrete. A few minutes' shift changes which buses are caught.

What departure time should I use?

Several: compute each minute across a window and show the range or the share of departures that reach each place.

Why cap the walking distance?

Without a cap, stops reached early get enormous discs that no rider would walk.

Why are there holes in the isochrone?

Places between stops can only be reached on foot from a stop; if no stop near them is reached in time, they are not reachable.

Circles or network catchments?

Network catchments are more accurate; circles are faster and fine for a first look.