Climate 3D: el planeta, cuadro a cuadro
Pipeline en cuatro etapas —descargar, prototipar, renderizar y animar— que convierte datos satelitales reales (GOES-R ABI, ERA5 y NASA GIBS) en pósteres y videos con matplotlib + cartopy.

Tecnologías
Descripción
Todo gira alrededor de un mismo objetivo: ir del dato crudo a la imagen terminada, sin abrir los archivos uno por uno. El repo está separado por rol, y se lee en el orden en que se usa:
descargar/ → prototipos/ → cuadros/ → animar/
baja el dato imagen fija render cuadro a cuadro MP4 / GIF
(necesita red) (offline) (offline) (offline)
Las fuentes son GOES-R ABI (satélite geoestacionario, bucket público de NOAA), ERA5 (reanálisis de Copernicus) y productos de NASA GIBS (GeoColor, anomalía de SST MUR y True Color MODIS/VIIRS). Todo se renderiza local con matplotlib + cartopy en un estilo de afiche: fondo casi negro, barra de color fina al pie y rótulos discretos.
- Sólo
descargar/necesita internet. Las otras tres etapas trabajan sobre lo ya bajado. - Los
.nc(varios GB) y las salidas viven fuera del repositorio: la raíz de datos la define un único archivo,comun/rutas.py. - Cada etapa imprime al terminar el comando de la siguiente, así que alcanza con arrancar por la primera.
1. Descargar desde el bucket de NOAA
NOAA publica cada archivo ABI en un bucket S3 público, así que se baja sin credenciales ni API key. El nombre del archivo lo dice todo:
OR_ABI-L2-CMIPF-M6C13_G19_s20262750000205_e20262750009524_c20262750009590.nc
│ │ │ │ │ │
│ │ │ │ │ └─ c: cuándo se creó
│ │ │ │ └─ s: cuándo empezó el escaneo
│ │ │ └─ G19: satélite
│ │ └─ C13: banda 13
│ └─ M6: modo 6 (disco cada 10 min)
└─ CMIPF: Cloud and Moisture Imagery, disco completo
No hace falta escribir ese nombre a mano: se arma a partir del slot de 10 minutos.
PATRON = re.compile(r"OR_ABI-L2-CMIPF-M6(?P<banda>C\d+)_(?P<sat>G\d+)_s(?P<s>\d{14})_")
def claves_de_la_hora(sat, t):
"""Claves S3 de la hora de `t` (una sola consulta por hora y satélite)."""
hora = t.replace(minute=0, second=0, microsecond=0)
pref = f"ABI-L2-CMIPF/{hora.year}/{int(hora.strftime('%j')):03d}/{hora.hour:02d}/"
url = f"https://noaa-goes{sat[1:]}.s3.amazonaws.com/?list-type=2&prefix={pref}"
xml = urllib.request.urlopen(url, timeout=120).read().decode()
return re.findall(r"<Key>([^<]+)</Key>", xml)
def clave_de(sat, banda, t):
"""Clave del archivo cuyo escaneo arranca en el slot de 10 min de `t`."""
for clave in claves_de_la_hora(sat, t):
m = PATRON.search(clave)
if not m or m["banda"] != banda or m["sat"] != sat:
continue
inicio = datetime.strptime(m["s"][:13], "%Y%j%H%M%S")
if (inicio.hour, inicio.minute) == (t.hour, t.minute):
return clave
raise LookupError(f"Sin archivo {sat} {banda} para {t:%Y-%m-%d %H:%M} UTC")
# GOES-East, banda 13, del 2 al 5 de octubre de 2026 (432 cuadros, ~10 GB)
.venv\Scripts\python.exe descargar\descargar_goes.py --sat G19 --banda C13 --desde 2026-10-02 --hasta 2026-10-05
La descarga es larga y reanudable: saltea los .nc que ya tenés, así que si la cortás,
volvés a correrla y sigue donde quedó.
Aparte de GOES, hay otras fuentes. Tres se bajan rasterizadas (un JPEG por cuadro) desde
la API de NASA GIBS, sin .nc que leer:
descargar/geocolor.py— GeoColor (GOES-East): color real de día, IR y luces de noche.descargar/mur_sst.py— anomalía de SST (GHRSST MUR L4), global y diaria.descargar/truecolor.py— True Color (Corrected Reflectance) de MODIS/VIIRS, el color real con el que se ve el humo de los incendios.descargar/descargar_era5.py— ERA5 2m temperature (2024-2026) desde Copernicus CDS. Requierecdsapiy la API key en~/.cdsapirc.
2. Pósteres fijos (prototipos/)
Antes de animar cientos de cuadros conviene mirar uno solo. Son tres entrypoints, todos sin conexión:
poster_goes.py— disco completo, globo o zona desde un archivo ABI.vista_paraguay.py— el encuadre aprobado: globo centrado en Paraguay.poster_anomalia.py— anomalía mensual de temperatura a partir de ERA5.
Un archivo ABI no trae una grilla lat/lon: trae el disco completo en su proyección
geoestacionaria. Los ejes x e y son ángulos de escaneo en radianes; multiplicándolos por
la altura del satélite se pasan al plano de la proyección. Dibujando con esa misma
proyección, pcolormesh no reproyecta nada y se conserva la resolución original.
with xr.open_dataset(nc) as ds:
cmi = ds["CMI"] # Cloud and Moisture Imagery
gp = ds["goes_imager_projection"].attrs
h = float(gp["perspective_point_height"]) # altura del satélite (m)
x = ds["x"].values * h # radianes de escaneo -> metros
y = ds["y"].values * h
proj = ccrs.Geostationary(
central_longitude=float(gp["longitude_of_projection_origin"]),
satellite_height=h, sweep_axis=str(gp.get("sweep_angle_axis", "x")))
La variable se interpreta sola según sus unidades: si viene en Kelvin es temperatura de
brillo (se muestra en °C), si viene en reflectancia se muestra en porcentaje. La paleta por
defecto sale del tipo de variable, pero se puede forzar con --paleta.
def to_display(raw, cmi):
units = str(cmi.attrs.get("units", "")).strip()
if units == "K":
return raw - 273.15, "°C", "Temperatura de brillo" # banda 13
if units in ("1", ""):
return raw * 100.0, "%", "Reflectancia" # cirrus C04
return raw, units, "Valor"
.venv\Scripts\python.exe prototipos\poster_goes.py --paleta ir_realce # disco completo
.venv\Scripts\python.exe prototipos\poster_goes.py --globo --zona sa # globo, Sudamérica
.venv\Scripts\python.exe prototipos\vista_paraguay.py # globo Paraguay
Así se ve el disco completo con la rampa de infrarrojo:

