yzt15806542928 commited on
Commit
006ea64
·
verified ·
1 Parent(s): 0440907

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ datasets:
3
+ - OneScience/ERA5
4
+ frameworks:
5
+ - PyTorch
6
+ language:
7
+ - en
8
+ - zh
9
+ license: apache-2.0
10
+ tags:
11
+ - OneScience
12
+ - Earth Science
13
+ - Weather Forecast
14
+ - Regional Forecast
15
+ - Diffusion Model
16
+ - ERA5
17
+ - HRRR
18
+ tasks: []
19
+ ---
20
+
21
+ <p align="center">
22
+ <strong>
23
+ <span style="font-size: 30px;">StormCast</span>
24
+ </strong>
25
+ </p>
26
+
27
+ # Model Introduction
28
+
29
+ StormCast is a generative regional weather forecasting model proposed by NVIDIA, targeting high-resolution nowcasting of mesoscale convective weather.
30
+
31
+ Paper: StormCast: A Machine Learning Method for Meso-β-Scale Convection-resolving Weather Forecasting
32
+
33
+ https://arxiv.org/abs/2408.10958
34
+
35
+ # Model Description
36
+
37
+ StormCast constrains the evolution of regional states with large-scale weather backgrounds, and uses a generative diffusion approach to supplement the fine-scale structures that deterministic forecasts struggle to represent.
38
+
39
+ # Use Cases
40
+
41
+ | Scenario | Description |
42
+ | :---: | :--- |
43
+ | Two-Stage Weather Forecast Training | Train a deterministic regression model and a conditional residual diffusion model in sequence. |
44
+ | Local Quick Validation | Use synthetic data to verify data loading, model training, inference, and inference result visualization. |
45
+ | ModelScope / OneCode Execution | Download as a standalone model package, install dependencies, and run scripts directly. |
46
+ | Multi-GPU Training | Launch multi-process training via `torchrun`. |
47
+
48
+ # Usage Guide
49
+
50
+ ## 1. OneCode Usage
51
+
52
+ Experience intelligent one-click AI4S programming through the OneCode online environment:
53
+
54
+ [Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
55
+
56
+ ## 2. Manual Installation and Usage
57
+
58
+ **Hardware Requirements**
59
+
60
+ - Training and inference require a GPU or DCU recognized by PyTorch; CPU can be used to generate synthetic data and verify configuration, but cannot run the current training and inference scripts.
61
+ - Multi-GPU training uses the NCCL backend. Please make sure the device driver, communication libraries, and PyTorch version are compatible.
62
+ - DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
63
+
64
+ ### Download the Model Package
65
+
66
+ ```bash
67
+ hf download OneScience-Group/StormCast --local-dir ./StormCast
68
+ cd StormCast
69
+ ```
70
+
71
+ ### Install the Runtime Environment
72
+
73
+ **DCU Environment**
74
+
75
+ ```bash
76
+ # Please activate DTK and CONDA first
77
+ conda create -n onescience311 python=3.11 -y
78
+ conda activate onescience311
79
+ # uv installation is supported
80
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
81
+ ```
82
+
83
+ **GPU Environment**
84
+
85
+ ```bash
86
+ # Please activate CONDA first
87
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
88
+ conda activate onescience311
89
+ # uv installation is supported
90
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
91
+ ```
92
+
93
+ ### Training Data Introduction
94
+
95
+ 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:
96
+
97
+ ```bash
98
+ hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
99
+ ```
100
+
101
+ ### Generate Synthetic Data for Pipeline Validation
102
+
103
+ ```bash
104
+ python scripts/fake_data.py
105
+ ```
106
+
107
+ Synthetic data is only used to verify the data protocol and program flow; it does not represent the model's scientific forecasting capability.
108
+
109
+ ### Training
110
+
111
+ Single GPU:
112
+
113
+ ```bash
114
+ # Train the deterministic regression model; weights are saved to data/checkpoint/regression/model_bak.pt by default
115
+ python scripts/train.py --stage regression
116
+ # Train the residual diffusion model; weights are saved to data/checkpoint/diffusion/model_bak.pt by default
117
+ python scripts/train.py --stage diffusion
118
+ ```
119
+
120
+ ### Multi-GPU
121
+
122
+ ```bash
123
+ # Train the deterministic regression model
124
+ torchrun --nproc_per_node=2 scripts/train.py --stage regression
125
+ # Train the residual diffusion model
126
+ torchrun --nproc_per_node=2 scripts/train.py --stage diffusion
127
+ ```
128
+
129
+ ### Training Weights
130
+
131
+ 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.
132
+
133
+ ### Inference
134
+
135
+ Run autoregressive prediction with the default configuration and the two sets of weights saved during training:
136
+
137
+ ```bash
138
+ python scripts/inference.py
139
+ ```
140
+
141
+ ### Evaluation and Visualization
142
+
143
+ ```bash
144
+ python scripts/result.py
145
+ ```
146
+
147
+ Plots are saved to `outputs/inference/plots/` by default.
148
+
149
+ # OneScience Official Information
150
+
151
+ | Platform | OneScience Main Repository | Skills Repository |
152
+ | --- | --- | --- |
153
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
154
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
155
+
156
+ # Citation & License
157
+
158
+ - This repository is a reproduction of the original StormCast paper.
conf/config.yaml ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ project:
2
+ name: stormcast
3
+ seed: 42
4
+ output_dir: ./outputs
5
+
6
+ data:
7
+ root_dir: ./data
8
+ train_years: [2019]
9
+ val_years: [2021]
10
+ test_years: [2022]
11
+ time_step_hours: 1
12
+ input_steps: 1
13
+ output_steps: 1
14
+ era5_image_size: [721, 1440]
15
+ image_size: [512, 640]
16
+ num_timesteps: 4
17
+ normalize: true
18
+ era5_variables:
19
+ - u10m
20
+ - v10m
21
+ - t2m
22
+ - tcwv
23
+ - sp
24
+ - msl
25
+ - u1000
26
+ - u850
27
+ - u500
28
+ - u250
29
+ - v1000
30
+ - v850
31
+ - v500
32
+ - v250
33
+ - z1000
34
+ - z850
35
+ - z500
36
+ - z250
37
+ - t1000
38
+ - t850
39
+ - t500
40
+ - t250
41
+ - q1000
42
+ - q850
43
+ - q500
44
+ - q250
45
+ state_variables:
46
+ - u10m
47
+ - v10m
48
+ - t2m
49
+ - msl
50
+ - u1hl
51
+ - u2hl
52
+ - u3hl
53
+ - u4hl
54
+ - u5hl
55
+ - u6hl
56
+ - u7hl
57
+ - u8hl
58
+ - u9hl
59
+ - u10hl
60
+ - u11hl
61
+ - u13hl
62
+ - u15hl
63
+ - u20hl
64
+ - u25hl
65
+ - u30hl
66
+ - v1hl
67
+ - v2hl
68
+ - v3hl
69
+ - v4hl
70
+ - v5hl
71
+ - v6hl
72
+ - v7hl
73
+ - v8hl
74
+ - v9hl
75
+ - v10hl
76
+ - v11hl
77
+ - v13hl
78
+ - v15hl
79
+ - v20hl
80
+ - v25hl
81
+ - v30hl
82
+ - t1hl
83
+ - t2hl
84
+ - t3hl
85
+ - t4hl
86
+ - t5hl
87
+ - t6hl
88
+ - t7hl
89
+ - t8hl
90
+ - t9hl
91
+ - t10hl
92
+ - t11hl
93
+ - t13hl
94
+ - t15hl
95
+ - t20hl
96
+ - t25hl
97
+ - t30hl
98
+ - q1hl
99
+ - q2hl
100
+ - q3hl
101
+ - q4hl
102
+ - q5hl
103
+ - q6hl
104
+ - q7hl
105
+ - q8hl
106
+ - q9hl
107
+ - q10hl
108
+ - q11hl
109
+ - q13hl
110
+ - q15hl
111
+ - q20hl
112
+ - q25hl
113
+ - q30hl
114
+ - Z1hl
115
+ - Z2hl
116
+ - Z3hl
117
+ - Z4hl
118
+ - Z5hl
119
+ - Z6hl
120
+ - Z7hl
121
+ - Z8hl
122
+ - Z9hl
123
+ - Z10hl
124
+ - Z11hl
125
+ - Z13hl
126
+ - Z15hl
127
+ - Z20hl
128
+ - Z25hl
129
+ - Z30hl
130
+ - p1hl
131
+ - p2hl
132
+ - p3hl
133
+ - p4hl
134
+ - p5hl
135
+ - p6hl
136
+ - p7hl
137
+ - p8hl
138
+ - p9hl
139
+ - p10hl
140
+ - p11hl
141
+ - p13hl
142
+ - p15hl
143
+ - p20hl
144
+ - refc
145
+ invariant_variables: [lsm, orography]
146
+
147
+ dataloader:
148
+ batch_size: 1
149
+ num_workers: 0
150
+ pin_memory: true
151
+
152
+ model:
153
+ regression_conditions: [state, background, invariant]
154
+ diffusion_conditions: [state, regression, invariant]
155
+ image_size: [512, 640]
156
+ model_channels: 128
157
+ channel_mult: [1, 2, 2, 2, 2]
158
+ num_blocks: 4
159
+ attention_resolutions: []
160
+ regression_weights: ./data/checkpoint/regression/model_bak.pt
161
+ diffusion_weights: ./data/checkpoint/diffusion/model_bak.pt
162
+
163
+ training:
164
+ stage: regression
165
+ from_scratch: true
166
+ initial_weights: null
167
+ resume_checkpoint: null
168
+ regression_weights: ./data/checkpoint/regression/model_bak.pt
169
+ epochs: 10
170
+ max_steps: 10
171
+ learning_rate: 0.0002
172
+ betas: [0.9, 0.999]
173
+ weight_decay: 0.0
174
+ gradient_clip_norm: null
175
+ checkpoint_interval: 1
176
+ log_interval: 1
177
+ sigma_data: 0.5
178
+ P_mean: -1.2
179
+ P_std: 1.2
180
+ checkpoint_dir: ./data/checkpoint
181
+
182
+ inference:
183
+ split: test
184
+ num_steps: 1
185
+ diffusion_steps: 18
186
+ sigma_min: 0.002
187
+ sigma_max: 800.0
188
+ rho: 7.0
189
+ output_dir: ./outputs/inference
190
+ plot_state_variable: refc
191
+ plot_background_variable: t2m
config.json ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "StormCast",
3
+ "model_type": "stormcast",
4
+ "architectures": [
5
+ "StormCast"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "atmosphere",
9
+ "task": "regional-convection-resolving-weather-forecasting",
10
+ "implementation": {
11
+ "entry_point": "model/stormer.py",
12
+ "scope": "two-stage generative forecasting: a deterministic regression UNet followed by an EDM-preconditioned conditional residual diffusion UNet, both built on the SongUNet backbone"
13
+ },
14
+ "architecture": {
15
+ "family": "two-stage UNet with conditional residual diffusion",
16
+ "stages": [
17
+ "regression",
18
+ "diffusion"
19
+ ],
20
+ "backbone": "SongUNet",
21
+ "image_size": [
22
+ 512,
23
+ 640
24
+ ],
25
+ "model_channels": 128,
26
+ "channel_mult": [
27
+ 1,
28
+ 2,
29
+ 2,
30
+ 2,
31
+ 2
32
+ ],
33
+ "num_blocks": 4,
34
+ "attention_resolutions": [],
35
+ "state_channels": 99,
36
+ "background_channels": 26,
37
+ "invariant_channels": 2,
38
+ "regression": {
39
+ "condition": [
40
+ "state",
41
+ "background",
42
+ "invariant"
43
+ ],
44
+ "input_channels": 127,
45
+ "output_channels": 99,
46
+ "additive_pos_embed": false
47
+ },
48
+ "diffusion": {
49
+ "condition": [
50
+ "state",
51
+ "regression",
52
+ "invariant"
53
+ ],
54
+ "input_channels": 299,
55
+ "output_channels": 99,
56
+ "additive_pos_embed": true,
57
+ "preconditioning": "EDM",
58
+ "sigma_data": 0.5,
59
+ "P_mean": -1.2,
60
+ "P_std": 1.2
61
+ }
62
+ },
63
+ "sampling": {
64
+ "diffusion_steps": 18,
65
+ "sigma_min": 0.002,
66
+ "sigma_max": 800.0,
67
+ "rho": 7.0
68
+ },
69
+ "data": {
70
+ "dataset": "ERA5 backgrounds with local high-resolution state",
71
+ "background_source": "ERA5",
72
+ "state_source": "convection-resolving regional reanalysis (HRRR-style)",
73
+ "regional_grid": {
74
+ "projection": "Lambert conformal conic",
75
+ "image_size": [
76
+ 512,
77
+ 640
78
+ ],
79
+ "horizontal_spacing_km": 3.0,
80
+ "standard_latitude_degrees": 38.5,
81
+ "origin_latitude_degrees": 38.5,
82
+ "central_longitude_degrees": -97.5
83
+ },
84
+ "background_grid": [
85
+ 721,
86
+ 1440
87
+ ],
88
+ "background_spatial_resolution_degrees": 0.25,
89
+ "time_step_hours": 1,
90
+ "input_steps": 1,
91
+ "output_steps": 1,
92
+ "state_surface_variables": [
93
+ "10m_u_component_of_wind",
94
+ "10m_v_component_of_wind",
95
+ "2m_temperature",
96
+ "mean_sea_level_pressure",
97
+ "composite_reflectivity"
98
+ ],
99
+ "state_model_level_variables": [
100
+ "u_component_of_wind",
101
+ "v_component_of_wind",
102
+ "temperature",
103
+ "specific_humidity",
104
+ "geopotential",
105
+ "pressure"
106
+ ],
107
+ "invariant_variables": [
108
+ "land_sea_mask",
109
+ "orography"
110
+ ]
111
+ },
112
+ "configuration_sources": [
113
+ "conf/config.yaml",
114
+ "model/stormer.py",
115
+ "scripts/train.py",
116
+ "scripts/data_loader.py",
117
+ "scripts/grid.py"
118
+ ]
119
+ }
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"PyTorch","task":"weather-forecasting"}
model/stormer.py ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import sys
5
+ import types
6
+ from typing import Any
7
+
8
+ import torch
9
+ from torch import nn
10
+
11
+
12
+ def _install_nvtx_fallback() -> None:
13
+ try:
14
+ __import__("nvtx")
15
+ return
16
+ except ModuleNotFoundError:
17
+ pass
18
+
19
+ class _Annotate(contextlib.ContextDecorator):
20
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
21
+ pass
22
+
23
+ def __enter__(self) -> _Annotate:
24
+ return self
25
+
26
+ def __exit__(self, *args: Any) -> bool:
27
+ return False
28
+
29
+ module = types.ModuleType("nvtx")
30
+ module.annotate = _Annotate
31
+ sys.modules["nvtx"] = module
32
+
33
+
34
+ _install_nvtx_fallback()
35
+
36
+ try:
37
+ __import__("onescience.models.module")
38
+ except ModuleNotFoundError:
39
+ from onescience.modules import module as _onescience_module
40
+
41
+ sys.modules["onescience.models.module"] = _onescience_module
42
+
43
+ from onescience.models.diffusion.song_unet import SongUNet # noqa: E402
44
+
45
+
46
+ class _StormCastSongUNet(SongUNet):
47
+ """OneScience SongUNet with StormCast's optional learned spatial embedding."""
48
+
49
+ def __init__(
50
+ self,
51
+ *args: Any,
52
+ additive_pos_embed: bool = False,
53
+ **kwargs: Any,
54
+ ) -> None:
55
+ super().__init__(*args, **kwargs)
56
+ self.additive_pos_embed = additive_pos_embed
57
+ if additive_pos_embed:
58
+ model_channels = self.enc[next(iter(self.enc))].out_channels
59
+ self.spatial_emb = nn.Parameter(
60
+ torch.empty(1, model_channels, self.img_shape_y, self.img_shape_x)
61
+ )
62
+ nn.init.trunc_normal_(self.spatial_emb, std=0.02)
63
+ first_layer = self.enc[next(iter(self.enc))]
64
+ first_layer.register_forward_hook(self._add_spatial_embedding)
65
+
66
+ def _add_spatial_embedding(
67
+ self,
68
+ module: nn.Module,
69
+ inputs: tuple[torch.Tensor, ...],
70
+ output: torch.Tensor,
71
+ ) -> torch.Tensor:
72
+ if output.shape[-2:] != self.spatial_emb.shape[-2:]:
73
+ raise ValueError(
74
+ "Input grid must match img_resolution when additive_pos_embed is enabled"
75
+ )
76
+ return output + self.spatial_emb.to(dtype=output.dtype)
77
+
78
+
79
+ class StormCastRegressionUNet(nn.Module):
80
+ """Deterministic regression wrapper."""
81
+
82
+ def __init__(
83
+ self,
84
+ img_resolution: int | list[int] | tuple[int, int],
85
+ img_in_channels: int,
86
+ img_out_channels: int,
87
+ use_fp16: bool = False,
88
+ sigma_min: float = 0.0,
89
+ sigma_max: float = float("inf"),
90
+ sigma_data: float = 0.5,
91
+ model_type: str = "SongUNet",
92
+ **model_kwargs: Any,
93
+ ) -> None:
94
+ super().__init__()
95
+ if model_type != "SongUNet":
96
+ raise ValueError("Regression requires model_type='SongUNet'")
97
+ self.register_buffer("device_buffer", torch.empty(0))
98
+ self.img_resolution = img_resolution
99
+ self.img_in_channels = img_in_channels
100
+ self.img_out_channels = img_out_channels
101
+ self.use_fp16 = use_fp16
102
+ self.sigma_min = sigma_min
103
+ self.sigma_max = sigma_max
104
+ self.sigma_data = sigma_data
105
+ self.model = _StormCastSongUNet(
106
+ img_resolution=img_resolution,
107
+ in_channels=img_in_channels,
108
+ out_channels=img_out_channels,
109
+ **model_kwargs,
110
+ )
111
+
112
+ def forward(self, x: torch.Tensor, force_fp32: bool = False) -> torch.Tensor:
113
+ _validate_image(x, "x", self.img_in_channels)
114
+ dtype = _model_dtype(x, self.use_fp16, force_fp32)
115
+ output = self.model(
116
+ x.to(dtype),
117
+ torch.zeros(x.shape[0], dtype=x.dtype, device=x.device),
118
+ class_labels=None,
119
+ )
120
+ _validate_output_dtype(output, dtype)
121
+ return output.to(torch.float32)
122
+
123
+
124
+ class StormCastEDMPrecond(nn.Module):
125
+ """EDM preconditioner for conditional residual diffusion."""
126
+
127
+ def __init__(
128
+ self,
129
+ img_resolution: int | list[int] | tuple[int, int],
130
+ img_channels: int,
131
+ label_dim: int = 0,
132
+ use_fp16: bool = False,
133
+ sigma_min: float = 0.0,
134
+ sigma_max: float = float("inf"),
135
+ sigma_data: float = 0.5,
136
+ model_type: str = "SongUNet",
137
+ img_in_channels: int | None = None,
138
+ img_out_channels: int | None = None,
139
+ **model_kwargs: Any,
140
+ ) -> None:
141
+ super().__init__()
142
+ if model_type != "SongUNet":
143
+ raise ValueError("Diffusion requires model_type='SongUNet'")
144
+ if label_dim != 0:
145
+ raise ValueError("Diffusion does not use class labels")
146
+ self.register_buffer("device_buffer", torch.empty(0))
147
+ self.img_resolution = img_resolution
148
+ self.img_channels = img_channels
149
+ self.img_in_channels = img_channels if img_in_channels is None else img_in_channels
150
+ self.img_out_channels = img_channels if img_out_channels is None else img_out_channels
151
+ self.label_dim = label_dim
152
+ self.use_fp16 = use_fp16
153
+ self.sigma_min = sigma_min
154
+ self.sigma_max = sigma_max
155
+ self.sigma_data = sigma_data
156
+ self.model = _StormCastSongUNet(
157
+ img_resolution=img_resolution,
158
+ in_channels=self.img_in_channels,
159
+ out_channels=self.img_out_channels,
160
+ label_dim=label_dim,
161
+ **model_kwargs,
162
+ )
163
+
164
+ def forward(
165
+ self,
166
+ x: torch.Tensor,
167
+ sigma: torch.Tensor,
168
+ condition: torch.Tensor | None = None,
169
+ force_fp32: bool = False,
170
+ ) -> torch.Tensor:
171
+ _validate_image(x, "x", self.img_out_channels)
172
+ sigma = torch.as_tensor(sigma, device=x.device, dtype=torch.float32).reshape(
173
+ -1, 1, 1, 1
174
+ )
175
+ if sigma.shape[0] not in (1, x.shape[0]):
176
+ raise ValueError("sigma must contain one value or one value per batch item")
177
+
178
+ c_skip = self.sigma_data**2 / (sigma.square() + self.sigma_data**2)
179
+ c_out = sigma * self.sigma_data / (sigma.square() + self.sigma_data**2).sqrt()
180
+ c_in = 1 / (self.sigma_data**2 + sigma.square()).sqrt()
181
+ c_noise = sigma.log() / 4
182
+ model_input = c_in * x.to(torch.float32)
183
+ if condition is not None:
184
+ _validate_condition(condition, x)
185
+ model_input = torch.cat((model_input, condition.to(torch.float32)), dim=1)
186
+ if model_input.shape[1] != self.img_in_channels:
187
+ raise ValueError(
188
+ f"Diffusion model expects {self.img_in_channels} total channels, "
189
+ f"got {model_input.shape[1]}"
190
+ )
191
+
192
+ dtype = _model_dtype(x, self.use_fp16, force_fp32)
193
+ output = self.model(
194
+ model_input.to(dtype), c_noise.flatten(), class_labels=None
195
+ )
196
+ _validate_output_dtype(output, dtype)
197
+ return c_skip * x.to(torch.float32) + c_out * output.to(torch.float32)
198
+
199
+ @staticmethod
200
+ def round_sigma(sigma: float | list[float] | torch.Tensor) -> torch.Tensor:
201
+ return torch.as_tensor(sigma)
202
+
203
+
204
+ class StormCast(nn.Module):
205
+ """Compose regression and conditional residual diffusion stages."""
206
+
207
+ def __init__(
208
+ self,
209
+ regression: StormCastRegressionUNet,
210
+ diffusion: StormCastEDMPrecond,
211
+ ) -> None:
212
+ super().__init__()
213
+ self.regression = regression
214
+ self.diffusion = diffusion
215
+
216
+ def regression_condition(
217
+ self,
218
+ state: torch.Tensor,
219
+ background: torch.Tensor,
220
+ invariant: torch.Tensor,
221
+ ) -> torch.Tensor:
222
+ invariant = _expand_invariant(invariant, state.shape[0])
223
+ return torch.cat((state, background, invariant), dim=1)
224
+
225
+ def diffusion_condition(
226
+ self,
227
+ state: torch.Tensor,
228
+ regression: torch.Tensor,
229
+ invariant: torch.Tensor,
230
+ ) -> torch.Tensor:
231
+ invariant = _expand_invariant(invariant, state.shape[0])
232
+ return torch.cat((state, regression, invariant), dim=1)
233
+
234
+ def predict_regression(
235
+ self,
236
+ state: torch.Tensor,
237
+ background: torch.Tensor,
238
+ invariant: torch.Tensor,
239
+ ) -> torch.Tensor:
240
+ return self.regression(self.regression_condition(state, background, invariant))
241
+
242
+ def denoise_residual(
243
+ self,
244
+ noisy_residual: torch.Tensor,
245
+ sigma: torch.Tensor,
246
+ state: torch.Tensor,
247
+ regression: torch.Tensor,
248
+ invariant: torch.Tensor,
249
+ ) -> torch.Tensor:
250
+ condition = self.diffusion_condition(state, regression, invariant)
251
+ return self.diffusion(noisy_residual, sigma, condition=condition)
252
+
253
+
254
+ @torch.no_grad()
255
+ def edm_heun_sample(
256
+ model: StormCastEDMPrecond,
257
+ condition: torch.Tensor,
258
+ output_channels: int,
259
+ num_steps: int = 18,
260
+ sigma_min: float = 0.002,
261
+ sigma_max: float = 800.0,
262
+ rho: float = 7.0,
263
+ generator: torch.Generator | None = None,
264
+ ) -> torch.Tensor:
265
+ """Sample a residual with the deterministic EDM Heun path."""
266
+ if num_steps < 1:
267
+ raise ValueError("num_steps must be at least 1")
268
+ if not 0 < sigma_min < sigma_max:
269
+ raise ValueError("Expected 0 < sigma_min < sigma_max")
270
+ if rho <= 0:
271
+ raise ValueError("rho must be positive")
272
+ if condition.ndim != 4:
273
+ raise ValueError("condition must have shape (B, C, H, W)")
274
+
275
+ step_indices = torch.arange(
276
+ num_steps, device=condition.device, dtype=torch.float64
277
+ )
278
+ denominator = max(num_steps - 1, 1)
279
+ sigma_steps = (
280
+ sigma_max ** (1 / rho)
281
+ + step_indices
282
+ / denominator
283
+ * (sigma_min ** (1 / rho) - sigma_max ** (1 / rho))
284
+ ).pow(rho)
285
+ sigma_steps = torch.cat((sigma_steps, sigma_steps.new_zeros(1)))
286
+ shape = (condition.shape[0], output_channels, *condition.shape[-2:])
287
+ latent = torch.randn(
288
+ shape,
289
+ device=condition.device,
290
+ dtype=torch.float32,
291
+ generator=generator,
292
+ )
293
+ x_next = latent.to(torch.float64) * sigma_steps[0]
294
+
295
+ for index, (sigma_cur, sigma_next) in enumerate(
296
+ zip(sigma_steps[:-1], sigma_steps[1:])
297
+ ):
298
+ x_cur = x_next
299
+ denoised = model(
300
+ x_cur.to(torch.float32),
301
+ sigma_cur.to(torch.float32),
302
+ condition=condition,
303
+ ).to(torch.float64)
304
+ derivative = (x_cur - denoised) / sigma_cur
305
+ x_next = x_cur + (sigma_next - sigma_cur) * derivative
306
+
307
+ if index < num_steps - 1:
308
+ denoised_next = model(
309
+ x_next.to(torch.float32),
310
+ sigma_next.to(torch.float32),
311
+ condition=condition,
312
+ ).to(torch.float64)
313
+ derivative_next = (x_next - denoised_next) / sigma_next
314
+ x_next = x_cur + (sigma_next - sigma_cur) * (
315
+ 0.5 * derivative + 0.5 * derivative_next
316
+ )
317
+ return x_next.to(torch.float32)
318
+
319
+
320
+ def build_regression_model(
321
+ image_size: list[int] | tuple[int, int] = (512, 640),
322
+ state_channels: int = 99,
323
+ background_channels: int = 26,
324
+ invariant_channels: int = 2,
325
+ model_channels: int = 128,
326
+ channel_mult: list[int] | tuple[int, ...] = (1, 2, 2, 2, 2),
327
+ attn_resolutions: list[int] | tuple[int, ...] = (),
328
+ **kwargs: Any,
329
+ ) -> StormCastRegressionUNet:
330
+ return StormCastRegressionUNet(
331
+ img_resolution=list(image_size),
332
+ img_in_channels=state_channels + background_channels + invariant_channels,
333
+ img_out_channels=state_channels,
334
+ model_type="SongUNet",
335
+ model_channels=model_channels,
336
+ channel_mult=list(channel_mult),
337
+ attn_resolutions=list(attn_resolutions),
338
+ embedding_type="zero",
339
+ additive_pos_embed=False,
340
+ **kwargs,
341
+ )
342
+
343
+
344
+ def build_diffusion_model(
345
+ image_size: list[int] | tuple[int, int] = (512, 640),
346
+ state_channels: int = 99,
347
+ invariant_channels: int = 2,
348
+ model_channels: int = 128,
349
+ channel_mult: list[int] | tuple[int, ...] = (1, 2, 2, 2, 2),
350
+ attn_resolutions: list[int] | tuple[int, ...] = (),
351
+ **kwargs: Any,
352
+ ) -> StormCastEDMPrecond:
353
+ condition_channels = state_channels + state_channels + invariant_channels
354
+ return StormCastEDMPrecond(
355
+ img_resolution=list(image_size),
356
+ img_channels=state_channels + condition_channels,
357
+ img_in_channels=state_channels + condition_channels,
358
+ img_out_channels=state_channels,
359
+ model_type="SongUNet",
360
+ model_channels=model_channels,
361
+ channel_mult=list(channel_mult),
362
+ attn_resolutions=list(attn_resolutions),
363
+ additive_pos_embed=True,
364
+ **kwargs,
365
+ )
366
+
367
+
368
+ def _model_dtype(
369
+ x: torch.Tensor, use_fp16: bool, force_fp32: bool
370
+ ) -> torch.dtype:
371
+ return (
372
+ torch.float16
373
+ if use_fp16 and not force_fp32 and x.device.type == "cuda"
374
+ else torch.float32
375
+ )
376
+
377
+
378
+ def _validate_image(x: torch.Tensor, name: str, channels: int) -> None:
379
+ if x.ndim != 4:
380
+ raise ValueError(f"{name} must have shape (B, C, H, W), got {tuple(x.shape)}")
381
+ if x.shape[1] != channels:
382
+ raise ValueError(f"{name} must have {channels} channels, got {x.shape[1]}")
383
+
384
+
385
+ def _validate_condition(condition: torch.Tensor, x: torch.Tensor) -> None:
386
+ if condition.ndim != 4:
387
+ raise ValueError("condition must have shape (B, C, H, W)")
388
+ if condition.shape[0] != x.shape[0] or condition.shape[-2:] != x.shape[-2:]:
389
+ raise ValueError("condition batch and spatial dimensions must match x")
390
+
391
+
392
+ def _validate_output_dtype(output: torch.Tensor, dtype: torch.dtype) -> None:
393
+ if output.dtype != dtype and not torch.is_autocast_enabled():
394
+ raise ValueError(f"Expected model output dtype {dtype}, got {output.dtype}")
395
+
396
+
397
+ def _expand_invariant(invariant: torch.Tensor, batch_size: int) -> torch.Tensor:
398
+ if invariant.ndim == 3:
399
+ invariant = invariant.unsqueeze(0)
400
+ if invariant.ndim != 4:
401
+ raise ValueError("invariant must have shape (C, H, W) or (B, C, H, W)")
402
+ if invariant.shape[0] == 1 and batch_size != 1:
403
+ invariant = invariant.expand(batch_size, -1, -1, -1)
404
+ if invariant.shape[0] != batch_size:
405
+ raise ValueError("invariant batch dimension must be 1 or match state")
406
+ return invariant
scripts/data_loader.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import h5py
7
+ import torch
8
+ from onescience.datapipes.climate.era5 import ERA5Dataset
9
+ from torch.utils.data import Dataset
10
+
11
+ from grid import lambert_grid
12
+
13
+
14
+ class StormCastDataset(Dataset):
15
+ """Pair OneScience ERA5 backgrounds with synchronized local state targets."""
16
+
17
+ def __init__(
18
+ self,
19
+ data_root: str | Path,
20
+ years: list[int],
21
+ era5_variables: list[str],
22
+ state_variables: list[str],
23
+ invariant_variables: list[str],
24
+ image_size: list[int] | tuple[int, int],
25
+ input_steps: int = 1,
26
+ output_steps: int = 1,
27
+ normalize: bool = True,
28
+ ) -> None:
29
+ if input_steps != 1 or output_steps != 1:
30
+ raise ValueError("StormCast pairing currently requires one input and one target step")
31
+
32
+ self.data_root = Path(data_root)
33
+ self.years = years
34
+ self.era5_variables = era5_variables
35
+ self.state_variables = state_variables
36
+ self.invariant_variables = invariant_variables
37
+ self.image_size = tuple(image_size)
38
+ self.normalize = normalize
39
+ self.era5 = ERA5Dataset(
40
+ dataset_dir=str(self.data_root / "era5"),
41
+ used_years=years,
42
+ used_variables=era5_variables,
43
+ input_steps=input_steps,
44
+ output_steps=output_steps,
45
+ normalize=normalize,
46
+ )
47
+ self.samples_per_year = self.era5.samples_per_year
48
+ self._validate_era5_grid()
49
+ self._validate_local_files()
50
+ self.invariants = self._load_invariants()
51
+ self._initialize_background_regrid()
52
+
53
+ def _validate_era5_grid(self) -> None:
54
+ if self.era5.H < 2 or self.era5.W < 2:
55
+ raise ValueError("ERA5 grid must have at least two points per dimension")
56
+ expected = (721, 1440)
57
+ if (self.era5.H, self.era5.W) != expected:
58
+ raise ValueError(
59
+ f"StormCast expects ERA5 on the global {expected} grid, "
60
+ f"got {(self.era5.H, self.era5.W)}"
61
+ )
62
+
63
+ def _validate_local_files(self) -> None:
64
+ for year in self.years:
65
+ path = self.data_root / "hrrr" / "data" / f"{year}.h5"
66
+ if not path.is_file():
67
+ raise FileNotFoundError(f"Missing local state file: {path}")
68
+ with h5py.File(path, "r") as handle:
69
+ fields = handle["fields"]
70
+ variables = [
71
+ value.decode() if isinstance(value, bytes) else str(value)
72
+ for value in fields.attrs["variables"]
73
+ ]
74
+ if variables != self.state_variables:
75
+ raise ValueError(
76
+ "Local state channel order differs from data.state_variables"
77
+ )
78
+ expected_steps = self.samples_per_year + 1
79
+ if fields.shape[0] != expected_steps:
80
+ raise ValueError(
81
+ f"{path} has {fields.shape[0]} steps, expected {expected_steps}"
82
+ )
83
+ if tuple(fields.shape[-2:]) != self.image_size:
84
+ raise ValueError(
85
+ f"Local state grid is {tuple(fields.shape[-2:])}, "
86
+ f"expected regional grid {self.image_size}"
87
+ )
88
+
89
+ def _load_invariants(self) -> torch.Tensor:
90
+ path = self.data_root / "hrrr" / "invariants.h5"
91
+ with h5py.File(path, "r") as handle:
92
+ fields = handle["fields"]
93
+ variables = [
94
+ value.decode() if isinstance(value, bytes) else str(value)
95
+ for value in fields.attrs["variables"]
96
+ ]
97
+ if variables != self.invariant_variables:
98
+ raise ValueError(
99
+ "Invariant channel order differs from data.invariant_variables"
100
+ )
101
+ invariants = torch.as_tensor(fields[:], dtype=torch.float32)
102
+ if tuple(invariants.shape[-2:]) != self.image_size:
103
+ raise ValueError(
104
+ f"Invariant grid is {tuple(invariants.shape[-2:])}, "
105
+ f"expected {self.image_size}"
106
+ )
107
+ return invariants
108
+
109
+ def _initialize_background_regrid(self) -> None:
110
+ with h5py.File(self.data_root / "hrrr" / "invariants.h5", "r") as handle:
111
+ if "lat" in handle and "lon" in handle:
112
+ target_lat = torch.as_tensor(handle["lat"][:], dtype=torch.float32)
113
+ target_lon = torch.as_tensor(handle["lon"][:], dtype=torch.float32)
114
+ else:
115
+ target_lat_np, target_lon_np = lambert_grid(self.image_size)
116
+ target_lat = torch.from_numpy(target_lat_np)
117
+ target_lon = torch.from_numpy(target_lon_np)
118
+ if target_lat.shape != self.image_size or target_lon.shape != self.image_size:
119
+ raise ValueError("StormCast target latitude/longitude grid has wrong shape")
120
+
121
+ lat_position = (90.0 - target_lat) / (180.0 / (self.era5.H - 1))
122
+ lon_position = torch.remainder(target_lon, 360.0) / (360.0 / self.era5.W)
123
+ self.lat0 = lat_position.floor().long().clamp(0, self.era5.H - 2)
124
+ self.lat1 = self.lat0 + 1
125
+ self.lon0 = lon_position.floor().long().remainder(self.era5.W)
126
+ self.lon1 = (self.lon0 + 1).remainder(self.era5.W)
127
+ self.lat_weight = lat_position - self.lat0
128
+ self.lon_weight = lon_position - lon_position.floor()
129
+
130
+ def _regrid_background(self, background: torch.Tensor) -> torch.Tensor:
131
+ f00 = background[..., self.lat0, self.lon0]
132
+ f01 = background[..., self.lat0, self.lon1]
133
+ f10 = background[..., self.lat1, self.lon0]
134
+ f11 = background[..., self.lat1, self.lon1]
135
+ lon_weight = self.lon_weight.to(background.dtype)
136
+ lat_weight = self.lat_weight.to(background.dtype)
137
+ top = torch.lerp(f00, f01, lon_weight)
138
+ bottom = torch.lerp(f10, f11, lon_weight)
139
+ return torch.lerp(top, bottom, lat_weight)
140
+
141
+ def __len__(self) -> int:
142
+ return len(self.era5)
143
+
144
+ def __getitem__(self, index: int) -> dict[str, Any]:
145
+ background, _, _, step_index, time_index = self.era5[index]
146
+ background = self._regrid_background(background)
147
+ year_index = index // self.samples_per_year
148
+ year = self.years[year_index]
149
+ path = self.data_root / "hrrr" / "data" / f"{year}.h5"
150
+
151
+ with h5py.File(path, "r") as handle:
152
+ state = torch.as_tensor(
153
+ handle["fields"][step_index : step_index + 2], dtype=torch.float32
154
+ )
155
+ if self.normalize:
156
+ means = torch.as_tensor(handle["global_means"][:], dtype=torch.float32)
157
+ stds = torch.as_tensor(handle["global_stds"][:], dtype=torch.float32)
158
+ state = (state - means) / stds
159
+
160
+ return {
161
+ "background": background,
162
+ "state": (state[0], state[1]),
163
+ "invariant": self.invariants,
164
+ "step_index": step_index,
165
+ "time_index": time_index,
166
+ }
scripts/fake_data.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from pathlib import Path
5
+
6
+ import h5py
7
+ import numpy as np
8
+ import yaml
9
+
10
+ from grid import lambert_grid
11
+
12
+
13
+ def load_config(path: str | Path) -> dict:
14
+ with Path(path).open("r", encoding="utf-8") as handle:
15
+ return yaml.safe_load(handle)
16
+
17
+
18
+ def write_temporal_fields(
19
+ path: Path,
20
+ variables: list[str],
21
+ num_timesteps: int,
22
+ image_size: tuple[int, int],
23
+ time_step_hours: int,
24
+ ) -> None:
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ channels = len(variables)
27
+ height, width = image_size
28
+ means = np.zeros((1, channels, 1, 1), dtype=np.float32)
29
+ stds = np.ones((1, channels, 1, 1), dtype=np.float32)
30
+ with h5py.File(path, "w") as handle:
31
+ fields = handle.create_dataset(
32
+ "fields",
33
+ shape=(num_timesteps, channels, height, width),
34
+ dtype=np.float32,
35
+ chunks=(1, channels, height, width),
36
+ fillvalue=0.0,
37
+ )
38
+ fields.attrs["variables"] = variables
39
+ fields.attrs["time_step"] = time_step_hours
40
+ handle.create_dataset("global_means", data=means)
41
+ handle.create_dataset("global_stds", data=stds)
42
+
43
+
44
+ def write_invariants(
45
+ path: Path,
46
+ variables: list[str],
47
+ image_size: tuple[int, int],
48
+ ) -> None:
49
+ path.parent.mkdir(parents=True, exist_ok=True)
50
+ height, width = image_size
51
+ target_lat, target_lon = lambert_grid(image_size)
52
+ invariants = np.zeros((len(variables), height, width), dtype=np.float32)
53
+ with h5py.File(path, "w") as handle:
54
+ fields = handle.create_dataset(
55
+ "fields",
56
+ shape=(len(variables), height, width),
57
+ dtype=np.float32,
58
+ chunks=(1, height, width),
59
+ data=invariants,
60
+ )
61
+ fields.attrs["variables"] = variables
62
+ handle.create_dataset("lat", data=target_lat)
63
+ handle.create_dataset("lon", data=target_lon)
64
+
65
+
66
+ def generate(config: dict) -> None:
67
+ data = config["data"]
68
+ root = Path(data["root_dir"])
69
+ years = sorted(
70
+ set(data["train_years"] + data["val_years"] + data["test_years"])
71
+ )
72
+ era5_image_size = tuple(data["era5_image_size"])
73
+ image_size = tuple(data["image_size"])
74
+ if era5_image_size != (721, 1440):
75
+ raise ValueError("ERA5 grid must be 721 x 1440")
76
+ if image_size != (512, 640):
77
+ raise ValueError("Regional grid must be 512 x 640")
78
+
79
+ if len(data["era5_variables"]) != 26:
80
+ raise ValueError("The configured ERA5 input must contain 26 channels")
81
+ if len(data["state_variables"]) != 99:
82
+ raise ValueError("The configured local state must contain 99 channels")
83
+ if data["invariant_variables"] != ["lsm", "orography"]:
84
+ raise ValueError("Invariant order must be [lsm, orography]")
85
+
86
+ for year in years:
87
+ write_temporal_fields(
88
+ root / "era5" / "data" / f"{year}.h5",
89
+ data["era5_variables"],
90
+ data["num_timesteps"],
91
+ era5_image_size,
92
+ data["time_step_hours"],
93
+ )
94
+ write_temporal_fields(
95
+ root / "hrrr" / "data" / f"{year}.h5",
96
+ data["state_variables"],
97
+ data["num_timesteps"],
98
+ image_size,
99
+ data["time_step_hours"],
100
+ )
101
+
102
+ write_invariants(
103
+ root / "hrrr" / "invariants.h5",
104
+ data["invariant_variables"],
105
+ image_size,
106
+ )
107
+ print(f"Generated project validation data under {root}")
108
+
109
+
110
+ def parse_args() -> argparse.Namespace:
111
+ parser = argparse.ArgumentParser(description="Generate lightweight StormCast data")
112
+ parser.add_argument("--config", default="conf/config.yaml")
113
+ return parser.parse_args()
114
+
115
+
116
+ if __name__ == "__main__":
117
+ args = parse_args()
118
+ config_path = Path(args.config).resolve()
119
+ config = load_config(config_path)
120
+ project_root = config_path.parent.parent
121
+ for key in ("root_dir",):
122
+ path = Path(config["data"][key])
123
+ if not path.is_absolute():
124
+ config["data"][key] = str((project_root / path).resolve())
125
+ generate(config)
scripts/grid.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+
5
+
6
+ def lambert_grid(image_size: tuple[int, int]) -> tuple[np.ndarray, np.ndarray]:
7
+ """Create the project's regional latitude/longitude grid in pure NumPy."""
8
+ if image_size != (512, 640):
9
+ raise ValueError("The regional grid must have shape (512, 640)")
10
+
11
+ radius = 6371229.0
12
+ standard_latitude = np.deg2rad(38.5)
13
+ origin_latitude = np.deg2rad(38.5)
14
+ central_longitude = np.deg2rad(-97.5)
15
+ x = -2697520.1425219304 + 3000.0 * np.arange(1799, dtype=np.float64)
16
+ y = -1587306.1525566636 + 3000.0 * np.arange(1059, dtype=np.float64)
17
+ x = x[579:1219]
18
+ y = y[273:785]
19
+ xx, yy = np.meshgrid(x, y)
20
+
21
+ n = np.sin(standard_latitude)
22
+ f = np.cos(standard_latitude) * np.tan(np.pi / 4 + standard_latitude / 2) ** n / n
23
+ rho0 = radius * f / np.tan(np.pi / 4 + origin_latitude / 2) ** n
24
+ rho = np.hypot(xx, rho0 - yy)
25
+ theta = np.arctan2(xx, rho0 - yy)
26
+ latitude = 2 * np.arctan((radius * f / rho) ** (1 / n)) - np.pi / 2
27
+ longitude = central_longitude + theta / n
28
+ return np.rad2deg(latitude).astype(np.float32), np.mod(np.rad2deg(longitude), 360).astype(np.float32)
scripts/inference.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
9
+ MODEL_ROOT = PROJECT_ROOT / "model"
10
+ SCRIPT_ROOT = PROJECT_ROOT / "scripts"
11
+ for path in (PROJECT_ROOT, MODEL_ROOT, SCRIPT_ROOT):
12
+ if str(path) not in sys.path:
13
+ sys.path.insert(0, str(path))
14
+
15
+ import h5py
16
+ import numpy as np
17
+ import torch
18
+ import yaml
19
+
20
+ from stormer import StormCast, edm_heun_sample
21
+ from data_loader import StormCastDataset
22
+
23
+
24
+ def parse_args() -> argparse.Namespace:
25
+ parser = argparse.ArgumentParser(description="Run StormCast inference")
26
+ parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
27
+ parser.add_argument("--regression-weights", type=Path)
28
+ parser.add_argument("--diffusion-weights", type=Path)
29
+ parser.add_argument("--output", type=Path)
30
+ parser.add_argument("--num-steps", type=int)
31
+ parser.add_argument("--diffusion-steps", type=int)
32
+ parser.add_argument("--seed", type=int)
33
+ return parser.parse_args()
34
+
35
+
36
+ def main() -> None:
37
+ args = parse_args()
38
+ config_path = args.config.resolve()
39
+ with config_path.open("r", encoding="utf-8") as handle:
40
+ config = yaml.safe_load(handle)
41
+ _resolve_paths(config, config_path.parent.parent)
42
+ inference_config = config["inference"]
43
+ regression_weights = args.regression_weights or Path(
44
+ config["model"]["regression_weights"]
45
+ )
46
+ diffusion_weights = args.diffusion_weights or Path(
47
+ config["model"]["diffusion_weights"]
48
+ )
49
+ output = args.output or Path(inference_config["output_dir"]) / "forecast.h5"
50
+ run_inference(
51
+ config=config,
52
+ regression_weights=regression_weights,
53
+ diffusion_weights=diffusion_weights,
54
+ output=output,
55
+ num_steps=args.num_steps or inference_config["num_steps"],
56
+ diffusion_steps=args.diffusion_steps or inference_config["diffusion_steps"],
57
+ seed=config["project"]["seed"] if args.seed is None else args.seed,
58
+ )
59
+
60
+
61
+ @torch.no_grad()
62
+ def run_inference(
63
+ config: dict[str, Any],
64
+ regression_weights: Path,
65
+ diffusion_weights: Path,
66
+ output: Path,
67
+ num_steps: int,
68
+ diffusion_steps: int,
69
+ seed: int,
70
+ ) -> Path:
71
+ if not torch.cuda.is_available():
72
+ raise RuntimeError("StormCast inference requires a CUDA/HIP device")
73
+ if num_steps < 1:
74
+ raise ValueError("num_steps must be at least 1")
75
+ device = torch.device("cuda", 0)
76
+ torch.cuda.set_device(device)
77
+ data_config = config["data"]
78
+ inference_config = config["inference"]
79
+ split = inference_config["split"]
80
+ years_key = f"{split}_years"
81
+ if years_key not in data_config:
82
+ raise ValueError(f"Unknown inference split: {split}")
83
+ dataset = StormCastDataset(
84
+ data_root=data_config["root_dir"],
85
+ years=data_config[years_key],
86
+ era5_variables=data_config["era5_variables"],
87
+ state_variables=data_config["state_variables"],
88
+ invariant_variables=data_config["invariant_variables"],
89
+ image_size=data_config["image_size"],
90
+ input_steps=data_config["input_steps"],
91
+ output_steps=data_config["output_steps"],
92
+ normalize=data_config["normalize"],
93
+ )
94
+ if num_steps > len(dataset):
95
+ raise ValueError(f"Requested {num_steps} steps but split contains {len(dataset)}")
96
+
97
+ regression = _load_weights(regression_weights, "regression").to(device).eval()
98
+ diffusion = _load_weights(diffusion_weights, "diffusion").to(device).eval()
99
+ model = StormCast(regression, diffusion)
100
+ generator = torch.Generator(device=device).manual_seed(seed)
101
+ output.parent.mkdir(parents=True, exist_ok=True)
102
+
103
+ first = dataset[0]
104
+ state = first["state"][0].unsqueeze(0).to(device, dtype=torch.float32)
105
+ invariant = first["invariant"].to(device, dtype=torch.float32)
106
+ state_channels = len(data_config["state_variables"])
107
+ background_channels = len(data_config["era5_variables"])
108
+ height, width = data_config["image_size"]
109
+ if [height, width] != list(config["model"]["image_size"]):
110
+ raise ValueError("Data and model image sizes must match")
111
+ if list(data_config["era5_image_size"]) != [721, 1440]:
112
+ raise ValueError("ERA5 grid must be 721 x 1440")
113
+
114
+ with h5py.File(output, "w") as handle:
115
+ handle.attrs["normalized"] = bool(data_config["normalize"])
116
+ handle.attrs["seed"] = seed
117
+ handle.attrs["diffusion_steps"] = diffusion_steps
118
+ handle.attrs["sigma_min"] = inference_config["sigma_min"]
119
+ handle.attrs["sigma_max"] = inference_config["sigma_max"]
120
+ handle.attrs["rho"] = inference_config["rho"]
121
+ handle.attrs["regression_weights"] = str(regression_weights.resolve())
122
+ handle.attrs["diffusion_weights"] = str(diffusion_weights.resolve())
123
+ handle.attrs["state_variables"] = np.asarray(
124
+ data_config["state_variables"], dtype="S"
125
+ )
126
+ handle.attrs["background_variables"] = np.asarray(
127
+ data_config["era5_variables"], dtype="S"
128
+ )
129
+ prediction_store = handle.create_dataset(
130
+ "prediction", (num_steps, state_channels, height, width), dtype="f4"
131
+ )
132
+ regression_store = handle.create_dataset(
133
+ "regression", (num_steps, state_channels, height, width), dtype="f4"
134
+ )
135
+ target_store = handle.create_dataset(
136
+ "target", (num_steps, state_channels, height, width), dtype="f4"
137
+ )
138
+ background_store = handle.create_dataset(
139
+ "background", (num_steps, background_channels, height, width), dtype="f4"
140
+ )
141
+ time_store = handle.create_dataset("time_index", (num_steps,), dtype="i8")
142
+
143
+ for index in range(num_steps):
144
+ sample = dataset[index]
145
+ background = sample["background"].unsqueeze(0).to(
146
+ device, dtype=torch.float32
147
+ )
148
+ regression_prediction = model.predict_regression(
149
+ state, background, invariant
150
+ )
151
+ condition = model.diffusion_condition(
152
+ state, regression_prediction, invariant
153
+ )
154
+ residual = edm_heun_sample(
155
+ diffusion,
156
+ condition,
157
+ output_channels=state_channels,
158
+ num_steps=diffusion_steps,
159
+ sigma_min=inference_config["sigma_min"],
160
+ sigma_max=inference_config["sigma_max"],
161
+ rho=inference_config["rho"],
162
+ generator=generator,
163
+ )
164
+ prediction = regression_prediction + residual
165
+
166
+ prediction_store[index] = prediction[0].cpu().numpy()
167
+ regression_store[index] = regression_prediction[0].cpu().numpy()
168
+ target_store[index] = sample["state"][1].numpy()
169
+ background_store[index] = sample["background"].numpy()
170
+ time_store[index] = int(np.asarray(sample["time_index"]).reshape(-1)[-1])
171
+ state = prediction
172
+ print(f"forecast_step={index + 1}/{num_steps}")
173
+ print(f"output={output}")
174
+ return output
175
+
176
+
177
+ def _load_weights(path: Path, kind: str) -> torch.nn.Module:
178
+ if not path.is_file():
179
+ raise FileNotFoundError(
180
+ f"Missing {kind} weights: {path}. "
181
+ "Train the corresponding stage first or pass an explicit weight path."
182
+ )
183
+ checkpoint = torch.load(path, map_location="cpu", weights_only=False)
184
+ saved_config = checkpoint.get("config")
185
+ if saved_config is None:
186
+ raise ValueError("Project .pt weights must include their training config")
187
+ model = _build_stage_models_from_config(saved_config, kind)
188
+ model.load_state_dict(checkpoint["model_state_dict"], strict=True)
189
+ return model
190
+
191
+
192
+ def _build_stage_models_from_config(config: dict[str, Any], kind: str) -> torch.nn.Module:
193
+ from stormer import build_diffusion_model, build_regression_model
194
+
195
+ data_config = config["data"]
196
+ model_config = config["model"]
197
+ common = {
198
+ "image_size": model_config["image_size"],
199
+ "state_channels": len(data_config["state_variables"]),
200
+ "invariant_channels": len(data_config["invariant_variables"]),
201
+ "model_channels": model_config["model_channels"],
202
+ "channel_mult": model_config["channel_mult"],
203
+ "num_blocks": model_config["num_blocks"],
204
+ "attn_resolutions": model_config["attention_resolutions"],
205
+ }
206
+ if kind == "regression":
207
+ return build_regression_model(
208
+ **common, background_channels=len(data_config["era5_variables"])
209
+ )
210
+ return build_diffusion_model(**common)
211
+
212
+
213
+ def _resolve_paths(config: dict[str, Any], project_root: Path) -> None:
214
+ for section, key in (("data", "root_dir"), ("inference", "output_dir")):
215
+ path = Path(config[section][key])
216
+ if not path.is_absolute():
217
+ config[section][key] = str((project_root / path).resolve())
218
+ for key in ("regression_weights", "diffusion_weights"):
219
+ path = Path(config["model"][key])
220
+ if not path.is_absolute():
221
+ config["model"][key] = str((project_root / path).resolve())
222
+
223
+
224
+ if __name__ == "__main__":
225
+ main()
scripts/result.py ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import h5py
9
+ import matplotlib
10
+ import numpy as np
11
+ import yaml
12
+
13
+ matplotlib.use("Agg")
14
+ from matplotlib import pyplot as plt
15
+
16
+
17
+ def parse_args() -> argparse.Namespace:
18
+ parser = argparse.ArgumentParser(description="Visualize StormCast HDF5 forecasts")
19
+ parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
20
+ parser.add_argument("--input", type=Path, default="./outputs/inference/forecast.h5")
21
+ parser.add_argument("--output-dir", type=Path)
22
+ parser.add_argument("--state-variable")
23
+ parser.add_argument("--background-variable")
24
+ parser.add_argument("--step", type=int, action="append")
25
+ parser.add_argument(
26
+ "--normalized",
27
+ action="store_true",
28
+ help="Plot model-space values instead of applying dataset statistics",
29
+ )
30
+ return parser.parse_args()
31
+
32
+
33
+ def main() -> None:
34
+ args = parse_args()
35
+ config_path = args.config.resolve()
36
+ with config_path.open("r", encoding="utf-8") as handle:
37
+ config = yaml.safe_load(handle)
38
+ project_root = config_path.parent.parent
39
+ data_root = Path(config["data"]["root_dir"])
40
+ if not data_root.is_absolute():
41
+ data_root = (project_root / data_root).resolve()
42
+ output_dir = args.output_dir or Path(config["inference"]["output_dir"]) / "plots"
43
+ if not output_dir.is_absolute():
44
+ output_dir = (project_root / output_dir).resolve()
45
+ visualize(
46
+ input_path=args.input,
47
+ output_dir=output_dir,
48
+ data_root=data_root,
49
+ state_variable=args.state_variable
50
+ or config["inference"]["plot_state_variable"],
51
+ background_variable=args.background_variable
52
+ or config["inference"]["plot_background_variable"],
53
+ steps=args.step,
54
+ denormalize=not args.normalized,
55
+ )
56
+
57
+
58
+ def visualize(
59
+ input_path: Path,
60
+ output_dir: Path,
61
+ data_root: Path,
62
+ state_variable: str,
63
+ background_variable: str,
64
+ steps: list[int] | None = None,
65
+ denormalize: bool = True,
66
+ ) -> list[Path]:
67
+ output_dir.mkdir(parents=True, exist_ok=True)
68
+ outputs: list[Path] = []
69
+ with h5py.File(input_path, "r") as handle:
70
+ state_variables = _decode_strings(handle.attrs["state_variables"])
71
+ background_variables = _decode_strings(handle.attrs["background_variables"])
72
+ state_index = _variable_index(state_variables, state_variable, "state")
73
+ background_index = _variable_index(
74
+ background_variables, background_variable, "background"
75
+ )
76
+ selected_steps = steps or list(range(handle["prediction"].shape[0]))
77
+ for step in selected_steps:
78
+ if not 0 <= step < handle["prediction"].shape[0]:
79
+ raise IndexError(f"Step {step} is outside the forecast range")
80
+
81
+ source_normalized = bool(handle.attrs.get("normalized", False))
82
+ stats_by_year: dict[
83
+ int,
84
+ tuple[
85
+ tuple[np.ndarray, np.ndarray],
86
+ tuple[np.ndarray, np.ndarray],
87
+ ],
88
+ ] = {}
89
+
90
+ for step in selected_steps:
91
+ prediction = handle["prediction"][step, state_index]
92
+ target = handle["target"][step, state_index]
93
+ background = handle["background"][step, background_index]
94
+ time_index = int(handle["time_index"][step])
95
+ if denormalize and source_normalized:
96
+ year = int(str(time_index)[:4])
97
+ if year not in stats_by_year:
98
+ stats_by_year[year] = (
99
+ _read_stats(
100
+ data_root / "hrrr" / "data" / f"{year}.h5",
101
+ state_variables,
102
+ ),
103
+ _read_stats(
104
+ data_root / "era5" / "data" / f"{year}.h5",
105
+ background_variables,
106
+ ),
107
+ )
108
+ state_stats, background_stats = stats_by_year[year]
109
+ prediction = _denormalize(prediction, state_stats, state_index)
110
+ target = _denormalize(target, state_stats, state_index)
111
+ background = _denormalize(
112
+ background, background_stats, background_index
113
+ )
114
+ output = output_dir / f"forecast_{step:03d}_{state_variable}.png"
115
+ _save_four_panel(
116
+ prediction,
117
+ target,
118
+ background,
119
+ state_variable,
120
+ background_variable,
121
+ time_index,
122
+ output,
123
+ normalized=source_normalized and not denormalize,
124
+ )
125
+ outputs.append(output)
126
+ print(f"plot={output}")
127
+ return outputs
128
+
129
+
130
+ def _save_four_panel(
131
+ prediction: np.ndarray,
132
+ target: np.ndarray,
133
+ background: np.ndarray,
134
+ state_variable: str,
135
+ background_variable: str,
136
+ time_index: int,
137
+ output: Path,
138
+ normalized: bool,
139
+ ) -> None:
140
+ error = prediction - target
141
+ state_min = float(min(np.nanmin(prediction), np.nanmin(target)))
142
+ state_max = float(max(np.nanmax(prediction), np.nanmax(target)))
143
+ error_limit = max(float(np.nanmax(np.abs(error))), np.finfo(np.float32).eps)
144
+ time_label = datetime.strptime(str(time_index), "%Y%m%d%H").strftime(
145
+ "%Y-%m-%d %H:00"
146
+ )
147
+ units = " (normalized)" if normalized else ""
148
+ figure, axes = plt.subplots(1, 4, figsize=(19, 4.8), constrained_layout=True)
149
+ panels = (
150
+ (
151
+ prediction,
152
+ f"StormCast {state_variable}{units}",
153
+ "viridis",
154
+ state_min,
155
+ state_max,
156
+ ),
157
+ (target, f"Target {state_variable}{units}", "viridis", state_min, state_max),
158
+ (background, f"ERA5 {background_variable}{units}", "magma", None, None),
159
+ (
160
+ error,
161
+ f"Error {state_variable}{units}",
162
+ "RdBu_r",
163
+ -error_limit,
164
+ error_limit,
165
+ ),
166
+ )
167
+ for axis, (data, title, cmap, vmin, vmax) in zip(axes, panels):
168
+ image = axis.imshow(
169
+ data, origin="lower", cmap=cmap, vmin=vmin, vmax=vmax, aspect="auto"
170
+ )
171
+ axis.set_title(title, fontsize=10)
172
+ axis.set_xticks([])
173
+ axis.set_yticks([])
174
+ figure.colorbar(image, ax=axis, fraction=0.046, pad=0.03)
175
+ figure.suptitle(f"StormCast valid time: {time_label}", fontsize=13)
176
+ figure.savefig(output, dpi=160)
177
+ plt.close(figure)
178
+
179
+
180
+ def _read_stats(
181
+ path: Path, expected_variables: list[str]
182
+ ) -> tuple[np.ndarray, np.ndarray]:
183
+ if not path.is_file():
184
+ raise FileNotFoundError(f"Missing statistics file: {path}")
185
+ with h5py.File(path, "r") as handle:
186
+ variables = _decode_strings(handle["fields"].attrs["variables"])
187
+ if variables != expected_variables:
188
+ raise ValueError(f"Variable order in {path} differs from inference output")
189
+ means = np.asarray(handle["global_means"][:], dtype=np.float32).reshape(-1)
190
+ stds = np.asarray(handle["global_stds"][:], dtype=np.float32).reshape(-1)
191
+ return means, stds
192
+
193
+
194
+ def _denormalize(
195
+ data: np.ndarray,
196
+ stats: tuple[np.ndarray, np.ndarray],
197
+ index: int,
198
+ ) -> np.ndarray:
199
+ means, stds = stats
200
+ return data * stds[index] + means[index]
201
+
202
+
203
+ def _decode_strings(values: Any) -> list[str]:
204
+ return [
205
+ value.decode() if isinstance(value, bytes) else str(value) for value in values
206
+ ]
207
+
208
+
209
+ def _variable_index(variables: list[str], name: str, kind: str) -> int:
210
+ try:
211
+ return variables.index(name)
212
+ except ValueError as error:
213
+ raise ValueError(f"Unknown {kind} variable {name!r}") from error
214
+
215
+
216
+ if __name__ == "__main__":
217
+ main()
scripts/train.py ADDED
@@ -0,0 +1,474 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import random
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
12
+ MODEL_ROOT = PROJECT_ROOT / "model"
13
+ SCRIPT_ROOT = PROJECT_ROOT / "scripts"
14
+ for path in (PROJECT_ROOT, MODEL_ROOT, SCRIPT_ROOT):
15
+ if str(path) not in sys.path:
16
+ sys.path.insert(0, str(path))
17
+
18
+ import torch
19
+ import torch.distributed as dist
20
+ import torch.nn.functional as F
21
+ import yaml
22
+ from torch import nn
23
+ from torch.nn.parallel import DistributedDataParallel
24
+ from torch.utils.data import DataLoader, DistributedSampler
25
+
26
+ from stormer import build_diffusion_model, build_regression_model
27
+ from data_loader import StormCastDataset
28
+
29
+
30
+ @dataclass
31
+ class DistributedContext:
32
+ device: torch.device
33
+ rank: int
34
+ local_rank: int
35
+ world_size: int
36
+
37
+ @property
38
+ def distributed(self) -> bool:
39
+ return self.world_size > 1
40
+
41
+ @property
42
+ def is_main(self) -> bool:
43
+ return self.rank == 0
44
+
45
+
46
+ def regression_loss(
47
+ model: nn.Module,
48
+ condition: torch.Tensor,
49
+ target: torch.Tensor,
50
+ ) -> torch.Tensor:
51
+ return F.mse_loss(model(condition), target)
52
+
53
+
54
+ def edm_residual_loss(
55
+ model: nn.Module,
56
+ residual: torch.Tensor,
57
+ condition: torch.Tensor,
58
+ sigma_data: float = 0.5,
59
+ p_mean: float = -1.2,
60
+ p_std: float = 1.2,
61
+ ) -> torch.Tensor:
62
+ sigma = torch.exp(
63
+ torch.randn(residual.shape[0], device=residual.device) * p_std + p_mean
64
+ )
65
+ noise = torch.randn_like(residual) * sigma[:, None, None, None]
66
+ denoised = model(residual + noise, sigma, condition=condition)
67
+ weight = (sigma.square() + sigma_data**2) / (sigma * sigma_data).square()
68
+ return (weight[:, None, None, None] * (denoised - residual).square()).mean()
69
+
70
+
71
+ def parse_args() -> argparse.Namespace:
72
+ parser = argparse.ArgumentParser(description="Train the two-stage StormCast model")
73
+ parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
74
+ parser.add_argument("--stage", choices=("regression", "diffusion"))
75
+ parser.add_argument("--resume", type=Path)
76
+ parser.add_argument("--initial-weights", type=Path)
77
+ parser.add_argument("--regression-weights", type=Path)
78
+ parser.add_argument("--max-steps", type=int)
79
+ return parser.parse_args()
80
+
81
+
82
+ def main() -> None:
83
+ args = parse_args()
84
+ config_path = args.config.resolve()
85
+ with config_path.open("r", encoding="utf-8") as handle:
86
+ config = yaml.safe_load(handle)
87
+ _resolve_config_paths(config, config_path.parent.parent)
88
+
89
+ training = config["training"]
90
+ stage = args.stage or training["stage"]
91
+ resume = args.resume or _optional_path(training.get("resume_checkpoint"))
92
+ initial_weights = args.initial_weights or _optional_path(training.get("initial_weights"))
93
+ regression_weights = args.regression_weights or _optional_path(
94
+ training.get("regression_weights")
95
+ )
96
+ max_steps = args.max_steps if args.max_steps is not None else training["max_steps"]
97
+ if resume is None and initial_weights is None and not training["from_scratch"]:
98
+ checkpoint_key = f"{stage}_checkpoint"
99
+ initial_weights = _optional_path(config["model"].get(checkpoint_key))
100
+ if initial_weights is None:
101
+ raise ValueError(
102
+ f"training.from_scratch is false but model.{checkpoint_key} is not set"
103
+ )
104
+
105
+ context = initialize_distributed()
106
+ _seed_everything(config["project"]["seed"], context.rank)
107
+ try:
108
+ train(
109
+ config=config,
110
+ stage=stage,
111
+ context=context,
112
+ resume=resume,
113
+ initial_weights=initial_weights,
114
+ regression_weights=regression_weights,
115
+ max_steps=max_steps,
116
+ )
117
+ finally:
118
+ if dist.is_initialized():
119
+ dist.destroy_process_group()
120
+
121
+
122
+ def train(
123
+ config: dict[str, Any],
124
+ stage: str,
125
+ context: DistributedContext,
126
+ resume: Path | None,
127
+ initial_weights: Path | None,
128
+ regression_weights: Path | None,
129
+ max_steps: int | None,
130
+ ) -> None:
131
+ if stage not in ("regression", "diffusion"):
132
+ raise ValueError("training.stage must be 'regression' or 'diffusion'")
133
+ data_config = config["data"]
134
+ loader_config = config["dataloader"]
135
+ training_config = config["training"]
136
+ if list(data_config["image_size"]) != list(config["model"]["image_size"]):
137
+ raise ValueError("Data and model image sizes must match")
138
+ if list(data_config["era5_image_size"]) != [721, 1440]:
139
+ raise ValueError("ERA5 grid must be 721 x 1440")
140
+
141
+ dataset = StormCastDataset(
142
+ data_root=data_config["root_dir"],
143
+ years=data_config["train_years"],
144
+ era5_variables=data_config["era5_variables"],
145
+ state_variables=data_config["state_variables"],
146
+ invariant_variables=data_config["invariant_variables"],
147
+ image_size=data_config["image_size"],
148
+ input_steps=data_config["input_steps"],
149
+ output_steps=data_config["output_steps"],
150
+ normalize=data_config["normalize"],
151
+ )
152
+ sampler = (
153
+ DistributedSampler(
154
+ dataset,
155
+ num_replicas=context.world_size,
156
+ rank=context.rank,
157
+ shuffle=True,
158
+ )
159
+ if context.distributed
160
+ else None
161
+ )
162
+ loader = DataLoader(
163
+ dataset,
164
+ batch_size=loader_config["batch_size"],
165
+ shuffle=sampler is None,
166
+ sampler=sampler,
167
+ num_workers=loader_config["num_workers"],
168
+ pin_memory=loader_config["pin_memory"],
169
+ drop_last=False,
170
+ )
171
+
172
+ regression, model = _build_stage_models(
173
+ config, stage, regression_weights, context.device
174
+ )
175
+ if initial_weights is not None and resume is None:
176
+ _load_initial_weights(model, initial_weights, stage)
177
+ model.to(context.device)
178
+ optimizer = torch.optim.Adam(
179
+ model.parameters(),
180
+ lr=training_config["learning_rate"],
181
+ betas=tuple(training_config["betas"]),
182
+ weight_decay=training_config["weight_decay"],
183
+ )
184
+
185
+ start_epoch = 0
186
+ start_batch = 0
187
+ global_step = 0
188
+ if resume is not None:
189
+ start_epoch, start_batch, global_step = load_training_checkpoint(
190
+ resume, model, optimizer, stage, context.device
191
+ )
192
+ if context.distributed:
193
+ model = DistributedDataParallel(
194
+ model,
195
+ device_ids=[context.local_rank],
196
+ output_device=context.local_rank,
197
+ )
198
+
199
+ checkpoint_dir = Path(training_config["checkpoint_dir"]) / stage
200
+ if context.is_main:
201
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
202
+ print(
203
+ f"stage={stage} world_size={context.world_size} "
204
+ f"parameters={sum(parameter.numel() for parameter in model.parameters())}"
205
+ )
206
+
207
+ stop = False
208
+ if max_steps is not None and global_step >= max_steps:
209
+ stop = True
210
+ for epoch in range(start_epoch, training_config["epochs"]):
211
+ if stop:
212
+ break
213
+ if sampler is not None:
214
+ sampler.set_epoch(epoch)
215
+ model.train()
216
+ for batch_index, batch in enumerate(loader):
217
+ if epoch == start_epoch and batch_index < start_batch:
218
+ continue
219
+ loss = _training_step(
220
+ stage,
221
+ model,
222
+ regression,
223
+ batch,
224
+ context.device,
225
+ training_config,
226
+ )
227
+ optimizer.zero_grad(set_to_none=True)
228
+ loss.backward()
229
+ clip_norm = training_config.get("gradient_clip_norm")
230
+ if clip_norm is not None:
231
+ torch.nn.utils.clip_grad_norm_(model.parameters(), clip_norm)
232
+ optimizer.step()
233
+ global_step += 1
234
+
235
+ reduced_loss = _mean_across_ranks(loss.detach(), context.world_size)
236
+ if context.is_main and global_step % training_config["log_interval"] == 0:
237
+ print(
238
+ f"stage={stage} epoch={epoch + 1} step={global_step} "
239
+ f"loss={reduced_loss.item():.8f}"
240
+ )
241
+ if (
242
+ context.is_main
243
+ and global_step % training_config["checkpoint_interval"] == 0
244
+ ):
245
+ save_training_checkpoint(
246
+ checkpoint_dir / "model_bak.pt",
247
+ model,
248
+ optimizer,
249
+ stage,
250
+ epoch,
251
+ batch_index + 1,
252
+ global_step,
253
+ config,
254
+ )
255
+ if max_steps is not None and global_step >= max_steps:
256
+ stop = True
257
+ break
258
+ if stop:
259
+ break
260
+
261
+ if context.is_main:
262
+ checkpoint = checkpoint_dir / "model_bak.pt"
263
+ save_training_checkpoint(
264
+ checkpoint,
265
+ model,
266
+ optimizer,
267
+ stage,
268
+ epoch if "epoch" in locals() else start_epoch,
269
+ batch_index + 1 if "batch_index" in locals() else start_batch,
270
+ global_step,
271
+ config,
272
+ )
273
+ print(f"checkpoint={checkpoint} steps={global_step}")
274
+
275
+
276
+ def _build_stage_models(
277
+ config: dict[str, Any],
278
+ stage: str,
279
+ regression_weights: Path | None,
280
+ device: torch.device,
281
+ ) -> tuple[nn.Module | None, nn.Module]:
282
+ data_config = config["data"]
283
+ model_config = config["model"]
284
+ common = {
285
+ "image_size": model_config["image_size"],
286
+ "state_channels": len(data_config["state_variables"]),
287
+ "invariant_channels": len(data_config["invariant_variables"]),
288
+ "model_channels": model_config["model_channels"],
289
+ "channel_mult": model_config["channel_mult"],
290
+ "num_blocks": model_config["num_blocks"],
291
+ "attn_resolutions": model_config["attention_resolutions"],
292
+ }
293
+ if stage == "regression":
294
+ model = build_regression_model(
295
+ **common,
296
+ background_channels=len(data_config["era5_variables"]),
297
+ )
298
+ return None, model
299
+
300
+ if regression_weights is None:
301
+ raise ValueError("Diffusion training requires --regression-weights")
302
+ regression = _load_model_weights(
303
+ build_regression_model(
304
+ **common,
305
+ background_channels=len(data_config["era5_variables"]),
306
+ ),
307
+ regression_weights,
308
+ "regression",
309
+ ).to(device)
310
+ regression.eval()
311
+ regression.requires_grad_(False)
312
+ return regression, build_diffusion_model(**common)
313
+
314
+
315
+ def _training_step(
316
+ stage: str,
317
+ model: nn.Module,
318
+ regression: nn.Module | None,
319
+ batch: dict[str, Any],
320
+ device: torch.device,
321
+ training_config: dict[str, Any],
322
+ ) -> torch.Tensor:
323
+ background = batch["background"].to(device, dtype=torch.float32)
324
+ state, target = (
325
+ tensor.to(device, dtype=torch.float32) for tensor in batch["state"]
326
+ )
327
+ invariant = batch["invariant"].to(device, dtype=torch.float32)
328
+ if invariant.ndim == 3:
329
+ invariant = invariant.unsqueeze(0)
330
+ if invariant.shape[0] == 1 and state.shape[0] > 1:
331
+ invariant = invariant.expand(state.shape[0], -1, -1, -1)
332
+
333
+ if stage == "regression":
334
+ condition = torch.cat((state, background, invariant), dim=1)
335
+ return regression_loss(model, condition, target)
336
+
337
+ if regression is None:
338
+ raise RuntimeError("Regression model is required for diffusion training")
339
+ with torch.no_grad():
340
+ regression_condition = torch.cat((state, background, invariant), dim=1)
341
+ regression_prediction = regression(regression_condition)
342
+ residual = target - regression_prediction
343
+ condition = torch.cat((state, regression_prediction, invariant), dim=1)
344
+ return edm_residual_loss(
345
+ model,
346
+ residual,
347
+ condition,
348
+ sigma_data=training_config["sigma_data"],
349
+ p_mean=training_config["P_mean"],
350
+ p_std=training_config["P_std"],
351
+ )
352
+
353
+
354
+ def save_training_checkpoint(
355
+ path: Path,
356
+ model: nn.Module,
357
+ optimizer: torch.optim.Optimizer,
358
+ stage: str,
359
+ epoch: int,
360
+ batch_in_epoch: int,
361
+ global_step: int,
362
+ config: dict[str, Any],
363
+ ) -> None:
364
+ model = model.module if isinstance(model, DistributedDataParallel) else model
365
+ temporary = path.with_suffix(path.suffix + ".tmp")
366
+ torch.save(
367
+ {
368
+ "stage": stage,
369
+ "epoch": epoch,
370
+ "batch_in_epoch": batch_in_epoch,
371
+ "global_step": global_step,
372
+ "model_state_dict": model.state_dict(),
373
+ "optimizer_state_dict": optimizer.state_dict(),
374
+ "config": config,
375
+ },
376
+ temporary,
377
+ )
378
+ temporary.replace(path)
379
+
380
+
381
+ def load_training_checkpoint(
382
+ path: Path,
383
+ model: nn.Module,
384
+ optimizer: torch.optim.Optimizer,
385
+ stage: str,
386
+ device: torch.device,
387
+ ) -> tuple[int, int, int]:
388
+ checkpoint = torch.load(path, map_location=device, weights_only=False)
389
+ if checkpoint["stage"] != stage:
390
+ raise ValueError(
391
+ f"Checkpoint stage is {checkpoint['stage']}, requested stage is {stage}"
392
+ )
393
+ model.load_state_dict(checkpoint["model_state_dict"], strict=True)
394
+ optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
395
+ return (
396
+ int(checkpoint["epoch"]),
397
+ int(checkpoint.get("batch_in_epoch", 0)),
398
+ int(checkpoint["global_step"]),
399
+ )
400
+
401
+
402
+ def initialize_distributed() -> DistributedContext:
403
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
404
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
405
+ if not torch.cuda.is_available():
406
+ raise RuntimeError("StormCast training requires a CUDA/HIP device")
407
+ torch.cuda.set_device(local_rank)
408
+ if world_size > 1:
409
+ dist.init_process_group(backend="nccl", init_method="env://")
410
+ rank = dist.get_rank()
411
+ else:
412
+ rank = 0
413
+ return DistributedContext(
414
+ device=torch.device("cuda", local_rank),
415
+ rank=rank,
416
+ local_rank=local_rank,
417
+ world_size=world_size,
418
+ )
419
+
420
+
421
+ def _load_initial_weights(model: nn.Module, path: Path, stage: str) -> None:
422
+ loaded = _load_model_weights(model, path, stage)
423
+ model.load_state_dict(loaded.state_dict(), strict=True)
424
+
425
+
426
+ def _load_model_weights(model: nn.Module, path: Path, stage: str) -> nn.Module:
427
+ checkpoint = torch.load(path, map_location="cpu", weights_only=False)
428
+ state = checkpoint.get("model_state_dict", checkpoint)
429
+ model.load_state_dict(state, strict=True)
430
+ return model
431
+
432
+
433
+ def _mean_across_ranks(value: torch.Tensor, world_size: int) -> torch.Tensor:
434
+ if world_size > 1:
435
+ dist.all_reduce(value, op=dist.ReduceOp.SUM)
436
+ value /= world_size
437
+ return value
438
+
439
+
440
+ def _resolve_config_paths(config: dict[str, Any], project_root: Path) -> None:
441
+ for section, key in (
442
+ ("data", "root_dir"),
443
+ ("training", "checkpoint_dir"),
444
+ ):
445
+ path = Path(config[section][key])
446
+ if not path.is_absolute():
447
+ config[section][key] = str((project_root / path).resolve())
448
+ for key in ("regression_weights", "diffusion_weights"):
449
+ value = config["model"].get(key)
450
+ if value:
451
+ path = Path(value)
452
+ if not path.is_absolute():
453
+ config["model"][key] = str((project_root / path).resolve())
454
+ for key in ("initial_weights", "resume_checkpoint", "regression_weights"):
455
+ value = config["training"].get(key)
456
+ if value:
457
+ path = Path(value)
458
+ if not path.is_absolute():
459
+ config["training"][key] = str((project_root / path).resolve())
460
+
461
+
462
+ def _optional_path(value: str | Path | None) -> Path | None:
463
+ return None if value is None else Path(value)
464
+
465
+
466
+ def _seed_everything(seed: int, rank: int) -> None:
467
+ seed += rank
468
+ random.seed(seed)
469
+ torch.manual_seed(seed)
470
+ torch.cuda.manual_seed(seed)
471
+
472
+
473
+ if __name__ == "__main__":
474
+ main()
weight/.gitkeep ADDED
File without changes