yzt15806542928 commited on
Commit
1616901
·
verified ·
1 Parent(s): 681fa2b

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ frameworks: PyTorch
3
+ language:
4
+ - en
5
+ license: apache-2.0
6
+ tags:
7
+ - OneScience
8
+ - Earth Science
9
+ - Weather Forecast
10
+ - Subseasonal Forecast
11
+ - Coupled Atmosphere-Ocean-Land
12
+ - ERA5
13
+ tasks: []
14
+ datasets:
15
+ - OneScience/ERA5
16
+ ---
17
+ <p align="center">
18
+ <strong>
19
+ <span style="font-size: 30px;">FengWu-W2S</span>
20
+ </strong>
21
+ </p>
22
+
23
+ # Model Introduction
24
+
25
+ FengWu-W2S (FengWu Weather-to-Subseasonal) is a seamless global weather-to-subseasonal forecasting model extending FengWu.
26
+
27
+ Paper: FengWu-W2S: A deep learning model for seamless weather-to-subseasonal forecast of global atmosphere
28
+
29
+ https://arxiv.org/abs/2411.10191
30
+
31
+ # Model Description
32
+
33
+ The model uses a six-hour time step for autoregressive forecasts of up to 42 days. Coupled atmospheric, ocean, and land branches, together with diversity perturbations, are used to improve extended-range forecast skill. This repository contains a compact implementation of the coupled interfaces for reproducible workflow checks.
34
+
35
+ # Use Cases
36
+
37
+ | Scenario | Description |
38
+ | :---: | :--- |
39
+ | Weather-to-subseasonal forecast research | Train a 78-channel coupled model with six-hourly global atmospheric, ocean, and land fields. |
40
+ | Local quick validation | Use small-grid synthetic HDF5 data to check training, fine-tuning, inference, and forecast visualization. |
41
+ | ModelScope / OneCode execution | Download the standalone model package, install dependencies, and run the scripts directly. |
42
+ | Multi-GPU training | Launch PyTorch DistributedDataParallel with `torchrun`. |
43
+
44
+ # Usage Guide
45
+
46
+ ## 1. OneCode Usage
47
+
48
+ Experience intelligent one-click AI4S programming through the OneCode online environment:
49
+
50
+ [Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
51
+
52
+ ## 2. Manual Installation and Usage
53
+
54
+ **Hardware Requirements**
55
+
56
+ - A GPU or DCU is recommended.
57
+ - CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
58
+ - DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
59
+
60
+ ### Download the Model Package
61
+
62
+ ```bash
63
+ hf download OneScience-Group/FengWu-W2S --local-dir ./FengWu-W2S
64
+ cd FengWu-W2S
65
+ ```
66
+
67
+ ### Install the Runtime Environment
68
+
69
+ **DCU Environment**
70
+
71
+ ```bash
72
+ # Please activate DTK and CONDA first
73
+ conda create -n onescience311 python=3.11 -y
74
+ conda activate onescience311
75
+ # uv installation is supported
76
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
77
+ ```
78
+
79
+ **GPU Environment**
80
+ ```bash
81
+ # Please activate CONDA first
82
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
83
+ conda activate onescience311
84
+ # uv installation is supported
85
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
86
+ ```
87
+
88
+ ### Training Data Introduction
89
+
90
+ The OneScience community provides an ERA5 data slice for training. Download it and confirm that the data path in `conf/config.yaml` is correct:
91
+
92
+ ```bash
93
+ hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
94
+ ```
95
+
96
+ Real files must contain all 78 channels listed in `conf/config.yaml`, including atmospheric, ocean, and land variables, with six-hour time spacing. The configured group indices define the coupled branches.
97
+
98
+ ### Generate Synthetic Data
99
+
100
+ The default configuration describes a full `721x1440` grid. For a practical local smoke test, generate a small fixture explicitly:
101
+
102
+ ```bash
103
+ python scripts/fake_data.py --height 32 --width 64 --timesteps 12
104
+ ```
105
+
106
+ Synthetic HDF5 files are for workflow validation only and do not represent the ERA5 reanalysis or the paper's forecast quality.
107
+
108
+ ### Training
109
+
110
+ Single GPU:
111
+
112
+ ```bash
113
+ python scripts/train.py
114
+ ```
115
+
116
+ Multi-GPU:
117
+
118
+ ```bash
119
+ torchrun --nproc_per_node=2 scripts/train.py
120
+ ```
121
+
122
+ For a short smoke run, reduce the workload while keeping the generated data and model grid consistent:
123
+
124
+ ```bash
125
+ python scripts/train.py --max-epoch 1 --batch-size 1 --num-workers 0 --rollout-steps 1
126
+ ```
127
+
128
+ The default checkpoint is saved to `data/checkpoints/model_bak.pth`.
129
+
130
+ ### Fine-tuning
131
+
132
+ Resume from the base checkpoint with the lower fine-tuning learning rate:
133
+
134
+ ```bash
135
+ python scripts/train.py --finetune
136
+ ```
137
+
138
+ ### Training Weights
139
+
140
+ This repository provides a `weight/` directory for FengWu-W2S checkpoints. The weight files will be uploaded soon and are expected to be available in the near future.
141
+
142
+ ### Inference
143
+
144
+ Inference reads `data/checkpoints/model_bak.pth` by default and writes forecasts grouped by initialization year to `result/output/<year>/`, together with `result/output/index.json`:
145
+
146
+ ```bash
147
+ python scripts/inference.py
148
+ ```
149
+
150
+ Use `--steps` and `--limit` to bound a local smoke test; `--stochastic` enables perturbation sampling.
151
+
152
+ ### Evaluation and Visualization
153
+
154
+ ```bash
155
+ python scripts/result.py
156
+ ```
157
+
158
+ The script computes per-channel RMSE, normalized RMSE, and anomaly ACC, and generates forecast-comparison, lead-time skill, channel-ranking, and training-loss figures.
159
+
160
+ # Official OneScience Resources
161
+
162
+ | Platform | OneScience Main Repository | Skills Repository |
163
+ | --- | --- | --- |
164
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
165
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
166
+
167
+ # Citation and License
168
+
169
+ - Paper: https://arxiv.org/abs/2411.10191
170
+ - This directory is an independent reproduction of the paper method and does not represent official code, weights, or training results released by the authors.
conf/config.yaml ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ name: FengWu-W2S
3
+ default_checkpoint: ./data/checkpoints/model_bak.pth
4
+ input_steps: 2
5
+ rollout_steps: 2
6
+ inference_steps: 4
7
+ patch_size: 4
8
+ hidden_channels: 32
9
+ latent_channels: 64
10
+ num_blocks: 2
11
+ perturbation_scale: 0.01
12
+ learning_rate: 0.0005
13
+ finetune_learning_rate: 0.00005
14
+ adam_beta1: 0.9
15
+ adam_beta2: 0.9
16
+ probability_loss: true
17
+ kl_weight: 0.0001
18
+ initial_perturbation_scale: 0.01
19
+ weight_decay: 0.00001
20
+ max_epoch: 10
21
+ finetune_epoch: 40
22
+ patience: 10
23
+ checkpoint_dir: ./data/checkpoints
24
+
25
+ data:
26
+ data_dir: ./data
27
+ train_years: [1999,2000]
28
+ val_years: [2001]
29
+ test_years: [2002]
30
+ time_step_hours: 6
31
+ channels:
32
+ - geopotential_50
33
+ - geopotential_100
34
+ - geopotential_150
35
+ - geopotential_200
36
+ - geopotential_250
37
+ - geopotential_300
38
+ - geopotential_400
39
+ - geopotential_500
40
+ - geopotential_600
41
+ - geopotential_700
42
+ - geopotential_850
43
+ - geopotential_925
44
+ - geopotential_1000
45
+ - temperature_50
46
+ - temperature_100
47
+ - temperature_150
48
+ - temperature_200
49
+ - temperature_250
50
+ - temperature_300
51
+ - temperature_400
52
+ - temperature_500
53
+ - temperature_600
54
+ - temperature_700
55
+ - temperature_850
56
+ - temperature_925
57
+ - temperature_1000
58
+ - u_component_of_wind_50
59
+ - u_component_of_wind_100
60
+ - u_component_of_wind_150
61
+ - u_component_of_wind_200
62
+ - u_component_of_wind_250
63
+ - u_component_of_wind_300
64
+ - u_component_of_wind_400
65
+ - u_component_of_wind_500
66
+ - u_component_of_wind_600
67
+ - u_component_of_wind_700
68
+ - u_component_of_wind_850
69
+ - u_component_of_wind_925
70
+ - u_component_of_wind_1000
71
+ - v_component_of_wind_50
72
+ - v_component_of_wind_100
73
+ - v_component_of_wind_150
74
+ - v_component_of_wind_200
75
+ - v_component_of_wind_250
76
+ - v_component_of_wind_300
77
+ - v_component_of_wind_400
78
+ - v_component_of_wind_500
79
+ - v_component_of_wind_600
80
+ - v_component_of_wind_700
81
+ - v_component_of_wind_850
82
+ - v_component_of_wind_925
83
+ - v_component_of_wind_1000
84
+ - specific_humidity_50
85
+ - specific_humidity_100
86
+ - specific_humidity_150
87
+ - specific_humidity_200
88
+ - specific_humidity_250
89
+ - specific_humidity_300
90
+ - specific_humidity_400
91
+ - specific_humidity_500
92
+ - specific_humidity_600
93
+ - specific_humidity_700
94
+ - specific_humidity_850
95
+ - specific_humidity_925
96
+ - specific_humidity_1000
97
+ - 2m_temperature
98
+ - total_precipitation
99
+ - mean_sea_level_pressure
100
+ - 10m_u_component_of_wind
101
+ - 10m_v_component_of_wind
102
+ - 100m_u_component_of_wind
103
+ - 100m_v_component_of_wind
104
+ - outgoing_longwave_radiation
105
+ - sea_surface_temperature
106
+ - significant_wave_height
107
+ - mean_wave_direction
108
+ - mean_wave_period
109
+ - soil_moisture_0_7cm
110
+ groups:
111
+ atmosphere: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64]
112
+ ocean: [73, 74, 75, 76]
113
+ land: [65, 66, 67, 68, 69, 70, 71, 72, 77]
114
+ normalization_epsilon: 1.0e-6
115
+ dataloader:
116
+ batch_size: 1
117
+ num_workers: 0
118
+ pin_memory: false
119
+ drop_last: false
120
+
121
+ fake_data:
122
+ # Small defaults keep the workflow runnable on a login node. Real ERA5 can use 721x1440.
123
+ # height: 32
124
+ height: 721
125
+ # width: 64
126
+ width: 1440
127
+ timesteps: 12
128
+ seed: 42
129
+
130
+ runtime:
131
+ # Fail fast instead of silently training on CPU when the DCU runtime is absent.
132
+ device: cuda
133
+ amp: false
config.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "FengWu-W2S",
3
+ "model_type": "fengwu_w2s",
4
+ "architectures": [
5
+ "FengWuW2S"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "coupled-atmosphere-ocean-land",
9
+ "task": "seamless-weather-to-subseasonal-forecasting",
10
+ "implementation": {
11
+ "entry_point": "model/fengwu_w2s.py",
12
+ "scope": "compact coupled weather, ocean, and land model with stochastic perturbations for autoregressive rollout"
13
+ },
14
+ "architecture": {
15
+ "family": "coupled branch encoders with latent fusion and perturbation module",
16
+ "input_steps": 2,
17
+ "rollout_steps": 2,
18
+ "inference_steps": 4,
19
+ "input_channels": 78,
20
+ "atmosphere_channels": 65,
21
+ "ocean_channels": 4,
22
+ "land_channels": 9,
23
+ "hidden_channels": 32,
24
+ "latent_channels": 64,
25
+ "patch_size": 4,
26
+ "blocks": 2,
27
+ "perturbation_scale": 0.01
28
+ },
29
+ "data": {
30
+ "dataset": "ERA5 with coupled ocean and land fields",
31
+ "spatial_resolution_degrees": 0.25,
32
+ "time_step_hours": 6,
33
+ "protocol": "78-channel weather-to-subseasonal contract"
34
+ },
35
+ "configuration_sources": [
36
+ "conf/config.yaml",
37
+ "model/fengwu_w2s.py",
38
+ "scripts/data_loader.py",
39
+ "scripts/train.py"
40
+ ]
41
+ }
configuration.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "subseasonal_forecasting",
4
+ "model": "FengWu-W2S",
5
+ "input_format": "BTCHW",
6
+ "protocol": "independent_reproduction",
7
+ "default_config": "conf/config.yaml",
8
+ "train": "scripts/train.py",
9
+ "inference": "scripts/inference.py",
10
+ "evaluation": "scripts/result.py",
11
+ "visualization": "scripts/result.py",
12
+ "paper": "https://arxiv.org/abs/2411.10191",
13
+ "license": "Apache License 2.0"
14
+ }
model/fengwu_w2s.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained FengWu-W2S style coupled weather model.
2
+
3
+ The original FengWu-W2S extends FengWu with coupled ocean-atmosphere-land
4
+ states and perturbations for seamless subseasonal rollout. This module keeps
5
+ those interfaces explicit while using compact convolutional blocks so the
6
+ workflow can be smoke-tested on small synthetic grids.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Dict, Iterable, Mapping, Optional, Sequence
12
+
13
+ import torch
14
+ from torch import Tensor, nn
15
+ from torch.nn import functional as F
16
+
17
+
18
+ def _group_count(channels: int, maximum: int = 8) -> int:
19
+ for groups in range(min(maximum, channels), 0, -1):
20
+ if channels % groups == 0:
21
+ return groups
22
+ return 1
23
+
24
+
25
+ class ResidualConvBlock(nn.Module):
26
+ """Local periodic convolution block used by each FengWu branch."""
27
+
28
+ def __init__(self, channels: int, dilation: int = 1) -> None:
29
+ super().__init__()
30
+ padding = dilation
31
+ self.norm1 = nn.GroupNorm(_group_count(channels), channels)
32
+ self.conv1 = nn.Conv2d(
33
+ channels,
34
+ channels,
35
+ kernel_size=3,
36
+ padding=padding,
37
+ dilation=dilation,
38
+ padding_mode="circular",
39
+ )
40
+ self.norm2 = nn.GroupNorm(_group_count(channels), channels)
41
+ self.conv2 = nn.Conv2d(
42
+ channels, channels, kernel_size=3, padding=1, padding_mode="circular"
43
+ )
44
+ self.activation = nn.GELU()
45
+
46
+ def forward(self, x: Tensor) -> Tensor:
47
+ residual = x
48
+ x = self.conv1(self.activation(self.norm1(x)))
49
+ x = self.conv2(self.activation(self.norm2(x)))
50
+ return residual + x
51
+
52
+
53
+ class BranchEncoder(nn.Module):
54
+ """Patch embedding followed by compact Swin/FengWu-like local blocks."""
55
+
56
+ def __init__(
57
+ self,
58
+ in_channels: int,
59
+ hidden_channels: int,
60
+ latent_channels: int,
61
+ patch_size: int,
62
+ num_blocks: int,
63
+ ) -> None:
64
+ super().__init__()
65
+ self.patch_size = max(1, int(patch_size))
66
+ self.stem = nn.Conv2d(
67
+ in_channels,
68
+ hidden_channels,
69
+ kernel_size=self.patch_size,
70
+ stride=self.patch_size,
71
+ )
72
+ blocks = []
73
+ for block_index in range(max(1, int(num_blocks))):
74
+ blocks.append(ResidualConvBlock(hidden_channels, dilation=1 + block_index % 2))
75
+ self.blocks = nn.Sequential(*blocks)
76
+ self.projection = nn.Conv2d(hidden_channels, latent_channels, kernel_size=1)
77
+
78
+ def forward(self, x: Tensor, output_size: Sequence[int]) -> Tensor:
79
+ # Padding here is implicit in interpolation, which also handles odd
80
+ # real-world grids such as 721x1440 with a compact patch embedding.
81
+ x = self.stem(x)
82
+ x = self.blocks(x)
83
+ x = self.projection(x)
84
+ return F.interpolate(x, size=tuple(output_size), mode="bilinear", align_corners=False)
85
+
86
+
87
+ class CoupledFusion(nn.Module):
88
+ """Fuse atmosphere/ocean/land latents with a learned global gate."""
89
+
90
+ def __init__(self, latent_channels: int, branches: int = 3) -> None:
91
+ super().__init__()
92
+ total = latent_channels * branches
93
+ self.mix = nn.Sequential(
94
+ nn.Conv2d(total, latent_channels, kernel_size=1),
95
+ nn.GroupNorm(_group_count(latent_channels), latent_channels),
96
+ nn.GELU(),
97
+ ResidualConvBlock(latent_channels),
98
+ )
99
+ self.gate = nn.Sequential(
100
+ nn.AdaptiveAvgPool2d(1),
101
+ nn.Conv2d(latent_channels, latent_channels, kernel_size=1),
102
+ nn.Sigmoid(),
103
+ )
104
+
105
+ def forward(self, features: Iterable[Tensor]) -> Tensor:
106
+ fused = self.mix(torch.cat(tuple(features), dim=1))
107
+ return fused * (1.0 + self.gate(fused))
108
+
109
+
110
+ class BranchDecoder(nn.Module):
111
+ def __init__(self, latent_channels: int, out_channels: int, num_blocks: int) -> None:
112
+ super().__init__()
113
+ blocks = [ResidualConvBlock(latent_channels) for _ in range(max(1, int(num_blocks)))]
114
+ self.blocks = nn.Sequential(*blocks)
115
+ self.head = nn.Conv2d(latent_channels, out_channels, kernel_size=1)
116
+
117
+ def forward(self, x: Tensor) -> Tensor:
118
+ return self.head(self.blocks(x))
119
+
120
+
121
+ class PerturbationModule(nn.Module):
122
+ """Inject scale-controlled latent perturbations for ensemble members."""
123
+
124
+ def __init__(self, channels: int, scale: float = 0.01) -> None:
125
+ super().__init__()
126
+ self.scale = float(scale)
127
+ self.correlation = nn.Conv2d(channels, channels, kernel_size=1, bias=False)
128
+ self.mean_head = nn.Conv2d(channels, channels, kernel_size=1)
129
+ self.logvar_head = nn.Conv2d(channels, channels, kernel_size=1)
130
+ nn.init.eye_(self.correlation.weight.squeeze(-1).squeeze(-1))
131
+
132
+ def forward(self, latent: Tensor, enabled: bool, return_kl: bool = False):
133
+ if not enabled or self.scale == 0.0:
134
+ return (latent, latent.new_zeros(())) if return_kl else latent
135
+ noise = torch.randn_like(latent)
136
+ mean = self.mean_head(latent)
137
+ logvar = self.logvar_head(latent).clamp(-6.0, 2.0)
138
+ sample = mean + torch.exp(0.5 * logvar) * self.correlation(noise)
139
+ kl = 0.5 * torch.mean(torch.exp(logvar) + mean.square() - 1.0 - logvar)
140
+ perturbed = latent + self.scale * sample
141
+ return (perturbed, kl) if return_kl else perturbed
142
+
143
+
144
+ class FengWuW2S(nn.Module):
145
+ """Coupled autoregressive FengWu-W2S model.
146
+
147
+ Inputs and outputs use ``[batch, channels, latitude, longitude]``. The
148
+ channel groups can be changed without changing the model interface.
149
+ """
150
+
151
+ def __init__(
152
+ self,
153
+ in_channels: int,
154
+ group_indices: Mapping[str, Sequence[int]],
155
+ hidden_channels: int = 32,
156
+ latent_channels: int = 64,
157
+ patch_size: int = 4,
158
+ num_blocks: int = 2,
159
+ perturbation_scale: float = 0.01,
160
+ ) -> None:
161
+ super().__init__()
162
+ required = {"atmosphere", "ocean", "land"}
163
+ missing = required.difference(group_indices)
164
+ if missing:
165
+ raise ValueError(f"group_indices missing required groups: {sorted(missing)}")
166
+ self.in_channels = int(in_channels)
167
+ self.group_indices = {name: tuple(int(i) for i in indices) for name, indices in group_indices.items()}
168
+ all_indices = [i for values in self.group_indices.values() for i in values]
169
+ if sorted(all_indices) != list(range(self.in_channels)):
170
+ raise ValueError("group_indices must partition all input channels")
171
+
172
+ self.encoders = nn.ModuleDict(
173
+ {
174
+ name: BranchEncoder(
175
+ len(indices), hidden_channels, latent_channels, patch_size, num_blocks
176
+ )
177
+ for name, indices in self.group_indices.items()
178
+ }
179
+ )
180
+ self.fusion = CoupledFusion(latent_channels, branches=3)
181
+ self.perturbation = PerturbationModule(latent_channels, perturbation_scale)
182
+ # A per-channel log scale makes the FengWu Gaussian probability loss
183
+ # explicit while keeping inference output equal to the predicted mean.
184
+ self.log_std = nn.Parameter(torch.zeros(self.in_channels, 1, 1))
185
+ self.decoders = nn.ModuleDict(
186
+ {
187
+ name: BranchDecoder(latent_channels, len(indices), num_blocks)
188
+ for name, indices in self.group_indices.items()
189
+ }
190
+ )
191
+
192
+ def _split(self, state: Tensor) -> Dict[str, Tensor]:
193
+ return {
194
+ name: state[:, indices, ...]
195
+ for name, indices in self.group_indices.items()
196
+ }
197
+
198
+ def forward(
199
+ self,
200
+ state: Tensor,
201
+ stochastic: Optional[bool] = None,
202
+ return_aux: bool = False,
203
+ ):
204
+ if state.ndim != 4 or state.shape[1] != self.in_channels:
205
+ raise ValueError(
206
+ f"expected [B,{self.in_channels},H,W], got {tuple(state.shape)}"
207
+ )
208
+ branches = self._split(state)
209
+ spatial = state.shape[-2:]
210
+ features = [self.encoders[name](branches[name], spatial) for name in ("atmosphere", "ocean", "land")]
211
+ latent = self.fusion(features)
212
+ latent, kl = self.perturbation(
213
+ latent,
214
+ self.training if stochastic is None else stochastic,
215
+ return_kl=True,
216
+ )
217
+ delta = state.new_zeros(state.shape)
218
+ for name, indices in self.group_indices.items():
219
+ delta[:, indices, ...] = self.decoders[name](latent)
220
+ # Predicting an increment stabilizes long autoregressive rollouts.
221
+ result = state + delta
222
+ if return_aux:
223
+ return result, {"kl": kl, "log_std": self.log_std}
224
+ return result
225
+
226
+ @torch.no_grad()
227
+ def rollout(
228
+ self,
229
+ initial_state: Tensor,
230
+ steps: int,
231
+ stochastic: bool = False,
232
+ seed: Optional[int] = None,
233
+ ) -> Tensor:
234
+ if initial_state.ndim == 5:
235
+ state = initial_state[:, -1]
236
+ elif initial_state.ndim == 4:
237
+ state = initial_state
238
+ else:
239
+ raise ValueError("initial_state must be [B,T,C,H,W] or [B,C,H,W]")
240
+ if seed is not None:
241
+ torch.manual_seed(int(seed))
242
+ predictions = []
243
+ was_training = self.training
244
+ self.eval()
245
+ for _ in range(max(0, int(steps))):
246
+ state = self.forward(state, stochastic=stochastic)
247
+ predictions.append(state)
248
+ if was_training:
249
+ self.train()
250
+ if not predictions:
251
+ return state.unsqueeze(1)[:, :0]
252
+ return torch.stack(predictions, dim=1)
scripts/data_loader.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ERA5-style HDF5 loader used by training and inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+ from typing import Dict, List, Sequence, Tuple
8
+
9
+ import h5py
10
+ import numpy as np
11
+ import torch
12
+ from torch.utils.data import DataLoader, Dataset
13
+ from torch.utils.data.distributed import DistributedSampler
14
+
15
+
16
+ def _decode(value: object) -> str:
17
+ return value.decode() if isinstance(value, bytes) else str(value)
18
+
19
+
20
+ def resolve_data_dir(path: str | Path, project_root: Path | None = None) -> Path:
21
+ root = project_root or Path(__file__).resolve().parents[1]
22
+ candidate = Path(path).expanduser()
23
+ return candidate if candidate.is_absolute() else (root / candidate).resolve()
24
+
25
+
26
+ def read_metadata(data_dir: str | Path, channels: Sequence[str]) -> Dict[str, np.ndarray | int | List[str]]:
27
+ data_dir = Path(data_dir)
28
+ files = sorted((data_dir / "data").glob("*.h5"))
29
+ if not files:
30
+ raise FileNotFoundError(f"No yearly HDF5 files found under {data_dir / 'data'}")
31
+ with h5py.File(files[0], "r") as source:
32
+ fields = source["fields"]
33
+ variables = [_decode(item) for item in fields.attrs["variables"]]
34
+ time_step = int(fields.attrs.get("time_step", 6))
35
+ means = np.asarray(source["global_means"][:], dtype=np.float32)
36
+ stds = np.asarray(source["global_stds"][:], dtype=np.float32)
37
+ shape = tuple(int(item) for item in fields.shape)
38
+ missing = [name for name in channels if name not in variables]
39
+ if missing:
40
+ raise ValueError(f"Variables missing from synthetic/ERA5 data: {missing}")
41
+ indices = np.asarray([variables.index(name) for name in channels], dtype=np.int64)
42
+ return {
43
+ "variables": variables,
44
+ "indices": indices,
45
+ "time_step": time_step,
46
+ "means": means[:, indices, :, :],
47
+ "stds": np.maximum(stds[:, indices, :, :], 1.0e-6),
48
+ "shape": shape,
49
+ }
50
+
51
+
52
+ class ERA5WindowDataset(Dataset):
53
+ def __init__(
54
+ self,
55
+ data_dir: str | Path,
56
+ years: Sequence[int],
57
+ channels: Sequence[str],
58
+ input_steps: int = 2,
59
+ rollout_steps: int = 1,
60
+ normalize: bool = True,
61
+ ) -> None:
62
+ self.data_dir = Path(data_dir)
63
+ self.years = [int(year) for year in years]
64
+ self.channels = list(channels)
65
+ self.input_steps = max(1, int(input_steps))
66
+ self.rollout_steps = max(1, int(rollout_steps))
67
+ self.normalize = bool(normalize)
68
+ metadata = read_metadata(self.data_dir, self.channels)
69
+ self.channel_indices = metadata["indices"]
70
+ self.time_step = int(metadata["time_step"])
71
+ self.means = torch.from_numpy(metadata["means"])
72
+ self.stds = torch.from_numpy(metadata["stds"])
73
+ self.shape = metadata["shape"]
74
+ self.samples: List[Tuple[int, int]] = []
75
+ for year in self.years:
76
+ path = self.data_dir / "data" / f"{year}.h5"
77
+ if not path.exists():
78
+ raise FileNotFoundError(f"Missing year file: {path}")
79
+ with h5py.File(path, "r") as source:
80
+ timesteps = int(source["fields"].shape[0])
81
+ count = timesteps - self.input_steps - self.rollout_steps + 1
82
+ if count <= 0:
83
+ raise ValueError(
84
+ f"Year {year} has {timesteps} steps, but input={self.input_steps} "
85
+ f"and rollout={self.rollout_steps} require at least {self.input_steps + self.rollout_steps}"
86
+ )
87
+ self.samples.extend((year, index) for index in range(count))
88
+
89
+ def __len__(self) -> int:
90
+ return len(self.samples)
91
+
92
+ def _timestamp(self, year: int, index: int) -> str:
93
+ value = datetime(year, 1, 1) + timedelta(hours=index * self.time_step)
94
+ return value.strftime("%Y%m%d%H")
95
+
96
+ def __getitem__(self, item: int):
97
+ year, start = self.samples[item]
98
+ path = self.data_dir / "data" / f"{year}.h5"
99
+ with h5py.File(path, "r") as source:
100
+ fields = source["fields"]
101
+ input_data = np.asarray(
102
+ fields[start : start + self.input_steps, self.channel_indices, :, :], dtype=np.float32
103
+ )
104
+ target_data = np.asarray(
105
+ fields[
106
+ start + self.input_steps : start + self.input_steps + self.rollout_steps,
107
+ self.channel_indices,
108
+ :,
109
+ :,
110
+ ],
111
+ dtype=np.float32,
112
+ )
113
+ input_tensor = torch.from_numpy(input_data)
114
+ target_tensor = torch.from_numpy(target_data)
115
+ if self.normalize:
116
+ input_tensor = (input_tensor - self.means) / self.stds
117
+ target_tensor = (target_tensor - self.means) / self.stds
118
+ timestamp = self._timestamp(year, start + self.input_steps)
119
+ return input_tensor, target_tensor, timestamp
120
+
121
+
122
+ def make_dataloader(
123
+ data_dir: str | Path,
124
+ years: Sequence[int],
125
+ channels: Sequence[str],
126
+ input_steps: int,
127
+ rollout_steps: int,
128
+ batch_size: int,
129
+ num_workers: int = 0,
130
+ distributed: bool = False,
131
+ train: bool = False,
132
+ pin_memory: bool = False,
133
+ ):
134
+ dataset = ERA5WindowDataset(
135
+ data_dir=data_dir,
136
+ years=years,
137
+ channels=channels,
138
+ input_steps=input_steps,
139
+ rollout_steps=rollout_steps,
140
+ )
141
+ sampler = DistributedSampler(dataset, shuffle=train) if distributed else None
142
+ loader = DataLoader(
143
+ dataset,
144
+ batch_size=max(1, int(batch_size)),
145
+ shuffle=train and sampler is None,
146
+ sampler=sampler,
147
+ num_workers=max(0, int(num_workers)),
148
+ pin_memory=bool(pin_memory),
149
+ drop_last=False,
150
+ )
151
+ return loader, sampler
152
+
scripts/fake_data.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Generate a compact deterministic ERA5-style dataset for workflow checks."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ import h5py
11
+ import numpy as np
12
+ import yaml
13
+
14
+
15
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
16
+
17
+
18
+ def parse_args() -> argparse.Namespace:
19
+ parser = argparse.ArgumentParser(description=__doc__)
20
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
21
+ parser.add_argument("--output-dir", help="Override data.data_dir from the config")
22
+ parser.add_argument("--height", type=int)
23
+ parser.add_argument("--width", type=int)
24
+ parser.add_argument("--timesteps", type=int)
25
+ parser.add_argument("--seed", type=int)
26
+ return parser.parse_args()
27
+
28
+
29
+ def _resolve(path: str, root: Path) -> Path:
30
+ candidate = Path(path).expanduser()
31
+ return candidate if candidate.is_absolute() else (root / candidate).resolve()
32
+
33
+
34
+ def generate_dataset(
35
+ output_dir: str | Path,
36
+ years: list[int],
37
+ channels: list[str],
38
+ height: int,
39
+ width: int,
40
+ timesteps: int,
41
+ time_step_hours: int = 6,
42
+ seed: int = 42,
43
+ ) -> Path:
44
+ """Write yearly HDF5 files and static fields without external data access."""
45
+ if min(height, width, timesteps) <= 0:
46
+ raise ValueError("height, width and timesteps must be positive")
47
+ output_dir = Path(output_dir)
48
+ data_dir = output_dir / "data"
49
+ data_dir.mkdir(parents=True, exist_ok=True)
50
+ rng = np.random.default_rng(seed)
51
+ channels = list(channels)
52
+ channel_count = len(channels)
53
+ channel_mean = np.linspace(0.0, 0.9, channel_count, dtype=np.float32)
54
+ channel_std = np.linspace(0.8, 1.2, channel_count, dtype=np.float32)
55
+ latitude = np.linspace(1.0, -1.0, height, dtype=np.float32)[None, :, None]
56
+ longitude = np.linspace(0.0, 2.0 * np.pi, width, endpoint=False, dtype=np.float32)[None, None, :]
57
+ base_pattern = 0.15 * latitude + 0.08 * np.sin(longitude)
58
+
59
+ for year_offset, year in enumerate(sorted(set(int(item) for item in years))):
60
+ path = data_dir / f"{year}.h5"
61
+ with h5py.File(path, "w") as output:
62
+ fields = output.create_dataset(
63
+ "fields",
64
+ shape=(timesteps, channel_count, height, width),
65
+ dtype="float32",
66
+ chunks=(1, channel_count, height, width),
67
+ compression="lzf",
68
+ )
69
+ fields.attrs["variables"] = np.asarray(channels, dtype="S")
70
+ fields.attrs["time_step"] = int(time_step_hours)
71
+ for timestep in range(timesteps):
72
+ values = np.empty((channel_count, height, width), dtype=np.float32)
73
+ for channel in range(channel_count):
74
+ phase = 0.05 * (timestep + year_offset) + 0.2 * channel
75
+ smooth = base_pattern + 0.03 * np.sin(phase)
76
+ noise = rng.normal(0.0, 0.01, size=(height, width)).astype(np.float32)
77
+ values[channel] = channel_mean[channel] + channel_std[channel] * (smooth + noise)
78
+ fields[timestep] = values
79
+ output.create_dataset("global_means", data=channel_mean.reshape(1, channel_count, 1, 1))
80
+ output.create_dataset("global_stds", data=channel_std.reshape(1, channel_count, 1, 1))
81
+ print(f"Generated {path} shape=({timesteps},{channel_count},{height},{width})")
82
+
83
+ static_dir = output_dir / "static"
84
+ static_dir.mkdir(parents=True, exist_ok=True)
85
+ latitude_2d = latitude.squeeze(0)
86
+ land_mask = np.broadcast_to((latitude_2d > 0).astype(np.float32), (height, width)).copy()
87
+ orography = (0.2 * np.cos(longitude) + 0.05 * latitude).astype(np.float32).squeeze(0)
88
+ sea_ice_mask = np.broadcast_to((latitude_2d < -0.55).astype(np.float32), (height, width)).copy()
89
+ np.save(static_dir / "land_mask.npy", land_mask)
90
+ np.save(static_dir / "orography.npy", orography)
91
+ np.save(static_dir / "sea_ice_mask.npy", sea_ice_mask)
92
+ metadata = {
93
+ "format": "ERA5-HDF5-window-v1",
94
+ "years": sorted(set(int(item) for item in years)),
95
+ "channels": channels,
96
+ "shape": [timesteps, channel_count, height, width],
97
+ "time_step_hours": int(time_step_hours),
98
+ "seed": int(seed),
99
+ }
100
+ (output_dir / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
101
+ print(f"Generated static fields and metadata under {output_dir}")
102
+ return output_dir
103
+
104
+
105
+ def main() -> None:
106
+ args = parse_args()
107
+ with Path(args.config).open(encoding="utf-8") as source:
108
+ config = yaml.safe_load(source)
109
+ data_cfg = config["data"]
110
+ fake_cfg = config.get("fake_data", {})
111
+ data_dir = _resolve(args.output_dir or data_cfg["data_dir"], PROJECT_ROOT)
112
+ years = list(data_cfg["train_years"]) + list(data_cfg["val_years"]) + list(data_cfg["test_years"])
113
+ generate_dataset(
114
+ data_dir,
115
+ years=years,
116
+ channels=list(data_cfg["channels"]),
117
+ height=int(args.height or fake_cfg.get("height", 32)),
118
+ width=int(args.width or fake_cfg.get("width", 64)),
119
+ timesteps=int(args.timesteps or fake_cfg.get("timesteps", 12)),
120
+ time_step_hours=int(data_cfg.get("time_step_hours", 6)),
121
+ seed=int(args.seed if args.seed is not None else fake_cfg.get("seed", 42)),
122
+ )
123
+
124
+
125
+ if __name__ == "__main__":
126
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run autoregressive FengWu-W2S inference and save forecast fields."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+ import torch
14
+ import yaml
15
+
16
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
17
+ sys.path.insert(0, str(PROJECT_ROOT))
18
+
19
+ from model.fengwu_w2s import FengWuW2S
20
+ from scripts.data_loader import ERA5WindowDataset, read_metadata, resolve_data_dir
21
+
22
+
23
+ def parse_args() -> argparse.Namespace:
24
+ parser = argparse.ArgumentParser(description=__doc__)
25
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
26
+ parser.add_argument("--data-dir")
27
+ parser.add_argument("--checkpoint")
28
+ parser.add_argument("--output-dir")
29
+ parser.add_argument("--split", choices=("train", "val", "test"), default="test")
30
+ parser.add_argument("--steps", type=int)
31
+ parser.add_argument("--limit", type=int, help="Limit windows for a quick smoke test")
32
+ parser.add_argument("--stochastic", action="store_true")
33
+ parser.add_argument("--seed", type=int, default=0)
34
+ return parser.parse_args()
35
+
36
+
37
+ def _resolve(path: str | Path, root: Path = PROJECT_ROOT) -> Path:
38
+ candidate = Path(path).expanduser()
39
+ return candidate if candidate.is_absolute() else (root / candidate).resolve()
40
+
41
+
42
+ def _build_model(config: dict) -> FengWuW2S:
43
+ model_cfg = config["model"]
44
+ data_cfg = config["data"]
45
+ return FengWuW2S(
46
+ in_channels=len(data_cfg["channels"]),
47
+ group_indices=data_cfg["groups"],
48
+ hidden_channels=int(model_cfg.get("hidden_channels", 32)),
49
+ latent_channels=int(model_cfg.get("latent_channels", 64)),
50
+ patch_size=int(model_cfg.get("patch_size", 4)),
51
+ num_blocks=int(model_cfg.get("num_blocks", 2)),
52
+ perturbation_scale=float(model_cfg.get("perturbation_scale", 0.01)),
53
+ )
54
+
55
+
56
+ def main() -> None:
57
+ args = parse_args()
58
+ with Path(args.config).open(encoding="utf-8") as source:
59
+ config = yaml.safe_load(source)
60
+ data_cfg = config["data"]
61
+ model_cfg = config["model"]
62
+ data_dir = resolve_data_dir(args.data_dir or data_cfg["data_dir"], PROJECT_ROOT)
63
+ checkpoint = _resolve(args.checkpoint or model_cfg.get("default_checkpoint", "./data/checkpoints/model_bak.pth"))
64
+ if not checkpoint.exists():
65
+ raise FileNotFoundError(f"Checkpoint not found: {checkpoint}; run scripts/train.py first")
66
+ split_years = {
67
+ "train": data_cfg["train_years"],
68
+ "val": data_cfg["val_years"],
69
+ "test": data_cfg["test_years"],
70
+ }[args.split]
71
+ steps = int(args.steps or model_cfg.get("inference_steps", 1))
72
+ dataset = ERA5WindowDataset(
73
+ data_dir=data_dir,
74
+ years=split_years,
75
+ channels=data_cfg["channels"],
76
+ input_steps=int(model_cfg.get("input_steps", 2)),
77
+ rollout_steps=max(1, steps),
78
+ )
79
+ metadata = read_metadata(data_dir, data_cfg["channels"])
80
+ means = torch.from_numpy(metadata["means"])
81
+ stds = torch.from_numpy(metadata["stds"])
82
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
83
+ model = _build_model(config).to(device)
84
+ state = torch.load(checkpoint, map_location=device, weights_only=False)
85
+ model.load_state_dict(state["model_state_dict"])
86
+ model.eval()
87
+ output_dir = _resolve(args.output_dir or "./result/output")
88
+ output_dir.mkdir(parents=True, exist_ok=True)
89
+ index = []
90
+ limit = len(dataset) if args.limit is None else min(len(dataset), max(0, int(args.limit)))
91
+ for sample_index in range(limit):
92
+ inputs, _, timestamp = dataset[sample_index]
93
+ initial = inputs.unsqueeze(0).to(device)
94
+ with torch.no_grad():
95
+ prediction = model.rollout(initial, steps=steps, stochastic=args.stochastic, seed=args.seed + sample_index)
96
+ prediction = prediction.squeeze(0).cpu() * stds + means
97
+ year = timestamp[:4]
98
+ year_dir = output_dir / year
99
+ year_dir.mkdir(parents=True, exist_ok=True)
100
+ start = datetime.strptime(timestamp, "%Y%m%d%H")
101
+ for lead, field in enumerate(prediction):
102
+ valid_time = start + timedelta(hours=lead * int(metadata["time_step"]))
103
+ valid_timestamp = valid_time.strftime("%Y%m%d%H")
104
+ path = year_dir / f"{timestamp}_lead{lead:03d}.npy"
105
+ np.save(path, field.numpy().astype(np.float32))
106
+ index.append({
107
+ "source_timestamp": timestamp,
108
+ "valid_timestamp": valid_timestamp,
109
+ "lead": lead,
110
+ # Keep the manifest relocatable when the project is copied to
111
+ # a compute node or downloaded from ModelScope.
112
+ "path": str(path.relative_to(output_dir)),
113
+ })
114
+ (output_dir / "index.json").write_text(json.dumps(index, indent=2), encoding="utf-8")
115
+ print(f"Saved {len(index)} forecast fields under {output_dir}")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
scripts/result.py ADDED
@@ -0,0 +1,405 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate forecasts and render meteorological diagnostic figures."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ from collections import defaultdict
10
+ from datetime import datetime, timedelta
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Mapping, Sequence, Tuple
13
+
14
+ import h5py
15
+ import numpy as np
16
+ import yaml
17
+
18
+ try:
19
+ import matplotlib
20
+
21
+ matplotlib.use("Agg")
22
+ import matplotlib.pyplot as plt
23
+ from matplotlib.colors import Normalize, TwoSlopeNorm
24
+ except ImportError as exc: # pragma: no cover
25
+ raise RuntimeError(
26
+ "scripts/result.py requires matplotlib for scientific figures"
27
+ ) from exc
28
+
29
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
30
+ sys.path.insert(0, str(PROJECT_ROOT))
31
+
32
+ from scripts.data_loader import read_metadata, resolve_data_dir
33
+
34
+
35
+ def parse_args() -> argparse.Namespace:
36
+ parser = argparse.ArgumentParser(description=__doc__)
37
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
38
+ parser.add_argument("--data-dir")
39
+ parser.add_argument("--output-dir", default=str(PROJECT_ROOT / "result/output"))
40
+ parser.add_argument("--plot-count", type=int, default=3, help="Initialization times to visualize")
41
+ parser.add_argument("--plot-leads", type=int, default=3, help="Lead times per initialization to visualize")
42
+ parser.add_argument("--dpi", type=int, default=160)
43
+ return parser.parse_args()
44
+
45
+
46
+ def _pretty_name(name: str) -> str:
47
+ replacements = {
48
+ "geopotential_": "Geopotential ",
49
+ "temperature_": "Temperature ",
50
+ "specific_humidity_": "Specific humidity ",
51
+ "2m_temperature": "2 m temperature",
52
+ "total_precipitation": "Total precipitation",
53
+ "mean_sea_level_pressure": "Mean sea-level pressure",
54
+ "10m_u_component_of_wind": "10 m U wind",
55
+ "10m_v_component_of_wind": "10 m V wind",
56
+ "100m_u_component_of_wind": "100 m U wind",
57
+ "100m_v_component_of_wind": "100 m V wind",
58
+ "u_component_of_wind_": "U wind ",
59
+ "v_component_of_wind_": "V wind ",
60
+ "outgoing_longwave_radiation": "Outgoing longwave radiation",
61
+ "sea_surface_temperature": "Sea-surface temperature",
62
+ "significant_wave_height": "Significant wave height",
63
+ "mean_wave_direction": "Mean wave direction",
64
+ "mean_wave_period": "Mean wave period",
65
+ "soil_moisture_0_7cm": "Soil moisture 0-7 cm",
66
+ }
67
+ for prefix, replacement in replacements.items():
68
+ if name.startswith(prefix):
69
+ suffix = name[len(prefix) :]
70
+ return f"{replacement}{suffix}" if suffix.isdigit() else replacement
71
+ return name.replace("_", " ").title()
72
+
73
+
74
+ def _safe_percentile(values: np.ndarray, percentile: float, default: float = 1.0) -> float:
75
+ finite = np.asarray(values, dtype=np.float64)
76
+ finite = finite[np.isfinite(finite)]
77
+ if finite.size == 0:
78
+ return float(default)
79
+ result = float(np.percentile(finite, percentile))
80
+ return result if np.isfinite(result) else float(default)
81
+
82
+
83
+ def _absolute_limits(values: np.ndarray) -> Tuple[float, float]:
84
+ lo, hi = _safe_percentile(values, 2.0, -1.0), _safe_percentile(values, 98.0, 1.0)
85
+ if not np.isfinite(lo) or not np.isfinite(hi) or hi <= lo:
86
+ center = float(np.nanmean(values)) if np.isfinite(values).any() else 0.0
87
+ span = max(abs(center) * 0.05, 1.0)
88
+ return center - span, center + span
89
+ return lo, hi
90
+
91
+
92
+ def _error_limit(values: np.ndarray) -> float:
93
+ limit = _safe_percentile(np.abs(values), 98.0, 1.0)
94
+ if not np.isfinite(limit) or limit <= 1.0e-8:
95
+ limit = max(_safe_percentile(np.abs(values), 100.0, 1.0), 1.0)
96
+ return float(limit)
97
+
98
+
99
+ def _field(field: np.ndarray, lookup: Mapping[str, int], name: str) -> np.ndarray | None:
100
+ index = lookup.get(name)
101
+ return None if index is None else np.asarray(field[index], dtype=np.float32)
102
+
103
+
104
+ def _diagnostics(channels: Sequence[str], prediction: np.ndarray, truth: np.ndarray) -> List[Dict[str, Any]]:
105
+ """Create fields with meteorological meaning from a channel tensor."""
106
+ lookup = {name: index for index, name in enumerate(channels)}
107
+ diagnostics: List[Dict[str, Any]] = []
108
+
109
+ temperature_name = "2m_temperature" if "2m_temperature" in lookup else "temperature_500"
110
+ pred, true = _field(prediction, lookup, temperature_name), _field(truth, lookup, temperature_name)
111
+ if pred is not None and true is not None:
112
+ diagnostics.append({"key": "temperature", "title": _pretty_name(temperature_name), "unit": "data units", "prediction": pred, "truth": true, "cmap": "coolwarm"})
113
+
114
+ pred, true = _field(prediction, lookup, "geopotential_500"), _field(truth, lookup, "geopotential_500")
115
+ if pred is not None and true is not None:
116
+ diagnostics.append({"key": "geopotential", "title": "500 hPa geopotential", "unit": "data units", "prediction": pred, "truth": true, "cmap": "viridis"})
117
+
118
+ wind_pairs = (
119
+ ("10m_u_component_of_wind", "10m_v_component_of_wind", "10 m wind speed"),
120
+ ("u_component_of_wind_850", "v_component_of_wind_850", "850 hPa wind speed"),
121
+ )
122
+ for u_name, v_name, title in wind_pairs:
123
+ up, vp = _field(prediction, lookup, u_name), _field(prediction, lookup, v_name)
124
+ ut, vt = _field(truth, lookup, u_name), _field(truth, lookup, v_name)
125
+ if all(value is not None for value in (up, vp, ut, vt)):
126
+ diagnostics.append({"key": "wind_speed", "title": title, "unit": "data units", "prediction": np.hypot(up, vp), "truth": np.hypot(ut, vt), "cmap": "magma", "prediction_uv": (up, vp), "truth_uv": (ut, vt)})
127
+ break
128
+
129
+ pred, true = _field(prediction, lookup, "total_precipitation"), _field(truth, lookup, "total_precipitation")
130
+ if pred is not None and true is not None:
131
+ diagnostics.append({"key": "precipitation", "title": "Total precipitation", "unit": "data units", "prediction": pred, "truth": true, "cmap": "YlGnBu"})
132
+
133
+ if len(diagnostics) < 2:
134
+ used = {item["title"] for item in diagnostics}
135
+ for index, name in enumerate(channels):
136
+ if _pretty_name(name) in used:
137
+ continue
138
+ diagnostics.append({"key": f"channel_{index}", "title": _pretty_name(name), "unit": "data units", "prediction": prediction[index], "truth": truth[index], "cmap": "coolwarm"})
139
+ if len(diagnostics) >= 3:
140
+ break
141
+ return diagnostics
142
+
143
+
144
+ def _draw_grid(
145
+ ax: Any,
146
+ field: np.ndarray,
147
+ title: str,
148
+ cmap: str,
149
+ unit: str,
150
+ error: bool = False,
151
+ overlay: Tuple[np.ndarray, np.ndarray] | None = None,
152
+ limits: Tuple[float, float] | None = None,
153
+ ) -> None:
154
+ """Draw a regular global latitude/longitude panel without cartopy."""
155
+ height, width = field.shape
156
+ longitude = np.linspace(0.0, 360.0, width, endpoint=False)
157
+ latitude = np.linspace(-90.0, 90.0, height)
158
+ if error:
159
+ limit = _error_limit(field) if limits is None else max(abs(float(limits[0])), abs(float(limits[1])))
160
+ norm = TwoSlopeNorm(vmin=-limit, vcenter=0.0, vmax=limit)
161
+ image = ax.imshow(field, extent=(0, 360, -90, 90), origin="lower", aspect="auto", cmap="RdBu_r", norm=norm, interpolation="nearest")
162
+ else:
163
+ lo, hi = _absolute_limits(field) if limits is None else limits
164
+ image = ax.imshow(field, extent=(0, 360, -90, 90), origin="lower", aspect="auto", cmap=cmap, norm=Normalize(vmin=lo, vmax=hi), interpolation="nearest")
165
+ ax.set_title(title, fontsize=10, pad=5)
166
+ ax.set_xlim(0, 360)
167
+ ax.set_ylim(-90, 90)
168
+ ax.set_xticks((0, 60, 120, 180, 240, 300, 360))
169
+ ax.set_yticks((-60, -30, 0, 30, 60))
170
+ ax.set_xlabel("Longitude (deg)", fontsize=8)
171
+ ax.set_ylabel("Latitude (deg)", fontsize=8)
172
+ ax.tick_params(labelsize=8)
173
+ ax.grid(color="white", alpha=0.35, linewidth=0.45)
174
+ ax.axhline(0, color="black", alpha=0.28, linewidth=0.55)
175
+ ax.axvline(180, color="black", alpha=0.28, linewidth=0.55)
176
+ if overlay is not None and not error:
177
+ u, v = overlay
178
+ step_y, step_x = max(1, height // 12), max(1, width // 24)
179
+ speed = np.hypot(u, v)
180
+ scale = max(_safe_percentile(speed, 95, 1.0) * 12, 1.0)
181
+ quiver = ax.quiver(longitude[::step_x], latitude[::step_y], u[::step_y, ::step_x], v[::step_y, ::step_x], color="black", alpha=0.68, scale=scale, width=0.0022, headwidth=3.5, minlength=0.1)
182
+ ax.quiverkey(quiver, 0.86, -0.16, _safe_percentile(speed, 75, 1.0), "75th pct", labelpos="E", coordinates="axes", fontproperties={"size": 7})
183
+ colorbar = ax.figure.colorbar(image, ax=ax, fraction=0.045, pad=0.025)
184
+ colorbar.ax.tick_params(labelsize=7)
185
+ colorbar.set_label(("Error (" + unit + ")") if error else unit, fontsize=8)
186
+
187
+
188
+ def _plot_overview(path: Path, source: str, record: Mapping[str, Any], channels: Sequence[str], prediction: np.ndarray, truth: np.ndarray, dpi: int) -> None:
189
+ diagnostics = _diagnostics(channels, prediction, truth)
190
+ lead = int(record["lead"])
191
+ time_step = int(record.get("time_step", 6))
192
+ lead_hours = (lead + 1) * time_step
193
+ fig, axes = plt.subplots(len(diagnostics), 3, figsize=(16.5, max(4.0 * len(diagnostics), 8.0)), squeeze=False, facecolor="#f5f7fa")
194
+ for row, diagnostic in enumerate(diagnostics):
195
+ pred, true = diagnostic["prediction"], diagnostic["truth"]
196
+ error = pred - true
197
+ limits = _absolute_limits(np.concatenate((pred.reshape(-1), true.reshape(-1))))
198
+ _draw_grid(axes[row, 0], pred, f"{diagnostic['title']} | forecast", diagnostic["cmap"], diagnostic["unit"], overlay=diagnostic.get("prediction_uv"), limits=limits)
199
+ _draw_grid(axes[row, 1], true, f"{diagnostic['title']} | verifying field", diagnostic["cmap"], diagnostic["unit"], overlay=diagnostic.get("truth_uv"), limits=limits)
200
+ _draw_grid(axes[row, 2], error, f"{diagnostic['title']} | forecast error", diagnostic["cmap"], diagnostic["unit"], error=True)
201
+ axes[row, 2].text(0.02, 0.03, f"RMSE {np.sqrt(np.nanmean(error * error)):.3g} bias {np.nanmean(error):+.3g}", transform=axes[row, 2].transAxes, fontsize=8, color="black", bbox={"facecolor": "white", "alpha": 0.82, "edgecolor": "none", "pad": 2.5})
202
+ valid = str(record["valid_timestamp"])
203
+ analysis_time = datetime.strptime(source, "%Y%m%d%H") - timedelta(hours=time_step)
204
+ analysis = analysis_time.strftime("%Y%m%d%H")
205
+ fig.suptitle(f"FengWu-W2S global forecast diagnostics\nInit {analysis[:8]} {analysis[8:]} UTC | Valid {valid[:8]} {valid[8:]} UTC | F{lead_hours:03d}", fontsize=15, fontweight="bold", y=0.995)
206
+ fig.subplots_adjust(top=0.93, left=0.04, right=0.98, bottom=0.045, hspace=0.56, wspace=0.20)
207
+ path.parent.mkdir(parents=True, exist_ok=True)
208
+ fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
209
+ plt.close(fig)
210
+
211
+
212
+ def _plot_lead_summary(path: Path, lead_metrics: Sequence[Mapping[str, Any]], dpi: int) -> None:
213
+ if not lead_metrics:
214
+ return
215
+ leads = np.asarray([item["lead_hours"] for item in lead_metrics], dtype=float)
216
+ nrmse = np.asarray([item["mean_normalized_rmse"] for item in lead_metrics], dtype=float)
217
+ acc = np.asarray([item["mean_acc"] for item in lead_metrics], dtype=float)
218
+ fig, axes = plt.subplots(1, 2, figsize=(14, 4.8), facecolor="#f5f7fa")
219
+ for ax in axes:
220
+ ax.set_facecolor("white")
221
+ ax.grid(alpha=0.25, linewidth=0.7)
222
+ ax.set_xlabel("Forecast lead (hours)")
223
+ axes[0].plot(leads, nrmse, color="#b43f3f", marker="o", linewidth=2.2, markersize=5)
224
+ axes[0].fill_between(leads, 0, nrmse, color="#e8a1a1", alpha=0.28)
225
+ axes[0].set_title("Normalized error growth", fontweight="bold")
226
+ axes[0].set_ylabel("Mean RMSE / channel standard deviation")
227
+ axes[0].set_ylim(bottom=0)
228
+ axes[1].plot(leads, acc, color="#20639b", marker="o", linewidth=2.2, markersize=5)
229
+ axes[1].axhline(0, color="#555", linewidth=0.8)
230
+ axes[1].set_title("Anomaly correlation by lead", fontweight="bold")
231
+ axes[1].set_ylabel("Mean ACC")
232
+ axes[1].set_ylim(-1, 1)
233
+ fig.suptitle("FengWu-W2S forecast skill across available leads", fontsize=14, fontweight="bold")
234
+ fig.tight_layout(rect=(0, 0, 1, 0.93))
235
+ path.parent.mkdir(parents=True, exist_ok=True)
236
+ fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
237
+ plt.close(fig)
238
+
239
+
240
+ def _plot_channel_summary(path: Path, channels: Sequence[str], rmse: np.ndarray, normalized_rmse: np.ndarray, acc: np.ndarray, dpi: int) -> None:
241
+ count = min(12, len(channels))
242
+ ranking = np.argsort(normalized_rmse)[::-1][:count]
243
+ labels = [_pretty_name(channels[index]) for index in ranking][::-1]
244
+ values, acc_values = normalized_rmse[ranking][::-1], acc[ranking][::-1]
245
+ fig, axes = plt.subplots(1, 2, figsize=(15, max(5.5, count * 0.46)), facecolor="#f5f7fa")
246
+ for ax in axes:
247
+ ax.set_facecolor("white")
248
+ ax.grid(axis="x", alpha=0.25)
249
+ axes[0].barh(labels, values, color="#d46a6a", alpha=0.9)
250
+ axes[0].set_title("Largest normalized RMSE", fontweight="bold")
251
+ axes[0].set_xlabel("RMSE / channel standard deviation")
252
+ axes[1].barh(labels, acc_values, color="#4b88b8", alpha=0.9)
253
+ axes[1].set_title("ACC for the same channels", fontweight="bold")
254
+ axes[1].set_xlabel("Anomaly correlation coefficient")
255
+ axes[1].set_xlim(-1, 1)
256
+ fig.suptitle("Channel-level forecast diagnostics", fontsize=14, fontweight="bold")
257
+ fig.tight_layout(rect=(0, 0, 1, 0.94))
258
+ path.parent.mkdir(parents=True, exist_ok=True)
259
+ fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
260
+ plt.close(fig)
261
+
262
+
263
+ def _plot_training_history(path: Path, train: np.ndarray, valid: np.ndarray, dpi: int) -> None:
264
+ if train.size == 0 and valid.size == 0:
265
+ return
266
+ fig, ax = plt.subplots(figsize=(9.5, 4.8), facecolor="#f5f7fa")
267
+ ax.set_facecolor("white")
268
+ if train.size:
269
+ ax.plot(np.arange(1, train.size + 1), train, marker="o", color="#2166ac", linewidth=2, label="Training")
270
+ if valid.size:
271
+ ax.plot(np.arange(1, valid.size + 1), valid, marker="o", color="#b2182b", linewidth=2, label="Validation")
272
+ ax.set_xlabel("Epoch")
273
+ ax.set_ylabel("Reported non-negative probability loss")
274
+ ax.set_title("FengWu-W2S optimization history", fontweight="bold")
275
+ ax.grid(alpha=0.28)
276
+ ax.legend(frameon=False)
277
+ fig.tight_layout()
278
+ path.parent.mkdir(parents=True, exist_ok=True)
279
+ fig.savefig(path, dpi=dpi, facecolor=fig.get_facecolor())
280
+ plt.close(fig)
281
+
282
+
283
+ def _truth_for_timestamp(data_dir: Path, timestamp: str, indices: np.ndarray, time_step: int) -> np.ndarray:
284
+ dt = datetime.strptime(timestamp, "%Y%m%d%H")
285
+ index = int((dt - datetime(dt.year, 1, 1)).total_seconds() // (3600 * time_step))
286
+ path = data_dir / "data" / f"{dt.year}.h5"
287
+ with h5py.File(path, "r") as source:
288
+ fields = source["fields"]
289
+ if index < 0 or index >= fields.shape[0]:
290
+ raise IndexError(f"Timestamp {timestamp} maps to index {index}, outside {path}")
291
+ return np.asarray(fields[index, indices, :, :], dtype=np.float32)
292
+
293
+
294
+ def _load_prediction(output_dir: Path, record: Mapping[str, Any]) -> np.ndarray:
295
+ path = Path(record["path"])
296
+ if not path.is_absolute():
297
+ path = output_dir / path
298
+ if not path.exists():
299
+ raise FileNotFoundError(f"Forecast field listed in index.json does not exist: {path}")
300
+ field = np.load(path).astype(np.float32)
301
+ if field.ndim != 3:
302
+ raise ValueError(f"Forecast field must have [channel, lat, lon] shape, got {field.shape}")
303
+ return field
304
+
305
+
306
+ def _selected_records(records: Sequence[Mapping[str, Any]], plot_count: int, plot_leads: int) -> List[Mapping[str, Any]]:
307
+ grouped: Dict[str, List[Mapping[str, Any]]] = defaultdict(list)
308
+ for record in records:
309
+ grouped[str(record["source_timestamp"])].append(record)
310
+ selected: List[Mapping[str, Any]] = []
311
+ for source in sorted(grouped)[: max(0, int(plot_count))]:
312
+ source_records = sorted(grouped[source], key=lambda item: int(item["lead"]))
313
+ number = max(1, min(int(plot_leads), len(source_records)))
314
+ positions = np.linspace(0, len(source_records) - 1, number, dtype=int)
315
+ selected.extend(source_records[int(position)] for position in np.unique(positions))
316
+ return selected
317
+
318
+
319
+ def main() -> None:
320
+ args = parse_args()
321
+ with Path(args.config).open(encoding="utf-8") as source:
322
+ config = yaml.safe_load(source)
323
+ data_cfg = config["data"]
324
+ data_dir = resolve_data_dir(args.data_dir or data_cfg["data_dir"], PROJECT_ROOT)
325
+ output_dir = Path(args.output_dir).expanduser()
326
+ if not output_dir.is_absolute():
327
+ output_dir = (PROJECT_ROOT / output_dir).resolve()
328
+ index_path = output_dir / "index.json"
329
+ if not index_path.exists():
330
+ raise FileNotFoundError(f"Forecast index not found: {index_path}; run scripts/inference.py first")
331
+ records = json.loads(index_path.read_text(encoding="utf-8"))
332
+ if not isinstance(records, list):
333
+ raise ValueError("Forecast index must contain a list")
334
+ channels = list(data_cfg["channels"])
335
+ metadata = read_metadata(data_dir, channels)
336
+ indices, time_step = metadata["indices"], int(metadata["time_step"])
337
+ means = np.asarray(metadata["means"], dtype=np.float64).reshape(-1)
338
+ stds = np.asarray(metadata["stds"], dtype=np.float64).reshape(-1)
339
+ sum_squared = np.zeros(len(channels), dtype=np.float64)
340
+ sum_pred_anom_sq = np.zeros(len(channels), dtype=np.float64)
341
+ sum_truth_anom_sq = np.zeros(len(channels), dtype=np.float64)
342
+ sum_cross = np.zeros(len(channels), dtype=np.float64)
343
+ lead_sum_squared = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
344
+ lead_sum_pred_anom_sq = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
345
+ lead_sum_truth_anom_sq = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
346
+ lead_sum_cross = defaultdict(lambda: np.zeros(len(channels), dtype=np.float64))
347
+ lead_counts: Dict[int, int] = defaultdict(int)
348
+ count = 0
349
+ for record in records:
350
+ prediction = _load_prediction(output_dir, record)
351
+ truth = _truth_for_timestamp(data_dir, str(record["valid_timestamp"]), indices, time_step)
352
+ if prediction.shape != truth.shape:
353
+ raise ValueError(f"Prediction/truth shape mismatch: {prediction.shape} vs {truth.shape}")
354
+ if not np.isfinite(prediction).all() or not np.isfinite(truth).all():
355
+ raise ValueError(f"Non-finite forecast or truth field for {record}")
356
+ error = prediction - truth
357
+ pred_anom, truth_anom = prediction - means[:, None, None], truth - means[:, None, None]
358
+ pixels = int(error.shape[1] * error.shape[2])
359
+ sum_squared += np.sum(error * error, axis=(1, 2))
360
+ sum_pred_anom_sq += np.sum(pred_anom * pred_anom, axis=(1, 2))
361
+ sum_truth_anom_sq += np.sum(truth_anom * truth_anom, axis=(1, 2))
362
+ sum_cross += np.sum(pred_anom * truth_anom, axis=(1, 2))
363
+ lead = int(record["lead"])
364
+ lead_sum_squared[lead] += np.sum(error * error, axis=(1, 2))
365
+ lead_sum_pred_anom_sq[lead] += np.sum(pred_anom * pred_anom, axis=(1, 2))
366
+ lead_sum_truth_anom_sq[lead] += np.sum(truth_anom * truth_anom, axis=(1, 2))
367
+ lead_sum_cross[lead] += np.sum(pred_anom * truth_anom, axis=(1, 2))
368
+ lead_counts[lead] += pixels
369
+ count += pixels
370
+ rmse = np.sqrt(sum_squared / max(1, count))
371
+ acc = sum_cross / (np.sqrt(sum_pred_anom_sq * sum_truth_anom_sq) + 1.0e-8)
372
+ normalized_rmse = rmse / np.maximum(stds, 1.0e-8)
373
+ lead_metrics: List[Dict[str, Any]] = []
374
+ pixels_per_field = int(metadata["shape"][-2] * metadata["shape"][-1])
375
+ for lead in sorted(lead_counts):
376
+ denominator = max(1, lead_counts[lead])
377
+ lead_rmse = np.sqrt(lead_sum_squared[lead] / denominator)
378
+ lead_acc = lead_sum_cross[lead] / (np.sqrt(lead_sum_pred_anom_sq[lead] * lead_sum_truth_anom_sq[lead]) + 1.0e-8)
379
+ lead_metrics.append({"lead": lead, "lead_hours": (lead + 1) * time_step, "mean_rmse": float(np.mean(lead_rmse)), "mean_normalized_rmse": float(np.mean(lead_rmse / np.maximum(stds, 1.0e-8))), "mean_acc": float(np.mean(lead_acc)), "records": int(lead_counts[lead] // max(1, pixels_per_field))})
380
+ result_root, plots_dir = output_dir.parent, output_dir.parent / "plots"
381
+ plots_dir.mkdir(parents=True, exist_ok=True)
382
+ for old_plot in plots_dir.glob("*.png"):
383
+ old_plot.unlink()
384
+ metrics = {"count": len(records), "channels": channels, "rmse": rmse.tolist(), "normalized_rmse": normalized_rmse.tolist(), "acc": acc.tolist(), "mean_rmse": float(np.mean(rmse)), "mean_normalized_rmse": float(np.mean(normalized_rmse)), "mean_acc": float(np.mean(acc)), "lead_metrics": lead_metrics, "visualization": {"domain": "meteorology", "coordinate_convention": "regular grid, longitude 0-360 degrees, latitude -90 to 90 degrees", "figures": ["plots/*_overview.png", "plots/lead_skill.png", "plots/channel_skill.png", "plots/training_history.png"]}}
385
+ (result_root / "metrics.json").write_text(json.dumps(metrics, indent=2), encoding="utf-8")
386
+ np.save(result_root / "rmse.npy", rmse.astype(np.float32))
387
+ np.save(result_root / "acc.npy", acc.astype(np.float32))
388
+ np.save(result_root / "normalized_rmse.npy", normalized_rmse.astype(np.float32))
389
+ for record in _selected_records(records, args.plot_count, args.plot_leads):
390
+ prediction = _load_prediction(output_dir, record)
391
+ truth = _truth_for_timestamp(data_dir, str(record["valid_timestamp"]), indices, time_step)
392
+ source, lead = str(record["source_timestamp"]), int(record["lead"])
393
+ _plot_overview(plots_dir / f"{source}_lead{lead:03d}_overview.png", source, {**record, "time_step": time_step}, channels, prediction, truth, max(80, int(args.dpi)))
394
+ _plot_lead_summary(plots_dir / "lead_skill.png", lead_metrics, max(80, int(args.dpi)))
395
+ _plot_channel_summary(plots_dir / "channel_skill.png", channels, rmse, normalized_rmse, acc, max(80, int(args.dpi)))
396
+ checkpoint_dir = PROJECT_ROOT / "data" / "checkpoints"
397
+ train_path, valid_path = checkpoint_dir / "trloss.npy", checkpoint_dir / "valoss.npy"
398
+ if train_path.exists() and valid_path.exists():
399
+ _plot_training_history(plots_dir / "training_history.png", np.load(train_path), np.load(valid_path), max(80, int(args.dpi)))
400
+ print(json.dumps(metrics, indent=2))
401
+ print(f"Saved scientific diagnostics under {plots_dir}")
402
+
403
+
404
+ if __name__ == "__main__":
405
+ main()
scripts/train.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train FengWu-W2S on ERA5-style HDF5 windows (single or torchrun DDP)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import math
9
+ import os
10
+ import random
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any, Dict
14
+
15
+ import numpy as np
16
+ import torch
17
+ import yaml
18
+ from torch import nn
19
+ from torch.nn.parallel import DistributedDataParallel as DDP
20
+
21
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
22
+ sys.path.insert(0, str(PROJECT_ROOT))
23
+
24
+ from model.fengwu_w2s import FengWuW2S
25
+ from scripts.data_loader import make_dataloader, resolve_data_dir
26
+
27
+
28
+ def parse_args() -> argparse.Namespace:
29
+ parser = argparse.ArgumentParser(description=__doc__)
30
+ parser.add_argument("--config", default=str(PROJECT_ROOT / "conf/config.yaml"))
31
+ parser.add_argument("--data-dir")
32
+ parser.add_argument("--checkpoint")
33
+ parser.add_argument("--max-epoch", type=int)
34
+ parser.add_argument("--rollout-steps", type=int)
35
+ parser.add_argument("--batch-size", type=int)
36
+ parser.add_argument("--num-workers", type=int)
37
+ parser.add_argument("--device", choices=("auto", "cpu", "cuda"), help="Execution device; auto prefers DCU/GPU")
38
+ parser.add_argument("--finetune", action="store_true", help="Resume with the lower finetune learning rate")
39
+ parser.add_argument("--seed", type=int, default=42)
40
+ return parser.parse_args()
41
+
42
+
43
+ def _device(config: Dict[str, Any], local_rank: int, override: str | None = None) -> torch.device:
44
+ requested = str(override or config.get("runtime", {}).get("device", "auto")).lower()
45
+ if requested not in {"auto", "cpu", "cuda"}:
46
+ raise ValueError(f"unsupported device {requested!r}; expected auto, cpu, or cuda")
47
+ cuda_available = bool(torch.cuda.is_available())
48
+ cuda_count = int(torch.cuda.device_count()) if cuda_available else 0
49
+ if requested == "cpu":
50
+ return torch.device("cpu")
51
+ if not cuda_available or cuda_count < 1:
52
+ if requested == "cuda":
53
+ raise RuntimeError(
54
+ "CUDA/DCU was explicitly requested but torch.cuda is unavailable; "
55
+ "load the accelerator module before starting training"
56
+ )
57
+ return torch.device("cpu")
58
+ index = local_rank
59
+ if index >= cuda_count:
60
+ raise RuntimeError(f"local rank {index} exceeds visible accelerator count {cuda_count}")
61
+ torch.cuda.set_device(index)
62
+ return torch.device("cuda", index)
63
+
64
+
65
+ def _distributed_context() -> tuple[bool, int, int]:
66
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
67
+ distributed = world_size > 1
68
+ rank = int(os.environ.get("RANK", "0"))
69
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
70
+ return distributed, rank, local_rank
71
+
72
+
73
+ def _model_from_config(config: Dict[str, Any]) -> FengWuW2S:
74
+ model_cfg = config["model"]
75
+ data_cfg = config["data"]
76
+ groups = {name: tuple(values) for name, values in data_cfg["groups"].items()}
77
+ return FengWuW2S(
78
+ in_channels=len(data_cfg["channels"]),
79
+ group_indices=groups,
80
+ hidden_channels=int(model_cfg.get("hidden_channels", 32)),
81
+ latent_channels=int(model_cfg.get("latent_channels", 64)),
82
+ patch_size=int(model_cfg.get("patch_size", 4)),
83
+ num_blocks=int(model_cfg.get("num_blocks", 2)),
84
+ perturbation_scale=float(model_cfg.get("perturbation_scale", 0.01)),
85
+ )
86
+
87
+
88
+ def _load_checkpoint(path: Path, model: nn.Module, optimizer: torch.optim.Optimizer | None, device: torch.device) -> Dict[str, Any]:
89
+ state = torch.load(path, map_location=device, weights_only=False)
90
+ model.load_state_dict(state["model_state_dict"])
91
+ if optimizer is not None and "optimizer_state_dict" in state:
92
+ optimizer.load_state_dict(state["optimizer_state_dict"])
93
+ return state
94
+
95
+
96
+ def _epoch(
97
+ model: nn.Module,
98
+ loader,
99
+ device: torch.device,
100
+ optimizer: torch.optim.Optimizer | None,
101
+ rollout_steps: int,
102
+ distributed: bool,
103
+ probability_loss: bool,
104
+ kl_weight: float,
105
+ perturbation_enabled: bool,
106
+ ) -> float:
107
+ training = optimizer is not None
108
+ model.train(training)
109
+ total = 0.0
110
+ batches = 0
111
+ for inputs, targets, _ in loader:
112
+ with torch.set_grad_enabled(training):
113
+ state = inputs[:, -1].to(device=device, dtype=torch.float32, non_blocking=True)
114
+ targets = targets.to(device=device, dtype=torch.float32, non_blocking=True)
115
+ if optimizer is not None:
116
+ optimizer.zero_grad(set_to_none=True)
117
+ loss = state.new_zeros(())
118
+ steps = max(1, min(int(rollout_steps), targets.shape[1]))
119
+ for step in range(steps):
120
+ prediction, aux = model(
121
+ state,
122
+ stochastic=training and perturbation_enabled,
123
+ return_aux=True,
124
+ )
125
+ error = prediction - targets[:, step]
126
+ if probability_loss:
127
+ min_log_std = -6.0
128
+ log_std = aux["log_std"].clamp(min_log_std, 2.0)
129
+ # The offset is constant and therefore preserves Gaussian
130
+ # NLL gradients while making its logged value non-negative.
131
+ nll = (
132
+ 0.5 * torch.exp(-2.0 * log_std) * error.square()
133
+ + log_std
134
+ - min_log_std
135
+ )
136
+ loss = loss + torch.mean(nll)
137
+ if training and perturbation_enabled:
138
+ loss = loss + float(kl_weight) * aux["kl"]
139
+ else:
140
+ loss = loss + 0.8 * torch.mean(torch.abs(error)) + 0.2 * torch.mean(error.square())
141
+ state = prediction
142
+ loss = loss / steps
143
+ loss_value = float(loss.detach().cpu())
144
+ if not math.isfinite(loss_value) or loss_value < -1.0e-7:
145
+ raise RuntimeError(f"invalid loss {loss_value} on {device}")
146
+ if optimizer is not None:
147
+ loss.backward()
148
+ torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
149
+ optimizer.step()
150
+ total += loss_value
151
+ batches += 1
152
+ if batches == 0:
153
+ return float("nan")
154
+ value = torch.tensor(total / batches, device=device)
155
+ if distributed:
156
+ torch.distributed.all_reduce(value, op=torch.distributed.ReduceOp.SUM)
157
+ value = value / torch.distributed.get_world_size()
158
+ return float(value.cpu())
159
+
160
+
161
+ def main() -> None:
162
+ args = parse_args()
163
+ with Path(args.config).open(encoding="utf-8") as source:
164
+ config = yaml.safe_load(source)
165
+ random.seed(args.seed)
166
+ np.random.seed(args.seed)
167
+ torch.manual_seed(args.seed)
168
+ distributed, rank, local_rank = _distributed_context()
169
+ device = _device(config, local_rank, args.device)
170
+ if distributed:
171
+ backend = "nccl" if device.type == "cuda" else "gloo"
172
+ torch.distributed.init_process_group(backend=backend, init_method="env://")
173
+ model_cfg = config["model"]
174
+ data_cfg = config["data"]
175
+ data_dir = resolve_data_dir(args.data_dir or data_cfg["data_dir"], PROJECT_ROOT)
176
+ input_steps = int(model_cfg.get("input_steps", 2))
177
+ rollout_steps = int(args.rollout_steps or model_cfg.get("rollout_steps", 1))
178
+ batch_size = int(args.batch_size or data_cfg.get("dataloader", {}).get("batch_size", 1))
179
+ num_workers = int(args.num_workers if args.num_workers is not None else data_cfg.get("dataloader", {}).get("num_workers", 0))
180
+ train_loader, train_sampler = make_dataloader(
181
+ data_dir, data_cfg["train_years"], data_cfg["channels"], input_steps, rollout_steps,
182
+ batch_size, num_workers, distributed, True, data_cfg.get("dataloader", {}).get("pin_memory", False),
183
+ )
184
+ val_loader, val_sampler = make_dataloader(
185
+ data_dir, data_cfg["val_years"], data_cfg["channels"], input_steps, rollout_steps,
186
+ batch_size, num_workers, distributed, False, data_cfg.get("dataloader", {}).get("pin_memory", False),
187
+ )
188
+ model = _model_from_config(config).to(device)
189
+ if not args.finetune:
190
+ for parameter in model.perturbation.parameters():
191
+ parameter.requires_grad_(False)
192
+ parameter_device = next(model.parameters()).device
193
+ if parameter_device != device:
194
+ raise RuntimeError(f"model parameters are on {parameter_device}, expected {device}")
195
+ learning_rate = float(model_cfg.get("learning_rate", 2e-4))
196
+ if args.finetune:
197
+ learning_rate = float(model_cfg.get("finetune_learning_rate", learning_rate * 0.1))
198
+ optimizer = torch.optim.AdamW(
199
+ model.parameters(),
200
+ lr=learning_rate,
201
+ betas=(
202
+ float(model_cfg.get("adam_beta1", 0.9)),
203
+ float(model_cfg.get("adam_beta2", 0.999)),
204
+ ),
205
+ weight_decay=float(model_cfg.get("weight_decay", 1e-5)),
206
+ )
207
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", factor=0.5, patience=3)
208
+ checkpoint_dir = resolve_data_dir(model_cfg.get("checkpoint_dir", "./data/checkpoints"), PROJECT_ROOT)
209
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
210
+ checkpoint_path = Path(args.checkpoint) if args.checkpoint else checkpoint_dir / "model_bak.pth"
211
+ if not checkpoint_path.is_absolute():
212
+ checkpoint_path = (PROJECT_ROOT / checkpoint_path).resolve()
213
+ start_epoch = 0
214
+ best_valid = float("inf")
215
+ train_losses: list[float] = []
216
+ valid_losses: list[float] = []
217
+ if args.finetune:
218
+ if not checkpoint_path.exists():
219
+ raise FileNotFoundError(
220
+ f"Finetune checkpoint not found: {checkpoint_path}; run base training first"
221
+ )
222
+ state = _load_checkpoint(checkpoint_path, model, optimizer, device)
223
+ start_epoch = int(state.get("epoch", -1)) + 1
224
+ best_valid = float(state.get("best_valid_loss", best_valid))
225
+ # Loading AdamW state also restores its previous learning rate; apply
226
+ # the explicit finetune rate after restoration.
227
+ for group in optimizer.param_groups:
228
+ group["lr"] = learning_rate
229
+ if distributed:
230
+ model = DDP(model, device_ids=[local_rank] if device.type == "cuda" else None)
231
+ requested_epochs = int(args.max_epoch or (model_cfg.get("finetune_epoch") if args.finetune else model_cfg.get("max_epoch", 1)))
232
+ # ``--max-epoch`` is the number of epochs for this invocation. A
233
+ # finetune run therefore always performs the requested extra epochs after
234
+ # the checkpoint epoch rather than silently skipping when it is already 1.
235
+ max_epoch = start_epoch + requested_epochs if args.finetune else requested_epochs
236
+ patience = int(model_cfg.get("patience", 10))
237
+ probability_loss = bool(model_cfg.get("probability_loss", True))
238
+ kl_weight = float(model_cfg.get("kl_weight", 1.0e-4))
239
+ stale = 0
240
+ if rank == 0:
241
+ cuda_state = {
242
+ "available": bool(torch.cuda.is_available()),
243
+ "count": int(torch.cuda.device_count()) if torch.cuda.is_available() else 0,
244
+ "name": torch.cuda.get_device_name(device) if device.type == "cuda" else None,
245
+ "allocated_bytes": torch.cuda.memory_allocated(device) if device.type == "cuda" else 0,
246
+ }
247
+ print(
248
+ f"FengWu-W2S training on {device}; model_device={parameter_device}; "
249
+ f"cuda={cuda_state}; samples={len(train_loader.dataset)}; rollout={rollout_steps}",
250
+ flush=True,
251
+ )
252
+ print(f"Parameters: {sum(parameter.numel() for parameter in model.parameters()):,}", flush=True)
253
+ for epoch in range(start_epoch, max_epoch):
254
+ if distributed:
255
+ train_sampler.set_epoch(epoch)
256
+ val_sampler.set_epoch(epoch)
257
+ train_loss = _epoch(
258
+ model, train_loader, device, optimizer, rollout_steps, distributed,
259
+ probability_loss, kl_weight, args.finetune,
260
+ )
261
+ valid_loss = _epoch(
262
+ model, val_loader, device, None, rollout_steps, distributed,
263
+ probability_loss, kl_weight, False,
264
+ )
265
+ scheduler.step(valid_loss)
266
+ train_losses.append(train_loss)
267
+ valid_losses.append(valid_loss)
268
+ improved = valid_loss < best_valid
269
+ if improved:
270
+ best_valid = valid_loss
271
+ stale = 0
272
+ else:
273
+ stale += 1
274
+ if rank == 0:
275
+ model_to_save = model.module if hasattr(model, "module") else model
276
+ state = {
277
+ "model_state_dict": model_to_save.state_dict(),
278
+ "optimizer_state_dict": optimizer.state_dict(),
279
+ "scheduler_state_dict": scheduler.state_dict(),
280
+ "epoch": epoch,
281
+ "best_valid_loss": best_valid,
282
+ "channels": data_cfg["channels"],
283
+ "group_indices": data_cfg["groups"],
284
+ "config": config,
285
+ }
286
+ if improved or epoch == max_epoch - 1:
287
+ torch.save(state, checkpoint_dir / "model_bak.pth")
288
+ np.save(checkpoint_dir / "trloss.npy", np.asarray(train_losses, dtype=np.float32))
289
+ np.save(checkpoint_dir / "valoss.npy", np.asarray(valid_losses, dtype=np.float32))
290
+ print(
291
+ f"Epoch {epoch + 1}/{max_epoch}: train={train_loss:.6f} valid={valid_loss:.6f}",
292
+ flush=True,
293
+ )
294
+ if stale > patience:
295
+ break
296
+ if distributed:
297
+ torch.distributed.barrier()
298
+ torch.distributed.destroy_process_group()
299
+ if rank == 0:
300
+ (checkpoint_dir / "training_summary.json").write_text(
301
+ json.dumps({"best_valid_loss": best_valid, "epochs": len(train_losses), "device": str(device)}, indent=2),
302
+ encoding="utf-8",
303
+ )
304
+ print(f"Saved checkpoint to {checkpoint_dir / 'model_bak.pth'}")
305
+
306
+
307
+ if __name__ == "__main__":
308
+ main()
weight/.gitkeep ADDED
File without changes