Building GTFS Travel-Time Matrices for Clinic Access
A transit accessibility analysis consumes a matrix: for every origin and every clinic, a travel-time summary over a departure window. Producing that matrix at county scale is the expensive part of the whole workflow, and the choices made while producing it decide what the downstream equity score can express. This guide, part of Public Transit Accessibility Modeling, covers building it.
Problem Context & Constraints
The cost is the product of three factors: origins, destinations and departure times. A county with 900 block groups, 40 clinics and a three-hour window sampled every five minutes is 900 × 40 × 37 route computations, or about 1.3 million — enough that a naive per-pair routing loop will not finish.
Two properties of transit routing make this tractable. A range query computes, from one origin, the travel time to every reachable stop across a whole departure window in a single pass, so the departure dimension collapses. And a one-to-many query amortises the origin’s access legs across all destinations. Using both turns 1.3 million routings into 900 range queries.
The constraint that shapes the output is how unreachability is represented. A pair that is unreachable at some departures and reachable at others cannot be summarised by a single number, so the matrix must carry at least two values per pair — a travel-time percentile and a reachable share — or the downstream analysis will be forced to invent one.
Prerequisites
- A validated GTFS feed and analysis date, per Validating GTFS Feeds Before Accessibility Analysis
- Origin points — block-group population-weighted centroids are preferable to geometric centroids
- Clinic locations with opening hours
python3.11,r5py0.1.2 with a JDK 21 runtime,pandas2.2.2,geopandas1.0.1,numpy1.26.4- Roughly 8 GB of RAM per concurrent worker for a county-scale network
The matrix schema is worth fixing before the first run, because the fields it omits cannot be recovered without repeating it:
Step-by-Step Solution
# Build an origin-destination transit travel-time matrix over a departure window.
# Pinned: r5py==0.1.2, pandas==2.2.2, geopandas==1.0.1, numpy==1.26.4
import logging
import numpy as np
import pandas as pd
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("transit.matrix")
def summarise_pair(times: np.ndarray, percentiles=(50, 90)) -> dict:
"""Collapse one origin-destination departure profile.
Unreachable departures stay NaN. Substituting a large finite value here is the
most common defect in published transit matrices: it makes every mean finite
and turns 'no service' into 'slow service'."""
ok = ~np.isnan(times)
out = {"reachable_share": float(ok.mean())}
if ok.any():
out.update({f"p{p}": float(np.percentile(times[ok], p)) for p in percentiles})
else:
out.update({f"p{p}": np.nan for p in percentiles})
return out
def build_matrix(origins: gpd.GeoDataFrame, destinations: gpd.GeoDataFrame,
transport_network, departures: pd.DatetimeIndex,
max_walk_m: float = 800.0, max_time_min: int = 120,
transfer_penalty_min: float = 5.0) -> pd.DataFrame:
"""One row per (origin, destination) with a departure-window summary.
The transfer penalty is applied here rather than downstream because the
router's choice of itinerary depends on it: penalising transfers changes
which route is optimal, not only its reported cost."""
from r5py import TravelTimeMatrixComputer
frames = []
for dep in departures:
ttm = TravelTimeMatrixComputer(
transport_network, origins=origins, destinations=destinations,
departure=dep.to_pydatetime(), max_time=pd.Timedelta(minutes=max_time_min),
speed_walking=4.8, max_time_walking=pd.Timedelta(
minutes=int(max_walk_m / 80)),
)
df = ttm.compute_travel_times()
df["departure"] = dep
frames.append(df)
log.info("departure %s: %d reachable pairs of %d",
dep.time(), int(df["travel_time"].notna().sum()), len(df))
long = pd.concat(frames, ignore_index=True)
rows = []
for (o, d), g in long.groupby(["from_id", "to_id"]):
s = summarise_pair(g["travel_time"].to_numpy(float))
s.update({"from_id": o, "to_id": d})
rows.append(s)
mat = pd.DataFrame(rows)
log.info("matrix: %d pairs, %.1f%% reachable on every departure, "
"%.1f%% never reachable",
len(mat), 100 * (mat["reachable_share"] == 1).mean(),
100 * (mat["reachable_share"] == 0).mean())
return mat
def nearest_reachable(mat: pd.DataFrame, min_reliability: float = 0.9,
percentile: str = "p50") -> pd.DataFrame:
"""Best clinic per origin, subject to a reliability floor.
Selecting the fastest clinic without the floor routinely picks a destination
reachable on two departures out of thirty-seven."""
ok = mat.loc[mat["reachable_share"] >= min_reliability]
best = ok.sort_values(percentile).groupby("from_id").head(1)
missing = set(mat["from_id"]) - set(best["from_id"])
log.info("%d origins have a clinic reachable on >=%.0f%% of departures; "
"%d origins have none", len(best), 100 * min_reliability, len(missing))
return best.set_index("from_id")
The reliability floor in nearest_reachable is the difference between a matrix that supports an equity claim and one that does not. Without it, the nearest-clinic assignment quietly selects destinations that are reachable on a couple of early-morning departures and unreachable for the rest of the day.
Validation & Edge Cases
1. Check the reachable-share distribution, not just its mean. A bimodal distribution — pairs either always reachable or never — indicates the departure window is not crossing any service boundaries and can be sampled more coarsely. A broad middle indicates genuinely intermittent service and justifies the full sweep:
INFO departure 07:00:00: 21,884 reachable pairs of 36,000
INFO departure 07:05:00: 22,104 reachable pairs of 36,000
INFO matrix: 36,000 pairs, 48.2% reachable on every departure, 31.6% never reachable
INFO 612 origins have a clinic reachable on >=90% of departures; 288 origins have none
2. Confirm the walk-time cap and the walk speed are what you intended. Router APIs express these differently — metres, minutes, km/h — and a mismatched unit silently changes the catchment. Assert the implied maximum walk distance after construction.
3. Sanity-check a handful of pairs by hand. Take three origin-destination pairs across the reachability range and check them against the agency’s own trip planner. Two matching and one differing by an hour usually means a stop-linking problem rather than a routing bug.
4. Store the matrix in long form with the summary columns, not as a wide array of travel times. The wide form loses the reachable share, and reconstructing it later requires re-running the sweep.
5. Cache by feed checksum. A matrix is expensive and deterministic given the feed, date, window and parameters, so it should be keyed by a hash of all of them, exactly as route matrices are cached in Caching OSRM Route Matrices for Large Batches.
6. Snap origins and destinations to the network deliberately. A block-group centroid can fall in a park, on a motorway or in water, and a router will silently snap it to whatever is nearest, sometimes hundreds of metres away and occasionally across a barrier. Use population-weighted centroids where available, log the snap distance for every origin, and review anything beyond a stated threshold rather than accepting it.
7. Budget the run before starting it. A county-scale matrix is minutes to hours; a state-scale one at the same settings can be days, and discovering that after three days is expensive. Time a hundred origins first, multiply, and if the projection is unacceptable reduce the departure sampling rather than the origin set — coarsening the departure grid degrades the percentile estimates gracefully, whereas dropping origins leaves holes in the map.
8. Store the matrix immutably. Downstream equity scores reference it by version, and a matrix regenerated in place invalidates every published figure that used it without any of them changing.
9. Record the router version. Routing engines change their itinerary selection between releases, and a matrix rebuilt on a newer version can differ materially with no change to the feed or the parameters. The engine version belongs in the matrix metadata beside the feed checksum.
Snapping distances are worth logging because a silently long snap moves an origin further than the mask in a privacy release would:
Compliance Notes
- Record every parameter in the matrix’s metadata: feed checksum, date, window, step, walk cap, walk speed, transfer penalty, maximum time. A matrix without them cannot be reproduced or compared.
- Never substitute a finite value for unreachable. Carry NaN and the reachable share, and let each downstream analysis decide how to handle them explicitly.
- Publish the never-reachable share with any derived accessibility figure, since it is the population the metric cannot describe.
- Version matrices rather than overwriting, because an equity score published from one matrix must remain reproducible after the feed is refreshed.
Related Topics
- Public Transit Accessibility Modeling — the parent guide, covering what the matrix is for and how to summarise it.
- Validating GTFS Feeds Before Accessibility Analysis — the checks that must pass before this runs.
- Spatial Equity Index Calculation — the catchment scoring this matrix feeds.