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:

  1. Origins: Where trips begin (homes, hotels, airports)
  2. Destinations: Where trips end (workplaces, schools, shops)
  3. Routes: Paths taken between origins and destinations
  4. 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 minutes

Actual 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.50

Volume/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: E

Average 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_hours

Routing 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@11

Basic 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 extract

Single 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, ax

Summary 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 summary

Dashboard 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

  1. Calculate free-flow and congested travel times for a route
  2. Implement V/C ratio calculation and LOS classification
  3. Compare shortest vs fastest routes between two locations
  4. Create a congestion visualisation with colour-coded segments
  5. 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.