Analyzing the Distribution of Denver Cooling Centers

R
leaflet
mapping
geospatial
Author
Published

August 14, 2024

Modified

August 19, 2024

Introduction

The heat wave in Colorado a few weeks ago motivated me to think about access to air conditioning for people who cannot afford it at their own home or apartment. During heat waves Denver Denver opens up its recreation centers as cooling centers. I decided to use geospatial analysis to try to figure out how many people in Denver have access to a cooling center.

Goal

  • The goal of this analysis is to identify locations/areas/populations in Denver that have lower or no access to cooling centers, in order to inform placement of additional cooling resources for these populations.

Methods Overview:

  • Obtain and map Denver cooling center locations.
  • Compute isochrones (the area that can be reached by walking/driving in a certain time) from each cooling center.
  • Identity areas that do not have access to cooling centers, defined here as not being inside any of the isochrones.
Load R libraries for analysis
library(mapboxapi) # compute isocrhone/isodistance 
library(leaflet) # mapping
library(sf) # working with shapefiles
library(dplyr)
library(janitor)
library(glue)
library(tidycensus) # census tracts
library(tigris) # county shapefile
options(tigris_use_cache = TRUE)

Data

Denver Cooling Centers

I found a data set containing the location of all Denver recreation centers on the Denver Open Data Catalog.

The Denver recreation centers shapefile was read into R with the {sf} package (Pebesma and Bivand 2023).

Load cooling centers
# rec shape file crs = 2877 for Colorado; use for calculations later?
shp_file <- "./data/ODC_PARK_RECCENTER_P_-1008034318397091855/PARK_RECCENTER_P.shp"

rec_shp <- sf::read_sf(shp_file) |>
  sf::st_transform(4326) |>
  janitor::clean_names()

head(rec_shp)
Simple feature collection with 6 features and 26 fields
Geometry type: POINT
Dimension:     XY
Bounding box:  xmin: -105.0512 ymin: 39.7582 xmax: -104.9832 ymax: 39.78888
Geodetic CRS:  WGS 84
# A tibble: 6 × 27
  rec_name      loc_code address_id address_li address_1 city  state zip   phone
  <chr>         <chr>         <dbl> <chr>      <chr>     <chr> <chr> <chr> <chr>
1 Scheitler Re… RB2               0 5031 W 46… <Null>    Denv… CO    <NA>  720-…
2 Aztlan Recre… RA3               0 4435 Nava… <NA>      Denv… CO    <NA>  720-…
3 Highland Rec… SH1               0 2880 Osce… <NA>      Denv… CO    <NA>  720-…
4 Ashland Recr… RA1               0 2475 W Du… <NA>      Denv… CO    <NA>  720-…
5 5090 Broadwa… RS2               0 5090 Broa… <NA>      Denv… CO    <NA>  720-…
6 Globeville R… RG2               0 4496 Gran… <NA>      Denv… CO    <NA>  303-…
# ℹ 18 more variables: rec_type <chr>, marketing <chr>, marketed_f <chr>,
#   marketed_p <chr>, programs_l <chr>, pool_hours <chr>, news_link <chr>,
#   hours <chr>, photo <chr>, year_built <chr>, year_remod <chr>,
#   bldg_sqft <dbl>, label <chr>, latitude <dbl>, longitude <dbl>,
#   facilities <chr>, globalid <chr>, geometry <POINT [°]>

County boundary and census data

To calculate the population within/outside range of cooling centers, I decided to use census tracts from the US census. I obtained shapefiles for census tracts and their population using the {tidycensus} R package (Walker and Herman 2024). The shapefile for the entire county (without population) was downloaded using the {tigris} package (Walker 2024b).

Get county boundary
counties <- tigris::counties(state = "CO",)
denver <- counties |> filter(NAME == "Denver")
Get census tracts
# Data from 2020 decennial US Census
den_tracts <- get_decennial(
  geography = "tract", 
  variables = "P1_001N",
  year = 2020,
  state = "CO",
  county = "Denver", 
  geometry = TRUE
) |> st_transform(4326)

