Diagnóstico del cambio de cobertura vegetal en Ixil (Yucatán, México)

Serie de NDVI Landsat y Sentinel-2 (2001–2025): matriz de transición observada, cicatrices de proyectos eólicos y propiedad ejidal

Autor/a

Carla Machicado Sanchez

Fecha de publicación

12 de octubre de 2026

1 Preparación del entorno

Código
paquetes <- c("terra", "sf", "dplyr", "tidyr", "ggplot2", "knitr", "scales", "leaflet")
faltantes <- paquetes[!paquetes %in% rownames(installed.packages())]
if (length(faltantes) > 0) install.packages(faltantes)
invisible(lapply(paquetes, library, character.only = TRUE))

terraOptions(progress = 0)
fmt <- function(x, d = 0) format(round(x, d), big.mark = ",", nsmall = d, trim = TRUE)
pct <- function(x, d = 1) paste0(fmt(100 * x, d), " %")

# 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())

El procesamiento espacial se hace con terra (Hijmans 2025) y sf (Pebesma 2018), y los mapas interactivos con leaflet (Cheng et al. 2024).

1.1 Parámetros del análisis

Código
# Límite municipal de Ixil
RUTA_SHP_IXIL <- "G:/FLACSO/SEMILLERO_ PAPER EJIDOS MEXICO/DATOS ESPACIALES/ixil_muni.shp"
MUNICIPIO <- "Ixil"

# En este paso las imagenes ya tienen la banda calculada de ndvi
DIR_IMAGENES <- "G:/flacso/modulo_5/modelacion_cienciadatos_prospecterr/trabajo final/img"
imagenes <- tribble(
  ~anio, ~sensor,       ~archivo,                                       ~banda_nir, ~banda_roja,
  2001,  "Landsat 5/7", "ndvi_landsat30m_en_grilla10m_ixil_2001.tif",   NA,         NA,
  2003,  "Landsat 5/7", "ndvi_landsat30m_en_grilla10m_ixil_2003.tif",   NA,         NA,
  2004,  "Landsat 5/7", "ndvi_landsat30m_en_grilla10m_ixil_2004.tif",   NA,         NA,
  2005,  "Landsat 5/7", "ndvi_landsat30m_en_grilla10m_ixil_2005.tif",   NA,         NA,
  2007,  "Landsat 5/7", "ndvi_landsat30m_en_grilla10m_ixil_2007.tif",   NA,         NA,
  2008,  "Landsat 5/7", "ndvi_landsat30m_en_grilla10m_ixil_2008.tif",   NA,         NA,
  2010,  "Landsat 5/7", "ndvi_landsat30m_en_grilla10m_ixil_2010.tif",   NA,         NA,
  2015,  "Landsat 8",   "ndvi_landsat30m_en_grilla10m_ixil_2015.tif",   NA,         NA,
  2020,  "Sentinel-2",  "ndvi_sentinel2_10m_ixil_2020.tif",             NA,         NA,
  2025,  "Sentinel-2",  "ndvi_sentinel2_10m_ixil_2025.tif",             NA,         NA
)
SIN_DATO <- -32768          # valor "sin dato" de los archivos exportados

# Resolución del análisis (m). Landsat tiene 30 m reales, así que toda la serie se
# analiza a 30 m; Sentinel-2 se agrega a esa grilla. En el mapa interactivo,
# Sentinel-2 se muestra a sus 10 m originales.
RES_ANALISIS <- 30

# Normalización entre fechas (ver la sección correspondiente). El año de
# referencia es la primera fecha disponible salvo que se indique otro.
NORMALIZAR      <- TRUE
ANIO_REFERENCIA <- NULL

# Clases de NDVI para la matriz de transición
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")
paleta <- setNames(c("#a50026", "#fdae61", "#a6d96a", "#1a9850"), CLASES_NDVI)

# Definición de cicatriz candidata (sobre el NDVI normalizado)
UMBRAL_VEGETADO <- 0.50     # NDVI mínimo ANTES para considerar que había vegetación
UMBRAL_DESMONTE <- 0.35     # NDVI máximo DESPUÉS, en todas las fechas posteriores
CAIDA_MINIMA    <- 0.20     # caída mínima entre antes y después
MIN_HA_PARCHE   <- 0.27     # parches menores se descartan (0.27 ha = 3 píxeles de 30 m)

# Validación con el índice Kappa: tres fechas separadas por el mismo intervalo
# (calibración inicial, calibración final y fecha a predecir). NULL = se eligen
# solas: la terna que termina en la fecha más reciente y, entre esas, la de
# intervalo más largo. Para fijarlas: c(2005, 2015, 2025).
ANIOS_VALIDACION <- NULL

# Capas opcionales. Si la ruta no existe, la sección se omite y el resto corre igual.
# - Tenencia: polígonos de tierras ejidales (aquí, tierras de uso común del RAN).
# - Infraestructura eólica: puntos, líneas o polígonos, en una o varias rutas.
RUTA_EJIDOS     <- "G:/FLACSO/SEMILLERO_ PAPER EJIDOS MEXICO/DATOS ESPACIALES/Acaparamiento ilegal (empresarios) cut.shp"
CAMPO_EJIDO     <- NULL     # campo con el nombre de cada polígono (NULL = numerar)
ETIQUETA_EJIDOS <- "Tierras ejidales (capa cargada)"
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      # área de influencia alrededor de la infraestructura (m)

# Línea de transmisión eléctrica (opcional): se mide si las cicatrices se le sobreponen
RUTA_LINEA     <- "G:/FLACSO/SEMILLERO_ PAPER EJIDOS MEXICO/DATOS ESPACIALES/Línea de transmisión eléctrica.shp"
BUFFER_LINEA_M <- 100       # corredor a cada lado de la línea (m)
BANDAS_LINEA_M <- c(0, 100, 250, 500, 1000, 3000, Inf)   # bandas de distancia a la línea (m)

# Costa. La franja costera es la parte del municipio a ANCHO_COSTA_M o menos de la
# línea de costa. RUTA_COSTA = NULL toma la costa del propio límite municipal (su
# borde norte, que da al mar); para usar otra, indica aquí un shapefile de líneas.
RUTA_COSTA     <- NULL
ANCHO_COSTA_M  <- 4000
BANDAS_COSTA_M <- c(0, 1000, 2000, 3000, 4000, 5000, Inf)  # bandas de distancia a la costa (m)

# Proyección de Markov y escenarios. Cada escenario multiplica por un factor la
# probabilidad de que un píxel pase a cicatriz: 1 = la tendencia observada sigue
# igual; 2 = la pérdida permanente se duplica; 0.5 = se reduce a la mitad.
ESCENARIOS <- c("Tendencial" = 1, "Disruptivo" = 2, "De política" = 0.5, "De política, fuerte" = 0)
PASOS_PROYECCION <- 2                                   # periodos hacia adelante
SENSIBILIDAD_K   <- c(0, 0.25, 0.5, 0.75, 1, 1.5, 2, 3) # factores para el análisis de sensibilidad

CRS_METRICO <- "EPSG:32616" # UTM zona 16 Norte (Yucatán)

dir_salidas <- "salidas_ixil_ndvi"
dir.create(dir_salidas, showWarnings = FALSE, recursive = TRUE)

2 Límite municipal de Ixil

Código
if (!file.exists(RUTA_SHP_IXIL)) {
  stop("No se encontró el shapefile de Ixil en '", RUTA_SHP_IXIL, "'.")
}
limite <- st_read(RUTA_SHP_IXIL, quiet = TRUE) |> st_make_valid()
if (is.na(st_crs(limite))) stop("El shapefile no tiene sistema de referencia (falta el .prj).")

limite_m <- st_sf(municipio = MUNICIPIO,
                  geometry = st_union(st_geometry(st_transform(limite, CRS_METRICO))))
limite_v <- vect(limite_m)
area_limite_ha <- as.numeric(st_area(limite_m)) / 1e4
bb <- round(st_bbox(st_transform(limite_m, 4326)), 3)

cat("Superficie del polígono:", fmt(area_limite_ha), "ha\n",
    "Extensión (lon/lat):", paste(names(bb), bb, collapse = "  "))
Superficie del polígono: 13,687 ha
 Extensión (lon/lat): xmin -89.522  ymin 21.134  xmax -89.414  ymax 21.323

3 Datos: imágenes Landsat y Sentinel-2

Código
if (!dir.exists(DIR_IMAGENES)) {
  stop("La carpeta de imágenes no existe: '", DIR_IMAGENES, "'. Revisa que la ruta esté bien escrita.")
}
imagenes <- imagenes |>
  mutate(ruta   = ifelse(file.exists(archivo), archivo, file.path(DIR_IMAGENES, archivo)),
         existe = file.exists(ruta)) |>
  arrange(anio)
faltan <- imagenes[!imagenes$existe, ]
disp   <- imagenes[imagenes$existe, ]
if (nrow(disp) < 2) {
  stop("Se necesitan al menos dos imágenes. Revisa DIR_IMAGENES y la tabla 'imagenes'.")
}
anios <- disp$anio
anios_chr <- as.character(anios)

# Lee un archivo y devuelve el NDVI (-1 a 1) recortado al municipio, en CRS métrico
leer_ndvi <- function(ruta, nir, rojo) {
  r <- rast(ruta)
  if (nlyr(r) == 1) {
    x <- subst(r, SIN_DATO, NA)
    # Los archivos exportados guardan NDVI x 10000 como entero
    if (global(abs(x), "max", na.rm = TRUE)[1, 1] > 1.5) x <- x / 10000
  } else {
    if (is.na(nir) || is.na(rojo)) {
      stop("'", basename(ruta), "' tiene ", nlyr(r), " bandas: indica banda_nir y banda_roja.")
    }
    x <- (r[[nir]] - r[[rojo]]) / (r[[nir]] + r[[rojo]])
  }
  if (!same.crs(x, CRS_METRICO)) x <- project(x, CRS_METRICO, method = "bilinear")
  x <- crop(x, limite_v, snap = "out")
  names(x) <- "ndvi"
  x
}
nativos <- lapply(seq_len(nrow(disp)), function(i) {
  leer_ndvi(disp$ruta[i], disp$banda_nir[i], disp$banda_roja[i])
})
names(nativos) <- anios_chr
disp$pixel_m <- vapply(nativos, function(x) res(x)[1], 0)

# Grilla común de análisis: se agrega cada imagen a RES_ANALISIS con la media
agregar <- function(x) {
  f <- max(1, round(RES_ANALISIS / res(x)[1]))
  if (f > 1) aggregate(x, fact = f, fun = "mean", na.rm = TRUE) else x
}
plantilla <- agregar(nativos[[1]])
a_grilla <- function(x) {
  x <- agregar(x)
  if (!compareGeom(x, plantilla, stopOnError = FALSE)) x <- resample(x, plantilla, method = "bilinear")
  mask(x, limite_v)
}
ndvi_bruto <- rast(lapply(nativos, a_grilla))
names(ndvi_bruto) <- anios_chr
ha_pixel <- prod(res(plantilla)) / 1e4
en_mun   <- !is.na(values(rasterize(limite_v, plantilla))[, 1])
Código
M_bruto <- values(ndvi_bruto)
resumen_img <- data.frame(
  anio    = anios,
  sensor  = disp$sensor,
  pixel   = disp$pixel_m,
  p10     = apply(M_bruto[en_mun, , drop = FALSE], 2, quantile, 0.10, na.rm = TRUE),
  mediana = apply(M_bruto[en_mun, , drop = FALSE], 2, median, na.rm = TRUE),
  p90     = apply(M_bruto[en_mun, , drop = FALSE], 2, quantile, 0.90, na.rm = TRUE),
  sin_dato = 100 * colMeans(is.na(M_bruto[en_mun, , drop = FALSE]))
)
resumen_img |>
  mutate(across(c(p10, mediana, p90), ~ round(.x, 3)), sin_dato = round(sin_dato, 2)) |>
  setNames(c("Año", "Sensor", "Píxel del archivo (m)", "NDVI percentil 10", "NDVI mediana",
             "NDVI percentil 90", "% sin dato")) |>
  kable(row.names = FALSE)
Tabla 1: Imágenes usadas y resumen del NDVI dentro del municipio, antes de normalizar.
Año Sensor Píxel del archivo (m) NDVI percentil 10 NDVI mediana NDVI percentil 90 % sin dato
2001 Landsat 5/7 10 0.496 0.655 0.762 0.00
2003 Landsat 5/7 10 0.294 0.569 0.774 0.00
2004 Landsat 5/7 10 0.375 0.586 0.751 0.11
2005 Landsat 5/7 10 0.448 0.663 0.833 0.27
2007 Landsat 5/7 10 0.406 0.598 0.720 0.01
2008 Landsat 5/7 10 0.407 0.635 0.787 0.00
2010 Landsat 5/7 10 0.392 0.588 0.680 0.00
2015 Landsat 8 10 0.508 0.762 0.839 0.00
2020 Sentinel-2 10 0.191 0.691 0.750 0.00
2025 Sentinel-2 10 0.305 0.661 0.722 0.00
NotaResolución real

Los archivos de Landsat vienen en una grilla de 10 m para poder superponerlos con Sentinel-2, pero su detalle real es de 30 m. Por eso toda la serie se analiza a 30 m. La resolución de 10 m de Sentinel-2 se conserva en el mapa interactivo.

3.1 Normalización entre fechas

El NDVI de dos fechas no es directamente comparable, aunque sean de la misma temporada, por tres razones: un año puede ser más seco que otro; los sensores de Landsat 5, 7 y 8 no miden exactamente lo mismo (Roy et al. 2016); y Sentinel-2 se usa aquí sin corrección atmosférica, lo que baja su NDVI respecto a Landsat. La Tabla 1 muestra cuánto se mueve la mediana de un año a otro por estas causas.

Para que esas diferencias generales no se lean como cambio de cobertura, cada fecha se ajusta linealmente a la fecha de referencia, de modo que compartan mediana y rango intercuartílico dentro del municipio. La consecuencia es doble: las cicatrices, que son cambios locales, se detectan mejor; pero un cambio que afectara por igual a todo el municipio quedaría oculto.

