Upload folder using huggingface_hub
Browse files- README.md +158 -0
- conf/config.yaml +191 -0
- config.json +119 -0
- configuration.json +1 -0
- model/stormer.py +406 -0
- scripts/data_loader.py +166 -0
- scripts/fake_data.py +125 -0
- scripts/grid.py +28 -0
- scripts/inference.py +225 -0
- scripts/result.py +217 -0
- scripts/train.py +474 -0
- weight/.gitkeep +0 -0
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
|