There are 178 tracts, with a total population of 715,522.

The data sets are visualized on an interactive map (figure Figure 1) using the {leaflet} package (Cheng et al. 2024).

Plot map of data
leaflet() |>
  addTiles() |>
  addMarkers(data = rec_shp,
             label = ~rec_name) |>
  addPolygons(data = den_tracts, 
              fillOpacity = 0.2, 
              color = "black", 
              weight = 1, 
              label = ~NAME,  
              highlight = highlightOptions(
                weight = 3,
                fillOpacity = 0,
                color = "black",
                opacity = 1.0,
                bringToFront = TRUE,
                sendToBack = TRUE)  
  )
Figure 1: Map of Denver recreation centers (blue markers) and census tracts (black/gray polygons).

Analysis

Compute isochrones

I used the {mapboxapi} package (Walker 2024a) to calculate walking isochrones from each recreation center location. I chose a 20 minute walk as reasonable access.

Calculate isochrones
time_minutes <- 20

isos <- mapboxapi::mb_isochrone(
  location = rec_shp,
  profile = "walking",
  time = time_minutes,
  id_column = "rec_name"
)

Figure 2 is an interactive map that shows all of the recreation centers and isochrones, as well as census tracts.

Map isochrones
leaflet() |>
  addProviderTiles(providers$CartoDB.Voyager) |>
  addPolygons(data = den_tracts, fillOpacity = 0.2, color = "black", weight = 1, label = ~NAME) |>
  addPolygons(data = isos, label = ~id) |>
  addMarkers(data = rec_shp,
             label = ~rec_name) 
Figure 2: Interactive Map of Denver area with 20-minute walking isochrones from Denver recreation centers

Calculate Access to Cooling Centers

The next step was to calculate what area and population are within/outside these isochrones. I tried two methods: one is simpler, and one is more complex but probably more accurate.

Simpler method

The simpler method is to keep census tracts whole and find which ones intersect any of the isochrones:

  • Calculate union of all the isochrones (using st_union() from the {sf} package)
  • Find which census tracts do not intersect isochrones at all (using st_join() from {sf})
Method 1
# Union of all isochrones
isos_union <- sf::st_union(isos, by_feature = FALSE) |> sf::st_as_sf()

isos_union$index <- 99 # need to add dummy column to identify where join is NA?

# join tracts and isocrhoes
tracts_iso_join <- sf::st_join(den_tracts, isos_union)

# find which tracts were not in isochrones (join is NA)
not_in <- tracts_iso_join |> filter(is.na(index))

#glue::glue('There are {nrow(not_in)} census tracts in Denver (out of total {nrow(den_tracts)}) that are not in range of a cooling center. The population of census tracts without access to cooling centers is {sum(not_in$value)}.')

There are 36 census tracts in Denver (out of a total 178) that are not in range of a cooling center. The population of census tracts without access to cooling centers is 135,635.

Figure 3 shows the results of this method, with census tracts that do not intersect isochrones shaded in red.

Map method 1
leaflet() |>
  addProviderTiles(providers$CartoDB.Voyager) |>
  addPolygons(data = den_tracts, fillOpacity = 0.2, color = "black", weight = 1, label = ~NAME) |>
  addPolygons(data = not_in, fillOpacity = 0.2, color = "red", weight = 1, label = ~NAME) |>
  addPolygons(data = isos_union, color = "blue", weight = 2, label = "Isochrones")# |>
Figure 3: Map of Denver county with census tracts and isochrones from all cooling centers. Census tracts in red do not intersect any of the isochrones.

Weighting by Area

The first method is much simpler but probably not very accurate, since some of the census tracts just barely intersect an isochrone. A more complex but more accurate method is to use areal interpolation that considers what part of each census tract intersects the isochrones. Note that this method assumes that population is distributed evenly across each census tract.

  • Calculate union of all the isochrones
  • For each tract, compute the percent of area that intersects with isochrones (using st_intersection() from {sf})
  • Use that ratio to multiply the total population of the tract, to find population in/out.
  • Sum the population estimates from all the tracts.