Código
j_ref <- if (is.null(ANIO_REFERENCIA)) 1L else match(ANIO_REFERENCIA, anios)
if (is.na(j_ref)) stop("ANIO_REFERENCIA no está entre las fechas disponibles.")

iqr <- apply(M_bruto[en_mun, , drop = FALSE], 2, IQR, na.rm = TRUE)
M <- M_bruto
if (NORMALIZAR) {
  for (j in seq_along(anios)) {
    M[, j] <- (M_bruto[, j] - resumen_img$mediana[j]) * (iqr[j_ref] / iqr[j]) + resumen_img$mediana[j_ref]
  }
  M[] <- pmin(pmax(M, -1), 1)
}
ndvi <- setValues(ndvi_bruto, M)

3.2 Las imágenes sobre Google Earth

Código
leer_opcional <- function(rutas, solo_geometria = FALSE) {
  rutas <- rutas[file.exists(rutas)]
  if (length(rutas) == 0) return(NULL)
  capas <- lapply(rutas, function(ruta) {
    x <- st_read(ruta, quiet = TRUE) |> st_make_valid() |> st_transform(CRS_METRICO)
    if (solo_geometria) st_sf(archivo = rep(basename(ruta), nrow(x)), geometry = st_geometry(x)) else x
  })
  x <- do.call(rbind, capas)
  if (nrow(x) == 0) NULL else x
}

# --- Tenencia ejidal ------------------------------------------------------
ejidos <- leer_opcional(RUTA_EJIDOS)
if (!is.null(ejidos)) {
  ejidos <- suppressWarnings(st_intersection(ejidos, st_geometry(limite_m)))
  ejidos <- ejidos[st_geometry_type(ejidos) %in% c("POLYGON", "MULTIPOLYGON"), ]
  if (nrow(ejidos) == 0) ejidos <- NULL
}
hay_ejidos <- !is.null(ejidos)
if (hay_ejidos) {
  ejidos$ejido <- if (!is.null(CAMPO_EJIDO) && CAMPO_EJIDO %in% names(ejidos)) {
    as.character(ejidos[[CAMPO_EJIDO]])
  } else paste("Polígono", seq_len(nrow(ejidos)))
  ejidos$id_ej <- seq_len(nrow(ejidos))
}

# --- Infraestructura eólica -----------------------------------------------
# No se recorta al municipio: un aerogenerador vecino también proyecta su área
# de influencia hacia dentro de Ixil.
eolico <- leer_opcional(RUTA_EOLICO, solo_geometria = TRUE)
hay_eolico <- !is.null(eolico)
if (hay_eolico) {
  zona_eolica <- st_sf(dentro = 1L, geometry = st_union(st_buffer(eolico, BUFFER_EOLICO_M)))
  # Para el mapa, cada tipo de geometría se dibuja por separado
  tipo_geom <- as.character(st_geometry_type(eolico))
  de_tipo <- function(tipos, destino) {
    x <- st_geometry(eolico)[tipo_geom %in% tipos]
    if (length(x) == 0) NULL else st_transform(suppressWarnings(st_cast(x, destino)), 4326)
  }
  eol_puntos    <- de_tipo(c("POINT", "MULTIPOINT"), "POINT")
  eol_lineas    <- de_tipo(c("LINESTRING", "MULTILINESTRING"), "MULTILINESTRING")
  eol_poligonos <- de_tipo(c("POLYGON", "MULTIPOLYGON"), "MULTIPOLYGON")
}

# --- Línea de transmisión eléctrica ---------------------------------------
linea <- leer_opcional(RUTA_LINEA)
if (!is.null(linea)) {
  linea <- linea[st_geometry_type(linea) %in% c("LINESTRING", "MULTILINESTRING"), ]
  if (nrow(linea) == 0) linea <- NULL
}
hay_linea <- !is.null(linea)
if (hay_linea) {
  linea_union <- st_union(st_geometry(linea))
  zona_linea  <- st_sf(dentro = 1L, geometry = st_buffer(linea_union, BUFFER_LINEA_M))
}

# --- Línea de costa -------------------------------------------------------
# Sin un shapefile propio, la costa se toma del límite municipal: los tramos de
# su borde cuya cara exterior mira al norte (hacia el mar), en la parte norte del
# municipio. Conviene comprobar en los mapas que la línea celeste sigue la playa.
costa_del_limite <- function(poligono, tolerancia = 4000) {
  pol <- st_cast(st_geometry(poligono), "POLYGON")
  pol <- pol[which.max(st_area(pol))]
  co  <- st_coordinates(pol); co <- co[co[, "L1"] == 1, c("X", "Y")]     # anillo exterior
  n <- nrow(co); a <- co[-n, , drop = FALSE]; b <- co[-1, , drop = FALSE]
  dx <- b[, 1] - a[, 1]; dy <- b[, 2] - a[, 2]; largo <- sqrt(dx^2 + dy^2)
  sentido <- if (sum(a[, 1] * b[, 2] - b[, 1] * a[, 2]) > 0) 1 else -1    # +1: vértices en sentido antihorario
  normal_norte <- -sentido * dx / pmax(largo, 1e-9)                       # cuánto mira al norte la cara exterior
  es_costa <- largo > 0 & normal_norte > 0.5 & (a[, 2] + b[, 2]) / 2 > max(co[, 2]) - tolerancia
  st_sfc(st_multilinestring(lapply(which(es_costa), function(i) rbind(a[i, ], b[i, ]))),
         crs = st_crs(poligono))
}
costa <- if (!is.null(RUTA_COSTA) && file.exists(RUTA_COSTA)) {
  x <- st_read(RUTA_COSTA, quiet = TRUE) |> st_make_valid() |> st_transform(CRS_METRICO)
  suppressWarnings(st_intersection(st_union(st_geometry(x)), st_buffer(st_geometry(limite_m), 2000)))
} else costa_del_limite(limite_m)
fuente_costa <- if (!is.null(RUTA_COSTA) && file.exists(RUTA_COSTA)) basename(RUTA_COSTA) else "borde norte del límite municipal"
km_costa <- as.numeric(sum(st_length(costa))) / 1000

# Mapa base: imagen satelital de Google, con el límite y las capas opcionales
mapa_base <- function() {
  m <- 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 = st_transform(limite_m, 4326), fill = FALSE, color = "#ffff00",
                weight = 2, group = "Límite municipal") |>
    # Todos los mapas abren encuadrados en el municipio
    fitBounds(bb[["xmin"]], bb[["ymin"]], bb[["xmax"]], bb[["ymax"]])
  if (hay_ejidos) {
    m <- addPolygons(m, data = st_transform(ejidos, 4326), fill = FALSE, color = "#00e5ff",
                     weight = 2, dashArray = "6", popup = ~ejido, group = ETIQUETA_EJIDOS)
  }
  if (hay_eolico) {
    if (!is.null(eol_poligonos)) {
      m <- addPolygons(m, data = eol_poligonos, fill = FALSE, color = "#ffffff", weight = 2,
                       group = "Infraestructura eólica")
    }
    if (!is.null(eol_lineas)) {
      m <- addPolylines(m, data = eol_lineas, color = "#ffffff", weight = 2,
                        group = "Infraestructura eólica")
    }
    if (!is.null(eol_puntos)) {
      m <- addCircleMarkers(m, data = eol_puntos, radius = 4, color = "#ffffff",
                            fillColor = "#2b83ba", fillOpacity = 1, weight = 1,
                            group = "Infraestructura eólica")
    }
  }
  if (hay_linea) {
    m <- addPolylines(m, data = st_transform(st_cast(st_geometry(linea), "MULTILINESTRING"), 4326),
                      color = "#ff9800", weight = 3, dashArray = "8", opacity = 1,
                      group = "Línea de transmisión")
  }
  m <- addPolylines(m, data = st_transform(costa, 4326), color = "#00e5ff", weight = 3, opacity = 1,
                    group = "Línea de costa")
  m
}
grupos_fijos <- c("Límite municipal", if (hay_ejidos) ETIQUETA_EJIDOS,
                  if (hay_eolico) "Infraestructura eólica",
                  if (hay_linea) "Línea de transmisión", "Línea de costa")
grupos_base  <- c("Google Satélite", "Google Híbrido", "Esri World Imagery", "Mapa oscuro")
Código
pal_ndvi <- colorNumeric("RdYlGn", domain = c(0, 1), na.color = "transparent")
m <- mapa_base()
for (a in anios_chr) {
  # Sentinel-2 se muestra a su resolución original; Landsat, a la de análisis
  capa <- if (grepl("Sentinel", disp$sensor[anios_chr == a])) mask(nativos[[a]], limite_v) else ndvi_bruto[[a]]
  m <- addRasterImage(m, clamp(capa, 0, 1), colors = pal_ndvi, opacity = 0.85,
                      group = paste("NDVI", a), maxBytes = 16 * 1024^2)
}
m |>
  addLegend(pal = pal_ndvi, values = c(0, 1), title = "NDVI", position = "bottomleft") |>
  addLayersControl(baseGroups = grupos_base,
                   overlayGroups = c(paste("NDVI", anios_chr), grupos_fijos),
                   options = layersControlOptions(collapsed = FALSE)) |>
  hideGroup(paste("NDVI", head(anios_chr, -1)))
Figura 1: NDVI de cada fecha sobre la imagen satelital de Google. Con el control de la derecha se enciende y apaga cada año; al apagar todos queda visible la imagen de fondo.

4 Cruce de las fechas

Para construir una matriz de transición, el NDVI normalizado se agrupa en cuatro clases: Sin vegetación (-1 a 0.2); Vegetación escasa (0.2 a 0.4); Vegetación media (0.4 a 0.6); Vegetación densa (0.6 a 1). La matriz compara la primera fecha con la última; las fechas intermedias se usan después para seguir la trayectoria y fechar las cicatrices.

Código
ANIO_T1 <- anios[1]
ANIO_T2 <- anios[length(anios)]
clasificar <- function(x) cut(x, CORTES_NDVI, labels = CLASES_NDVI, include.lowest = TRUE, right = FALSE)

# Píxeles del análisis: dentro del municipio y con dato en la primera y la última fecha
validos <- en_mun & !is.na(M[, 1]) & !is.na(M[, ncol(M)])
px <- data.frame(celda = which(validos), ha = ha_pixel)
N  <- M[px$celda, , drop = FALSE]                      # NDVI normalizado: píxeles x fechas
px$g1 <- clasificar(N[, 1])
px$g2 <- clasificar(N[, ncol(N)])
px <- cbind(px, xyFromCell(plantilla, px$celda))
niveles <- CLASES_NDVI

# Auxiliar: vector de valores por píxel -> raster sobre la grilla de análisis
a_raster <- function(valores, nombre) {
  r <- rast(plantilla); v <- rep(NA_real_, ncell(r)); v[px$celda] <- as.numeric(valores)
  values(r) <- v; names(r) <- nombre
  r
}

5 Matriz de transición observada

5.1 En hectáreas

Las filas son la clase de origen (2001) y las columnas la de destino (2025). La diagonal es lo que persistió; todo lo demás es cambio.

Código
M_ha <- xtabs(ha ~ g1 + g2, data = px)
names(dimnames(M_ha)) <- c("desde", "hacia")

# Probabilidades de transición: cada fila dividida por su total
tot_fila <- rowSums(M_ha)
P <- M_ha / tot_fila
P[tot_fila == 0, ] <- NA      # clase ausente en T1: su fila no está definida

# Verificación: toda fila con superficie en T1 debe sumar 1
stopifnot(all(abs(rowSums(P)[tot_fila > 0] - 1) < 1e-8))
Código
kable(round(addmargins(M_ha), 1), format.args = list(big.mark = ","))
Tabla 2: Matriz de transición observada (ha). Filas: origen; columnas: destino.
Sin vegetación Vegetación escasa Vegetación media Vegetación densa Sum
Sin vegetación 251.6 3.2 0.3 0.0 255.1
Vegetación escasa 271.4 113.1 39.9 10.7 435.1
Vegetación media 262.8 305.7 1,053.3 1,509.2 3,131.0
Vegetación densa 419.9 211.7 1,225.3 8,009.2 9,866.2
Sum 1,205.8 633.8 2,318.8 9,529.1 13,687.5

5.2 En probabilidades

Código
kable(round(100 * P, 2))
Tabla 3: Matriz de transición observada (% de la superficie de origen). Cada fila suma 100.
Sin vegetación Vegetación escasa Vegetación media Vegetación densa
Sin vegetación 98.62 1.27 0.11 0.00
Vegetación escasa 62.38 26.00 9.16 2.46
Vegetación media 8.39 9.76 33.64 48.20
Vegetación densa 4.26 2.15 12.42 81.18
Código
df_mat <- as.data.frame(as.table(M_ha), responseName = "ha") |>
  left_join(as.data.frame(as.table(P), responseName = "p"), by = c("desde", "hacia")) |>
  mutate(etiqueta = ifelse(ha > 0, paste0(number(100 * p, 0.1), "%\n",
                                          number(ha, 1, big.mark = ","), " ha"), ""))
ggplot(df_mat, aes(hacia, desde, fill = p)) +
  geom_tile(colour = "#000000") +
  geom_text(aes(label = etiqueta, colour = p > 0.5), size = 3, lineheight = 0.9) +
  scale_fill_distiller(palette = "YlOrRd", direction = 1, labels = percent,
                       na.value = "#1a1a1a", name = "% del origen") +
  scale_colour_manual(values = c("TRUE" = "white", "FALSE" = "black"), guide = "none") +
  scale_y_discrete(limits = rev(niveles), labels = label_wrap(16)) +
  scale_x_discrete(position = "top", labels = label_wrap(12)) +
  labs(x = paste("Hacia", ANIO_T2), y = paste("Desde", ANIO_T1)) +
  tema_oscuro() +
  theme(axis.text.x = element_text(size = 9), panel.grid = element_blank())
Figura 2: Matriz de transición: el color indica qué porcentaje de la clase de origen terminó en cada destino; el texto añade las hectáreas.

6 Lectura de la matriz: qué cambia y hacia dónde

