How to measure the population served by frequent transit

On this page

Problem statement

"What share of residents live near frequent transit?" is one of the most quoted numbers in transport planning, and it is built from three choices that are rarely stated together: what counts as frequent, what counts as near, and how "near" is measured. Each choice moves the answer by several percentage points, and together they move it by more than thirty.

For 297,681 residents of inner Portland, on TriMet's Tuesday morning-peak timetable, the share within 400 metres of a stop with a route every 15 minutes or better is 75.5% measured in a straight line and 58.4% measured along the walking network. Within 400 metres of any stop, in a straight line, it is 95.9%.

Quick answer

Classify stops by frequency, compute the walking distance from every census block to the nearest frequent stop in one multi-source search, and sum population below the threshold:

import networkx as nx
import numpy as np
import osmnx as ox
import geopandas as gpd

def population_within(G, nodes, blocks, stop_pts, metres):
    """Population of blocks whose network distance to the nearest stop is <= metres."""
    src = set(ox.distance.nearest_nodes(G, stop_pts.x, stop_pts.y))
    dist = nx.multi_source_dijkstra_path_length(G, src, cutoff=metres, weight="length")
    bn = ox.distance.nearest_nodes(G, blocks.geometry.x, blocks.geometry.y)
    snap = np.hypot(nodes.loc[bn].geometry.x.values - blocks.geometry.x.values,
                    nodes.loc[bn].geometry.y.values - blocks.geometry.y.values)
    d = np.array([dist.get(n, np.inf) for n in bn]) + snap
    return int(blocks.POP20[d <= metres].sum())

The block's own walk to the network is added to its distance, and a single search from all frequent stops gives every node's distance to its nearest one.

The three choices behind a population-near-transit figure. three choices behind one headline numberchoicecommon optionsused herewhat is frequent10, 12 or 15 min; peak or all day≀ 15 min, 07–09what is near400 m bus, 800 m railbothhow near is measuredstraight line or networkbothplus the date, the feed version and the census geographyA figure that states none of these cannot be compared with any other spatialworkflow.io
Three choices, each with defensible options; the headline number is only meaningful with all three stated.

What you get β€” from The data used on this page

TriMet feed 20260927-20261007-0900, valid 20260927 to 20270227

Classify stops, then measure by network from fine zones

1. Classify stops for one date and window

Use the best single-route headway per stop, with the guard against two-bus stops, as in the frequency guide. State the threshold, window and date.

2. Get population at the finest geography available

Census blocks in the US, output areas in the UK. Coarser zones smear population across their whole area and inflate coverage near stops.

3. Represent each zone by a point inside it

representative_point() is guaranteed to fall inside the polygon, unlike a centroid. For large zones, split population by area or by buildings first β€” the dasymetric guide covers that.

4. Measure distance along the network

One multi-source Dijkstra from all frequent stops, cut off at the largest threshold, gives the distance from every network node to its nearest frequent stop.

5. Keep a margin around the study area

Blocks near the edge of the network have their nearest stop outside it. Build the network with a margin and report only blocks well inside.

6. Report the straight-line figure beside the network one

Many published figures use circles. Reporting both shows how much of any difference from them comes from the method rather than the service.

Share of inner Portland residents near frequent transit, by distance threshold and measure. residents near a stop every 15 min or better400 m, straight line75.5%400 m, network58.4%800 m, straight line92.8%800 m, network84.1%050%100%297,681 residents in 6,283 census blocks; TriMet, Tuesday 26 January 2027 spatialworkflow.io
The measure moves the 400-metre figure by seventeen points; the service did not change.

Residents near frequent morning service

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. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.

import io
import os
import zipfile

import pandas as pd
import requests

# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://data.spatialworkflow.io/osm-cache-population-served-by-transit-python-2026-10-10.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

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

The street network is a walking network for inner Portland from OpenStreetMap, built with OSMnx and saved as GraphML. overpass-api.de refuses connections from here, so the query goes to the maps.mail.ru mirror of the same API; OpenStreetMap is edited continuously, so node counts move a little between downloads.

import os

import osmnx as ox

ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"   # overpass-api.de mirror
PDX = (-122.75, 45.46, -122.56, 45.57)                 # west, south, east, north: inner Portland
if not os.path.exists("pdx_walk.graphml"):
    ox.save_graphml(ox.graph_from_bbox(PDX, network_type="walk"), "pdx_walk.graphml")
G_raw = ox.load_graphml("pdx_walk.graphml")
print(f"walk network: {len(G_raw):,} nodes, {G_raw.number_of_edges():,} edges")
walk network: 51,032 nodes, 148,302 edges

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 β€” frequent stops for the morning peak

import pandas as pd

FEED = "trimet_gtfs"
DAYS = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
cal = pd.read_csv(f"{FEED}/calendar.txt", dtype=str)
cd = pd.read_csv(f"{FEED}/calendar_dates.txt", dtype=str)
trips = pd.read_csv(f"{FEED}/trips.txt", dtype=str)
d = pd.Timestamp("2027-01-26"); ds, dow = d.strftime("%Y%m%d"), DAYS[d.dayofweek]
active = (set(cal.loc[(cal[dow] == "1") & (cal.start_date <= ds) & (cal.end_date >= ds), "service_id"])
          | set(cd.loc[(cd.date == ds) & (cd.exception_type == "1"), "service_id"])) \
    - set(cd.loc[(cd.date == ds) & (cd.exception_type == "2"), "service_id"])
day = trips[trips.service_id.isin(active)]