3. Renderizar los cuadros (cuadros/)
El problema: 432 cuadros del disco, cada uno una figura de Matplotlib, dentro de un mismo
proceso, agotan la memoria de una máquina chica. La solución de cuadros_goes.py es
renderizar por lotes en procesos separados (cada lote arranca con la memoria limpia) y
reutilizar los PNG que ya existen.
for s in range(1, args.n + 1, args.lote):
e = min(s + args.lote - 1, args.n)
print(f"lote {s}-{e} ...", flush=True)
subprocess.run([sys.executable, str(Path(__file__).resolve()),
*sys.argv[1:], "--rango", str(s), str(e), str(vmin), str(vmax)],
check=True)
La paleta y los rótulos son los mismos del póster, así que un cuadro de la animación y una imagen fija se leen igual. Los parámetros de aspecto quedan sellados: si cambiás el texto, el borde o la escala, los PNG viejos se descartan solos en vez de reutilizarse con el rótulo anterior.
# Globo centrado en Paraguay, 432 cuadros a 720 px
.venv\Scripts\python.exe cuadros\cuadros_goes.py --sat G19 --banda C13 --desde 2026-10-02 `
--n 432 --px 720 --globo --centro -73 -18 --paleta ir_realce
4. Armar la animación (animar/)
armar.py sólo ensambla: no renderiza ni descarga nada. Toma una carpeta frames_* con los
cuadro_NNN.png, verifica que la secuencia sea contigua y deriva el nombre de salida del de
la carpeta (frames_ → animacion_), con la cantidad real de cuadros encontrados. Si la
numeración tiene un hueco, aborta: ffmpeg cortaría el video ahí sin avisar.
proc = subprocess.run([
imageio_ffmpeg.get_ffmpeg_exe(), "-y",
"-framerate", f"{fps:.6f}", "-start_number", "1",
"-i", str(frames_dir / "cuadro_%03d.png"),
"-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2",
"-c:v", "libx264", "-preset", "slow", "-pix_fmt", "yuv420p", "-crf", "18",
"-movflags", "+faststart", str(destino)], capture_output=True, text=True)
El MP4 se codifica con el ffmpeg que trae imageio-ffmpeg (no hace falta tenerlo en el
PATH). El GIF queda para el loop universal, con una paleta común a todos los cuadros para
que los colores no parpadeen.
.venv\Scripts\python.exe animar\armar.py --frames "D:\Users\guill\dataset era5\salidas\frames_G19_2026-10-02T0000_432f_720px" --fps 12.5
Resultado
El encuadre final es el disco completo reproyectado a globo, centrado en Sudamérica y con el contorno del Paraguay marcado sobre el campo de temperatura de brillo:
GeoColor: color real de día, IR y luces de noche
El mismo globo, el mismo día, a las 16:50 y a las 04:00 UTC. GeoColor no es una banda: es un producto RGB ya mezclado que se baja de NASA GIBS. De día se ve color real —la Amazonía en verde, los Andes en ocre, el océano y las nubes como los vería el ojo—. De noche el terreno da paso al infrarrojo, que separa las nubes bajas de agua líquida (azul claro) de las altas de hielo (gris y blanco), y por encima van las luces de ciudad.


Y la animación completa, tres días de GeoColor cuadro a cuadro:
Anomalía de temperatura (ERA5)
Cada mes se compara contra la climatología de ese mismo mes calculada con los otros años del archivo. El rojo es más calor que lo normal, el azul menos. Excluir el año objetivo evita diluir la anomalía consigo misma, y la lectura del NetCDF es lazy y por bloques: sin dask, la aritmética sobre todo el array materializaría los GB en RAM.
# Mes objetivo menos su climatología (los DEMÁS años del archivo).
clim, counts, used = month_climatology(da, "valid_time", times, exclude_years=(y_t,))
field_c = to_c(mean_steps(da, "valid_time", np.flatnonzero(mask_t)))
anom_c = field_c - to_c(clim[m_t - 1])
.venv\Scripts\python.exe prototipos\poster_anomalia.py --mes 2026-08

Cirrus C04 (GOES-West)
La banda de cirrus (1.4 µm) aísla las nubes altas de hielo y las despega del resto de la
escena: los topes convectivos aparecen en rojo sobre un fondo casi negro. Es la misma
maquinaria del póster, sólo que con la banda C04 y la paleta de reflectancia.

Anomalía del mar: El Niño en el Pacífico (MUR)
El producto MUR (GHRSST L4 v4.1) es la anomalía de temperatura superficial del mar, global y diaria. Como la animación abarca dos años, la muestrea una vez por semana (~94 cuadros en lugar de cientos). En el Pacífico ecuatorial se lee de un vistazo la lengua cálida de El Niño: el rojo que avanza sobre la costa de Sudamérica.
# Descarga (una muestra por semana) y, después, el render
.venv\Scripts\python.exe descargar\mur_sst.py --desde 2025-01-01 --hasta 2026-10-17 --paso 7 --px 2048
.venv\Scripts\python.exe cuadros\cuadros_mur.py --desde 2025-01-01 --hasta 2026-10-17 --paso 7 --px 2048
La serie de fechas y el nombre de la carpeta de cuadros salen de un solo módulo,
comun/series.py, así que la descarga y el render nunca se desincronizan: si el paso o la
etiqueta cambiaran en un lado, el render saldría a buscar una carpeta que no existe.

Humo y fuego en color real (True Color, NOAA-20)
Para ver el humo de los incendios de 2024 hace falta color real, y GeoColor no tiene archivo 2024 (su capa devuelve un placeholder). La salida es True Color (Corrected Reflectance) de VIIRS NOAA-20 vía NASA GIBS: global, diaria y de 250 m. Son 112 días —uno por día— entre julio y octubre de 2024.
La capa por defecto es NOAA-20 y no MODIS a propósito: las composiciones diarias de Terra y Aqua dejan cuñas de barrido cerca del ecuador —el barrido de MODIS es más angosto que la separación de órbitas— que en el globo se ven como franjas negras diagonales.
# Descarga (un cuadro por día) y, después, el render
.venv\Scripts\python.exe descargar\truecolor.py --desde 2024-07-11 --hasta 2024-10-30 --paso 1 --px 2711
.venv\Scripts\python.exe cuadros\cuadros_truecolor.py --desde 2024-07-11 --hasta 2024-10-30 --paso 1 --px 2711

Dónde viven los datos
Los .nc pesan varios GB y las salidas también, así que no van al repo. La raíz de datos
la define un solo archivo, comun/rutas.py, y se puede mover sin tocar código:
DATOS = Path(os.environ.get("CLIMATE3D_DATOS", r"D:\Users\guill\dataset era5"))
SALIDAS = DATOS / "salidas"
$env:CLIMATE3D_DATOS = "D:\mi\carpeta\datos"
Nota de datos
Todas las imágenes y animaciones salen de GOES-R ABI L2 CMIP reales (NOAA/NESDIS), de
ERA5 (Copernicus) y de productos de NASA GIBS (GeoColor, anomalía de SST MUR de
GHRSST y True Color de MODIS/VIIRS). El disco GOES se lee en su proyección geoestacionaria
nativa y sólo se reproyecta para el encuadre en globo; los .nc crudos y las salidas quedan
fuera del repositorio. La excepción es el primer render que dibuja costas: cartopy baja los
shapefiles de Natural Earth una vez y después quedan cacheados.