6.1 Balance por clase

Para cada clase se separan cinco cantidades (Pontius et al. 2004):

  • persistencia: superficie que no cambió (diagonal);
  • pérdida y ganancia: lo que salió de la clase y lo que entró;
  • cambio neto = ganancia − pérdida: lo que se ve al comparar solo los totales;
  • intercambio = 2 × mín(ganancia, pérdida): cambio que los totales esconden, porque la clase pierde en un sitio y gana en otro.
Código
balance <- data.frame(
  clase        = niveles,
  ha_t1        = as.numeric(rowSums(M_ha)),
  ha_t2        = as.numeric(colSums(M_ha)),
  persistencia = as.numeric(diag(M_ha))
) |>
  mutate(perdida     = ha_t1 - persistencia,
         ganancia    = ha_t2 - persistencia,
         cambio_neto = ganancia - perdida,
         intercambio = 2 * pmin(ganancia, perdida))

area_total   <- sum(M_ha)
cambio_total <- area_total - sum(diag(M_ha))
cambio_neto_paisaje <- sum(abs(balance$cambio_neto)) / 2
intercambio_paisaje <- cambio_total - cambio_neto_paisaje

# Sentido del cambio: bajar de clase es perder vegetación; subir, ganarla
codigo1 <- as.integer(px$g1); codigo2 <- as.integer(px$g2)
ha_baja <- sum(px$ha[codigo2 < codigo1])
ha_sube <- sum(px$ha[codigo2 > codigo1])
Código
balance |>
  mutate(across(where(is.numeric), ~ round(.x, 1))) |>
  setNames(c("Clase", paste("ha", ANIO_T1), paste("ha", ANIO_T2), "Persistencia", "Pérdida",
             "Ganancia", "Cambio neto", "Intercambio")) |>
  kable(format.args = list(big.mark = ","))
Tabla 4: Balance del cambio por clase de NDVI (ha).
Clase ha 2001 ha 2025 Persistencia Pérdida Ganancia Cambio neto Intercambio
Sin vegetación 255.1 1,205.8 251.6 3.5 954.2 950.7 7.0
Vegetación escasa 435.1 633.8 113.1 322.0 520.6 198.6 644.0
Vegetación media 3,131.0 2,318.8 1,053.3 2,077.7 1,265.5 -812.3 2,531.0
Vegetación densa 9,866.2 9,529.1 8,009.2 1,857.0 1,519.9 -337.0 3,039.8
Código
balance |>
  transmute(clase = factor(clase, rev(niveles)), Ganancia = ganancia, Pérdida = -perdida) |>
  pivot_longer(-clase, names_to = "componente", values_to = "ha") |>
  ggplot(aes(ha, clase)) +
  geom_col(aes(fill = componente)) +
  geom_point(data = transmute(balance, clase = factor(clase, rev(niveles)), ha = cambio_neto),
             size = 2.5, colour = "white") +
  geom_vline(xintercept = 0, colour = "white") +
  scale_fill_manual(values = c("Ganancia" = "#4575b4", "Pérdida" = "#d73027"), name = NULL) +
  scale_x_continuous(labels = label_number(big.mark = ",")) +
  labs(x = "Hectáreas", y = NULL) +
  tema_oscuro() + theme(legend.position = "bottom")
Figura 3: Ganancias y pérdidas por clase. El punto negro marca el cambio neto.

6.2 Transiciones dominantes

Código
trans <- df_mat |>
  filter(desde != hacia, ha > 0) |>
  arrange(desc(ha)) |>
  transmute(desde, hacia, ha,
            pct_del_origen = 100 * p,
            pct_del_cambio = 100 * ha / cambio_total)

trans |>
  head(12) |>
  mutate(ha = round(ha, 1), across(starts_with("pct"), ~ round(.x, 2))) |>
  setNames(c("Desde", "Hacia", "ha", "% de la clase de origen", "% de todo el cambio")) |>
  kable(format.args = list(big.mark = ","))
Tabla 5: Transiciones ordenadas por superficie: de qué clase a qué clase pasó el suelo.
Desde Hacia ha % de la clase de origen % de todo el cambio
Vegetación media Vegetación densa 1,509.2 48.20 35.43
Vegetación densa Vegetación media 1,225.3 12.42 28.76
Vegetación densa Sin vegetación 419.9 4.26 9.86
Vegetación media Vegetación escasa 305.7 9.76 7.18
Vegetación escasa Sin vegetación 271.4 62.38 6.37
Vegetación media Sin vegetación 262.8 8.39 6.17
Vegetación densa Vegetación escasa 211.7 2.15 4.97
Vegetación escasa Vegetación media 39.9 9.16 0.94
Vegetación escasa Vegetación densa 10.7 2.46 0.25
Sin vegetación Vegetación escasa 3.2 1.27 0.08
Sin vegetación Vegetación media 0.3 0.11 0.01

6.3 Trayectoria a lo largo de la serie

Código
# Clase de cada píxel en cada fecha (código entero; NA si no hay dato)
C <- matrix(as.integer(clasificar(N)), nrow = nrow(N))
composicion <- do.call(rbind, lapply(seq_along(anios), function(j) {
  data.frame(anio = anios[j], clase = factor(CLASES_NDVI, CLASES_NDVI),
             ha = tabulate(C[, j], nbins = length(CLASES_NDVI)) * ha_pixel)
}))

# Cambio entre fechas consecutivas
periodos <- do.call(rbind, lapply(seq_len(length(anios) - 1), function(j) {
  a <- C[, j]; b <- C[, j + 1]; ok <- !is.na(a) & !is.na(b)
  data.frame(periodo = paste(anios[j], "→", anios[j + 1]),
             baja = sum(b[ok] < a[ok]) * ha_pixel,
             sube = sum(b[ok] > a[ok]) * ha_pixel,
             igual = sum(b[ok] == a[ok]) * ha_pixel)
}))
Código
ggplot(composicion, aes(factor(anio), ha, fill = clase)) +
  geom_col() +
  scale_fill_manual(values = paleta, name = NULL) +
  scale_y_continuous(labels = label_number(big.mark = ",")) +
  labs(x = NULL, y = "Hectáreas") +
  tema_oscuro() + theme(legend.position = "bottom")
Figura 4: Superficie de cada clase de NDVI en cada fecha de la serie.
Código
periodos |>
  mutate(across(where(is.numeric), ~ round(.x, 1))) |>
  setNames(c("Periodo", "Baja de clase (pierde vegetación)", "Sube de clase (gana vegetación)",
             "Sin cambio de clase")) |>
  kable(format.args = list(big.mark = ","))
Tabla 6: Superficie que baja o sube de clase de NDVI entre fechas consecutivas (ha).
Periodo Baja de clase (pierde vegetación) Sube de clase (gana vegetación) Sin cambio de clase
2001 → 2003 2,087.5 2,483.5 9,116.5
2003 → 2004 1,615.1 1,513.3 10,543.9
2004 → 2005 1,713.4 1,290.9 10,636.4
2005 → 2007 1,736.4 1,358.8 10,553.6
2007 → 2008 1,090.5 1,278.1 11,317.8
2008 → 2010 2,180.2 1,141.4 10,365.9
2010 → 2015 1,749.2 1,385.0 10,553.2
2015 → 2020 1,812.3 1,123.3 10,751.9
2020 → 2025 769.7 1,218.6 11,699.2

Una superficie grande que baja de clase en un periodo y vuelve a subir en el siguiente no es desmonte: es la variación entre años que la normalización no alcanza a quitar, o ruido de la propia imagen. Por eso las cicatrices no se definen con una sola pareja de fechas.

7 Cicatrices candidatas

Una cicatriz candidata es un píxel que cumple tres condiciones a la vez:

  1. tenía vegetación: la mediana de su NDVI en las fechas anteriores es de al menos 0.5;
  2. la perdió: su NDVI cae al menos 0.2;
  3. no la recuperó: en todas las fechas posteriores su NDVI se queda en 0.35 o menos.

La tercera condición es la que separa un desmonte permanente (plataforma, camino, banco de material, expansión urbana) de una quema, una sequía o una parcela en descanso, que reverdecen. El año de aparición es la primera fecha en que se cumplen las tres. Las cicatrices que aparecen en la última fecha no tienen fechas posteriores que las confirmen, y se señalan como sin confirmar.

Código
n_f <- ncol(N)
anio_cic <- rep(NA_integer_, nrow(N))
caida    <- rep(NA_real_, nrow(N))
for (k in 2:n_f) {
  antes   <- if (k == 2) N[, 1] else apply(N[, 1:(k - 1), drop = FALSE], 1, median, na.rm = TRUE)
  despues <- suppressWarnings(apply(N[, k:n_f, drop = FALSE], 1, max, na.rm = TRUE))
  es <- is.na(anio_cic) & is.finite(despues) & !is.na(antes) &
    antes >= UMBRAL_VEGETADO & despues <= UMBRAL_DESMONTE & (antes - despues) >= CAIDA_MINIMA
  anio_cic[es] <- anios[k]
  caida[es]    <- (antes - despues)[es]
}

# Parches: píxeles contiguos de cicatriz. Se descartan los menores de MIN_HA_PARCHE.
hay_cicatrices <- any(!is.na(anio_cic))
if (hay_cicatrices) {
  r_anio   <- a_raster(anio_cic, "anio")
  r_caida  <- a_raster(caida, "caida")
  parches  <- patches(!is.na(r_anio), directions = 8, zeroAsNA = TRUE)
  id_parche <- values(parches)[px$celda, 1]
  tam <- table(id_parche) * ha_pixel
  conservar <- as.integer(names(tam)[tam >= MIN_HA_PARCHE])
  fuera <- !is.na(id_parche) & !(id_parche %in% conservar)
  anio_cic[fuera] <- NA; caida[fuera] <- NA; id_parche[is.na(anio_cic)] <- NA
  hay_cicatrices <- any(!is.na(anio_cic))
}
px$anio_cic <- anio_cic
px$caida    <- caida
px$tipo <- ifelse(!is.na(px$anio_cic), "Cicatriz candidata", "Sin cicatriz")
px$vegetado_t1 <- N[, 1] >= UMBRAL_VEGETADO

if (hay_cicatrices) {
  r_parche <- a_raster(id_parche, "id_parche")
  cicatrices <- st_as_sf(as.polygons(r_parche))
  por_parche <- px[!is.na(id_parche), ] |>
    mutate(id_parche = id_parche[!is.na(id_parche)]) |>
    group_by(id_parche) |>
    summarise(ha = sum(ha), anio = min(anio_cic), caida_media = mean(caida), .groups = "drop")
  cicatrices <- left_join(cicatrices, por_parche, by = "id_parche")
  cicatrices$confirmada <- ifelse(cicatrices$anio < ANIO_T2, "Sí", "Sin confirmar")
  if (hay_ejidos) {
    cicatrices <- suppressWarnings(st_join(cicatrices, ejidos["ejido"], largest = TRUE))
  }
  if (hay_eolico) {
    cicatrices$dist_eolico_m <- round(as.numeric(st_distance(cicatrices, st_union(eolico))[, 1]))
  }
  # Distancia de cada parche (su borde más próximo) a la línea eléctrica y a la costa
  if (hay_linea) {
    cicatrices$dist_linea_m <- round(as.numeric(st_distance(cicatrices, linea_union)[, 1]))
  }
  cicatrices$dist_costa_m <- round(as.numeric(st_distance(cicatrices, costa)[, 1]))
} else {
  cicatrices <- NULL
}
Código
if (hay_cicatrices) {
  cic_anio <- px[!is.na(px$anio_cic), ] |>
    group_by(anio = anio_cic) |>
    summarise(ha = sum(ha), caida_media = mean(caida), .groups = "drop") |>
    left_join(st_drop_geometry(cicatrices) |> count(anio, name = "parches"), by = "anio") |>
    mutate(parches = replace_na(parches, 0L),
           confirmada = ifelse(anio < ANIO_T2, "Sí", "Sin confirmar"))
  cic_anio |>
    mutate(anio = as.character(anio), ha = round(ha, 1), caida_media = round(caida_media, 2)) |>
    setNames(c("Aparece en", "ha", "Caída media de NDVI", "Parches que empiezan ese año", "Confirmada")) |>
    kable(format.args = list(big.mark = ","))
} else {
  cat("No se detectaron cicatrices candidatas con la definición actual.")
}
Tabla 7: Cicatrices candidatas según la fecha en que aparecen.
Aparece en ha Caída media de NDVI Parches que empiezan ese año Confirmada
2003 1.4 0.25 6 Sí
2004 3.6 0.23 8 Sí
2005 4.5 0.23 6 Sí
2007 52.8 0.28 12 Sí
2008 2.2 0.24 0 Sí
2010 139.3 0.36 42 Sí
2015 59.8 0.37 24 Sí
2020 288.1 0.41 66 Sí
2025 17.8 0.29 8 Sin confirmar
Código
if (hay_cicatrices) {
  cicatrices$tamano <- cut(cicatrices$ha, c(0, 0.5, 2, 10, Inf),
                           labels = c("< 0.5 ha", "0.5 a 2 ha", "2 a 10 ha", "> 10 ha"))
  cicatrices |>
    st_drop_geometry() |>
    group_by(tamano, .drop = FALSE) |>
    summarise(parches = n(), ha = round(sum(ha), 1), .groups = "drop") |>
    setNames(c("Tamaño del parche", "Número de parches", "ha")) |>
    kable(format.args = list(big.mark = ","))
}
Tabla 8: Parches de cicatriz candidata por tamaño.
Tamaño del parche Número de parches ha
< 0.5 ha 86 28.6
0.5 a 2 ha 62 52.7
2 a 10 ha 21 86.9
> 10 ha 3 401.2

7.1 Las cicatrices sobre Google Earth

Este mapa es la herramienta de verificación: cada parche se dibuja solo con su contorno, para que debajo se vea la imagen satelital reciente y se pueda comprobar a ojo si corresponde a una plataforma, un camino, una casa o un falso positivo. Al pulsar un parche aparecen su año de aparición, su superficie y la caída de NDVI.

