OneScience commited on
Commit
e91a975
·
verified ·
1 Parent(s): 9421e07

Upload folder using huggingface_hub

Browse files
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