multimodalart HF Staff commited on
Commit
d943db6
·
verified ·
1 Parent(s): 0bea0b6

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +33 -6
  2. app.py +400 -0
  3. requirements.txt +23 -0
  4. samudra/__init__.py +12 -0
  5. samudra/aggregator/LICENSE-ACE.txt +201 -0
  6. samudra/aggregator/__init__.py +14 -0
  7. samudra/aggregator/inference/__init__.py +6 -0
  8. samudra/aggregator/inference/main.py +265 -0
  9. samudra/aggregator/inference/reduced.py +475 -0
  10. samudra/aggregator/inference/time_mean.py +350 -0
  11. samudra/aggregator/loss.py +73 -0
  12. samudra/aggregator/main.py +112 -0
  13. samudra/aggregator/metrics.py +142 -0
  14. samudra/aggregator/plotting.py +140 -0
  15. samudra/aggregator/train.py +62 -0
  16. samudra/aggregator/validate/__init__.py +6 -0
  17. samudra/aggregator/validate/main.py +117 -0
  18. samudra/aggregator/validate/map.py +122 -0
  19. samudra/aggregator/validate/reduced.py +165 -0
  20. samudra/aggregator/validate/snapshot.py +114 -0
  21. samudra/aggregator/validate/sub_aggregator.py +28 -0
  22. samudra/backend.py +107 -0
  23. samudra/cli.py +77 -0
  24. samudra/config.py +1339 -0
  25. samudra/config_base.py +166 -0
  26. samudra/config_schema.py +177 -0
  27. samudra/configs/data/llc.yaml +26 -0
  28. samudra/configs/data/obs.yaml +35 -0
  29. samudra/configs/data/om4.yaml +25 -0
  30. samudra/configs/data/om4_demo.yaml +51 -0
  31. samudra/configs/samudra_mini_om4/README.md +11 -0
  32. samudra/configs/samudra_mini_om4/model.yaml +20 -0
  33. samudra/configs/samudra_mini_om4/train.yaml +36 -0
  34. samudra/configs/samudra_multi_om4/README.md +11 -0
  35. samudra/configs/samudra_multi_om4/eval.yaml +71 -0
  36. samudra/configs/samudra_multi_om4/model.yaml +45 -0
  37. samudra/configs/samudra_multi_om4/train.yaml +75 -0
  38. samudra/configs/samudra_om4/README.md +10 -0
  39. samudra/configs/samudra_om4/eval.yaml +21 -0
  40. samudra/configs/samudra_om4/model.yaml +25 -0
  41. samudra/configs/samudra_om4/train.yaml +37 -0
  42. samudra/configs/samudra_om4/viz.yaml +25 -0
  43. samudra/configs/samudra_om4_v1/README.md +10 -0
  44. samudra/configs/samudra_om4_v1/eval.yaml +20 -0
  45. samudra/configs/samudra_om4_v1/model.yaml +25 -0
  46. samudra/configs/samudra_om4_v1/train.yaml +34 -0
  47. samudra/configs/samudra_om4_v1/viz.yaml +22 -0
  48. samudra/configs/samudra_om4_v2/README.md +11 -0
  49. samudra/configs/samudra_om4_v2/eval.yaml +20 -0
  50. samudra/configs/samudra_om4_v2/model.yaml +25 -0
README.md CHANGED
@@ -1,13 +1,40 @@
1
  ---
2
- title: Samudra2
3
- emoji: 🦀
4
- colorFrom: pink
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.25.0
8
- python_version: '3.12'
9
  app_file: app.py
 
 
 
10
  pinned: false
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Samudra 2
3
+ emoji: 🌊
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.25.0
 
8
  app_file: app.py
9
+ short_description: Neural ocean emulator rollouts scored against GFDL OM4
10
+ python_version: "3.12"
11
+ startup_duration_timeout: 1h
12
  pinned: false
13
+ license: apache-2.0
14
+ models:
15
+ - M2LInES/Samudra2
16
  ---
17
 
