Download app.py from M2LInES/samudra2: direct link, hf CLI and curl.
- Browser
- Download file 14.7 kB
-
https://huggingface.co/spaces/M2LInES/samudra2/resolve/main/app.py
- Command line
-
hf download hf://spaces/M2LInES/samudra2/app.py
-
curl -L -o app.py https://huggingface.co/spaces/M2LInES/samudra2/resolve/main/app.py
14.7 kB
| # SPDX-License-Identifier: Apache-2.0 | |
| """Samudra 2 — autoregressive global ocean emulator, rolled out live. | |
| Streams a real initial condition from the public 1° GFDL-OM4 store, runs the | |
| released `M2LInES/Samudra2` checkpoint forward autoregressively on ZeroGPU, and | |
| animates the emulated ocean next to the OM4 ground truth it is emulating. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import tempfile | |
| import time | |
| import spaces # noqa: F401 — must precede torch / any CUDA-touching import | |
| import gradio as gr | |
| import matplotlib | |
| import numpy as np | |
| import torch | |
| matplotlib.use("Agg") | |
| import imageio.v2 as imageio # noqa: E402 | |
| import matplotlib.pyplot as plt # noqa: E402 | |
| from huggingface_hub import hf_hub_download # noqa: E402 | |
| from ocean_data import HIST, N_PROG, PROG_VARS, STEP_DAYS, get_store # noqa: E402 | |
| from samudra_model import build_samudra # noqa: E402 | |
| try: | |
| import cmocean.cm as cmo | |
| CM_THERMAL, CM_HALINE, CM_BALANCE = cmo.thermal, cmo.haline, cmo.balance | |
| except Exception: # pragma: no cover - cosmetic fallback only | |
| CM_THERMAL, CM_HALINE, CM_BALANCE = "inferno", "viridis", "RdBu_r" | |
| MODEL_REPO = "M2LInES/Samudra2" | |
| MAX_STEPS = 36 # 36 x 10 days ~= 1 year | |
| # --------------------------------------------------------------------- model | |
| print("[boot] building Samudra 2 (1deg) ...", flush=True) | |
| ckpt_path = hf_hub_download(MODEL_REPO, "onedeg/ema_ckpt.pt") | |
| model = build_samudra() | |
| _state = torch.load(ckpt_path, map_location="cpu", weights_only=False)["model"] | |
| model.load_state_dict({k.replace("module.", "", 1): v for k, v in _state.items()}) | |
| model.eval().to("cuda") | |
| print("[boot] model on cuda", flush=True) | |
| # ---------------------------------------------------------------- OM4 store | |
| print("[boot] opening public OM4 zarr store ...", flush=True) | |
| store = get_store() | |
| LABEL_MASK = torch.from_numpy(np.tile(store.prog_mask, (HIST + 1, 1, 1))) | |
| DATES = [store.date_str(i) for i in range(len(store.time))] | |
| # Upstream held-out inference window (configs/data/om4.yaml). | |
| WINDOW_START, WINDOW_END = "2014-10-10", "2022-12-24" | |
| _first = next(i for i, d in enumerate(DATES) if d >= WINDOW_START) | |
| _last = len(DATES) - 2 * MAX_STEPS - 2 | |
| START_DATES = [DATES[i] for i in range(_first, _last + 1, 6)] # ~monthly | |
| print(f"[boot] {len(START_DATES)} start dates, {DATES[_first]} .. {DATES[_last]}", flush=True) | |
| # ------------------------------------------------------------------ variables | |
| # label -> (channel name, colormap, units, pretty name) | |
| VARIABLES: dict[str, tuple[str, object, str, str]] = { | |
| "Sea surface temperature (0 m)": ("thetao_0", CM_THERMAL, "°C", "SST"), | |
| "Sea surface height": ("zos", CM_BALANCE, "m", "SSH"), | |
| "Sea surface salinity (0 m)": ("so_0", CM_HALINE, "psu", "SSS"), | |
| "Temperature at 250 m": ("thetao_7", CM_THERMAL, "°C", "T 250 m"), | |
| "Temperature at 1050 m": ("thetao_11", CM_THERMAL, "°C", "T 1050 m"), | |
| "Zonal velocity at 0 m": ("uo_0", CM_BALANCE, "m/s", "u 0 m"), | |
| "Meridional velocity at 0 m": ("vo_0", CM_BALANCE, "m/s", "v 0 m"), | |
| "Salinity at 1050 m": ("so_11", CM_HALINE, "psu", "S 1050 m"), | |
| } | |
| DEFAULT_VARIABLE = "Sea surface temperature (0 m)" | |
| DEFAULT_DATE = START_DATES[0] | |
| def _gpu_duration(prognostic: np.ndarray, boundary: np.ndarray) -> int: | |
| """ZeroGPU budget, measured live: ~0.3 s/step plus a small fixed cost.""" | |
| return int(4 + 0.35 * boundary.shape[0]) | |
| def _rollout_gpu(prognostic: np.ndarray, boundary: np.ndarray) -> np.ndarray: | |
| """Run the autoregressive rollout on GPU. | |
| Args: | |
| prognostic: Normalized initial state, shape (1, 154, y, x). | |
| boundary: Normalized forcing per model step, shape (n_steps, 8, y, x). | |
| Returns: | |
| Normalized predictions, shape (n_steps, 154, y, x). | |
| """ | |
| x = torch.from_numpy(prognostic).to("cuda") | |
| bnd = torch.from_numpy(boundary).to("cuda") | |
| mask = LABEL_MASK.to("cuda") | |
| outs = [] | |
| with torch.no_grad(): | |
| for k in range(bnd.shape[0]): | |
| x = model.forward_once(x, bnd[k : k + 1], mask) | |
| outs.append(x.float().cpu().numpy()) | |
| return np.concatenate(outs, axis=0) | |
| def _panel_frames(pred: np.ndarray, truth: np.ndarray, var_label: str, | |
| start_index: int, path: str, fps: int) -> None: | |
| """Write the prediction / truth / error animation to `path` as MP4.""" | |
| _, cmap, units, short = VARIABLES[var_label] | |
| finite = truth[np.isfinite(truth)] | |
| vmin, vmax = np.percentile(finite, [1.0, 99.0]) | |
| if short in ("SSH", "u 0 m", "v 0 m"): | |
| lim = float(max(abs(vmin), abs(vmax))) | |
| vmin, vmax = -lim, lim | |
| diff = pred - truth | |
| dlim = float(np.nanpercentile(np.abs(diff), 99.0)) or 1e-6 | |
| extent = [store.lon[0], store.lon[-1], store.lat[0], store.lat[-1]] | |
| field_cm = matplotlib.colormaps[cmap].copy() if isinstance(cmap, str) else cmap.copy() | |
| diff_cm = matplotlib.colormaps["RdBu_r"].copy() | |
| for c in (field_cm, diff_cm): | |
| c.set_bad("#3a3f45") | |
| fig, axes = plt.subplots(1, 3, figsize=(15.6, 4.0), dpi=110) | |
| fig.patch.set_facecolor("#101418") | |
| ims = [] | |
| titles = [f"Samudra 2 — {short}", f"GFDL-OM4 (truth) — {short}", "Emulator − truth"] | |
| for i, ax in enumerate(axes): | |
| data = [pred, truth, diff][i][0] | |
| kw = dict(cmap=field_cm, vmin=vmin, vmax=vmax) if i < 2 else dict( | |
| cmap=diff_cm, vmin=-dlim, vmax=dlim) | |
| im = ax.imshow(data, origin="lower", extent=extent, aspect="auto", | |
| interpolation="nearest", **kw) | |
| ims.append(im) | |
| ax.set_title(titles[i], color="white", fontsize=10) | |
| ax.tick_params(colors="#9aa4ad", labelsize=7) | |
| for s in ax.spines.values(): | |
| s.set_color("#3a3f45") | |
| cb = fig.colorbar(im, ax=ax, fraction=0.032, pad=0.015) | |
| cb.ax.tick_params(colors="#9aa4ad", labelsize=7) | |
| cb.set_label(units, color="#9aa4ad", fontsize=8) | |
| sup = fig.suptitle("", color="white", fontsize=11) | |
| fig.tight_layout(rect=(0, 0, 1, 0.93)) | |
| with imageio.get_writer(path, fps=fps, codec="libx264", quality=8, | |
| macro_block_size=1, ffmpeg_log_level="error") as w: | |
| for t in range(pred.shape[0]): | |
| for im, data in zip(ims, (pred[t], truth[t], diff[t])): | |
| im.set_data(data) | |
| lead = (t + 1) * (STEP_DAYS // 2) | |
| sup.set_text(f"valid {DATES[start_index + 2 + t]} · lead +{lead} days") | |
| fig.canvas.draw() | |
| frame = np.asarray(fig.canvas.buffer_rgba())[..., :3] | |
| w.append_data(np.ascontiguousarray(frame)) | |
| plt.close(fig) | |
| def _skill_plot(pred: np.ndarray, truth: np.ndarray, persist: np.ndarray, | |
| var_label: str, path: str) -> None: | |
| """RMSE growth + global-mean drift, saved to `path` as PNG.""" | |
| _, _, units, short = VARIABLES[var_label] | |
| n = pred.shape[0] | |
| lead = np.arange(1, n + 1) * (STEP_DAYS // 2) | |
| ok = np.isfinite(truth[0]) & np.isfinite(pred[0]) | |
| w = np.cos(np.deg2rad(store.lat))[:, None] * ok | |
| def _rmse(a): | |
| e = (a - truth) ** 2 | |
| return np.sqrt(np.array([np.nansum(e[t] * w) / w.sum() for t in range(n)])) | |
| def _mean(a): | |
| return np.array([np.nansum(a[t] * w) / w.sum() for t in range(n)]) | |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 3.6), dpi=120) | |
| fig.patch.set_facecolor("#101418") | |
| ax1.plot(lead, _rmse(pred), "o-", color="#f59e0b", lw=2, ms=4, label="Samudra 2") | |
| ax1.plot(lead, _rmse(persist), "s--", color="#94a3b8", lw=1.5, ms=3, | |
| label="persistence") | |
| ax1.set_ylabel(f"area-weighted RMSE [{units}]") | |
| ax1.set_title(f"{short} — error growth", color="white", fontsize=11) | |
| ax2.plot(lead, _mean(pred), "o-", color="#f59e0b", lw=2, ms=4, label="Samudra 2") | |
| ax2.plot(lead, _mean(truth), "^-", color="#38bdf8", lw=2, ms=4, label="GFDL-OM4") | |
| ax2.set_ylabel(f"global mean [{units}]") | |
| ax2.set_title(f"{short} — global mean", color="white", fontsize=11) | |
| for ax in (ax1, ax2): | |
| ax.set_xlabel("lead time [days]") | |
| ax.set_facecolor("#171c22") | |
| ax.grid(alpha=0.18, color="#9aa4ad") | |
| ax.tick_params(colors="#9aa4ad", labelsize=8) | |
| ax.xaxis.label.set_color("#9aa4ad") | |
| ax.yaxis.label.set_color("#9aa4ad") | |
| for s in ax.spines.values(): | |
| s.set_color("#3a3f45") | |
| leg = ax.legend(fontsize=8, facecolor="#171c22", edgecolor="#3a3f45") | |
| for txt in leg.get_texts(): | |
| txt.set_color("#cbd5e1") | |
| fig.tight_layout() | |
| fig.savefig(path, facecolor=fig.get_facecolor()) | |
| plt.close(fig) | |
| def emulate( | |
| start_date: str = DEFAULT_DATE, | |
| n_steps: int = 12, | |
| variable: str = DEFAULT_VARIABLE, | |
| fps: int = 6, | |
| progress=gr.Progress(), | |
| ) -> tuple[str, str, str]: | |
| """Roll Samudra 2 forward from a real GFDL-OM4 ocean state. | |
| Args: | |
| start_date: Initial-condition date (5-daily, inside the held-out window). | |
| n_steps: Number of model steps; each advances the ocean by 10 days. | |
| variable: Ocean field to visualize. | |
| fps: Frames per second of the output animation. | |
| Returns: | |
| Path to the comparison MP4, path to the skill-score PNG, and a | |
| markdown summary of the rollout. | |
| """ | |
| if variable not in VARIABLES: | |
| raise gr.Error(f"Unknown variable: {variable}") | |
| if start_date not in DATES: | |
| raise gr.Error(f"{start_date} is not a valid OM4 timestamp.") | |
| t0 = DATES.index(start_date) | |
| n_steps = int(max(1, min(MAX_STEPS, n_steps))) | |
| if t0 + 2 + 2 * n_steps > len(DATES): | |
| n_steps = (len(DATES) - t0 - 2) // 2 | |
| var, _, units, short = VARIABLES[variable] | |
| progress(0.05, desc="Streaming initial condition from the OM4 store…") | |
| prognostic = store.initial_prognostic(t0) | |
| progress(0.20, desc="Streaming surface forcing…") | |
| boundary = store.boundary_sequence(t0, n_steps) | |
| progress(0.40, desc=f"Rolling out {n_steps} steps on GPU…") | |
| t_gpu = time.time() | |
| out = _rollout_gpu(prognostic, boundary) | |
| gpu_s = time.time() - t_gpu | |
| v = PROG_VARS.index(var) | |
| pred = store.denormalize( | |
| np.concatenate([out[k, [v, v + N_PROG]] for k in range(n_steps)]), var | |
| ) | |
| progress(0.70, desc="Fetching OM4 ground truth…") | |
| truth = store.truth(var, t0, n_steps) | |
| persist = np.broadcast_to(store.truth(var, t0 - HIST - 1, 1)[1:2], truth.shape) | |
| progress(0.82, desc="Rendering animation…") | |
| tmp = tempfile.mkdtemp() | |
| video = os.path.join(tmp, "samudra2.mp4") | |
| plot = os.path.join(tmp, "skill.png") | |
| _panel_frames(pred, truth, variable, t0, video, int(fps)) | |
| _skill_plot(pred, truth, persist, variable, plot) | |
| ok = np.isfinite(pred) & np.isfinite(truth) | |
| rmse = float(np.sqrt(np.mean((pred[ok] - truth[ok]) ** 2))) | |
| pk = np.isfinite(persist) & np.isfinite(truth) | |
| prmse = float(np.sqrt(np.mean((persist[pk] - truth[pk]) ** 2))) | |
| end = DATES[t0 + 1 + 2 * n_steps] | |
| summary = ( | |
| f"**{short}** · initialised **{start_date}** → **{end}** " | |
| f"({n_steps} model steps = {n_steps * STEP_DAYS} days, " | |
| f"{2 * n_steps} frames)\n\n" | |
| f"- RMSE vs GFDL-OM4: **{rmse:.4g} {units}** " | |
| f"(persistence {prmse:.4g} {units} → " | |
| f"**{100 * (1 - rmse / prmse):.0f}%** skill improvement)\n" | |
| f"- Rollout on GPU: **{gpu_s:.1f} s** for {n_steps} steps " | |
| f"({1000 * gpu_s / n_steps:.0f} ms/step)" | |
| ) | |
| progress(1.0, desc="Done") | |
| return video, plot, summary | |
| # ---------------------------------------------------------------------- UI | |
| CSS = """ | |
| .dark .gradio-container { background: #0b0f14; } | |
| #hdr h1 { margin-bottom: 0.15em; } | |
| """ | |
| with gr.Blocks(title="Samudra 2") as demo: | |
| gr.Markdown( | |
| """ | |
| # 🌊 Samudra 2 — global ocean emulator | |
| Roll [`M2LInES/Samudra2`](https://huggingface.co/M2LInES/Samudra2) forward from a | |
| **real ocean state**. A ConvNeXt U-Net (84 M params) trained on GFDL-OM4 predicts | |
| temperature, salinity, velocity and sea-surface height over 19 depth levels — | |
| each model step advances the global ocean by **10 days**. | |
| The initial condition and forcing are streamed live from the public 1° OM4 store, | |
| so every rollout is scored against the ocean model it emulates. | |
| """, | |
| elem_id="hdr", | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| start_date = gr.Dropdown( | |
| START_DATES, value=DEFAULT_DATE, label="Initial condition", | |
| info="5-daily OM4 state, held-out period (2014-10-10 → 2022-12-24)", | |
| ) | |
| variable = gr.Dropdown( | |
| list(VARIABLES), value=DEFAULT_VARIABLE, label="Field to visualize", | |
| ) | |
| n_steps = gr.Slider( | |
| 1, MAX_STEPS, value=12, step=1, label="Rollout length (model steps)", | |
| info="1 step = 10 days · 12 steps ≈ 4 months", | |
| ) | |
| run = gr.Button("Run rollout", variant="primary") | |
| with gr.Accordion("Advanced", open=False): | |
| fps = gr.Slider(2, 15, value=6, step=1, label="Animation frame rate") | |
| with gr.Column(scale=2): | |
| video = gr.Video(label="Emulator vs GFDL-OM4", autoplay=True, loop=True) | |
| summary = gr.Markdown() | |
| plot = gr.Image(label="Forecast skill", type="filepath") | |
| inputs = [start_date, variable, n_steps, fps] | |
| def _run(d: str = DEFAULT_DATE, v: str = DEFAULT_VARIABLE, n: int = 12, | |
| f: int = 6, progress=gr.Progress()): | |
| """Thin adapter so the UI order (date, variable, steps) drives `emulate`.""" | |
| return emulate(d, n, v, f, progress) | |
| run.click(_run, inputs=inputs, outputs=[video, plot, summary]) | |
| gr.Examples( | |
| examples=[ | |
| ["2014-10-10", "Sea surface temperature (0 m)", 12], | |
| ["2015-09-05", "Sea surface temperature (0 m)", 36], | |
| ["2016-07-01", "Sea surface height", 18], | |
| ["2018-03-24", "Sea surface salinity (0 m)", 12], | |
| ["2019-05-18", "Temperature at 250 m", 24], | |
| ["2017-01-28", "Zonal velocity at 0 m", 12], | |
| ], | |
| inputs=[start_date, variable, n_steps], | |
| outputs=[video, plot, summary], | |
| fn=_run, | |
| cache_examples=True, | |
| cache_mode="lazy", | |
| ) | |
| gr.Markdown( | |
| "Model: [M2LInES/Samudra2](https://huggingface.co/M2LInES/Samudra2) (CC-BY-4.0) · " | |
| "code [m2lines/Samudra](https://github.com/m2lines/Samudra) · " | |
| "paper [arXiv:2606.02610](https://arxiv.org/abs/2606.02610) · " | |
| "data: public 1° GFDL-OM4 store on the NYU OSN pod." | |
| ) | |
| demo.queue(max_size=12).launch(theme=gr.themes.Citrus(), css=CSS, mcp_server=True) | |