3.4.b Amenaza por calor extremo
Tendencias de temperatura, días y olas de calor, temperatura aparente e isla de calor urbana
En el trópico húmedo el calor se siente por la combinación de temperatura y humedad: con humedades del 80–90 %, el cuerpo pierde poco calor por sudor. Por eso esta página mide la temperatura máxima, la temperatura aparente (que integra humedad y viento) y los días y olas de calor con umbrales relativos al clima de Leticia, siguiendo el flujo de olas de calor de CLIMAAX CLIMAAX consortium, 2026. Al final, Landsat muestra dónde hace más calor dentro de la ciudad.
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import Markdown, display
from leticia import clima, espacial, fuentes, hidro, viz
from leticia.config import aoi_cfg, parametros
pc = parametros()["calor"]
base = tuple(aoi_cfg()["clima"]["periodo_base"])
era = fuentes.clima_diario()
tmax, tmin, tap = era["temperature_2m_max"], era["temperature_2m_min"], era["apparent_temperature_max"]1. Calentamiento observado¶
anom = clima.anomalia_anual(tmax[tmax.index.year < pd.Timestamp.today().year], base)
anom_min = clima.anomalia_anual(tmin[tmin.index.year < pd.Timestamp.today().year], base)
mk = hidro.mann_kendall(anom.loc[1980:])
fig, ax = plt.subplots(figsize=(10, 3.6))
ax.bar(anom.index, anom, color=np.where(anom > 0, viz.CALOR, "#4393c3"))
ax.plot(anom_min.index, anom_min, color="k", lw=1, label="anomalía de la mínima")
ax.axhline(0, color="k", lw=0.5)
ax.set_ylabel("°C respecto a 1991–2020")
ax.set_title("Anomalía anual de la temperatura máxima en Leticia (ERA5)")
ax.legend()
fig.tight_layout()
display(Markdown(f"Tendencia 1980–presente de la temperatura máxima: **{mk['sen']*10:+.2f} °C por década** (Mann-Kendall p = {mk['p']:.3f}). "
f"Años más cálidos: " + ", ".join(f"{a} ({v:+.2f} °C)" for a, v in anom.nlargest(4).items()) + "."))
2. Días y olas de calor con umbral local¶
umbral = clima.umbral_percentil(tmax, pc["percentil_umbral"], base)
olas = clima.olas_calor(tmax, umbral, pc["dias_minimos_ola"])
olas = olas[olas.index < pd.Timestamp.today().year]
fig, ax = plt.subplots(1, 2, figsize=(11, 3.4))
ax[0].bar(olas.index, olas["dias_sobre_umbral"], color=viz.CALOR)
ax[0].set_title(f"Días con Tmax > {umbral:.1f} °C (p{pc['percentil_umbral']} de {base[0]}–{base[1]})")
ax[1].bar(olas.index, olas["ola_mas_larga"], color="#7f2704")
ax[1].set_title("Duración de la ola de calor más larga (días)")
fig.tight_layout()
dec = olas.groupby((olas.index // 10) * 10)[["dias_sobre_umbral", "olas", "ola_mas_larga"]].mean().round(1)
dec.index = [f"{d}s" for d in dec.index]
display(dec)
Por construcción, en 1991–2020 el 10 % de los días supera el umbral (~36 días por año). Lo que importa es lo que pasa después: si los años recientes quedan muy por encima de esa línea de base, el clima de Leticia ya se desplazó hacia lo cálido. Las cuatro olas de calor de 2023 en la Amazonía, de 2 a 5 °C sobre lo normal, coincidieron con la sequía extrema Costa & Marengo, 2023Espinoza et al., 2024.
3. Temperatura aparente: el calor que siente el cuerpo¶
cat = pc["indice_calor_categorias"]
anual = pd.DataFrame({
"temperatura aparente máxima media (°C)": tap.groupby(tap.index.year).mean(),
f"días ≥ {cat['peligro']} °C (peligro)": (tap >= cat["peligro"]).groupby(tap.index.year).sum(),
})
anual = anual[anual.index < pd.Timestamp.today().year]
fig, ax = plt.subplots(figsize=(10, 3.4))
ax.plot(anual.index, anual.iloc[:, 1], color=viz.ROJO, lw=1.5)
ax.set_ylabel("días por año")
ax.set_title(f"Días con temperatura aparente máxima ≥ {cat['peligro']} °C (categoría «peligro»)")
fig.tight_layout()
display(anual.groupby((anual.index // 10) * 10).mean().round(1).rename(index=lambda d: f"{d}s"))
La temperatura aparente de Open-Meteo sigue la fórmula de Steadman, con humedad, viento y radiación. En Leticia supera los 32 °C casi todos los días del año, de modo que el umbral útil es el de «peligro» (≥ 41 °C) del índice de calor del Servicio Meteorológico de EE. UU., que se usa aquí como referencia, no como umbrales de salud locales, que Colombia aún no ha definido para la Amazonía. ERA5 representa una celda de ~28 km dominada por bosque, así que subestima el calor de la ciudad construida.
4. Estrés térmico con el UTCI¶
El Índice Universal de Clima Térmico (UTCI) estima la temperatura que produciría la misma respuesta fisiológica en un ambiente de referencia, a partir de la temperatura, la humedad, el viento y la radiación, y clasifica el estrés por calor en moderado (26–32 °C), fuerte (32–38 °C), muy fuerte (38–46 °C) y extremo (más de 46 °C) Bröde et al., 2012. Miranda et al. lo calcularon con el reanálisis ERA5-HEAT para Suramérica entre 1979 y 2020: los mayores focos de estrés por calor están alrededor de la Amazonía, con 26–35 % de las horas bajo estrés fuerte, y las horas con estrés aumentaron de forma significativa, con episodios más largos desde 2000 Miranda et al., 2023. Aquí se calcula hora a hora para la celda de Leticia con la misma implementación del ECMWF Brimicombe et al., 2022, pero a la sombra: sin datos de radiación de onda larga en la serie horaria disponible, la temperatura media radiante se iguala a la del aire.
horario = fuentes.clima_horario()
ut = clima.estres_termico_diario(horario, pc["utci_categorias"])
ut = ut[ut.index.year < pd.Timestamp.today().year]
g = ut.groupby(ut.index.year)
anual_ut = pd.DataFrame({"horas con estrés moderado o mayor": g["horas_moderado"].sum(), "horas con estrés fuerte o mayor": g["horas_fuerte"].sum(),
"días con estrés fuerte": g["horas_fuerte"].apply(lambda s: int((s > 0).sum())), "UTCI máximo diario medio (°C)": g["utci_max"].mean()})
anual_ut = anual_ut[g.size() >= 360]
mk_ut = hidro.mann_kendall(anual_ut["horas con estrés fuerte o mayor"])
u = pd.Series(clima.utci(horario["temperature_2m"], horario["wind_speed_10m"], horario["dew_point_2m"]), index=horario.index)
dia = pd.DataFrame({"utci": u, "hora": u.index.hour, "periodo": np.where(u.index.year < 2000, "1979–1999", "2000–" + str(u.index.year.max()))})
ciclo = dia.groupby(["hora", "periodo"])["utci"].mean().unstack()
fig, ax = plt.subplots(1, 2, figsize=(12, 3.6), gridspec_kw={"width_ratios": [1.6, 1]})
ax[0].bar(anual_ut.index, anual_ut["horas con estrés fuerte o mayor"], color=viz.CALOR)
t_ = np.array(anual_ut.index, dtype=float)
ax[0].plot(t_, np.median(anual_ut["horas con estrés fuerte o mayor"]) + mk_ut["sen"] * (t_ - np.median(t_)), color="k", lw=1, label="pendiente de Sen")
ax[0].set_ylabel("horas por año")
ax[0].set_title("Horas con estrés por calor fuerte o mayor (UTCI ≥ 32 °C, a la sombra)")
ax[0].legend()
for col, color in zip(ciclo.columns, (viz.GRIS, viz.ROJO)):
ax[1].plot(ciclo.index, ciclo[col], "-o", ms=3, color=color, label=col)
for nombre, v in pc["utci_categorias"].items():
if v <= ciclo.max().max() + 2:
ax[1].axhline(v, color="k", lw=0.6, ls=":")
ax[1].text(0.2, v + 0.2, nombre, fontsize=7)
ax[1].set_xlabel("hora local")
ax[1].set_ylabel("UTCI medio (°C)")
ax[1].set_title("Ciclo diario del UTCI")
ax[1].legend(fontsize=7.5)
fig.tight_layout()
dec = anual_ut.loc[1980:].groupby((anual_ut.loc[1980:].index // 10) * 10).mean().round(1)
dec.index = [f"{d}s" for d in dec.index]
display(dec)
total = ut["horas"].sum()
display(Markdown(f"Fracción de las horas con estrés fuerte o mayor (UTCI ≥ 32 °C) en 1979–2020: **{100 * ut.loc[:'2020', 'horas_fuerte'].sum() / ut.loc[:'2020', 'horas'].sum():.1f} %**; "
f"con estrés moderado o mayor: {100 * ut.loc[:'2020', 'horas_moderado'].sum() / ut.loc[:'2020', 'horas'].sum():.0f} %. Tendencia de las horas con estrés "
f"fuerte: **{mk_ut['sen']:+.1f} horas por año** (Mann-Kendall p = {mk_ut['p']:.3f})."))
Lectura. Por la humedad, el UTCI a la sombra está en estrés moderado casi todo el día, incluso de noche, y entre las 11 y las 15 horas alcanza en promedio el umbral de estrés fuerte. Las horas con estrés fuerte aumentaron de forma significativa desde 1979, con los valores más altos en los años de sequía (2005, 2010, 2015–2016 y 2024), como encontraron Miranda et al. para la región Miranda et al., 2023. Como no incluye el sol directo, este cálculo subestima el estrés de quien trabaja o se desplaza a pleno sol: la fracción de horas con estrés fuerte es menor que el 26–35 % que ese estudio encontró con ERA5-HEAT en la Amazonía. Además, la celda de ERA5 es sobre todo bosque. En Manaos, la diferencia media anual de temperatura entre la ciudad y un sitio de bosque a unos 30 km llega a unos 3 °C, y la ciudad se calienta antes y se enfría más tarde que el bosque Souza & Santos Alvalá, 2014; en Leticia, a menor escala, la isla de calor también suma grados al estrés que mide este cálculo.
5. ¿Dónde hace más calor dentro de Leticia? Temperatura de superficie¶
from leticia import aoi
lst = espacial.temperatura_superficie(2024, pc["meses_landsat"], nubes_max=pc["nubes_max"])
urbana = aoi.area_urbana().to_crs(4326)
fig, ax = plt.subplots(1, 2, figsize=(12, 6))
for a, var, cmap, titulo, lim in ((ax[0], "lst", "inferno", "Temperatura de superficie (°C)", None),
(ax[1], "ndvi", "YlGn", "Vegetación (NDVI)", (0, 0.9))):
d = lst[var]
vmin, vmax = lim or (np.nanpercentile(d, 2), np.nanpercentile(d, 98))
viz.fondo(a)
im = a.imshow(d.values, extent=(float(d.x.min()), float(d.x.max()), float(d.y.min()), float(d.y.max())), cmap=cmap, vmin=vmin, vmax=vmax,
alpha=0.85, zorder=2)
urbana.boundary.plot(ax=a, color="cyan", lw=0.8, zorder=3)
fig.colorbar(im, ax=a, shrink=0.65)
viz.marco(a, "bbox_urbano", titulo)
fig.tight_layout()
ok = np.isfinite(lst["lst"].values) & (lst["ndvi"].values > 0.2) # solo tierra: el agua es fresca y sin vegetación
r = np.corrcoef(lst["ndvi"].values[ok], lst["lst"].values[ok])[0, 1]
display(Markdown(f"Mediana de **{lst.attrs['escenas']} escenas** Landsat 8/9 de julio a octubre (2022–2024) con nubes enmascaradas. "
f"Correlación NDVI–temperatura de superficie entre píxeles de tierra: **r = {r:.2f}**."))
Lectura. La temperatura de superficie a media mañana es mucho mayor sobre techos de zinc, pavimentos y suelo desnudo (pista del aeropuerto, barrios densos) que sobre el bosque y los humedales: la diferencia entre el centro de Leticia y el bosque vecino supera los 10 °C. La correlación negativa con el NDVI en tierra confirma que la vegetación es el principal regulador del calor urbano. No es la temperatura del aire, pero ordena bien los lugares según su exposición al calor.
- CLIMAAX consortium. (2026). CLIMAAX Climate Risk Assessment Handbook. Zenodo. 10.5281/zenodo.18186357
- Costa, F., & Marengo, J. (2023). Statement on the 2023 Amazon drought and its unforeseen consequences [Techreport]. Science Panel for the Amazon. https://eng-other-pub.sp-amazon.org/231208%20Statement%20on%20the%202023%20Amazon%20Drought%20(English).pdf
- Espinoza, J. C., Jimenez, J. C., Marengo, J. A., Schongart, J., Ronchail, J., Lavado-Casimiro, W., & Ribeiro, J. V. M. (2024). The new record of drought and warmth in the Amazon in 2023 related to regional and global climatic features. Scientific Reports, 14, 8107. 10.1038/s41598-024-58782-5
- Bröde, P., Fiala, D., Błażejczyk, K., Holmér, I., Jendritzky, G., Kampmann, B., Tinz, B., & Havenith, G. (2012). Deriving the operational procedure for the Universal Thermal Climate Index (UTCI). International Journal of Biometeorology, 56(3), 481–494. 10.1007/s00484-011-0454-1
- Miranda, V. F. V. V., dos Santos, D. M., Peres, L. F., Salvador, C., Nieto, R., Müller, G. V., & others. (2023). Heat stress in South America over the last four decades: a bioclimatic analysis. Theoretical and Applied Climatology, 155(2), 911–928. 10.1007/s00704-023-04668-x
- Brimicombe, C., Di Napoli, C., Quintino, T., Pappenberger, F., Cornforth, R., & Cloke, H. L. (2022). Thermofeel: a python thermal comfort indices library. SoftwareX, 18, 101005. 10.1016/j.softx.2022.101005
- de Souza, D. O., & dos Santos Alvalá, R. C. (2014). Observational evidence of the urban heat island of Manaus City, Brazil. Meteorological Applications, 21(2), 186–193. 10.1002/met.1340