Method 2
# function to compute perc overlap and weighted population for 1 tract
calc_pop_out <- function(tract, isos) {
  
  tract_intersect <- sf::st_intersection(tract, isos)
  
  total_pop <- tract$value
  
  if ( nrow(tract_intersect) == 0) {return(total_pop)}
  
  area_tot <- sf::st_area(tract)
  area_intersect <- sf::st_area(tract_intersect)
  perc_intersect <- as.numeric(area_intersect/area_tot*100)
  perc_area_not_in <- 100 - perc_intersect
  
  pop_out <- perc_area_not_in/100*total_pop
  
}

# apply to each tract (could use *map* function instead of loop? )
pops_out <- rep(NA, times = nrow(den_tracts))
for (i in 1:nrow(den_tracts)) {
  pops_out[i] <- calc_pop_out(den_tracts[i,], isos_union)
}



# Union of all Denver census tracts >> 1 multi-polygon
tracts_union <- sf::st_union(den_tracts)

tracts_isos_intersect <- sf::st_intersection(tracts_union, isos_union)
tracts_isos_diff <- sf::st_difference(tracts_union, isos_union)

area_tot <- sf::st_area(tracts_union)
area_in <- sf::st_area(tracts_isos_intersect)
area_out <- sf::st_area(tracts_isos_diff)

percent_out <- round(area_out/area_tot*100)
pop_out <- round(sum(pops_out))

#glue('Approximately {round(area_out/area_tot*100)} percent of the area of Denver is not within a 20 minute walk of a cooling center.')

#glue('An estimated {round(sum(pops_out))} people in Denver are not within a 20 minute walk of a cooling center.')

Figure Figure 4 shows the results of this method. The union of all the isochrones is shown in blue, and the difference (the area not within any isochrones) is shown in blue.

Map method 2
leaflet() |> 
  addProviderTiles(providers$CartoDB.Voyager) |>
  addPolygons(data = tracts_isos_intersect, 
              color = "blue", 
              weight = 1, 
              label = "Intersection") |> 
  addPolygons(data = tracts_isos_diff, 
              color = "red", 
              weight = 1, 
              label = "Difference")
Figure 4: Map of Denver county with census tracts and isochrones from all cooling centers (blue). Area shaded in red does not intersect any of the isochrones.

Conclusion

  • 20-minute walking isochrones were calculated for all Denver cooling centers (recreation centers).
  • The area and population not within any of these isochrones was computed.
  • Approximately 69 percent of the area of Denver (County) is not within a 20 minute walk of a cooling center.
  • An estimated 389,095 people in Denver (County) are not within a 20 minute walk of a cooling center.

Caveats/future work

This analysis provides a starting framework and estimate; however there are many assumptions and areas for further research and improvement. Below are some that come to mind:

  • Areal interpolation assumes that population is evenly distributed across each tract area.
  • Future work could try to incorporate estimates of how many people have air conditioning in their homes or apartments.
  • Future work could attempt to also include other cooling locations besides recreation centers (such as public libraries).
  • It is difficult to specify exactly what “access” to a cooling center means (here I defined it as a 20 minute walk). Future work could also try to take into account public transit (though currently the Mapbox isochrone API does not appear to have a public transit mode option).
  • It would be interesting to compare the distribution of cooling centers with data on tree coverage and temperatures.

SessionInfo

R version 4.4.1 (2024-06-14)
Platform: x86_64-apple-darwin20
Running under: macOS Sonoma 14.6.1

Matrix products: default
BLAS:   /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRblas.0.dylib 
LAPACK: /Library/Frameworks/R.framework/Versions/4.4-x86_64/Resources/lib/libRlapack.dylib;  LAPACK version 3.12.0

