Upload folder using huggingface_hub
Browse files- README.md +132 -0
- conf/config.yaml +298 -0
- configuration.json +1 -0
- model/aifs.py +535 -0
- model/aifs_config.json +0 -0
- model/grid-n320.npz +3 -0
- scripts/fake_data.py +307 -0
- scripts/inference.py +720 -0
- scripts/result.py +396 -0
- scripts/train.py +894 -0
- weights/.gitkeep +0 -0
README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
frameworks: PyTorch
|
| 3 |
+
tasks: []
|
| 4 |
+
tags:
|
| 5 |
+
- OneScience
|
| 6 |
+
- Earth Science
|
| 7 |
+
- Weather Forecast
|
| 8 |
+
- ERA5
|
| 9 |
+
- Deterministic Forecast
|
| 10 |
+
language:
|
| 11 |
+
- zh
|
| 12 |
+
- en
|
| 13 |
+
license: apache-2.0
|
| 14 |
+
datasets:
|
| 15 |
+
- OneScience/ERA5
|
| 16 |
+
---
|
| 17 |
+
<p align="center">
|
| 18 |
+
<strong>
|
| 19 |
+
<span style="font-size: 30px;">AIFS_Single_v1</span>
|
| 20 |
+
</strong>
|
| 21 |
+
</p>
|
| 22 |
+
|
| 23 |
+
# Model Introduction
|
| 24 |
+
|
| 25 |
+
AIFS Single v1.1 is the deterministic version of the AI-powered weather forecasting system developed by the European Centre for Medium-Range Weather Forecasts (ECMWF).
|
| 26 |
+
|
| 27 |
+
Paper: AIFS — ECMWF's data-driven forecasting system, arXiv:2406.01465
|
| 28 |
+
|
| 29 |
+
https://arxiv.org/abs/2406.01465
|
| 30 |
+
|
| 31 |
+
# Model Description
|
| 32 |
+
|
| 33 |
+
AIFS is built on a Graph Neural Network (GNN), pre-trained on ERA5 data and fine-tuned with NWP operational analysis data.
|
| 34 |
+
|
| 35 |
+
# Use Cases
|
| 36 |
+
|
| 37 |
+
| Scenario | Description |
|
| 38 |
+
| :---: | :--- |
|
| 39 |
+
| Weather Forecast Training | Train AIFS from scratch using ERA5 HDF5 data |
|
| 40 |
+
| Local Quick Validation | Use synthetic data to verify data loading, model training & inference, and inference result visualization. |
|
| 41 |
+
| ModelScope / OneCode Execution | Download as a standalone model package, install dependencies, and run scripts directly. |
|
| 42 |
+
|
| 43 |
+
# Usage Guide
|
| 44 |
+
|
| 45 |
+
## 1. OneCode Usage
|
| 46 |
+
|
| 47 |
+
Experience intelligent one-click AI4S programming through the OneCode online environment:
|
| 48 |
+
|
| 49 |
+
[Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 50 |
+
|
| 51 |
+
## 2. Manual Installation and Usage
|
| 52 |
+
|
| 53 |
+
**Hardware Requirements**
|
| 54 |
+
|
| 55 |
+
- A GPU or DCU is recommended.
|
| 56 |
+
- CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
|
| 57 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or above is recommended.
|
| 58 |
+
|
| 59 |
+
### Download the Model Package
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
modelscope download --model OneScience/AIFS_Single_v1 --local_dir ./AIFS_Single_v1
|
| 63 |
+
cd AIFS_Single_v1
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
### Install the Runtime Environment
|
| 67 |
+
|
| 68 |
+
**DCU**
|
| 69 |
+
```bash
|
| 70 |
+
# Please activate DTK and CONDA first
|
| 71 |
+
conda create -n onescience311 python=3.11 -y
|
| 72 |
+
conda activate onescience311
|
| 73 |
+
# uv installation is supported
|
| 74 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
**GPU**
|
| 78 |
+
```bash
|
| 79 |
+
# Please activate CONDA first
|
| 80 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 81 |
+
conda activate onescience311
|
| 82 |
+
# uv installation is supported
|
| 83 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
### Training Data Introduction
|
| 87 |
+
|
| 88 |
+
The OneScience community provides ERA5 data for training (due to file size limits, the current repository contains a slice of the full dataset). Users can download it with the command below and confirm that the data path in `conf/config.yaml` is set correctly:
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
modelscope download --dataset OneScience/ERA5 --local_dir ./data
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
### Training
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
python scripts/train.py
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
Training weights are saved to `weights/model_bak.ckpt`, and the normalization file computed before training is saved to `weights/era5_stats.npz`.
|
| 101 |
+
|
| 102 |
+
### Training Weights
|
| 103 |
+
|
| 104 |
+
This repository provides weights trained on ERA5 reanalysis data in the `weights/` folder. The weight files will be uploaded soon and are expected to be available in the near future.
|
| 105 |
+
|
| 106 |
+
### Inference
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
python scripts/inference.py
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
The number of forecast steps is controlled by `test_lead_time` in `conf/config.yaml` (in hours; default 24 = 1 day).
|
| 113 |
+
Inference results will be saved to the `output` directory.
|
| 114 |
+
|
| 115 |
+
### Evaluation and Visualization
|
| 116 |
+
|
| 117 |
+
```bash
|
| 118 |
+
python scripts/result.py
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Computes ACC / RMSE metrics and generates plots. Metrics are saved to `metrics/` and figures are saved to `plots/`.
|
| 122 |
+
|
| 123 |
+
# OneScience Official Information
|
| 124 |
+
|
| 125 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 126 |
+
| --- | --- | --- |
|
| 127 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 128 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 129 |
+
|
| 130 |
+
# Citation & License
|
| 131 |
+
|
| 132 |
+
- This repository is a reproduction of the original AIFS Single v1.1 paper.
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# AIFS v1.1 — Configuration
|
| 3 |
+
# =============================================================================
|
| 4 |
+
|
| 5 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 6 |
+
# Data (most frequently modified)
|
| 7 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 8 |
+
data:
|
| 9 |
+
data_dir: "./fake_era5" # ERA5 H5 root (contains data/{year}.h5)
|
| 10 |
+
# data_dir: "/work2/share/sugonhpcapp01/ERA5"
|
| 11 |
+
|
| 12 |
+
train_years: # training set
|
| 13 |
+
- 2005
|
| 14 |
+
- 2006
|
| 15 |
+
|
| 16 |
+
val_years: # validation set
|
| 17 |
+
- 2007
|
| 18 |
+
|
| 19 |
+
test_years: # test / inference set
|
| 20 |
+
- 2008
|
| 21 |
+
|
| 22 |
+
test_lead_time: 24 # hours (240 = 10 days, official default)
|
| 23 |
+
|
| 24 |
+
fake_data_nt: 60 # timesteps per fake-data year
|
| 25 |
+
|
| 26 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 27 |
+
# Hardware
|
| 28 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 29 |
+
hardware:
|
| 30 |
+
device: "dcu"
|
| 31 |
+
device_ids: "0"
|
| 32 |
+
|
| 33 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 34 |
+
# Checkpoint
|
| 35 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 36 |
+
checkpoint:
|
| 37 |
+
pretrained: "./weights/model_bak.ckpt" # official AIFS v1.1 checkpoint (relative to aifs_v11/)
|
| 38 |
+
output_dir: "./weights"
|
| 39 |
+
|
| 40 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 41 |
+
# Model (model/aifs.py — onescience-style wrapper)
|
| 42 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 43 |
+
model:
|
| 44 |
+
module: "model.aifs.AIFS" # Python import path
|
| 45 |
+
description: >-
|
| 46 |
+
Wraps AnemoiModelEncProcDec (encoder-processor-decoder GNN) loaded from
|
| 47 |
+
the pretrained checkpoint. Exposes input/output variable ordering,
|
| 48 |
+
N320 grid coordinates, and node weights via public properties.
|
| 49 |
+
|
| 50 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 51 |
+
# Output
|
| 52 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 53 |
+
output:
|
| 54 |
+
inference_dir: "./output"
|
| 55 |
+
plot_dir: "./plots"
|
| 56 |
+
|
| 57 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 58 |
+
# Training (config_pretraining.yaml)
|
| 59 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 60 |
+
training:
|
| 61 |
+
batch_size: 1
|
| 62 |
+
from_scratch: true # true: random weights (like FengWu/Fuxi); false: load pretrained
|
| 63 |
+
accum_grad_batches: 1
|
| 64 |
+
max_steps: 5
|
| 65 |
+
warmup_steps: 0
|
| 66 |
+
peak_lr: 3.125e-5
|
| 67 |
+
min_lr: 3.0e-7
|
| 68 |
+
precision: "16-mixed"
|
| 69 |
+
amp_dtype: "float16" # "float16" or "bfloat16" (DCU may need bfloat16)
|
| 70 |
+
gradient_clip_val: 32.0
|
| 71 |
+
gradient_clip_algorithm: "value"
|
| 72 |
+
multistep_input: 2
|
| 73 |
+
seed: 42
|
| 74 |
+
|
| 75 |
+
optimizer:
|
| 76 |
+
betas: [0.9, 0.95]
|
| 77 |
+
weight_decay: 0.01 # official AdamW weight_decay
|
| 78 |
+
|
| 79 |
+
lr_schedule:
|
| 80 |
+
type: "cosine"
|
| 81 |
+
warmup: 1000
|
| 82 |
+
iterations: 260000
|
| 83 |
+
|
| 84 |
+
log_interval: 100
|
| 85 |
+
val_interval: 500
|
| 86 |
+
save_interval: 5000
|
| 87 |
+
|
| 88 |
+
# ════════════════════════════════════════════════════��════════════════════════
|
| 89 |
+
# Normalizer (config_pretraining.yaml data.normalizer)
|
| 90 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 91 |
+
normalizer:
|
| 92 |
+
default: "mean-std"
|
| 93 |
+
statistics_path: "weights/era5_stats.npz" # auto-computed if missing; set "" for identity (fake data)
|
| 94 |
+
remap:
|
| 95 |
+
cp: "tp"
|
| 96 |
+
sf: "tp"
|
| 97 |
+
std:
|
| 98 |
+
- tp
|
| 99 |
+
- cp
|
| 100 |
+
- sf
|
| 101 |
+
- ro
|
| 102 |
+
- tcw
|
| 103 |
+
- ssrd
|
| 104 |
+
- q_50
|
| 105 |
+
- q_100
|
| 106 |
+
- q_150
|
| 107 |
+
- q_200
|
| 108 |
+
- q_250
|
| 109 |
+
- q_300
|
| 110 |
+
- q_400
|
| 111 |
+
- q_500
|
| 112 |
+
- q_600
|
| 113 |
+
- q_700
|
| 114 |
+
- q_850
|
| 115 |
+
- q_925
|
| 116 |
+
- q_1000
|
| 117 |
+
max:
|
| 118 |
+
- sdor
|
| 119 |
+
- slor
|
| 120 |
+
- z
|
| 121 |
+
none:
|
| 122 |
+
- cos_latitude
|
| 123 |
+
- cos_longitude
|
| 124 |
+
- sin_latitude
|
| 125 |
+
- sin_longitude
|
| 126 |
+
- cos_julian_day
|
| 127 |
+
- cos_local_time
|
| 128 |
+
- sin_julian_day
|
| 129 |
+
- sin_local_time
|
| 130 |
+
- insolation
|
| 131 |
+
- lsm
|
| 132 |
+
- tcc
|
| 133 |
+
- mcc
|
| 134 |
+
- hcc
|
| 135 |
+
- lcc
|
| 136 |
+
- swvl1
|
| 137 |
+
- swvl2
|
| 138 |
+
|
| 139 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 140 |
+
# Loss scaling (config_pretraining.yaml training.variable_loss_scaling)
|
| 141 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 142 |
+
loss_scaling:
|
| 143 |
+
default: 1
|
| 144 |
+
pl:
|
| 145 |
+
q: 0.6
|
| 146 |
+
t: 6
|
| 147 |
+
u: 0.8
|
| 148 |
+
v: 0.5
|
| 149 |
+
w: 0.001
|
| 150 |
+
z: 12
|
| 151 |
+
sfc:
|
| 152 |
+
sp: 10
|
| 153 |
+
10u: 0.5
|
| 154 |
+
10v: 0.5
|
| 155 |
+
100u: 0.1
|
| 156 |
+
100v: 0.1
|
| 157 |
+
2d: 0.5
|
| 158 |
+
tp: 0.025
|
| 159 |
+
cp: 0.0025
|
| 160 |
+
ro: 0.0025
|
| 161 |
+
sf: 0.025
|
| 162 |
+
tcc: 0.1
|
| 163 |
+
mcc: 0.1
|
| 164 |
+
lcc: 0.1
|
| 165 |
+
hcc: 0.1
|
| 166 |
+
swvl2: 2
|
| 167 |
+
swvl1: 1
|
| 168 |
+
stl2: 10
|
| 169 |
+
stl1: 1
|
| 170 |
+
ssrd: 0.05
|
| 171 |
+
strd: 0.1
|
| 172 |
+
pressure_level_scaler:
|
| 173 |
+
minimum: 0.2
|
| 174 |
+
slope: 0.001
|
| 175 |
+
|
| 176 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 177 |
+
# AIFS variable definitions (immutable — from official checkpoint)
|
| 178 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 179 |
+
aifs_variables:
|
| 180 |
+
surface:
|
| 181 |
+
- 10u
|
| 182 |
+
- 10v
|
| 183 |
+
- 2d
|
| 184 |
+
- 2t
|
| 185 |
+
- msl
|
| 186 |
+
- skt
|
| 187 |
+
- sp
|
| 188 |
+
- tcw
|
| 189 |
+
- lsm
|
| 190 |
+
- z
|
| 191 |
+
- slor
|
| 192 |
+
- sdor
|
| 193 |
+
|
| 194 |
+
soil:
|
| 195 |
+
- stl1
|
| 196 |
+
- stl2
|
| 197 |
+
- swvl1
|
| 198 |
+
- swvl2
|
| 199 |
+
|
| 200 |
+
pressure_level:
|
| 201 |
+
- z
|
| 202 |
+
- t
|
| 203 |
+
- u
|
| 204 |
+
- v
|
| 205 |
+
- w
|
| 206 |
+
- q
|
| 207 |
+
|
| 208 |
+
pressure_levels:
|
| 209 |
+
- 50
|
| 210 |
+
- 100
|
| 211 |
+
- 150
|
| 212 |
+
- 200
|
| 213 |
+
- 250
|
| 214 |
+
- 300
|
| 215 |
+
- 400
|
| 216 |
+
- 500
|
| 217 |
+
- 600
|
| 218 |
+
- 700
|
| 219 |
+
- 850
|
| 220 |
+
- 925
|
| 221 |
+
- 1000
|
| 222 |
+
|
| 223 |
+
diagnostic:
|
| 224 |
+
- tp
|
| 225 |
+
- cp
|
| 226 |
+
- sf
|
| 227 |
+
- tcc
|
| 228 |
+
- hcc
|
| 229 |
+
- lcc
|
| 230 |
+
- mcc
|
| 231 |
+
- ro
|
| 232 |
+
- ssrd
|
| 233 |
+
- strd
|
| 234 |
+
- 100u
|
| 235 |
+
- 100v
|
| 236 |
+
|
| 237 |
+
computed_forcing:
|
| 238 |
+
- cos_latitude
|
| 239 |
+
- cos_longitude
|
| 240 |
+
- sin_latitude
|
| 241 |
+
- sin_longitude
|
| 242 |
+
- cos_julian_day
|
| 243 |
+
- cos_local_time
|
| 244 |
+
- sin_julian_day
|
| 245 |
+
- sin_local_time
|
| 246 |
+
- insolation
|
| 247 |
+
|
| 248 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 249 |
+
# ERA5 H5 → AIFS variable-name mapping
|
| 250 |
+
# ═════════════════════════════════════════════════════════════════════════════
|
| 251 |
+
# Keys = canonical AIFS names (DO NOT CHANGE).
|
| 252 |
+
# Values = ERA5 H5 variable names (update to match your H5).
|
| 253 |
+
era5_mapping:
|
| 254 |
+
surface:
|
| 255 |
+
10u: "10m_u_component_of_wind"
|
| 256 |
+
10v: "10m_v_component_of_wind"
|
| 257 |
+
2d: "2m_dewpoint_temperature"
|
| 258 |
+
2t: "2m_temperature"
|
| 259 |
+
msl: "mean_sea_level_pressure"
|
| 260 |
+
skt: "skin_temperature"
|
| 261 |
+
sp: "surface_pressure"
|
| 262 |
+
tcw: "total_column_water"
|
| 263 |
+
lsm: "land_sea_mask"
|
| 264 |
+
z: "geopotential"
|
| 265 |
+
slor: "slope_of_sub_gridscale_orography"
|
| 266 |
+
sdor: "standard_deviation_of_orography"
|
| 267 |
+
|
| 268 |
+
soil:
|
| 269 |
+
stl1: "soil_temperature_level_1"
|
| 270 |
+
stl2: "soil_temperature_level_2"
|
| 271 |
+
swvl1: "volumetric_soil_water_layer_1"
|
| 272 |
+
swvl2: "volumetric_soil_water_layer_2"
|
| 273 |
+
|
| 274 |
+
pressure_level:
|
| 275 |
+
z: "geopotential_{level}"
|
| 276 |
+
t: "temperature_{level}"
|
| 277 |
+
u: "u_component_of_wind_{level}"
|
| 278 |
+
v: "v_component_of_wind_{level}"
|
| 279 |
+
w: "vertical_velocity_{level}"
|
| 280 |
+
q: "specific_humidity_{level}"
|
| 281 |
+
|
| 282 |
+
diagnostic:
|
| 283 |
+
tp: "total_precipitation"
|
| 284 |
+
cp: "convective_precipitation"
|
| 285 |
+
sf: "snowfall_water_equivalent"
|
| 286 |
+
tcc: "total_cloud_cover"
|
| 287 |
+
hcc: "high_cloud_cover"
|
| 288 |
+
lcc: "low_cloud_cover"
|
| 289 |
+
mcc: "medium_cloud_cover"
|
| 290 |
+
ro: "runoff"
|
| 291 |
+
ssrd: "surface_solar_radiation_downwards"
|
| 292 |
+
strd: "surface_thermal_radiation_downwards"
|
| 293 |
+
100u: "100m_u_component_of_wind"
|
| 294 |
+
100v: "100m_v_component_of_wind"
|
| 295 |
+
|
| 296 |
+
fuzzy_fixes:
|
| 297 |
+
surface_solar_radiation_downwards: "surface_solar_radiation"
|
| 298 |
+
total_column_water_vapour: "total_column_water"
|
configuration.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"framework":"Anemoi","task":"other"}
|
model/aifs.py
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# coding: utf-8
|
| 3 |
+
"""
|
| 4 |
+
AIFS v1.1 — Onescience-style Model (真正从零构建)
|
| 5 |
+
====================================================
|
| 6 |
+
|
| 7 |
+
``class AIFS(nn.Module)`` — 兼容 onescience 的 ECMWF AIFS
|
| 8 |
+
编码器-处理器-解码器图神经网络。
|
| 9 |
+
|
| 10 |
+
两种构建模式
|
| 11 |
+
------------
|
| 12 |
+
1. **从零构建** — 无需 ``.ckpt`` 文件,与 FengWu/Fuxi 一致::
|
| 13 |
+
|
| 14 |
+
model = AIFS.from_scratch(device="cuda")
|
| 15 |
+
|
| 16 |
+
从项目内的静态文件加载架构配置(``aifs_config.json``, 47 KB)和
|
| 17 |
+
N320 网格坐标(``grid-n320.npz``, 4.2 MB),通过 ``anemoi-graphs``
|
| 18 |
+
构建 HeteroData 图,由 ``AnemoiModelEncProcDec`` 随机初始化所有权重。
|
| 19 |
+
|
| 20 |
+
2. **加载预训练** — 向后兼容::
|
| 21 |
+
|
| 22 |
+
model = AIFS("aifs.ckpt", device="cuda", pretrained=True)
|
| 23 |
+
|
| 24 |
+
架构 (anemoi-models 0.5.0)
|
| 25 |
+
--------------------------
|
| 26 |
+
- **Encoder** : ``GraphTransformerForwardMapper`` — data nodes → hidden nodes
|
| 27 |
+
- **Processor**: ``TransformerProcessor`` × 16 layers — 在 o96 隐藏图上做
|
| 28 |
+
滑动窗口注意力 (window_size=1120, flash_attention, 16 heads)
|
| 29 |
+
- **Decoder** : ``GraphTransformerBackwardMapper`` — hidden → data nodes
|
| 30 |
+
- **Bounding** : ``ReLUBounding`` / ``HardtanhBounding`` / ``FractionBounding``
|
| 31 |
+
— 强制输出变量满足物理约束
|
| 32 |
+
|
| 33 |
+
静态文件说明
|
| 34 |
+
------------
|
| 35 |
+
- ``aifs_config.json``: 从官方 checkpoint 的 ``ai-models.json`` 提取
|
| 36 |
+
(model_config + data_indices + dataset), 定义完整模型架构
|
| 37 |
+
- ``grid-n320.npz``: N320 高斯网格经纬度坐标 (542,080 节点), 从官方
|
| 38 |
+
checkpoint 的 ``latitudes.numpy`` / ``longitudes.numpy`` 提取
|
| 39 |
+
|
| 40 |
+
Reference
|
| 41 |
+
---------
|
| 42 |
+
- Lang et al., *AIFS — ECMWF's data-driven forecasting system*, arXiv:2406.01465
|
| 43 |
+
- anemoi-models: https://github.com/ecmwf/anemoi-models
|
| 44 |
+
- anemoi-graphs: https://github.com/ecmwf/anemoi-graphs
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import json
|
| 50 |
+
import logging
|
| 51 |
+
import os
|
| 52 |
+
from dataclasses import dataclass
|
| 53 |
+
from typing import Dict, List, Optional
|
| 54 |
+
|
| 55 |
+
import numpy as np
|
| 56 |
+
import torch
|
| 57 |
+
from torch import nn
|
| 58 |
+
|
| 59 |
+
from onescience.models.meta import ModelMetaData
|
| 60 |
+
|
| 61 |
+
LOG = logging.getLogger(__name__)
|
| 62 |
+
|
| 63 |
+
# ============================================================================
|
| 64 |
+
# 静态文件路径(相对于本文件所在目录)
|
| 65 |
+
# ============================================================================
|
| 66 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 67 |
+
_CONFIG_PATH = os.path.join(_HERE, "aifs_config.json")
|
| 68 |
+
_GRID_PATH = os.path.join(_HERE, "grid-n320.npz")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ============================================================================
|
| 72 |
+
# Metadata (onescience convention)
|
| 73 |
+
# ============================================================================
|
| 74 |
+
|
| 75 |
+
@dataclass
|
| 76 |
+
class MetaData(ModelMetaData):
|
| 77 |
+
name: str = "AIFS"
|
| 78 |
+
jit: bool = False
|
| 79 |
+
cuda_graphs: bool = False
|
| 80 |
+
amp: bool = True
|
| 81 |
+
amp_gpu: bool = True
|
| 82 |
+
bf16: bool = False
|
| 83 |
+
onnx: bool = False # flash_attn 不兼容 ONNX
|
| 84 |
+
func_torch: bool = False
|
| 85 |
+
auto_grad: bool = False
|
| 86 |
+
var_dim: int = -1 # 每图节点的变量数
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ============================================================================
|
| 90 |
+
# Model
|
| 91 |
+
# ============================================================================
|
| 92 |
+
|
| 93 |
+
class AIFS(nn.Module):
|
| 94 |
+
"""AIFS v1.1 编码器-处理器-解码器 GNN。
|
| 95 |
+
|
| 96 |
+
Parameters
|
| 97 |
+
----------
|
| 98 |
+
checkpoint_path : str, optional
|
| 99 |
+
``pretrained=True`` 时必传——预训练 ``.ckpt`` 文件路径。
|
| 100 |
+
device : str
|
| 101 |
+
PyTorch 设备。默认 ``"cuda"``。
|
| 102 |
+
pretrained : bool
|
| 103 |
+
``False`` (默认): 从零构建——读取本地 ``aifs_config.json`` +
|
| 104 |
+
``grid-n320.npz``, 不依赖 ``.ckpt`` 文件。
|
| 105 |
+
``True``: 从 ``.ckpt`` 加载预训练权重(向后兼容)。
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
metadata = MetaData()
|
| 109 |
+
|
| 110 |
+
# ==================================================================
|
| 111 |
+
# Factory: 真正从零(无需 .ckpt)
|
| 112 |
+
# ==================================================================
|
| 113 |
+
|
| 114 |
+
@classmethod
|
| 115 |
+
def from_scratch(cls, device: str = "cuda") -> "AIFS":
|
| 116 |
+
"""从零创建 AIFS 模型——无需 ``.ckpt`` 文件。
|
| 117 |
+
|
| 118 |
+
等价于 FengWu/Fuxi 的 ``model = Fengwu()`` 模式。
|
| 119 |
+
所有权重随机初始化,架构配置和网格坐标从项目内静态文件加载。
|
| 120 |
+
"""
|
| 121 |
+
return cls(device=device, pretrained=False)
|
| 122 |
+
|
| 123 |
+
# ==================================================================
|
| 124 |
+
# Construction
|
| 125 |
+
# ==================================================================
|
| 126 |
+
|
| 127 |
+
def __init__(
|
| 128 |
+
self,
|
| 129 |
+
checkpoint_path: Optional[str] = None,
|
| 130 |
+
device: str = "cuda",
|
| 131 |
+
pretrained: bool = False,
|
| 132 |
+
):
|
| 133 |
+
super().__init__()
|
| 134 |
+
|
| 135 |
+
if pretrained:
|
| 136 |
+
if not checkpoint_path:
|
| 137 |
+
raise ValueError(
|
| 138 |
+
"checkpoint_path is required when pretrained=True"
|
| 139 |
+
)
|
| 140 |
+
if not os.path.exists(checkpoint_path):
|
| 141 |
+
raise FileNotFoundError(
|
| 142 |
+
f"Checkpoint not found: {checkpoint_path}"
|
| 143 |
+
)
|
| 144 |
+
self._init_from_pretrained(checkpoint_path, device)
|
| 145 |
+
else:
|
| 146 |
+
self._init_from_scratch(device)
|
| 147 |
+
|
| 148 |
+
# ==================================================================
|
| 149 |
+
# Mode 1: 从零构建(无需 .ckpt)
|
| 150 |
+
# ==================================================================
|
| 151 |
+
|
| 152 |
+
def _init_from_scratch(self, device: str):
|
| 153 |
+
"""用静态配置文件和标准网格坐标构建模型。"""
|
| 154 |
+
LOG.info("Building AIFS from scratch (no checkpoint) …")
|
| 155 |
+
|
| 156 |
+
# ---- 1. 加载静态架构配置 ----------------------------------------
|
| 157 |
+
if not os.path.exists(_CONFIG_PATH):
|
| 158 |
+
raise FileNotFoundError(
|
| 159 |
+
f"Static config not found: {_CONFIG_PATH}. "
|
| 160 |
+
"Run extract_static_config.py first."
|
| 161 |
+
)
|
| 162 |
+
if not os.path.exists(_GRID_PATH):
|
| 163 |
+
raise FileNotFoundError(
|
| 164 |
+
f"Grid file not found: {_GRID_PATH}. "
|
| 165 |
+
"Run extract_static_config.py first."
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
with open(_CONFIG_PATH) as f:
|
| 169 |
+
config_data = json.load(f)
|
| 170 |
+
LOG.info(
|
| 171 |
+
"Loaded static config (%d KB)",
|
| 172 |
+
os.path.getsize(_CONFIG_PATH) // 1024,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
grid = np.load(_GRID_PATH)
|
| 176 |
+
lat = np.asarray(grid["latitudes"], dtype=np.float32)
|
| 177 |
+
lon = np.asarray(grid["longitudes"], dtype=np.float32)
|
| 178 |
+
LOG.info(
|
| 179 |
+
"Loaded N320 grid: %d nodes", len(lat),
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# ---- 2. 构建 model_config + data_indices (官方类) ---------
|
| 183 |
+
# IndexCollection 需要 OmegaConf, AnemoiModelEncProcDec 需要 DotDict.
|
| 184 |
+
# 先从原始 JSON dict 创建 OmegaConf, 再转为 DotDict.
|
| 185 |
+
raw_mc = config_data["model_config"]
|
| 186 |
+
|
| 187 |
+
from anemoi.utils.config import DotDict
|
| 188 |
+
|
| 189 |
+
# ---- 兼容性: 0.5.0 config → 0.9.0 API ---------------------------
|
| 190 |
+
# 1. activation 参数已从 mapper/processor 移除(内部默认 GELU)
|
| 191 |
+
for section in ("encoder", "decoder", "processor"):
|
| 192 |
+
raw_mc["model"][section].pop("activation", None)
|
| 193 |
+
raw_mc["model"].pop("activation", None)
|
| 194 |
+
|
| 195 |
+
# 2. layer_kernels 从顶层移入各子模块 config
|
| 196 |
+
lk = raw_mc["model"].get("layer_kernels", {})
|
| 197 |
+
for section in ("encoder", "decoder", "processor"):
|
| 198 |
+
if section in lk:
|
| 199 |
+
raw_mc["model"][section]["layer_kernels"] = lk[section]
|
| 200 |
+
|
| 201 |
+
model_config = DotDict(raw_mc)
|
| 202 |
+
|
| 203 |
+
all_vars = config_data["dataset"]["variables"]
|
| 204 |
+
name_to_index = {name: i for i, name in enumerate(all_vars)}
|
| 205 |
+
|
| 206 |
+
from anemoi.models.data_indices.collection import IndexCollection
|
| 207 |
+
from omegaconf import OmegaConf
|
| 208 |
+
data_indices = IndexCollection(
|
| 209 |
+
config=OmegaConf.create(raw_mc),
|
| 210 |
+
name_to_index=name_to_index,
|
| 211 |
+
)
|
| 212 |
+
graph_data = self._build_graph(lat, lon)
|
| 213 |
+
|
| 214 |
+
# 提取 area_weight 作为 node_weights
|
| 215 |
+
area_wt = (
|
| 216 |
+
graph_data["data"]["area_weight"].cpu().numpy().squeeze()
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# ---- 4. 实例化 AnemoiModelEncProcDec(随机权重)---------------
|
| 220 |
+
# 训练时直接用裸模型——AnemoiModelInterface 自带 normalizer
|
| 221 |
+
# 预处理,会导致训练时双重归一化。保存 checkpoint 时再包装。
|
| 222 |
+
LOG.info("Instantiating AnemoiModelEncProcDec (random weights) …")
|
| 223 |
+
from anemoi.models.models.encoder_processor_decoder import \
|
| 224 |
+
AnemoiModelEncProcDec
|
| 225 |
+
|
| 226 |
+
self._model = AnemoiModelEncProcDec(
|
| 227 |
+
model_config=model_config,
|
| 228 |
+
data_indices=data_indices,
|
| 229 |
+
statistics={},
|
| 230 |
+
graph_data=graph_data,
|
| 231 |
+
truncation_data={},
|
| 232 |
+
).to(device)
|
| 233 |
+
|
| 234 |
+
# 保存接口构建所需素材(_save 中构建 AnemoiModelInterface 用)
|
| 235 |
+
self._interface_config = model_config
|
| 236 |
+
self._interface_graph_data = graph_data
|
| 237 |
+
self._interface_data_indices = data_indices
|
| 238 |
+
|
| 239 |
+
# ---- 5. 提取变量排序和坐标 -----------------------------------
|
| 240 |
+
self._meta = config_data
|
| 241 |
+
self._arrays = {
|
| 242 |
+
"latitudes": lat,
|
| 243 |
+
"longitudes": lon,
|
| 244 |
+
"area_weight": area_wt,
|
| 245 |
+
}
|
| 246 |
+
self._extract_metadata()
|
| 247 |
+
|
| 248 |
+
LOG.info(
|
| 249 |
+
"AIFS (from scratch) ready: %d → %d vars, %d grid pts, "
|
| 250 |
+
"%.1f M params",
|
| 251 |
+
len(self._input_vars),
|
| 252 |
+
len(self._output_vars),
|
| 253 |
+
len(lat),
|
| 254 |
+
sum(p.numel() for p in self.parameters()) / 1e6,
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
def _build_graph(
|
| 258 |
+
self,
|
| 259 |
+
lat: np.ndarray,
|
| 260 |
+
lon: np.ndarray,
|
| 261 |
+
) -> "HeteroData":
|
| 262 |
+
"""构建 AIFS 的 HeteroData 图(N320 ↔ o96)。
|
| 263 |
+
|
| 264 |
+
完全使用 ``anemoi-graphs`` 官方 API, 不依赖任何外部数据文件。
|
| 265 |
+
节点坐标来自标准 ECMWF 网格定义, 边由纯几何算法计算。
|
| 266 |
+
"""
|
| 267 |
+
from torch_geometric.data import HeteroData
|
| 268 |
+
|
| 269 |
+
from anemoi.graphs.edges.builders.cutoff import CutOffEdges
|
| 270 |
+
from anemoi.graphs.edges.builders.knn import KNNEdges
|
| 271 |
+
from anemoi.graphs.nodes.builders.from_reduced_gaussian import \
|
| 272 |
+
ReducedGaussianGridNodes
|
| 273 |
+
from anemoi.graphs.nodes.builders.from_vectors import LatLonNodes
|
| 274 |
+
|
| 275 |
+
graph = HeteroData()
|
| 276 |
+
|
| 277 |
+
# -- 数据节点 (N320: 542,080 nodes) --
|
| 278 |
+
data_builder = LatLonNodes(
|
| 279 |
+
latitudes=lat, longitudes=lon, name="data",
|
| 280 |
+
)
|
| 281 |
+
data_attrs = {
|
| 282 |
+
"area_weight": {
|
| 283 |
+
"_target_": "anemoi.graphs.nodes.attributes.SphericalAreaWeights",
|
| 284 |
+
"fill_value": 0,
|
| 285 |
+
"norm": "unit-max",
|
| 286 |
+
},
|
| 287 |
+
}
|
| 288 |
+
graph = data_builder.update_graph(graph, attrs_config=data_attrs)
|
| 289 |
+
|
| 290 |
+
# -- 隐藏节点 (o96: ~8,000 nodes) --
|
| 291 |
+
hidden_builder = ReducedGaussianGridNodes(
|
| 292 |
+
grid="o96", name="hidden",
|
| 293 |
+
)
|
| 294 |
+
graph = hidden_builder.update_graph(graph, attrs_config={})
|
| 295 |
+
|
| 296 |
+
# -- data → hidden 边 (CutOffEdges, cutoff_factor=0.6) --
|
| 297 |
+
d2h_attrs = {
|
| 298 |
+
"edge_dirs": {
|
| 299 |
+
"_target_": "anemoi.graphs.edges.attributes.EdgeDirection",
|
| 300 |
+
"norm": "unit-std",
|
| 301 |
+
},
|
| 302 |
+
"edge_length": {
|
| 303 |
+
"_target_": "anemoi.graphs.edges.attributes.EdgeLength",
|
| 304 |
+
"norm": "unit-std",
|
| 305 |
+
},
|
| 306 |
+
}
|
| 307 |
+
d2h_builder = CutOffEdges(
|
| 308 |
+
source_name="data",
|
| 309 |
+
target_name="hidden",
|
| 310 |
+
cutoff_factor=0.6,
|
| 311 |
+
)
|
| 312 |
+
graph = d2h_builder.update_graph(graph, attrs_config=None)
|
| 313 |
+
graph = d2h_builder.register_attributes(graph, d2h_attrs)
|
| 314 |
+
|
| 315 |
+
# -- hidden → data 边 (KNNEdges, K=3) --
|
| 316 |
+
h2d_attrs = {
|
| 317 |
+
"edge_dirs": {
|
| 318 |
+
"_target_": "anemoi.graphs.edges.attributes.EdgeDirection",
|
| 319 |
+
"norm": "unit-std",
|
| 320 |
+
},
|
| 321 |
+
"edge_length": {
|
| 322 |
+
"_target_": "anemoi.graphs.edges.attributes.EdgeLength",
|
| 323 |
+
"norm": "unit-std",
|
| 324 |
+
},
|
| 325 |
+
}
|
| 326 |
+
h2d_builder = KNNEdges(
|
| 327 |
+
source_name="hidden",
|
| 328 |
+
target_name="data",
|
| 329 |
+
num_nearest_neighbours=3,
|
| 330 |
+
)
|
| 331 |
+
graph = h2d_builder.update_graph(graph, attrs_config=None)
|
| 332 |
+
graph = h2d_builder.register_attributes(graph, h2d_attrs)
|
| 333 |
+
|
| 334 |
+
LOG.info(
|
| 335 |
+
"Graph built: data(%d nodes) ↔ hidden(%d nodes), "
|
| 336 |
+
"d→h edges=%d, h→d edges=%d",
|
| 337 |
+
graph["data"].num_nodes,
|
| 338 |
+
graph["hidden"].num_nodes,
|
| 339 |
+
graph["data", "to", "hidden"].edge_index.shape[1],
|
| 340 |
+
graph["hidden", "to", "data"].edge_index.shape[1],
|
| 341 |
+
)
|
| 342 |
+
return graph
|
| 343 |
+
|
| 344 |
+
# ==================================================================
|
| 345 |
+
# Mode 2: 加载预训练 (pretrained=True, 向后兼容)
|
| 346 |
+
# ==================================================================
|
| 347 |
+
|
| 348 |
+
def _init_from_pretrained(self, checkpoint_path: str, device: str):
|
| 349 |
+
"""从 checkpoint 加载完整序列化模型(图 + 权重 + 索引)。"""
|
| 350 |
+
LOG.info("Loading AIFS checkpoint from %s …", checkpoint_path)
|
| 351 |
+
_cp = torch.load(
|
| 352 |
+
checkpoint_path, map_location="cpu", weights_only=False,
|
| 353 |
+
)
|
| 354 |
+
self._model = _cp.to(device)
|
| 355 |
+
|
| 356 |
+
# 提取元数据
|
| 357 |
+
self._meta, self._arrays = self._read_checkpoint_metadata(
|
| 358 |
+
checkpoint_path,
|
| 359 |
+
)
|
| 360 |
+
self._extract_metadata()
|
| 361 |
+
|
| 362 |
+
LOG.info(
|
| 363 |
+
"AIFS (pretrained) ready: %d → %d vars, %d grid pts, "
|
| 364 |
+
"%.1f M params",
|
| 365 |
+
len(self._input_vars),
|
| 366 |
+
len(self._output_vars),
|
| 367 |
+
len(self.latitudes),
|
| 368 |
+
sum(p.numel() for p in self.parameters()) / 1e6,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
# ==================================================================
|
| 372 |
+
# Metadata helpers
|
| 373 |
+
# ==================================================================
|
| 374 |
+
|
| 375 |
+
@staticmethod
|
| 376 |
+
def _read_checkpoint_metadata(checkpoint_path: str):
|
| 377 |
+
"""从 checkpoint ZIP 中读取 JSON 元数据和支持数组。"""
|
| 378 |
+
import zipfile
|
| 379 |
+
from anemoi.utils.checkpoints import load_supporting_arrays
|
| 380 |
+
|
| 381 |
+
with zipfile.ZipFile(checkpoint_path, "r") as zf:
|
| 382 |
+
# 支持新旧两种元数据文件名
|
| 383 |
+
metadata = None
|
| 384 |
+
for name in ["anemoi.json", "ai-models.json"]:
|
| 385 |
+
for fname in zf.namelist():
|
| 386 |
+
if fname.endswith(name):
|
| 387 |
+
metadata = json.load(zf.open(fname, "r"))
|
| 388 |
+
break
|
| 389 |
+
if metadata:
|
| 390 |
+
break
|
| 391 |
+
|
| 392 |
+
if metadata is None:
|
| 393 |
+
raise FileNotFoundError(
|
| 394 |
+
f"No metadata JSON found in {checkpoint_path}"
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
arrays = load_supporting_arrays(
|
| 398 |
+
zf, metadata.get("supporting_arrays_paths", {}),
|
| 399 |
+
)
|
| 400 |
+
return metadata, arrays
|
| 401 |
+
|
| 402 |
+
def _extract_metadata(self):
|
| 403 |
+
"""从已加载的元数据中提取变量排序和网格坐标。"""
|
| 404 |
+
all_vars: List[str] = self._meta["dataset"]["variables"]
|
| 405 |
+
di = self._meta["data_indices"]["data"]
|
| 406 |
+
self._input_vars = [all_vars[i] for i in di["input"]["full"]]
|
| 407 |
+
self._output_vars = [all_vars[i] for i in di["output"]["full"]]
|
| 408 |
+
self._aifs_name_to_ds_idx: Dict[str, int] = {
|
| 409 |
+
n: i for i, n in enumerate(all_vars)
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
lat = np.asarray(self._arrays["latitudes"], dtype=np.float32)
|
| 413 |
+
lon = np.asarray(self._arrays["longitudes"], dtype=np.float32)
|
| 414 |
+
self.register_buffer("latitudes", torch.from_numpy(lat))
|
| 415 |
+
self.register_buffer("longitudes", torch.from_numpy(lon))
|
| 416 |
+
|
| 417 |
+
# ------------------------------------------------------------------
|
| 418 |
+
# Public accessors
|
| 419 |
+
# ------------------------------------------------------------------
|
| 420 |
+
|
| 421 |
+
@property
|
| 422 |
+
def num_input_vars(self) -> int:
|
| 423 |
+
"""输入张量中的变量数 (103)。"""
|
| 424 |
+
return len(self._input_vars)
|
| 425 |
+
|
| 426 |
+
@property
|
| 427 |
+
def num_output_vars(self) -> int:
|
| 428 |
+
"""输出张量中的变量数 (102)。"""
|
| 429 |
+
return len(self._output_vars)
|
| 430 |
+
|
| 431 |
+
@property
|
| 432 |
+
def num_grid_points(self) -> int:
|
| 433 |
+
"""N320 网格节点数 (542,080)。"""
|
| 434 |
+
return len(self.latitudes)
|
| 435 |
+
|
| 436 |
+
@property
|
| 437 |
+
def node_weights(self) -> Optional[np.ndarray]:
|
| 438 |
+
"""损失函数中每节点面积权重,无则为 None。"""
|
| 439 |
+
for k in ["node_weights", "area_weight"]:
|
| 440 |
+
if k in self._arrays:
|
| 441 |
+
return np.asarray(
|
| 442 |
+
self._arrays[k], dtype=np.float32,
|
| 443 |
+
).squeeze()
|
| 444 |
+
return None
|
| 445 |
+
|
| 446 |
+
@property
|
| 447 |
+
def input_variables(self) -> List[str]:
|
| 448 |
+
"""输入张量中 AIFS 变量名的有序列表。"""
|
| 449 |
+
return self._input_vars
|
| 450 |
+
|
| 451 |
+
@property
|
| 452 |
+
def output_variables(self) -> List[str]:
|
| 453 |
+
"""输出张量中 AIFS 变量名的有序列表。"""
|
| 454 |
+
return self._output_vars
|
| 455 |
+
|
| 456 |
+
def aifs_name_to_dataset_index(self, name: str) -> int:
|
| 457 |
+
"""返回 *name* 在 115 变量数据集中的位置。"""
|
| 458 |
+
return self._aifs_name_to_ds_idx.get(name, -1)
|
| 459 |
+
|
| 460 |
+
def input_name_to_channel(self, name: str) -> int:
|
| 461 |
+
"""返回 *name* 在输入张量中的通道索引。"""
|
| 462 |
+
try:
|
| 463 |
+
return self._input_vars.index(name)
|
| 464 |
+
except ValueError:
|
| 465 |
+
return -1
|
| 466 |
+
|
| 467 |
+
def output_name_to_channel(self, name: str) -> int:
|
| 468 |
+
"""返回 *name* 在输出张量中的通道索引。"""
|
| 469 |
+
try:
|
| 470 |
+
return self._output_vars.index(name)
|
| 471 |
+
except ValueError:
|
| 472 |
+
return -1
|
| 473 |
+
|
| 474 |
+
# ------------------------------------------------------------------
|
| 475 |
+
# Forward (匹配官方 AnemoiModelEncProcDec.forward)
|
| 476 |
+
# ------------------------------------------------------------------
|
| 477 |
+
|
| 478 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 479 |
+
"""前向传播。
|
| 480 |
+
|
| 481 |
+
等价于 ``AnemoiModelEncProcDec.forward(x, model_comm_group=None)``
|
| 482 |
+
在单 GPU 训练/推理时的行为。
|
| 483 |
+
|
| 484 |
+
Parameters
|
| 485 |
+
----------
|
| 486 |
+
x : torch.Tensor
|
| 487 |
+
归一化输入。
|
| 488 |
+
- 4-D: ``(B, T, G, V_in)`` — 自动提升为 5-D。
|
| 489 |
+
- 5-D: ``(B, T, E, G, V_in)`` — ensemble 维度保留。
|
| 490 |
+
|
| 491 |
+
Returns
|
| 492 |
+
-------
|
| 493 |
+
torch.Tensor
|
| 494 |
+
- 4-D 输入 → ``(B, G, V_out)``
|
| 495 |
+
- 5-D 输入 → ``(B, E, G, V_out)``
|
| 496 |
+
"""
|
| 497 |
+
if x.ndim == 4:
|
| 498 |
+
x = x.unsqueeze(2) # (B, T, G, V) → (B, T, 1, G, V)
|
| 499 |
+
|
| 500 |
+
out = self._model(x) # (B, E, G, V_out)
|
| 501 |
+
|
| 502 |
+
if out.shape[1] == 1:
|
| 503 |
+
out = out.squeeze(1) # (B, 1, G, V) → (B, G, V)
|
| 504 |
+
return out
|
| 505 |
+
|
| 506 |
+
# ------------------------------------------------------------------
|
| 507 |
+
# Inference helper
|
| 508 |
+
# ------------------------------------------------------------------
|
| 509 |
+
|
| 510 |
+
@torch.no_grad()
|
| 511 |
+
def predict(self, x: torch.Tensor) -> torch.Tensor:
|
| 512 |
+
"""推理前向传播(无梯度,eval 模式,autocast fp16)。
|
| 513 |
+
|
| 514 |
+
FlashAttention 要求 fp16/bf16 输入,用 autocast 自动转换。
|
| 515 |
+
"""
|
| 516 |
+
was_training = self.training
|
| 517 |
+
self.eval()
|
| 518 |
+
with torch.amp.autocast("cuda", dtype=torch.float16):
|
| 519 |
+
out = self.forward(x)
|
| 520 |
+
if was_training:
|
| 521 |
+
self.train()
|
| 522 |
+
return out.float()
|
| 523 |
+
|
| 524 |
+
# ------------------------------------------------------------------
|
| 525 |
+
# Train / eval propagation
|
| 526 |
+
# ------------------------------------------------------------------
|
| 527 |
+
|
| 528 |
+
def train(self, mode: bool = True):
|
| 529 |
+
"""将 train/eval 模式传播到被封装的官方模型。"""
|
| 530 |
+
super().train(mode)
|
| 531 |
+
self._model.train(mode)
|
| 532 |
+
return self
|
| 533 |
+
|
| 534 |
+
def eval(self):
|
| 535 |
+
return self.train(False)
|
model/aifs_config.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
model/grid-n320.npz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:83085553383f2f97c5653a22368b10fe4b2a379eed0501b03a547d582102fd34
|
| 3 |
+
size 4337164
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# coding: utf-8
|
| 3 |
+
"""
|
| 4 |
+
Generate AIFS-only ERA5 fake H5 dataset for training & inference.
|
| 5 |
+
|
| 6 |
+
Follows the onescience fake-data generation pattern (chunked H5 with
|
| 7 |
+
fillvalue=0, embedded global_means/global_stds). Contains exactly the
|
| 8 |
+
106 ERA5 variables required by AIFS v1.1 — no more, no less.
|
| 9 |
+
|
| 10 |
+
Each year file has 60 timesteps (6h × 60 = 360h = 15 days), matching
|
| 11 |
+
AIFS's maximum inference lead time.
|
| 12 |
+
|
| 13 |
+
Usage::
|
| 14 |
+
|
| 15 |
+
python fake_data_all.py # 2005, 60 steps
|
| 16 |
+
python fake_data_all.py --years 2005,2006 # two full years
|
| 17 |
+
python fake_data_all.py -y 2005 -o ./my_era5 # custom output dir
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import List
|
| 27 |
+
|
| 28 |
+
import h5py
|
| 29 |
+
import numpy as np
|
| 30 |
+
|
| 31 |
+
# Project root (scripts/ → aifs_v11/)
|
| 32 |
+
ROOT = Path(__file__).parent.parent
|
| 33 |
+
sys.path.insert(0, str(ROOT))
|
| 34 |
+
|
| 35 |
+
# ============================================================================
|
| 36 |
+
# AIFS-required ERA5 variables (106 total, no extras)
|
| 37 |
+
# ============================================================================
|
| 38 |
+
# Order: surface (12) → soil (4) → pressure-levels (78) → diagnostic (12)
|
| 39 |
+
# These exactly match the keys/values in train.py's ERA5_*_MAP dictionaries.
|
| 40 |
+
|
| 41 |
+
AIFS_ERA5_VARIABLES: List[str] = [
|
| 42 |
+
# ── surface prognostic (12) ─────────────────────────────────────────
|
| 43 |
+
"10m_u_component_of_wind",
|
| 44 |
+
"10m_v_component_of_wind",
|
| 45 |
+
"2m_dewpoint_temperature",
|
| 46 |
+
"2m_temperature",
|
| 47 |
+
"mean_sea_level_pressure",
|
| 48 |
+
"skin_temperature",
|
| 49 |
+
"surface_pressure",
|
| 50 |
+
"total_column_water",
|
| 51 |
+
"land_sea_mask",
|
| 52 |
+
"geopotential",
|
| 53 |
+
"slope_of_sub_gridscale_orography",
|
| 54 |
+
"standard_deviation_of_orography",
|
| 55 |
+
|
| 56 |
+
# ── soil prognostic (4) ─────────────────────────────────────────────
|
| 57 |
+
"soil_temperature_level_1",
|
| 58 |
+
"soil_temperature_level_2",
|
| 59 |
+
"volumetric_soil_water_layer_1",
|
| 60 |
+
"volumetric_soil_water_layer_2",
|
| 61 |
+
|
| 62 |
+
# ── pressure levels: geopotential (13) ──────────────────────────────
|
| 63 |
+
"geopotential_50",
|
| 64 |
+
"geopotential_100",
|
| 65 |
+
"geopotential_150",
|
| 66 |
+
"geopotential_200",
|
| 67 |
+
"geopotential_250",
|
| 68 |
+
"geopotential_300",
|
| 69 |
+
"geopotential_400",
|
| 70 |
+
"geopotential_500",
|
| 71 |
+
"geopotential_600",
|
| 72 |
+
"geopotential_700",
|
| 73 |
+
"geopotential_850",
|
| 74 |
+
"geopotential_925",
|
| 75 |
+
"geopotential_1000",
|
| 76 |
+
|
| 77 |
+
# ── pressure levels: temperature (13) ───────────────────────────────
|
| 78 |
+
"temperature_50",
|
| 79 |
+
"temperature_100",
|
| 80 |
+
"temperature_150",
|
| 81 |
+
"temperature_200",
|
| 82 |
+
"temperature_250",
|
| 83 |
+
"temperature_300",
|
| 84 |
+
"temperature_400",
|
| 85 |
+
"temperature_500",
|
| 86 |
+
"temperature_600",
|
| 87 |
+
"temperature_700",
|
| 88 |
+
"temperature_850",
|
| 89 |
+
"temperature_925",
|
| 90 |
+
"temperature_1000",
|
| 91 |
+
|
| 92 |
+
# ── pressure levels: u wind (13) ────────────────────────────────────
|
| 93 |
+
"u_component_of_wind_50",
|
| 94 |
+
"u_component_of_wind_100",
|
| 95 |
+
"u_component_of_wind_150",
|
| 96 |
+
"u_component_of_wind_200",
|
| 97 |
+
"u_component_of_wind_250",
|
| 98 |
+
"u_component_of_wind_300",
|
| 99 |
+
"u_component_of_wind_400",
|
| 100 |
+
"u_component_of_wind_500",
|
| 101 |
+
"u_component_of_wind_600",
|
| 102 |
+
"u_component_of_wind_700",
|
| 103 |
+
"u_component_of_wind_850",
|
| 104 |
+
"u_component_of_wind_925",
|
| 105 |
+
"u_component_of_wind_1000",
|
| 106 |
+
|
| 107 |
+
# ── pressure levels: v wind (13) ────────────────────────────────────
|
| 108 |
+
"v_component_of_wind_50",
|
| 109 |
+
"v_component_of_wind_100",
|
| 110 |
+
"v_component_of_wind_150",
|
| 111 |
+
"v_component_of_wind_200",
|
| 112 |
+
"v_component_of_wind_250",
|
| 113 |
+
"v_component_of_wind_300",
|
| 114 |
+
"v_component_of_wind_400",
|
| 115 |
+
"v_component_of_wind_500",
|
| 116 |
+
"v_component_of_wind_600",
|
| 117 |
+
"v_component_of_wind_700",
|
| 118 |
+
"v_component_of_wind_850",
|
| 119 |
+
"v_component_of_wind_925",
|
| 120 |
+
"v_component_of_wind_1000",
|
| 121 |
+
|
| 122 |
+
# ── pressure levels: vertical velocity (13) ─────────────────────────
|
| 123 |
+
"vertical_velocity_50",
|
| 124 |
+
"vertical_velocity_100",
|
| 125 |
+
"vertical_velocity_150",
|
| 126 |
+
"vertical_velocity_200",
|
| 127 |
+
"vertical_velocity_250",
|
| 128 |
+
"vertical_velocity_300",
|
| 129 |
+
"vertical_velocity_400",
|
| 130 |
+
"vertical_velocity_500",
|
| 131 |
+
"vertical_velocity_600",
|
| 132 |
+
"vertical_velocity_700",
|
| 133 |
+
"vertical_velocity_850",
|
| 134 |
+
"vertical_velocity_925",
|
| 135 |
+
"vertical_velocity_1000",
|
| 136 |
+
|
| 137 |
+
# ── pressure levels: specific humidity (13) ─────────────────────────
|
| 138 |
+
"specific_humidity_50",
|
| 139 |
+
"specific_humidity_100",
|
| 140 |
+
"specific_humidity_150",
|
| 141 |
+
"specific_humidity_200",
|
| 142 |
+
"specific_humidity_250",
|
| 143 |
+
"specific_humidity_300",
|
| 144 |
+
"specific_humidity_400",
|
| 145 |
+
"specific_humidity_500",
|
| 146 |
+
"specific_humidity_600",
|
| 147 |
+
"specific_humidity_700",
|
| 148 |
+
"specific_humidity_850",
|
| 149 |
+
"specific_humidity_925",
|
| 150 |
+
"specific_humidity_1000",
|
| 151 |
+
|
| 152 |
+
# ── diagnostic (12, output-only) ────────────────────────────────────
|
| 153 |
+
"total_precipitation",
|
| 154 |
+
"convective_precipitation",
|
| 155 |
+
"snowfall_water_equivalent",
|
| 156 |
+
"total_cloud_cover",
|
| 157 |
+
"high_cloud_cover",
|
| 158 |
+
"low_cloud_cover",
|
| 159 |
+
"medium_cloud_cover",
|
| 160 |
+
"runoff",
|
| 161 |
+
"surface_solar_radiation_downwards",
|
| 162 |
+
"surface_thermal_radiation_downwards",
|
| 163 |
+
"100m_u_component_of_wind",
|
| 164 |
+
"100m_v_component_of_wind",
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
# ===========================================================================
|
| 168 |
+
# Dataset dimensions
|
| 169 |
+
# ===========================================================================
|
| 170 |
+
# 60 timesteps @ 6h = 360h = 15 days (covers AIFS max inference lead time)
|
| 171 |
+
_DIMS = {
|
| 172 |
+
"T": 60,
|
| 173 |
+
"H": 721,
|
| 174 |
+
"W": 1440,
|
| 175 |
+
"time_step": 6,
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ===========================================================================
|
| 180 |
+
# Core generation
|
| 181 |
+
# ===========================================================================
|
| 182 |
+
|
| 183 |
+
def generate_fake_h5(
|
| 184 |
+
output_dir: str,
|
| 185 |
+
var_names: List[str],
|
| 186 |
+
years: List[int],
|
| 187 |
+
dims: dict,
|
| 188 |
+
) -> None:
|
| 189 |
+
"""Generate one H5 file per year with the correct schema.
|
| 190 |
+
|
| 191 |
+
Uses HDF5 chunked datasets with ``fillvalue=0.0`` — unwritten chunks
|
| 192 |
+
return zero, keeping files tiny while preserving the logical shape.
|
| 193 |
+
"""
|
| 194 |
+
data_dir = os.path.join(output_dir, "data")
|
| 195 |
+
os.makedirs(data_dir, exist_ok=True)
|
| 196 |
+
|
| 197 |
+
T, C, H, W = dims["T"], len(var_names), dims["H"], dims["W"]
|
| 198 |
+
means = np.zeros((1, C, 1, 1), dtype=np.float32)
|
| 199 |
+
stds = np.ones((1, C, 1, 1), dtype=np.float32)
|
| 200 |
+
logical_gib = T * C * H * W * 4 / 1024**3
|
| 201 |
+
|
| 202 |
+
for year in years:
|
| 203 |
+
path = os.path.join(data_dir, f"{year}.h5")
|
| 204 |
+
with h5py.File(path, "w") as f:
|
| 205 |
+
ds = f.create_dataset(
|
| 206 |
+
"fields",
|
| 207 |
+
shape=(T, C, H, W),
|
| 208 |
+
dtype="float32",
|
| 209 |
+
chunks=(1, C, H, W),
|
| 210 |
+
fillvalue=0.0,
|
| 211 |
+
)
|
| 212 |
+
ds.attrs["variables"] = var_names
|
| 213 |
+
ds.attrs["time_step"] = dims["time_step"]
|
| 214 |
+
f.create_dataset("global_means", data=means)
|
| 215 |
+
f.create_dataset("global_stds", data=stds)
|
| 216 |
+
|
| 217 |
+
size_mb = os.path.getsize(path) / 1024**2
|
| 218 |
+
print(f" {year}.h5 shape=({T},{C},{H},{W}) "
|
| 219 |
+
f"logical={logical_gib:.1f} GiB actual={size_mb:.1f} MiB")
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
# ===========================================================================
|
| 223 |
+
# CLI
|
| 224 |
+
# ===========================================================================
|
| 225 |
+
|
| 226 |
+
def parse_args() -> argparse.Namespace:
|
| 227 |
+
p = argparse.ArgumentParser(
|
| 228 |
+
description="Generate AIFS-only ERA5 fake H5 files",
|
| 229 |
+
)
|
| 230 |
+
p.add_argument("--config", "-c", type=str,
|
| 231 |
+
default=str(ROOT / "conf" / "config.yaml"),
|
| 232 |
+
help="Path to config.yaml")
|
| 233 |
+
p.add_argument("--output_dir", "-o", type=str,
|
| 234 |
+
default=str(ROOT / "fake_era5"),
|
| 235 |
+
help="Output root directory")
|
| 236 |
+
p.add_argument("--years", "-y", type=str, default=None,
|
| 237 |
+
help="Comma-separated years (overrides config). "
|
| 238 |
+
"Each year = 60 timesteps (15 days @ 6h).")
|
| 239 |
+
return p.parse_args()
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ===========================================================================
|
| 243 |
+
# Main
|
| 244 |
+
# ===========================================================================
|
| 245 |
+
|
| 246 |
+
def main() -> None:
|
| 247 |
+
args = parse_args()
|
| 248 |
+
|
| 249 |
+
# Resolve years: CLI > config train+val+test > default
|
| 250 |
+
if args.years:
|
| 251 |
+
years = [int(y.strip()) for y in args.years.replace(",", ",").split(",")
|
| 252 |
+
if y.strip()]
|
| 253 |
+
elif os.path.exists(args.config):
|
| 254 |
+
import yaml
|
| 255 |
+
cfg = yaml.safe_load(open(args.config))
|
| 256 |
+
data = cfg.get("data", {})
|
| 257 |
+
raw = (data.get("train_years", [])
|
| 258 |
+
+ data.get("val_years", [])
|
| 259 |
+
+ data.get("test_years", []))
|
| 260 |
+
years = sorted(set(raw))
|
| 261 |
+
if not years:
|
| 262 |
+
years = [2005]
|
| 263 |
+
else:
|
| 264 |
+
years = [2005]
|
| 265 |
+
|
| 266 |
+
dims = _DIMS
|
| 267 |
+
|
| 268 |
+
print("=" * 60)
|
| 269 |
+
print(" AIFS v1.1 — Fake ERA5 Dataset Generator")
|
| 270 |
+
print("=" * 60)
|
| 271 |
+
print(f" Output dir : {os.path.abspath(args.output_dir)}")
|
| 272 |
+
print(f" Years : {years} ({dims['T']} steps = "
|
| 273 |
+
f"{dims['T'] * dims['time_step'] // 24} days each)")
|
| 274 |
+
print(f" Variables : {len(AIFS_ERA5_VARIABLES)} (AIFS-only, no extras)")
|
| 275 |
+
print(f" Shape : ({dims['T']}, {len(AIFS_ERA5_VARIABLES)}, "
|
| 276 |
+
f"{dims['H']}, {dims['W']})")
|
| 277 |
+
print(f" Timestep : {dims['time_step']}h")
|
| 278 |
+
if not args.years:
|
| 279 |
+
print(f" (years auto-collected from config: train+val+test)")
|
| 280 |
+
print()
|
| 281 |
+
|
| 282 |
+
print("[1/1] Generating per-year H5 files ...")
|
| 283 |
+
generate_fake_h5(args.output_dir, AIFS_ERA5_VARIABLES, years, dims)
|
| 284 |
+
|
| 285 |
+
# Quick verification
|
| 286 |
+
diag_count = sum(
|
| 287 |
+
1 for v in AIFS_ERA5_VARIABLES
|
| 288 |
+
if v in {"total_precipitation", "convective_precipitation",
|
| 289 |
+
"snowfall_water_equivalent", "total_cloud_cover",
|
| 290 |
+
"high_cloud_cover", "low_cloud_cover", "medium_cloud_cover",
|
| 291 |
+
"runoff", "surface_solar_radiation_downwards",
|
| 292 |
+
"surface_thermal_radiation_downwards",
|
| 293 |
+
"100m_u_component_of_wind", "100m_v_component_of_wind"}
|
| 294 |
+
)
|
| 295 |
+
pl_count = sum(1 for v in AIFS_ERA5_VARIABLES
|
| 296 |
+
if any(v.endswith(f"_{lvl}") for lvl in
|
| 297 |
+
[50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000]))
|
| 298 |
+
sfc_count = len(AIFS_ERA5_VARIABLES) - pl_count - diag_count
|
| 299 |
+
|
| 300 |
+
print(f"\n Variables: {sfc_count} surface/soil + {pl_count} PL + "
|
| 301 |
+
f"{diag_count} diagnostic = {len(AIFS_ERA5_VARIABLES)}")
|
| 302 |
+
print(f"\n✅ Done. Use with train.py:")
|
| 303 |
+
print(f" python train.py --dataset_path {os.path.abspath(args.output_dir)}")
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
if __name__ == "__main__":
|
| 307 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# coding: utf-8
|
| 3 |
+
"""
|
| 4 |
+
AIFS v1.1 — Inference Script (AIFS-powered)
|
| 5 |
+
===============================================
|
| 6 |
+
10-day deterministic forecast from ERA5 initial conditions.
|
| 7 |
+
|
| 8 |
+
Uses ``model.aifs.AIFS`` for autoregressive forecast — variable ordering,
|
| 9 |
+
normalisation, and model forward are guaranteed to match training.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python scripts/inference.py
|
| 13 |
+
python scripts/inference.py --checkpoint weights/aifs_pretrain_final.ckpt
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import datetime
|
| 20 |
+
import math
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import time
|
| 24 |
+
import traceback
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Dict, List, Optional, Tuple
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
import pytz
|
| 30 |
+
import torch
|
| 31 |
+
import yaml
|
| 32 |
+
from tqdm import tqdm
|
| 33 |
+
|
| 34 |
+
# Project root (scripts/ → aifs_v11/)
|
| 35 |
+
ROOT = Path(__file__).parent.parent
|
| 36 |
+
sys.path.insert(0, str(ROOT))
|
| 37 |
+
|
| 38 |
+
import earthkit.regrid as ekr
|
| 39 |
+
from onescience.datapipes.climate.era5 import ERA5Dataset
|
| 40 |
+
from model.aifs import AIFS
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ============================================================================
|
| 44 |
+
# Config loader
|
| 45 |
+
# ============================================================================
|
| 46 |
+
|
| 47 |
+
def load_config(path: str) -> dict:
|
| 48 |
+
with open(path) as f:
|
| 49 |
+
return yaml.safe_load(f)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ============================================================================
|
| 53 |
+
# ERA5 variable list builder (matches train.py)
|
| 54 |
+
# ============================================================================
|
| 55 |
+
|
| 56 |
+
def build_era5_variable_list(cfg: dict) -> List[str]:
|
| 57 |
+
av = cfg["aifs_variables"]
|
| 58 |
+
em = cfg["era5_mapping"]
|
| 59 |
+
vars_: List[str] = []
|
| 60 |
+
for name in av["surface"]:
|
| 61 |
+
vars_.append(em["surface"][name])
|
| 62 |
+
for name in av["soil"]:
|
| 63 |
+
vars_.append(em["soil"][name])
|
| 64 |
+
for v in av["pressure_level"]:
|
| 65 |
+
tpl = em["pressure_level"][v]
|
| 66 |
+
for lvl in av["pressure_levels"]:
|
| 67 |
+
vars_.append(tpl.format(level=lvl))
|
| 68 |
+
for name in av["diagnostic"]:
|
| 69 |
+
vars_.append(em["diagnostic"][name])
|
| 70 |
+
return vars_
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _auto_correct_variable_names(required: List[str], available: set, cfg: dict) -> List[str]:
|
| 74 |
+
fuzzy = cfg["era5_mapping"].get("fuzzy_fixes", {})
|
| 75 |
+
corrected = list(required)
|
| 76 |
+
for i, v in enumerate(corrected):
|
| 77 |
+
if v not in available and v in fuzzy:
|
| 78 |
+
alt = fuzzy[v]
|
| 79 |
+
if alt in available:
|
| 80 |
+
print(f"[INFO] Auto-corrected: '{v}' → '{alt}'")
|
| 81 |
+
corrected[i] = alt
|
| 82 |
+
return corrected
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# ============================================================================
|
| 86 |
+
# Normalisation (consistent with train.py)
|
| 87 |
+
# ============================================================================
|
| 88 |
+
|
| 89 |
+
def compute_normalisation_params(
|
| 90 |
+
cfg: dict, variable_names: List[str],
|
| 91 |
+
statistics_path: Optional[str] = None,
|
| 92 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 93 |
+
"""Compute per-variable norm_mul and norm_add from config.
|
| 94 |
+
|
| 95 |
+
If ``statistics_path`` provides a ``.npz`` with ``mean``/``stdev``
|
| 96 |
+
arrays, those values are used. Otherwise identity.
|
| 97 |
+
"""
|
| 98 |
+
nc = cfg["normalizer"]
|
| 99 |
+
default_method = nc["default"]
|
| 100 |
+
remap = nc.get("remap", {}) or {}
|
| 101 |
+
methods: Dict[str, str] = {}
|
| 102 |
+
for v in variable_names:
|
| 103 |
+
if v in (nc.get("none") or []):
|
| 104 |
+
methods[v] = "none"
|
| 105 |
+
elif v in (nc.get("max") or []):
|
| 106 |
+
methods[v] = "max"
|
| 107 |
+
elif v in (nc.get("min-max") or []):
|
| 108 |
+
methods[v] = "min-max"
|
| 109 |
+
elif v in (nc.get("std") or []):
|
| 110 |
+
methods[v] = "std"
|
| 111 |
+
else:
|
| 112 |
+
methods[v] = default_method
|
| 113 |
+
|
| 114 |
+
name_to_idx = {n: i for i, n in enumerate(variable_names)}
|
| 115 |
+
num_vars = len(variable_names)
|
| 116 |
+
_mean = np.zeros(num_vars, dtype=np.float32)
|
| 117 |
+
_stdev = np.ones(num_vars, dtype=np.float32)
|
| 118 |
+
|
| 119 |
+
if statistics_path and os.path.exists(statistics_path):
|
| 120 |
+
stats = np.load(statistics_path)
|
| 121 |
+
if "variables" in stats:
|
| 122 |
+
ds_vars = [str(v) for v in stats["variables"]]
|
| 123 |
+
else:
|
| 124 |
+
import json
|
| 125 |
+
ds_vars = json.load(
|
| 126 |
+
open(str(ROOT / "model" / "aifs_config.json"))
|
| 127 |
+
)["dataset"]["variables"]
|
| 128 |
+
ds_name_to_idx = {n: i for i, n in enumerate(ds_vars)}
|
| 129 |
+
for name, i in name_to_idx.items():
|
| 130 |
+
if name in ds_name_to_idx:
|
| 131 |
+
j = ds_name_to_idx[name]
|
| 132 |
+
_mean[i] = float(stats["mean"][j])
|
| 133 |
+
_stdev[i] = float(stats["stdev"][j])
|
| 134 |
+
print(f"[INFO] Loaded statistics from {statistics_path}")
|
| 135 |
+
|
| 136 |
+
for target_var, source_var in remap.items():
|
| 137 |
+
if target_var in name_to_idx and source_var in name_to_idx:
|
| 138 |
+
ti, si = name_to_idx[target_var], name_to_idx[source_var]
|
| 139 |
+
_mean[ti], _stdev[ti] = _mean[si], _stdev[si]
|
| 140 |
+
|
| 141 |
+
norm_mul = np.ones(num_vars, dtype=np.float32)
|
| 142 |
+
norm_add = np.zeros(num_vars, dtype=np.float32)
|
| 143 |
+
for name, i in name_to_idx.items():
|
| 144 |
+
method = methods.get(name, default_method)
|
| 145 |
+
if method == "mean-std":
|
| 146 |
+
norm_mul[i] = 1.0 / max(float(_stdev[i]), 1e-9)
|
| 147 |
+
norm_add[i] = -float(_mean[i]) / max(float(_stdev[i]), 1e-9)
|
| 148 |
+
elif method == "std":
|
| 149 |
+
norm_mul[i] = 1.0 / max(float(_stdev[i]), 1e-9)
|
| 150 |
+
elif method in ("max", "min-max", "none"):
|
| 151 |
+
norm_mul[i] = 1.0
|
| 152 |
+
return norm_mul, norm_add
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def build_denorm_params(norm_mul: np.ndarray, norm_add: np.ndarray):
|
| 156 |
+
"""Invert normalisation: x = (x_norm - add) / mul."""
|
| 157 |
+
eps = 1e-9
|
| 158 |
+
denorm_mul = np.where(np.abs(norm_mul) > eps, 1.0 / norm_mul, 1.0)
|
| 159 |
+
denorm_add = -norm_add * denorm_mul
|
| 160 |
+
return denorm_mul.astype(np.float32), denorm_add.astype(np.float32)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ============================================================================
|
| 164 |
+
# Forcing features (consistent with train.py)
|
| 165 |
+
# ============================================================================
|
| 166 |
+
|
| 167 |
+
def _compute_insolation(
|
| 168 |
+
ts: datetime.datetime, lat_rad: np.ndarray, lon_rad: np.ndarray
|
| 169 |
+
) -> np.ndarray:
|
| 170 |
+
ref = datetime.datetime(2000, 1, 1, 12, 0, 0, tzinfo=pytz.utc)
|
| 171 |
+
mt = ts.timestamp()
|
| 172 |
+
days = (mt - ref.timestamp()) / (24.0 * 3600.0)
|
| 173 |
+
jc = days / 36525.0
|
| 174 |
+
theta = 67310.54841 + jc * (
|
| 175 |
+
876600.0 * 3600.0
|
| 176 |
+
+ 8640184.812866
|
| 177 |
+
+ jc * (0.093104 - jc * 6.2e-5)
|
| 178 |
+
)
|
| 179 |
+
gmst = np.fmod((theta / 240.0) * np.pi / 180.0, 2.0 * np.pi)
|
| 180 |
+
ma = np.deg2rad(
|
| 181 |
+
357.52910 + 35999.05030 * jc - 0.0001559 * jc**2 - 0.00000048 * jc**3
|
| 182 |
+
)
|
| 183 |
+
ml = np.deg2rad(280.46645 + 36000.76983 * jc + 0.0003032 * jc**2)
|
| 184 |
+
dl = np.deg2rad(
|
| 185 |
+
(1.914600 - 0.004817 * jc - 0.000014 * jc**2) * np.sin(ma)
|
| 186 |
+
+ (0.019993 - 0.000101 * jc) * np.sin(2.0 * ma)
|
| 187 |
+
+ 0.000290 * np.sin(3.0 * ma)
|
| 188 |
+
)
|
| 189 |
+
tl = ml + dl
|
| 190 |
+
eps = np.deg2rad(
|
| 191 |
+
23.0
|
| 192 |
+
+ 26.0 / 60.0
|
| 193 |
+
+ 21.406 / 3600.0
|
| 194 |
+
- (
|
| 195 |
+
46.836769 * jc
|
| 196 |
+
- 0.0001831 * jc**2
|
| 197 |
+
+ 0.00200340 * jc**3
|
| 198 |
+
- 0.576e-6 * jc**4
|
| 199 |
+
- 4.34e-8 * jc**5
|
| 200 |
+
)
|
| 201 |
+
/ 3600.0
|
| 202 |
+
)
|
| 203 |
+
x = np.cos(tl)
|
| 204 |
+
y = np.cos(eps) * np.sin(tl)
|
| 205 |
+
z = np.sin(eps) * np.sin(tl)
|
| 206 |
+
r = np.sqrt(1.0 - z * z)
|
| 207 |
+
dec = np.arctan2(z, r)
|
| 208 |
+
ra = 2.0 * np.arctan2(y, x + r)
|
| 209 |
+
ha = (gmst + lon_rad) - ra
|
| 210 |
+
cos_z = np.sin(lat_rad) * np.sin(dec) + np.cos(lat_rad) * np.cos(dec) * np.cos(ha)
|
| 211 |
+
return cos_z.astype(np.float32)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def compute_forcing_features(
|
| 215 |
+
latitudes: np.ndarray,
|
| 216 |
+
longitudes: np.ndarray,
|
| 217 |
+
timestamps: List[datetime.datetime],
|
| 218 |
+
static_fields: Dict[str, np.ndarray],
|
| 219 |
+
) -> Dict[str, np.ndarray]:
|
| 220 |
+
"""Compute the 13 forcing channels for a 2-timestep window.
|
| 221 |
+
|
| 222 |
+
*static_fields* supplies lsm, z, slor, sdor (time-invariant).
|
| 223 |
+
"""
|
| 224 |
+
lat_rad = np.deg2rad(latitudes)
|
| 225 |
+
lon_rad = np.deg2rad(longitudes)
|
| 226 |
+
multi_step = len(timestamps)
|
| 227 |
+
forcing: Dict[str, np.ndarray] = {}
|
| 228 |
+
|
| 229 |
+
forcing["cos_latitude"] = np.tile(
|
| 230 |
+
np.cos(lat_rad)[np.newaxis, :], (multi_step, 1)
|
| 231 |
+
)
|
| 232 |
+
forcing["sin_latitude"] = np.tile(
|
| 233 |
+
np.sin(lat_rad)[np.newaxis, :], (multi_step, 1)
|
| 234 |
+
)
|
| 235 |
+
forcing["cos_longitude"] = np.tile(
|
| 236 |
+
np.cos(lon_rad)[np.newaxis, :], (multi_step, 1)
|
| 237 |
+
)
|
| 238 |
+
forcing["sin_longitude"] = np.tile(
|
| 239 |
+
np.sin(lon_rad)[np.newaxis, :], (multi_step, 1)
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
cos_jd = np.zeros((multi_step, len(latitudes)), dtype=np.float32)
|
| 243 |
+
sin_jd = np.zeros_like(cos_jd)
|
| 244 |
+
cos_lt = np.zeros_like(cos_jd)
|
| 245 |
+
sin_lt = np.zeros_like(cos_jd)
|
| 246 |
+
insol = np.zeros_like(cos_jd)
|
| 247 |
+
|
| 248 |
+
for t_idx, ts in enumerate(timestamps):
|
| 249 |
+
doy = ts.timetuple().tm_yday
|
| 250 |
+
jd_angle = 2.0 * np.pi * doy / 365.25
|
| 251 |
+
cos_jd[t_idx] = np.cos(jd_angle)
|
| 252 |
+
sin_jd[t_idx] = np.sin(jd_angle)
|
| 253 |
+
hours = ts.hour + ts.minute / 60.0 + ts.second / 3600.0
|
| 254 |
+
lt_angle = 2.0 * np.pi * hours / 24.0 + lon_rad
|
| 255 |
+
cos_lt[t_idx] = np.cos(lt_angle)
|
| 256 |
+
sin_lt[t_idx] = np.sin(lt_angle)
|
| 257 |
+
insol[t_idx] = _compute_insolation(ts, lat_rad, lon_rad)
|
| 258 |
+
|
| 259 |
+
forcing["cos_julian_day"] = cos_jd
|
| 260 |
+
forcing["sin_julian_day"] = sin_jd
|
| 261 |
+
forcing["cos_local_time"] = cos_lt
|
| 262 |
+
forcing["sin_local_time"] = sin_lt
|
| 263 |
+
forcing["insolation"] = insol
|
| 264 |
+
|
| 265 |
+
# Static fields — copied from initial conditions, held invariant
|
| 266 |
+
for var_name in ["lsm", "z", "slor", "sdor"]:
|
| 267 |
+
if var_name in static_fields:
|
| 268 |
+
forcing[var_name] = np.tile(
|
| 269 |
+
static_fields[var_name][:1], (multi_step, 1)
|
| 270 |
+
)
|
| 271 |
+
return forcing
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
# ============================================================================
|
| 275 |
+
# N320 interpolation
|
| 276 |
+
# ============================================================================
|
| 277 |
+
|
| 278 |
+
def _interp_n320(arr: np.ndarray) -> np.ndarray:
|
| 279 |
+
return ekr.interpolate(arr, {"grid": (0.25, 0.25)}, {"grid": "N320"})
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
# ============================================================================
|
| 283 |
+
# ERA5 frame loading
|
| 284 |
+
# ============================================================================
|
| 285 |
+
|
| 286 |
+
def load_frames(era5_dir: str, year: int, step_idx: int, used_vars: List[str]):
|
| 287 |
+
"""Load two consecutive frames for the initial condition."""
|
| 288 |
+
# ERA5Dataset expects dataset_dir to contain data/{year}.h5,
|
| 289 |
+
# so if our path is ./fake_era5, data is at ./fake_era5/data/2008.h5
|
| 290 |
+
ds = ERA5Dataset(
|
| 291 |
+
dataset_dir=era5_dir,
|
| 292 |
+
used_years=[year],
|
| 293 |
+
used_variables=used_vars,
|
| 294 |
+
input_steps=2,
|
| 295 |
+
output_steps=0,
|
| 296 |
+
normalize=False,
|
| 297 |
+
)
|
| 298 |
+
invar, _, _, _, _ = ds[step_idx]
|
| 299 |
+
return invar[0].numpy(), invar[1].numpy()
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
# ============================================================================
|
| 303 |
+
# ERA5 → AIFS field construction
|
| 304 |
+
# ============================================================================
|
| 305 |
+
|
| 306 |
+
def build_aifs_fields_from_frames(
|
| 307 |
+
frame_tm6: np.ndarray,
|
| 308 |
+
frame_t0: np.ndarray,
|
| 309 |
+
era5_var_list: List[str],
|
| 310 |
+
cfg: dict,
|
| 311 |
+
diag_map: Optional[Dict[str, str]] = None,
|
| 312 |
+
) -> Dict[str, np.ndarray]:
|
| 313 |
+
"""Convert raw ERA5 frames to per-variable N320-interpolated fields.
|
| 314 |
+
|
| 315 |
+
Returns
|
| 316 |
+
-------
|
| 317 |
+
dict {aifs_name: np.array([frame_t-6h, frame_t0])}
|
| 318 |
+
"""
|
| 319 |
+
av = cfg["aifs_variables"]
|
| 320 |
+
em = cfg["era5_mapping"]
|
| 321 |
+
name_to_ch = {n: i for i, n in enumerate(era5_var_list)}
|
| 322 |
+
fields: Dict[str, np.ndarray] = {}
|
| 323 |
+
|
| 324 |
+
for aifs_name in av["surface"]:
|
| 325 |
+
ch = name_to_ch[em["surface"][aifs_name]]
|
| 326 |
+
fields[aifs_name] = np.stack(
|
| 327 |
+
[_interp_n320(frame_tm6[ch]), _interp_n320(frame_t0[ch])]
|
| 328 |
+
)
|
| 329 |
+
for aifs_name in av["soil"]:
|
| 330 |
+
ch = name_to_ch[em["soil"][aifs_name]]
|
| 331 |
+
fields[aifs_name] = np.stack(
|
| 332 |
+
[_interp_n320(frame_tm6[ch]), _interp_n320(frame_t0[ch])]
|
| 333 |
+
)
|
| 334 |
+
for aifs_var in av["pressure_level"]:
|
| 335 |
+
tpl = em["pressure_level"][aifs_var]
|
| 336 |
+
for level in av["pressure_levels"]:
|
| 337 |
+
ch = name_to_ch[tpl.format(level=level)]
|
| 338 |
+
fields[f"{aifs_var}_{level}"] = np.stack(
|
| 339 |
+
[_interp_n320(frame_tm6[ch]), _interp_n320(frame_t0[ch])]
|
| 340 |
+
)
|
| 341 |
+
_diag_map = diag_map if diag_map is not None else em["diagnostic"]
|
| 342 |
+
for aifs_name in av["diagnostic"]:
|
| 343 |
+
era5_name = _diag_map.get(aifs_name)
|
| 344 |
+
if era5_name and era5_name in name_to_ch:
|
| 345 |
+
ch = name_to_ch[era5_name]
|
| 346 |
+
fields[aifs_name] = np.stack(
|
| 347 |
+
[_interp_n320(frame_tm6[ch]), _interp_n320(frame_t0[ch])]
|
| 348 |
+
)
|
| 349 |
+
return fields
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
# ============================================================================
|
| 353 |
+
# Autoregressive forecaster
|
| 354 |
+
# ============================================================================
|
| 355 |
+
|
| 356 |
+
class AIFSForecaster:
|
| 357 |
+
"""Deterministic autoregressive forecaster using the AIFS model.
|
| 358 |
+
|
| 359 |
+
Parameters
|
| 360 |
+
----------
|
| 361 |
+
model : AIFS
|
| 362 |
+
Loaded AIFS model wrapper.
|
| 363 |
+
norm_mul : np.ndarray shape (V_in,)
|
| 364 |
+
Per-input-channel multiplicative normalisation.
|
| 365 |
+
norm_add : np.ndarray shape (V_in,)
|
| 366 |
+
Per-input-channel additive normalisation.
|
| 367 |
+
denorm_mul : np.ndarray shape (V_out,)
|
| 368 |
+
Per-output-channel multiplicative denormalisation.
|
| 369 |
+
denorm_add : np.ndarray shape (V_out,)
|
| 370 |
+
Per-output-channel additive denormalisation.
|
| 371 |
+
device : str
|
| 372 |
+
PyTorch device.
|
| 373 |
+
"""
|
| 374 |
+
|
| 375 |
+
# Variables that are time-invariant — copied from initial condition
|
| 376 |
+
_STATIC_NAMES = {"lsm", "z", "slor", "sdor"}
|
| 377 |
+
|
| 378 |
+
def __init__(
|
| 379 |
+
self,
|
| 380 |
+
model: AIFS,
|
| 381 |
+
norm_mul: np.ndarray,
|
| 382 |
+
norm_add: np.ndarray,
|
| 383 |
+
denorm_mul: np.ndarray,
|
| 384 |
+
denorm_add: np.ndarray,
|
| 385 |
+
device: str = "cuda",
|
| 386 |
+
):
|
| 387 |
+
self.model = model
|
| 388 |
+
self.device = device
|
| 389 |
+
|
| 390 |
+
self._input_vars = model.input_variables
|
| 391 |
+
self._output_vars = model.output_variables
|
| 392 |
+
self._norm_mul = norm_mul.astype(np.float32)
|
| 393 |
+
self._norm_add = norm_add.astype(np.float32)
|
| 394 |
+
self._denorm_mul = denorm_mul.astype(np.float32)
|
| 395 |
+
self._denorm_add = denorm_add.astype(np.float32)
|
| 396 |
+
|
| 397 |
+
# Channel lookups
|
| 398 |
+
self._in_ch: Dict[str, int] = {
|
| 399 |
+
n: i for i, n in enumerate(self._input_vars)
|
| 400 |
+
}
|
| 401 |
+
self._out_ch: Dict[str, int] = {
|
| 402 |
+
n: i for i, n in enumerate(self._output_vars)
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
# Input variable → output variable channel mapping (for feedback)
|
| 406 |
+
# Only prognostic vars exist in both input and output.
|
| 407 |
+
self._in_to_out: Dict[int, int] = {}
|
| 408 |
+
for i_idx, name in enumerate(self._input_vars):
|
| 409 |
+
if name in self._out_ch:
|
| 410 |
+
self._in_to_out[i_idx] = self._out_ch[name]
|
| 411 |
+
|
| 412 |
+
# Identify which input vars are prognostic (fed back from output)
|
| 413 |
+
# vs forcing (computed each step).
|
| 414 |
+
self._prognostic_input_indices = sorted(self._in_to_out.keys())
|
| 415 |
+
self._forcing_input_indices = sorted(
|
| 416 |
+
set(range(len(self._input_vars))) - set(self._in_to_out.keys())
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
n_prog, n_forc = (
|
| 420 |
+
len(self._prognostic_input_indices),
|
| 421 |
+
len(self._forcing_input_indices),
|
| 422 |
+
)
|
| 423 |
+
print(
|
| 424 |
+
f"[INFO] Forecaster: {n_prog} prognostic + {n_forc} forcing "
|
| 425 |
+
f"= {len(self._input_vars)} input vars → {len(self._output_vars)} output vars"
|
| 426 |
+
)
|
| 427 |
+
|
| 428 |
+
# ------------------------------------------------------------------
|
| 429 |
+
def forecast(
|
| 430 |
+
self,
|
| 431 |
+
initial_fields: Dict[str, np.ndarray],
|
| 432 |
+
start_date: datetime.datetime,
|
| 433 |
+
lead_time_hours: int,
|
| 434 |
+
latitudes: np.ndarray,
|
| 435 |
+
longitudes: np.ndarray,
|
| 436 |
+
) -> List[Dict]:
|
| 437 |
+
"""Run an autoregressive forecast.
|
| 438 |
+
|
| 439 |
+
Parameters
|
| 440 |
+
----------
|
| 441 |
+
initial_fields : dict {aifs_name: np.array([frame_t-6h, frame_t0])}
|
| 442 |
+
Initial atmospheric state on the N320 grid.
|
| 443 |
+
start_date : datetime
|
| 444 |
+
Valid time of the second frame (t0).
|
| 445 |
+
lead_time_hours : int
|
| 446 |
+
Total forecast length in hours (multiple of 6).
|
| 447 |
+
latitudes, longitudes : np.ndarray
|
| 448 |
+
N320 node coordinates.
|
| 449 |
+
|
| 450 |
+
Returns
|
| 451 |
+
-------
|
| 452 |
+
list of dict
|
| 453 |
+
One dict per output step::
|
| 454 |
+
{"date": datetime, "latitudes": array, "longitudes": array,
|
| 455 |
+
"fields": {var_name: array}}
|
| 456 |
+
"""
|
| 457 |
+
num_steps = lead_time_hours // 6
|
| 458 |
+
states: List[Dict] = []
|
| 459 |
+
fields = initial_fields.copy()
|
| 460 |
+
current_date = start_date
|
| 461 |
+
num_nodes = len(latitudes)
|
| 462 |
+
|
| 463 |
+
t_start = time.time()
|
| 464 |
+
pbar = tqdm(
|
| 465 |
+
range(num_steps), desc="Forecast", unit="step",
|
| 466 |
+
dynamic_ncols=True,
|
| 467 |
+
)
|
| 468 |
+
for step in pbar:
|
| 469 |
+
# ---- 1. Timestamps for this window ----------------------------
|
| 470 |
+
t0 = current_date
|
| 471 |
+
t_m6 = t0 - datetime.timedelta(hours=6)
|
| 472 |
+
|
| 473 |
+
# ---- 2. Compute time-dependent forcing -----------------------
|
| 474 |
+
forcing = compute_forcing_features(
|
| 475 |
+
latitudes, longitudes, [t_m6, t0], fields
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
# ---- 3. Assemble normalised input tensor (2, G, V_in) --------
|
| 479 |
+
x = np.zeros(
|
| 480 |
+
(2, num_nodes, len(self._input_vars)), dtype=np.float32
|
| 481 |
+
)
|
| 482 |
+
for var_name, ch in self._in_ch.items():
|
| 483 |
+
if var_name in fields:
|
| 484 |
+
x[:, :, ch] = fields[var_name]
|
| 485 |
+
elif var_name in forcing:
|
| 486 |
+
x[:, :, ch] = forcing[var_name]
|
| 487 |
+
# else: stays zero (should not happen for valid inputs)
|
| 488 |
+
|
| 489 |
+
x = x * self._norm_mul[np.newaxis, np.newaxis, :] + self._norm_add[
|
| 490 |
+
np.newaxis, np.newaxis, :
|
| 491 |
+
]
|
| 492 |
+
|
| 493 |
+
# ---- 4. Model forward ----------------------------------------
|
| 494 |
+
x_t = torch.from_numpy(x).unsqueeze(0).to(self.device)
|
| 495 |
+
with torch.no_grad():
|
| 496 |
+
pred = self.model.predict(x_t) # (1, G, V_out)
|
| 497 |
+
pred_np = pred[0].cpu().numpy() # (G, V_out)
|
| 498 |
+
|
| 499 |
+
# ---- 5. Denormalise output -----------------------------------
|
| 500 |
+
pred_phys = (
|
| 501 |
+
pred_np * self._denorm_mul[np.newaxis, :]
|
| 502 |
+
+ self._denorm_add[np.newaxis, :]
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
# ---- 6. Parse into fields dict -------------------------------
|
| 506 |
+
next_fields: Dict[str, np.ndarray] = {}
|
| 507 |
+
for var_name, ch in self._out_ch.items():
|
| 508 |
+
next_fields[var_name] = pred_phys[:, ch].copy()
|
| 509 |
+
|
| 510 |
+
# ---- 7. Save state -------------------------------------------
|
| 511 |
+
next_date = t0 + datetime.timedelta(hours=6)
|
| 512 |
+
states.append(
|
| 513 |
+
{
|
| 514 |
+
"date": next_date,
|
| 515 |
+
"latitudes": latitudes.copy(),
|
| 516 |
+
"longitudes": longitudes.copy(),
|
| 517 |
+
"fields": {k: v.copy() for k, v in next_fields.items()},
|
| 518 |
+
}
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
# ---- 8. Shift for next autoregressive step -------------------
|
| 522 |
+
# New window: [old t0, prediction]
|
| 523 |
+
shifted: Dict[str, np.ndarray] = {}
|
| 524 |
+
for var_name in fields:
|
| 525 |
+
if var_name in next_fields:
|
| 526 |
+
shifted[var_name] = np.stack(
|
| 527 |
+
[fields[var_name][1], next_fields[var_name]], axis=0
|
| 528 |
+
)
|
| 529 |
+
elif var_name in self._STATIC_NAMES:
|
| 530 |
+
# Static fields: keep invariant
|
| 531 |
+
shifted[var_name] = fields[var_name].copy()
|
| 532 |
+
fields = shifted
|
| 533 |
+
current_date = next_date
|
| 534 |
+
|
| 535 |
+
# 更新进度条
|
| 536 |
+
elapsed = time.time() - t_start
|
| 537 |
+
pbar.set_postfix({
|
| 538 |
+
"date": next_date.strftime("%m-%d %H:%M"),
|
| 539 |
+
"spd": f"{elapsed / (step + 1):.1f}s",
|
| 540 |
+
})
|
| 541 |
+
|
| 542 |
+
elapsed = time.time() - t_start
|
| 543 |
+
print(
|
| 544 |
+
f"[INFO] Forecast done: {num_steps} steps in {elapsed:.1f}s "
|
| 545 |
+
f"({elapsed / max(num_steps, 1):.2f}s/step)"
|
| 546 |
+
)
|
| 547 |
+
return states
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
# ============================================================================
|
| 551 |
+
# Main
|
| 552 |
+
# ============================================================================
|
| 553 |
+
|
| 554 |
+
def main():
|
| 555 |
+
p = argparse.ArgumentParser(description="AIFS v1.1 — 10-day Inference")
|
| 556 |
+
p.add_argument("-c", "--config", default=str(ROOT / "conf" / "config.yaml"))
|
| 557 |
+
p.add_argument("--checkpoint", default=None, help="override checkpoint path")
|
| 558 |
+
p.add_argument("--year", type=int, default=None, help="override test year")
|
| 559 |
+
p.add_argument(
|
| 560 |
+
"--lead_time", type=int, default=None, help="override lead time (h)"
|
| 561 |
+
)
|
| 562 |
+
p.add_argument("-o", "--output_dir", default=None)
|
| 563 |
+
p.add_argument("--device", default=None)
|
| 564 |
+
p.add_argument("--device_ids", default=None)
|
| 565 |
+
args = p.parse_args()
|
| 566 |
+
|
| 567 |
+
cfg = load_config(args.config) if os.path.exists(args.config) else {}
|
| 568 |
+
hw = cfg.get("hardware", {})
|
| 569 |
+
ck = cfg.get("checkpoint", {})
|
| 570 |
+
data = cfg.get("data", {})
|
| 571 |
+
out = cfg.get("output", {})
|
| 572 |
+
|
| 573 |
+
device_str = args.device or hw.get("device", "dcu")
|
| 574 |
+
device_ids = args.device_ids or str(hw.get("device_ids", "0"))
|
| 575 |
+
ckpt = args.checkpoint or ck.get("pretrained", "")
|
| 576 |
+
test_years = data.get("test_years", [2008])
|
| 577 |
+
test_year = args.year or test_years[0]
|
| 578 |
+
lead_time = args.lead_time or data.get("test_lead_time", 240)
|
| 579 |
+
era5_dir = data.get("data_dir", str(ROOT / "data" / "fake_era5"))
|
| 580 |
+
out_dir = args.output_dir or str(ROOT / "output")
|
| 581 |
+
|
| 582 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = device_ids
|
| 583 |
+
if device_str == "dcu":
|
| 584 |
+
os.environ["HIP_VISIBLE_DEVICES"] = device_ids
|
| 585 |
+
os.environ.setdefault(
|
| 586 |
+
"PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True"
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 590 |
+
if device_str == "cpu":
|
| 591 |
+
print("warning: CPU running")
|
| 592 |
+
device = "cpu"
|
| 593 |
+
|
| 594 |
+
# ---- Analysis date: Jan 1 06:00 of test_year -------------------------
|
| 595 |
+
step_idx = 1 # second 6h step → Jan 1 06:00
|
| 596 |
+
analysis_date = datetime.datetime(
|
| 597 |
+
test_year, 1, 1, 6, 0, 0, tzinfo=pytz.utc
|
| 598 |
+
)
|
| 599 |
+
date_str = analysis_date.strftime("%Y%m%d%H")
|
| 600 |
+
|
| 601 |
+
print(f"\n{'='*60}\n AIFS v1.1 — 10-day Forecast\n{'='*60}")
|
| 602 |
+
print(f" Test year : {test_year} | analysis: {date_str}")
|
| 603 |
+
print(
|
| 604 |
+
f" Lead time : {lead_time}h ({lead_time // 6} steps)"
|
| 605 |
+
)
|
| 606 |
+
print(f" Device : {device} | ckpt: {ckpt}\n{'='*60}\n")
|
| 607 |
+
|
| 608 |
+
try:
|
| 609 |
+
# ---- 1. Load AIFS model ----------------------------------------
|
| 610 |
+
t0 = time.time()
|
| 611 |
+
model = AIFS(ckpt, device=device, pretrained=True)
|
| 612 |
+
print(f"[INFO] Model loaded ({time.time() - t0:.1f}s)")
|
| 613 |
+
|
| 614 |
+
input_vars = model.input_variables
|
| 615 |
+
output_vars = model.output_variables
|
| 616 |
+
grid_lat = model.latitudes.cpu().numpy()
|
| 617 |
+
grid_lon = model.longitudes.cpu().numpy()
|
| 618 |
+
|
| 619 |
+
# ---- 2. Compute normalisation params ---------------------------
|
| 620 |
+
stats_path = cfg["normalizer"].get("statistics_path") or None
|
| 621 |
+
norm_mul_in, norm_add_in = compute_normalisation_params(
|
| 622 |
+
cfg, input_vars, statistics_path=stats_path,
|
| 623 |
+
)
|
| 624 |
+
norm_mul_out, norm_add_out = compute_normalisation_params(
|
| 625 |
+
cfg, output_vars, statistics_path=stats_path,
|
| 626 |
+
)
|
| 627 |
+
denorm_mul, denorm_add = build_denorm_params(norm_mul_out, norm_add_out)
|
| 628 |
+
|
| 629 |
+
# ---- 3. Load ERA5 initial frames -------------------------------
|
| 630 |
+
era5_list = build_era5_variable_list(cfg)
|
| 631 |
+
|
| 632 |
+
# Auto-correct names against available H5 variables
|
| 633 |
+
data_dir = os.path.join(era5_dir, "data")
|
| 634 |
+
if os.path.isdir(data_dir):
|
| 635 |
+
h5_files = sorted(
|
| 636 |
+
[f for f in os.listdir(data_dir) if f.endswith(".h5")]
|
| 637 |
+
)
|
| 638 |
+
available_set: set = set()
|
| 639 |
+
if h5_files:
|
| 640 |
+
import h5py
|
| 641 |
+
|
| 642 |
+
with h5py.File(
|
| 643 |
+
os.path.join(data_dir, h5_files[0]), "r"
|
| 644 |
+
) as f:
|
| 645 |
+
available_vars = [
|
| 646 |
+
v.decode() if isinstance(v, bytes) else v
|
| 647 |
+
for v in f["fields"].attrs["variables"]
|
| 648 |
+
]
|
| 649 |
+
available_set = set(available_vars)
|
| 650 |
+
corrected = _auto_correct_variable_names(
|
| 651 |
+
era5_list, available_set, cfg
|
| 652 |
+
)
|
| 653 |
+
used_vars = [v for v in corrected if v in available_set]
|
| 654 |
+
missing = [v for v in corrected if v not in available_set]
|
| 655 |
+
if missing:
|
| 656 |
+
print(
|
| 657 |
+
f"[WARN] {len(missing)} vars missing from H5: {missing[:10]}"
|
| 658 |
+
)
|
| 659 |
+
else:
|
| 660 |
+
used_vars = era5_list
|
| 661 |
+
|
| 662 |
+
# Build diagnostic map with corrected names
|
| 663 |
+
diag_map: Dict[str, str] = {}
|
| 664 |
+
for aifs_name, orig in cfg["era5_mapping"]["diagnostic"].items():
|
| 665 |
+
cn = _auto_correct_variable_names([orig], available_set, cfg)[0]
|
| 666 |
+
diag_map[aifs_name] = (
|
| 667 |
+
cn if cn in available_set else orig
|
| 668 |
+
)
|
| 669 |
+
|
| 670 |
+
ft6, ft0 = load_frames(era5_dir, test_year, step_idx, used_vars)
|
| 671 |
+
fields = build_aifs_fields_from_frames(
|
| 672 |
+
ft6, ft0, used_vars, cfg, diag_map=diag_map
|
| 673 |
+
)
|
| 674 |
+
|
| 675 |
+
# ---- 4. Autoregressive forecast --------------------------------
|
| 676 |
+
forecaster = AIFSForecaster(
|
| 677 |
+
model,
|
| 678 |
+
norm_mul_in,
|
| 679 |
+
norm_add_in,
|
| 680 |
+
denorm_mul,
|
| 681 |
+
denorm_add,
|
| 682 |
+
device=device,
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
initial_state = {
|
| 686 |
+
"date": analysis_date,
|
| 687 |
+
"fields": fields,
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
states = forecaster.forecast(
|
| 691 |
+
initial_fields=fields,
|
| 692 |
+
start_date=analysis_date,
|
| 693 |
+
lead_time_hours=lead_time,
|
| 694 |
+
latitudes=grid_lat,
|
| 695 |
+
longitudes=grid_lon,
|
| 696 |
+
)
|
| 697 |
+
|
| 698 |
+
# ---- 5. Save to .npz -------------------------------------------
|
| 699 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 700 |
+
for s in states:
|
| 701 |
+
vt = s["date"].strftime("%Y%m%d%H")
|
| 702 |
+
path = os.path.join(out_dir, f"aifs_forecast_{vt}.npz")
|
| 703 |
+
d = dict(
|
| 704 |
+
latitudes=s["latitudes"], longitudes=s["longitudes"]
|
| 705 |
+
)
|
| 706 |
+
d.update(s["fields"])
|
| 707 |
+
np.savez_compressed(
|
| 708 |
+
path, **d, date=np.array(str(s["date"]))
|
| 709 |
+
)
|
| 710 |
+
print(f"[INFO] Saved: {path}")
|
| 711 |
+
|
| 712 |
+
print(f"[INFO] Done — {len(states)} files → {out_dir}")
|
| 713 |
+
|
| 714 |
+
except Exception:
|
| 715 |
+
traceback.print_exc()
|
| 716 |
+
sys.exit(1)
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
if __name__ == "__main__":
|
| 720 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# coding: utf-8
|
| 3 |
+
"""
|
| 4 |
+
AIFS v1.1 — Evaluation & Visualisation
|
| 5 |
+
========================================
|
| 6 |
+
Computes RMSE and ACC (Anomaly Correlation Coefficient) on inference
|
| 7 |
+
results, then plots selected variables on the N320 Gaussian grid.
|
| 8 |
+
|
| 9 |
+
Usage:
|
| 10 |
+
python scripts/result.py
|
| 11 |
+
python scripts/result.py -v 2t,z_500,tp
|
| 12 |
+
python scripts/result.py -v all
|
| 13 |
+
python scripts/result.py --no-metrics # plot only
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import glob
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import yaml
|
| 26 |
+
from tqdm import tqdm
|
| 27 |
+
|
| 28 |
+
# Project root (scripts/ → aifs_v11/)
|
| 29 |
+
ROOT = Path(__file__).parent.parent
|
| 30 |
+
sys.path.insert(0, str(ROOT))
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ============================================================================
|
| 34 |
+
# Variable metadata
|
| 35 |
+
# ============================================================================
|
| 36 |
+
_VAR_META = {
|
| 37 |
+
"10u": ("10m U-wind", "RdBu_r", "m/s"),
|
| 38 |
+
"10v": ("10m V-wind", "RdBu_r", "m/s"),
|
| 39 |
+
"2t": ("2m Temperature", "RdYlBu_r", "K"),
|
| 40 |
+
"2d": ("2m Dewpoint", "RdYlBu_r", "K"),
|
| 41 |
+
"msl": ("MSLP", "RdYlBu_r", "Pa"),
|
| 42 |
+
"skt": ("Skin Temp", "RdYlBu_r", "K"),
|
| 43 |
+
"sp": ("Surface Pressure", "RdYlBu_r", "Pa"),
|
| 44 |
+
"tcw": ("Total Column Water", "Blues", "kg/m²"),
|
| 45 |
+
"z": ("Surface Geopotential", "terrain", "m²/s²"),
|
| 46 |
+
"tp": ("Total Precip", "YlGnBu", "m"),
|
| 47 |
+
"cp": ("Convective Precip", "YlGnBu", "m"),
|
| 48 |
+
"sf": ("Snowfall", "PuBu", "m"),
|
| 49 |
+
"tcc": ("Total Cloud Cover", "Greys", "0-1"),
|
| 50 |
+
"lcc": ("Low Cloud", "Greys", "0-1"),
|
| 51 |
+
"mcc": ("Mid Cloud", "Greys", "0-1"),
|
| 52 |
+
"hcc": ("High Cloud", "Greys", "0-1"),
|
| 53 |
+
"ro": ("Runoff", "YlGnBu", "m"),
|
| 54 |
+
"ssrd": ("Solar Radiation", "YlOrRd", "J/m²"),
|
| 55 |
+
"strd": ("Thermal Radiation", "YlOrRd", "J/m²"),
|
| 56 |
+
"100u": ("100m U-wind", "RdBu_r", "m/s"),
|
| 57 |
+
"100v": ("100m V-wind", "RdBu_r", "m/s"),
|
| 58 |
+
"stl1": ("Soil Temp L1", "RdYlBu_r", "K"),
|
| 59 |
+
"stl2": ("Soil Temp L2", "RdYlBu_r", "K"),
|
| 60 |
+
"swvl1": ("Soil Moisture L1", "Blues", "m³/m³"),
|
| 61 |
+
"swvl2": ("Soil Moisture L2", "Blues", "m³/m³"),
|
| 62 |
+
}
|
| 63 |
+
_PL_META = {
|
| 64 |
+
"z": ("Geopotential", "RdYlBu_r", "m²/s²"),
|
| 65 |
+
"t": ("Temperature", "RdYlBu_r", "K"),
|
| 66 |
+
"u": ("U-wind", "RdBu_r", "m/s"),
|
| 67 |
+
"v": ("V-wind", "RdBu_r", "m/s"),
|
| 68 |
+
"w": ("Vertical Velocity", "RdBu_r", "Pa/s"),
|
| 69 |
+
"q": ("Specific Humidity", "Blues", "kg/kg"),
|
| 70 |
+
}
|
| 71 |
+
for _a, (_desc, _cmap, _unit) in list(_PL_META.items()):
|
| 72 |
+
for _l in [1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, 100, 50]:
|
| 73 |
+
_VAR_META[f"{_a}_{_l}"] = (f"{_desc} {_l}hPa", _cmap, _unit)
|
| 74 |
+
|
| 75 |
+
DEFAULT_VARS = ["10u", "2t", "msl", "tp", "z_500", "t_850"]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ============================================================================
|
| 79 |
+
# Helpers
|
| 80 |
+
# ============================================================================
|
| 81 |
+
|
| 82 |
+
def _load_config():
|
| 83 |
+
config_path = ROOT / "conf" / "config.yaml"
|
| 84 |
+
if config_path.exists():
|
| 85 |
+
return yaml.safe_load(open(config_path))
|
| 86 |
+
return {}
|
| 87 |
+
|
| 88 |
+
# ============================================================================
|
| 89 |
+
# ACC / RMSE computation
|
| 90 |
+
# ============================================================================
|
| 91 |
+
|
| 92 |
+
def compute_metrics(pred_dir: str, label_dir: str, test_years: list):
|
| 93 |
+
"""Compute per-variable RMSE and ACC vs ERA5 ground truth.
|
| 94 |
+
|
| 95 |
+
Parameters
|
| 96 |
+
----------
|
| 97 |
+
pred_dir : str
|
| 98 |
+
Directory of ``aifs_forecast_*.npz`` files.
|
| 99 |
+
label_dir : str
|
| 100 |
+
Root of ERA5 H5 data (contains ``data/{year}.h5``).
|
| 101 |
+
test_years : list[int]
|
| 102 |
+
Years to evaluate.
|
| 103 |
+
"""
|
| 104 |
+
import h5py
|
| 105 |
+
|
| 106 |
+
files = sorted(glob.glob(os.path.join(pred_dir, "aifs_forecast_*.npz")))
|
| 107 |
+
if not files:
|
| 108 |
+
raise FileNotFoundError(f"No forecast .npz in {pred_dir}")
|
| 109 |
+
|
| 110 |
+
# Discover variables from first prediction
|
| 111 |
+
sample = np.load(files[0])
|
| 112 |
+
var_names = sorted(
|
| 113 |
+
k for k in sample.keys()
|
| 114 |
+
if k not in ("latitudes", "longitudes", "date")
|
| 115 |
+
)
|
| 116 |
+
num_vars = len(var_names)
|
| 117 |
+
sample.close()
|
| 118 |
+
|
| 119 |
+
# Build AIFS → ERA5 H5 channel mapping from config
|
| 120 |
+
cfg = _load_config()
|
| 121 |
+
em = cfg.get("era5_mapping", {})
|
| 122 |
+
av = cfg.get("aifs_variables", {})
|
| 123 |
+
aifs_to_era5 = {}
|
| 124 |
+
for cat in ["surface", "soil", "diagnostic"]:
|
| 125 |
+
for aifs_name, era5_name in em.get(cat, {}).items():
|
| 126 |
+
aifs_to_era5[aifs_name] = era5_name
|
| 127 |
+
for vv in av.get("pressure_level", []):
|
| 128 |
+
tpl = em.get("pressure_level", {}).get(vv, "")
|
| 129 |
+
for lvl in av.get("pressure_levels", []):
|
| 130 |
+
aifs_to_era5[f"{vv}_{lvl}"] = tpl.format(level=lvl)
|
| 131 |
+
|
| 132 |
+
# Accumulators
|
| 133 |
+
num_samples = 0
|
| 134 |
+
numerator = np.zeros(num_vars, dtype=np.float64)
|
| 135 |
+
pred_sq = np.zeros(num_vars, dtype=np.float64)
|
| 136 |
+
label_sq = np.zeros(num_vars, dtype=np.float64)
|
| 137 |
+
rmse_sum = np.zeros(num_vars, dtype=np.float64)
|
| 138 |
+
|
| 139 |
+
pbar = tqdm(files, desc="Computing metrics", unit="file")
|
| 140 |
+
for fp in pbar:
|
| 141 |
+
data = np.load(fp)
|
| 142 |
+
fname = os.path.splitext(os.path.basename(fp))[0]
|
| 143 |
+
date_str = fname.replace("aifs_forecast_", "")
|
| 144 |
+
year = int(date_str[:4])
|
| 145 |
+
if year not in test_years:
|
| 146 |
+
data.close()
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
# Load corresponding ERA5 label
|
| 150 |
+
h5_path = os.path.join(label_dir, "data", f"{year}.h5")
|
| 151 |
+
if not os.path.exists(h5_path):
|
| 152 |
+
data.close()
|
| 153 |
+
continue
|
| 154 |
+
|
| 155 |
+
with h5py.File(h5_path, "r") as hf:
|
| 156 |
+
ds = hf["fields"]
|
| 157 |
+
h5_vars = [v.decode() if isinstance(v, bytes) else v
|
| 158 |
+
for v in ds.attrs["variables"]]
|
| 159 |
+
h5_time_step = int(ds.attrs.get("time_step", 6))
|
| 160 |
+
# Parse date → time index
|
| 161 |
+
from datetime import datetime
|
| 162 |
+
dt = datetime.strptime(date_str, "%Y%m%d%H")
|
| 163 |
+
year_start = datetime(dt.year, 1, 1)
|
| 164 |
+
hours = (dt - year_start).total_seconds() / 3600
|
| 165 |
+
t_idx = int(hours / h5_time_step)
|
| 166 |
+
if t_idx >= ds.shape[0]:
|
| 167 |
+
data.close()
|
| 168 |
+
continue
|
| 169 |
+
label_full = hf["fields"][t_idx] # (C, 721, 1440)
|
| 170 |
+
|
| 171 |
+
# Build per-file H5 channel index
|
| 172 |
+
h5_ch_map = {}
|
| 173 |
+
for vi, vname in enumerate(var_names):
|
| 174 |
+
era5_name = aifs_to_era5.get(vname)
|
| 175 |
+
if era5_name and era5_name in h5_vars:
|
| 176 |
+
h5_ch_map[vi] = h5_vars.index(era5_name)
|
| 177 |
+
|
| 178 |
+
if not h5_ch_map:
|
| 179 |
+
data.close()
|
| 180 |
+
continue
|
| 181 |
+
|
| 182 |
+
# Regrid labels to N320
|
| 183 |
+
label_n320 = {}
|
| 184 |
+
for vi, h5_ch in h5_ch_map.items():
|
| 185 |
+
arr_2d = label_full[h5_ch]
|
| 186 |
+
arr_1d = _interp_to_n320(arr_2d)
|
| 187 |
+
if arr_1d is not None:
|
| 188 |
+
label_n320[vi] = arr_1d
|
| 189 |
+
|
| 190 |
+
# Compute metrics per variable
|
| 191 |
+
for vi, label_1d in label_n320.items():
|
| 192 |
+
pred = data[var_names[vi]]
|
| 193 |
+
|
| 194 |
+
# RMSE
|
| 195 |
+
sq_err = (pred - label_1d) ** 2
|
| 196 |
+
rmse_sum[vi] += np.sqrt(sq_err.mean())
|
| 197 |
+
|
| 198 |
+
# ACC: anomaly correlation (anomaly = deviation from spatial mean)
|
| 199 |
+
pred_anom = pred - pred.mean()
|
| 200 |
+
label_anom = label_1d - label_1d.mean()
|
| 201 |
+
|
| 202 |
+
numerator[vi] += (pred_anom * label_anom).sum()
|
| 203 |
+
pred_sq[vi] += (pred_anom ** 2).sum()
|
| 204 |
+
label_sq[vi] += (label_anom ** 2).sum()
|
| 205 |
+
|
| 206 |
+
num_samples += 1
|
| 207 |
+
data.close()
|
| 208 |
+
|
| 209 |
+
if num_samples == 0:
|
| 210 |
+
raise RuntimeError("No matching label data found")
|
| 211 |
+
|
| 212 |
+
rmse = rmse_sum / num_samples
|
| 213 |
+
denom = np.sqrt(pred_sq * label_sq)
|
| 214 |
+
denom = np.where(denom > 1e-8, denom, 1.0)
|
| 215 |
+
acc = numerator / denom
|
| 216 |
+
|
| 217 |
+
return var_names, rmse, acc
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _interp_to_n320(field_2d: np.ndarray) -> np.ndarray | None:
|
| 221 |
+
"""Interpolate (721, 1440) → N320 (542080,)."""
|
| 222 |
+
try:
|
| 223 |
+
import earthkit.regrid as ekr
|
| 224 |
+
result = ekr.interpolate(
|
| 225 |
+
field_2d[np.newaxis, ...].astype(np.float64),
|
| 226 |
+
{"grid": (0.25, 0.25)},
|
| 227 |
+
{"grid": "N320"},
|
| 228 |
+
)
|
| 229 |
+
return result.flatten().astype(np.float64)
|
| 230 |
+
except Exception:
|
| 231 |
+
return None
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ============================================================================
|
| 235 |
+
# Display
|
| 236 |
+
# ============================================================================
|
| 237 |
+
|
| 238 |
+
def print_metrics(var_names, rmse, acc):
|
| 239 |
+
w = 24
|
| 240 |
+
print(f"\n┌{'─' * (w + 2)}┬{'─' * 14}┬{'─' * 14}┐")
|
| 241 |
+
print(f"│ {'Variable':<{w}} │ {'RMSE':>12} │ {'ACC':>12} │")
|
| 242 |
+
print(f"├{'─' * (w + 2)}┼{'─' * 14}┼{'─' * 14}┤")
|
| 243 |
+
for i in range(len(var_names)):
|
| 244 |
+
print(f"│ {var_names[i]:<{w}} │ {rmse[i]:>12.6f} │ {acc[i]:>12.6f} │")
|
| 245 |
+
print(f"├{'─' * (w + 2)}┼{'─' * 14}┼{'─' * 14}┤")
|
| 246 |
+
print(f"│ {'Average':<{w}} │ {np.mean(rmse):>12.6f} │ {np.mean(acc):>12.6f} │")
|
| 247 |
+
print(f"└{'─' * (w + 2)}┴{'─' * 14}┴{'─' * 14}┘")
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ============================================================================
|
| 251 |
+
# Plotting
|
| 252 |
+
# ============================================================================
|
| 253 |
+
|
| 254 |
+
def plot_field(state, var_name, out_dir):
|
| 255 |
+
import matplotlib
|
| 256 |
+
matplotlib.use("Agg")
|
| 257 |
+
import matplotlib.pyplot as plt
|
| 258 |
+
import cartopy.crs as ccrs
|
| 259 |
+
import cartopy.feature as cfeat
|
| 260 |
+
import matplotlib.tri as tri
|
| 261 |
+
|
| 262 |
+
lats = state.get("latitudes")
|
| 263 |
+
lons = state.get("longitudes")
|
| 264 |
+
values = state.get("fields", {}).get(var_name)
|
| 265 |
+
if lats is None or values is None:
|
| 266 |
+
return None
|
| 267 |
+
|
| 268 |
+
desc, cmap, unit = _VAR_META.get(var_name, (var_name, "viridis", ""))
|
| 269 |
+
|
| 270 |
+
fig, ax = plt.subplots(
|
| 271 |
+
figsize=(11, 6),
|
| 272 |
+
subplot_kw={"projection": ccrs.PlateCarree()},
|
| 273 |
+
)
|
| 274 |
+
ax.coastlines(linewidth=0.5)
|
| 275 |
+
ax.add_feature(cfeat.BORDERS, linestyle=":", linewidth=0.3)
|
| 276 |
+
ax.set_global()
|
| 277 |
+
|
| 278 |
+
lons_adj = np.where(lons > 180, lons - 360, lons)
|
| 279 |
+
t = tri.Triangulation(lons_adj, lats)
|
| 280 |
+
c = ax.tricontourf(t, values, levels=20, transform=ccrs.PlateCarree(),
|
| 281 |
+
cmap=cmap)
|
| 282 |
+
cb = fig.colorbar(c, ax=ax, orientation="vertical", shrink=0.7, pad=0.02)
|
| 283 |
+
if unit:
|
| 284 |
+
cb.set_label(unit)
|
| 285 |
+
|
| 286 |
+
vs = str(state.get("date", "unknown")).replace("T", " ")[:16]
|
| 287 |
+
plt.title(f"{desc} | valid {vs}", fontsize=12)
|
| 288 |
+
|
| 289 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 290 |
+
path = os.path.join(out_dir, f"{var_name}_{vs.replace(' ', '_').replace(':','')}.png")
|
| 291 |
+
fig.savefig(path, dpi=150, bbox_inches="tight")
|
| 292 |
+
plt.close(fig)
|
| 293 |
+
return path
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# ============================================================================
|
| 297 |
+
# Main
|
| 298 |
+
# ============================================================================
|
| 299 |
+
|
| 300 |
+
def main():
|
| 301 |
+
p = argparse.ArgumentParser(description="AIFS v1.1 — Evaluate & Visualise")
|
| 302 |
+
p.add_argument("-c", "--config",
|
| 303 |
+
default=str(ROOT / "conf" / "config.yaml"))
|
| 304 |
+
p.add_argument("-i", "--input_dir", default=None)
|
| 305 |
+
p.add_argument("-p", "--plot_dir", default=None)
|
| 306 |
+
p.add_argument("-v", "--variables", default="",
|
| 307 |
+
help="comma-separated vars; empty=default, 'all'=all")
|
| 308 |
+
p.add_argument("-l", "--list", action="store_true",
|
| 309 |
+
help="list available vars and exit")
|
| 310 |
+
p.add_argument("--no-metrics", action="store_true",
|
| 311 |
+
help="skip ACC/RMSE (plot only)")
|
| 312 |
+
args = p.parse_args()
|
| 313 |
+
|
| 314 |
+
cfg = _load_config()
|
| 315 |
+
out_cfg = cfg.get("output", {})
|
| 316 |
+
data_cfg = cfg.get("data", {})
|
| 317 |
+
|
| 318 |
+
in_dir = args.input_dir or str(ROOT / "output")
|
| 319 |
+
plt_dir = args.plot_dir or str(ROOT / "plots")
|
| 320 |
+
test_years = data_cfg.get("test_years", [2008])
|
| 321 |
+
|
| 322 |
+
files = sorted(glob.glob(os.path.join(in_dir, "aifs_forecast_*.npz")))
|
| 323 |
+
if not files:
|
| 324 |
+
print(f"[ERROR] No aifs_forecast_*.npz in {in_dir}")
|
| 325 |
+
sys.exit(1)
|
| 326 |
+
|
| 327 |
+
s = np.load(files[0])
|
| 328 |
+
avail = [k for k in s.keys()
|
| 329 |
+
if k not in ("latitudes", "longitudes", "date")]
|
| 330 |
+
s.close()
|
| 331 |
+
|
| 332 |
+
if args.list:
|
| 333 |
+
print(f"\nAvailable variables ({len(avail)}):")
|
| 334 |
+
for v in avail:
|
| 335 |
+
d, _, u = _VAR_META.get(v, (v, "", ""))
|
| 336 |
+
print(f" {v:12s} {d}")
|
| 337 |
+
return
|
| 338 |
+
|
| 339 |
+
if args.variables == "":
|
| 340 |
+
vars_ = [v for v in DEFAULT_VARS if v in avail]
|
| 341 |
+
elif args.variables == "all":
|
| 342 |
+
vars_ = avail
|
| 343 |
+
else:
|
| 344 |
+
vars_ = [v.strip() for v in args.variables.split(",")
|
| 345 |
+
if v.strip() in avail]
|
| 346 |
+
|
| 347 |
+
# ---- ACC / RMSE ----
|
| 348 |
+
if not args.no_metrics:
|
| 349 |
+
label_dir = data_cfg.get("data_dir", "")
|
| 350 |
+
if label_dir and os.path.isdir(os.path.join(label_dir, "data")):
|
| 351 |
+
try:
|
| 352 |
+
var_names, rmse, acc = compute_metrics(
|
| 353 |
+
in_dir, label_dir, test_years,
|
| 354 |
+
)
|
| 355 |
+
print_metrics(var_names, rmse, acc)
|
| 356 |
+
# Save metrics
|
| 357 |
+
metrics_dir = ROOT / "metrics"
|
| 358 |
+
os.makedirs(str(metrics_dir), exist_ok=True)
|
| 359 |
+
np.save(str(metrics_dir / "rmse.npy"), rmse)
|
| 360 |
+
np.save(str(metrics_dir / "acc.npy"), acc)
|
| 361 |
+
with open(str(metrics_dir / "metrics.txt"), "w") as f:
|
| 362 |
+
f.write(f"{'Variable':<24s} {'RMSE':>12s} {'ACC':>12s}\n")
|
| 363 |
+
for i, v in enumerate(var_names):
|
| 364 |
+
f.write(f"{v:<24s} {rmse[i]:>12.6f} {acc[i]:>12.6f}\n")
|
| 365 |
+
print(f"[INFO] Metrics saved to metrics/")
|
| 366 |
+
except Exception as e:
|
| 367 |
+
print(f"[WARN] ACC/RMSE skipped: {e}")
|
| 368 |
+
else:
|
| 369 |
+
print("[INFO] No label data found — skipping ACC/RMSE")
|
| 370 |
+
|
| 371 |
+
# ---- Plotting ----
|
| 372 |
+
print(f"[INFO] {len(files)} file(s), {len(vars_)} var(s): {', '.join(vars_[:6])}")
|
| 373 |
+
total = 0
|
| 374 |
+
for fp in files:
|
| 375 |
+
d = np.load(fp)
|
| 376 |
+
st = dict(
|
| 377 |
+
date=str(d.get("date", "unknown")),
|
| 378 |
+
latitudes=d["latitudes"],
|
| 379 |
+
longitudes=d["longitudes"],
|
| 380 |
+
fields={k: d[k] for k in avail if k in d},
|
| 381 |
+
)
|
| 382 |
+
for v in vars_:
|
| 383 |
+
out_path = plot_field(st, v, plt_dir)
|
| 384 |
+
if out_path:
|
| 385 |
+
total += 1
|
| 386 |
+
d.close()
|
| 387 |
+
|
| 388 |
+
if total > 0:
|
| 389 |
+
print(f"[INFO] Done: {total} plot(s) → {plt_dir}")
|
| 390 |
+
# Print only first few to avoid flooding
|
| 391 |
+
else:
|
| 392 |
+
print("[WARN] No plots generated")
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
if __name__ == "__main__":
|
| 396 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
# coding: utf-8
|
| 3 |
+
"""
|
| 4 |
+
AIFS v1.1 — Pre-training Script
|
| 5 |
+
=================================
|
| 6 |
+
Training logic strictly follows config_pretraining.yaml and anemoi-training 0.4.0.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
python scripts/train.py
|
| 10 |
+
python scripts/train.py --config conf/config.yaml
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import argparse
|
| 16 |
+
import datetime
|
| 17 |
+
import math
|
| 18 |
+
import os
|
| 19 |
+
import sys
|
| 20 |
+
import time
|
| 21 |
+
import traceback
|
| 22 |
+
import warnings
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
from typing import Dict, List, Optional, Tuple
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import pytz
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
from tqdm import tqdm
|
| 31 |
+
import yaml
|
| 32 |
+
|
| 33 |
+
# Project root (scripts/ → aifs_v11/)
|
| 34 |
+
ROOT = Path(__file__).parent.parent
|
| 35 |
+
sys.path.insert(0, str(ROOT))
|
| 36 |
+
|
| 37 |
+
import onescience.datapipes.climate.era5 as onescience_era5
|
| 38 |
+
import earthkit.regrid as ekr
|
| 39 |
+
|
| 40 |
+
from model.aifs import AIFS
|
| 41 |
+
|
| 42 |
+
# ============================================================================
|
| 43 |
+
# Config loader
|
| 44 |
+
# ============================================================================
|
| 45 |
+
|
| 46 |
+
def load_config(path: str) -> dict:
|
| 47 |
+
with open(path) as f:
|
| 48 |
+
return yaml.safe_load(f)
|
| 49 |
+
|
| 50 |
+
# ============================================================================
|
| 51 |
+
# Variable list builder
|
| 52 |
+
# ============================================================================
|
| 53 |
+
|
| 54 |
+
def build_era5_variable_list(cfg: dict) -> List[str]:
|
| 55 |
+
av = cfg["aifs_variables"]
|
| 56 |
+
em = cfg["era5_mapping"]
|
| 57 |
+
vars_: List[str] = []
|
| 58 |
+
for name in av["surface"]:
|
| 59 |
+
vars_.append(em["surface"][name])
|
| 60 |
+
for name in av["soil"]:
|
| 61 |
+
vars_.append(em["soil"][name])
|
| 62 |
+
for v in av["pressure_level"]:
|
| 63 |
+
tpl = em["pressure_level"][v]
|
| 64 |
+
for lvl in av["pressure_levels"]:
|
| 65 |
+
vars_.append(tpl.format(level=lvl))
|
| 66 |
+
for name in av["diagnostic"]:
|
| 67 |
+
vars_.append(em["diagnostic"][name])
|
| 68 |
+
return vars_
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _auto_correct_variable_names(required: List[str], available: set, cfg: dict) -> List[str]:
|
| 72 |
+
fuzzy = cfg["era5_mapping"].get("fuzzy_fixes", {})
|
| 73 |
+
corrected = list(required)
|
| 74 |
+
for i, v in enumerate(corrected):
|
| 75 |
+
if v not in available and v in fuzzy:
|
| 76 |
+
alt = fuzzy[v]
|
| 77 |
+
if alt in available:
|
| 78 |
+
print(f"[INFO] Auto-corrected: '{v}' → '{alt}'")
|
| 79 |
+
corrected[i] = alt
|
| 80 |
+
return corrected
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _era5_to_aifs_name(era5_name: str, cfg: dict) -> Optional[str]:
|
| 84 |
+
em = cfg["era5_mapping"]
|
| 85 |
+
for aifs, ename in em["surface"].items():
|
| 86 |
+
if ename == era5_name: return aifs
|
| 87 |
+
for aifs, ename in em["soil"].items():
|
| 88 |
+
if ename == era5_name: return aifs
|
| 89 |
+
for v in cfg["aifs_variables"]["pressure_level"]:
|
| 90 |
+
tpl = em["pressure_level"][v]
|
| 91 |
+
for lvl in cfg["aifs_variables"]["pressure_levels"]:
|
| 92 |
+
if tpl.format(level=lvl) == era5_name: return f"{v}_{lvl}"
|
| 93 |
+
for aifs, ename in em["diagnostic"].items():
|
| 94 |
+
if ename == era5_name: return aifs
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
# ============================================================================
|
| 98 |
+
# N320 interpolation
|
| 99 |
+
# ============================================================================
|
| 100 |
+
|
| 101 |
+
def _interp_n320(arr: np.ndarray) -> np.ndarray:
|
| 102 |
+
return ekr.interpolate(arr, {"grid": (0.25, 0.25)}, {"grid": "N320"})
|
| 103 |
+
|
| 104 |
+
# ============================================================================
|
| 105 |
+
# ERA5 → AIFS field conversion
|
| 106 |
+
# ============================================================================
|
| 107 |
+
|
| 108 |
+
def build_aifs_fields_from_frames(
|
| 109 |
+
frame_tm6: np.ndarray, frame_t0: np.ndarray, frame_tp6: np.ndarray,
|
| 110 |
+
era5_var_list: List[str], cfg: dict,
|
| 111 |
+
diag_map: Optional[Dict[str, str]] = None,
|
| 112 |
+
) -> Dict[str, np.ndarray]:
|
| 113 |
+
av = cfg["aifs_variables"]
|
| 114 |
+
em = cfg["era5_mapping"]
|
| 115 |
+
name_to_ch = {n: i for i, n in enumerate(era5_var_list)}
|
| 116 |
+
fields: Dict[str, np.ndarray] = {}
|
| 117 |
+
|
| 118 |
+
for aifs_name in av["surface"]:
|
| 119 |
+
ch = name_to_ch[em["surface"][aifs_name]]
|
| 120 |
+
fields[aifs_name] = np.stack([_interp_n320(frame_tm6[ch]),
|
| 121 |
+
_interp_n320(frame_t0[ch]),
|
| 122 |
+
_interp_n320(frame_tp6[ch])])
|
| 123 |
+
for aifs_name in av["soil"]:
|
| 124 |
+
ch = name_to_ch[em["soil"][aifs_name]]
|
| 125 |
+
fields[aifs_name] = np.stack([_interp_n320(frame_tm6[ch]),
|
| 126 |
+
_interp_n320(frame_t0[ch]),
|
| 127 |
+
_interp_n320(frame_tp6[ch])])
|
| 128 |
+
for aifs_var in av["pressure_level"]:
|
| 129 |
+
tpl = em["pressure_level"][aifs_var]
|
| 130 |
+
for level in av["pressure_levels"]:
|
| 131 |
+
ch = name_to_ch[tpl.format(level=level)]
|
| 132 |
+
fields[f"{aifs_var}_{level}"] = np.stack([_interp_n320(frame_tm6[ch]),
|
| 133 |
+
_interp_n320(frame_t0[ch]),
|
| 134 |
+
_interp_n320(frame_tp6[ch])])
|
| 135 |
+
_diag_map = diag_map if diag_map is not None else em["diagnostic"]
|
| 136 |
+
for aifs_name in av["diagnostic"]:
|
| 137 |
+
era5_name = _diag_map.get(aifs_name)
|
| 138 |
+
if era5_name and era5_name in name_to_ch:
|
| 139 |
+
ch = name_to_ch[era5_name]
|
| 140 |
+
fields[aifs_name] = np.stack([_interp_n320(frame_tm6[ch]),
|
| 141 |
+
_interp_n320(frame_t0[ch]),
|
| 142 |
+
_interp_n320(frame_tp6[ch])])
|
| 143 |
+
return fields
|
| 144 |
+
|
| 145 |
+
# ============================================================================
|
| 146 |
+
# Forcing feature computation
|
| 147 |
+
# ============================================================================
|
| 148 |
+
|
| 149 |
+
def compute_forcing_features(
|
| 150 |
+
latitudes: np.ndarray, longitudes: np.ndarray,
|
| 151 |
+
timestamps: List[datetime.datetime],
|
| 152 |
+
fields: Dict[str, np.ndarray],
|
| 153 |
+
) -> Dict[str, np.ndarray]:
|
| 154 |
+
lat_rad = np.deg2rad(latitudes)
|
| 155 |
+
lon_rad = np.deg2rad(longitudes)
|
| 156 |
+
multi_step = len(timestamps)
|
| 157 |
+
forcing: Dict[str, np.ndarray] = {}
|
| 158 |
+
|
| 159 |
+
forcing["cos_latitude"] = np.tile(np.cos(lat_rad)[np.newaxis, :], (multi_step, 1))
|
| 160 |
+
forcing["sin_latitude"] = np.tile(np.sin(lat_rad)[np.newaxis, :], (multi_step, 1))
|
| 161 |
+
forcing["cos_longitude"] = np.tile(np.cos(lon_rad)[np.newaxis, :], (multi_step, 1))
|
| 162 |
+
forcing["sin_longitude"] = np.tile(np.sin(lon_rad)[np.newaxis, :], (multi_step, 1))
|
| 163 |
+
|
| 164 |
+
cos_jd = np.zeros((multi_step, len(latitudes)), dtype=np.float32)
|
| 165 |
+
sin_jd = np.zeros_like(cos_jd)
|
| 166 |
+
cos_lt = np.zeros_like(cos_jd)
|
| 167 |
+
sin_lt = np.zeros_like(cos_jd)
|
| 168 |
+
insol = np.zeros_like(cos_jd)
|
| 169 |
+
|
| 170 |
+
for t_idx, ts in enumerate(timestamps):
|
| 171 |
+
doy = ts.timetuple().tm_yday
|
| 172 |
+
jd_angle = 2.0 * np.pi * doy / 365.25
|
| 173 |
+
cos_jd[t_idx] = np.cos(jd_angle)
|
| 174 |
+
sin_jd[t_idx] = np.sin(jd_angle)
|
| 175 |
+
hours = ts.hour + ts.minute / 60.0 + ts.second / 3600.0
|
| 176 |
+
lt_angle = 2.0 * np.pi * hours / 24.0 + lon_rad
|
| 177 |
+
cos_lt[t_idx] = np.cos(lt_angle)
|
| 178 |
+
sin_lt[t_idx] = np.sin(lt_angle)
|
| 179 |
+
insol[t_idx] = _compute_insolation(ts, lat_rad, lon_rad)
|
| 180 |
+
|
| 181 |
+
forcing["cos_julian_day"] = cos_jd
|
| 182 |
+
forcing["sin_julian_day"] = sin_jd
|
| 183 |
+
forcing["cos_local_time"] = cos_lt
|
| 184 |
+
forcing["sin_local_time"] = sin_lt
|
| 185 |
+
forcing["insolation"] = insol
|
| 186 |
+
|
| 187 |
+
for var_name in ["lsm", "z", "slor", "sdor"]:
|
| 188 |
+
if var_name in fields:
|
| 189 |
+
forcing[var_name] = np.tile(fields[var_name][:1], (multi_step, 1))
|
| 190 |
+
return forcing
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _compute_insolation(ts: datetime.datetime, lat_rad: np.ndarray, lon_rad: np.ndarray) -> np.ndarray:
|
| 194 |
+
ref = datetime.datetime(2000, 1, 1, 12, 0, 0, tzinfo=pytz.utc)
|
| 195 |
+
mt = ts.timestamp()
|
| 196 |
+
days = (mt - ref.timestamp()) / (24.0 * 3600.0)
|
| 197 |
+
jc = days / 36525.0
|
| 198 |
+
theta = (67310.54841 + jc * (876600.0 * 3600.0 + 8640184.812866
|
| 199 |
+
+ jc * (0.093104 - jc * 6.2e-5)))
|
| 200 |
+
gmst = np.fmod((theta / 240.0) * np.pi / 180.0, 2.0 * np.pi)
|
| 201 |
+
ma = np.deg2rad(357.52910 + 35999.05030 * jc - 0.0001559 * jc**2 - 0.00000048 * jc**3)
|
| 202 |
+
ml = np.deg2rad(280.46645 + 36000.76983 * jc + 0.0003032 * jc**2)
|
| 203 |
+
dl = np.deg2rad((1.914600 - 0.004817 * jc - 0.000014 * jc**2) * np.sin(ma)
|
| 204 |
+
+ (0.019993 - 0.000101 * jc) * np.sin(2.0 * ma)
|
| 205 |
+
+ 0.000290 * np.sin(3.0 * ma))
|
| 206 |
+
tl = ml + dl
|
| 207 |
+
eps = np.deg2rad(23.0 + 26.0 / 60.0 + 21.406 / 3600.0
|
| 208 |
+
- (46.836769 * jc - 0.0001831 * jc**2 + 0.00200340 * jc**3
|
| 209 |
+
- 0.576e-6 * jc**4 - 4.34e-8 * jc**5) / 3600.0)
|
| 210 |
+
x = np.cos(tl); y = np.cos(eps) * np.sin(tl); z = np.sin(eps) * np.sin(tl)
|
| 211 |
+
r = np.sqrt(1.0 - z * z)
|
| 212 |
+
dec = np.arctan2(z, r)
|
| 213 |
+
ra = 2.0 * np.arctan2(y, x + r)
|
| 214 |
+
ha = (gmst + lon_rad) - ra
|
| 215 |
+
cos_z = np.sin(lat_rad) * np.sin(dec) + np.cos(lat_rad) * np.cos(dec) * np.cos(ha)
|
| 216 |
+
return cos_z.astype(np.float32)
|
| 217 |
+
|
| 218 |
+
# ============================================================================
|
| 219 |
+
# Normalisation
|
| 220 |
+
# ============================================================================
|
| 221 |
+
|
| 222 |
+
def compute_normalisation_params(
|
| 223 |
+
cfg: dict, variable_names: List[str],
|
| 224 |
+
statistics_path: Optional[str] = None,
|
| 225 |
+
) -> Tuple[np.ndarray, np.ndarray]:
|
| 226 |
+
"""Compute per-variable norm_mul and norm_add from config.
|
| 227 |
+
|
| 228 |
+
If ``statistics_path`` points to a ``.npz`` file with keys
|
| 229 |
+
``mean`` and ``stdev`` (per-variable arrays matching the full
|
| 230 |
+
dataset variable order), those values are used. Otherwise
|
| 231 |
+
identity (mean=0, stdev=1) is assumed — correct for fake data
|
| 232 |
+
but NOT for real ERA5.
|
| 233 |
+
"""
|
| 234 |
+
nc = cfg["normalizer"]
|
| 235 |
+
default_method = nc["default"]
|
| 236 |
+
remap = nc.get("remap", {}) or {}
|
| 237 |
+
methods: Dict[str, str] = {}
|
| 238 |
+
for v in variable_names:
|
| 239 |
+
if v in (nc.get("none") or []): methods[v] = "none"
|
| 240 |
+
elif v in (nc.get("max") or []): methods[v] = "max"
|
| 241 |
+
elif v in (nc.get("min-max") or []): methods[v] = "min-max"
|
| 242 |
+
elif v in (nc.get("std") or []): methods[v] = "std"
|
| 243 |
+
else: methods[v] = default_method
|
| 244 |
+
|
| 245 |
+
name_to_idx = {n: i for i, n in enumerate(variable_names)}
|
| 246 |
+
num_vars = len(variable_names)
|
| 247 |
+
|
| 248 |
+
# Load pre-computed statistics if available, else identity
|
| 249 |
+
_mean = np.zeros(num_vars, dtype=np.float32)
|
| 250 |
+
_stdev = np.ones(num_vars, dtype=np.float32)
|
| 251 |
+
|
| 252 |
+
if statistics_path and os.path.exists(statistics_path):
|
| 253 |
+
stats = np.load(statistics_path)
|
| 254 |
+
# statistics file stores arrays indexed by its own variable list
|
| 255 |
+
if "variables" in stats:
|
| 256 |
+
ds_vars = [str(v) for v in stats["variables"]]
|
| 257 |
+
else:
|
| 258 |
+
# Fallback: assume 115-variable dataset order from aifs_config
|
| 259 |
+
import json
|
| 260 |
+
cfg_path = ROOT / "model" / "aifs_config.json"
|
| 261 |
+
ds_vars = json.load(open(cfg_path))["dataset"]["variables"]
|
| 262 |
+
ds_name_to_idx = {n: i for i, n in enumerate(ds_vars)}
|
| 263 |
+
for name, i in name_to_idx.items():
|
| 264 |
+
if name in ds_name_to_idx:
|
| 265 |
+
j = ds_name_to_idx[name]
|
| 266 |
+
_mean[i] = float(stats["mean"][j])
|
| 267 |
+
_stdev[i] = float(stats["stdev"][j])
|
| 268 |
+
print(f"[INFO] Loaded statistics from {statistics_path}")
|
| 269 |
+
else:
|
| 270 |
+
print("[INFO] No statistics file — using identity normalisation "
|
| 271 |
+
"(mean=0, stdev=1). Fine for fake data, "
|
| 272 |
+
"WRONG for real ERA5.")
|
| 273 |
+
|
| 274 |
+
for target_var, source_var in remap.items():
|
| 275 |
+
if target_var in name_to_idx and source_var in name_to_idx:
|
| 276 |
+
ti, si = name_to_idx[target_var], name_to_idx[source_var]
|
| 277 |
+
_mean[ti], _stdev[ti] = _mean[si], _stdev[si]
|
| 278 |
+
|
| 279 |
+
norm_mul = np.ones(num_vars, dtype=np.float32)
|
| 280 |
+
norm_add = np.zeros(num_vars, dtype=np.float32)
|
| 281 |
+
for name, i in name_to_idx.items():
|
| 282 |
+
method = methods.get(name, default_method)
|
| 283 |
+
if method == "mean-std":
|
| 284 |
+
norm_mul[i] = 1.0 / max(float(_stdev[i]), 1e-9)
|
| 285 |
+
norm_add[i] = -float(_mean[i]) / max(float(_stdev[i]), 1e-9)
|
| 286 |
+
elif method == "std":
|
| 287 |
+
norm_mul[i] = 1.0 / max(float(_stdev[i]), 1e-9)
|
| 288 |
+
elif method in ("max", "min-max", "none"):
|
| 289 |
+
norm_mul[i] = 1.0
|
| 290 |
+
return norm_mul, norm_add
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def compute_era5_statistics(
|
| 294 |
+
data_dir: str, years: List[int],
|
| 295 |
+
variable_names: List[str],
|
| 296 |
+
output_path: str,
|
| 297 |
+
) -> None:
|
| 298 |
+
"""Compute per-variable mean and stdev from ERA5 H5 files.
|
| 299 |
+
|
| 300 |
+
Saves ``mean`` and ``stdev`` arrays (indexed by *variable_names*
|
| 301 |
+
order) to *output_path* as a ``.npz`` file.
|
| 302 |
+
|
| 303 |
+
This is a one-time pre-computation step before training with
|
| 304 |
+
real ERA5 data. The resulting file is consumed by
|
| 305 |
+
``compute_normalisation_params(statistics_path=...)``.
|
| 306 |
+
"""
|
| 307 |
+
import h5py
|
| 308 |
+
|
| 309 |
+
num_vars = len(variable_names)
|
| 310 |
+
sum_x = np.zeros(num_vars, dtype=np.float64)
|
| 311 |
+
sum_x2 = np.zeros(num_vars, dtype=np.float64)
|
| 312 |
+
count = 0
|
| 313 |
+
|
| 314 |
+
for year in years:
|
| 315 |
+
h5_path = os.path.join(data_dir, "data", f"{year}.h5")
|
| 316 |
+
if not os.path.exists(h5_path):
|
| 317 |
+
print(f"[WARN] Missing {h5_path}, skipping")
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
with h5py.File(h5_path, "r") as f:
|
| 321 |
+
ds = f["fields"]
|
| 322 |
+
h5_vars = [v.decode() if isinstance(v, bytes) else v
|
| 323 |
+
for v in ds.attrs["variables"]]
|
| 324 |
+
T = ds.shape[0]
|
| 325 |
+
|
| 326 |
+
# Index mapping for each timestep
|
| 327 |
+
for t in range(T):
|
| 328 |
+
frame = ds[t] # (C, H, W)
|
| 329 |
+
for i, vname in enumerate(variable_names):
|
| 330 |
+
if vname in h5_vars:
|
| 331 |
+
ch = h5_vars.index(vname)
|
| 332 |
+
vals = frame[ch].astype(np.float64)
|
| 333 |
+
sum_x[i] += vals.mean()
|
| 334 |
+
sum_x2[i] += (vals ** 2).mean()
|
| 335 |
+
count += 1
|
| 336 |
+
|
| 337 |
+
mean = (sum_x / count).astype(np.float32)
|
| 338 |
+
# stdev = sqrt(E[X²] - E[X]²)
|
| 339 |
+
var = np.maximum(sum_x2 / count - mean.astype(np.float64) ** 2, 0)
|
| 340 |
+
stdev = np.sqrt(var).astype(np.float32)
|
| 341 |
+
|
| 342 |
+
np.savez(output_path, mean=mean, stdev=stdev)
|
| 343 |
+
print(f"[INFO] Statistics saved to {output_path} "
|
| 344 |
+
f"({count} frames, {num_vars} vars)")
|
| 345 |
+
|
| 346 |
+
# ============================================================================
|
| 347 |
+
# Loss scaling
|
| 348 |
+
# ============================================================================
|
| 349 |
+
|
| 350 |
+
def build_loss_scale_factors(cfg: dict, output_var_names: List[str]) -> np.ndarray:
|
| 351 |
+
sc = cfg["loss_scaling"]
|
| 352 |
+
default = sc["default"]; pl = sc["pl"]; sfc = sc["sfc"]
|
| 353 |
+
av = cfg["aifs_variables"]
|
| 354 |
+
name_to_scale: Dict[str, float] = {}
|
| 355 |
+
for var_short in av["pressure_level"]:
|
| 356 |
+
base = pl.get(var_short, default)
|
| 357 |
+
for lvl in av["pressure_levels"]:
|
| 358 |
+
name_to_scale[f"{var_short}_{lvl}"] = base
|
| 359 |
+
for var_name in av["surface"] + av["soil"] + av["diagnostic"]:
|
| 360 |
+
name_to_scale[var_name] = sfc.get(var_name, default)
|
| 361 |
+
for k, v in sfc.items():
|
| 362 |
+
if k not in name_to_scale: name_to_scale[k] = v
|
| 363 |
+
return np.array([name_to_scale.get(n, default) for n in output_var_names], dtype=np.float32)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def build_pressure_level_scaler(cfg: dict, output_var_names: List[str]) -> np.ndarray:
|
| 367 |
+
pls = cfg["loss_scaling"]["pressure_level_scaler"]
|
| 368 |
+
minimum, slope = pls["minimum"], pls["slope"]
|
| 369 |
+
av = cfg["aifs_variables"]
|
| 370 |
+
factors = np.ones(len(output_var_names), dtype=np.float32)
|
| 371 |
+
for i, name in enumerate(output_var_names):
|
| 372 |
+
for aifs_var in av["pressure_level"]:
|
| 373 |
+
if name.startswith(f"{aifs_var}_"):
|
| 374 |
+
plev_hpa = int(name[len(aifs_var) + 1:])
|
| 375 |
+
factors[i] = max(minimum, slope * plev_hpa)
|
| 376 |
+
break
|
| 377 |
+
return factors
|
| 378 |
+
|
| 379 |
+
# ============================================================================
|
| 380 |
+
# Dataset
|
| 381 |
+
# ============================================================================
|
| 382 |
+
|
| 383 |
+
class AIFSTrainingDataset:
|
| 384 |
+
def __init__(self, dataset_path: str, years: List[int], cfg: dict,
|
| 385 |
+
model_input_order: List[str], model_output_order: List[str],
|
| 386 |
+
norm_mul: np.ndarray, norm_add: np.ndarray,
|
| 387 |
+
input_steps=2, output_steps=1,
|
| 388 |
+
grid_lat=None, grid_lon=None):
|
| 389 |
+
dp = dataset_path.rstrip("/")
|
| 390 |
+
if os.path.isdir(os.path.join(dp, "data")): era5_root = dp
|
| 391 |
+
elif os.path.isdir(dp) and any(f.endswith(".h5") for f in os.listdir(dp)): era5_root = os.path.dirname(dp)
|
| 392 |
+
else: era5_root = dp
|
| 393 |
+
self._era5_root = era5_root
|
| 394 |
+
|
| 395 |
+
data_dir = os.path.join(era5_root, "data")
|
| 396 |
+
h5_files = sorted([f for f in os.listdir(data_dir) if f.endswith(".h5")])
|
| 397 |
+
era5_var_list = build_era5_variable_list(cfg)
|
| 398 |
+
available_set: set = set()
|
| 399 |
+
|
| 400 |
+
if h5_files:
|
| 401 |
+
import h5py
|
| 402 |
+
with h5py.File(os.path.join(data_dir, h5_files[0]), "r") as f:
|
| 403 |
+
available_vars = [v.decode() if isinstance(v, bytes) else v for v in f["fields"].attrs["variables"]]
|
| 404 |
+
available_set = set(available_vars)
|
| 405 |
+
corrected = _auto_correct_variable_names(era5_var_list, available_set, cfg)
|
| 406 |
+
used_vars = [v for v in corrected if v in available_set]
|
| 407 |
+
missing = [v for v in corrected if v not in available_set]
|
| 408 |
+
if missing:
|
| 409 |
+
print(f"[WARN] {len(missing)}/{len(era5_var_list)} vars missing from H5: {missing[:10]}")
|
| 410 |
+
else:
|
| 411 |
+
used_vars = era5_var_list
|
| 412 |
+
|
| 413 |
+
self._cfg = cfg
|
| 414 |
+
self.era5_var_list = used_vars
|
| 415 |
+
self.model_input_order = model_input_order
|
| 416 |
+
self.model_output_order = model_output_order
|
| 417 |
+
self.norm_mul = norm_mul; self.norm_add = norm_add
|
| 418 |
+
self.input_steps = input_steps; self.output_steps = output_steps
|
| 419 |
+
self._name_to_input_idx = {n: i for i, n in enumerate(model_input_order)}
|
| 420 |
+
self._name_to_output_idx = {n: i for i, n in enumerate(model_output_order)}
|
| 421 |
+
|
| 422 |
+
# corrected diagnostic map
|
| 423 |
+
self._diag_map: Dict[str, str] = {}
|
| 424 |
+
for aifs_name, orig in cfg["era5_mapping"]["diagnostic"].items():
|
| 425 |
+
cn = _auto_correct_variable_names([orig], available_set, cfg)[0] if h5_files else orig
|
| 426 |
+
self._diag_map[aifs_name] = cn if (h5_files and cn in available_set) else orig
|
| 427 |
+
|
| 428 |
+
self._dataset = onescience_era5.ERA5Dataset(
|
| 429 |
+
dataset_dir=era5_root, used_years=years,
|
| 430 |
+
used_variables=used_vars, input_steps=input_steps,
|
| 431 |
+
output_steps=output_steps, normalize=False)
|
| 432 |
+
|
| 433 |
+
self._grid_lat = grid_lat; self._grid_lon = grid_lon
|
| 434 |
+
self.n_samples = len(self._dataset)
|
| 435 |
+
|
| 436 |
+
# audit
|
| 437 |
+
self._print_audit(cfg, used_vars)
|
| 438 |
+
|
| 439 |
+
def _print_audit(self, cfg, used_vars):
|
| 440 |
+
av = cfg["aifs_variables"]; em = cfg["era5_mapping"]
|
| 441 |
+
fields_ok = set()
|
| 442 |
+
for aifs in av["surface"]:
|
| 443 |
+
if em["surface"][aifs] in used_vars: fields_ok.add(aifs)
|
| 444 |
+
for aifs in av["soil"]:
|
| 445 |
+
if em["soil"][aifs] in used_vars: fields_ok.add(aifs)
|
| 446 |
+
for vv in av["pressure_level"]:
|
| 447 |
+
for lvl in av["pressure_levels"]:
|
| 448 |
+
if em["pressure_level"][vv].format(level=lvl) in used_vars: fields_ok.add(f"{vv}_{lvl}")
|
| 449 |
+
for aifs in av["diagnostic"]:
|
| 450 |
+
if self._diag_map[aifs] in used_vars: fields_ok.add(aifs)
|
| 451 |
+
forcing_ok = set(av["computed_forcing"] + ["lsm", "z", "slor", "sdor"])
|
| 452 |
+
mi = [v for v in self.model_input_order if v not in fields_ok and v not in forcing_ok]
|
| 453 |
+
mo = [v for v in self.model_output_order if v not in fields_ok]
|
| 454 |
+
print(f"[INFO] Variables: {len(fields_ok)} fields + {len(forcing_ok)} forcing = all available")
|
| 455 |
+
if mi: print(f"[WARN] {len(mi)} input vars zero-filled: {mi}")
|
| 456 |
+
if mo: print(f"[WARN] {len(mo)} output vars no target data: {mo}")
|
| 457 |
+
|
| 458 |
+
def __len__(self): return self.n_samples
|
| 459 |
+
def set_grid(self, lat, lon):
|
| 460 |
+
self._grid_lat = lat.astype(np.float32); self._grid_lon = lon.astype(np.float32)
|
| 461 |
+
|
| 462 |
+
def _get_raw_sample(self, idx: int):
|
| 463 |
+
result = self._dataset[int(idx)]
|
| 464 |
+
return result[0], result[1], int(result[3]), list(result[4])
|
| 465 |
+
|
| 466 |
+
def process_sample(self, invar, outvar, step_idx, time_index):
|
| 467 |
+
if invar.ndim == 3: invar = invar[np.newaxis, ...]
|
| 468 |
+
if outvar.ndim == 3: outvar = outvar[np.newaxis, ...]
|
| 469 |
+
total_steps = self.input_steps + self.output_steps
|
| 470 |
+
all_frames = np.concatenate([invar, outvar], axis=0)
|
| 471 |
+
timestamps = [datetime.datetime.strptime(t, "%Y%m%d%H").replace(tzinfo=pytz.utc) for t in time_index]
|
| 472 |
+
|
| 473 |
+
frame_tm6, frame_t0, frame_tp6 = all_frames[0], all_frames[1], all_frames[2] if total_steps >= 3 else all_frames[-1]
|
| 474 |
+
fields = build_aifs_fields_from_frames(frame_tm6, frame_t0, frame_tp6, self.era5_var_list, self._cfg, diag_map=self._diag_map)
|
| 475 |
+
|
| 476 |
+
num_pts = next(iter(fields.values())).shape[-1]
|
| 477 |
+
if self._grid_lat is None:
|
| 478 |
+
self._grid_lat = np.linspace(90, -90, 542080, dtype=np.float32)[:num_pts]
|
| 479 |
+
self._grid_lon = np.linspace(0, 360, 542080, dtype=np.float32)[:num_pts]
|
| 480 |
+
|
| 481 |
+
forcing = compute_forcing_features(self._grid_lat, self._grid_lon, timestamps, fields)
|
| 482 |
+
|
| 483 |
+
num_input_vars = len(self.model_input_order)
|
| 484 |
+
input_tensor = np.zeros((self.input_steps, num_pts, num_input_vars), dtype=np.float32)
|
| 485 |
+
for var_name, var_idx in self._name_to_input_idx.items():
|
| 486 |
+
for t in range(self.input_steps):
|
| 487 |
+
if var_name in fields: input_tensor[t, :, var_idx] = fields[var_name][t]
|
| 488 |
+
elif var_name in forcing: input_tensor[t, :, var_idx] = forcing[var_name][t]
|
| 489 |
+
|
| 490 |
+
input_tensor = input_tensor * self.norm_mul[np.newaxis, np.newaxis, :] + self.norm_add[np.newaxis, np.newaxis, :]
|
| 491 |
+
|
| 492 |
+
num_output_vars = len(self.model_output_order)
|
| 493 |
+
target_tensor = np.zeros((1, num_pts, num_output_vars), dtype=np.float32)
|
| 494 |
+
t_target = self.input_steps
|
| 495 |
+
for var_name, var_idx in self._name_to_output_idx.items():
|
| 496 |
+
if var_name in fields: target_tensor[0, :, var_idx] = fields[var_name][t_target]
|
| 497 |
+
return input_tensor, target_tensor
|
| 498 |
+
|
| 499 |
+
# ============================================================================
|
| 500 |
+
# Model loading
|
| 501 |
+
# ============================================================================
|
| 502 |
+
|
| 503 |
+
def load_aifs_model(checkpoint_path: str, device: str, pretrained: bool = False):
|
| 504 |
+
"""Load the AIFS model through the onescience wrapper.
|
| 505 |
+
|
| 506 |
+
Parameters
|
| 507 |
+
----------
|
| 508 |
+
pretrained : bool
|
| 509 |
+
False (default): Build model from scratch via ``AIFS.from_scratch()``.
|
| 510 |
+
Uses local static config + grid files — NO checkpoint needed.
|
| 511 |
+
Equivalent to FengWu/Fuxi ``model = Fengwu()`` pattern.
|
| 512 |
+
True: Load serialised model with pretrained weights from .ckpt.
|
| 513 |
+
"""
|
| 514 |
+
t0 = time.time()
|
| 515 |
+
if pretrained:
|
| 516 |
+
model = AIFS(checkpoint_path, device=device, pretrained=True)
|
| 517 |
+
else:
|
| 518 |
+
model = AIFS.from_scratch(device=device)
|
| 519 |
+
mode = "pretrained" if pretrained else "from scratch"
|
| 520 |
+
print(f"[INFO] Model loaded ({mode}, {time.time() - t0:.1f}s)")
|
| 521 |
+
|
| 522 |
+
input_vars = model.input_variables
|
| 523 |
+
output_vars = model.output_variables
|
| 524 |
+
grid_lat = model.latitudes.cpu().numpy()
|
| 525 |
+
grid_lon = model.longitudes.cpu().numpy()
|
| 526 |
+
node_wts = model.node_weights # np.ndarray or None
|
| 527 |
+
return model, input_vars, output_vars, grid_lat, grid_lon, node_wts
|
| 528 |
+
|
| 529 |
+
# ============================================================================
|
| 530 |
+
# Loss
|
| 531 |
+
# ============================================================================
|
| 532 |
+
|
| 533 |
+
class WeightedMSELoss(nn.Module):
|
| 534 |
+
def __init__(self, var_scale: np.ndarray, pl_scale: np.ndarray, node_weights=None):
|
| 535 |
+
super().__init__()
|
| 536 |
+
self.register_buffer("combined_scale", torch.from_numpy((var_scale * pl_scale).astype(np.float32)))
|
| 537 |
+
if node_weights is not None:
|
| 538 |
+
self.register_buffer("node_weights", torch.from_numpy(node_weights.astype(np.float32)))
|
| 539 |
+
else: self.node_weights = None
|
| 540 |
+
|
| 541 |
+
def forward(self, pred, target):
|
| 542 |
+
sq_error = (pred - target) ** 2
|
| 543 |
+
scaled = sq_error * self.combined_scale[None, None, :]
|
| 544 |
+
if self.node_weights is not None:
|
| 545 |
+
x = scaled.mean(dim=-1) * self.node_weights[None, :]
|
| 546 |
+
return (x / self.node_weights.sum()).sum()
|
| 547 |
+
return scaled.mean()
|
| 548 |
+
|
| 549 |
+
# ============================================================================
|
| 550 |
+
# LR schedule
|
| 551 |
+
# ============================================================================
|
| 552 |
+
|
| 553 |
+
def cosine_lr_schedule(step, warmup_steps, total_steps, peak_lr, min_lr):
|
| 554 |
+
"""Cosine LR with linear warmup.
|
| 555 |
+
|
| 556 |
+
Manual implementation equivalent to ``timm.scheduler.CosineLRScheduler``
|
| 557 |
+
for a single decay cycle (no restarts). Same formula as the official
|
| 558 |
+
anemoi-training configuration.
|
| 559 |
+
"""
|
| 560 |
+
if step < warmup_steps:
|
| 561 |
+
return peak_lr * (step / max(warmup_steps, 1))
|
| 562 |
+
progress = min(
|
| 563 |
+
(step - warmup_steps) / max(total_steps - warmup_steps, 1), 1.0,
|
| 564 |
+
)
|
| 565 |
+
return min_lr + (peak_lr - min_lr) * 0.5 * (1.0 + math.cos(math.pi * progress))
|
| 566 |
+
|
| 567 |
+
# ============================================================================
|
| 568 |
+
# Training
|
| 569 |
+
# ============================================================================
|
| 570 |
+
|
| 571 |
+
def train(cfg: dict):
|
| 572 |
+
hw = cfg["hardware"]; data = cfg["data"]; ck = cfg["checkpoint"]; tr = cfg["training"]
|
| 573 |
+
|
| 574 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 575 |
+
if hw["device"] == "cpu": device = "cpu"
|
| 576 |
+
print(f"[INFO] Device: {device}")
|
| 577 |
+
|
| 578 |
+
train_years = data["train_years"]
|
| 579 |
+
val_years = data["val_years"]
|
| 580 |
+
|
| 581 |
+
from_scratch = tr.get("from_scratch", True)
|
| 582 |
+
model, model_input_vars, model_output_vars, grid_lat, grid_lon, node_wts = \
|
| 583 |
+
load_aifs_model(ck.get("pretrained", ""), device,
|
| 584 |
+
pretrained=not from_scratch)
|
| 585 |
+
|
| 586 |
+
# ---- 自动计算归一化统计量(如需要)-------------------------------
|
| 587 |
+
stats_path = cfg["normalizer"].get("statistics_path") or None
|
| 588 |
+
if stats_path:
|
| 589 |
+
stats_path = os.path.join(str(ROOT), stats_path)
|
| 590 |
+
if not os.path.exists(stats_path):
|
| 591 |
+
# 自动计算 —— 对标 anemoi-training DataModule 的统计量阶段
|
| 592 |
+
print("[INFO] Statistics file not found — auto-computing ...")
|
| 593 |
+
ds_vars = None
|
| 594 |
+
if hasattr(model, '_meta') and 'dataset' in model._meta:
|
| 595 |
+
ds_vars = model._meta['dataset']['variables']
|
| 596 |
+
elif os.path.exists(str(ROOT / "model" / "aifs_config.json")):
|
| 597 |
+
import json
|
| 598 |
+
ds_vars = json.load(
|
| 599 |
+
open(str(ROOT / "model" / "aifs_config.json"))
|
| 600 |
+
)['dataset']['variables']
|
| 601 |
+
if ds_vars is None:
|
| 602 |
+
print("[WARN] Cannot determine dataset variables — "
|
| 603 |
+
"falling back to identity normalisation")
|
| 604 |
+
stats_path = None
|
| 605 |
+
else:
|
| 606 |
+
os.makedirs(os.path.dirname(stats_path), exist_ok=True)
|
| 607 |
+
compute_era5_statistics(
|
| 608 |
+
data["data_dir"], train_years + val_years,
|
| 609 |
+
ds_vars, stats_path,
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
norm_mul, norm_add = compute_normalisation_params(
|
| 613 |
+
cfg, model_input_vars,
|
| 614 |
+
statistics_path=stats_path,
|
| 615 |
+
)
|
| 616 |
+
|
| 617 |
+
var_scale = build_loss_scale_factors(cfg, model_output_vars)
|
| 618 |
+
pl_scale = build_pressure_level_scaler(cfg, model_output_vars)
|
| 619 |
+
|
| 620 |
+
criterion = WeightedMSELoss(var_scale, pl_scale, node_wts).to(device)
|
| 621 |
+
val_criterion = WeightedMSELoss(
|
| 622 |
+
np.ones(len(model_output_vars), dtype=np.float32),
|
| 623 |
+
np.ones(len(model_output_vars), dtype=np.float32), node_wts).to(device)
|
| 624 |
+
|
| 625 |
+
optimizer = torch.optim.AdamW(
|
| 626 |
+
model.parameters(), lr=0.0,
|
| 627 |
+
betas=tr["optimizer"]["betas"],
|
| 628 |
+
weight_decay=tr["optimizer"].get("weight_decay", 0.01),
|
| 629 |
+
)
|
| 630 |
+
|
| 631 |
+
train_dataset = AIFSTrainingDataset(
|
| 632 |
+
data["data_dir"], train_years, cfg, model_input_vars, model_output_vars,
|
| 633 |
+
norm_mul, norm_add, input_steps=2, output_steps=1,
|
| 634 |
+
grid_lat=grid_lat, grid_lon=grid_lon)
|
| 635 |
+
train_dataset.set_grid(grid_lat, grid_lon)
|
| 636 |
+
val_dataset = AIFSTrainingDataset(
|
| 637 |
+
data["data_dir"], val_years, cfg, model_input_vars, model_output_vars,
|
| 638 |
+
norm_mul, norm_add, input_steps=2, output_steps=1,
|
| 639 |
+
grid_lat=grid_lat, grid_lon=grid_lon)
|
| 640 |
+
val_dataset.set_grid(grid_lat, grid_lon)
|
| 641 |
+
|
| 642 |
+
os.makedirs(ck["output_dir"], exist_ok=True)
|
| 643 |
+
use_amp = (device == "cuda")
|
| 644 |
+
amp_dtype = getattr(torch, tr.get("amp_dtype", "float16"))
|
| 645 |
+
scaler = torch.amp.GradScaler("cuda") if use_amp else None
|
| 646 |
+
|
| 647 |
+
model.train()
|
| 648 |
+
global_step, epoch, best_val = 0, 0, float("inf")
|
| 649 |
+
t_start = time.time()
|
| 650 |
+
pbar = tqdm(total=tr["max_steps"], desc="Training", unit="step", dynamic_ncols=True)
|
| 651 |
+
|
| 652 |
+
while global_step < tr["max_steps"]:
|
| 653 |
+
epoch += 1
|
| 654 |
+
indices = np.random.permutation(len(train_dataset))
|
| 655 |
+
for batch_start in range(0, len(train_dataset), tr["batch_size"]):
|
| 656 |
+
batch_indices = indices[batch_start:batch_start + tr["batch_size"]]
|
| 657 |
+
batch_inputs, batch_targets = [], []
|
| 658 |
+
for idx in batch_indices:
|
| 659 |
+
try:
|
| 660 |
+
invar, outvar, si, ti = train_dataset._get_raw_sample(idx)
|
| 661 |
+
invar_np = invar.numpy() if hasattr(invar, "numpy") else np.asarray(invar)
|
| 662 |
+
outvar_np = outvar.numpy() if hasattr(outvar, "numpy") else np.asarray(outvar)
|
| 663 |
+
inp, tgt = train_dataset.process_sample(invar_np, outvar_np, si, ti)
|
| 664 |
+
batch_inputs.append(inp); batch_targets.append(tgt)
|
| 665 |
+
except Exception as e:
|
| 666 |
+
print(f"[WARN] Skip sample {idx}: {e}")
|
| 667 |
+
if not batch_inputs: continue
|
| 668 |
+
|
| 669 |
+
x = torch.from_numpy(np.stack(batch_inputs, axis=0)).unsqueeze(2).to(device)
|
| 670 |
+
y = torch.from_numpy(np.stack(batch_targets, axis=0)).squeeze(1).to(device)
|
| 671 |
+
|
| 672 |
+
with (torch.amp.autocast("cuda", dtype=amp_dtype) if use_amp
|
| 673 |
+
else torch.no_grad()):
|
| 674 |
+
pred = model(x) # AIFS.forward already squeezes dim
|
| 675 |
+
|
| 676 |
+
loss = criterion(pred, y) / tr["accum_grad_batches"]
|
| 677 |
+
(scaler.scale(loss) if scaler else loss).backward()
|
| 678 |
+
|
| 679 |
+
# optimizer step
|
| 680 |
+
if (batch_start // tr["batch_size"] + 1) % tr["accum_grad_batches"] == 0:
|
| 681 |
+
if scaler: scaler.unscale_(optimizer)
|
| 682 |
+
torch.nn.utils.clip_grad_value_(model.parameters(), tr["gradient_clip_val"])
|
| 683 |
+
(scaler.step(optimizer) if scaler else optimizer.step())
|
| 684 |
+
if scaler: scaler.update()
|
| 685 |
+
optimizer.zero_grad()
|
| 686 |
+
global_step += 1
|
| 687 |
+
|
| 688 |
+
lr = cosine_lr_schedule(global_step, tr["warmup_steps"], tr["max_steps"], tr["peak_lr"], tr["min_lr"])
|
| 689 |
+
for pg in optimizer.param_groups: pg["lr"] = lr
|
| 690 |
+
|
| 691 |
+
elapsed = time.time() - t_start
|
| 692 |
+
pbar.set_postfix({"loss": f"{loss.item():.4f}", "lr": f"{lr:.2e}", "stp/s": f"{global_step/max(elapsed,1):.1f}"})
|
| 693 |
+
pbar.update(1)
|
| 694 |
+
|
| 695 |
+
if global_step % tr["val_interval"] == 0:
|
| 696 |
+
vl = validate(model, val_dataset, val_criterion, device)
|
| 697 |
+
pbar.write(f"[step {global_step:06d}] VAL loss={vl:.6f}")
|
| 698 |
+
model.train()
|
| 699 |
+
if vl < best_val:
|
| 700 |
+
best_val = vl
|
| 701 |
+
_save(model, optimizer, global_step, epoch, vl,
|
| 702 |
+
ck["output_dir"], "best")
|
| 703 |
+
if global_step % tr["save_interval"] == 0:
|
| 704 |
+
_save(model, optimizer, global_step, epoch, loss.item(),
|
| 705 |
+
ck["output_dir"], f"step{global_step:06d}")
|
| 706 |
+
if global_step >= tr["max_steps"]: break
|
| 707 |
+
|
| 708 |
+
pbar.close()
|
| 709 |
+
_save(model, optimizer, global_step, epoch, loss.item(),
|
| 710 |
+
ck["output_dir"], "final")
|
| 711 |
+
print(f"\n[INFO] Done: {global_step} steps in {(time.time()-t_start)/60:.1f}min, best_val={best_val:.6f}")
|
| 712 |
+
|
| 713 |
+
@torch.no_grad()
|
| 714 |
+
def validate(model, dataset, criterion, device):
|
| 715 |
+
model.eval()
|
| 716 |
+
total, n = 0.0, 0
|
| 717 |
+
use_amp = (device == "cuda")
|
| 718 |
+
amp_dtype_val = getattr(torch, "float16")
|
| 719 |
+
_first_err = True
|
| 720 |
+
for idx in range(len(dataset)):
|
| 721 |
+
try:
|
| 722 |
+
invar, outvar, si, ti = dataset._get_raw_sample(idx)
|
| 723 |
+
invar_np = invar.numpy() if hasattr(invar, "numpy") else np.asarray(invar)
|
| 724 |
+
outvar_np = outvar.numpy() if hasattr(outvar, "numpy") else np.asarray(outvar)
|
| 725 |
+
inp, tgt = dataset.process_sample(invar_np, outvar_np, si, ti)
|
| 726 |
+
x = torch.from_numpy(inp).unsqueeze(0).unsqueeze(2).to(device)
|
| 727 |
+
y = torch.from_numpy(tgt).squeeze(1).unsqueeze(0).to(device)
|
| 728 |
+
with (torch.amp.autocast("cuda", dtype=amp_dtype_val) if use_amp
|
| 729 |
+
else torch.no_grad()):
|
| 730 |
+
pred = model(x) # AIFS.forward already squeezes dim
|
| 731 |
+
total += criterion(pred, y).item(); n += 1
|
| 732 |
+
except Exception as e:
|
| 733 |
+
if _first_err:
|
| 734 |
+
print(f"[WARN] Validation error on sample {idx}: {e}")
|
| 735 |
+
_first_err = False
|
| 736 |
+
return total / max(n, 1)
|
| 737 |
+
|
| 738 |
+
def _save(model, optimizer, step, epoch, loss, out_dir, tag):
|
| 739 |
+
"""Save training checkpoint — 推理+续训合一,零外部依赖.
|
| 740 |
+
|
| 741 |
+
单个 .ckpt 文件包含:
|
| 742 |
+
- 完整 AnemoiModelEncProcDec (torch.load 直接得模型 → SimpleRunner 可用)
|
| 743 |
+
- ai-models.json + supporting arrays (inference metadata)
|
| 744 |
+
- optimizer.pkl (恢复训练用,推理时忽略)
|
| 745 |
+
"""
|
| 746 |
+
import pickle, zipfile
|
| 747 |
+
|
| 748 |
+
path = os.path.join(out_dir, f"model_bak.ckpt")
|
| 749 |
+
|
| 750 |
+
# ---- 1. 构建 AnemoiModelInterface(含 predict_step)---------
|
| 751 |
+
from anemoi.models.interface import AnemoiModelInterface
|
| 752 |
+
import numpy as np
|
| 753 |
+
|
| 754 |
+
# 虚拟统计量(identity normalization:mean=0, stdev=1)
|
| 755 |
+
num_vars = len(model._interface_data_indices.data.input.name_to_index)
|
| 756 |
+
dummy_stats = {
|
| 757 |
+
"minimum": np.zeros(num_vars, dtype=np.float32),
|
| 758 |
+
"maximum": np.ones(num_vars, dtype=np.float32),
|
| 759 |
+
"mean": np.zeros(num_vars, dtype=np.float32),
|
| 760 |
+
"stdev": np.ones(num_vars, dtype=np.float32),
|
| 761 |
+
}
|
| 762 |
+
|
| 763 |
+
interface = AnemoiModelInterface(
|
| 764 |
+
config=model._interface_config,
|
| 765 |
+
graph_data=model._interface_graph_data,
|
| 766 |
+
statistics=dummy_stats,
|
| 767 |
+
data_indices=model._interface_data_indices,
|
| 768 |
+
metadata={},
|
| 769 |
+
truncation_data=getattr(model._model, '_truncation_data', {}),
|
| 770 |
+
)
|
| 771 |
+
interface.model.load_state_dict(model._model.state_dict())
|
| 772 |
+
interface.cpu()
|
| 773 |
+
torch.save(interface, path)
|
| 774 |
+
del interface
|
| 775 |
+
|
| 776 |
+
# ---- 2. 注入 metadata (从本地静态文件) ---------------------------
|
| 777 |
+
_inject_metadata(path)
|
| 778 |
+
|
| 779 |
+
# ---- 3. 追加 optimizer 到同一子目录 ----------------------------
|
| 780 |
+
with zipfile.ZipFile(path, "r") as zf:
|
| 781 |
+
for name in zf.namelist():
|
| 782 |
+
if name.endswith("/data.pkl") or name.endswith("/byteorder"):
|
| 783 |
+
base_dir = name.split("/")[0]
|
| 784 |
+
break
|
| 785 |
+
else:
|
| 786 |
+
base_dir = "checkpoint"
|
| 787 |
+
|
| 788 |
+
opt_data = {
|
| 789 |
+
"optimizer_state_dict": optimizer.state_dict(),
|
| 790 |
+
"step": step, "epoch": epoch, "loss": loss,
|
| 791 |
+
}
|
| 792 |
+
with zipfile.ZipFile(path, "a") as zf:
|
| 793 |
+
zf.writestr(f"{base_dir}/optimizer.pkl", pickle.dumps(opt_data))
|
| 794 |
+
|
| 795 |
+
print(f"[INFO] Saved: {path}")
|
| 796 |
+
|
| 797 |
+
|
| 798 |
+
def _inject_metadata(dst_path: str):
|
| 799 |
+
"""从项目静态文件生成完整 metadata 并注入 checkpoint ZIP。
|
| 800 |
+
|
| 801 |
+
自给自足——不依赖原始 .ckpt。metadata 写入 torch.save 使用的
|
| 802 |
+
同一个子目录,保持 PyTorch ZIP 结构兼容。
|
| 803 |
+
"""
|
| 804 |
+
import json, zipfile
|
| 805 |
+
import numpy as np
|
| 806 |
+
|
| 807 |
+
config_path = str(ROOT / "model" / "aifs_config.json")
|
| 808 |
+
grid_path = str(ROOT / "model" / "grid-n320.npz")
|
| 809 |
+
|
| 810 |
+
with open(config_path) as f:
|
| 811 |
+
config_data = json.load(f)
|
| 812 |
+
grid = np.load(grid_path)
|
| 813 |
+
|
| 814 |
+
# ---- 1. 探测 torch.save 使用的子目录 -------------------------------
|
| 815 |
+
with zipfile.ZipFile(dst_path, "r") as zf:
|
| 816 |
+
for name in zf.namelist():
|
| 817 |
+
if name.endswith("/data.pkl") or name.endswith("/byteorder"):
|
| 818 |
+
base_dir = name.split("/")[0]
|
| 819 |
+
break
|
| 820 |
+
else:
|
| 821 |
+
base_dir = "checkpoint"
|
| 822 |
+
meta_dir = f"{base_dir}/anemoi-metadata"
|
| 823 |
+
|
| 824 |
+
# ---- 2. 构建完整 metadata -------------------------------------------
|
| 825 |
+
metadata = {
|
| 826 |
+
"config": config_data["model_config"],
|
| 827 |
+
"data_indices": config_data["data_indices"],
|
| 828 |
+
"dataset": config_data["dataset"],
|
| 829 |
+
"provenance_training": config_data.get("provenance_training", {}),
|
| 830 |
+
"training": config_data.get("training", {}),
|
| 831 |
+
"run_id": config_data.get("run_id", ""),
|
| 832 |
+
"seed": config_data.get("seed", 0),
|
| 833 |
+
"uuid": config_data.get("uuid", ""),
|
| 834 |
+
"timestamp": config_data.get("timestamp", ""),
|
| 835 |
+
"version": "1.0",
|
| 836 |
+
"supporting_arrays_paths": {
|
| 837 |
+
"latitudes": {
|
| 838 |
+
"path": f"{meta_dir}/latitudes.numpy",
|
| 839 |
+
"shape": [542080],
|
| 840 |
+
"dtype": "float64",
|
| 841 |
+
},
|
| 842 |
+
"longitudes": {
|
| 843 |
+
"path": f"{meta_dir}/longitudes.numpy",
|
| 844 |
+
"shape": [542080],
|
| 845 |
+
"dtype": "float64",
|
| 846 |
+
},
|
| 847 |
+
},
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
# ---- 3. 写入 checkpoint ZIP(与模型同一子目录)---------------------
|
| 851 |
+
meta_json = json.dumps(metadata).encode("utf-8")
|
| 852 |
+
with zipfile.ZipFile(dst_path, "a") as dst:
|
| 853 |
+
dst.writestr(f"{meta_dir}/ai-models.json", meta_json)
|
| 854 |
+
dst.writestr(
|
| 855 |
+
f"{meta_dir}/latitudes.numpy",
|
| 856 |
+
grid["latitudes"].astype(np.float64).tobytes(),
|
| 857 |
+
)
|
| 858 |
+
dst.writestr(
|
| 859 |
+
f"{meta_dir}/longitudes.numpy",
|
| 860 |
+
grid["longitudes"].astype(np.float64).tobytes(),
|
| 861 |
+
)
|
| 862 |
+
|
| 863 |
+
print(f"[INFO] Metadata injected into {base_dir}/")
|
| 864 |
+
|
| 865 |
+
# ============================================================================
|
| 866 |
+
# CLI
|
| 867 |
+
# ============================================================================
|
| 868 |
+
|
| 869 |
+
def main():
|
| 870 |
+
parser = argparse.ArgumentParser(description="AIFS v1.1 Training")
|
| 871 |
+
parser.add_argument("--config", "-c", type=str,
|
| 872 |
+
default=str(ROOT / "conf" / "config.yaml"))
|
| 873 |
+
args = parser.parse_args()
|
| 874 |
+
|
| 875 |
+
cfg = load_config(args.config)
|
| 876 |
+
hw = cfg["hardware"]
|
| 877 |
+
os.environ["CUDA_VISIBLE_DEVICES"] = str(hw["device_ids"])
|
| 878 |
+
if hw["device"] == "dcu": os.environ["HIP_VISIBLE_DEVICES"] = str(hw["device_ids"])
|
| 879 |
+
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
|
| 880 |
+
|
| 881 |
+
seed = cfg["training"]["seed"]
|
| 882 |
+
np.random.seed(seed); torch.manual_seed(seed)
|
| 883 |
+
if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed)
|
| 884 |
+
|
| 885 |
+
print(f"\n{'='*60}\n AIFS v1.1 — Pre-training\n{'='*60}")
|
| 886 |
+
print(f" Device: {hw['device']} Dataset: {cfg['data']['data_dir']}")
|
| 887 |
+
print(f" LR: {cfg['training']['peak_lr']:.2e} Steps: {cfg['training']['max_steps']}")
|
| 888 |
+
print(f" Output: {cfg['checkpoint']['output_dir']}\n{'='*60}\n")
|
| 889 |
+
|
| 890 |
+
try: train(cfg)
|
| 891 |
+
except KeyboardInterrupt: print("\n[INFO] Interrupted.")
|
| 892 |
+
except Exception: traceback.print_exc(); sys.exit(1)
|
| 893 |
+
|
| 894 |
+
if __name__ == "__main__": main()
|
weights/.gitkeep
ADDED
|
File without changes
|