Upload folder using huggingface_hub
Browse files- README.md +149 -0
- conf/config.yaml +58 -0
- config.json +87 -0
- configuration.json +12 -0
- model/spherical_dyffusion.py +20 -0
- scripts/download.sh +8 -0
- scripts/fake_data.py +76 -0
- scripts/inference.py +17 -0
- scripts/local_pipeline.py +289 -0
- scripts/result.py +240 -0
- scripts/show_features.py +35 -0
- scripts/train.py +19 -0
- scripts/validate_data.py +57 -0
- weight/.gitkeep +1 -0
README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
frameworks: PyTorch
|
| 3 |
+
language:
|
| 4 |
+
- en
|
| 5 |
+
license: apache-2.0
|
| 6 |
+
tags:
|
| 7 |
+
- OneScience
|
| 8 |
+
- Earth Science
|
| 9 |
+
- Climate Simulation
|
| 10 |
+
- Probabilistic Forecasting
|
| 11 |
+
- FV3GFS
|
| 12 |
+
- Spherical DYffusion
|
| 13 |
+
tasks: []
|
| 14 |
+
datasets:
|
| 15 |
+
- FV3GFS
|
| 16 |
+
---
|
| 17 |
+
<p align="center">
|
| 18 |
+
<strong>
|
| 19 |
+
<span style="font-size: 30px;">Spherical DYffusion</span>
|
| 20 |
+
</strong>
|
| 21 |
+
</p>
|
| 22 |
+
|
| 23 |
+
# Model Introduction
|
| 24 |
+
|
| 25 |
+
Spherical DYffusion was proposed by Salva Ruhling Cachay and collaborators for probabilistic simulation of a global climate model.
|
| 26 |
+
|
| 27 |
+
Paper: Probabilistic Emulation of a Global Climate Model with Spherical DYffusion
|
| 28 |
+
|
| 29 |
+
https://arxiv.org/abs/2406.14798
|
| 30 |
+
|
| 31 |
+
# Model Description
|
| 32 |
+
|
| 33 |
+
The original method models spherical dynamics with an SFNO and uses the DYffusion interpolator and forecaster in a two-stage training procedure for probabilistic ensemble simulation. This repository contains a compact local implementation that preserves the project's tensor and data contracts for smoke testing; it is not a full paper-scale SFNO/DYffusion implementation.
|
| 34 |
+
|
| 35 |
+
# Use Cases
|
| 36 |
+
|
| 37 |
+
| Scenario | Description |
|
| 38 |
+
| :---: | :--- |
|
| 39 |
+
| Local pipeline validation | Use synthetic 37-channel global-grid data to check training, inference, and visualization. |
|
| 40 |
+
| FV3GFS protocol checks | Validate NetCDF variables, spatial dimensions, and consecutive time frames. |
|
| 41 |
+
| ModelScope / OneCode execution | Download the standalone model package and run the compact local pipeline. |
|
| 42 |
+
| Multi-GPU training | Launch PyTorch DistributedDataParallel with `torchrun`. |
|
| 43 |
+
|
| 44 |
+
# Usage Guide
|
| 45 |
+
|
| 46 |
+
## 1. OneCode Usage
|
| 47 |
+
|
| 48 |
+
Experience intelligent one-click AI4S programming through the OneCode online environment:
|
| 49 |
+
|
| 50 |
+
[Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
|
| 51 |
+
|
| 52 |
+
## 2. Manual Installation and Usage
|
| 53 |
+
|
| 54 |
+
**Hardware Requirements**
|
| 55 |
+
|
| 56 |
+
- A GPU or DCU is recommended.
|
| 57 |
+
- CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
|
| 58 |
+
- DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
|
| 59 |
+
|
| 60 |
+
### Download the Model Package
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
hf download OneScience-Group/Spherical_DYffusion --local-dir ./Spherical_DYffusion
|
| 64 |
+
cd Spherical_DYffusion
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
### Install the Runtime Environment
|
| 68 |
+
|
| 69 |
+
**DCU Environment**
|
| 70 |
+
|
| 71 |
+
```bash
|
| 72 |
+
# Please activate DTK and CONDA first
|
| 73 |
+
conda create -n onescience311 python=3.11 -y
|
| 74 |
+
conda activate onescience311
|
| 75 |
+
# uv installation is supported
|
| 76 |
+
pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
**GPU Environment**
|
| 80 |
+
```bash
|
| 81 |
+
# Please activate CONDA first
|
| 82 |
+
conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
|
| 83 |
+
conda activate onescience311
|
| 84 |
+
# uv installation is supported
|
| 85 |
+
pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
### Generate Synthetic Data
|
| 89 |
+
|
| 90 |
+
Generate a deterministic NetCDF FV3GFS-contract fixture at `data/data/synthetic_fv3gfs.nc`:
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
python scripts/fake_data.py
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
The fixture contains 37 protocol variables, including surface pressure and temperature, eight vertical levels of temperature, total water, and wind components, plus `DSWRFtoa`, `HGTsfc`, and `ocean_fraction`. It is intended only for protocol checks. The local training pipeline creates its own learnable `data/data/virtual_fv3gfs.npz` fixture.
|
| 97 |
+
|
| 98 |
+
### Training
|
| 99 |
+
|
| 100 |
+
Single GPU:
|
| 101 |
+
|
| 102 |
+
```bash
|
| 103 |
+
python scripts/train.py
|
| 104 |
+
```
|
| 105 |
+
|
| 106 |
+
Multi-GPU:
|
| 107 |
+
|
| 108 |
+
```bash
|
| 109 |
+
torchrun --nproc_per_node=8 scripts/train.py
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
Training starts from random initialization and saves `data/checkpoint/model_bak.pt` and `data/checkpoint/last.pt`.
|
| 113 |
+
|
| 114 |
+
The complete local smoke workflow can also be run with:
|
| 115 |
+
|
| 116 |
+
```bash
|
| 117 |
+
python scripts/local_pipeline.py all
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
### Training Weights
|
| 121 |
+
|
| 122 |
+
This repository provides a `weight/` directory for FV3GFS-compatible checkpoints. The weight files will be uploaded soon and are expected to be available in the near future.
|
| 123 |
+
|
| 124 |
+
### Inference
|
| 125 |
+
|
| 126 |
+
Inference reads `data/checkpoint/model_bak.pt` and writes `output/inference/prediction.npz`:
|
| 127 |
+
|
| 128 |
+
```bash
|
| 129 |
+
python scripts/inference.py
|
| 130 |
+
```
|
| 131 |
+
|
| 132 |
+
### Evaluation and Visualization
|
| 133 |
+
|
| 134 |
+
```bash
|
| 135 |
+
python scripts/result.py
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
The script computes per-variable and overall diagnostics and writes `output/visualization/diagnostic_dashboard.png` and `output/visualization/variable_metrics.png`, with machine-readable summaries under `output/metrics/`.
|
| 139 |
+
|
| 140 |
+
# Official OneScience Resources
|
| 141 |
+
|
| 142 |
+
| Platform | OneScience Main Repository | Skills Repository |
|
| 143 |
+
| --- | --- | --- |
|
| 144 |
+
| Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
|
| 145 |
+
| GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
|
| 146 |
+
|
| 147 |
+
# Citation and License
|
| 148 |
+
|
| 149 |
+
- This repository is a compact local reproduction of the original Spherical DYffusion paper and does not claim to reproduce the paper-scale training setup or metrics.
|
conf/config.yaml
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
project:
|
| 2 |
+
name: Spherical-DYffusion-SFNO
|
| 3 |
+
version: "1.0"
|
| 4 |
+
|
| 5 |
+
paths:
|
| 6 |
+
data_root: data
|
| 7 |
+
raw_data: data/raw
|
| 8 |
+
processed_data: data/processed
|
| 9 |
+
metadata: data/metadata
|
| 10 |
+
statistics: data/statistics
|
| 11 |
+
pretrained_weights: data/weights
|
| 12 |
+
checkpoints: data/checkpoint
|
| 13 |
+
result_root: output/result
|
| 14 |
+
predictions: output/inference
|
| 15 |
+
metrics: output/metrics
|
| 16 |
+
figures: output/visualization
|
| 17 |
+
work_dir: data/work
|
| 18 |
+
|
| 19 |
+
model:
|
| 20 |
+
name: Spherical-DYffusion-SFNO
|
| 21 |
+
resolution: 1deg
|
| 22 |
+
latitude: 180
|
| 23 |
+
longitude: 360
|
| 24 |
+
|
| 25 |
+
runtime:
|
| 26 |
+
device: auto
|
| 27 |
+
devices: auto
|
| 28 |
+
distributed_backend: auto
|
| 29 |
+
num_workers: 8
|
| 30 |
+
|
| 31 |
+
training:
|
| 32 |
+
seed: 11
|
| 33 |
+
epochs: 5
|
| 34 |
+
batch_size: 2
|
| 35 |
+
learning_rate: 0.0001
|
| 36 |
+
checkpoint_dir: data/checkpoint
|
| 37 |
+
validation_split: 0.2
|
| 38 |
+
|
| 39 |
+
synthetic_data:
|
| 40 |
+
output_dir: data/data
|
| 41 |
+
time_steps: 8
|
| 42 |
+
latitude: 180
|
| 43 |
+
longitude: 360
|
| 44 |
+
samples: 16
|
| 45 |
+
input_steps: 1
|
| 46 |
+
channels: 37
|
| 47 |
+
seed: 11
|
| 48 |
+
|
| 49 |
+
inference:
|
| 50 |
+
output_dir: output/inference
|
| 51 |
+
checkpoint: data/checkpoint/model_bak.pt
|
| 52 |
+
|
| 53 |
+
visualization:
|
| 54 |
+
output_dir: output/visualization
|
| 55 |
+
prediction_index: 0
|
| 56 |
+
channel: 1
|
| 57 |
+
scatter_points: 12000
|
| 58 |
+
dpi: 150
|
config.json
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"model_name": "Spherical_DYffusion",
|
| 3 |
+
"model_type": "spherical_dyffusion",
|
| 4 |
+
"architectures": [
|
| 5 |
+
"SphericalDYffusion"
|
| 6 |
+
],
|
| 7 |
+
"framework": "PyTorch",
|
| 8 |
+
"domain": "climate-and-atmosphere",
|
| 9 |
+
"task": "global-grid-climate-emulation",
|
| 10 |
+
"implementation": {
|
| 11 |
+
"entry_point": "model/spherical_dyffusion.py",
|
| 12 |
+
"scope": "Compact local convolutional forecaster preserving the Spherical DYffusion project tensor contract; it is a smoke-test implementation, not the full SFNO/DYffusion paper architecture"
|
| 13 |
+
},
|
| 14 |
+
"architecture": {
|
| 15 |
+
"family": "three-layer Conv2d forecaster with GELU activations",
|
| 16 |
+
"input_format": "BCHW",
|
| 17 |
+
"output_format": "BCHW",
|
| 18 |
+
"input_grid_shape": [
|
| 19 |
+
180,
|
| 20 |
+
360
|
| 21 |
+
],
|
| 22 |
+
"resolution_degrees": 1.0,
|
| 23 |
+
"channels": 37,
|
| 24 |
+
"hidden_channels": 32,
|
| 25 |
+
"layers": [
|
| 26 |
+
"Conv2d(37,32,kernel_size=3,padding=1)",
|
| 27 |
+
"GELU",
|
| 28 |
+
"Conv2d(32,32,kernel_size=3,padding=1)",
|
| 29 |
+
"GELU",
|
| 30 |
+
"Conv2d(32,37,kernel_size=1)"
|
| 31 |
+
],
|
| 32 |
+
"stochasticity": "none in the current local model; the training target is a deterministic synthetic local-dynamics rule"
|
| 33 |
+
},
|
| 34 |
+
"data": {
|
| 35 |
+
"dataset_protocol": "FV3GFS",
|
| 36 |
+
"variables": [
|
| 37 |
+
"PRESsfc",
|
| 38 |
+
"surface_temperature",
|
| 39 |
+
"air_temperature_0",
|
| 40 |
+
"air_temperature_1",
|
| 41 |
+
"air_temperature_2",
|
| 42 |
+
"air_temperature_3",
|
| 43 |
+
"air_temperature_4",
|
| 44 |
+
"air_temperature_5",
|
| 45 |
+
"air_temperature_6",
|
| 46 |
+
"air_temperature_7",
|
| 47 |
+
"specific_total_water_0",
|
| 48 |
+
"specific_total_water_1",
|
| 49 |
+
"specific_total_water_2",
|
| 50 |
+
"specific_total_water_3",
|
| 51 |
+
"specific_total_water_4",
|
| 52 |
+
"specific_total_water_5",
|
| 53 |
+
"specific_total_water_6",
|
| 54 |
+
"specific_total_water_7",
|
| 55 |
+
"eastward_wind_0",
|
| 56 |
+
"eastward_wind_1",
|
| 57 |
+
"eastward_wind_2",
|
| 58 |
+
"eastward_wind_3",
|
| 59 |
+
"eastward_wind_4",
|
| 60 |
+
"eastward_wind_5",
|
| 61 |
+
"eastward_wind_6",
|
| 62 |
+
"eastward_wind_7",
|
| 63 |
+
"northward_wind_0",
|
| 64 |
+
"northward_wind_1",
|
| 65 |
+
"northward_wind_2",
|
| 66 |
+
"northward_wind_3",
|
| 67 |
+
"northward_wind_4",
|
| 68 |
+
"northward_wind_5",
|
| 69 |
+
"northward_wind_6",
|
| 70 |
+
"northward_wind_7",
|
| 71 |
+
"DSWRFtoa",
|
| 72 |
+
"HGTsfc",
|
| 73 |
+
"ocean_fraction"
|
| 74 |
+
],
|
| 75 |
+
"synthetic_time_steps": 8,
|
| 76 |
+
"input_steps": 1,
|
| 77 |
+
"samples": 16,
|
| 78 |
+
"data_note": "Synthetic FV3GFS-equivalent data is for protocol and pipeline checks only and is not a paper-reproduction dataset."
|
| 79 |
+
},
|
| 80 |
+
"configuration_sources": [
|
| 81 |
+
"conf/config.yaml",
|
| 82 |
+
"model/spherical_dyffusion.py",
|
| 83 |
+
"scripts/local_pipeline.py",
|
| 84 |
+
"scripts/fake_data.py",
|
| 85 |
+
"README.md"
|
| 86 |
+
]
|
| 87 |
+
}
|
configuration.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"framework": "PyTorch",
|
| 3 |
+
"task": "probabilistic_climate_emulation",
|
| 4 |
+
"model": "Spherical_DYffusion",
|
| 5 |
+
"input_format": "BCHW",
|
| 6 |
+
"protocol": "fv3gfs_1deg_37_channel",
|
| 7 |
+
"default_config": "conf/config.yaml",
|
| 8 |
+
"train": "scripts/train.py",
|
| 9 |
+
"inference": "scripts/inference.py",
|
| 10 |
+
"evaluation": "scripts/result.py",
|
| 11 |
+
"visualization": "scripts/result.py"
|
| 12 |
+
}
|
model/spherical_dyffusion.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compact trainable model used by the local Spherical DYffusion pipeline."""
|
| 2 |
+
|
| 3 |
+
from torch import nn
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class SphericalDYffusion(nn.Module):
|
| 7 |
+
"""Small convolutional forecaster preserving the global-grid tensor contract."""
|
| 8 |
+
|
| 9 |
+
def __init__(self, channels: int):
|
| 10 |
+
super().__init__()
|
| 11 |
+
self.net = nn.Sequential(
|
| 12 |
+
nn.Conv2d(channels, 32, 3, padding=1),
|
| 13 |
+
nn.GELU(),
|
| 14 |
+
nn.Conv2d(32, 32, 3, padding=1),
|
| 15 |
+
nn.GELU(),
|
| 16 |
+
nn.Conv2d(32, channels, 1),
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
def forward(self, inputs):
|
| 20 |
+
return self.net(inputs)
|
scripts/download.sh
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
WEIGHT_DIR="${WEIGHT_DIR:-/path/to/weight/pretrained}"
|
| 5 |
+
mkdir -p "$WEIGHT_DIR"
|
| 6 |
+
curl -L --fail --retry 3 "https://huggingface.co/salv47/spherical-dyffusion/resolve/main/forecaster-sfno-best-inference_avg_crps.ckpt?download=true" -o "$WEIGHT_DIR/forecaster-sfno-best-inference_avg_crps.ckpt"
|
| 7 |
+
curl -L --fail --retry 3 "https://huggingface.co/salv47/spherical-dyffusion/resolve/main/interpolator-sfno-best-val_avg_crps.ckpt?download=true" -o "$WEIGHT_DIR/interpolator-sfno-best-val_avg_crps.ckpt"
|
| 8 |
+
curl -L --fail --retry 3 "https://huggingface.co/salv47/spherical-dyffusion/resolve/main/interpolator_sfno_paper_v0_hydra_config.yaml?download=true" -o "$WEIGHT_DIR/interpolator_sfno_paper_v0_hydra_config.yaml"
|
scripts/fake_data.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate deterministic synthetic spherical data for pipeline smoke tests."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import xarray as xr
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
VARIABLES = [
|
| 12 |
+
"PRESsfc",
|
| 13 |
+
"surface_temperature",
|
| 14 |
+
*[f"air_temperature_{i}" for i in range(8)],
|
| 15 |
+
*[f"specific_total_water_{i}" for i in range(8)],
|
| 16 |
+
*[f"eastward_wind_{i}" for i in range(8)],
|
| 17 |
+
*[f"northward_wind_{i}" for i in range(8)],
|
| 18 |
+
"DSWRFtoa",
|
| 19 |
+
"HGTsfc",
|
| 20 |
+
"ocean_fraction",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def generate_data(output_dir: str, time_steps: int, latitude: int, longitude: int) -> Path:
|
| 25 |
+
"""Write a small NetCDF fixture and metadata to the configured data directory."""
|
| 26 |
+
output_path = Path(output_dir)
|
| 27 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 28 |
+
data = np.zeros((time_steps, len(VARIABLES), latitude, longitude), dtype=np.float32)
|
| 29 |
+
lat = np.linspace(-90, 90, latitude, dtype=np.float32)
|
| 30 |
+
lon = np.linspace(0, 360, longitude, endpoint=False, dtype=np.float32)
|
| 31 |
+
data[:, 0] = 1.0
|
| 32 |
+
data[:, 1] = 280.0 + np.sin(np.deg2rad(lat))[None, :, None]
|
| 33 |
+
data[:, -1] = 1.0
|
| 34 |
+
dataset = xr.Dataset(
|
| 35 |
+
{name: (("time", "lat", "lon"), data[:, index]) for index, name in enumerate(VARIABLES)},
|
| 36 |
+
coords={"time": np.arange(time_steps), "lat": lat, "lon": lon},
|
| 37 |
+
attrs={"dataset_type": "synthetic_smoke_data", "paper_reproduction": "false"},
|
| 38 |
+
)
|
| 39 |
+
netcdf_path = output_path / "synthetic_fv3gfs.nc"
|
| 40 |
+
dataset.to_netcdf(netcdf_path)
|
| 41 |
+
metadata = {
|
| 42 |
+
"dataset_type": "synthetic_smoke_data",
|
| 43 |
+
"paper_reproduction": False,
|
| 44 |
+
"variables": VARIABLES,
|
| 45 |
+
"shape": [time_steps, len(VARIABLES), latitude, longitude],
|
| 46 |
+
"source": "scripts/generate_data.py",
|
| 47 |
+
}
|
| 48 |
+
(output_path / "synthetic_fv3gfs.json").write_text(
|
| 49 |
+
json.dumps(metadata, indent=2) + "\n", encoding="utf-8"
|
| 50 |
+
)
|
| 51 |
+
return netcdf_path
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def main() -> None:
|
| 55 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 56 |
+
parser.add_argument("--config", default="conf/config.yaml")
|
| 57 |
+
parser.add_argument("--output-dir")
|
| 58 |
+
parser.add_argument("--time-steps", type=int)
|
| 59 |
+
parser.add_argument("--latitude", type=int)
|
| 60 |
+
parser.add_argument("--longitude", type=int)
|
| 61 |
+
args = parser.parse_args()
|
| 62 |
+
import yaml
|
| 63 |
+
|
| 64 |
+
config = yaml.safe_load(open(args.config, encoding="utf-8"))["synthetic_data"]
|
| 65 |
+
path = generate_data(
|
| 66 |
+
args.output_dir or config["output_dir"],
|
| 67 |
+
args.time_steps or config["time_steps"],
|
| 68 |
+
args.latitude or config["latitude"],
|
| 69 |
+
args.longitude or config["longitude"],
|
| 70 |
+
)
|
| 71 |
+
print(f"Generated synthetic smoke data: {path}")
|
| 72 |
+
print("This fixture is for pipeline validation only, not paper reproduction.")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
if __name__ == "__main__":
|
| 76 |
+
main()
|
scripts/inference.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run inference with the locally trained model."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from local_pipeline import infer, load_config
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main() -> None:
|
| 8 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 9 |
+
parser.add_argument(
|
| 10 |
+
"--config", default="conf/config.yaml", help="Pipeline YAML configuration",
|
| 11 |
+
)
|
| 12 |
+
args = parser.parse_args()
|
| 13 |
+
infer(load_config(args.config))
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
if __name__ == "__main__":
|
| 17 |
+
main()
|
scripts/local_pipeline.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the self-contained virtual-data training, inference, and plotting pipeline."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import random
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
import torch.distributed as dist
|
| 12 |
+
from torch import nn
|
| 13 |
+
from torch.nn.parallel import DistributedDataParallel
|
| 14 |
+
from torch.utils.data import DataLoader, DistributedSampler, TensorDataset
|
| 15 |
+
import sys
|
| 16 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 17 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 18 |
+
from model.spherical_dyffusion import SphericalDYffusion
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
VARIABLES = [
|
| 22 |
+
"PRESsfc", "surface_temperature",
|
| 23 |
+
*[f"air_temperature_{i}" for i in range(8)],
|
| 24 |
+
*[f"specific_total_water_{i}" for i in range(8)],
|
| 25 |
+
*[f"eastward_wind_{i}" for i in range(8)],
|
| 26 |
+
*[f"northward_wind_{i}" for i in range(8)],
|
| 27 |
+
"DSWRFtoa", "HGTsfc", "ocean_fraction",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_config(path: str) -> dict:
|
| 32 |
+
import yaml
|
| 33 |
+
|
| 34 |
+
with open(path, encoding="utf-8") as file:
|
| 35 |
+
return yaml.safe_load(file)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def resolve_device(config: dict, local_rank: int = 0) -> torch.device:
|
| 39 |
+
requested = str(config.get("runtime", {}).get("device", "auto")).lower()
|
| 40 |
+
if requested == "auto":
|
| 41 |
+
requested = "cuda" if torch.cuda.is_available() else "cpu"
|
| 42 |
+
if requested.startswith("cuda") and not torch.cuda.is_available():
|
| 43 |
+
raise RuntimeError(
|
| 44 |
+
f"runtime.device={requested!r}, but PyTorch cannot access a CUDA/ROCm device. "
|
| 45 |
+
"Use runtime.device=cpu or install a GPU-enabled PyTorch build."
|
| 46 |
+
)
|
| 47 |
+
device = torch.device(requested)
|
| 48 |
+
if device.type == "cuda":
|
| 49 |
+
device_index = device.index if device.index is not None else local_rank
|
| 50 |
+
if device_index >= torch.cuda.device_count():
|
| 51 |
+
raise RuntimeError(
|
| 52 |
+
f"LOCAL_RANK={local_rank} maps to GPU {device_index}, but only "
|
| 53 |
+
f"{torch.cuda.device_count()} GPU(s) are visible."
|
| 54 |
+
)
|
| 55 |
+
torch.cuda.set_device(device_index)
|
| 56 |
+
device = torch.device("cuda", device_index)
|
| 57 |
+
print(
|
| 58 |
+
f"device: {device} ({torch.cuda.get_device_name(device_index)}), "
|
| 59 |
+
f"backend={'ROCm ' + torch.version.hip if torch.version.hip else 'CUDA ' + str(torch.version.cuda)}"
|
| 60 |
+
)
|
| 61 |
+
else:
|
| 62 |
+
print("device: cpu")
|
| 63 |
+
return device
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def setup_distributed(config: dict) -> tuple[int, int, torch.device]:
|
| 67 |
+
world_size = int(os.environ.get("WORLD_SIZE", "1"))
|
| 68 |
+
rank = int(os.environ.get("RANK", "0"))
|
| 69 |
+
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
|
| 70 |
+
configured_devices = config.get("runtime", {}).get("devices", "auto")
|
| 71 |
+
requested_device = str(config.get("runtime", {}).get("device", "auto")).lower()
|
| 72 |
+
if isinstance(configured_devices, int) and configured_devices > 1 and world_size == 1:
|
| 73 |
+
raise RuntimeError(
|
| 74 |
+
f"runtime.devices={configured_devices} requires a distributed launcher. Run: "
|
| 75 |
+
f"python -m torch.distributed.run --standalone --nproc-per-node={configured_devices} "
|
| 76 |
+
"scripts/train.py"
|
| 77 |
+
)
|
| 78 |
+
if isinstance(configured_devices, int) and world_size > 1 and configured_devices != world_size:
|
| 79 |
+
raise RuntimeError(
|
| 80 |
+
f"runtime.devices={configured_devices} does not match torchrun WORLD_SIZE={world_size}."
|
| 81 |
+
)
|
| 82 |
+
if world_size > 1 and requested_device.startswith("cuda:"):
|
| 83 |
+
raise RuntimeError(
|
| 84 |
+
"Do not set an explicit CUDA device index for DDP. Use runtime.device=cuda or auto; "
|
| 85 |
+
"each torchrun process is mapped to its LOCAL_RANK automatically."
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
device = resolve_device(config, local_rank=local_rank)
|
| 89 |
+
if world_size > 1:
|
| 90 |
+
if not dist.is_available():
|
| 91 |
+
raise RuntimeError("Distributed training is unavailable in this PyTorch build.")
|
| 92 |
+
backend = str(config.get("runtime", {}).get("distributed_backend", "auto")).lower()
|
| 93 |
+
if backend == "auto":
|
| 94 |
+
backend = "nccl" if device.type == "cuda" else "gloo"
|
| 95 |
+
dist.init_process_group(backend=backend, init_method="env://")
|
| 96 |
+
if dist.get_world_size() != world_size or dist.get_rank() != rank:
|
| 97 |
+
raise RuntimeError("The process group does not match the torchrun rank settings.")
|
| 98 |
+
return rank, world_size, device
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def generate(config: dict) -> Path:
|
| 102 |
+
spec = config["synthetic_data"]
|
| 103 |
+
rng = np.random.default_rng(spec["seed"])
|
| 104 |
+
shape = (spec["samples"], spec["channels"], spec["latitude"], spec["longitude"])
|
| 105 |
+
inputs = rng.normal(0, 1, shape).astype(np.float32)
|
| 106 |
+
# A deterministic local dynamics rule provides a learnable target.
|
| 107 |
+
targets = (0.85 * inputs + 0.05 * np.roll(inputs, 1, axis=2) + 0.05 * np.roll(inputs, -1, axis=3)).astype(np.float32)
|
| 108 |
+
output = Path(spec["output_dir"])
|
| 109 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 110 |
+
path = output / "virtual_fv3gfs.npz"
|
| 111 |
+
np.savez_compressed(path, inputs=inputs, targets=targets)
|
| 112 |
+
(output / "metadata.json").write_text(json.dumps({
|
| 113 |
+
"variables": VARIABLES, "shape": list(shape), "time_steps": spec["time_steps"],
|
| 114 |
+
"latitude": spec["latitude"], "longitude": spec["longitude"],
|
| 115 |
+
"dataset_type": "virtual_fv3gfs_equivalent_contract",
|
| 116 |
+
}, indent=2) + "\n", encoding="utf-8")
|
| 117 |
+
print(f"virtual data: {path}")
|
| 118 |
+
return path
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def train(config: dict, finetune: str | None = None) -> Path:
|
| 122 |
+
seed = config["training"]["seed"]
|
| 123 |
+
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
|
| 124 |
+
rank, world_size, device = setup_distributed(config)
|
| 125 |
+
is_root = rank == 0
|
| 126 |
+
checkpoint = Path(config["training"]["checkpoint_dir"])
|
| 127 |
+
try:
|
| 128 |
+
if int(config["training"]["epochs"]) < 1:
|
| 129 |
+
raise ValueError("training.epochs must be at least 1.")
|
| 130 |
+
data_path = Path(config["synthetic_data"]["output_dir"]) / "virtual_fv3gfs.npz"
|
| 131 |
+
if is_root:
|
| 132 |
+
data_path = generate(config)
|
| 133 |
+
if world_size > 1:
|
| 134 |
+
dist.barrier()
|
| 135 |
+
arrays = np.load(data_path)
|
| 136 |
+
dataset = TensorDataset(torch.from_numpy(arrays["inputs"]), torch.from_numpy(arrays["targets"]))
|
| 137 |
+
|
| 138 |
+
global_batch_size = int(config["training"]["batch_size"])
|
| 139 |
+
if global_batch_size % world_size:
|
| 140 |
+
raise ValueError(
|
| 141 |
+
f"training.batch_size={global_batch_size} is the global batch size and must be "
|
| 142 |
+
f"divisible by WORLD_SIZE={world_size}."
|
| 143 |
+
)
|
| 144 |
+
if len(dataset) % world_size:
|
| 145 |
+
raise ValueError(
|
| 146 |
+
f"Dataset size {len(dataset)} must be divisible by WORLD_SIZE={world_size}; "
|
| 147 |
+
"otherwise DistributedSampler would duplicate samples and bias the epoch loss."
|
| 148 |
+
)
|
| 149 |
+
local_batch_size = global_batch_size // world_size
|
| 150 |
+
if local_batch_size < 1:
|
| 151 |
+
raise ValueError("The global batch size must be at least WORLD_SIZE.")
|
| 152 |
+
sampler = DistributedSampler(
|
| 153 |
+
dataset,
|
| 154 |
+
num_replicas=world_size,
|
| 155 |
+
rank=rank,
|
| 156 |
+
shuffle=True,
|
| 157 |
+
seed=seed,
|
| 158 |
+
drop_last=False,
|
| 159 |
+
) if world_size > 1 else None
|
| 160 |
+
total_workers = int(config.get("runtime", {}).get("num_workers", 0))
|
| 161 |
+
local_workers = max(0, total_workers // world_size)
|
| 162 |
+
loader = DataLoader(
|
| 163 |
+
dataset,
|
| 164 |
+
batch_size=local_batch_size,
|
| 165 |
+
shuffle=sampler is None,
|
| 166 |
+
sampler=sampler,
|
| 167 |
+
num_workers=local_workers,
|
| 168 |
+
pin_memory=device.type == "cuda",
|
| 169 |
+
persistent_workers=local_workers > 0,
|
| 170 |
+
)
|
| 171 |
+
model = SphericalDYffusion(config["synthetic_data"]["channels"]).to(device)
|
| 172 |
+
if finetune:
|
| 173 |
+
finetune_path = Path(finetune)
|
| 174 |
+
if not finetune_path.exists():
|
| 175 |
+
raise FileNotFoundError(f"Fine-tune checkpoint not found: {finetune_path}")
|
| 176 |
+
state = torch.load(finetune_path, map_location="cpu", weights_only=False)
|
| 177 |
+
state_dict = state.get("model", state) if isinstance(state, dict) else state
|
| 178 |
+
try:
|
| 179 |
+
model.load_state_dict(state_dict)
|
| 180 |
+
except RuntimeError as error:
|
| 181 |
+
raise RuntimeError(
|
| 182 |
+
"Fine-tune checkpoint is incompatible with SphericalDYffusion. "
|
| 183 |
+
"Use a checkpoint produced by this local pipeline or a matching model architecture."
|
| 184 |
+
) from error
|
| 185 |
+
if is_root:
|
| 186 |
+
print(f"fine-tuning from: {finetune_path}")
|
| 187 |
+
elif is_root:
|
| 188 |
+
print("training from scratch")
|
| 189 |
+
if world_size > 1:
|
| 190 |
+
model = DistributedDataParallel(
|
| 191 |
+
model,
|
| 192 |
+
device_ids=[device.index] if device.type == "cuda" else None,
|
| 193 |
+
output_device=device.index if device.type == "cuda" else None,
|
| 194 |
+
)
|
| 195 |
+
if is_root:
|
| 196 |
+
print(
|
| 197 |
+
f"distributed: DDP world_size={world_size}, global_batch_size={global_batch_size}, "
|
| 198 |
+
f"local_batch_size={local_batch_size}"
|
| 199 |
+
)
|
| 200 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=config["training"]["learning_rate"])
|
| 201 |
+
loss_fn = nn.MSELoss()
|
| 202 |
+
best = float("inf")
|
| 203 |
+
if is_root:
|
| 204 |
+
checkpoint.mkdir(parents=True, exist_ok=True)
|
| 205 |
+
for epoch in range(1, config["training"]["epochs"] + 1):
|
| 206 |
+
if sampler is not None:
|
| 207 |
+
sampler.set_epoch(epoch)
|
| 208 |
+
model.train(); total = 0.0; sample_count = 0
|
| 209 |
+
for inputs, targets in loader:
|
| 210 |
+
inputs = inputs.to(device, non_blocking=True)
|
| 211 |
+
targets = targets.to(device, non_blocking=True)
|
| 212 |
+
optimizer.zero_grad(); loss = loss_fn(model(inputs), targets); loss.backward(); optimizer.step()
|
| 213 |
+
total += loss.item() * len(inputs); sample_count += len(inputs)
|
| 214 |
+
loss_stats = torch.tensor([total, sample_count], dtype=torch.float64, device=device)
|
| 215 |
+
if world_size > 1:
|
| 216 |
+
dist.all_reduce(loss_stats, op=dist.ReduceOp.SUM)
|
| 217 |
+
mean_loss = (loss_stats[0] / loss_stats[1]).item()
|
| 218 |
+
if is_root:
|
| 219 |
+
print(f"epoch {epoch}/{config['training']['epochs']} loss={mean_loss:.6f}")
|
| 220 |
+
state_dict = model.module.state_dict() if isinstance(model, DistributedDataParallel) else model.state_dict()
|
| 221 |
+
state = {
|
| 222 |
+
"model": state_dict,
|
| 223 |
+
"channels": config["synthetic_data"]["channels"],
|
| 224 |
+
"loss": mean_loss,
|
| 225 |
+
"world_size": world_size,
|
| 226 |
+
"global_batch_size": global_batch_size,
|
| 227 |
+
}
|
| 228 |
+
if mean_loss < best:
|
| 229 |
+
best = mean_loss
|
| 230 |
+
torch.save(state, checkpoint / "model_bak.pt")
|
| 231 |
+
if is_root:
|
| 232 |
+
torch.save(state, checkpoint / "last.pt")
|
| 233 |
+
print(f"checkpoint: {checkpoint / 'model_bak.pt'}")
|
| 234 |
+
if world_size > 1:
|
| 235 |
+
dist.barrier()
|
| 236 |
+
return checkpoint / "model_bak.pt"
|
| 237 |
+
finally:
|
| 238 |
+
if dist.is_available() and dist.is_initialized():
|
| 239 |
+
dist.destroy_process_group()
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def infer(config: dict) -> Path:
|
| 243 |
+
arrays = np.load(Path(config["synthetic_data"]["output_dir"]) / "virtual_fv3gfs.npz")
|
| 244 |
+
checkpoint_path = Path(config["inference"]["checkpoint"])
|
| 245 |
+
if not checkpoint_path.exists():
|
| 246 |
+
raise FileNotFoundError(f"Inference checkpoint not found: {checkpoint_path}. Run scripts/train.py first.")
|
| 247 |
+
state = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 248 |
+
model = SphericalDYffusion(state["channels"]); model.load_state_dict(state["model"]); model.eval()
|
| 249 |
+
with torch.no_grad(): prediction = model(torch.from_numpy(arrays["inputs"]))
|
| 250 |
+
output = Path(config["inference"]["output_dir"]); output.mkdir(parents=True, exist_ok=True)
|
| 251 |
+
path = output / "prediction.npz"; np.savez_compressed(path, prediction=prediction.numpy(), target=arrays["targets"])
|
| 252 |
+
print(f"inference: {path}"); return path
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def visualize(config: dict) -> Path:
|
| 256 |
+
import matplotlib.pyplot as plt
|
| 257 |
+
|
| 258 |
+
arrays = np.load(Path(config["inference"]["output_dir"]) / "prediction.npz")
|
| 259 |
+
index, channel = config["visualization"]["prediction_index"], config["visualization"]["channel"]
|
| 260 |
+
figure, axes = plt.subplots(1, 2, figsize=(12, 4), constrained_layout=True)
|
| 261 |
+
for axis, image, title in zip(axes, [arrays["target"][index, channel], arrays["prediction"][index, channel]], ["Target", "Prediction"]):
|
| 262 |
+
plot = axis.imshow(image, cmap="viridis"); axis.set_title(title); axis.set_xlabel("longitude"); axis.set_ylabel("latitude"); figure.colorbar(plot, ax=axis)
|
| 263 |
+
output = Path(config["visualization"]["output_dir"]); output.mkdir(parents=True, exist_ok=True)
|
| 264 |
+
path = output / "prediction_comparison.png"; figure.savefig(path, dpi=150); plt.close(figure)
|
| 265 |
+
print(f"visualization: {path}"); return path
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def main():
|
| 269 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 270 |
+
parser.add_argument("--config", default="conf/config.yaml")
|
| 271 |
+
parser.add_argument("action", choices=["generate", "train", "infer", "visualize", "all"], nargs="?", default="all")
|
| 272 |
+
args = parser.parse_args(); config = load_config(args.config)
|
| 273 |
+
if args.action == "generate":
|
| 274 |
+
generate(config)
|
| 275 |
+
elif args.action == "train":
|
| 276 |
+
train(config)
|
| 277 |
+
elif args.action == "infer":
|
| 278 |
+
infer(config)
|
| 279 |
+
elif args.action == "visualize":
|
| 280 |
+
visualize(config)
|
| 281 |
+
elif args.action == "all":
|
| 282 |
+
train(config)
|
| 283 |
+
if int(os.environ.get("RANK", "0")) == 0:
|
| 284 |
+
infer(config)
|
| 285 |
+
visualize(config)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
if __name__ == "__main__":
|
| 289 |
+
main()
|
scripts/result.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Create diagnostic figures and metrics for virtual-data predictions."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import csv
|
| 5 |
+
import json
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import matplotlib
|
| 9 |
+
import numpy as np
|
| 10 |
+
|
| 11 |
+
matplotlib.use("Agg")
|
| 12 |
+
import matplotlib.pyplot as plt
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_config(path: str) -> dict:
|
| 16 |
+
import yaml
|
| 17 |
+
|
| 18 |
+
with open(path, encoding="utf-8") as file:
|
| 19 |
+
return yaml.safe_load(file)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def load_variables(metadata_path: Path, channels: int) -> list[str]:
|
| 23 |
+
if metadata_path.exists():
|
| 24 |
+
variables = json.loads(metadata_path.read_text(encoding="utf-8")).get("variables", [])
|
| 25 |
+
if len(variables) == channels:
|
| 26 |
+
return variables
|
| 27 |
+
return [f"channel_{index}" for index in range(channels)]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def compute_metrics(prediction: np.ndarray, target: np.ndarray, variables: list[str]) -> tuple[list[dict], dict]:
|
| 31 |
+
channel_metrics = []
|
| 32 |
+
total_squared_error = total_absolute_error = total_error = 0.0
|
| 33 |
+
total_count = 0
|
| 34 |
+
sum_target = sum_prediction = sum_target_squared = sum_prediction_squared = sum_product = 0.0
|
| 35 |
+
for index, name in enumerate(variables):
|
| 36 |
+
channel_target = target[:, index].astype(np.float64)
|
| 37 |
+
channel_prediction = prediction[:, index].astype(np.float64)
|
| 38 |
+
error = channel_prediction - channel_target
|
| 39 |
+
count = error.size
|
| 40 |
+
squared_error = float(np.sum(error**2))
|
| 41 |
+
absolute_error = float(np.sum(np.abs(error)))
|
| 42 |
+
error_sum = float(np.sum(error))
|
| 43 |
+
target_sum = float(np.sum(channel_target))
|
| 44 |
+
prediction_sum = float(np.sum(channel_prediction))
|
| 45 |
+
target_squared = float(np.sum(channel_target**2))
|
| 46 |
+
prediction_squared = float(np.sum(channel_prediction**2))
|
| 47 |
+
product_sum = float(np.sum(channel_target * channel_prediction))
|
| 48 |
+
covariance = product_sum - target_sum * prediction_sum / count
|
| 49 |
+
variance_target = target_squared - target_sum**2 / count
|
| 50 |
+
variance_prediction = prediction_squared - prediction_sum**2 / count
|
| 51 |
+
correlation = covariance / max(np.sqrt(variance_target * variance_prediction), 1e-12)
|
| 52 |
+
channel_metrics.append({
|
| 53 |
+
"channel": index,
|
| 54 |
+
"variable": name,
|
| 55 |
+
"rmse": float(np.sqrt(squared_error / count)),
|
| 56 |
+
"mae": absolute_error / count,
|
| 57 |
+
"bias": error_sum / count,
|
| 58 |
+
"correlation": float(correlation),
|
| 59 |
+
})
|
| 60 |
+
total_squared_error += squared_error
|
| 61 |
+
total_absolute_error += absolute_error
|
| 62 |
+
total_error += error_sum
|
| 63 |
+
total_count += count
|
| 64 |
+
sum_target += target_sum
|
| 65 |
+
sum_prediction += prediction_sum
|
| 66 |
+
sum_target_squared += target_squared
|
| 67 |
+
sum_prediction_squared += prediction_squared
|
| 68 |
+
sum_product += product_sum
|
| 69 |
+
covariance = sum_product - sum_target * sum_prediction / total_count
|
| 70 |
+
variance_target = sum_target_squared - sum_target**2 / total_count
|
| 71 |
+
variance_prediction = sum_prediction_squared - sum_prediction**2 / total_count
|
| 72 |
+
overall = {
|
| 73 |
+
"rmse": float(np.sqrt(total_squared_error / total_count)),
|
| 74 |
+
"mae": total_absolute_error / total_count,
|
| 75 |
+
"bias": total_error / total_count,
|
| 76 |
+
"correlation": float(covariance / max(np.sqrt(variance_target * variance_prediction), 1e-12)),
|
| 77 |
+
}
|
| 78 |
+
return channel_metrics, overall
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def plot_diagnostics(
|
| 82 |
+
prediction: np.ndarray,
|
| 83 |
+
target: np.ndarray,
|
| 84 |
+
variables: list[str],
|
| 85 |
+
channel_metrics: list[dict],
|
| 86 |
+
config: dict,
|
| 87 |
+
output_dir: Path,
|
| 88 |
+
) -> Path:
|
| 89 |
+
spec = config["visualization"]
|
| 90 |
+
sample = int(spec["prediction_index"])
|
| 91 |
+
channel = int(spec["channel"])
|
| 92 |
+
if sample >= prediction.shape[0] or channel >= prediction.shape[1]:
|
| 93 |
+
raise IndexError(f"Requested sample={sample}, channel={channel}, but prediction shape is {prediction.shape}")
|
| 94 |
+
|
| 95 |
+
selected_target = target[sample, channel]
|
| 96 |
+
selected_prediction = prediction[sample, channel]
|
| 97 |
+
selected_error = selected_prediction - selected_target
|
| 98 |
+
field_min = min(float(selected_target.min()), float(selected_prediction.min()))
|
| 99 |
+
field_max = max(float(selected_target.max()), float(selected_prediction.max()))
|
| 100 |
+
error_limit = max(float(np.abs(selected_error).max()), 1e-8)
|
| 101 |
+
latitude = np.linspace(-89.5, 89.5, prediction.shape[2])
|
| 102 |
+
longitude = np.linspace(0.0, 360.0, prediction.shape[3], endpoint=False)
|
| 103 |
+
|
| 104 |
+
figure = plt.figure(figsize=(16, 10), constrained_layout=True)
|
| 105 |
+
grid = figure.add_gridspec(2, 3)
|
| 106 |
+
extent = [longitude[0], longitude[-1], latitude[0], latitude[-1]]
|
| 107 |
+
for axis, field, title in zip(
|
| 108 |
+
[figure.add_subplot(grid[0, 0]), figure.add_subplot(grid[0, 1])],
|
| 109 |
+
[selected_target, selected_prediction],
|
| 110 |
+
["Target field", "Predicted field"],
|
| 111 |
+
):
|
| 112 |
+
image = axis.imshow(field, origin="lower", extent=extent, aspect="auto", cmap="viridis", vmin=field_min, vmax=field_max)
|
| 113 |
+
axis.set_title(title)
|
| 114 |
+
axis.set_xlabel("Longitude (degrees)")
|
| 115 |
+
axis.set_ylabel("Latitude (degrees)")
|
| 116 |
+
figure.colorbar(image, ax=axis, shrink=0.82)
|
| 117 |
+
|
| 118 |
+
error_axis = figure.add_subplot(grid[0, 2])
|
| 119 |
+
image = error_axis.imshow(selected_error, origin="lower", extent=extent, aspect="auto", cmap="RdBu_r", vmin=-error_limit, vmax=error_limit)
|
| 120 |
+
error_axis.set_title("Prediction error (prediction - target)")
|
| 121 |
+
error_axis.set_xlabel("Longitude (degrees)")
|
| 122 |
+
error_axis.set_ylabel("Latitude (degrees)")
|
| 123 |
+
figure.colorbar(image, ax=error_axis, shrink=0.82)
|
| 124 |
+
|
| 125 |
+
zonal_axis = figure.add_subplot(grid[1, 0])
|
| 126 |
+
zonal_axis.plot(selected_target.mean(axis=1), latitude, label="Target", linewidth=2)
|
| 127 |
+
zonal_axis.plot(selected_prediction.mean(axis=1), latitude, label="Prediction", linewidth=2)
|
| 128 |
+
zonal_axis.set_title("Zonal-mean profile")
|
| 129 |
+
zonal_axis.set_xlabel("Zonal mean")
|
| 130 |
+
zonal_axis.set_ylabel("Latitude (degrees)")
|
| 131 |
+
zonal_axis.grid(alpha=0.25)
|
| 132 |
+
zonal_axis.legend()
|
| 133 |
+
|
| 134 |
+
scatter_axis = figure.add_subplot(grid[1, 1])
|
| 135 |
+
stride = max(1, selected_target.size // int(spec["scatter_points"]))
|
| 136 |
+
x = selected_target.ravel()[::stride]
|
| 137 |
+
y = selected_prediction.ravel()[::stride]
|
| 138 |
+
scatter_axis.hexbin(x, y, gridsize=45, mincnt=1, cmap="magma")
|
| 139 |
+
diagonal_min = min(float(x.min()), float(y.min()))
|
| 140 |
+
diagonal_max = max(float(x.max()), float(y.max()))
|
| 141 |
+
scatter_axis.plot([diagonal_min, diagonal_max], [diagonal_min, diagonal_max], "--", color="white", linewidth=1.5)
|
| 142 |
+
scatter_axis.set_title("Pointwise agreement")
|
| 143 |
+
scatter_axis.set_xlabel("Target")
|
| 144 |
+
scatter_axis.set_ylabel("Prediction")
|
| 145 |
+
|
| 146 |
+
sample_axis = figure.add_subplot(grid[1, 2])
|
| 147 |
+
sample_rmse = np.array([
|
| 148 |
+
np.sqrt(np.mean((prediction[index].astype(np.float64) - target[index]) ** 2))
|
| 149 |
+
for index in range(prediction.shape[0])
|
| 150 |
+
])
|
| 151 |
+
sample_axis.bar(np.arange(len(sample_rmse)), sample_rmse, color="#2a6f97")
|
| 152 |
+
sample_axis.axhline(sample_rmse.mean(), color="#d1495b", linestyle="--", label=f"Mean {sample_rmse.mean():.3f}")
|
| 153 |
+
sample_axis.set_title("RMSE by sample")
|
| 154 |
+
sample_axis.set_xlabel("Sample index")
|
| 155 |
+
sample_axis.set_ylabel("RMSE")
|
| 156 |
+
sample_axis.legend()
|
| 157 |
+
|
| 158 |
+
metric = channel_metrics[channel]
|
| 159 |
+
figure.suptitle(
|
| 160 |
+
f"Virtual FV3GFS diagnostic | {variables[channel]} | sample {sample}\n"
|
| 161 |
+
f"RMSE={metric['rmse']:.4f} MAE={metric['mae']:.4f} Bias={metric['bias']:.4f} Corr={metric['correlation']:.4f}",
|
| 162 |
+
fontsize=15,
|
| 163 |
+
)
|
| 164 |
+
path = output_dir / "diagnostic_dashboard.png"
|
| 165 |
+
figure.savefig(path, dpi=int(spec["dpi"]))
|
| 166 |
+
plt.close(figure)
|
| 167 |
+
return path
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def plot_channel_metrics(channel_metrics: list[dict], output_dir: Path, dpi: int) -> Path:
|
| 171 |
+
labels = [item["variable"] for item in channel_metrics]
|
| 172 |
+
rmse = [item["rmse"] for item in channel_metrics]
|
| 173 |
+
correlation = [item["correlation"] for item in channel_metrics]
|
| 174 |
+
positions = np.arange(len(labels))
|
| 175 |
+
figure, axes = plt.subplots(1, 2, figsize=(16, 10), constrained_layout=True)
|
| 176 |
+
axes[0].barh(positions, rmse, color="#457b9d")
|
| 177 |
+
axes[0].set_title("RMSE by variable")
|
| 178 |
+
axes[0].set_xlabel("RMSE")
|
| 179 |
+
axes[1].barh(positions, correlation, color="#2a9d8f")
|
| 180 |
+
axes[1].set_title("Correlation by variable")
|
| 181 |
+
axes[1].set_xlabel("Pearson correlation")
|
| 182 |
+
axes[1].set_xlim(-1, 1)
|
| 183 |
+
for axis in axes:
|
| 184 |
+
axis.set_yticks(positions, labels, fontsize=8)
|
| 185 |
+
axis.invert_yaxis()
|
| 186 |
+
axis.grid(axis="x", alpha=0.25)
|
| 187 |
+
figure.suptitle("Virtual-data forecast skill by variable", fontsize=15)
|
| 188 |
+
path = output_dir / "variable_metrics.png"
|
| 189 |
+
figure.savefig(path, dpi=dpi)
|
| 190 |
+
plt.close(figure)
|
| 191 |
+
return path
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def create_report(config: dict) -> list[Path]:
|
| 195 |
+
prediction_path = Path(config["inference"]["output_dir"]) / "prediction.npz"
|
| 196 |
+
metadata_path = Path(config["synthetic_data"]["output_dir"]) / "metadata.json"
|
| 197 |
+
if not prediction_path.exists():
|
| 198 |
+
raise FileNotFoundError(f"Prediction file not found: {prediction_path}. Run scripts/inference.py first.")
|
| 199 |
+
with np.load(prediction_path) as arrays:
|
| 200 |
+
prediction = arrays["prediction"]
|
| 201 |
+
target = arrays["target"]
|
| 202 |
+
if prediction.shape != target.shape or prediction.ndim != 4:
|
| 203 |
+
raise ValueError(f"Expected matching [sample, channel, latitude, longitude] arrays, got {prediction.shape} and {target.shape}")
|
| 204 |
+
|
| 205 |
+
variables = load_variables(metadata_path, prediction.shape[1])
|
| 206 |
+
channel_metrics, overall = compute_metrics(prediction, target, variables)
|
| 207 |
+
output_dir = Path(config["visualization"]["output_dir"])
|
| 208 |
+
metrics_dir = Path(config["paths"]["metrics"])
|
| 209 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 210 |
+
metrics_dir.mkdir(parents=True, exist_ok=True)
|
| 211 |
+
|
| 212 |
+
summary_path = metrics_dir / "result_summary.json"
|
| 213 |
+
summary_path.write_text(json.dumps({
|
| 214 |
+
"evaluation_scope": "virtual_data_only",
|
| 215 |
+
"prediction_shape": list(prediction.shape),
|
| 216 |
+
"overall": overall,
|
| 217 |
+
"channels": channel_metrics,
|
| 218 |
+
"note": "These metrics evaluate the synthetic task and are not paper reproduction metrics.",
|
| 219 |
+
}, indent=2) + "\n", encoding="utf-8")
|
| 220 |
+
csv_path = metrics_dir / "channel_metrics.csv"
|
| 221 |
+
with csv_path.open("w", newline="", encoding="utf-8") as file:
|
| 222 |
+
writer = csv.DictWriter(file, fieldnames=channel_metrics[0].keys())
|
| 223 |
+
writer.writeheader()
|
| 224 |
+
writer.writerows(channel_metrics)
|
| 225 |
+
|
| 226 |
+
dashboard = plot_diagnostics(prediction, target, variables, channel_metrics, config, output_dir)
|
| 227 |
+
metric_plot = plot_channel_metrics(channel_metrics, output_dir, int(config["visualization"]["dpi"]))
|
| 228 |
+
return [dashboard, metric_plot, summary_path, csv_path]
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def main() -> None:
|
| 232 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 233 |
+
parser.add_argument("--config", default="conf/config.yaml")
|
| 234 |
+
args = parser.parse_args()
|
| 235 |
+
for path in create_report(load_config(args.config)):
|
| 236 |
+
print(f"result: {path}")
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
if __name__ == "__main__":
|
| 240 |
+
main()
|
scripts/show_features.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Print the supported and unavailable project features."""
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
COMPLETED_FEATURES = [
|
| 5 |
+
"Compact global-grid forecaster construction and checkpoint loading",
|
| 6 |
+
"Single-GPU and single-node DDP training",
|
| 7 |
+
"Offline local checkpoint inference",
|
| 8 |
+
"Synthetic smoke-data generation",
|
| 9 |
+
"FV3GFS NetCDF schema validation",
|
| 10 |
+
"Separate data, weight, result, and work directories",
|
| 11 |
+
"Configurable model name, data paths, checkpoint paths, and output paths",
|
| 12 |
+
"AMD DCU-compatible model loading and smoke validation",
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
PENDING_FEATURES = [
|
| 16 |
+
"Full SFNO and DYffusion architecture integration",
|
| 17 |
+
"Official Hugging Face checkpoint compatibility",
|
| 18 |
+
"Full FV3GFS validation rollout, pending requester-pays data",
|
| 19 |
+
"Paper-scale 100-year training and 10-year evaluation",
|
| 20 |
+
"Paper-matched 25-member climate metrics",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main() -> None:
|
| 25 |
+
print("Spherical-DYffusion-SFNO project features")
|
| 26 |
+
print("Completed:")
|
| 27 |
+
for index, feature in enumerate(COMPLETED_FEATURES, start=1):
|
| 28 |
+
print(f" {index}. {feature}")
|
| 29 |
+
print("Pending external inputs or resources:")
|
| 30 |
+
for index, feature in enumerate(PENDING_FEATURES, start=1):
|
| 31 |
+
print(f" {index}. {feature}")
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
if __name__ == "__main__":
|
| 35 |
+
main()
|
scripts/train.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Train the local model from scratch on virtual FV3GFS-contract data."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from local_pipeline import load_config, train
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main() -> None:
|
| 8 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 9 |
+
parser.add_argument("--config", default="conf/config.yaml")
|
| 10 |
+
parser.add_argument(
|
| 11 |
+
"--finetune",
|
| 12 |
+
help="Optional checkpoint path. Without this argument, training starts from scratch.",
|
| 13 |
+
)
|
| 14 |
+
args = parser.parse_args()
|
| 15 |
+
train(load_config(args.config), finetune=args.finetune)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
if __name__ == "__main__":
|
| 19 |
+
main()
|
scripts/validate_data.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Validate NetCDF variables, dimensions, and statistics for FV3GFS inputs."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import xarray as xr
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
REQUIRED_VARIABLES = {
|
| 10 |
+
"PRESsfc",
|
| 11 |
+
"surface_temperature",
|
| 12 |
+
"DSWRFtoa",
|
| 13 |
+
"HGTsfc",
|
| 14 |
+
"ocean_fraction",
|
| 15 |
+
*{f"air_temperature_{i}" for i in range(8)},
|
| 16 |
+
*{f"specific_total_water_{i}" for i in range(8)},
|
| 17 |
+
*{f"eastward_wind_{i}" for i in range(8)},
|
| 18 |
+
*{f"northward_wind_{i}" for i in range(8)},
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def validate_data(data_path: str, latitude: int, longitude: int) -> int:
|
| 23 |
+
files = sorted(Path(data_path).rglob("*.nc"))
|
| 24 |
+
if not files:
|
| 25 |
+
print(f"ERROR: no NetCDF files found under {data_path}")
|
| 26 |
+
return 1
|
| 27 |
+
failed = False
|
| 28 |
+
for path in files:
|
| 29 |
+
with xr.open_dataset(path) as dataset:
|
| 30 |
+
variables = set(dataset.data_vars)
|
| 31 |
+
missing = sorted(REQUIRED_VARIABLES - variables)
|
| 32 |
+
dimensions = {name: int(size) for name, size in dataset.sizes.items()}
|
| 33 |
+
print(f"{path}: dimensions={dimensions}, variables={len(variables)}")
|
| 34 |
+
if missing:
|
| 35 |
+
print(f"ERROR: missing required variables: {', '.join(missing)}")
|
| 36 |
+
failed = True
|
| 37 |
+
if latitude not in dimensions.values() or longitude not in dimensions.values():
|
| 38 |
+
print(f"ERROR: expected spatial dimensions containing {latitude} and {longitude}")
|
| 39 |
+
failed = True
|
| 40 |
+
time_sizes = [dimensions[name] for name in dimensions if "time" in name.lower()]
|
| 41 |
+
if not time_sizes or max(time_sizes) < 7:
|
| 42 |
+
print("ERROR: expected a time dimension with at least 7 frames")
|
| 43 |
+
failed = True
|
| 44 |
+
return int(failed)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def main() -> None:
|
| 48 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 49 |
+
parser.add_argument("--data-dir", required=True)
|
| 50 |
+
parser.add_argument("--latitude", type=int, default=180)
|
| 51 |
+
parser.add_argument("--longitude", type=int, default=360)
|
| 52 |
+
args = parser.parse_args()
|
| 53 |
+
raise SystemExit(validate_data(args.data_dir, args.latitude, args.longitude))
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
if __name__ == "__main__":
|
| 57 |
+
main()
|
weight/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Reserved for model weights.
|