Skip to article content

Simulación del Tsunami de Tumaco de 1979

Reproducción numérica de la propagación del tsunami del 12 de diciembre de 1979 (Mw 8.2) mediante ecuaciones de aguas someras linealizadas en Google Colab

Back to Article
Cuaderno 3: Visualización y validación
Download Notebook

Cuaderno 3: Visualización y validación

Este cuaderno analiza y visualiza los resultados de la simulación del tsunami de 1979 en Tumaco. Incluye:

  1. Mapa de inundación máxima

  2. Series de tiempo en estaciones de observación históricas

  3. Validación cuantitativa contra run-up observado (Herd et al., 1981)

  4. Animación de la propagación del tsunami

Prerrequisito: Ejecutar 02-simulation.ipynb para generar los archivos h-*.np.

import sys
IN_COLAB = 'google.colab' in sys.modules

if IN_COLAB:
    !pip install -q rasterio pyproj

import os
import glob
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.animation as animation
import matplotlib.colors as mcolors
from matplotlib.colors import TwoSlopeNorm, BoundaryNorm
from matplotlib.patches import FancyArrowPatch
from pathlib import Path

import rasterio
from pyproj import Transformer

# Directorios
WORK_DIR   = Path('/content') if IN_COLAB else Path('.')
OUTPUT_DIR = WORK_DIR / 'output_1979'
DATA_DIR   = Path('../data') if not IN_COLAB else WORK_DIR / 'data'
DEM_PATH   = WORK_DIR / 'tumaco_dem_utm_sim.tif'

print(f"Directorio de resultados: {OUTPUT_DIR}")
Directorio de resultados: output_1979

1. Cargar datos de la simulación

# Celda 1 — Cargar DEM (auto-genera si no existe en esta sesión)
from scipy.ndimage import gaussian_filter
from rasterio.crs import CRS
from rasterio.transform import from_bounds
from rasterio.warp import calculate_default_transform, reproject, Resampling
import re, requests

def _t_from_name(path):
    m = re.search(r'h-([0-9.]+)\.np$', str(path))
    return float(m.group(1)) if m else 0.0

