GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — GitHub - vlarmet/cppRouting: Algorithms for Routing and Solving the Traffic Assignment Problem

BranchesTags
Open more actions menu

Latest commit

 

History

207 Commits
207 Commits

Folders and files

NameName
Last commit message
Last commit date
.github
.github
 
 
R
R
 
 
README_files/figure-gfm
README_files/figure-gfm
 
 
build
build
 
 
data_readme
data_readme
 
 
inst/doc
inst/doc
 
 
man
man
 
 
src
src
 
 
tests
tests
 
 
vignettes
vignettes
 
 
.Rbuildignore
.Rbuildignore
 
 
.gitignore
.gitignore
 
 
DESCRIPTION
DESCRIPTION
 
 
NAMESPACE
NAMESPACE
 
 
NEWS.md
NEWS.md
 
 
README.md
README.md
 
 
cpp_extract_tutorial.md
cpp_extract_tutorial.md
 
 

Repository files navigation

Support Our Open Source Project

Creating and maintaining an open source project takes significant time and effort. If you find value in our work and would like to see it continue to grow and improve, please consider making a donation. Your support helps us cover hosting costs, development tools, and allows us to dedicate more time to enhancing this project. Every contribution, no matter how small, makes a big difference. Thank you for supporting open source!

cppRouting v4 : Algorithms for Routing and Solving the Traffic Assignment Problem

Vincent LARMET November 09, 2026

  • Package presentation
  • Installation
    • Stable version from CRAN
    • or from github
  • What are we talking about ?
  • Readme Data
    • Set the number of threads used by cppRouting
    • Instantiate the graph
  • Main functions
  • Network extraction
  • Routing
    • Algorithms
    • Compute isochrones
    • Compute possible detours within a fixed additional cost
    • Contraction hierarchies (CH)
    • Customizable contraction hierarchies (CCH)
    • Work with multiple weights network
  • Network simplification
  • Traffic assignment
    • All-or-Nothing (AON)
      • Choosing the best routing algorithm
    • User Equilibrium (UE)
      • Link-based algorithms
      • Bush-based algorithms
      • Performance comparison
  • Algorithm compatibility
  • Parallel implementation
  • Applications
    • Application 1 : Calculate Two Step Floating Catchment Areas (2SFCA) of general practitioners in France
    • Application 2 : Calculate the minimum travel time to the closest maternity ward in France
    • Application 3 : Calculate average commuting time to go to job in France
    • Application 4 : Calculate the flow of people crossing each municipality in the context of commuting in Bourgogne-Franche-Comte region
    • Application 5 : Estimate the commuter traffic at user equilibrium in Bourgogne-Franche-Comte
  • Citation

Package presentation

cppRouting is an R package which provide routing algorithms (shortest paths/distances, isochrones) and traffic assignment solvers on non-negative weighted graphs. cppRouting is characterized by :

  • its ability to work on large road graphs (country/continent scale)
  • its large choice of one-to-one shortest path algorithms
  • its implementation of contraction hierarchies, customizable contraction hierarchies and associated routing algorithms
  • its large choice of algorithms for solving the traffic assignment problem (TAP)
  • its high performance through memory usage and parallel programming

cppRouting is therefore particularly adapted for geographer, or whoever who need to calculate accessibility indicators at large scale. All algorithms are written in C++ and mainly use containers from the Standard Template Library (STL). This package have been made with Rcpp and RcppParallel packages.

Installation

Stable version from CRAN

install.packages("cppRouting")

or from github

library(remotes)
remotes::install_github("vlarmet/cppRouting")

What are we talking about ?

cppRouting implements algorithms belonging to graph theory, so let's define what a graph is. A graph is commonly used to represent a network, which is composed of vertices connected by edges.

In cppRouting, an edge has at least three attributes : vertice's ID from which it start, vertice's ID from which it end and a weight (length, flow, travel time ...).

Readme Data

This README file and all time measurements were made on an Ubuntu computer, with 13th generation i7 (10 cores) processor and 32GB of memory. The data presented here is the official french road network describing over 300000 km of roads. All data used in this README are free and can be downloaded here :

  • roads : https://cartes.gouv.fr/rechercher-une-donnee/dataset/IGNF_ROUTE-500#telechargement
  • general practitioners and maternity ward locations : https://api.insee.fr/melodi/file/DS_BPE/DS_BPE_2025_CSV_FR
  • population : https://www.insee.fr/fr/statistiques/7739582?sommaire=7728826
  • shapefile of the ~36000 communes in France : http://professionnels.ign.fr/adminexpress
  • commuting to work from the French national census : https://www.insee.fr/fr/statistiques/fichier/8998300/base-flux-mobilite-domicile-lieu-travail-2023_csv.zip

Graph data have been preprocessed for more readability (see data_preparation.R).

The final graph is composed of 911 769 nodes and 2 595 571 edges. Data has to be a 3 columns data.frame or matrix containing from, to and a cost/distance column. Here the cost is the time needed to travel in each edges (in minutes). From and to are vertices IDs (character or numeric).

library(cppRouting)
library(dplyr)
library(sf)
library(ggplot2)
library(concaveman)
library(ggmap)
library(tmap)
library(microbenchmark)
library(reshape2)
library(kableExtra)

#Reading french road data
roads  <-  read.csv("roads.csv",colClasses = c("character","character","numeric"))
#Shapefile data of communes (polygons)
com  <-  read_sf("com_simplified_geom.shp")
#Correspondance file between communes and nodes in the graph (nearest node to each commune centroid)
ndcom  <-  read.csv("node_commune.csv",colClasses = c("character","character","numeric"))
#General practitioners locations
med  <-  read.csv("doctor.csv",colClasses = c("character","numeric","character","numeric"))
#Import materinty ward locations
maternity  <-  read.csv("maternity.csv",colClasses = c("character","numeric"))
#Commuting data from national census
load("commuting.Rds")
#Import nodes coordinates (projected in EPSG : 2154)
coord  <-  read.csv("coordinates.csv",colClasses = c("character","numeric","numeric"))

Head of road network data

head(roads)
##   from to    weight
## 1    0  1 2.1200000
## 2    1  0 2.1200000
## 3    1  2 0.8600000
## 4    2  1 0.8600000
## 5    3  4 0.1745455
## 6    4  3 0.1745455

Head of coordinates data

head(coord)
##   ID        X       Y
## 1  0 894833.5 6265744
## 2  1 894051.2 6262806
## 3  2 893427.9 6261751
## 4  3 340196.8 6276241
## 5  4 340108.9 6275932
## 6  5 433102.7 6715832

Set the number of threads used by cppRouting

RcppParallel::setThreadOptions(numThreads = 1)

Instantiate the graph

#Instantiate a graph with coordinates
graph  <-  makegraph(roads, directed = T, coords = coord)

Graph object have some useful attributes for the user :

  • graph$nbnode : total number of vertices,
  • graph$dict$ref : vertices ids.

Other attributes are internals data and have no interest for the user. All graph attributes should never be modified by the user.

Main functions

cppRouting package provide these functions :

  • cpp_extract : extract a routable network from geographic source (OpenStreetMap, shapefiles)
  • get_distance_matrix : compute distance matrix (between all combinations origin-destination nodes - one-to-many),
  • get_distance_pair : compute distances between origin and destination by pair (one-to-one),
  • get_path_pair : compute shortest paths between origin and destination by pair (one-to-one),
  • get_multi_paths : compute shortest paths between all origin nodes and all destination nodes (one-to-many),
  • get_isochrone : compute isochrones/isodistances with one or multiple breaks.
  • get_detour : return nodes that are reachable within a fixed additional cost around shortest paths. This function can be useful in producing accessibility indicators.
  • cpp_simplify : remove non-intersection nodes, duplicated edges and isolated loops in the graph. Graph topology is preserved so distance calculation is faster and remains true. This function can be applied to very large graphs (several millions of nodes).
  • cpp_contract : contract the graph by applying contraction hierarchies (CH) or customizable contraction hierarchies (CCH) algorithm.
  • cpp_customize : assign edge weights to a graph contracted with CCH
  • get_aon : given an origin-destination matrix, compute All-or-Nothing assignment.
  • assign_traffic : given an origin-destination matrix, estimate the traffic flows on the network (user equilibrium).

