yzt15806542928 commited on
Commit
04fe5fd
·
verified ·
1 Parent(s): 4cd896c

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ datasets:
3
+ - OneScience/ERA5
4
+ frameworks:
5
+ - PyTorch
6
+ language:
7
+ - en
8
+ - zh
9
+ license: mit
10
+ tags:
11
+ - OneScience
12
+ - Earth Science
13
+ - Weather Forecast
14
+ - Global Forecast
15
+ - ERA5
16
+ - OneForecast
17
+ tasks: []
18
+ ---
19
+
20
+ <p align="center">
21
+ <strong>
22
+ <span style="font-size: 30px;">OneForecast</span>
23
+ </strong>
24
+ </p>
25
+
26
+ # Model Introduction
27
+
28
+ OneForecast was developed by the team of Prof. Xiaomeng Huang in the Department of Earth System Science at Tsinghua University, in collaboration with multiple institutions. The paper has been accepted by ICML 2025.
29
+
30
+ Paper: OneForecast: A Universal Framework for Global and Regional Weather Forecasting
31
+
32
+ https://arxiv.org/abs/2502.00338
33
+
34
+ # Model Description
35
+
36
+ OneForecast is a universal framework for nested global-regional weather forecasting based on graph neural networks (GNNs). Its core goal is to address the challenges of existing AI weather models in balancing low-resolution global forecasts with high-resolution regional forecasts, as well as issues such as over-smoothing in extreme event forecasting.
37
+
38
+ # Use Cases
39
+
40
+ | Scenario | Description |
41
+ | :---: | :--- |
42
+ | Global Weather Forecast Training | Train the single-step OneForecast model using ERA5 HDF5 data. |
43
+ | Local Quick Validation | Use synthetic data to verify the data protocol, model construction, training, inference, and result visualization. |
44
+ | Multi-GPU Training | Data-parallel training on multiple GPUs/DCUs via PyTorch DDP and `torchrun`. |
45
+ | ModelScope / OneCode Execution | Download as a standalone model package, install the OneScience dependencies, and run. |
46
+
47
+ # Usage Guide
48
+
49
+ ## 1. OneCode Usage
50
+
51
+ Experience intelligent one-click AI4S programming through the OneCode online environment:
52
+
53
+ [Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
54
+
55
+ ## 2. Manual Installation and Usage
56
+
57
+ **Hardware Requirements**
58
+
59
+ - A GPU or DCU is recommended.
60
+ - CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
61
+ - DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
62
+
63
+ ### Download the Model Package
64
+
65
+ ```bash
66
+ hf download OneScience-Group/OneForecast --local-dir ./OneForecast
67
+ cd OneForecast
68
+ ```
69
+
70
+ ### Install the Runtime Environment
71
+
72
+ **DCU Environment**
73
+
74
+ ```bash
75
+ # Please activate DTK and CONDA first
76
+ conda create -n onescience311 python=3.11 -y
77
+ conda activate onescience311
78
+ # uv installation is supported
79
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
80
+ ```
81
+
82
+ **GPU Environment**
83
+
84
+ ```bash
85
+ # Please activate CONDA first
86
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
87
+ conda activate onescience311
88
+ # uv installation is supported
89
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
90
+ ```
91
+
92
+ ### Training Data Introduction
93
+
94
+ The OneScience community provides ERA5 data for training (due to file size limits, the current repository contains a slice of the full dataset). Users can download it with the command below and confirm that the data path in `conf/config.yaml` is set correctly:
95
+
96
+ ```bash
97
+ hf download --repo-type dataset OneScience-Group/ERA5 --local-dir ./data
98
+ ```
99
+
100
+ ### Generate Synthetic Data
101
+
102
+ Synthetic data is only used to verify the data protocol and program flow; it does not represent scientific forecast quality:
103
+
104
+ ```bash
105
+ python scripts/fake_data.py
106
+ ```
107
+
108
+ ### Training
109
+
110
+ Single GPU:
111
+
112
+ ```bash
113
+ python scripts/train.py
114
+ ```
115
+
116
+ Training starts from random initialization by default and saves the model to `data/checkpoint/model_bak.tar`.
117
+
118
+ Multi-GPU:
119
+
120
+ ```bash
121
+ torchrun --nproc-per-node=4 scripts/train.py
122
+ ```
123
+
124
+ ### Fine-tuning
125
+
126
+ Fine-tuning starts from the training checkpoint `data/checkpoint/model_bak.tar` by default and saves the result to `data/checkpoint/model_finetuned.tar`:
127
+
128
+ ```bash
129
+ python scripts/finetune.py
130
+ ```
131
+
132
+ ### Training Weights
133
+
134
+ This repository provides weights trained on ERA5 reanalysis data in the `weights/` folder. The weight files will be uploaded soon and are expected to be available in the near future.
135
+
136
+ ### Inference
137
+
138
+ Inference loads the training checkpoint `data/checkpoint/model_bak.tar` by default, uses the test-year data, and writes predictions to `outputs/predictions/`:
139
+
140
+ ```bash
141
+ python scripts/inference.py --config conf/config.yaml
142
+ ```
143
+
144
+ ### Result Visualization
145
+
146
+ ```bash
147
+ python scripts/result.py
148
+ ```
149
+
150
+ # OneScience Official Information
151
+
152
+ | Platform | OneScience Main Repository | Skills Repository |
153
+ | --- | --- | --- |
154
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
155
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
156
+
157
+ # Citation & License
158
+
159
+ - This repository is a reproduction of the original OneForecast paper.
conf/config.yaml ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ name: OneForecast
3
+ input_channels: 69
4
+ output_channels: 69
5
+ # Native ERA5 721x1440 is sampled every sixth point to 121x240, then cropped to 120x240.
6
+ grid_height: 120
7
+ grid_width: 240
8
+ dt_hours: 6
9
+ mesh_level: 5
10
+ processor_layers: 16
11
+ hidden_layers: 1
12
+ hidden_dim: 512
13
+ num_heads_edge: 4
14
+ num_heads_node: 4
15
+ weight_init: scratch
16
+ official_checkpoint_path: /root/private_data/workspaces/yangzt01/OneForecast/best_ckpt.tar
17
+
18
+ runtime:
19
+ seed: 42
20
+ device: dcu
21
+ output_dir: ./outputs
22
+ distributed_backend: nccl
23
+
24
+ training:
25
+ start_epoch: 0
26
+ max_epoch: 5
27
+ learning_rate: 0.00025
28
+ weight_decay: 0.0
29
+ checkpoint_dir: ./data/checkpoint
30
+ model_name: model_bak
31
+ save_every_epoch: 1
32
+ max_batches: 1
33
+
34
+ finetuning:
35
+ steps: 2
36
+ max_epoch: 2
37
+ learning_rate: 0.00025
38
+ max_batches: 1
39
+ model_source: trained
40
+ trained_model_path: ./data/checkpoint/model_bak.tar
41
+ official_checkpoint_path: /root/private_data/workspaces/yangzt01/OneForecast/best_ckpt.tar
42
+ output_path: ./data/checkpoint/model_finetuned.tar
43
+
44
+ datapipe:
45
+ name: ERA5
46
+ task: global_weather_forecasting
47
+ dataset_dir: ./data
48
+ train_years: [2000]
49
+ valid_years: [2001]
50
+ test_years: [2002]
51
+ input_steps: 1
52
+ output_steps: 1
53
+ normalize: true
54
+ batch_size: 1
55
+ num_workers: 0
56
+ # Runtime distribution is enabled automatically when launched with torchrun.
57
+ distributed: false
58
+
59
+ # This exact order matches the official OneForecast global dataset.
60
+ variables:
61
+ - Z50
62
+ - Z100
63
+ - Z150
64
+ - Z200
65
+ - Z250
66
+ - Z300
67
+ - Z400
68
+ - Z500
69
+ - Z600
70
+ - Z700
71
+ - Z850
72
+ - Z925
73
+ - Z1000
74
+ - Q50
75
+ - Q100
76
+ - Q150
77
+ - Q200
78
+ - Q250
79
+ - Q300
80
+ - Q400
81
+ - Q500
82
+ - Q600
83
+ - Q700
84
+ - Q850
85
+ - Q925
86
+ - Q1000
87
+ - T50
88
+ - T100
89
+ - T150
90
+ - T200
91
+ - T250
92
+ - T300
93
+ - T400
94
+ - T500
95
+ - T600
96
+ - T700
97
+ - T850
98
+ - T925
99
+ - T1000
100
+ - U50
101
+ - U100
102
+ - U150
103
+ - U200
104
+ - U250
105
+ - U300
106
+ - U400
107
+ - U500
108
+ - U600
109
+ - U700
110
+ - U850
111
+ - U925
112
+ - U1000
113
+ - V50
114
+ - V100
115
+ - V150
116
+ - V200
117
+ - V250
118
+ - V300
119
+ - V400
120
+ - V500
121
+ - V600
122
+ - V700
123
+ - V850
124
+ - V925
125
+ - V1000
126
+ - U10M
127
+ - V10M
128
+ - T2M
129
+ - MSLP
130
+
131
+ inference:
132
+ model_source: trained
133
+ trained_model_path: ./data/checkpoint/model_bak.tar
134
+ official_checkpoint_path: /root/private_data/workspaces/yangzt01/OneForecast/best_ckpt.tar
135
+ output_dir: ./outputs/predictions
136
+ max_batches: 1
137
+
138
+ visualization:
139
+ input_dir: ./outputs/predictions
140
+ output_dir: ./outputs/visualizations
141
+ channels: [0, 18, 36, 54, 67, 68]
config.json ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "OneForecast",
3
+ "model_type": "oneforecast",
4
+ "architectures": [
5
+ "OneForecast"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "atmosphere",
9
+ "task": "global-regional-weather-forecasting",
10
+ "implementation": {
11
+ "entry_point": "model/oneforecast.py",
12
+ "scope": "portable encoder-processor-decoder mesh graph neural network with PyTorch index-tensor graph kernels usable on CPU, CUDA, and DCU; official checkpoint compatible"
13
+ },
14
+ "architecture": {
15
+ "family": "encoder-processor-decoder graph neural network on a refined icosahedral mesh",
16
+ "input_grid_shape": [
17
+ 120,
18
+ 240
19
+ ],
20
+ "grid_sampling": "ERA5 0.25-degree 721x1440 sampled every sixth point, then cropped to 120x240",
21
+ "input_channels": 69,
22
+ "output_channels": 69,
23
+ "mesh_level": 5,
24
+ "mesh_local_refinement_regions": [
25
+ {
26
+ "description": "South and East Asia",
27
+ "lat_range": [
28
+ 0.0,
29
+ 30.0
30
+ ],
31
+ "lon_range": [
32
+ 105.0,
33
+ 160.0
34
+ ]
35
+ },
36
+ {
37
+ "description": "Central North America",
38
+ "lat_range": [
39
+ 10.0,
40
+ 30.0
41
+ ],
42
+ "lon_range": [
43
+ -95.0,
44
+ -35.0
45
+ ]
46
+ }
47
+ ],
48
+ "hidden_dim": 512,
49
+ "processor_layers": 16,
50
+ "hidden_layers": 1,
51
+ "num_heads_edge": 4,
52
+ "num_heads_node": 4,
53
+ "aggregation": "sum",
54
+ "activation": "SiLU",
55
+ "normalization": "LayerNorm"
56
+ },
57
+ "data": {
58
+ "dataset": "ERA5",
59
+ "grid_spatial_resolution_degrees": 1.5,
60
+ "time_step_hours": 6,
61
+ "input_steps": 1,
62
+ "output_steps": 1,
63
+ "surface_variables": [
64
+ "10m_u_component_of_wind",
65
+ "10m_v_component_of_wind",
66
+ "2m_temperature",
67
+ "mean_sea_level_pressure"
68
+ ],
69
+ "atmospheric_variables": [
70
+ "geopotential",
71
+ "specific_humidity",
72
+ "temperature",
73
+ "u_component_of_wind",
74
+ "v_component_of_wind"
75
+ ],
76
+ "pressure_levels_hpa": [
77
+ 50,
78
+ 100,
79
+ 150,
80
+ 200,
81
+ 250,
82
+ 300,
83
+ 400,
84
+ 500,
85
+ 600,
86
+ 700,
87
+ 850,
88
+ 925,
89
+ 1000
90
+ ]
91
+ },
92
+ "configuration_sources": [
93
+ "conf/config.yaml",
94
+ "model/oneforecast.py",
95
+ "model/era5_adapter.py"
96
+ ]
97
+ }
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"PyTorch","task":"global_weather_forecasting","hardware":"DCU"}
model/era5_adapter.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OneScience ERA5 adapter for the official OneForecast 69-channel contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import tempfile
7
+ from typing import Any, Iterable
8
+
9
+ import numpy as np
10
+
11
+ SOURCE_GRID = (721, 1440)
12
+ ONEFORECAST_FILE_GRID = (121, 240)
13
+ SPATIAL_STRIDE = 6
14
+
15
+ OFFICIAL_VARIABLES = tuple(
16
+ [f"Z{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
17
+ + [f"Q{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
18
+ + [f"T{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
19
+ + [f"U{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
20
+ + [f"V{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
21
+ + ["U10M", "V10M", "T2M", "MSLP"]
22
+ )
23
+
24
+ VARIABLE_ALIASES = {
25
+ **{f"Z{x}": f"geopotential_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
26
+ **{f"Q{x}": f"specific_humidity_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
27
+ **{f"T{x}": f"temperature_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
28
+ **{f"U{x}": f"u_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
29
+ **{f"V{x}": f"v_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
30
+ "U10M": "10m_u_component_of_wind",
31
+ "V10M": "10m_v_component_of_wind",
32
+ "T2M": "2m_temperature",
33
+ "MSLP": "mean_sea_level_pressure",
34
+ }
35
+
36
+
37
+ def _decode_variables(values: Iterable[Any]) -> list[str]:
38
+ return [value.decode() if isinstance(value, bytes) else str(value) for value in values]
39
+
40
+
41
+ class OneForecastERA5Adapter:
42
+ """Validate files and construct OneScience's ERA5 DataLoader."""
43
+
44
+ def __init__(self, dataset_dir: str | Path, years: Iterable[int], batch_size: int = 1,
45
+ input_steps: int = 1, output_steps: int = 1, normalize: bool = True,
46
+ num_workers: int = 0, distributed: bool = False) -> None:
47
+ self.dataset_dir = Path(dataset_dir).expanduser().resolve()
48
+ self.years = [int(year) for year in years]
49
+ self.batch_size = batch_size
50
+ self.input_steps = input_steps
51
+ self.output_steps = output_steps
52
+ self.normalize = normalize
53
+ self.num_workers = num_workers
54
+ self.distributed = distributed
55
+ self.source_variables: list[str] = []
56
+ self.channel_indices: list[int] = []
57
+ self.global_means: np.ndarray | None = None
58
+ self.global_stds: np.ndarray | None = None
59
+ self.time_step_hours: int | None = None
60
+ self.source_grid: tuple[int, int] | None = None
61
+ self._external_stats: tuple[Path, Path] | None = None
62
+ self._layout_dir: tempfile.TemporaryDirectory[str] | None = None
63
+ self._validate_files()
64
+
65
+ def _year_path(self, year: int) -> Path:
66
+ for path in (self.dataset_dir / "data" / f"{year}.h5", self.dataset_dir / f"{year}.h5"):
67
+ if path.is_file():
68
+ return path
69
+ raise FileNotFoundError(f"ERA5 file for year {year} was not found below {self.dataset_dir}")
70
+
71
+ def _validate_files(self) -> None:
72
+ try:
73
+ import h5py
74
+ except ImportError as exc:
75
+ raise RuntimeError("h5py is required to validate ERA5 HDF5 files") from exc
76
+ if not self.years:
77
+ raise ValueError("At least one ERA5 year is required")
78
+ reference_variables: list[str] | None = None
79
+ reference_indices: list[int] | None = None
80
+ for year in self.years:
81
+ path = self._year_path(year)
82
+ with h5py.File(path, "r") as handle:
83
+ if "fields" not in handle:
84
+ raise ValueError(f"{path} does not contain a fields dataset")
85
+ fields = handle["fields"]
86
+ if len(fields.shape) != 4:
87
+ raise ValueError(f"{path}: fields must have shape [T, C, H, W], got {fields.shape}")
88
+ variables = _decode_variables(fields.attrs.get("variables", []))
89
+ source_variables = [
90
+ name if name in variables else VARIABLE_ALIASES[name]
91
+ for name in OFFICIAL_VARIABLES
92
+ if name in variables or VARIABLE_ALIASES[name] in variables
93
+ ]
94
+ missing = [
95
+ name for name in OFFICIAL_VARIABLES
96
+ if name not in variables and VARIABLE_ALIASES[name] not in variables
97
+ ]
98
+ if missing:
99
+ raise ValueError(f"{path}: missing official variables: {missing}")
100
+ indices = [variables.index(name) for name in source_variables]
101
+ if reference_variables is not None and variables != reference_variables:
102
+ raise ValueError(f"{path}: complete variable metadata differs between yearly files")
103
+ if reference_indices is not None and indices != reference_indices:
104
+ raise ValueError(f"{path}: official channel indices differ between yearly files")
105
+ reference_variables, reference_indices = variables, indices
106
+ self.source_variables = source_variables
107
+ self.channel_indices = indices
108
+ if fields.shape[1] != len(variables):
109
+ raise ValueError(f"{path}: variables metadata does not match channel dimension")
110
+ if fields.shape[1] != 69 or tuple(fields.shape[2:]) not in (SOURCE_GRID, ONEFORECAST_FILE_GRID):
111
+ raise ValueError(
112
+ f"{path}: expected fields [T, 69, 721, 1440] or [T, 69, 121, 240], got {fields.shape}"
113
+ )
114
+ grid = tuple(fields.shape[2:])
115
+ if self.source_grid is not None and grid != self.source_grid:
116
+ raise ValueError(f"{path}: spatial grid differs between yearly files")
117
+ self.source_grid = grid
118
+ if fields.shape[0] < self.input_steps + self.output_steps:
119
+ raise ValueError(f"{path}: not enough time steps for configured window")
120
+ if "time_step" not in fields.attrs:
121
+ raise ValueError(f"{path}: fields.attrs['time_step'] is required by ERA5Datapipe")
122
+ time_step = int(fields.attrs["time_step"])
123
+ if time_step != 6 or (self.time_step_hours is not None and time_step != self.time_step_hours):
124
+ raise ValueError(f"{path}: expected a consistent 6-hour time_step, got {time_step}")
125
+ self.time_step_hours = time_step
126
+ if "global_means" in handle and "global_stds" in handle:
127
+ means = np.asarray(handle["global_means"])
128
+ stds = np.asarray(handle["global_stds"])
129
+ else:
130
+ candidates = (
131
+ (self.dataset_dir / "stats" / "global_means.npy",
132
+ self.dataset_dir / "stats" / "global_stds.npy"),
133
+ (self.dataset_dir / "mean.npy", self.dataset_dir / "std.npy"),
134
+ (self.dataset_dir.parent / "mean.npy", self.dataset_dir.parent / "std.npy"),
135
+ )
136
+ stats_paths = next(((mean, std) for mean, std in candidates
137
+ if mean.is_file() and std.is_file()), None)
138
+ if stats_paths is None:
139
+ raise ValueError(f"{path}: embedded or external ERA5 statistics are required")
140
+ self._external_stats = stats_paths
141
+ means, stds = (np.load(item) for item in stats_paths)
142
+ expected_shape = (1, len(variables), 1, 1)
143
+ if means.shape != expected_shape or stds.shape != expected_shape:
144
+ raise ValueError(f"{path}: statistics must have shape {expected_shape}")
145
+ if not np.isfinite(means).all() or not np.isfinite(stds).all() or not (stds > 0).all():
146
+ raise ValueError(f"{path}: statistics must be finite and standard deviations positive")
147
+ if self.global_means is not None and not np.array_equal(means, self.global_means):
148
+ raise ValueError(f"{path}: global_means differ between yearly files")
149
+ if self.global_stds is not None and not np.array_equal(stds, self.global_stds):
150
+ raise ValueError(f"{path}: global_stds differ between yearly files")
151
+ self.global_means, self.global_stds = means, stds
152
+
153
+ def _onescience_dataset_dir(self) -> Path:
154
+ if self._layout_dir is not None:
155
+ return Path(self._layout_dir.name)
156
+ self._layout_dir = tempfile.TemporaryDirectory(prefix="oneforecast_era5_")
157
+ root = Path(self._layout_dir.name)
158
+ data_dir = root / "data"
159
+ data_dir.mkdir()
160
+ for year in self.years:
161
+ source_path = self._year_path(year)
162
+ target_path = data_dir / f"{year}.h5"
163
+ if self.source_grid == SOURCE_GRID:
164
+ import h5py
165
+
166
+ with h5py.File(source_path, "r") as source_handle:
167
+ source_fields = source_handle["fields"]
168
+ layout = h5py.VirtualLayout(
169
+ shape=(source_fields.shape[0], source_fields.shape[1], *ONEFORECAST_FILE_GRID),
170
+ dtype=source_fields.dtype,
171
+ )
172
+ virtual_source = h5py.VirtualSource(str(source_path), "fields", shape=source_fields.shape)
173
+ layout[:] = virtual_source[:, :, ::SPATIAL_STRIDE, ::SPATIAL_STRIDE]
174
+ with h5py.File(target_path, "w", libver="latest") as target_handle:
175
+ fields = target_handle.create_virtual_dataset("fields", layout)
176
+ for name, value in source_fields.attrs.items():
177
+ fields.attrs[name] = value
178
+ else:
179
+ target_path.symlink_to(source_path)
180
+ if self._external_stats is not None:
181
+ stats_dir = root / "stats"
182
+ stats_dir.mkdir()
183
+ (stats_dir / "global_means.npy").symlink_to(self._external_stats[0])
184
+ (stats_dir / "global_stds.npy").symlink_to(self._external_stats[1])
185
+
186
+ return root
187
+
188
+ def get_dataloader(self, mode: str):
189
+ """Delegate loading to OneScience, then align native ERA5 to OneForecast's grid."""
190
+ try:
191
+ from onescience.datapipes.climate.era5 import ERA5Datapipe
192
+ except ImportError as exc:
193
+ raise RuntimeError("OneScience ERA5Datapipe is required for data loading") from exc
194
+ datapipe = ERA5Datapipe(
195
+ dataset_dir=str(self._onescience_dataset_dir()), used_years=self.years,
196
+ used_variables=self.source_variables, distributed=self.distributed,
197
+ input_steps=self.input_steps, output_steps=self.output_steps,
198
+ normalize=self.normalize, batch_size=self.batch_size, num_workers=self.num_workers,
199
+ )
200
+ loader, sampler = datapipe.get_dataloader(mode=mode)
201
+ return _SpatiallyAdaptedLoader(loader, self.source_grid), sampler
202
+
203
+ def inspect(self) -> dict[str, Any]:
204
+ try:
205
+ import h5py
206
+ except ImportError as exc:
207
+ raise RuntimeError("h5py is required to inspect ERA5 HDF5 files") from exc
208
+ path = self._year_path(self.years[0])
209
+ with h5py.File(path, "r") as handle:
210
+ fields = handle["fields"]
211
+ variables = _decode_variables(fields.attrs["variables"])
212
+ indices = [variables.index(name) for name in self.source_variables]
213
+ return {"path": str(path), "fields_shape": list(fields.shape),
214
+ "source_grid": list(fields.shape[2:]),
215
+ "oneforecast_file_grid": list(ONEFORECAST_FILE_GRID),
216
+ "oneforecast_model_grid": [120, 240],
217
+ "spatial_transform": "identity" if tuple(fields.shape[2:]) == ONEFORECAST_FILE_GRID else "stride_6",
218
+ "time_step_hours": int(fields.attrs["time_step"]),
219
+ "variable_count": len(variables), "official_channel_indices": indices,
220
+ "source_variables": self.source_variables,
221
+ "statistics_shape": list(self.global_means.shape),
222
+ "statistics_shared_across_years": True,
223
+ "official_variables_match": len(indices) == len(OFFICIAL_VARIABLES)}
224
+
225
+ def selected_statistics(self) -> tuple[np.ndarray, np.ndarray]:
226
+ """Return normalization statistics in the model's 69-channel order."""
227
+ if self.global_means is None or self.global_stds is None:
228
+ raise RuntimeError("ERA5 statistics have not been validated")
229
+ return self.global_means[:, self.channel_indices], self.global_stds[:, self.channel_indices]
230
+
231
+
232
+ def _adapt_spatial(value: Any, source_grid: tuple[int, int] | None) -> Any:
233
+ if not hasattr(value, "shape") or len(value.shape) < 2:
234
+ return value
235
+ if tuple(value.shape[-2:]) == ONEFORECAST_FILE_GRID:
236
+ return value
237
+ if tuple(value.shape[-2:]) != SOURCE_GRID or source_grid != SOURCE_GRID:
238
+ return value
239
+ return value[..., ::SPATIAL_STRIDE, ::SPATIAL_STRIDE]
240
+
241
+
242
+ class _SpatiallyAdaptedLoader:
243
+ """Preserve the DataLoader interface while adapting fields after ERA5Datapipe."""
244
+
245
+ def __init__(self, loader: Any, source_grid: tuple[int, int] | None) -> None:
246
+ self.loader = loader
247
+ self.source_grid = source_grid
248
+
249
+ def __len__(self) -> int:
250
+ return len(self.loader)
251
+
252
+ def __iter__(self):
253
+ for batch in self.loader:
254
+ yield tuple(_adapt_spatial(value, self.source_grid) for value in batch)
model/oneforecast.py ADDED
@@ -0,0 +1,553 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Portable OneForecast model and official checkpoint compatibility helpers.
2
+
3
+ The parameter hierarchy mirrors the official model. Graph operations use
4
+ PyTorch index tensors instead of CUDA-only CuGraph kernels, making the model
5
+ usable on CPU, CUDA, and DCU PyTorch builds.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ import sys
13
+ import types
14
+ from typing import Any, NamedTuple
15
+
16
+ import numpy as np
17
+ import torch
18
+ from torch import Tensor, nn
19
+ from torch.nn import functional as F
20
+
21
+
22
+ class GraphData:
23
+ """Minimal homogeneous or bipartite graph used by the portable kernels."""
24
+
25
+ def __init__(self, src: Tensor, dst: Tensor, num_src: int, num_dst: int) -> None:
26
+ self.src = src.to(torch.long)
27
+ self.dst = dst.to(torch.long)
28
+ self.num_src = num_src
29
+ self.num_dst = num_dst
30
+
31
+ def to(self, device: torch.device | str) -> "GraphData":
32
+ self.src = self.src.to(device)
33
+ self.dst = self.dst.to(device)
34
+ return self
35
+
36
+
37
+ def _aggregate(values: Tensor, dst: Tensor, num_dst: int, reduction: str) -> Tensor:
38
+ output = values.new_zeros((num_dst,) + values.shape[1:])
39
+ index = dst.view((-1,) + (1,) * (values.ndim - 1)).expand_as(values)
40
+ output.scatter_add_(0, index, values)
41
+ if reduction == "mean":
42
+ counts = values.new_zeros(num_dst)
43
+ counts.scatter_add_(0, dst, torch.ones_like(dst, dtype=values.dtype))
44
+ output = output / counts.clamp_min(1).view((-1,) + (1,) * (values.ndim - 1))
45
+ elif reduction != "sum":
46
+ raise ValueError(f"Unsupported aggregation: {reduction}")
47
+ return output
48
+
49
+
50
+ def _edge_softmax(logits: Tensor, dst: Tensor, num_dst: int) -> Tensor:
51
+ index = dst[:, None].expand_as(logits)
52
+ maxima = logits.new_full((num_dst, logits.shape[1]), -torch.inf)
53
+ maxima.scatter_reduce_(0, index, logits, reduce="amax", include_self=True)
54
+ exp = torch.exp(logits - maxima[dst])
55
+ denominator = logits.new_zeros((num_dst, logits.shape[1]))
56
+ denominator.scatter_add_(0, index, exp)
57
+ return exp / denominator[dst].clamp_min(torch.finfo(exp.dtype).tiny)
58
+
59
+
60
+ class MeshGraphMLP(nn.Module):
61
+ def __init__(self, input_dim: int, output_dim: int = 512, hidden_dim: int = 512,
62
+ hidden_layers: int | None = 1, activation_fn: nn.Module | None = None,
63
+ norm_type: str | None = "LayerNorm", recompute_activation: bool = False) -> None:
64
+ super().__init__()
65
+ del recompute_activation
66
+ activation_fn = activation_fn or nn.SiLU()
67
+ if hidden_layers is None:
68
+ self.model = nn.Identity()
69
+ return
70
+ layers: list[nn.Module] = [nn.Linear(input_dim, hidden_dim), activation_fn]
71
+ for _ in range(hidden_layers - 1):
72
+ layers.extend([nn.Linear(hidden_dim, hidden_dim), nn.SiLU()])
73
+ layers.append(nn.Linear(hidden_dim, output_dim))
74
+ if norm_type is not None:
75
+ if norm_type != "LayerNorm":
76
+ raise ValueError("The portable model supports LayerNorm only")
77
+ layers.append(nn.LayerNorm(output_dim))
78
+ self.model = nn.Sequential(*layers)
79
+
80
+ def forward(self, x: Tensor) -> Tensor:
81
+ return self.model(x)
82
+
83
+
84
+ class MeshGraphEdgeMLPSum(nn.Module):
85
+ """Concat-trick edge MLP with the official parameter names and initialization."""
86
+
87
+ def __init__(self, efeat_dim: int, src_dim: int, dst_dim: int,
88
+ output_dim: int = 512, hidden_dim: int = 512,
89
+ hidden_layers: int = 1, activation_fn: nn.Module | None = None,
90
+ norm_type: str | None = "LayerNorm", recompute_activation: bool = False) -> None:
91
+ super().__init__()
92
+ del recompute_activation
93
+ activation_fn = activation_fn or nn.SiLU()
94
+ initial = nn.Linear(efeat_dim + src_dim + dst_dim, hidden_dim)
95
+ weights = torch.split(initial.weight, [efeat_dim, src_dim, dst_dim], dim=1)
96
+ self.lin_efeat = nn.Parameter(weights[0])
97
+ self.lin_src = nn.Parameter(weights[1])
98
+ self.lin_dst = nn.Parameter(weights[2])
99
+ self.bias = initial.bias
100
+ layers: list[nn.Module] = [activation_fn]
101
+ for _ in range(hidden_layers - 1):
102
+ layers.extend([nn.Linear(hidden_dim, hidden_dim), nn.SiLU()])
103
+ layers.append(nn.Linear(hidden_dim, output_dim))
104
+ if norm_type is not None:
105
+ if norm_type != "LayerNorm":
106
+ raise ValueError("The portable model supports LayerNorm only")
107
+ layers.append(nn.LayerNorm(output_dim))
108
+ self.model = nn.Sequential(*layers)
109
+
110
+ def forward(self, efeat: Tensor, nfeat: Tensor | tuple[Tensor, Tensor],
111
+ graph: GraphData) -> Tensor:
112
+ src_feat, dst_feat = (nfeat, nfeat) if isinstance(nfeat, Tensor) else nfeat
113
+ hidden = F.linear(efeat, self.lin_efeat)
114
+ hidden = hidden + F.linear(src_feat[graph.src], self.lin_src)
115
+ hidden = hidden + F.linear(dst_feat[graph.dst], self.lin_dst, self.bias)
116
+ return self.model(hidden)
117
+
118
+
119
+ class OneForecastEncoderEmbedder(nn.Module):
120
+ def __init__(self, input_dim_grid_nodes: int = 69, input_dim_mesh_nodes: int = 3,
121
+ input_dim_edges: int = 4, output_dim: int = 512,
122
+ hidden_dim: int = 512, hidden_layers: int = 1) -> None:
123
+ super().__init__()
124
+ kwargs = dict(output_dim=output_dim, hidden_dim=hidden_dim, hidden_layers=hidden_layers)
125
+ self.grid_node_mlp = MeshGraphMLP(input_dim_grid_nodes, **kwargs)
126
+ self.mesh_node_mlp = MeshGraphMLP(input_dim_mesh_nodes, **kwargs)
127
+ self.mesh_edge_mlp = MeshGraphMLP(input_dim_edges, **kwargs)
128
+ self.grid2mesh_edge_mlp = MeshGraphMLP(input_dim_edges, **kwargs)
129
+
130
+ def forward(self, grid: Tensor, mesh: Tensor, g2m: Tensor,
131
+ mesh_edges: Tensor) -> tuple[Tensor, Tensor, Tensor, Tensor]:
132
+ return (self.grid_node_mlp(grid), self.mesh_node_mlp(mesh),
133
+ self.grid2mesh_edge_mlp(g2m), self.mesh_edge_mlp(mesh_edges))
134
+
135
+
136
+ class OneForecastDecoderEmbedder(nn.Module):
137
+ def __init__(self, input_dim_edges: int = 4, output_dim: int = 512,
138
+ hidden_dim: int = 512, hidden_layers: int = 1) -> None:
139
+ super().__init__()
140
+ self.mesh2grid_edge_mlp = MeshGraphMLP(
141
+ input_dim_edges, output_dim, hidden_dim, hidden_layers)
142
+
143
+ def forward(self, edges: Tensor) -> Tensor:
144
+ return self.mesh2grid_edge_mlp(edges)
145
+
146
+
147
+ class MeshGraphEncoder(nn.Module):
148
+ def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
149
+ aggregation: str = "sum") -> None:
150
+ super().__init__()
151
+ self.aggregation = aggregation
152
+ self.edge_mlp = MeshGraphEdgeMLPSum(hidden_dim, hidden_dim, hidden_dim,
153
+ hidden_dim, hidden_dim, hidden_layers)
154
+ self.src_node_mlp = MeshGraphMLP(hidden_dim, hidden_dim, hidden_dim, hidden_layers)
155
+ self.dst_node_mlp = MeshGraphMLP(hidden_dim * 2, hidden_dim, hidden_dim, hidden_layers)
156
+
157
+ def forward(self, edges: Tensor, grid: Tensor, mesh: Tensor,
158
+ graph: GraphData) -> tuple[Tensor, Tensor]:
159
+ edges = self.edge_mlp(edges, (grid, mesh), graph)
160
+ aggregated = _aggregate(edges, graph.dst, graph.num_dst, self.aggregation)
161
+ return grid + self.src_node_mlp(grid), mesh + self.dst_node_mlp(torch.cat((aggregated, mesh), -1))
162
+
163
+
164
+ class MeshGraphDecoder(nn.Module):
165
+ def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
166
+ aggregation: str = "sum") -> None:
167
+ super().__init__()
168
+ self.aggregation = aggregation
169
+ self.edge_mlp = MeshGraphEdgeMLPSum(hidden_dim, hidden_dim, hidden_dim,
170
+ hidden_dim, hidden_dim, hidden_layers)
171
+ self.node_mlp = MeshGraphMLP(hidden_dim * 2, hidden_dim, hidden_dim, hidden_layers)
172
+
173
+ def forward(self, edges: Tensor, grid: Tensor, mesh: Tensor, graph: GraphData) -> Tensor:
174
+ edges = self.edge_mlp(edges, (mesh, grid), graph)
175
+ aggregated = _aggregate(edges, graph.dst, graph.num_dst, self.aggregation)
176
+ return grid + self.node_mlp(torch.cat((aggregated, grid), -1))
177
+
178
+
179
+ class MeshEdgeBlockMultiHeadGated(nn.Module):
180
+ def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
181
+ num_heads: int = 4) -> None:
182
+ super().__init__()
183
+ self.num_heads = num_heads
184
+ self.edge_mlp = MeshGraphEdgeMLPSum(hidden_dim, hidden_dim, hidden_dim,
185
+ hidden_dim, hidden_dim, hidden_layers)
186
+ gating_hidden = max(16, hidden_dim // 8)
187
+ self.gate_net = nn.Sequential(nn.Linear(hidden_dim * 3, gating_hidden), nn.SiLU(),
188
+ nn.Linear(gating_hidden, 3 * num_heads), nn.Sigmoid())
189
+
190
+ def forward(self, edges: Tensor, nodes: Tensor, graph: GraphData) -> tuple[Tensor, Tensor]:
191
+ raw = torch.cat((edges, nodes[graph.src], nodes[graph.dst]), -1)
192
+ gates = self.gate_net(raw).view(-1, self.num_heads, 3).mean(1)
193
+ updated = self.edge_mlp(edges, nodes, graph)
194
+ return edges + updated * gates.mean(-1, keepdim=True), nodes
195
+
196
+
197
+ class MeshNodeBlockMultiHeadAttn(nn.Module):
198
+ def __init__(self, hidden_dim: int = 512, hidden_layers: int = 1,
199
+ aggregation: str = "sum", num_heads: int = 4) -> None:
200
+ super().__init__()
201
+ self.num_heads = num_heads
202
+ self.aggregation = aggregation
203
+ self.node_mlp = MeshGraphMLP(hidden_dim * (num_heads + 1), hidden_dim,
204
+ hidden_dim, hidden_layers)
205
+ attention_hidden = max(16, hidden_dim // 8)
206
+ self.attn_net = nn.Sequential(nn.Linear(hidden_dim, attention_hidden), nn.SiLU(),
207
+ nn.Linear(attention_hidden, num_heads))
208
+
209
+ def forward(self, edges: Tensor, nodes: Tensor, graph: GraphData) -> tuple[Tensor, Tensor]:
210
+ scores = _edge_softmax(self.attn_net(edges), graph.dst, graph.num_dst)
211
+ messages = edges[:, None, :].expand(-1, self.num_heads, -1) * scores[:, :, None]
212
+ aggregated = _aggregate(messages, graph.dst, graph.num_dst, self.aggregation).flatten(1)
213
+ return edges, nodes + self.node_mlp(torch.cat((aggregated, nodes), -1))
214
+
215
+
216
+ class OneForecastProcessor(nn.Module):
217
+ def __init__(self, processor_layers: int, hidden_dim: int = 512,
218
+ hidden_layers: int = 1, aggregation: str = "sum",
219
+ num_heads_edge: int = 4, num_heads_node: int = 4) -> None:
220
+ super().__init__()
221
+ layers: list[nn.Module] = []
222
+ for _ in range(processor_layers):
223
+ layers.append(MeshEdgeBlockMultiHeadGated(hidden_dim, hidden_layers, num_heads_edge))
224
+ layers.append(MeshNodeBlockMultiHeadAttn(hidden_dim, hidden_layers, aggregation, num_heads_node))
225
+ self.processor_layers = nn.ModuleList(layers)
226
+
227
+ def forward(self, edges: Tensor, nodes: Tensor, graph: GraphData) -> tuple[Tensor, Tensor]:
228
+ for layer in self.processor_layers:
229
+ edges, nodes = layer(edges, nodes, graph)
230
+ return edges, nodes
231
+
232
+
233
+ class TriangularMesh(NamedTuple):
234
+ vertices: np.ndarray
235
+ faces: np.ndarray
236
+
237
+
238
+ def _icosahedron() -> TriangularMesh:
239
+ from scipy.spatial.transform import Rotation
240
+
241
+ phi = (1 + np.sqrt(5)) / 2
242
+ vertices = []
243
+ for first in (1.0, -1.0):
244
+ for second in (phi, -phi):
245
+ vertices.extend(((first, second, 0.0), (0.0, first, second), (second, 0.0, first)))
246
+ vertices = np.asarray(vertices, dtype=np.float32) / np.linalg.norm([1.0, phi])
247
+ faces = np.asarray(((0,1,2),(0,6,1),(8,0,2),(8,4,0),(3,8,2),(3,2,7),(7,2,1),
248
+ (0,4,6),(4,11,6),(6,11,5),(1,5,7),(4,10,11),(4,8,10),(10,8,3),
249
+ (10,3,9),(11,10,9),(11,9,5),(5,9,7),(9,3,7),(1,6,5)), dtype=np.int32)
250
+ angle = (np.pi - 2 * np.arcsin(phi / np.sqrt(3))) / 2
251
+ vertices = vertices @ Rotation.from_euler("y", angle).as_matrix()
252
+ return TriangularMesh(vertices.astype(np.float32), faces)
253
+
254
+
255
+ def _split_mesh(mesh: TriangularMesh) -> TriangularMesh:
256
+ vertices = list(mesh.vertices)
257
+ children: dict[tuple[int, int], int] = {}
258
+ faces = []
259
+ for a, b, c in mesh.faces:
260
+ mids = []
261
+ for pair in ((a, b), (b, c), (c, a)):
262
+ key = tuple(sorted(map(int, pair)))
263
+ if key not in children:
264
+ position = mesh.vertices[list(pair)].mean(0)
265
+ position /= np.linalg.norm(position)
266
+ children[key] = len(vertices)
267
+ vertices.append(position)
268
+ mids.append(children[key])
269
+ ab, bc, ca = mids
270
+ faces.extend(((a, ab, ca), (ab, b, bc), (ca, bc, c), (ab, bc, ca)))
271
+ return TriangularMesh(np.asarray(vertices, dtype=np.float32), np.asarray(faces, dtype=np.int32))
272
+
273
+
274
+ def _mesh_hierarchy(level: int) -> list[TriangularMesh]:
275
+ meshes = [_icosahedron()]
276
+ for _ in range(level):
277
+ meshes.append(_split_mesh(meshes[-1]))
278
+ return meshes
279
+
280
+
281
+ def _faces_to_edges(faces: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
282
+ return (np.concatenate((faces[:, 0], faces[:, 1], faces[:, 2])),
283
+ np.concatenate((faces[:, 1], faces[:, 2], faces[:, 0])))
284
+
285
+
286
+ def _latlon_to_xyz(latlon: Tensor) -> Tensor:
287
+ values = torch.deg2rad(latlon)
288
+ lat, lon = values[:, 0], values[:, 1]
289
+ return torch.stack((torch.cos(lat) * torch.cos(lon), torch.cos(lat) * torch.sin(lon), torch.sin(lat)), 1)
290
+
291
+
292
+ def _node_features(xyz: Tensor) -> Tensor:
293
+ # The official implementation applies trigonometric functions to the
294
+ # degree-valued xyz2latlon output; retain that behavior for parity.
295
+ lat = torch.rad2deg(torch.asin(xyz[:, 2]))
296
+ lon = torch.rad2deg(torch.atan2(xyz[:, 1], xyz[:, 0]))
297
+ return torch.stack((torch.cos(lat), torch.sin(lon), torch.cos(lon)), -1)
298
+
299
+
300
+ def _edge_features(src_pos: Tensor, dst_pos: Tensor, src: Tensor, dst: Tensor) -> Tensor:
301
+ source, target = src_pos[src], dst_pos[dst]
302
+ lat = torch.asin(target[:, 2])
303
+ lon = torch.atan2(target[:, 1], target[:, 0])
304
+ cos_lon, sin_lon = torch.cos(-lon), torch.sin(-lon)
305
+ source = torch.stack((cos_lon * source[:, 0] - sin_lon * source[:, 1],
306
+ sin_lon * source[:, 0] + cos_lon * source[:, 1], source[:, 2]), -1)
307
+ target = torch.stack((cos_lon * target[:, 0] - sin_lon * target[:, 1],
308
+ sin_lon * target[:, 0] + cos_lon * target[:, 1], target[:, 2]), -1)
309
+ cos_lat, sin_lat = torch.cos(lat), torch.sin(lat)
310
+ source = torch.stack((cos_lat * source[:, 0] + sin_lat * source[:, 2], source[:, 1],
311
+ -sin_lat * source[:, 0] + cos_lat * source[:, 2]), -1)
312
+ target = torch.stack((cos_lat * target[:, 0] + sin_lat * target[:, 2], target[:, 1],
313
+ -sin_lat * target[:, 0] + cos_lat * target[:, 2]), -1)
314
+ displacement = source - target
315
+ norm = torch.linalg.norm(displacement, dim=-1, keepdim=True)
316
+ maximum = norm.max()
317
+ return torch.cat((displacement / maximum, norm / maximum), -1)
318
+
319
+
320
+ def _local_refine(mesh: TriangularMesh, lat_min: float, lat_max: float,
321
+ lon_min: float, lon_max: float) -> TriangularMesh:
322
+ centroids = mesh.vertices[mesh.faces].mean(axis=1)
323
+ # Match the official xyz2latlon call, which assumes radius=1 for centroids.
324
+ latitudes = np.rad2deg(np.arcsin(centroids[:, 2]))
325
+ longitudes = np.rad2deg(np.arctan2(centroids[:, 1], centroids[:, 0]))
326
+ selected = ((latitudes >= lat_min) & (latitudes <= lat_max)
327
+ & (longitudes >= lon_min) & (longitudes <= lon_max))
328
+ refined = _split_mesh(TriangularMesh(mesh.vertices, mesh.faces[selected]))
329
+ combined_vertices = np.concatenate((refined.vertices, mesh.vertices), axis=0)
330
+ combined_faces = np.concatenate((refined.faces, mesh.faces[~selected] + len(refined.vertices)), axis=0)
331
+ rounded = np.round(combined_vertices, decimals=6)
332
+ unique: dict[tuple[float, float, float], int] = {}
333
+ remap = np.empty(len(rounded), dtype=np.int64)
334
+ vertices = []
335
+ for index, coordinates in enumerate(rounded):
336
+ key = tuple(coordinates.tolist())
337
+ if key not in unique:
338
+ unique[key] = len(vertices)
339
+ vertices.append(combined_vertices[index])
340
+ remap[index] = unique[key]
341
+ return TriangularMesh(np.asarray(vertices, dtype=np.float32), remap[combined_faces].astype(np.int32))
342
+
343
+
344
+ def _build_graphs(height: int, width: int, mesh_level: int) -> tuple[GraphData, GraphData, GraphData, Tensor, Tensor, Tensor, Tensor]:
345
+ from scipy.spatial import cKDTree
346
+
347
+ latitudes = torch.linspace(-90, 90, height + 1)[:-1]
348
+ longitudes = torch.linspace(-180, 180, width + 1)[1:]
349
+ latlon = torch.stack(torch.meshgrid(latitudes, longitudes, indexing="ij"), -1).reshape(-1, 2)
350
+ grid_xyz = _latlon_to_xyz(latlon)
351
+ hierarchy = _mesh_hierarchy(mesh_level)
352
+ finest = hierarchy[-1]
353
+ refined = _local_refine(finest, 0.0, 30.0, 105.0, 160.0)
354
+ refined = _local_refine(refined, 10.0, 30.0, -95.0, -35.0)
355
+ mesh_vertices = refined.vertices
356
+ mesh_faces = np.concatenate([mesh.faces for mesh in hierarchy] + [refined.faces], axis=0)
357
+ mesh_src, mesh_dst = _faces_to_edges(mesh_faces)
358
+ mesh_src = np.concatenate((mesh_src, mesh_dst))
359
+ mesh_dst = np.concatenate((mesh_dst, mesh_src[:len(mesh_dst)]))
360
+ pairs = np.unique(np.stack((mesh_src, mesh_dst), 1), axis=0)
361
+ mesh_src_t = torch.from_numpy(pairs[:, 0])
362
+ mesh_dst_t = torch.from_numpy(pairs[:, 1])
363
+ mesh_xyz = torch.from_numpy(mesh_vertices)
364
+ mesh_graph = GraphData(mesh_src_t, mesh_dst_t, len(mesh_vertices), len(mesh_vertices))
365
+
366
+ finest_src, finest_dst = _faces_to_edges(finest.faces)
367
+ max_edge = np.linalg.norm(finest.vertices[finest_src] - finest.vertices[finest_dst], axis=1).max()
368
+ distances, neighbors = cKDTree(mesh_vertices).query(grid_xyz.numpy(), k=4)
369
+ valid = distances <= 0.6 * max_edge
370
+ g2m_src, neighbor_slot = np.nonzero(valid)
371
+ g2m_dst = neighbors[g2m_src, neighbor_slot]
372
+ g2m_graph = GraphData(torch.from_numpy(g2m_src), torch.from_numpy(g2m_dst), len(grid_xyz), len(mesh_vertices))
373
+
374
+ centroids = mesh_vertices[mesh_faces].mean(axis=1)
375
+ face_indices = cKDTree(centroids).query(grid_xyz.numpy(), k=1)[1]
376
+ m2g_src = mesh_faces[face_indices].reshape(-1)
377
+ m2g_dst = np.repeat(np.arange(len(grid_xyz)), 3)
378
+ m2g_graph = GraphData(torch.from_numpy(m2g_src), torch.from_numpy(m2g_dst), len(mesh_vertices), len(grid_xyz))
379
+ mesh_nodes = _node_features(mesh_xyz)
380
+ mesh_edges = _edge_features(mesh_xyz, mesh_xyz, mesh_graph.src, mesh_graph.dst)
381
+ g2m_edges = _edge_features(grid_xyz, mesh_xyz, g2m_graph.src, g2m_graph.dst)
382
+ m2g_edges = _edge_features(mesh_xyz, grid_xyz, m2g_graph.src, m2g_graph.dst)
383
+ return mesh_graph, g2m_graph, m2g_graph, mesh_nodes, mesh_edges, g2m_edges, m2g_edges
384
+
385
+
386
+ class OneForecast(nn.Module):
387
+ """Official OneForecast message-passing architecture with portable graph kernels."""
388
+
389
+ def __init__(self, input_res: tuple[int, int] = (120, 240), input_dim_grid_nodes: int = 69,
390
+ output_dim_grid_nodes: int = 69, mesh_level: int = 5,
391
+ processor_layers: int = 16, hidden_layers: int = 1,
392
+ hidden_dim: int = 512, aggregation: str = "sum",
393
+ num_heads_edge: int = 4, num_heads_node: int = 4,
394
+ build_graph: bool = True) -> None:
395
+ super().__init__()
396
+ if processor_layers <= 2:
397
+ raise ValueError("Expected at least 3 processor layers")
398
+ self.register_buffer("device_buffer", torch.empty(0))
399
+ self.input_res = tuple(input_res)
400
+ self.input_dim_grid_nodes = input_dim_grid_nodes
401
+ self.output_dim_grid_nodes = output_dim_grid_nodes
402
+ self.mesh_level = mesh_level
403
+ self.encoder_embedder = OneForecastEncoderEmbedder(
404
+ input_dim_grid_nodes, 3, 4, hidden_dim, hidden_dim, hidden_layers)
405
+ self.decoder_embedder = OneForecastDecoderEmbedder(4, hidden_dim, hidden_dim, hidden_layers)
406
+ self.encoder = MeshGraphEncoder(hidden_dim, hidden_layers, aggregation)
407
+ self.processor_encoder = OneForecastProcessor(
408
+ 1, hidden_dim, hidden_layers, aggregation, num_heads_edge, num_heads_node)
409
+ self.processor = OneForecastProcessor(
410
+ processor_layers - 2, hidden_dim, hidden_layers, aggregation, num_heads_edge, num_heads_node)
411
+ self.processor_decoder = OneForecastProcessor(
412
+ 1, hidden_dim, hidden_layers, aggregation, num_heads_edge, num_heads_node)
413
+ self.decoder = MeshGraphDecoder(hidden_dim, hidden_layers, aggregation)
414
+ self.finale = MeshGraphMLP(hidden_dim, output_dim_grid_nodes, hidden_dim, hidden_layers, norm_type=None)
415
+ self._graph_ready = False
416
+ if build_graph:
417
+ self.build_graph()
418
+
419
+ def build_graph(self) -> None:
420
+ values = _build_graphs(*self.input_res, self.mesh_level)
421
+ self.mesh_graph, self.g2m_graph, self.m2g_graph = values[:3]
422
+ for name, value in zip(("mesh_ndata", "mesh_edata", "g2m_edata", "m2g_edata"), values[3:]):
423
+ self.register_buffer(name, value, persistent=False)
424
+ self._graph_ready = True
425
+
426
+ def forward(self, grid_nfeat: Tensor) -> Tensor:
427
+ if not self._graph_ready:
428
+ raise RuntimeError("Graph construction was disabled for this model instance")
429
+ if grid_nfeat.shape != (1, self.input_dim_grid_nodes, *self.input_res):
430
+ raise ValueError(f"Expected input shape (1, {self.input_dim_grid_nodes}, {self.input_res[0]}, {self.input_res[1]}), got {tuple(grid_nfeat.shape)}")
431
+ grid = grid_nfeat[0].reshape(self.input_dim_grid_nodes, -1).T
432
+ grid, mesh, g2m, mesh_edges = self.encoder_embedder(
433
+ grid, self.mesh_ndata, self.g2m_edata, self.mesh_edata)
434
+ grid, mesh = self.encoder(g2m, grid, mesh, self.g2m_graph)
435
+ mesh_edges, mesh = self.processor_encoder(mesh_edges, mesh, self.mesh_graph)
436
+ mesh_edges, mesh = self.processor(mesh_edges, mesh, self.mesh_graph)
437
+ _, mesh = self.processor_decoder(mesh_edges, mesh, self.mesh_graph)
438
+ grid = self.decoder(self.decoder_embedder(self.m2g_edata), grid, mesh, self.m2g_graph)
439
+ output = self.finale(grid).T.reshape(self.output_dim_grid_nodes, *self.input_res)
440
+ return output.unsqueeze(0)
441
+
442
+ def to(self, *args: Any, **kwargs: Any) -> "OneForecast":
443
+ super().to(*args, **kwargs)
444
+ if self._graph_ready:
445
+ device = self.device_buffer.device
446
+ self.mesh_graph.to(device)
447
+ self.g2m_graph.to(device)
448
+ self.m2g_graph.to(device)
449
+ return self
450
+
451
+
452
+ @dataclass(frozen=True)
453
+ class CheckpointReport:
454
+ checkpoint_path: str
455
+ checkpoint_keys: int
456
+ model_keys: int
457
+ missing_keys: tuple[str, ...]
458
+ unexpected_keys: tuple[str, ...]
459
+ shape_mismatches: tuple[str, ...]
460
+
461
+ @property
462
+ def compatible(self) -> bool:
463
+ return not (self.missing_keys or self.unexpected_keys or self.shape_mismatches)
464
+
465
+
466
+ def _install_scalarfloat_safe_global() -> type[float]:
467
+ """Allow the known ruamel ScalarFloat metadata type without importing ruamel."""
468
+ module_name = "ruamel.yaml.scalarfloat"
469
+ module = sys.modules.get(module_name)
470
+ if module is not None and hasattr(module, "ScalarFloat"):
471
+ scalar_float = module.ScalarFloat
472
+ else:
473
+ ruamel = sys.modules.setdefault("ruamel", types.ModuleType("ruamel"))
474
+ yaml_module = sys.modules.setdefault("ruamel.yaml", types.ModuleType("ruamel.yaml"))
475
+ module = types.ModuleType(module_name)
476
+ scalar_float = type("ScalarFloat", (float,), {})
477
+ scalar_float.__module__ = module_name
478
+ module.ScalarFloat = scalar_float
479
+ yaml_module.scalarfloat = module
480
+ ruamel.yaml = yaml_module
481
+ sys.modules[module_name] = module
482
+ torch.serialization.add_safe_globals([scalar_float])
483
+ return scalar_float
484
+
485
+
486
+ def read_official_checkpoint(path: str | Path) -> tuple[dict[str, Tensor], dict[str, Any]]:
487
+ path = Path(path).expanduser().resolve()
488
+ _install_scalarfloat_safe_global()
489
+ checkpoint = torch.load(path, map_location="cpu", weights_only=True, mmap=True)
490
+ if not isinstance(checkpoint, dict) or "model_state" not in checkpoint:
491
+ raise ValueError(f"{path} does not contain an official model_state")
492
+ state = checkpoint["model_state"]
493
+ if not isinstance(state, dict):
494
+ raise TypeError("checkpoint model_state must be a mapping")
495
+ cleaned = {key.removeprefix("module."): value for key, value in state.items()}
496
+ metadata = {key: value for key, value in checkpoint.items() if key not in {"model_state", "optimizer_state_dict"}}
497
+ return cleaned, metadata
498
+
499
+
500
+ def _compare_checkpoint_state(model: nn.Module, checkpoint_state: dict[str, Tensor],
501
+ path: str | Path) -> CheckpointReport:
502
+ model_state = model.state_dict()
503
+ missing = tuple(sorted(set(model_state) - set(checkpoint_state)))
504
+ unexpected = tuple(sorted(set(checkpoint_state) - set(model_state)))
505
+ mismatches = tuple(sorted(
506
+ f"{key}: checkpoint={tuple(checkpoint_state[key].shape)} model={tuple(model_state[key].shape)}"
507
+ for key in set(model_state) & set(checkpoint_state)
508
+ if model_state[key].shape != checkpoint_state[key].shape
509
+ ))
510
+ return CheckpointReport(str(Path(path).expanduser().resolve()), len(checkpoint_state),
511
+ len(model_state), missing, unexpected, mismatches)
512
+
513
+
514
+ def check_checkpoint_compatibility(model: nn.Module, path: str | Path) -> CheckpointReport:
515
+ checkpoint_state, _ = read_official_checkpoint(path)
516
+ return _compare_checkpoint_state(model, checkpoint_state, path)
517
+
518
+
519
+ def load_official_checkpoint(model: nn.Module, path: str | Path, strict: bool = True) -> CheckpointReport:
520
+ state, _ = read_official_checkpoint(path)
521
+ report = _compare_checkpoint_state(model, state, path)
522
+ if strict and not report.compatible:
523
+ raise RuntimeError(f"Official checkpoint is incompatible: {report}")
524
+ compatible = {key: value for key, value in state.items()
525
+ if key in model.state_dict() and value.shape == model.state_dict()[key].shape}
526
+ model.load_state_dict(compatible, strict=strict)
527
+ return report
528
+
529
+
530
+ def build_model(config: dict[str, Any], build_graph: bool = True) -> OneForecast:
531
+ settings = config["model"]
532
+ model = OneForecast(
533
+ input_res=(settings["grid_height"], settings["grid_width"]),
534
+ input_dim_grid_nodes=settings["input_channels"],
535
+ output_dim_grid_nodes=settings["output_channels"],
536
+ mesh_level=settings.get("mesh_level", 5),
537
+ processor_layers=settings.get("processor_layers", 16),
538
+ hidden_layers=settings.get("hidden_layers", 1),
539
+ hidden_dim=settings.get("hidden_dim", 512),
540
+ num_heads_edge=settings.get("num_heads_edge", 4),
541
+ num_heads_node=settings.get("num_heads_node", 4),
542
+ build_graph=build_graph,
543
+ )
544
+ initialization = settings.get("weight_init", "scratch")
545
+ if initialization == "official":
546
+ load_official_checkpoint(model, settings["checkpoint_path"])
547
+ elif initialization != "scratch":
548
+ raise ValueError("model.weight_init must be 'scratch' or 'official'")
549
+ return model
550
+
551
+
552
+ __all__ = ["CheckpointReport", "OneForecast", "build_model", "check_checkpoint_compatibility",
553
+ "load_official_checkpoint", "read_official_checkpoint"]
scripts/fake_data.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate metadata-compatible ERA5 HDF5 fixtures at the native 0.25 degree grid."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ import h5py
9
+ import numpy as np
10
+
11
+
12
+ OFFICIAL_VARIABLES = (
13
+ [f"Z{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
14
+ + [f"Q{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
15
+ + [f"T{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
16
+ + [f"U{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
17
+ + [f"V{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)]
18
+ + ["U10M", "V10M", "T2M", "MSLP"]
19
+ )
20
+
21
+ VARIABLE_ALIASES = {
22
+ **{f"Z{x}": f"geopotential_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
23
+ **{f"Q{x}": f"specific_humidity_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
24
+ **{f"T{x}": f"temperature_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
25
+ **{f"U{x}": f"u_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
26
+ **{f"V{x}": f"v_component_of_wind_{x}" for x in (50, 100, 150, 200, 250, 300, 400, 500, 600, 700, 850, 925, 1000)},
27
+ "U10M": "10m_u_component_of_wind",
28
+ "V10M": "10m_v_component_of_wind",
29
+ "T2M": "2m_temperature",
30
+ "MSLP": "mean_sea_level_pressure",
31
+ }
32
+ REAL_VARIABLES = tuple(VARIABLE_ALIASES[name] for name in OFFICIAL_VARIABLES)
33
+
34
+
35
+ def _synthetic_field(time_index: int, channel: int, height: int, width: int) -> np.ndarray:
36
+ lat = np.linspace(1.0, -1.0, height, dtype=np.float32)[:, None]
37
+ lon = np.linspace(0.0, 2.0 * np.pi, width, endpoint=False, dtype=np.float32)[None, :]
38
+ phase = np.float32(channel * 0.17)
39
+ field = np.sin(lon + phase) + 0.4 * np.cos(np.float32(time_index / 3.0) + phase) + 0.2 * lat
40
+ return np.asarray(field, dtype=np.float32)
41
+
42
+
43
+ def generate_fake_h5(output_dir: Path, years: list[int], stats_years: set[int], time_steps: int,
44
+ height: int, width: int, seed: int) -> None:
45
+ data_dir = output_dir / "data"
46
+ data_dir.mkdir(parents=True, exist_ok=True)
47
+ sums = np.zeros(len(OFFICIAL_VARIABLES), dtype=np.float64)
48
+ squared_sums = np.zeros_like(sums)
49
+ value_count = 0
50
+
51
+ for offset, year in enumerate(years):
52
+ path = data_dir / f"{year}.h5"
53
+ with h5py.File(path, "w") as handle:
54
+ dataset = handle.create_dataset(
55
+ "fields", shape=(time_steps, len(OFFICIAL_VARIABLES), height, width),
56
+ dtype="float32", chunks=(1, 1, height, width),
57
+ )
58
+ dataset.attrs["variables"] = list(REAL_VARIABLES)
59
+ dataset.attrs["time_step"] = 6
60
+ for time_index in range(time_steps):
61
+ for channel in range(len(OFFICIAL_VARIABLES)):
62
+ field = _synthetic_field(time_index + offset, channel, height, width)
63
+ dataset[time_index, channel] = field
64
+ if year in stats_years:
65
+ sums[channel] += field.sum(dtype=np.float64)
66
+ squared_sums[channel] += np.square(field, dtype=np.float64).sum()
67
+ if year in stats_years:
68
+ value_count += time_steps * height * width
69
+ print(f"{path}: fields={(time_steps, len(OFFICIAL_VARIABLES), height, width)}, variables={len(OFFICIAL_VARIABLES)}")
70
+
71
+ means = (sums / value_count).reshape(1, -1, 1, 1)
72
+ variances = squared_sums / value_count - np.square(means.reshape(-1))
73
+ stds = np.sqrt(np.maximum(variances, 1e-12)).reshape(1, -1, 1, 1)
74
+ stats_dir = output_dir / "stats"
75
+ stats_dir.mkdir(parents=True, exist_ok=True)
76
+ np.save(stats_dir / "global_means.npy", means)
77
+ np.save(stats_dir / "global_stds.npy", stds)
78
+
79
+
80
+ def main() -> None:
81
+ parser = argparse.ArgumentParser()
82
+ parser.add_argument("--output-dir", type=Path, default=Path("./data"))
83
+ parser.add_argument("--years", nargs="+", type=int, default=[2000, 2001, 2002])
84
+ parser.add_argument("--stats-years", nargs="+", type=int, default=None)
85
+ parser.add_argument("--time-steps", type=int, default=3)
86
+ parser.add_argument("--height", type=int, default=721)
87
+ parser.add_argument("--width", type=int, default=1440)
88
+ parser.add_argument("--seed", type=int, default=42)
89
+ args = parser.parse_args()
90
+ if len(args.years) < 1 or min(args.time_steps, args.height, args.width) < 1:
91
+ parser.error("years, time-steps, height, and width must be positive")
92
+ if (args.height, args.width) != (721, 1440):
93
+ parser.error("ERA5 fixtures must use the native 0.25 degree grid 721x1440")
94
+ stats_years = set(args.stats_years or args.years[:1])
95
+ if not stats_years.issubset(args.years):
96
+ parser.error("stats-years must be included in years")
97
+ generate_fake_h5(args.output_dir, args.years, stats_years, args.time_steps,
98
+ args.height, args.width, args.seed)
99
+
100
+
101
+ if __name__ == "__main__":
102
+ main()
scripts/finetune.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-step autoregressive fine-tuning for OneForecast."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ import torch
10
+ import torch.distributed as dist
11
+ from torch.nn.parallel import DistributedDataParallel
12
+ from torch.utils.checkpoint import checkpoint
13
+ import yaml
14
+
15
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
16
+
17
+ from model.era5_adapter import OFFICIAL_VARIABLES, OneForecastERA5Adapter
18
+ from model.oneforecast import build_model, read_official_checkpoint
19
+ from scripts.train import _LossScaleFunction, _reduce_metrics, _relative_channel_l2, _set_seed, _setup_distributed
20
+
21
+
22
+ def _resolve_path(value: str | Path, config_path: Path) -> Path:
23
+ path = Path(value).expanduser()
24
+ return path if path.is_absolute() else (config_path.parent.parent / path).resolve()
25
+
26
+
27
+ def _prepare_batch(batch: tuple, steps: int) -> tuple[torch.Tensor, torch.Tensor]:
28
+ inputs, targets = batch[0], batch[1]
29
+ if inputs.ndim != 4 or targets.ndim != 5:
30
+ raise ValueError(f"Expected [B,C,H,W] inputs and [B,S,C,H,W] targets, got {inputs.shape} and {targets.shape}")
31
+ if targets.shape[1] != steps:
32
+ raise ValueError(f"Expected {steps} target steps, got {targets.shape[1]}")
33
+ if inputs.shape[-2] == 121:
34
+ inputs = inputs[..., :120, :]
35
+ if targets.shape[-2] == 121:
36
+ targets = targets[..., :120, :]
37
+ if inputs.shape[-2:] != (120, 240) or targets.shape[-2:] != (120, 240):
38
+ raise ValueError(f"Expected official model grid 120x240, got {inputs.shape} and {targets.shape}")
39
+ return torch.nan_to_num(inputs.float()), torch.nan_to_num(targets.float())
40
+
41
+
42
+ def main() -> None:
43
+ parser = argparse.ArgumentParser()
44
+ parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
45
+ parser.add_argument("--model-source", choices=("trained", "official"), default=None)
46
+ parser.add_argument("--max-epochs", type=int, default=None)
47
+ parser.add_argument("--max-batches", type=int, default=None)
48
+ args = parser.parse_args()
49
+ config_path = args.config.resolve()
50
+ with config_path.open("r", encoding="utf-8") as handle:
51
+ config = yaml.safe_load(handle)
52
+ if tuple(config["datapipe"]["variables"]) != OFFICIAL_VARIABLES:
53
+ raise ValueError("datapipe.variables must exactly match the official 69-channel order")
54
+
55
+ settings = config["datapipe"]
56
+ finetune = config["finetuning"]
57
+ if args.max_epochs is not None:
58
+ finetune["max_epoch"] = args.max_epochs
59
+ if args.max_batches is not None:
60
+ finetune["max_batches"] = args.max_batches
61
+ steps = int(finetune["steps"])
62
+ if steps < 2:
63
+ raise ValueError("finetuning.steps must be at least 2")
64
+ source = args.model_source or finetune.get("model_source", "trained")
65
+ checkpoint_path = _resolve_path(
66
+ finetune["trained_model_path"] if source == "trained" else finetune["official_checkpoint_path"],
67
+ config_path,
68
+ )
69
+ output_path = _resolve_path(finetune["output_path"], config_path)
70
+ dataset_dir = _resolve_path(settings["dataset_dir"], config_path)
71
+
72
+ device, rank, world_size, distributed = _setup_distributed(
73
+ config["runtime"].get("device", "cpu"), config["runtime"].get("distributed_backend", "nccl")
74
+ )
75
+ _set_seed(int(config["runtime"].get("seed", 42)))
76
+ config["model"]["weight_init"] = "scratch"
77
+ model = build_model(config).to(device)
78
+ state, _ = read_official_checkpoint(checkpoint_path)
79
+ model.load_state_dict(state)
80
+ if distributed:
81
+ ddp_devices = {"device_ids": [device.index], "output_device": device.index} if device.type == "cuda" else {}
82
+ model = DistributedDataParallel(model, broadcast_buffers=False, **ddp_devices)
83
+ optimizer = torch.optim.Adam(model.parameters(), lr=float(finetune["learning_rate"]))
84
+ adapter = OneForecastERA5Adapter(
85
+ dataset_dir, settings["train_years"], batch_size=settings["batch_size"],
86
+ input_steps=1, output_steps=steps, normalize=settings["normalize"],
87
+ num_workers=settings["num_workers"], distributed=distributed,
88
+ )
89
+ loader, sampler = adapter.get_dataloader("train")
90
+ max_batches = int(finetune.get("max_batches", -1))
91
+
92
+ for epoch in range(int(finetune["max_epoch"])):
93
+ if sampler is not None:
94
+ sampler.set_epoch(epoch)
95
+ model.train()
96
+ epoch_loss = 0.0
97
+ batches = 0
98
+ for batch in loader:
99
+ inputs, targets = _prepare_batch(batch, steps)
100
+ current = inputs.to(device)
101
+ targets = targets.to(device)
102
+ optimizer.zero_grad(set_to_none=True)
103
+ loss = torch.zeros((), device=device)
104
+ for step in range(steps):
105
+ current = checkpoint(model, current, use_reentrant=False)
106
+ scaled = _LossScaleFunction.apply(current, 1e-5)
107
+ step_loss, _ = _relative_channel_l2(scaled, targets[:, step])
108
+ loss = loss + step_loss
109
+ loss.backward()
110
+ optimizer.step()
111
+ epoch_loss += float(loss.detach())
112
+ batches += 1
113
+ if max_batches >= 0 and batches >= max_batches:
114
+ break
115
+ mean_loss = _reduce_metrics(epoch_loss, batches, device, distributed)
116
+ if rank == 0:
117
+ print({"epoch": epoch + 1, "steps": steps, "loss": mean_loss,
118
+ "batches_per_rank": batches, "world_size": world_size})
119
+
120
+ if rank == 0:
121
+ output_path.parent.mkdir(parents=True, exist_ok=True)
122
+ state = model.module.state_dict() if distributed else model.state_dict()
123
+ torch.save({"model_state": state, "epoch": int(finetune["max_epoch"]),
124
+ "finetune_steps": steps, "world_size": world_size}, output_path)
125
+ print({"checkpoint": str(output_path)})
126
+ if distributed:
127
+ dist.destroy_process_group()
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OneForecast inference entry point with the shared ERA5 adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+ import sys
8
+
9
+ import numpy as np
10
+ import torch
11
+ import yaml
12
+
13
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
14
+
15
+ from model.era5_adapter import OFFICIAL_VARIABLES, OneForecastERA5Adapter
16
+ from model.oneforecast import build_model, check_checkpoint_compatibility, read_official_checkpoint
17
+
18
+
19
+ def _resolve_path(value: str | Path, config_path: Path) -> Path:
20
+ path = Path(value).expanduser()
21
+ return path if path.is_absolute() else (config_path.parent.parent / path).resolve()
22
+
23
+
24
+ def _load_config(path: Path) -> dict:
25
+ with path.open("r", encoding="utf-8") as handle:
26
+ config = yaml.safe_load(handle)
27
+ config["datapipe"]["dataset_dir"] = str(_resolve_path(config["datapipe"]["dataset_dir"], path))
28
+ config["model"]["official_checkpoint_path"] = str(
29
+ _resolve_path(config["model"]["official_checkpoint_path"], path)
30
+ )
31
+ config["model"]["checkpoint_path"] = config["model"]["official_checkpoint_path"]
32
+ config["inference"]["trained_model_path"] = str(
33
+ _resolve_path(config["inference"]["trained_model_path"], path)
34
+ )
35
+ config["inference"]["official_checkpoint_path"] = str(
36
+ _resolve_path(config["inference"]["official_checkpoint_path"], path)
37
+ )
38
+ config["inference"]["output_dir"] = str(_resolve_path(config["inference"]["output_dir"], path))
39
+ return config
40
+
41
+
42
+ def _resolve_device(name: str) -> torch.device:
43
+ """Map the logical DCU name to the backend exposed by this PyTorch build."""
44
+ requested = str(name).lower()
45
+ if requested == "dcu":
46
+ if torch.cuda.is_available():
47
+ return torch.device("cuda")
48
+ privateuse = torch._C._get_privateuse1_backend_name()
49
+ if privateuse != "privateuseone":
50
+ return torch.device(privateuse)
51
+ raise RuntimeError("runtime.device=dcu, but this PyTorch build exposes no usable accelerator")
52
+ if requested == "auto":
53
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
54
+ device = torch.device(requested)
55
+ if device.type == "cuda" and not torch.cuda.is_available():
56
+ raise RuntimeError("runtime.device=cuda, but torch.cuda.is_available() is False")
57
+ return device
58
+
59
+
60
+ def main() -> None:
61
+ parser = argparse.ArgumentParser()
62
+ parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
63
+ parser.add_argument("--check-data", action="store_true")
64
+ parser.add_argument("--check-model", action="store_true")
65
+ parser.add_argument("--check-checkpoint", action="store_true")
66
+ parser.add_argument("--model-source", choices=("trained", "official"), default=None)
67
+ args = parser.parse_args()
68
+ config = _load_config(args.config.resolve())
69
+ if tuple(config["datapipe"]["variables"]) != OFFICIAL_VARIABLES:
70
+ raise ValueError("datapipe.variables must exactly match the official 69-channel order")
71
+ if args.model_source is not None:
72
+ config["inference"]["model_source"] = args.model_source
73
+ if args.check_data:
74
+ settings = config["datapipe"]
75
+ adapter = OneForecastERA5Adapter(
76
+ settings["dataset_dir"], settings["test_years"], batch_size=1,
77
+ input_steps=settings["input_steps"], output_steps=settings["output_steps"],
78
+ normalize=settings["normalize"], num_workers=settings["num_workers"],
79
+ )
80
+ print(adapter.inspect())
81
+ return
82
+ if args.check_model:
83
+ configured_init = config["model"].get("weight_init", "scratch")
84
+ config["model"]["weight_init"] = "scratch"
85
+ with __import__("torch").device("meta"):
86
+ model = build_model(config, build_graph=False)
87
+ print({"model": type(model).__name__, "parameters": sum(p.numel() for p in model.parameters()),
88
+ "configured_weight_init": configured_init})
89
+ return
90
+ if args.check_checkpoint:
91
+ with __import__("torch").device("meta"):
92
+ model = build_model(config, build_graph=False)
93
+ report = check_checkpoint_compatibility(
94
+ model, config["model"]["official_checkpoint_path"]
95
+ )
96
+ print(report)
97
+ if not report.compatible:
98
+ raise SystemExit(1)
99
+ return
100
+ settings = config["datapipe"]
101
+ if settings["input_steps"] != 1 or settings["output_steps"] != 1:
102
+ raise SystemExit("OneForecast inference currently requires input_steps=1 and output_steps=1")
103
+ device = _resolve_device(config["runtime"].get("device", "cpu"))
104
+ config["model"]["weight_init"] = "scratch"
105
+ model = build_model(config).to(device)
106
+ source = config["inference"].get("model_source", "trained")
107
+ checkpoint_path = config["inference"][
108
+ "trained_model_path" if source == "trained" else "official_checkpoint_path"
109
+ ]
110
+ state, _ = read_official_checkpoint(checkpoint_path)
111
+ model.load_state_dict(state)
112
+ model.eval()
113
+ adapter = OneForecastERA5Adapter(
114
+ _resolve_path(settings["dataset_dir"], args.config), settings["test_years"],
115
+ batch_size=1, input_steps=1, output_steps=1,
116
+ normalize=settings["normalize"], num_workers=settings["num_workers"],
117
+ )
118
+ loader, _ = adapter.get_dataloader("test")
119
+ output_dir = Path(config["inference"]["output_dir"])
120
+ output_dir.mkdir(parents=True, exist_ok=True)
121
+ max_batches = int(config["inference"].get("max_batches", -1))
122
+ processed = 0
123
+ with torch.no_grad():
124
+ for index, batch in enumerate(loader):
125
+ inputs, targets = batch[0].float().to(device), batch[1].float().to(device)
126
+ if inputs.ndim == 5 or targets.ndim == 5:
127
+ raise ValueError("OneForecast currently supports input_steps=1 and output_steps=1 only")
128
+ if inputs.ndim != 4:
129
+ raise ValueError(f"Expected batched input with four dimensions, got {inputs.shape}")
130
+ if inputs.shape[-2] == 121:
131
+ inputs = inputs[..., :120, :]
132
+ if targets.shape[-2] == 121:
133
+ targets = targets[..., :120, :]
134
+ if inputs.shape[-2:] != (120, 240) or targets.shape[-2:] != (120, 240):
135
+ raise ValueError(f"Expected official model grid 120x240, got {inputs.shape} and {targets.shape}")
136
+ prediction = model(torch.nan_to_num(inputs))
137
+ if settings["normalize"]:
138
+ means, stds = adapter.selected_statistics()
139
+ prediction = prediction.cpu() * torch.from_numpy(stds).float() + torch.from_numpy(means).float()
140
+ np.save(output_dir / f"prediction_{index:05d}.npy", prediction.cpu().numpy())
141
+ processed += 1
142
+ if max_batches >= 0 and index + 1 >= max_batches:
143
+ break
144
+ print({"output_dir": str(output_dir), "batches": processed})
145
+
146
+
147
+ if __name__ == "__main__":
148
+ main()
scripts/result.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create quick field images from OneForecast prediction files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import argparse
7
+ import numpy as np
8
+ import yaml
9
+
10
+
11
+ def main() -> None:
12
+ parser = argparse.ArgumentParser()
13
+ parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
14
+ args = parser.parse_args()
15
+ with args.config.open("r", encoding="utf-8") as handle:
16
+ config = yaml.safe_load(handle)
17
+ root = args.config.resolve().parent.parent
18
+ input_dir = Path(config["visualization"]["input_dir"])
19
+ output_dir = Path(config["visualization"]["output_dir"])
20
+ if not input_dir.is_absolute():
21
+ input_dir = root / input_dir
22
+ if not output_dir.is_absolute():
23
+ output_dir = root / output_dir
24
+ output_dir.mkdir(parents=True, exist_ok=True)
25
+ files = sorted(input_dir.glob("prediction_*.npy"))
26
+ if not files:
27
+ raise SystemExit(f"No prediction files found in {input_dir}")
28
+ import matplotlib.pyplot as plt
29
+
30
+ channels = config["visualization"].get("channels", [0])
31
+ for source in files:
32
+ prediction = np.load(source)
33
+ if prediction.shape != (1, 69, 120, 240):
34
+ raise ValueError(f"Expected official prediction shape [1, 69, 120, 240], got {prediction.shape}")
35
+ field = prediction[0]
36
+ for channel in channels:
37
+ if channel < 0 or channel >= field.shape[0]:
38
+ raise ValueError(f"Channel {channel} is outside prediction shape {field.shape}")
39
+ figure, axis = plt.subplots(figsize=(8, 3.5))
40
+ image = axis.imshow(field[channel], cmap="coolwarm", aspect="auto")
41
+ axis.set_title(f"{source.stem}, channel {channel}")
42
+ axis.set_xlabel("longitude index")
43
+ axis.set_ylabel("latitude index")
44
+ figure.colorbar(image, ax=axis, shrink=0.8)
45
+ figure.tight_layout()
46
+ figure.savefig(output_dir / f"{source.stem}_ch{channel}.png", dpi=160)
47
+ plt.close(figure)
48
+ print({"input_dir": str(input_dir), "output_dir": str(output_dir), "files": len(files)})
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
scripts/train.py ADDED
@@ -0,0 +1,284 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OneForecast training entry point with integrated data checking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ from pathlib import Path
8
+ import sys
9
+ import random
10
+
11
+ import numpy as np
12
+ import torch
13
+ import torch.distributed as dist
14
+ from torch.nn import functional as F
15
+ from torch.autograd import Function
16
+ from torch.nn.parallel import DistributedDataParallel
17
+
18
+ import yaml
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
21
+
22
+ from model.era5_adapter import OFFICIAL_VARIABLES, OneForecastERA5Adapter
23
+ from model.oneforecast import build_model, check_checkpoint_compatibility
24
+
25
+
26
+ def _resolve_path(value: str | Path, config_path: Path) -> Path:
27
+ path = Path(value).expanduser()
28
+ return path if path.is_absolute() else (config_path.parent.parent / path).resolve()
29
+
30
+
31
+ def _load_config(path: Path) -> dict:
32
+ with path.open("r", encoding="utf-8") as handle:
33
+ config = yaml.safe_load(handle)
34
+ config["datapipe"]["dataset_dir"] = str(_resolve_path(config["datapipe"]["dataset_dir"], path))
35
+ config["model"]["official_checkpoint_path"] = str(
36
+ _resolve_path(config["model"]["official_checkpoint_path"], path)
37
+ )
38
+ config["model"]["checkpoint_path"] = config["model"]["official_checkpoint_path"]
39
+ config["training"]["checkpoint_dir"] = str(_resolve_path(config["training"]["checkpoint_dir"], path))
40
+ return config
41
+
42
+
43
+ def _set_seed(seed: int) -> None:
44
+ random.seed(seed)
45
+ np.random.seed(seed)
46
+ torch.manual_seed(seed)
47
+
48
+
49
+ def _resolve_device(name: str) -> torch.device:
50
+ """Map the logical DCU name to the backend exposed by this PyTorch build."""
51
+ requested = str(name).lower()
52
+ if requested == "dcu":
53
+ if torch.cuda.is_available():
54
+ return torch.device("cuda")
55
+ privateuse = torch._C._get_privateuse1_backend_name()
56
+ if privateuse != "privateuseone":
57
+ return torch.device(privateuse)
58
+ raise RuntimeError("runtime.device=dcu, but this PyTorch build exposes no usable accelerator")
59
+ if requested == "auto":
60
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
61
+ device = torch.device(requested)
62
+ if device.type == "cuda" and not torch.cuda.is_available():
63
+ raise RuntimeError("runtime.device=cuda, but torch.cuda.is_available() is False")
64
+ return device
65
+
66
+
67
+ def _setup_distributed(device_name: str, backend: str = "nccl") -> tuple[torch.device, int, int, bool]:
68
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
69
+ distributed = world_size > 1
70
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
71
+ if distributed:
72
+ device = _resolve_device(device_name)
73
+ if device.type == "cuda":
74
+ torch.cuda.set_device(local_rank)
75
+ device = torch.device("cuda", local_rank)
76
+ dist.init_process_group(backend=backend, init_method="env://")
77
+ return device, dist.get_rank(), world_size, True
78
+ return _resolve_device(device_name), 0, 1, False
79
+
80
+
81
+ def _reduce_metrics(total: float, count: int, device: torch.device, distributed: bool) -> float:
82
+ metrics = torch.tensor([total, count], dtype=torch.float64, device=device)
83
+ if distributed:
84
+ dist.all_reduce(metrics, op=dist.ReduceOp.SUM)
85
+ return float(metrics[0] / metrics[1].clamp_min(1))
86
+
87
+
88
+ def _loader_batch(batch: tuple) -> tuple[torch.Tensor, torch.Tensor]:
89
+ inputs, targets = batch[0], batch[1]
90
+ if inputs.ndim == 5 or targets.ndim == 5:
91
+ raise ValueError("OneForecast currently supports input_steps=1 and output_steps=1 only")
92
+ if inputs.ndim != 4 or targets.ndim != 4:
93
+ raise ValueError(f"Expected batched fields with four dimensions, got {inputs.shape} and {targets.shape}")
94
+ if inputs.shape[-2] == 121:
95
+ inputs = inputs[..., :120, :]
96
+ if targets.shape[-2] == 121:
97
+ targets = targets[..., :120, :]
98
+ if inputs.shape[-2:] != (120, 240) or targets.shape[-2:] != (120, 240):
99
+ raise ValueError(f"Expected official model grid 120x240, got {inputs.shape} and {targets.shape}")
100
+ return torch.nan_to_num(inputs.float()), torch.nan_to_num(targets.float())
101
+
102
+
103
+ class _LossScaleFunction(Function):
104
+ @staticmethod
105
+ def forward(ctx, values: torch.Tensor, eps: float) -> torch.Tensor:
106
+ ctx.eps = eps
107
+ return values
108
+
109
+ @staticmethod
110
+ def backward(ctx, gradients: torch.Tensor) -> tuple[torch.Tensor, None]:
111
+ channels = gradients.shape[1]
112
+ weights = 1.0 / gradients.norm(p=2, dim=(-1, -2), keepdim=True).clamp_min(ctx.eps)
113
+ weights = weights / weights.sum(dim=1, keepdim=True).clamp_min(ctx.eps)
114
+ return channels * weights * gradients, None
115
+
116
+
117
+ def _relative_channel_l2(prediction: torch.Tensor, target: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
118
+ difference = (prediction - target).flatten(2).norm(p=2, dim=2)
119
+ target_norm = target.flatten(2).norm(p=2, dim=2).clamp_min(1e-10)
120
+ channel_loss = (difference / target_norm).mean(dim=0)
121
+ return channel_loss.mean(), channel_loss
122
+
123
+
124
+ def check_data(config: dict) -> dict:
125
+ settings = config["datapipe"]
126
+ adapter = OneForecastERA5Adapter(
127
+ settings["dataset_dir"], settings["train_years"],
128
+ batch_size=settings["batch_size"], input_steps=settings["input_steps"],
129
+ output_steps=settings["output_steps"], normalize=settings["normalize"],
130
+ num_workers=settings["num_workers"],
131
+ )
132
+ report = adapter.inspect()
133
+ print(report)
134
+ return report
135
+
136
+
137
+ def main() -> None:
138
+ parser = argparse.ArgumentParser()
139
+ parser.add_argument("--config", type=Path, default=Path("conf/config.yaml"))
140
+ parser.add_argument("--check-data", action="store_true")
141
+ parser.add_argument("--check-model", action="store_true")
142
+ parser.add_argument("--check-checkpoint", action="store_true")
143
+ parser.add_argument("--check-distributed", action="store_true")
144
+ parser.add_argument("--device", default=None)
145
+ parser.add_argument("--distributed-backend", default=None)
146
+ parser.add_argument("--max-epochs", type=int, default=None)
147
+ parser.add_argument("--max-batches", type=int, default=None)
148
+ parser.add_argument("--weight-init", choices=("scratch", "official"), default=None)
149
+ args = parser.parse_args()
150
+ config = _load_config(args.config.resolve())
151
+ if args.device is not None:
152
+ config["runtime"]["device"] = args.device
153
+ if args.distributed_backend is not None:
154
+ config["runtime"]["distributed_backend"] = args.distributed_backend
155
+ if args.max_epochs is not None:
156
+ config["training"]["max_epoch"] = args.max_epochs
157
+ if args.max_batches is not None:
158
+ config["training"]["max_batches"] = args.max_batches
159
+ if tuple(config["datapipe"]["variables"]) != OFFICIAL_VARIABLES:
160
+ raise ValueError("datapipe.variables must exactly match the official 69-channel order")
161
+ if args.weight_init is not None:
162
+ config["model"]["weight_init"] = args.weight_init
163
+ if config["model"].get("weight_init") == "official":
164
+ config["model"]["checkpoint_path"] = config["model"]["official_checkpoint_path"]
165
+ if args.check_data:
166
+ check_data(config)
167
+ return
168
+ if args.check_model:
169
+ configured_init = config["model"].get("weight_init", "scratch")
170
+ config["model"]["weight_init"] = "scratch"
171
+ with __import__("torch").device("meta"):
172
+ model = build_model(config, build_graph=False)
173
+ print({"model": type(model).__name__, "parameters": sum(p.numel() for p in model.parameters()),
174
+ "configured_weight_init": configured_init})
175
+ return
176
+ if args.check_checkpoint:
177
+ with __import__("torch").device("meta"):
178
+ model = build_model(config, build_graph=False)
179
+ report = check_checkpoint_compatibility(
180
+ model, config["model"]["official_checkpoint_path"]
181
+ )
182
+ print(report)
183
+ if not report.compatible:
184
+ raise SystemExit(1)
185
+ return
186
+ if args.check_distributed:
187
+ device, rank, world_size, distributed = _setup_distributed(
188
+ config["runtime"].get("device", "cpu"), config["runtime"].get("distributed_backend", "nccl")
189
+ )
190
+ settings = config["datapipe"]
191
+ adapter = OneForecastERA5Adapter(
192
+ settings["dataset_dir"], settings["train_years"], batch_size=settings["batch_size"],
193
+ input_steps=settings["input_steps"], output_steps=settings["output_steps"],
194
+ normalize=settings["normalize"], num_workers=0, distributed=distributed,
195
+ )
196
+ loader, sampler = adapter.get_dataloader("train")
197
+ sample_indices = list(iter(sampler)) if sampler is not None else list(range(len(loader.dataset)))
198
+ print({"rank": rank, "world_size": world_size, "distributed": distributed,
199
+ "backend": dist.get_backend() if distributed else None, "device": str(device),
200
+ "sampler": type(sampler).__name__ if sampler is not None else None,
201
+ "sample_indices": sample_indices})
202
+ if distributed:
203
+ dist.barrier()
204
+ dist.destroy_process_group()
205
+ return
206
+ settings = config["datapipe"]
207
+ if settings["input_steps"] != 1 or settings["output_steps"] != 1:
208
+ raise SystemExit("OneForecast training currently requires input_steps=1 and output_steps=1")
209
+ device, rank, world_size, distributed = _setup_distributed(
210
+ config["runtime"].get("device", "cpu"), config["runtime"].get("distributed_backend", "nccl")
211
+ )
212
+ _set_seed(int(config["runtime"].get("seed", 42)))
213
+ model = build_model(config).to(device)
214
+ if distributed:
215
+ ddp_devices = {"device_ids": [device.index], "output_device": device.index} if device.type == "cuda" else {}
216
+ model = DistributedDataParallel(model, broadcast_buffers=False, **ddp_devices)
217
+ optimizer = torch.optim.Adam(
218
+ model.parameters(), lr=float(config["training"]["learning_rate"]),
219
+ )
220
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(
221
+ optimizer, T_max=max(1, int(config["training"]["max_epoch"])),
222
+ )
223
+ train_adapter = OneForecastERA5Adapter(
224
+ _resolve_path(settings["dataset_dir"], args.config), settings["train_years"],
225
+ batch_size=settings["batch_size"], input_steps=1, output_steps=1,
226
+ normalize=settings["normalize"], num_workers=settings["num_workers"], distributed=distributed,
227
+ )
228
+ valid_adapter = OneForecastERA5Adapter(
229
+ _resolve_path(settings["dataset_dir"], args.config), settings["valid_years"],
230
+ batch_size=settings["batch_size"], input_steps=1, output_steps=1,
231
+ normalize=settings["normalize"], num_workers=settings["num_workers"], distributed=distributed,
232
+ )
233
+ train_loader, train_sampler = train_adapter.get_dataloader("train")
234
+ valid_loader, valid_sampler = valid_adapter.get_dataloader("val")
235
+ checkpoint_dir = Path(config["training"]["checkpoint_dir"])
236
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
237
+ max_batches = int(config["training"].get("max_batches", -1))
238
+ for epoch in range(int(config["training"]["start_epoch"]), int(config["training"]["max_epoch"])):
239
+ if train_sampler is not None:
240
+ train_sampler.set_epoch(epoch)
241
+ if valid_sampler is not None:
242
+ valid_sampler.set_epoch(epoch)
243
+ model.train()
244
+ train_loss = 0.0
245
+ train_batches = 0
246
+ for batch in train_loader:
247
+ inputs, targets = _loader_batch(batch)
248
+ optimizer.zero_grad(set_to_none=True)
249
+ prediction = _LossScaleFunction.apply(model(inputs.to(device)), 1e-5)
250
+ loss, _ = _relative_channel_l2(prediction, targets.to(device))
251
+ loss.backward()
252
+ optimizer.step()
253
+ train_loss += float(loss.detach())
254
+ train_batches += 1
255
+ if max_batches >= 0 and train_batches >= max_batches:
256
+ break
257
+ model.eval()
258
+ valid_loss = 0.0
259
+ with torch.no_grad():
260
+ valid_batches = 0
261
+ for batch in valid_loader:
262
+ inputs, targets = _loader_batch(batch)
263
+ prediction = model(inputs.to(device))
264
+ valid_loss += float(F.mse_loss(prediction, targets.to(device)))
265
+ valid_batches += 1
266
+ if max_batches >= 0 and valid_batches >= max_batches:
267
+ break
268
+ train_mean = _reduce_metrics(train_loss, train_batches, device, distributed)
269
+ valid_mean = _reduce_metrics(valid_loss, valid_batches, device, distributed)
270
+ if rank == 0:
271
+ print({"epoch": epoch + 1, "train_loss": train_mean, "valid_loss": valid_mean,
272
+ "world_size": world_size})
273
+ if rank == 0 and (epoch + 1) % int(config["training"].get("save_every_epoch", 1)) == 0:
274
+ model_name = config["training"].get("model_name", "model_bak")
275
+ state = model.module.state_dict() if distributed else model.state_dict()
276
+ torch.save({"model_state": state, "epoch": epoch + 1, "world_size": world_size},
277
+ checkpoint_dir / f"{model_name}.tar")
278
+ scheduler.step()
279
+ if distributed:
280
+ dist.destroy_process_group()
281
+
282
+
283
+ if __name__ == "__main__":
284
+ main()
weight/.gitkeep ADDED
File without changes