if not DEM_PATH.exists():
    print('DEM no encontrado — generando desde GEBCO/ETOPO...')
    LAT_MIN, LAT_MAX, LON_MIN, LON_MAX = 0.5, 3.5, -79.5, -77.0
    CRS_SRC  = CRS.from_epsg(4326)
    CRS_DEST = CRS.from_epsg(32618)
    geo_path = WORK_DIR / 'tumaco_bathy_geo.tif'
    bathy_ok = False

    # Intento 1: GEBCO 2023
    try:
        url = (f'https://download.gebco.net/a/gebco_2023'
               f'?lat1={LAT_MIN}&lat2={LAT_MAX}&lon1={LON_MIN}&lon2={LON_MAX}&format=netcdf4')
        r = requests.get(url, timeout=120)
        if r.status_code == 200 and len(r.content) > 5000:
            import xarray as xr, io
            ds      = xr.open_dataset(io.BytesIO(r.content))
            varname = [v for v in ds.data_vars if 'elev' in v.lower() or 'z' in v.lower()][0]
            da      = ds[varname]
            lat_dim = [d for d in da.dims if 'lat' in d.lower()][0]
            lon_dim = [d for d in da.dims if 'lon' in d.lower()][0]
            if da[lat_dim].values[0] < da[lat_dim].values[-1]:
                da = da.isel({lat_dim: slice(None, None, -1)})
            arr  = da.values.astype('float32')
            lats = da[lat_dim].values
            lons = da[lon_dim].values
            tf_g = from_bounds(lons.min(), lats.min(), lons.max(), lats.max(),
                               arr.shape[1], arr.shape[0])
            with rasterio.open(geo_path, 'w', driver='GTiff',
                               height=arr.shape[0], width=arr.shape[1],
                               count=1, dtype='float32', crs=CRS_SRC, transform=tf_g) as dst:
                dst.write(arr, 1)
            bathy_ok = True
            print('  ✓ GEBCO 2023')
    except Exception as e:
        print(f'  ✗ GEBCO: {e}')

    # Intento 2: ETOPO 2022
    if not bathy_ok:
        try:
            nc = int((LON_MAX - LON_MIN) / 0.004167)
            nr = int((LAT_MAX - LAT_MIN) / 0.004167)
            url2 = ('https://gis.ngdc.noaa.gov/arcgis/rest/services/DEM_mosaics/'
                    'DEM_global_mosaic/ImageServer/exportImage'
                    f'?bbox={LON_MIN},{LAT_MIN},{LON_MAX},{LAT_MAX}'
                    f'&bboxSR=4326&size={nc},{nr}&imageSR=4326&format=tiff&pixelType=F32&f=image')
            r2 = requests.get(url2, timeout=120)
            r2.raise_for_status()
            with open(geo_path, 'wb') as f:
                f.write(r2.content)
            bathy_ok = True
            print('  ✓ ETOPO 2022')
        except Exception as e:
            print(f'  ✗ ETOPO: {e}')

    # Intento 3: DEM sintético
    if not bathy_ok:
        print('  ⚠ DEM sintético (solo pruebas)')
        res    = 0.004167
        lons_s = np.arange(LON_MIN, LON_MAX, res)
        lats_s = np.arange(LAT_MIN, LAT_MAX, res)
        nr, nc = len(lats_s), len(lons_s)
        xr2 = (lons_s - LON_MIN) / (LON_MAX - LON_MIN)
        z   = np.where(xr2 < 0.70, -4000 + 3800*(xr2/0.70),
              np.where(xr2 < 0.88, -200 + 200*((xr2-0.70)/0.18), 10.0))
        dem_s  = np.tile(z, (nr, 1)).astype('float32')
        dem_s += gaussian_filter(np.random.randn(nr, nc)*50, sigma=5)
        tf_s   = from_bounds(LON_MIN, LAT_MIN, LON_MAX, LAT_MAX, nc, nr)
        with rasterio.open(geo_path, 'w', driver='GTiff', height=nr, width=nc,
                           count=1, dtype='float32', crs=CRS_SRC, transform=tf_s) as dst:
            dst.write(dem_s, 1)

    # Reproyectar a UTM 18N
    utm_path = WORK_DIR / 'tumaco_dem_utm.tif'
    with rasterio.open(geo_path) as src:
        tf_utm, w_utm, h_utm = calculate_default_transform(
            src.crs, CRS_DEST, src.width, src.height, *src.bounds)
        meta = src.meta.copy()
        meta.update({'crs': CRS_DEST, 'transform': tf_utm,
                     'width': w_utm, 'height': h_utm, 'dtype': 'float32'})
        with rasterio.open(utm_path, 'w', **meta) as dst:
            reproject(source=rasterio.band(src, 1), destination=rasterio.band(dst, 1),
                      src_transform=src.transform, src_crs=src.crs,
                      dst_transform=tf_utm, dst_crs=CRS_DEST,
                      resampling=Resampling.bilinear)

    # Invertir eje X (orientación W→E compatible con el solver)
    with rasterio.open(utm_path) as src:
        arr_u  = src.read(1)
        old_tf = src.transform
        meta2  = src.meta.copy()
    arr_flip = arr_u[:, ::-1]
    meta2['transform'] = rasterio.transform.from_origin(
        west=old_tf.c + old_tf.a*arr_u.shape[1], north=old_tf.f,
        xsize=-old_tf.a, ysize=-old_tf.e)
    with rasterio.open(DEM_PATH, 'w', **meta2) as dst:
        dst.write(arr_flip, 1)
    print(f'  ✓ DEM guardado: {DEM_PATH}')

