Emma Scharfmann commited on
Commit
80f643f
·
1 Parent(s): 9ab6408

add aifs wrapper

Browse files
AIFS-tutorial DELETED
@@ -1 +0,0 @@
1
- Subproject commit 00f678c38f3d4222cf0e7cf0835732a754e02237
 
 
__init__.py ADDED
File without changes
aifs/__init__.py ADDED
File without changes
aifs/compat.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Import this module *before* importing anything from ``anemoi`` to allow the compatibility with available GPU / CPU
3
+ """
4
+
5
+ import sys
6
+ import time
7
+ import types
8
+
9
+ import torch
10
+ import torch.nn.functional as F
11
+
12
+
13
+ # ── SDPA-based attention replacement ─────────────────────────────────────────
14
+
15
+ def _sdpa_compat(q, k, v, causal=False, window_size=(-1, -1), dropout_p=0.0, softcap=None, alibi_slopes=None):
16
+ """
17
+ Drop-in replacement for ``flash_attn_func``.
18
+
19
+ Parameters mirror the flash-attn 2.x signature that Anemoi calls.
20
+ Input tensors are shaped ``(batch, seq, heads, dim)``.
21
+ """
22
+ t0 = time.perf_counter()
23
+
24
+ # flash-attn layout: (B, S, H, D) → SDPA layout: (B, H, S, D)
25
+ q, k, v = (t.permute(0, 2, 1, 3) for t in (q, k, v))
26
+ ws = window_size[0] if isinstance(window_size, (tuple, list)) else int(window_size)
27
+
28
+ if q.device.type == "cuda":
29
+ # Full global attention; SDPA dispatches to flash-attn kernel when available
30
+ out = F.scaled_dot_product_attention(q, k, v, dropout_p=dropout_p)
31
+
32
+ elif ws > 0:
33
+ # MPS: chunked sliding-window attention (avoids OOM on large sequences)
34
+ B, H, S, D = q.shape
35
+ out = torch.zeros_like(q)
36
+ for i in range(0, S, ws):
37
+ k_start = max(0, i - ws)
38
+ k_end = min(S, i + ws + ws)
39
+ out[:, :, i : i + ws] = F.scaled_dot_product_attention(
40
+ q[:, :, i : i + ws],
41
+ k[:, :, k_start:k_end],
42
+ v[:, :, k_start:k_end],
43
+ dropout_p=dropout_p,
44
+ )
45
+
46
+ else:
47
+ # CPU fallback — move to CPU in case tensors are on an unsupported device
48
+ out = F.scaled_dot_product_attention(
49
+ q.cpu(), k.cpu(), v.cpu(), dropout_p=dropout_p
50
+ ).to(q.device)
51
+
52
+ elapsed = time.perf_counter() - t0
53
+ print(f" [compat] attn {elapsed:.2f}s device={q.device.type} ws={ws}")
54
+
55
+ return out.permute(0, 2, 1, 3)
56
+
57
+
58
+ # ── Build stub modules ────────────────────────────────────────────────────────
59
+
60
+ def _patch():
61
+ """Install the flash_attn stub into ``sys.modules``."""
62
+ if "flash_attn" in sys.modules:
63
+ return
64
+
65
+ flash_attn = types.ModuleType("flash_attn")
66
+ flash_attn.__version__ = "2.6.0" # version Anemoi checks against
67
+
68
+ # flash_attn.layers.rotary (imported but only used on specific GPU paths)
69
+ layers_mod = types.ModuleType("flash_attn.layers")
70
+ rotary_mod = types.ModuleType("flash_attn.layers.rotary")
71
+ rotary_mod.RotaryEmbedding = None
72
+ layers_mod.rotary = rotary_mod
73
+ flash_attn.layers = layers_mod
74
+
75
+ # flash_attn.flash_attn_interface (the one Anemoi actually calls)
76
+ interface_mod = types.ModuleType("flash_attn.flash_attn_interface")
77
+ interface_mod.flash_attn_func = _sdpa_compat
78
+ flash_attn.flash_attn_interface = interface_mod
79
+
80
+ sys.modules["flash_attn"] = flash_attn
81
+ sys.modules["flash_attn.layers"] = layers_mod
82
+ sys.modules["flash_attn.layers.rotary"] = rotary_mod
83
+ sys.modules["flash_attn.flash_attn_interface"] = interface_mod
84
+
85
+
86
+ _patch()
aifs/device.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import ssl
3
+
4
+ import certifi
5
+ import torch
6
+
7
+
8
+ def configure_ssl():
9
+ """Point OpenSSL to the certifi bundle (needed on some macOS setups)."""
10
+ os.environ.setdefault("SSL_CERT_FILE", certifi.where())
11
+ os.environ.setdefault("REQUESTS_CA_BUNDLE", certifi.where())
12
+ ssl._create_default_https_context = ssl.create_default_context
13
+
14
+
15
+ def get_device() -> str:
16
+ """Return ``'cuda'``, ``'mps'``, or ``'cpu'`` in priority order."""
17
+ if torch.cuda.is_available():
18
+ return "cuda"
19
+ if torch.backends.mps.is_available():
20
+ return "mps"
21
+ return "cpu"
22
+
23
+
24
+ def device_label() -> str:
25
+ """Human-readable description of the active compute device."""
26
+ device = get_device()
27
+ if device == "cuda":
28
+ return f"CUDA — {torch.cuda.get_device_name(0)}"
29
+ if device == "mps":
30
+ return "Apple MPS (Metal)"
31
+ return "CPU (no GPU detected — inference will be slow)"
aifs/forecast.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import datetime
3
+ from typing import Generator
4
+
5
+ # Apply flash-attn shim before any anemoi import
6
+ import aifs.compat # noqa: F401
7
+ from aifs.device import get_device, device_label
8
+
9
+ DEFAULT_CHECKPOINT = "aifs-single-2.0"
10
+
11
+ CHECKPOINTS = {
12
+ DEFAULT_CHECKPOINT: {"huggingface": f"ecmwf/{DEFAULT_CHECKPOINT}"},
13
+ }
14
+
15
+ def run_forecast(
16
+ fields: dict,
17
+ date: datetime.datetime,
18
+ lead_time: int = 24,
19
+ num_chunks: int = 16,
20
+ checkpoint: str = DEFAULT_CHECKPOINT,
21
+ verbose: bool = True,
22
+ ) -> list[dict]:
23
+ """
24
+ Run an AIFS forecast and return all output states.
25
+
26
+ Parameters
27
+ ----------
28
+ fields:
29
+ Initial-condition field dict as returned by ``load_ics()``.
30
+ Shape of each array: ``(2, N320_nodes)``.
31
+ date:
32
+ Forecast initialisation datetime.
33
+ lead_time:
34
+ Forecast horizon in hours. Must be a multiple of 6.
35
+ num_chunks:
36
+ Number of chunks for the attention computation.
37
+ Increase if you run out of memory; decrease for speed.
38
+ 16 is a safe default for 16 GB RAM / VRAM.
39
+ checkpoint:
40
+ Key into ``CHECKPOINTS`` dict, or a raw ``{"huggingface": "..."}``
41
+ dict you can pass directly.
42
+ verbose:
43
+ Print step-by-step progress.
44
+
45
+ Returns
46
+ -------
47
+ list of state dicts, one per 6-hour output step.
48
+ Each state dict has at minimum:
49
+ ``state["date"]`` — output datetime
50
+ ``state["fields"]`` — ``{variable: np.ndarray}``
51
+ """
52
+ if lead_time % 6 != 0:
53
+ raise ValueError(f"lead_time must be a multiple of 6, got {lead_time}")
54
+
55
+ from anemoi.inference.runners.simple import SimpleRunner
56
+
57
+ device = get_device()
58
+
59
+ # Environment knobs consumed by anemoi-inference internals
60
+ if device == "cuda":
61
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
62
+ os.environ["ANEMOI_INFERENCE_NUM_CHUNKS"] = str(num_chunks)
63
+
64
+ if verbose:
65
+ print(f"🖥️ Device : {device_label()}")
66
+ print(f"📦 Checkpoint: {checkpoint}")
67
+ print(f"⏱️ Lead time : {lead_time} h ({lead_time // 6} steps)")
68
+
69
+ ckpt = CHECKPOINTS.get(checkpoint, checkpoint)
70
+
71
+ if verbose:
72
+ print("🤖 Loading model …")
73
+
74
+ runner = SimpleRunner(ckpt)
75
+
76
+ if verbose:
77
+ print(f"🌍 Running inference …")
78
+
79
+ states: list[dict] = []
80
+ input_state = {"fields": fields, "date": date}
81
+ for state in runner.run(input_states=input_state, lead_time=lead_time):
82
+ states.append(state)
83
+ if verbose:
84
+ print(f" ✓ {state['date']}")
85
+
86
+ if verbose:
87
+ print(f"✅ Done — {len(states)} steps produced.")
88
+
89
+ return states
90
+
91
+
92
+ def run_forecast_streaming(
93
+ fields: dict,
94
+ date: datetime.datetime,
95
+ lead_time: int = 24,
96
+ num_chunks: int = 16,
97
+ checkpoint: str = DEFAULT_CHECKPOINT,
98
+ ) -> Generator[dict, None, None]:
99
+ """
100
+ Generator variant of :func:`run_forecast`.
101
+
102
+ Yields each state dict as soon as it is computed, which is useful for
103
+ Gradio apps or notebooks that want to display results incrementally.
104
+
105
+ Example
106
+ -------
107
+ for state in run_forecast_streaming(fields, date, lead_time=48):
108
+ plot_field(state)
109
+ """
110
+ if lead_time % 6 != 0:
111
+ raise ValueError(f"lead_time must be a multiple of 6, got {lead_time}")
112
+
113
+ from anemoi.inference.runners.simple import SimpleRunner
114
+
115
+ device = get_device()
116
+ if device == "cuda":
117
+ os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"
118
+ os.environ["ANEMOI_INFERENCE_NUM_CHUNKS"] = str(num_chunks)
119
+
120
+ ckpt = CHECKPOINTS.get(checkpoint, checkpoint)
121
+ runner = SimpleRunner(ckpt)
122
+
123
+ input_state = {"fields": fields, "date": date}
124
+ for state in runner.run(input_states=input_state, lead_time=lead_time):
125
+ yield state
aifs/initial_conditions.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import datetime
2
+ import warnings
3
+ from collections import defaultdict
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+
8
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
9
+ warnings.filterwarnings("ignore", category=UserWarning)
10
+
11
+
12
+ # ── Meteorological variable lists ─────────────────────────────────────────────
13
+
14
+ #: Surface parameters (levtype=sfc)
15
+ PARAM_SFC = [
16
+ "10u", "10v", "2d", "2t", "msl", "skt", "sp",
17
+ "tcw", "lsm", "z", "slor", "sdor", "sd",
18
+ ]
19
+
20
+ #: Soil parameters (levtype=sfc, levelist=[1,2])
21
+ PARAM_SOIL = ["vsw", "sot"]
22
+ SOIL_LEVELS = [1, 2]
23
+
24
+ #: Ocean-wave parameters (stream=wave)
25
+ PARAM_WAVE = [
26
+ "wmb", "h1012", "h1214", "h1417", "h1721",
27
+ "h2125", "h2530", "mwd", "cdww", "mwp", "swh",
28
+ ]
29
+
30
+ #: Pressure-level parameters
31
+ PARAM_PL = ["gh", "t", "u", "v", "q"]
32
+ LEVELS = [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100, 50, 10]
33
+
34
+ SOURCE = "ecmwf"
35
+
36
+ # ── Cache helpers ─────────────────────────────────────────────────────────────
37
+
38
+ DEFAULT_CACHE_DIR = Path("ic_cache")
39
+
40
+
41
+ def _cache_path(date: datetime.datetime, cache_dir: Path) -> Path:
42
+ return cache_dir / f"ic_{date.strftime('%Y%m%dT%H%M%S')}.npz"
43
+
44
+
45
+ def _save(date: datetime.datetime, fields: dict, cache_dir: Path) -> Path:
46
+ cache_dir.mkdir(parents=True, exist_ok=True)
47
+ path = _cache_path(date, cache_dir)
48
+ np.savez_compressed(str(path), **fields)
49
+ return path
50
+
51
+
52
+ def _try_load(date: datetime.datetime, cache_dir: Path):
53
+ """Return ``(fields_dict, path)`` if cached, else ``(None, None)``."""
54
+ path = _cache_path(date, cache_dir)
55
+ if path.exists():
56
+ return dict(np.load(str(path))), path
57
+ return None, None
58
+
59
+
60
+ def list_cached(cache_dir: Path = DEFAULT_CACHE_DIR) -> list[Path]:
61
+ """Return all cached .npz files, newest first."""
62
+ if not cache_dir.exists():
63
+ return []
64
+ return sorted(cache_dir.glob("ic_*.npz"), reverse=True)
65
+
66
+
67
+ # ── Download helpers ──────────────────────────────────────────────────────────
68
+
69
+ def _fetch_fields(ekd, ekr, date, param, levelist=None, **kwargs) -> dict:
70
+ """
71
+ Download ``param`` for two time-steps (t-6h, t) and return a dict
72
+ ``{variable_name: np.ndarray shape (2, N320_nodes)}``.
73
+ """
74
+ levelist = levelist or []
75
+ raw: dict[str, list] = defaultdict(list)
76
+
77
+ for t in [date - datetime.timedelta(hours=6), date]:
78
+ dataset = ekd.from_source(
79
+ "ecmwf-open-data",
80
+ date=t,
81
+ param=param,
82
+ levelist=levelist,
83
+ source=SOURCE,
84
+ **kwargs,
85
+ )
86
+ for field in dataset:
87
+ assert field.to_numpy().shape == (721, 1440), (
88
+ f"Unexpected grid shape for {field.metadata('param')}: "
89
+ f"{field.to_numpy().shape}"
90
+ )
91
+ # Shift lon from [0,360) to [-180,180) then regrid to N320 Gaussian
92
+ values = np.roll(field.to_numpy(), -field.shape[1] // 2, axis=1)
93
+ values = ekr.interpolate(values, {"grid": (0.25, 0.25)}, {"grid": "N320"})
94
+
95
+ if levelist:
96
+ name = f"{field.metadata('param')}_{field.metadata('levelist')}"
97
+ else:
98
+ name = field.metadata("param")
99
+ raw[name].append(values)
100
+
101
+ return {k: np.stack(v) for k, v in raw.items()}
102
+
103
+
104
+ def _build_fields(ekd, ekr, date: datetime.datetime) -> dict:
105
+ """Download and transform all required fields for ``date``."""
106
+ fields: dict = {}
107
+
108
+ print(" ⬇ Surface fields …")
109
+ fields.update(_fetch_fields(ekd, ekr, date, PARAM_SFC, levtype="sfc"))
110
+
111
+ print(" ⬇ Wave fields …")
112
+ fields.update(_fetch_fields(ekd, ekr, date, PARAM_WAVE, stream="wave"))
113
+
114
+ print(" ⬇ Soil fields …")
115
+ soil = _fetch_fields(ekd, ekr, date, PARAM_SOIL, levelist=SOIL_LEVELS)
116
+
117
+ print(" ⬇ Pressure-level fields …")
118
+ fields.update(_fetch_fields(ekd, ekr, date, PARAM_PL, levelist=LEVELS))
119
+
120
+ # ── Transformations ───────────────────────────────────────────────────────
121
+
122
+ # Wave direction: decompose scalar angle into sin/cos components
123
+ mwd = fields.pop("mwd")
124
+ mwd_rad = np.deg2rad(mwd)
125
+ fields["cos_mwd"] = np.cos(mwd_rad)
126
+ fields["sin_mwd"] = np.sin(mwd_rad)
127
+
128
+ # Rename soil fields to ECMWF short-names expected by AIFS
129
+ _soil_rename = {
130
+ "sot_1": "stl1", "sot_2": "stl2",
131
+ "vsw_1": "swvl1", "vsw_2": "swvl2",
132
+ }
133
+ for src, dst in _soil_rename.items():
134
+ fields[dst] = soil[src]
135
+
136
+ # Remove q levels that AIFS does not use
137
+ fields.pop("q_10", None)
138
+ fields.pop("q_50", None)
139
+
140
+ # Apply land-sea mask to snow depth and soil moisture (ocean → NaN)
141
+ try:
142
+ lsm = ekd.from_source("file", "lsm.grib")[0].to_numpy(flatten=True)
143
+ ocean_mask = np.equal(lsm, 0)
144
+ for var in ("sd", "swvl1", "swvl2"):
145
+ if var in fields:
146
+ fields[var][:, ocean_mask] = np.nan
147
+ except Exception:
148
+ pass # lsm.grib not found; skip masking
149
+
150
+ # Convert geopotential height → geopotential (Z = gh × g)
151
+ G = 9.80665
152
+ for level in LEVELS:
153
+ gh = fields.pop(f"gh_{level}", None)
154
+ if gh is not None:
155
+ fields[f"z_{level}"] = gh * G
156
+
157
+ return fields
158
+
159
+
160
+ # ── Public API ────────────────────────────────────────────────────────────────
161
+
162
+ def load_ics(
163
+ cache_dir: Path | str = DEFAULT_CACHE_DIR,
164
+ force: bool = False,
165
+ ) -> tuple[dict, datetime.datetime]:
166
+ """
167
+ Return ``(fields, date)`` for the latest available ECMWF Open Data run.
168
+
169
+ Parameters
170
+ ----------
171
+ cache_dir:
172
+ Directory where .npz caches are stored.
173
+ force:
174
+ Re-download even when a local cache exists.
175
+
176
+ Returns
177
+ -------
178
+ fields:
179
+ ``{variable_name: np.ndarray shape (2, N320_nodes)}``.
180
+ The first axis indexes the two input time-steps: ``[t-6h, t]``.
181
+ date:
182
+ The forecast initialisation date/time (the *later* of the two
183
+ time-steps).
184
+ """
185
+ import earthkit.data as ekd
186
+ import earthkit.regrid as ekr
187
+ from ecmwf.opendata import Client as OpendataClient
188
+
189
+ ekd.config.set({"cache-policy": "user"})
190
+ cache_dir = Path(cache_dir)
191
+
192
+ date: datetime.datetime = OpendataClient(SOURCE).latest()
193
+ print(f"📅 Latest ECMWF run: {date}")
194
+
195
+ if not force:
196
+ cached, path = _try_load(date, cache_dir)
197
+ if cached is not None:
198
+ sz_mb = path.stat().st_size / 1e6
199
+ print(f"✅ Loaded from cache ({sz_mb:.0f} MB) → {path}")
200
+ return cached, date
201
+
202
+ print("⬇️ Downloading initial conditions …")
203
+ fields = _build_fields(ekd, ekr, date)
204
+
205
+ path = _save(date, fields, cache_dir)
206
+ sz_mb = path.stat().st_size / 1e6
207
+ print(f"💾 Saved to {path} ({sz_mb:.0f} MB)")
208
+
209
+ return fields, date
aifs/plot.py ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ aifs.plot
3
+ =========
4
+ Cartopy-based helpers for visualising AIFS forecast output on a global map.
5
+
6
+ All functions return ``matplotlib.figure.Figure`` objects so they work in
7
+ both notebooks (``plt.show()``) and scripts (``fig.savefig(...)``).
8
+
9
+ Quickstart
10
+ ----------
11
+ from aifs.plot import plot_field, plot_field_sequence
12
+
13
+ # Single map
14
+ fig = plot_field(state, "2t", title="2-m Temperature — T+6h")
15
+ fig.savefig("t2m_T+6.png", dpi=150)
16
+
17
+ # Multi-panel sequence
18
+ fig = plot_field_sequence(states, "2t", max_steps=4)
19
+ fig.savefig("t2m_sequence.png", dpi=150)
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import warnings
25
+ import numpy as np
26
+
27
+ warnings.filterwarnings("ignore", category=UserWarning)
28
+
29
+ # ── Variable metadata ─────────────────────────────────────────────────────────
30
+
31
+ #: Variables that can be extracted from forecast state dicts
32
+ PLOTTABLE = [
33
+ "2t", "msl", "sp", "tcw", "10u", "10v", "swh", "mwp",
34
+ "t_850", "t_500", "u_850", "v_850", "z_500", "q_700",
35
+ ]
36
+
37
+ _CMAP = {
38
+ "2t": "RdBu_r", "t_850": "RdBu_r", "t_500": "RdBu_r",
39
+ "msl": "viridis", "sp": "viridis",
40
+ "10u": "RdBu", "10v": "RdBu",
41
+ "u_850": "RdBu", "v_850": "RdBu",
42
+ "swh": "Blues", "mwp": "Blues", "tcw": "Blues",
43
+ "z_500": "plasma", "q_700": "YlGn",
44
+ }
45
+
46
+ _UNITS = {
47
+ "2t": "K", "t_850": "K", "t_500": "K",
48
+ "msl": "Pa", "sp": "Pa", "z_500": "m²/s²",
49
+ "10u": "m/s", "10v": "m/s", "u_850": "m/s", "v_850": "m/s",
50
+ "swh": "m", "mwp": "s", "tcw": "kg/m²", "q_700": "kg/kg",
51
+ }
52
+
53
+ _LONG_NAME = {
54
+ "2t": "2-m Temperature",
55
+ "msl": "Mean Sea-Level Pressure",
56
+ "sp": "Surface Pressure",
57
+ "tcw": "Total Column Water",
58
+ "10u": "10-m U Wind",
59
+ "10v": "10-m V Wind",
60
+ "swh": "Significant Wave Height",
61
+ "mwp": "Mean Wave Period",
62
+ "t_850": "Temperature at 850 hPa",
63
+ "t_500": "Temperature at 500 hPa",
64
+ "u_850": "U Wind at 850 hPa",
65
+ "v_850": "V Wind at 850 hPa",
66
+ "z_500": "Geopotential at 500 hPa",
67
+ "q_700": "Specific Humidity at 700 hPa",
68
+ }
69
+
70
+
71
+ # ── Grid coordinate extraction ────────────────────────────────────────────────
72
+
73
+ def _get_latlons(state: dict) -> tuple[np.ndarray, np.ndarray]:
74
+ """
75
+ Return (lats, lons) for the grid the forecast was run on.
76
+
77
+ The anemoi tensor handler injects ``state["latitudes"]`` and
78
+ ``state["longitudes"]`` from the checkpoint metadata before the first
79
+ inference step, and these are propagated to every output state via
80
+ ``new_states = input_states.copy()``. We read them directly — no
81
+ separate grid-geometry lookup needed.
82
+
83
+ Longitudes are returned in the range [0, 360) as stored by anemoi;
84
+ callers that need [-180, 180) should call ``_to_180(lons)``.
85
+ """
86
+ lats = state.get("latitudes")
87
+ lons = state.get("longitudes")
88
+
89
+ if lats is None or lons is None:
90
+ raise KeyError(
91
+ "State dict does not contain 'latitudes'/'longitudes'. "
92
+ "Make sure you are passing a state returned by run_forecast() "
93
+ "and have not stripped those keys."
94
+ )
95
+
96
+ lats = np.asarray(lats).ravel()
97
+ lons = np.asarray(lons).ravel()
98
+
99
+ if len(lats) < 3 or len(lons) < 3:
100
+ raise ValueError(
101
+ f"Grid has only {len(lats)} points — expected ~542 080 for N320. "
102
+ "The state latitudes/longitudes may be corrupt."
103
+ )
104
+
105
+ return lats, lons
106
+
107
+
108
+ def _to_180(lons: np.ndarray) -> np.ndarray:
109
+ """Normalise longitudes from [0, 360) to [-180, 180) for Cartopy."""
110
+ return np.where(lons > 180, lons - 360, lons)
111
+
112
+
113
+ def _extract_field(state: dict, variable: str) -> np.ndarray | None:
114
+ """Pull ``variable`` out of ``state["fields"]``, return None if missing."""
115
+ return state.get("fields", {}).get(variable)
116
+
117
+
118
+ # ── Public API ────────────────────────────────────────────────────────────────
119
+
120
+ def plot_field(
121
+ state: dict,
122
+ variable: str,
123
+ title: str | None = None,
124
+ projection: str = "Robinson",
125
+ figsize: tuple[float, float] = (14, 7),
126
+ vmin: float | None = None,
127
+ vmax: float | None = None,
128
+ ) -> "matplotlib.figure.Figure":
129
+ """
130
+ Plot a single forecast field on a global map.
131
+
132
+ Parameters
133
+ ----------
134
+ state:
135
+ One element from the list returned by :func:`aifs.forecast.run_forecast`.
136
+ variable:
137
+ Short name of the field to plot (e.g. ``"2t"``, ``"msl"``).
138
+ See :data:`PLOTTABLE` for supported names.
139
+ title:
140
+ Figure title; defaults to ``"{long_name} — {state_date}"``.
141
+ projection:
142
+ Cartopy projection class name (e.g. ``"Robinson"``, ``"PlateCarree"``).
143
+ figsize:
144
+ Matplotlib figure size in inches.
145
+ vmin, vmax:
146
+ Colour-scale limits; auto-derived from percentiles if not provided.
147
+
148
+ Returns
149
+ -------
150
+ matplotlib.figure.Figure
151
+ """
152
+ import matplotlib
153
+ import matplotlib.pyplot as plt
154
+ import cartopy.crs as ccrs
155
+ import cartopy.feature as cfeature
156
+ import matplotlib.tri as tri
157
+
158
+ data = _extract_field(state, variable)
159
+ if data is None:
160
+ raise KeyError(
161
+ f"Variable '{variable}' not found in forecast state. "
162
+ f"Available: {sorted(state.get('fields', {}).keys())}"
163
+ )
164
+
165
+ data = np.asarray(data).ravel()
166
+ lats, lons = _get_latlons(state)
167
+ lons_plot = _to_180(lons)
168
+
169
+ units = _UNITS.get(variable, "")
170
+ lname = _LONG_NAME.get(variable, variable)
171
+ dt = state.get("date", "")
172
+
173
+ # Default colormap: coolwarm gives blue=cold, red=warm; fall back per variable
174
+ cmap = _CMAP.get(variable, "coolwarm")
175
+
176
+ if vmin is None:
177
+ vmin = float(np.nanpercentile(data, 2))
178
+ if vmax is None:
179
+ vmax = float(np.nanpercentile(data, 98))
180
+
181
+ proj_cls = getattr(ccrs, projection, ccrs.Robinson)
182
+ proj = proj_cls()
183
+ pc = ccrs.PlateCarree()
184
+
185
+ # Pre-project coordinates into projection space before triangulating
186
+ xy = proj.transform_points(pc, lons_plot, lats) # (N, 3)
187
+ x, y = xy[:, 0], xy[:, 1]
188
+
189
+ # Drop points that failed to project
190
+ valid = np.isfinite(x) & np.isfinite(y)
191
+ x, y, data = x[valid], y[valid], data[valid]
192
+
193
+ triangulation = tri.Triangulation(x, y)
194
+
195
+ # Mask triangles spanning the antimeridian (threshold in projection metres)
196
+ x_verts = x[triangulation.triangles]
197
+ max_x_span = np.max(x_verts, axis=1) - np.min(x_verts, axis=1)
198
+ triangulation.set_mask(max_x_span > 1e6)
199
+
200
+ fig = plt.figure(figsize=figsize)
201
+ ax = fig.add_subplot(1, 1, 1, projection=proj)
202
+ ax.set_global()
203
+ ax.add_feature(cfeature.COASTLINE, linewidth=0.5)
204
+ ax.add_feature(cfeature.BORDERS, linewidth=0.3, alpha=0.5)
205
+
206
+ # No transform= — coordinates are already in projection space
207
+ pcm = ax.tripcolor(
208
+ triangulation, data,
209
+ cmap=cmap, vmin=vmin, vmax=vmax,
210
+ shading="gouraud",
211
+ )
212
+
213
+ cbar = fig.colorbar(pcm, ax=ax, orientation="horizontal",
214
+ pad=0.04, fraction=0.03, shrink=0.8)
215
+ cbar.set_label(f"{lname} [{units}]", fontsize=10)
216
+
217
+ if title is None:
218
+ title = f"{lname} — {dt}"
219
+ ax.set_title(title, fontsize=12, pad=10)
220
+
221
+ fig.tight_layout()
222
+
223
+ return fig
224
+
225
+
226
+ def plot_field_sequence(
227
+ states: list[dict],
228
+ variable: str,
229
+ max_steps: int = 4,
230
+ figsize_per_panel: tuple[float, float] = (7, 3.5),
231
+ projection: str = "Robinson",
232
+ shared_colorscale: bool = True,
233
+ ) -> "matplotlib.figure.Figure":
234
+ """
235
+ Plot a sequence of forecast steps side-by-side in a single figure.
236
+
237
+ Parameters
238
+ ----------
239
+ states:
240
+ List of state dicts from :func:`aifs.forecast.run_forecast`.
241
+ variable:
242
+ Variable short name.
243
+ max_steps:
244
+ Maximum number of panels (steps) to show.
245
+ figsize_per_panel:
246
+ Width × height for each panel in inches.
247
+ projection:
248
+ Cartopy projection class name.
249
+ shared_colorscale:
250
+ Use the same colour limits for all panels (recommended).
251
+
252
+ Returns
253
+ -------
254
+ matplotlib.figure.Figure
255
+ """
256
+ import matplotlib.pyplot as plt
257
+ import cartopy.crs as ccrs
258
+ import cartopy.feature as cfeature
259
+ import matplotlib.tri as tri
260
+
261
+ steps = states[:max_steps]
262
+ n = len(steps)
263
+ ncols = min(n, 2)
264
+ nrows = (n + ncols - 1) // ncols
265
+
266
+ fig_w = figsize_per_panel[0] * ncols
267
+ fig_h = figsize_per_panel[1] * nrows
268
+ proj_cls = getattr(ccrs, projection, ccrs.Robinson)
269
+ proj = proj_cls()
270
+ pc = ccrs.PlateCarree()
271
+
272
+ # Pre-project coordinates once from the first state
273
+ lats, lons = _get_latlons(steps[0])
274
+ lons_plot = _to_180(lons)
275
+
276
+ xy = proj.transform_points(pc, lons_plot, lats)
277
+ x, y = xy[:, 0], xy[:, 1]
278
+ valid = np.isfinite(x) & np.isfinite(y)
279
+ x, y = x[valid], y[valid]
280
+
281
+ triangulation = tri.Triangulation(x, y)
282
+ x_verts = x[triangulation.triangles]
283
+ max_x_span = np.max(x_verts, axis=1) - np.min(x_verts, axis=1)
284
+ triangulation.set_mask(max_x_span > 1e6)
285
+
286
+ cmap = _CMAP.get(variable, "coolwarm")
287
+ units = _UNITS.get(variable, "")
288
+ lname = _LONG_NAME.get(variable, variable)
289
+
290
+ if shared_colorscale:
291
+ all_data = np.concatenate(
292
+ [np.asarray(_extract_field(s, variable)).ravel()[valid]
293
+ for s in steps if _extract_field(s, variable) is not None]
294
+ )
295
+ vmin = float(np.nanpercentile(all_data, 2))
296
+ vmax = float(np.nanpercentile(all_data, 98))
297
+ else:
298
+ vmin = vmax = None
299
+
300
+ fig, axes = plt.subplots(
301
+ nrows, ncols,
302
+ figsize=(fig_w, fig_h),
303
+ subplot_kw={"projection": proj},
304
+ )
305
+ axes_flat = np.array(axes).ravel()
306
+
307
+ for idx, (state, ax) in enumerate(zip(steps, axes_flat)):
308
+ data = _extract_field(state, variable)
309
+ if data is None:
310
+ ax.set_visible(False)
311
+ continue
312
+
313
+ data = np.asarray(data).ravel()[valid] # apply same valid mask
314
+ _vmin = vmin if shared_colorscale else float(np.nanpercentile(data, 2))
315
+ _vmax = vmax if shared_colorscale else float(np.nanpercentile(data, 98))
316
+
317
+ ax.set_global()
318
+ ax.add_feature(cfeature.COASTLINE, linewidth=0.4)
319
+
320
+ # No transform= — coordinates already in projection space
321
+ pcm = ax.tripcolor(
322
+ triangulation, data,
323
+ cmap=cmap, vmin=_vmin, vmax=_vmax,
324
+ shading="gouraud",
325
+ )
326
+ step_label = f"T+{(idx + 1) * 6}h ({state.get('date', '')})"
327
+ ax.set_title(step_label, fontsize=9)
328
+
329
+ fig.colorbar(pcm, ax=ax, orientation="horizontal",
330
+ pad=0.04, fraction=0.04, shrink=0.85,
331
+ label=f"{units}")
332
+
333
+ for ax in axes_flat[len(steps):]:
334
+ ax.set_visible(False)
335
+
336
+ fig.suptitle(f"{lname} — AIFS Forecast", fontsize=13, y=1.01)
337
+ fig.tight_layout()
338
+ return fig