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.

Mean door-to-door time over 25 Portland pairs as each optimistic assumption is replaced. mean minutes, 25 origin–destination pairsall optimistic43.7 min+ median minute53.3 (+9.6)+ realistic walking57.9 (+4.6)+ 2 min transfer slack62.6 (+4.6)035 min70 minThe waiting assumption is the largest single step; together they add 43% spatialworkflow.io
Each assumption adds a few minutes; together they add twenty.

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.

Four optimistic assumptions in transit travel times and their realistic replacements. optimistic → realisticassumptionreplace withbest departure minutemedian over a 60-minute windowstraight-line walk at 1.4 m/s1.25 m/s with a 1.3 detourzero-second transfersone to two minutes of slackboarding at drop-off-only stopsrespect pickup and drop-off typesNone of the four adds a cost, which is why their errors never cancel spatialworkflow.io
Four assumptions that each look reasonable, and all err in the same direction.

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.

A transfer with zero slack against the same transfer with two minutes of slack. zero slackarrive 08:12depart 08:12connection made: 0 min waittwo minutes of slackarrive 08:12missed 08:12catch 08:27next bus: +15 minIllustrative times: slack costs nothing or a whole headway, which is why 2 minutes added 5 spatialworkflow.io
Slack costs either nothing or a whole headway, never just the two minutes.

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

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.