Patterns of Cycling flows in UK cities

Nicolas Palominos

November, 2021

What are the cycling patterns of the biggest cycling cities in the UK? By visualising a large quantity of travel data we can observe the spatial patterns of different cities.

Provided that travel behaviour is associated with the distance to potentially attractive destinations and that cycling in particular can be sensible not only to distance but also to the quality of cycling infrastructure, the visualisation of cycling flows can reveal interesting patterns of urban morphology. This is particularly relevant for identifying city and neighbourhood level urban adaptations and strategies that can promote more energy and space-efficient modes of travel and contribute to achieving net-zero cities.

This exercise uses origin-destination data that captures the number of trips by mode between census areas in the UK.

Prerequisites

The following packages are necessary to run the code.

knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE)
library(pct)
library(stplanr)
library(tidyverse)
library(sf)
library(ggplot2)
library(extrafont)
library(patchwork)

Reading data from {pct}

# Get origin destination data from the 2011 Census
# ref: https://www.rdocumentation.org/packages/pct/versions/0.9.1/topics/get_od
flows <- get_od(
  region = NULL,
  n = NULL,
  type = "within",
  omit_intrazonal = FALSE,
  base_url = paste0("https://s3-eu-west-1.amazonaws.com/",
    "statistics.digitalresources.jisc.ac.uk", "/dkan/files/FLOW/"),
  filename = "wu03ew_v2",
  u = NULL
)

# Download MSOA centroids for England and Wales
nodes <- get_centroids_ew() 

# Download regions (pct defined regions)
regions <- pct_regions

Bicycle flows and nodes

flows %>%
  filter(bicycle > 0) %>%
  # remove O-D geo areas that are NA eg. not MSOA
  filter(!is.na(geo_name2)) -> flows_bike

# create sf with unique nodes
x1 <- flows_bike$geo_code1 %>%
  unique() %>%
  as.data.frame()
x2 <- flows_bike$geo_code2 %>%
  unique() %>%
  as.data.frame()
bind_rows(x1, x2) %>%
  unique() %>%
  rename(nodes = ".") -> flows_nodes

nodes %>%
  filter(msoa11cd %in% flows_nodes$nodes) -> nodes_bike

Spatial flows by region

# Convert origin-destination data to sf lines (od2line {stplanr})
bike_commute <- od2line(flows_bike, nodes_bike)
regions %>%
  st_transform(27700) -> regions
# join pct regions
bike_commute %>%
  st_join(., regions, join = st_within) -> bike_commute_r
# create bicycle count breaks (categories c1)
bike_commute_r %>%
  mutate(c1 = as.factor(bicycle)) -> bike_commute_r

Summary of bike flows by region

# bike commute max and sum by region
bike_commute_r %>%
  st_drop_geometry() %>%
  group_by(region_name) %>%
  summarise(max_br = max(bicycle),
            sum_br =sum(bicycle),
            # distinct values for colour palette
            dv_br = n_distinct(bicycle)) %>%
  arrange(desc(sum_br,max_br)) -> suta
suta
#> # A tibble: 46 × 4
#>    region_name        max_br sum_br dv_br
#>    <chr>               <dbl>  <dbl> <int>
#>  1 <NA>                  546 148594   174
#>  2 london                109 136858    88
#>  3 cambridgeshire        613  26163   133
#>  4 hampshire             118  22513    77
#>  5 avon                  311  22143    86
#>  6 greater-manchester     55  21099    39
#>  7 oxfordshire           444  17271   100
#>  8 humberside            254  15684    76
#>  9 north-east            105  13201    43
#> 10 wales                 139  13018    55
#> # … with 36 more rows

Plot regions

Bounding boxes

# selected regions considering aggregated bicycle flows
regl <- c("london", "cambridgeshire", "avon", "greater-manchester", "oxfordshire", "west-midlands")
# get bounding boxes
bx <- list()
for (i in regl) {
  bx[[i]] <- bike_commute_r %>% 
    filter(region_name == i) %>%
    st_bbox() %>%
    st_as_sfc() %>%
    st_sf()
}

bx1 <- do.call(rbind, bx) %>%
  mutate(rn = regl) %>%
  st_sf()
# plot regions to check largest study area
regions %>%
  ggplot() +
  geom_sf() +
  geom_sf(data = bx1, fill= NA, col= "tomato") + 
  theme_bw()

# compare length of largest bounding boxes 

lbb <- "cambridgeshire"