locale:
[1] en_US.UTF-8/en_US.UTF-8/en_US.UTF-8/C/en_US.UTF-8/en_US.UTF-8

time zone: America/Denver
tzcode source: internal

attached base packages:
[1] stats     graphics  grDevices datasets  utils     methods   base     

other attached packages:
[1] tigris_2.1       tidycensus_1.6.5 glue_1.7.0       janitor_2.2.0   
[5] dplyr_1.1.4      sf_1.0-17        leaflet_2.2.2    mapboxapi_0.6   

loaded via a namespace (and not attached):
 [1] xfun_0.47               raster_3.6-26           htmlwidgets_1.6.4      
 [4] lattice_0.22-6          tzdb_0.4.0              leaflet.providers_2.0.0
 [7] vctrs_0.6.5             tools_4.4.1             crosstalk_1.2.1        
[10] generics_0.1.3          curl_5.2.1              tibble_3.2.1           
[13] proxy_0.4-27            fansi_1.0.6             pkgconfig_2.0.3        
[16] KernSmooth_2.23-24      protolite_2.3.0         uuid_1.2-0             
[19] lifecycle_1.0.4         compiler_4.4.1          stringr_1.5.1          
[22] munsell_0.5.1           terra_1.7-71            codetools_0.2-20       
[25] snakecase_0.11.1        htmltools_0.5.8.1       class_7.3-22           
[28] yaml_2.3.10             jquerylib_0.1.4         crayon_1.5.3           
[31] pillar_1.9.0            tidyr_1.3.1             aws.s3_0.3.21          
[34] classInt_0.4-10         slippymath_0.3.1        wk_0.9.1               
[37] magick_2.8.4            tidyselect_1.2.1        rvest_1.0.4            
[40] digest_0.6.36           stringi_1.8.4           purrr_1.0.2            
[43] fastmap_1.2.0           grid_4.4.1              colorspace_2.1-0       
[46] cli_3.6.3               magrittr_2.0.3          base64enc_0.1-3        
[49] utf8_1.2.4              e1071_1.7-14            aws.signature_0.6.0    
[52] withr_3.0.1             readr_2.1.5             scales_1.3.0           
[55] rappdirs_0.3.3          sp_2.1-4                lubridate_1.9.3        
[58] timechange_0.3.0        rmarkdown_2.28          httr_1.4.7             
[61] jpeg_0.1-10             hms_1.1.3               png_0.1-8              
[64] evaluate_0.24.0         knitr_1.48              s2_1.1.6               
[67] rlang_1.1.4             Rcpp_1.0.13             DBI_1.2.2              
[70] geojsonsf_2.0.3         xml2_1.3.6              renv_1.0.4             
[73] rstudioapi_0.16.0       jsonlite_1.8.8          R6_2.5.1               
[76] units_0.8-5            

Updates

  • 2024-08-19 : Update to use 2020 census data instead of 2010. Total population increases from 600,158 to 715,522.
  • 2024-08-19 : Fix error in calculating population not within isochrones. For tracts that do not intersect at all, population was set to 0 instead of tract population.

References

Cheng, Joe, Barret Schloerke, Bhaskar Karambelkar, and Yihui Xie. 2024. “Leaflet: Create Interactive Web Maps with the JavaScript ’Leaflet’ Library.” https://rstudio.github.io/leaflet/.
Pebesma, Edzer, and Roger Bivand. 2023. Spatial Data Science: With Applications in r.” https://doi.org/10.1201/9780429459016.
Walker, Kyle. 2024a. “Mapboxapi: R Interface to ’Mapbox’ Web Services.” https://CRAN.R-project.org/package=mapboxapi.
———. 2024b. “Tigris: Load Census TIGER/Line Shapefiles.” https://CRAN.R-project.org/package=tigris.
Walker, Kyle, and Matt Herman. 2024. “Tidycensus: Load US Census Boundary and Attribute Data as ’Tidyverse’ and ’Sf’-Ready Data Frames.” https://CRAN.R-project.org/package=tidycensus.