Network extraction

cppRouting is able to extract a routable network from the most used geographic sources:

  • OpenStreetMap
    • object from osmdata R package
    • cached file from osmextract R package
    • .osm.pbf file on disk (downloadable at Geofabrik)
  • generic LINESTRING/MULTILINESTRING datasets
    • shapefile (.shp, .dbf, .prj, .shx) on disk
    • sf object

Extraction is done through an optimized C++ engine, without extra dependencies, using cpp_extract() function. France's road network used here have been extracted using the following command :

# path to ROUTE500 LINESTRING shapefile
shp <- ".../RESEAU_ROUTIER/TRONCON_ROUTE.shp"

# extract network, nodes coordinates, auxiliary edges weights (length) in csv files
cppRouting::cpp_extract(
  shp, 
  output = "csv", 
  out_prefix = "route500", 
  coords = TRUE, 
  keep_length = TRUE,
  geom_params = list(
    speed_field = "vitesse",
    length_field = "LONGUEUR",
    direction_field = "SENS",
    direction_values = list(forward = "Sens direct", backward = "Sens inverse", twoway = "Double sens"),
    keep_fields = "LONGUEUR"
  )
)

See detailed examples in cpp_extract_tutorial.md.

Routing

As the package name suggest, cppRouting is initially aimed to provide efficient algorithms for finding shortest paths.

Algorithms