# Cargar DEM
with rasterio.open(str(DEM_PATH)) as src:
    dem = src.read(1)
    dem_transform = src.transform
    dem_crs = src.crs
    RESOLUTION = abs(src.transform.a)
    NROWS, NCOLS = src.height, src.width

print(f"DEM: {NROWS}×{NCOLS} px | Resolución: {RESOLUTION:.0f} m/px")

# Cargar snapshots — sort numérico para h-0 … h-1800
output_files = sorted(glob.glob(str(OUTPUT_DIR / 'h-*.np')), key=_t_from_name)

if not output_files:
    print("⚠ No se encontraron archivos de simulación en:", OUTPUT_DIR)
    print("  Ejecuta el Cuaderno 2 en esta sesión antes de continuar.")
    DEMO_MODE = True
else:
    DEMO_MODE = False
    print(f"✓ {len(output_files)} snapshots cargados | "
          f"{Path(output_files[0]).name} → {Path(output_files[-1]).name}")

# Construir diccionario tiempo → array
heightmaps = {}
if not DEMO_MODE:
    for fpath in output_files:
        t = _t_from_name(fpath)
        heightmaps[t] = np.load(fpath)
    t_range = sorted(heightmaps.keys())
    print(f"  Tiempo: {t_range[0]:.0f}s – {t_range[-1]:.0f}s")
else:
    # Modo demo
    t_range = list(range(0, 1860, 60))
    Ly = RESOLUTION * NCOLS
    for t in t_range:
        y_vec = np.linspace(0, Ly, NCOLS)
        c_wave = 200
        y_epi = Ly - 60_000
        y_wave = y_epi - c_wave * t
        lamb = 80_000
        k1, k2 = 28.416 / lamb**2, 256. / lamb**2
        eta = 2 * (8.0 * np.exp(-k1 * (y_vec - (y_wave + 0.3153*lamb))**2)
                   - (8.0/3) * np.exp(-k2 * (y_vec - y_wave)**2))
        depth = np.abs(np.minimum(dem, 0)).mean(axis=0).clip(1, None)
        amp = (depth.mean() / depth.clip(1))**0.25
        hmap = np.tile(eta * amp, (NROWS, 1)) - np.minimum(dem, 0)
        hmap = np.clip(hmap, 0.001, None)
        heightmaps[float(t)] = hmap.astype(np.float32)
    t_range = sorted(heightmaps.keys())
DEM: 719×602 px | Resolución: 463 m/px
✓ 31 snapshots cargados | h-0.0.np → h-1800.0.np
  Tiempo: 0s – 1800s

2. Mapa de inundación máxima

# Calcular η máxima en toda la simulación
# LSWE no inunda tierra: las celdas terrestres tienen h_arr=0 por construcción.
# Lo significativo es la elevación máxima de la superficie marina (η) en el océano.
# η = h_arr + min(dem, 0) = (H0+η) + b = η  para celdas oceánicas
# η = 0 + 0 = 0                              para celdas terrestres

max_eta = np.zeros_like(dem, dtype=np.float32)

for t, hmap in heightmaps.items():
    h_arr = hmap if hmap.shape == dem.shape else hmap[:dem.shape[0], :dem.shape[1]]
    eta = (h_arr + np.minimum(dem, 0)).astype(np.float32)
    max_eta = np.maximum(max_eta, eta)

ocean_mask_val = dem < 0
coast_zone_val = (dem < 0) & (dem > -200)

print(f"η máxima (todo el océano):    {max_eta[ocean_mask_val].max():.2f} m")
print(f"η máxima (zona costera <200m): {max_eta[coast_zone_val].max():.2f} m")
η máxima (todo el océano):    16.00 m
η máxima (zona costera <200m): 16.00 m
# Coordenadas de lugares de interés en UTM 18N
transformer = Transformer.from_crs(4326, 32618, always_xy=True)