st = pd.read_csv(f"{FEED}/stop_times.txt", usecols=["trip_id", "departure_time", "stop_id", "pickup_type"],
                 dtype={"trip_id": str, "stop_id": str})
st = st[st.trip_id.isin(day.trip_id) & (st.pickup_type != 1)]
p = st.departure_time.str.split(":", expand=True).astype(int)
st["dep"] = p[0] * 3600 + p[1] * 60 + p[2]
st = st[(st.dep >= 7 * 3600) & (st.dep < 9 * 3600)].merge(
    day[["trip_id", "route_id", "direction_id"]], on="trip_id")

def headway(x, window_min=120):
    x = np.sort(x.to_numpy())
    return max(np.median(np.diff(x)) / 60, window_min / len(x)) if len(x) > 1 else np.nan

best = st.groupby(["stop_id", "route_id", "direction_id"]).dep.apply(headway).groupby("stop_id").min()
frequent_ids = set(best[best <= 15].index)
served_ids = set(st.stop_id)
print(len(served_ids), "stops served 07-09;", len(frequent_ids), "frequent")
5729 stops served 07-09; 1919 frequent

Example 2 β€” the network, the blocks and the study area

from shapely.geometry import box

G = ox.project_graph(ox.load_graphml("pdx_walk.graphml"), to_crs=32610)
nodes = ox.graph_to_gdfs(G, edges=False)
xmin, ymin, xmax, ymax = nodes.total_bounds
inner = box(xmin + 1500, ymin + 1500, xmax - 1500, ymax - 1500)

blocks = gpd.read_parquet("blocks_pdx.parquet").to_crs(32610)   # TIGER 2020 blocks, POP20
blocks["geometry"] = blocks.representative_point()
blocks = blocks[blocks.within(inner)]

stops = pd.read_csv(f"{FEED}/stops.txt", dtype=str)
stops = gpd.GeoDataFrame(stops, crs=4326, geometry=gpd.points_from_xy(
    stops.stop_lon.astype(float), stops.stop_lat.astype(float))).to_crs(32610).set_index("stop_id")
print(f"study area {inner.area / 1e6:.0f} kmΒ², {len(blocks):,} blocks, "
      f"{int(blocks.POP20.sum()):,} residents")
study area 110 kmΒ², 6,283 blocks, 297,681 residents

Example 3 β€” four answers to one question

from scipy.spatial import cKDTree

total = blocks.POP20.sum()
for label, ids in [("any stop", served_ids), ("frequent stop", frequent_ids)]:
    pts = stops.loc[sorted(ids)].geometry
    tree = cKDTree(np.c_[pts.x, pts.y])
    straight, _ = tree.query(np.c_[blocks.geometry.x, blocks.geometry.y])
    for m in (400, 800):
        s = blocks.POP20[straight <= m].sum()
        n = population_within(G, nodes, blocks, pts, m)
        print(f"{label:13} {m} m: straight line {s / total:6.1%}   network {n / total:6.1%}")
any stop      400 m: straight line  95.9%   network  81.2%
any stop      800 m: straight line  99.9%   network  98.6%
frequent stop 400 m: straight line  75.5%   network  58.4%
frequent stop 800 m: straight line  92.8%   network  84.1%

Straight lines overstate coverage

Why straight lines overstate coverage

A circle credits every block within 400 metres as the crow flies, including blocks across a river, a freeway or a rail yard. Walking distance is always at least the straight-line distance, and on a grid it is typically a fifth to two-fifths longer. At 400 metres that difference moves a large share of blocks over the threshold; at 800 metres, where most blocks in a dense city are well inside, it matters less.

Why the frequency definition matters more than it seems

Frequent stops are a subset of all stops, and they cluster on the arterials. A block can be 150 metres from a local stop and 700 metres from a frequent one. That is why the gap between "any stop" and "frequent stop" is largest at 400 metres, where the arterial spacing starts to show.

Why the finest geography matters

Coverage is decided block by block. With block groups or tracts, every resident of a zone is placed at one point, and a zone straddling the threshold is counted entirely in or entirely out. Blocks are small enough in a city that this error mostly cancels; tracts are not.

A census block close to a local stop but far from a frequent stop on an arterial. distance to any stoplocal stopblock150 m: covereddistance to frequent stopfrequent stop on arterial700 m: not covered at 400Illustrative distances: frequent service clusters on arterials, so the gap opens at 400 m spatialworkflow.io
Near a stop and near frequent service are different claims, and the difference is widest at 400 metres.

Residents only, block points and census vintage

  • Population is residents. Jobs, students and visitors need other sources.
  • Block points misplace population in large rural blocks; split them first.
  • The network margin must exceed the distance threshold, or edge blocks look unserved.
  • Pedestrian barriers such as unmarked freeway crossings may exist in OSM but not in practice.
  • Frequency in the evening is often much lower; report more than one window.
  • Census vintage should match the question; 2020 blocks are the latest full count in the US.

FAQ

How do I calculate the population near frequent transit?

Classify stops by headway, compute each block's walking distance to the nearest frequent stop with a multi-source Dijkstra, and sum population below the threshold.

Should I use 400 m or 800 m?

Both are used: 400 metres for bus stops and 800 metres for rail stations is a common convention. State which.

Why is my network-based figure lower than published ones?

Many published figures use straight-line buffers, which overstate walkable coverage.

What counts as frequent?

A threshold you choose and state β€” commonly every 15 minutes or better in the peak.

Which census geography should I use?

The finest available: blocks in the US, output areas in the UK.

Do I need a margin around the study area?

Yes, at least the distance threshold, or blocks near the edge look unserved.