Skip to content

Instantly share code, notes, and snippets.

@francisbarton
Last active September 10, 2025 18:45
Show Gist options
  • Select an option

  • Save francisbarton/b9af0ea60d65db83f9811cf817013f84 to your computer and use it in GitHub Desktop.

Select an option

Save francisbarton/b9af0ea60d65db83f9811cf817013f84 to your computer and use it in GitHub Desktop.
Mapping NHS England Acute Trusts
---
title: NHS England Acute trust league table rankings map 2025
code-fold: true
knitr:
opts_chunk:
dev: "ragg_png"
fig-dpi: 144
fig-width: 8.2
fig-height: 11.6
---
```{r read in data}
#| eval: false
url <- file.path(
"https://www.england.nhs.uk",
"wp-content",
"uploads",
"2025",
"09",
"nhs-oversight-framework-acute-trust-league-table.csv"
)
t <- "lt_data.rds"
lt_data <- readr::read_csv(url, col_types = "--ccc-d-d-d-") |>
readr::write_rds(t)
```
```{r pull out table}
tbl <- readr::read_rds(t) |>
janitor::clean_names() |>
dplyr::rename_with(\(x) sub("^(trust|average)_", "", x)) |>
dplyr::mutate(
dplyr::across("subtype", \(x) sub("^Acute \\- ", "", x)),
decile = dplyr::ntile(.data[["score"]], 10L)
) |>
dplyr::mutate(dplyr::across(c("segment", "decile"), forcats::as_factor))
```
```{r talk to ods api}
api_req <- httr2::request("https://sandbox.api.service.nhs.uk")
get_org_data <- function(org_code, req = api_req) {
req |>
httr2::req_url_path_append("organisation-data-terminology-api") |>
httr2::req_url_path_append("fhir") |>
httr2::req_url_path_append("Organization") |>
httr2::req_url_path_append(org_code) |>
httr2::req_perform() |>
httr2::resp_check_status() |>
httr2::resp_body_json()
}
get_postcode <- \(lst) purrr::pluck(lst, "address", 1, "postalCode")
```
```{r get trust postcodes}
#| eval: false
tbl_full <- tbl |>
dplyr::mutate(ods_data = purrr::map(.data[["code"]], get_org_data)) |>
dplyr::mutate(
postcode = purrr::map_chr(.data[["ods_data"]], get_postcode),
.keep = "unused"
) |>
# https://codeberg.org/francisbarton/myrmidon
myrmidon::postcode_data_join() |>
dplyr::select(
dplyr::all_of(c(colnames(tbl), "postcode", "eastings", "northings"))
) |>
readr::write_rds("tbl_full.rds")
```
```{r get shape data}
#| eval: false
# https://codeberg.org/francisbarton/boundr
england <- boundr::bounds("rgn", opts = boundr::opts("BUC", crs = 27700)) |>
readr::write_rds("england.rds")
```
```{r read data back in}
england <- readr::read_rds("england.rds")
tbl_full <- readr::read_rds("tbl_full.rds")
set.seed(1444) # for sf::st_jitter()
shape_breaks <- sort(unique(tbl_full[["subtype"]]))[c(1, 2, 4, 6, 5, 3)]
shape_values <- rep("square filled", length(shape_breaks)) |>
rlang::set_names(shape_breaks) |>
purrr::assign_in("Specialist", "circle filled") |>
purrr::assign_in("Teaching", "triangle filled") |>
purrr::assign_in("Multi-Service", "diamond filled")
size_values <- rep(4, length(shape_breaks)) |>
rlang::set_names(shape_breaks) |>
purrr::assign_in("Large", 6) |>
purrr::assign_in("Teaching", 5) |>
purrr::assign_in("Small", 2)
nt <- nrow(tbl_full)
tbl_full |>
dplyr::arrange(match(.data[["subtype"]], shape_breaks[c(1, 2, 4:6, 3)])) |>
sf::st_as_sf(crs = 27700, coords = c("eastings", "northings")) |>
sf::st_jitter(6e3) |>
ggplot2::ggplot() +
ggplot2::geom_sf(data = england, fill = "bisque") +
ggplot2::geom_sf(
ggplot2::aes(
fill = .data[["decile"]],
shape = .data[["subtype"]],
size = .data[["subtype"]]
),
alpha = 0.8,
stroke = 0.6,
colour = "grey33"
) +
ggplot2::scale_fill_viridis_d() +
ggplot2::scale_shape_manual(breaks = shape_breaks, values = shape_values) +
ggplot2::scale_size_manual(breaks = shape_breaks, values = size_values) +
ggplot2::theme_void() +
ggplot2::labs(
title = "NHS England: Acute trusts by AMR league table ranking (decile)",
subtitle = glue::glue(
"Trusts are scored and then ranked nationally, with the lowest (best) ",
"score given rank 1, down to rank {nt}. For ease of display, trusts ",
"are here grouped into deciles."
) |>
stringr::str_wrap(92),
caption = paste0(
"Source: NHS England Oversight Framework, September 2025\n",
"https://www.england.nhs.uk/long-read/acute-trust-league-table/\n",
"AMR = aggregated metric rankings. Locations are jittered slightly to ",
"reduce symbol overlap."
)
) +
ggplot2::theme(
text = ggplot2::element_text("Fira Sans", colour = "grey33", size = 12),
title = ggplot2::element_text(face = "bold"),
plot.title = ggplot2::element_text(
size = 18,
margin = ggplot2::margin(0, 4, 4, 6)
),
plot.subtitle = ggplot2::element_text(margin = ggplot2::margin(l = 6)),
plot.title.position = "plot",
plot.caption = ggplot2::element_text(face = "plain", hjust = 1),
plot.caption.position = "plot",
legend.title.position = "top",
legend.position = "inside",
plot.margin = ggplot2::margin(4, 12, 2, 12)
) +
ggplot2::guides(
fill = ggplot2::guide_legend(
"decile (1 = highest ranked 10% of trusts)",
nrow = 1,
theme = ggplot2::theme(
legend.direction = "horizontal",
legend.text.position = "bottom",
legend.spacing.x = grid::unit(0, "mm"),
legend.key.spacing.x = grid::unit(0, "mm"),
legend.justification.inside = c(0.05, 0.3)
),
override.aes = list(size = 8, shape = "square filled", stroke = 0)
),
shape = ggplot2::guide_legend(
"Trust type",
ncol = 2,
theme = ggplot2::theme(legend.justification.inside = c(0.05, 0.45)),
override.aes = list(
colour = "grey33",
fill = "grey66",
size = size_values
)
),
size = ggplot2::guide_none()
)
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment