DEV Community

Christian Gonzales Komiya
Christian Gonzales Komiya

Posted on

(Spanish) Dash: Monitoreo del Fenómeno El Niño

Dash: Monitoreo del Fenómeno El Niño

Hace unas semanas quería construir un dashboard rápido para visualizar datos, y en vez de ir directo a mis herramientas de siempre (Streamlit, o armar un frontend con React), decidí probar Dash. El resultado terminó siendo un proyecto que combina dos cosas que me interesan bastante: la simplicidad de construir dashboards en Python, y un tema que como peruano me toca de cerca — el Fenómeno El Niño.

¿Qué es Dash?

Dash es un framework de Python (de la gente de Plotly) para construir aplicaciones web de datos sin escribir una sola línea de JavaScript. Bajo el capó corre sobre Flask, React y Plotly.js, pero todo eso queda oculto: tú escribes Python, defines un layout con componentes HTML y gráficos, y conectas la interactividad con callbacks (funciones normales de Python decoradas con @app.callback).

Lo que más me sorprendió:

  • Es sorprendentemente sencillo de aplicar. Si ya sabes armar un gráfico con Plotly, ya sabes el 70% de Dash.
  • Se ve increíble desde el día uno. Los gráficos de Plotly ya traen zoom, pan, range sliders y tooltips interactivos gratis — sin escribir CSS ni JS.
  • Todo vive en un solo lenguaje. No hay que saltar entre Python para el backend y JavaScript para la interactividad del frontend.

Con esa primera impresión, busqué un dataset con el que valiera la pena practicar. Y ahí es donde entra el segundo interés.

Por qué El Niño

Como peruano, El Fenómeno El Niño no es un tema abstracto de oceanografía: es lluvias e inundaciones en el norte del país, sequías en otras zonas, impacto directo en la pesca (nuestra anchoveta se mueve o desaparece), y en los casos más fuertes, desastres naturales que golpean carreteras, cultivos y ciudades enteras. Cada cierto tiempo el país entero está pendiente de si "viene El Niño" o no, y de qué tan fuerte va a ser.

Quería entender mejor cómo se mide esto oficialmente, y construir algo que me permitiera ver la evolución del fenómeno en el tiempo, no solo el titular de la noticia del día.

ICEN y RONI: dos formas de medir el mismo fenómeno

Investigando, encontré que no existe un solo índice para "El Niño" — hay al menos dos que se complementan:

  • ICEN (Índice Costero El Niño): mide la anomalía de temperatura superficial del mar frente a la costa peruana (región Niño 1+2). Lo calcula el IGP (Instituto Geofísico del Perú), y refleja las condiciones oceánicas locales que afectan directamente el clima y la pesca en el país.
  • RONI (Relative Oceanic Niño Index): mide la anomalía en el Pacífico central-oriental (región Niño 3.4). Lo calcula NOAA CPC (el organismo climático de EE.UU.) y es el estándar internacional para monitorear el ENOS (El Niño-Oscilación del Sur) a escala global.

¿Por qué existen ambos si miden "lo mismo"? Porque el fenómeno puede manifestarse distinto según la zona: un evento puede verse fuerte en RONI (a nivel global) pero débil en ICEN (en la costa peruana), o viceversa. Perú necesita su propio índice porque las condiciones costeras locales no siempre siguen el patrón del Pacífico central al pie de la letra.

Con ambos índices en un mismo gráfico, se puede comparar la "foto global" del ENOS con la "foto local" que de verdad le importa al país.

El proyecto: Vigía ENOS

Repo: github.com/ckomiya/vigia-enos
Demo: vigia-enos.onrender.com

La estructura del proyecto es deliberadamente simple:

etl/
  fetch_icen.py     # descarga y parsea ICEN (IGP)
  fetch_roni.py     # descarga y parsea RONI (NOAA CPC)
  transform.py      # une ambas series, categoriza y detecta eventos RONI
app/
  main.py           # dashboard Dash (single page)
data/
  raw/              # snapshots crudos descargados
  processed/        # CSVs procesados (icen.csv, roni.csv, merged.csv)
Enter fullscreen mode Exit fullscreen mode

La idea fue separar completamente la obtención de datos (ETL) de la presentación (Dash). El pipeline ETL son tres scripts que se corren en secuencia:

venv\Scripts\python.exe etl\fetch_icen.py
venv\Scripts\python.exe etl\fetch_roni.py
venv\Scripts\python.exe etl\transform.py
Enter fullscreen mode Exit fullscreen mode

