Spaces:
Running on Zero
Running on Zero
Emma Scharfmann commited on
Commit ·
80f643f
1
Parent(s): 9ab6408
add aifs wrapper
Browse files- AIFS-tutorial +0 -1
- __init__.py +0 -0
- aifs/__init__.py +0 -0
- aifs/compat.py +86 -0
- aifs/device.py +31 -0
- aifs/forecast.py +125 -0
- aifs/initial_conditions.py +209 -0
- aifs/plot.py +338 -0
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
|