zhangrenchao commited on
Commit
7f71cfd
·
verified ·
1 Parent(s): 1b38903

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -1,35 +1,3 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pt filter=lfs diff=lfs merge=lfs -text
2
+ *.npz filter=lfs diff=lfs merge=lfs -text
3
+ *.png binary
 
 
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ tags:
6
+ - OneScience
7
+ - Earth Science
8
+ - Physics Parameterization
9
+ frameworks: PyTorch
10
+ ---
11
+
12
+ <p align="center">
13
+ <strong><span style="font-size: 30px;">StableNN-Phys</span></strong>
14
+ </p>
15
+
16
+ # Model Introduction
17
+
18
+ StableNN-Phys is an engineering reproduction of a neural-network unified physics parameterization for an atmospheric single-column model, continuously predicting thermodynamic-state evolution at three-hour intervals from column states and surface fluxes.
19
+
20
+ Paper: Prognostic Validation of a Neural Network Unified Physics Parameterization
21
+ https://doi.org/10.1029/2018GL078510
22
+
23
+ # Model Description
24
+
25
+ The method reproduced by StableNN-Phys was proposed by researchers from the Department of Atmospheric Sciences at the University of Washington. The model takes 34-level liquid-water static energy and total-water states with surface sensible heat, latent heat, and incoming solar radiation, maps 71 input features to 68 physics tendencies at each step, and integrates multiple steps with advective forcing and no teacher forcing. The model is suitable for neural physics-parameterization training, three-hourly single-column prediction, 64-step long-term stability validation, and column-water-budget diagnosis.
26
+
27
+ # Use Cases
28
+
29
+ | Use Case | Description |
30
+ | :---: | :--- |
31
+ | Single-column prediction | Continuously predict three-hourly state evolution from 34-level thermodynamic states and surface fluxes. |
32
+ | Physics-parameterization training | Learn the mapping from 71 input features to 68 physics tendencies with `T=20` multistep windows. |
33
+ | Long-term stability validation | Run a fixed 64-step, eight-day single-column integration without teacher forcing. |
34
+ | Water-budget evaluation | Diagnose precipitation from column-water storage, latent heat flux, and advective moisture convergence. |
35
+ | Hugging Face/OneCode execution | Validate training, inference, evaluation, visualization, and checkpoint workflows in Hugging Face or OneCode environments. |
36
+
37
+ # Usage Instructions
38
+
39
+ ## 1.OneCode
40
+
41
+ Experience intelligent, one-click AI4S programming through the OneCode online environment:
42
+
43
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
44
+
45
+ ## 2. Download and Installation
46
+
47
+ ```bash
48
+ hf download OneScience-Group/StableNN-Phys --local-dir ./StableNN-Phys
49
+ cd StableNN-Phys
50
+ ```
51
+
52
+ ### Environment Dependencies
53
+
54
+ **Hardware Requirements**
55
+
56
+ - A GPU or DCU is recommended.
57
+ - A CPU can be used for connectivity validation with the default small-sample configuration.
58
+ - DCU users must install DTK first. DTK 25.04.2 or later, or the OneScience-recommended version matching the cluster, is recommended.
59
+
60
+ **DCU Environment**
61
+
62
+ ```bash
63
+ # Activate DTK and Conda first
64
+ conda create -n onescience311 python=3.11 -y
65
+ conda activate onescience311
66
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
67
+ ```
68
+
69
+ **GPU Environment**
70
+
71
+ ```bash
72
+ # Activate Conda first
73
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
74
+ conda activate onescience311
75
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
76
+ ```
77
+
78
+ ### Training Data
79
+
80
+ Training data consist of atmospheric-column thermodynamic states and external physical forcing. Inputs contain 34-level static energy, 34-level total water, surface fluxes, and insolation for 71 values. Targets are 34-level temperature and moisture physics tendencies for the same column, giving 68 values. Samples form continuous `T=20` windows at three-hour intervals. This repository uses a small amount of synthetic data to validate training, inference, and evaluation and does not represent the paper data distribution, training scale, or formal performance.
81
+
82
+ ```bash
83
+ python scripts/fake_data.py
84
+ ```
85
+
86
+ ### Training
87
+
88
+ For single-device training, use:
89
+
90
+ ```bash
91
+ python scripts/train.py
92
+ ```
93
+
94
+ For multi-GPU training, use:
95
+
96
+ ```bash
97
+ torchrun --nproc_per_node=8 --nnodes=1 --rdzv_id=1000 --rdzv_backend=c10d --max_restarts=0 --master_addr="localhost" --master_port=29500 scripts/train.py
98
+ ```
99
+
100
+ Training uses Adam to fit `T=20` multistep state sequences, with layer-mass-weighted MAD as the default `paper` loss. The recorded paper configuration uses a learning rate of 0.01, batch size 200, five epochs, and hidden width 128, while the default engineering configuration reduces the hidden width and sample count for quick workflow validation; results are saved to:
101
+
102
+ ```text
103
+ result/checkpoints/stablenn_phys.pt
104
+ result/training/metrics.json
105
+ ```
106
+
107
+ ### Trained Weights
108
+
109
+ This repository does not include pretrained weights under `weight/`. Checkpoints generated with synthetic data validate the engineering workflow only and are not official paper weights.
110
+
111
+ ### Inference
112
+
113
+ ```bash
114
+ python scripts/inference.py
115
+ ```
116
+
117
+ Inference performs a fixed 64-step, eight-day single-column rollout without teacher forcing and saves the complete state, tendency, and forcing sequence at every three-hour time. Complete numerical results are saved to `result/output/rollout.npz`.
118
+
119
+ ### Evaluation and Visualization
120
+
121
+ ```bash
122
+ python scripts/result.py
123
+ ```
124
+
125
+ Evaluation reports length-65 per-lead mass-weighted MAD and bias and computes R2 by data source. Precipitation is diagnosed from column-water storage, latent heat flux, and advective moisture convergence while the budget residual is checked; synthetic-data results validate the engineering workflow only and do not represent formal paper performance. Structured results and the figure are saved to:
126
+
127
+ ```text
128
+ result/evaluation/metrics.json
129
+ result/evaluation/state_precipitation_timeseries.png
130
+ ```
131
+
132
+ # Official OneScience Information
133
+
134
+ | Platform | OneScience Main Repository | Skills Repository |
135
+ | --- | --- | --- |
136
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
137
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
138
+
139
+ # Citation and License
140
+
141
+ This repository is an independent engineering reproduction of the public StableNN-Phys specifications.
142
+
143
+ Use of this repository's code, official model weights, and data remains subject to the licenses and terms of their respective projects.
conf/config.yaml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 42
2
+ data:
3
+ file: data/stablenn_phys_virtual.npz
4
+ format_version: stablenn_phys_v1
5
+ levels: 34
6
+ window_steps: 20
7
+ columns: 6
8
+ windows_per_column: 4
9
+ step_hours: 3
10
+ model:
11
+ name: StableNN-Phys
12
+ hidden_size: 32
13
+ paper_hidden_size: 128
14
+ input_features: 71
15
+ output_features: 68
16
+ train:
17
+ epochs: 3
18
+ batch_size: 4
19
+ learning_rate: 0.003
20
+ loss_mode: paper
21
+ paper_model:
22
+ hidden_size: 128
23
+ learning_rate: 0.01
24
+ batch_size: 200
25
+ epochs: 5
26
+ runtime:
27
+ device: auto
28
+ rollout_steps: 64
29
+ paths:
30
+ checkpoint: result/checkpoints/stablenn_phys.pt
31
+ training_metrics: result/training/metrics.json
32
+ inference: result/output/rollout.npz
33
+ evaluation: result/evaluation/metrics.json
config.json ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "StableNN-Phys",
3
+ "model_type": "stablenn_phys",
4
+ "architectures": ["StableNNPhys"],
5
+ "framework": "PyTorch",
6
+ "domain": "atmospheric physics",
7
+ "task": "single-column prognostic rollout",
8
+ "implementation": {
9
+ "entry_point": "model/stablenn_phys.py",
10
+ "scope": "Core-method, full-window and full-vertical-dimension reduced-sample engineering reproduction",
11
+ "train_script": "scripts/train.py",
12
+ "inference_script": "scripts/inference.py",
13
+ "evaluation_script": "scripts/result.py",
14
+ "synthetic_data_script": "scripts/fake_data.py"
15
+ },
16
+ "architecture": {
17
+ "levels": 34,
18
+ "input_features": 71,
19
+ "output_features": 68,
20
+ "engineering_hidden_size": 32,
21
+ "paper_hidden_size": 128,
22
+ "activation": "ReLU",
23
+ "linear_bypass": true
24
+ },
25
+ "integration": {
26
+ "step_hours": 3,
27
+ "training_window_steps": 20,
28
+ "rollout_steps": 64,
29
+ "forcing": "trapezoidal horizontal-advection update before neural-network Euler physics update",
30
+ "teacher_forcing": false
31
+ },
32
+ "checkpoint": {
33
+ "path": "result/checkpoints/stablenn_phys.pt",
34
+ "required_fields": ["model", "model_config", "format_version"],
35
+ "format_version": "stablenn_phys_checkpoint_v1"
36
+ },
37
+ "loss_modes": {
38
+ "default": "paper",
39
+ "paper": "layer-mass-weighted MAD of prognostic state errors over all rollout steps",
40
+ "official_v0_3": "equal-level normalized MAD of prognostic state errors over all rollout steps"
41
+ }
42
+ }
configuration.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "atmospheric_single_column_rollout",
4
+ "model": "StableNN-Phys",
5
+ "config": "config.json",
6
+ "input_format": "sL [B,34] + qT [B,34] + SHF/LHF/SOLIN [B,3] = [B,71]",
7
+ "output_format": "sL and qT tendencies [B,68]",
8
+ "training_window_steps": 20,
9
+ "rollout_steps": 64,
10
+ "per_step_length": 65,
11
+ "step_hours": 3,
12
+ "default_config": "conf/config.yaml",
13
+ "training": "scripts/train.py",
14
+ "inference": "scripts/inference.py",
15
+ "evaluation": "scripts/result.py",
16
+ "visualization": "scripts/result.py"
17
+ }
model/stablenn_phys.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ from torch import nn
3
+
4
+
5
+ class StableNNPhys(nn.Module):
6
+ def __init__(self, hidden_size=32, input_size=71, output_size=68):
7
+ super().__init__()
8
+ self.hidden_size = hidden_size
9
+ self.input_size = input_size
10
+ self.output_size = output_size
11
+ self.hidden = nn.Linear(input_size, hidden_size)
12
+ self.output = nn.Linear(hidden_size, output_size)
13
+ self.bypass = nn.Linear(input_size, output_size)
14
+ nn.init.zeros_(self.output.weight)
15
+ nn.init.zeros_(self.output.bias)
16
+ nn.init.zeros_(self.bypass.weight)
17
+ nn.init.zeros_(self.bypass.bias)
18
+
19
+ def forward(self, x):
20
+ return self.output(torch.relu(self.hidden(x))) + self.bypass(x)
21
+
22
+
23
+ def rollout(model, initial_state, surface, advection, state_mean, state_std,
24
+ tendency_mean, tendency_std, dt_seconds=10800.0):
25
+ """Integrate advection trapezoidally, then neural physics with Euler."""
26
+ states = [initial_state]
27
+ physics = []
28
+ state = initial_state
29
+ for step in range(surface.shape[1]):
30
+ adv_now = advection[:, step]
31
+ adv_next = advection[:, min(step + 1, advection.shape[1] - 1)]
32
+ forced = state + 0.5 * dt_seconds * (adv_now + adv_next)
33
+ surface_scaled = surface[:, step] / surface.new_tensor([100.0, 100.0, 1000.0])
34
+ features = torch.cat(((forced - state_mean) / state_std, surface_scaled), dim=-1)
35
+ tendency = model(features) * tendency_std + tendency_mean
36
+ state = forced + dt_seconds * tendency
37
+ physics.append(tendency)
38
+ states.append(state)
39
+ return torch.stack(states, dim=1), torch.stack(physics, dim=1)
40
+
41
+
42
+ def rollout_loss(prediction, target, layer_mass, mode="paper"):
43
+ error = torch.abs(prediction[:, 1:] - target[:, 1:])
44
+ if mode == "paper":
45
+ weights = torch.cat((layer_mass, layer_mass), dim=-1)
46
+ weights = weights / weights.mean(dim=-1, keepdim=True)
47
+ return (error * weights[:, None]).mean()
48
+ if mode == "official_v0_3":
49
+ scale = target[:, 1:].std(dim=(0, 1), unbiased=False).clamp_min(1e-6)
50
+ return (error / scale).mean()
51
+ raise ValueError(f"Unknown loss mode: {mode}")
scripts/fake_data.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import math
3
+ import yaml
4
+ import numpy as np
5
+
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+
9
+
10
+ def main():
11
+ cfg = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
12
+ rng = np.random.default_rng(cfg["seed"])
13
+ ncol = cfg["data"]["columns"]
14
+ nwin = cfg["data"]["windows_per_column"]
15
+ steps = cfg["data"]["window_steps"]
16
+ levels = cfg["data"]["levels"]
17
+ dt = cfg["data"]["step_hours"] * 3600.0
18
+ total_steps = nwin * steps + 64
19
+ sigma = np.linspace(0.02, 0.995, levels, dtype=np.float32)
20
+ pressure = 100000.0 * sigma
21
+ interfaces = np.linspace(0.0, 100000.0, levels + 1, dtype=np.float32)
22
+ layer_mass = np.diff(interfaces) / 9.80665
23
+ time = np.arange(total_steps + 1, dtype=np.float32)
24
+ states = np.empty((ncol, total_steps + 1, 68), dtype=np.float32)
25
+ surface = np.empty((ncol, total_steps, 3), dtype=np.float32)
26
+ advection = np.empty((ncol, total_steps + 1, 68), dtype=np.float32)
27
+ for c in range(ncol):
28
+ phase = 2.0 * math.pi * c / ncol
29
+ sl0 = 2.82e5 + 1.25e5 * (1.0 - sigma) + 1400.0 * np.sin(math.pi * sigma + phase)
30
+ qt0 = 0.0175 * sigma ** 2.6 + 0.00012 + 0.0004 * np.sin(2 * math.pi * sigma + phase)
31
+ states[c, 0] = np.concatenate((sl0, np.maximum(qt0, 2e-5)))
32
+ solar = np.maximum(0.0, np.sin(2.0 * math.pi * (time[:-1] % 8.0) / 8.0 + phase))
33
+ surface[c, :, 0] = 30.0 + 18.0 * np.sin(2 * math.pi * time[:-1] / 8.0 + phase)
34
+ surface[c, :, 1] = 92.0 + 34.0 * np.sin(2 * math.pi * time[:-1] / 8.0 + phase - 0.5)
35
+ surface[c, :, 2] = 760.0 * solar
36
+ vertical = np.sin(math.pi * sigma)
37
+ for t in range(total_steps + 1):
38
+ wave = math.sin(2 * math.pi * t / 16.0 + phase)
39
+ advection[c, t, :levels] = (0.010 * wave * vertical + rng.normal(0, 0.0003, levels))
40
+ advection[c, t, levels:] = (1.4e-9 * wave * vertical + rng.normal(0, 4e-11, levels))
41
+ for t in range(total_steps):
42
+ adv = 0.5 * (advection[c, t] + advection[c, t + 1])
43
+ sl, qt = np.split(states[c, t], 2)
44
+ shf, lhf, solin = surface[c, t]
45
+ heat = 2.0e-7 * (300000.0 - sl) + 1.0e-5 * shf * sigma + 1.0e-7 * solin * vertical
46
+ moist = 1.4e-7 * (0.012 * sigma ** 2.4 - qt) + 2.5e-11 * lhf * sigma
47
+ next_state = states[c, t] + dt * (adv + np.concatenate((heat, moist)))
48
+ next_state[levels:] = np.maximum(next_state[levels:], 1e-6)
49
+ states[c, t + 1] = next_state
50
+ initial, targets, surf_windows, adv_windows, source = [], [], [], [], []
51
+ for c in range(ncol):
52
+ for w in range(nwin):
53
+ start = w * steps
54
+ initial.append(states[c, start])
55
+ targets.append(states[c, start:start + steps + 1])
56
+ surf_windows.append(surface[c, start:start + steps])
57
+ adv_windows.append(advection[c, start:start + steps + 1])
58
+ source.append(c)
59
+ out = ROOT / cfg["data"]["file"]
60
+ out.parent.mkdir(parents=True, exist_ok=True)
61
+ np.savez_compressed(out, initial=np.array(initial), target=np.array(targets),
62
+ surface=np.array(surf_windows), advection=np.array(adv_windows),
63
+ layer_mass=layer_mass, pressure=pressure, source=np.array(source),
64
+ long_state=states, long_surface=surface, long_advection=advection,
65
+ dt_seconds=np.float32(dt), format_version=cfg["data"]["format_version"])
66
+ print(f"saved {out}: windows={len(initial)}, T={steps}, levels={levels}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import sys
3
+
4
+ import numpy as np
5
+ import torch
6
+ import yaml
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ from model.stablenn_phys import StableNNPhys, rollout
14
+
15
+
16
+ CHECKPOINT_FORMAT_VERSION = "stablenn_phys_checkpoint_v1"
17
+ REQUIRED_CHECKPOINT_FIELDS = {"model", "model_config", "format_version"}
18
+
19
+
20
+ def main():
21
+ cfg = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
22
+ raw = np.load(ROOT / cfg["data"]["file"])
23
+ checkpoint = torch.load(ROOT / cfg["paths"]["checkpoint"], map_location="cpu", weights_only=False)
24
+ missing = REQUIRED_CHECKPOINT_FIELDS.difference(checkpoint)
25
+ if missing:
26
+ raise ValueError(f"Checkpoint is missing required fields: {', '.join(sorted(missing))}")
27
+ if checkpoint["format_version"] != CHECKPOINT_FORMAT_VERSION:
28
+ raise ValueError(
29
+ f"Unsupported checkpoint format_version {checkpoint['format_version']!r}; "
30
+ f"expected {CHECKPOINT_FORMAT_VERSION!r}"
31
+ )
32
+ model = StableNNPhys(checkpoint["model_config"]["hidden_size"])
33
+ model.load_state_dict(checkpoint["model"])
34
+ model.eval()
35
+ steps = cfg["runtime"]["rollout_steps"]
36
+ if steps != 64:
37
+ raise ValueError(f"runtime.rollout_steps must be 64, got {steps}")
38
+ initial = torch.from_numpy(raw["long_state"][:, 0].astype(np.float32))
39
+ surface = torch.from_numpy(raw["long_surface"][:, :steps].astype(np.float32))
40
+ advection = torch.from_numpy(raw["long_advection"][:, :steps + 1].astype(np.float32))
41
+ norm = checkpoint["normalization"]
42
+ with torch.no_grad():
43
+ prediction, tendency = rollout(model, initial, surface, advection, norm["state_mean"], norm["state_std"],
44
+ norm["tendency_mean"], norm["tendency_std"], checkpoint["dt_seconds"])
45
+ target = raw["long_state"][:, :steps + 1]
46
+ out = ROOT / cfg["paths"]["inference"]
47
+ out.parent.mkdir(parents=True, exist_ok=True)
48
+ np.savez_compressed(out, prediction=prediction.numpy(), target=target, tendency=tendency.numpy(),
49
+ surface=surface.numpy(), advection=advection.numpy(), layer_mass=raw["layer_mass"],
50
+ source=np.arange(initial.shape[0]),
51
+ lead_hours=np.arange(steps + 1) * cfg["data"]["step_hours"],
52
+ dt_seconds=np.float32(checkpoint["dt_seconds"]))
53
+ print(f"saved {out}: {steps} steps, {steps * 3 / 24:.1f} days")
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
scripts/result.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import matplotlib
5
+ matplotlib.use("Agg")
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ import yaml
9
+
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+
13
+
14
+ def main():
15
+ cfg = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
16
+ data = np.load(ROOT / cfg["paths"]["inference"])
17
+ pred, target = data["prediction"], data["target"]
18
+ if pred.shape != target.shape or pred.shape[1:] != (65, 68):
19
+ raise ValueError(f"Expected prediction and target shaped [N,65,68], got {pred.shape} and {target.shape}")
20
+ mass = np.concatenate((data["layer_mass"], data["layer_mass"]))
21
+ mass = mass / mass.mean()
22
+ error = pred - target
23
+ mad = (np.abs(error) * mass[None, None]).mean(axis=(0, 2))
24
+ bias = error.mean(axis=(0, 2))
25
+ flat_target = target[:, 1:].astype(np.float64).reshape(target.shape[0], -1)
26
+ flat_pred = pred[:, 1:].astype(np.float64).reshape(pred.shape[0], -1)
27
+ source_r2 = []
28
+ for truth, estimate in zip(flat_target, flat_pred):
29
+ denom = np.sum((truth - truth.mean()) ** 2)
30
+ source_r2.append(float(1.0 - np.sum((truth - estimate) ** 2) / max(denom, 1e-12)))
31
+ dt = float(data["dt_seconds"])
32
+ qt_pred, qt_target = pred[..., 34:], target[..., 34:]
33
+ column_water_pred = (qt_pred * data["layer_mass"][None, None]).sum(-1)
34
+ column_water_target = (qt_target * data["layer_mass"][None, None]).sum(-1)
35
+ lhf_evap = data["surface"][:, :, 1] / 2.5e6
36
+ adv_q = (0.5 * (data["advection"][:, :-1, 34:] + data["advection"][:, 1:, 34:]) *
37
+ data["layer_mass"][None, None]).sum(-1)
38
+ storage = np.diff(column_water_pred, axis=1) / dt
39
+ precipitation = np.maximum(0.0, lhf_evap + adv_q - storage)
40
+ residual = storage + precipitation - lhf_evap - adv_q
41
+ metrics = {"mass_weighted_mad_per_step": mad.tolist(), "bias_per_step": bias.tolist(),
42
+ "source_r2": source_r2, "mean_source_r2": float(np.mean(source_r2)),
43
+ "water_budget": {"mean_precipitation_kg_m2_s": float(precipitation.mean()),
44
+ "mean_abs_residual_kg_m2_s": float(np.abs(residual).mean())},
45
+ "per_step_length": int(len(mad)), "step_hours": cfg["data"]["step_hours"]}
46
+ out = ROOT / cfg["paths"]["evaluation"]
47
+ out.parent.mkdir(parents=True, exist_ok=True)
48
+ out.write_text(json.dumps(metrics, indent=2))
49
+ hours = data["lead_hours"]
50
+ fig, axes = plt.subplots(2, 1, figsize=(9, 7), constrained_layout=True)
51
+ axes[0].plot(hours, target[:, :, :34].mean((0, 2)), label="target sL")
52
+ axes[0].plot(hours, pred[:, :, :34].mean((0, 2)), "--", label="predicted sL")
53
+ ax2 = axes[0].twinx()
54
+ ax2.plot(hours, target[:, :, 34:].mean((0, 2)), color="tab:green", label="target qT")
55
+ ax2.plot(hours, pred[:, :, 34:].mean((0, 2)), "--", color="tab:red", label="predicted qT")
56
+ axes[0].set(xlabel="lead time (h)", title="SCM state rollout")
57
+ axes[0].legend(loc="upper left")
58
+ ax2.legend(loc="upper right")
59
+ axes[1].plot(hours[1:], precipitation.mean(0) * 86400.0, color="navy")
60
+ axes[1].set(xlabel="lead time (h)", ylabel="mm day-1", title="Diagnosed precipitation")
61
+ fig.savefig(out.parent / "state_precipitation_timeseries.png", dpi=150)
62
+ plt.close(fig)
63
+ print(f"saved {out}: per_step={len(mad)}, source_R2={metrics['mean_source_r2']:.4f}")
64
+
65
+
66
+ if __name__ == "__main__":
67
+ main()
scripts/train.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ import numpy as np
7
+ import torch
8
+ import torch.distributed as dist
9
+ from torch.nn.parallel import DistributedDataParallel
10
+ from torch.utils.data import DataLoader, DistributedSampler, TensorDataset
11
+ import yaml
12
+
13
+
14
+ ROOT = Path(__file__).resolve().parents[1]
15
+ if str(ROOT) not in sys.path:
16
+ sys.path.insert(0, str(ROOT))
17
+
18
+ from model.stablenn_phys import StableNNPhys, rollout, rollout_loss
19
+
20
+
21
+ CHECKPOINT_FORMAT_VERSION = "stablenn_phys_checkpoint_v1"
22
+
23
+
24
+ def main():
25
+ cfg = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
26
+ torch.manual_seed(cfg["seed"])
27
+ distributed = int(os.environ.get("WORLD_SIZE", "1")) > 1
28
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
29
+ use_accelerator = torch.cuda.is_available() and torch.cuda.device_count() >= world_size
30
+ if distributed:
31
+ dist.init_process_group("nccl" if use_accelerator else "gloo")
32
+ rank = dist.get_rank() if distributed else 0
33
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
34
+ device = torch.device(f"cuda:{local_rank}" if use_accelerator else "cpu")
35
+ if use_accelerator:
36
+ torch.cuda.set_device(device)
37
+ raw = np.load(ROOT / cfg["data"]["file"])
38
+ tensors = [torch.from_numpy(raw[name].astype(np.float32)) for name in ("initial", "target", "surface", "advection")]
39
+ source = torch.from_numpy(raw["source"].astype(np.int64))
40
+ dataset = TensorDataset(*tensors, source)
41
+ sampler = DistributedSampler(dataset, shuffle=True, seed=cfg["seed"]) if distributed else None
42
+ loader = DataLoader(dataset, batch_size=cfg["train"]["batch_size"], sampler=sampler,
43
+ shuffle=sampler is None)
44
+ all_state = torch.from_numpy(raw["target"].astype(np.float32))
45
+ state_mean = all_state.mean((0, 1)).to(device)
46
+ state_std = all_state.std((0, 1), unbiased=False).clamp_min(1e-5).to(device)
47
+ tendency_mean = torch.zeros(68, device=device)
48
+ tendency_std = torch.cat((torch.full((34,), 0.02), torch.full((34,), 2e-7))).to(device)
49
+ layer_mass = torch.from_numpy(raw["layer_mass"].astype(np.float32)).to(device)
50
+ model = StableNNPhys(cfg["model"]["hidden_size"]).to(device)
51
+ if distributed:
52
+ model = DistributedDataParallel(model, device_ids=[local_rank] if device.type == "cuda" else None)
53
+ optimizer = torch.optim.Adam(model.parameters(), lr=cfg["train"]["learning_rate"])
54
+ history = []
55
+ for epoch in range(cfg["train"]["epochs"]):
56
+ if sampler:
57
+ sampler.set_epoch(epoch)
58
+ total = 0.0
59
+ for initial, target, surface, advection, _ in loader:
60
+ initial, target, surface, advection = [x.to(device) for x in (initial, target, surface, advection)]
61
+ prediction, _ = rollout(model, initial, surface, advection, state_mean, state_std,
62
+ tendency_mean, tendency_std, float(raw["dt_seconds"]))
63
+ loss = rollout_loss(prediction, target, layer_mass.expand(initial.shape[0], -1), cfg["train"]["loss_mode"])
64
+ optimizer.zero_grad()
65
+ loss.backward()
66
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
67
+ optimizer.step()
68
+ total += loss.item() * initial.shape[0]
69
+ value = total / len(dataset)
70
+ history.append(value)
71
+ if rank == 0:
72
+ print(f"epoch={epoch + 1} loss={value:.6g}")
73
+ if rank == 0:
74
+ module = model.module if distributed else model
75
+ checkpoint = ROOT / cfg["paths"]["checkpoint"]
76
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
77
+ payload = {"format_version": CHECKPOINT_FORMAT_VERSION, "model": module.state_dict(),
78
+ "optimizer_state_dict": optimizer.state_dict(), "epoch": cfg["train"]["epochs"],
79
+ "model_config": dict(cfg["model"]), "training_config": dict(cfg["train"]),
80
+ "normalization": {"state_mean": state_mean.cpu(), "state_std": state_std.cpu(),
81
+ "tendency_mean": tendency_mean.cpu(), "tendency_std": tendency_std.cpu()},
82
+ "variables": {"input": ["sL[34]", "qT[34]", "SHF", "LHF", "SOLIN"],
83
+ "output": ["dsL_dt[34]", "dqT_dt[34]"]}, "dt_seconds": float(raw["dt_seconds"])}
84
+ torch.save(payload, checkpoint)
85
+ metrics = ROOT / cfg["paths"]["training_metrics"]
86
+ metrics.parent.mkdir(parents=True, exist_ok=True)
87
+ metrics.write_text(json.dumps({"loss": history, "world_size": dist.get_world_size() if distributed else 1,
88
+ "paper_model": cfg["train"]["paper_model"]}, indent=2))
89
+ print(f"saved {checkpoint}")
90
+ if distributed:
91
+ dist.destroy_process_group()
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
weight/.gitkeep ADDED
File without changes