Using custom OSM car speeds and LTS

2025-08-21

Abstract

This vignette shows how to calculate travel times and accessibility with custom OSM car speeds and Level of Traffic Stress, which can be used to simulate different scenarios of traffic congestion and road closures, or interventions in cycling infrastructure.

1. Introduction

By default, routing by car in R5 considers that vehicles travel at the legal speed limit in each OSM road edge. This is commonly referred to as a “free flow scenario”, without congestion. However, the average speed of car trips is different (usually slower) than the legal speed limit in most real case scenarios due to traffic conditions and driving behavior. Similarly, R5 considers road speeds, road hierarchy and the quality of cycling infrastructure to infer the Level of Traffic Stress (LTS) of each road segment in the transportation network.

This vignette shows how you can calculate travel times and accessibility using custom OSM car speeds or LTS, which can be used to simulate different scenarios of traffic congestion and road closures, or interventions in cycling infrastructure.

In short, OSM car speeds and LTS values can be changed using two different strategies: (1) using a data.frame to apply changes to individual road segments, or (2) using a spatial sf to apply changes to certain roads within / touching the spatial simple feature. Let’s see how this works with a few examples using the a sample data set for the city of Porto Alegre (Brazil) included in r5r. First, let’s load a few libraries and build our rotatable transportation network.

# increase Java memory
options(java.parameters = "-Xmx2G")

# load libraries
library(r5r)
library(dplyr)
library(data.table)
library(ggplot2)

# data path
data_path <- system.file("extdata/poa", package = "r5r")

# build network
r5r_network <- r5r::build_network(
  data_path = data_path,
  verbose = FALSE
  )

2. Changing car speeds

All of the routing and accessibility functions in {r5r} now have the following parameters that allow one to use custom OSM car speeds.

Let’s see a couple examples.

2.1 Changing car speeds by OSM edge

In this first example, we will pass the new car speeds using a sample data.frame that comes with the package. Mind you that this data frame must contain the columns "osm_id", "max_speed" and "speed_type". The "speed_type" column is of class character where all values must be either "scale" or "km/h", indicating whether the values in "max_speed" should be interpreted as percentages of original speeds ("scale") or as absolute speeds ("km/h"). Like this:

# read data.frame with new car speeds
edge_speed_factors <- read.csv(
  file.path(data_path, "poa_osm_congestion.csv")
  )

head(edge_speed_factors)
#>      osm_id max_speed speed_type
#> 1  27184648       0.5      scale
#> 2 762361901       0.5      scale
#> 3 568609955       0.5      scale
#> 4 709834913       0.5      scale
#> 5 709834914       0.5      scale
#> 6  77705540       0.5      scale

In this example, the values of the "max_speed" column are all set to 0.5 and speed_type == "scale", which means that the driving speed of those OSM edges listed in the data.frame will be at 50% of the original speed in the OSM data.

Now you can simply run any routing or accessibility function passing the parameters new_carspeeds and carspeed_scale:

# origins and destination points
points <- read.csv(file.path(data_path, "poa_points_of_interest.csv"))

# travel time matrix
ttm_congestion <- r5r::travel_time_matrix(
  r5r_network = r5r_network,
  origins = points,
  destinations = points,
  mode = 'car',
  departure_datetime = Sys.time(),
  max_trip_duration = 30,
  new_carspeeds = edge_speed_factors,
  carspeed_scale = 0.8
)

Obs. Mind you that, even though we have set the speed factors to 0.5, travel times might not become twice as long. This is because of how travel times by car are also affected by intersections, and how changes in the road speeds might also affect the route and hence the trip distance itself.

Now let’s dive into a more realistic examples.

2.1.1 Setting different congestion levels by road hierarchy

In this example, we’ll set different speed factors for roads of different hierarchy levels. We can assume for example that congestion levels tend to be more intense in roads of higher hierarchy. We can do this in two simple steps.

First we need to do read the OSM data from our .pbf file, and to filter the OSM edges with the road types we want.

