yzt15806542928 commited on
Commit
6f3c6ef
·
verified ·
1 Parent(s): beb3a68

Upload folder using huggingface_hub

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