bx1 %>%
  filter(rn == lbb) %>%
  st_cast("MULTILINESTRING") %>%
  # perimeter
  st_length() -> cam_p

bx1 %>%
  filter(rn == lbb) %>%
  st_cast("MULTILINESTRING") %>%
  # distance to nearest side
  st_nearest_points(., st_centroid(.)) %>%
  st_length() -> cam_n

# total length (perimeter) - radius of inscribed circle * 4, divided by 4
(cam_p - (4 * cam_n)) / 4
#> 33819.5 [m]
lbb <- "oxfordshire"

bx1 %>%
  filter(rn == lbb) %>%
  st_cast("MULTILINESTRING") %>%
  # perimeter
  st_length() -> oxf_p

bx1 %>%
  filter(rn == lbb) %>%
  st_cast("MULTILINESTRING") %>%
  # distance to nearest side
  st_nearest_points(., st_centroid(.)) %>%
  st_length() -> oxf_n

# total length (perimeter) - radius of inscribed circle * 4, divided by 4
(oxf_p - (4 * oxf_n)) / 4
#> 32356.5 [m]
# create a circle of radius = 33819.5 for each region
for (i in regl) {
  cf <- bx1 %>%
    filter(rn == i) %>%
    st_centroid(.) %>%
    st_buffer(., dist = 33819.5)
  assign(paste("c", i, sep = "_"), cf)
}
# plot regions and circles to check largest study area
regions %>%
  ggplot() +
  geom_sf() +
  geom_sf(data = bx1, fill= NA, col= "gold") + 
  geom_sf(data = c_avon, fill= NA, col= "tomato") + 
  geom_sf(data = c_london, fill= NA, col= "tomato") +
  geom_sf(data = c_oxfordshire, fill= NA, col= "tomato") +
  geom_sf(data = c_cambridgeshire, fill= NA, col= "tomato") +
  geom_sf(data = `c_greater-manchester`, fill= NA, col= "tomato") +
  geom_sf(data = `c_west-midlands`, fill= NA, col= "tomato") +
  theme_bw()

Plot flows by region

London

filtered <- "london"

bike_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_london, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'green', 'white'))(88)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='green', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p1

Cambridgeshire

filtered <- "cambridgeshire"

bike_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_cambridgeshire, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'green', 'white'))(133)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='green', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p2

Avon

filtered <- "avon"

bike_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_avon, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'green', 'white'))(86)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='green', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p3

Greater-manchester

filtered <- "greater-manchester"

bike_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = `c_greater-manchester`, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'green', 'white'))(39)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='green', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = 'Greater Manchester') -> p4

Oxfordshire

filtered <- "oxfordshire"

bike_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_oxfordshire, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'green', 'white'))(100)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='green', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p5

West-midlands

filtered <- "west-midlands"

bike_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = `c_west-midlands`, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'green', 'white'))(28)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='green', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = 'West Midlands') -> p6

Final Plot

ft <- "Times New Roman"
ct <- "#58685c"

(p1 + p2 + p3 ) / (p4 + p5 + p6 ) + 
  plot_annotation(title = 'Green Power: Cycling Flows in English Regions',
                  subtitle = 'Regions at the same scale organised in decreasing order',
                  caption = 'Data: {pct} | Graphic: @npalomin',
                  theme = theme(panel.background = element_rect(fill = 'black'),
                                plot.background = element_rect(fill = 'black'),
                                plot.title = element_text(color=ct, size=16,  family=ft, face="bold"),
                                plot.subtitle = element_text(color=ct, size=10, family=ft),
                                plot.caption = element_text(color=ct, size=10, family=ft))) -> pgrid
pgrid

Conclusions and next steps

The final plot contains ~300k origin-destination lines. Often, this kind of plots tend to get cluttered making it difficult to identify meaningful patterns (the total number of links between n set of nodes is n(n-1)/2). An important contribution of this graphic is that it can provide a synoptic comparative view of flow patterns that relate to the morphology of cities. Additional quantitative analysis could be done by calculating the centrality degree distribution of the studied areas.

A great time-saver is being able to access the data using an R package which encourages reproducible research. The code in this exercise can be adapted to investigate other patterns by mode of travel or include other parameters such as travel by age groups (see data in UK Data Service).

Main references:

Bonus: Car flows patterns

Car_driver flows and nodes

