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.

The Same 20 Minutes, Three Times of Day Three nested outlines around one urban clinic. The free-flow twenty-minute isochrone, representing night-time travel, is the largest and reaches along highways well outside the city. The midday isochrone is somewhat smaller. The morning peak isochrone is much smaller, especially along the radial highways, where congestion is worst. Population within each outline is shown, falling from about three hundred and ten thousand at free flow to about one hundred and ninety thousand at the morning peak. Access shrinks when traffic is counted free flow: 310k midday: 262k AM peak: 189k radial highways lose the most reach at the peak

Prerequisites

  • python 3.11
  • pandas 2.2.2, numpy 1.26.4, networkx 3.3, osmnx 1.9.3, geopandas 0.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.

Congestion Hits Urban Access Hardest Two groups of bars for urban and rural counties show the share of population within twenty minutes of a clinic at free flow, midday and morning peak. Urban coverage falls from ninety-four percent at free flow to seventy-one percent at the morning peak. Rural coverage falls only from sixty-two to fifty-nine percent. The urban-rural gap, which looks like thirty-two points at free flow, narrows to twelve points at the peak. Population within 20 minutes of a clinic 94% 84% 71% urban counties 62% 61% 59% rural counties free flow midday AM peak the urban-rural gap is 32 points at free flow and 12 at the peak

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
Where Each Travel Time Comes From A road network diagram in which major roads carry travel times from observed segment speeds by hour and minor roads carry free-flow times multiplied by a road-class congestion factor. Signalised intersections add a fixed delay. Each isochrone records the share of its travel time coming from each source, so an isochrone dominated by fallback factors can be flagged for lower confidence. Blend sources, and record the blend Major roads segment speeds by hour of week Minor roads free flow × class factor fitted per region Intersections fixed delay at signalised nodes each isochrone logs the share of its time from each source mostly-fallback isochrones get a lower-confidence flag

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.