Esta página caracteriza la amenaza de calor con dos fuentes complementarias: la temperatura de superficie observada por Landsat (dónde hace más calor dentro del barrio) y la serie diaria de temperatura máxima (cuántos días y olas de calor hay, con un umbral relativo al clima local).
Temperatura de superficie y vegetación¶
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import Markdown, display
from sidro import process, viz
from sidro.analisis import clima, espacial
from sidro.config import aoi_cfg, parametros
from sidro.fetch import obtener_capa
ruta = intentar(obtener_capa, "temperatura_superficial",
mensaje="No se pudo obtener Landsat (requiere acceso a Planetary Computer)")
if ruta is not None:
bandas = espacial.bandas_landsat(process.abrir_raster(ruta))
lst, ndvi, validas = bandas["lst"], bandas["ndvi"], bandas["validas"]
v = lst.values[np.isfinite(lst.values)]
display(pd.DataFrame({
"LST (°C)": pd.Series({"mínima": v.min(), "mediana": np.median(v), "máxima": v.max()}),
"NDVI": pd.Series({"mínima": np.nanmin(ndvi.values), "mediana": np.nanmedian(ndvi.values), "máxima": np.nanmax(ndvi.values)}),
}).round(2))
print(f"Fracción media de escenas válidas por píxel: {np.nanmean(validas.values):.2f}")Fracción media de escenas válidas por píxel: 0.75
if ruta is not None:
fig, ax = plt.subplots(1, 2, figsize=(10, 4))
for a, da, titulo, cmap in ((ax[0], lst, "Temperatura de superficie (°C)", "inferno"), (ax[1], ndvi, "NDVI", "YlGn")):
im = a.imshow(da.values, cmap=cmap, extent=(float(da.x.min()), float(da.x.max()), float(da.y.min()), float(da.y.max())))
a.set_title(titulo), a.set_xticks([]), a.set_yticks([])
fig.colorbar(im, ax=a, shrink=0.8)
fig.tight_layout()
fig
La LST es la mediana de las escenas Landsat 8 y 9 de diciembre a febrero con menos de 30 % de nubes en la escena, tras enmascarar nubes y sombras. Es la temperatura de la superficie a media mañana, no la del aire. Las zonas con NDVI alto (vegetación) suelen ser más frías que las construidas.
if ruta is not None:
ok = np.isfinite(lst.values) & np.isfinite(ndvi.values)
r = np.corrcoef(ndvi.values[ok], lst.values[ok])[0, 1]
print(f"Correlación NDVI–LST entre píxeles: {r:.2f}")Correlación NDVI–LST entre píxeles: -0.84
Una correlación negativa confirma que, dentro del barrio, más vegetación se asocia con menor temperatura superficial; es una asociación, no un efecto causal medido.
Días calurosos y olas de calor¶
serie = intentar(obtener_capa, "clima_observado_diario", mensaje="No se pudo obtener la serie climática diaria")
tmax = serie["temperature_2m_max"] if serie is not None and "temperature_2m_max" in serie.columns else None
if tmax is not None:
cfg = parametros()["calor"]
umbral = clima.umbral_percentil(tmax, cfg["percentil_umbral_tmax"])
olas = clima.olas_calor(tmax, umbral, cfg["dias_minimos_ola"])
base = aoi_cfg()["clima"]["periodo_base"]
print(f"Umbral (percentil {cfg['percentil_umbral_tmax']} de Tmax, {base[0]}–{base[1]}): {umbral:.1f} °C; "
f"ola = al menos {cfg['dias_minimos_ola']} días seguidos")
display(olas.describe().loc[["mean", "min", "max"]].round(1))Umbral (percentil 90 de Tmax, 1991–2020): 20.5 °C; ola = al menos 3 días seguidos
if tmax is not None:
fig, ax = plt.subplots(1, 2, figsize=(10, 3.4))
ax[0].bar(olas.index, olas["dias_sobre_umbral"], color="#d1495b")
ax[0].set_title("Días sobre el umbral por año"), ax[0].set_xlabel("Año")
ax[1].bar(olas.index, olas["eventos"], color="#8c2d04")
ax[1].set_title("Olas de calor por año"), ax[1].set_xlabel("Año")
fig.tight_layout()
fig
Por construcción, el 10 % de los días del periodo base supera el umbral, de modo que en 1991–2020 los valores rondan 36 días al año. Lo que importa es la tendencia posterior: si los años recientes quedan por encima de esa línea de base, el clima del barrio se ha desplazado hacia lo cálido. Una serie de reanálisis de ~9 km no refleja la isla de calor del barrio; si hay una estación local de temperatura, úsela como nivel local.
Duración de las olas y noches cálidas¶
Además de contar días y eventos, el flujo CLIMAAX de olas de calor urbano mide la duración de la ola más larga del año (HWML) y las noches cálidas, porque el calor sin alivio nocturno es lo que más daña la salud CLIMAAX consortium, 2026Subdirección para el Conocimiento del Riesgo, 2026. Aquí se calculan con umbrales relativos (percentil 90 de Tmax y de Tmin en el periodo base), pues en Bogotá ninguna temperatura alcanza los 35 °C del Caribe. La definición compuesta exige que el día y la noche superen su umbral.
if tmax is not None and "temperature_2m_min" in serie.columns:
tmin = serie["temperature_2m_min"]
umbral_tn = clima.umbral_percentil(tmin, cfg["percentil_umbral_tmax"])
solo_dia = clima.indices_ola_calor(tmax, None, umbral, None, cfg["dias_minimos_ola"])
compuesta = clima.indices_ola_calor(tmax, tmin, umbral, umbral_tn, cfg["dias_minimos_ola"])
print(f"Umbral nocturno (percentil {cfg['percentil_umbral_tmax']} de Tmin): {umbral_tn:.1f} °C")
display(pd.DataFrame({"HWML día (d)": solo_dia["HWML"].describe(), "HWML compuesta (d)": compuesta["HWML"].describe(),
"noches cálidas": compuesta["noches_calidas"].describe()}).loc[["mean", "max"]].round(1))Umbral nocturno (percentil 90 de Tmin): 11.6 °C
if tmax is not None and "temperature_2m_min" in serie.columns:
fig, ax = plt.subplots(1, 3, figsize=(11, 3.2))
ax[0].plot(solo_dia.index, solo_dia["HWML"], color="#d1495b", label="solo Tmax")
ax[0].plot(compuesta.index, compuesta["HWML"], color="#3d405b", label="día y noche")
ax[0].set_title("Ola más larga del año (días)"), ax[0].legend(fontsize=8)
ax[1].bar(compuesta.index, compuesta["noches_calidas"], color="#e07a5f")
ax[1].set_title("Noches cálidas por año")
ax[2].scatter(solo_dia["dias_calurosos"], compuesta["noches_calidas"], c=solo_dia.index, cmap="viridis", s=18)
ax[2].set_xlabel("Días calurosos"), ax[2].set_ylabel("Noches cálidas"), ax[2].set_title("Día vs noche (color = año)")
fig.tight_layout()
fig
Si las noches cálidas crecen más rápido que los días calurosos, el barrio pierde su alivio nocturno aunque el máximo diurno cambie poco; es la señal que más importa para la población vulnerable.
- CLIMAAX consortium. (2026). CLIMAAX Climate Risk Assessment Handbook. Zenodo. 10.5281/zenodo.18186357
- Subdirección para el Conocimiento del Riesgo. (2026). Riesgo por olas de calor urbano en Cartagena, Bolívar, bajo escenarios de cambio climático (CRC002). https://scr-ungrd.github.io/cuadernos-reproducibles-crc002/