Código
m <- mapa_base()
if (hay_cicatrices) {
  cic_ll <- st_transform(cicatrices, 4326)
  cic_ll$anio_f <- factor(cic_ll$anio, levels = anios[-1])
  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.08, group = "Cicatrices candidatas",
                popup = ~paste0("<b>Aparece en ", anio, "</b> (", confirmada, ")<br>",
                                round(ha, 2), " ha<br>Caída media de NDVI: ", round(caida_media, 2))) |>
    addLegend(pal = pal_anio, values = cic_ll$anio_f, title = "Aparece en", position = "bottomleft")
}
m |>
  addLayersControl(baseGroups = grupos_base,
                   overlayGroups = c(if (hay_cicatrices) "Cicatrices candidatas", grupos_fijos),
                   options = layersControlOptions(collapsed = FALSE))
Figura 5: Cicatrices candidatas, coloreadas por año de aparición, sobre la imagen satelital de Google.

7.2 Cicatrices y propiedad ejidal

Código
valor_raster <- function(capa_sf, campo) {
  values(rasterize(vect(capa_sf), plantilla, field = campo))[px$celda, 1]
}
if (hay_ejidos) {
  px$ejido    <- ejidos$ejido[valor_raster(ejidos, "id_ej")]
  px$tenencia <- ifelse(is.na(px$ejido), "Fuera de la capa", ETIQUETA_EJIDOS)
}
if (hay_eolico) {
  px$eolico <- ifelse(is.na(valor_raster(zona_eolica, "dentro")),
                      paste("A más de", BUFFER_EOLICO_M, "m"),
                      paste("A", BUFFER_EOLICO_M, "m o menos de infraestructura eólica"))
}

# Distancia de cada píxel a la costa y a la línea de transmisión
px_pts <- st_as_sf(px[, c("x", "y")], coords = c("x", "y"), crs = CRS_METRICO)
etiquetar_bandas <- function(cortes) {
  e <- paste0(format(head(cortes, -1), big.mark = ",", trim = TRUE), "–",
              format(tail(cortes, -1), big.mark = ",", trim = TRUE), " m")
  e[length(e)] <- paste0("> ", format(cortes[length(cortes) - 1], big.mark = ",", trim = TRUE), " m")
  e
}
en_bandas <- function(d, cortes) cut(d, cortes, right = FALSE, labels = etiquetar_bandas(cortes))

px$dist_costa  <- as.numeric(st_distance(px_pts, costa))
px$franja      <- ifelse(px$dist_costa <= ANCHO_COSTA_M,
                         paste0("Franja costera (a ", format(ANCHO_COSTA_M, big.mark = ","), " m o menos del mar)"),
                         "Resto del municipio")
if (hay_linea) {
  px$dist_linea <- as.numeric(st_distance(px_pts, linea_union))
  px$corredor   <- ifelse(px$dist_linea <= BUFFER_LINEA_M,
                          paste("A", BUFFER_LINEA_M, "m o menos de la línea"),
                          paste("A más de", BUFFER_LINEA_M, "m"))
}

# Para cada zona: cuánta superficie vegetada había al inicio y cuánta quedó como
# cicatriz. La "concentración" compara la parte de las cicatrices que cae en la
# zona con la parte de la vegetación inicial que la zona tenía: un valor mayor
# que 1 indica que la zona carga con más cicatriz de la que le correspondería
# si el desmonte se repartiera de forma pareja.
resumen_zona <- function(zona) {
  px |>
    mutate(zona = zona) |>
    group_by(zona) |>
    summarise(superficie  = sum(ha),
              vegetado_t1 = sum(ha[vegetado_t1]),
              cicatriz    = sum(ha[tipo == "Cicatriz candidata"]),
              .groups = "drop") |>
    mutate(pct_vegetado_perdido = 100 * cicatriz / vegetado_t1,
           pct_de_cicatrices    = 100 * cicatriz / sum(cicatriz),
           concentracion        = (cicatriz / sum(cicatriz)) / (vegetado_t1 / sum(vegetado_t1)))
}
mostrar_zonas <- function(tab, nombre_zona) {
  tab |>
    mutate(across(c(superficie, vegetado_t1, cicatriz), ~ round(.x, 1)),
           across(c(pct_vegetado_perdido, pct_de_cicatrices, concentracion), ~ round(.x, 2))) |>
    setNames(c(nombre_zona, "Superficie (ha)", paste("Con vegetación en", ANIO_T1, "(ha)"),
               "Cicatriz candidata (ha)", "% de su vegetación convertida en cicatriz",
               "% del total de cicatrices", "Índice de concentración")) |>
    kable(format.args = list(big.mark = ","))
}
Código
if (hay_ejidos) {
  zona_tenencia <- resumen_zona(px$tenencia)
  mostrar_zonas(zona_tenencia, "Tenencia")
}
Tabla 9: Cicatrices según tenencia de la tierra.
Tenencia Superficie (ha) Con vegetación en 2001 (ha) Cicatriz candidata (ha) % de su vegetación convertida en cicatriz % del total de cicatrices Índice de concentración
Fuera de la capa 7,899.3 6,656.0 554.1 8.33 97.3 1.79
Tierras ejidales (capa cargada) 5,788.2 5,611.8 15.4 0.27 2.7 0.06
Código
if (hay_ejidos) {
  zona_ejido <- resumen_zona(ifelse(is.na(px$ejido), "(fuera de la capa)", px$ejido))
  mostrar_zonas(arrange(zona_ejido, desc(cicatriz)), "Polígono")
}
Tabla 10: Cicatrices en cada polígono de la capa de tenencia.
Polígono Superficie (ha) Con vegetación en 2001 (ha) Cicatriz candidata (ha) % de su vegetación convertida en cicatriz % del total de cicatrices Índice de concentración
(fuera de la capa) 7,899.3 6,656.0 554.1 8.33 97.30 1.79
Polígono 3 4,494.2 4,374.5 14.7 0.34 2.58 0.07
Polígono 4 225.4 181.8 0.6 0.35 0.11 0.07
Polígono 6 78.8 74.1 0.1 0.12 0.02 0.03
Polígono 1 3.1 3.1 0.0 0.00 0.00 0.00
Polígono 2 943.5 935.1 0.0 0.00 0.00 0.00
Polígono 5 18.4 18.4 0.0 0.00 0.00 0.00
Polígono 7 24.8 24.8 0.0 0.00 0.00 0.00
Polígono 8 0.1 0.1 0.0 0.00 0.00 0.00

7.3 Cicatrices e infraestructura eólica

Código
if (hay_eolico) {
  zona_eolico <- resumen_zona(px$eolico)
  mostrar_zonas(zona_eolico, "Zona")
}
Tabla 11: Cicatrices según cercanía a la infraestructura eólica.
Zona Superficie (ha) Con vegetación en 2001 (ha) Cicatriz candidata (ha) % de su vegetación convertida en cicatriz % del total de cicatrices Índice de concentración
A 500 m o menos de infraestructura eólica 492.1 490.7 0.0 0.00 0 0.00
A más de 500 m 13,195.3 11,777.1 569.5 4.84 100 1.04
Código
if (hay_eolico && hay_cicatrices) {
  cruce_ea <- xtabs(ha ~ anio_cic + eolico, data = px[!is.na(px$anio_cic), ])
  names(dimnames(cruce_ea)) <- c("Aparece en", "")
  kable(round(addmargins(cruce_ea), 1), format.args = list(big.mark = ","))
}
Tabla 12: Cicatrices candidatas (ha) por año de aparición y cercanía a la infraestructura eólica.
A más de 500 m Sum
2003 1.4 1.4
2004 3.6 3.6
2005 4.5 4.5
2007 52.8 52.8
2008 2.2 2.2
2010 139.3 139.3
2015 59.8 59.8
2020 288.1 288.1
2025 17.8 17.8
Sum 569.5 569.5
Código
if (hay_eolico && hay_ejidos && hay_cicatrices) {
  cruce_ee <- xtabs(ha ~ tenencia + eolico, data = px[px$tipo == "Cicatriz candidata", ])
  kable(round(addmargins(cruce_ee), 1), format.args = list(big.mark = ","))
}
Tabla 13: Cicatrices candidatas (ha) según tenencia y cercanía a la infraestructura eólica.
A más de 500 m Sum
Fuera de la capa 554.1 554.1
Tierras ejidales (capa cargada) 15.4 15.4
Sum 569.5 569.5

7.4 Cicatrices y línea de transmisión eléctrica

Una línea de transmisión deja una cicatriz propia: la brecha que se desmonta bajo los cables. Si la línea estuviera construida, a lo largo de su trazo deberían aparecer cicatrices alargadas. Aquí se comprueba si las cicatrices detectadas se sobreponen a la línea, de dos formas: por superficie (cuánta cicatriz cae en un corredor de 100 m a cada lado) y por parches (cuántos tocan la línea o entran en ese corredor).

Código
if (hay_linea) {
  km_linea_total <- as.numeric(sum(st_length(linea))) / 1000
  km_linea_mun   <- as.numeric(sum(st_length(suppressWarnings(
    st_intersection(linea_union, st_geometry(limite_m)))))) / 1000
  campos <- intersect(c("Nombre", "Tipo", "Empresa", "Fase"), names(linea))
  datos_linea <- data.frame(
    dato  = c(campos, "Longitud total (km)", "Longitud dentro del municipio (km)"),
    valor = c(vapply(campos, function(k) paste(unique(na.omit(as.character(linea[[k]]))), collapse = "; "), ""),
              fmt(km_linea_total, 2), fmt(km_linea_mun, 2)))
  kable(datos_linea, col.names = c("Dato", "Valor"), row.names = FALSE)
}
Tabla 14: Línea de transmisión cargada.
Dato Valor
Nombre Parque Eólico Chicxulub - Subestación CFE San Ignacio
Tipo Lína transmisión proyecto eolico
Empresa Eólica del Mayab, S.A.P.I de C.V
Fase En proyecto
Longitud total (km) 12.36
Longitud dentro del municipio (km) 12.36
Código
if (hay_linea) {
  zona_corredor <- resumen_zona(px$corredor)
  mostrar_zonas(zona_corredor, "Zona")
}
Tabla 15: Cicatrices dentro y fuera del corredor de la línea de transmisión.
Zona Superficie (ha) Con vegetación en 2001 (ha) Cicatriz candidata (ha) % de su vegetación convertida en cicatriz % del total de cicatrices Índice de concentración
A 100 m o menos de la línea 246.7 242.5 0.0 0.00 0 0.00
A más de 100 m 13,440.8 12,025.3 569.5 4.74 100 1.02
Código
if (hay_linea) {
  zona_bandas_linea <- resumen_zona(en_bandas(px$dist_linea, BANDAS_LINEA_M))
  mostrar_zonas(zona_bandas_linea, "Distancia a la línea")
}
Tabla 16: Cicatrices según la distancia a la línea de transmisión.
Distancia a la línea Superficie (ha) Con vegetación en 2001 (ha) Cicatriz candidata (ha) % de su vegetación convertida en cicatriz % del total de cicatrices Índice de concentración
0–100 m 246.7 242.5 0.0 0.00 0 0.00
100–250 m 367.3 366.1 0.0 0.00 0 0.00
250–500 m 602.6 601.1 0.0 0.00 0 0.00
500–1,000 m 1,152.9 1,138.7 0.0 0.00 0 0.00
1,000–3,000 m 4,075.3 4,003.0 0.0 0.00 0 0.00
> 3,000 m 7,242.7 5,916.3 569.5 9.63 100 2.07
Código
cicatriz_total_ha <- sum(px$ha[px$tipo == "Cicatriz candidata"])
txt_linea <- if (!hay_linea) {
  "No se cargó ninguna línea de transmisión."
} else if (!hay_cicatrices) {
  "No hay cicatrices candidatas que cruzar con la línea de transmisión."
} else {
  n_tocan    <- sum(cicatrices$dist_linea_m == 0)
  n_corredor <- sum(cicatrices$dist_linea_m <= BUFFER_LINEA_M)
  ha_corredor <- sum(px$ha[px$tipo == "Cicatriz candidata" & px$dist_linea <= BUFFER_LINEA_M])
  mas_cercano <- min(cicatrices$dist_linea_m)
  fase <- if ("Fase" %in% names(linea)) paste(unique(na.omit(as.character(linea$Fase))), collapse = "; ") else ""
  paste0(
    "De los ", nrow(cicatrices), " parches de cicatriz, ", n_tocan, " se sobreponen a la línea de transmisión y ",
    n_corredor, " entran en el corredor de ", BUFFER_LINEA_M, " m (", fmt(ha_corredor, 1),
    " ha de cicatriz, el ", fmt(100 * ha_corredor / cicatriz_total_ha, 2), " % del total). ",
    "El parche más cercano queda a ", fmt(mas_cercano), " m de la línea. ",
    ifelse(n_corredor == 0,
           paste0("**Las cicatrices no se sobreponen a la línea de transmisión**: a lo largo de sus ",
                  fmt(km_linea_mun, 1), " km dentro del municipio no hay ninguna pérdida permanente de vegetación."),
           "Hay cicatrices sobre el trazo o junto a él: conviene revisarlas una a una en el mapa para ver si siguen la forma alargada de una brecha."),
    ifelse(nzchar(fase), paste0(" La capa registra la línea con la fase «", fase,
                                "», lo que es coherente con que no haya dejado huella en las imágenes."), ""))
}

Resultado. De los 172 parches de cicatriz, 0 se sobreponen a la línea de transmisión y 0 entran en el corredor de 100 m (0.0 ha de cicatriz, el 0.00 % del total). El parche más cercano queda a 3,079 m de la línea. Las cicatrices no se sobreponen a la línea de transmisión: a lo largo de sus 12.4 km dentro del municipio no hay ninguna pérdida permanente de vegetación. La capa registra la línea con la fase «En proyecto», lo que es coherente con que no haya dejado huella en las imágenes.

7.5 Cicatrices en la costa

La costa se toma de borde norte del límite municipal (9.2 km, la línea celeste de los mapas). Se llama franja costera a la parte del municipio situada a 4,000 m o menos del mar; es la zona de playa, duna, ciénaga y manglar. La tabla por bandas muestra cómo cambia el resultado si la franja se define más angosta o más ancha.