places = {
    'Tumaco': (1.82, -78.76),
    'Bocagrande': (1.73, -78.94),
    'Epicentro\n1979': (1.598, -79.359),
}
places_utm = {
    name: transformer.transform(lon, lat)
    for name, (lat, lon) in places.items()
}

def utm_to_pixel(x_utm, y_utm, transform):
    col = int((x_utm - transform.c) / transform.a)
    row = int((y_utm - transform.f) / transform.e)
    return row, col

# Extensión geográfica del DEM en km (bordes del raster)
x_origin = dem_transform.c / 1000
y_origin = dem_transform.f / 1000
x_end = (dem_transform.c + dem_transform.a * NCOLS) / 1000
y_end = (dem_transform.f + dem_transform.e * NROWS) / 1000

# Grilla de coordenadas para ax.contour (centros de pixel, en km)
# Necesaria porque contour no acepta el parámetro extent de imshow
_x_c = (dem_transform.c + dem_transform.a * (np.arange(NCOLS) + 0.5)) / 1000
_y_c = (dem_transform.f + dem_transform.e * (np.arange(NROWS) + 0.5)) / 1000
X_ct, Y_ct = np.meshgrid(_x_c, _y_c)

ext = [x_origin, x_end, y_end, y_origin]  # extent común para imshow

eta_clim = float(np.nanpercentile(max_eta[coast_zone_val], 99)) if coast_zone_val.any() else 6.0
eta_clim = max(eta_clim, 3.0)

fig, axes = plt.subplots(1, 2, figsize=(16, 7))

# --- Panel izquierdo: η máxima en el océano ---
ax = axes[0]
ax.imshow(np.where(dem < 0, dem, np.nan), cmap='Blues', alpha=0.4,
          extent=ext, vmin=-5000, vmax=0)
ax.imshow(np.where(dem >= 0, dem, np.nan), cmap='Greys', alpha=0.35,
          extent=ext, vmin=0, vmax=200)

eta_masked = np.ma.masked_where((max_eta <= 0.1) | (dem >= 0), max_eta)
im = ax.imshow(eta_masked, cmap='YlOrRd', vmin=0.1, vmax=eta_clim,
               extent=ext, alpha=0.85)
plt.colorbar(im, ax=ax, label='η máxima (m sobre NMM)', shrink=0.8)

ax.contour(X_ct, Y_ct, dem, levels=[0], colors='k', linewidths=0.8)

for name, (x_utm, y_utm) in places_utm.items():
    is_epi = 'Epicentro' in name
    ax.plot(x_utm/1000, y_utm/1000, 'r*' if is_epi else 'ko',
            markersize=12 if is_epi else 7, zorder=5)
    ax.annotate(name, (x_utm/1000, y_utm/1000),
                textcoords='offset points', xytext=(5, 5),
                fontsize=8, color='darkred' if is_epi else 'black')

ax.set_xlabel('Easting UTM 18N (km)')
ax.set_ylabel('Northing UTM 18N (km)')
ax.set_title('Elevación máxima de la ola — Tsunami Tumaco 1979\n(30 minutos, solver LSWE)')

# --- Panel derecho: η en zona costera (<200 m de profundidad) ---
ax2 = axes[1]
im2 = ax2.imshow(np.where(coast_zone_val, max_eta, np.nan),
                 cmap='hot_r', vmin=0, vmax=eta_clim, extent=ext)
plt.colorbar(im2, ax=ax2, label='η máxima en zona costera (m)', shrink=0.8)
ax2.contour(X_ct, Y_ct, dem, levels=[0], colors='k', linewidths=0.5)
ax2.set_xlabel('Easting UTM 18N (km)')
ax2.set_title('Amplitud máxima de la ola\nen zona costera (<200 m profundidad)')

for name, (x_utm, y_utm) in places_utm.items():
    if 'Epicentro' not in name:
        ax2.plot(x_utm/1000, y_utm/1000, 'ko', markersize=7)
        ax2.annotate(name, (x_utm/1000, y_utm/1000),
                     textcoords='offset points', xytext=(5, 3), fontsize=8)

plt.tight_layout()
plt.savefig(WORK_DIR / 'max_inundation_map.png', dpi=200, bbox_inches='tight')
plt.show()
print(f"η_max zona costera: {float(np.nanmax(np.where(coast_zone_val, max_eta, np.nan))):.2f} m")
print(f"Mapa guardado en: {WORK_DIR}/max_inundation_map.png")
<Figure size 1600x700 with 4 Axes>
η_max zona costera: 16.00 m
Mapa guardado en: ./max_inundation_map.png

3. Series de tiempo en estaciones de observación

Extraemos la serie temporal de altura del agua en las estaciones donde Herd et al. (1981) reportaron observaciones de run-up del tsunami de 1979.

# Estaciones de observación con coordenadas y run-up histórico
stations = [
    {'name': 'Tumaco (muelle)', 'lat': 1.82,  'lon': -78.76, 'obs_runup': 5.0,
     'obs_arrival': 15, 'color': 'firebrick'},
    {'name': 'Bocagrande',       'lat': 1.73,  'lon': -78.94, 'obs_runup': 3.0,
     'obs_arrival': 18, 'color': 'darkorange'},
    {'name': 'Esmeraldas (ECU)', 'lat': 0.97,  'lon': -79.65, 'obs_runup': 4.0,
     'obs_arrival': 12, 'color': 'steelblue'},
    {'name': 'El Charco',        'lat': 2.47,  'lon': -78.11, 'obs_runup': 1.5,
     'obs_arrival': 30, 'color': 'darkgreen'},
]

# Extraer series de tiempo de los heightmaps
fig, axes = plt.subplots(2, 2, figsize=(14, 8), sharex=True)
fig.suptitle('Series de tiempo — Altura de la superficie del agua\nTsunami Tumaco 1979',
             fontsize=13)

for idx, (sta, ax) in enumerate(zip(stations, axes.flatten())):
    # Convertir coordenadas a píxel
    x_utm, y_utm = transformer.transform(sta['lon'], sta['lat'])
    try:
        row, col = utm_to_pixel(x_utm, y_utm, dem_transform)
        row = np.clip(row, 0, NROWS - 1)
        col = np.clip(col, 0, NCOLS - 1)
        elev = dem[row, col]

        # Extraer serie de tiempo
        times = np.array(sorted(heightmaps.keys()))
        h_vals = np.array([heightmaps[t][row, col] + min(dem[row, col], 0)
                           for t in times])
    except (IndexError, KeyError):
        # Si la estación está fuera del dominio, mostrar señal vacía
        times = np.array(sorted(heightmaps.keys()))
        h_vals = np.zeros_like(times)
        elev = 0.0

    # Graficar
    ax.plot(times / 60, h_vals, color=sta['color'], linewidth=2, label='Simulado')
    ax.axhline(0, color='k', linewidth=0.5, linestyle=':')
    ax.axvline(sta['obs_arrival'], color='gray', linewidth=1, linestyle='--',
               label=f"Arribo obs. ({sta['obs_arrival']} min)")
    ax.axhline(sta['obs_runup'], color=sta['color'], linewidth=1.5, linestyle='--',
               alpha=0.6, label=f"Run-up obs.: {sta['obs_runup']} m")

    ax.fill_between(times / 60, h_vals, 0,
                    where=h_vals > 0, alpha=0.2, color=sta['color'])
    ax.set_title(f"{sta['name']} (z={elev:.1f}m)", fontsize=10)
    ax.set_ylabel('Η (m)', fontsize=9)
    ax.set_ylim(-3, 8)
    ax.legend(fontsize=8, loc='upper right')
    ax.grid(True, alpha=0.3)

for ax in axes[1]:
    ax.set_xlabel('Tiempo desde el sismo (minutos)')

