Análisis de la red vial de Ixil (Yucatán, México) y cambio de la vegetación

Teoría de grafos con sfnetworks: cómo se relaciona la estructura vial con el cambio medido con NDVI (2001–2025)

Autor/a

Carla Machicado Sanchez

Fecha de publicación

12 de octubre de 2026

1 Presentación

Este documento lee la red vial de Ixil como un sistema y la cruza con el diagnóstico de cambio de la vegetación hecho con la serie de NDVI de 2001 a 2025 (realizado en el anterior codigo). El NDVI resume cuánta vegetación verde hay en cada píxel; aquel diagnóstico entrega, para cada píxel, su NDVI en cada fecha y, si perdió la vegetación de forma permanente, el año en que apareció esa cicatriz. Aquí se responde a cuatro preguntas:

  1. ¿Qué estructura tiene la red? Jerarquía, conectividad, centralidad y accesibilidad desde la cabecera.
  2. ¿El cambio sigue a los ejes viales? Se compara qué tipo de cambio ocurre cerca y lejos de las vías.
  3. ¿Las zonas mal conectadas concentran algún tipo de cambio? Se compara el cambio según la accesibilidad por la red.
  4. ¿Las cicatrices se acercan o se alejan de las vías con el tiempo? Se usa el año de aparición de cada cicatriz.

La red se construye y analiza con sfnetworks (Meer et al. 2024) a partir de las vías de OpenStreetMap, descargadas con osmdata (Padgham et al. 2017).

2 Preparación del entorno

Código
# Al renderizar fuera de RStudio puede no haber un repositorio de CRAN definido
if (identical(unname(getOption("repos")["CRAN"]), "@CRAN@")) {
  options(repos = c(CRAN = "https://cloud.r-project.org"))
}
if (!requireNamespace("pacman", quietly = TRUE)) install.packages("pacman")

# sfnetworks depende de sfheaders, que exige Rcpp >= 1.1.0. Si la versión instalada
# es anterior, se actualiza. Si aun así falla la carga, reinicia R y vuelve a ejecutar.
if (!requireNamespace("Rcpp", quietly = TRUE) || packageVersion("Rcpp") < "1.1.0") {
  install.packages("Rcpp")
}

pacman::p_load(sf,
               sfnetworks,
               tidygraph,
               tidyverse,
               terra,
               osmdata,
               httr2,       # lo necesita osmdata para descargar
               spdep,
               leaflet,
               knitr,
               scales
)
terraOptions(progress = 0)
fmt <- function(x, d = 0) format(round(x, d), big.mark = ",", nsmall = d, trim = TRUE)

# Tema oscuro para las gráficas, a juego con el fondo negro del reporte
tema_oscuro <- function() {
  theme_minimal() +
    theme(plot.background   = element_rect(fill = "#000000", colour = NA),
          panel.background  = element_rect(fill = "#000000", colour = NA),
          legend.background = element_rect(fill = "#000000", colour = NA),
          legend.key        = element_rect(fill = "#000000", colour = NA),
          panel.grid        = element_line(colour = "#2b2b2b"),
          text              = element_text(colour = "#e6e6e6"),
          axis.text         = element_text(colour = "#cfcfcf"),
          strip.text        = element_text(colour = "#e6e6e6"))
}
theme_set(tema_oscuro())

2.1 Parámetros del análisis

Código
# Límite municipal: el mismo shapefile del diagnóstico de cambio
RUTA_SHP_IXIL <- "G:/FLACSO/SEMILLERO_ PAPER EJIDOS MEXICO/DATOS ESPACIALES/ixil_muni.shp"
CAMPO_FILTRO  <- NULL
VALOR_FILTRO  <- NULL

# Resultado del diagnóstico con NDVI (diagnostico_cambio_ndvi_ixil.qmd)
DIR_NDVI      <- "salidas_ixil_ndvi"
RUTA_NDVI     <- file.path(DIR_NDVI, "ixil_ndvi_normalizado.tif")       # una banda por fecha
RUTA_CIC_ANIO <- file.path(DIR_NDVI, "ixil_cicatrices_anio.tif")        # año de cada cicatriz
RUTA_CIC_GPKG <- file.path(DIR_NDVI, "cicatrices_candidatas_ndvi.gpkg") # parches (para el mapa)

# Clases de NDVI: deben ser las mismas que en el diagnóstico con NDVI
CORTES_NDVI <- c(-1, 0.2, 0.4, 0.6, 1)
CLASES_NDVI <- c("Sin vegetación", "Vegetación escasa", "Vegetación media", "Vegetación densa")

# Red vial. NULL = descargar de OpenStreetMap (se guarda una copia en CACHE_OSM
# para no volver a descargar). Para usar una capa propia (p. ej. la Red Nacional
# de Caminos de INEGI) indica su ruta y el campo con el tipo de vía.
# >>> AQUÍ VA EL SHAPEFILE DE VÍAS <<<  (y el nombre del campo con el tipo de vía)
RUTA_RED       <- NULL
CAMPO_TIPO_RED <- "highway"
# La red se toma con este margen alrededor del municipio. No conviene reducirlo:
# varias partes de Ixil (la franja costera, por ejemplo) solo se conectan entre sí
# por carreteras que pasan por los municipios vecinos, y con un margen pequeño
# quedan como islas sueltas que la limpieza topológica descarta.
BUFFER_RED_M   <- 5000
CACHE_OSM      <- sprintf("datos_ixil/red_vial_osm_margen_%sm.gpkg", BUFFER_RED_M)

MIN_HA_CATEGORIA <- 10   # categorías de cambio con menos superficie no se leen en
                         # los índices de concentración (son demasiado ruidosas)

# Origen de las rutas y de la accesibilidad. NULL = el punto de la red con más
# intersecciones a menos de 500 m dentro del municipio, es decir, el centro de la
# traza urbana de la cabecera. Para fijarlo a mano: c(longitud, latitud).
CABECERA_LONLAT <- NULL

# Infraestructura eólica (opcional; si las rutas no existen, la sección se omite)
RUTA_EOLICO <- c("G:/FLACSO/SEMILLERO_ PAPER EJIDOS MEXICO/DATOS ESPACIALES/Aerogeneradores1.shp",
                 "G:/flacso/SEMILLERO_ PAPER EJIDOS MEXICO/SHAPEFILES-20260609T232032Z-3-001/SHAPEFILES/GEOCOMUNES/_3_1_3_parque_eolico_peninsula_poli_2020 (1)/_3_1_3_parque_eolico_peninsula_poli_2020.shp")
BUFFER_EOLICO_M <- 500

ALFA           <- 2       # penalización de las vías poco transitables (0 = solo distancia)
UMBRAL_ACCES_M <- 5000    # umbral de accesibilidad desde la cabecera (m)
BANDAS_VIA_M   <- c(0, 100, 250, 500, 1000, Inf)   # bandas de distancia a la vía (m)
ANCHO_MORAN_M  <- 2000    # distancia máxima entre vecinos en el índice de Moran (m)
MIN_HA_NODO    <- 1       # superficie mínima del área servida por un nodo (ha)

# Validación con el índice Kappa: ¿la red vial predice dónde se pierde vegetación?
CATEGORIAS_OBJETIVO <- c("Cicatriz candidata", "Pérdida de vegetación")  # lo que se intenta predecir
TAM_BLOQUE_M        <- 1000   # lado de los bloques que separan calibración y validación (m)
SEMILLA             <- 2026   # fija el sorteo de bloques para que el resultado sea reproducible

CRS_METRICO <- "EPSG:32616"   # UTM zona 16 Norte: distancias en metros

# Las salidas llevan el prefijo "red_ndvi_" para no pisar las del diagnóstico
dir_salidas <- "salidas_ixil_ndvi"
dir.create(dir_salidas, showWarnings = FALSE, recursive = TRUE)
dir.create(dirname(CACHE_OSM), showWarnings = FALSE, recursive = TRUE)

3 Datos de entrada

3.1 Límite municipal y patrón de cambio

Código
limite <- st_read(RUTA_SHP_IXIL, quiet = TRUE) |> st_make_valid()
if (!is.null(CAMPO_FILTRO)) {
  limite <- limite[as.character(limite[[CAMPO_FILTRO]]) == as.character(VALOR_FILTRO), ]
}
limite_m <- st_sf(geometry = st_union(st_geometry(st_transform(limite, CRS_METRICO))))
zona_red <- st_as_sfc(st_bbox(st_buffer(limite_m, BUFFER_RED_M)))   # rectángulo con margen
area_mun_km2 <- as.numeric(st_area(limite_m)) / 1e6

if (!file.exists(RUTA_NDVI) || !file.exists(RUTA_CIC_ANIO)) {
  stop("No se encontró el resultado del diagnóstico con NDVI en '", DIR_NDVI,
       "'. Renderiza primero diagnostico_cambio_ndvi_ixil.qmd.")
}

# Serie de NDVI normalizado (una banda por fecha) y año de aparición de cada cicatriz
ndvi        <- rast(RUTA_NDVI)
anios_serie <- as.integer(gsub("\\D", "", names(ndvi)))
anios       <- range(anios_serie)                 # primera y última fecha
r_cambio    <- ndvi[[1]]                          # grilla de referencia del análisis
r_cic_anio  <- rast(RUTA_CIC_ANIO)
if (!compareGeom(r_cic_anio, r_cambio, stopOnError = FALSE)) {
  stop("La serie de NDVI y el raster de cicatrices no comparten grilla: vuelve a renderizar el diagnóstico.")
}
crs_r    <- st_crs(crs(r_cambio))
limite_r <- st_transform(limite_m, crs_r)

# Tabla de píxeles: NDVI en la primera y la última fecha, su diferencia, año de
# cicatriz (NA si no es cicatriz), superficie y coordenadas
v1 <- values(ndvi[[which.min(anios_serie)]])[, 1]
v2 <- values(ndvi[[which.max(anios_serie)]])[, 1]
celdas <- which(!is.na(v1) & !is.na(v2))
px <- data.frame(celda    = celdas,
                 ha       = values(cellSize(r_cambio, unit = "ha"))[celdas, 1],
                 ndvi1    = v1[celdas],
                 ndvi2    = v2[celdas],
                 anio_cic = values(r_cic_anio)[celdas, 1])
px$delta <- px$ndvi2 - px$ndvi1
px <- cbind(px, xyFromCell(r_cambio, celdas))

# Categoría de cambio de cada píxel. La cicatriz (pérdida que persiste en todas
# las fechas posteriores) se separa de la simple bajada de clase entre la
# primera y la última fecha, que puede ser pasajera.
clasificar <- function(x) as.integer(cut(x, CORTES_NDVI, labels = CLASES_NDVI,
                                         include.lowest = TRUE, right = FALSE))
c1 <- clasificar(px$ndvi1); c2 <- clasificar(px$ndvi2)
categorias <- c("Persistencia", "Cicatriz candidata", "Pérdida de vegetación",
                "Ganancia de vegetación")
paleta_cat <- setNames(c("#e6e6e6", "#d7191c", "#fdae61", "#1a9641"), categorias)
px$cambio <- factor(case_when(
  !is.na(px$anio_cic) ~ "Cicatriz candidata",
  c2 < c1             ~ "Pérdida de vegetación",
  c2 > c1             ~ "Ganancia de vegetación",
  TRUE                ~ "Persistencia"
), categorias)

px_pts <- st_as_sf(px[, c("x", "y")], coords = c("x", "y"), crs = crs_r) |>
  st_transform(CRS_METRICO)
area_total_ha <- sum(px$ha)

# Auxiliar: vector de valores por píxel -> raster sobre la grilla del análisis
a_raster <- function(valores, nombre) {
  r <- rast(r_cambio); v <- rep(NA_real_, ncell(r)); v[px$celda] <- valores
  values(r) <- v; names(r) <- nombre; r
}
Código
# Todos los mapas del documento comparten el mismo fondo: imagen satelital de
# Google (por defecto), Google híbrido, Esri y un mapa base oscuro.
a_ll <- function(x) st_transform(x, 4326)
grupos_base <- c("Google Satélite", "Google Híbrido", "Esri World Imagery", "Mapa oscuro")
mapa_base <- function() {
  leaflet() |>
    addTiles(urlTemplate = "https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}",
             attribution = "Imágenes © Google", group = "Google Satélite",
             options = tileOptions(maxZoom = 20)) |>
    addTiles(urlTemplate = "https://mt1.google.com/vt/lyrs=y&x={x}&y={y}&z={z}",
             attribution = "Imágenes © Google", group = "Google Híbrido",
             options = tileOptions(maxZoom = 20)) |>
    addProviderTiles(providers$Esri.WorldImagery, group = "Esri World Imagery") |>
    addProviderTiles(providers$CartoDB.DarkMatter, group = "Mapa oscuro") |>
    addPolygons(data = a_ll(limite_m), fill = FALSE, color = "#000000", weight = 2,
                group = "Límite municipal") |>
    # Todos los mapas abren encuadrados en el municipio
    fitBounds(caja_ll[["xmin"]], caja_ll[["ymin"]], caja_ll[["xmax"]], caja_ll[["ymax"]])
}
caja_ll <- st_bbox(a_ll(limite_m))
# Control para encender y apagar capas y cambiar de fondo
control_capas <- function(m, grupos) {
  addLayersControl(m, baseGroups = grupos_base,
                   overlayGroups = c(grupos, "Límite municipal"),
                   options = layersControlOptions(collapsed = TRUE))
}
Código
px |>
  group_by(cambio, .drop = FALSE) |>
  summarise(ha = sum(ha), .groups = "drop") |>
  mutate(pct = 100 * ha / sum(ha)) |>
  mutate(ha = round(ha, 1), pct = round(pct, 2)) |>
  setNames(c(paste("Categoría de cambio", anios[1], "→", anios[2]), "ha", "% del municipio")) |>
  kable(format.args = list(big.mark = ","))