Path algorithms proposed by the package are :

  • 1 uni-directional Dijkstra algorithm,
  • 2 bi-directional Dijkstra algorithm,
  • 3 uni-directional A* algorithm
  • 4 New bi-directional A* algorithm (Piljs & Post, 2009 : see http://repub.eur.nl/pub/16100/ei2009-10.pdf)
  • 5 one-to-one bi-directional Dijkstra adapted to contraction hierarchies (Geisberger & al., 2008)
  • 6 many-to-many bi-directional Dijkstra adapted to contraction hierarchies (Geisberger & al., 2008)
  • 7 PHAST algorithm (Hardware-accelerated shortest path trees), one-to-all algorithm adapted to contraction hierarchies (Delling & al., 2011)
  • 8 one-to-one and one-to-many query on customizable contraction hierarchies (CCH), as implemented in RoutingKit: bidirectional search restricted to the contracted hierarchy (forward search from the source, backward search from the target), where edge weights can be updated after contraction. The one-to-many variant reuses one backward search per target through per-node buckets (RPHAST-style many-to-many).

1, 2, 3 and 4 are available for one-to-one calculation in get_distance_pair and get_path_pair functions on a non-contracted graph. In these functions, uni-directional Dijkstra algorithm is stopped when the destination node is reached. A* and NBA* are relevant if geographic coordinates of all nodes are provided. Note that coordinates should be expressed in a projection system. To be accurate and efficient, A* and NBA* algorithms should use an admissible heuristic function (here the Euclidean distance), i.e cost and heuristic function must be expressed in the same unit. In cppRouting, heuristic function h for a node (n) is defined such that : h(n,d) = ED(n,d) / k with h the heuristic, ED the Euclidean distance, d the destination node and a constant k. So in the case where coordinates are expressed in meters and cost is expressed in time, k is the maximum speed allowed on the road. By default, constant is 1 and is designed for graphs with cost expressed in the same unit than coordinates (for example in meters). If coordinates cannot be provided, bi-directional Dijkstra algorithm is the best option in terms of performance.

5 is used for one-to-one calculation in get_distance_pair and get_path_pair functions on a contracted graph.

1 is used for one-to-many calculation in get_distance_matrix function on a non-contracted graph.

6 and 7 are available for one-to-many calculation in get_distance_matrix function on a contracted graph.

8 is used in get_distance_pair, get_path_pair and get_distance_matrix functions on a customized CCH graph (cpp_contract(Graph, customizable = TRUE) followed by cpp_customize). Unlike 5 and 6, edge weights are not fixed at contraction time: the hierarchy is prepared once, and customization only updates arc costs, which is much cheaper than re-contracting the graph. This is what makes CCH well suited for traffic assignment, where congestion changes travel times at every iteration.

Let's compare different path algorithms in terms of performance. For A* and NBA algorithms, coordinates are defined in meters and max speed is 110km/h; so for the heuristic function to be admissible, we have to convert meters to minutes by setting constant to 110/0.06 :

#Generate 2000 random origin and destination nodes
origin  <-  sample(graph$dict$ref,  2000)
destination  <-  sample(graph$dict$ref,  2000)
microbenchmark(dijkstra=pair_dijkstra  <-  get_distance_pair(graph, origin, destination, algorithm = "Dijkstra"), 
               bidir=pair_bidijkstra  <-  get_distance_pair(graph, origin, destination, algorithm = "bi"), 
               astar=pair_astar  <-  get_distance_pair(graph, origin, destination, algorithm = "A*", constant = 110/0.06), 
               nba=pair_nba  <-  get_distance_pair(graph, origin, destination, algorithm = "NBA", constant = 110/0.06), 
               times=1)
## Unit: seconds
##      expr       min        lq      mean    median        uq       max neval
##  dijkstra 122.87958 122.87958 122.87958 122.87958 122.87958 122.87958     1
##     bidir  83.18095  83.18095  83.18095  83.18095  83.18095  83.18095     1
##     astar  74.30699  74.30699  74.30699  74.30699  74.30699  74.30699     1
##       nba  34.97340  34.97340  34.97340  34.97340  34.97340  34.97340     1

Output

head(cbind(pair_dijkstra,pair_bidijkstra,pair_astar,pair_nba))
##      pair_dijkstra pair_bidijkstra pair_astar  pair_nba
## [1,]     191.74489       191.74489  191.74489 191.74489
## [2,]     519.59685       519.59685  519.59685 519.59685
## [3,]     308.14500       308.14500  308.14500 308.14500
## [4,]      77.99867        77.99867   77.99867  77.99867
## [5,]     194.39985       194.39985  194.39985 194.39985
## [6,]     314.71795       314.71795  314.71795 314.71795

So, how to choose the algorithm ? It's simple, the faster, the better. If coordinates are provided, go for NBA, else go for bidirectional Dijkstra. Uni-directional Dijkstra and A* algorithms should be used if main memory is (almost) full because they require only one graph instead of two (forward and backward).

Compute isochrones

An isochrone is a set of nodes reachable from a node within a fixed limit. Let's compute isochrones around Dijon city

#Node nearest to the Dijon commune centroid (from the node-to-commune correspondence file)
dijon_id <- as.character(ndcom$id_noeud[ndcom$com == "21231"])
#Compute isochrones
iso  <-  get_isochrone(graph, from = dijon_id, lim = c(15, 25, 45, 60, 90, 120))
#Convert nodes to concave polygons with concaveman package
poly  <-  lapply(iso[[1]], function(x){
  x  <-  data.frame(noeuds=x, stringsAsFactors = F)
  x  <-  left_join(x, coord, by=c("noeuds"="ID"))
  return(concaveman(st_as_sf(x, coords=c("X", "Y"), crs=2154)))
})

poly  <-  do.call(rbind, poly)
poly$time  <-  as.factor(names(iso[[1]]))
#Multipolygon
poly2  <-  st_cast(poly, "MULTIPOLYGON")
poly2$time  <-  reorder(poly2$time, c(120, 90, 60, 45, 25, 15))
#Draw the largest isochrone first, so the smaller ones are not hidden
poly2  <-  poly2[order(as.integer(poly2$time)), ]
#Reproject for plotting
poly2  <-  st_transform(poly2, "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs")
#Plot the map over the commune polygons, reprojected in longlat like the isochrones
com_ll  <-  st_transform(com, "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs")
p  <-  ggplot() +
  geom_sf(data = com_ll, fill = "white", color = "grey85", linewidth = 0.1) +
  geom_sf(data=poly2, aes(fill=time), alpha=.8, inherit.aes = FALSE)+
  scale_fill_brewer(palette = "YlOrRd")+
  labs(fill="Minutes")+
  ggtitle("Isochrones around Dijon")+
  coord_sf(xlim = c(2.5, 8), ylim = c(45.126, 49)) +
  theme(axis.text.x = element_blank(), 
        axis.text.y = element_blank(), 
        axis.ticks = element_blank(), 
        axis.title.y=element_blank(), axis.title.x=element_blank())
p

plot of chunk unnamed-chunk-9

Compute possible detours within a fixed additional cost

get_detour function returns all reachable nodes within a fixed detour time around the shortest path between origin and destination nodes. Returned nodes (n) meet the following condition : SP(o,n) + SP(n,d) < SP(o,d) + t with SP shortest distance/time, o the origin node, d the destination node and t the extra cost. The algorithm used is a slightly modified bidirectional Dijkstra. Let's see an example for the path between Dijon and Lyon city :

#Node nearest to the Lyon city centroid (Lyon is not in the node-to-commune correspondence file)
lyon_pt <- st_transform(st_sfc(st_point(c(4.8706, 45.777)), crs = 4326), 2154)
lyon_xy <- st_coordinates(lyon_pt)[1, ]
lyon_id <- coord$ID[which.min((coord$X - lyon_xy[1])^2 + (coord$Y - lyon_xy[2])^2)]
#Compute shortest path
trajet <- get_path_pair(graph,from=dijon_id,to=lyon_id)

#Compute shortest path
distance <- get_distance_pair(graph,from=dijon_id,to=lyon_id)

#Compute detour time of 25 and 45 minutes
det25 <- get_detour(graph,from=dijon_id,to=lyon_id,extra=25)
det45 <- get_detour(graph,from=dijon_id,to=lyon_id,extra=45)

#Create sf object of nodes
pts <- st_as_sf(coord,coords=c("X","Y"),crs=2154)
pts <- st_transform(pts,crs=4326)
pts$time <- ifelse(pts$ID %in% unlist(det45),"45","0")
pts$time <- ifelse(pts$ID %in% unlist(det25),"25",pts$time)
pts$time <- ifelse(pts$ID %in% unlist(trajet),"Shortest Path",pts$time)
pts$time <- factor(pts$time,levels = c("25","45","Shortest Path","0"))

#Plot
#Plot over the commune polygons, reprojected in longlat like the points
com_ll <- st_transform(com, "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs")

p <- ggplot() +
  geom_sf(data = com_ll, fill = "white", color = "grey85", linewidth = 0.1) +
  geom_sf(data=pts[pts$time %in% c("25","45"),],aes(color=time),inherit.aes = FALSE)+
  geom_sf(data=pts[pts$time =="Shortest Path",],aes(color=time),inherit.aes = FALSE)+
  scale_color_brewer(palette = "YlOrRd") +
  ggtitle(paste0("Detours around Dijon-lyon path - ",round(distance,digits = 2)," minutes"))+
  labs(color="Minutes") +
  coord_sf(xlim = c(3.2, 6.5), ylim = c(45.126, 47.8)) +
  theme(axis.text.x = element_blank(),
        axis.text.y = element_blank(),
        axis.ticks = element_blank(),
        axis.title.y=element_blank(),axis.title.x=element_blank())
p

plot of chunk unnamed-chunk-10

Contraction hierarchies (CH)

Contraction hierarchies is a speed-up technique for finding shortest path on a network. It was proposed by Geisberger & al.(2008). Initially created for one-to-one queries, it has been extended to many-to-many and distance matrix calculation. This technique is composed of two phases:

  • preprocessing phase called contraction with cpp_contract function
  • query phase : a slightly modified version of bidirectional search for one-to-one query, available in get_distance_pair and get_path_pair; PHAST algorithm and a many-to-many algorithm using buckets available in get_distance_matrix function.

Contraction phase consists of iteratively removing a vertex v from the graph and creating a shortcut for each pair (u,w) of v's neighborhood if the shortest path from u to w contains v. To be efficient and avoid creating too much shortcuts, vertices have to be ordered according to several heuristics. The two heuristics used by cppRouting are :

  • edge difference (number of shortcuts potentially created by removing v - number of incoming edges - number of outcoming edges)
  • deleted neighbors (number of already contracted neighbors)

The nodes are initially ordered using only edge difference, then importance of v is lazily updated during contraction phase with the combined two heuristics. To see more detailed explanations, see these ressources :

  • quick review
  • authors article
  • detailed author thesis
#Contraction of input graph using CH
graph3 <- cpp_contract(graph, silent=TRUE)

#Calculate distances on the contracted graph
system.time(
  pair_ch <- get_distance_pair(graph3, origin, destination)
)
## utilisateur     système      écoulé 
##       0.701       0.014       0.715

Compare outputs

summary(pair_ch-pair_dijkstra)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
##       0       0       0       0       0       0      14

Customizable contraction hierarchies (CCH)

Customizable contraction hierarchies is also a speed-up technique for finding shortest path on a network (J. Dibbelt, B. Strasser, D. Wagner (2014)). Unlike standard contraction hierarchies, only network's topology is used during contraction phase (cost is not taken into account). The main advantage is the ability to update the network's weights without needing to reconstruct the graph. This algorithm actually needs three phases :

  • preprocessing phase called contraction with cpp_contract(customizable=TRUE) function
  • weights assignation using cpp_customize() function
  • query phase : similar to contraction hierarchies

Nodes are ordered using min-degree order but the user can provide its own order with order argument. This technique is slightly slower than CH during a query, but it is more flexible and perfectly suited for traffic assignment where edge weights are updated at each iteration.

#Contraction of input graph using CCH
cch_graph <- cpp_contract(graph, customizable = TRUE, silent=TRUE)

# Assign original weights
graph4 <- cpp_customize(cch_graph)

#Calculate distances on the contracted graph
system.time(
  pair_cch <- get_distance_pair(graph4, origin, destination)
)
## utilisateur     système      écoulé 
##       2.610       0.000       2.609

Compare outputs

summary(pair_cch-pair_dijkstra)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
##       0       0       0       0       0       0      14

Performance comparison

Distance by pair

Here are the measurements of contraction time and query time (in second) of contraction hierarchies on different graphs :

Number of queries
Graph Vertices Edges preprocessing algorithm 1000 2000 5000 10000 100000 1000000
ROUTE 500 911769 2595571 43 ch 0.62 0.71 0.94 1.33 8.59 81.71
35 cch 1.42 2.78 6.72 12.81 120.62 875.38
- nba 18.46 37.57 91.51 187.04 1856.47 18474.10
OSM France 4559270 10389741 154 ch 4.08 3.92 4.66 5.11 18.20 147.05
111 cch 2.91 5.34 12.07 22.97 221.63 2096.49
- nba 144.80 289.60 724.00 1448.00 14480.00 144800.00
OSM Europe 16210743 36890020 536 ch 13.44 13.61 14.50 15.91 35.14 227.22
330 cch 7.39 11.38 23.36 43.33 402.75 4007.01
- nba 630.60 1261.20 3153.00 6306.00 63060.00 630600.00
ch : bidirectional search on the contracted graph
cch : customizable contraction hierarchies (topology contracted once, weights customized)
nba : new bidirectional A* on the original graph
OSM Europe cch values are a linear extrapolation from the measured 1,000 and 10,000 query timings

Here are the plots (in log-log) of query time improvement factor of CH (solid lines) and CCH (dotted lines) algorithms compared to NBA :

plot of chunk unnamed-chunk-16

As we can see on the plot, the larger is the graph, the higher is the benefit of using contraction hierarchies (CH). For OSM Europe, query time can be faster by a factor of 2800 with CH and 150 with CCH compared to NBA. Customizable contraction hierarchies (CCH) pair queries are consistently slower than CH pair queries — about 2× slower for 1,000 queries and up to 10–18× slower for 1,000,000 queries on ROUTE500, OSM France and OSM Europe.

Distance matrix

Here are the measurements of query time (in second) of contraction hierarchies on different graphs. We compare PHAST, many to many CH and CCH to Dijkstra algorithm on square matrix (i.e the sets of source and target nodes are of equal length).

|S|=|T|
Graph algorithm 1000 2000 5000 10000
ROUTE 500 ch - mch 0.64 1.31 5.19 19.31
ch - phast 4.21 8.42 21.05 42.10
cch 2.21 6.39 41.63 165.79
Dijkstra 27.45 54.90 137.25 274.50
OSM France ch - mch 11.15 18.66 46.13 102.18
ch - phast 100.50 201.00 502.50 1005.00
cch 5.24 12.76 67.45 248.41
Dijkstra 781.50 1563.00 3907.50 7815.00
OSM Europe ch - mch 39.17 64.50 144.39 291.28
ch - phast 460.00 920.00 2300.00 4600.00
cch 9.92 20.58 91.06 304.90
Dijkstra 2886.40 5772.80 14432.00 28864.00
ch - mch : many-to-many bidirectional search on the contracted graph
ch - phast : phast algorithm on the contracted graph
cch : many-to-many query on the customizable CCH (upward searches + per-node buckets)
Dijkstra : Dijkstra search on the original graph
cch 10,000 : quadratic extrapolation from the measured values at 1000, 2000 and 5000
|S| : number of source nodes
|T| : number of target nodes

Here are the plots (in log-log) of query time improvement factor of PHAST, many to many CH and CCH compared to Dijkstra algorithm :

plot of chunk unnamed-chunk-18

Benefits are less important than one-to-one queries but still interesting. Customizable contraction hierarchies (CCH) seems better suited than CH as long as network is big. On the other hand, many-to-many CH tends to outperforms when matrix dimensions grow.

Contraction hierarchies's PHAST improvement is constant since it iteratively perform an one-to-all search, just like original Dijkstra. many to many CH is well adapted for square matrix.

Here is the plot of query times of the three algorithms on asymmetric matrices (i.e. the number of sources and the number of targets are unequal), with |S|/|T| the number of sources divided by the number of targets. The matrix size is kept constant: |S| x |T| = 1,048,576.

plot of chunk unnamed-chunk-19

Note that this plot is the same for |T| / |S| (the query times were measured to be symmetric, within 1-2 %, for both mirror shapes on ROUTE500 and OSM France). PHAST is by far the fastest when the matrix is very unbalanced (ratio of 256 or more), because it only performs one search per source. As the matrix approaches the square shape, CCH takes over on OSM France and OSM Europe, while many-to-many CH remains competitive only on the small ROUTE500 network. many-to-many CH is never the best algorithm on OSM France or OSM Europe for this matrix size (1,048,576 paths); but this trend could be different as matrix size grow (see figure above).

Work with multiple weights network

Sometimes it can be useful to sum up additional weights along the shortest path. For a use-case example, let's say we want to compute the distance along the shortest time path or the time needed to travel the shortest distance path. It is now possible to set one or multiple additional edge weights during graph construction in makegraph() function and set aggregate_aux to TRUE in get_distance_* functions. Let's see an example where we set two auxiliary weights: a constant of 1 (summing it counts the number of edges in each shortest path) and the length of each edge in km (summing it gives the total distance traveled, read from roads_aux.csv). aux accepts a data.frame with one column per weight; we then plot the travel time against both aggregated values (number of edges, and path length):

# The weight to be minimized is set in 'df'; the additional weights are set in 'aux'.
# 'aux' is a data.frame with one column per auxiliary weight. Here we set two:
#  - n_edges : a constant of 1, so summing it counts the edges in each shortest path
#  - length  : the length of each edge (km), read from roads_aux.csv (aligned with roads)
auxdf <- read.csv("roads_aux.csv", header = TRUE, stringsAsFactors = FALSE)
aux_graph <- makegraph(df = roads, directed = TRUE, coords = coord,
                 aux = data.frame(n_edges = 1, length = auxdf$length))
# 2000 random origin-destination pairs
set.seed(1)
from <- sample(aux_graph$dict$ref, 2000)
to <- sample(aux_graph$dict$ref, 2000)
# Travel time, and both auxiliary weights aggregated along each shortest path
# (aggregate_aux = TRUE returns one data.frame with the aggregated aux columns)
time_pair <- get_distance_pair(aux_graph, from = from, to = to)
aux_pair <- get_distance_pair(aux_graph, from = from, to = to, aggregate_aux = TRUE)
res <- data.frame(travel_time = time_pair, aux_pair)
res$length[is.na(res$length)] <- 0
res$n_edges[is.na(res$n_edges)] <- 0

# Scatter plot 1: travel time vs number of edges
p1 <- ggplot(data = res, aes(x = n_edges, y = travel_time)) +
  geom_point() +
  labs(x = "Number of edges", y = "Travel time (minutes)")
p1

plot of chunk unnamed-chunk-20

# Scatter plot 2: travel time vs path length
p2 <- ggplot(data = res, aes(x = length, y = travel_time)) +
  geom_point() +
  labs(x = "Path length (km)", y = "Travel time (minutes)")
p2

plot of chunk unnamed-chunk-20

Note that this functionality work for contracted graphs (CH and CCH) as well.

Network simplification

cpp_simplify's internal function performs two major steps :

  • removing non-intersection nodes between two intersection nodes then calculate cost of the new edges,
  • removing duplicate edges that are potentially created in the first step.

In order to remove maximum number of nodes, some iterations are needed until only intersection nodes are remained.

Let's see a small example :

library(igraph)
#Create directed graph
edges<-data.frame(from=c("a","b","c","d",
                         "d","e","e","e",
                         "f","f","g","h","h","h",
                         "i","j","k","k","k",
                         "l","l","l","m","m","m",
                         "n","n","o","p","q","r"),
                  to=c("b","c","d","e","k","f","d",
                       "h","g","e","f","e","i","k",
                       "j","i","h","d","l","k",
                       "m","n","n","o","l","l","m","m",
                       "r","p","q"),
                  dist=rep(1,31))

#Plotting with igraph
par(mfrow=c(1,2),mar=c(3,0,3,0))

igr1<-graph_from_data_frame(edges)
set.seed(2)
plot.igraph(igr1,edge.arrow.size=.3,main="Original graph")
box(col="black")

#Instantiate cppRouting graph, then simplify without iterations
graph_ex<-makegraph(edges,directed = TRUE)
simp<-cpp_simplify(graph_ex,rm_loop = FALSE)
#Convert graph to df
edges2<-to_df(simp)

#Plotting simplified graph
igr2<-graph_from_data_frame(edges2)
set.seed(20)
plot(igr2,edge.arrow.size=.3,edge.label=E(igr2)$dist,main="One iteration - keeping loop")
box(col="black")

plot of chunk unnamed-chunk-21

Here, junction nodes are e, h, d, k, l, i and m. So b, c, f and n have been contracted in the first step of the function. By contracting n, an edge with cost of 2 has been created between m and l nodes. The second step of the function has removed this edge which is greater than the original one (i.e 1), and the whole process now need a second iteration to remove m and l that aren't intersection nodes anymore. Let's try with iterate argument set to TRUE :

par(mfrow=c(1,2),mar=c(3,0,3,0))
#Simplify with iterations
simp2<-cpp_simplify(graph_ex,rm_loop = FALSE,iterate = TRUE)
edges3<-to_df(simp2)
igr3<-graph_from_data_frame(edges3)
set.seed(2)
plot(igr3,edge.arrow.size=.3,edge.label=E(igr3)$dist,main="Second iteration - keeping loop")
box(col="black")

#The same but removing loops
simp3<-cpp_simplify(graph_ex,rm_loop = TRUE,iterate = TRUE)
edges4<-to_df(simp3)

igr4<-graph_from_data_frame(edges4)
set.seed(2)
plot(igr4,edge.arrow.size=.3,edge.label=E(igr4)$dist,main="Second iteration - removing loop")
box(col="black")

plot of chunk unnamed-chunk-22

French road network simplification

#Simplify original graph by keeping nodes of interest
graph2<-cpp_simplify(graph,
                     iterate = TRUE,
                     keep = unique(c(origin,destination))) #we don't want to remove origin and destination nodes

#Running NBA*
system.time(
  pair_nba_2<-get_distance_pair(graph2,origin,destination,algorithm = "NBA",constant = 110/0.06)
)
## utilisateur     système      écoulé 
##      25.151       0.007      25.159
Compare outputs
summary(pair_nba-pair_nba_2)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
##       0       0       0       0       0       0      14

Running time

Here are running times in second on graphs of different sizes :

  • data presented here,
  • OpenStreetMap french road network from Geofabrik,
  • an assembly of several european countries from OSM (France, Italy, Spain, Germany, Portugal, Switzerland, Belgium and Netherlands).
Network Nodes Edges Runtime without iterations Removed nodes in first iteration Runtime with iterations Number of iteration Total removed nodes Removed nodes percentage
README data 911,769 2,595,571 1.46 259,535 1.64 5 273,382 29.98
OSM France 4,559,270 10,389,741 6.73 818,096 11.62 9 842,252 18.47
OSM ‘Europe’ 16,210,743 36,890,020 27.94 3,365,240 46.47 11 3,465,724 21.38

Traffic assignment

Traffic assignment models are used to estimate the traffic flows on a network. It take as input origin-destinations matrix describing volume between each OD pair. For this part, we will use standard networks massively used in traffic modelling studies : Siouxfalls and Chicago networks (source). Let's import Siouxfalls. It is composed of 24 nodes and 76 links.

net <- read.csv("siouxfalls_net.csv")
head(net)
##   Init.node Term.node  Capacity Length Free.Flow.Time    B Power Speed.limit
## 1         1         2 25900.201      6              6 0.15     4           0
## 2         1         3 23403.473      4              4 0.15     4           0
## 3         2         1 25900.201      6              6 0.15     4           0
## 4         2         6  4958.181      5              5 0.15     4           0
## 5         3         1 23403.473      4              4 0.15     4           0
## 6         3         4 17110.524      4              4 0.15     4           0
##   Toll Type
## 1    0    1
## 2    0    1
## 3    0    1
## 4    0    1
## 5    0    1
## 6    0    1

We also have OD matrix as well :

trips <- read.csv("siouxfalls_trips.csv")
head(trips)
##   from to demand
## 1    1  2    100
## 2    1  3    100
## 3    1  4    500
## 4    1  5    200
## 5    1  6    300
## 6    1  7    500

All-or-Nothing (AON)

All-or-Nothing assignment (AON) is the most simplistic (and fastest) method to load flow on a network, since it assume there is no congestion effects. The assignment algorithm itself is the procedure that loads the origin-destination matrix to the shortest path trees and produces the flows. In cppRouting, OD matrix is represented as 3 vectors of equal length :

  • from : origin node,
  • to : destination node,
  • demand : volume.

Let's load flows on the network using get_aon() function :

sgr <- makegraph(df = net[,c("Init.node", "Term.node", "Free.Flow.Time")], directed = TRUE) 

# Compute AON assignment using OD trips
aon <- get_aon(Graph = sgr, from = trips$from, to = trips$to, demand = trips$demand)
head(aon)
##   from to cost  flow
## 1    1  2    6  3800
## 2    1  3    4  6000
## 3    2  1    6  3800
## 4    2  6    5  6600
## 5    3  1    4  6000
## 6    3  4    4 10200
g <- graph_from_data_frame(aon[,c("from", "to", "flow")], directed = TRUE)
c_scale <- colorRamp(c('#60AF66', 'yellow', 'red'))
maxflow <- max(c(aon$flow))

E(g)$color = apply(c_scale(aon$flow/maxflow), 1, function(x) rgb(x[1]/255,x[2]/255,x[3]/255) )
set.seed(5)
plot(g, edge.width = net$Capacity/2000, edge.arrow.size = 0, main = "AON assignment")
box(col = "black")

plot of chunk unnamed-chunk-29

get_aon work also for contracted networks (CH and CCH).

Choosing the best routing algorithm

Computing time is directly linked to shortest paths calculations. The user has the choice between three kind of routing algorithms, depending the sparsity of OD matrix and network size. By default, aon_method argument is set to bidirectional Dijkstra. Just like get_*_pair functions, it run length(from) times routing algorithm for finding each OD pair's shortest path. If OD matrix is dense, recursive one-to-many methods like Dijkstra algorithm would be preferred. Given an origin node, bidirectional Dijkstra et NBA* algorithms are on average 2 and 5 times faster than Dijkstra algorithm, respectively. If network is big enough, a network preprocessed with customizable contraction hierarchies could be a win since the graph have to be contracted just one time.

Note that it is also possible to contract the network using CH at each iteration by applying aon_method = "cphast"or aon_method = "cbi". cphast algorithm is for matrix-like shortest paths calculation, and cbi is pairwise. In that case, cbi algorithm is 200 times faster than cphast for a given node.

For resuming :

  • matrix-like calculation (aon_method="d"): optimal for dense matrix, run one-to-many search for min(length(unique(from)), length(unique(to))) times,

  • pairwise calculation (aon_method="bi" or aon_method="nba"): optimal for highly sparse matrix, run one-to-one search for length(from) times.

  • customizable contraction hierarchies (aon_method="cch") : optimal for large network and large OD matrix

  • contraction hierarchies (aon_method="cbi" or aon_method="cphast") : contract network with CH at each iteration then apply phast or bi algorithm. It's a legacy feature, CCH must be preferred.

User Equilibrium (UE)

The term "User Equilibrium" is used to describe a route choice assumption formally proposed by Wardrop : “The journey times on all the routes actually used are equal and less than those which would be experienced by a single vehicle on any unused routes”. Note that this principle follows directly from the assumptions that drivers choose minimum time paths, and are well-informed about network conditions.

Unlike AON assignment, this more realistic way to assign flows on a network take into account congestion effect. In this paradigm, the cost of a given link is dependent of the flow on it.

As an example, let's assume 3000 commuters going from one node to another connected by two links. The User equilibrium is illustrated on this figure (source):

The relation between cost and flow is called volume decay function and is written as :

$t = t_{0}.(1+alpha.(frac{v}{c})^{beta})$ with $t$ the cost, $t_{0}$ the free-flow cost, $v$ the flow (or volume), $c$ the link capacity and $alpha, beta$ unitless parameters.

Traffic Assignment Problem (TAP) is a convex optimization problem solved by iterative algorithms. The relative gap is the metric to minimize and is written as :

$gap=|(frac{TSTT}{SPTT}) - 1|$

Total System Travel Time is written as $TSTT = sum_{x in E} v_{x}.t_{x}(v_{x})$ Shortest Path Travel Time is written as $SPTT = sum_{x in E} hat{v}{x}.t{x}(v_{x})$ With $E$ the set of edge in the network, $v$ the volume or flow, $t$ the cost, $hat{v}$ the flow estimated by All-or-Nothing assignment.

Link-based algorithms

These methods use the descent direction given by AON assignment at each iteration. All links are updated simultaneously using descent direction and a step size parameter $theta$.

Link-based algorithms implemented in cppRouting are, in increasing order of complexity :

  • Method of Successive Average (msa) : $theta$ is defined as $frac{1}{it}$ with $it$ the actual number of iteration.
  • Frank-Wolfe (fw) : $theta$ is computed by minimizing the Beckmann function with bisection method.
  • Conjugate Frank-Wolfe (cfw) : $theta$ is computed by minimizing the Beckmann function with bisection method. Descent direction is computed using AON assignment and direction at $it-1$.
  • Bi-Conjugate Frank-Wolfe (bfw) : $theta$ is computed by minimizing the Beckmann function with bisection method. Descent direction is computed using AON assignment and directions at $it-1$ and $it-2$.

By going down through that list, we slightly increase computing time in each iteration but we will need less iterations to reach a given gap. Let's equilibrate traffic within our small network, first we need to construct the network with important parameters : capacity, alpha and beta.

For these algorithms, 99% of computation time is done within AON assignment.

sgr <- makegraph(df = net[,c("Init.node", "Term.node", "Free.Flow.Time")], 
                 directed = TRUE,
                 capacity = net$Capacity,
                 alpha = net$B,
                 beta = net$Power)


traffic <- assign_traffic(Graph = sgr,  from = trips$from, to = trips$to, demand = trips$demand, 
                          max_gap = 1e-6, algorithm = "bfw", verbose = FALSE)
  
traffic$gap
## [1] 0.0000009230227

Returned data contains the equilibrated network with the following edge attributes :

  • ftt : free-travel time i.e. the initial cost set during graph construction,
  • cost : actual travel time at equilibrium,
  • flow : equilibrated flow.
head(traffic$data)
##   from to ftt     cost      flow  capacity alpha beta
## 1    1  2   6 6.000779  4442.890 25900.201  0.15    4
## 2    1  3   4 4.008658  8111.525 23403.473  0.15    4
## 3    2  1   6 6.000829  4511.531 25900.201  0.15    4
## 4    2  6   5 6.545270  5940.297  4958.181  0.15    4
## 5    3  1   4 4.008369  8042.884 23403.473  0.15    4
## 6    3  4   4 4.262114 13910.673 17110.524  0.15    4

Now, we can plot the result with line size varying with road capacity and the color with flow.

par(mfrow=c(1,2),mar=c(3,0,3,0))
g <- graph_from_data_frame(aon[,c("from", "to", "flow")], directed = TRUE)
c_scale <- colorRamp(c('#60AF66', 'yellow', 'red'))
maxflow <- max(c(aon$flow, traffic$data$flow))

#Applying the color scale to edge weights.
#rgb method is to convert colors to a character vector.
E(g)$color = apply(c_scale(aon$flow/maxflow), 1, function(x) rgb(x[1]/255,x[2]/255,x[3]/255) )
set.seed(5)
plot(g, edge.width = traffic$data$capacity/2000, edge.arrow.size = 0, main = "AON assignment")
box(col = "black")


g <- graph_from_data_frame(traffic$data[,c("from", "to", "flow")], directed = TRUE)
E(g)$color = apply(c_scale(traffic$data$flow/maxflow), 1, function(x) rgb(x[1]/255,x[2]/255,x[3]/255) )
set.seed(5)
plot(g, edge.width = traffic$data$capacity/2000, edge.arrow.size = 0, main = "Flows at equilibrium")
box(col = "black")

plot of chunk unnamed-chunk-32

Bush-based algorithms

cppRouting also propose a bush-based algorithm called Algorithm B from R.B. Dial (2006). The problem is decomposed into sub-problems, corresponding to each origin of the OD matrix, that operate on acyclic sub-networks of the original transportation network, called bushes. Link flows are shifted from the longest path to the shortest path recursively within each bush using Newton method. Unlike link-based algorithm, Algorithm B can achieve very precise solution by minimizing relative gap down to 1e-16.

The main steps of the procedure are :

Initialization

  • bushes initialization : for each unique origin node of the OD matrix, an acyclic sub-network is created by computing shortest path tree from root node to all other nodes.

For each bush :

  • Topological ordering of the nodes - for each node, shortest path and longest path is computed and stored. - given an OD matrix, flows are loaded on each bush using shortest path labels.

Iteration For each bush :

  • shortest and longest paths are updated
  • bush optimization : the bush is updated by removing unused links (no flow on it) and adding links leading to shorter paths. Network costs are updated in the same time.
  • bush equilibration : the flow is shifted from the shortest to the longest path using Newton method.

Evaluation We compute AON assignment and relative gap.

For detailed explanations of Algorithm B, please see this course (part 1, part 2).

Important note : computation time for algorithm-B is depending of the number of origin node AND AON assignment.

Algorithm B is used by setting dial to algorithm argument :

traffic <- assign_traffic(Graph = sgr,  from = trips$from, to = trips$to, demand = trips$demand, 
                          max_gap = 1e-6, algorithm = "dial", verbose = TRUE)
## Bushes initialization...
## Iterating...
## iteration 1 : 0.05019
## iteration 2 : 0.0152742
## iteration 3 : 0.00363109
## iteration 4 : 0.000592523
## iteration 5 : 0.000106401
## iteration 6 : 3.00525e-05
## iteration 7 : 3.5758e-05
## iteration 8 : 1.21886e-05
## iteration 9 : 6.67853e-06
## iteration 10 : 1.659e-06
## iteration 11 : 2.34866e-07
traffic$gap
## [1] 0.000000234866

Performance comparison

Now, let's measure performance of Traffic assignment algorithms on a larger road network. We use Chicago network, composed of 12982 nodes and 39018 edges.

net <- read.csv("chicagoregional_net.csv")
head(net)
##   from    to capacity length ftime    B power speed toll type
## 1    1 10293   100000   0.45     0 0.15     4    25    0    3
## 2    2 10294   100000   0.50     0 0.15     4    25    0    3
## 3    3 10295   100000   0.39     0 0.15     4    25    0    3
## 4    4 10296   100000   0.58     0 0.15     4    25    0    3
## 5    5 10297   100000   0.50     0 0.15     4    25    0    3
## 6    6 10298   100000   0.58     0 0.15     4    25    0    3

OD matrix contains 2,297,945 trips.

trips <- read.csv("chicagoregional_trips.csv")
head(trips)
##   from to demand
## 1    1  1  28.94
## 2    1  2  16.58
## 3    1  3   2.01
## 4    1  4  13.99
## 5    1  5   4.90
## 6    1  6   5.10

Since All-or-Nothing algorithm will be run multiple times, we have to choose the fastest AON assignment algorithm. Let's benchmark d, bi and cch methods.

# Construct graph with link attributes
sgr <- makegraph(df = net[,c("from", "to", "ftime")], directed = TRUE,
                 capacity = net$capacity, alpha = net$B, beta = net$power)

# benchmark using all cores. We don't test NBA* because we don't have node coordinates
RcppParallel::setThreadOptions(numThreads = parallel::detectCores())

cch <- cpp_contract(sgr, customizable = TRUE, silent = TRUE)

microbenchmark(
  pairwise = pw <- get_aon(Graph = sgr, from = trips$from, to = trips$to, demand = trips$demand, algorithm = "bi"),
  matrixlike = ml <- get_aon(Graph = sgr, from = trips$from, to = trips$to, demand = trips$demand, algorithm = "d"),
  cch = {
    cch_metric <- cpp_customize(cch)
    caon <- get_aon(Graph = cch_metric, from = trips$from, to = trips$to, demand = trips$demand)
  },
  times = 1
)
## Unit: seconds
##        expr       min        lq      mean    median        uq       max neval
##    pairwise 55.368205 55.368205 55.368205 55.368205 55.368205 55.368205     1
##  matrixlike  1.092179  1.092179  1.092179  1.092179  1.092179  1.092179     1
##         cch  1.909323  1.909323  1.909323  1.909323  1.909323  1.909323     1

By setting matrix-like AON calculation, we speed up computation time by a factor of 50 for link-based methods. On this network, CCH is slightly slower than matrix-like Dijkstra (1.9 s vs 1.2 s) due to its small size (~10 000 nodes).

sgr <- makegraph(df = net[,c("from", "to", "ftime")], directed = TRUE,
                 capacity = net$capacity, alpha = net$B, beta = net$power)

# we set 5 minutes for each algorithm (about 600 iterations for link-based, and 20 for algorithm-b)
## capture console output for plotting
outs <- list()
times <- list()
for (i in c("msa", "fw", "cfw", "bfw", "dial")){
  max_it <- 600
  if (i == "dial"){
    max_it <- 20
  }
  
  stdout <- vector(mode = "character")
  con    <- textConnection('stdout', 'wr', local = TRUE)
  sink(con)
  st <- system.time(
  traffic <- assign_traffic(Graph = sgr,  from = trips$from, to = trips$to, demand = trips$demand, 
                          max_gap = 1e-6, algorithm = i, aon_method = "d", verbose = TRUE, max_it = max_it)
  )
  sink()
  outs[[i]] <- stdout
  times[[i]] <- st
  rm(stdout)
}

# Plotting gap vs time

dfp <- list()
for (i in c("msa", "fw", "cfw", "bfw", "dial")){
  out <- outs[[i]]
  out <- out[grepl("iteration", out)]
  out <- sapply(strsplit(out, " : "), function(x) as.numeric(x[2]))
  
  df <- data.frame(gap = out, time = 1:length(out)  * (times[[i]][1]/length(out)), algo = i)
  dfp[[i]] <- df
}
dfp <- do.call(rbind, dfp)

options(scipen=999)
p <- ggplot(dfp, aes(x = time, y = gap, colour = algo))+
  geom_line(size = 1)+
  scale_y_log10()+
  theme_bw()+
  labs(x = "Time(second)", y = "Relative gap (log10 scale)", colour = "Algorithm")+
  scale_color_hue(labels = c("Bi-Conjugate FW", "Conjugate FW", "Algorithm B", "Frank-Wole", "MSA"))
  
p

plot of chunk unnamed-chunk-37

Algorithm B could achieve very small gap within an reasonable amount of time, while link-based methods seems to converge much more slowly. For achieving highly precise solutions, algorithm B should be implemented. However, algorithm B generally use more memory than link-based methods, depending network size and OD matrix. On the other hand, link-based method may have a higher benefit through parallel computing since computation time is mainly due to AON assignment.

Algorithm compatibility

Here is a table summarizing cppRouting functions compatibility with network nature (contracted (CH or CCH), simplified, normal).

Functions contracted simplified
assign_traffic no yes
get_aon yes yes
get_detour no yes
get_distance_matrix - one weight yes yes
get_distance_matrix - two weights yes no
get_distance_pair - one weight yes yes
get_distance_pair - two weights yes no
get_isochrone no yes
get_multi_paths no yes
get_path_pair yes yes

Parallel implementation

Now a table summarizing cppRouting functions compatibility with parallel computing.

Functions multithreaded
makegraph no
cpp_extract no
cpp_contract no
cpp_customize no
cpp_simplify no
assign_traffic - link-based yes
assign_traffic - bush-based partly
get_aon yes
get_detour yes
get_distance_matrix yes
get_distance_pair yes
get_isochrone yes
get_multi_paths yes
get_path_pair yes

Applications

Except applications 4 and 5, all indicators are calculated at the country scale but for the limited R's ability to plot large shapefile, only one region is mapped.

Application 1 : Calculate Two Step Floating Catchment Areas (2SFCA) of general practitioners in France

2SFCA method is explained here : https://en.wikipedia.org/wiki/Two-step_floating_catchment_area_method

First step Isochrones are calculated with the cppRouting function get_isochrone

#Isochrone around doctor locations with time limit of 15 minutes
iso<-get_isochrone(graph,
                   from = ndcom[ndcom$com %in% med$CODGEO,"id_noeud"],
                   lim = 15,
                   keep=ndcom$id_noeud, #return only commune nodes
                   long = TRUE) #data.frame output
#Joining and summing population located in each isochrone
iso<-left_join(iso,ndcom[,c("id_noeud","POPULATION")],by=c("node"="id_noeud"))
df<-iso %>% group_by(origin) %>%
  summarise(pop=sum(POPULATION))
#Joining number of doctors 
df<-left_join(df,med[,c("id_noeud","NB_D201")],by=c("origin"="id_noeud"))
#Calculate ratios
df$ratio<-df$NB_D201/df$pop

Second step

#Isochrone around each commune with time limit of 15 minutes (few seconds to compute)
iso2<-get_isochrone(graph,
                    from=ndcom$id_noeud,
                    lim = 15,
                    keep=ndcom$id_noeud,
                    long=TRUE)
#Joining and summing ratios calculated in first step
df2<-left_join(iso2,df[,c("origin","ratio")],by=c("node"="origin"))
df2<-df2 %>% group_by(origin) %>%
  summarise(sfca=sum(ratio,na.rm=T))

Plot the map for Bourgogne-Franche-Comte region

#Joining commune IDs to nodes
df2<-left_join(df2,ndcom[,c("id_noeud","com")],by=c("origin"="id_noeud"))
#Joining 2SFCA to shapefile
com<-left_join(com,df2[,c("com","sfca")],by=c("INSEE_COM"="com"))
#Plot for one region
p<-tm_shape(com[com$NOM_REG=="BOURGOGNE-FRANCHE-COMTE",]) + 
  tm_fill(col = "sfca",style="cont", lwd=0)+
  tm_layout(main.title="2SFCA applied to general practitioners",legend.outside=TRUE)
p

plot of chunk unnamed-chunk-42

Application 2 : Calculate the minimum travel time to the closest maternity ward in France

Shortest travel time matrix The shortest travel time is computed with the cppRouting function get_distance_matrix. In order to compute multiple distances from one source, original uni-directional Dijkstra algorithm is ran without early stopping. We compute travel time from all commune nodes to all maternity ward nodes (i.e ~36000*400 distances).

#Distance matrix on contracted graph
dists<-get_distance_matrix(graph3,
                           from=ndcom$id_noeud,
                           to=ndcom$id_noeud[ndcom$com %in% maternity$CODGEO],
                           algorithm = "phast")#because of the rectangular shape of the matrix
#We extract each minimum travel time for all the communes
dists2<-data.frame(node=ndcom$id_noeud,mindist=apply(dists,1,min,na.rm=T))
#Joining commune IDs to nodes
dists2<-left_join(dists2,ndcom[,c("id_noeud","com")],by=c("node"="id_noeud"))
#Joining minimum travel time to the shapefile
com<-left_join(com,dists2[,c("com","mindist")],by=c("INSEE_COM"="com"))

Plot the map of minimum travel time in Bourgogne-Franche-Comte region

p<-tm_shape(com[com$NOM_REG=="BOURGOGNE-FRANCHE-COMTE",]) + 
  tm_fill(col = "mindist",style="cont",palette="Greens",title="Minutes", lwd=0)+
  tm_layout(main.title="Travel time to the closest maternity ward",legend.outside=T)
p

plot of chunk unnamed-chunk-44

Application 3 : Calculate average commuting time to go to job in France

Commuting data from national census is composed of 1003647 unique pairs of origin - destination locations (home municipality, job municipality). Using other R packages like igraph or dodgr, we would have to calculate the whole matrix between all communes (36000 x 36000). We would end with a 10Gb matrix whereas we only need 0.075% of the result (1003647/(36000 x 36000)). So this example illustrate the advantage of calculating distance by pair.

#Merge node to communes
ndcom$id_noeud<-as.character(ndcom$id_noeud)
cmt$node1<-ndcom$id_noeud[match(cmt$CODGEO,ndcom$com)]
cmt$node2<-ndcom$id_noeud[match(cmt$DCLT,ndcom$com)]
cmt<-cmt[!is.na(cmt$node1) & !is.na(cmt$node2),]

#Calculate distance for each pair using contracted graph
dist<-get_distance_pair(graph3,from=cmt$node1,to=cmt$node2)

#Mean weighted by the flow of each pair
traveltime<-cbind(cmt,dist)
traveltime<-traveltime %>% group_by(CODGEO) %>% summarise(time=weighted.mean(dist,flux,na.rm=T))

Plot the map of average travel time of Bourgogne-Franche-Comte inhabitants

#Merge to shapefile
com_time <- left_join(com, traveltime, by = c("INSEE_COM"="CODGEO"))
p <- tm_shape(com_time[com_time$NOM_REG=="BOURGOGNE-FRANCHE-COMTE", ]) +
  tm_fill(col = "time",style="quantile",n=5,title="Minutes",palette="Reds", lwd=0)+
  tm_layout(main.title="Average travel time",legend.outside=TRUE)
p

plot of chunk unnamed-chunk-46

Application 4 : Calculate the flow of people crossing each municipality in the context of commuting in Bourgogne-Franche-Comte region

First, we must determine in which commune each node is located.

#Convert nodes coordinates to spatial points
pts<-st_as_sf(coord,coords = c("X","Y"),crs=2154)
st_crs(com)<-st_crs(pts)
#One row per commune: the app 1/2 joins may have duplicated communes sharing
#the same nearest node (node_commune.csv is not unique by node)
com<-com[!duplicated(com$INSEE_COM),]
#Spatial join commune ID to each node
spjoin<-st_join(pts,com[,"INSEE_COM"])
st_geometry(spjoin)<-NULL
spjoin$ID<-as.character(spjoin$ID)
#Vector of commune IDs of Bourgogne-Franche-Comte region
comBourg<-com$INSEE_COM[com$NOM_REG=="BOURGOGNE-FRANCHE-COMTE"]
#Calculate shortest paths for all commuters on the contracted graph
shortPath<-get_path_pair(graph3,
                         from=cmt$node1,
                         to=cmt$node2,
                         keep = spjoin$ID[spjoin$INSEE_COM %in% comBourg],#return only the nodes in Bourgogne Franche-Comte because of memory usage
                         long = TRUE)#return a long data.frame instead of a list

#Joining commune ID to each traveled node
shortPath<-left_join(shortPath,spjoin,by=c("node"="ID"))

If a commuter crosses multiple nodes in a municipality, we count it only once.

#Remove duplicated 
shortPath<-shortPath[!duplicated(shortPath[,c("from","to","INSEE_COM")]),]

#Joining flow per each commuter
shortPath<-left_join(shortPath,
                     cmt[,c("node1","node2","flux")],
                     by=c("from"="node1","to"="node2"))

#Aggregating flows by each commune
shortPath<-shortPath %>% group_by(INSEE_COM) %>% summarise(flow=sum(flux))

Plot the flow of people crossing Bourgogne-Franche-Comte's communes

#Merge to shapefile
com<-left_join(com,shortPath,by="INSEE_COM")

p<-tm_shape(com[com$NOM_REG=="BOURGOGNE-FRANCHE-COMTE",]) + 
  tm_fill(col = "flow",style="quantile",n=10,palette="Blues", lwd=0)+
  tm_layout(main.title="Commuters flow",legend.outside=TRUE)
p

plot of chunk unnamed-chunk-49

Application 5 : Estimate the commuter traffic at user equilibrium in Bourgogne-Franche-Comte

In application 4, every commuter followed the shortest path, whatever the volume of people on it. In this last application, we estimate the traffic at user equilibrium. assign_traffic solves this problem with the bi-conjugate Frank-Wolfe algorithm ("bfw"). Each Frank-Wolfe iteration requires an All-or-Nothing assignment, and edge weights change at every iteration, so we use a customizable contraction hierarchy (aon_method = "cch"): the topology is prepared once with cpp_contract, and only the edge costs are updated at each iteration, which is the fastest option for this use case (see the traffic assignment section).

We work on the sub-network of the BFC region (arcs whose two endpoints are located in the region). This approximation means that origin-destination pairs whose real path exits the region are routed within the sub-network instead. The capacity parameter is set to 1,800, a standard value for a single lane in France; the commuter flows are expressed in people, so this assignment should be read as a relative congestion model rather than a strict veh/hour calibration.

#Nodes located in the region (node ids are 0-based, aligned with the road file)
nodecom  <- spjoin$INSEE_COM
bfc_nodes <- which(nodecom %in% comBourg) - 1L
#Sub-network: keep only arcs whose two endpoints are in the region
keep_e <- (as.integer(roads$from) %in% bfc_nodes) & (as.integer(roads$to) %in% bfc_nodes)
sub_road <- roads[keep_e, ]
old_ids <- sort(unique(c(as.integer(sub_road$from), as.integer(sub_road$to))))
#Renumber the nodes from 0
idmap <- setNames(as.character(seq_along(old_ids) - 1L), as.character(old_ids))
sub_road$from <- idmap[as.character(sub_road$from)]
sub_road$to   <- idmap[as.character(sub_road$to)]
sub_coord <- coord[match(old_ids, as.integer(coord$ID)), ]
sub_coord$ID <- as.character(seq_along(old_ids) - 1L)
#Keep only origin-destination pairs whose two endpoints are in the sub-network
od <- cmt[as.character(cmt$node1) %in% old_ids & as.character(cmt$node2) %in% old_ids, ]
od$node1 <- idmap[as.character(od$node1)]
od$node2 <- idmap[as.character(od$node2)]
cat(sprintf("Sub-network: %d nodes, %d arcs - %d origin-destination pairsn",
            length(old_ids), nrow(sub_road), nrow(od)))
## Sub-network: 64310 nodes, 179340 arcs - 47628 origin-destination pairs
#Traffic assignment needs the BPR parameters, defined at graph construction
RcppParallel::setThreadOptions(numThreads = parallel::detectCores() - 1) #the AON step is multithreaded
g_ue <- makegraph(sub_road, coords = sub_coord, directed = TRUE,
                  capacity = 1800, alpha = 0.15, beta = 4)
cch_ue <- cpp_contract(g_ue, customizable = TRUE) #topology prepared once
## Computing elimination order...
## Eliminating nodes...
ue <- assign_traffic(g_ue, from = od$node1, to = od$node2, demand = od$flux,
                     algorithm = "bfw", aon_method = "cch", cch = cch_ue,
                     max_gap = 0.001, verbose = FALSE)
cat(sprintf("Gap achieved: %.5f (%d iterations)n", ue$gap, ue$it))
## Gap achieved: 0.00100 (635 iterations)

Each row of ue$data is an arc, with its free-flow time (ftt), its equilibrium cost (cost) and its equilibrated flow (flow). To visualize the result, we map each arc on the segment between the coordinates of its two nodes, and we plot every arc with a positive flow: both the line thickness and the color intensity are proportional to the flow (with a log scale for the color, to make the hubs stand out from the thin rural mesh).

#Coordinates of the two endpoints of each arc
cof <- sub_coord; colnames(cof) <- c("ID_f","X_f","Y_f")
cot <- sub_coord; colnames(cot) <- c("ID_t","X_t","Y_t")
ue_df <- left_join(ue$data, cof, by = c("from" = "ID_f"))
ue_df <- left_join(ue_df, cot, by = c("to" = "ID_t"))
ue_df <- ue_df[!is.na(ue_df$X_f) & !is.na(ue_df$X_t) & ue_df$flow > 0, ]
ue_df$geom <- mapply(function(a,b,c,dd) st_linestring(rbind(c(a,b), c(c,dd))),
                     ue_df$X_f, ue_df$Y_f, ue_df$X_t, ue_df$Y_t, SIMPLIFY = FALSE)
ue_df <- st_sf(ue_df, crs = 2154)
com_bfc <- com[com$NOM_REG=="BOURGOGNE-FRANCHE-COMTE", ]
bbox <- st_bbox(com_bfc$geometry)
p <- ggplot() +
  geom_sf(data = com_bfc, fill = "white", color = "grey85", linewidth = 0.2) +
  geom_sf(data = ue_df, aes(size = flow, color = flow), alpha = 0.9) +
  scale_size_continuous(range = c(0.2, 2.5), trans = "sqrt", name = "Commuter flow (people)") +
  scale_color_gradient(low="yellow", high = "red", trans = "log10", name = NULL, guide = "none") +
  coord_sf(xlim = bbox[c("xmin","xmax")], ylim = bbox[c("ymin","ymax")]) +
  theme_void()
p

plot of chunk unnamed-chunk-52

The resulting picture matches the expected commuter structure of the region: the traffic concentrates on the rings around the urban cores (Dijon, Besançon, Dole, Auxerre, Chalon-sur-Saône...), with thick radial roads and empty corridors between the rural areas.

Citation

Please don't forget to cite cppRouting package in your work !

citation("cppRouting")

About

Algorithms for Routing and Solving the Traffic Assignment Problem

Topics

algorithmalgorithm-bbidirectional-a-star-algorithmc-plus-pluscontraction-hierarchiesdijkstra-algorithmdistancefrank-wolfeisochronesparallel-computingrrcppshortest-pathstraffic-assignment

Resources

Readme
Activity

Stars

122 stars

Watchers

6 watching

Forks

11 forks
Report repository

Releases

Packages

Contributors

Languages

You are visitor 01402503 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.