18
+ # Samudra 2 — a neural ocean emulator
19
+
20
+ [Samudra 2](https://huggingface.co/M2LInES/Samudra2) (M2LInES) is an autoregressive
21
+ deep-learning emulator of the **GFDL OM4 global ocean model**. Given one ocean state plus
22
+ atmospheric forcing it advances temperature, salinity and horizontal currents across 19
23
+ depth levels together with sea surface height — 77 prognostic fields at once — 10 days per
24
+ autoregressive step.
25
+
26
+ This Space runs the released **1° checkpoint** (`onedeg/ema_ckpt.pt`, 84M parameters) on
27
+ genuinely held-out years (2014–2022). The initial condition and the atmospheric forcing are
28
+ streamed live from the public OM4 archive (`m2lines-pubs/Samudra` on the Open Storage
29
+ Network), the emulator is rolled forward on ZeroGPU, and the emulated ocean is scored
30
+ against the OM4 simulation it emulates — including a persistence baseline.
31
+
32
+ ## Notes
33
+
34
+ - The vendored `samudra/` package is the upstream
35
+ [`m2lines/Samudra`](https://github.com/m2lines/Samudra) library (Apache-2.0), included
36
+ directly because its full dependency set (SkyPilot, xESMF) cannot be pip-resolved inside
37
+ a Space. Model configuration matches the upstream `samudra_om4_v2` config.
38
+ - Only the 1° checkpoint is demoed: the half- and quarter-degree checkpoints require the
39
+ `hfds_anomalies` boundary field, which is not present in the corresponding public stores.
40
+ - Each emulator step emits two 5-day snapshots, so *n* steps ⇒ 10·*n* days of forecast.
app.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Samudra 2 — an autoregressive neural ocean emulator (M2LInES).
2
+
3
+ The app streams real GFDL-OM4 ocean state from public S3 storage, rolls the
4
+ released 1-degree Samudra 2 checkpoint forward autoregressively on ZeroGPU,
5
+ and compares the emulated ocean against the OM4 simulation it emulates.
6
+ """
7
+
8
+ import spaces # noqa: F401 (must precede any CUDA-touching import)
9
+
10
+ import dataclasses
11
+ import functools
12
+ import os
13
+ import tempfile
14
+ import time
15
+
16
+ import matplotlib
17
+
18
+ matplotlib.use("Agg")
19
+
20
+ import gradio as gr
21
+ import imageio.v2 as imageio
22
+ import matplotlib.pyplot as plt
23
+ import numpy as np
24
+ import s3fs
25
+ import torch
26
+ import xarray as xr
27
+ from huggingface_hub import hf_hub_download
28
+
29
+ import samudra.utils.device as sdev
30
+ from samudra.config import BlockConfig, SamudraConfig, UNetBackboneConfig
31
+ from samudra.constants import build_om4_spec
32
+ from samudra.datasets import InferenceDataset
33
+ from samudra.utils.data import DataSource, Normalize
34
+
35
+ # Data pipeline work happens on CPU in the main process; the GPU worker flips
36
+ # this to "cuda" for the forward passes only.
37
+ sdev.set_device(torch.device("cpu"))
38
+
39
+ # --------------------------------------------------------------------------
40
+ # Data: public 1-degree OM4 store (Open Storage Network, anonymous access)
41
+ # --------------------------------------------------------------------------
42
+ ENDPOINT = "https://nyu1.osn.mghpcc.org"
43
+ ROOT = "m2lines-pubs/Samudra"
44
+ HIST = 1
45
+ MAX_STEPS = 12
46
+
47
+ _fs = s3fs.S3FileSystem(anon=True, client_kwargs={"endpoint_url": ENDPOINT})
48
+
49
+
50
+ def _open(name: str) -> xr.Dataset:
51
+ return xr.open_zarr(
52
+ s3fs.S3Map(root=f"{ROOT}/{name}", s3=_fs, check=False),
53
+ consolidated=True,
54
+ chunks={},
55
+ )
56
+
57
+
58
+ print("[samudra] opening public OM4 zarr stores ...", flush=True)
59
+ _t0 = time.time()
60
+ SPEC = build_om4_spec("thermo_dynamic_all", "tau_hfds_hfds_anom")
61
+ SRC = DataSource.from_datasets(
62
+ _open("OM4"),
63
+ _open("OM4_means"),
64
+ _open("OM4_stds"),
65
+ dataset_spec=SPEC,
66
+ prognostic_var_names=SPEC.prognostic_var_names,
67
+ boundary_var_names=SPEC.boundary_var_names,
68
+ name="om4-1deg",
69
+ )
70
+ PROG_NAMES = list(SPEC.prognostic_var_names)
71
+ BND_NAMES = list(SPEC.boundary_var_names)
72
+ N_PROG = len(PROG_NAMES)
73
+ _norm = Normalize(SRC, SPEC.prognostic_var_names, SPEC.boundary_var_names)
74
+ PROG_MEAN = np.asarray(_norm._prognostic_mean_np, dtype=np.float64)
75
+ PROG_STD = np.asarray(_norm._prognostic_std_np, dtype=np.float64)
76
+ TIMES = [str(t)[:10] for t in SRC.data.time.values]
77
+ print(f"[samudra] data source ready in {time.time() - _t0:.1f}s "
78
+ f"({len(TIMES)} snapshots, {N_PROG} prognostic vars)", flush=True)
79
+
80
+ # Samudra 2 was trained on 1975-2013 and validated to 2014-10; everything from
81
+ # 2014-10-10 onward is held out, so restrict the demo to genuine forecasts.
82
+ FIRST_HELDOUT = TIMES.index("2014-10-10")
83
+ LAST_START = len(TIMES) - (2 * MAX_STEPS + 2)
84
+ START_DATES = TIMES[FIRST_HELDOUT : LAST_START + 1]
85
+
86
+ # --------------------------------------------------------------------------
87
+ # Model: the released 1-degree Samudra 2 checkpoint
88
+ # --------------------------------------------------------------------------
89
+ print("[samudra] building model ...", flush=True)
90
+ _model_cfg = SamudraConfig(
91
+ checkpointing=None,
92
+ pred_residuals=False,
93
+ last_kernel_size=3,
94
+ pad="circular",
95
+ unet=UNetBackboneConfig(
96
+ ch_width=[280, 380, 480, 520],
97
+ dilation=[1, 2, 4, 8],
98
+ n_layers=[1, 1, 1, 1],
99
+ core_block=BlockConfig(
100
+ block_type="conv_next_block",
101
+ kernel_size=3,
102
+ activation="capped_gelu",
103
+ upscale_factor=2,
104
+ norm="batch",
105
+ ),
106
+ down_sampling_block="avg_pool",
107
+ up_sampling_block="zonally_periodic_upsample",
108
+ ),
109
+ )
110
+ _grid_src = dataclasses.replace(SRC, data=SRC.data.isel(time=slice(0, 4)))
111
+ MODEL = _model_cfg.build(
112
+ prog_channels=(HIST + 1) * N_PROG,
113
+ boundary_channels=(HIST + 1) * len(BND_NAMES),
114
+ out_channels=(HIST + 1) * N_PROG,
115
+ hist=HIST,
116
+ srcs=[_grid_src],
117
+ )
118
+ _ckpt_path = hf_hub_download("M2LInES/Samudra2", "onedeg/ema_ckpt.pt")
119
+ _ck = torch.load(_ckpt_path, map_location="cpu", weights_only=False)
120
+ MODEL.load_state_dict(
121
+ {k.removeprefix("module."): v for k, v in _ck["model"].items()}, strict=True
122
+ )
123
+ del _ck
124
+ DEVICE = os.environ.get("SAMUDRA_DEVICE", "cuda")
125
+ MODEL.eval().to(DEVICE)
126
+ N_PARAMS = sum(p.numel() for p in MODEL.parameters())
127
+ print(f"[samudra] model ready ({N_PARAMS / 1e6:.1f}M params)", flush=True)
128
+
129
+ # --------------------------------------------------------------------------
130
+ # Variable menu
131
+ # --------------------------------------------------------------------------
132
+ LEVELS = [2.5, 10, 22.5, 40, 65, 105, 165, 250, 375, 550, 775, 1050, 1400,
133
+ 1850, 2400, 3100, 4000, 5000, 6000]
134
+
135
+ VARIABLES: dict[str, dict] = {
136
+ "Sea surface temperature (2.5 m)": dict(key="thetao_0", unit="°C", cmap="RdYlBu_r"),
137
+ "Ocean temperature at 105 m": dict(key="thetao_5", unit="°C", cmap="RdYlBu_r"),
138
+ "Ocean temperature at 550 m": dict(key="thetao_9", unit="°C", cmap="RdYlBu_r"),
139
+ "Sea surface salinity (2.5 m)": dict(key="so_0", unit="psu", cmap="viridis"),
140
+ "Sea surface height": dict(key="zos", unit="m", cmap="Spectral_r"),
141
+ "Zonal velocity (2.5 m)": dict(key="uo_0", unit="m/s", cmap="RdBu_r"),
142
+ "Meridional velocity (2.5 m)": dict(key="vo_0", unit="m/s", cmap="RdBu_r"),
143
+ }
144
+ for _meta in VARIABLES.values():
145
+ _meta["idx"] = PROG_NAMES.index(_meta["key"])
146
+
147
+
148
+ # --------------------------------------------------------------------------
149
+ # Rollout
150
+ # --------------------------------------------------------------------------
151
+ @functools.lru_cache(maxsize=4)
152
+ def _prepare(i0: int, n_steps: int):
153
+ """Stream the initial ocean state and atmospheric forcing for a window."""
154
+ window = SRC.data.isel(time=slice(i0, i0 + 2 * n_steps + 2))
155
+ windowed_src = dataclasses.replace(SRC, data=window)
156
+ dataset = InferenceDataset(
157
+ src=windowed_src,
158
+ prognostic_var_names=SPEC.prognostic_var_names,
159
+ boundary_var_names=SPEC.boundary_var_names,
160
+ hist=HIST,
161
+ normalize_before_mask=True,
162
+ masked_fill_value=0.0,
163
+ long_rollout=False,
164
+ )
165
+ initial = dataset.initial_prognostic
166
+ boundaries = tuple(dataset.get_boundary(k) for k in range(n_steps))
167
+ target_times = dataset.get_target_time(0, n_steps)
168
+ return initial, boundaries, dataset.ctx, target_times, window
169
+
170
+
171
+ def _gpu_duration(initial, boundaries, ctx, var_idx) -> int:
172
+ return int(12 + 2 * len(boundaries))
173
+
174
+
175
+ @spaces.GPU(duration=_gpu_duration)
176
+ def _rollout(initial, boundaries, ctx, var_idx: int):
177
+ """Autoregressively step the emulator; returns one normalised field on CPU."""
178
+ sdev.set_device(torch.device(DEVICE))
179
+ ctx = ctx.to(torch.device(DEVICE))
180
+ state = initial.to(DEVICE)
181
+ outputs = []
182
+ with torch.no_grad():
183
+ for boundary in boundaries:
184
+ state = MODEL.forward_once(state, boundary.to(DEVICE), ctx)
185
+ outputs.append(state)
186
+ stacked = torch.cat(outputs, dim=0)
187
+ stacked = stacked.reshape(-1, N_PROG, stacked.shape[-2], stacked.shape[-1])
188
+ return stacked[:, var_idx].float().cpu().numpy()
189
+
190
+
191
+ # --------------------------------------------------------------------------
192
+ # Rendering
193
+ # --------------------------------------------------------------------------
194
+ def _roll(field: np.ndarray) -> np.ndarray:
195
+ """Shift longitudes from 0..360 to -180..180 for a familiar world map."""
196
+ return np.roll(field, field.shape[-1] // 2, axis=-1)
197
+
198
+
199
+ def _make_video(pred, truth, dates, label, unit, cmap, fps):
200
+ diff = pred - truth
201
+ finite = np.isfinite(truth)
202
+ vmin, vmax = np.nanpercentile(truth[finite], [1, 99])
203
+ dmax = float(np.nanpercentile(np.abs(diff[np.isfinite(diff)]), 99.5))
204
+ dmax = max(dmax, 1e-6)
205
+ extent = [-180, 180, -90, 90]
206
+
207
+ fig, axes = plt.subplots(3, 1, figsize=(7.2, 9.6), dpi=100)
208
+ artists = []
209
+ titles = [f"Samudra 2 emulator — {label}", f"GFDL OM4 simulation — {label}",
210
+ "Difference (emulator − OM4)"]
211
+ for ax, title, cm, lo, hi in zip(
212
+ axes, titles, [cmap, cmap, "RdBu_r"], [vmin, vmin, -dmax], [vmax, vmax, dmax]
213
+ ):
214
+ colormap = matplotlib.colormaps[cm].copy()
215
+ colormap.set_bad("0.85")
216
+ im = ax.imshow(
217
+ np.zeros_like(pred[0]), origin="lower", extent=extent,
218
+ cmap=colormap, vmin=lo, vmax=hi, interpolation="nearest",
219
+ )
220
+ ax.set_title(title, fontsize=10)
221
+ ax.set_xticks([-180, -90, 0, 90, 180])
222
+ ax.set_yticks([-90, -45, 0, 45, 90])
223
+ ax.tick_params(labelsize=7)
224
+ fig.colorbar(im, ax=ax, fraction=0.025, pad=0.02).ax.tick_params(labelsize=7)
225
+ artists.append(im)
226
+ suptitle = fig.suptitle("", fontsize=12)
227
+ fig.tight_layout(rect=[0, 0, 1, 0.96])
228
+
229
+ frames = []
230
+ for k, date in enumerate(dates):
231
+ artists[0].set_data(_roll(pred[k]))
232
+ artists[1].set_data(_roll(truth[k]))
233
+ artists[2].set_data(_roll(diff[k]))
234
+ suptitle.set_text(f"{date} · lead time +{5 * (k + 1)} days · [{unit}]")
235
+ fig.canvas.draw()
236
+ frames.append(np.asarray(fig.canvas.buffer_rgba())[..., :3].copy())
237
+ plt.close(fig)
238
+
239
+ frames = frames + [frames[-1]] * max(1, int(fps))
240
+ path = os.path.join(tempfile.mkdtemp(), "samudra2_rollout.mp4")
241
+ imageio.mimwrite(
242
+ path, frames, fps=fps, codec="libx264", quality=8, macro_block_size=16
243
+ )
244
+ return path
245
+
246
+
247
+ def _make_skill_plot(rmse, persistence, unit, label):
248
+ leads = 5 * (np.arange(len(rmse)) + 1)
249
+ fig, ax = plt.subplots(figsize=(7.2, 3.6), dpi=100)
250
+ ax.plot(leads, persistence, "o--", color="0.55", label="Persistence baseline")
251
+ ax.plot(leads, rmse, "o-", color="#1f77b4", label="Samudra 2 emulator")
252
+ ax.set_xlabel("Lead time (days)")
253
+ ax.set_ylabel(f"RMSE vs OM4 [{unit}]")
254
+ ax.set_title(f"Emulator error growth — {label}", fontsize=11)
255
+ ax.grid(alpha=0.3)
256
+ ax.legend(fontsize=9)
257
+ ax.set_ylim(bottom=0)
258
+ fig.tight_layout()
259
+ path = os.path.join(tempfile.mkdtemp(), "samudra2_skill.png")
260
+ fig.savefig(path)
261
+ plt.close(fig)
262
+ return path
263
+
264
+
265
+ # --------------------------------------------------------------------------
266
+ # Handler
267
+ # --------------------------------------------------------------------------
268
+ def forecast(
269
+ variable: str,
270
+ start_date: str,
271
+ n_steps: int = 6,
272
+ fps: int = 3,
273
+ progress=gr.Progress(track_tqdm=True),
274
+ ):
275
+ """Roll Samudra 2 forward from a held-out ocean state and score it against OM4.
276
+
277
+ Args:
278
+ variable: Which ocean field to visualise (the emulator always advances
279
+ the full 77-channel ocean state).
280
+ start_date: Initial condition date, from the held-out 2014-2022 period.
281
+ n_steps: Number of autoregressive emulator steps; each step advances the
282
+ ocean by 10 days (two 5-day snapshots).
283
+ fps: Frames per second of the returned animation.
284
+
285
+ Returns:
286
+ An MP4 animation (emulator / OM4 / difference), an error-growth chart,
287
+ and a short text summary.
288
+ """
289
+ meta = VARIABLES[variable]
290
+ n_steps = int(n_steps)
291
+ i0 = TIMES.index(start_date)
292
+
293
+ progress(0.05, desc="Streaming ocean state from public OM4 archive…")
294
+ initial, boundaries, ctx, target_times, window = _prepare(i0, n_steps)
295
+
296
+ key = meta["key"]
297
+ idx = meta["idx"]
298
+
299
+ progress(0.45, desc="Running the emulator on GPU…")
300
+ t_gpu = time.time()
301
+ pred_norm = _rollout(initial, boundaries, ctx, idx)
302
+ gpu_seconds = time.time() - t_gpu
303
+
304
+ progress(0.7, desc="Fetching the OM4 reference…")
305
+ wet = SRC.masks.prognostic[idx].numpy().astype(bool)
306
+ pred = pred_norm.astype(np.float64) * PROG_STD[idx] + PROG_MEAN[idx]
307
+ pred = np.where(wet[None], pred, np.nan)
308
+ truth = np.asarray(window[key].sel(time=target_times).values, dtype=np.float32)
309
+ truth = np.where(wet[None], truth, np.nan)
310
+ initial_field = np.where(wet, np.asarray(window[key].isel(time=1).values), np.nan)
311
+
312
+ rmse = np.sqrt(np.nanmean((pred - truth) ** 2, axis=(1, 2)))
313
+ persistence = np.sqrt(np.nanmean((initial_field[None] - truth) ** 2, axis=(1, 2)))
314
+
315
+ dates = [str(t)[:10] for t in np.asarray(target_times)]
316
+ progress(0.8, desc="Rendering animation…")
317
+ video = _make_video(pred, truth, dates, variable, meta["unit"], meta["cmap"], int(fps))
318
+ plot = _make_skill_plot(rmse, persistence, meta["unit"], variable)
319
+
320
+ summary = (
321
+ f"**Initial condition** {TIMES[i0 + 1]} → **forecast** {dates[0]} … {dates[-1]} \n"
322
+ f"**{n_steps} autoregressive steps** = {10 * n_steps} days "
323
+ f"({len(dates)} 5-day snapshots) in **{gpu_seconds:.1f} s** of GPU time. \n"
324
+ f"**{variable}** RMSE vs OM4: {rmse[0]:.3f} {meta['unit']} at +5 days → "
325
+ f"{rmse[-1]:.3f} {meta['unit']} at +{5 * len(dates)} days "
326
+ f"(persistence: {persistence[-1]:.3f} {meta['unit']}). \n"
327
+ f"Every step advances all {N_PROG} prognostic channels "
328
+ f"(temperature, salinity, currents over 19 depth levels, plus sea surface height)."
329
+ )
330
+ progress(1.0, desc="Done")
331
+ return video, plot, summary
332
+
333
+
334
+ DESCRIPTION = """
335
+ # 🌊 Samudra 2 — a neural ocean emulator
336
+
337
+ [**Samudra 2**](https://huggingface.co/M2LInES/Samudra2) (M2LInES) is an autoregressive
338
+ deep-learning emulator of the **GFDL OM4 global ocean model**. From one ocean state plus
339
+ the atmospheric forcing, it advances *temperature, salinity, and horizontal currents across
340
+ 19 depth levels together with sea surface height* — 77 prognostic fields at once — 10 days
341
+ per step, orders of magnitude faster than the ocean model it replaces.
342
+
343
+ This Space runs the released **1° checkpoint** on genuinely held-out years (2014–2022):
344
+ the initial condition and forcing are streamed live from the public OM4 archive, the
345
+ emulator is rolled forward on ZeroGPU, and the result is scored against the OM4
346
+ simulation itself.
347
+ """
348
+
349
+ with gr.Blocks(title="Samudra 2 ocean emulator") as demo:
350
+ gr.Markdown(DESCRIPTION)
351
+
352
+ with gr.Row():
353
+ with gr.Column(scale=1):
354
+ variable = gr.Dropdown(
355
+ choices=list(VARIABLES), value="Sea surface temperature (2.5 m)",
356
+ label="Field to visualise",
357
+ info="The emulator always advances the full ocean state.",
358
+ )
359
+ start_date = gr.Dropdown(
360
+ choices=START_DATES, value="2015-06-02",
361
+ label="Initial condition (held-out period)",
362
+ )
363
+ n_steps = gr.Slider(
364
+ 1, MAX_STEPS, value=6, step=1,
365
+ label="Autoregressive steps (10 days each)",
366
+ )
367
+ run = gr.Button("Run the ocean emulator", variant="primary")
368
+ with gr.Accordion("Advanced", open=False):
369
+ fps = gr.Slider(1, 8, value=3, step=1, label="Animation frames per second")
370
+ summary = gr.Markdown()
371
+ with gr.Column(scale=2):
372
+ video = gr.Video(label="Emulator vs OM4", autoplay=True, loop=True)
373
+ plot = gr.Image(label="Error growth with lead time", type="filepath")
374
+
375
+ inputs = [variable, start_date, n_steps, fps]
376
+ outputs = [video, plot, summary]
377
+ run.click(forecast, inputs=inputs, outputs=outputs, api_name="forecast")
378
+
379
+ gr.Examples(
380
+ examples=[
381
+ ["Sea surface temperature (2.5 m)", "2015-06-02", 6],
382
+ ["Sea surface height", "2016-02-07", 8],
383
+ ["Sea surface salinity (2.5 m)", "2018-11-04", 6],
384
+ ["Ocean temperature at 105 m", "2020-03-18", 10],
385
+ ],
386
+ inputs=[variable, start_date, n_steps],
387
+ outputs=outputs,
388
+ fn=forecast,
389
+ cache_examples=True,
390
+ cache_mode="lazy",
391
+ )
392
+
393
+ gr.Markdown(
394
+ "Data: public 1° GFDL-OM4 archive (`m2lines-pubs/Samudra`, Open Storage Network). "
395
+ "Model: [M2LInES/Samudra2](https://huggingface.co/M2LInES/Samudra2) · "
396
+ "code: [m2lines/Samudra](https://github.com/m2lines/Samudra) (Apache-2.0)."
397
+ )
398
+
399
+ if __name__ == "__main__":
400
+ demo.queue().launch(theme=gr.themes.Citrus(), mcp_server=True)
requirements.txt ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ numpy>=1.26.4,<2
2
+ pandas>=2.2
3
+ scipy>=1.15.2
4
+ matplotlib>=3.10.1
5
+ imageio>=2.34
6
+ imageio-ffmpeg>=0.5
7
+ cftime>=1.6.4.post1
8
+ dacite>=1.9.2
9
+ dask>=2025.2,<2026
10
+ einops>=0.7
11
+ jaxtyping>=0.3
12
+ microsoft-aurora>=1.8
13
+ perceiver-pytorch>=0.8.8
14
+ pydantic-settings>=2.8.1
15
+ pyyaml>=6.0.2
16
+ s3fs==2025.5.1
17
+ torchinfo>=1.8
18
+ tqdm>=4.67.1
19
+ typing-extensions>=4.15
20
+ wandb>=0.19.8
21
+ xarray>=2025.1.2
22
+ xarray-einstats>=0.8
23
+ zarr<3
samudra/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ __version__ = version("samudra")
9
+ except PackageNotFoundError: # not installed (e.g. running from a bare source tree)
10
+ __version__ = "0.0.0+unknown"
11
+
12
+ __all__ = ["__version__"]
samudra/aggregator/LICENSE-ACE.txt ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
samudra/aggregator/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ from .inference import InferenceEvaluatorAggregator
7
+ from .loss import (
8
+ get_channel_loss_dict,
9
+ get_channel_loss_scale_dict,
10
+ get_depth_loss_dict,
11
+ get_variable_loss_dict,
12
+ )
13
+ from .main import Aggregator
14
+ from .validate import ValidateAggregator
samudra/aggregator/inference/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ from .main import InferenceEvaluatorAggregator
samudra/aggregator/inference/main.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import torch
7
+ import wandb
8
+ import xarray as xr
9
+
10
+ from samudra.constants import TensorMap
11
+ from samudra.utils.data import Normalize, get_aggregator_dicts
12
+ from samudra.utils.output import ModelInferenceOutput
13
+ from samudra.utils.wandb import Metrics, MetricsDict
14
+
15
+ from ..validate.reduced import MeanAggregator as OneStepMeanAggregator
16
+ from .reduced import MeanAggregator
17
+ from .time_mean import TimeMeanEvaluatorAggregator
18
+
19
+
20
+ class InferenceEvaluatorAggregator:
21
+ """
22
+ Aggregates statistics for inference comparing a generated and target series.
23
+ """
24
+
25
+ def __init__(
26
+ self,
27
+ n_timesteps: int,
28
+ metadata: dict[str, dict[str, str]],
29
+ hist: int,
30
+ area_weights: torch.Tensor,
31
+ wet: torch.Tensor,
32
+ num_prognostic_channels: int,
33
+ normalize: Normalize,
34
+ tensor_map: TensorMap,
35
+ record_step_20: bool = True,
36
+ log_global_mean_time_series: bool = True,
37
+ log_global_mean_norm_time_series: bool = True,
38
+ time_mean_reference_data: xr.Dataset | None = None,
39
+ channel_mean_names: list[str] | None = None,
40
+ ):
41
+ """
42
+ Args:
43
+ n_timesteps: Number of timesteps of inference that will be run.
44
+ metadata: Mapping of variable names their metadata that will
45
+ used in generating logged image captions.
46
+ hist: Number of timesteps of history.
47
+ area_weights: Area weights for the data.
48
+ wet: Wet mask for the data.
49
+ num_prognostic_channels: Number of prognostic channels in the data.
50
+ normalize: Normalization helper for prognostic channels.
51
+ tensor_map: Mapping from prognostic variables to tensor channels.
52
+ record_step_20: Whether to record the mean of the 20th steps.
53
+ log_global_mean_time_series: Whether to log global mean time series metrics.
54
+ log_global_mean_norm_time_series: Whether to log the normalized global mean
55
+ time series metrics.
56
+ time_mean_reference_data: Reference time means for computing bias stats.
57
+ channel_mean_names: List of channel names to compute the mean of.
58
+ """
59
+ self._aggregators: dict[
60
+ str, MeanAggregator | OneStepMeanAggregator | TimeMeanEvaluatorAggregator
61
+ ] = {}
62
+ self._time_dependent_aggregators: dict[str, TimeMeanEvaluatorAggregator] = {}
63
+ self._log_time_series = (
64
+ log_global_mean_time_series or log_global_mean_norm_time_series
65
+ )
66
+ if log_global_mean_time_series:
67
+ self._aggregators["mean"] = MeanAggregator(
68
+ target="denorm",
69
+ n_timesteps=n_timesteps,
70
+ metadata=metadata,
71
+ area_weights=area_weights,
72
+ )
73
+ if log_global_mean_norm_time_series:
74
+ self._aggregators["mean_norm"] = MeanAggregator(
75
+ target="norm",
76
+ n_timesteps=n_timesteps,
77
+ metadata=metadata,
78
+ area_weights=area_weights,
79
+ )
80
+ if record_step_20:
81
+ self._aggregators["mean_step_20"] = OneStepMeanAggregator(
82
+ target_time=20,
83
+ area_weights=area_weights,
84
+ )
85
+ self._aggregators["time_mean"] = TimeMeanEvaluatorAggregator(
86
+ metadata=metadata,
87
+ area_weights=area_weights,
88
+ reference_means=time_mean_reference_data,
89
+ channel_mean_names=channel_mean_names,
90
+ )
91
+ self._aggregators["time_mean_norm"] = TimeMeanEvaluatorAggregator(
92
+ metadata=metadata,
93
+ area_weights=area_weights,
94
+ target="norm",
95
+ reference_means=time_mean_reference_data,
96
+ channel_mean_names=channel_mean_names,
97
+ )
98
+
99
+ self._summary_aggregators = {
100
+ name: agg
101
+ for name, agg in list(self._aggregators.items())
102
+ + list(self._time_dependent_aggregators.items())
103
+ if name not in ["mean", "mean_norm"]
104
+ }
105
+ self._n_timesteps_seen = 0
106
+ self._normalize = normalize
107
+ self._tensor_map = tensor_map
108
+ self.num_prognostic_channels = num_prognostic_channels
109
+ self.hist = hist
110
+ self.wet = wet
111
+
112
+ @property
113
+ def log_time_series(self) -> bool:
114
+ return self._log_time_series
115
+
116
+ @torch.no_grad()
117
+ def record_batch(self, data: ModelInferenceOutput):
118
+ if len(data.prediction) == 0:
119
+ raise ValueError("No prediction values in data")
120
+ if len(data.target) == 0:
121
+ raise ValueError("No target values in data")
122
+ total_len = len(data.time)
123
+ assert data.prediction.shape[0] == total_len // (self.hist + 1)
124
+ target_norm_dict, target_unnorm_dict = get_aggregator_dicts(
125
+ data.target,
126
+ normalize=self._normalize,
127
+ tensor_map=self._tensor_map,
128
+ wet=self.wet,
129
+ long_rollout=True,
130
+ input_type="prognostic",
131
+ num_prognostic_channels=self.num_prognostic_channels,
132
+ hist=self.hist,
133
+ )
134
+ gen_norm_dict, gen_unnorm_dict = get_aggregator_dicts(
135
+ data.prediction,
136
+ normalize=self._normalize,
137
+ tensor_map=self._tensor_map,
138
+ wet=self.wet,
139
+ long_rollout=True,
140
+ input_type="prognostic",
141
+ num_prognostic_channels=self.num_prognostic_channels,
142
+ hist=self.hist,
143
+ )
144
+
145
+ for aggregator in self._aggregators.values():
146
+ aggregator.record_batch(
147
+ target_data=target_unnorm_dict,
148
+ gen_data=gen_unnorm_dict,
149
+ target_data_norm=target_norm_dict,
150
+ gen_data_norm=gen_norm_dict,
151
+ i_time_start=self._n_timesteps_seen,
152
+ )
153
+ # TODO: Add time-dependent aggregators
154
+ # for time_dependent_aggregator in self._time_dependent_aggregators.values():
155
+ # time_dependent_aggregator.record_batch(
156
+ # time=data.time,
157
+ # target_data=target_unnorm_dict,
158
+ # gen_data=gen_unnorm_dict,
159
+ # )
160
+ key = list(target_unnorm_dict.keys())[0]
161
+ n_times = target_unnorm_dict[key].shape[1]
162
+ logs = self._get_inference_logs_slice(
163
+ step_slice=slice(self._n_timesteps_seen, self._n_timesteps_seen + n_times),
164
+ )
165
+ self._n_timesteps_seen += n_times
166
+ return logs
167
+
168
+ def record_initial_prognostic(
169
+ self,
170
+ initial_prognostic: torch.Tensor,
171
+ ):
172
+ if self._n_timesteps_seen != 0:
173
+ raise RuntimeError(
174
+ "record_initial_condition may only be called once, "
175
+ "before recording any batches"
176
+ )
177
+
178
+ data_norm_dict, data_unnorm_dict = get_aggregator_dicts(
179
+ initial_prognostic,
180
+ normalize=self._normalize,
181
+ tensor_map=self._tensor_map,
182
+ wet=self.wet,
183
+ long_rollout=True,
184
+ input_type="input",
185
+ num_prognostic_channels=self.num_prognostic_channels,
186
+ hist=self.hist,
187
+ )
188
+ for aggregator_name in ["mean", "mean_norm"]:
189
+ aggregator = self._aggregators.get(aggregator_name)
190
+ if aggregator is not None:
191
+ aggregator.record_batch(
192
+ target_data=data_unnorm_dict,
193
+ gen_data=data_unnorm_dict,
194
+ target_data_norm=data_norm_dict,
195
+ gen_data_norm=data_norm_dict,
196
+ i_time_start=0,
197
+ )
198
+ key = list(data_unnorm_dict.keys())[0]
199
+ n_times = data_unnorm_dict[key].shape[1]
200
+ logs = self._get_inference_logs_slice(
201
+ step_slice=slice(0, n_times),
202
+ )
203
+ self._n_timesteps_seen = n_times
204
+ return logs
205
+
206
+ def get_summary_logs(self):
207
+ # These aggregators require a full timeseries of
208
+ # data and thus wandb logged at the end
209
+ logs: MetricsDict = {}
210
+ for name, aggregator in self._summary_aggregators.items():
211
+ logs.update(aggregator.get_logs(label=name))
212
+ return logs
213
+
214
+ @torch.no_grad()
215
+ def _get_logs(self) -> Metrics:
216
+ """
217
+ Returns logs as can be reported to WandB.
218
+ """
219
+ logs: MetricsDict = {}
220
+ for name, aggregator in self._aggregators.items():
221
+ logs.update(aggregator.get_logs(label=name))
222
+ for name, time_dependent_aggregator in self._time_dependent_aggregators.items():
223
+ logs.update(time_dependent_aggregator.get_logs(label=name))
224
+ return logs
225
+
226
+ @torch.no_grad()
227
+ def _get_inference_logs_slice(self, step_slice: slice):
228
+ """
229
+ Returns a subset of the time series for applicable metrics
230
+ for a specific slice of as can be reported to WandB.
231
+
232
+ Args:
233
+ step_slice: Timestep slice to determine the time series subset.
234
+
235
+ Returns:
236
+ Tuple of start index and list of logs.
237
+ """
238
+ logs = {}
239
+ for name, aggregator in self._aggregators.items():
240
+ if isinstance(aggregator, MeanAggregator):
241
+ logs.update(aggregator.get_logs(label=name, step_slice=step_slice))
242
+ return to_inference_logs(logs)
243
+
244
+
245
+ def to_inference_logs(log):
246
+ # we have a dictionary which contains WandB tables
247
+ # which we will convert to a list of dictionaries, one for each
248
+ # row in the tables. Any scalar values will be reported in the last
249
+ # dictionary.
250
+ n_rows = 0
251
+ for val in log.values():
252
+ if isinstance(val, wandb.Table):
253
+ n_rows = max(n_rows, len(val.data))
254
+ logs: list[dict[str, float | int]] = []
255
+ for i in range(max(1, n_rows)): # need at least one for non-series values
256
+ logs.append({})
257
+ for key, val in log.items():
258
+ if isinstance(val, wandb.Table):
259
+ for i, row in enumerate(val.data):
260
+ for j, col in enumerate(val.columns):
261
+ key_without_table_name = key[: key.rfind("/")]
262
+ logs[i][f"{key_without_table_name}/{col}"] = row[j]
263
+ else:
264
+ logs[-1][key] = val
265
+ return logs
samudra/aggregator/inference/reduced.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import dataclasses
7
+ from collections import defaultdict
8
+ from functools import partial
9
+ from typing import Literal, Protocol
10
+
11
+ import numpy as np
12
+ import torch
13
+ import wandb
14
+
15
+ from samudra.aggregator.metrics import (
16
+ area_weighted_gradient_magnitude_percent_diff,
17
+ area_weighted_mean,
18
+ area_weighted_mean_bias,
19
+ area_weighted_rmse,
20
+ area_weighted_std,
21
+ )
22
+ from samudra.utils.device import get_device
23
+ from samudra.utils.distributed import all_reduce_mean
24
+ from samudra.utils.wandb import Metrics
25
+
26
+
27
+ @dataclasses.dataclass
28
+ class _SeriesData:
29
+ metric_name: str
30
+ var_name: str
31
+ data: np.ndarray
32
+
33
+ def get_wandb_key(self) -> str:
34
+ return f"{self.metric_name}/{self.var_name}"
35
+
36
+
37
+ def get_gen_shape(gen_data: dict[str, torch.Tensor]):
38
+ for name in gen_data:
39
+ return gen_data[name].shape
40
+
41
+
42
+ class MeanMetric(Protocol):
43
+ def record(self, target: torch.Tensor, gen: torch.Tensor, i_time_start: int):
44
+ """
45
+ Update metric for a batch of data.
46
+ """
47
+ ...
48
+
49
+ def get(self) -> torch.Tensor:
50
+ """
51
+ Get the total metric value, not divided by number of recorded batches.
52
+ """
53
+ ...
54
+
55
+
56
+ class SingleTargetMeanMetric(Protocol):
57
+ def record(self, tensor: torch.Tensor, i_time_start: int):
58
+ """
59
+ Update metric for a batch of data.
60
+ """
61
+ ...
62
+
63
+ def get(self) -> torch.Tensor:
64
+ """
65
+ Get the total metric value, not divided by number of recorded batches.
66
+ """
67
+ ...
68
+
69
+
70
+ class AreaWeightedFunction(Protocol):
71
+ """
72
+ A function that computes a metric on the true and predicted values,
73
+ weighted by area.
74
+ """
75
+
76
+ def __call__(
77
+ self,
78
+ target: torch.Tensor,
79
+ gen: torch.Tensor,
80
+ ) -> torch.Tensor: ...
81
+
82
+
83
+ class AreaWeightedSingleTargetFunction(Protocol):
84
+ """
85
+ A function that computes a metric on a single value, weighted by area.
86
+ """
87
+
88
+ def __call__(
89
+ self,
90
+ tensor: torch.Tensor,
91
+ ) -> torch.Tensor: ...
92
+
93
+
94
+ def compute_metric_on(
95
+ source: Literal["gen", "target"], metric: AreaWeightedSingleTargetFunction
96
+ ) -> AreaWeightedFunction:
97
+ """Turns a single-target metric function
98
+ (computed on only the generated or target data) into a function that takes in
99
+ both the generated and target data as arguments, as required for the APIs
100
+ which call generic metric functions.
101
+ """
102
+
103
+ def metric_wrapper(
104
+ target: torch.Tensor,
105
+ gen: torch.Tensor,
106
+ ) -> torch.Tensor:
107
+ if source == "gen":
108
+ return metric(gen)
109
+ elif source == "target":
110
+ return metric(target)
111
+
112
+ return metric_wrapper
113
+
114
+
115
+ class AreaWeightedReducedMetric:
116
+ """
117
+ A wrapper around an area-weighted metric function.
118
+ """
119
+
120
+ def __init__(
121
+ self,
122
+ device: torch.device,
123
+ compute_metric: AreaWeightedFunction,
124
+ n_timesteps: int,
125
+ ):
126
+ self._compute_metric = compute_metric
127
+ self._total: torch.Tensor | None = None
128
+ self._n_batches = torch.zeros(
129
+ n_timesteps, dtype=torch.int32, device=get_device()
130
+ )
131
+ self._device = device
132
+ self._n_timesteps = n_timesteps
133
+
134
+ def record(self, target: torch.Tensor, gen: torch.Tensor, i_time_start: int):
135
+ """Add a batch of data to the metric.
136
+
137
+ Args:
138
+ target: Target data. Should have shape [batch, time, height, width].
139
+ gen: Generated data. Should have shape [batch, time, height, width].
140
+ i_time_start: The index of the first timestep in the batch.
141
+ """
142
+ time_dim = 1
143
+ if target.shape != gen.shape:
144
+ raise RuntimeError(
145
+ "target and gen must have the same shape, got "
146
+ f"{target.shape} and {gen.shape}"
147
+ )
148
+ new_value = (
149
+ self._compute_metric(target=target, gen=gen).mean(dim=0).to(self._device)
150
+ )
151
+ if self._total is None:
152
+ self._total = torch.zeros(
153
+ [self._n_timesteps], dtype=new_value.dtype, device=self._device
154
+ )
155
+ time_slice = slice(i_time_start, i_time_start + gen.shape[time_dim])
156
+ self._total[time_slice] += new_value
157
+ self._n_batches[time_slice] += 1
158
+
159
+ def get(self) -> torch.Tensor:
160
+ """Returns the mean metric across recorded batches."""
161
+ if self._total is None:
162
+ return torch.tensor(torch.nan)
163
+ return self._total / self._n_batches
164
+
165
+
166
+ class MeanAggregator:
167
+ def __init__(
168
+ self,
169
+ target: Literal["norm", "denorm"],
170
+ n_timesteps: int,
171
+ area_weights: torch.Tensor,
172
+ metadata: dict[str, dict[str, str]] | None = None,
173
+ ):
174
+ self._variable_metrics: dict[str, dict[str, MeanMetric]] | None = None
175
+ self._shape_x = None
176
+ self._shape_y = None
177
+ self._target = target
178
+ self._n_timesteps = n_timesteps
179
+ self._area_weights = area_weights
180
+
181
+ if metadata is None:
182
+ self._metadata: dict[str, dict[str, str]] = {}
183
+ else:
184
+ self._metadata = metadata
185
+
186
+ def _get_variable_metrics(self, gen_data: dict[str, torch.Tensor]):
187
+ if self._variable_metrics is None:
188
+ self._variable_metrics = {}
189
+
190
+ device = get_device()
191
+ self._variable_metrics["weighted_rmse"] = defaultdict(
192
+ lambda: AreaWeightedReducedMetric(
193
+ device=device,
194
+ compute_metric=partial(
195
+ area_weighted_rmse, area_weights=self._area_weights
196
+ ),
197
+ n_timesteps=self._n_timesteps,
198
+ )
199
+ )
200
+ if self._target == "denorm":
201
+ self._variable_metrics["weighted_grad_mag_percent_diff"] = defaultdict(
202
+ lambda: AreaWeightedReducedMetric(
203
+ device=device,
204
+ compute_metric=partial(
205
+ area_weighted_gradient_magnitude_percent_diff,
206
+ area_weights=self._area_weights,
207
+ ), # noqa: E501
208
+ n_timesteps=self._n_timesteps,
209
+ )
210
+ )
211
+ self._variable_metrics["weighted_mean_gen"] = defaultdict(
212
+ lambda: AreaWeightedReducedMetric(
213
+ device=device,
214
+ compute_metric=compute_metric_on(
215
+ source="gen",
216
+ metric=lambda tensor: area_weighted_mean(
217
+ tensor, area_weights=self._area_weights
218
+ ),
219
+ ),
220
+ n_timesteps=self._n_timesteps,
221
+ )
222
+ )
223
+ self._variable_metrics["weighted_mean_target"] = defaultdict(
224
+ lambda: AreaWeightedReducedMetric(
225
+ device=device,
226
+ compute_metric=compute_metric_on(
227
+ source="target",
228
+ metric=lambda tensor: area_weighted_mean(
229
+ tensor, area_weights=self._area_weights
230
+ ),
231
+ ),
232
+ n_timesteps=self._n_timesteps,
233
+ )
234
+ )
235
+ self._variable_metrics["weighted_bias"] = defaultdict(
236
+ lambda: AreaWeightedReducedMetric(
237
+ device=device,
238
+ compute_metric=partial(
239
+ area_weighted_mean_bias, area_weights=self._area_weights
240
+ ),
241
+ n_timesteps=self._n_timesteps,
242
+ )
243
+ )
244
+ self._variable_metrics["weighted_std_gen"] = defaultdict(
245
+ lambda: AreaWeightedReducedMetric(
246
+ device=device,
247
+ compute_metric=compute_metric_on(
248
+ source="gen",
249
+ metric=(
250
+ lambda tensor: area_weighted_std(
251
+ tensor, area_weights=self._area_weights
252
+ )
253
+ ),
254
+ ),
255
+ n_timesteps=self._n_timesteps,
256
+ )
257
+ )
258
+ return self._variable_metrics
259
+
260
+ @torch.no_grad()
261
+ def record_batch(
262
+ self,
263
+ target_data: dict[str, torch.Tensor],
264
+ gen_data: dict[str, torch.Tensor],
265
+ target_data_norm: dict[str, torch.Tensor],
266
+ gen_data_norm: dict[str, torch.Tensor],
267
+ i_time_start: int = 0,
268
+ ):
269
+ if self._target == "norm":
270
+ target_data = target_data_norm
271
+ gen_data = gen_data_norm
272
+ variable_metrics = self._get_variable_metrics(gen_data)
273
+ for name in gen_data.keys():
274
+ for metric in variable_metrics:
275
+ variable_metrics[metric][name].record(
276
+ target=target_data[name],
277
+ gen=gen_data[name],
278
+ i_time_start=i_time_start,
279
+ )
280
+
281
+ def _get_series_data(self, step_slice: slice | None = None) -> list[_SeriesData]:
282
+ """Converts internally stored variable_metrics to a list."""
283
+ if self._variable_metrics is None:
284
+ raise ValueError("No batches have been recorded.")
285
+ data: list[_SeriesData] = []
286
+ for metric in self._variable_metrics:
287
+ sorted_keys = sorted(list(self._variable_metrics[metric].keys()))
288
+ for key in sorted_keys:
289
+ arr = self._variable_metrics[metric][key].get().detach()
290
+ if step_slice is not None:
291
+ arr = arr[step_slice]
292
+ datum = _SeriesData(
293
+ metric_name=metric,
294
+ var_name=key,
295
+ data=all_reduce_mean(arr).cpu().numpy(),
296
+ )
297
+ data.append(datum)
298
+ return data
299
+
300
+ @torch.no_grad()
301
+ def get_logs(self, label: str, step_slice: slice | None = None):
302
+ """
303
+ Returns logs as can be reported to WandB.
304
+
305
+ Args:
306
+ label: Label to prepend to all log keys.
307
+ step_slice: Slice of forecast steps to log.
308
+ """
309
+ logs = {}
310
+ series_data: dict[str, np.ndarray] = {
311
+ datum.get_wandb_key(): datum.data
312
+ for datum in self._get_series_data(step_slice)
313
+ }
314
+ init_step = 0 if step_slice is None else step_slice.start
315
+ table = data_to_table(series_data, init_step)
316
+ logs[f"{label}/series"] = table
317
+ return logs
318
+
319
+
320
+ def data_to_table(data: dict[str, np.ndarray], init_step: int = 0) -> wandb.Table:
321
+ """
322
+ Convert a dictionary of 1-dimensional timeseries data to a wandb Table.
323
+
324
+ Args:
325
+ data: dictionary of timeseries data.
326
+ init_step: initial step corresponding to the first row's "forecast_step"
327
+ """
328
+ keys = sorted(list(data.keys()))
329
+ table = wandb.Table(columns=["forecast_step"] + keys)
330
+ if len(keys) > 0:
331
+ for i in range(len(data[keys[0]])):
332
+ row = [init_step + i]
333
+ for key in keys:
334
+ row.append(data[key][i])
335
+ table.add_data(*row)
336
+ return table
337
+
338
+
339
+ class AreaWeightedSingleTargetReducedMetric:
340
+ """
341
+ A wrapper around an area-weighted metric function on a single data source.
342
+ """
343
+
344
+ def __init__(
345
+ self,
346
+ device: torch.device,
347
+ compute_metric: AreaWeightedSingleTargetFunction,
348
+ n_timesteps: int,
349
+ ):
350
+ self._compute_metric = compute_metric
351
+ self._total: torch.Tensor | None = None
352
+ self._n_batches = torch.zeros(
353
+ n_timesteps, dtype=torch.int32, device=get_device()
354
+ )
355
+ self._device = device
356
+ self._n_timesteps = n_timesteps
357
+
358
+ def record(self, tensor: torch.Tensor, i_time_start: int):
359
+ """Add a batch of data to the metric.
360
+
361
+ Args:
362
+ tensor: batch data. Should have shape [batch, time, height, width].
363
+ i_time_start: The index of the first timestep in the batch.
364
+ """
365
+ new_value = self._compute_metric(tensor).mean(dim=0).to(self._device)
366
+ if self._total is None:
367
+ self._total = torch.zeros(
368
+ [self._n_timesteps], dtype=new_value.dtype, device=self._device
369
+ )
370
+ time_slice = slice(i_time_start, i_time_start + tensor.shape[1])
371
+ self._total[time_slice] += new_value
372
+ self._n_batches[time_slice] += 1
373
+
374
+ def get(self) -> torch.Tensor:
375
+ """Returns the mean metric across recorded batches."""
376
+ if self._total is None:
377
+ return torch.tensor(torch.nan)
378
+ return self._total / self._n_batches
379
+
380
+
381
+ class SingleTargetMeanAggregator:
382
+ def __init__(
383
+ self,
384
+ n_timesteps: int,
385
+ area_weights: torch.Tensor,
386
+ metadata: dict[str, dict[str, str]] | None = None,
387
+ ):
388
+ self._variable_metrics: dict[str, dict[str, SingleTargetMeanMetric]] | None = (
389
+ None
390
+ )
391
+ self._shape_x = None
392
+ self._shape_y = None
393
+ self._n_timesteps = n_timesteps
394
+ self._area_weights = area_weights
395
+ if metadata is None:
396
+ self._metadata: dict[str, dict[str, str]] = {}
397
+ else:
398
+ self._metadata = metadata
399
+
400
+ def _get_variable_metrics(self, gen_data: dict[str, torch.Tensor]):
401
+ if self._variable_metrics is None:
402
+ self._variable_metrics = {
403
+ "weighted_mean_gen": {},
404
+ "weighted_std_gen": {},
405
+ }
406
+
407
+ device = get_device()
408
+ self._variable_metrics["weighted_mean_gen"] = defaultdict(
409
+ lambda: AreaWeightedSingleTargetReducedMetric(
410
+ device=device,
411
+ compute_metric=lambda x: area_weighted_mean(x, self._area_weights),
412
+ n_timesteps=self._n_timesteps,
413
+ )
414
+ )
415
+ self._variable_metrics["weighted_std_gen"] = defaultdict(
416
+ lambda: AreaWeightedSingleTargetReducedMetric(
417
+ device=device,
418
+ compute_metric=lambda x: area_weighted_std(x, self._area_weights),
419
+ n_timesteps=self._n_timesteps,
420
+ )
421
+ )
422
+
423
+ return self._variable_metrics
424
+
425
+ @torch.no_grad()
426
+ def record_batch(
427
+ self,
428
+ data: dict[str, torch.Tensor],
429
+ i_time_start: int = 0,
430
+ ):
431
+ variable_metrics = self._get_variable_metrics(data)
432
+ for name in data.keys():
433
+ for metric in variable_metrics:
434
+ variable_metrics[metric][name].record(
435
+ tensor=data[name],
436
+ i_time_start=i_time_start,
437
+ )
438
+
439
+ def _get_series_data(self, step_slice: slice | None = None) -> list[_SeriesData]:
440
+ """Converts internally stored variable_metrics to a list."""
441
+ if self._variable_metrics is None:
442
+ raise ValueError("No batches have been recorded.")
443
+ data: list[_SeriesData] = []
444
+ for metric in self._variable_metrics:
445
+ sorted_keys = sorted(list(self._variable_metrics[metric].keys()))
446
+ for key in sorted_keys:
447
+ arr = self._variable_metrics[metric][key].get().detach()
448
+ if step_slice is not None:
449
+ arr = arr[step_slice]
450
+ datum = _SeriesData(
451
+ metric_name=metric,
452
+ var_name=key,
453
+ data=all_reduce_mean(arr).cpu().numpy(),
454
+ )
455
+ data.append(datum)
456
+ return data
457
+
458
+ @torch.no_grad()
459
+ def get_logs(self, label: str, step_slice: slice | None = None) -> Metrics:
460
+ """
461
+ Returns logs as can be reported to WandB.
462
+
463
+ Args:
464
+ label: Label to prepend to all log keys.
465
+ step_slice: Slice of forecast steps to log.
466
+ """
467
+ logs = {}
468
+ series_data: dict[str, np.ndarray] = {
469
+ datum.get_wandb_key(): datum.data
470
+ for datum in self._get_series_data(step_slice)
471
+ }
472
+ init_step = 0 if step_slice is None else step_slice.start
473
+ table = data_to_table(series_data, init_step)
474
+ logs[f"{label}/series"] = table
475
+ return logs
samudra/aggregator/inference/time_mean.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import dataclasses
7
+ from typing import Literal
8
+
9
+ import matplotlib.pyplot as plt
10
+ import torch
11
+ import wandb
12
+ import xarray as xr
13
+
14
+ from samudra.aggregator.metrics import area_weighted_mean_bias, area_weighted_rmse
15
+ from samudra.aggregator.plotting import get_cmap_limits, plot_imshow
16
+ from samudra.utils.distributed import all_reduce_mean
17
+ from samudra.utils.wandb import Metrics, MetricsDict
18
+
19
+
20
+ @dataclasses.dataclass
21
+ class _TargetGenPair:
22
+ name: str
23
+ target: torch.Tensor
24
+ gen: torch.Tensor
25
+ area_weights: torch.Tensor
26
+
27
+ def bias(self):
28
+ return self.gen - self.target
29
+
30
+ def rmse(self) -> float:
31
+ ret = float(
32
+ area_weighted_rmse(
33
+ gen=self.gen,
34
+ target=self.target,
35
+ area_weights=self.area_weights,
36
+ )
37
+ .cpu()
38
+ .numpy()
39
+ )
40
+ return ret
41
+
42
+ def weighted_mean_bias(self) -> float:
43
+ return float(
44
+ area_weighted_mean_bias(
45
+ gen=self.gen,
46
+ target=self.target,
47
+ area_weights=self.area_weights,
48
+ )
49
+ .cpu()
50
+ .numpy()
51
+ )
52
+
53
+
54
+ def get_gen_shape(gen_data: dict[str, torch.Tensor]):
55
+ for name in gen_data:
56
+ return gen_data[name].shape
57
+
58
+
59
+ class TimeMeanAggregator:
60
+ _image_captions = {
61
+ "bias_map": "{name} time-mean bias (generated - reference) [{units}]",
62
+ "gen_map": "{name} time-mean generated [{units}]",
63
+ }
64
+
65
+ def __init__(
66
+ self,
67
+ area_weights: torch.Tensor,
68
+ target: Literal["norm", "denorm"] = "denorm",
69
+ metadata: dict[str, dict[str, str]] | None = None,
70
+ reference_means: xr.Dataset | None = None,
71
+ ):
72
+ """
73
+ Args:
74
+ area_weights: Tensor of shape [n_lat, n_lon] representing the area weights.
75
+ target: Whether to compute metrics on the normalized or denormalized data,
76
+ defaults to "denorm".
77
+ metadata: Mapping of variable names their metadata that will
78
+ used in generating logged image captions.
79
+ reference_means: Dataset containing reference time-mean values
80
+ for bias computation.
81
+ """
82
+ self._target = target
83
+ if metadata is None:
84
+ self._metadata: dict[str, dict[str, str]] = {}
85
+ else:
86
+ self._metadata = metadata
87
+ # Dictionaries of tensors of shape [n_lat, n_lon] represnting time means
88
+ self._data: dict[str, torch.Tensor] | None = None
89
+ self._n_timesteps = 0
90
+ self._n_samples: int | None = None
91
+ self._reference_means = reference_means
92
+ self._reference_validated = False
93
+ self._area_weights = area_weights
94
+
95
+ @staticmethod
96
+ def _add_or_initialize_time_mean(
97
+ maybe_dict: dict[str, torch.Tensor] | None,
98
+ new_data: dict[str, torch.Tensor],
99
+ ignore_initial: bool = False,
100
+ ) -> dict[str, torch.Tensor]:
101
+ sample_dim = 0
102
+ time_dim = 1
103
+ if ignore_initial:
104
+ time_slice = slice(1, None)
105
+ else:
106
+ time_slice = slice(0, None)
107
+ if maybe_dict is None:
108
+ d: dict[str, torch.Tensor] = {
109
+ name: tensor[:, time_slice].sum(dim=time_dim).sum(dim=sample_dim)
110
+ for name, tensor in new_data.items()
111
+ }
112
+ else:
113
+ d = dict(maybe_dict)
114
+ for name, tensor in new_data.items():
115
+ d[name] += tensor[:, time_slice].sum(dim=time_dim).sum(dim=sample_dim)
116
+ return d
117
+
118
+ @torch.no_grad()
119
+ def record_batch(
120
+ self,
121
+ data: dict[str, torch.Tensor],
122
+ i_time_start: int = 0,
123
+ ):
124
+ ignore_initial = i_time_start == 0
125
+ if ignore_initial:
126
+ raise Exception(
127
+ "This is wrong! Make sure this isnt called while recording "
128
+ "initial prognostic"
129
+ )
130
+ self._data = self._add_or_initialize_time_mean(self._data, data, ignore_initial)
131
+ if self._n_samples is None:
132
+ self._n_samples = data[list(data)[0]].size(0)
133
+ if ignore_initial:
134
+ self._n_timesteps = data[list(data)[0]].size(1) - 1
135
+ else:
136
+ self._n_timesteps += data[list(data)[0]].size(1)
137
+ if not self._reference_validated:
138
+ if self._reference_means is not None:
139
+ self.get_logs(label="")
140
+ self._reference_validated = True
141
+
142
+ def get_data(self) -> dict[str, torch.Tensor]:
143
+ if self._n_timesteps == 0 or self._data is None:
144
+ raise ValueError("No data recorded.")
145
+
146
+ assert self._n_samples is not None # for type checker
147
+
148
+ ret = {}
149
+ names = sorted(list(self._data.keys())) # sort for rank-consistent order
150
+ for name in names:
151
+ value = self._data[name]
152
+ gen = all_reduce_mean(value / self._n_timesteps / self._n_samples)
153
+ ret[name] = gen
154
+ return ret
155
+
156
+ @torch.no_grad()
157
+ def get_logs(self, label: str) -> Metrics:
158
+ logs: MetricsDict = {}
159
+ data = self.get_data()
160
+ gen_map_key = "gen_map"
161
+ for name, pred in data.items():
162
+ vmin_pred, vmax_pred = get_cmap_limits(pred.cpu().numpy())
163
+ prediction_image = wandb.Image(
164
+ plot_imshow(pred.cpu().numpy()),
165
+ caption=self._get_caption(gen_map_key, name, vmin_pred, vmax_pred),
166
+ )
167
+ plt.close("all")
168
+ logs.update(
169
+ {
170
+ f"{gen_map_key}/{name}": prediction_image,
171
+ }
172
+ )
173
+ if self._reference_means is not None and name in self._reference_means:
174
+ pair = _TargetGenPair(
175
+ name=name,
176
+ target=torch.as_tensor(
177
+ self._reference_means[name].values, device=pred.device
178
+ ),
179
+ gen=pred,
180
+ area_weights=self._area_weights,
181
+ )
182
+ bias_map = pair.bias().cpu().numpy()
183
+ vmin_bias, vmax_bias = get_cmap_limits(bias_map, diverging=True)
184
+ cmap = plt.colormaps.get_cmap("RdBu_r")
185
+ cmap.set_bad(color=(0.7, 0.7, 0.7))
186
+
187
+ bias_fig: plt.Figure = plot_imshow(
188
+ bias_map,
189
+ vmin=vmin_bias,
190
+ vmax=vmax_bias,
191
+ cmap=cmap,
192
+ nan_padding=False,
193
+ )
194
+ bias_image = wandb.Image(
195
+ bias_fig,
196
+ caption=self._get_caption("bias_map", name, vmin_bias, vmax_bias),
197
+ )
198
+ logs.update({f"ref_bias/{name}": pair.weighted_mean_bias()})
199
+ logs.update({f"ref_rmse/{name}": pair.rmse()})
200
+ logs.update({f"ref_bias_map/{name}": bias_image})
201
+
202
+ if len(label) != 0:
203
+ return {f"{label}/{key}": logs[key] for key in logs}
204
+ return logs
205
+
206
+ def _get_caption(self, key: str, name: str, vmin: float, vmax: float) -> str:
207
+ if name in self._metadata:
208
+ caption_name = self._metadata[name]["long_name"]
209
+ units = self._metadata[name]["units"]
210
+ else:
211
+ caption_name, units = name, "unknown_units"
212
+ caption = self._image_captions[key].format(name=caption_name, units=units)
213
+ caption += f" vmin={vmin:.4g}, vmax={vmax:.4g}."
214
+ return caption
215
+
216
+
217
+ class TimeMeanEvaluatorAggregator:
218
+ """Statistics and images on the time-mean state.
219
+
220
+ This aggregator keeps track of the time-mean state, then computes
221
+ statistics and images on that time-mean state when logs are retrieved.
222
+ """
223
+
224
+ _image_captions = {
225
+ "bias_map": "{name} time-mean bias (generated - target) [{units}]",
226
+ "gen_map": "{name} time-mean generated [{units}]",
227
+ }
228
+
229
+ def __init__(
230
+ self,
231
+ area_weights: torch.Tensor,
232
+ target: Literal["norm", "denorm"] = "denorm",
233
+ metadata: dict[str, dict[str, str]] | None = None,
234
+ reference_means: xr.Dataset | None = None,
235
+ channel_mean_names: list[str] | None = None,
236
+ ):
237
+ """
238
+ Args:
239
+ area_weights: Tensor of shape [n_lat, n_lon] representing the area weights.
240
+ target: Whether to compute metrics on the normalized or denormalized data,
241
+ defaults to "denorm".
242
+ metadata: Mapping of variable names their metadata that will
243
+ used in generating logged image captions.
244
+ reference_means: Dataset containing reference time-mean values
245
+ for bias computation.
246
+ channel_mean_names: Names of variables whose RMSE will be averaged. If
247
+ not provided, all available variables will be used.
248
+ """
249
+ self._target = target
250
+ if metadata is None:
251
+ self._metadata: dict[str, dict[str, str]] = {}
252
+ else:
253
+ self._metadata = metadata
254
+ self._area_weights = area_weights
255
+ # Dictionaries of tensors of shape [n_lat, n_lon] represnting time means
256
+ self._target_agg = TimeMeanAggregator(
257
+ target=target, metadata=metadata, area_weights=self._area_weights
258
+ )
259
+ self._gen_agg = TimeMeanAggregator(
260
+ target=target,
261
+ metadata=metadata,
262
+ reference_means=reference_means,
263
+ area_weights=self._area_weights,
264
+ )
265
+ self._channel_mean_names = channel_mean_names
266
+
267
+ @torch.no_grad()
268
+ def record_batch(
269
+ self,
270
+ target_data: dict[str, torch.Tensor],
271
+ gen_data: dict[str, torch.Tensor],
272
+ target_data_norm: dict[str, torch.Tensor],
273
+ gen_data_norm: dict[str, torch.Tensor],
274
+ i_time_start: int = 0,
275
+ ):
276
+ if self._target == "norm":
277
+ target_data = target_data_norm
278
+ gen_data = gen_data_norm
279
+ self._target_agg.record_batch(target_data, i_time_start)
280
+ self._gen_agg.record_batch(gen_data, i_time_start)
281
+
282
+ def _get_target_gen_pairs(self) -> list[_TargetGenPair]:
283
+ target_data = self._target_agg.get_data()
284
+ gen_data = self._gen_agg.get_data()
285
+
286
+ ret = []
287
+ for name in gen_data.keys():
288
+ ret.append(
289
+ _TargetGenPair(
290
+ gen=gen_data[name],
291
+ target=target_data[name],
292
+ name=name,
293
+ area_weights=self._area_weights,
294
+ )
295
+ )
296
+ return ret
297
+
298
+ @torch.no_grad()
299
+ def get_logs(self, label: str) -> Metrics:
300
+ logs = dict(self._gen_agg.get_logs(""))
301
+ preds = self._get_target_gen_pairs()
302
+ bias_map_key = "bias_map"
303
+ rmse_all_channels = {}
304
+ for pred in preds:
305
+ bias_data = pred.bias().cpu().numpy()
306
+ vmin_bias, vmax_bias = get_cmap_limits(bias_data, diverging=True)
307
+ cmap = plt.colormaps.get_cmap("RdBu_r")
308
+ cmap.set_bad(color=(0.7, 0.7, 0.7))
309
+ bias_fig = plot_imshow(
310
+ bias_data, vmin=vmin_bias, vmax=vmax_bias, cmap=cmap, nan_padding=False
311
+ )
312
+ bias_image = wandb.Image(
313
+ bias_fig,
314
+ caption=self._get_caption(
315
+ bias_map_key, pred.name, vmin_bias, vmax_bias
316
+ ),
317
+ )
318
+ plt.close("all")
319
+ rmse_all_channels[pred.name] = pred.rmse()
320
+ logs.update({f"rmse/{pred.name}": rmse_all_channels[pred.name]})
321
+ if self._target == "denorm":
322
+ logs.update(
323
+ {
324
+ f"{bias_map_key}/{pred.name}": bias_image,
325
+ f"bias/{pred.name}": pred.weighted_mean_bias(),
326
+ }
327
+ )
328
+ if self._target == "norm":
329
+ metric_name = "rmse/channel_mean"
330
+ if self._channel_mean_names is None:
331
+ values_to_average = list(rmse_all_channels.values())
332
+ else:
333
+ values_to_average = [
334
+ rmse_all_channels[name] for name in self._channel_mean_names
335
+ ]
336
+ logs.update({metric_name: sum(values_to_average) / len(values_to_average)})
337
+
338
+ if len(label) != 0:
339
+ return {f"{label}/{key}": logs[key] for key in logs}
340
+ return logs
341
+
342
+ def _get_caption(self, key: str, name: str, vmin: float, vmax: float) -> str:
343
+ if name in self._metadata:
344
+ caption_name = self._metadata[name]["long_name"]
345
+ units = self._metadata[name]["units"]
346
+ else:
347
+ caption_name, units = name, "unknown_units"
348
+ caption = self._image_captions[key].format(name=caption_name, units=units)
349
+ caption += f" vmin={vmin:.4g}, vmax={vmax:.4g}."
350
+ return caption
samudra/aggregator/loss.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import torch
7
+
8
+ from samudra.constants import TensorMap
9
+
10
+
11
+ def get_depth_loss_dict(
12
+ label: str,
13
+ loss_per_channel: torch.Tensor,
14
+ *,
15
+ tensor_map: TensorMap,
16
+ ) -> dict[str, torch.Tensor]:
17
+ metrics = {}
18
+ for depth in tensor_map.DEPTH_SET:
19
+ metrics[f"{label}/loss/depth/depth_{depth}_loss"] = loss_per_channel[
20
+ tensor_map.DP_3D_IDX[depth]
21
+ ].mean()
22
+ return metrics
23
+
24
+
25
+ def get_variable_loss_dict(
26
+ label: str,
27
+ loss_per_channel: torch.Tensor,
28
+ *,
29
+ tensor_map: TensorMap,
30
+ ) -> dict[str, torch.Tensor]:
31
+ metrics = {}
32
+ for variable in tensor_map.VAR_SET:
33
+ metrics[f"{label}/loss/variable/{variable}_loss"] = loss_per_channel[
34
+ tensor_map.VAR_3D_IDX[variable]
35
+ ].mean()
36
+ return metrics
37
+
38
+
39
+ def get_channel_loss_dict(
40
+ label: str,
41
+ loss_per_channel: torch.Tensor,
42
+ *,
43
+ tensor_map: TensorMap,
44
+ loss_name: str = "loss",
45
+ ) -> dict[str, torch.Tensor]:
46
+ return get_channel_dict(label, loss_name, loss_per_channel, tensor_map=tensor_map)
47
+
48
+
49
+ def get_channel_loss_scale_dict(
50
+ label: str,
51
+ loss_scale_per_channel: torch.Tensor,
52
+ *,
53
+ tensor_map: TensorMap,
54
+ ) -> dict[str, torch.Tensor]:
55
+ return get_channel_dict(
56
+ label,
57
+ "loss_scale",
58
+ loss_scale_per_channel,
59
+ tensor_map=tensor_map,
60
+ )
61
+
62
+
63
+ def get_channel_dict(
64
+ prefix: str,
65
+ measure: str,
66
+ per_channel: torch.Tensor,
67
+ *,
68
+ tensor_map: TensorMap,
69
+ ) -> dict[str, torch.Tensor]:
70
+ metrics = {}
71
+ for i, channel in enumerate(tensor_map.prognostic_var_names):
72
+ metrics[f"{prefix}/{measure}/channel/{channel}_{measure}"] = per_channel[i]
73
+ return metrics
samudra/aggregator/main.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ """
7
+ The code in this directory is directly inspired by the ACE project by AI2.
8
+
9
+ See the original repository at: https://github.com/ai2cm/ace/tree/39133c18524cda85965486ecdc8cb64aac06f4c3/fme/fme/ace/aggregator
10
+ """
11
+
12
+ import torch
13
+
14
+ from samudra.aggregator.inference import InferenceEvaluatorAggregator
15
+ from samudra.aggregator.train import TrainAggregator
16
+ from samudra.aggregator.validate import ValidateAggregator
17
+ from samudra.aggregator.validate.map import MapAggregator
18
+ from samudra.aggregator.validate.reduced import MeanAggregator
19
+ from samudra.aggregator.validate.snapshot import SnapshotAggregator
20
+ from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
21
+ from samudra.constants import TensorMap
22
+ from samudra.utils.data import Normalize
23
+
24
+
25
+ class Aggregator:
26
+ @staticmethod
27
+ def get_train_aggregator(tensor_map: TensorMap) -> TrainAggregator:
28
+ return TrainAggregator(tensor_map)
29
+
30
+ @staticmethod
31
+ def get_validation_aggregator(
32
+ metadata: dict[str, dict[str, str]],
33
+ hist: int,
34
+ area_weights: torch.Tensor,
35
+ num_prognostic_channels: int,
36
+ tensor_map: TensorMap,
37
+ normalize: Normalize,
38
+ *,
39
+ include_image_aggregators: bool = True,
40
+ ) -> ValidateAggregator:
41
+ val_aggregators: dict[str, ValidateSubAggregator] = {
42
+ "reduced": MeanAggregator(area_weights, hist),
43
+ }
44
+ if include_image_aggregators:
45
+ val_aggregators.update(
46
+ {
47
+ "snapshot": SnapshotAggregator(metadata, hist),
48
+ "mean_map": MapAggregator(metadata, hist),
49
+ }
50
+ )
51
+
52
+ return ValidateAggregator(
53
+ val_aggregators,
54
+ hist=hist,
55
+ num_prognostic_channels=num_prognostic_channels,
56
+ tensor_map=tensor_map,
57
+ normalize=normalize,
58
+ )
59
+
60
+ @staticmethod
61
+ def get_inline_inference_aggregator(
62
+ n_timesteps: int,
63
+ metadata: dict[str, dict[str, str]],
64
+ hist: int,
65
+ area_weights: torch.Tensor,
66
+ wet: torch.Tensor,
67
+ num_prognostic_channels: int,
68
+ tensor_map: TensorMap,
69
+ normalize: Normalize,
70
+ channel_mean_names: list[str] | None = None,
71
+ ) -> InferenceEvaluatorAggregator:
72
+ return InferenceEvaluatorAggregator(
73
+ n_timesteps=n_timesteps,
74
+ metadata=metadata,
75
+ hist=hist,
76
+ area_weights=area_weights,
77
+ wet=wet,
78
+ num_prognostic_channels=num_prognostic_channels,
79
+ normalize=normalize,
80
+ tensor_map=tensor_map,
81
+ record_step_20=(n_timesteps > 20),
82
+ log_global_mean_time_series=False,
83
+ log_global_mean_norm_time_series=False,
84
+ channel_mean_names=channel_mean_names,
85
+ )
86
+
87
+ @staticmethod
88
+ def get_standalone_inference_aggregator(
89
+ n_timesteps: int,
90
+ metadata: dict[str, dict[str, str]],
91
+ hist: int,
92
+ area_weights: torch.Tensor,
93
+ wet: torch.Tensor,
94
+ num_prognostic_channels: int,
95
+ tensor_map: TensorMap,
96
+ normalize: Normalize,
97
+ channel_mean_names: list[str] | None = None,
98
+ ) -> InferenceEvaluatorAggregator:
99
+ return InferenceEvaluatorAggregator(
100
+ n_timesteps=n_timesteps,
101
+ metadata=metadata,
102
+ hist=hist,
103
+ area_weights=area_weights,
104
+ wet=wet,
105
+ num_prognostic_channels=num_prognostic_channels,
106
+ normalize=normalize,
107
+ tensor_map=tensor_map,
108
+ record_step_20=(n_timesteps > 20),
109
+ log_global_mean_time_series=True,
110
+ log_global_mean_norm_time_series=True,
111
+ channel_mean_names=channel_mean_names,
112
+ )
samudra/aggregator/metrics.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import torch
7
+
8
+
9
+ def weighted_sum(
10
+ tensor: torch.Tensor,
11
+ weights: torch.Tensor | None = None,
12
+ dim: tuple[int, ...] = (-2, -1),
13
+ keepdim: bool = False,
14
+ ) -> torch.Tensor:
15
+ """Computes the weighted sum across the specified list of dimensions.
16
+
17
+ Args:
18
+ tensor: torch.Tensor
19
+ weights: Weights to apply to the sum.
20
+ dim: Dimensions to compute the sum over.
21
+ keepdim: Whether the output tensor has `dim` retained or not.
22
+
23
+ Returns:
24
+ a tensor of the weighted sum averaged over the specified dimensions `dim`.
25
+ """
26
+ if weights is None:
27
+ return tensor.nansum(dim=dim, keepdim=keepdim)
28
+
29
+ weights = weights.to(tensor.device)
30
+ return (tensor * weights).nansum(dim=dim, keepdim=keepdim)
31
+
32
+
33
+ def area_weighted_sum(
34
+ data: torch.Tensor,
35
+ area_weights: torch.Tensor,
36
+ dim: tuple[int, ...] = (-2, -1),
37
+ keepdim: bool = False,
38
+ ) -> torch.Tensor:
39
+ return weighted_sum(data, area_weights, dim=dim, keepdim=keepdim)
40
+
41
+
42
+ def weighted_mean(
43
+ tensor: torch.Tensor,
44
+ weights: torch.Tensor | None = None,
45
+ dim: tuple[int, ...] = (-2, -1),
46
+ keepdim: bool = False,
47
+ ) -> torch.Tensor:
48
+ """Computes the weighted mean across the specified list of dimensions.
49
+
50
+ This function treats NaNs as land cells and does not include them in the
51
+ computation.
52
+
53
+ Args:
54
+ tensor: torch.Tensor
55
+ weights: Weights to apply to the mean.
56
+ dim: Dimensions to compute the mean over.
57
+ keepdim: Whether the output tensor has `dim` retained or not.
58
+
59
+ Returns:
60
+ a tensor of the weighted mean averaged over the specified dimensions `dim`.
61
+ """
62
+ if weights is None:
63
+ return tensor.nanmean(dim=dim, keepdim=keepdim)
64
+
65
+ weights = weights.to(tensor.device)
66
+ denom = torch.where(torch.isnan(tensor), 0.0, weights.expand(tensor.shape)).sum(
67
+ dim=dim, keepdim=keepdim
68
+ )
69
+ return (tensor * weights).nansum(dim=dim, keepdim=keepdim) / denom
70
+
71
+
72
+ def area_weighted_mean(
73
+ data: torch.Tensor,
74
+ area_weights: torch.Tensor,
75
+ dim: tuple[int, ...] = (-2, -1),
76
+ keepdim: bool = False,
77
+ ) -> torch.Tensor:
78
+ return weighted_mean(data, area_weights, dim=dim, keepdim=keepdim)
79
+
80
+
81
+ def area_weighted_std(
82
+ data: torch.Tensor,
83
+ area_weights: torch.Tensor,
84
+ keepdim: bool = False,
85
+ ):
86
+ return area_weighted_mean(
87
+ (data - area_weighted_mean(data, area_weights, keepdim=True)) ** 2,
88
+ area_weights,
89
+ keepdim=keepdim,
90
+ ).sqrt()
91
+
92
+
93
+ def area_weighted_rmse(
94
+ target: torch.Tensor, gen: torch.Tensor, area_weights: torch.Tensor
95
+ ) -> torch.Tensor:
96
+ area_weights = area_weights.to(target.device)
97
+ return torch.sqrt(area_weighted_mean((gen - target) ** 2, area_weights))
98
+
99
+
100
+ def area_weighted_mean_bias(
101
+ target: torch.Tensor, gen: torch.Tensor, area_weights: torch.Tensor
102
+ ) -> torch.Tensor:
103
+ area_weights = area_weights.to(target.device)
104
+ return area_weighted_mean(gen - target, area_weights)
105
+
106
+
107
+ def gradient_magnitude(
108
+ tensor: torch.Tensor, dim: tuple[int, ...] = (-2, -1)
109
+ ) -> torch.Tensor:
110
+ """Compute the magnitude of gradient across the specified dimensions."""
111
+ gradients = torch.gradient(tensor, dim=dim)
112
+ return torch.sqrt(sum([g**2 for g in gradients])) # type: ignore
113
+
114
+
115
+ def weighted_mean_gradient_magnitude(
116
+ tensor: torch.Tensor,
117
+ weights: torch.Tensor | None = None,
118
+ dim: tuple[int, ...] = (-2, -1),
119
+ ) -> torch.Tensor:
120
+ """Compute weighted mean of gradient magnitude across the specified dimensions."""
121
+ return weighted_mean(gradient_magnitude(tensor, dim), weights=weights, dim=dim)
122
+
123
+
124
+ def gradient_magnitude_percent_diff(
125
+ target: torch.Tensor,
126
+ gen: torch.Tensor,
127
+ weights: torch.Tensor | None = None,
128
+ dim: tuple[int, ...] = (-2, -1),
129
+ ) -> torch.Tensor:
130
+ """Compute the percent difference of the weighted mean gradient magnitude across
131
+ the specified dimensions.
132
+ """
133
+ target_grad_mag = weighted_mean_gradient_magnitude(target, weights, dim)
134
+ gen_grad_mag = weighted_mean_gradient_magnitude(gen, weights, dim)
135
+ return 100 * (gen_grad_mag - target_grad_mag) / target_grad_mag
136
+
137
+
138
+ def area_weighted_gradient_magnitude_percent_diff(
139
+ target: torch.Tensor, gen: torch.Tensor, area_weights: torch.Tensor
140
+ ):
141
+ area_weights = area_weights.to(target.device)
142
+ return gradient_magnitude_percent_diff(target, gen, weights=area_weights)
samudra/aggregator/plotting.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import gc
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ from matplotlib.colors import Colormap
11
+ from matplotlib.figure import Figure
12
+
13
+ from samudra.utils.wandb import WandBLogger
14
+
15
+
16
+ def get_cmap_limits(data: np.ndarray, diverging=False) -> tuple[float, float]:
17
+ vmin = np.nanmin(data)
18
+ vmax = np.nanmax(data)
19
+ if diverging:
20
+ vmax = max(abs(vmin), abs(vmax))
21
+ vmin = -vmax
22
+ return vmin, vmax
23
+
24
+
25
+ def plot_imshow(
26
+ data: np.ndarray,
27
+ vmin: float | None = None,
28
+ vmax: float | None = None,
29
+ cmap: Colormap | None = None,
30
+ flip_lat: bool = True,
31
+ use_colorbar: bool = True,
32
+ nan_padding: bool = True,
33
+ ) -> Figure:
34
+ """Plot a 2D array using imshow, ensuring figure size is same as array size."""
35
+ min_ = np.nanmin(data) if vmin is None else vmin
36
+ max_ = np.nanmax(data) if vmax is None else vmax
37
+
38
+ if flip_lat:
39
+ lat_dim = -2
40
+ data = np.flip(data, axis=lat_dim)
41
+
42
+ if use_colorbar:
43
+ height, width = data.shape
44
+ colorbar_width = max(1, int(0.025 * width))
45
+ range_ = np.linspace(min_, max_, height)
46
+ range_ = np.repeat(range_[:, np.newaxis], repeats=colorbar_width, axis=1)
47
+ range_ = np.flipud(range_) # wandb images start from top (and left)
48
+ padding = np.zeros((height, colorbar_width))
49
+ if nan_padding:
50
+ padding = padding + np.nan # Set when using non-diverging map
51
+ data = np.concatenate((data, padding, range_), axis=1)
52
+
53
+ # make figure size (in pixels) be the same as array size
54
+ figsize = np.array(data.T.shape) / plt.rcParams["figure.dpi"]
55
+ fig = Figure(figsize=figsize) # create directly for cleanup when it leaves scope
56
+ ax = fig.add_axes((0, 0, 1, 1))
57
+ ax.imshow(data, cmap=cmap, vmin=vmin, vmax=vmax)
58
+ ax.set_axis_off()
59
+ return fig
60
+
61
+
62
+ def plot_paneled_data(
63
+ data: list[list[np.ndarray]],
64
+ diverging: bool,
65
+ caption: str | None = None,
66
+ ):
67
+ """Plot a list of 2D data arrays in a paneled plot."""
68
+ if diverging:
69
+ cmap = plt.colormaps.get_cmap("RdBu_r")
70
+ cmap.set_bad(color=(0.7, 0.7, 0.7))
71
+ else:
72
+ cmap = plt.colormaps.get_cmap("viridis")
73
+ cmap.set_bad(color="white")
74
+ vmin = np.inf
75
+ vmax = -np.inf
76
+ for row in data:
77
+ for arr in row:
78
+ vmin = min(vmin, np.nanmin(arr))
79
+ vmax = max(vmax, np.nanmax(arr))
80
+ if diverging:
81
+ vmax = max(abs(vmin), abs(vmax))
82
+ vmin = -vmax
83
+ if caption is not None:
84
+ caption += " "
85
+ else:
86
+ caption = ""
87
+
88
+ caption += f"vmin={vmin:.4g}, vmax={vmax:.4g}."
89
+
90
+ if diverging:
91
+ fill_value = 0.5 * (vmin + vmax)
92
+ else:
93
+ fill_value = vmin
94
+ all_data = _stitch_data_panels(data, fill_value=fill_value)
95
+
96
+ fig = plot_imshow(
97
+ all_data, vmin=vmin, vmax=vmax, cmap=cmap, nan_padding=not diverging
98
+ )
99
+ wandb = WandBLogger.get_instance()
100
+ wandb_image = wandb.Image(fig, caption=caption)
101
+ plt.close(fig)
102
+
103
+ # necessary to avoid CUDA error in some contexts
104
+ # see https://github.com/ai2cm/full-model/issues/740#issuecomment-2086546187
105
+ gc.collect()
106
+
107
+ return wandb_image
108
+
109
+
110
+ def _stitch_data_panels(data: list[list[np.ndarray]], fill_value) -> np.ndarray:
111
+ for row in data:
112
+ if len(row) != len(data[0]):
113
+ raise ValueError("All rows must have the same number of panels.")
114
+
115
+ n_rows = len(data)
116
+ n_cols = len(data[0])
117
+ for row in data:
118
+ for arr in row:
119
+ if arr.shape != data[0][0].shape:
120
+ raise ValueError("All panels must have the same shape.")
121
+
122
+ stitched_data = np.full(
123
+ (
124
+ n_rows * data[0][0].shape[0] + n_rows - 1,
125
+ n_cols * data[0][0].shape[1] + n_cols - 1,
126
+ ),
127
+ fill_value=fill_value,
128
+ )
129
+
130
+ # iterate over rows backwards, as the image starts in the bottom left
131
+ # and moves upwards
132
+ for i, row in enumerate(reversed(data)):
133
+ for j, arr in enumerate(row):
134
+ start_row = i * (arr.shape[0] + 1)
135
+ end_row = start_row + arr.shape[0]
136
+ start_col = j * (arr.shape[1] + 1)
137
+ end_col = start_col + arr.shape[1]
138
+ stitched_data[start_row:end_row, start_col:end_col] = arr
139
+
140
+ return stitched_data
samudra/aggregator/train.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import torch
7
+
8
+ from samudra.aggregator.loss import (
9
+ get_channel_loss_dict,
10
+ get_depth_loss_dict,
11
+ get_variable_loss_dict,
12
+ )
13
+ from samudra.constants import TensorMap
14
+ from samudra.utils.distributed import all_reduce_mean
15
+ from samudra.utils.output import TrainBatchOutput
16
+ from samudra.utils.wandb import Metrics
17
+
18
+
19
+ class TrainAggregator:
20
+ """Aggregates train statistics for an epoch."""
21
+
22
+ def __init__(self, tensor_map: TensorMap):
23
+ self.tensor_map = tensor_map
24
+ self._n_batches = 0
25
+ self._loss = torch.tensor(torch.nan)
26
+ self._loss_per_channel = torch.tensor(torch.nan)
27
+
28
+ @torch.no_grad()
29
+ def record_batch(self, batch: TrainBatchOutput):
30
+ if self._n_batches == 0:
31
+ self._loss = batch.loss
32
+ self._loss_per_channel = batch.loss_per_channel
33
+ else:
34
+ self._loss += batch.loss
35
+ self._loss_per_channel += batch.loss_per_channel
36
+ self._n_batches += 1
37
+
38
+ @torch.no_grad()
39
+ def get_logs(self, label: str = "train") -> Metrics:
40
+ loss = self._loss / self._n_batches
41
+
42
+ loss_per_channel = self._loss_per_channel / self._n_batches
43
+ depth_loss_dict = get_depth_loss_dict(
44
+ label, loss_per_channel, tensor_map=self.tensor_map
45
+ )
46
+ var_loss_dict = get_variable_loss_dict(
47
+ label, loss_per_channel, tensor_map=self.tensor_map
48
+ )
49
+ channel_loss_dict = get_channel_loss_dict(
50
+ label, loss_per_channel, tensor_map=self.tensor_map
51
+ )
52
+ logs = {
53
+ f"{label}/mean/loss": loss,
54
+ **depth_loss_dict,
55
+ **var_loss_dict,
56
+ **channel_loss_dict,
57
+ }
58
+ meaned_logs = {}
59
+ for key in sorted(logs.keys()):
60
+ meaned_logs[key] = float(all_reduce_mean(logs[key].detach()).cpu().numpy())
61
+
62
+ return meaned_logs
samudra/aggregator/validate/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ from .main import ValidateAggregator
samudra/aggregator/validate/main.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import torch
7
+
8
+ from samudra.aggregator.train import TrainAggregator
9
+ from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
10
+ from samudra.constants import TensorMap
11
+ from samudra.utils.data import Normalize, get_aggregator_dicts
12
+ from samudra.utils.output import ValBatchOutput
13
+ from samudra.utils.wandb import Metrics, MetricsDict
14
+
15
+
16
+ class ValidateAggregator(TrainAggregator):
17
+ """Aggregates Validation Statistics."""
18
+
19
+ def __init__(
20
+ self,
21
+ aggregators: dict[str, ValidateSubAggregator],
22
+ hist: int,
23
+ num_prognostic_channels: int,
24
+ *,
25
+ tensor_map: TensorMap,
26
+ normalize: Normalize,
27
+ ):
28
+ super().__init__(tensor_map)
29
+ self._aggregators = aggregators
30
+ self.hist = hist
31
+ self.num_prognostic_channels = num_prognostic_channels
32
+ self.normalize = normalize
33
+
34
+ # TODO(jder): we could remove this by moving from inheritance
35
+ # to composition with the TrainAggregator functionality.
36
+ def record_batch(self, batch):
37
+ raise NotImplementedError(
38
+ "Call record_validation_batch instead of record_batch"
39
+ )
40
+
41
+ @torch.no_grad()
42
+ def record_validation_batch(self, batch: ValBatchOutput):
43
+ super().record_batch(batch) # Record losses
44
+
45
+ # If there are no log aggregators, omit doing any extra work.
46
+ if not self._aggregators:
47
+ return
48
+
49
+ # Translate the GridContext mask by removing history.
50
+ target_data = batch.target_data # [B, C*(hist+1), H, W]
51
+ wet = batch.ctx.label_mask # [C*(hist+1), H, W]
52
+ assert wet.shape == target_data.shape[1:], (
53
+ "The wetmask must match the target data shape excluding batch."
54
+ )
55
+ assert wet.shape[0] % (self.hist + 1) == 0, (
56
+ "The wetmask channel count must be divisible by history size."
57
+ )
58
+ first_wetmask_chunk = wet.shape[0] // (self.hist + 1)
59
+ wet = wet[:first_wetmask_chunk] # [C, H, W]
60
+
61
+ if len(target_data) == 0:
62
+ raise ValueError("No data in target_data")
63
+ if len(batch.gen_data) == 0:
64
+ raise ValueError("No data in gen_data")
65
+
66
+ assert target_data.shape[1] == self.num_prognostic_channels
67
+ target_data_dict, target_data_unnorm_dict = get_aggregator_dicts(
68
+ target_data,
69
+ normalize=self.normalize,
70
+ tensor_map=self.tensor_map,
71
+ wet=wet,
72
+ long_rollout=False,
73
+ input_type="prognostic",
74
+ num_prognostic_channels=self.num_prognostic_channels,
75
+ hist=self.hist,
76
+ )
77
+
78
+ gen_data_dict, gen_data_unnorm_dict = get_aggregator_dicts(
79
+ batch.gen_data,
80
+ normalize=self.normalize,
81
+ tensor_map=self.tensor_map,
82
+ wet=wet,
83
+ long_rollout=False,
84
+ input_type="prognostic",
85
+ num_prognostic_channels=self.num_prognostic_channels,
86
+ hist=self.hist,
87
+ )
88
+ input_data_dict, input_data_unnorm_dict = get_aggregator_dicts(
89
+ batch.input_data,
90
+ normalize=self.normalize,
91
+ tensor_map=self.tensor_map,
92
+ wet=wet,
93
+ long_rollout=False,
94
+ input_type="input",
95
+ num_prognostic_channels=self.num_prognostic_channels,
96
+ hist=self.hist,
97
+ )
98
+
99
+ for agg in self._aggregators.values():
100
+ agg.record_batch(
101
+ loss=batch.loss,
102
+ target_data=target_data_unnorm_dict,
103
+ gen_data=gen_data_unnorm_dict,
104
+ input_data=input_data_unnorm_dict,
105
+ target_data_norm=target_data_dict,
106
+ gen_data_norm=gen_data_dict,
107
+ input_data_norm=input_data_dict,
108
+ )
109
+
110
+ @torch.no_grad()
111
+ def get_logs(self, label: str = "train") -> Metrics:
112
+ logs: MetricsDict = dict(super().get_logs(label))
113
+ for agg_label in self._aggregators:
114
+ for k, v in self._aggregators[agg_label].get_logs(label=agg_label).items():
115
+ logs[f"{label}/{k}"] = v
116
+
117
+ return logs
samudra/aggregator/validate/map.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import torch
7
+
8
+ from samudra.aggregator.plotting import plot_paneled_data
9
+ from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
10
+ from samudra.utils.distributed import all_reduce_mean, is_main_process
11
+
12
+
13
+ class MapAggregator(ValidateSubAggregator):
14
+ """
15
+ An aggregator that records the average over batches as function of lat and lon.
16
+ """
17
+
18
+ _captions = {
19
+ "full-field": (
20
+ "{name} one step mean full field; "
21
+ "(top) generated and (bottom) target [{units}]"
22
+ ),
23
+ "error": (
24
+ "{name} one step mean full field error (generated - target) [{units}]"
25
+ ),
26
+ }
27
+
28
+ def __init__(
29
+ self, metadata: dict[str, dict[str, str]] | None = None, hist: int = 0
30
+ ):
31
+ """
32
+ Args:
33
+ metadata: Mapping of variable names their metadata that will
34
+ used in generating logged image captions.
35
+ hist: Number of history steps to include in the snapshot.
36
+ """
37
+ if metadata is None:
38
+ metadata = {}
39
+ else:
40
+ self._metadata = metadata
41
+ self._n_batches = 0
42
+ self._target_data: dict[str, torch.Tensor] = {}
43
+ self._gen_data: dict[str, torch.Tensor] = {}
44
+ self.hist = hist
45
+
46
+ @torch.no_grad()
47
+ def record_batch(
48
+ self,
49
+ loss,
50
+ target_data,
51
+ gen_data,
52
+ input_data,
53
+ target_data_norm,
54
+ gen_data_norm,
55
+ input_data_norm,
56
+ ):
57
+ self._loss = loss
58
+ for name in target_data:
59
+ if name in self._target_data:
60
+ self._target_data[name] += target_data[name].mean(dim=0)
61
+ else:
62
+ self._target_data[name] = target_data[name].mean(dim=0)
63
+ for name in gen_data:
64
+ if name in self._gen_data:
65
+ self._gen_data[name] += gen_data[name].mean(dim=0)
66
+ else:
67
+ self._gen_data[name] = gen_data[name].mean(dim=0)
68
+ self._n_batches += 1
69
+
70
+ @torch.no_grad()
71
+ def get_logs(self, label: str):
72
+ """
73
+ Returns logs as can be reported to WandB.
74
+
75
+ Args:
76
+ label: Label to prepend to all log keys.
77
+ """
78
+ time_dim = 0
79
+ target_time = self.hist # Use latest time step
80
+ image_logs = {}
81
+ render_images = is_main_process()
82
+ sorted_names = sorted(list(self._gen_data.keys()))
83
+ for name in sorted_names:
84
+ # use first sample in batch
85
+ gen = (
86
+ all_reduce_mean(
87
+ self._gen_data[name].select(dim=time_dim, index=target_time)
88
+ )
89
+ / self._n_batches
90
+ )
91
+ target = (
92
+ all_reduce_mean(
93
+ self._target_data[name].select(dim=time_dim, index=target_time)
94
+ )
95
+ / self._n_batches
96
+ )
97
+ if not render_images:
98
+ continue
99
+ image_logs[f"image-error/{name}"] = plot_paneled_data(
100
+ [[(gen - target).cpu().numpy()]],
101
+ diverging=True,
102
+ caption=self._get_caption("error", name),
103
+ )
104
+ image_logs[f"image-full-field/{name}"] = plot_paneled_data(
105
+ [
106
+ [gen.cpu().numpy()],
107
+ [target.cpu().numpy()],
108
+ ],
109
+ diverging=False,
110
+ caption=self._get_caption("full-field", name),
111
+ )
112
+ image_logs = {f"{label}/{key}": image_logs[key] for key in image_logs}
113
+ return image_logs
114
+
115
+ def _get_caption(self, key: str, name: str) -> str:
116
+ if name in self._metadata:
117
+ caption_name = self._metadata[name]["long_name"]
118
+ units = self._metadata[name]["units"]
119
+ else:
120
+ caption_name, units = name, "unknown_units"
121
+ caption = self._captions[key].format(name=caption_name, units=units)
122
+ return caption
samudra/aggregator/validate/reduced.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ from collections.abc import Callable
7
+ from functools import partial
8
+
9
+ import numpy as np
10
+ import torch
11
+
12
+ from samudra.aggregator.metrics import (
13
+ area_weighted_gradient_magnitude_percent_diff,
14
+ area_weighted_mean_bias,
15
+ area_weighted_rmse,
16
+ )
17
+ from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
18
+ from samudra.utils.device import get_device
19
+ from samudra.utils.distributed import all_reduce_mean
20
+
21
+
22
+ class AreaWeightedReducedMetric:
23
+ """
24
+ A wrapper around an area-weighted metric function.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ device: torch.device,
30
+ compute_metric: Callable[[torch.Tensor, torch.Tensor], torch.Tensor],
31
+ ):
32
+ self._compute_metric = compute_metric
33
+ self._total: torch.Tensor | None = None
34
+ self._device = device
35
+
36
+ def record(self, target: torch.Tensor, gen: torch.Tensor):
37
+ """Add a batch of data to the metric.
38
+
39
+ Args:
40
+ target: Target data. Should have shape [batch, time, height, width].
41
+ gen: Generated data. Should have shape [batch, time, height, width].
42
+ """
43
+ new_value = self._compute_metric(target, gen).mean(dim=0)
44
+ if self._total is None:
45
+ self._total = torch.zeros_like(new_value, device=self._device)
46
+ self._total += new_value
47
+
48
+ def get(self) -> torch.Tensor:
49
+ """Returns the metric."""
50
+ assert self._total is not None
51
+ return self._total
52
+
53
+
54
+ class MeanAggregator(ValidateSubAggregator):
55
+ """
56
+ Aggregator for mean-reduced metrics.
57
+
58
+ These are metrics such as means which reduce to a single float for each batch,
59
+ and then can be averaged across batches to get a single float for the
60
+ entire dataset. This is important because the aggregator uses the mean to combine
61
+ metrics across batches and processors.
62
+ """
63
+
64
+ def __init__(self, area_weights: torch.Tensor, target_time: int):
65
+ self._n_batches = 0
66
+ self._variable_metrics: dict | None = None
67
+ self._target_time = target_time
68
+ self._area_weights = area_weights
69
+
70
+ def _get_variable_metrics(self, gen_data):
71
+ if self._variable_metrics is None:
72
+ self._variable_metrics = {
73
+ "weighted_rmse": {},
74
+ "weighted_bias": {},
75
+ "weighted_grad_mag_percent_diff": {},
76
+ }
77
+ device = get_device()
78
+ for key in gen_data:
79
+ self._variable_metrics["weighted_rmse"][key] = (
80
+ AreaWeightedReducedMetric(
81
+ device=device,
82
+ compute_metric=partial(
83
+ area_weighted_rmse, area_weights=self._area_weights
84
+ ),
85
+ )
86
+ )
87
+ self._variable_metrics["weighted_bias"][key] = (
88
+ AreaWeightedReducedMetric(
89
+ device=device,
90
+ compute_metric=partial(
91
+ area_weighted_mean_bias, area_weights=self._area_weights
92
+ ),
93
+ )
94
+ )
95
+ self._variable_metrics["weighted_grad_mag_percent_diff"][key] = (
96
+ AreaWeightedReducedMetric(
97
+ device=device,
98
+ compute_metric=partial(
99
+ area_weighted_gradient_magnitude_percent_diff,
100
+ area_weights=self._area_weights,
101
+ ), # noqa: E501
102
+ )
103
+ )
104
+
105
+ return self._variable_metrics
106
+
107
+ @torch.no_grad()
108
+ def record_batch(
109
+ self,
110
+ *,
111
+ loss: torch.Tensor = torch.tensor(np.nan),
112
+ target_data,
113
+ gen_data,
114
+ target_data_norm,
115
+ gen_data_norm,
116
+ input_data: dict[str, torch.Tensor] | None = None,
117
+ input_data_norm: dict[str, torch.Tensor] | None = None,
118
+ i_time_start: int = 0,
119
+ ):
120
+ if input_data is None:
121
+ input_data = {}
122
+ if input_data_norm is None:
123
+ input_data_norm = {}
124
+
125
+ variable_metrics = self._get_variable_metrics(gen_data)
126
+ time_dim = 1
127
+ time_len = gen_data[list(gen_data.keys())[0]].shape[time_dim]
128
+ target_time = self._target_time - i_time_start
129
+ if target_time >= 0 and time_len > target_time:
130
+ for name in gen_data.keys():
131
+ target = target_data[name].select(dim=time_dim, index=target_time)
132
+ gen = gen_data[name].select(dim=time_dim, index=target_time)
133
+ for metric in variable_metrics:
134
+ variable_metrics[metric][name].record(
135
+ target=target,
136
+ gen=gen,
137
+ )
138
+ # only increment n_batches if we actually recorded a batch
139
+ self._n_batches += 1
140
+
141
+ def _get_data(self):
142
+ if self._variable_metrics is None or self._n_batches == 0:
143
+ raise ValueError("No batches have been recorded.")
144
+ data: dict[str, torch.Tensor] = {}
145
+ for metric in self._variable_metrics:
146
+ for key in self._variable_metrics[metric]:
147
+ data[f"{metric}/{key}"] = (
148
+ self._variable_metrics[metric][key].get() / self._n_batches
149
+ )
150
+ meaned_data: dict[str, float] = {}
151
+ for key in sorted(data.keys()):
152
+ meaned_data[key] = float(all_reduce_mean(data[key].detach()).cpu().numpy())
153
+ return meaned_data
154
+
155
+ @torch.no_grad()
156
+ def get_logs(self, label: str):
157
+ """
158
+ Returns logs as can be reported to WandB.
159
+
160
+ Args:
161
+ label: Label to prepend to all log keys.
162
+ """
163
+ return {
164
+ f"{label}/{key}": data for key, data in sorted(self._get_data().items())
165
+ }
samudra/aggregator/validate/snapshot.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ import torch
7
+
8
+ from samudra.aggregator.plotting import plot_paneled_data
9
+ from samudra.aggregator.validate.sub_aggregator import ValidateSubAggregator
10
+ from samudra.utils.distributed import is_main_process
11
+ from samudra.utils.wandb import Metrics
12
+
13
+
14
+ class SnapshotAggregator(ValidateSubAggregator):
15
+ """
16
+ An aggregator that records the first sample of the last batch of data.
17
+ """
18
+
19
+ _captions = {
20
+ "full-field": (
21
+ "{name} one step full field for last sample; "
22
+ "(top) generated and (bottom) target [{units}]"
23
+ ),
24
+ "residual": (
25
+ "{name} one step residual (prediction - previous time) for last sample; "
26
+ "(top) generated and (bottom) target [{units}]"
27
+ ),
28
+ "error": (
29
+ "{name} one step full field error (generated - target) "
30
+ "for last sample [{units}]"
31
+ ),
32
+ }
33
+
34
+ def __init__(
35
+ self, metadata: dict[str, dict[str, str]] | None = None, hist: int = 0
36
+ ):
37
+ """
38
+ Args:
39
+ metadata: Mapping of variable names their metadata that will
40
+ used in generating logged image captions.
41
+ hist: Number of history steps to include in the snapshot.
42
+ """
43
+ if metadata is None:
44
+ metadata = {}
45
+ else:
46
+ self._metadata = metadata
47
+ self.hist = hist
48
+
49
+ @torch.no_grad()
50
+ def record_batch(
51
+ self,
52
+ loss: torch.Tensor,
53
+ target_data,
54
+ gen_data,
55
+ input_data,
56
+ target_data_norm,
57
+ gen_data_norm,
58
+ input_data_norm,
59
+ ):
60
+ self._loss = loss
61
+ self._target_data = target_data
62
+ self._gen_data = gen_data
63
+ self._target_data_norm = target_data_norm
64
+ self._gen_data_norm = gen_data_norm
65
+ self._input_data = input_data
66
+ self._input_data_norm = input_data_norm
67
+
68
+ @torch.no_grad()
69
+ def get_logs(self, label: str) -> Metrics:
70
+ """
71
+ Returns logs as can be reported to WandB.
72
+
73
+ Args:
74
+ label: Label to prepend to all log keys.
75
+ """
76
+ if not is_main_process():
77
+ return {}
78
+
79
+ time_dim = 1
80
+ target_time = 0 # first output time step
81
+ input_time = self.hist # last input time step
82
+ image_logs = {}
83
+ for name in self._gen_data.keys():
84
+ # use first sample in batch
85
+ gen = self._gen_data[name].select(dim=time_dim, index=target_time)[0].cpu()
86
+ target = (
87
+ self._target_data[name].select(dim=time_dim, index=target_time)[0].cpu()
88
+ )
89
+ input = (
90
+ self._input_data[name].select(dim=time_dim, index=input_time)[0].cpu()
91
+ )
92
+ images = {}
93
+ images["error"] = [[(gen - target).numpy()]]
94
+ images["full-field"] = [[gen.numpy()], [target.numpy()]]
95
+ images["residual"] = [[(gen - input).numpy()], [(target - input).numpy()]]
96
+ for key, data in images.items():
97
+ if key == "error" or key == "residual":
98
+ diverging = True
99
+ else:
100
+ diverging = False
101
+ caption = self._get_caption(key, name)
102
+ wandb_image = plot_paneled_data(data, diverging, caption=caption)
103
+ image_logs[f"image-{key}/{name}"] = wandb_image
104
+ image_logs = {f"{label}/{key}": image_logs[key] for key in image_logs}
105
+ return image_logs
106
+
107
+ def _get_caption(self, key: str, name: str) -> str:
108
+ if name in self._metadata:
109
+ caption_name = self._metadata[name]["long_name"]
110
+ units = self._metadata[name]["units"]
111
+ else:
112
+ caption_name, units = name, "unknown_units"
113
+ caption = self._captions[key].format(name=caption_name, units=units)
114
+ return caption
samudra/aggregator/validate/sub_aggregator.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2024 Allen Institute for Artificial Intelligence
2
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
3
+ #
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ from abc import ABC, abstractmethod
7
+
8
+ import torch
9
+
10
+ from samudra.utils.wandb import Metrics
11
+
12
+
13
+ class ValidateSubAggregator(ABC):
14
+ @abstractmethod
15
+ def get_logs(self, label: str) -> Metrics: ...
16
+
17
+ @abstractmethod
18
+ def record_batch(
19
+ self,
20
+ *,
21
+ loss: torch.Tensor,
22
+ target_data,
23
+ gen_data,
24
+ input_data,
25
+ target_data_norm,
26
+ gen_data_norm,
27
+ input_data_norm,
28
+ ): ...
samudra/backend.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import logging
6
+
7
+ import torch
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ import os
12
+
13
+ from samudra.config import DistributedConfig, EvalBackendConfig, TrainBackendConfig
14
+ from samudra.utils.device import set_device
15
+ from samudra.utils.distributed import init_distributed_mode
16
+
17
+
18
+ def _cuda_diagnostics() -> str:
19
+ """Return a short string that helps debug why CUDA is (not) available."""
20
+ visible = os.getenv("CUDA_VISIBLE_DEVICES", None)
21
+ return (
22
+ "CUDA diagnostics: "
23
+ f"torch={torch.__version__}, "
24
+ f"torch.version.cuda={torch.version.cuda}, "
25
+ f"torch.cuda.is_available()={torch.cuda.is_available()}, "
26
+ f"torch.cuda.device_count()={torch.cuda.device_count()}, "
27
+ f"CUDA_VISIBLE_DEVICES={visible!r}"
28
+ )
29
+
30
+
31
+ def init_train_backend(
32
+ backend: TrainBackendConfig,
33
+ ) -> tuple[torch.device, DistributedConfig | None]:
34
+ """Given backend config, get the device and (if any) distributed configuration."""
35
+ match backend:
36
+ case "cpu":
37
+ device = torch.device("cpu")
38
+ dist_cfg = None
39
+ case "cuda":
40
+ if not torch.cuda.is_available():
41
+ raise RuntimeError(
42
+ "Requested backend='cuda' but CUDA is not available. "
43
+ + _cuda_diagnostics()
44
+ )
45
+ device = torch.device("cuda")
46
+ dist_cfg = None
47
+ case "nccl":
48
+ if not torch.cuda.is_available():
49
+ raise RuntimeError(
50
+ "Requested backend='nccl' but CUDA is not available. "
51
+ + _cuda_diagnostics()
52
+ )
53
+ device = torch.device("cuda")
54
+ dist_cfg = init_distributed_mode()
55
+ case "auto" if torch.cuda.is_available():
56
+ logger.info("auto backend detected CUDA")
57
+ device = torch.device("cuda")
58
+ try:
59
+ dist_cfg = init_distributed_mode()
60
+ logger.info("succeeded in initializing distributed mode")
61
+ except RuntimeError as e:
62
+ logger.info(
63
+ f"Failed to initialize distributed mode, running on single GPU.",
64
+ exc_info=e,
65
+ )
66
+ dist_cfg = None
67
+ case "auto":
68
+ logger.warning(
69
+ "auto backend: cuda not found, using CPU. " + _cuda_diagnostics()
70
+ )
71
+ device = torch.device("cpu")
72
+ dist_cfg = None
73
+ case _:
74
+ raise ValueError(f"Invalid backend: {backend}")
75
+
76
+ # We set this globally so we don't need to hand the device around.
77
+ # See https://github.com/m2lines/Samudra/issues/87.
78
+ set_device(device)
79
+
80
+ return device, dist_cfg
81
+
82
+
83
+ def init_eval_backend(backend: EvalBackendConfig) -> torch.device:
84
+ """Given evaluation backend config, get the device."""
85
+ match backend:
86
+ case "cpu":
87
+ device = torch.device("cpu")
88
+ case "cuda":
89
+ if not torch.cuda.is_available():
90
+ raise RuntimeError(
91
+ "Requested backend='cuda' but CUDA is not available. "
92
+ + _cuda_diagnostics()
93
+ )
94
+ device = torch.device("cuda")
95
+ case "auto" if torch.cuda.is_available():
96
+ logger.info("auto backend detected CUDA")
97
+ device = torch.device("cuda")
98
+ case "auto":
99
+ logger.warning(
100
+ "auto backend: cuda not found, using CPU. " + _cuda_diagnostics()
101
+ )
102
+ device = torch.device("cpu")
103
+ case _:
104
+ raise ValueError(f"Invalid backend: {backend}")
105
+
106
+ set_device(device)
107
+ return device
samudra/cli.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ """Console-script entry point: ``samudra <train|eval|viz> CONFIG [OVERRIDES...]``.
6
+
7
+ Installed as the ``samudra`` command (see ``[project.scripts]`` in
8
+ pyproject.toml), so a user who ``pip install samudra`` can run
9
+ ``samudra train path/to/config.yaml`` without cloning the repo. Each
10
+ subcommand forwards to the same ``main`` the module entry points use
11
+ (``python -m samudra.train`` etc.), so behaviour is identical.
12
+ """
13
+
14
+ import sys
15
+
16
+ _COMMANDS = ("train", "eval", "viz")
17
+
18
+ _HELP = """\
19
+ samudra — train and evaluate emulators of ocean physics
20
+
21
+ Samudra is a PyTorch package for models that auto-regressively predict future
22
+ ocean states — temperature, salinity, horizontal currents, sea-surface height,
23
+ and surface heat flux — learned from the OM4 ocean model at 1°, 1/2°, and 1/4°
24
+ resolution. See https://arxiv.org/abs/2412.03795 for the method.
25
+
26
+ Usage:
27
+ samudra <command> CONFIG [--KEY VALUE ...]
28
+
29
+ Commands:
30
+ train Train a model from a config (checkpointing, W&B logging, multi-GPU).
31
+ eval Roll a trained model out autoregressively and collect metrics.
32
+ viz Render maps, time series, and PDFs from evaluation outputs.
33
+
34
+ CONFIG is a path to a YAML file or the name of a bundled preset such as
35
+ `samudra_om4/train.yaml`. Any config key can be overridden inline (e.g.
36
+ `--epochs 100`); run `samudra <command> --help` for a command's full options.
37
+
38
+ Examples:
39
+ samudra train samudra_om4/train.yaml --experiment.data_root ./data
40
+ samudra eval samudra_om4/eval.yaml --ckpt_path ./checkpoint.pt
41
+
42
+ Docs: https://m2lines.github.io/Samudra/docs/
43
+ """
44
+
45
+
46
+ def main() -> None:
47
+ """Dispatch ``samudra <command> ...`` to the matching task entry point."""
48
+ argv = sys.argv[1:]
49
+ if not argv or argv[0] in ("-h", "--help"):
50
+ sys.stdout.write(_HELP)
51
+ raise SystemExit(0)
52
+ if argv[0] not in _COMMANDS:
53
+ sys.stderr.write(f"samudra: unknown command {argv[0]!r}\n\n{_HELP}")
54
+ raise SystemExit(2)
55
+
56
+ command, rest = argv[0], argv[1:]
57
+ # The config loaders parse sys.argv directly, so strip the subcommand token
58
+ # and leave them an argv of `CONFIG [OVERRIDES...]`.
59
+ sys.argv = [f"samudra {command}", *rest]
60
+
61
+ if command == "train":
62
+ from samudra.train import main as train_main
63
+
64
+ train_main()
65
+ elif command == "eval":
66
+ from samudra.eval import main as eval_main
67
+
68
+ eval_main()
69
+ else: # viz
70
+ from samudra.viz.config import VizConfig
71
+ from samudra.viz.config import main as viz_main
72
+
73
+ viz_main(VizConfig.from_yaml_and_cli())
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
samudra/config.py ADDED
@@ -0,0 +1,1339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import abc
6
+ import datetime
7
+ from functools import cached_property
8
+ from pathlib import Path
9
+ from typing import Annotated, Literal, Self, assert_never
10
+
11
+ import cftime
12
+ import numpy as np
13
+ import pandas as pd
14
+ import pydantic
15
+ import torch
16
+ import xarray as xr
17
+ from perceiver_pytorch import Perceiver as NaivePerceiver
18
+ from pydantic import (
19
+ Field,
20
+ PlainSerializer,
21
+ PlainValidator,
22
+ WithJsonSchema,
23
+ model_validator,
24
+ )
25
+ from torch import nn
26
+ from torch.nn import GELU
27
+
28
+ from samudra.config_base import BaseConfig, TopLevelConfig
29
+ from samudra.constants import (
30
+ DatasetSpec,
31
+ GridType,
32
+ LoaderVersion,
33
+ build_llc_spec,
34
+ build_om4_spec,
35
+ )
36
+ from samudra.models import Samudra, SamudraMini, SamudraMulti
37
+ from samudra.models.base import BaseModel
38
+ from samudra.models.modules import (
39
+ AvgPool,
40
+ BilinearUpsample,
41
+ CappedGELU,
42
+ ConvBlock,
43
+ ConvNeXtBlock,
44
+ CoreBlock,
45
+ CoreBlockBuilder,
46
+ MaxPool,
47
+ PerceiverDecoder,
48
+ PerceiverEncoder,
49
+ ReLU,
50
+ TransposedConvUpsample,
51
+ UNetBackbone,
52
+ )
53
+ from samudra.models.modules.augment_input import (
54
+ Concat3dCoordinates,
55
+ FourierFeatures2D,
56
+ fourier_features_2d_dim,
57
+ )
58
+ from samudra.models.modules.blocks import ZonallyPeriodicBilinearUpsample
59
+ from samudra.models.modules.encoder import patch_from
60
+ from samudra.utils.data import DataContainer, DataSource, DataSourceSplits
61
+ from samudra.utils.llc import canonicalize_llc_datasets
62
+ from samudra.utils.location import (
63
+ LocalLocation,
64
+ Location,
65
+ ResolvedLocation,
66
+ UnresolvedLocation,
67
+ )
68
+ from samudra.utils.loss import (
69
+ DynamicLoss,
70
+ GradientLoss,
71
+ LossFnWithContext,
72
+ LossMetric,
73
+ loss_fn_from_metric,
74
+ )
75
+ from samudra.utils.profiler import Profiler
76
+ from samudra.utils.schedule import SchedulerConfig
77
+
78
+
79
+ class WandBConfig(BaseConfig):
80
+ mode: Literal["online", "disabled"] = "disabled"
81
+ project: str = "default"
82
+ entity: str = "ocean_emulators"
83
+ group: str | None = None
84
+ tags: list[str] | None = None
85
+ notes: str | None = None
86
+
87
+
88
+ class JulianDate:
89
+ """Represents a Julian date as a cftime.datetime at noon on the relevant day.
90
+
91
+ This is the format the OM4 data uses, so we match that here.
92
+ TODO(jder): probably worth asserting the date format when opening the data.
93
+ """
94
+
95
+ datetime: cftime.datetime
96
+
97
+ def __init__(self, value: str):
98
+ parsed = cftime.datetime.strptime(value, "%Y-%m-%d", calendar="julian")
99
+ self.datetime = parsed.replace(hour=12)
100
+
101
+ def __str__(self) -> str:
102
+ return self.datetime.strftime("%Y-%m-%d")
103
+
104
+
105
+ def _julian_date_validator(value: str | JulianDate) -> JulianDate:
106
+ return JulianDate(value) if isinstance(value, str) else value
107
+
108
+
109
+ JulianDateConfig = Annotated[
110
+ JulianDate,
111
+ PlainValidator(_julian_date_validator),
112
+ PlainSerializer(JulianDate.__str__),
113
+ WithJsonSchema({"type": "string", "format": "date"}),
114
+ ]
115
+
116
+
117
+ # We reuse pydantic's AwareDatetime to match JSONSchema's expected RFC3339 format
118
+ _aware_datetime_adapter = pydantic.TypeAdapter(pydantic.AwareDatetime)
119
+
120
+
121
+ def _datetime64_validator(value: str | np.datetime64) -> np.datetime64:
122
+ if isinstance(value, np.datetime64):
123
+ return value.astype("datetime64[ns]")
124
+ parsed = _aware_datetime_adapter.validate_python(value)
125
+ utc = parsed.astimezone(datetime.UTC).replace(tzinfo=None)
126
+ return np.datetime64(utc, "ns")
127
+
128
+
129
+ def _serialize_datetime64(value: np.datetime64) -> str:
130
+ return str(np.datetime_as_string(value, unit="ns", timezone="UTC"))
131
+
132
+
133
+ LlcDatetimeConfig = Annotated[
134
+ np.datetime64,
135
+ PlainValidator(_datetime64_validator),
136
+ PlainSerializer(_serialize_datetime64),
137
+ WithJsonSchema({"type": "string", "format": "date-time"}),
138
+ ]
139
+
140
+
141
+ class Om4TimeConfig(BaseConfig):
142
+ model_config = pydantic.ConfigDict(frozen=True)
143
+
144
+ start: JulianDateConfig
145
+ end: JulianDateConfig
146
+
147
+ @property
148
+ def time_slice(self) -> slice:
149
+ return slice(self.start.datetime, self.end.datetime)
150
+
151
+ def overlaps(self, other: Self) -> bool:
152
+ return (
153
+ self.start.datetime < other.end.datetime
154
+ and self.end.datetime > other.start.datetime
155
+ )
156
+
157
+ def __str__(self) -> str:
158
+ return f"{self.start} to {self.end}"
159
+
160
+
161
+ class LlcTimeConfig(BaseConfig):
162
+ model_config = pydantic.ConfigDict(frozen=True)
163
+
164
+ start: LlcDatetimeConfig
165
+ end: LlcDatetimeConfig
166
+
167
+ @property
168
+ def time_slice(self) -> slice:
169
+ return slice(self.start, self.end)
170
+
171
+ def overlaps(self, other: Self) -> bool:
172
+ return bool(self.start < other.end and self.end > other.start)
173
+
174
+ def __str__(self) -> str:
175
+ return f"{self.start} to {self.end}"
176
+
177
+
178
+ TimeConfig = Om4TimeConfig | LlcTimeConfig
179
+
180
+
181
+ LOCATION_DOCS = (
182
+ "Use a string relative to the `data_root` or use a structured location "
183
+ "see location.py for possible types."
184
+ )
185
+
186
+
187
+ class BaseDataSourceConfig[SourceTimeConfigT: TimeConfig](BaseConfig, abc.ABC):
188
+ train_time: SourceTimeConfigT = Field(frozen=True)
189
+ val_time: SourceTimeConfigT = Field(frozen=True)
190
+ inference_times: tuple[SourceTimeConfigT, ...] = Field(default=(), frozen=True)
191
+ data_location: Location = Field(
192
+ description="Location of the data; " + LOCATION_DOCS
193
+ )
194
+ data_means_location: Location = Field(
195
+ description="Location of the data means; " + LOCATION_DOCS
196
+ )
197
+ data_stds_location: Location = Field(
198
+ description="Location of the data standard deviations; " + LOCATION_DOCS
199
+ )
200
+
201
+ @property
202
+ @abc.abstractmethod
203
+ def dataset_spec(self) -> DatasetSpec:
204
+ raise NotImplementedError
205
+
206
+ @abc.abstractmethod
207
+ def canonicalize_datasets(
208
+ self,
209
+ data: xr.Dataset,
210
+ means: xr.Dataset,
211
+ stds: xr.Dataset,
212
+ ) -> tuple[xr.Dataset, xr.Dataset, xr.Dataset]:
213
+ raise NotImplementedError
214
+
215
+ def build(
216
+ self,
217
+ data_root: ResolvedLocation,
218
+ *,
219
+ use_dask: bool,
220
+ is_primary: bool,
221
+ ) -> DataSourceSplits:
222
+ source = self._build_source(
223
+ data_root,
224
+ turn_on_dask=use_dask,
225
+ )
226
+ inference_source = None
227
+ if is_primary and self.inference_times:
228
+ if use_dask:
229
+ full_inference_source = source
230
+ else:
231
+ full_inference_source = self._build_source(
232
+ data_root,
233
+ turn_on_dask=True,
234
+ )
235
+ # TODO: remove multiple inference time ranges altogether (see #813)
236
+ assert len(self.inference_times) == 1, (
237
+ "multiple inference time ranges have been deprecated"
238
+ )
239
+ inference_source = full_inference_source.slice(self.inference_times[0])
240
+
241
+ return DataSourceSplits(
242
+ train=source.slice(self.train_time),
243
+ val=source.slice(self.val_time),
244
+ inference=inference_source,
245
+ )
246
+
247
+ def _build_source(
248
+ self,
249
+ data_root: ResolvedLocation,
250
+ *,
251
+ turn_on_dask: bool,
252
+ ) -> DataSource:
253
+ resolved_data_location = data_root.resolve(self.data_location)
254
+ resolved_means_location = data_root.resolve(self.data_means_location)
255
+ resolved_stds_location = data_root.resolve(self.data_stds_location)
256
+
257
+ chunks: dict[str, int] | None = {} if turn_on_dask else None
258
+ data = resolved_data_location.open(chunks)
259
+ means = resolved_means_location.open(chunks)
260
+ stds = resolved_stds_location.open(chunks)
261
+ data, means, stds = self.canonicalize_datasets(
262
+ data,
263
+ means,
264
+ stds,
265
+ )
266
+ dataset_spec = self.dataset_spec
267
+
268
+ source = DataSource.from_datasets(
269
+ data,
270
+ means,
271
+ stds,
272
+ dataset_spec=dataset_spec,
273
+ prognostic_var_names=dataset_spec.prognostic_var_names,
274
+ boundary_var_names=dataset_spec.boundary_var_names,
275
+ name=f"{resolved_data_location}-{turn_on_dask}",
276
+ )
277
+ return source
278
+
279
+
280
+ class BaseDataLoadingConfig(BaseConfig):
281
+ def num_pytorch_workers(self) -> int:
282
+ raise NotImplementedError
283
+
284
+ def persistent_pytorch_workers(self) -> bool:
285
+ raise NotImplementedError
286
+
287
+
288
+ class CpuDataLoadingConfig(BaseDataLoadingConfig):
289
+ type: Literal["cpu"] = "cpu"
290
+ num_workers: int = Field(default=4, ge=0)
291
+ persistent_workers: bool = True
292
+
293
+ def num_pytorch_workers(self) -> int:
294
+ return self.num_workers
295
+
296
+ def persistent_pytorch_workers(self) -> bool:
297
+ return self.persistent_workers
298
+
299
+
300
+ class GpuDataLoadingConfig(BaseDataLoadingConfig):
301
+ type: Literal["gpu"] = "gpu"
302
+ kvikio_task_size: int = Field(default=64 * 1024 * 1024, gt=0)
303
+ kvikio_num_threads: int = Field(default=8, gt=0)
304
+
305
+ def num_pytorch_workers(self) -> int:
306
+ # When loading data direct to GPU, we don't want worker processes.
307
+ # 0 means "load in the main process"
308
+ return 0
309
+
310
+ def persistent_pytorch_workers(self) -> bool:
311
+ return False
312
+
313
+
314
+ DataLoadingConfig = Annotated[
315
+ CpuDataLoadingConfig | GpuDataLoadingConfig,
316
+ Field(discriminator="type"),
317
+ ]
318
+
319
+
320
+ class Om4DataSourceConfig(BaseDataSourceConfig[Om4TimeConfig]):
321
+ type: Literal["om4"] = "om4"
322
+ prognostic_vars_key: str = "thermo_dynamic_all"
323
+ boundary_vars_key: str = "tau_hfds"
324
+ grid_type: GridType = "gaussian"
325
+
326
+ @property
327
+ def dataset_spec(self) -> DatasetSpec:
328
+ return build_om4_spec(
329
+ self.prognostic_vars_key,
330
+ self.boundary_vars_key,
331
+ grid_type=self.grid_type,
332
+ )
333
+
334
+ @pydantic.model_validator(mode="after")
335
+ def validate_time_splits(self) -> Self:
336
+ if self.train_time.overlaps(self.val_time):
337
+ raise ValueError(
338
+ f"Training time range {self.train_time} overlaps "
339
+ f"with validation time range {self.val_time}"
340
+ )
341
+ return self
342
+
343
+ def canonicalize_datasets(
344
+ self,
345
+ data: xr.Dataset,
346
+ means: xr.Dataset,
347
+ stds: xr.Dataset,
348
+ ) -> tuple[xr.Dataset, xr.Dataset, xr.Dataset]:
349
+ # TODO: move OM4 canonicalization in here instead of in validation afte.
350
+ return data, means, stds
351
+
352
+
353
+ class LlcDataSourceConfig(BaseDataSourceConfig[LlcTimeConfig]):
354
+ type: Literal["llc"] = "llc"
355
+ prognostic_vars_key: str = "single_1"
356
+ boundary_vars_key: str = "single_1"
357
+ face: int = Field(default=1, ge=0)
358
+ i_start: int = Field(default=0, ge=0)
359
+ i_end: int = Field(default=720, gt=0)
360
+ j_start: int = Field(default=0, ge=0)
361
+ j_end: int = Field(default=720, gt=0)
362
+
363
+ @property
364
+ def dataset_spec(self) -> DatasetSpec:
365
+ return build_llc_spec(
366
+ self.prognostic_vars_key,
367
+ self.boundary_vars_key,
368
+ )
369
+
370
+ @pydantic.model_validator(mode="after")
371
+ def validate_time_splits(self) -> Self:
372
+ if self.train_time.overlaps(self.val_time):
373
+ raise ValueError(
374
+ f"Training time range {self.train_time} overlaps "
375
+ f"with validation time range {self.val_time}"
376
+ )
377
+ return self
378
+
379
+ @pydantic.model_validator(mode="after")
380
+ def validate_crop_bounds(self) -> Self:
381
+ if self.i_end <= self.i_start:
382
+ raise ValueError("LLC crop bounds must satisfy i_start < i_end")
383
+ if self.j_end <= self.j_start:
384
+ raise ValueError("LLC crop bounds must satisfy j_start < j_end")
385
+ return self
386
+
387
+ def canonicalize_datasets(
388
+ self,
389
+ data: xr.Dataset,
390
+ means: xr.Dataset,
391
+ stds: xr.Dataset,
392
+ ) -> tuple[xr.Dataset, xr.Dataset, xr.Dataset]:
393
+ return canonicalize_llc_datasets(
394
+ data,
395
+ means,
396
+ stds,
397
+ face=self.face,
398
+ i_start=self.i_start,
399
+ i_end=self.i_end,
400
+ j_start=self.j_start,
401
+ j_end=self.j_end,
402
+ dataset_spec=self.dataset_spec,
403
+ )
404
+
405
+
406
+ DataSourceConfig = Annotated[
407
+ Om4DataSourceConfig | LlcDataSourceConfig,
408
+ Field(discriminator="type"),
409
+ ]
410
+
411
+
412
+ class DataConfig(BaseConfig):
413
+ sources: list[DataSourceConfig] = Field(
414
+ description=(
415
+ "Data sources to include, each with explicit data/means/stds "
416
+ "locations. These are resolved relative to data_root."
417
+ ),
418
+ min_length=1,
419
+ )
420
+ loading: DataLoadingConfig = Field(default_factory=CpuDataLoadingConfig)
421
+ hist: int = 1
422
+ loader_version: str = str(LoaderVersion.OM4_TORCH.value)
423
+ normalize_before_mask: bool = True
424
+ masked_fill_value: float = 0.0
425
+ concurrent_compute: bool = False
426
+
427
+ def build(
428
+ self,
429
+ data_root: ResolvedLocation,
430
+ ) -> DataContainer:
431
+ loader_version = LoaderVersion(self.loader_version)
432
+ use_dask = loader_version != LoaderVersion.OM4_TORCH
433
+ dataset_spec = self.sources[0].dataset_spec
434
+ assert all(source.dataset_spec == dataset_spec for source in self.sources), (
435
+ "All data sources must use the same dataset spec"
436
+ )
437
+
438
+ source_splits = [
439
+ source_cfg.build(
440
+ data_root,
441
+ use_dask=use_dask,
442
+ is_primary=index == 0,
443
+ )
444
+ for index, source_cfg in enumerate(self.sources)
445
+ ]
446
+ train_sources = [splits.train for splits in source_splits]
447
+ val_sources = [splits.val for splits in source_splits]
448
+
449
+ return DataContainer(
450
+ train_sources=train_sources,
451
+ val_sources=val_sources,
452
+ inference_source=source_splits[0].inference,
453
+ loader_version=loader_version,
454
+ dataset_spec=dataset_spec,
455
+ )
456
+
457
+
458
+ BlockType = Literal["conv_next_block", "conv_block"]
459
+ ActivationType = Literal["relu", "gelu", "capped_gelu"]
460
+ NormType = Literal["batch", "instance", "layer"]
461
+
462
+
463
+ class BlockConfig(BaseConfig):
464
+ block_type: BlockType = "conv_next_block"
465
+ kernel_size: int = 3
466
+ activation: ActivationType = "capped_gelu"
467
+ upscale_factor: int = 4
468
+ norm: NormType = "batch"
469
+ pointwise_linear: bool = False
470
+
471
+ def build(self) -> CoreBlockBuilder:
472
+ match self.activation:
473
+ case "relu":
474
+ activation: type[nn.Module] = ReLU
475
+ case "capped_gelu":
476
+ activation = CappedGELU
477
+ case "gelu":
478
+ activation = GELU
479
+ case _:
480
+ assert_never(self.activation)
481
+
482
+ def create_block(
483
+ in_channels: int,
484
+ out_channels: int,
485
+ dilation: int,
486
+ n_layers: int,
487
+ pad: str,
488
+ checkpoint_simple: bool,
489
+ ) -> CoreBlock:
490
+ match self.block_type:
491
+ case "conv_block":
492
+ return ConvBlock(
493
+ in_channels=in_channels,
494
+ out_channels=out_channels,
495
+ dilation=dilation,
496
+ n_layers=n_layers,
497
+ pad=pad,
498
+ checkpoint_simple=checkpoint_simple,
499
+ kernel_size=self.kernel_size,
500
+ activation=activation,
501
+ )
502
+ case "conv_next_block":
503
+ return ConvNeXtBlock(
504
+ in_channels=in_channels,
505
+ out_channels=out_channels,
506
+ dilation=dilation,
507
+ n_layers=n_layers,
508
+ pad=pad,
509
+ checkpoint_simple=checkpoint_simple,
510
+ kernel_size=self.kernel_size,
511
+ upscale_factor=self.upscale_factor,
512
+ norm=self.norm,
513
+ activation=activation,
514
+ pointwise_linear=self.pointwise_linear,
515
+ )
516
+ case _:
517
+ assert_never(self.block_type)
518
+
519
+ return create_block
520
+
521
+
522
+ PerceiverImpl = Literal["auto", "naive", "flash"]
523
+
524
+
525
+ class PerceiverConfig(BaseConfig):
526
+ """A standard config interface to various perceiver implementations.
527
+
528
+ Builds either a regular Perceiver (for the encoder, via ``build``) or a
529
+ PerceiverIO (for the decoder, via ``build_io``). Both respect the shared
530
+ ``implementation`` setting from ``SamudraMultiConfig.perceiver_implementation``.
531
+ """
532
+
533
+ depth: int = 6
534
+ latent_dim: int = Field(
535
+ default=128,
536
+ description="The small, latent dimension of the Perceiver. This is the `N` dimension for the Perceiver's `O(M*N)` complexity",
537
+ )
538
+ num_latents: int = Field(
539
+ default=512,
540
+ description="The number of latent vectors in the Perceiver. This is the `M` dimension for the Perceiver's `O(M*N)` complexity",
541
+ )
542
+
543
+ def build(
544
+ self,
545
+ in_channels: int,
546
+ out_channels: int,
547
+ max_patch_size: tuple[int, int],
548
+ implementation: PerceiverImpl,
549
+ ) -> nn.Module:
550
+ """Build a regular Perceiver (used by the encoder)."""
551
+ # This is not really a "frequency" but a maximum of the width appears to be reasonable from looking at the code.
552
+ max_freq = max(*max_patch_size)
553
+
554
+ num_freq_bands = 4
555
+ fourier_dim = fourier_features_2d_dim(num_freq_bands)
556
+ # Use the same explicit 2D Fourier features in both implementations so
557
+ # intra-patch positions are encoded equivalently.
558
+ if _use_flash(implementation):
559
+ try:
560
+ from flash_perceiver import Perceiver as FlashPerceiver # type: ignore
561
+ except ImportError as e:
562
+ raise _flash_import_error() from e
563
+ from einops.layers.torch import Rearrange
564
+
565
+ perceiver: nn.Module = nn.Sequential(
566
+ FourierFeatures2D(num_freq_bands=num_freq_bands, max_freq=max_freq),
567
+ Rearrange("b ph pw v -> b (ph pw) v"),
568
+ FlashPerceiver(
569
+ depth=self.depth,
570
+ input_dim=in_channels + fourier_dim,
571
+ output_dim=out_channels,
572
+ output_mode="average",
573
+ latent_dim=self.latent_dim,
574
+ num_latents=self.num_latents,
575
+ use_flash_attn=True,
576
+ weight_tie_layers=True,
577
+ self_per_cross_attn=2,
578
+ ),
579
+ )
580
+ elif _use_naive(implementation):
581
+ perceiver = nn.Sequential(
582
+ FourierFeatures2D(num_freq_bands=num_freq_bands, max_freq=max_freq),
583
+ NaivePerceiver(
584
+ # Required by perceiver-pytorch even when its internal
585
+ # Fourier encoding is disabled below.
586
+ num_freq_bands=num_freq_bands,
587
+ max_freq=max_freq,
588
+ depth=self.depth,
589
+ input_axis=2,
590
+ input_channels=in_channels + fourier_dim,
591
+ num_classes=out_channels,
592
+ latent_dim=self.latent_dim,
593
+ num_latents=self.num_latents,
594
+ weight_tie_layers=True,
595
+ fourier_encode_data=False,
596
+ self_per_cross_attn=2,
597
+ ),
598
+ )
599
+ else:
600
+ raise ValueError(f"Unknown perceiver implementation: {implementation}.")
601
+
602
+ return perceiver
603
+
604
+ def build_io(
605
+ self,
606
+ in_channels: int,
607
+ queries_dim: int,
608
+ out_channels: int,
609
+ implementation: PerceiverImpl,
610
+ ) -> nn.Module:
611
+ """Build a PerceiverIO (used by the decoder)."""
612
+ if _use_flash(implementation):
613
+ try:
614
+ from flash_perceiver.perceiver import ( # type: ignore
615
+ PerceiverIO as FlashPerceiverIO, # type: ignore
616
+ )
617
+ except ImportError as e:
618
+ raise _flash_import_error() from e
619
+ perceiver_io: nn.Module = FlashPerceiverIO(
620
+ depth=self.depth,
621
+ input_dim=in_channels,
622
+ query_dim=queries_dim,
623
+ proj_dim=out_channels,
624
+ num_latents=self.num_latents,
625
+ latent_dim=self.latent_dim,
626
+ use_flash_attn=True,
627
+ weight_tie_layers=True,
628
+ )
629
+ elif _use_naive(implementation):
630
+ from perceiver_pytorch.perceiver_io import PerceiverIO as NaivePerceiverIO
631
+
632
+ perceiver_io = NaivePerceiverIO(
633
+ depth=self.depth,
634
+ dim=in_channels,
635
+ queries_dim=queries_dim,
636
+ logits_dim=out_channels,
637
+ num_latents=self.num_latents,
638
+ latent_dim=self.latent_dim,
639
+ weight_tie_layers=True,
640
+ decoder_ff=True,
641
+ )
642
+ else:
643
+ raise ValueError(f"Unknown perceiver implementation: {implementation}.")
644
+
645
+ return perceiver_io
646
+
647
+
648
+ def _use_flash(implementation: PerceiverImpl) -> bool:
649
+ return (
650
+ implementation == "auto" and torch.cuda.is_available()
651
+ ) or implementation == "flash"
652
+
653
+
654
+ def _use_naive(implementation: PerceiverImpl) -> bool:
655
+ return (
656
+ implementation == "auto" and not torch.cuda.is_available()
657
+ ) or implementation == "naive"
658
+
659
+
660
+ def _flash_import_error() -> ValueError:
661
+ return ValueError(
662
+ "`implementation==flash` or flash was automatically chosen for `implementation==auto`, "
663
+ "but the flash attention dependencies could not be imported. "
664
+ "Please run `uv sync --extra cuda` or specify the `naive` attention implementation."
665
+ )
666
+
667
+
668
+ class EncoderConfig(BaseConfig):
669
+ perceiver: PerceiverConfig = PerceiverConfig()
670
+
671
+ def build(
672
+ self,
673
+ in_channels: int,
674
+ out_channels: int,
675
+ patch_extent: tuple[float, float],
676
+ max_lat_size: int,
677
+ max_lon_size: int,
678
+ implementation: PerceiverImpl,
679
+ ) -> PerceiverEncoder:
680
+ max_patch_size = patch_from(patch_extent, max_lat_size, max_lon_size)
681
+ return PerceiverEncoder(
682
+ in_channels=in_channels,
683
+ out_channels=out_channels,
684
+ patch_extent=patch_extent,
685
+ perceiver=self.perceiver.build(
686
+ in_channels, out_channels, max_patch_size, implementation
687
+ ),
688
+ )
689
+
690
+
691
+ class DecoderConfig(BaseConfig):
692
+ """A PerceiverIO-based decoder configuration.
693
+
694
+ Uses PerceiverIO (with an explicit query mechanism) rather than a regular
695
+ Perceiver. Output pixel positions are encoded as queries, so the output
696
+ size is determined by the query count — not by ``num_latents``.
697
+
698
+ When ``window_patches`` is set, the decoder tiles the output grid into
699
+ spatial blocks of that many patches per side. Each block's PerceiverIO
700
+ call receives only the overlapping latent tokens plus ``context_patches``
701
+ extra rings of neighbors, keeping cost bounded even when the latent grid
702
+ is large (i.e. fine ``patch_extent``).
703
+ """
704
+
705
+ perceiver: PerceiverConfig = PerceiverConfig()
706
+ queries_dim: int = Field(
707
+ default=64,
708
+ description="Embedding dimension for pixel-position queries in the PerceiverIO decoder head.",
709
+ )
710
+ window_patches: int | None = Field(
711
+ default=4096,
712
+ description="Side length (in patches) of each spatial decode window. "
713
+ "None = decode all patches at once (global attention). "
714
+ "E.g. window_patches=8 means each PerceiverIO call covers an 8x8 block of patches.",
715
+ )
716
+ context_patches: int | None = Field(
717
+ default=1,
718
+ description="Number of extra patch rings around each window to include as data context. "
719
+ "Only used when window_patches is set. None = full context (every window sees all latent tokens).",
720
+ )
721
+
722
+ def build(
723
+ self,
724
+ in_channels: int,
725
+ out_channels: int,
726
+ patch_extent: tuple[float, float],
727
+ implementation: PerceiverImpl,
728
+ ) -> PerceiverDecoder:
729
+ return PerceiverDecoder(
730
+ in_channels=in_channels,
731
+ out_channels=out_channels,
732
+ patch_extent=patch_extent,
733
+ queries_dim=self.queries_dim,
734
+ perceiver_io=self.perceiver.build_io(
735
+ in_channels, self.queries_dim, out_channels, implementation
736
+ ),
737
+ window_patches=self.window_patches,
738
+ context_patches=self.context_patches,
739
+ )
740
+
741
+
742
+ DownSamplingBlocks = Literal["avg_pool", "max_pool"]
743
+ UpSamplingBlocks = Literal[
744
+ "bilinear_upsample", "transposed_conv", "zonally_periodic_upsample"
745
+ ]
746
+ Checkpointing = Literal["all", "simple"]
747
+
748
+
749
+ class UNetBackboneConfig(BaseConfig):
750
+ ch_width: list[int] = [200, 250, 300, 400]
751
+ dilation: list[int] = [1, 2, 4, 8]
752
+ n_layers: list[int] = [1, 1, 1, 1]
753
+ core_block: BlockConfig = BlockConfig()
754
+ down_sampling_block: DownSamplingBlocks = "avg_pool"
755
+ up_sampling_block: UpSamplingBlocks = "zonally_periodic_upsample"
756
+ drop_path_rate: float = Field(
757
+ default=0.0,
758
+ description="Shortcut dropout rate. The chance we turn off skip connections in the UNet. Reasonable values are 0.1-0.3. Use 0.0 to disable.",
759
+ )
760
+
761
+ def build(
762
+ self,
763
+ in_channels: int,
764
+ pad: str,
765
+ checkpointing: Checkpointing | None,
766
+ ) -> UNetBackbone:
767
+ assert len(self.ch_width) == len(self.dilation) == len(self.n_layers), (
768
+ "`ch_width`, `dilation`, and `n_layers` must have the same length."
769
+ )
770
+
771
+ def create_upsampling_block(in_channels: int, out_channels: int):
772
+ match self.up_sampling_block:
773
+ case "bilinear_upsample":
774
+ return BilinearUpsample(
775
+ in_channels=in_channels, out_channels=out_channels
776
+ )
777
+ case "transposed_conv":
778
+ return TransposedConvUpsample(
779
+ in_channels=in_channels, out_channels=out_channels
780
+ )
781
+ case "zonally_periodic_upsample":
782
+ return ZonallyPeriodicBilinearUpsample()
783
+ case _:
784
+ assert_never(self.up_sampling_block)
785
+
786
+ match self.down_sampling_block:
787
+ case "avg_pool":
788
+ downsampling_block: nn.Module = AvgPool()
789
+ case "max_pool":
790
+ downsampling_block = MaxPool()
791
+ case _:
792
+ assert_never(self.down_sampling_block)
793
+
794
+ return UNetBackbone(
795
+ in_channels=in_channels,
796
+ ch_width=self.ch_width,
797
+ dilation=self.dilation,
798
+ n_layers=self.n_layers,
799
+ pad=pad,
800
+ create_block=self.core_block.build(),
801
+ downsampling_block=downsampling_block,
802
+ create_upsampling_block=create_upsampling_block,
803
+ checkpointing=checkpointing,
804
+ drop_path_rate=self.drop_path_rate,
805
+ )
806
+
807
+
808
+ class BaseModelConfig(BaseConfig, abc.ABC):
809
+ pred_residuals: bool = False
810
+ last_kernel_size: int = 3
811
+ pad: str = "circular"
812
+
813
+ checkpointing: Checkpointing | None = Field(
814
+ default=None,
815
+ description="""Strategy for storing activations for the model for use in
816
+ the backward pass. If not set, the model will store all activations in memory
817
+ (fast but lots of memory). If set to 'all', the model will recompute each
818
+ top-level layer (CoreBlocks, scaling layers, etc.) in the backward pass.
819
+ If set to 'simple', the model will recompute only cheap layers like scales
820
+ and nonlinearities.""",
821
+ )
822
+
823
+ gradient_detach_interval: int = Field(
824
+ default=0,
825
+ description="""Interval for detaching gradients in autoregressive training. `0` means no detaching.""",
826
+ )
827
+
828
+ add_3d_coordinates: bool = Field(
829
+ default=False,
830
+ description="Add 3d coordinates representing position on the Earth (cartesian coordinates on a unit sphere) to the input channels.",
831
+ )
832
+
833
+ @abc.abstractmethod
834
+ def build(
835
+ self,
836
+ prog_channels: int,
837
+ boundary_channels: int,
838
+ out_channels: int,
839
+ hist: int,
840
+ srcs: list[DataSource],
841
+ ) -> BaseModel:
842
+ pass
843
+
844
+
845
+ class SamudraConfig(BaseModelConfig):
846
+ unet: UNetBackboneConfig = UNetBackboneConfig()
847
+ pos_channels: int = Field(
848
+ default=0,
849
+ description="""Number of channels used for a learned positional embedding""",
850
+ )
851
+ use_bfloat16: bool = Field(
852
+ default=False,
853
+ description="Use bfloat16 for most layers rather than float32.",
854
+ )
855
+
856
+ def build(
857
+ self,
858
+ prog_channels: int,
859
+ boundary_channels: int,
860
+ out_channels: int,
861
+ hist: int,
862
+ srcs: list[DataSource],
863
+ ) -> Samudra:
864
+ if len(srcs) != 1:
865
+ raise ValueError(
866
+ "Samudra only supports training at a single scale! Please configure exactly one data source."
867
+ )
868
+ src = srcs[0]
869
+ in_channels = prog_channels + boundary_channels
870
+ total_in_channels = (
871
+ in_channels + self.pos_channels + (3 if self.add_3d_coordinates else 0)
872
+ )
873
+ add_3d_coordinates = Concat3dCoordinates() if self.add_3d_coordinates else None
874
+ return Samudra(
875
+ in_channels=total_in_channels,
876
+ out_channels=out_channels,
877
+ pred_residuals=self.pred_residuals,
878
+ last_kernel_size=self.last_kernel_size,
879
+ pad=self.pad,
880
+ unet=self.unet.build(
881
+ in_channels=total_in_channels,
882
+ pad=self.pad,
883
+ checkpointing=self.checkpointing,
884
+ ),
885
+ pos_channels=self.pos_channels,
886
+ add_3d_coordinates=add_3d_coordinates,
887
+ hist=hist,
888
+ grid_size=src.grid_size,
889
+ gradient_detach_interval=self.gradient_detach_interval,
890
+ use_bfloat16=self.use_bfloat16,
891
+ )
892
+
893
+
894
+ class SamudraMultiConfig(BaseModelConfig):
895
+ encoder: EncoderConfig = EncoderConfig()
896
+ processor: UNetBackboneConfig = UNetBackboneConfig()
897
+ decoder: DecoderConfig = DecoderConfig()
898
+ perceiver_implementation: PerceiverImpl = Field(
899
+ default="auto",
900
+ description="Perceiver attention implementation shared by the encoder and decoder. "
901
+ "'auto' selects flash attention when CUDA is available, otherwise naive.",
902
+ )
903
+ patch_extent: list[float] = Field(
904
+ default=[6.0, 10.0],
905
+ description="Target physical extent of each patch in degrees [height_deg, width_deg]. "
906
+ "Shared by the encoder and decoder for consistent spatial semantics.",
907
+ )
908
+ embedding_dim: int = 128
909
+ use_bfloat16: bool = Field(
910
+ default=True,
911
+ description="Use bfloat16 for most layers rather than float32. Required for flash attention.",
912
+ )
913
+
914
+ def build(
915
+ self,
916
+ prog_channels: int,
917
+ boundary_channels: int,
918
+ out_channels: int,
919
+ hist: int,
920
+ srcs: list[DataSource],
921
+ ) -> SamudraMulti:
922
+ assert len(self.patch_extent) == 2, "patch_extent must be a pair of floats."
923
+ extent = self.patch_extent[0], self.patch_extent[1]
924
+
925
+ all_grid_sizes = [s.grid_size for s in srcs]
926
+ max_lat_size, max_lon_size = (
927
+ max(g[0] for g in all_grid_sizes),
928
+ max(g[1] for g in all_grid_sizes),
929
+ )
930
+
931
+ impl = self.perceiver_implementation
932
+ if _use_flash(impl) and not self.use_bfloat16:
933
+ raise ValueError(
934
+ "Perceiver implementation resolves to flash attention. "
935
+ "Please set `use_bfloat16=True` or `perceiver_implementation='naive'`."
936
+ )
937
+
938
+ in_channels = prog_channels + boundary_channels
939
+ total_in_channels = in_channels + (3 if self.add_3d_coordinates else 0)
940
+
941
+ encoder = self.encoder.build(
942
+ total_in_channels,
943
+ self.embedding_dim,
944
+ extent,
945
+ max_lat_size,
946
+ max_lon_size,
947
+ impl,
948
+ )
949
+ processor = self.processor.build(
950
+ self.embedding_dim,
951
+ self.pad,
952
+ self.checkpointing,
953
+ )
954
+ decoder = self.decoder.build(
955
+ processor.out_channels,
956
+ out_channels,
957
+ extent,
958
+ impl,
959
+ )
960
+
961
+ add_3d_coordinates = Concat3dCoordinates() if self.add_3d_coordinates else None
962
+ return SamudraMulti(
963
+ in_channels=total_in_channels,
964
+ out_channels=out_channels,
965
+ pred_residuals=self.pred_residuals,
966
+ last_kernel_size=self.last_kernel_size,
967
+ pad=self.pad,
968
+ encoder=encoder,
969
+ processor=processor,
970
+ decoder=decoder,
971
+ add_3d_coordinates=add_3d_coordinates,
972
+ hist=hist,
973
+ checkpointing=self.checkpointing,
974
+ gradient_detach_interval=self.gradient_detach_interval,
975
+ use_bfloat16=self.use_bfloat16,
976
+ )
977
+
978
+
979
+ class SamudraMiniConfig(BaseModelConfig):
980
+ perceiver: PerceiverConfig = PerceiverConfig()
981
+ perceiver_implementation: PerceiverImpl = Field(
982
+ default="auto",
983
+ description="Perceiver attention implementation for the single PerceiverIO model. "
984
+ "'auto' selects flash attention when CUDA is available, otherwise naive.",
985
+ )
986
+ embedding_dim: int = Field(
987
+ default=128,
988
+ description="Dimension of data-token embeddings before PerceiverIO.",
989
+ )
990
+ queries_dim: int = Field(
991
+ default=128,
992
+ description="Dimension of PerceiverIO output queries.",
993
+ )
994
+ coordinate_embedding_dim: int = Field(
995
+ default=64,
996
+ description="Hidden dimension used by learned 3D Cartesian coordinate embeddings.",
997
+ )
998
+ query_chunk_size: int | None = Field(
999
+ default=None,
1000
+ description="Optional chunk size for query decoding. If set, PerceiverIO is called "
1001
+ "over query chunks to reduce memory use.",
1002
+ )
1003
+ use_bfloat16: bool = Field(
1004
+ default=True,
1005
+ description="Use bfloat16 for most layers rather than float32. Required for flash attention.",
1006
+ )
1007
+
1008
+ def build(
1009
+ self,
1010
+ prog_channels: int,
1011
+ boundary_channels: int,
1012
+ out_channels: int,
1013
+ hist: int,
1014
+ srcs: list[DataSource],
1015
+ ) -> SamudraMini:
1016
+ if self.add_3d_coordinates:
1017
+ raise ValueError(
1018
+ "SamudraMini always uses learned Cartesian coordinate embeddings. "
1019
+ "Please set `add_3d_coordinates=False`."
1020
+ )
1021
+
1022
+ impl = self.perceiver_implementation
1023
+ if _use_flash(impl) and not self.use_bfloat16:
1024
+ raise ValueError(
1025
+ "Perceiver implementation resolves to flash attention. "
1026
+ "Please set `use_bfloat16=True` or `perceiver_implementation='naive'`."
1027
+ )
1028
+
1029
+ in_channels = prog_channels + boundary_channels
1030
+ perceiver_io = self.perceiver.build_io(
1031
+ self.embedding_dim,
1032
+ self.queries_dim,
1033
+ out_channels,
1034
+ impl,
1035
+ )
1036
+ return SamudraMini(
1037
+ in_channels=in_channels,
1038
+ out_channels=out_channels,
1039
+ pred_residuals=self.pred_residuals,
1040
+ last_kernel_size=self.last_kernel_size,
1041
+ pad=self.pad,
1042
+ input_embedding_dim=self.embedding_dim,
1043
+ coordinate_embedding_dim=self.coordinate_embedding_dim,
1044
+ queries_dim=self.queries_dim,
1045
+ query_chunk_size=self.query_chunk_size,
1046
+ perceiver_io=perceiver_io,
1047
+ hist=hist,
1048
+ checkpointing=self.checkpointing,
1049
+ gradient_detach_interval=self.gradient_detach_interval,
1050
+ use_bfloat16=self.use_bfloat16,
1051
+ )
1052
+
1053
+
1054
+ AnyModelConfig = SamudraConfig | SamudraMultiConfig | SamudraMiniConfig
1055
+
1056
+
1057
+ class DistributedConfig(BaseConfig):
1058
+ dist_url: str | None = None
1059
+ world_size: int | None = None
1060
+ rank: int | None = None
1061
+ gpu: int | None = None
1062
+ dist_backend: str | None = None
1063
+
1064
+
1065
+ class ExperimentConfig(BaseConfig):
1066
+ name: str = "cm4_samudra"
1067
+ rand_seed: int = 1
1068
+ base_output_dir: str = "train"
1069
+ # we require this to be set by the user but have optional here
1070
+ # so we can leave it out of config files
1071
+ data_root: Location | None = None
1072
+ wandb: WandBConfig
1073
+
1074
+ @cached_property
1075
+ def output_dir(self) -> Path:
1076
+ return Path(self.base_output_dir) / f"{self.name}"
1077
+
1078
+ @cached_property
1079
+ def nets_dir(self) -> Path:
1080
+ return self.output_dir / "saved_nets"
1081
+
1082
+ @cached_property
1083
+ def resolved_data_root(self) -> ResolvedLocation:
1084
+ # Default to the current directory when no data_root is given. Absolute
1085
+ # data locations (e.g. an s3:// demo source) ignore the root entirely, so
1086
+ # they run flagless; relative locations resolve against cwd and still
1087
+ # fail loudly at open() if the data isn't there.
1088
+ default_root = LocalLocation(path=Path.cwd())
1089
+ if self.data_root is None:
1090
+ return default_root
1091
+ return default_root.resolve(self.data_root)
1092
+
1093
+
1094
+ class ProfilerConfig(BaseConfig):
1095
+ # How often (in batches processed) to take a snapshot of the CUDA memory
1096
+ # (None = no snapshots)
1097
+ cuda_snapshot_frequency: int | None = None
1098
+
1099
+ def build(self, output_dir: Path, device: torch.device) -> Profiler:
1100
+ if self.cuda_snapshot_frequency is not None and device.type != "cuda":
1101
+ raise ValueError(
1102
+ "cuda_snapshot_frequency is only supported on CUDA devices, got "
1103
+ f"{device.type}"
1104
+ )
1105
+ return Profiler(output_dir, self.cuda_snapshot_frequency)
1106
+
1107
+
1108
+ # See backend.py for how these are turned into concrete devices
1109
+ TrainBackendConfig = Literal["cpu", "cuda", "nccl", "auto"]
1110
+
1111
+
1112
+ class DynamicLossConfig(pydantic.BaseModel):
1113
+ type: Literal["dynamic"] = "dynamic"
1114
+ metric: LossMetric = "mse"
1115
+ limit: float | None = Field(
1116
+ description="The ratio of the largest weight to the smallest weight across all channels which we'll allow. Default of None means no limit.",
1117
+ default=None,
1118
+ ge=1.0,
1119
+ )
1120
+
1121
+
1122
+ class GradientLossConfig(pydantic.BaseModel):
1123
+ type: Literal["gradient"] = "gradient"
1124
+ # at the moment this metric is only used for the non-gradient loss
1125
+ # (and would take a bit of refactoring to make it work for the gradient loss too)
1126
+ # so we fix it to MAE for now until it's clear we what flexibility is needed here.
1127
+ # TODO(#497): support other metrics for the gradient loss
1128
+ metric: Literal["mae"] = "mae"
1129
+ alpha: float = Field(
1130
+ description="Scaling factor for the gradient penalty term (alpha in the gradient-weighted loss).",
1131
+ default=0.1,
1132
+ ge=0.0,
1133
+ )
1134
+
1135
+
1136
+ Loss = LossMetric | DynamicLossConfig | GradientLossConfig
1137
+
1138
+
1139
+ def build_loss_fn(
1140
+ loss_cfg: Loss,
1141
+ device: torch.device,
1142
+ num_channels: int,
1143
+ pad_mode: str,
1144
+ ) -> LossFnWithContext:
1145
+ match loss_cfg:
1146
+ case str():
1147
+ return loss_fn_from_metric(loss_cfg)
1148
+ case DynamicLossConfig(metric=metric, limit=limit):
1149
+ loss_fn = loss_fn_from_metric(metric)
1150
+ return DynamicLoss(
1151
+ loss_fn=loss_fn,
1152
+ limit=limit,
1153
+ device=device,
1154
+ num_channels=num_channels,
1155
+ )
1156
+ case GradientLossConfig(metric=metric, alpha=alpha):
1157
+ loss_fn = loss_fn_from_metric(metric)
1158
+ return GradientLoss(
1159
+ loss_fn=loss_fn,
1160
+ gradient_weight=alpha,
1161
+ pad_mode=pad_mode,
1162
+ )
1163
+ case _:
1164
+ assert_never(loss_cfg)
1165
+
1166
+
1167
+ class TrainConfig(TopLevelConfig):
1168
+ # Training parameters
1169
+ disk_mode: bool = True
1170
+ pin_mem: bool = True
1171
+ save_freq: int = 5
1172
+ validation_image_log_freq: int = Field(
1173
+ default=10,
1174
+ ge=1,
1175
+ description=(
1176
+ "How often to log expensive validation images. Epochs are 1-based, so "
1177
+ "a value of 10 logs on epochs 1, 11, 21, ..."
1178
+ ),
1179
+ )
1180
+ epochs: int = 120
1181
+ preemptible: bool = True
1182
+ batch_size: int = 2
1183
+ learning_rate: float = 2e-4
1184
+ gradient_accumulation_steps: int = 1
1185
+ scheduler: SchedulerConfig | None = None
1186
+ loss: Loss = "mse"
1187
+ finetune: bool = False
1188
+ resume_ckpt_path: str | None = None
1189
+ debug: bool = False
1190
+ test_using_ema: bool = True
1191
+ ema_decay: float = 0.999
1192
+ faster_decay_at_start: bool = True
1193
+ delayed_loss_estimate: bool = False
1194
+ backend: TrainBackendConfig = "auto"
1195
+
1196
+ # Profiling parameters
1197
+ profiler: ProfilerConfig = ProfilerConfig()
1198
+
1199
+ # Data parameters at root level
1200
+ data_stride: list[int] = [1]
1201
+ steps: list[int] = [4]
1202
+ step_transition: list[int] = []
1203
+ inference_epochs: list[int] = [-1]
1204
+
1205
+ # Config components
1206
+ experiment: ExperimentConfig
1207
+ data: DataConfig
1208
+ model: AnyModelConfig
1209
+
1210
+ def prepare_output_dirs(self) -> None:
1211
+ self.experiment.nets_dir.mkdir(parents=True, exist_ok=True)
1212
+ self.experiment.output_dir.mkdir(parents=True, exist_ok=True)
1213
+
1214
+
1215
+ class ObsMetricsConfig(BaseConfig):
1216
+ """Where the observation products live, and over what period to score.
1217
+
1218
+ Supplying this block is what turns observation metrics on: an eval job then
1219
+ compares its rollout against DUACS, OISST and ARGO-IAP once the rollout is
1220
+ on disk. Omit it (or pass `--observations=null`) to skip that phase.
1221
+ """
1222
+
1223
+ duacs_location: Location = Field(
1224
+ default=UnresolvedLocation(path="obs/duacs.zarr"),
1225
+ description="DUACS surface geostrophic velocity, on its native grid.",
1226
+ )
1227
+ oisst_location: Location = Field(
1228
+ default=UnresolvedLocation(path="obs/oisst.zarr"),
1229
+ description="OISST sea-surface temperature, on its native grid.",
1230
+ )
1231
+ argo_iap_location: Location = Field(
1232
+ default=UnresolvedLocation(path="obs/argo-iap.zarr"),
1233
+ description="ARGO-IAP gridded temperature, on its native grid.",
1234
+ )
1235
+ rmse_start: str = Field(
1236
+ default="2015-01-01",
1237
+ description=(
1238
+ "Start of the primary scoring window. Must begin a complete calendar "
1239
+ "year: the score and its bootstrap both use equal-year blocks."
1240
+ ),
1241
+ )
1242
+ rmse_end: str = Field(
1243
+ default="2022-12-31",
1244
+ description="End of the primary scoring window; must end a complete calendar year.",
1245
+ )
1246
+ bootstrap_samples: int = Field(
1247
+ default=10_000,
1248
+ ge=0,
1249
+ description="Calendar-year block-bootstrap draws for the 95% CI; 0 disables.",
1250
+ )
1251
+ velocity_kind: Literal["absolute", "anomaly"] = Field(
1252
+ default="absolute",
1253
+ description=(
1254
+ "Which DUACS geostrophic velocity to compare against. 'absolute' "
1255
+ "matches the model velocity derived from zos."
1256
+ ),
1257
+ )
1258
+ baselines: list[str] = Field(
1259
+ default_factory=lambda: ["om4"],
1260
+ description=(
1261
+ "Reference rollouts scored alongside the model. 'om4' scores the "
1262
+ "ground-truth OM4 data already staged for the eval, which is what "
1263
+ "makes the model's own numbers interpretable."
1264
+ ),
1265
+ )
1266
+
1267
+ @model_validator(mode="after")
1268
+ def _check(self) -> "ObsMetricsConfig":
1269
+ unknown = set(self.baselines) - {"om4"}
1270
+ if unknown:
1271
+ raise ValueError(f"Unknown baselines {sorted(unknown)}; supported: ['om4']")
1272
+ if self.window[0] > self.window[1]:
1273
+ raise ValueError(
1274
+ f"rmse_start ({self.rmse_start}) must not be after rmse_end "
1275
+ f"({self.rmse_end})"
1276
+ )
1277
+ return self
1278
+
1279
+ @property
1280
+ def window(self) -> tuple[pd.Timestamp, pd.Timestamp]:
1281
+ """The scoring window, with a date-only end bound taken to end-of-day.
1282
+
1283
+ OM4 samples are stamped at 12:00, so a bare `2022-12-31` end bound would
1284
+ exclude that day's sample. Currently masked because the observation
1285
+ products end earlier and the window gets trimmed to the shared span, but
1286
+ it becomes a silent one-sample loss the moment coverage extends past
1287
+ `rmse_end` -- and the 7.5-day tolerance in the complete-calendar-year
1288
+ check is far too loose to notice.
1289
+ """
1290
+ end = pd.Timestamp(self.rmse_end)
1291
+ if end == end.normalize():
1292
+ end = end + pd.Timedelta(days=1) - pd.Timedelta(1, "ns")
1293
+ return pd.Timestamp(self.rmse_start), end
1294
+
1295
+
1296
+ # See backend.py for how these are turned into concrete devices
1297
+ EvalBackendConfig = Literal["cpu", "cuda", "auto"]
1298
+
1299
+
1300
+ class EvalConfig(TopLevelConfig):
1301
+ # Basic parameters
1302
+ debug: bool = False
1303
+ save_zarr: bool = False
1304
+ disk_mode: bool = True
1305
+ # we require this to be set by the user but have optional here
1306
+ # so we can leave it out of config files
1307
+ ckpt_path: str | None = None
1308
+ num_model_steps_forward: int = 200
1309
+ backend: EvalBackendConfig = "auto"
1310
+
1311
+ # Config components
1312
+ experiment: ExperimentConfig
1313
+ data: DataConfig
1314
+ model: AnyModelConfig
1315
+ observations: ObsMetricsConfig | None = Field(
1316
+ default=None,
1317
+ description=(
1318
+ "Observation-based metrics computed after the rollout finishes. "
1319
+ "Present means enabled; omit to skip the phase."
1320
+ ),
1321
+ )
1322
+
1323
+ @pydantic.model_validator(mode="after")
1324
+ def _observations_need_a_saved_rollout(self) -> Self:
1325
+ # Caught here rather than after the rollout: observation metrics score a
1326
+ # rollout read back from disk, and discovering the misconfiguration at
1327
+ # the end would waste the whole job.
1328
+ if self.observations is not None and not self.save_zarr:
1329
+ raise ValueError(
1330
+ "observations requires save_zarr=true: the metrics are computed "
1331
+ "from the predictions.zarr the rollout writes."
1332
+ )
1333
+ return self
1334
+
1335
+ def prepare_output_dirs(self) -> None:
1336
+ self.experiment.output_dir.mkdir(parents=True, exist_ok=True)
1337
+
1338
+
1339
+ AnyTopLevelConfig = TrainConfig | EvalConfig
samudra/config_base.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import argparse
6
+ import importlib.resources
7
+ import os
8
+ import textwrap
9
+ from pathlib import Path
10
+ from typing import Any, Self
11
+
12
+ import yaml
13
+ from pydantic import BaseModel, ConfigDict
14
+ from pydantic.fields import FieldInfo
15
+ from pydantic_settings import (
16
+ BaseSettings,
17
+ CliSettingsSource,
18
+ PydanticBaseSettingsSource,
19
+ SettingsConfigDict,
20
+ YamlConfigSettingsSource,
21
+ )
22
+
23
+
24
+ def register_include_constructor():
25
+ """Set up yaml.safe_load to include other yaml files via !include."""
26
+
27
+ def include_constructor(loader: yaml.Loader, node: yaml.Node) -> Any:
28
+ if hasattr(loader.stream, "name"):
29
+ name = loader.stream.name # type: ignore
30
+ else:
31
+ raise ValueError(
32
+ "To support includes, you must load a file object, not a string"
33
+ )
34
+ filename = os.path.normpath(os.path.join(os.path.dirname(name), node.value))
35
+ with open(filename) as f:
36
+ return yaml.safe_load(f)
37
+
38
+ # This is arguably unsafe, but we don't parse untrusted YAML
39
+ yaml.loader.SafeLoader.add_constructor("!include", include_constructor)
40
+
41
+
42
+ register_include_constructor()
43
+
44
+
45
+ def resolve_config_path(config: str) -> Path:
46
+ """Resolve a config argument to a file on disk.
47
+
48
+ A real filesystem path wins, so a checkout behaves exactly as before. If it
49
+ doesn't exist, fall back to a preset bundled in the installed package (e.g.
50
+ ``samudra_om4/train.yaml``), so ``samudra train samudra_om4/train.yaml``
51
+ works with no checkout. The bundled configs install as real co-located
52
+ files, so their relative ``!include``s resolve unchanged.
53
+ """
54
+ path = Path(config).expanduser()
55
+ if not path.exists():
56
+ bundled = importlib.resources.files("samudra") / "configs" / config
57
+ if bundled.is_file():
58
+ path = Path(str(bundled))
59
+ return path.resolve()
60
+
61
+
62
+ class BaseConfig(BaseModel):
63
+ """
64
+ Base class for all configs.
65
+ """
66
+
67
+ model_config = ConfigDict(extra="forbid")
68
+
69
+
70
+ class TopLevelConfig(BaseSettings):
71
+ """
72
+ Base class for top-level configs (ie tasks like train or eval).
73
+ """
74
+
75
+ model_config = SettingsConfigDict(nested_model_default_partial_update=True)
76
+
77
+ def __init__(self, *args, **kwargs):
78
+ super().__init__(*args, **kwargs)
79
+
80
+ @classmethod
81
+ def settings_customise_sources(
82
+ cls,
83
+ settings_cls: type[BaseSettings],
84
+ init_settings: PydanticBaseSettingsSource,
85
+ env_settings: PydanticBaseSettingsSource,
86
+ dotenv_settings: PydanticBaseSettingsSource,
87
+ file_secret_settings: PydanticBaseSettingsSource,
88
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
89
+ # We don't need env/dotenv/secrets, yaml & CLI are injected in from_yaml_and_cli
90
+ return (init_settings,)
91
+
92
+ @classmethod
93
+ def from_yaml_and_cli(
94
+ cls,
95
+ args_to_parse: list[str] | None = None,
96
+ ) -> Self:
97
+ """Load config from YAML & CLI with validation."""
98
+ parser = argparse.ArgumentParser(
99
+ description=cls.__doc__,
100
+ epilog=textwrap.dedent(
101
+ """
102
+ CONFIG may be a path to a YAML file or, when samudra is installed,
103
+ the name of a bundled preset such as `samudra_om4/train.yaml`.
104
+ YAML files can include other YAML files using the !include tag,
105
+ as in `data: !include ../data/om4.yaml` (resolved relative to the
106
+ including file). You can also replace any JSON argument listed above
107
+ with a YAML file by specifying it with an @ symbol,
108
+ eg `--some_param=@../data/om4.yaml`.
109
+ """
110
+ ),
111
+ )
112
+ parser.add_argument(
113
+ "config",
114
+ type=str,
115
+ help="Path to a config YAML file, or a bundled preset name",
116
+ )
117
+
118
+ cli_source = IncludeYamlCliSettingsSource(
119
+ cls,
120
+ root_parser=parser,
121
+ # If args_to_parse is None, we parse argv, which is what `True` does
122
+ cli_parse_args=args_to_parse if args_to_parse is not None else True,
123
+ )
124
+
125
+ # We do this after creating CliSettingsSource (which populates the parser)
126
+ # so the help is complete on error.
127
+ args = parser.parse_args(args_to_parse)
128
+
129
+ # Then we read the YAML file specified in the CLI. A bare path resolves
130
+ # against the filesystem; if it's missing we try a preset bundled in the
131
+ # installed package before giving up.
132
+ # Note that by default, YamlConfigSettingsSource will ignore missing files
133
+ config_path = resolve_config_path(args.config)
134
+ if not config_path.exists():
135
+ raise FileNotFoundError(
136
+ f"Config file `{args.config}` (full path: {config_path}) not found"
137
+ )
138
+ yaml_values = YamlConfigSettingsSource(cls, yaml_file=config_path)()
139
+
140
+ return cls(
141
+ _cli_settings_source=cli_source,
142
+ **yaml_values,
143
+ )
144
+
145
+ def save_yaml(self, save_path: Path) -> None:
146
+ """Save config to YAML file."""
147
+ with open(save_path, "w") as f:
148
+ yaml.dump(self.model_dump(), f)
149
+
150
+
151
+ class IncludeYamlCliSettingsSource(CliSettingsSource):
152
+ """CliSettingsSource which permits @filename.yaml for JSON arguments."""
153
+
154
+ def __init__(self, *args, **kwargs):
155
+ super().__init__(*args, **kwargs)
156
+
157
+ def decode_complex_value(
158
+ self, field_name: str, field: FieldInfo, value: Any
159
+ ) -> Any:
160
+ if isinstance(value, str) and value.startswith("@"):
161
+ # Resolve like the positional config: a real path wins, otherwise
162
+ # fall back to a bundled preset (so `--data @data/om4_demo.yaml`
163
+ # works without a checkout).
164
+ with open(resolve_config_path(value[1:])) as f:
165
+ return yaml.safe_load(f)
166
+ return super().decode_complex_value(field_name, field, value)
samudra/config_schema.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import argparse
6
+ import json
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import get_args, get_origin
11
+
12
+ import pydantic
13
+ import yaml
14
+
15
+ from samudra.config import EvalConfig, TrainConfig
16
+ from samudra.viz import VizConfig
17
+
18
+
19
+ def get_pydantic_models(
20
+ model: type[pydantic.BaseModel],
21
+ models: dict[str, type[pydantic.BaseModel]] | None = None,
22
+ seen: set[int] | None = None,
23
+ ) -> dict[str, type[pydantic.BaseModel]]:
24
+ """Recursively find all Pydantic models in a model's fields.
25
+
26
+ Args:
27
+ model: The Pydantic model to start from
28
+ models: Collected models keyed by model name
29
+ seen: Visited annotation objects keyed by identity
30
+
31
+ Returns:
32
+ Dictionary of model names to model classes
33
+ """
34
+ if models is None:
35
+ models = {}
36
+ if seen is None:
37
+ seen = set()
38
+
39
+ _collect_pydantic_models(model, models, seen)
40
+ return models
41
+
42
+
43
+ def _collect_pydantic_models(
44
+ annotation: object,
45
+ models: dict[str, type[pydantic.BaseModel]],
46
+ seen: set[int],
47
+ ) -> None:
48
+ annotation_id = id(annotation)
49
+ if annotation_id in seen:
50
+ return
51
+ seen.add(annotation_id)
52
+
53
+ if isinstance(annotation, type) and issubclass(annotation, pydantic.BaseModel):
54
+ models[annotation.__name__] = annotation
55
+ for field in annotation.model_fields.values():
56
+ _collect_pydantic_models(field.annotation, models, seen)
57
+ return
58
+
59
+ # We check if this is a parameterized type and recurse if so (eg Union[int, str])
60
+ # (Union is the origin in that case.)
61
+ origin = get_origin(annotation)
62
+ if origin is None:
63
+ return
64
+
65
+ for arg in get_args(annotation):
66
+ _collect_pydantic_models(arg, models, seen)
67
+
68
+
69
+ def generate_schemas(
70
+ output_dir: Path,
71
+ models: dict[str, type[pydantic.BaseModel]],
72
+ ) -> None:
73
+ """Generate JSON schemas for all Pydantic models and save them to output_dir.
74
+
75
+ Args:
76
+ output_dir: Directory to save the generated schemas
77
+ models: Dictionary of model names to model classes
78
+ """
79
+ output_dir.mkdir(parents=True, exist_ok=True)
80
+
81
+ for model_name, model in models.items():
82
+ schema = model.model_json_schema()
83
+ output_path = output_dir / f"{model_name}.json"
84
+
85
+ # Check if file exists and content is different
86
+ if output_path.exists():
87
+ with open(output_path) as f:
88
+ existing_schema = json.load(f)
89
+ if existing_schema == schema:
90
+ continue
91
+
92
+ # Write only if content differs or file doesn't exist
93
+ with open(output_path, "w") as f:
94
+ json.dump(schema, f, indent=2)
95
+ print(f"🆕 Updated schema for {model_name} at {output_path}")
96
+
97
+
98
+ def validate_config_files(
99
+ config_dir: Path,
100
+ models: dict[str, type[pydantic.BaseModel]],
101
+ ) -> bool:
102
+ """Validate YAML configuration files against their Pydantic models.
103
+
104
+ Args:
105
+ config_dir: Directory containing YAML configuration files to validate
106
+ models: Dictionary of model names to model classes
107
+ """
108
+ valid = True
109
+
110
+ # Find all YAML files in the config directory
111
+ yaml_files = list(config_dir.rglob("*.yaml"))
112
+
113
+ for yaml_file in yaml_files:
114
+ # Read the YAML file
115
+ with open(yaml_file) as f:
116
+ yaml_content = f.read()
117
+
118
+ # Extract the schema path from the YAML file's first line
119
+ schema_match = re.search(
120
+ r"\s*#\s+yaml-language-server:\s+\$schema=(.*\.json)", yaml_content
121
+ )
122
+ if not schema_match:
123
+ print(f"⚠️ No schema specified in {yaml_file}")
124
+ continue
125
+
126
+ schema_name = Path(schema_match.group(1)).stem
127
+ if schema_name not in models:
128
+ print(f"✗ Model {schema_name} not found for {yaml_file}")
129
+ valid = False
130
+ continue
131
+
132
+ # Load and validate the YAML content
133
+ try:
134
+ with open(yaml_file) as f:
135
+ config = yaml.safe_load(f)
136
+ # Validate using Pydantic model
137
+ models[schema_name].model_validate(config)
138
+ print(f"✅ {yaml_file} is a valid {schema_name}")
139
+ except Exception as e:
140
+ print(f"❌ {yaml_file} is an invalid {schema_name}: {str(e)}")
141
+ valid = False
142
+
143
+ return valid
144
+
145
+
146
+ def main():
147
+ parser = argparse.ArgumentParser(
148
+ description="Generate JSON schemas and validate config files"
149
+ )
150
+ parser.add_argument(
151
+ "--output-dir",
152
+ type=Path,
153
+ default=Path("src/samudra/configs/schemas"),
154
+ help="Directory to save the generated schemas",
155
+ )
156
+ parser.add_argument(
157
+ "--validate-dir",
158
+ type=Path,
159
+ default=Path("src/samudra/configs"),
160
+ help="Directory of config files to validate",
161
+ )
162
+
163
+ args = parser.parse_args()
164
+
165
+ # Get all available models
166
+ models = get_pydantic_models(TrainConfig)
167
+ models.update(get_pydantic_models(EvalConfig))
168
+ models.update(get_pydantic_models(VizConfig))
169
+
170
+ generate_schemas(args.output_dir, models)
171
+
172
+ if not validate_config_files(args.validate_dir, models):
173
+ sys.exit(1)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ main()
samudra/configs/data/llc.yaml ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Ocean Emulator Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/DataConfig.json
6
+
7
+ sources:
8
+ - type: llc
9
+ face: 1
10
+ i_start: 0
11
+ i_end: 720
12
+ j_start: 0
13
+ j_end: 720
14
+ train_time:
15
+ start: "2011-09-10T12:00:00Z"
16
+ end: "2012-09-01T12:00:00Z"
17
+ val_time:
18
+ start: "2012-09-01T12:00:00Z"
19
+ end: "2012-11-15T12:00:00Z"
20
+ data_location: LLC.zarr
21
+ data_means_location: LLC_means.zarr
22
+ data_stds_location: LLC_stds.zarr
23
+ concurrent_compute: false
24
+ loading:
25
+ type: cpu
26
+ num_workers: 4
samudra/configs/data/obs.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # Observation-based evaluation metrics, scored once the rollout is on disk.
6
+ # Including this file is what enables them; omit it to skip the phase.
7
+ #
8
+ # The three products are published anonymously readable, so an eval run needs
9
+ # neither credentials nor a staging step to produce these metrics. They were
10
+ # built from the providers by `python -m ocean_preprocessing.obs_preprocessing`
11
+ # (see data/README.md), and each store records its own source, attribution and
12
+ # provenance in its zarr attrs.
13
+ #
14
+ # To read a locally staged copy instead -- worth it on repeat runs, since DUACS
15
+ # alone is 46 GB -- set OSN_OBS_PATH in the skypilot eval job and override the
16
+ # locations to the relative paths `obs/duacs.zarr` and so on, which resolve
17
+ # against experiment.data_root.
18
+
19
+ duacs_location: &obs_s3
20
+ type: s3
21
+ bucket: m2lines-pubs
22
+ path: Samudra/v2026-07/obs/duacs.zarr
23
+ endpoint_url: https://nyu1.osn.mghpcc.org
24
+ anon: true
25
+ oisst_location:
26
+ <<: *obs_s3
27
+ path: Samudra/v2026-07/obs/oisst.zarr
28
+ argo_iap_location:
29
+ <<: *obs_s3
30
+ path: Samudra/v2026-07/obs/argo-iap.zarr
31
+
32
+ # Eight complete calendar years, so the score and its block bootstrap share
33
+ # equal-weight blocks.
34
+ rmse_start: "2015-01-01"
35
+ rmse_end: "2022-12-31"
samudra/configs/data/om4.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/DataConfig.json
6
+
7
+ sources:
8
+ - type: om4
9
+ prognostic_vars_key: thermo_dynamic_all
10
+ boundary_vars_key: tau_hfds_hfds_anom
11
+ train_time:
12
+ start: "1975-01-03"
13
+ end: "2013-10-05"
14
+ val_time:
15
+ start: "2013-10-05"
16
+ end: "2014-10-05"
17
+ inference_times:
18
+ - start: "2014-10-10"
19
+ end: "2022-12-24"
20
+ data_location: OM4.zarr
21
+ data_means_location: OM4_means.zarr
22
+ data_stds_location: OM4_stds.zarr
23
+ concurrent_compute: true
24
+ loading:
25
+ type: cpu
samudra/configs/data/om4_demo.yaml ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/DataConfig.json
6
+
7
+ # Two-degree (2°) OM4 demo data source that streams straight from the public
8
+ # m2lines-pubs bucket on OSN over S3 — no local download or credentials needed
9
+ # (anon: true reads the public bucket anonymously). The locations below are
10
+ # absolute (type: s3), so they are read as-is and `experiment.data_root` is
11
+ # unused — you can omit it entirely.
12
+
13
+ sources:
14
+ - type: om4
15
+ prognostic_vars_key: thermo_dynamic_all
16
+ # tau_hfds (tauuo, tauvo, hfds) rather than the samudra_om4 presets'
17
+ # tau_hfds_hfds_anom: the public 2deg data has no `hfds_anomalies` variable.
18
+ # This is a self-contained train -> eval -> viz demo (train a fresh model on
19
+ # this data); its boundary channels differ from the released checkpoints, so
20
+ # don't eval a preset-trained checkpoint against this source.
21
+ boundary_vars_key: tau_hfds
22
+ train_time:
23
+ start: "1975-01-03"
24
+ end: "2013-10-05"
25
+ val_time:
26
+ start: "2013-10-05"
27
+ end: "2014-10-05"
28
+ inference_times:
29
+ - start: "2014-10-10"
30
+ end: "2022-12-24"
31
+ data_location:
32
+ type: s3
33
+ endpoint_url: "https://nyu1.osn.mghpcc.org"
34
+ anon: true
35
+ bucket: m2lines-pubs
36
+ path: Samudra/v2026-07/om4_twodeg/OM4.zarr
37
+ data_means_location:
38
+ type: s3
39
+ endpoint_url: "https://nyu1.osn.mghpcc.org"
40
+ anon: true
41
+ bucket: m2lines-pubs
42
+ path: Samudra/v2026-07/om4_twodeg/OM4_means.zarr
43
+ data_stds_location:
44
+ type: s3
45
+ endpoint_url: "https://nyu1.osn.mghpcc.org"
46
+ anon: true
47
+ bucket: m2lines-pubs
48
+ path: Samudra/v2026-07/om4_twodeg/OM4_stds.zarr
49
+ concurrent_compute: true
50
+ loading:
51
+ type: cpu
samudra/configs/samudra_mini_om4/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Samudra Authors
3
+
4
+ SPDX-License-Identifier: CC-BY-4.0
5
+ -->
6
+
7
+ # SamudraMini OM4
8
+
9
+ These configurations are for SamudraMini, a single PerceiverIO model that treats
10
+ each `(lat, lon)` point as a token and uses learned embeddings of 3D Cartesian
11
+ coordinates for both data tokens and output queries.
samudra/configs/samudra_mini_om4/model.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/SamudraMiniConfig.json
6
+
7
+ pred_residuals: false
8
+ last_kernel_size: 3
9
+ pad: "circular"
10
+ checkpointing: "all"
11
+ perceiver_implementation: "auto"
12
+ embedding_dim: 128
13
+ queries_dim: 128
14
+ coordinate_embedding_dim: 64
15
+ query_chunk_size: 8192
16
+
17
+ perceiver:
18
+ depth: 4
19
+ latent_dim: 96
20
+ num_latents: 256
samudra/configs/samudra_mini_om4/train.yaml ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/TrainConfig.json
6
+
7
+ debug: false
8
+ disk_mode: true
9
+ pin_mem: true
10
+ save_freq: 5
11
+ epochs: 70
12
+ batch_size: 2
13
+ learning_rate: 0.0006
14
+ scheduler: { type: cosine }
15
+ loss:
16
+ type: dynamic
17
+ metric: mse
18
+ finetune: false
19
+ resume_ckpt_path: null
20
+ inference_epochs: []
21
+ data_stride: [1]
22
+ steps: [4]
23
+ step_transition: []
24
+ preemptible: false
25
+
26
+ backend: auto
27
+
28
+ experiment:
29
+ name: samudra_mini_om4
30
+ rand_seed: 15
31
+ base_output_dir: .LOCAL
32
+ wandb:
33
+ mode: disabled
34
+ project: default
35
+ data: !include ../data/om4.yaml
36
+ model: !include model.yaml
samudra/configs/samudra_multi_om4/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Samudra Authors
3
+
4
+ SPDX-License-Identifier: CC-BY-4.0
5
+ -->
6
+
7
+ # samudra-multi OM4
8
+
9
+ These configurations are for the samudra-multi (encoder-processor-decoder) model,
10
+ trained on OM4 data. The intent is for this to support heterogeneous
11
+ resolutions and modalities.
samudra/configs/samudra_multi_om4/eval.yaml ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/EvalConfig.json
6
+ #
7
+ # Eval config for the multi-scale samudra-multi model.
8
+ #
9
+ # The eval code runs inference on the *first* data source only.
10
+ # To evaluate all three scales, run three separate eval jobs and
11
+ # reorder the sources list so the target resolution is first.
12
+ # All three sources must always be present so the model builds
13
+ # with the correct max grid size for the encoder patch grid.
14
+ # The max grid size affects the rotary positional embedding
15
+ # dimension for the encoder's Perceiver. If it is set wrong,
16
+ # it would likely cause hard-to-debug model level errors.
17
+ # Setting multiple data sources lets us use checkpoints for
18
+ # samudra-multi models that use multiple scales.
19
+
20
+ debug: false
21
+ save_zarr: true
22
+ disk_mode: true
23
+ num_model_steps_forward: 25
24
+
25
+ experiment:
26
+ name: samudra_multi_om4_eval
27
+ rand_seed: 15
28
+ base_output_dir: .LOCAL
29
+ wandb:
30
+ mode: disabled
31
+ project: default
32
+
33
+ data:
34
+ concurrent_compute: true
35
+ sources:
36
+ # First source is used for inference. Reorder to eval a different scale.
37
+ - type: om4
38
+ prognostic_vars_key: thermo_dynamic_all
39
+ boundary_vars_key: tau_hfds
40
+ train_time: &train_time
41
+ start: "1975-01-03"
42
+ end: "2013-10-05"
43
+ val_time: &val_time
44
+ start: "2013-10-05"
45
+ end: "2014-10-05"
46
+ inference_times: &inference_times
47
+ - start: "2014-10-05"
48
+ end: "2015-10-05"
49
+ data_location: om4_halfdeg_v4/OM4.zarr
50
+ data_means_location: om4_halfdeg_v4/OM4_means.zarr
51
+ data_stds_location: om4_halfdeg_v4/OM4_stds.zarr
52
+ - type: om4
53
+ prognostic_vars_key: thermo_dynamic_all
54
+ boundary_vars_key: tau_hfds
55
+ train_time: *train_time
56
+ val_time: *val_time
57
+ inference_times: *inference_times
58
+ data_location: om4_onedeg_v3/OM4.zarr
59
+ data_means_location: om4_onedeg_v3/OM4_means.zarr
60
+ data_stds_location: om4_onedeg_v3/OM4_stds.zarr
61
+ - type: om4
62
+ prognostic_vars_key: thermo_dynamic_all
63
+ boundary_vars_key: tau_hfds
64
+ train_time: *train_time
65
+ val_time: *val_time
66
+ inference_times: *inference_times
67
+ data_location: om4_quarterdeg_v2/OM4.zarr
68
+ data_means_location: om4_quarterdeg_v2/OM4_means.zarr
69
+ data_stds_location: om4_quarterdeg_v2/OM4_stds.zarr
70
+
71
+ model: !include model.yaml
samudra/configs/samudra_multi_om4/model.yaml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/SamudraMultiConfig.json
6
+
7
+ pred_residuals: false
8
+ last_kernel_size: 3
9
+ pad: "circular"
10
+ checkpointing: "all"
11
+ embedding_dim: 128 # Reduced from 280
12
+ perceiver_implementation: "auto"
13
+ # TODO(alxmrs): How fine of a resolution should each patch be? This produces a 60x72 latent grid.
14
+ patch_extent: [3.0, 5.0]
15
+
16
+ encoder:
17
+ perceiver:
18
+ depth: 2
19
+ latent_dim: 64
20
+ num_latents: 256
21
+
22
+ decoder:
23
+ perceiver:
24
+ depth: 2
25
+ latent_dim: 64
26
+ num_latents: 256
27
+ queries_dim: 64 # on the small end, but typical for PercieverIO models (usually in the ~100s-1000s).
28
+ window_patches: 12 # Must evenly divide latent grid (60x72). Enables real spatial windowing.
29
+ context_patches: 1 # Look at this many window_patches around the target patch.
30
+
31
+ processor:
32
+ ch_width: [380, 480, 520]
33
+ dilation: [1, 1, 1]
34
+ n_layers: [1, 1, 1]
35
+
36
+ core_block:
37
+ block_type: "conv_next_block"
38
+ kernel_size: 3
39
+ activation: "capped_gelu"
40
+ upscale_factor: 2
41
+ norm: "instance"
42
+ pointwise_linear: true
43
+
44
+ down_sampling_block: "avg_pool"
45
+ up_sampling_block: "zonally_periodic_upsample"
samudra/configs/samudra_multi_om4/train.yaml ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/TrainConfig.json
6
+
7
+ debug: false
8
+ disk_mode: true
9
+ pin_mem: true
10
+ save_freq: 5
11
+ epochs: 70
12
+ batch_size: 2
13
+ learning_rate: 0.0006
14
+ scheduler: { type: cosine }
15
+ loss:
16
+ type: dynamic
17
+ metric: mse
18
+ limit: 20
19
+ finetune: false
20
+ resume_ckpt_path: null
21
+ inference_epochs: []
22
+ data_stride: [1]
23
+ steps: [4]
24
+ step_transition: []
25
+ preemptible: false
26
+
27
+ backend: auto
28
+
29
+ experiment:
30
+ name: samudra_multi_om4
31
+ rand_seed: 15
32
+ base_output_dir: .LOCAL
33
+ wandb:
34
+ mode: disabled
35
+ project: default
36
+ data:
37
+ concurrent_compute: true
38
+ loading:
39
+ type: cpu
40
+ num_workers: 4
41
+ sources:
42
+ - type: om4
43
+ prognostic_vars_key: thermo_dynamic_all
44
+ boundary_vars_key: tau_hfds
45
+ train_time: &train_time
46
+ start: "1975-01-03"
47
+ end: "2013-10-05"
48
+ val_time: &val_time
49
+ start: "2013-10-05"
50
+ end: "2014-10-05"
51
+ inference_times: &inference_times
52
+ - start: "2014-10-05"
53
+ end: "2015-10-05"
54
+ data_location: om4_quarterdeg_v2/OM4.zarr
55
+ data_means_location: om4_quarterdeg_v2/OM4_means.zarr
56
+ data_stds_location: om4_quarterdeg_v2/OM4_stds.zarr
57
+ - type: om4
58
+ prognostic_vars_key: thermo_dynamic_all
59
+ boundary_vars_key: tau_hfds
60
+ train_time: *train_time
61
+ val_time: *val_time
62
+ inference_times: *inference_times
63
+ data_location: om4_halfdeg_v4/OM4.zarr
64
+ data_means_location: om4_halfdeg_v4/OM4_means.zarr
65
+ data_stds_location: om4_halfdeg_v4/OM4_stds.zarr
66
+ - type: om4
67
+ prognostic_vars_key: thermo_dynamic_all
68
+ boundary_vars_key: tau_hfds
69
+ train_time: *train_time
70
+ val_time: *val_time
71
+ inference_times: *inference_times
72
+ data_location: om4_onedeg_v3/OM4.zarr
73
+ data_means_location: om4_onedeg_v3/OM4_means.zarr
74
+ data_stds_location: om4_onedeg_v3/OM4_stds.zarr
75
+ model: !include model.yaml
samudra/configs/samudra_om4/README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Samudra Authors
3
+
4
+ SPDX-License-Identifier: CC-BY-4.0
5
+ -->
6
+
7
+ # Samudra OM4
8
+
9
+ These configurations are our current WIP Samudra-style (UNet) model,
10
+ trained on OM4 data.
samudra/configs/samudra_om4/eval.yaml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/EvalConfig.json
6
+
7
+ debug: false
8
+ save_zarr: true
9
+ disk_mode: true
10
+ num_model_steps_forward: 25
11
+
12
+ experiment:
13
+ name: samudra_om4_eval
14
+ rand_seed: 15
15
+ base_output_dir: .LOCAL
16
+ wandb:
17
+ mode: disabled
18
+ project: default
19
+ data: !include ../data/om4.yaml
20
+ model: !include model.yaml
21
+ observations: !include ../data/obs.yaml
samudra/configs/samudra_om4/model.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/SamudraConfig.json
6
+
7
+ checkpointing: all
8
+ pred_residuals: false
9
+ last_kernel_size: 3
10
+ pad: "circular"
11
+
12
+ unet:
13
+ ch_width: [280, 380, 480, 520]
14
+ dilation: [1, 2, 4, 8]
15
+ n_layers: [1, 1, 1, 1]
16
+
17
+ core_block:
18
+ block_type: "conv_next_block"
19
+ kernel_size: 3
20
+ activation: "capped_gelu"
21
+ upscale_factor: 2
22
+ norm: "batch"
23
+
24
+ down_sampling_block: "avg_pool"
25
+ up_sampling_block: "zonally_periodic_upsample"
samudra/configs/samudra_om4/train.yaml ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/TrainConfig.json
6
+
7
+ debug: false
8
+ disk_mode: true
9
+ pin_mem: true
10
+ save_freq: 5
11
+ epochs: 70
12
+ batch_size: 4
13
+ learning_rate: 0.0006
14
+ gradient_accumulation_steps: 1
15
+ scheduler: { type: cosine }
16
+ loss:
17
+ type: dynamic
18
+ metric: mse
19
+ finetune: false
20
+ resume_ckpt_path: null
21
+ inference_epochs: []
22
+ data_stride: [1]
23
+ steps: [4]
24
+ step_transition: []
25
+ preemptible: false
26
+
27
+ backend: auto
28
+
29
+ experiment:
30
+ name: samudra_om4
31
+ rand_seed: 15
32
+ base_output_dir: .LOCAL
33
+ wandb:
34
+ mode: disabled
35
+ project: default
36
+ data: !include ../data/om4.yaml
37
+ model: !include model.yaml
samudra/configs/samudra_om4/viz.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/VizConfig.json
6
+
7
+ base_output_dir: ".LOCAL"
8
+ name: "samudra_om4_viz"
9
+ dataset_name: "OM4"
10
+ # Ground truth comes from this data source (same shape as train/eval), so
11
+ # `--data @data/om4_demo.yaml` points viz at the demo data just like the others.
12
+ data: !include ../data/om4.yaml
13
+ basins_location:
14
+ type: s3
15
+ anon: true
16
+ bucket: "m2lines-pubs"
17
+ path: "Samudra/basins/basin_masks_original.zarr"
18
+ endpoint_url: "https://nyu1.osn.mghpcc.org"
19
+ groundtruth_time_range:
20
+ start: "2014-10-20"
21
+ end: "2022-12-24"
22
+ runs:
23
+ - name: "samudra_om4_eval"
24
+ location: ".LOCAL/samudra_om4_eval/predictions.zarr"
25
+ observations: !include ../data/obs.yaml
samudra/configs/samudra_om4_v1/README.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Samudra Authors
3
+
4
+ SPDX-License-Identifier: CC-BY-4.0
5
+ -->
6
+
7
+ # Samudra OM4 V1
8
+
9
+ These configurations are our replication of the [original Samudra model](https://arxiv.org/abs/2412.03795)
10
+ trained on OM4 data.
samudra/configs/samudra_om4_v1/eval.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/EvalConfig.json
6
+
7
+ debug: false
8
+ save_zarr: true
9
+ disk_mode: true
10
+ num_model_steps_forward: 25
11
+
12
+ experiment:
13
+ name: samudra_om4_v1_eval
14
+ rand_seed: 15
15
+ base_output_dir: .LOCAL
16
+ wandb:
17
+ mode: disabled
18
+ project: default
19
+ data: !include ../data/om4.yaml
20
+ model: !include model.yaml
samudra/configs/samudra_om4_v1/model.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/SamudraConfig.json
6
+
7
+ pred_residuals: false
8
+ last_kernel_size: 3
9
+ pad: "circular"
10
+ checkpointing: "all"
11
+
12
+ unet:
13
+ ch_width: [200, 250, 300, 400]
14
+ dilation: [1, 2, 4, 8]
15
+ n_layers: [1, 1, 1, 1]
16
+
17
+ core_block:
18
+ block_type: "conv_next_block"
19
+ kernel_size: 3
20
+ activation: "capped_gelu"
21
+ upscale_factor: 4
22
+ norm: "batch"
23
+
24
+ down_sampling_block: "avg_pool"
25
+ up_sampling_block: "bilinear_upsample"
samudra/configs/samudra_om4_v1/train.yaml ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/TrainConfig.json
6
+
7
+ debug: false
8
+ disk_mode: true
9
+ pin_mem: true
10
+ save_freq: 5
11
+ epochs: 70
12
+ batch_size: 4
13
+ learning_rate: 0.0006
14
+ scheduler: { type: cosine }
15
+ loss: mse
16
+ finetune: false
17
+ resume_ckpt_path: null
18
+ inference_epochs: []
19
+ data_stride: [1]
20
+ steps: [4]
21
+ step_transition: []
22
+ preemptible: false
23
+
24
+ backend: auto
25
+
26
+ experiment:
27
+ name: samudra_om4_v1
28
+ rand_seed: 15
29
+ base_output_dir: .LOCAL
30
+ wandb:
31
+ mode: disabled
32
+ project: default
33
+ data: !include ../data/om4.yaml
34
+ model: !include model.yaml
samudra/configs/samudra_om4_v1/viz.yaml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/VizConfig.json
6
+
7
+ base_output_dir: ".LOCAL"
8
+ name: "samudra_om4_v1_viz"
9
+ dataset_name: "OM4"
10
+ groundtruth_location: "OM4.zarr"
11
+ basins_location:
12
+ type: s3
13
+ anon: true
14
+ bucket: "m2lines-pubs"
15
+ path: "Samudra/basins/basin_masks_original.zarr"
16
+ endpoint_url: "https://nyu1.osn.mghpcc.org"
17
+ groundtruth_time_range:
18
+ start: "2014-10-20"
19
+ end: "2022-12-24"
20
+ runs:
21
+ - name: "samudra_om4_v1_eval"
22
+ location: ".LOCAL/samudra_om4_v1_eval/predictions.zarr"
samudra/configs/samudra_om4_v2/README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Samudra Authors
3
+
4
+ SPDX-License-Identifier: CC-BY-4.0
5
+ -->
6
+
7
+ # Samudra OM4 V2
8
+
9
+ These configurations are the v2 version of Samudra-style (UNet) model,
10
+ trained on OM4 data. Major changes from the v1 model are that it is
11
+ wider and uses a dynamically-weighted loss.
samudra/configs/samudra_om4_v2/eval.yaml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/EvalConfig.json
6
+
7
+ debug: false
8
+ save_zarr: true
9
+ disk_mode: true
10
+ num_model_steps_forward: 25
11
+
12
+ experiment:
13
+ name: samudra_om4_v2_eval
14
+ rand_seed: 15
15
+ base_output_dir: .LOCAL
16
+ wandb:
17
+ mode: disabled
18
+ project: default
19
+ data: !include ../data/om4.yaml
20
+ model: !include model.yaml
samudra/configs/samudra_om4_v2/model.yaml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2026 Samudra Authors
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ # yaml-language-server: $schema=../schemas/SamudraConfig.json
6
+
7
+ checkpointing: all
8
+ pred_residuals: false
9
+ last_kernel_size: 3
10
+ pad: "circular"
11
+
12
+ unet:
13
+ ch_width: [280, 380, 480, 520]
14
+ dilation: [1, 2, 4, 8]
15
+ n_layers: [1, 1, 1, 1]
16
+
17
+ core_block:
18
+ block_type: "conv_next_block"
19
+ kernel_size: 3
20
+ activation: "capped_gelu"
21
+ upscale_factor: 2
22
+ norm: "batch"
23
+
24
+ down_sampling_block: "avg_pool"
25
+ up_sampling_block: "zonally_periodic_upsample"