Código
zona_franja <- resumen_zona(px$franja)
mostrar_zonas(zona_franja, "Zona")
Tabla 17: Cicatrices en la franja costera y en el resto del municipio.
Zona Superficie (ha) Con vegetación en 2001 (ha) Cicatriz candidata (ha) % de su vegetación convertida en cicatriz % del total de cicatrices Índice de concentración
Franja costera (a 4,000 m o menos del mar) 3,037.6 1,966.0 551.7 28.06 96.87 6.04
Resto del municipio 10,649.9 10,301.8 17.8 0.17 3.13 0.04
Código
zona_bandas_costa <- resumen_zona(en_bandas(px$dist_costa, BANDAS_COSTA_M))
mostrar_zonas(zona_bandas_costa, "Distancia a la costa")
Tabla 18: Cicatrices según la distancia a la costa.
Distancia a la costa Superficie (ha) Con vegetación en 2001 (ha) Cicatriz candidata (ha) % de su vegetación convertida en cicatriz % del total de cicatrices Índice de concentración
0–1,000 m 944.1 379.1 81.8 21.58 14.36 4.65
1,000–2,000 m 728.5 473.0 263.4 55.70 46.25 12.00
2,000–3,000 m 688.8 591.7 163.3 27.61 28.68 5.95
3,000–4,000 m 676.2 522.4 43.1 8.25 7.57 1.78
4,000–5,000 m 781.9 740.9 1.1 0.15 0.19 0.03
> 5,000 m 9,868.0 9,560.9 16.7 0.18 2.94 0.04
Código
if (hay_cicatrices) {
  ggplot(zona_bandas_costa, aes(zona, cicatriz)) +
    geom_col(fill = "#d7191c") +
    geom_text(aes(label = paste0(fmt(cicatriz, 1), " ha")), vjust = -0.4, colour = "white", size = 3.3) +
    scale_y_continuous(labels = label_number(big.mark = ","), expand = expansion(mult = c(0, 0.12))) +
    labs(x = "Distancia a la costa", y = "Cicatriz candidata (ha)") +
    tema_oscuro()
}
Figura 6: Superficie de cicatrices candidatas según la distancia a la costa.
Código
if (hay_cicatrices) {
  cruce_ca <- xtabs(ha ~ anio_cic + franja, data = px[!is.na(px$anio_cic), ])
  names(dimnames(cruce_ca)) <- c("Aparece en", "")
  kable(round(addmargins(cruce_ca), 1), format.args = list(big.mark = ","))
}
Tabla 19: Cicatrices candidatas (ha) por año de aparición, en la franja costera y en el resto del municipio.
Franja costera (a 4,000 m o menos del mar) Resto del municipio Sum
2003 1.4 0.0 1.4
2004 3.6 0.0 3.6
2005 4.5 0.0 4.5
2007 52.8 0.0 52.8
2008 2.2 0.0 2.2
2010 139.0 0.4 139.3
2015 57.6 2.2 59.8
2020 279.8 8.3 288.1
2025 10.8 7.0 17.8
Sum 551.7 17.8 569.5
Código
txt_costa <- if (!hay_cicatrices) {
  "No hay cicatrices candidatas que situar respecto a la costa."
} else {
  fr <- zona_franja[grepl("^Franja", zona_franja$zona), ]
  # Un parche se cuenta en la costa si su punto interior representativo cae en la franja
  dist_centro <- as.numeric(st_distance(suppressWarnings(st_point_on_surface(st_geometry(cicatrices))), costa))
  n_costa <- sum(dist_centro <= ANCHO_COSTA_M)
  ha_parches_costa <- sum(cicatrices$ha[dist_centro <= ANCHO_COSTA_M])
  a_1km <- sum(px$ha[px$tipo == "Cicatriz candidata" & px$dist_costa <= 1000])
  paste0(
    "**", n_costa, " de los ", nrow(cicatrices), " parches de cicatriz (", fmt(100 * n_costa / nrow(cicatrices), 1),
    " %) se alojan en la franja costera.** Por superficie, la franja concentra ", fmt(fr$cicatriz, 1),
    " ha de las ", fmt(cicatriz_total_ha, 1), " ha de cicatrices (", fmt(fr$pct_de_cicatrices, 1),
    " %), aunque solo ocupa el ", fmt(100 * fr$superficie / sum(zona_franja$superficie), 1),
    " % del municipio: su índice de concentración es ", fmt(fr$concentracion, 2),
    ". En esa franja, el ", fmt(fr$pct_vegetado_perdido, 1), " % de la superficie que tenía vegetación en ",
    ANIO_T1, " terminó como cicatriz. A menos de 1 km del mar hay ", fmt(a_1km, 1), " ha. ",
    "Fuera de la franja quedan ", nrow(cicatrices) - n_costa, " parches (",
    fmt(cicatriz_total_ha - fr$cicatriz, 1), " ha).")
}

Resultado. 133 de los 172 parches de cicatriz (77.3 %) se alojan en la franja costera. Por superficie, la franja concentra 551.7 ha de las 569.5 ha de cicatrices (96.9 %), aunque solo ocupa el 22.2 % del municipio: su índice de concentración es 6.04. En esa franja, el 28.1 % de la superficie que tenía vegetación en 2001 terminó como cicatriz. A menos de 1 km del mar hay 81.8 ha. Fuera de la franja quedan 39 parches (17.8 ha).

Estas cicatrices costeras no tienen la forma de una obra (plataformas, caminos o brechas): son manchas amplias en la zona de ciénaga y manglar. Lo más probable es que reflejen un cambio del propio humedal (más agua superficial, pérdida o muerte de manglar, suelo salino descubierto) y no un desmonte. Atribuirlas a una causa exige revisar la imagen de fondo y, de ser posible, verificarlo en campo.

8 Validación: índice Kappa

Una matriz de transición describe el pasado, pero también es un modelo: si las probabilidades de cambio se mantuvieran, permitiría anticipar la fecha siguiente. Validarla consiste en ponerla a prueba con una fecha que ya se conoce:

  1. calibrar la matriz con dos fechas pasadas;
  2. predecir con ella una tercera fecha, separada por el mismo número de años;
  3. comparar la predicción con lo que realmente se observó en esa tercera fecha.

La comparación se resume con el índice Kappa, que mide cuánto del acuerdo entre dos mapas supera al que se obtendría por azar: 0 es un acuerdo no mejor que el azar y 1 es un acuerdo perfecto. Se interpreta con la escala habitual de Landis y Koch (1977). Como las clases de NDVI están ordenadas, se añade el Kappa ponderado, que castiga menos confundir dos clases vecinas que dos clases alejadas.

Kappa tiene dos límites conocidos, y por eso no se reporta solo (Pontius y Millones 2011). Primero, mezcla dos errores distintos, que aquí se separan: el desacuerdo de cantidad (el modelo predice una superficie equivocada de cada clase) y el desacuerdo de asignación (acierta la superficie pero la pone en otro sitio). Segundo, un modelo que diga “nada cambia” suele obtener un Kappa alto, porque la mayor parte del territorio persiste; por eso la predicción se compara con ese modelo nulo de persistencia. Un modelo de cambio solo aporta algo si lo supera.

Código
K <- length(CLASES_NDVI)

# Terna de fechas: calibración inicial, calibración final y fecha a predecir
if (is.null(ANIOS_VALIDACION)) {
  ternas <- expand.grid(a = anios, b = anios, c = anios) |>
    dplyr::filter(a < b, b < c, b - a == c - b) |>
    arrange(desc(c), desc(b - a))
  terna <- if (nrow(ternas) > 0) as.integer(unlist(ternas[1, ])) else NULL
} else {
  if (!all(ANIOS_VALIDACION %in% anios)) stop("ANIOS_VALIDACION contiene fechas que no están en la serie.")
  terna <- as.integer(sort(ANIOS_VALIDACION))
  if (diff(terna)[1] != diff(terna)[2]) {
    warning("Los dos intervalos de ANIOS_VALIDACION no duran lo mismo: la matriz se aplica tal cual.")
  }
}
hay_validacion <- !is.null(terna) && length(terna) == 3

if (hay_validacion) {
  jv  <- match(terna, anios)
  ok3 <- complete.cases(C[, jv])                 # píxeles con dato en las tres fechas
  c_a <- C[ok3, jv[1]]; c_b <- C[ok3, jv[2]]; c_o <- C[ok3, jv[3]]

  # 1. Calibración: matriz de transición entre las dos primeras fechas
  T_cal <- table(factor(c_a, 1:K), factor(c_b, 1:K))
  P_cal <- matrix(as.numeric(T_cal), K, K) / as.numeric(rowSums(T_cal))
  sin_origen <- rowSums(T_cal) == 0              # clase ausente al inicio: se supone que persiste
  P_cal[sin_origen, ] <- diag(K)[sin_origen, ]
  dimnames(P_cal) <- list(desde = CLASES_NDVI, hacia = CLASES_NDVI)
  stopifnot(all(abs(rowSums(P_cal) - 1) < 1e-8))

  # 2. Predicción de la tercera fecha. La cadena de Markov dice CUÁNTA superficie
  #    de cada clase pasa a cada otra, pero no QUÉ píxeles. Se prueban dos reglas:
  #    - clase más probable: cada píxel recibe el destino más probable de su clase;
  #    - reparto según NDVI: se respeta la superficie que espera la cadena y, dentro
  #      de cada clase de origen, bajan de clase los píxeles de NDVI más bajo y
  #      suben los de NDVI más alto.
  repartir_segun_ndvi <- function(clase, ndvi_valor, P) {
    pred <- integer(length(clase))
    for (i in 1:K) {
      idx <- which(clase == i)
      if (length(idx) == 0) next
      cortes <- round(cumsum(P[i, ]) * length(idx)); cortes[K] <- length(idx)
      pred[idx[order(ndvi_valor[idx])]] <- rep(1:K, times = diff(c(0, cortes)))
    }
    pred
  }
  pred_nulo   <- c_b                               # modelo nulo: nada cambia
  pred_map    <- max.col(P_cal, ties.method = "first")[c_b]
  pred_markov <- repartir_segun_ndvi(c_b, N[ok3, jv[2]], P_cal)
  ha_markov   <- as.numeric(tabulate(c_b, K) %*% P_cal) * ha_pixel

  # 3. Comparación con lo observado
  pesos_lin <- 1 - abs(outer(1:K, 1:K, "-")) / (K - 1)   # acuerdo parcial entre clases vecinas
  evaluar <- function(pred, obs, base) {
    cm <- table(factor(pred, 1:K), factor(obs, 1:K))
    p  <- cm / sum(cm); fila <- rowSums(p); col <- colSums(p)
    po <- sum(diag(p));          pe   <- sum(fila * col)
    po_w <- sum(pesos_lin * p);  pe_w <- sum(pesos_lin * outer(fila, col))
    cantidad <- sum(abs(fila - col)) / 2
    # Figura de mérito: de todo el cambio observado o predicho, qué parte se acertó
    cambio_obs <- obs != base; cambio_pred <- pred != base
    merito <- sum(cambio_obs & cambio_pred & pred == obs) / max(1, sum(cambio_obs | cambio_pred))
    list(matriz = cm,
         resumen = data.frame(exactitud = po, kappa = (po - pe) / (1 - pe),
                              kappa_pond = (po_w - pe_w) / (1 - pe_w),
                              cantidad = cantidad, asignacion = (1 - po) - cantidad,
                              merito = merito))
  }
  val_nulo   <- evaluar(pred_nulo,   c_o, c_b)
  val_map    <- evaluar(pred_map,    c_o, c_b)
  val_markov <- evaluar(pred_markov, c_o, c_b)
  tabla_val <- bind_rows("Persistencia (nada cambia)" = val_nulo$resumen,
                         "Cadena de Markov, clase más probable" = val_map$resumen,
                         "Cadena de Markov, reparto según NDVI" = val_markov$resumen,
                         .id = "modelo")

  # Escala de Landis y Koch (1977)
  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")))
  }
}

La matriz se calibra con 2005 → 2015 y se usa para predecir 2025, que se compara con el NDVI observado ese año. Los dos intervalos duran 10 años, así que la matriz se aplica directamente. Entran en la validación 13,650 ha: los píxeles con dato en las tres fechas.