fetch_icen.py y fetch_roni.py descargan los datos crudos directamente de las fuentes oficiales (IGP y NOAA CPC respectivamente) y los guardan como snapshots en data/raw/. transform.py toma ambas series, las une por fecha, calcula la categorización (Neutro / Cálida / Fría, según los umbrales oficiales) y detecta eventos por persistencia. El resultado es un único merged.csv que alimenta todo el dashboard.

Y del lado de la app, todo el dashboard vive en un solo archivo: app/main.py. Nada de blueprints, nada de carpetas de componentes — solo funciones de Python que arman piezas de HTML y un callback que conecta el botón de actualizar con el pipeline ETL.

El gráfico principal: serie temporal con umbrales

El corazón visual del dashboard es un gráfico de líneas con Plotly que muestra ICEN y RONI en el tiempo, más las líneas de umbral que definen cada categoría:

def build_timeseries_figure(df: pd.DataFrame) -> go.Figure:
    fig = go.Figure()
    fig.add_trace(go.Scatter(
        x=df["date"], y=df["icen"], name="ICEN", mode="lines",
        line=dict(color=COLOR_WARM),
    ))
    fig.add_trace(go.Scatter(
        x=df["date"], y=df["roni"], name="RONI", mode="lines",
        line=dict(color=COLOR_COLD),
    ))

    icen_thresholds = [
        (ICEN_FRIA_THRESHOLD, "ICEN Fría"),
        (ICEN_NEUTRA_THRESHOLD, "ICEN Neutra/Cálida déb."),
        (ICEN_CALIDA_DEBIL_THRESHOLD, "ICEN Cálida déb./Cálida"),
    ]
    for value, label in icen_thresholds:
        fig.add_hline(
            y=value, line_dash="dot", line_color=COLOR_WARM, opacity=0.5,
            annotation_text=label, annotation_position="top left",
            annotation_font_size=9, annotation_font_color=COLOR_TEXT_MUTED,
        )
    ...
Enter fullscreen mode Exit fullscreen mode

Cada línea de umbral se agrega con fig.add_hline() — una sola llamada te da la línea punteada, el color, y la anotación con el texto flotando encima. Esto es lo que quiero decir con "se ve increíble sin esfuerzo": no hace falta calcular posiciones de texto ni dibujar nada a mano, Plotly lo resuelve.

También agrego un selector de rango temporal (botones de "1 año", "5 años", "10 años", "Todo") y un range slider debajo del gráfico, con una sola configuración:

fig.update_xaxes(
    range=[default_start, last_date],
    rangeselector=dict(
        buttons=[
            dict(count=1, label="1 año", step="year", stepmode="backward"),
            dict(count=5, label="5 años", step="year", stepmode="backward"),
            dict(count=10, label="10 años", step="year", stepmode="backward"),
            dict(step="all", label="Todo"),
        ],
        ...
    ),
    rangeslider=dict(visible=True, bgcolor=COLOR_PANEL, bordercolor=COLOR_GRID),
)
Enter fullscreen mode Exit fullscreen mode

Esos botones y el slider son 100% interactivos desde el navegador, sin ningún callback de Dash detrás — vienen incluidos en el objeto Figure de Plotly.

Los KPIs tipo semáforo

Además del gráfico, quería una respuesta rápida a "¿cómo estamos ahora mismo?" — para eso están las dos tarjetas KPI, una para ICEN y otra para RONI, con un color de "semáforo" según el estado:

SEMAFORO_COLOR = {
    "El Niño": COLOR_WARM,
    "Cálida": COLOR_WARM,
    "Cálida débil": COLOR_WARM,
    "Neutro": COLOR_TEXT_MUTED,
    "Neutra": COLOR_TEXT_MUTED,
    "La Niña": COLOR_COLD,
    "Fría": COLOR_COLD,
}

def kpi_card(titulo: str, estado: str, valor: float, fecha_label: str) -> html.Div:
    color = SEMAFORO_COLOR.get(estado, COLOR_TEXT_MUTED)
    return html.Div(
        style=_kpi_card_style(color),
        children=[
            html.H4(titulo, style={...}),
            html.H2(estado, style={..., "color": color}),
            html.P(f"Valor: {valor:.2f} ({fecha_label})", style={...}),
        ],
    )
Enter fullscreen mode Exit fullscreen mode

Aquí es donde se ve claramente el estilo declarativo de Dash: html.Div, html.H2, html.P son funciones de Python que devuelven el equivalente a las etiquetas HTML del mismo nombre, y el style es simplemente un diccionario de Python que mapea 1:1 a CSS. No hay plantillas, ni Jinja, ni archivos .html sueltos — todo es Python puro que se compone como cualquier otra función.