Tabla 1: Categorías de cambio que se cruzan con la red vial.
Categoría de cambio 2001 → 2025 ha % del municipio
Persistencia 9,497.1 68.83
Cicatriz candidata 569.0 4.12
Pérdida de vegetación 2,158.8 15.65
Ganancia de vegetación 1,572.4 11.40

Persistencia, pérdida y ganancia comparan la clase de NDVI de 2001 con la de 2025 (Sin vegetación, Vegetación escasa, Vegetación media, Vegetación densa). La cicatriz candidata viene del diagnóstico: un píxel que tenía vegetación, la perdió y no la recuperó en ninguna fecha posterior.

3.2 Red vial

Código
# Descarga desde OpenStreetMap todas las vías (highway) de la zona. El servidor
# público de Overpass a veces rechaza la consulta por saturación (error 429 o
# 504): se reintenta, alternando entre servidores, antes de darse por vencido.
descargar_osm <- function(zona) {
  bb <- st_bbox(st_transform(zona, 4326))
  servidores <- c("https://overpass-api.de/api/interpreter",
                  "https://overpass.kumi.systems/api/interpreter")
  for (intento in 1:6) {
    set_overpass_url(servidores[(intento - 1) %% length(servidores) + 1])
    osm <- tryCatch(
      opq(bbox = unname(bb), timeout = 120) |>
        add_osm_feature(key = "highway") |>
        osmdata_sf(),
      error = function(e) { message("Intento ", intento, " fallido: ", conditionMessage(e)); NULL })
    if (!is.null(osm) && !is.null(osm$osm_lines) && nrow(osm$osm_lines) > 0) {
      return(dplyr::select(osm$osm_lines, any_of(c("osm_id", "name")), type = highway))
    }
    Sys.sleep(20)
  }
  stop("No se pudo descargar la red de OpenStreetMap. Espera unos minutos y vuelve a ",
       "intentar, o indica una capa local en RUTA_RED.")
}

if (!is.null(RUTA_RED)) {
  calles <- st_read(RUTA_RED, quiet = TRUE)
  calles$type <- as.character(calles[[CAMPO_TIPO_RED]])
  fuente_red <- paste("capa local:", basename(RUTA_RED))
} else {
  if (!file.exists(CACHE_OSM)) st_write(descargar_osm(zona_red), CACHE_OSM, quiet = TRUE)
  calles <- st_read(CACHE_OSM, quiet = TRUE)
  fuente_red <- paste("OpenStreetMap, descargada el", format(file.mtime(CACHE_OSM), "%d/%m/%Y"))
}

# CRS proyectado y solo líneas simples. Se conservan enteras las vías que tocan
# la zona (recortarlas rompería conexiones) y se descartan las que están en
# construcción o proyectadas: aún no forman parte de la red.
calles <- calles |>
  st_transform(CRS_METRICO) |>
  dplyr::filter(!type %in% c("construction", "proposed"), !is.na(type)) |>
  st_filter(zona_red)
calles <- suppressWarnings(st_cast(calles, "LINESTRING"))

# Jerarquía vial: agrupa los tipos de OSM en cuatro clases y les asigna una
# transitabilidad (1 = pavimentada y continua; 0.25 = brecha o sendero).
jerarquias <- c("Principal", "Local", "Servicio", "Brecha o sendero", "Otra")
clasificar_via <- function(type) {
  factor(case_when(
    grepl("^(motorway|trunk|primary|secondary|tertiary)", type)      ~ "Principal",
    type %in% c("residential", "living_street", "unclassified")       ~ "Local",
    type == "service"                                                 ~ "Servicio",
    type %in% c("track", "path", "footway", "cycleway", "bridleway")  ~ "Brecha o sendero",
    TRUE                                                              ~ "Otra"
  ), jerarquias)
}
transitabilidad_jer <- c("Principal" = 1.00, "Local" = 0.75, "Servicio" = 0.50,
                         "Brecha o sendero" = 0.25, "Otra" = 0.50)
# Colores claros, legibles sobre la imagen satelital y sobre el fondo negro
paleta_jer <- c("Principal" = "#ff4d4d", "Local" = "#f0f0f0", "Servicio" = "#4db8ff",
                "Brecha o sendero" = "#ffc533", "Otra" = "#9e9e9e")
calles$jerarquia <- clasificar_via(calles$type)

# Todas las vías mapeadas dentro del municipio (estén o no conectadas al resto).
# Se usan para longitudes, densidades y distancia a la vía; el grafo, en cambio,
# solo conserva la parte conectada.
vias_mun <- suppressWarnings(st_intersection(calles[, c("type", "jerarquia")], st_geometry(limite_m)))
vias_mun$km <- as.numeric(st_length(vias_mun)) / 1000
km_mun <- sum(vias_mun$km)

# Versiones en lon/lat para los mapas (geometría algo simplificada para aligerar
# el HTML): todas las vías de la zona, y las del municipio y su entorno inmediato
pal_jer_ll <- colorFactor(unname(paleta_jer), levels = names(paleta_jer))
calles_ll  <- a_ll(st_simplify(calles[, c("type", "jerarquia")], dTolerance = 3))
vias_ll    <- a_ll(st_filter(calles[, c("type", "jerarquia")], st_buffer(limite_m, 1500)))

4 Construcción del grafo

Las calles (líneas) pasan a ser las aristas y sus extremos (puntos) los nodos.

Código
red <- as_sfnetwork(calles, directed = FALSE)
n_bruto <- c(nodos = igraph::vcount(red), aristas = igraph::ecount(red))
km_bruto <- sum(as.numeric(st_length(calles))) / 1000
red
# A sfnetwork with 2477 nodes and 1591 edges
#
# CRS:  EPSG:32616 
#
# An undirected multigraph with 899 components with spatially explicit edges
#
# Node data: 2,477 × 1 (active)
                geom
         <POINT [m]>
1 (254194.1 2335567)
2 (254037.5 2335590)
3 (245381.2 2340046)
4 (241698.9 2340613)
5 (259495.5 2339402)
6 (251858.2 2345385)
# ℹ 2,471 more rows
#
# Edge data: 1,591 × 7
   from    to osm_id   name            type                       geom jerarquia
  <int> <int> <chr>    <chr>           <chr>          <LINESTRING [m]> <fct>    
