Modeling Peak-Hour Travel Time Variation
This guide, part of Drive-Time Isochrone Generation for Public Health Network Analysis, replaces the single free-flow travel time behind most access analyses with times that depend on when people travel.
Problem Context & Constraints
Most drive-time isochrones are computed on free-flow speeds: the speed limit, or a default speed per road class, with no traffic. That is the time a trip takes at three in the morning. Patients go to clinics during working hours, often arriving at eight or leaving at five, and in metropolitan areas a trip that takes 18 minutes at night takes 30 during the morning peak. An access analysis that reports 94 percent of residents within 20 minutes of a clinic may be describing a population that could reach it at night.
The gap is uneven. In rural areas with little congestion, peak and free-flow times differ by a few percent. On urban arterials and highway approaches to city centres, peak times can be 50 to 100 percent higher. So free-flow access is most optimistic exactly where population is densest, and equity comparisons between urban and rural areas are biased by the choice.
Three data sources support time-of-day modelling. Historical speed profiles from commercial providers give typical speeds per road segment by hour of week. Open probe data — Uber Movement historically, and various regional open datasets — cover some metropolitan areas. Road-class congestion factors apply a multiplier by road class and hour, derived from a sample of observed trips; they are the fallback where segment-level data are not available. The constraint is that whatever source is used must be dated, versioned and applied consistently across every facility compared.
Prerequisites
python3.11pandas2.2.2,numpy1.26.4,networkx3.3,osmnx1.9.3,geopandas0.14.4- An OpenStreetMap road network for the study area, downloaded with a recorded date
- Speed data by segment and hour of week, or observed trip times to derive road-class congestion factors
- Clinic locations with opening hours, so the relevant time windows are known
- Population-weighted origins at block or block-group level
Step-by-Step Solution
Attach an hourly speed profile to every edge, compute travel time for each time window of interest, build isochrones per window, and report population coverage for each window alongside free flow.
# Time-of-day travel times and isochrones on an OSM road network.
# Pinned: pandas==2.2.2, numpy==1.26.4, networkx==3.3, osmnx==1.9.3, geopandas==0.14.4
import logging
import numpy as np
import pandas as pd
import networkx as nx
import osmnx as ox
import geopandas as gpd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("peak_travel")
WINDOWS = {"free_flow": None, "am_peak": (7, 9), "midday": (11, 14), "pm_peak": (16, 18)}
# Fallback congestion factors (free-flow time multipliers) by road class and window,
# fitted from observed trips when segment speeds are unavailable.
CLASS_FACTORS = {
"motorway": {"am_peak": 1.65, "midday": 1.15, "pm_peak": 1.75},
"trunk": {"am_peak": 1.50, "midday": 1.15, "pm_peak": 1.55},
"primary": {"am_peak": 1.40, "midday": 1.20, "pm_peak": 1.45},
"secondary": {"am_peak": 1.25, "midday": 1.12, "pm_peak": 1.30},
"other": {"am_peak": 1.10, "midday": 1.05, "pm_peak": 1.10},
}
def road_class(hw) -> str:
h = hw[0] if isinstance(hw, list) else hw
h = str(h).replace("_link", "")
return h if h in CLASS_FACTORS else "other"
def attach_times(G: nx.MultiDiGraph, seg_speeds: pd.DataFrame | None) -> nx.MultiDiGraph:
"""Add travel_time_<window> (seconds) to every edge."""
G = ox.add_edge_speeds(G)
G = ox.add_edge_travel_times(G) # free-flow travel_time
speeds = seg_speeds.set_index(["u", "v", "hour"])["kph"] if seg_speeds is not None else None
for u, v, k, d in G.edges(keys=True, data=True):
d["travel_time_free_flow"] = d["travel_time"]
cls = road_class(d.get("highway"))
for w, span in WINDOWS.items():
if span is None:
continue
t = None
if speeds is not None:
hrs = [speeds.get((u, v, h)) for h in range(*span)]
hrs = [x for x in hrs if x is not None and x > 0]
if hrs:
t = d["length"] / (np.mean(hrs) / 3.6)
d[f"travel_time_{w}"] = t if t is not None else d["travel_time"] * CLASS_FACTORS[cls][w]
return G
def reachable_nodes(G, origin_node, minutes: float, window: str) -> set:
lengths = nx.single_source_dijkstra_path_length(
G, origin_node, cutoff=minutes * 60, weight=f"travel_time_{window}")
return set(lengths)
def coverage(G, clinics: gpd.GeoDataFrame, blocks: gpd.GeoDataFrame, minutes: float) -> pd.DataFrame:
"""Population within `minutes` of any clinic, per time window."""
Grev = G.reverse(copy=False) # travel towards the clinic
block_nodes = ox.nearest_nodes(G, blocks.geometry.x, blocks.geometry.y)
clinic_nodes = ox.nearest_nodes(G, clinics.geometry.x, clinics.geometry.y)
rows = []
for w in WINDOWS:
reach = set().union(*(reachable_nodes(Grev, n, minutes, w) for n in clinic_nodes))
covered = np.isin(block_nodes, list(reach))
rows.append({"window": w, "population": float(blocks.loc[covered, "population"].sum()),
"share": float(blocks.loc[covered, "population"].sum() / blocks["population"].sum())})
df = pd.DataFrame(rows)
log.info("coverage within %d min: %s", minutes,
", ".join(f"{r.window}={r.share:.1%}" for r in df.itertuples()))
return df
Routing on the reversed graph computes travel time towards the clinic, which is the direction patients travel for a morning appointment. On networks with one-way streets and asymmetric congestion — inbound heavy in the morning, outbound in the evening — the direction matters, and routing from the clinic outward measures the wrong trip.
The fallback factors are only as good as the trips they were fitted from. Fit them per region from a sample of observed trips — a few hundred origin–destination pairs with recorded peak and off-peak times is enough to estimate a factor per road class — and store the fitted table with the analysis rather than borrowing numbers from another metropolitan area.
Validation & Edge Cases
Validating against observed times. Compare modelled peak times with observed trip times for a sample of origin–destination pairs, as in Validating Isochrones Against Observed Travel Times. Report the median ratio of modelled to observed time by road class and window; a ratio well below one means congestion is understated.
Intersection delay. Edge speeds do not include time spent waiting at signals and turning across traffic, which can add a third to urban trip times. Where speed data are link-based, add a per-intersection penalty on signalised nodes, or calibrate it into the class factors.
Missing segment speeds. Speed datasets rarely cover minor roads. Blending segment speeds on major roads with class factors on minor ones is reasonable, but log the share of travel time from each source per isochrone, since an isochrone dominated by fallback factors deserves less confidence.
Choosing windows by clinic hours. A clinic open 9 to 5 is reached in the morning peak for early appointments and midday for most others. Weight windows by the appointment distribution if it is known, or report the morning peak as the conservative figure and midday as the typical one.
Seasonal and event variation. School terms, holidays and weather change congestion. Historical profiles average over these; state which period the profiles represent, and prefer weekday term-time profiles for routine access analysis.
Weekend and evening services. Urgent care and pharmacy access at weekends or in the evening follow different congestion patterns. Add windows for those hours when the facilities being analysed are open then, rather than assuming weekday peaks apply.
Holding congestion fixed over years. Traffic grows, and a congestion profile from five years ago understates today’s peak. When comparing access over time, decide whether the comparison should hold congestion fixed — isolating changes in facilities — or update it, capturing both, and state which.
INFO peak_travel coverage within 20 min: free_flow=94.1%, am_peak=71.3%, midday=84.0%, pm_peak=69.8%
INFO peak_travel travel time source share: segment_speeds=0.78, class_factors=0.22
Compliance Notes
Record the network extract date, the speed data source, period and licence, the fitted class factors and the trips they came from, the windows analysed, the routing direction, and the population coverage for each window alongside free flow.
Commercial speed data usually come with licence terms that restrict redistribution of the speeds themselves; derived isochrones and coverage statistics are normally permitted. Check the licence before publishing edge-level travel times, and publish access results with the window stated in every table title, so free-flow and peak figures are never confused.
Related Topics
- Drive-Time Isochrone Generation for Public Health Network Analysis — the parent guide.
- Combining Walk and Transit Legs for Realistic Access Times — time-dependent access without a car.
- Comparing Fixed-Radius and Network Catchments — where time-of-day catchments are compared with circles.