# path to OSM pbf
pbf_path <- paste0(data_path, "/poa_osm.pbf")
  
# read layer of lines from pbf
roads <- sf::st_read(
  pbf_path, 
  layer = 'lines', 
  quiet = TRUE
  )

# Filter only road types of interest
rt <- c("motorway", "primary", "secondary", "tertiary") 

roads <- roads |>
  select(osm_id, highway) |>
  filter(highway %in% rt)

head(roads)
#> Simple feature collection with 6 features and 2 fields
#> Geometry type: LINESTRING
#> Dimension:     XY
#> Bounding box:  xmin: -51.21315 ymin: -30.06624 xmax: -51.15025 ymax: -30.04939
#> Geodetic CRS:  WGS 84
#>     osm_id highway                       geometry
#> 1 26786712 primary LINESTRING (-51.15164 -30.0...
#> 2 26786730 primary LINESTRING (-51.17265 -30.0...
#> 3 26786732 primary LINESTRING (-51.15025 -30.0...
#> 4 26847798 primary LINESTRING (-51.21315 -30.0...
#> 5 26936215 primary LINESTRING (-51.20818 -30.0...
#> 6 26936224 primary LINESTRING (-51.20818 -30.0...

Here’s how the road network looks like.

# map
plot(roads["highway"])

Now we only need to add a new column "max_speed" with values conditioned on the road type, and make sure the osm_id is of class numeric. We also need to include the column speed_type to make it clear that max speeds should be interpreted as a "scale" factor. The data.frame looks like this:

new_edge_speeds <- roads |>
  mutate( 
    osm_id = as.numeric(osm_id),
    max_speed = case_when(
      highway == "motorway"  ~ 0.75,
      highway == "primary"   ~ 0.8,
      highway == "secondary" ~ 0.85,
      highway == "tertiary"  ~ 0.9)) |>
  sf::st_drop_geometry()

new_edge_speeds$speed_type <- "scale"

head(new_edge_speeds)
#>     osm_id highway max_speed speed_type
#> 1 26786712 primary       0.8      scale
#> 2 26786730 primary       0.8      scale
#> 3 26786732 primary       0.8      scale
#> 4 26847798 primary       0.8      scale
#> 5 26936215 primary       0.8      scale
#> 6 26936224 primary       0.8      scale

That’s it. Now we can calculate travel times with the modified OSM car speeds.

# travel time matrix
ttm_congestion <- r5r::travel_time_matrix(
  r5r_network = r5r_network,
  origins = points,
  destinations = points,
  mode = 'car',
  departure_datetime = Sys.time(),
  max_trip_duration = 30,
  new_carspeeds = new_edge_speeds
  )

2.1.2 Applying the same speed factor to all roads

In this example, we’ll simulate as if the speed limits of all roads were changed to 40 Km/h. To do this, we simply edit the new_edge_speeds table we created in our previous example to assign a 40 Km/h to each and every OSM id.

# edit table with custom speeds to 40 km/h
new_edge_speeds40 <- new_edge_speeds |>
  mutate(max_speed = 40,
         speed_type = "km/h")
  
# travel time matrix
ttm_congestion <- r5r::travel_time_matrix(
  r5r_network = r5r_network,
  origins = points,
  destinations = points,
  mode = 'car',
  departure_datetime = Sys.time(),
  max_trip_duration = 30,
  new_carspeeds = new_edge_speeds40
  )

Extra tip:

  • Road closure: one can simulate a road closure by setting the "max_speed" value to 0. This can be quite handy for studies that try to measure the resilience of transport systems to network disruptions.

2.2 Changing car speeds with a spatial polygon

If you do not want to set the speed factor for each individual OSM road edge, you can use one or more spatial polygons to set the new car speeds of all the roads within those polygons. In this example with the sample data from {r5r}, we have two polygons in the city of Porto Alegre. The first one covers the extended city center, and the second polygon covers a few important roads that connect two major avenues in the city.