La tarjeta de RONI tiene un pequeño extra: no solo muestra el valor puntual más reciente, sino que detecta si hay un evento activo por persistencia (5 temporadas consecutivas cruzando el umbral de ±0.5, que es el criterio real que usa NOAA para declarar un evento El Niño/La Niña oficial, no solo un mes suelto):

def find_active_roni_event(roni_df: pd.DataFrame):
    df = roni_df.sort_values(["year", "ref_month"]).reset_index(drop=True)
    latest = df.iloc[-1]
    if pd.isna(latest["evento_roni"]):
        return None, None, None
    evento = latest["evento_roni"]
    i = len(df) - 1
    while i > 0 and df.iloc[i - 1]["evento_roni"] == evento:
        i -= 1
    inicio = df.iloc[i]
    return evento, inicio["season"], int(inicio["year"])
Enter fullscreen mode Exit fullscreen mode

Es un while simple que retrocede en el tiempo mientras la categoría del evento se mantenga igual, para encontrar desde cuándo empezó la racha actual. Nada sofisticado, pero le da al KPI un contexto real ("La Niña desde DJF 2024") en vez de solo un número aislado — esa fue una de las mejoras que pedí explícitamente al iterar sobre el dashboard.

La parte reactiva: un solo callback

Todo el dinamismo del dashboard se reduce a un callback. El botón "Actualizar datos" dispara el pipeline ETL completo (los tres scripts de arriba) sin reiniciar el servidor:

@app.callback(
    Output("dashboard-content", "children"),
    Output("ultima-actualizacion", "children"),
    Input("actualizar-btn", "n_clicks"),
    prevent_initial_call=True,
)
def actualizar_datos(n_clicks):
    try:
        run_etl_pipeline()
        df = load_data()
        return build_dashboard_content(df), get_last_update_label()
    except Exception as exc:
        df = load_data()
        mensaje = f"Error al actualizar ({exc}). Mostrando últimos datos disponibles."
        return build_dashboard_content(df), mensaje
Enter fullscreen mode Exit fullscreen mode

Esta es la esencia del modelo de Dash: declaras qué Input dispara el callback (el número de clics del botón) y qué Output va a actualizar (el contenido del dashboard y el texto de "última actualización"). Dash se encarga de todo el cableado — escuchar el evento en el navegador, mandar la petición al servidor, correr la función de Python, y actualizar el DOM con lo que la función devuelva. Yo nunca escribí una línea de JavaScript para lograr esto.

El try/except es a propósito defensivo: si las fuentes externas (IGP o NOAA) fallan o cambian de formato, el dashboard no se rompe — muestra un mensaje de error y sigue mostrando los últimos datos disponibles en vez de una pantalla en blanco.

Y para que el estado de "última actualización" sobreviva incluso a un reinicio del servidor, en vez de guardarlo en una variable en memoria, lo derivo directamente del mtime (fecha de modificación) del archivo merged.csv:

def get_last_update_label() -> str:
    if not MERGED_PATH.exists():
        return "Sin datos aún"
    mtime = datetime.fromtimestamp(MERGED_PATH.stat().st_mtime)
    return f"Última actualización: {mtime.strftime('%d/%m/%Y %H:%M:%S')}"
Enter fullscreen mode Exit fullscreen mode

Es un detalle pequeño, pero es el tipo de decisión que evita bugs raros cuando el servidor se reinicia (por ejemplo, en un deploy) y de repente el estado en memoria ya no coincide con lo que realmente hay en disco. Fue justamente uno de los puntos donde Claude Code propuso este enfoque en vez de una variable global, al pedirle explícitamente que el estado sobreviviera a un reinicio.

Cerrando

Lo que más rescato de esta experiencia con Dash es lo poco que hubo que pelear contra el framework para lograr algo que se ve profesional: un solo archivo main.py de menos de 500 líneas contiene layout, estilos, y toda la interactividad de un dashboard con gráficos de serie temporal, zoom interactivo, KPIs dinámicos y actualización de datos en vivo. Y trabajando con Claude Code, ese "poco esfuerzo" se sintió todavía más real: pude enfocarme en decidir qué mostrar y cómo interpretarlo (los umbrales, la lógica de eventos, la UX del semáforo), mientras la implementación del layout y los callbacks avanzaba en paralelo.

Si vienes del mundo Python y quieres construir dashboards de datos sin meterte a aprender un framework de frontend completo, Dash vale mucho la pena probarlo — con o sin un agente como Claude Code de por medio — y si de paso el tema es algo que te importa como país, mejor todavía.

Repo completo, con el detalle de metodología de los umbrales (por qué ICEN usa valores fijos en vez de percentiles) en el CLAUDE.md: github.com/ckomiya/vigia-enos

Top comments (0)