Transit travel times come out too short
On this page
Problem statement
Your transit travel-time matrix looks good — suspiciously good. Journeys that take riders an hour come out at forty minutes, and accessibility scores are higher than any published figure for the same city. Nothing is broken; each assumption in the calculation was reasonable on its own, and every one of them errs in the same direction.
On 25 random origin–destination pairs in Portland, the most optimistic common set of assumptions — the best departure minute, straight-line walking at 1.4 m/s, and instant transfers — gives a mean door-to-door time of 43.7 minutes. The same pairs under realistic assumptions — the median departure minute, walking along a detour at 1.25 m/s, and two minutes to make a transfer — take 62.6 minutes, 43% longer.
Quick answer
Summarise over a departure window with the median, walk at a realistic speed with a detour factor, and give transfers slack:
import numpy as np
from transit_csa import build, access, csa, travel_time
realistic = build("trimet_gtfs", "2027-01-26", walk_speed=1.25, detour=1.3)
def door_to_door(net, o, d, slack=120, minutes=range(0, 60)):
src = access(net, *o)
times = [travel_time(net, csa(net, src, 7 * 3600 + 1800 + m * 60, max_time=7200,
transfer_slack=slack), *d) for m in minutes]
return np.median(times), min(times)
med, best = door_to_door(realistic, (-122.6280, 45.5350), (-122.8013, 45.4914))
print(f"Hollywood -> Beaverton TC: median {med:.1f} min, best minute {best:.1f} min")
Hollywood -> Beaverton TC: median 54.1 min, best minute 49.3 min
Report the median as the travel time. Report the best minute only as what a rider who plans around the timetable can achieve.
Median departures, street walks, transfer slack, boarding rules
1. Stop taking the best departure
The minimum over a departure window is the travel time of a rider who arrives at the stop exactly as the bus does. The median describes the rider who does not. On these pairs the difference alone is about ten minutes.
2. Walk along the streets, at a realistic pace
Straight-line walking at 1.4 m/s — a brisk adult pace — assumes a path through every building. A detour factor of 1.2–1.4 and 1.2–1.3 m/s is closer to real walking, and much closer for older riders.
3. Give transfers time
A zero-second transfer lets a rider step off one bus and onto another that leaves at the same second. Real transfers need a minute or two, more at large stations.
4. Respect boarding rules
Exclude connections from stops where pickup_type = 1 and to stops where drop_off_type = 1. A router that boards at drop-off-only stops finds journeys nobody can make.
5. Check against a trip planner
Pick five journeys and compare with the agency's own planner for the same date and time. If yours are consistently shorter, one of the assumptions above is still optimistic.
6. Publish the assumptions
Walking speed, detour factor, transfer slack, departure window and the summary statistic belong next to any travel-time figure.
Random pairs under three sets of assumptions
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
Population comes from the 2020 Census: TIGER/Line block polygons for Oregon, which carry the POP20 count, kept for the three counties TriMet serves.
import os
import geopandas as gpd
TIGER = "https://www2.census.gov/geo/tiger/TIGER2020/TABBLOCK20/tl_2020_41_tabblock20.zip"
if not os.path.exists("blocks_pdx.parquet"): # Oregon, about 300 MB, once
blocks_or = gpd.read_file(TIGER, columns=["GEOID20", "COUNTYFP20", "POP20", "HOUSING20"])
blocks_or[blocks_or.COUNTYFP20.isin(["005", "051", "067"])].to_parquet("blocks_pdx.parquet")
b = gpd.read_parquet("blocks_pdx.parquet")
print(f"{len(b):,} blocks in Clackamas, Multnomah and Washington counties, {int(b.POP20.sum()):,} residents")
29,644 blocks in Clackamas, Multnomah and Washington counties, 1,837,201 residents
Example 1 — random pairs under three sets of assumptions
import geopandas as gpd
blocks = gpd.read_parquet("blocks_pdx.parquet")
blocks = blocks[(blocks.COUNTYFP20 == "051") & (blocks.POP20 > 0)].to_crs(4326)
rng = np.random.default_rng(42)
pts = blocks.representative_point().iloc[rng.choice(len(blocks), 60, replace=False)]
pairs = [((pts.iloc[i].x, pts.iloc[i].y), (pts.iloc[i + 1].x, pts.iloc[i + 1].y))
for i in range(0, 60, 2)]
optimistic = build("trimet_gtfs", "2027-01-26", walk_speed=1.4, detour=1.0)
rows = []
for o, d in pairs:
a_med, a_best = door_to_door(optimistic, o, d, slack=0)
b_med, b_best = door_to_door(realistic, o, d, slack=0)
c_med, c_best = door_to_door(realistic, o, d, slack=120)
rows.append([a_best, a_med, b_best, b_med, c_best, c_med])
r = np.array(rows)
r = r[np.isfinite(r).all(axis=1)]
labels = ["straight walk, no slack", "detour walk, no slack", "detour walk, 2 min slack"]
print(len(r), "pairs reachable within two hours")
for i, lab in enumerate(labels):
print(f"{lab:26} best minute {r[:, 2 * i].mean():5.1f} median minute {r[:, 2 * i + 1].mean():5.1f}")
25 pairs reachable within two hours
straight walk, no slack best minute 43.7 median minute 53.3
detour walk, no slack best minute 48.0 median minute 57.9
detour walk, 2 min slack best minute 52.2 median minute 62.6
Thirty pairs were drawn; five had no connection within two hours under the optimistic assumptions and are left out of every column, so each row averages the same 25 journeys.
Example 2 — which assumption matters most
base = r[:, 0].mean()
steps = {"best -> median minute": r[:, 1].mean() - base,
"walking realism": r[:, 3].mean() - r[:, 1].mean(),
"2 min transfer slack": r[:, 5].mean() - r[:, 3].mean()}
for k, v in steps.items():
print(f"{k:24} +{v:4.1f} min")
print(f"total +{r[:, 5].mean() - base:4.1f} min ({r[:, 5].mean() / base - 1:.0%})")
best -> median minute + 9.6 min
walking realism + 4.6 min
2 min transfer slack + 4.6 min
total +18.8 min (43%)
Every error points the same way
Why every error points the same way
Each simplification removes a cost — waiting, walking distance, transfer time — and none of them adds one. A minimum removes the wait; straight-line walking removes the detour; zero slack removes the time between vehicles. Stacked, they describe a rider with perfect timing who walks through walls and teleports between platforms.
Why the median departure is the honest default
Travel time over a departure window is a sawtooth, and its minimum is the tip of one tooth. Most riders do not time their departures to the minute, and even those who do cannot for every trip. The median is the travel time of a typical departure, and it is what published accessibility studies conventionally report.
Why transfer slack matters more than its size
Two minutes looks small against a 60-minute journey. But slack decides whether a transfer is made at all: a connection that needed zero seconds now misses the bus and waits a whole headway. That is why two minutes of slack adds more than two minutes on average.
Peaks, realtime delays and large stations
- Peak vs off-peak windows differ; do not compare a peak median with an off-peak one.
- Realtime delays make real journeys longer still; timetable figures are a lower bound.
- Large stations need more than two minutes of slack; use
transfers.txtminimum transfer times where given. - Walking speed for accessibility studies of older residents is often 0.9–1.0 m/s.
- Maximum walk also matters: an 800 m cap on access excludes some long walks a router might otherwise use.
- Compare like with like. Published figures differ in all of these; check before comparing.
Internal links
- Transit accessibility explained — why departure time matters
- How to compute transit travel times from GTFS in Python — the router used here
- Transit frequency and headways explained — where waiting comes from
- Network distance vs straight-line distance explained — the walking detour
- How to draw a transit isochrone from a GTFS feed — the same assumptions as an area
- Street network routing returns absurd distances — the opposite failure
FAQ
Why are my transit travel times shorter than a journey planner's?
Usually a combination of taking the best departure minute, straight-line walking and zero transfer time.
Which statistic should I report?
The median over a departure window, with the best minute alongside if useful.
What walking speed should I use?
About 1.2–1.3 m/s with a detour factor of 1.2–1.4; slower for older populations.
How much transfer time should I allow?
One to two minutes at ordinary stops, more at large stations.
How much do these assumptions matter?
On the pairs here, together they add 43% to the mean travel time.
How do I check my results?
Compare a handful of journeys with the agency's trip planner for the same date and time.