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.

Minimum, median and maximum transit travel time from Hollywood to four destinations, departing each minute 07:30–08:29. door-to-door minutes, 60 departure minutes eachdowntown23.535.529.2airport32.251.242.2Clackamas TC41.356.348.8Beaverton TC456050.220 min30 min40 min50 min60 minDot: median departure. Bar: best to worst minute. TriMet, Tuesday 26 January 2027. spatialworkflow.io
The range between best and worst departure is 12 to 19 minutes to every destination.

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.

The legs of a door-to-door transit journey. door to door, leg by legwalk to stopdistance Γ— detourwaitset by departureminuteridethe timetabletransferwalk + second waitride, thenwalkto the doorThe two amber legs are the ones a single departure time hides spatialworkflow.io
Only the ride appears in the timetable; the wait is where the departure minute enters.

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.

Travel time against departure minute for a direct 15-minute route and for a journey with a transfer. direct route, every 15 minjust missed: +15departure minute β†’with a transfertwo sets of teethdeparture minute β†’Illustrative shapes: every missed departure adds its headway, and transfers compound them spatialworkflow.io
Each missed departure adds a whole headway; a transfer adds a second sawtooth on top.

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.

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.