1     1     2 26995460 Calle 21        seco… (254194.1 2335567, 25413… Principal
2     3     4 27555706 Carretera Chic… tert… (245381.2 2340046, 24537… Principal
3     5     6 27596103 <NA>            tert… (259495.5 2339402, 25948… Principal
# ℹ 1,588 more rows

5 Limpieza topológica

Paso crítico con datos de OSM: se eliminan duplicados y bucles, se parten las aristas donde se cruzan, se quitan los nodos de grado 2 (que no son intersecciones) y se conserva el componente principal, es decir, la parte de la red que está conectada entre sí.

Código
red <- red |>
  activate("edges") |>
  filter(!edge_is_multiple(), !edge_is_loop()) |>       # duplicados y bucles
  convert(to_spatial_subdivision, .clean = TRUE) |>     # une cruces interiores
  convert(to_spatial_smooth,                            # quita nodos de grado 2
          summarise_attributes = "first", .clean = TRUE) |>
  activate("nodes") |>
  filter(group_components() == 1)                       # componente principal

n_limpio <- c(nodos = igraph::vcount(red), aristas = igraph::ecount(red))

6 Pesos de las aristas

Cada arista recibe dos pesos:

  • longitud (m): la distancia real;
  • costo de tránsito: la longitud penalizada por la mala transitabilidad, \(costo = longitud \times (1 + \alpha\,(1 - transitabilidad))\). Con \(\alpha\) = 2, un kilómetro de brecha “cuesta” lo mismo que 2.5 km de carretera pavimentada.
Código
red <- red |>
  activate("edges") |>
  mutate(
    longitud        = as.numeric(edge_length()),               # metros
    jerarquia       = clasificar_via(type),
    transitabilidad = unname(transitabilidad_jer[as.character(jerarquia)]),
    costo_transito  = longitud * (1 + ALFA * (1 - transitabilidad))
  )

7 Métricas de centralidad

Se calculan el grado de cada nodo (cuántos tramos llegan a él), su cercanía al resto de la red y la centralidad de intermediación de nodos y aristas: cuántas de las rutas más cortas entre todos los pares de nodos pasan por ese elemento (Freeman 1977).

Código
red <- red |>
  activate("nodes") |>
  mutate(
    grado    = centrality_degree(),
    cercania = centrality_closeness(weights = longitud, normalized = TRUE),
    interm   = centrality_betweenness(weights = longitud, normalized = TRUE)
  ) |>
  activate("edges") |>
  mutate(flujo = centrality_edge_betweenness(weights = longitud))

nodos   <- st_as_sf(red, "nodes")
aristas <- st_as_sf(red, "edges")
km_limpio <- sum(aristas$longitud) / 1000

# Aristas en lon/lat para los mapas (geometría algo simplificada)
aristas_ll <- a_ll(st_simplify(aristas[, c("type", "jerarquia", "longitud", "transitabilidad", "flujo")],
                               dTolerance = 3))
Código
mapa_base() |>
  addPolylines(data = calles_ll, color = "#bdbdbd", weight = 1, dashArray = "3", opacity = 0.8,
               group = "Vías fuera del grafo") |>
  addPolylines(data = aristas_ll, color = ~pal_jer_ll(jerarquia), opacity = 0.95,
               weight = ~ifelse(jerarquia == "Principal", 4, 2),
               popup = ~paste0(jerarquia, " (", type, ")"), group = "Red analizada") |>
  addLegend(pal = pal_jer_ll, values = droplevels(aristas_ll$jerarquia), title = "Jerarquía",
            position = "bottomleft") |>
  control_capas(c("Red analizada", "Vías fuera del grafo"))
Figura 1: Red vial analizada (componente principal, por jerarquía) y límite municipal de Ixil, sobre la imagen satelital. En gris punteado, las vías mapeadas que quedaron fuera del grafo por no estar conectadas.

7.1 Indicadores de estructura

Código
tab_jer <- vias_mun |>
  st_drop_geometry() |>
  group_by(jerarquia) |>
  summarise(km = sum(km), .groups = "drop") |>
  mutate(pct = 100 * km / sum(km))
tab_jer |>
  mutate(km = round(km, 1), pct = round(pct, 1)) |>
  setNames(c("Jerarquía", "km", "% de la red")) |>
  kable()
Tabla 2: Longitud de las vías mapeadas dentro del municipio por jerarquía vial.
Jerarquía km % de la red
Principal 7.8 7.3
Local 51.5 48.4
Servicio 8.6 8.0
Brecha o sendero 38.6 36.2
Código
V <- n_limpio[["nodos"]]; E <- n_limpio[["aristas"]]
prop_sin_salida <- mean(nodos$grado == 1)
indices <- c(
  beta  = E / V,                         # aristas por nodo
  gamma = E / (3 * (V - 2)),             # aristas existentes / máximo posible
  alfa  = (E - V + 1) / (2 * V - 5)      # circuitos existentes / máximo posible
)
num <- function(x, d = 0) format(round(x, d), big.mark = ",", nsmall = d, trim = TRUE)
data.frame(
  indicador = c("Nodos del grafo (intersecciones y extremos)", "Aristas del grafo (tramos)",
                "Longitud del grafo, municipio más margen (km)",
                "Vías mapeadas dentro del municipio (km)",
                "Densidad vial del municipio (km de vía por km²)",
                "Nodos sin salida (grado 1, %)", "Intersecciones de 3 o más tramos (%)",
                "Índice beta (aristas por nodo)",
                "Índice gamma (0 = mínima conexión, 1 = máxima)",
                "Índice alfa (0 = red en árbol, 1 = malla completa)"),
  valor = c(num(V), num(E), num(km_limpio, 1), num(km_mun, 1), num(km_mun / area_mun_km2, 2),
            num(100 * prop_sin_salida, 1), num(100 * mean(nodos$grado >= 3), 1),
            num(indices[["beta"]], 2), num(indices[["gamma"]], 2), num(indices[["alfa"]], 2))
) |> kable(col.names = c("Indicador", "Valor"), align = "lr")
Tabla 3: Indicadores de conectividad de la red.
Indicador Valor
Nodos del grafo (intersecciones y extremos) 2,380
Aristas del grafo (tramos) 3,158
Longitud del grafo, municipio más margen (km) 810.8
Vías mapeadas dentro del municipio (km) 106.4
Densidad vial del municipio (km de vía por km²) 0.78
Nodos sin salida (grado 1, %) 26.6
Intersecciones de 3 o más tramos (%) 73.4
Índice beta (aristas por nodo) 1.33
Índice gamma (0 = mínima conexión, 1 = máxima) 0.44
Índice alfa (0 = red en árbol, 1 = malla completa) 0.16

Un índice alfa cercano a 0 describe una red en árbol, esto significa que como en este caso casi no hay rutas alternativas y cada camino lleva a un solo lugar. Es lo esperable en un municipio rural como Ixil donde las brechas entran al monte y terminan ahí.

Código
# La intermediación varía en varios órdenes de magnitud: el color y el grosor
# siguen su logaritmo para que también se distingan los tramos poco centrales.
aristas_ll$log_flujo <- log10(aristas_ll$flujo + 1)
pal_flujo <- colorNumeric("YlOrRd", domain = range(aristas_ll$log_flujo))
mapa_base() |>
  addPolylines(data = aristas_ll, color = ~pal_flujo(log_flujo), opacity = 0.95,
               weight = ~rescale(log_flujo, to = c(1, 6)),
               popup = ~paste0("Intermediación: ", format(round(flujo), big.mark = ","),
                               "<br>", jerarquia),
               group = "Intermediación") |>
  addLegend(pal = pal_flujo, values = aristas_ll$log_flujo, title = "Intermediación",
            labFormat = labelFormat(transform = function(x) round(10^x - 1), big.mark = ","),
            position = "bottomleft") |>
  control_capas("Intermediación")
Figura 2: Centralidad de intermediación de las aristas: cuántas de las rutas más cortas entre pares de nodos pasan por cada tramo. Es un indicador del flujo potencial.

8 Ruta más corta y ruta más transitable

Se comparan dos formas de ir de la cabecera a la costa: la ruta más corta en metros y la que minimiza el costo de tránsito (evita brechas aunque el recorrido sea más largo).

Código
nodos_dentro <- which(lengths(st_intersects(nodos, limite_m)) > 0)

# Origen: la cabecera. Por defecto, el nodo del municipio con más intersecciones a
# menos de 500 m: el centro de la traza urbana, donde la red es más densa.
if (is.null(CABECERA_LONLAT)) {
  nd <- nodos[nodos_dentro, ]
  vecinos_500 <- lengths(st_is_within_distance(nd, nd, dist = 500))
  cabecera <- st_geometry(nd)[which.max(vecinos_500)]
} else {
  cabecera <- st_transform(st_sfc(st_point(CABECERA_LONLAT), crs = 4326), CRS_METRICO)
}

origen  <- st_nearest_feature(cabecera, nodos)
# Destino: el nodo más al norte dentro del municipio (la costa)
destino <- nodos_dentro[which.max(st_coordinates(nodos)[nodos_dentro, 2])]
if (origen == destino) stop("El origen y el destino coinciden: revisa CABECERA_LONLAT.")

# ¿El grafo llega hasta las vías mapeadas más al norte del municipio? Si esas vías
# quedaron sueltas (fuera del componente principal), la ruta no alcanza la costa.
falta_norte_m <- unname(st_bbox(vias_mun)["ymax"]) - st_coordinates(nodos)[destino, 2]
txt_norte <- if (falta_norte_m > 1000) {
  paste0("**Atención:** las vías mapeadas más al norte del municipio están ",
         format(round(falta_norte_m), big.mark = ","), " m más allá del destino y no están ",
         "conectadas al grafo. Con esta capa de vías la ruta no llega a la costa, sino al ",
         "último punto conectado con la cabecera.")
} else ""
Código
ruta_corta  <- st_network_paths(red, from = origen, to = destino, weights = "longitud")
ruta_transi <- st_network_paths(red, from = origen, to = destino, weights = "costo_transito")

e_corta  <- aristas[ruta_corta$edge_paths[[1]], ]
e_transi <- aristas[ruta_transi$edge_paths[[1]], ]
d_euclid <- as.numeric(st_distance(nodos[origen, ], nodos[destino, ]))

describir_ruta <- function(e, nombre) {
  tibble(ruta = nombre,
         longitud_km      = sum(e$longitud) / 1000,
         transitabilidad  = weighted.mean(e$transitabilidad, e$longitud),
         pct_brecha       = 100 * sum(e$longitud[e$jerarquia == "Brecha o sendero"]) / sum(e$longitud),
         circuidad        = sum(e$longitud) / d_euclid)   # red / línea recta
}
resumen <- bind_rows(describir_ruta(e_corta, "Más corta"),
                     describir_ruta(e_transi, "Más transitable"))
resumen |>
  mutate(across(where(is.numeric), ~ round(.x, 2))) |>
  setNames(c("Ruta", "Longitud (km)", "Transitabilidad media", "% del recorrido en brecha",
             "Circuidad (red / línea recta)")) |>
  kable()
Tabla 4: Ruta más corta frente a ruta más transitable, de la cabecera a la costa.
Ruta Longitud (km) Transitabilidad media % del recorrido en brecha Circuidad (red / línea recta)
Más corta 28.17 0.43 69.83 1.50
Más transitable 29.68 0.51 58.60 1.58
Código
pal_transi <- colorNumeric(c("#ff7043", "#ffee58", "#66bb6a"), domain = c(0.25, 1))
mapa_base() |>
  addPolylines(data = aristas_ll, color = ~pal_transi(transitabilidad), weight = 1.5, opacity = 0.9,
               group = "Transitabilidad de la red") |>
  addPolylines(data = aristas_ll[ruta_transi$edge_paths[[1]], ], color = "#00e5ff", weight = 6,
               opacity = 0.95, group = "Ruta más transitable") |>
  addPolylines(data = aristas_ll[ruta_corta$edge_paths[[1]], ], color = "#ffffff", weight = 3,
               dashArray = "8", opacity = 1, group = "Ruta más corta") |>
  addMarkers(data = a_ll(nodos[c(origen, destino), ]),
             popup = c("Origen: cabecera", "Destino: nodo más al norte del municipio")) |>
  addLegend(pal = pal_transi, values = c(0.25, 1), title = "Transitabilidad", position = "bottomleft") |>
  control_capas(c("Ruta más corta", "Ruta más transitable", "Transitabilidad de la red"))
Figura 3: Ruta más corta (blanca, discontinua) y ruta más transitable (celeste) entre la cabecera y la costa, sobre la transitabilidad de cada tramo y la imagen satelital.

8.1 Curva de compromiso: ¿cuánto se alarga el viaje por evitar las brechas?

Código
sensibilidad <- lapply(c(0, 0.5, 1, 2, 4, 8), function(a) {
  r <- red |>
    activate("edges") |>
    mutate(c = longitud * (1 + a * (1 - transitabilidad)))
  p <- st_network_paths(r, from = origen, to = destino, weights = "c")
  e <- aristas[p$edge_paths[[1]], ]
  tibble(alfa                  = a,
         longitud_m            = sum(e$longitud),
         transitabilidad_media = weighted.mean(e$transitabilidad, e$longitud))
}) |>
  bind_rows() |>
  mutate(desvio_m = longitud_m - first(longitud_m))

sensibilidad |>
  mutate(longitud_m = round(longitud_m), desvio_m = round(desvio_m),
         transitabilidad_media = round(transitabilidad_media, 3)) |>
  setNames(c("α", "Longitud (m)", "Transitabilidad media", "Desvío respecto a la más corta (m)")) |>
  kable(format.args = list(big.mark = ","))
Tabla 5: Sensibilidad de la ruta cabecera–costa al parámetro α.
α Longitud (m) Transitabilidad media Desvío respecto a la más corta (m)
0.0 28,168 0.430 0
0.5 28,168 0.430 0
1.0 28,168 0.430 0
2.0 29,680 0.507 1,512
4.0 29,680 0.507 1,512
8.0 46,146 0.763 17,977
Código
ggplot(sensibilidad, aes(longitud_m, transitabilidad_media, label = alfa)) +
  geom_path(color = "grey60") +
  geom_point(size = 3, color = "#00e5ff") +
  geom_text(vjust = -1, colour = "white") +
  scale_x_continuous(labels = label_number(big.mark = ",")) +
  labs(x = "Longitud de la ruta (m)", y = "Transitabilidad media de la ruta") +
  tema_oscuro()
Figura 4: Compromiso entre distancia y transitabilidad según α (la etiqueta de cada punto).

9 Accesibilidad desde la cabecera

Código
# Costo de llegar desde la cabecera a cada nodo, con los dos pesos
nodos$dist_red  <- as.numeric(st_network_cost(red, from = origen, weights = "longitud")[1, ])
nodos$costo_red <- as.numeric(st_network_cost(red, from = origen, weights = "costo_transito")[1, ])

# Enlace de cada píxel del mapa de cambio con la red:
#  - la vía mapeada más cercana, conectada o no (distancia y jerarquía);
#  - la arista del grafo más cercana (centralidad);
#  - el nodo más cercano (por dónde "entra" el píxel a la red).

# Distancia exacta de cada punto a la línea que se le indica (su línea más cercana),
# calculada segmento a segmento con operaciones vectorizadas. Da el mismo resultado
# que st_distance(..., by_element = TRUE), pero en segundos en lugar de minutos.
# 'lineas' debe ser de tipo LINESTRING.
dist_a_linea <- function(xy, lineas, i_linea) {
  co <- st_coordinates(lineas)                           # X, Y y L1 (número de línea)
  n  <- nrow(co)
  seg <- data.frame(x1 = co[-n, "X"], y1 = co[-n, "Y"], x2 = co[-1, "X"], y2 = co[-1, "Y"],
                    linea = co[-n, "L1"])[co[-n, "L1"] == co[-1, "L1"], ]
  seg_por_linea <- split(seq_len(nrow(seg)), seg$linea)
  d <- numeric(nrow(xy))
  puntos_por_linea <- split(seq_len(nrow(xy)), i_linea)
  for (g in names(puntos_por_linea)) {
    p <- puntos_por_linea[[g]]; s <- seg[seg_por_linea[[g]], ]
    x <- xy[p, 1]; y <- xy[p, 2]
    dmin <- rep(Inf, length(p))
    for (k in seq_len(nrow(s))) {
      vx <- s$x2[k] - s$x1[k]; vy <- s$y2[k] - s$y1[k]; largo2 <- vx^2 + vy^2
      # posición (0 a 1) del punto del segmento más cercano a cada píxel
      t <- if (largo2 == 0) 0 else pmin(1, pmax(0, ((x - s$x1[k]) * vx + (y - s$y1[k]) * vy) / largo2))
      dmin <- pmin(dmin, sqrt((x - (s$x1[k] + t * vx))^2 + (y - (s$y1[k] + t * vy))^2))
    }
    d[p] <- dmin
  }
  d
}
xy_px <- st_coordinates(px_pts)

i_via <- st_nearest_feature(px_pts, calles)
px$dist_via <- dist_a_linea(xy_px, calles, i_via)
px$jer_via  <- calles$jerarquia[i_via]

principales <- calles[calles$jerarquia == "Principal", ]
px$dist_principal <- if (nrow(principales) > 0) {
  dist_a_linea(xy_px, principales, st_nearest_feature(px_pts, principales))
} else NA_real_

px$flujo_via <- percent_rank(aristas$flujo)[st_nearest_feature(px_pts, aristas)]  # percentil 0-1

px$nodo   <- st_nearest_feature(px_pts, nodos)
xy_nodo   <- st_coordinates(nodos)[px$nodo, ]
dist_nodo <- sqrt((xy_px[, 1] - xy_nodo[, 1])^2 + (xy_px[, 2] - xy_nodo[, 2])^2)

# Accesibilidad del píxel = costo por la red hasta su nodo + tramo final fuera de
# la red. En la versión de costo, ese tramo se penaliza como terreno sin camino.
px$acc_dist  <- nodos$dist_red[px$nodo]  + dist_nodo
px$acc_costo <- nodos$costo_red[px$nodo] + dist_nodo * (1 + ALFA)
Código
alcanzables <- a_ll(dplyr::filter(nodos, dist_red <= UMBRAL_ACCES_M))
pal_dist <- colorNumeric("viridis", domain = c(0, UMBRAL_ACCES_M))
mapa_base() |>
  addPolylines(data = vias_ll, color = "#e0e0e0", weight = 1, opacity = 0.7, group = "Red vial") |>
  addCircleMarkers(data = alcanzables, radius = 4, stroke = FALSE, fillOpacity = 0.95,
                   fillColor = ~pal_dist(dist_red),
                   popup = ~paste0(format(round(dist_red), big.mark = ","), " m por la red"),
                   group = "Nodos alcanzables") |>
  addMarkers(data = a_ll(cabecera), popup = "Cabecera (origen)", group = "Cabecera") |>
  addLegend(pal = pal_dist, values = c(0, UMBRAL_ACCES_M), title = "Distancia por la red (m)",
            position = "bottomleft") |>
  control_capas(c("Nodos alcanzables", "Red vial", "Cabecera"))
Figura 5: Nodos alcanzables desde la cabecera dentro del umbral de distancia, por la red.

9.1 Ejercicio 1: accesibilidad con el costo penalizado

Repetir el paso 8 con el peso penalizado: ¿cuánto se reduce el área accesible?

Código
ej1 <- tibble(
  peso = c("Distancia (longitud)", "Costo de tránsito (penaliza vías poco transitables)"),
  nodos_alcanzables = c(sum(nodos$dist_red <= UMBRAL_ACCES_M), sum(nodos$costo_red <= UMBRAL_ACCES_M)),
  ha_alcanzables    = c(sum(px$ha[px$acc_dist <= UMBRAL_ACCES_M]),
                        sum(px$ha[px$acc_costo <= UMBRAL_ACCES_M]))
) |>
  mutate(pct_municipio = 100 * ha_alcanzables / area_total_ha)
reduccion_ej1 <- 1 - ej1$ha_alcanzables[2] / ej1$ha_alcanzables[1]

ej1 |>
  mutate(ha_alcanzables = round(ha_alcanzables), pct_municipio = round(pct_municipio, 1)) |>
  setNames(c("Peso", "Nodos alcanzables", "Superficie alcanzable (ha)", "% del municipio")) |>
  kable(format.args = list(big.mark = ","))
Tabla 6: Ejercicio 1. Alcance desde la cabecera con el mismo umbral, midiendo solo distancia o penalizando las vías poco transitables.
Peso Nodos alcanzables Superficie alcanzable (ha) % del municipio
Distancia (longitud) 241 2,095 15.2
Costo de tránsito (penaliza vías poco transitables) 182 1,104 8.0

Respuesta. Con un umbral de 5,000 m, penalizar la mala transitabilidad reduce el área accesible desde la cabecera en 47.3 %: de 2,095 a 1,104 ha. Esa diferencia es el territorio que en el mapa parece cercano pero al que solo se llega por vías de baja jerarquía o caminando fuera de la red.

Código
pal_acc <- colorNumeric("viridis", domain = range(px$acc_costo / 1000), na.color = "transparent")
mapa_base() |>
  addRasterImage(a_raster(px$acc_costo / 1000, "acc"), colors = pal_acc, opacity = 0.75,
                 group = "Accesibilidad") |>
  addPolylines(data = vias_ll, color = "#ffffff", weight = 1, opacity = 0.8, group = "Red vial") |>
  addMarkers(data = a_ll(cabecera), popup = "Cabecera (origen)", group = "Cabecera") |>
  addLegend(pal = pal_acc, values = range(px$acc_costo / 1000), title = "km equivalentes",
            position = "bottomleft") |>
  control_capas(c("Accesibilidad", "Red vial", "Cabecera"))
Figura 6: Accesibilidad de cada píxel desde la cabecera (costo de tránsito, en km equivalentes de carretera pavimentada), sobre la imagen satelital. Los tonos amarillos son las zonas mal conectadas. La capa se puede apagar para ver el fondo.

10 Red vial y patrón de cambio

Esta sección es el cruce con el diagnóstico de NDVI. Para cada zona se calcula un índice de concentración de cada categoría de cambio:

\[\text{concentración} = \frac{\text{parte de la categoría que cae en la zona}}{\text{parte del municipio que ocupa la zona}}\]

Un valor de 1 significa que el cambio se reparte igual que el territorio; mayor que 1, que ese cambio se concentra en la zona; menor que 1, que la evita. Las categorías con menos de 10 ha en todo el municipio aparecen en las tablas pero no en las figuras ni en los hallazgos: con tan poca superficie el índice es ruido.

Código
# Superficie, tasa e índice de concentración de cada categoría de cambio por zona
cruce_por_zona <- function(zona) {
  d <- data.frame(zona = zona, cambio = px$cambio, ha = px$ha)
  totales <- d |> group_by(cambio, .drop = FALSE) |> summarise(ha_cat = sum(ha), .groups = "drop")
  d |>
    group_by(zona, cambio, .drop = FALSE) |>
    summarise(ha = sum(ha), .groups = "drop") |>
    group_by(zona) |>
    mutate(ha_zona = sum(ha)) |>
    ungroup() |>
    left_join(totales, by = "cambio") |>
    mutate(pct_zona      = 100 * ha / ha_zona,
           concentracion = (ha / ha_cat) / (ha_zona / area_total_ha))
}

tabla_cruce <- function(cruce, nombre_zona) {
  cruce |>
    dplyr::filter(cambio != "Persistencia", ha_cat > 0) |>
    transmute(zona, ha_zona = round(ha_zona), cambio,
              valor = paste0(format(round(ha, 1), big.mark = ",", trim = TRUE),
                             " (", number(concentracion, 0.01), ")")) |>
    pivot_wider(names_from = cambio, values_from = valor) |>
    rename(!!nombre_zona := zona, "Superficie de la zona (ha)" = ha_zona) |>
    kable(format.args = list(big.mark = ","))
}

figura_cruce <- function(cruce, etiqueta_x) {
  cruce |>
    dplyr::filter(cambio != "Persistencia", ha_cat >= MIN_HA_CATEGORIA) |>
    ggplot(aes(zona, concentracion, colour = cambio, group = cambio)) +
    geom_hline(yintercept = 1, linetype = "dashed", colour = "grey70") +
    geom_line(linewidth = 0.9) + geom_point(size = 2.5) +
    scale_colour_manual(values = paleta_cat, name = NULL) +
    labs(x = etiqueta_x, y = "Índice de concentración") +
    guides(colour = guide_legend(nrow = 2)) +
    tema_oscuro() + theme(legend.position = "bottom")
}
conc <- function(cruce, z, cat) cruce$concentracion[cruce$zona == z & cruce$cambio == cat]

# Lectura continua, que no depende de los cortes de clase: cambio medio de NDVI
# entre la primera y la última fecha en cada zona
delta_por_zona <- function(zona, nombre_zona) {
  data.frame(zona = zona, delta = px$delta, ha = px$ha) |>
    group_by(zona) |>
    summarise(ha = sum(ha), delta_medio = mean(delta), delta_mediano = median(delta),
              pct_cae = 100 * mean(delta <= -0.2), .groups = "drop") |>
    mutate(ha = round(ha), across(c(delta_medio, delta_mediano), ~ round(.x, 3)),
           pct_cae = round(pct_cae, 2)) |>
    setNames(c(nombre_zona, "Superficie (ha)", "Cambio medio de NDVI", "Cambio mediano de NDVI",
               "% de píxeles con caída de 0.2 o más")) |>
    kable(format.args = list(big.mark = ","))
}
Código
# La persistencia se deja transparente para que se vea la imagen de fondo
codigo_cat <- as.integer(px$cambio)
codigo_cat[px$cambio == "Persistencia"] <- NA
cat_mapa <- categorias[-1]
pal_cat <- colorFactor(unname(paleta_cat[cat_mapa]), levels = match(cat_mapa, categorias),
                       na.color = "transparent")
mapa_base() |>
  addRasterImage(a_raster(codigo_cat, "categoria"), colors = pal_cat, method = "ngb",
                 opacity = 0.85, group = "Cambio de vegetación") |>
  addPolylines(data = vias_ll, color = "#cfcfcf", weight = 1, opacity = 0.8, group = "Red vial") |>
  addPolylines(data = vias_ll[vias_ll$jerarquia == "Principal", ], color = "#ffffff", weight = 4,
               opacity = 1, group = "Red vial") |>
  addLegend(colors = unname(paleta_cat[cat_mapa]), labels = cat_mapa, opacity = 1,
            title = "Cambio de vegetación", position = "bottomleft") |>
  control_capas(c("Cambio de vegetación", "Red vial"))
Figura 7: Categorías de cambio y red vial sobre la imagen satelital. Solo se colorean los píxeles que cambiaron; donde hubo persistencia se ve el fondo. Las vías principales, en blanco grueso.

10.1 ¿El cambio sigue a los ejes viales?

Código
etiquetas_banda <- paste0(head(BANDAS_VIA_M, -1), "–", tail(BANDAS_VIA_M, -1), " m")
etiquetas_banda[length(etiquetas_banda)] <- paste0("> ", BANDAS_VIA_M[length(BANDAS_VIA_M) - 1], " m")
banda <- function(d) cut(d, BANDAS_VIA_M, right = FALSE, labels = etiquetas_banda)

cruce_via <- cruce_por_zona(banda(px$dist_via))
tabla_cruce(cruce_via, "Distancia a la vía")
Tabla 7: Cambio según la distancia a la vía más cercana (cualquier jerarquía). En cada celda: hectáreas y, entre paréntesis, índice de concentración.
Distancia a la vía Superficie de la zona (ha) Cicatriz candidata Pérdida de vegetación Ganancia de vegetación
0–100 m 1,715 34.1 (0.48) 251.1 (0.94) 318.2 (1.63)
100–250 m 1,809 18.4 (0.25) 196.7 (0.69) 252.3 (1.22)
250–500 m 2,475 20.8 (0.20) 296.0 (0.76) 271.5 (0.96)
500–1000 m 3,465 54.9 (0.38) 379.8 (0.70) 369.0 (0.93)
> 1000 m 4,334 440.8 (2.47) 1,035.2 (1.53) 361.4 (0.73)
Código
figura_cruce(cruce_via, "Distancia a la vía más cercana")
Figura 8: Índice de concentración de cada categoría de cambio según la distancia a la vía más cercana. Una curva que baja de izquierda a derecha indica un cambio pegado a las vías.
Código
delta_por_zona(banda(px$dist_via), "Distancia a la vía")
Tabla 8: Cambio de NDVI entre la primera y la última fecha según la distancia a la vía más cercana.
Distancia a la vía Superficie (ha) Cambio medio de NDVI Cambio mediano de NDVI % de píxeles con caída de 0.2 o más
0–100 m 1,715 -0.016 -0.002 6.47
100–250 m 1,809 -0.025 -0.002 5.70
250–500 m 2,475 -0.056 -0.007 9.66
500–1000 m 3,465 -0.075 -0.015 11.60
> 1000 m 4,334 -0.134 -0.051 19.81
Código
cruce_principal <- cruce_por_zona(banda(px$dist_principal))
tabla_cruce(cruce_principal, "Distancia a la vía principal")
Tabla 9: Cambio según la distancia a la vía PRINCIPAL más cercana (carreteras primarias, secundarias y terciarias). Hectáreas e índice de concentración.
Distancia a la vía principal Superficie de la zona (ha) Cicatriz candidata Pérdida de vegetación Ganancia de vegetación
0–100 m 158 7.2 (1.10) 35.1 (1.42) 31.9 (1.77)
100–250 m 257 25.5 (2.41) 79.0 (1.97) 34.1 (1.16)
250–500 m 402 24.1 (1.45) 177.2 (2.82) 41.8 (0.91)
500–1000 m 800 63.7 (1.93) 320.5 (2.56) 37.2 (0.41)
> 1000 m 12,180 448.5 (0.89) 1,546.9 (0.81) 1,427.4 (1.03)
Código
cerca <- px$dist_via < 250
zona_jer <- factor(ifelse(cerca, as.character(px$jer_via), "A 250 m o más de cualquier vía"),
                   c(jerarquias, "A 250 m o más de cualquier vía"))
cruce_jer <- cruce_por_zona(droplevels(zona_jer))
tabla_cruce(cruce_jer, "Vía más cercana")
Tabla 10: Cambio a menos de 250 m de una vía, según la jerarquía de esa vía. Hectáreas e índice de concentración.
Vía más cercana Superficie de la zona (ha) Cicatriz candidata Pérdida de vegetación Ganancia de vegetación
Principal 233 17.7 (1.84) 71.3 (1.96) 28.0 (1.05)
Local 1,345 18.7 (0.34) 217.3 (1.03) 279.4 (1.82)
Servicio 209 7.4 (0.86) 29.6 (0.91) 33.5 (1.41)
Brecha o sendero 1,737 8.7 (0.12) 129.5 (0.48) 229.8 (1.16)
A 250 m o más de cualquier vía 10,274 516.5 (1.22) 1,711.0 (1.06) 1,001.9 (0.86)

10.2 ¿Las zonas mal conectadas concentran algún tipo de cambio?

El municipio se divide en cinco partes de igual número de píxeles según su accesibilidad desde la cabecera (costo de tránsito): Q1 es el quinto mejor conectado y Q5 el peor.

Código
cortes_q <- quantile(px$acc_costo, 0:5 / 5)
quintil <- cut(px$acc_costo, cortes_q, include.lowest = TRUE,
               labels = c("Q1 (mejor conectado)", "Q2", "Q3", "Q4", "Q5 (peor conectado)"))
cruce_acc <- cruce_por_zona(quintil)
tabla_cruce(cruce_acc, "Accesibilidad")
Tabla 11: Cambio según la accesibilidad desde la cabecera, en quintiles. Hectáreas e índice de concentración.
Accesibilidad Superficie de la zona (ha) Cicatriz candidata Pérdida de vegetación Ganancia de vegetación
Q1 (mejor conectado) 2,760 16.7 (0.15) 306.2 (0.71) 475.2 (1.51)
Q2 2,759 0.0 (0.00) 64.7 (0.15) 310.8 (0.99)
Q3 2,759 1.2 (0.01) 239.2 (0.55) 295.9 (0.94)
Q4 2,759 38.3 (0.34) 344.5 (0.80) 379.0 (1.21)
Q5 (peor conectado) 2,760 512.8 (4.51) 1,204.2 (2.79) 111.6 (0.35)
Código
figura_cruce(cruce_acc, "Accesibilidad desde la cabecera")
Figura 9: Índice de concentración de cada categoría de cambio según la accesibilidad desde la cabecera.
Código
delta_por_zona(quintil, "Accesibilidad")
Tabla 12: Cambio de NDVI entre la primera y la última fecha según la accesibilidad desde la cabecera.
Accesibilidad Superficie (ha) Cambio medio de NDVI Cambio mediano de NDVI % de píxeles con caída de 0.2 o más
Q1 (mejor conectado) 2,760 -0.001 0.004 4.11
Q2 2,759 0.009 0.008 0.13
Q3 2,759 -0.008 -0.006 0.09
Q4 2,759 -0.023 -0.022 1.82
Q5 (peor conectado) 2,760 -0.356 -0.254 55.95

10.3 Perfil vial de cada categoría de cambio

Código
perfil <- px |>
  group_by(cambio) |>
  summarise(dist_via_m       = median(dist_via),
            dist_principal_m = median(dist_principal),
            acc_km_equiv     = median(acc_costo) / 1000,
            centralidad      = 100 * median(flujo_via),
            delta_medio      = mean(delta),
            ha               = sum(ha),
            .groups = "drop") |>
  relocate(ha, .after = cambio)
perfil |>
  mutate(ha = round(ha, 1), across(c(dist_via_m, dist_principal_m), round),
         acc_km_equiv = round(acc_km_equiv, 1), centralidad = round(centralidad),
         delta_medio = round(delta_medio, 3)) |>
  setNames(c("Categoría de cambio", "ha", "Distancia mediana a una vía (m)",
             "Distancia mediana a una vía principal (m)",
             "Accesibilidad mediana desde la cabecera (km equiv.)",
             "Percentil de centralidad de la vía más cercana", "Cambio medio de NDVI")) |>
  kable(format.args = list(big.mark = ","))
Tabla 13: Perfil vial de cada categoría de cambio (medianas de los píxeles de cada categoría).
Categoría de cambio ha Distancia mediana a una vía (m) Distancia mediana a una vía principal (m) Accesibilidad mediana desde la cabecera (km equiv.) Percentil de centralidad de la vía más cercana Cambio medio de NDVI
Persistencia 9,497.1 557 4,898 21.9 19 -0.022
Cicatriz candidata 569.0 1,587 1,625 64.1 98 -0.630
Pérdida de vegetación 2,158.8 932 2,019 56.7 84 -0.300
Ganancia de vegetación 1,572.4 441 4,326 20.0 19 0.109

10.4 ¿Las cicatrices se acercan o se alejan de las vías con el tiempo?

Cada cicatriz tiene un año de aparición. Si los desmontes avanzaran a lo largo de los caminos, las cicatrices más recientes quedarían cada vez más lejos de la cabecera pero igual de cerca de una vía; si respondieran a otra causa (inundación, costa), no guardarían relación con la red.

Código
hay_cicatrices <- any(px$cambio == "Cicatriz candidata")
if (hay_cicatrices) {
  cic_anio <- px[px$cambio == "Cicatriz candidata", ] |>
    group_by(anio = anio_cic) |>
    summarise(dist_via_m       = median(dist_via),
              dist_principal_m = median(dist_principal),
              acc_km_equiv     = median(acc_costo) / 1000,
              pct_a_250        = 100 * mean(dist_via < 250),
              ha               = sum(ha),
              .groups = "drop") |>
    relocate(ha, .after = anio)
  cic_anio |>
    mutate(anio = as.character(anio), ha = round(ha, 1),
           across(c(dist_via_m, dist_principal_m), round),
           acc_km_equiv = round(acc_km_equiv, 1), pct_a_250 = round(pct_a_250, 1)) |>
    setNames(c("Aparece en", "ha", "Distancia mediana a una vía (m)",
               "Distancia mediana a una vía principal (m)",
               "Accesibilidad mediana desde la cabecera (km equiv.)",
               "% a menos de 250 m de una vía")) |>
    kable(format.args = list(big.mark = ","))
} else {
  cat("El diagnóstico con NDVI no detectó cicatrices candidatas.")
}
Tabla 14: Cicatrices candidatas según su año de aparición y su posición respecto a la red vial.
Aparece en ha Distancia mediana a una vía (m) Distancia mediana a una vía principal (m) Accesibilidad mediana desde la cabecera (km equiv.) % a menos de 250 m de una vía
2003 1.4 1,076 1,076 61.8 0.0
2004 3.6 603 603 58.8 0.0
2005 4.5 947 960 61.6 16.0
2007 52.8 1,062 1,062 65.2 1.5
2008 2.2 1,252 1,252 65.1 0.0
2010 139.2 1,195 1,211 64.1 8.5
2015 59.7 1,624 1,638 65.7 13.4
2020 287.9 2,037 2,104 63.9 6.0
2025 17.8 28 245 55.3 77.8
Código
if (hay_cicatrices) {
  ggplot(px[px$cambio == "Cicatriz candidata", ], aes(factor(anio_cic), dist_via)) +
    geom_boxplot(varwidth = TRUE, fill = "#d7191c", alpha = 0.6, colour = "grey85",
                 outlier.size = 0.4, outlier.colour = "grey70") +
    geom_hline(yintercept = median(px$dist_via), linetype = "dashed", colour = "grey70") +
    scale_y_continuous(labels = label_number(big.mark = ",")) +
    labs(x = "Año de aparición", y = "Distancia a la vía más cercana (m)",
         caption = "Línea discontinua: mediana de todo el municipio") +
    tema_oscuro()
}
Figura 10: Distancia a la vía más cercana de las cicatrices candidatas, por año de aparición. El ancho de cada caja es proporcional a la superficie de cicatriz de ese año.

11 Ejercicio 2: ¿las vías principales son también las más centrales?

Código
umbral_top <- quantile(aristas$flujo, 0.9)
ej2 <- aristas |>
  st_drop_geometry() |>
  group_by(jerarquia) |>
  summarise(tramos = n(), km = sum(longitud) / 1000,
            flujo_mediano = median(flujo), flujo_medio = mean(flujo),
            en_top10 = sum(flujo >= umbral_top), .groups = "drop") |>
  mutate(pct_tramos = 100 * tramos / sum(tramos),
         pct_top10  = 100 * en_top10 / sum(en_top10))

prueba_kw <- kruskal.test(flujo ~ jerarquia, data = st_drop_geometry(aristas))
rho <- suppressWarnings(cor(aristas$transitabilidad, aristas$flujo, method = "spearman"))

ej2 |>
  transmute(jerarquia, tramos, km = round(km, 1), flujo_mediano = round(flujo_mediano),
            flujo_medio = round(flujo_medio), pct_tramos = round(pct_tramos, 1),
            pct_top10 = round(pct_top10, 1)) |>
  setNames(c("Jerarquía", "Tramos", "km", "Intermediación mediana", "Intermediación media",
             "% de los tramos", "% del 10 % más central")) |>
  kable(format.args = list(big.mark = ","))
Tabla 15: Ejercicio 2. Intermediación de las aristas según la jerarquía vial.
Jerarquía Tramos km Intermediación mediana Intermediación media % de los tramos % del 10 % más central
Principal 629 176.5 116,093 227,154 19.9 75.3
Local 1,841 385.4 4,628 27,964 58.3 20.3
Servicio 412 66.5 2,379 9,245 13.0 0.0
Brecha o sendero 275 182.3 2,379 48,456 8.7 4.4
Otra 1 0.1 8,684 8,684 0.0 0.0
Código
ggplot(st_drop_geometry(aristas), aes(jerarquia, flujo + 1, fill = jerarquia)) +
  geom_boxplot(outlier.size = 0.6, show.legend = FALSE, colour = "grey75",
               outlier.colour = "grey60") +
  scale_fill_manual(values = paleta_jer) +
  scale_y_log10(labels = label_number(big.mark = ",")) +
  labs(x = NULL, y = "Intermediación de la arista (+1)") +
  tema_oscuro()
Figura 11: Distribución de la intermediación de aristas por jerarquía vial (escala logarítmica).
Código
fila_p <- ej2[ej2$jerarquia == "Principal", ]
hay_principal <- nrow(fila_p) == 1
txt_ej2 <- if (!hay_principal) {
  "La red no tiene tramos clasificados como principales, así que la comparación no aplica."
} else {
  paste0("Las vías principales son el ", round(fila_p$pct_tramos, 1), " % de los tramos y aportan el ",
         round(fila_p$pct_top10, 1), " % del 10 % de tramos más centrales. Su intermediación mediana es ",
         format(round(fila_p$flujo_mediano), big.mark = ","), ", frente a ",
         format(round(median(aristas$flujo[aristas$jerarquia != "Principal"])), big.mark = ","),
         " del resto de la red. La diferencia entre jerarquías ",
         ifelse(prueba_kw$p.value < 0.05, "es estadísticamente significativa",
                "no es estadísticamente significativa"),
         " (Kruskal-Wallis, p ", ifelse(prueba_kw$p.value < 0.001, "< 0.001",
                                        paste("=", round(prueba_kw$p.value, 3))),
         "), y la correlación de Spearman entre transitabilidad e intermediación es ",
         round(rho, 2), ". ",
         ifelse(fila_p$pct_top10 > 50,
                "Jerarquía y centralidad coinciden en lo esencial: los ejes principales son también los corredores por donde pasaría el flujo.",
                "Jerarquía y centralidad coinciden solo en parte: buena parte de los tramos más centrales no son vías principales, sino calles locales o caminos que funcionan como conectores obligados."))
}

Respuesta. Las vías principales son el 19.9 % de los tramos y aportan el 75.3 % del 10 % de tramos más centrales. Su intermediación mediana es 116,093, frente a 3,072 del resto de la red. La diferencia entre jerarquías es estadísticamente significativa (Kruskal-Wallis, p < 0.001), y la correlación de Spearman entre transitabilidad e intermediación es 0.45. Jerarquía y centralidad coinciden en lo esencial: los ejes principales son también los corredores por donde pasaría el flujo.

12 Ejercicio 3: Moran con distancia de red frente a distancia euclidiana

Se construye una matriz de pesos por distancia de red con st_network_cost() y se compara el Moran’s I con el euclidiano.

La unidad de análisis es el área servida por cada nodo: los píxeles cuyo nodo más cercano es ese. Se usan dos variables: el cambio medio de NDVI de esa área entre la primera y la última fecha, y el porcentaje del área que cambió de clase. El índice de Moran mide si las áreas vecinas se parecen entre sí, y se calcula con spdep (Bivand y Wong 2018). Se construyen dos matrices de pesos con la misma regla (inverso de la distancia, hasta 2,000 m) y lo único que cambia es cómo se mide la distancia entre nodos: en línea recta o por la red.

Código
areas_nodo <- px |>
  group_by(nodo) |>
  summarise(pct_cambio = 100 * sum(ha[cambio != "Persistencia"]) / sum(ha),
            delta_medio = mean(delta),         # cambio medio de NDVI del área del nodo
            ha_nodo    = sum(ha),
            .groups = "drop") |>
  dplyr::filter(ha_nodo >= MIN_HA_NODO)
ids <- areas_nodo$nodo

D_red <- st_network_cost(red, from = ids, to = ids, weights = "longitud")
D_red <- matrix(as.numeric(D_red), nrow = length(ids))
D_euc <- matrix(as.numeric(st_distance(nodos[ids, ])), nrow = length(ids))

# Inverso de la distancia hasta ANCHO_MORAN_M. La distancia se acota por abajo
# en 30 m (un píxel) para que dos nodos casi encimados no dominen los pesos.
matriz_pesos <- function(D) {
  W <- ifelse(is.finite(D) & D <= ANCHO_MORAN_M, 1 / pmax(D, 30), 0)
  diag(W) <- 0
  suppressWarnings(mat2listw(W, style = "W", zero.policy = TRUE))
}
lw_red <- matriz_pesos(D_red)
lw_euc <- matriz_pesos(D_euc)

moran_fila <- function(x, lw, variable, distancia) {
  m <- moran.test(x, lw, zero.policy = TRUE)
  tibble(variable = variable, distancia = distancia,
         I = unname(m$estimate[1]), esperado = unname(m$estimate[2]), p = m$p.value,
         vecinos_medios = mean(card(lw$neighbours)),
         sin_vecinos = sum(card(lw$neighbours) == 0))
}
ej3 <- bind_rows(
  moran_fila(areas_nodo$pct_cambio, lw_euc, "% del área que cambió", "Euclidiana"),
  moran_fila(areas_nodo$pct_cambio, lw_red, "% del área que cambió", "Por la red"),
  moran_fila(areas_nodo$delta_medio, lw_euc, "Cambio medio de NDVI", "Euclidiana"),
  moran_fila(areas_nodo$delta_medio, lw_red, "Cambio medio de NDVI", "Por la red")
)
Código
ej3 |>
  mutate(I = round(I, 3), esperado = round(esperado, 4),
         p = ifelse(p < 0.001, "< 0.001", as.character(round(p, 3))),
         vecinos_medios = round(vecinos_medios, 1)) |>
  setNames(c("Variable", "Distancia", "I de Moran", "I esperado", "p", "Vecinos por nodo (media)",
             "Nodos sin vecinos")) |>
  kable()
Tabla 16: Ejercicio 3. Índice de Moran con pesos por distancia euclidiana y por distancia de red.
Variable Distancia I de Moran I esperado p Vecinos por nodo (media) Nodos sin vecinos
% del área que cambió Euclidiana 0.511 -0.0034 < 0.001 58.8 1
% del área que cambió Por la red 0.505 -0.0035 < 0.001 51.6 4
Cambio medio de NDVI Euclidiana 0.751 -0.0034 < 0.001 58.8 1
Cambio medio de NDVI Por la red 0.749 -0.0035 < 0.001 51.6 4
Código
I_euc <- ej3$I[1]; I_red <- ej3$I[2]
txt_ej3 <- paste0(
  "Con ", length(ids), " áreas de nodo, el I de Moran del porcentaje de cambio es ", round(I_euc, 3),
  " con distancia euclidiana y ", round(I_red, 3), " con distancia de red. ",
  ifelse(abs(I_red - I_euc) < 0.02,
         "Los dos valores son prácticamente iguales: en Ixil, estar cerca en línea recta y estar cerca por la red es casi lo mismo a esta escala, y la red no añade estructura al patrón de cambio.",
         ifelse(I_red > I_euc,
                "La autocorrelación es mayor por la red: los lugares que se parecen en su cambio son los que están conectados entre sí, no solo los que están cerca. El patrón de cambio está organizado por la red vial.",
                "La autocorrelación es mayor en línea recta: el cambio forma manchas continuas en el territorio que no siguen la conectividad vial. El patrón responde más a la vecindad física (tipo de suelo, humedad, cobertura contigua) que a la red.")),
  " La matriz por red tiene menos vecinos por nodo (", round(ej3$vecinos_medios[2], 1), " frente a ",
  round(ej3$vecinos_medios[1], 1), "), porque la distancia por la red nunca es menor que la recta.")

Respuesta. Con 293 áreas de nodo, el I de Moran del porcentaje de cambio es 0.511 con distancia euclidiana y 0.505 con distancia de red. Los dos valores son prácticamente iguales: en Ixil, estar cerca en línea recta y estar cerca por la red es casi lo mismo a esta escala, y la red no añade estructura al patrón de cambio. La matriz por red tiene menos vecinos por nodo (51.6 frente a 58.8), porque la distancia por la red nunca es menor que la recta.

13 Caminos e infraestructura eólica

Los caminos de acceso a los aerogeneradores son demasiado angostos para el píxel de 30 m de la serie de NDVI, pero deberían aparecer en la red vial si están mapeados. Aquí se mide cuánta vía mapeada hay dentro del área de influencia eólica, de qué jerarquía, y a qué distancia queda cada aerogenerador de la vía más cercana.

Código
rutas_eol <- RUTA_EOLICO[file.exists(RUTA_EOLICO)]
hay_eolico <- length(rutas_eol) > 0
if (hay_eolico) {
  eolico <- do.call(rbind, lapply(rutas_eol, function(f) {
    x <- st_read(f, quiet = TRUE) |> st_make_valid() |> st_transform(CRS_METRICO)
    st_sf(archivo = rep(basename(f), nrow(x)), geometry = st_geometry(x))
  }))
  zona_eol <- st_intersection(st_union(st_buffer(eolico, BUFFER_EOLICO_M)), st_geometry(limite_m))
  hay_eolico <- length(zona_eol) > 0 && as.numeric(sum(st_area(zona_eol))) > 0
}
if (hay_eolico) {
  area_eol_km2 <- as.numeric(sum(st_area(zona_eol))) / 1e6
  dentro <- suppressWarnings(st_intersection(vias_mun[, "jerarquia"], zona_eol))
  dentro$km <- as.numeric(st_length(dentro)) / 1000
  tab_eol <- tab_jer |>
    dplyr::select(jerarquia, km_total = km) |>
    left_join(dentro |> st_drop_geometry() |> group_by(jerarquia) |>
                summarise(km_dentro = sum(km), .groups = "drop"), by = "jerarquia") |>
    mutate(km_dentro = replace_na(km_dentro, 0), km_fuera = km_total - km_dentro)
  dens_dentro <- sum(tab_eol$km_dentro) / area_eol_km2
  dens_fuera  <- sum(tab_eol$km_fuera) / (area_mun_km2 - area_eol_km2)

  # Distancia de cada elemento puntual (aerogenerador) a la vía mapeada más cercana
  puntos_eol <- eolico[st_geometry_type(eolico) %in% c("POINT", "MULTIPOINT"), ]
  puntos_eol <- puntos_eol[lengths(st_intersects(puntos_eol, limite_m)) > 0, ]
  dist_aero <- if (nrow(puntos_eol) > 0) {
    as.numeric(st_distance(puntos_eol, calles[st_nearest_feature(puntos_eol, calles), ],
                           by_element = TRUE))
  } else numeric(0)
}
Código
if (hay_eolico) {
  tab_eol |>
    transmute(jerarquia, km_dentro = round(km_dentro, 1), km_fuera = round(km_fuera, 1)) |>
    bind_rows(tibble(jerarquia = "Total", km_dentro = round(sum(tab_eol$km_dentro), 1),
                     km_fuera = round(sum(tab_eol$km_fuera), 1)),
              tibble(jerarquia = "Densidad (km de vía por km²)", km_dentro = round(dens_dentro, 2),
                     km_fuera = round(dens_fuera, 2))) |>
    setNames(c("Jerarquía", "Dentro del área de influencia", "Resto del municipio")) |>
    kable()
} else {
  cat("No se cargó infraestructura eólica dentro del municipio.")
}
Tabla 17: Vías mapeadas dentro y fuera del área de influencia de la infraestructura eólica, dentro del municipio (km).
Jerarquía Dentro del área de influencia Resto del municipio
Principal 0 7.80
Local 0 51.50
Servicio 0 8.60
Brecha o sendero 0 38.60
Total 0 106.40
Densidad (km de vía por km²) 0 0.81
Código
if (hay_eolico && length(dist_aero) > 0) {
  tibble(indicador = c("Aerogeneradores dentro del municipio", "Distancia mínima (m)",
                       "Distancia mediana (m)", "Distancia máxima (m)",
                       "A menos de 100 m de una vía mapeada"),
         valor = c(length(dist_aero), round(min(dist_aero)), round(median(dist_aero)),
                   round(max(dist_aero)), sum(dist_aero < 100))) |>
    kable(col.names = c("Indicador", "Valor"), format.args = list(big.mark = ","))
}
Tabla 18: Distancia de los aerogeneradores situados dentro del municipio a la vía mapeada más cercana.
Indicador Valor
Aerogeneradores dentro del municipio 6
Distancia mínima (m) 804
Distancia mediana (m) 1,180
Distancia máxima (m) 1,895
A menos de 100 m de una vía mapeada 0
Código
if (hay_eolico) {
  tipo_eol <- as.character(st_geometry_type(eolico))
  eol_pts <- st_geometry(eolico)[tipo_eol %in% c("POINT", "MULTIPOINT")]
  eol_pol <- st_geometry(eolico)[tipo_eol %in% c("POLYGON", "MULTIPOLYGON")]
  m <- mapa_base() |>
    addPolygons(data = a_ll(zona_eol), stroke = FALSE, fillColor = "#4db8ff", fillOpacity = 0.3,
                group = "Área de influencia eólica") |>
    addPolylines(data = vias_ll, color = ~pal_jer_ll(jerarquia), opacity = 0.95,
                 weight = ~ifelse(jerarquia == "Principal", 4, 2),
                 popup = ~paste0(jerarquia, " (", type, ")"), group = "Red vial") |>
    addLegend(pal = pal_jer_ll, values = droplevels(vias_ll$jerarquia), title = "Jerarquía vial",
              position = "bottomleft")
  if (length(eol_pol) > 0) {
    m <- addPolygons(m, data = a_ll(suppressWarnings(st_cast(eol_pol, "MULTIPOLYGON"))), fill = FALSE,
                     color = "#ffffff", weight = 2, group = "Infraestructura eólica")
  }
  if (length(eol_pts) > 0) {
    m <- addCircleMarkers(m, data = a_ll(suppressWarnings(st_cast(eol_pts, "POINT"))), radius = 5,
                          color = "#ffffff", fillColor = "#2b83ba", fillOpacity = 1, weight = 1,
                          group = "Infraestructura eólica")
  }
  control_capas(m, c("Red vial", "Área de influencia eólica", "Infraestructura eólica"))
}
Figura 12: Red vial e infraestructura eólica sobre la imagen satelital. En celeste, el área de influencia dentro del municipio; los puntos son los aerogeneradores y el contorno blanco, los polígonos de parque cargados.

14 La red y las cicatrices sobre Google Earth

El mapa reúne la red vial por jerarquía, las cicatrices candidatas del diagnóstico con NDVI (coloreadas por año de aparición), la cabecera usada como origen y la infraestructura eólica, sobre la imagen satelital de Google. Sirve para comprobar a ojo si una cicatriz está junto a un camino y qué es.

Código
m <- mapa_base() |>
  addPolylines(data = vias_ll, color = ~pal_jer_ll(jerarquia), opacity = 0.9,
               weight = ~ifelse(jerarquia == "Principal", 4, 2), popup = ~paste0(jerarquia, " (", type, ")"),
               group = "Red vial") |>
  addLegend(pal = pal_jer_ll, values = droplevels(vias_ll$jerarquia), title = "Jerarquía vial",
            position = "bottomright") |>
  addMarkers(data = a_ll(cabecera), popup = "Cabecera (origen de rutas y accesibilidad)",
             group = "Cabecera")
grupos_mapa <- c("Red vial", "Cabecera")

if (file.exists(RUTA_CIC_GPKG)) {
  cic_ll <- a_ll(st_read(RUTA_CIC_GPKG, quiet = TRUE))
  cic_ll$anio_f <- factor(cic_ll$anio, levels = sort(unique(cic_ll$anio)))
  pal_anio <- colorFactor("YlOrRd", levels = levels(cic_ll$anio_f))
  m <- m |>
    addPolygons(data = cic_ll, color = ~pal_anio(anio_f), weight = 2, opacity = 1, fillOpacity = 0.1,
                popup = ~paste0("<b>Cicatriz que aparece en ", anio, "</b><br>", round(ha, 2), " ha"),
                group = "Cicatrices candidatas") |>
    addLegend(pal = pal_anio, values = cic_ll$anio_f, title = "Cicatriz: aparece en",
              position = "bottomleft")
  grupos_mapa <- c("Cicatrices candidatas", grupos_mapa)
}
if (hay_eolico && nrow(puntos_eol) > 0) {
  m <- addCircleMarkers(m, data = a_ll(suppressWarnings(st_cast(puntos_eol, "POINT"))), radius = 4,
                        color = "#ffffff", fillColor = "#2b83ba", fillOpacity = 1, weight = 1,
                        group = "Aerogeneradores")
  grupos_mapa <- c(grupos_mapa, "Aerogeneradores")
}
control_capas(m, grupos_mapa)
Figura 13: Red vial, cicatrices candidatas e infraestructura eólica sobre la imagen satelital de Google.

15 Validación: índice Kappa

Las secciones anteriores describen dónde ocurre el cambio respecto a la red. Aquí se pone a prueba esa lectura como modelo: si la posición de un píxel respecto a la red vial dijera algo sobre la pérdida de vegetación, debería servir para acertar dónde se perdió en una parte del municipio que el modelo no ha visto.

  1. Qué se predice: si un píxel perdió vegetación entre 2001 y 2025 (categorías: Cicatriz candidata y Pérdida de vegetación).
  2. Con qué: tres variables de la red, y nada más: distancia a la vía más cercana, distancia a la vía principal más cercana y accesibilidad desde la cabecera. Se combinan en una regresión logística.
  3. Cómo se valida: el municipio se divide en bloques de 1,000 m; la mitad, elegida al azar, sirve para calibrar el modelo y la otra mitad para validarlo. Separar por bloques y no por píxeles sueltos evita un acierto inflado: dos píxeles vecinos se parecen tanto que predecir uno con el otro no prueba nada.
  4. Cómo se mide: con el índice Kappa, que indica cuánto del acuerdo entre lo predicho y lo observado supera al esperado por azar, leído con la escala de Landis y Koch (1977).

Kappa no se reporta solo (Pontius y Millones 2011). Se acompaña del desacuerdo de cantidad (se predice más o menos pérdida de la que hubo) y de asignación (la cantidad es correcta pero está en otro sitio), y de un modelo nulo que dice que no se pierde nada: como la pérdida ocupa una parte menor del municipio, ese modelo acierta casi siempre, y su exactitud es la vara con la que hay que medir la del modelo.

Código
set.seed(SEMILLA)

# Bloques espaciales: la mitad calibra y la otra mitad valida
xy_m   <- st_coordinates(px_pts)
bloque <- paste(floor(xy_m[, 1] / TAM_BLOQUE_M), floor(xy_m[, 2] / TAM_BLOQUE_M))
ids_bloque <- unique(bloque)
en_calib   <- bloque %in% sample(ids_bloque, round(length(ids_bloque) / 2))

val <- data.frame(perdio        = px$cambio %in% CATEGORIAS_OBJETIVO,
                  log_via       = log1p(px$dist_via),         # las distancias, en logaritmo:
                  log_principal = log1p(px$dist_principal),   # importa más pasar de 50 a 500 m que de 5,000 a 5,450
                  acc_km        = px$acc_costo / 1000)

# Calibración: regresión logística solo con los bloques de calibración
modelo   <- glm(perdio ~ log_via + log_principal + acc_km, family = binomial, data = val[en_calib, ])
val$prob <- predict(modelo, newdata = val, type = "response")

# Umbral: el que hace que el modelo prediga, en calibración, tanta pérdida como la observada
umbral   <- quantile(val$prob[en_calib], 1 - mean(val$perdio[en_calib]))
val$pred <- val$prob >= umbral

evaluar_bin <- function(pred, obs, prob = NULL) {
  cm <- table(factor(pred, c(FALSE, TRUE)), factor(obs, c(FALSE, TRUE)))
  p  <- cm / sum(cm); fila <- rowSums(p); col <- colSums(p)
  po <- sum(diag(p)); pe <- sum(fila * col)
  cantidad <- sum(abs(fila - col)) / 2
  # Área bajo la curva ROC: capacidad de ordenar los píxeles, sin depender del umbral
  auc <- if (is.null(prob)) NA else {
    n1 <- as.numeric(sum(obs)); n0 <- as.numeric(sum(!obs))
    (sum(rank(prob)[obs]) - n1 * (n1 + 1) / 2) / (n1 * n0)
  }
  data.frame(exactitud = po, kappa = (po - pe) / (1 - pe), cantidad = cantidad,
             asignacion = (1 - po) - cantidad,
             sensibilidad = cm[2, 2] / sum(cm[, 2]), precision = cm[2, 2] / sum(cm[2, ]), auc = auc)
}
en_valid <- !en_calib
tabla_val <- bind_rows(
  "Modelo de red, en calibración" = evaluar_bin(val$pred[en_calib], val$perdio[en_calib], val$prob[en_calib]),
  "Modelo de red, en validación"  = evaluar_bin(val$pred[en_valid], val$perdio[en_valid], val$prob[en_valid]),
  "Modelo nulo (no se pierde nada), en validación" =
    evaluar_bin(rep(FALSE, sum(en_valid)), val$perdio[en_valid]),
  .id = "modelo")

leer_kappa <- function(k) {
  as.character(cut(k, c(-Inf, 0, 0.2, 0.4, 0.6, 0.8, Inf), right = TRUE,
                   labels = c("pobre (no mejor que el azar)", "leve", "aceptable", "moderado",
                              "considerable", "casi perfecto")))
}
Código
data.frame(conjunto = ifelse(en_calib, "Calibración", "Validación"), bloque = bloque,
           ha = px$ha, perdio = val$perdio) |>
  group_by(conjunto) |>
  summarise(bloques = n_distinct(bloque), superficie = sum(ha), perdida = sum(ha[perdio]),
            .groups = "drop") |>
  mutate(pct_perdida = round(100 * perdida / superficie, 1),
         superficie = round(superficie), perdida = round(perdida)) |>
  setNames(c("Conjunto", "Bloques", "Superficie (ha)", "Con pérdida de vegetación (ha)",
             "% con pérdida")) |>
  kable(format.args = list(big.mark = ","))
Tabla 19: Reparto del municipio entre calibración y validación.
Conjunto Bloques Superficie (ha) Con pérdida de vegetación (ha) % con pérdida
Calibración 88 6,720 1,296 19.3
Validación 88 7,077 1,432 20.2
Código
tabla_val |>
  mutate(across(c(exactitud, cantidad, asignacion, sensibilidad, precision), ~ round(100 * .x, 1)),
         across(c(kappa, auc), ~ round(.x, 3)),
         lectura = leer_kappa(kappa)) |>
  setNames(c("Modelo", "Exactitud global (%)", "Kappa", "Desacuerdo de cantidad (%)",
             "Desacuerdo de asignación (%)", "Pérdida real que se detecta (%)",
             "Pérdida predicha que es real (%)", "AUC", "Lectura del Kappa")) |>
  kable()
Tabla 20: Validación del modelo de red vial frente a la pérdida de vegetación observada.
Modelo Exactitud global (%) Kappa Desacuerdo de cantidad (%) Desacuerdo de asignación (%) Pérdida real que se detecta (%) Pérdida predicha que es real (%) AUC Lectura del Kappa
Modelo de red, en calibración 86.5 0.565 0.0 13.5 64.9 64.9 0.817 moderado
Modelo de red, en validación 85.4 0.566 2.3 12.3 69.5 62.5 0.825 moderado
Modelo nulo (no se pierde nada), en validación 79.8 0.000 20.2 0.0 0.0 NaN NA pobre (no mejor que el azar)
Código
etiq <- c("Sin pérdida", "Con pérdida")
cm_ha <- xtabs(ha ~ pred + obs,
               data = data.frame(ha   = px$ha[en_valid],
                                 pred = factor(val$pred[en_valid], c(FALSE, TRUE), etiq),
                                 obs  = factor(val$perdio[en_valid], c(FALSE, TRUE), etiq)))
names(dimnames(cm_ha)) <- c("Predicho", "Observado")
kable(round(addmargins(cm_ha), 1), format.args = list(big.mark = ","))
Tabla 21: Matriz de confusión en los bloques de validación (ha). Filas: predicho; columnas: observado.
Sin pérdida Con pérdida Sum
Sin pérdida 5,048.7 436.5 5,485.2
Con pérdida 596.6 995.5 1,592.1
Sum 5,645.2 1,432.0 7,077.2
Código
coefs <- coef(modelo)[-1]
data.frame(
  variable = c("Distancia a la vía más cercana (logaritmo)",
               "Distancia a la vía principal más cercana (logaritmo)",
               "Accesibilidad desde la cabecera (km equivalentes)"),
  razon = round(exp(coefs), 3),
  sentido = ifelse(coefs > 0, "más lejos o peor conectado: más pérdida",
                   "más lejos o peor conectado: menos pérdida")
) |>
  kable(col.names = c("Variable", "Razón de probabilidades", "Sentido"), row.names = FALSE)
Tabla 22: Sentido de cada variable en el modelo. Una razón de probabilidades mayor que 1 indica que, al aumentar la variable, la pérdida de vegetación se vuelve más probable.
Variable Razón de probabilidades Sentido
Distancia a la vía más cercana (logaritmo) 1.050 más lejos o peor conectado: más pérdida
Distancia a la vía principal más cercana (logaritmo) 0.497 más lejos o peor conectado: menos pérdida
Accesibilidad desde la cabecera (km equivalentes) 1.058 más lejos o peor conectado: más pérdida

Los coeficientes se leen por su sentido, no por su significancia: con decenas de miles de píxeles vecinos y parecidos entre sí, cualquier valor p saldría diminuto sin que eso signifique nada.

Código
pal_prob <- colorNumeric("inferno", domain = c(0, 1), na.color = "transparent")
perdida_obs <- ifelse(val$perdio, 1, NA)
bloques_val <- ifelse(en_valid, 1, NA)
mapa_base() |>
  addRasterImage(a_raster(bloques_val, "validacion"),
                 colors = colorFactor(c("#ffffff", "#ffffff"), levels = 1, na.color = "transparent"),
                 method = "ngb", opacity = 0.25, group = "Bloques de validación") |>
  addRasterImage(a_raster(val$prob, "probabilidad"), colors = pal_prob, opacity = 0.75,
                 group = "Probabilidad según la red") |>
  addRasterImage(a_raster(perdida_obs, "observada"),
                 colors = colorFactor(c("#00e5ff", "#00e5ff"), levels = 1, na.color = "transparent"),
                 method = "ngb", opacity = 0.9, group = "Pérdida observada") |>
  addPolylines(data = vias_ll, color = "#ffffff", weight = 1, opacity = 0.8, group = "Red vial") |>
  addLegend(pal = pal_prob, values = c(0, 1), title = "Probabilidad de pérdida", position = "bottomleft") |>
  addLegend(colors = "#00e5ff", labels = "Pérdida observada", opacity = 1, position = "bottomleft") |>
  control_capas(c("Pérdida observada", "Probabilidad según la red", "Bloques de validación", "Red vial")) |>
  hideGroup("Bloques de validación")
Figura 14: Probabilidad de pérdida de vegetación según el modelo de red vial y pérdida realmente observada, sobre la imagen satelital. En tono claro, los bloques usados para validar.
Código
kv <- tabla_val[2, ]; kc <- tabla_val[1, ]; kn <- tabla_val[3, ]
txt_valid <- paste0(
  "Con solo tres variables de la red vial, el modelo predice la pérdida de vegetación en los bloques de validación con un Kappa de ",
  fmt(kv$kappa, 3), " (acuerdo ", leer_kappa(kv$kappa), "), frente a ", fmt(kc$kappa, 3),
  " en los bloques con que se calibró. ",
  ifelse(kc$kappa - kv$kappa > 0.1,
         "La caída entre uno y otro indica que parte de lo aprendido es propio de los bloques de calibración y no se traslada al resto del municipio. ",
         "Que los dos valores sean parecidos indica que la relación se mantiene en zonas que el modelo no había visto. "),
  "Detecta el ", fmt(100 * kv$sensibilidad, 1), " % de la pérdida real, y el ", fmt(100 * kv$precision, 1),
  " % de la pérdida que predice es real. Su exactitud global es de ", fmt(100 * kv$exactitud, 1),
  " %, frente a ", fmt(100 * kn$exactitud, 1), " % del modelo nulo: ",
  ifelse(kv$exactitud > kn$exactitud,
         "supera a la hipótesis de que no se pierde nada.",
         "no supera en exactitud a la hipótesis de que no se pierde nada, aunque, a diferencia de ella, sí localiza una parte de la pérdida."),
  " El error se reparte en ", fmt(100 * kv$cantidad, 1), " % de cantidad y ", fmt(100 * kv$asignacion, 1),
  " % de asignación. El área bajo la curva ROC es ", fmt(kv$auc, 3),
  " (0.5 equivale a ordenar los píxeles al azar y 1 a ordenarlos sin error).")

Lectura. Con solo tres variables de la red vial, el modelo predice la pérdida de vegetación en los bloques de validación con un Kappa de 0.566 (acuerdo moderado), frente a 0.565 en los bloques con que se calibró. Que los dos valores sean parecidos indica que la relación se mantiene en zonas que el modelo no había visto. Detecta el 69.5 % de la pérdida real, y el 62.5 % de la pérdida que predice es real. Su exactitud global es de 85.4 %, frente a 79.8 % del modelo nulo: supera a la hipótesis de que no se pierde nada. El error se reparte en 2.3 % de cantidad y 12.3 % de asignación. El área bajo la curva ROC es 0.825 (0.5 equivale a ordenar los píxeles al azar y 1 a ordenarlos sin error).

Este Kappa valida una asociación espacial, no una causa. Un valor alto puede deberse a que la pérdida se concentra en la franja costera, que además es la zona peor conectada: el modelo acertaría por la geografía y no porque los caminos expliquen la pérdida. El resultado depende también del tamaño de los bloques (TAM_BLOQUE_M), del sorteo (SEMILLA) y del umbral con que la probabilidad se convierte en sí o no; conviene repetirlo con otros valores antes de darlo por firme.

16 Principales hallazgos

Código
fmt <- function(x, d = 0) format(round(x, d), big.mark = ",", nsmall = d, trim = TRUE)
pct_jer <- function(j) { p <- tab_jer$pct[tab_jer$jerarquia == j]; if (length(p) == 0) 0 else p }

# Categoría (distinta de persistencia) más concentrada en una zona dada
mas_concentrada <- function(cruce, z) {
  d <- cruce |> dplyr::filter(zona == z, cambio != "Persistencia", ha_cat >= MIN_HA_CATEGORIA) |>
    arrange(desc(concentracion))
  list(cat = as.character(d$cambio[1]), conc = d$concentracion[1], ha = d$ha[1])
}
b1 <- etiquetas_banda[1]; bn <- etiquetas_banda[length(etiquetas_banda)]
cerca_via <- mas_concentrada(cruce_via, b1)
lejos_via <- mas_concentrada(cruce_via, bn)
q5 <- mas_concentrada(cruce_acc, "Q5 (peor conectado)")
q1 <- mas_concentrada(cruce_acc, "Q1 (mejor conectado)")

# Cicatrices candidatas: dónde están respecto a la red y cómo se mueven en el tiempo
cic_total <- sum(px$ha[px$cambio == "Cicatriz candidata"])
txt_cic <- if (hay_cicatrices) {
  fila_cic <- perfil[perfil$cambio == "Cicatriz candidata", ]
  paste0("Las cicatrices candidatas (", fmt(cic_total, 1), " ha) tienen un índice de concentración de ",
         fmt(conc(cruce_via, b1, "Cicatriz candidata"), 2), " en la banda de ", b1, ", de ",
         fmt(conc(cruce_via, bn, "Cicatriz candidata"), 2), " en la de ", bn, " y de ",
         fmt(conc(cruce_acc, "Q5 (peor conectado)", "Cicatriz candidata"), 2),
         " en el quintil peor conectado. Su distancia mediana a una vía es de ",
         fmt(fila_cic$dist_via_m), " m, frente a ", fmt(median(px$dist_via)),
         " m del conjunto del municipio.")
} else "El diagnóstico con NDVI no detectó cicatrices candidatas."

txt_cic_anio <- if (hay_cicatrices && nrow(cic_anio) >= 3) {
  ct <- suppressWarnings(cor.test(px$anio_cic[px$cambio == "Cicatriz candidata"],
                                  px$dist_via[px$cambio == "Cicatriz candidata"], method = "spearman"))
  pico <- cic_anio[which.max(cic_anio$ha), ]
  paste0("La fecha con más superficie nueva de cicatriz es ", pico$anio, " (", fmt(pico$ha, 1),
         " ha), a una distancia mediana de ", fmt(pico$dist_via_m), " m de una vía. La distancia a la vía ",
         ifelse(abs(ct$estimate) < 0.1, "no cambia de forma apreciable con el año de aparición",
                ifelse(ct$estimate > 0, "aumenta con el año de aparición: las cicatrices recientes están más lejos de las vías",
                       "disminuye con el año de aparición: las cicatrices recientes están más cerca de las vías")),
         " (correlación de Spearman ", fmt(unname(ct$estimate), 2), "). El ",
         fmt(100 * mean(px$dist_via[px$cambio == "Cicatriz candidata"] < 250), 1),
         " % de la superficie de cicatrices está a menos de 250 m de una vía mapeada.")
} else "No hay suficientes fechas con cicatrices para leer su evolución respecto a la red."

txt_eolico <- if (hay_eolico) {
  km_eol <- sum(tab_eol$km_dentro)
  paste0(
    "Dentro del área de influencia eólica (", fmt(area_eol_km2, 1), " km² del municipio) hay ",
    fmt(km_eol, 1), " km de vías mapeadas: ", fmt(dens_dentro, 2), " km/km², frente a ",
    fmt(dens_fuera, 2), " km/km² en el resto del municipio.",
    if (km_eol > 0) paste0(" El ", fmt(100 * sum(tab_eol$km_dentro[tab_eol$jerarquia %in%
                             c("Servicio", "Brecha o sendero")]) / km_eol, 1),
                           " % son caminos de servicio o brechas.") else "",
    if (length(dist_aero) > 0) paste0(
      " De los ", length(dist_aero), " aerogeneradores situados dentro del municipio, ",
      sum(dist_aero < 100), " están a menos de 100 m de una vía mapeada; la distancia mediana es de ",
      fmt(median(dist_aero)), " m.",
      if (median(dist_aero) > 250) paste0(
        " Un aerogenerador construido necesita un camino de acceso a pie de torre, así que esta ",
        "distancia indica una de dos cosas: los caminos del parque no están dibujados en ",
        "OpenStreetMap") else "") else "")
} else "No se cargó infraestructura eólica."

# Ruta cabecera–costa: ¿hay alternativa a la ruta más corta?
desvio_m <- 1000 * (resumen$longitud_km[2] - resumen$longitud_km[1])
txt_ruta <- if (desvio_m < 1) {
  paste0("La ruta más transitable coincide con la más corta: ",
         ifelse(resumen$pct_brecha[1] > 0,
                "no hay forma de evitar el tramo de brecha sin salir de la red analizada.",
                "la ruta más corta ya va por vías transitables."))
} else {
  paste0("La ruta más transitable mide ", fmt(resumen$longitud_km[2], 1), " km y recorre un ",
         fmt(resumen$pct_brecha[2], 1), " % por brecha: evitar las brechas alarga el viaje ",
         fmt(desvio_m), " m.")
}

1. Estructura de la red. Ixil tiene 106.4 km de vías mapeadas (0.78 km por km²). El 7.3 % son vías principales y el 36.2 % brechas o senderos. El 26.6 % de los nodos no tiene salida y el índice alfa es 0.16: una red con cierto grado de malla y rutas alternativas.

2. Cabecera–costa. La ruta más corta mide 28.2 km (circuidad 1.50) y recorre un 69.8 % por brecha. La ruta más transitable mide 29.7 km y recorre un 58.6 % por brecha: evitar las brechas alarga el viaje 1,512 m.

3. Accesibilidad (ejercicio 1). Penalizar la mala transitabilidad reduce el área alcanzable desde la cabecera en 47.3 % con el umbral de 5,000 m.

4. Cambio y ejes viales. A menos de 100 m de una vía, el cambio más concentrado es Ganancia de vegetación (índice 1.63). En la banda más alejada (> 1000 m), el más concentrado es Cicatriz candidata (índice 2.47).

5. Zonas mal conectadas. En el quintil peor conectado, el cambio más concentrado es Cicatriz candidata (índice 4.51, 512.8 ha). En el mejor conectado, Ganancia de vegetación (índice 1.51).

6. Cicatrices y red vial. Las cicatrices candidatas (569.0 ha) tienen un índice de concentración de 0.48 en la banda de 0–100 m, de 2.47 en la de > 1000 m y de 4.51 en el quintil peor conectado. Su distancia mediana a una vía es de 1,587 m, frente a 601 m del conjunto del municipio.

7. Cicatrices en el tiempo. La fecha con más superficie nueva de cicatriz es 2020 (287.9 ha), a una distancia mediana de 2,037 m de una vía. La distancia a la vía aumenta con el año de aparición: las cicatrices recientes están más lejos de las vías (correlación de Spearman 0.47). El 9.2 % de la superficie de cicatrices está a menos de 250 m de una vía mapeada.

8. Jerarquía y centralidad (ejercicio 2). Las vías principales son el 19.9 % de los tramos y aportan el 75.3 % del 10 % de tramos más centrales. Su intermediación mediana es 116,093, frente a 3,072 del resto de la red. La diferencia entre jerarquías es estadísticamente significativa (Kruskal-Wallis, p < 0.001), y la correlación de Spearman entre transitabilidad e intermediación es 0.45. Jerarquía y centralidad coinciden en lo esencial: los ejes principales son también los corredores por donde pasaría el flujo.

9. Estructura espacial del cambio (ejercicio 3). Con 293 áreas de nodo, el I de Moran del porcentaje de cambio es 0.511 con distancia euclidiana y 0.505 con distancia de red. Los dos valores son prácticamente iguales: en Ixil, estar cerca en línea recta y estar cerca por la red es casi lo mismo a esta escala, y la red no añade estructura al patrón de cambio. La matriz por red tiene menos vecinos por nodo (51.6 frente a 58.8), porque la distancia por la red nunca es menor que la recta.

10. Infraestructura eólica. Dentro del área de influencia eólica (4.9 km² del municipio) hay 0.0 km de vías mapeadas: 0.00 km/km², frente a 0.81 km/km² en el resto del municipio. De los 6 aerogeneradores situados dentro del municipio, 0 están a menos de 100 m de una vía mapeada; la distancia mediana es de 1,180 m. Un aerogenerador construido necesita un camino de acceso a pie de torre, así que esta distancia indica una de dos cosas: los caminos del parque no están dibujados en OpenStreetMap

11. Validación (índice Kappa). Con solo tres variables de la red vial, el modelo predice la pérdida de vegetación en los bloques de validación con un Kappa de 0.566 (acuerdo moderado), frente a 0.565 en los bloques con que se calibró. Que los dos valores sean parecidos indica que la relación se mantiene en zonas que el modelo no había visto. Detecta el 69.5 % de la pérdida real, y el 62.5 % de la pérdida que predice es real. Su exactitud global es de 85.4 %, frente a 79.8 % del modelo nulo: supera a la hipótesis de que no se pierde nada. El error se reparte en 2.3 % de cantidad y 12.3 % de asignación. El área bajo la curva ROC es 0.825 (0.5 equivale a ordenar los píxeles al azar y 1 a ordenarlos sin error).

17 Verificaciones

Código
data.frame(
  control = c("Fuente de la red vial",
              paste0("Tramos y km en la zona (municipio + margen de ", BUFFER_RED_M, " m)"),
              "Nodos y aristas tras la limpieza topológica",
              "Longitud conservada en el componente principal (%)",
              "Píxeles del mapa de cambio enlazados a la red",
              "Superficie del mapa de cambio (ha) frente al polígono (ha)",
              "Ruta cabecera–costa: metros que faltan hasta la vía mapeada más al norte",
              "Píxeles a más de 2 km de cualquier vía (%)"),
  resultado = c(fuente_red,
                paste0(fmt(nrow(calles)), " tramos; ", fmt(km_bruto, 1), " km"),
                paste0(fmt(V), " nodos; ", fmt(E), " aristas"),
                fmt(100 * km_limpio / km_bruto, 1),
                paste0(fmt(nrow(px)), " de ", fmt(length(celdas))),
                paste0(fmt(area_total_ha), " / ", fmt(100 * area_mun_km2)),
                fmt(max(falta_norte_m, 0)),
                fmt(100 * mean(px$dist_via > 2000), 1))
) |> kable(col.names = c("Control", "Resultado"))
Tabla 23: Controles de consistencia de la lectura de red.
Control Resultado
Fuente de la red vial OpenStreetMap, descargada el 07/10/2026
Tramos y km en la zona (municipio + margen de 5000 m) 1,591 tramos; 837.5 km
Nodos y aristas tras la limpieza topológica 2,380 nodos; 3,158 aristas
Longitud conservada en el componente principal (%) 96.8
Píxeles del mapa de cambio enlazados a la red 153,431 de 153,431
Superficie del mapa de cambio (ha) frente al polígono (ha) 13,797 / 13,687
Ruta cabecera–costa: metros que faltan hasta la vía mapeada más al norte 0
Píxeles a más de 2 km de cualquier vía (%) 8.7

Si la longitud conservada baja mucho de 90 %, la capa de vías tiene partes sueltas que el grafo descarta (aparecen punteadas en el mapa de la red). Suele pasar cuando a la capa le faltan los caminos que las unen, por ejemplo las brechas: la accesibilidad, las rutas y la centralidad dejan entonces de representar esas zonas.

18 Referencias

Los datos de la red vial son © colaboradores de OpenStreetMap, bajo licencia ODbL.

Bivand, Roger S., y David W. S. Wong. 2018. «Comparing Implementations of Global and Local Indicators of Spatial Association». TEST 27 (3): 716-48. https://doi.org/10.1007/s11749-018-0599-x.
Freeman, Linton C. 1977. «A Set of Measures of Centrality Based on Betweenness». Sociometry 40 (1): 35-41. https://doi.org/10.2307/3033543.
Landis, J. Richard, y Gary G. Koch. 1977. «The Measurement of Observer Agreement for Categorical Data». Biometrics 33 (1): 159-74. https://doi.org/10.2307/2529310.
Meer, Lucas van der, Lorena Abad, Andrea Gilardi, y Robin Lovelace. 2024. sfnetworks: Tidy Geospatial Networks. https://luukvdmeer.github.io/sfnetworks/.
Padgham, Mark, Bob Rudis, Robin Lovelace, y Maëlle Salmon. 2017. «osmdata». Journal of Open Source Software 2 (14): 305. https://doi.org/10.21105/joss.00305.
Pontius, Robert Gilmore, y Marco Millones. 2011. «Death to Kappa: Birth of Quantity Disagreement and Allocation Disagreement for Accuracy Assessment». International Journal of Remote Sensing 32 (15): 4407-29. https://doi.org/10.1080/01431161.2011.552923.
Código
sessionInfo()
R version 4.4.3 (2025-02-28 ucrt)
Platform: x86_64-w64-mingw32/x64
Running under: Windows 11 x64 (build 26200)

Matrix products: default


locale:
[1] LC_COLLATE=Spanish_Mexico.utf8  LC_CTYPE=Spanish_Mexico.utf8   
[3] LC_MONETARY=Spanish_Mexico.utf8 LC_NUMERIC=C                   
[5] LC_TIME=Spanish_Mexico.utf8    

time zone: America/Bogota
tzcode source: internal

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

other attached packages:
 [1] scales_1.4.0     knitr_1.52       leaflet_2.2.3    spdep_1.4-2     
 [5] spData_2.3.5     httr2_1.3.0      osmdata_0.3.0    terra_1.9-11    
 [9] lubridate_1.9.5  forcats_1.0.1    stringr_1.6.0    dplyr_1.2.1     
[13] purrr_1.2.2      readr_2.2.0      tidyr_1.3.2      tibble_3.3.1    
[17] ggplot2_4.0.3    tidyverse_2.0.0  tidygraph_1.3.1  sfnetworks_0.6.6
[21] sf_1.1-0        

loaded via a namespace (and not attached):
 [1] tidyselect_1.2.1        viridisLite_0.4.3       farver_2.1.2           
 [4] S7_0.2.2                fastmap_1.2.0           spatstat.geom_3.7-3    
 [7] spatstat.explore_3.8-0  pacman_0.5.1            digest_0.6.39          
[10] rpart_4.1.24            timechange_0.4.0        lifecycle_1.0.5        
[13] spatstat.data_3.1-9     magrittr_2.0.5          compiler_4.4.3         
[16] rlang_1.3.0             tools_4.4.3             utf8_1.2.6             
[19] igraph_2.3.0            yaml_2.3.12             labeling_0.4.3         
[22] htmlwidgets_1.6.4       sp_2.2-1                classInt_0.4-11        
[25] RColorBrewer_1.1-3      abind_1.4-8             KernSmooth_2.23-26     
[28] withr_3.0.3             grid_4.4.3              polyclip_1.10-7        
[31] e1071_1.7-17            spatstat.utils_3.2-2    cli_3.6.6              
[34] rmarkdown_2.32          crayon_1.5.3            generics_0.1.4         
[37] otel_0.2.0              rstudioapi_0.19.0       tzdb_0.5.0             
[40] DBI_1.3.0               proxy_0.4-29            splines_4.4.3          
[43] spatstat.model_3.7-0    s2_1.1.9                vctrs_0.7.3            
[46] boot_1.3-31             Matrix_1.7-2            jsonlite_2.0.0         
[49] hms_1.1.4               tensor_1.5.1            crosstalk_1.2.2        
[52] jquerylib_0.1.4         spatstat.univar_3.1-7   units_1.0-1            
[55] goftest_1.2-3           glue_1.8.1              spatstat.random_3.4-5  
[58] lwgeom_0.2-15           leaflet.providers_3.0.0 codetools_0.2-20       
[61] stringi_1.8.7           gtable_0.3.6            deldir_2.0-4           
[64] pillar_1.11.1           htmltools_0.5.9         R6_2.6.1               
[67] wk_0.9.5                sfheaders_0.4.5         evaluate_1.0.5         
[70] lattice_0.22-6          png_0.1-9               class_7.3-23           
[73] Rcpp_1.1.1              spatstat.linnet_3.5-0   nlme_3.1-167           
[76] spatstat.sparse_3.1-0   mgcv_1.9-1              xfun_0.57              
[79] pkgconfig_2.0.3