3.3. Routing and Congestion Analysis
Story Problem: Explaining Auckland Congestion
Understanding Transport Performance
Auckland, like many growing cities, faces congestion challenges. To address these effectively, we need indicators that:
- Connect driver behaviour to network performance
- Are simple enough to explain to stakeholders
- Can be calculated from available data
- Can be mapped and visualised in dashboards
This section introduces key routing and congestion concepts used in transport analytics and shows how to implement them in Python.
What You Will Learn
- Origin-destination framework and route choice concepts
- Congestion measures: Volume/Capacity (V/C) ratio, Travel Time Index
- Calculating route travel times with realistic constraints
- Visualising congestion on networks and in tables
- Preparing transport data for dashboard presentation
Route Choice and Origin-Destination Framing
The OD Framework
Transport analysis typically follows an origin-destination (OD) approach:
- Origins: Where trips begin (homes, hotels, airports)
- Destinations: Where trips end (workplaces, schools, shops)
- Routes: Paths taken between origins and destinations
- Mode: How people travel (car, bus, walk, bike)
Understanding congestion requires knowing: - Where people are trying to go (OD pairs) - Which routes they choose - When they travel (temporal patterns) - How network capacity constrains flows
Route Choice Principles
People don’t always take the shortest distance route. They choose based on:
Minimising Travel Time - Primary driver for most trips - Accounts for distance, speed limits, and expected delays
Avoiding Congestion - Historical knowledge of busy times/routes - Real-time information (GPS apps)
Personal Preferences - Scenic routes - Familiar roads - Perceived safety
Network Constraints - One-way streets - Turn restrictions - Road closures
Congestion Measures
Travel Time
The most fundamental measure.
Free-Flow Travel Time
def calculate_free_flow_time(distance_m, speed_limit_kmh):
"""
Calculate travel time at speed limit with no delays.
Parameters:
-----------
distance_m : float
Distance in metres
speed_limit_kmh : float
Speed limit in km/h
Returns:
--------
float
Travel time in minutes
"""
speed_ms = speed_limit_kmh / 3.6 # Convert to m/s
time_seconds = distance_m / speed_ms
return time_seconds / 60 # Convert to minutesActual Travel Time
Includes delays from: - Traffic signals - Congestion - Incidents - Weather conditions
Travel Time Index (TTI)
Ratio of actual travel time to free-flow travel time.
Formula: TTI = Actual Travel Time / Free-Flow Travel Time
Interpretation: - TTI = 1.0: No delay (free-flow conditions) - TTI = 1.2: 20% delay (6-minute trip takes 7.2 minutes) - TTI = 1.5: 50% delay (congested) - TTI = 2.0: 100% delay (severe congestion)
Implementation:
def calculate_tti(actual_time_min, free_flow_time_min):
"""
Calculate Travel Time Index.
Returns:
--------
float
TTI value (≥ 1.0)
"""
if free_flow_time_min == 0:
return None
return actual_time_min / free_flow_time_min
# Example
free_flow = calculate_free_flow_time(5000, 50) # 6 minutes
actual = 9 # 9 minutes in practice
tti = calculate_tti(actual, free_flow)
print(f"TTI: {tti:.2f} (50% delay)") # TTI: 1.50Volume/Capacity (V/C) Ratio
Compares traffic volume to road capacity.
Formula: V/C = Volume / Capacity
Interpretation: - V/C < 0.7: Free flow, minimal delays - V/C = 0.7-0.9: Approaching capacity, minor delays - V/C = 0.9-1.0: At capacity, moderate delays - V/C > 1.0: Demand exceeds capacity, severe congestion
Implementation:
def calculate_vc_ratio(volume_vph, capacity_vph):
"""
Calculate Volume/Capacity ratio.
Parameters:
-----------
volume_vph : int
Traffic volume in vehicles per hour
capacity_vph : int
Road capacity in vehicles per hour
Returns:
--------
float
V/C ratio
"""
if capacity_vph == 0:
return None
return volume_vph / capacity_vph
def classify_los(vc_ratio):
"""
Classify Level of Service based on V/C ratio.
Returns:
--------
str
LOS classification (A-F)
"""
if vc_ratio < 0.60:
return 'A' # Free flow
elif vc_ratio < 0.70:
return 'B' # Reasonably free flow
elif vc_ratio < 0.80:
return 'C' # Stable flow
elif vc_ratio < 0.90:
return 'D' # Approaching unstable
elif vc_ratio < 1.00:
return 'E' # Unstable flow
else:
return 'F' # Forced flow (congestion)
# Example
volume = 1800 # vehicles per hour
capacity = 2000 # vehicles per hour
vc = calculate_vc_ratio(volume, capacity)
los = classify_los(vc)
print(f"V/C: {vc:.2f}, LOS: {los}") # V/C: 0.90, LOS: EAverage Speed
Simple but effective measure.
def calculate_average_speed(distance_m, time_min):
"""
Calculate average speed.
Returns:
--------
float
Speed in km/h
"""
if time_min == 0:
return None
distance_km = distance_m / 1000
time_hours = time_min / 60
return distance_km / time_hoursRouting Analysis with r5py
Why r5py?
r5py is a Python wrapper for R5 (Rapid Realistic Routing on Real-world and Reimagined networks), a routing engine developed by Conveyal. It’s designed specifically for transport analysis:
Advantages over OSMnx for routing: - Faster: Multi-threaded, optimised for large-scale analysis - Multi-modal: Handles walking, cycling, driving, and public transport - Realistic: Uses proper transport modelling assumptions - Time-dependent: Accounts for schedules and time-of-day variations - Accessibility-focused: Built for calculating travel times and accessibility
Installation
# Install r5py
pip install r5py
# Requires Java JDK 11 or later
# On Ubuntu: sudo apt-get install openjdk-11-jdk
# On macOS: brew install openjdk@11Basic Setup
import r5py
from r5py import TransportNetwork, TravelTimeMatrixComputer
import geopandas as gpd
from datetime import datetime
# Set up transport network
# r5py needs OSM data and optionally GTFS data
transport_network = r5py.TransportNetwork(
osm_pbf="auckland.osm.pbf", # Download from Geofabrik
gtfs=["auckland_gtfs.zip"] # Optional: public transport
)Download OSM Data
import requests
def download_osm_data(place_name, output_file):
"""
Download OSM data for a region.
For Auckland, download from:
https://download.geofabrik.de/australia-oceania/new-zealand-latest.osm.pbf
"""
# For small areas, you can use OSMnx
import osmnx as ox
# Get bounding box
gdf = ox.geocode_to_gdf(place_name)
north, south, east, west = gdf.total_bounds[[3, 1, 2, 0]]
# Download
ox.utils_graph.osm_xml_download_from_bbox(
north, south, east, west,
network_type='all',
custom_settings={'timeout': 180}
)
print(f"OSM data saved to {output_file}")
# For this course, we'll use a pre-downloaded Auckland extractSingle Route Calculation
# Define origin and destination
origin = gpd.GeoDataFrame(
{'id': [1]},
geometry=gpd.points_from_xy([174.7633], [-36.8485]), # Sky Tower
crs='EPSG:4326'
)
destination = gpd.GeoDataFrame(
{'id': [1]},
geometry=gpd.points_from_xy([174.7680], [-36.8447]), # University
crs='EPSG:4326'
)
# Calculate travel time
travel_time_computer = TravelTimeMatrixComputer(
transport_network,
origins=origin,
destinations=destination,
transport_modes=[r5py.TransportMode.WALK],
departure=datetime(2024, 3, 15, 8, 0), # Friday 8 AM
max_time=datetime.timedelta(minutes=30)
)
# Get results
travel_times = travel_time_computer.compute_travel_times()
print(f"Walking travel time: {travel_times['travel_time'].iloc[0]} minutes")Multi-Modal Routing with r5py
r5py excels at comparing different travel modes:
# Define modes to compare
modes_to_test = [
[r5py.TransportMode.WALK],
[r5py.TransportMode.BICYCLE],
[r5py.TransportMode.CAR],
[r5py.TransportMode.TRANSIT, r5py.TransportMode.WALK] # PT + walking
]
results = []
for modes in modes_to_test:
computer = TravelTimeMatrixComputer(
transport_network,
origins=origins_gdf,
destinations=destinations_gdf,
transport_modes=modes,
departure=datetime(2024, 3, 15, 8, 0),
max_time=datetime.timedelta(hours=1)
)
travel_times = computer.compute_travel_times()
mode_name = "+".join([m.name for m in modes])
travel_times['mode'] = mode_name
results.append(travel_times)
# Combine results
all_results = pd.concat(results, ignore_index=True)
# Compare modes
comparison = all_results.groupby('mode')['travel_time'].agg(['mean', 'median', 'min'])
print(comparison)Travel Time Matrix
Calculate travel times from many origins to many destinations:
# Multiple origins (residential areas)
origins = gpd.GeoDataFrame(
{'id': range(10)},
geometry=gpd.points_from_xy(
np.random.uniform(174.7, 174.8, 10),
np.random.uniform(-36.9, -36.8, 10)
),
crs='EPSG:4326'
)
# Multiple destinations (employment centres)
destinations = gpd.GeoDataFrame(
{'id': range(5)},
geometry=gpd.points_from_xy(
np.random.uniform(174.75, 174.78, 5),
np.random.uniform(-36.85, -36.84, 5)
),
crs='EPSG:4326'
)
# Compute matrix
computer = TravelTimeMatrixComputer(
transport_network,
origins=origins,
destinations=destinations,
transport_modes=[r5py.TransportMode.CAR],
departure=datetime(2024, 3, 15, 8, 0)
)
matrix = computer.compute_travel_times()
print(matrix.head())
# Pivot to matrix format
matrix_wide = matrix.pivot(
index='from_id',
columns='to_id',
values='travel_time'
)
print(matrix_wide)Time-Dependent Routing
Account for time-of-day variations:
# Compare rush hour vs off-peak
times_to_test = [
datetime(2024, 3, 15, 7, 30), # Morning peak
datetime(2024, 3, 15, 10, 0), # Mid-morning
datetime(2024, 3, 15, 17, 30), # Evening peak
datetime(2024, 3, 15, 21, 0) # Evening off-peak
]
temporal_results = []
for departure_time in times_to_test:
computer = TravelTimeMatrixComputer(
transport_network,
origins=origins,
destinations=destinations,
transport_modes=[r5py.TransportMode.CAR],
departure=departure_time,
max_time=datetime.timedelta(minutes=45)
)
times = computer.compute_travel_times()
times['hour'] = departure_time.hour
temporal_results.append(times)
# Combine and analyse
temporal_df = pd.concat(temporal_results)
# Average travel time by hour
hourly_avg = temporal_df.groupby('hour')['travel_time'].mean()
print(hourly_avg)
# Visualise temporal variation
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(10, 6))
hourly_avg.plot(kind='bar', ax=ax, color='steelblue')
ax.set_xlabel('Departure Hour')
ax.set_ylabel('Average Travel Time (minutes)')
ax.set_title('Travel Time by Time of Day')
plt.xticks(rotation=0)
plt.tight_layout()
plt.show()Visualising Congestion
Colour-Coded Network Map
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
def plot_congestion_network(G, vc_ratios=None):
"""
Plot network with edges coloured by congestion level.
Parameters:
-----------
G : networkx graph
Street network
vc_ratios : dict
Edge keys mapped to V/C ratios
"""
fig, ax = plt.subplots(figsize=(12, 12))
# Extract edges
nodes, edges = ox.graph_to_gdfs(G)
if vc_ratios is not None:
# Assign V/C ratios to edges
edges['vc_ratio'] = edges.index.map(
lambda x: vc_ratios.get(x, 0)
)
# Define colour scheme
# Green (free flow) -> Yellow -> Red (congestion)
edges['color'] = edges['vc_ratio'].apply(
lambda x: 'green' if x < 0.7
else 'yellow' if x < 0.9
else 'orange' if x < 1.0
else 'red'
)
# Plot coloured by congestion
edges.plot(ax=ax, color=edges['color'], linewidth=2, alpha=0.7)
else:
# Plot all edges grey
edges.plot(ax=ax, color='grey', linewidth=1)
ax.set_title('Road Network Congestion', fontsize=16)
ax.set_axis_off()
# Add legend
from matplotlib.patches import Patch
legend_elements = [
Patch(facecolor='green', label='V/C < 0.7 (Free flow)'),
Patch(facecolor='yellow', label='0.7 ≤ V/C < 0.9'),
Patch(facecolor='orange', label='0.9 ≤ V/C < 1.0'),
Patch(facecolor='red', label='V/C ≥ 1.0 (Congested)')
]
ax.legend(handles=legend_elements, loc='upper right')
return fig, axSummary Table
def create_congestion_summary(edges_gdf, vc_ratios):
"""
Create summary table of congestion statistics.
Returns:
--------
pandas.DataFrame
Summary by road type or area
"""
import pandas as pd
# Add V/C ratios
edges_gdf['vc_ratio'] = edges_gdf.index.map(
lambda x: vc_ratios.get(x, 0)
)
# Classify LOS
edges_gdf['los'] = edges_gdf['vc_ratio'].apply(classify_los)
# Group by road type
summary = edges_gdf.groupby('highway').agg({
'length': ['count', 'sum'],
'vc_ratio': ['mean', 'max']
}).round(2)
return summaryDashboard Integration
Congestion Dashboard Example
from shiny import App, ui, render, reactive
import osmnx as ox
import networkx as nx
import matplotlib.pyplot as plt
import pandas as pd
# Load network once
G = ox.graph_from_place('Auckland CBD, New Zealand', network_type='drive')
G = add_travel_time(G)
app_ui = ui.page_fluid(
ui.h2("Auckland Congestion Dashboard"),
ui.layout_sidebar(
ui.panel_sidebar(
ui.input_slider(
"time_of_day",
"Time of Day",
min=0,
max=23,
value=8,
step=1
),
ui.input_select(
"metric",
"Congestion Metric",
choices=["V/C Ratio", "Travel Time Index", "Average Speed"]
),
ui.hr(),
ui.output_text("summary_stats")
),
ui.panel_main(
ui.navset_tab(
ui.nav("Network Map", ui.output_plot("congestion_map")),
ui.nav("Statistics", ui.output_table("summary_table"))
)
)
)
)
def server(input, output, session):
@reactive.Calc
def simulated_congestion():
"""
Simulate congestion based on time of day.
In practice, this would come from real traffic data.
"""
hour = input.time_of_day()
# Simulate: higher congestion during rush hours (7-9, 17-19)
if 7 <= hour <= 9 or 17 <= hour <= 19:
base_vc = 0.85 # High congestion
elif 10 <= hour <= 16:
base_vc = 0.65 # Moderate
else:
base_vc = 0.40 # Low
# Add some randomness per edge
import numpy as np
vc_ratios = {}
for u, v, k in G.edges(keys=True):
vc_ratios[(u, v, k)] = min(1.5, max(0.3,
base_vc + np.random.normal(0, 0.15)
))
return vc_ratios
@output
@render.plot
def congestion_map():
"""Plot network with congestion colours"""
vc_ratios = simulated_congestion()
fig, ax = plot_congestion_network(G, vc_ratios)
ax.set_title(f'Congestion at {input.time_of_day()}:00')
return fig
@output
@render.text
def summary_stats():
"""Display summary statistics"""
vc_ratios = simulated_congestion()
values = list(vc_ratios.values())
avg_vc = sum(values) / len(values)
congested = sum(1 for v in values if v >= 0.9)
total = len(values)
pct_congested = (congested / total) * 100
return f"""
Time: {input.time_of_day()}:00
Average V/C: {avg_vc:.2f}
Congested segments: {congested} ({pct_congested:.1f}%)
""".strip()
@output
@render.table
def summary_table():
"""Summary by road type"""
vc_ratios = simulated_congestion()
nodes, edges = ox.graph_to_gdfs(G)
edges['vc_ratio'] = edges.index.map(
lambda x: vc_ratios.get(x, 0)
)
edges['los'] = edges['vc_ratio'].apply(classify_los)
# Count by LOS
los_counts = edges['los'].value_counts().sort_index()
return pd.DataFrame({
'Level of Service': los_counts.index,
'Number of Segments': los_counts.values
})
app = App(app_ui, server)Summary
You’ve learned:
- Routing concepts: OD framework and route choice principles
- Congestion measures: TTI, V/C ratio, average speed, LOS classification
- Travel time calculation: Free-flow vs actual times
- Routing with OSMnx: Finding paths by distance and time
- Visualisation: Colour-coded networks and summary tables
- Dashboard integration: Interactive congestion monitoring
Practice Exercises
- Calculate free-flow and congested travel times for a route
- Implement V/C ratio calculation and LOS classification
- Compare shortest vs fastest routes between two locations
- Create a congestion visualisation with colour-coded segments
- Build a simple dashboard showing congestion by time of day
Next Steps
In sec-origin-destination, you’ll learn to analyse movement patterns between multiple origin-destination pairs.