flows %>%
  filter(car_driver > 0) %>%
  # remove O-D geo areas that are NA eg. not MSOA
  filter(!is.na(geo_name2)) -> flows_car

# create sf with unique nodes
x1c <- flows_car$geo_code1 %>%
  unique() %>%
  as.data.frame()
x2c <- flows_car$geo_code2 %>%
  unique() %>%
  as.data.frame()
bind_rows(x1c, x2c) %>%
  unique() %>%
  rename(nodes = ".") -> flows_nodes_c

nodes %>%
  filter(msoa11cd %in% flows_nodes_c$nodes) -> nodes_car

Spatial flows by region

# Convert origin-destination data to sf lines (od2line {stplanr})
car_commute <- od2line(flows_car, nodes_car)
regions %>%
  st_transform(27700) -> regions
# join pct regions
car_commute %>%
  st_join(., regions, join = st_within) -> car_commute_r
# create car_driver count breaks (categories c1)
car_commute_r %>%
  mutate(c1 = as.factor(car_driver)) -> car_commute_r

Summary of car flows by region

# bike commute max and sum by region
car_commute_r %>%
  st_drop_geometry() %>%
  group_by(region_name) %>%
  summarise(max_br = max(car_driver),
            sum_br =sum(car_driver),
            # distinct values for colour palette
            dv_br = n_distinct(car_driver)) %>%
  arrange(desc(sum_br,max_br)) -> suta_c
suta_c
#> # A tibble: 46 × 4
#>    region_name        max_br  sum_br dv_br
#>    <chr>               <dbl>   <dbl> <int>
#>  1 <NA>                 1926 3865415   600
#>  2 london                284  708971   156
#>  3 wales                 989  608826   374
#>  4 greater-manchester    418  514229   234
#>  5 west-yorkshire        445  449770   266
#>  6 west-midlands         335  414589   195
#>  7 north-east            630  411017   306
#>  8 hampshire             472  319641   267
#>  9 kent                  522  304585   271
#> 10 lancashire            524  284016   269
#> # … with 36 more rows

Plot flows by region

London

filtered <- "london"

car_commute_r %>%
  filter(region_name == filtered) %>%
  arrange(desc(car_driver)) %>% 
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_london, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'tomato', 'white'))(156)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='tomato', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p1c

Cambridgeshire

filtered <- "cambridgeshire"

car_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_cambridgeshire, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'tomato', 'white'))(259)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='tomato', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p2c

Avon

filtered <- "avon"

car_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_avon, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'tomato', 'white'))(230)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='tomato', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p3c

Greater-manchester

filtered <- "greater-manchester"

car_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = `c_greater-manchester`, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'tomato', 'white'))(234)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='tomato', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = 'Greater Manchester') -> p4c

Oxfordshire

filtered <- "oxfordshire"

car_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = c_oxfordshire, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'tomato', 'white'))(224)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='tomato', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = str_to_title(filtered)) -> p5c

West-midlands

filtered <- "west-midlands"

car_commute_r %>%
  filter(region_name == filtered) %>%
  ggplot() +
  geom_sf(aes(col = c1, size = c1), alpha = .2, show.legend = F) + 
  geom_sf(data = `c_west-midlands`, fill=NA, col=NA) +
  scale_color_manual(values = colorRampPalette(c('#233423', 'tomato', 'white'))(195)) +
  scale_size_discrete(range = c(0.2, 0.7)) +
  theme_void() +
  theme(panel.background = element_rect(fill = 'black'),
        plot.background = element_rect(fill = 'black'),
        plot.title = element_text(color='tomato', size=14, family="AppleGothic", hjust = 0.5)) +
  labs(title = 'West Midlands') -> p6c

Final Plot

This might take a while to plot as it shows more than 2 million lines

ft <- "Times New Roman"
ct <- "#58685c"

(p1c + p4c + p6c ) / (p3c + p2c + p5c ) + 
  plot_annotation(title = 'Car-driver Flows in English Regions',
                  subtitle = 'Regions at the same scale organised in decreasing order (~2 million flows)',
                  caption = 'Data: {pct} | Graphic: @npalomin',
                  theme = theme(panel.background = element_rect(fill = 'black'),
                                plot.background = element_rect(fill = 'black'),
                                plot.title = element_text(color=ct, size=16,  family=ft, face="bold"),
                                plot.subtitle = element_text(color=ct, size=10, family=ft),
                                plot.caption = element_text(color=ct, size=10, family=ft))) -> pgrid_c
pgrid_c