Transit accessibility explained: why travel time depends on the minute you leave
On this page
Problem statement
A drive time is a property of two places and the traffic. A transit travel time is a property of two places, the traffic and the exact minute you set off, because the timetable is a set of discrete departures and missing one by a minute can cost the whole headway. Accessibility measures built on transit therefore have a time dimension that street-network measures do not, and ignoring it produces numbers that nobody can reproduce.
The spread is not small. From a point in Portland's Hollywood district, the door-to-door time to downtown on TriMet's Tuesday timetable is anywhere from 23.5 to 35.5 minutes depending on which minute between 07:30 and 08:29 you leave. To Beaverton Transit Center it ranges from 45.0 to 60.0 minutes.
Quick answer
Compute travel time for every departure minute across a window and report a distribution β usually the median β instead of a single departure:
import numpy as np
from transit_csa import build, access, csa, travel_time # the router from the travel-time guide
net = build("trimet_gtfs", "2027-01-26")
origin = access(net, -122.6280, 45.5350) # stops within 800 m of the origin
minutes = [travel_time(net, csa(net, origin, 7 * 3600 + 1800 + m * 60),
-122.6794, 45.5189) # downtown
for m in range(60)]
print(f"min {min(minutes):.1f} median {np.median(minutes):.1f} max {max(minutes):.1f}")
min 23.5 median 29.2 max 35.5
The median over a window is the conventional summary because it describes the trip a rider gets without planning around the timetable. The minimum describes the trip they get if they time it perfectly.
What riders know, a departure window, both walks
1. Decide what a rider knows
A rider who checks the timetable leaves at the right minute and gets close to the minimum. A rider who turns up at random gets something near the median. Most accessibility work assumes the second, and it should say so.
2. Choose a departure window
A window of 60 minutes at one-minute resolution covers every combination of headways up to an hour. Peak and off-peak differ, so choose the window for the question β a commute measure uses the morning peak.
3. Include both walking legs
Door-to-door time is the walk to a stop, the wait, the ride, any transfers and the walk from the last stop. The router in the travel-time guide includes both walks at 1.25 m/s with a detour factor of 1.3 on the straight-line distance.
4. Aggregate to an accessibility measure
A cumulative measure counts destinations reachable within a threshold; a gravity measure weights them by travel time. Either can be computed per departure minute and then summarised, or computed on the median travel time. The two orders give different results; state which.
5. Report the date, the window and the feed version
A transit accessibility score is only reproducible with the feed version, the service date and the departure window alongside it. Without those, two analysts using the same software on the same city will disagree.
Reach minute by minute from four destinations
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 β four destinations, one window
dests = {"downtown": (-122.6794, 45.5189), "Beaverton TC": (-122.8013, 45.4914),
"Clackamas TC": (-122.5673, 45.4351), "airport": (-122.5920, 45.5887)}
for name, (lon, lat) in dests.items():
v = np.array([travel_time(net, csa(net, origin, 7 * 3600 + 1800 + m * 60), lon, lat)
for m in range(60)])
print(f"{name:13} min {v.min():5.1f} median {np.median(v):5.1f} "
f"max {v.max():5.1f} range {v.max() - v.min():4.1f}")
downtown min 23.5 median 29.2 max 35.5 range 12.0
Beaverton TC min 45.0 median 50.2 max 60.0 range 15.0
Clackamas TC min 41.3 median 48.8 max 56.3 range 15.0
airport min 32.2 median 42.2 max 51.2 range 19.0
Example 2 β how many stops are within 30 minutes, minute by minute
for m in range(0, 30, 5):
r = csa(net, origin, 8 * 3600 + m * 60, max_time=1800)
print(f"08:{m:02d} stops reached within 30 min: {len(r):5,}")
08:00 stops reached within 30 min: 704
08:05 stops reached within 30 min: 578
08:10 stops reached within 30 min: 881
08:15 stops reached within 30 min: 575
08:20 stops reached within 30 min: 636
08:25 stops reached within 30 min: 615
A single departure time would have reported any of these, and the largest (881 at 08:10) is 53% bigger than the smallest (575 at 08:15).
Why the timetable makes travel time lumpy
Why the timetable makes travel time lumpy
On a street network, leaving a minute later arrives a minute later. On a timetable, leaving a minute later either costs one minute (you still catch the same bus) or costs the whole gap until the next one (you just missed it). Across a window, travel time forms a sawtooth whose teeth are the headways of every route in the journey, and a transfer compounds two sawtooths.
Why the median, not the mean
The distribution across departure minutes is skewed by the occasional long wait. The median is less sensitive to those and matches what a regular rider experiences on a typical day. Some studies report both, or the 25th percentile as "a rider who plans ahead".
Why the order of aggregation matters
A cumulative score computed per minute and then averaged gives a destination partial credit for the minutes it was reachable. A score computed once on the median travel time gives full credit or none. Near the threshold the two differ; far from it they agree.
Late evenings, transfers and walking speed
- Late evening windows have longer headways and wider distributions; do not reuse a peak window.
- Schedules are not reality. A timetable measure is an upper bound on reliability.
- Transfers need slack. A zero-second transfer is not possible for a rider; see the optimistic-times fix.
- Walking speed matters for older riders and people with mobility impairments; 1.25 m/s is a typical adult pace.
- Edge effects apply at the study-area boundary exactly as they do for street-network accessibility.
- Frequency-based feeds need expansion into explicit departures before a connection scan.
Internal links
- How to compute transit travel times from GTFS in Python β the router used here
- How to draw a transit isochrone from a GTFS feed β the same idea as an area
- Transit travel times come out too short β the assumptions that flatter a network
- Transit frequency and headways explained β where the sawtooth comes from
- Accessibility measures explained β cumulative and gravity measures
- Isochrones explained β the street-network version
FAQ
Why does transit travel time depend on departure time?
Because departures are discrete. Missing one by a minute can cost the whole headway until the next.
Which departure time should I use?
A window of departures, typically 60 minutes at one-minute steps, summarised by the median.
Is the minimum travel time useful?
It describes a rider who times their trip perfectly. Report it alongside the median, not instead of it.
Should I include walking?
Yes, both ends. Door-to-door time is what a rider experiences.
How much does the departure minute matter?
In the example here, 12 to 19 minutes between best and worst departure in a single hour.
What do I need to report for reproducibility?
The feed version, the service date, the departure window and the walking assumptions.