Código
if (hay_validacion) kable(round(100 * P_cal, 2))
Tabla 20: Matriz de transición de calibración (% de la superficie de origen). Cada fila suma 100.
Sin vegetación Vegetación escasa Vegetación media Vegetación densa
Sin vegetación 99.93 0.07 0.00 0.00
Vegetación escasa 92.69 2.22 2.03 3.06
Vegetación media 11.79 6.65 38.35 43.20
Vegetación densa 0.45 0.71 15.29 83.55
Código
if (hay_validacion) {
  tabla_val |>
    mutate(across(c(exactitud, cantidad, asignacion), ~ round(100 * .x, 2)),
           across(c(kappa, kappa_pond, merito), ~ round(.x, 3)),
           lectura = leer_kappa(kappa)) |>
    setNames(c("Modelo", "Exactitud global (%)", "Kappa", "Kappa ponderado",
               "Desacuerdo de cantidad (%)", "Desacuerdo de asignación (%)", "Figura de mérito",
               "Lectura del Kappa")) |>
    kable()
}
Tabla 21: Validación de la predicción frente al NDVI observado.
Modelo Exactitud global (%) Kappa Kappa ponderado Desacuerdo de cantidad (%) Desacuerdo de asignación (%) Figura de mérito Lectura del Kappa
Persistencia (nada cambia) 78.89 0.552 0.712 3.25 17.87 0.000 moderado
Cadena de Markov, clase más probable 77.66 0.391 0.620 21.61 0.73 0.276 aceptable
Cadena de Markov, reparto según NDVI 73.99 0.469 0.647 5.25 20.76 0.256 moderado
Código
if (hay_validacion) {
  cm_ha <- unclass(val_markov$matriz) * ha_pixel
  dimnames(cm_ha) <- list(Predicha = CLASES_NDVI, Observada = CLASES_NDVI)
  kable(round(addmargins(as.table(cm_ha)), 1), format.args = list(big.mark = ","))
}
Tabla 22: Matriz de confusión de la cadena de Markov con reparto según NDVI (ha). Filas: clase predicha; columnas: clase observada.
Sin vegetación Vegetación escasa Vegetación media Vegetación densa Sum
Sin vegetación 1,134.4 309.2 215.4 77.2 1,736.3
Vegetación escasa 8.6 58.9 111.5 76.3 255.3
Vegetación media 31.8 171.2 932.8 1,367.2 2,502.9
Vegetación densa 31.0 94.3 1,056.8 7,973.3 9,155.3
Sum 1,205.8 633.6 2,316.4 9,494.0 13,649.9
Código
if (hay_validacion) {
  cantidades <- data.frame(
    clase     = CLASES_NDVI,
    observada = tabulate(c_o, K) * ha_pixel,
    markov    = ha_markov,
    nulo      = tabulate(c_b, K) * ha_pixel
  ) |>
    mutate(error_markov = markov - observada, error_nulo = nulo - observada)
  cantidades |>
    mutate(across(where(is.numeric), ~ round(.x, 1))) |>
    setNames(c("Clase", paste("Observada", terna[3]), "Esperada por Markov",
               paste0("Modelo nulo (= ", terna[2], ")"), "Error de Markov", "Error del modelo nulo")) |>
    kable(format.args = list(big.mark = ","))
}
Tabla 23: Superficie de cada clase en la fecha predicha: observada, esperada por la cadena de Markov y según el modelo nulo (ha).
Clase Observada 2025 Esperada por Markov Modelo nulo (= 2015) Error de Markov Error del modelo nulo
Sin vegetación 1,205.8 1,736.2 1,107.5 530.4 -98.3
Vegetación escasa 633.6 255.3 288.5 -378.3 -345.1
Vegetación media 2,316.4 2,502.9 2,704.7 186.5 388.3
Vegetación densa 9,494.0 9,155.4 9,549.1 -338.6 55.1
Código
if (hay_validacion) {
  # 1 = se observó menos vegetación de la predicha; 2 = se observó más
  discrepancia <- rep(NA_real_, nrow(px))
  discrepancia[ok3] <- ifelse(pred_markov == c_o, NA, ifelse(c_o < pred_markov, 1, 2))
  pal_disc <- colorFactor(c("#ff4d4d", "#4db8ff"), levels = 1:2, na.color = "transparent")
  mapa_base() |>
    addRasterImage(a_raster(discrepancia, "discrepancia"), colors = pal_disc, method = "ngb",
                   opacity = 0.85, group = "Discrepancias") |>
    addLegend(colors = c("#ff4d4d", "#4db8ff"), opacity = 1, position = "bottomleft",
              labels = c("Menos vegetación que la predicha", "Más vegetación que la predicha"),
              title = paste("Predicción de", terna[3])) |>
    addLayersControl(baseGroups = grupos_base, overlayGroups = c("Discrepancias", grupos_fijos),
                     options = layersControlOptions(collapsed = FALSE))
}
Figura 7: Dónde falla la predicción de la cadena de Markov con reparto según NDVI, sobre la imagen satelital. Solo se colorean los píxeles en que la clase predicha y la observada no coinciden.
Código
txt_valid <- if (hay_validacion) {
  km <- val_markov$resumen; kn <- val_nulo$resumen
  supera <- km$kappa > kn$kappa + 0.005
  peor_cantidad <- cantidades[which.max(abs(cantidades$error_markov)), ]
  paste0(
    "Calibrada con ", terna[1], "–", terna[2], ", la cadena de Markov con reparto según NDVI predice ", terna[3],
    " con una exactitud global de ", pct(km$exactitud), " y un Kappa de ", fmt(km$kappa, 3),
    " (acuerdo ", leer_kappa(km$kappa), "; Kappa ponderado ", fmt(km$kappa_pond, 3),
    "). Con la regla de la clase más probable el Kappa es ", fmt(val_map$resumen$kappa, 3),
    ". El modelo nulo de persistencia obtiene un Kappa de ", fmt(kn$kappa, 3), ": el modelo de cambio ",
    ifelse(supera, "**supera**", ifelse(abs(km$kappa - kn$kappa) <= 0.005, "**iguala**", "**no supera**")),
    " a la hipótesis de que nada cambia. Su error se reparte en ", pct(km$cantidad),
    " de cantidad y ", pct(km$asignacion), " de asignación: ",
    ifelse(km$asignacion > km$cantidad,
           "predomina el error de localización, es decir, el modelo estima razonablemente cuánta superficie hay de cada clase pero no dónde.",
           "predomina el error de cantidad, es decir, las tasas de cambio del periodo de calibración no se mantuvieron en el siguiente."),
    " La clase peor estimada en superficie es *", peor_cantidad$clase, "* (",
    ifelse(peor_cantidad$error_markov > 0, "sobrestimada", "subestimada"), " en ",
    fmt(abs(peor_cantidad$error_markov)), " ha). La figura de mérito es ", fmt(km$merito, 3),
    ": de todo el cambio observado o predicho, esa es la parte que el modelo acertó.")
} else "No hay tres fechas con el mismo intervalo, así que la matriz no se pudo validar."

Lectura. Calibrada con 2005–2015, la cadena de Markov con reparto según NDVI predice 2025 con una exactitud global de 74.0 % y un Kappa de 0.469 (acuerdo moderado; Kappa ponderado 0.647). Con la regla de la clase más probable el Kappa es 0.391. El modelo nulo de persistencia obtiene un Kappa de 0.552: el modelo de cambio no supera a la hipótesis de que nada cambia. Su error se reparte en 5.3 % de cantidad y 20.8 % de asignación: predomina el error de localización, es decir, el modelo estima razonablemente cuánta superficie hay de cada clase pero no dónde. La clase peor estimada en superficie es Sin vegetación (sobrestimada en 530 ha). La figura de mérito es 0.256: de todo el cambio observado o predicho, esa es la parte que el modelo acertó.

Al interpretar este resultado conviene recordar tres cosas. La cadena de Markov solo dice cuánta superficie cambia; qué píxeles cambian depende de la regla de reparto. La de la clase más probable manda todos los píxeles de una clase al mismo destino y por eso hace desaparecer clases enteras; la de reparto según NDVI respeta las superficies esperadas, pero supone que dentro de cada clase pierden vegetación los píxeles que ya tenían menos, lo cual es un supuesto y no un hecho. En segundo lugar, las tres fechas provienen de sensores distintos y, aunque están normalizadas, parte del desacuerdo es ruido de las imágenes y no cambio real. Por último, un Kappa alto no valida el modelo si el modelo nulo obtiene uno igual o mayor: indica solo que la mayor parte del territorio no cambió.

9 Proyección de Markov y escenarios

Si las probabilidades de cambio de la última década se mantuvieran, ¿cuánta vegetación más se perdería? Para responderlo se construye una cadena de Markov con cinco estados: las cuatro clases de NDVI y un quinto estado, cicatriz, para los píxeles que perdieron la vegetación de forma permanente. La cicatriz es un estado sin retorno: por definición, un píxel que entra en él no vuelve a salir.

La matriz se calibra con el intervalo más reciente de la serie y se aplica hacia adelante en pasos de la misma duración. Sobre esa base se comparan los escenarios, que se distinguen por un solo parámetro: el factor por el que se multiplica la probabilidad de pasar a cicatriz.

  • Tendencial (factor 1): la última década se repite.
  • Disruptivo (factor mayor que 1): la pérdida permanente se acelera, porque el deterioro del humedal costero se intensifica o porque se ejecutan obras que hoy están en proyecto.
  • De política (factor menor que 1): protección o restauración efectiva que frena la pérdida.
Código
ESTADOS <- c("Cicatriz", CLASES_NDVI)
S <- length(ESTADOS)

# Estado de cada píxel en la fecha j: cicatriz si ya había aparecido; si no, su clase de NDVI
estado_en <- function(j) {
  e <- C[, j] + 1L
  e[!is.na(px$anio_cic) & px$anio_cic <= anios[j]] <- 1L
  e
}
# Calibración: el último intervalo de la terna de validación o, si no la hay, las dos últimas fechas
if (hay_validacion) {
  j_a <- match(terna[2], anios); j_b <- match(terna[3], anios)
} else {
  j_a <- length(anios) - 1; j_b <- length(anios)
}
paso <- anios[j_b] - anios[j_a]
anio_base  <- anios[j_b]
anios_proy <- anio_base + paso * seq_len(PASOS_PROYECCION)
e_a <- estado_en(j_a); e_b <- estado_en(j_b)

matriz_estados <- function(x, y, sel = rep(TRUE, length(x))) {
  ok  <- sel & !is.na(x) & !is.na(y)
  tab <- table(factor(x[ok], 1:S), factor(y[ok], 1:S))
  P   <- matrix(as.numeric(tab), S, S) / pmax(as.numeric(rowSums(tab)), 1)
  vacias <- rowSums(tab) == 0
  P[vacias, ] <- diag(S)[vacias, ]              # estado ausente: se supone que persiste
  dimnames(P) <- list(desde = ESTADOS, hacia = ESTADOS)
  P
}
ha_por_estado <- function(x, sel = rep(TRUE, length(x))) tabulate(x[sel & !is.na(x)], S) * ha_pixel

# Escenario: la probabilidad de pasar a cicatriz se multiplica por k, y la
# diferencia se toma de (o se devuelve a) la persistencia del estado de origen
escalar <- function(P, k) {
  Q <- P
  for (i in 2:S) {
    nueva <- min(P[i, 1] * k, P[i, 1] + P[i, i])
    Q[i, i] <- P[i, i] - (nueva - P[i, 1]); Q[i, 1] <- nueva
  }
  Q
}
proyectar <- function(v0, P, pasos) {
  v <- v0; filas <- vector("list", pasos)
  for (p in seq_len(pasos)) { v <- as.numeric(v %*% P); filas[[p]] <- v }
  do.call(rbind, filas)
}

P_proy <- matriz_estados(e_a, e_b)
stopifnot(all(abs(rowSums(P_proy) - 1) < 1e-8), P_proy[1, 1] == 1)
v_base <- ha_por_estado(e_b)

# Proyección de cada escenario: una fila por año proyectado y una columna por estado
proy <- lapply(ESCENARIOS, function(k) {
  m <- proyectar(v_base, escalar(P_proy, k), PASOS_PROYECCION)
  dimnames(m) <- list(anios_proy, ESTADOS); m
})
vegetado <- CLASES_NDVI[CORTES_NDVI[seq_along(CLASES_NDVI)] >= 0.4]   # clases con vegetación media o densa
tabla_esc <- do.call(rbind, lapply(names(proy), function(nm) {
  m <- proy[[nm]]
  fila <- data.frame(escenario = nm, factor = ESCENARIOS[[nm]], base = v_base[1])
  for (a in rownames(m)) fila[[paste0("cic_", a)]] <- m[a, "Cicatriz"]
  fila$veg_base  <- sum(v_base[match(vegetado, ESTADOS)])
  fila$veg_final <- sum(m[nrow(m), vegetado])
  fila
}))

La calibración usa el intervalo 2015 → 2025 (10 años) y la proyección avanza en pasos de 10 años hasta 2045.

Código
kable(round(100 * P_proy, 2))
Tabla 24: Matriz de transición usada para proyectar (% de la superficie de origen). La cicatriz es un estado sin retorno.
Cicatriz Sin vegetación Vegetación escasa Vegetación media Vegetación densa
Cicatriz 100.00 0.00 0.00 0.00 0.00
Sin vegetación 0.00 91.36 6.66 1.85 0.13
Vegetación escasa 14.93 12.65 42.44 26.18 3.80
Vegetación media 7.42 0.11 8.44 44.42 39.62
Vegetación densa 0.73 0.00 0.29 10.83 88.14

9.1 Escenarios

Código
tabla_esc |>
  mutate(across(where(is.numeric) & !factor, ~ round(.x))) |>
  setNames(c("Escenario", "Factor sobre la pérdida", paste("Cicatriz", anio_base),
             paste("Cicatriz", anios_proy), paste("Vegetación media y densa", anio_base),
             paste("Vegetación media y densa", max(anios_proy)))) |>
  kable(format.args = list(big.mark = ","), row.names = FALSE)
Tabla 25: Superficie de cicatriz (pérdida permanente de vegetación) en cada escenario, y vegetación media y densa que quedaría al final (ha).
Escenario Factor sobre la pérdida Cicatriz 2025 Cicatriz 2035 Cicatriz 2045 Vegetación media y densa 2025 Vegetación media y densa 2045
Tendencial 1.0 570 873 1,172 11,848 11,230
Disruptivo 2.0 570 1,177 1,730 11,848 10,780
De política 0.5 570 721 876 11,848 11,465
De política, fuerte 0.0 570 570 570 11,848 11,706
Código
observado <- data.frame(anio = anios, escenario = "Observado",
                        ha = vapply(anios, function(a) sum(px$ha[!is.na(px$anio_cic) & px$anio_cic <= a]), 0))
trayectorias <- do.call(rbind, lapply(names(proy), function(nm) {
  data.frame(anio = c(anio_base, anios_proy), escenario = nm, ha = c(v_base[1], proy[[nm]][, "Cicatriz"]))
}))
colores_esc <- c("Observado" = "#ffffff",
                 setNames(c("#ffc533", "#ff4d4d", "#66bb6a", "#4db8ff", "#b39ddb", "#f48fb1")[seq_along(proy)],
                          names(proy)))
ggplot(mapping = aes(anio, ha, colour = escenario)) +
  geom_line(data = trayectorias, linewidth = 1, linetype = "dashed") +
  geom_point(data = trayectorias, size = 2.5) +
  geom_line(data = observado, linewidth = 1.2) + geom_point(data = observado, size = 2.5) +
  scale_colour_manual(values = colores_esc, breaks = names(colores_esc), name = NULL) +
  scale_y_continuous(labels = label_number(big.mark = ",")) +
  labs(x = NULL, y = "Cicatriz acumulada (ha)") +
  tema_oscuro() + theme(legend.position = "bottom")