plt.tight_layout()
plt.savefig(WORK_DIR / 'time_series.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"Series de tiempo guardadas en: {WORK_DIR}/time_series.png")
<Figure size 1400x800 with 4 Axes>
Series de tiempo guardadas en: ./time_series.png

4. Validación cuantitativa

Comparación del run-up máximo simulado vs. observado en 8 estaciones costeras de Herd et al. (1981) y la base de datos NOAA NGDC.

# Cargar observaciones históricas
try:
    obs_path = DATA_DIR / 'observaciones_1979.csv'
    df_obs = pd.read_csv(obs_path)
    print(f"Observaciones cargadas: {len(df_obs)} estaciones")
except FileNotFoundError:
    df_obs = pd.DataFrame({
        'estacion': ['Tumaco (muelle)', 'Tumaco (playa)', 'Bocagrande',
                     'San Juan', 'El Charco', 'Esmeraldas (ECU)',
                     'Buenaventura', 'Salinas (ECU)'],
        'lat': [1.82, 1.80, 1.73, 1.23, 2.47, 0.97, 3.88, -2.20],
        'lon': [-78.76, -78.80, -78.94, -77.97, -78.11, -79.65, -77.02, -80.97],
        'runup_m': [5.0, 4.5, 3.0, 2.5, 1.5, 4.0, 0.8, 1.2],
    })

# η máxima simulada cerca de cada estación (celdas oceánicas dentro de 3×3 px)
sim_runup = []
for _, row in df_obs.iterrows():
    try:
        x_utm, y_utm = transformer.transform(row['lon'], row['lat'])
        r, c = utm_to_pixel(x_utm, y_utm, dem_transform)
        r = np.clip(r, 0, NROWS - 1)
        c = np.clip(c, 0, NCOLS - 1)
        r0, r1 = max(0, r-2), min(NROWS, r+3)
        c0, c1 = max(0, c-2), min(NCOLS, c+3)
        # Usar max_eta (sea surface η) en zona cercana
        patch = max_eta[r0:r1, c0:c1]
        ocean_patch = patch[dem[r0:r1, c0:c1] < 0]
        max_val = float(ocean_patch.max()) if ocean_patch.size > 0 else float(patch.max())
    except Exception:
        max_val = np.nan
    sim_runup.append(max_val)

df_obs['sim_runup_m'] = sim_runup
df_val = df_obs.dropna(subset=['sim_runup_m'])
df_val = df_val[df_val['sim_runup_m'] > 0.01]   # excluir puntos fuera del dominio

if len(df_val) > 0:
    rmse = np.sqrt(((df_val['runup_m'] - df_val['sim_runup_m'])**2).mean())
    bias = (df_val['sim_runup_m'] - df_val['runup_m']).mean()
    r_sq = np.corrcoef(df_val['runup_m'], df_val['sim_runup_m'])[0,1]**2
    print(f"Métricas de validación (n={len(df_val)})")
    print(f"  RMSE: {rmse:.2f} m  |  Sesgo: {bias:+.2f} m  |  R²: {r_sq:.3f}")

print("\nComparación por estación:")
print(df_obs[['estacion', 'runup_m', 'sim_runup_m']].to_string(index=False))
Observaciones cargadas: 8 estaciones

Comparación por estación:
            estacion  runup_m  sim_runup_m
     Tumaco (muelle)      5.0          0.0
      Tumaco (playa)      4.5          0.0
          Bocagrande      3.0          0.0
            San Juan      2.5          0.0
           El Charco      1.5          0.0
Esmeraldas (Ecuador)      4.0          0.0
        Buenaventura      0.8          0.0
   Salinas (Ecuador)      1.2          0.0
# Gráfica de validación: observado vs. simulado
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Panel izquierdo: scatter observado vs. simulado
ax = axes[0]
v_max = max(df_obs['runup_m'].max(), df_obs['sim_runup_m'].fillna(0).max()) * 1.15
ax.plot([0, v_max], [0, v_max], 'k--', linewidth=1, label='1:1')
ax.plot([0, v_max], [0, 2*v_max], 'gray', linewidth=0.5, linestyle=':', alpha=0.5)
ax.plot([0, v_max], [0, v_max/2], 'gray', linewidth=0.5, linestyle=':', alpha=0.5)

sc = ax.scatter(df_obs['runup_m'], df_obs['sim_runup_m'],
                c=np.abs(df_obs['lat']), cmap='viridis', s=80, zorder=5)
plt.colorbar(sc, ax=ax, label='|Latitud| (°N)')

for _, row in df_obs.iterrows():
    ax.annotate(row['estacion'], (row['runup_m'], row['sim_runup_m'] or 0),
                fontsize=7, textcoords='offset points', xytext=(4, 3))

ax.set_xlim(0, v_max)
ax.set_ylim(0, v_max)
ax.set_xlabel('Run-up observado (m) — Herd et al. 1981')
ax.set_ylabel('Run-up simulado (m)')
ax.set_title('Validación del modelo\nTsunami Tumaco 1979')
ax.grid(True, alpha=0.3)

if len(df_val) > 0:
    ax.text(0.05, 0.92, f'RMSE = {rmse:.2f} m\nR² = {r_sq:.2f}\nSesgo = {bias:+.2f} m',
            transform=ax.transAxes, fontsize=9, verticalalignment='top',
            bbox=dict(boxstyle='round', facecolor='lightyellow', alpha=0.8))

# Panel derecho: barras comparativas
ax2 = axes[1]
x_pos = np.arange(len(df_obs))
width = 0.38
ax2.bar(x_pos - width/2, df_obs['runup_m'], width,
        label='Observado (Herd et al. 1981)', color='steelblue', alpha=0.85)
ax2.bar(x_pos + width/2, df_obs['sim_runup_m'].fillna(0), width,
        label='Simulado (N-wave Mw 8.2)', color='firebrick', alpha=0.85)
ax2.set_xticks(x_pos)
ax2.set_xticklabels(df_obs['estacion'], rotation=35, ha='right', fontsize=8)
ax2.set_ylabel('Run-up (m)')
ax2.set_title('Comparación run-up por estación')
ax2.legend(fontsize=9)
ax2.grid(True, axis='y', alpha=0.3)

plt.tight_layout()
plt.savefig(WORK_DIR / 'validation.png', dpi=150, bbox_inches='tight')
plt.show()
print(f"Gráfica de validación guardada en: {WORK_DIR}/validation.png")
<Figure size 1300x500 with 3 Axes>
Gráfica de validación guardada en: ./validation.png

5. Animación de la propagación

Genera un GIF animado mostrando la propagación del tsunami desde el epicentro hasta la costa de Tumaco durante los primeros 30 minutos.

from matplotlib.animation import FuncAnimation

fig, ax = plt.subplots(figsize=(10, 7))

# X_ct, Y_ct definidos en cell-6 (coordenadas geográficas en km para ax.contour)
from matplotlib.colors import LightSource
ls = LightSource(azdeg=315, altdeg=35)
dem_vis_hs = np.where(dem > 0, dem, 0).astype(float)
hillshade = ls.hillshade(dem_vis_hs, vert_exag=0.001)
ax.imshow(hillshade, cmap='gray', alpha=0.5, extent=ext)

ocean_mask_anim = np.ma.masked_where(dem >= 0, dem)
ax.imshow(ocean_mask_anim, cmap='Blues', vmin=-5000, vmax=0, alpha=0.4, extent=ext)

ax.contour(X_ct, Y_ct, dem, levels=[0], colors='k', linewidths=0.8)

epi_x_utm, epi_y_utm = transformer.transform(-79.359, 1.598)
ax.plot(epi_x_utm/1000, epi_y_utm/1000, 'r*', markersize=14, zorder=10,
        label='Epicentro 1979')

ANIM_CLIM = 5.0
t_sorted = sorted(heightmaps.keys())

def _wave_eta(hmap):
    return hmap + np.minimum(dem, 0)

first_eta = _wave_eta(heightmaps[t_sorted[0]])
water_layer = ax.imshow(
    np.where(np.abs(first_eta) > 0.1, first_eta, np.nan),
    cmap='RdBu_r', vmin=-ANIM_CLIM, vmax=ANIM_CLIM, alpha=0.75, extent=ext
)
plt.colorbar(water_layer, ax=ax, label='η (m sobre NMM)', shrink=0.7)

title_obj = ax.set_title('t = 0 s (0 min)', fontsize=12)
ax.set_xlabel('Easting UTM 18N (km)')
ax.set_ylabel('Northing UTM 18N (km)')
ax.legend(fontsize=9, loc='upper right')

def update_frame(frame_idx):
    t = t_sorted[frame_idx]
    eta = _wave_eta(heightmaps[t])
    water_layer.set_array(np.where(np.abs(eta) > 0.1, eta, np.nan))
    title_obj.set_text(f't = {t:.0f} s ({t/60:.0f} min)\nSimulación Tsunami Tumaco 1979')
    return [water_layer, title_obj]

anim = FuncAnimation(fig, update_frame, frames=len(t_sorted), interval=250, blit=True)
plt.tight_layout()

gif_path = WORK_DIR / 'tsunami_propagation.gif'
anim.save(str(gif_path), writer='pillow', fps=4, dpi=100)
print(f"Animación guardada: {gif_path}")
plt.close()
Animación guardada: tsunami_propagation.gif
# Mostrar la animación en Colab
from IPython.display import Image, display
if gif_path.exists():
    display(Image(str(gif_path)))
<IPython.core.display.Image object>

6. Resumen de resultados

print("=" * 60)
print("RESUMEN DE RESULTADOS — SIMULACIÓN TSUNAMI TUMACO 1979")
print("=" * 60)
print(f"  Modelo:            LSWE NumPy / Lax-Friedrichs 2D")
print(f"  Evento:            12-dic-1979, Mw 8.2")
print(f"  Resolución DEM:    {RESOLUTION:.0f} m (GEBCO 2023 / ETOPO 2022)")
print(f"  Duración sim.:     {max(t_sorted)/60:.0f} minutos")
print(f"  Snapshots:         {len(t_sorted)}")
print()
print("  --- Resultados ---")
print(f"  η máxima (océano): {max_eta[dem < 0].max():.2f} m")
print(f"  η máxima (costera <200m): {max_eta[coast_zone_val].max():.2f} m")
if len(df_val) > 0:
    print(f"  RMSE validación:   {rmse:.2f} m")
    print(f"  R²:                {r_sq:.3f}")
    print(f"  Sesgo:             {bias:+.2f} m")
print()
print("  --- Archivos generados ---")
for fname in ['max_inundation_map.png', 'time_series.png',
              'validation.png', 'tsunami_propagation.gif']:
    fp = WORK_DIR / fname
    if fp.exists():
        print(f"  ✓ {fname} ({fp.stat().st_size/1e3:.0f} KB)")
    else:
        print(f"  ✗ {fname} (no generado)")
============================================================
RESUMEN DE RESULTADOS — SIMULACIÓN TSUNAMI TUMACO 1979
============================================================
  Modelo:            LSWE NumPy / Lax-Friedrichs 2D
  Evento:            12-dic-1979, Mw 8.2
  Resolución DEM:    463 m (GEBCO 2023 / ETOPO 2022)
  Duración sim.:     30 minutos
  Snapshots:         31

  --- Resultados ---
  η máxima (océano): 16.00 m
  η máxima (costera <200m): 16.00 m

  --- Archivos generados ---
  ✓ max_inundation_map.png (575 KB)
  ✓ time_series.png (125 KB)
  ✓ validation.png (127 KB)
  ✓ tsunami_propagation.gif (2291 KB)