yzt15806542928 commited on
Commit
929e312
·
verified ·
1 Parent(s): 91322f0

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ frameworks: PyTorch
3
+ language:
4
+ - en
5
+ license: apache-2.0
6
+ tags:
7
+ - OneScience
8
+ - Earth Science
9
+ - Ocean Simulation
10
+ - Global Ocean Forecasting
11
+ - OM4
12
+ - ConvNeXt
13
+ tasks: []
14
+ datasets:
15
+ - M2LInES/Samudra-OM4
16
+ ---
17
+ <p align="center">
18
+ <strong>
19
+ <span style="font-size: 30px;">Samudra</span>
20
+ </strong>
21
+ </p>
22
+
23
+ # Model Introduction
24
+
25
+ Samudra is a global ocean emulator developed by the M2LInES team.
26
+
27
+ Paper: Samudra: An AI Global Ocean Emulator for Climate
28
+
29
+ https://doi.org/10.1029/2024GL114318
30
+
31
+ # Model Description
32
+
33
+ Samudra predicts global ocean states on an approximately one-degree grid with a five-day time step. It is designed to emulate the evolution of the OM4 ocean circulation model with a deep neural network.
34
+
35
+ # Use Cases
36
+
37
+ | Scenario | Description |
38
+ | :---: | :--- |
39
+ | Global ocean simulation | Train the model on OM4 data following the 77-state-channel and 4-forcing-channel Samudra protocol. |
40
+ | Local quick validation | Use synthetic NPZ data to check training, inference, and ocean-field visualization. |
41
+ | ModelScope / OneCode execution | Download the standalone model package, install dependencies, and run the scripts directly. |
42
+ | Multi-GPU training | Launch PyTorch DistributedDataParallel with `torchrun`. |
43
+
44
+ # Usage Guide
45
+
46
+ ## 1. OneCode Usage
47
+
48
+ Experience intelligent one-click AI4S programming through the OneCode online environment:
49
+
50
+ [Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
51
+
52
+ ## 2. Manual Installation and Usage
53
+
54
+ **Hardware Requirements**
55
+
56
+ - A GPU or DCU is recommended.
57
+ - CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
58
+ - DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
59
+
60
+ ### Download the Model Package
61
+
62
+ ```bash
63
+ hf download OneScience-Group/Samudra --local-dir ./Samudra
64
+ cd Samudra
65
+ ```
66
+
67
+ ### Install the Runtime Environment
68
+
69
+ **DCU Environment**
70
+
71
+ ```bash
72
+ # Please activate DTK and CONDA first
73
+ conda create -n onescience311 python=3.11 -y
74
+ conda activate onescience311
75
+ # uv installation is supported
76
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
77
+ ```
78
+
79
+ **GPU Environment**
80
+ ```bash
81
+ # Please activate CONDA first
82
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
83
+ conda activate onescience311
84
+ # uv installation is supported
85
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
86
+ ```
87
+
88
+ ### Training Data Introduction
89
+
90
+ The official training data is generated by NOAA/GFDL OM4. The M2LInES project provides the dataset and documentation:
91
+
92
+ https://huggingface.co/datasets/M2LInES/Samudra-OM4
93
+
94
+ The dataset must be converted to the native NPZ layout expected by this repository. When real OM4 data is unavailable, generate a synthetic fixture for pipeline validation:
95
+
96
+ ```bash
97
+ python scripts/fake_data.py
98
+ ```
99
+
100
+ The synthetic fixture contains 77 prognostic state channels and 4 boundary-forcing channels and is not suitable for scientific evaluation.
101
+
102
+ ### Training
103
+
104
+ Single GPU:
105
+
106
+ ```bash
107
+ python scripts/train.py
108
+ ```
109
+
110
+ Multi-GPU:
111
+
112
+ ```bash
113
+ torchrun --nproc_per_node=8 scripts/train.py
114
+ ```
115
+
116
+ The default checkpoint is saved to `data/checkpoints/model_bak.pth`.
117
+
118
+ ### Training Weights
119
+
120
+ This repository provides a `weight/` directory for Samudra checkpoints. The weight files will be uploaded soon and are expected to be available in the near future.
121
+
122
+ ### Inference
123
+
124
+ Inference performs an autoregressive rollout from `data/test.npz` and reads `data/checkpoints/model_bak.pth` by default:
125
+
126
+ ```bash
127
+ python scripts/inference.py
128
+ ```
129
+
130
+ Predictions are written to `result/output/prediction.npz`.
131
+
132
+ ### Evaluation and Visualization
133
+
134
+ ```bash
135
+ python scripts/result.py
136
+ ```
137
+
138
+ The default outputs are `result/forecast_maps.png` and `result/temperature_profile.png`.
139
+
140
+ # Official OneScience Resources
141
+
142
+ | Platform | OneScience Main Repository | Skills Repository |
143
+ | --- | --- | --- |
144
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
145
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
146
+
147
+ # Citation and License
148
+
149
+ - Paper: https://doi.org/10.1029/2024GL114318
150
+ - This repository is an independent reproduction of the original Samudra paper.
conf/config.yaml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ project:
2
+ name: Samudra
3
+ default_model_name: samudra
4
+ seed: 1
5
+
6
+ fake_data:
7
+ time: 12
8
+ height: 32
9
+ width: 64
10
+
11
+ data:
12
+ source_dir: ./data
13
+ output_dir: ./data
14
+ format: npz
15
+ grid:
16
+ height: 180
17
+ width: 360
18
+ depths_m: [2.5, 10.0, 22.5, 40.0, 65.0, 105.0, 165.0, 250.0, 375.0, 550.0, 775.0, 1050.0, 1400.0, 1850.0, 2400.0, 3100.0, 4000.0, 5000.0, 6000.0]
19
+ time_step_days: 5
20
+ input_state_steps: 2
21
+ output_state_steps: 2
22
+ recurrent_passes: 4
23
+
24
+ model:
25
+ variant: thermo_dynamic
26
+ input_channels: 158
27
+ output_channels: 154
28
+ state_channels: 77
29
+ widths: [200, 250, 300, 400]
30
+ dilations: [1, 2, 4, 8]
31
+ bottleneck_width: 400
32
+ bottleneck_dilation: 8
33
+ checkpoint: null
34
+ load_official_weights: false
35
+
36
+ training:
37
+ batch_size: 2
38
+ epochs: 10
39
+ learning_rate: 0.0006
40
+ weight_decay: 0.0
41
+ num_workers: 0
42
+ scheduler: cosine
43
+ save_frequency: 5
44
+ resume_checkpoint: null
45
+ output_dir: ./data/checkpoints
46
+
47
+ inference:
48
+ checkpoint: null
49
+ rollout_steps: 6
50
+ output_dir: ./result/output
51
+
52
+ visualization:
53
+ output_dir: ./result
config.json ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "Samudra",
3
+ "model_type": "samudra",
4
+ "architectures": [
5
+ "Samudra"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "ocean",
9
+ "task": "global-ocean-emulation",
10
+ "implementation": {
11
+ "entry_point": "model/samudra.py",
12
+ "scope": "thermo-dynamic Samudra v1 ConvNeXt U-Net with periodic longitude handling"
13
+ },
14
+ "architecture": {
15
+ "family": "dilated ConvNeXt U-Net",
16
+ "variant": "thermo_dynamic",
17
+ "input_channels": 158,
18
+ "output_channels": 154,
19
+ "state_channels": 77,
20
+ "widths": [
21
+ 200,
22
+ 250,
23
+ 300,
24
+ 400
25
+ ],
26
+ "dilations": [
27
+ 1,
28
+ 2,
29
+ 4,
30
+ 8
31
+ ],
32
+ "bottleneck_width": 400,
33
+ "activation": "capped GELU"
34
+ },
35
+ "data": {
36
+ "dataset": "NOAA/GFDL OM4",
37
+ "spatial_resolution_degrees": 1.0,
38
+ "time_step_days": 5,
39
+ "input_state_steps": 2,
40
+ "output_state_steps": 2,
41
+ "recurrent_passes": 4,
42
+ "depth_levels_m": [
43
+ 2.5,
44
+ 10.0,
45
+ 22.5,
46
+ 40.0,
47
+ 65.0,
48
+ 105.0,
49
+ 165.0,
50
+ 250.0,
51
+ 375.0,
52
+ 550.0,
53
+ 775.0,
54
+ 1050.0,
55
+ 1400.0,
56
+ 1850.0,
57
+ 2400.0,
58
+ 3100.0,
59
+ 4000.0,
60
+ 5000.0,
61
+ 6000.0
62
+ ],
63
+ "protocol": "samudra_v1_om4"
64
+ },
65
+ "configuration_sources": [
66
+ "conf/config.yaml",
67
+ "model/samudra.py",
68
+ "scripts/train.py"
69
+ ]
70
+ }
configuration.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "ocean_emulation",
4
+ "model": "Samudra",
5
+ "input_format": "BCHW",
6
+ "protocol": "samudra_v1_om4",
7
+ "default_config": "conf/config.yaml",
8
+ "train": "scripts/train.py",
9
+ "inference": "scripts/inference.py",
10
+ "evaluation": "scripts/result.py",
11
+ "visualization": "scripts/result.py"
12
+ }
model/samudra.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paper-version Samudra ConvNeXt U-Net implementation.
2
+
3
+ The layer layout follows the official ``samudra_om4_v1/model.yaml`` and the
4
+ official v1 ``blocks.py`` implementation. Inputs are channel-stacked tensors
5
+ with shape ``[batch, 158, lat, lon]`` for the full thermo-dynamic variant.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Iterable
12
+
13
+ import torch
14
+ from torch import nn
15
+ from torch.nn import functional as F
16
+
17
+
18
+ def circular_pad_width(x: torch.Tensor, padding: int) -> torch.Tensor:
19
+ """Apply periodic longitude padding, including widths smaller than padding."""
20
+ if padding == 0:
21
+ return x
22
+ width = x.shape[-1]
23
+ repeats = (padding + width - 1) // width
24
+ tiled = x.repeat(1, 1, 1, repeats)
25
+ left = tiled[..., -padding:]
26
+ right = tiled[..., :padding]
27
+ return torch.cat((left, x, right), dim=-1)
28
+
29
+
30
+ def globe_pad(x: torch.Tensor, padding: int) -> torch.Tensor:
31
+ x = circular_pad_width(x, padding)
32
+ return F.pad(x, (0, 0, padding, padding), mode="constant")
33
+
34
+
35
+ class CappedGELU(nn.Module):
36
+ def __init__(self, cap_value: float = 10.0):
37
+ super().__init__()
38
+ self.gelu = nn.GELU()
39
+ # Keep this constant out of DDP buffers. DDP synchronizes buffers at
40
+ # every forward, which would mutate the autograd version counter during
41
+ # a recurrent multi-step rollout.
42
+ self.cap = float(cap_value)
43
+
44
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
45
+ return torch.clamp(self.gelu(x), max=self.cap)
46
+
47
+
48
+ class ConvNeXtBlock(nn.Module):
49
+ """Official v1 residual block with dilated 3x3 convolutions."""
50
+
51
+ def __init__(self, in_channels: int, out_channels: int, dilation: int):
52
+ super().__init__()
53
+ self.in_channels = in_channels
54
+ self.out_channels = out_channels
55
+ self.dilation = dilation
56
+ self.padding = dilation
57
+ self.skip = nn.Identity() if in_channels == out_channels else nn.Conv2d(in_channels, out_channels, 1)
58
+ hidden = in_channels * 4
59
+ self.layers = nn.ModuleList(
60
+ [
61
+ nn.Conv2d(in_channels, hidden, 3, dilation=dilation),
62
+ nn.BatchNorm2d(hidden),
63
+ CappedGELU(),
64
+ nn.Conv2d(hidden, hidden, 3, dilation=dilation),
65
+ nn.BatchNorm2d(hidden),
66
+ CappedGELU(),
67
+ nn.Conv2d(hidden, out_channels, 1),
68
+ ]
69
+ )
70
+
71
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
72
+ skip = self.skip(x)
73
+ for layer in self.layers:
74
+ if isinstance(layer, nn.Conv2d) and layer.kernel_size == (3, 3):
75
+ x = globe_pad(x, self.padding)
76
+ x = layer(x)
77
+ return skip + x
78
+
79
+
80
+ class PeriodicBilinearUpsample(nn.Module):
81
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
82
+ width = x.shape[-1]
83
+ padded = F.pad(x, (1, 1, 0, 0), mode="circular")
84
+ result = F.interpolate(padded, scale_factor=2, mode="bilinear", align_corners=False)
85
+ return result[..., 2 : 2 + width * 2]
86
+
87
+
88
+ class SamudraUNet(nn.Module):
89
+ def __init__(self, in_channels: int, widths: Iterable[int], dilations: Iterable[int]):
90
+ super().__init__()
91
+ widths = list(widths)
92
+ dilations = list(dilations)
93
+ if len(widths) != 4 or len(dilations) != 4:
94
+ raise ValueError("paper v1 requires four widths and four dilation rates")
95
+ channels = [in_channels, *widths]
96
+ self.down_blocks = nn.ModuleList(
97
+ ConvNeXtBlock(channels[i], channels[i + 1], dilations[i]) for i in range(4)
98
+ )
99
+ self.pools = nn.ModuleList(nn.AvgPool2d(2) for _ in range(4))
100
+ self.middle = ConvNeXtBlock(widths[-1], widths[-1], dilations[-1])
101
+ self.first_up = PeriodicBilinearUpsample()
102
+ reversed_widths = list(reversed(widths))
103
+ reversed_dilations = list(reversed(dilations))
104
+ self.up_blocks = nn.ModuleList()
105
+ self.upsamples = nn.ModuleList()
106
+ current = widths[-1]
107
+ for index in range(3):
108
+ target = reversed_widths[index + 1]
109
+ self.up_blocks.append(ConvNeXtBlock(current, target, reversed_dilations[index]))
110
+ self.upsamples.append(PeriodicBilinearUpsample())
111
+ current = target
112
+ self.final = ConvNeXtBlock(current, widths[0], reversed_dilations[-1])
113
+ self.out_channels = widths[0]
114
+
115
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
116
+ skips = []
117
+ for block, pool in zip(self.down_blocks, self.pools):
118
+ x = block(x)
119
+ skips.append(x)
120
+ x = pool(x)
121
+ x = self.middle(x)
122
+ x = self.first_up(x)
123
+ x = self._merge(x, skips[-1])
124
+ for block, up, skip in zip(self.up_blocks, self.upsamples, reversed(skips[:-1])):
125
+ x = block(x)
126
+ x = up(x)
127
+ x = self._merge(x, skip)
128
+ return self.final(x)
129
+
130
+ @staticmethod
131
+ def _merge(x: torch.Tensor, skip: torch.Tensor) -> torch.Tensor:
132
+ height = min(x.shape[-2], skip.shape[-2])
133
+ width = min(x.shape[-1], skip.shape[-1])
134
+ x = x[..., :height, :width]
135
+ skip = skip[..., :height, :width]
136
+ if x.shape[1] != skip.shape[1]:
137
+ raise RuntimeError(f"skip channel mismatch: {x.shape[1]} != {skip.shape[1]}")
138
+ return x + skip
139
+
140
+
141
+ class Samudra(nn.Module):
142
+ """Samudra v1 single-scale emulator.
143
+
144
+ ``variant='thermo_dynamic'`` uses the paper's 158-to-154 interface.
145
+ ``variant='thermo'`` uses 78 input channels and 154 output channels.
146
+ """
147
+
148
+ def __init__(
149
+ self,
150
+ variant: str = "thermo_dynamic",
151
+ input_channels: int | None = None,
152
+ output_channels: int | None = None,
153
+ widths: Iterable[int] = (200, 250, 300, 400),
154
+ dilations: Iterable[int] = (1, 2, 4, 8),
155
+ ):
156
+ super().__init__()
157
+ if variant not in {"thermo_dynamic", "thermo"}:
158
+ raise ValueError("variant must be thermo_dynamic or thermo")
159
+ default_input = 158 if variant == "thermo_dynamic" else 78
160
+ default_output = 154
161
+ self.variant = variant
162
+ self.input_channels = default_input if input_channels is None else input_channels
163
+ self.output_channels = default_output if output_channels is None else output_channels
164
+ self.unet = SamudraUNet(self.input_channels, widths, dilations)
165
+ self.decoder = nn.Conv2d(self.unet.out_channels, self.output_channels, 3)
166
+
167
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
168
+ if x.ndim != 4 or x.shape[1] != self.input_channels:
169
+ raise ValueError(f"expected [batch, {self.input_channels}, lat, lon], got {tuple(x.shape)}")
170
+ features = self.unet(x)
171
+ features = globe_pad(features, 1)
172
+ return self.decoder(features)
173
+
174
+ def load_official_checkpoint(self, path: str | Path, strict: bool = True) -> None:
175
+ checkpoint = torch.load(path, map_location="cpu")
176
+ if isinstance(checkpoint, dict) and "state_dict" in checkpoint:
177
+ checkpoint = checkpoint["state_dict"]
178
+ if not isinstance(checkpoint, dict):
179
+ raise TypeError("official checkpoint must contain a state dictionary")
180
+ # Older local checkpoints stored the fixed GELU cap as a buffer.
181
+ checkpoint = {key: value for key, value in checkpoint.items() if not key.endswith(".cap")}
182
+ self.load_state_dict(checkpoint, strict=strict)
183
+
184
+
185
+ def build_model(config: dict) -> Samudra:
186
+ """Build a model from the project's YAML model section."""
187
+ model_config = config.get("model", config)
188
+ model = Samudra(
189
+ variant=model_config.get("variant", "thermo_dynamic"),
190
+ input_channels=model_config.get("input_channels"),
191
+ output_channels=model_config.get("output_channels"),
192
+ widths=model_config.get("widths", (200, 250, 300, 400)),
193
+ dilations=model_config.get("dilations", (1, 2, 4, 8)),
194
+ )
195
+ checkpoint = model_config.get("checkpoint")
196
+ if checkpoint:
197
+ model.load_official_checkpoint(checkpoint)
198
+ return model
scripts/_bootstrap.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Make the project root importable when an entry point is run by path."""
2
+
3
+ from pathlib import Path
4
+ import sys
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ SCRIPT_DIR = str(ROOT / "scripts")
8
+ if SCRIPT_DIR in sys.path:
9
+ sys.path.remove(SCRIPT_DIR)
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
scripts/fake_data.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate virtual data in the native Samudra NPZ layout."""
2
+
3
+ try:
4
+ from ._bootstrap import ROOT
5
+ except ImportError:
6
+ from _bootstrap import ROOT
7
+
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import yaml
13
+
14
+ STATE_CHANNELS = 77
15
+ BOUNDARY_CHANNELS = 4
16
+
17
+
18
+ def generate(path: str | Path, time: int, height: int, width: int, seed: int) -> None:
19
+ if time < 10:
20
+ raise ValueError("time must be at least 10 for four-pass recurrent training")
21
+ rng = np.random.default_rng(seed)
22
+ prognostic = rng.standard_normal((time, STATE_CHANNELS, height, width), dtype=np.float32)
23
+ boundary = rng.standard_normal((time, BOUNDARY_CHANNELS, height, width), dtype=np.float32)
24
+ output = Path(path)
25
+ output.parent.mkdir(parents=True, exist_ok=True)
26
+ np.savez_compressed(output, prognostic=prognostic, boundary=boundary)
27
+ print(f"saved native data: {output} prognostic={prognostic.shape} boundary={boundary.shape}")
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser()
32
+ parser.add_argument("--config", default="./conf/config.yaml")
33
+ parser.add_argument("--train-output", default="./data/train.npz")
34
+ parser.add_argument("--test-output", default="./data/test.npz")
35
+ parser.add_argument("--time", type=int, default=None)
36
+ parser.add_argument("--height", type=int, default=None)
37
+ parser.add_argument("--width", type=int, default=None)
38
+ parser.add_argument("--seed", type=int, default=None)
39
+ args = parser.parse_args()
40
+ with open(args.config, encoding="utf-8") as handle:
41
+ config = yaml.safe_load(handle)
42
+ fake = config.get("fake_data", {})
43
+ time = args.time or int(fake.get("time", 12))
44
+ height = args.height or int(fake.get("height", 32))
45
+ width = args.width or int(fake.get("width", 64))
46
+ seed = args.seed if args.seed is not None else int(config["project"].get("seed", 1))
47
+ generate(args.train_output, time, height, width, seed)
48
+ generate(args.test_output, time, height, width, seed + 1)
49
+
50
+
51
+ if __name__ == "__main__":
52
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autoregressive inference for the paper-version Samudra model."""
2
+
3
+ try:
4
+ from ._bootstrap import ROOT
5
+ except ImportError:
6
+ from _bootstrap import ROOT
7
+
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import torch
13
+ import yaml
14
+
15
+ from model.samudra import build_model
16
+
17
+ STATE_CHANNELS = 77
18
+ BOUNDARY_CHANNELS = 4
19
+
20
+
21
+ def load_data(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
22
+ """Load and validate native Samudra time-major arrays."""
23
+ with np.load(path) as data:
24
+ prognostic = np.asarray(data["prognostic"], dtype=np.float32)
25
+ boundary = np.asarray(data["boundary"], dtype=np.float32)
26
+ if prognostic.ndim != 4 or prognostic.shape[1] != STATE_CHANNELS:
27
+ raise ValueError("prognostic must have shape [time, 77, lat, lon]")
28
+ if boundary.ndim != 4 or boundary.shape[1] != BOUNDARY_CHANNELS:
29
+ raise ValueError("boundary must have shape [time, 4, lat, lon]")
30
+ if prognostic.shape[0] != boundary.shape[0] or prognostic.shape[2:] != boundary.shape[2:]:
31
+ raise ValueError("prognostic and boundary time/grid dimensions must match")
32
+ return prognostic, boundary
33
+
34
+
35
+ def load_checkpoint(model: torch.nn.Module, path: str, device: torch.device) -> None:
36
+ try:
37
+ checkpoint = torch.load(path, map_location=device, weights_only=True)
38
+ except TypeError:
39
+ checkpoint = torch.load(path, map_location=device)
40
+ state = checkpoint.get("model", checkpoint.get("state_dict", checkpoint))
41
+ model.load_state_dict(state)
42
+
43
+
44
+ @torch.no_grad()
45
+ def rollout(model, prognostic, boundary, steps, device):
46
+ # Training uses one consecutive boundary frame for each recurrent pass.
47
+ available_steps = boundary.shape[0] - 1
48
+ if prognostic.shape[0] < 2 or available_steps < 1:
49
+ raise ValueError(
50
+ "rollout requires at least two states and two boundary time samples"
51
+ )
52
+ if steps > available_steps:
53
+ raise ValueError(
54
+ f"requested {steps} model steps, but input data provides only {available_steps}; "
55
+ f"use --steps <= {available_steps} or provide a longer data file"
56
+ )
57
+ previous = torch.from_numpy(prognostic[0]).to(device)
58
+ current = torch.from_numpy(prognostic[1]).to(device)
59
+ predictions = []
60
+ for step in range(steps):
61
+ forcing = torch.from_numpy(boundary[step + 1]).to(device)
62
+ inputs = torch.cat((previous, current, forcing), dim=0).unsqueeze(0)
63
+ prediction = model(inputs).squeeze(0)
64
+ previous, current = prediction[:STATE_CHANNELS], prediction[STATE_CHANNELS:]
65
+ predictions.extend((previous.cpu().numpy(), current.cpu().numpy()))
66
+ return np.stack(predictions)
67
+
68
+
69
+ def main() -> None:
70
+ parser = argparse.ArgumentParser()
71
+ parser.add_argument("--config", default="./conf/config.yaml")
72
+ parser.add_argument("--data", default="./data/test.npz")
73
+ parser.add_argument("--checkpoint", default=None)
74
+ parser.add_argument("--output", default=None)
75
+ parser.add_argument("--steps", type=int, default=None)
76
+ parser.add_argument("--device", default=None)
77
+ args = parser.parse_args()
78
+ if args.steps is not None and args.steps < 1:
79
+ raise ValueError("--steps must be positive")
80
+ with open(args.config, encoding="utf-8") as handle:
81
+ config = yaml.safe_load(handle)
82
+ device = torch.device(args.device or ("cuda" if torch.cuda.is_available() else "cpu"))
83
+ model = build_model(config).to(device)
84
+ checkpoint = args.checkpoint or config["inference"].get("checkpoint") or "./data/checkpoints/model_bak.pth"
85
+ output_path = args.output or config["inference"].get("output_dir", "./result/output")
86
+ if Path(output_path).suffix != ".npz":
87
+ output_path = str(Path(output_path) / "prediction.npz")
88
+ load_checkpoint(model, checkpoint, device)
89
+ model.eval()
90
+ prognostic, boundary = load_data(args.data)
91
+ configured_steps = int(config["inference"].get("rollout_steps", 1))
92
+ if configured_steps < 1:
93
+ raise ValueError("inference.rollout_steps must be positive")
94
+ available_steps = boundary.shape[0] - 1
95
+ steps = args.steps if args.steps is not None else min(configured_steps, available_steps)
96
+ if args.steps is None and steps < configured_steps:
97
+ print(
98
+ f"input data supports {available_steps} model steps; "
99
+ f"using rollout_steps={steps}"
100
+ )
101
+ predictions = rollout(model, prognostic, boundary, steps, device)
102
+ output = Path(output_path)
103
+ output.parent.mkdir(parents=True, exist_ok=True)
104
+ np.savez_compressed(output, predictions=predictions, forcing=boundary[1 : steps + 1])
105
+ print(f"saved predictions: {output} shape={predictions.shape}")
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
scripts/result.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create diagnostic plots from Samudra NPZ rollout outputs."""
2
+
3
+ try:
4
+ from ._bootstrap import ROOT
5
+ except ImportError:
6
+ from _bootstrap import ROOT
7
+
8
+ import argparse
9
+ from pathlib import Path
10
+
11
+ import matplotlib.pyplot as plt
12
+ import numpy as np
13
+
14
+ DEPTHS = (2.5, 10.0, 22.5, 40.0, 65.0, 105.0, 165.0, 250.0, 375.0, 550.0, 775.0, 1050.0, 1400.0, 1850.0, 2400.0, 3100.0, 4000.0, 5000.0, 6000.0)
15
+ PROGNOSTIC_CHANNELS = tuple(
16
+ f"{name}_{level}"
17
+ for name in ("uo", "vo", "thetao", "so")
18
+ for level in range(len(DEPTHS))
19
+ ) + ("zos",)
20
+
21
+
22
+ def plot_rollout(prediction_path: str, output_dir: str) -> None:
23
+ with np.load(prediction_path) as data:
24
+ predictions = np.asarray(data["predictions"])
25
+ output = Path(output_dir)
26
+ output.mkdir(parents=True, exist_ok=True)
27
+ fig, axes = plt.subplots(1, 2, figsize=(12, 4), constrained_layout=True)
28
+ axes[0].imshow(predictions[0, PROGNOSTIC_CHANNELS.index("zos")], cmap="coolwarm", aspect="auto")
29
+ axes[0].set_title("SSH forecast")
30
+ axes[1].imshow(predictions[0, PROGNOSTIC_CHANNELS.index("thetao_0")], cmap="turbo", aspect="auto")
31
+ axes[1].set_title("Surface potential temperature")
32
+ fig.savefig(output / "forecast_maps.png", dpi=150)
33
+ plt.close(fig)
34
+ indices = [PROGNOSTIC_CHANNELS.index(f"thetao_{i}") for i in range(len(DEPTHS))]
35
+ profile = predictions[:, indices].mean(axis=(0, 2, 3))
36
+ fig, ax = plt.subplots(figsize=(5, 5), constrained_layout=True)
37
+ ax.plot(profile, DEPTHS, marker="o")
38
+ ax.invert_yaxis()
39
+ ax.set(xlabel="potential temperature", ylabel="depth (m)", title="Mean temperature profile")
40
+ fig.savefig(output / "temperature_profile.png", dpi=150)
41
+ plt.close(fig)
42
+
43
+
44
+ def main() -> None:
45
+ parser = argparse.ArgumentParser()
46
+ parser.add_argument("--prediction", default="./result/output/prediction.npz")
47
+ parser.add_argument("--output-dir", default="./result")
48
+ args = parser.parse_args()
49
+ plot_rollout(args.prediction, args.output_dir)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ main()
54
+ print("save to ./result/")
scripts/train.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-device and torchrun-based distributed training for Samudra v1."""
2
+
3
+ try:
4
+ from ._bootstrap import ROOT
5
+ except ImportError:
6
+ from _bootstrap import ROOT
7
+
8
+ import argparse
9
+ import os
10
+ import random
11
+ from pathlib import Path
12
+
13
+ import numpy as np
14
+ import torch
15
+ import yaml
16
+ from torch import nn
17
+ from torch.nn.parallel import DistributedDataParallel
18
+ from torch.utils.data import DataLoader, Dataset, DistributedSampler
19
+
20
+ from model.samudra import build_model
21
+
22
+ STATE_CHANNELS = 77
23
+ BOUNDARY_CHANNELS = 4
24
+
25
+
26
+ def load_data(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
27
+ """Load and validate native Samudra time-major arrays."""
28
+ with np.load(path) as data:
29
+ prognostic = np.asarray(data["prognostic"], dtype=np.float32)
30
+ boundary = np.asarray(data["boundary"], dtype=np.float32)
31
+ if prognostic.ndim != 4 or prognostic.shape[1] != STATE_CHANNELS:
32
+ raise ValueError("prognostic must have shape [time, 77, lat, lon]")
33
+ if boundary.ndim != 4 or boundary.shape[1] != BOUNDARY_CHANNELS:
34
+ raise ValueError("boundary must have shape [time, 4, lat, lon]")
35
+ if prognostic.shape[0] != boundary.shape[0] or prognostic.shape[2:] != boundary.shape[2:]:
36
+ raise ValueError("prognostic and boundary time/grid dimensions must match")
37
+ return prognostic, boundary
38
+
39
+
40
+ class SamudraDataset(Dataset):
41
+ """Build recurrent training windows from native Samudra arrays."""
42
+
43
+ def __init__(self, path: str | Path, recurrent_passes: int):
44
+ self.prognostic, self.boundary = load_data(path)
45
+ self.recurrent_passes = recurrent_passes
46
+ self.end = self.prognostic.shape[0] - 2 * recurrent_passes
47
+ if self.end <= 1:
48
+ raise ValueError("dataset does not contain enough samples for recurrent training")
49
+
50
+ def __len__(self) -> int:
51
+ return self.end - 1
52
+
53
+ def __getitem__(self, index: int) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
54
+ t = index + 1
55
+ history = np.stack((self.prognostic[t - 1], self.prognostic[t]))
56
+ forcing = self.boundary[t : t + self.recurrent_passes]
57
+ labels = np.stack(
58
+ [
59
+ np.concatenate(
60
+ (self.prognostic[t + 2 * step + 1], self.prognostic[t + 2 * step + 2])
61
+ )
62
+ for step in range(self.recurrent_passes)
63
+ ]
64
+ )
65
+ return torch.from_numpy(history), torch.from_numpy(forcing), torch.from_numpy(labels)
66
+
67
+
68
+ def set_seed(seed: int) -> None:
69
+ random.seed(seed)
70
+ np.random.seed(seed)
71
+ torch.manual_seed(seed)
72
+
73
+
74
+ def load_config(path: str) -> dict:
75
+ with open(path, encoding="utf-8") as handle:
76
+ return yaml.safe_load(handle)
77
+
78
+
79
+ def distributed_setup(device: torch.device) -> tuple[int, int, bool]:
80
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
81
+ rank = int(os.environ.get("RANK", "0"))
82
+ distributed = world_size > 1
83
+ if distributed:
84
+ backend = "nccl" if device.type == "cuda" else "gloo"
85
+ torch.distributed.init_process_group(backend=backend)
86
+ return rank, world_size, distributed
87
+
88
+
89
+ def save_checkpoint(path: Path, model: nn.Module, optimizer: torch.optim.Optimizer, scheduler, epoch: int, loss: float) -> None:
90
+ state = model.module.state_dict() if isinstance(model, DistributedDataParallel) else model.state_dict()
91
+ torch.save({"epoch": epoch, "loss": loss, "model": state, "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict()}, path)
92
+
93
+
94
+ def freeze_batch_norm_stats(module: nn.Module) -> None:
95
+ """Prevent recurrent forwards from mutating BatchNorm buffers in one graph."""
96
+ for child in module.modules():
97
+ if isinstance(child, nn.modules.batchnorm._BatchNorm):
98
+ child.eval()
99
+
100
+
101
+ def train(
102
+ config_path: str,
103
+ data_path: str,
104
+ device_name: str | None = None,
105
+ epochs_override: int | None = None,
106
+ output_dir_override: str | None = None,
107
+ ) -> None:
108
+ config = load_config(config_path)
109
+ rank = int(os.environ.get("RANK", "0"))
110
+ local_rank = int(os.environ.get("LOCAL_RANK", rank))
111
+ if device_name:
112
+ device = torch.device(device_name)
113
+ elif torch.cuda.is_available():
114
+ device = torch.device(f"cuda:{local_rank}")
115
+ else:
116
+ device = torch.device("cpu")
117
+ rank, world_size, distributed = distributed_setup(device)
118
+ set_seed(int(config["project"].get("seed", 1)) + rank)
119
+ if device.type == "cuda":
120
+ torch.cuda.set_device(device)
121
+
122
+ recurrent_passes = int(config["data"].get("recurrent_passes", 1))
123
+ dataset = SamudraDataset(data_path, recurrent_passes)
124
+ sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank, shuffle=True) if distributed else None
125
+ loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], shuffle=sampler is None, sampler=sampler, num_workers=config["training"].get("num_workers", 0), pin_memory=device.type == "cuda")
126
+ model = build_model(config).to(device)
127
+ if distributed:
128
+ model = DistributedDataParallel(
129
+ model,
130
+ device_ids=[device.index] if device.type == "cuda" else None,
131
+ broadcast_buffers=False,
132
+ )
133
+ optimizer = torch.optim.Adam(model.parameters(), lr=config["training"]["learning_rate"], weight_decay=config["training"].get("weight_decay", 0.0))
134
+ epochs = epochs_override if epochs_override is not None else int(config["training"]["epochs"])
135
+ if epochs < 1:
136
+ raise ValueError("epochs must be positive")
137
+ scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=epochs)
138
+ resume = config["training"].get("resume_checkpoint")
139
+ start_epoch = 0
140
+ if resume:
141
+ try:
142
+ checkpoint = torch.load(resume, map_location=device, weights_only=True)
143
+ except TypeError:
144
+ checkpoint = torch.load(resume, map_location=device)
145
+ target = model.module if isinstance(model, DistributedDataParallel) else model
146
+ state = {key: value for key, value in checkpoint["model"].items() if not key.endswith(".cap")}
147
+ target.load_state_dict(state)
148
+ optimizer.load_state_dict(checkpoint["optimizer"])
149
+ scheduler.load_state_dict(checkpoint["scheduler"])
150
+ start_epoch = int(checkpoint["epoch"]) + 1
151
+
152
+ output_dir = Path(output_dir_override or config["training"]["output_dir"])
153
+ output_dir.mkdir(parents=True, exist_ok=True)
154
+ for epoch in range(start_epoch, epochs):
155
+ if sampler is not None:
156
+ sampler.set_epoch(epoch)
157
+ model.train()
158
+ freeze_batch_norm_stats(model)
159
+ total_loss = 0.0
160
+ for batch in loader:
161
+ optimizer.zero_grad(set_to_none=True)
162
+ history, forcing, labels = (item.to(device, non_blocking=True) for item in batch)
163
+ previous, current = history[:, 0], history[:, 1]
164
+ losses = []
165
+ for step in range(recurrent_passes):
166
+ prediction = model(torch.cat((previous, current, forcing[:, step]), dim=1))
167
+ losses.append(nn.functional.mse_loss(prediction, labels[:, step]))
168
+ previous, current = prediction[:, :STATE_CHANNELS], prediction[:, STATE_CHANNELS:]
169
+ loss = torch.stack(losses).mean()
170
+ loss.backward()
171
+ optimizer.step()
172
+ total_loss += float(loss.detach())
173
+ scheduler.step()
174
+ mean_loss = total_loss / max(1, len(loader))
175
+ if rank == 0:
176
+ print(f"epoch={epoch + 1} loss={mean_loss:.6e} lr={scheduler.get_last_lr()[0]:.6e}")
177
+ frequency = int(config["training"].get("save_frequency", 5))
178
+ if (epoch + 1) % frequency == 0 or epoch + 1 == epochs:
179
+ save_checkpoint(output_dir / f"epoch_{epoch + 1:04d}.pt", model, optimizer, scheduler, epoch, mean_loss)
180
+ save_checkpoint(output_dir / "model_bak.pth", model, optimizer, scheduler, epoch, mean_loss)
181
+ if distributed:
182
+ torch.distributed.destroy_process_group()
183
+
184
+
185
+ def main() -> None:
186
+ parser = argparse.ArgumentParser()
187
+ parser.add_argument("--config", default="./conf/config.yaml")
188
+ parser.add_argument("--data", default="./data/train.npz")
189
+ parser.add_argument("--device", default=None)
190
+ parser.add_argument("--epochs", type=int, default=None)
191
+ parser.add_argument("--output-dir", default=None)
192
+ args = parser.parse_args()
193
+ train(args.config, args.data, args.device, args.epochs, args.output_dir)
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
weight/.gitkeep ADDED
File without changes