Figura 8: Cicatriz acumulada: lo observado en la serie y lo proyectado en cada escenario.

9.2 Sensibilidad

El resultado depende sobre todo del factor aplicado a la pérdida permanente. La tabla muestra cuánta cicatriz habría con distintos valores, sin cambiar nada más.

Código
sens <- t(vapply(SENSIBILIDAD_K, function(k) proyectar(v_base, escalar(P_proy, k), PASOS_PROYECCION)[, 1],
                 numeric(PASOS_PROYECCION)))
if (PASOS_PROYECCION == 1) sens <- t(sens)
data.frame(factor = SENSIBILIDAD_K, round(sens)) |>
  setNames(c("Factor", paste("Cicatriz", anios_proy))) |>
  kable(format.args = list(big.mark = ","))
Tabla 26: Sensibilidad de la cicatriz proyectada (ha) al factor sobre la probabilidad de pasar a cicatriz.
Factor Cicatriz 2035 Cicatriz 2045
0.00 570 570
0.25 645 724
0.50 721 876
0.75 797 1,026
1.00 873 1,172
1.50 1,025 1,457
2.00 1,177 1,730
3.00 1,481 2,242
Código
# Segunda sensibilidad: calibrar con el intervalo anterior, si existe en la serie
j_ant <- match(anios[j_a] - paso, anios)
hay_periodo_previo <- !is.na(j_ant)
if (hay_periodo_previo) {
  P_previa   <- matriz_estados(estado_en(j_ant), e_a)
  alt_previa <- proyectar(v_base, P_previa, PASOS_PROYECCION)[, 1]
}
tend <- proy[[names(ESCENARIOS)[which.min(abs(ESCENARIOS - 1))]]]
txt_periodo <- if (hay_periodo_previo) {
  paste0("Si la matriz se calibrara con el intervalo anterior (", anios[j_ant], " → ", anios[j_a],
         "), la tendencia daría ", fmt(alt_previa[PASOS_PROYECCION]), " ha de cicatriz en ", max(anios_proy),
         " en lugar de ", fmt(tend[PASOS_PROYECCION, "Cicatriz"]), " ha.")
} else "La serie no tiene un intervalo anterior de la misma duración con el que repetir la calibración."

Si la matriz se calibrara con el intervalo anterior (2005 → 2015), la tendencia daría 975 ha de cicatriz en 2045 en lugar de 1,172 ha.

9.3 Dónde ocurriría

Una sola matriz para todo el municipio reparte la pérdida como si cualquier lugar tuviera el mismo riesgo. Como las cicatrices se concentran en la costa, la cadena se calcula también por separado para la franja costera y para el resto, con la tendencia observada en cada zona.

Código
zonas <- sort(unique(px$franja))
P_zona <- lapply(setNames(zonas, zonas), function(z) matriz_estados(e_a, e_b, px$franja == z))
proy_zona <- do.call(rbind, lapply(zonas, function(z) {
  v0 <- ha_por_estado(e_b, px$franja == z)
  m  <- proyectar(v0, P_zona[[z]], PASOS_PROYECCION)
  fila <- data.frame(zona = z, superficie = sum(v0), base = v0[1])
  for (p in seq_len(PASOS_PROYECCION)) fila[[paste0("cic_", anios_proy[p])]] <- m[p, 1]
  fila$veg_base  <- sum(v0[match(vegetado, ESTADOS)])
  fila$veg_final <- sum(m[PASOS_PROYECCION, match(vegetado, ESTADOS)])
  fila
}))
proy_zona |>
  mutate(across(where(is.numeric), ~ round(.x))) |>
  setNames(c("Zona", "Superficie", paste("Cicatriz", anio_base), paste("Cicatriz", anios_proy),
             paste("Vegetación media y densa", anio_base),
             paste("Vegetación media y densa", max(anios_proy)))) |>
  kable(format.args = list(big.mark = ","), row.names = FALSE)
Tabla 27: Proyección tendencial calculada por separado en la franja costera y en el resto del municipio (ha).
Zona Superficie Cicatriz 2025 Cicatriz 2035 Cicatriz 2045 Vegetación media y densa 2025 Vegetación media y densa 2045
Franja costera (a 4,000 m o menos del mar) 3,038 552 819 1,051 1,277 787
Resto del municipio 10,650 18 33 48 10,571 10,536
Código
# Riesgo de cada píxel que aún no es cicatriz: la probabilidad de su estado en la matriz de su zona
riesgo <- rep(NA_real_, nrow(px))
for (z in zonas) {
  sel <- px$franja == z & !is.na(e_b) & e_b > 1
  riesgo[sel] <- P_zona[[z]][e_b[sel], 1]
}
riesgo[!is.na(riesgo) & riesgo < 0.01] <- NA          # se deja ver el fondo donde el riesgo es casi nulo
pal_riesgo <- colorNumeric("YlOrRd", domain = c(0, max(riesgo, na.rm = TRUE)), na.color = "transparent")
m <- mapa_base() |>
  addRasterImage(a_raster(riesgo, "riesgo"), colors = pal_riesgo, method = "ngb", opacity = 0.8,
                 group = "Riesgo de cicatriz") |>
  addLegend(pal = pal_riesgo, values = c(0, max(riesgo, na.rm = TRUE)), position = "bottomleft",
            title = paste0("Probabilidad en ", paso, " años"),
            labFormat = labelFormat(suffix = " %", transform = function(x) round(100 * x)))
if (hay_cicatrices) {
  m <- addPolygons(m, data = st_transform(cicatrices, 4326), fill = FALSE, color = "#ffffff", weight = 1,
                   group = "Cicatrices actuales")
}
m |> addLayersControl(baseGroups = grupos_base,
                      overlayGroups = c("Riesgo de cicatriz", if (hay_cicatrices) "Cicatrices actuales", grupos_fijos),
                      options = layersControlOptions(collapsed = FALSE))
Figura 9: Probabilidad de que cada píxel pase a cicatriz en el próximo periodo, según su estado actual y la tendencia de su zona, sobre la imagen satelital. En blanco, el contorno de las cicatrices que ya existen. Solo se colorean los píxeles con probabilidad apreciable.

9.4 El escenario disruptivo y los proyectos eólicos

Código
# Si alguna capa eólica trae la superficie de cambio de uso de suelo declarada
# por el proyecto, se usa para dimensionar su huella directa.
etiquetas_proy <- c(Nombre = "Proyecto", Fase = "Fase", Empresa = "Empresa", Municipio = "Municipios",
                    PotenciaMW = "Potencia (MW)", N_Turbin = "Aerogeneradores",
                    Camb_Suelo = "Cambio de uso de suelo declarado (ha)",
                    Area_Ejido = "Superficie sobre ejido (ha)", Fecha = "Año")
proyectos <- NULL
for (f in RUTA_EOLICO[file.exists(RUTA_EOLICO)]) {
  x <- st_read(f, quiet = TRUE)
  if (!"Camb_Suelo" %in% names(x)) next
  x <- st_transform(st_make_valid(x), CRS_METRICO)
  x <- x[lengths(st_intersects(x, limite_m)) > 0, ]
  if (nrow(x) == 0) next
  x$ha_en_municipio <- round(as.numeric(st_area(suppressWarnings(st_intersection(st_geometry(x), st_geometry(limite_m))))) / 1e4, 1)
  proyectos <- st_drop_geometry(x)[, c(intersect(names(etiquetas_proy), names(x)), "ha_en_municipio")]
}
hay_proyectos <- !is.null(proyectos)
Código
if (hay_proyectos) {
  mostrar <- proyectos
  names(mostrar) <- c(etiquetas_proy[names(proyectos)[-ncol(proyectos)]], "Polígono dentro del municipio (ha)")
  kable(t(mostrar), col.names = rep("", nrow(mostrar)))
}
Tabla 28: Proyectos eólicos que tocan el municipio, según la capa cargada.
Proyecto Chicxulub
Fase En proyecto
Empresa Eólica del Mayab, S.A.P.I de C.V
Municipios Motul y Ixil
Potencia (MW) 71
Aerogeneradores 20
Cambio de uso de suelo declarado (ha) 78.8
Superficie sobre ejido (ha) 639
Año 2018
Polígono dentro del municipio (ha) 225.6
Código
nuevas_tend <- tend[1, "Cicatriz"] - v_base[1]                 # cicatriz nueva en el primer paso tendencial
txt_huella <- if (hay_proyectos) {
  cs <- suppressWarnings(sum(as.numeric(proyectos$Camb_Suelo), na.rm = TRUE))
  paste0("La capa registra ",
         ifelse(nrow(proyectos) == 1, "un proyecto que toca", paste(nrow(proyectos), "proyectos que tocan")),
         " el municipio, con ", fmt(cs, 1), " ha de cambio de uso de suelo declaradas en total",
         if (hay_linea) paste0(" y una línea de transmisión de ", fmt(km_linea_mun, 1), " km dentro de Ixil") else "",
         ". Esa huella directa equivale al ", fmt(100 * cs / nuevas_tend, 0),
         " % de la cicatriz nueva que la tendencia añade en ", paso, " años (", fmt(nuevas_tend),
         " ha), pero caería en el interior del municipio, donde hoy casi no hay cicatrices.")
} else "Las capas eólicas cargadas no traen la superficie de cambio de uso de suelo declarada, así que la huella directa de los proyectos no se puede dimensionar aquí."

fila_esc <- function(nm) tabla_esc[tabla_esc$escenario == nm, ]
ultimo <- paste0("cic_", max(anios_proy))
txt_proy <- paste0(
  "Calibrada con ", anios[j_a], "–", anio_base, ", la cadena de Markov lleva la cicatriz de ", fmt(v_base[1]),
  " ha en ", anio_base, " a ", fmt(tend[1, "Cicatriz"]), " ha en ", anios_proy[1],
  if (PASOS_PROYECCION > 1) paste0(" y ", fmt(tend[PASOS_PROYECCION, "Cicatriz"]), " ha en ", max(anios_proy)) else "",
  " si la tendencia se mantiene. En ", max(anios_proy), " los escenarios van de ",
  fmt(min(tabla_esc[[ultimo]])), " ha (", tabla_esc$escenario[which.min(tabla_esc[[ultimo]])], ") a ",
  fmt(max(tabla_esc[[ultimo]])), " ha (", tabla_esc$escenario[which.max(tabla_esc[[ultimo]])], "). ",
  "Calculada por zonas, la franja costera pasaría de ",
  fmt(proy_zona$base[grepl("^Franja", proy_zona$zona)]), " a ",
  fmt(proy_zona[[ultimo]][grepl("^Franja", proy_zona$zona)]), " ha de cicatriz, y su vegetación media y densa de ",
  fmt(proy_zona$veg_base[grepl("^Franja", proy_zona$zona)]), " a ",
  fmt(proy_zona$veg_final[grepl("^Franja", proy_zona$zona)]), " ha.")

La capa registra un proyecto que toca el municipio, con 78.8 ha de cambio de uso de suelo declaradas en total y una línea de transmisión de 12.4 km dentro de Ixil. Esa huella directa equivale al 26 % de la cicatriz nueva que la tendencia añade en 10 años (304 ha), pero caería en el interior del municipio, donde hoy casi no hay cicatrices.

Esto separa dos presiones que conviene no confundir. La pérdida que se proyecta es, casi toda, la del humedal costero, y no depende de los proyectos eólicos, que hasta ahora no han dejado huella. Si esos proyectos se construyeran, su efecto directo sería menor en superficie, pero recaería en otra parte del municipio y sobre otro tipo de tenencia.

Lectura. Calibrada con 2015–2025, la cadena de Markov lleva la cicatriz de 570 ha en 2025 a 873 ha en 2035 y 1,172 ha en 2045 si la tendencia se mantiene. En 2045 los escenarios van de 570 ha (De política, fuerte) a 1,730 ha (Disruptivo). Calculada por zonas, la franja costera pasaría de 552 a 1,051 ha de cicatriz, y su vegetación media y densa de 1,277 a 787 ha.

9.5 Cómo leer esta proyección

  • Es una comparación de escenarios, no un pronóstico. En la validación, la cadena de Markov no superó al modelo que supone que nada cambia. Los números sirven para comparar qué pasaría con más o menos presión, no para anunciar una cifra.
  • Los factores son supuestos. Duplicar la pérdida o reducirla a la mitad son valores ilustrativos. Un escenario de política bien fundado debería apoyarse en lo que la normativa permite o prohíbe, y uno disruptivo en la superficie que los proyectos declaran.
  • La cicatriz no tiene retorno. A muy largo plazo, una cadena con un estado así convierte todo en cicatriz. Es una propiedad matemática del modelo y no una predicción; por eso la proyección se limita a 2 pasos.
  • La cadena dice cuánto, no dónde. El mapa de riesgo asigna a cada píxel la probabilidad de su estado y de su zona; no usa la distancia al agua, la elevación ni ninguna otra variable que explique por qué un sitio se pierde y otro no.
  • Las cicatrices de la última fecha no están confirmadas por una fecha posterior, y forman parte del periodo con que se calibra.

10 Principales hallazgos

Código
frase_trans <- function(i) {
  if (i > nrow(trans)) return("(no hay más transiciones)")
  paste0("*", trans$desde[i], " → ", trans$hacia[i], "* (", fmt(trans$ha[i], 1), " ha, ",
         fmt(trans$pct_del_cambio[i], 1), " % de todo el cambio)")
}
con_area <- balance[balance$ha_t1 > 0, ]
persist  <- con_area$persistencia / con_area$ha_t1
mas_estable  <- con_area$clase[which.max(persist)]
mas_dinamico <- con_area$clase[which.min(persist)]

vegetado_ha <- sum(px$ha[px$vegetado_t1])
cicatriz_ha <- sum(px$ha[px$tipo == "Cicatriz candidata"])
peor_periodo <- periodos[which.max(periodos$baja), ]

