library(sf)
library(dplyr)
library(SpatialPosition)
In this blog, I will present the projects I developed while I was at IMEPLAN, an organization focused on urban and regional planning in the Guadalajara Metropolitan Area (GMA). IMEPLAN plays a crucial role in shaping the future of the GMA by guiding development policies, infrastructure planning, and resource management to improve the quality of life for its citizens.
While working at IMEPLAN, I had the opportunity to contribute to various data-driven projects focused on enhancing urban planning efficiency and decision-making. My work primarily involved automating spatial analysis methodologies, optimizing resource allocation, and reducing costs through the use of open-source tools. In this blog, I will walk you through some key projects, such as school proximity analysis and supply-demand assessments, which helped inform critical decisions about infrastructure and public services. These projects highlight the potential of data science and geospatial analysis in shaping the future of urban environments.
1. Huff Probabilistic Catchment Areas in Schools
For demonstrative purposes, instead of analyzing all the schools, I will use a subset of them to illustrate how the code works and explain the methodology behind it. This first project will focus on computing the Huff model, creating a raster to represent the probabilistic catchment areas, and visualizing the results. It’s a stepping stone towards more comprehensive analyses that will include broader datasets and additional public services.
The Huff model, a probabilistic spatial interaction model, offers a powerful tool for this type of analysis. By estimating the likelihood that a student will attend a specific school based on distance and school capacity, the Huff model can help urban planners and policymakers make informed decisions on school placements, expansions, and resource allocation. This blog will explore the application of Huff catchment areas to public schools in Jalisco, offering insights into how this model can aid in addressing educational inequalities and improving access to schooling in the region.
<- st_read("media/example_points.shp") schools
<- st_convex_hull(schools)
convex_hull_amg <- st_buffer(schools, dist = 500) convex_hull_buffered
<- CreateDistMatrix(schools, mygrid, longlat = TRUE) matdist
\[ \text{Interaction} = \exp\left(-\alpha \cdot \text{mDistance}^{\beta}\right)\] Modeling spatial interactions means quantifying the distance friction or impedance. The role of the distance can be interpreted as a disincentive to access desired destinations or opportunities (e.g. jobs, shops). At the very place of the opportunity, the interaction function equals 1, meaning that the potential access is 100%. Far away from the opportunity, the interaction function tends to 0, meaning that the potential access is 0 %. The span is defined as the value where the interaction function falls to 0.5 (50%). From the individuals’ point of vue, this function may be seen as a degree of availability of a given opportunity. From the opportunity’s point of vue (a store for example), the interaction function may be seen as a decreasing catchment area: there is a maximal attraction close to the opportunity and this attraction decreases progressively through distance.
One way to visualize and analyze the accessibility and influence of each hospital is by drawing probabilistic catchment areas. These areas represent the likelihood that a patient will choose one hospital over another based on proximity and school capacity.
The Huff model, commonly used in geomarketing to identify regions of competition between shopping malls, can be adapted here to healthcare. It helps identify regions where schools compete for students, enabling planners to make strategic decisions on schools expansions, resource allocation, or even areas where new facilities may be needed. The fuzzy boundaries between catchment areas illustrate where patients are likely to face choices between two or more hospitals.
To create these probabilistic catchment areas, we follow three main steps:
Compute the Huff model values for each school , based on its attractiveness and distance from population centers.
Create a raster using the
rasterHuff
function, which transforms these probabilities into a grid format that covers the entire region.Plot the raster using
plotHuff
, visualizing the catchment areas and the regions where hospitals compete for patients.
<- huff(
catchHuff knownpts = puntos,
unknownpts = mygrid,
matdist = matdist,
varname = "capacdd",
typefct = "exponential",
span = 450,
beta = 2,
returnclass = "sf",
longlat = TRUE
)
<- rasterHuff(x = catchHuff) rasterCatch
plotHuff(x = rasterCatch)
plot(st_geometry(primarias), pch = 20, col = "red", add = TRUE)
mtext("Probabilistic Catchment Areas \nof Public Schools",
side = 3,cex = 1.5, line=0.5)
mtext(text = "distance function: exponential, span = 0.45 km, beta = 2",
side = 1, line = 0.5)
2. Predict School Enrollment Probabilities
An alternative implementation of the Huff Model1 allows us to predict the probability of a customer choosing a specific good or service based on the distance between the customer and the firm.
We can extrapolate these concepts to predict school enrollment probabilities, where the “customers” are children and the “firms” are schools. This approach is particularly useful in urban planning because it helps planners understand how the spatial distribution of schools affects enrollment patterns. By predicting which schools children are more likely to attend based on proximity, urban planners can optimize the allocation of educational resources, identify areas underserved by schools, and plan future school locations to improve accessibility and balance capacity with demand.
The model is constituded by the following expression:
\[ P_{ij} = \frac{\frac{S_j}{\lambda T_{ij}}}{\sum_{j=1}^{n} \frac{S_j}{\lambda T_{ij}}} \]
where \(P_{ij}\) = the probability of a consumer at a given point of origin \(i\) traveling to a particular shopping center \(j\);
\(S_j\) = the size of a shopping center \(j\) (measured in terms of the square footage of selling area devoted to the sale of a particular class of goods);
\(T_{ij}\) = the travel time involved in getting from a consumer’s travel base \(i\) to a given shopping center \(j\); and
\(\lambda\) = a parameter which is to be estimated empirically to reflect the effect of travel time on various kinds of shopping trips.
The expected number of consumers at a given place of origin \(i\) that shop at a particular shopping center \(j\) is equal to the number of consumers at \(i\) multiplied by the probability that a consumer at \(i\) will select \(j\) for shopping. That is,
\[ E_{ij} = P_{ij} \cdot C_i \]
Where \(E_{ij}\) = the expected number of consumers at \(i\) that are likely to travel to shopping center $j$; and
\(C_i\) = the number of consumers at \(i\).
I am currently working in a python implementation.
3. Schools Proximity Analysis
In this project, I automated a methodology developed by the Urban Planning Department of IMEPLAN, which focuses on analyzing school proximity and accessibility. By using free and open-source tools to generate isochrones (polygons representing areas reachable within a specified distance or time), I saved the department $2,500 annually in software licensing fees and reduced analysis time by 30%.
library(sf)
library(dplyr)
library(leaflet)
library(mapboxapi)
mb_access_token("pk.eyJ1IjoibWFub2xvbWFwcyIsImEiOiJjbHozNWpwamcxZjh0MmpwcW43OGdwN3I0In0.ELXy2MolTMyHKdVX8SWJgA", install = TRUE, overwrite=TRUE)
<- Sys.getenv("MAPBOX_PUBLIC_TOKEN")
token
library(BAMMtools)
Read data
<- st_read('data/primarias.shp')
primarias <- st_transform(primarias, st_crs('EPSG: 4326'))
primarias <- primarias %>% rename(id = Id_prmr) # Renombrar el id
primarias
<- st_read('data/manzanas.shp')
manzanas <- st_transform(manzanas, st_crs('EPSG: 4326')) manzanas
Overlapping Isochrones Polygons
The first part of the project involved generating overlapping isochrone polygons at distances of 500 and 1100 meters, which were the suggested walkable distances for school accessibility.
<- list()
resultados
for (i in 1:nrow(primarias)) {
<- primarias[i, ]
primaria
# Calcular las isócronas de 500 y 1100 metros
<- mb_isochrone(primaria,
isocrona_500 profile = "walking",
distance = 500, # mts
geometry = "polygon")
<- mb_isochrone(primaria,
isocrona_1100 profile = "walking",
distance = 1100, # mts
geometry = "polygon")
# Agregar el id de la primaria a las isócronas
$id <- primaria$id
isocrona_500$id <- primaria$id
isocrona_1100
# Combinar las dos isócronas en un solo sf object (Overlapping)
<- rbind(isocrona_500, isocrona_1100)
poligonos_sf
# Almacenar los poligonos en la lista de resultados
<- poligonos_sf
resultados[[i]]
}
# Combinar todos los poligonos de la lista en un solo sf object final
<- do.call(rbind, resultados) sf_final
<- leaflet(sf_final) %>%
m addProviderTiles(providers$MapBox, options = providerTileOptions(
id = "mapbox/streets-v11",
accessToken = Sys.getenv("MAPBOX_PUBLIC_TOKEN")
%>%
)) addPolygons(color = "#3388ff", weight = 1, opacity = 1, fillOpacity = 0.2)
# Añadir la leyenda
<- addLegend(m, position = "bottomright",
m colors = c("#3388ff"),
labels = c("Isocronas Overlapped"),
title = "Overlapping isochrones",
opacity = 1)
# Mostrar el mapa
m
Proximity
The second part was a proximity analysis. Here, I intersected city blocks with the isochrones and classified each block based on the degree of intersection. The classification criteria were as follows:
- Outside Coverage
- Partially Covered)
- Fully Covered
$C_clase <- 0
manzanas<- st_intersects(manzanas, isocronas[isocronas$distance == 1100, ])
intersecciones_1100 $C_clase[lengths(intersecciones_1100) > 0] <- 0.5
manzanas<- st_intersects(manzanas, isocronas[isocronas$distance == 500, ])
intersecciones_500 $C_clase[lengths(intersecciones_500) > 0] <- 1
manzanas
$C_pond <- 0
manzanas<- manzanas %>%
manzanas mutate(C_pond = case_when(
== 0 ~ "Fuera de Cobertura",
C_clase == 0.5 ~ "Mediananmente Cubierto",
C_clase == 1 ~ "Cubierto",
C_clase #TRUE ~ NA_character_ # En caso de que haya valores fuera del rango esperado
))
Frecuencies
# Se calcula el número de intersecciones
<- st_intersects(manzanas, isocronas)
intersecciones <- lengths(intersecciones)
conteo_intersecciones $conteo_intersecciones <- conteo_intersecciones # Se añade al df de manzanas
manzanas
# Se calculan las rupturas naturales de Jenks con 4 categorías
<- getJenksBreaks(manzanas$conteo_intersecciones, k = 4)
jenks_breaks
# Agregar las clasificaciones al df de manzanas
$categoria_jenks <- cut(manzanas$conteo_intersecciones,
manzanasbreaks = jenks_breaks,
include.lowest = TRUE,
labels = FALSE)
<- manzanas %>%
manzanas mutate(F_pond = case_when(
== 1 ~ "Poco Frecuente",
categoria_jenks == 2 ~ "Frecuente",
categoria_jenks == 3 ~ "Muy Frecuente",
categoria_jenks #TRUE ~ NA_character_ # En caso de que haya valores fuera del rango esperado
))
<- manzanas %>%
manzanas mutate(F_clase = case_when(
== 1 ~ 0.5,
categoria_jenks == 2 ~ 0.75,
categoria_jenks == 3 ~ 1,
categoria_jenks #TRUE ~ NA_character_ # En caso de que haya valores fuera del rango esperado
))
Finally, I calculated a proximity index for each block:
# Índice de próximidad
$I_prox <- manzanas$C_clase * 0.7 + manzanas$F_clase * 0.3 manzanas
This proximity index combines the coverage classification and additional factors ´F_clase´ to provide a more comprehensive measure of accessibility, which aids in better urban planning decisions regarding school locations and resource allocation.
#Guardar el df de manzanas como shp
#st_write(sf_final, "data/manzanas_proximidad.shp")
4. Supply-Demand Index of Elementary Schools
In this project, I performed a kernel density analysis to assess facility sufficiency, analyzing over 1,200 schools and more than 56,000 blocks. This analysis significantly improved resource allocation for urban planners by identifying areas where schools are either oversupplied or underserved.
The methodology was automated, allowing the process to be easily adapted for other types of facilities, such as healthcare centers or public services. By leveraging open-source tools, this approach enables urban planners to efficiently evaluate facility distribution, ensuring that resources are allocated where they are most needed, enhancing the overall planning process.
library(qgisprocess) # Conect to QGIS
library(terra) # Raster manipulation
library(sf) # shapefiles manipulation
library(dplyr) # Data manipulation
qgis_configure()
Sys.setenv("QGIS_ALLOW_OVERWRITE" = "TRUE")
<- "qgis:heatmapkerneldensityestimation" alg_id
Supply (Schools)
# Define input and output paths
<- "C:/Users/manue/IMEPLAN/Intraurbanos_Suficiencia/datos/primarias/AMG_Equipamientos_primaria.shp"
equip_layer
<- "C:/Users/manue/IMEPLAN/Intraurbanos_Suficiencia/primarias/resultados/kernel_primarias.tif"
output_path
<- st_read(equip_layer, quiet = TRUE)
input_layer
# Define parameters
<- list(
params INPUT = equip_layer,
RADIUS = 1100,
RADIUS_FIELD = NULL,
PIXEL_SIZE = 20,
WEIGHT_FIELD = "EXISTENTES",
KERNEL = 0, # Or use "Quartic (biweight)" if required
DECAY = 0,
OUTPUT_VALUE = 0, # Or use "Density" if required
OUTPUT = output_path
)
# Correctly pass the algorithm ID and parameters using do.call
<- tryCatch({
result do.call(qgis_run_algorithm, c(list(algorithm = alg_id), params))
error = function(e) {
}, message("An error occurred:")
message(e$message)
NULL
})
# Check if the result is valid
if (!is.null(result)) {
message("Algorithm executed successfully.")
else {
} message("Algorithm execution failed.")
We adjust to the correct output size.
# Leer el raster y el shapefile
<- rast("resultados/kernel_primarias.tif")
raster <- st_read("C:/Users/manue/IMEPLAN/Intraurbanos_Suficiencia/datos/Limite_metropolitano/Limite_metropolitano.shp")
shapefile
# Asegurar que ambos están en el mismo CRS
<- st_transform(shapefile, crs(raster))
shapefile
# Obtener la extensión (bounding box) del shapefile
<- ext(shapefile)
bbox
# Extender el raster para cubrir toda la extensión del shapefile con valores de NoData
<- extend(raster, bbox)
extended_raster
# Establecer el valor de NoData a -9999 (opcional)
NAflag(extended_raster) <- -9999
# Reemplazar todos los valores NoData (-9999) por ceros dentro del raster
== -9999] <- 0
extended_raster[extended_raster
# Reemplazar cualquier valor NA que exista en el raster por ceros
is.na(extended_raster)] <- 0
extended_raster[
# Guardar el raster resultante
writeRaster(extended_raster, "resultados/kernel_primarias_limite_amg.tif", overwrite = TRUE)
Demand
# Define input and output paths
<- "C:/Users/manue/IMEPLAN/Intraurbanos_Suficiencia/datos/primarias/manzanas.shp"
equip_layer <- "resultados/kernel_manzanas.tif"
output_path
<- st_read(equip_layer, quiet = TRUE)
input_layer
# Define parameters
<- list(
params INPUT = equip_layer,
RADIUS = 1100,
RADIUS_FIELD = NULL,
PIXEL_SIZE = 20,
WEIGHT_FIELD = "demanda",
KERNEL = 0, # Or use "Quartic (biweight)" if required
DECAY = 0,
OUTPUT_VALUE = 0, # Or use "Density" if required
OUTPUT = output_path
)
# Correctly pass the algorithm ID and parameters using do.call
<- tryCatch({
result do.call(qgis_run_algorithm, c(list(algorithm = alg_id), params))
error = function(e) {
}, message("An error occurred:")
message(e$message)
NULL
})
# Check if the result is valid
if (!is.null(result)) {
message("Algorithm executed successfully.")
else {
} message("Algorithm execution failed.")
}
We adjust to the correct output size.
# Leer el raster y el shapefile
<- rast("resultados/kernel_manzanas.tif")
raster <- st_read("C:/Users/manue/IMEPLAN/Intraurbanos_Suficiencia/datos/Limite_metropolitano/Limite_metropolitano.shp")
shapefile
# Asegurar que ambos están en el mismo CRS
<- st_transform(shapefile, crs(raster))
shapefile
# Obtener la extensión (bounding box) del shapefile
<- ext(shapefile)
bbox
# Extender el raster para cubrir toda la extensión del shapefile con valores de NoData
<- extend(raster, bbox)
extended_raster
# Establecer el valor de NoData a -9999 (opcional)
NAflag(extended_raster) <- -9999
# Reemplazar todos los valores NoData (-9999) por ceros dentro del raster
== -9999] <- 0
extended_raster[extended_raster
# Reemplazar cualquier valor NA que exista en el raster por ceros
is.na(extended_raster)] <- 0
extended_raster[
# Guardar el raster resultante
writeRaster(extended_raster, "resultados/kernel_manzanas_limite_amg.tif", overwrite = TRUE)
Normalization Index
# Cargar los rasters
<- rast("resultados/kernel_primarias_limite_amg.tif")
primarias <- rast("resultados/kernel_manzanas_limite_amg.tif")
manzanas
# Definir un raster base (primarias) como referencia para el alineamiento
<- resample(manzanas, primarias, method = "bilinear")
manzanas_aligned
# Verificar la alineación
#compareGeom(primarias, manzanas_aligned) # Debería devolver TRUE
# Realizar la resta
<- primarias - manzanas_aligned
resultado
writeRaster(resultado, "resultados/resultado_resta.tif", overwrite = TRUE)
Depending on each subsystem, we apply the normalization formula with different parameters. In the case of elementary schools the formula looks like this:
\[\text{indice_norm} = \left( \frac{2}{1 + e^{-0.20 \cdot \text{result}}} \right) - 1 \]
# Aplicar la normalización al raster
<- (2 / (1 + exp(-0.20 * resultado))) - 1
indice_norm
# Guardar el raster normalizado
writeRaster(indice_norm, "resultados/resultado_normalizado.tif", overwrite = TRUE)
The classification you’re using assigns each block (manzana) into one of five categories based on the intersected index (valor_intersectado
). Here’s how the categories are defined:
Proximidad media con alto déficit de equipamiento: For values between -1 and -0.60, this represents areas with medium proximity but a high deficit of infrastructure or equipment.
Proximidad alta con déficit de equipamiento: For values between -0.60 and -0.20, this represents areas with high proximity but still a deficit of equipment.
Proximidad alta con balance de equipamiento: For values between -0.20 and 0.20, indicating a balance between proximity and equipment availability.
Proximidad alta con superávit de equipamientos: For values between 0.20 and 0.60, this reflects areas with high proximity and a surplus of equipment.
Proximidad muy alta con alto superávit de equipamiento: For values between 0.60 and 1, representing areas with very high proximity and a significant surplus of equipment.
Anything outside these ranges is categorized as “Sin categoría” to handle undefined or missing cases.
# Cargar el raster normalizado
<- rast("resultados/resultado_normalizado.tif")
raster_normalizado
# Cargar el shapefile de manzanas
<- st_read("C:/Users/manue/IMEPLAN/Intraurbanos_Suficiencia/datos/primarias/manzanas.shp")
manzanas
# Asegurarse de que ambos tienen el mismo CRS
# Cambiar el CRS de manzanas para que coincida con el del raster si es necesario
if (!st_crs(manzanas) == crs(raster_normalizado)) {
<- st_transform(manzanas, crs(raster_normalizado))
manzanas
}
# Convertir el shapefile a puntos (puedes usar centroides o los puntos del contorno)
<- st_centroid(manzanas)
manzanas_centroid
# Extraer los valores del raster en las ubicaciones de los puntos
<- terra::extract(raster_normalizado, vect(manzanas_centroid))
valores_extraidos
# Asegurarse de que los valores extraídos se asignan correctamente
# `valores_extraidos` es un data.frame con la estructura: ID del punto y valor del raster
# Combinar los valores extraídos con el shapefile original
$valor_intersectado <- valores_extraidos[, 2]
manzanas
# Crear la columna de categorías según los rangos proporcionados
<- manzanas %>%
manzanas mutate(categoria = case_when(
>= -1 & valor_intersectado < -0.60 ~ "Proximidad media con alto déficit de equipamiento",
valor_intersectado >= -0.60 & valor_intersectado < -0.20 ~ "Proximidad alta con déficit de equipamiento",
valor_intersectado >= -0.20 & valor_intersectado <= 0.20 ~ "Proximidad alta con balance de equipamiento",
valor_intersectado > 0.20 & valor_intersectado <= 0.60 ~ "Proximidad alta con superávit de equipamientos",
valor_intersectado > 0.60 & valor_intersectado <= 1 ~ "Proximidad muy alta con alto superávit de equipamiento",
valor_intersectado TRUE ~ "Sin categoría"
))
<- manzanas %>%
manzanas mutate(CV_Categoria = case_when(
>= -1 & valor_intersectado < -0.60 ~ 1,
valor_intersectado >= -0.60 & valor_intersectado < -0.20 ~ 2,
valor_intersectado >= -0.20 & valor_intersectado <= 0.20 ~ 3,
valor_intersectado > 0.20 & valor_intersectado <= 0.60 ~ 4,
valor_intersectado > 0.60 & valor_intersectado <= 1 ~ 5,
valor_intersectado TRUE ~ NA_real_ # Usamos NA_real_ para representar valores faltantes como numéricos
))
# Guardar el shapefile resultante con la nueva columna
st_write(manzanas, "resultados/manzanas_resultados.shp", delete_layer = TRUE)
This process generated the following key outputs:
Density Kernel Analysis for Elementary Schools (raster): A spatial representation of the distribution of elementary schools, providing insights into the areas with high and low concentration of educational facilities.
Density Kernel Analysis for Blocks Where the Objective Population (Children) Resides (raster): A spatial analysis identifying the distribution of the target population, which helps in understanding the demand for educational services in different areas.
Difference Between Both Density Kernel Analyses (raster): A comparison between the supply (schools) and demand (children), highlighting the areas where there is a surplus or deficit of educational facilities relative to the population.
Final File with Evaluation Values for Each Block (shapefile): A shapefile that assigns an evaluation value to each block, indicating how well the needs of the population are being met by the existing infrastructure in that area.
This process was automated for all subsystems of public facilities: schools, health centers, and markets. By automating the analysis, it ensures scalability and repeatability, making it easier to apply across different facility types and geographical areas. The automation also allows for consistent evaluation and more efficient resource allocation across various public service sectors.
This process generated the following key outputs:
Density Kernel Analysis for Elementary Schools (raster): A spatial representation of the distribution of elementary schools, providing insights into the areas with high and low concentration of educational facilities.
Density Kernel Analysis for Blocks Where the Objective Population (Children) Resides (raster): A spatial analysis identifying the distribution of the target population, which helps in understanding the demand for educational services in different areas.
Difference Between Both Density Kernel Analyses (raster): A comparison between the supply (schools) and demand (children), highlighting the areas where there is a surplus or deficit of educational facilities relative to the population.
Final File with Evaluation Values for Each Block (shapefile): A shapefile that assigns an evaluation value to each block, indicating how well the needs of the population are being met by the existing infrastructure in that area.
This process was automated for all subsystems of public facilities: schools, health centers, and markets. By automating the analysis, it ensures scalability and repeatability, making it easier to apply across different facility types and geographical areas. The automation also allows for consistent evaluation and more efficient resource allocation across various public service sectors.
Graphical Interface
A significant achievement of the project is the development of a Graphical Interface designed specifically for urban planners, created using Shiny in R. This interface enables users to input and customize data, facilitating the evaluation of educational facilities based on various parameters. By offering an intuitive and adaptable tool, it supports replicability and the exploration of new planning scenarios, making it invaluable for urban planning analysis.
The interface’s success in Jalisco demonstrates its potential to be implemented in other states across Mexico, contributing to strategic urban development efforts and enhancing the ability of planners to make data-driven decisions for future growth and infrastructure planning.
Conclusion
My time at IMEPLAN was a transformative experience where I had the opportunity to apply data science and geospatial analysis to real-world urban challenges. The projects I worked on not only enhanced urban planning workflows but also demonstrated the value of open-source tools in driving cost-effective and scalable solutions.
The culmination of this work is a GitHub repository containing the spatial evaluation app, complete with documentation to ensure its replicability and usability. This deliverable reflects my commitment to fostering transparency, collaboration, and innovation in urban planning. I am proud to have contributed to IMEPLAN’s mission of improving the quality of life in the Guadalajara Metropolitan Area and look forward to seeing how this work impacts future decision-making processes.
Footnotes
Huff, D. L. (1964). Defining and estimating a trading area. The Journal of Marketing, 28(3), 34-38. https://doi.org/10.2307/1249154↩︎