# read sf with congestion polygons
congestion_poly <- readRDS(file.path(data_path, "poa_poly_congestion.rds"))

# preview
mapview::mapview(congestion_poly, zcol="scale")

Mind you that this sf data.frame must have a few mandatory columns:

head(congestion_poly)
#> Simple feature collection with 2 features and 3 fields
#> Geometry type: POLYGON
#> Dimension:     XY
#> Bounding box:  xmin: -51.24537 ymin: -30.04352 xmax: -51.21426 ymax: -30.02548
#> Geodetic CRS:  WGS 84
#>   poly_id scale priority                       geometry
#> 1       1   0.7        1 POLYGON ((-51.22463 -30.034...
#> 2       2   0.8        2 POLYGON ((-51.21426 -30.034...

In this example, we are simulating a higher congestion level of the roads in the city center, which would be running at 70% of the legal speed limit, and a slightly better performance for the roads in the second polygon, running at 80% of the speed limit. Finally, we can set carspeed_scale = 0.95 to simulate that all the other roads in the city would be running at 95%.

ttm_congestion <- r5r::travel_time_matrix(
  r5r_network = r5r_network,
  origins = points,
  destinations = points,
  mode = 'car',
  departure_datetime = Sys.time(),
  max_trip_duration = 30,
  new_carspeeds = congestion_poly,
  carspeed_scale = 0.95
  )

And that’s it!

3. Changing cycling LTS values

There are two approaches to changing LTS, which are fairly similar to how we can change road speeds. This can be done either with a data.frame indicating the new LTS of each individual OSM edge id, or an sf data.frame. The key difference is that the sf object needs to be of type LINESTRING. R5 will then find the nearest road for each line and update its LTS value accordingly.

3.1 Changing LTS by OSM edge

Now let’s simulate the local government has implemented protected cycle lanes or tracks across a few designated roads in our transport network. Mind you that the data.frame must contain the column "lts" indicating the new LTS value.

# read data.frame with new lts
edge_lts <- read.csv(
  file.path(data_path, "poa_osm_lts.csv")
  )

head(edge_lts)
#>      osm_id lts
#> 1  27184648   1
#> 2 762361901   1
#> 3 568609955   1
#> 4 709834913   1
#> 5 709834914   1
#> 6  77705540   1

Now we simply need to pass this data.frame with the new LTS values to the parameter new_lts in any routing/accessibility function:

ttm_new_lts <- r5r::travel_time_matrix(
  r5r_network = r5r_network,
  origins = points,
  destinations = points,
  mode = 'bicycle',
  departure_datetime = Sys.time(),
  max_trip_duration = 30,
  new_lts = edge_lts
  )

3.2. Changing LTS with a spatial polygon

Alternatively, we can pass a sf linestring of the roads where we want to simulate a some cycling network intervetion. Here we simulate that local authorities would have build dedicated lanes along all secondary roads in the city.

# read sf with congestion polygons
lts_lines <- readRDS(file.path(data_path, "poa_ls_lts.rds"))

# preview
mapview::mapview(lts_lines, zcol="lts")

Now we only need to pass this sf with the new LTS values to the new_lts parameter in any routing/accessibility function:

ttm_new_lts <- r5r::travel_time_matrix(
  r5r_network = r5r_network,
  origins = points,
  destinations = points,
  mode = 'bicycle',
  departure_datetime = Sys.time(),
  max_trip_duration = 30,
  new_lts = lts_lines
  )

Cleaning up after usage

r5r objects are still allocated to any amount of memory previously set after they are done with their calculations. In order to remove an existing r5r object and reallocate the memory it had been using, we use the stop_r5 function followed by a call to Java’s garbage collector, as follows:

# stop an specific r5r network
r5r::stop_r5(r5r_network)

# or stop all r5r networks at once
r5r::stop_r5()
rJava::.jgc(R.gc = TRUE)

If you have any suggestions or want to report an error, please visit the package GitHub page.