Calor urbano en Los Rosales — cuaderno integrado
Temperatura de superficie, días y olas de calor con umbrales locales, vulnerabilidad y escenarios CMIP6
Resumen¶
Bogotá no tiene olas de calor como las europeas, pero sí días anormalmente cálidos para su clima y una isla de calor urbana. Este cuaderno sigue el flujo «Urban heatwaves» de CLIMAAX CLIMAAX consortium, 2026 y el cuaderno de Cartagena del que toma su estructura Subdirección para el Conocimiento del Riesgo, 2026. Mide la temperatura de superficie con Landsat, cuenta días y olas de calor con umbrales relativos al clima local y cruza el calor con un índice de vulnerabilidad. Al final proyecta el cambio con NEX-GDDP-CMIP6 Thrasher et al., 2022.
Resultados clave (ejecución del 2026-10-27 con datos reales):
Dentro del área, la temperatura de superficie de la temporada seca va de 15 a 35 °C. Entre la manzana más cálida y el promedio del barrio hay unos 2,8 °C. Más vegetación se asocia con menos calor (r = −0,84).
Bellavista, la parte baja del barrio entre las calles 72 y 76–78, es en promedio 0,9 °C más cálida que el sector Los Rosales, y concentra las seis manzanas más calientes.
Un día es caluroso si su Tmax supera 20,5 °C (percentil 90 de 1991–2020). Hoy hay unos 33 días así por año y 3 olas de calor.
Con SSP2-4.5 a mediados de siglo serían unos 120 días al año. Con SSP5-8.5 a final de siglo, casi todos los días (353) superarían el umbral histórico.
Las páginas de detalle a, b, c y d explican cada paso con más extensión.
1. Marco¶
1.1 La amenaza y el barrio¶
El calor no recae igual sobre todos. Las personas mayores, los niños pequeños y los hogares sin sombra son más sensibles. Por eso el riesgo combina cuánto se calienta un lugar con quién vive allí. Un umbral absoluto tomado de Europa o del Caribe sería inalcanzable en Bogotá. Un umbral relativo mide lo que es inusual para este clima.
1.2 Cadena de análisis¶
2. Configuración del entorno¶
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
3. Amenaza¶
3.1 Temperatura de superficie y vegetación¶
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
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
Lectura. La LST es la mediana de las escenas de diciembre a febrero, tras enmascarar nubes. Cada píxel tiene en promedio un 75 % de escenas válidas. La fuerte correlación negativa con el NDVI confirma que las zonas vegetadas son más frescas. Es la temperatura de la superficie a media mañana, no la del aire.
3.2 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
3.3 Duración de las olas y noches cálidas¶
El flujo CLIMAAX mide también la ola más larga del año (HWML) y las noches cálidas, porque el calor sin alivio nocturno es el que más daña la salud. 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
Lectura. En promedio hay 33 días calurosos y 3 olas por año, con años de hasta 83 días. Las olas que solo miden el día duran en promedio 9 días en su peor racha anual. Cuando se exige también una noche cálida (Tmin ≥ 11,6 °C), la racha baja a poco más de 1 día. Las noches cálidas son 38 por año en promedio.
4. Riesgo: calor × vulnerabilidad¶
4.1 Temperatura de superficie por manzana¶
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import Markdown, display
from sidro import exposicion, process, viz
from sidro.analisis import calor, espacial, indices
from sidro.config import parametros, rutas
from sidro.fetch import obtener_capa
ruta = intentar(obtener_capa, "temperatura_superficial", mensaje="No se pudo obtener Landsat (requiere Planetary Computer)")
unidades = process.unidades_analisis() if ruta is not None else None
if ruta is not None:
bandas = espacial.bandas_landsat(process.abrir_raster(ruta))
agg = unidades[["id_unidad", "geometry"]].copy()
agg["lst_media_c"] = process.estadistica_zonal(bandas["lst"], unidades, "mean").values
agg["ndvi_medio"] = process.estadistica_zonal(bandas["ndvi"], unidades, "mean").values
agg["lst_anomalia_c"] = agg["lst_media_c"] - agg["lst_media_c"].mean()
print(f"Unidades: {len(agg)} (origen: {unidades.attrs.get('origen')}); "
f"sin dato de LST: {int(agg['lst_media_c'].isna().sum())}")
display(agg.drop(columns="geometry").sort_values("lst_media_c", ascending=False).head(10).round(2))WARNING sidro.fetch: manzanas_censo: falló nivel nacional/usuario -> Archivo de usuario no encontrado: /Users/mauricio/Documents/Data-science-local/SIDRo/data/user/dane_mgn_manzanas_cnpv2018.gpkg
WARNING sidro.process: Unidades: manzanas_censo no disponible (No se pudo obtener 'manzanas_censo'. Intentos: [{'nivel': 'nacional', 'tipo': 'usuario', 'error': 'Archivo de usuario no encontrado: /Users/mauricio/Documents/Data-science-local/SIDRo/data/user/dane_mgn_manzanas_cnpv2018.gpkg'}])
Unidades: 57 (origen: estratificacion); sin dato de LST: 1
if ruta is not None:
m = viz.mapa_base()
viz.capa(m, agg, "lst_media_c", nombre="LST media (°C)", cmap="inferno",
tooltip=["id_unidad", "lst_media_c", "lst_anomalia_c", "ndvi_medio"])
viz.cerrar(m)
else:
m = None
m4.2 Vulnerabilidad al calor¶
edif = intentar(exposicion.edificaciones, mensaje="Sin edificaciones: la densidad de población no se podrá estimar")
arboles = intentar(obtener_capa, "arbolado", mensaje="Sin censo de arbolado: la variable falta_arbolado se omite")if ruta is not None:
personas = exposicion.ocupantes_por_unidad(unidades, edif) if edif is not None else None
var = calor.variables_calor(unidades, arboles, personas)
agg["vulnerabilidad_calor"] = calor.vulnerabilidad_calor(var).values
usados = calor.vulnerabilidad_calor(var).attrs.get("pesos_usados", {})
pesos = parametros()["calor"]["pesos_vulnerabilidad"]
display(pd.DataFrame({"peso base": pd.Series(pesos), "peso usado": pd.Series(usados),
"unidades con dato": var[list(pesos)].notna().sum()}).fillna(0).round(2))
agg = agg.join(var.drop(columns="id_unidad"))if ruta is not None:
display(agg.drop(columns="geometry").sort_values("vulnerabilidad_calor", ascending=False).head(10).round(2))Lectura. Sin el censo DANE, las proporciones de mayores de 65 y menores de 5 años no existen. El índice queda con densidad de población y falta de arbolado (50 % cada una), así que no recoge la vulnerabilidad por edad. Varias manzanas salen con cero árboles por hectárea, lo que sugiere revisar el cruce espacial del censo de arbolado antes de interpretar esa variable.
4.3 Mapa bivariado¶
if ruta is not None:
biv = indices.bivariado(agg["lst_media_c"], agg["vulnerabilidad_calor"])
agg = agg.join(biv)
m = viz.mapa_base()
viz.capa_bivariada(m, agg)
viz.cerrar(m)
else:
m = None
mLeyenda: temperatura de superficie en el eje vertical, vulnerabilidad al calor en el horizontal.
if ruta is not None and agg["arboles_ha"].notna().any():
fig, ax = plt.subplots(figsize=(5, 3.6))
ok = agg["arboles_ha"].notna() & agg["lst_media_c"].notna()
ax.scatter(agg.loc[ok, "arboles_ha"], agg.loc[ok, "lst_media_c"], s=14, color="#8c2d04", alpha=0.7)
ax.set_xlabel("Árboles por hectárea"), ax.set_ylabel("LST media (°C)"), ax.grid(alpha=0.3)
fig
if ruta is not None:
process.guardar(agg[["id_unidad", "geometry", "lst_media_c", "lst_anomalia_c", "vulnerabilidad_calor"]]
.rename(columns={"lst_media_c": "riesgo", "vulnerabilidad_calor": "vulnerabilidad_social"}), "riesgo_calor")
print("Guardado:", (rutas().processed / "riesgo_calor.parquet").relative_to(rutas().raiz))Guardado: data/processed/riesgo_calor.parquet
5. Escenarios climáticos¶
A la serie diaria se le suma el cambio medio de Tmax de cada modelo (método delta aditivo) bajo SSP2-4.5 y SSP5-8.5 Riahi et al., 2017O'Neill et al., 2017. Luego se recuentan los días sobre el umbral histórico fijo.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import display
from sidro.analisis import clima
from sidro.config import aoi_cfg, parametros
from sidro.fetch import obtener_capa
MODELOS = None # p. ej. ["ACCESS-CM2", "EC-Earth3"] para una prueba rápida
cfg = aoi_cfg()["clima"]
par = parametros()["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
deltas = None
if tmax is not None:
deltas = intentar(clima.deltas_tabla, ["tasmax"], cfg["escenarios"], cfg["horizontes"], MODELOS, cache="deltas_calor",
mensaje="No se pudieron obtener las proyecciones CMIP6 (requiere acceso a Planetary Computer)")
if deltas is not None:
display(deltas.groupby(["escenario", "horizonte"])["delta"].describe()[["count", "min", "50%", "max"]].round(2))resultados = None
if deltas is not None:
umbral = clima.umbral_percentil(tmax, par["percentil_umbral_tmax"])
base = clima.olas_calor(tmax, umbral, par["dias_minimos_ola"])
q = cfg["percentiles_ensamble"]
filas = []
for (esc, hor), g in deltas.groupby(["escenario", "horizonte"]):
por_modelo = [clima.olas_calor(clima.aplicar_delta(tmax, d, aditivo=True), umbral, par["dias_minimos_ola"])
[["dias_sobre_umbral", "eventos"]].mean() for d in g["delta"]]
t = pd.DataFrame(por_modelo)
for col in ("dias_sobre_umbral", "eventos"):
filas.append({"escenario": esc, "horizonte": hor, "indicador": col, "base": base[col].mean(),
**{f"p{x}": np.percentile(t[col], x) for x in q}})
resultados = pd.DataFrame(filas)
print(f"Umbral histórico: {umbral:.1f} °C (percentil {par['percentil_umbral_tmax']} de Tmax en {cfg['periodo_base'][0]}–{cfg['periodo_base'][1]})")
display(resultados.round(1))Umbral histórico: 20.5 °C (percentil 90 de Tmax en 1991–2020)
if resultados is not None:
r = resultados[resultados["indicador"] == "dias_sobre_umbral"]
fig, ax = plt.subplots(figsize=(7, 3.4))
x = np.arange(len(r))
med = r[f"p{q[1]}"].values
ax.bar(x, med, yerr=[med - r[f"p{q[0]}"].values, r[f"p{q[2]}"].values - med], color="#d1495b", capsize=4)
ax.axhline(r["base"].iloc[0], ls="--", color="#555", label="historia observada")
ax.set_xticks(x, [f"{e}\n{h}" for e, h in zip(r.escenario, r.horizonte)], fontsize=8)
ax.set_ylabel("Días sobre el umbral por año"), ax.legend()
fig
Lectura. El calentamiento mediano de Tmax es de +1,4 °C (SSP2-4.5) a +1,8 °C (SSP5-8.5) a mediados de siglo, y de +2,5 a +4,6 °C a final de siglo. Con el umbral en el percentil 90, desplazamientos pequeños producen aumentos grandes de días. Con SSP5-8.5 en 2081–2100 casi todo el año supera el umbral y las olas se funden en rachas largas, por eso el número de olas baja. Interprete el orden entre escenarios más que la cifra exacta.
5.1 Trayectoria de la temperatura hasta 2100¶
Se arma un conjunto de 500 miembros que interpola los deltas reales de cada modelo y suma variabilidad interanual observada, en el espíritu del escalamiento por patrón Tebaldi & Arblaster, 2014 y del cuaderno de Montería Subdirección para el Conocimiento del Riesgo, 2026.
proyeccion = None
if deltas is not None:
base_anual = tmax.groupby(tmax.index.year).mean()
en_base = base_anual[(base_anual.index >= cfg["periodo_base"][0]) & (base_anual.index <= cfg["periodo_base"][1])]
media_base, sigma = float(en_base.mean()), float(en_base.std())
anios = np.arange(2024, 2101)
proyeccion = clima.trayectoria_temperatura(media_base, sigma, deltas, anios)
print(f"Tmax media anual 1991–2020: {media_base:.2f} °C; variabilidad interanual (σ): {sigma:.2f} °C")Tmax media anual 1991–2020: 18.61 °C; variabilidad interanual (σ): 0.44 °C
if proyeccion is not None:
colores = {"ssp245": "#2a9d8f", "ssp585": "#d1495b"}
fig, ax = plt.subplots(figsize=(8, 3.8))
ax.plot(base_anual.index, base_anual.values, color="#777", lw=1, label="observada (ERA5-Land)")
for esc, m in proyeccion.items():
ax.fill_between(anios, *np.percentile(m, [5, 95], axis=0), color=colores.get(esc), alpha=0.2)
ax.plot(anios, np.median(m, axis=0), color=colores.get(esc), lw=2, label=f"{esc} (mediana y 5–95 %)")
ax.axhline(media_base + 1.645 * sigma, ls=":", color="#555", label="año extremo (p95 de 1991–2020)")
ax.set_xlabel("Año"), ax.set_ylabel("Tmax media anual (°C)"), ax.legend(fontsize=8, loc="upper left")
fig
if proyeccion is not None:
umbral_extremo = media_base + 1.645 * sigma
freq = clima.frecuencia_extremos(proyeccion, umbral_extremo) * 100
fig, ax = plt.subplots(figsize=(8, 3.2))
for esc in freq:
ax.plot(anios, freq[esc], color=colores.get(esc), lw=2, label=esc)
ax.axhline(5, ls="--", color="#555", label="frecuencia climatológica (5 %)")
ax.set_ylim(0, 100), ax.set_xlabel("Año"), ax.set_ylabel("Probabilidad de año extremo (%)"), ax.legend(fontsize=8)
fig
pd.DataFrame({esc: {a: round(float(freq[esc].iloc[list(anios).index(a)]), 0) for a in (2030, 2050, 2070, 2090)}
for esc in freq}).rename_axis("año")
if proyeccion is not None:
sel = (anios >= 2041) & (anios <= 2060)
display(pd.DataFrame({esc: clima.nivel_retorno(m[:, sel]) for esc, m in proyeccion.items()})
.rename_axis("periodo de retorno (años)").round(2).add_prefix("Tmax anual 2041–2060, "))Lectura. La Tmax media anual de 1991–2020 es de 18,6 °C, con una variabilidad interanual de 0,44 °C. Hoy, un año «extremo» (percentil 95) supera unos 19,3 °C. En 2041–2060 eso sería un año ordinario: la mediana de un año típico ya es de unos 20,0 °C (SSP2-4.5) y 20,5 °C (SSP5-8.5). La banda mezcla dispersión entre modelos y variabilidad natural. No es un intervalo de confianza formal.
if resultados is not None:
from sidro import export
extra = {}
if proyeccion is not None:
extra = {"prob_ano_extremo_pct": {esc: {int(a): float(freq[esc].iloc[i]) for i, a in enumerate(anios) if a % 10 == 0}
for esc in freq}}
export.guardar_resumen("calor_escenarios", {"umbral_c": float(umbral), "escenarios": resultados.to_dict("records"), **extra})6. Tabla resumen para el informe ejecutivo¶
| Indicador | Valor (ejecución 2026-10-25) |
|---|---|
| LST en el área (mín. – mediana – máx.) | 14,8 – 23,2 – 34,6 °C |
| Manzana más cálida (anomalía frente al barrio) | 00830504, Bellavista (+2,8 °C) |
| LST media por sector (Bellavista / Los Rosales) | 23,2 / 22,3 °C |
| Correlación NDVI–LST | −0,84 |
| Umbral de día caluroso (p90 Tmax 1991–2020) | 20,5 °C |
| Días calurosos / olas por año, clima actual | 32,8 / 3,1 |
| Días calurosos por año, SSP2-4.5 2041–2060 (p50) | 120 |
| Días calurosos por año, SSP5-8.5 2081–2100 (p50) | 353 |
7. Conclusiones¶
El calor se reparte de forma desigual dentro del barrio. Las manzanas con menos vegetación llegan a estar 2,8 °C por encima del promedio en la temperatura de superficie, y las más calientes están en Bellavista.
Con cualquier escenario, lo que hoy es un día caluroso para Bogotá se vuelve frecuente a mediados de siglo.
Sombra, arbolado y atención a la población sensible deberían concentrarse en las manzanas de la esquina «calor alto – vulnerabilidad alta» del mapa bivariado, una vez cargado el censo.
7.1 Limitaciones¶
La LST de Landsat (resolución térmica nativa de 100 m) no resuelve manzanas pequeñas ni techos.
ERA5-Land (~9 km) y NEX-GDDP (~25 km) no representan la isla de calor del barrio. El cambio proyectado es el del clima regional.
El método delta aditivo no cambia la variabilidad ni la duración de las rachas.
No se estima mortalidad ni morbilidad por calor. El resultado es indicativo.
7.2 Próximos pasos¶
Cargar el censo DANE por manzana para incluir mayores de 65 y menores de 5 años en la vulnerabilidad.
Verificar el filtro espacial del censo de arbolado.
Usar una estación de temperatura local como nivel preferente de la serie.
- 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/
- Thrasher, B., Wang, W., Michaelis, A., Melton, F., Lee, T., & Nemani, R. (2022). NASA Global Daily Downscaled Projections, CMIP6. Scientific Data, 9, 262. 10.1038/s41597-022-01393-4
- Riahi, K., van Vuuren, D. P., Kriegler, E., & others. (2017). The Shared Socioeconomic Pathways and their energy, land use, and greenhouse gas emissions implications: An overview. Global Environmental Change, 42, 153–168. 10.1016/j.gloenvcha.2016.05.009
- O’Neill, B. C., Kriegler, E., Ebi, K. L., & others. (2017). The roads ahead: Narratives for shared socioeconomic pathways describing world futures in the 21st century. Global Environmental Change, 42, 169–180. 10.1016/j.gloenvcha.2015.01.004
- Tebaldi, C., & Arblaster, J. M. (2014). Pattern scaling: Its strengths and limitations, and an update on the latest model simulations. Climatic Change, 122, 459–471. 10.1007/s10584-013-1032-9
- Subdirección para el Conocimiento del Riesgo. (2026). Proyección de temperatura en Montería, Córdoba, bajo escenarios de cambio climático (CRC001). Zenodo. 10.5281/zenodo.20061610