txt_cic <- if (hay_cicatrices) {
  pico <- cic_anio[which.max(cic_anio$ha), ]
  sin_conf <- sum(cic_anio$ha[cic_anio$confirmada != "Sí"])
  paste0(fmt(cicatriz_ha, 1), " ha (", pct(cicatriz_ha / vegetado_ha, 2), " de la superficie que tenía vegetación en ",
         ANIO_T1, ") perdieron la vegetación y no la recuperaron. Se agrupan en ", nrow(cicatrices),
         " parches; el parche mediano mide ", fmt(median(cicatrices$ha), 2), " ha y el mayor ",
         fmt(max(cicatrices$ha), 1), " ha. La fecha con más superficie nueva de cicatriz es ", pico$anio,
         " (", fmt(pico$ha, 1), " ha). De ese total, ", fmt(sin_conf, 1),
         " ha aparecen en la última fecha y están sin confirmar.")
} else "No se detectó ninguna con la definición actual."

txt_ejidos <- if (hay_ejidos && hay_cicatrices) {
  ej <- zona_tenencia[zona_tenencia$zona == ETIQUETA_EJIDOS, ]
  if (nrow(ej) == 0) {
    "Ningún píxel del municipio cae dentro de la capa de tenencia cargada."
  } else {
    paste0("El ", fmt(ej$pct_de_cicatrices, 1), " % de la superficie de cicatrices (", fmt(ej$cicatriz, 1),
           " ha) cae dentro de la capa de tenencia, que concentraba el ", pct(ej$vegetado_t1 / vegetado_ha),
           " de la vegetación de ", ANIO_T1, " (índice de concentración: ", fmt(ej$concentracion, 2), ").")
  }
} else "Sin capa de tenencia (o sin cicatrices) no es posible cuantificar este cruce."

txt_eolico <- if (hay_eolico && hay_cicatrices) {
  eo <- zona_eolico[grepl("o menos", zona_eolico$zona), ]
  if (nrow(eo) == 0) {
    paste0("Ningún píxel del municipio está a ", BUFFER_EOLICO_M, " m o menos de la infraestructura cargada.")
  } else {
    paste0("A ", BUFFER_EOLICO_M, " m o menos de la infraestructura eólica se localiza el ",
           fmt(eo$pct_de_cicatrices, 1), " % de las cicatrices (", fmt(eo$cicatriz, 1),
           " ha); allí se convirtió en cicatriz el ", fmt(eo$pct_vegetado_perdido, 2),
           " % de la vegetación que había en ", ANIO_T1, " (índice de concentración: ",
           fmt(eo$concentracion, 2), ").")
  }
} else "Sin capa de infraestructura eólica las cicatrices no pueden atribuirse a un proyecto."

1. Magnitud del cambio. Entre 2001 y 2025 cambió de clase de NDVI el 31.1 % de Ixil (4,260 de 13,687 ha). De ese cambio, 1,149 ha son cambio neto y 3,111 ha son intercambio (la misma clase pierde en un lugar y gana en otro).

2. Sentido del cambio. 2,697 ha bajaron de clase (menos vegetación) y 1,563 ha subieron (más vegetación).

3. Hacia dónde. La transición dominante es Vegetación media → Vegetación densa (1,509.2 ha, 35.4 % de todo el cambio). Le siguen Vegetación densa → Vegetación media (1,225.3 ha, 28.8 % de todo el cambio) y Vegetación densa → Sin vegetación (419.9 ha, 9.9 % de todo el cambio).

4. Persistencia. La clase más estable es Sin vegetación (98.6 % de su superficie no cambió) y la más dinámica es Vegetación escasa (26.0 %).

5. Trayectoria. El periodo con más superficie que baja de clase es 2008 → 2010 (2,180 ha).

6. Cicatrices candidatas. 569.5 ha (4.64 % de la superficie que tenía vegetación en 2001) perdieron la vegetación y no la recuperaron. Se agrupan en 172 parches; el parche mediano mide 0.50 ha y el mayor 351.5 ha. La fecha con más superficie nueva de cicatriz es 2020 (288.1 ha). De ese total, 17.8 ha aparecen en la última fecha y están sin confirmar.

7. Propiedad ejidal. El 2.7 % de la superficie de cicatrices (15.4 ha) cae dentro de la capa de tenencia, que concentraba el 45.7 % de la vegetación de 2001 (índice de concentración: 0.06).

8. Proyectos eólicos. A 500 m o menos de la infraestructura eólica se localiza el 0.0 % de las cicatrices (0.0 ha); allí se convirtió en cicatriz el 0.00 % de la vegetación que había en 2001 (índice de concentración: 0.00).

9. Línea de transmisión eléctrica. De los 172 parches de cicatriz, 0 se sobreponen a la línea de transmisión y 0 entran en el corredor de 100 m (0.0 ha de cicatriz, el 0.00 % del total). El parche más cercano queda a 3,079 m de la línea. Las cicatrices no se sobreponen a la línea de transmisión: a lo largo de sus 12.4 km dentro del municipio no hay ninguna pérdida permanente de vegetación. La capa registra la línea con la fase «En proyecto», lo que es coherente con que no haya dejado huella en las imágenes.

10. Costa. 133 de los 172 parches de cicatriz (77.3 %) se alojan en la franja costera. Por superficie, la franja concentra 551.7 ha de las 569.5 ha de cicatrices (96.9 %), aunque solo ocupa el 22.2 % del municipio: su índice de concentración es 6.04. En esa franja, el 28.1 % de la superficie que tenía vegetación en 2001 terminó como cicatriz. A menos de 1 km del mar hay 81.8 ha. Fuera de la franja quedan 39 parches (17.8 ha).

11. Validación (índice Kappa). Calibrada con 2005–2015, la cadena de Markov con reparto según NDVI predice 2025 con una exactitud global de 74.0 % y un Kappa de 0.469 (acuerdo moderado; Kappa ponderado 0.647). Con la regla de la clase más probable el Kappa es 0.391. El modelo nulo de persistencia obtiene un Kappa de 0.552: el modelo de cambio no supera a la hipótesis de que nada cambia. Su error se reparte en 5.3 % de cantidad y 20.8 % de asignación: predomina el error de localización, es decir, el modelo estima razonablemente cuánta superficie hay de cada clase pero no dónde. La clase peor estimada en superficie es Sin vegetación (sobrestimada en 530 ha). La figura de mérito es 0.256: de todo el cambio observado o predicho, esa es la parte que el modelo acertó.

12. Proyección y escenarios. Calibrada con 2015–2025, la cadena de Markov lleva la cicatriz de 570 ha en 2025 a 873 ha en 2035 y 1,172 ha en 2045 si la tendencia se mantiene. En 2045 los escenarios van de 570 ha (De política, fuerte) a 1,730 ha (Disruptivo). Calculada por zonas, la franja costera pasaría de 552 a 1,051 ha de cicatriz, y su vegetación media y densa de 1,277 a 787 ha. La capa registra un proyecto que toca el municipio, con 78.8 ha de cambio de uso de suelo declaradas en total y una línea de transmisión de 12.4 km dentro de Ixil. Esa huella directa equivale al 26 % de la cicatriz nueva que la tendencia añade en 10 años (304 ha), pero caería en el interior del municipio, donde hoy casi no hay cicatrices.

11 Verificaciones

Código
data.frame(
  control = c("Imágenes usadas / previstas",
              "Las filas de la matriz de probabilidades suman 1",
              "Superficie analizada frente a la del polígono de Ixil (diferencia %)",
              "Píxeles del municipio excluidos por no tener dato en la primera o la última fecha (%)",
              "Suma de transiciones + persistencia = superficie analizada",
              "Normalización entre fechas",
              "Resolución del análisis (m)"),
  resultado = c(
    paste(nrow(disp), "/", nrow(imagenes)),
    ifelse(all(abs(rowSums(P)[tot_fila > 0] - 1) < 1e-8), "Sí", "NO"),
    fmt(100 * (area_total - area_limite_ha) / area_limite_ha, 2),
    fmt(100 * (1 - sum(validos) / sum(en_mun)), 2),
    ifelse(isTRUE(all.equal(sum(trans$ha) + sum(diag(M_ha)), area_total)), "Sí", "NO"),
    ifelse(NORMALIZAR, paste("Sí, respecto a", anios[j_ref]), "No"),
    as.character(RES_ANALISIS))
) |> kable(col.names = c("Control", "Resultado"))
Tabla 29: Controles de consistencia del diagnóstico.
Control Resultado
Imágenes usadas / previstas 10 / 10
Las filas de la matriz de probabilidades suman 1 Sí
Superficie analizada frente a la del polígono de Ixil (diferencia %) 0.00
Píxeles del municipio excluidos por no tener dato en la primera o la última fecha (%) 0.00
Suma de transiciones + persistencia = superficie analizada Sí
Normalización entre fechas Sí, respecto a 2001
Resolución del análisis (m) 30

12 Exportación

Código
write.csv(as.data.frame.matrix(round(M_ha, 2)),
          file.path(dir_salidas, "ndvi_matriz_transicion_ha.csv"))
write.csv(as.data.frame.matrix(round(P, 6)),
          file.path(dir_salidas, "ndvi_matriz_transicion_probabilidad.csv"))
write.csv(balance, file.path(dir_salidas, "ndvi_balance_por_clase.csv"), row.names = FALSE)
write.csv(trans, file.path(dir_salidas, "ndvi_transiciones_ordenadas.csv"), row.names = FALSE)
write.csv(periodos, file.path(dir_salidas, "ndvi_cambio_por_periodo.csv"), row.names = FALSE)

# Serie de NDVI normalizado (una banda por fecha) y año de aparición de cada cicatriz
names(ndvi) <- paste0("ndvi_", anios)
writeRaster(ndvi, file.path(dir_salidas, "ixil_ndvi_normalizado.tif"), overwrite = TRUE)
writeRaster(a_raster(px$anio_cic, "anio_cicatriz"),
            file.path(dir_salidas, "ixil_cicatrices_anio.tif"), overwrite = TRUE, datatype = "INT2U")

if (hay_cicatrices) {
  ruta_cic <- file.path(dir_salidas, "cicatrices_candidatas_ndvi.gpkg")
  if (file.exists(ruta_cic)) file.remove(ruta_cic)
  st_write(cicatrices, ruta_cic, quiet = TRUE)
}

El archivo cicatrices_candidatas_ndvi.gpkg se puede abrir en QGIS.

13 Referencias

Cheng, Joe, Barret Schloerke, Bhaskar Karambelkar, y Yihui Xie. 2024. leaflet: Create Interactive Web Maps with the JavaScript «Leaflet» Library. https://CRAN.R-project.org/package=leaflet.
Drusch, M., U. Del Bello, S. Carlier, et al. 2012. «Sentinel-2: ESA’s Optical High-Resolution Mission for GMES Operational Services». Remote Sensing of Environment 120: 25-36. https://doi.org/10.1016/j.rse.2011.11.026.
Hijmans, Robert J. 2025. terra: Spatial Data Analysis. https://CRAN.R-project.org/package=terra.
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.
Pasquarella, Valerie J., Christopher F. Brown, Wanda Czerwinski, y William J. Rucklidge. 2023. «Comprehensive Quality Assessment of Optical Satellite Imagery Using Weakly Supervised Video Learning». Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition Workshops.
Pebesma, Edzer. 2018. «Simple Features for R: Standardized Support for Spatial Vector Data». The R Journal 10 (1): 439-46. https://doi.org/10.32614/RJ-2018-009.
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.
Pontius, Robert Gilmore, Emily Shusas, y Menzie McEachern. 2004. «Detecting Important Categorical Land Changes While Accounting for Persistence». Agriculture, Ecosystems & Environment 101 (2–3): 251-68. https://doi.org/10.1016/j.agee.2003.09.008.
Roy, D. P., V. Kovalskyy, H. K. Zhang, et al. 2016. «Characterization of Landsat-7 to Landsat-8 Reflective Wavelength and Normalized Difference Vegetation Index Continuity». Remote Sensing of Environment 185: 57-70. https://doi.org/10.1016/j.rse.2015.12.024.
Wulder, Michael A., Thomas R. Loveland, David P. Roy, et al. 2019. «Current Status of Landsat Program, Science, and Applications». Remote Sensing of Environment 225: 127-47. https://doi.org/10.1016/j.rse.2019.02.015.
Zhu, Zhe, y Curtis E. Woodcock. 2012. «Object-Based Cloud and Cloud Shadow Detection in Landsat Imagery». Remote Sensing of Environment 118: 83-94. https://doi.org/10.1016/j.rse.2011.10.028.
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] leaflet_2.2.3 scales_1.4.0  knitr_1.52    ggplot2_4.0.3 tidyr_1.3.2  
[6] dplyr_1.2.1   sf_1.1-0      terra_1.9-11 

loaded via a namespace (and not attached):
 [1] s2_1.1.9                generics_0.1.4          class_7.3-23           
 [4] KernSmooth_2.23-26      digest_0.6.39           magrittr_2.0.5         
 [7] evaluate_1.0.5          grid_4.4.3              RColorBrewer_1.1-3     
[10] fastmap_1.2.0           jsonlite_2.0.0          e1071_1.7-17           
[13] DBI_1.3.0               purrr_1.2.2             crosstalk_1.2.2        
[16] jquerylib_0.1.4         codetools_0.2-20        cli_3.6.6              
[19] rlang_1.3.0             units_1.0-1             withr_3.0.3            
[22] yaml_2.3.12             otel_0.2.0              tools_4.4.3            
[25] png_0.1-9               vctrs_0.7.3             R6_2.6.1               
[28] proxy_0.4-29            lifecycle_1.0.5         classInt_0.4-11        
[31] leaflet.providers_3.0.0 htmlwidgets_1.6.4       pkgconfig_2.0.3        
[34] pillar_1.11.1           gtable_0.3.6            glue_1.8.1             
[37] Rcpp_1.1.1              xfun_0.57               tibble_3.3.1           
[40] tidyselect_1.2.1        rstudioapi_0.19.0       farver_2.1.2           
[43] htmltools_0.5.9         labeling_0.4.3          rmarkdown_2.32         
[46] wk_0.9.5                compiler_4.4.3          S7_0.2.2