yzt15806542928 commited on
Commit
1aeffbb
·
verified ·
1 Parent(s): 7f3d30f

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ frameworks: PyTorch
3
+ language:
4
+ - en
5
+ license: apache-2.0
6
+ tags:
7
+ - OneScience
8
+ - Earth Science
9
+ - Ocean Forecasting
10
+ - Global Ocean Forecasting
11
+ - GLORYS12
12
+ - FNO
13
+ tasks: []
14
+ datasets:
15
+ - GLORYS12
16
+ ---
17
+ <p align="center">
18
+ <strong>
19
+ <span style="font-size: 30px;">GLONET</span>
20
+ </strong>
21
+ </p>
22
+
23
+ # Model Introduction
24
+
25
+ GLONET (Global Ocean Neural Network) is a global ocean neural-network forecasting system developed by Mercator Ocean International, a leading European ocean forecasting center.
26
+
27
+ # Model Description
28
+
29
+ GLONET forecasts global ocean states. It takes two consecutive daily states as input and outputs the 34-channel ocean state for the next day.
30
+
31
+ # Use Cases
32
+
33
+ | Scenario | Description |
34
+ | :---: | :--- |
35
+ | Global ocean forecast research | Train a dual-branch FNO/CNN ocean forecast model with GLORYS12-compatible data. |
36
+ | Local quick validation | Use synthetic ocean fields to check data loading, pretraining, fine-tuning, inference, and visualization. |
37
+ | ModelScope / OneCode execution | Download the standalone model package, install dependencies, and run the scripts directly. |
38
+ | Multi-GPU training | Run multi-GPU training with `torchrun`. |
39
+
40
+ # Usage Guide
41
+
42
+ ## 1. OneCode Usage
43
+
44
+ Experience intelligent one-click AI4S programming through the OneCode online environment:
45
+
46
+ [Click to Experience Intelligent One-Click AI4S Programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
47
+
48
+ ## 2. Manual Installation and Usage
49
+
50
+ **Hardware Requirements**
51
+
52
+ - A GPU or DCU is recommended.
53
+ - CPU can be used for import and small-scale connectivity verification; full training and inference will be slow.
54
+ - DCU users must install DTK in advance. DTK 25.04.2 or above, or the OneScience recommended version matching your cluster, is recommended.
55
+
56
+ ### Download the Model Package
57
+
58
+ ```bash
59
+ hf download OneScience-Group/GLONET --local-dir ./GLONET
60
+ cd GLONET
61
+ ```
62
+
63
+ ### Install the Runtime Environment
64
+
65
+ **DCU Environment**
66
+
67
+ ```bash
68
+ # Please activate DTK and CONDA first
69
+ conda create -n onescience311 python=3.11 -y
70
+ conda activate onescience311
71
+ # uv installation is supported
72
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
73
+ ```
74
+
75
+ **GPU Environment**
76
+ ```bash
77
+ # Please activate CONDA first
78
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
79
+ conda activate onescience311
80
+ # uv installation is supported
81
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
82
+ ```
83
+
84
+ ### Training Data Introduction
85
+
86
+ The original work uses GLORYS12 reanalysis data. Real data must first be converted to the channel order and grid specified in `conf/config.yaml`; the raw GLORYS12 data is not included in this package. The default synthetic data is only for interface checks:
87
+
88
+ ```bash
89
+ python scripts/fake_data.py
90
+ ```
91
+
92
+ ### Training
93
+
94
+ Single GPU:
95
+
96
+ ```bash
97
+ python scripts/train.py
98
+ ```
99
+
100
+ Multi-GPU:
101
+
102
+ ```bash
103
+ torchrun --nproc_per_node=8 scripts/train.py
104
+ ```
105
+
106
+ Checkpoints are saved to `data/checkpoints/` by default.
107
+
108
+ ### Training Weights
109
+
110
+ This repository provides weights trained on GLORYS12 data in the `weight/` folder. The weight files will be uploaded soon and are expected to be available in the near future.
111
+
112
+ ### Inference
113
+
114
+ ```bash
115
+ python scripts/inference.py
116
+ ```
117
+
118
+ The prediction tensor is written to `result/glonet/data/prediction.pt` by default.
119
+
120
+ ### Evaluation and Visualization
121
+
122
+ ```bash
123
+ python scripts/result.py
124
+ ```
125
+
126
+ The default output is `result/glonet/prediction.png`. Meaningful errors are computed only when a real reference field is provided.
127
+
128
+ # Official OneScience Resources
129
+
130
+ | Platform | OneScience Main Repository | Skills Repository |
131
+ | --- | --- | --- |
132
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
133
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
134
+
135
+ # Citation and License
136
+
137
+ - This repository is a reproduction of the original GLONET paper.
conf/config.yaml ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ project:
2
+ name: glonet_reference
3
+ seed: 42
4
+ result_dir: ./result/glonet
5
+
6
+ data:
7
+ data_dir: ./data/
8
+ channels:
9
+ - temperature_0.49m
10
+ - temperature_47m
11
+ - temperature_92m
12
+ - temperature_155m
13
+ - salinity_222m
14
+ - salinity_318m
15
+ - salinity_380m
16
+ - salinity_453m
17
+ - salinity_541m
18
+ - u_643m
19
+ - u_763m
20
+ - u_902m
21
+ - u_1245m
22
+ - u_1684m
23
+ - u_2225m
24
+ - u_3220m
25
+ - u_3597m
26
+ - u_3992m
27
+ - u_4405m
28
+ - u_4833m
29
+ - u_5274m
30
+ - v_643m
31
+ - v_763m
32
+ - v_902m
33
+ - v_1245m
34
+ - v_1684m
35
+ - v_2225m
36
+ - v_3220m
37
+ - v_3597m
38
+ - v_3992m
39
+ - v_4405m
40
+ - v_4833m
41
+ - v_5274m
42
+ - ssh_surface
43
+ grid: [16, 32]
44
+ input_steps: 2
45
+ output_steps: 4
46
+ synthetic_samples: 32
47
+ batch_size: 2
48
+
49
+ model:
50
+ hidden_channels: 32
51
+ modes: [6, 8]
52
+ layers: 4
53
+
54
+ training:
55
+ epochs: 10
56
+ learning_rate: 0.0001
57
+ device: auto
58
+ checkpoint_dir: ./data/checkpoints
59
+ checkpoint: ./data/checkpoints/model_glonet.pth
60
+ pretrain_rollout_steps: 1
61
+ finetune_rollout_steps: 4
config.json ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "GLONET",
3
+ "model_type": "glonet",
4
+ "architectures": [
5
+ "GLONET"
6
+ ],
7
+ "framework": "PyTorch",
8
+ "domain": "ocean",
9
+ "task": "global-ocean-forecasting",
10
+ "implementation": {
11
+ "entry_point": "model/glonet.py",
12
+ "scope": "two-branch FNO/CNN reference implementation"
13
+ },
14
+ "architecture": {
15
+ "family": "Fourier neural operator with convolutional branch",
16
+ "grid_shape": [
17
+ 16,
18
+ 32
19
+ ],
20
+ "input_steps": 2,
21
+ "input_channels_per_step": 34,
22
+ "input_channels": 68,
23
+ "output_channels": 34,
24
+ "hidden_channels": 32,
25
+ "spectral_modes": [
26
+ 6,
27
+ 8
28
+ ],
29
+ "spectral_layers": 4,
30
+ "activation": "GELU"
31
+ },
32
+ "data": {
33
+ "dataset": "GLORYS12",
34
+ "time_step_days": 1,
35
+ "output_steps": 4,
36
+ "protocol": "synthetic_glorys12_shape"
37
+ },
38
+ "configuration_sources": [
39
+ "conf/config.yaml",
40
+ "model/glonet.py",
41
+ "scripts/data_loader.py"
42
+ ]
43
+ }
configuration.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "framework": "PyTorch",
3
+ "task": "ocean_forecasting",
4
+ "model": "GLONET",
5
+ "input_format": "BTCHW",
6
+ "protocol": "synthetic_glorys12_shape",
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/glonet.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GLONET reference architecture based on the public paper description."""
2
+
3
+ import torch
4
+ from torch import nn
5
+
6
+
7
+ class SpectralConv2d(nn.Module):
8
+ def __init__(self, channels, modes):
9
+ super().__init__()
10
+ self.modes_y, self.modes_x = modes
11
+ self.weight = nn.Parameter(torch.randn(channels, channels, self.modes_y, self.modes_x, 2) * 0.02)
12
+
13
+ def forward(self, x):
14
+ height, width = x.shape[-2:]
15
+ spectrum = torch.fft.rfft2(x, norm="ortho")
16
+ out = torch.zeros_like(spectrum)
17
+ modes_y = min(self.modes_y, height)
18
+ modes_x = min(self.modes_x, spectrum.shape[-1])
19
+ weight = torch.view_as_complex(self.weight[:, :, :modes_y, :modes_x].contiguous())
20
+ out[:, :, :modes_y, :modes_x] = torch.einsum(
21
+ "bixy,ioxy->boxy", spectrum[:, :, :modes_y, :modes_x], weight
22
+ )
23
+ return torch.fft.irfft2(out, s=(height, width), norm="ortho")
24
+
25
+
26
+ class SpectralBlock(nn.Module):
27
+ def __init__(self, channels, modes):
28
+ super().__init__()
29
+ self.spectral = SpectralConv2d(channels, modes)
30
+ self.pointwise = nn.Conv2d(channels, channels, 1)
31
+ self.activation = nn.GELU()
32
+
33
+ def forward(self, x):
34
+ return self.activation(self.spectral(x) + self.pointwise(x))
35
+
36
+
37
+ class CNNBranch(nn.Module):
38
+ def __init__(self, channels):
39
+ super().__init__()
40
+ self.net = nn.Sequential(
41
+ nn.Conv2d(channels, channels, 3, padding=1), nn.GELU(),
42
+ nn.Conv2d(channels, channels, 3, padding=1), nn.GELU(),
43
+ )
44
+
45
+ def forward(self, x):
46
+ return self.net(x)
47
+
48
+
49
+ class GLONET(nn.Module):
50
+ """Two-day to one-day global ocean forecast reference model.
51
+
52
+ The paper does not publish a complete layer configuration, so all sizing
53
+ choices remain explicit constructor parameters rather than hidden claims.
54
+ """
55
+
56
+ def __init__(self, in_channels, out_channels=None, hidden_channels=32, modes=(6, 8), layers=4):
57
+ super().__init__()
58
+ out_channels = out_channels or in_channels
59
+ self.input_projection = nn.Conv2d(in_channels, hidden_channels, 1)
60
+ self.fno = nn.Sequential(*[SpectralBlock(hidden_channels, modes) for _ in range(layers)])
61
+ self.cnn = CNNBranch(hidden_channels)
62
+ self.output_projection = nn.Sequential(
63
+ nn.Conv2d(hidden_channels * 2, hidden_channels, 1), nn.GELU(),
64
+ nn.Conv2d(hidden_channels, out_channels, 1),
65
+ )
66
+
67
+ def forward(self, x):
68
+ if x.ndim == 5:
69
+ x = x.flatten(1, 2)
70
+ features = self.input_projection(x)
71
+ return self.output_projection(torch.cat((self.fno(features), self.cnn(features)), dim=1))
scripts/data_loader.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthetic GLORYS12-shaped data for smoke tests and local development."""
2
+
3
+ import glob
4
+ import torch
5
+ from torch.utils.data import Dataset
6
+
7
+ try:
8
+ import h5py
9
+ except ImportError:
10
+ h5py = None
11
+
12
+
13
+ class SyntheticOceanDataset(Dataset):
14
+ def __init__(self, samples, channels, grid, input_steps=2, output_steps=1, seed=42, data_dir=None):
15
+ generator = torch.Generator().manual_seed(seed)
16
+ files = sorted(glob.glob(f"{data_dir}/data/*.h5")) if data_dir else []
17
+ if files and h5py is not None:
18
+ with h5py.File(files[0], "r") as handle:
19
+ fields = torch.from_numpy(handle["fields"][:]).float()
20
+ total = min(samples, fields.shape[0] - input_steps - output_steps + 1)
21
+ self.x = torch.stack([fields[i:i + input_steps] for i in range(total)])
22
+ self.y = torch.stack([fields[i + input_steps:i + input_steps + output_steps] for i in range(total)])
23
+ else:
24
+ self.x = torch.randn(samples, input_steps, channels, *grid, generator=generator)
25
+ self.y = torch.randn(samples, output_steps, channels, *grid, generator=generator)
26
+
27
+ def __len__(self):
28
+ return self.x.shape[0]
29
+
30
+ def __getitem__(self, index):
31
+ return self.x[index], self.y[index]
scripts/fake_data.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a small synthetic dataset description for local smoke tests."""
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import yaml
9
+
10
+ try:
11
+ import h5py
12
+ except ImportError as exc:
13
+ raise SystemExit("fake_data.py requires h5py; install it in the active OneScience environment") from exc
14
+
15
+ ROOT = Path(__file__).resolve().parents[1]
16
+
17
+
18
+ def main():
19
+ parser = argparse.ArgumentParser()
20
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
21
+ parser.add_argument("--output", default=None)
22
+ args = parser.parse_args()
23
+ with open(args.config, encoding="utf-8") as handle:
24
+ config = yaml.safe_load(handle)
25
+ metadata = {
26
+ "variables": config["data"]["channels"],
27
+ "grid": config["data"]["grid"],
28
+ "input_steps": config["data"]["input_steps"],
29
+ "output_steps": config["data"]["output_steps"],
30
+ "time_resolution": "1 day",
31
+ "source": "synthetic; not GLORYS12 values",
32
+ }
33
+ data_root = ROOT / config["data"]["data_dir"] / "data"
34
+ data_root.mkdir(parents=True, exist_ok=True)
35
+ fields = np.random.default_rng(config["project"]["seed"]).standard_normal(
36
+ (config["data"]["synthetic_samples"] + config["data"]["input_steps"],
37
+ len(config["data"]["channels"]), *config["data"]["grid"]), dtype=np.float32
38
+ )
39
+ with h5py.File(data_root / "2000.h5", "w") as handle:
40
+ dataset = handle.create_dataset("fields", data=fields)
41
+ dataset.attrs["variables"] = config["data"]["channels"]
42
+ dataset.attrs["time_step"] = 24
43
+ output = Path(args.output or ROOT / config["data"]["data_dir"] / "synthetic_metadata.json")
44
+ output.parent.mkdir(parents=True, exist_ok=True)
45
+ output.write_text(json.dumps(metadata, indent=2) + "\n", encoding="utf-8")
46
+ print(f"saved={output}")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+ sys.path.insert(0, str(ROOT))
7
+
8
+ import torch
9
+ import yaml
10
+
11
+ from data_loader import SyntheticOceanDataset
12
+ from model.glonet import GLONET
13
+
14
+
15
+ def main():
16
+ parser = argparse.ArgumentParser()
17
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
18
+ parser.add_argument("--checkpoint", default=None)
19
+ args = parser.parse_args()
20
+ with open(args.config, encoding="utf-8") as handle:
21
+ config = yaml.safe_load(handle)
22
+ channels = len(config["data"]["channels"])
23
+ model = GLONET(channels * config["data"]["input_steps"], out_channels=channels,
24
+ hidden_channels=config["model"]["hidden_channels"], modes=config["model"]["modes"],
25
+ layers=config["model"]["layers"])
26
+ checkpoint = Path(args.checkpoint or ROOT / config["training"]["checkpoint"])
27
+ state = torch.load(checkpoint, map_location="cpu", weights_only=False)
28
+ model.load_state_dict(state["model"])
29
+ model.eval()
30
+ sample, _ = SyntheticOceanDataset(1, channels, config["data"]["grid"],
31
+ input_steps=config["data"]["input_steps"],
32
+ output_steps=config["data"]["output_steps"],
33
+ data_dir=str(ROOT / config["data"]["data_dir"]))[0]
34
+ with torch.no_grad():
35
+ prediction = model(sample.unsqueeze(0))
36
+ output = ROOT / config["project"]["result_dir"] / "data" / "prediction.pt"
37
+ output.parent.mkdir(parents=True, exist_ok=True)
38
+ torch.save(prediction, output)
39
+ print(f"prediction_shape={tuple(prediction.shape)}")
40
+
41
+
42
+ if __name__ == "__main__":
43
+ main()
scripts/result.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+ sys.path.insert(0, str(ROOT))
7
+
8
+ import math
9
+
10
+ import matplotlib.pyplot as plt
11
+ import numpy as np
12
+ import torch
13
+ import yaml
14
+
15
+
16
+ def load_field(path, time_index=0):
17
+ """Return a prediction as [C, H, W] from common model output layouts."""
18
+ value = torch.load(path, map_location="cpu", weights_only=False)
19
+ if isinstance(value, dict):
20
+ for key in ("prediction", "predictions", "output", "outputs"):
21
+ if key in value:
22
+ value = value[key]
23
+ break
24
+ field = torch.as_tensor(value).detach().cpu().float().numpy()
25
+ if field.ndim == 5: # [B, T, C, H, W]
26
+ field = field[0, time_index]
27
+ elif field.ndim == 4: # [B, C, H, W] or [T, C, H, W]
28
+ field = field[0 if field.shape[0] == 1 else time_index]
29
+ elif field.ndim != 3:
30
+ raise ValueError(f"Expected [C,H,W], [B,C,H,W], or [B,T,C,H,W], got {field.shape}")
31
+ if field.ndim != 3:
32
+ raise ValueError(f"Selected output is not [C,H,W]: {field.shape}")
33
+ return field
34
+
35
+
36
+ def load_channel_names(config_path, channel_count):
37
+ if config_path is None:
38
+ return [f"channel_{index}" for index in range(channel_count)]
39
+ with open(config_path, encoding="utf-8") as handle:
40
+ config = yaml.safe_load(handle)
41
+ names = config.get("data", {}).get("channels", [])
42
+ if len(names) != channel_count:
43
+ return [f"channel_{index}" for index in range(channel_count)]
44
+ return names
45
+
46
+
47
+ def choose_channels(names, requested, max_panels):
48
+ if requested:
49
+ selected = []
50
+ for item in requested:
51
+ if item.isdigit():
52
+ index = int(item)
53
+ if not 0 <= index < len(names):
54
+ raise ValueError(f"Channel index out of range: {index}")
55
+ else:
56
+ if item not in names:
57
+ raise ValueError(f"Unknown channel: {item}")
58
+ index = names.index(item)
59
+ if index not in selected:
60
+ selected.append(index)
61
+ return selected
62
+ return list(range(min(max_panels, len(names))))
63
+
64
+
65
+ def is_signed_channel(name):
66
+ return name.startswith(("u_", "v_")) or name.startswith("ssh_")
67
+
68
+
69
+ def plot_fields(field, names, indices, output, title, reference=None):
70
+ columns = min(3, len(indices))
71
+ rows = math.ceil(len(indices) / columns)
72
+ has_reference = reference is not None
73
+ fig, axes = plt.subplots(rows, columns, figsize=(5.6 * columns, 4.4 * rows), squeeze=False)
74
+ axes = axes.ravel()
75
+ height, width = field.shape[-2:]
76
+ longitude = np.linspace(0, 360, width, endpoint=False)
77
+ latitude = np.linspace(90, -90, height)
78
+ extent = [longitude[0], longitude[-1], latitude[-1], latitude[0]]
79
+
80
+ for axis, index in zip(axes, indices):
81
+ data = field[index]
82
+ reference_data = reference[index] if has_reference else None
83
+ if reference_data is not None:
84
+ data_min = min(np.nanpercentile(data, 2), np.nanpercentile(reference_data, 2))
85
+ data_max = max(np.nanpercentile(data, 98), np.nanpercentile(reference_data, 98))
86
+ else:
87
+ data_min, data_max = np.nanpercentile(data, [2, 98])
88
+ if np.isclose(data_min, data_max):
89
+ data_min, data_max = float(np.nanmin(data)), float(np.nanmax(data) + 1e-6)
90
+ cmap = "RdBu_r" if is_signed_channel(names[index]) else "viridis"
91
+ image = axis.imshow(data, extent=extent, origin="upper", cmap=cmap,
92
+ vmin=data_min, vmax=data_max, aspect="auto")
93
+ axis.set_title(names[index], fontsize=11, fontweight="bold")
94
+ axis.set_xlabel("Longitude (degrees)")
95
+ axis.set_ylabel("Latitude (degrees)")
96
+ axis.set_xticks([0, 90, 180, 270, 360])
97
+ axis.set_yticks([-90, -45, 0, 45, 90])
98
+ axis.grid(color="white", linewidth=0.35, alpha=0.35)
99
+ colorbar = fig.colorbar(image, ax=axis, fraction=0.046, pad=0.04)
100
+ colorbar.ax.tick_params(labelsize=8)
101
+ stats = f"min {np.nanmin(data):.3g} | max {np.nanmax(data):.3g} | mean {np.nanmean(data):.3g}"
102
+ if reference_data is not None:
103
+ rmse = np.sqrt(np.nanmean((data - reference_data) ** 2))
104
+ stats += f" | RMSE {rmse:.3g}"
105
+ axis.text(0.02, 0.02, stats, transform=axis.transAxes, fontsize=8,
106
+ color="white", bbox={"facecolor": "black", "alpha": 0.55, "pad": 3})
107
+
108
+ for axis in axes[len(indices):]:
109
+ axis.remove()
110
+ fig.suptitle(title, fontsize=15, fontweight="bold")
111
+ fig.tight_layout()
112
+ fig.savefig(output, dpi=180, bbox_inches="tight")
113
+ plt.close(fig)
114
+
115
+
116
+ def main():
117
+ parser = argparse.ArgumentParser()
118
+ parser.add_argument("--input", default=str(ROOT / "result/glonet/data/prediction.pt"))
119
+ parser.add_argument("--output", default=str(ROOT / "result/glonet/prediction.png"))
120
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
121
+ parser.add_argument("--reference", default=None, help="Optional .pt truth field for RMSE comparison")
122
+ parser.add_argument("--channel", action="append", help="Channel name or zero-based index; repeatable")
123
+ parser.add_argument("--max-panels", type=int, default=6)
124
+ parser.add_argument("--time-index", type=int, default=0)
125
+ args = parser.parse_args()
126
+ prediction = load_field(args.input, args.time_index)
127
+ names = load_channel_names(args.config, prediction.shape[0])
128
+ indices = choose_channels(names, args.channel, args.max_panels)
129
+ reference = load_field(args.reference, args.time_index) if args.reference else None
130
+ if reference is not None and reference.shape != prediction.shape:
131
+ raise ValueError(f"Prediction/reference shape mismatch: {prediction.shape} vs {reference.shape}")
132
+ Path(args.output).parent.mkdir(parents=True, exist_ok=True)
133
+ plot_fields(prediction, names, indices, args.output,
134
+ f"GLONET ocean forecast | {len(indices)} channel(s)", reference)
135
+ print(f"saved={args.output}")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
scripts/train.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse
2
+ import os
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).resolve().parents[1]
7
+ sys.path.insert(0, str(ROOT))
8
+
9
+ import torch
10
+ import torch.distributed as dist
11
+ import yaml
12
+ from torch.nn.parallel import DistributedDataParallel as DDP
13
+ from torch.utils.data import DataLoader, DistributedSampler
14
+
15
+ from data_loader import SyntheticOceanDataset
16
+ from model.glonet import GLONET
17
+
18
+
19
+ def setup_distributed():
20
+ world_size = int(os.environ.get("WORLD_SIZE", "1"))
21
+ if world_size == 1:
22
+ return 0, 0, torch.device("cuda" if torch.cuda.is_available() else "cpu")
23
+ local_rank = int(os.environ["LOCAL_RANK"])
24
+ force_cpu = os.environ.get("GLONET_FORCE_CPU", "0") == "1"
25
+ if torch.cuda.is_available() and not force_cpu:
26
+ device_count = torch.cuda.device_count()
27
+ if local_rank >= device_count:
28
+ raise RuntimeError(
29
+ f"LOCAL_RANK={local_rank} but only {device_count} accelerator(s) are visible; "
30
+ "reduce --nproc_per_node or fix CUDA_VISIBLE_DEVICES."
31
+ )
32
+ torch.cuda.set_device(local_rank)
33
+ device = torch.device("cuda", local_rank)
34
+ backend = "nccl"
35
+ else:
36
+ device = torch.device("cpu")
37
+ backend = "gloo"
38
+ dist.init_process_group(backend=backend, init_method="env://")
39
+ return dist.get_rank(), local_rank, device
40
+
41
+
42
+ def main():
43
+ parser = argparse.ArgumentParser()
44
+ parser.add_argument("--config", default=str(ROOT / "conf/config.yaml"))
45
+ parser.add_argument("--phase", choices=["pretrain", "finetune"], default="pretrain")
46
+ args = parser.parse_args()
47
+ with open(args.config, encoding="utf-8") as handle:
48
+ config = yaml.safe_load(handle)
49
+ rank, local_rank, device = setup_distributed()
50
+ torch.manual_seed(config["project"]["seed"] + rank)
51
+ channels = len(config["data"]["channels"])
52
+ rollout_steps = config["training"][f"{args.phase}_rollout_steps"]
53
+ dataset = SyntheticOceanDataset(config["data"]["synthetic_samples"], channels, config["data"]["grid"],
54
+ input_steps=config["data"]["input_steps"],
55
+ output_steps=config["data"]["output_steps"],
56
+ data_dir=str(ROOT / config["data"]["data_dir"]))
57
+ sampler = DistributedSampler(dataset, shuffle=True) if dist.is_initialized() else None
58
+ loader = DataLoader(dataset, batch_size=config["data"]["batch_size"], shuffle=sampler is None, sampler=sampler)
59
+ model = GLONET(channels * config["data"]["input_steps"], out_channels=channels,
60
+ hidden_channels=config["model"]["hidden_channels"], modes=config["model"]["modes"],
61
+ layers=config["model"]["layers"]).to(device)
62
+ checkpoint = ROOT / config["training"]["checkpoint"]
63
+ if args.phase == "finetune" and checkpoint.exists():
64
+ state = torch.load(checkpoint, map_location=device, weights_only=False)
65
+ model.load_state_dict(state["model"])
66
+ if dist.is_initialized():
67
+ model = DDP(model, device_ids=[local_rank] if device.type == "cuda" else None)
68
+ optimizer = torch.optim.Adam(model.parameters(), lr=config["training"]["learning_rate"])
69
+ for epoch in range(config["training"]["epochs"]):
70
+ if sampler is not None:
71
+ sampler.set_epoch(epoch)
72
+ model.train()
73
+ total = 0.0
74
+ for inputs, targets in loader:
75
+ inputs, targets = inputs.to(device), targets.to(device)
76
+ optimizer.zero_grad(set_to_none=True)
77
+ loss = 0.0
78
+ state = inputs
79
+ for step in range(rollout_steps):
80
+ prediction = model(state)
81
+ loss = loss + torch.nn.functional.mse_loss(prediction, targets[:, step])
82
+ state = torch.cat((state[:, 1:], prediction.unsqueeze(1)), dim=1)
83
+ loss = loss / rollout_steps
84
+ loss.backward()
85
+ optimizer.step()
86
+ total += loss.item()
87
+ if rank == 0:
88
+ print(f"epoch={epoch + 1} loss={total / len(loader):.6f}")
89
+ if rank == 0:
90
+ checkpoint.parent.mkdir(parents=True, exist_ok=True)
91
+ torch.save({"model": model.module.state_dict() if hasattr(model, "module") else model.state_dict(),
92
+ "config": config}, checkpoint)
93
+ print(f"saved={checkpoint}")
94
+ if dist.is_initialized():
95
+ dist.destroy_process_group()
96
+
97
+
98
+ if __name__ == "__main__":
99
+ main()
weight/.gitkeep ADDED
File without changes