zhangrenchao commited on
Commit
d3e46b7
·
verified ·
1 Parent(s): a349ff4

Publish FuXi-Ocean engineering reproduction

Browse files
.gitattributes CHANGED
@@ -1,35 +1,2 @@
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
 
 
 
 
 
 
 
 
 
 
 
 
README.md ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - en
5
+ - zh
6
+ tags:
7
+ - OneScience
8
+ - ocean-forecasting
9
+ - eddy-resolving
10
+ frameworks: PyTorch
11
+ ---
12
+
13
+ <p align="center">
14
+ <strong><span style="font-size: 30px;">FuXi-Ocean</span></strong>
15
+ </p>
16
+
17
+ # Model Introduction
18
+
19
+ FuXi-Ocean performs autoregressive ocean-field prediction at six-hour intervals from historical ocean states and initialization-time atmospheric variables, providing an executable engineering workflow for research on high-resolution ocean forecasting methods.
20
+
21
+ Paper: A deep learning global ocean forecasting model with sub-daily and eddy-resolving resolution
22
+ https://doi.org/10.1038/s41612-026-01444-2
23
+
24
+ # Model Description
25
+
26
+ The method was proposed by research teams from Tianjin University, the Shanghai Academy of Artificial Intelligence for Science, Fudan University, the Shanghai Innovation Institute, FuXi Intelligent Computing Technology Co. Ltd, Hainan Tropical Ocean University, and collaborating institutions. The paper trains on HYCOM reanalysis and analysis ocean fields together with ERA5 near-surface atmospheric variables. The model performs autoregressive prediction of temperature, salinity, zonal current, meridional current, and sea-surface height at six-hour intervals.
27
+
28
+ # Use Cases
29
+
30
+ | Use Case | Description |
31
+ | :---: | :--- |
32
+ | Autoregressive ocean-field prediction | Validate the primary task of predicting subsequent ocean states from four historical states and initialization-time atmospheric variables on synthetic sampled tiles. |
33
+ | Core-method validation | Validate shared convolutional encoding, a spatiotemporal prior, historical-feature fusion, low-resolution attention, shared decoding, and latitude-weighted Charbonnier loss. |
34
+ | Local engineering validation | Validate indexing, overlap crops, data contracts, inference, evaluation, and visualization interfaces for the complete `2160×4320` scientific-grid protocol; the current workflow does not produce a complete global prediction. |
35
+ | ModelScope/OneCode execution | Validate structured data, training, inference, per-lead ocean-forecast metrics, and visualization in ModelScope or OneCode environments. |
36
+ | Multi-GPU training | Validate distributed training and the checkpoint workflow through `torchrun`. |
37
+
38
+ # Usage Instructions
39
+
40
+ ## 1.OneCode
41
+
42
+ Experience intelligent, one-click AI4S programming through the OneCode online environment:
43
+
44
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
45
+
46
+ ## 2. Download and Installation
47
+
48
+ ```bash
49
+ hf download OneScience-Group/FuXi-Ocean --local-dir ./FuXi-Ocean
50
+ cd FuXi-Ocean
51
+ ```
52
+
53
+ ### Environment Dependencies
54
+
55
+ **Hardware Requirements**
56
+
57
+ - A GPU or DCU is recommended.
58
+ - A CPU can be used for connectivity validation with the default small-sample configuration.
59
+ - DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version matching the current cluster, is recommended.
60
+
61
+ **DCU Environment**
62
+
63
+ ```bash
64
+ # Activate DTK and Conda first
65
+ conda create -n onescience311 python=3.11 -y
66
+ conda activate onescience311
67
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
68
+ ```
69
+
70
+ **GPU Environment**
71
+
72
+ ```bash
73
+ # Activate Conda first
74
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
75
+ conda activate onescience311
76
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
77
+ ```
78
+
79
+ ### Training Data
80
+
81
+ The paper uses HYCOM ocean fields and ERA5 near-surface atmospheric variables to predict the next six-hour ocean state on a `2160×4320` global grid from four historical states. This repository uses six structured synthetic tiles while preserving 105 ocean channels, five atmospheric channels, 26 depth levels, and the complete global indexing protocol. The synthetic data validate the engineering workflow only and do not represent the real HYCOM or ERA5 distributions, training scale, or paper performance.
82
+
83
+ ```bash
84
+ python scripts/fake_data.py
85
+ ```
86
+
87
+ ### Training
88
+
89
+ For single-device training, use:
90
+
91
+ ```bash
92
+ python scripts/train.py
93
+ ```
94
+
95
+ For multi-GPU training, use:
96
+
97
+ ```bash
98
+ 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
99
+ ```
100
+
101
+ Training uses latitude-weighted Charbonnier loss and multi-step autoregressive rollout; the default configuration reduces tiles, samples, model scale, epochs, and rollout length without reducing the real variables or global-coordinate protocol. Formal experiments require real HYCOM and ERA5 data and full-scale computing resources, with training artifacts saved to:
102
+
103
+ ```text
104
+ result/checkpoints/fuxi_ocean.pt
105
+ result/training/metrics.json
106
+ ```
107
+
108
+ ### Trained Weights
109
+
110
+ This repository does not bundle official weights under `weight/`, and no confirmed official model weights were identified. The complete resource link in the paper's code-availability statement is https://doi.org/10.5281/zenodo.17412508; it must not be treated as a confirmed weight-download link. The local checkpoint is an engineering artifact trained on synthetic `sampled_tiles` and makes no compatibility claim with paper or official weights.
111
+
112
+ ### Inference
113
+
114
+ ```bash
115
+ python scripts/inference.py
116
+ ```
117
+
118
+ Inference loads the locally trained checkpoint and runs three autoregressive steps on test tiles by default while retaining a record of the paper's 40-step protocol. Outputs are explicitly marked as `sampled_tiles` and `is_complete_global=false` and include their coverage fraction, so they must not be treated as a completed global prediction. Numerical outputs and metadata are saved to:
119
+
120
+ ```text
121
+ result/output/predictions.npz
122
+ result/output/metadata.json
123
+ ```
124
+
125
+ ### Evaluation and Visualization
126
+
127
+ ```bash
128
+ python scripts/result.py
129
+ ```
130
+
131
+ Evaluation computes latitude-weighted RMSE and MBE per lead for the model and persistence baseline over sampled tiles, reports S, T, U, V, and SSH unit groups separately, and retains per-channel results without averaging all 105 channels across incompatible units. Synthetic-data evaluation validates the engineering workflow only and does not represent formal paper performance. Results are saved to:
132
+
133
+ ```text
134
+ result/evaluation/metrics.json
135
+ result/evaluation/lead_metrics.png
136
+ ```
137
+
138
+ # Official OneScience Information
139
+
140
+ | Platform | OneScience Main Repository | Skills Repository |
141
+ | --- | --- | --- |
142
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
143
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
144
+
145
+ # Citation and License
146
+
147
+ This repository is an independent engineering reproduction of the public FuXi-Ocean specifications.
148
+
149
+ Use of this repository's code is subject to the Apache-2.0 license terms.
150
+
151
+ The original paper is published under CC BY 4.0, and its citation and use are subject to that license.
152
+
153
+ Use of HYCOM, ERA5, official Zenodo artifacts, and other third-party data or weights remains subject to the licenses and terms of their respective projects.
README_zh.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language:
4
+ - zh
5
+ - en
6
+ tags:
7
+ - OneScience
8
+ - 地球科学
9
+ - 海洋预报
10
+ - 涡分辨率模型
11
+ frameworks: PyTorch
12
+ ---
13
+
14
+ <p align="center">
15
+ <strong><span style="font-size: 30px;">FuXi-Ocean</span></strong>
16
+ </p>
17
+
18
+ # 模型介绍
19
+
20
+ FuXi-Ocean 用于根据历史海洋状态和初始化大气变量进行 6 小时间隔的海洋场自回归预测,为高时空分辨率海洋预报方法研究提供可运行的工程验证流程。
21
+
22
+ 论文:A deep learning global ocean forecasting model with sub-daily and eddy-resolving resolution
23
+ https://doi.org/10.1038/s41612-026-01444-2
24
+
25
+ # 模型描述
26
+
27
+ 该方法由天津大学、上海科学智能研究院、复旦大学、上海创新研究院、上海伏羲智算科技有限公司和海南热带海洋学院等机构的研究团队提出。论文使用 HYCOM 再分析与分析海洋场以及 ERA5 近地面大气变量开展训练。模型执行 6 小时间隔的温度、盐度、纬向流、经向流和海表高度自回归预测任务。
28
+
29
+ # 适用场景
30
+
31
+ | 场景 | 说明 |
32
+ | :---: | :--- |
33
+ | 海洋场自回归预测 | 在合成 sampled tiles 上验证由 4 帧历史海洋状态和初始化大气变量预测后续海洋状态的主任务。 |
34
+ | 核心方法验证 | 验证共享卷积编码、时空先验、历史特征融合、低分辨率 attention、共享解码和纬度加权 Charbonnier 损失。 |
35
+ | 本地工程验证 | 验证完整 `2160×4320` 科学网格的索引、重叠裁剪、数据契约、推理、评估和可视化接口;当前不生成完整全球预测。 |
36
+ | ModelScope/OneCode 运行 | 在 ModelScope 或 OneCode 环境中验证结构化数据、训练、推理、逐时效海洋预报指标和可视化流程。 |
37
+ | 多卡训练 | 通过 `torchrun` 验证分布式训练和 checkpoint 流程。 |
38
+
39
+ # 使用说明
40
+
41
+ ## 1.OneCode
42
+
43
+ 可通过 OneCode 在线环境体验智能化一键式 AI4S 编程:
44
+
45
+ [点击体验智能化一键式 AI4S 编程](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
46
+
47
+ ## 2.下载安装
48
+
49
+ ```bash
50
+ modelscope download --model OneScience/FuXi-Ocean --local_dir ./FuXi-Ocean
51
+ cd FuXi-Ocean
52
+ ```
53
+
54
+ ### 环境依赖
55
+
56
+ **硬件要求**
57
+
58
+ - 推荐使用 GPU 或 DCU 运行。
59
+ - CPU 可用于默认小样本配置的连通性验证。
60
+ - DCU 用户需预先安装 DTK,建议使用 DTK 25.04.2 以上版本或与当前集群匹配的 OneScience 推荐版本。
61
+
62
+ **DCU环境**
63
+
64
+ ```bash
65
+ # 请首先激活 DTK 及 Conda
66
+ conda create -n onescience311 python=3.11 -y
67
+ conda activate onescience311
68
+ pip install onescience[earth-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
69
+ ```
70
+
71
+ **GPU环境**
72
+
73
+ ```bash
74
+ # 请首先激活 Conda
75
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
76
+ conda activate onescience311
77
+ pip install onescience[earth-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
78
+ ```
79
+
80
+ ### 训练数据介绍
81
+
82
+ 论文使用 HYCOM 海洋场和 ERA5 近地面大气变量,模型根据 4 个历史时刻预测 `2160×4320` 全球网格上的下一 6 小时海洋状态。本仓库使用 6 个结构化合成 tile,保留 105 个海洋通道、5 个大气通道、26 个深度层和完整全球索引协议。虚拟数据仅用于验证工程流程,不代表 HYCOM 或 ERA5 的真实数据分布、训练规模或论文性能。
83
+
84
+ ```bash
85
+ python scripts/fake_data.py
86
+ ```
87
+
88
+ ### 训练
89
+
90
+ 单卡训练可使用:
91
+
92
+ ```bash
93
+ python scripts/train.py
94
+ ```
95
+
96
+ 多卡训练可使用:
97
+
98
+ ```bash
99
+ 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
100
+ ```
101
+
102
+ 训练使用纬度加权 Charbonnier 损失和多步自回归流程;默认配置仅缩小 tile、样本数、模型规模、训练轮数和 rollout 步数,不缩小真实变量与全局坐标协议。正式实验需要真实 HYCOM、ERA5 数据和完整计算资源,训练产物保存到:
103
+
104
+ ```text
105
+ result/checkpoints/fuxi_ocean.pt
106
+ result/training/metrics.json
107
+ ```
108
+
109
+ ### 训练权重
110
+
111
+ 本仓库不在 `weight/` 中内置官方权重,也未发现可确认的官方模型权重。论文代码可用性部分提供的完整资源链接为 https://doi.org/10.5281/zenodo.17412508 ,该链接不能视为已确认的权重下载链接。当前本地 checkpoint 是合成 `sampled_tiles` 的工程训练产物,不声明与论文或官方权重兼容。
112
+
113
+ ### 推理
114
+
115
+ ```bash
116
+ python scripts/inference.py
117
+ ```
118
+
119
+ 推理加载本地训练 checkpoint,默认在测试 tile 上执行 3 步自回归预测,同时记录论文的 40 步协议。结果明确标记为 `sampled_tiles`、`is_complete_global=false` 并保存覆盖率,不能视为已完成的全球预测;数值结果和元数据保存到:
120
+
121
+ ```text
122
+ result/output/predictions.npz
123
+ result/output/metadata.json
124
+ ```
125
+
126
+ ### 评估和可视化
127
+
128
+ ```bash
129
+ python scripts/result.py
130
+ ```
131
+
132
+ 评估在已采样 tile ���逐时效计算模型和 persistence 基线的纬度加权 RMSE 与 MBE,按 S、T、U、V 和 SSH 单位组报告并保留逐通道结果,不对单位不同的 105 个通道求统一均值。合成数据评估仅用于验证工程流程,不代表论文正式性能;结果保存到:
133
+
134
+ ```text
135
+ result/evaluation/metrics.json
136
+ result/evaluation/lead_metrics.png
137
+ ```
138
+
139
+ # OneScience官方信息
140
+
141
+ | 平台 | OneScience 主仓库 | Skills 仓库 |
142
+ | --- | --- | --- |
143
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
144
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
145
+
146
+ # 引用与许可证
147
+
148
+ 本仓库为 FuXi-Ocean 公开规格的独立工程复现版本。
149
+
150
+ 本仓库代码的使用应遵循 Apache-2.0 许可证条款。
151
+
152
+ 原论文按照 CC BY 4.0 许可证发布,引用和使用时应遵循该许可证条款。
153
+
154
+ HYCOM、ERA5、官方 Zenodo 产物和其他第三方数据或权重的使用应遵循各自项目的许可证及使用条款。
conf/config.yaml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ seed: 2026
2
+ data:
3
+ path: data/fuxi_ocean_tiles.npz
4
+ format_version: fuxi_ocean_sampled_tiles_v2
5
+ input_shape: [4, 105, 2160, 4320]
6
+ atmosphere_shape: [5, 2160, 4320]
7
+ output_shape: [105, 2160, 4320]
8
+ global_height: 2160
9
+ global_width: 4320
10
+ history_steps: 4
11
+ ocean_channels: 105
12
+ atmosphere_channels: 5
13
+ time_step_hours: 6
14
+ tile_height: 16
15
+ tile_width: 16
16
+ tile_overlap: 4
17
+ train_samples: 4
18
+ test_samples: 2
19
+ depth_levels_m: [0, 2, 4, 6, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125, 150, 200, 300, 400, 500, 600, 800, 1000, 1250, 1500]
20
+ model:
21
+ embed_dim: 24
22
+ patch_size: 2
23
+ encoder_levels: 2
24
+ attention_layers: 2
25
+ attention_heads: 4
26
+ epsilon: 0.000001
27
+ training:
28
+ epochs: 1
29
+ batch_size: 1
30
+ learning_rate: 0.0005
31
+ weight_decay: 0.00001
32
+ multistep_rollout: 2
33
+ runtime:
34
+ device: auto
35
+ ddp_backend: gloo
36
+ num_threads: 2
37
+ inference:
38
+ rollout_steps: 3
39
+ paper_model:
40
+ paper_determined:
41
+ input_shape: [4, 105, 2160, 4320]
42
+ atmosphere_shape: [5, 2160, 4320]
43
+ output_shape: [105, 2160, 4320]
44
+ ocean_channel_groups: {S: 26, T: 26, U: 26, V: 26, SSH: 1}
45
+ atmosphere_variables: [U10M, V10M, MSL, T2M, D2M]
46
+ shared_convolutional_patch_encoder: true
47
+ spatiotemporal_prior_network: true
48
+ history_fusion: four encodings concatenated then 1x1 convolution
49
+ low_resolution_attention: stacked attention with AdaLN and FFN
50
+ shared_transposed_convolution_decoder: true
51
+ skip_connections: true
52
+ depth_dependent_land_sea_mask: true
53
+ loss: latitude-weighted Charbonnier
54
+ training_stages: single-step pretraining then multi-step fine-tuning
55
+ metrics: [latitude-weighted RMSE, MBE, persistence]
56
+ missing_architecture:
57
+ patch_size: MISSING
58
+ hidden_width: MISSING
59
+ attention_layer_count: MISSING
60
+ attention_head_count: MISSING
61
+ exact_prior_network: MISSING
62
+ normalization_statistics: MISSING
63
+ optimizer_and_schedule: MISSING
64
+ batch_size_and_epochs: MISSING
65
+ multistep_finetuning_horizon: MISSING
66
+ rollout_steps: 40
67
+ time_step_hours: 6
68
+ paths:
69
+ checkpoint: result/checkpoints/fuxi_ocean.pt
70
+ training_metrics: result/training/metrics.json
71
+ inference: result/output/predictions.npz
72
+ inference_metadata: result/output/metadata.json
73
+ evaluation_metrics: result/evaluation/metrics.json
74
+ evaluation_plot: result/evaluation/lead_metrics.png
config.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "format_version": "1.0",
3
+ "model_name": "FuXi-Ocean",
4
+ "model_type": "tile-streamed-global-ocean-transformer",
5
+ "architectures": ["FuXiOcean"],
6
+ "framework": "PyTorch",
7
+ "domain": "earth-science-ocean",
8
+ "license": "Apache-2.0",
9
+ "paper": {
10
+ "title": "A deep learning global ocean forecasting model with sub-daily and eddy-resolving resolution",
11
+ "doi": "10.1038/s41612-026-01444-2",
12
+ "paper_license": "CC-BY-4.0"
13
+ },
14
+ "implementation": {
15
+ "entry_point": "model/fuxi_ocean.py",
16
+ "train_script": "scripts/train.py",
17
+ "inference_script": "scripts/inference.py",
18
+ "evaluation_script": "scripts/result.py",
19
+ "synthetic_data_script": "scripts/fake_data.py"
20
+ },
21
+ "scientific_grid": {
22
+ "history": 4,
23
+ "ocean_channels": 105,
24
+ "atmosphere_channels": 5,
25
+ "height": 2160,
26
+ "width": 4320,
27
+ "hours_per_step": 6,
28
+ "forecast_steps": 40
29
+ },
30
+ "execution_protocol": "Tiles retain all 105 channels, physical coordinates, and indices into the unchanged 2160x4320 global grid. Tile dimensions are not the scientific grid dimensions."
31
+ }
configuration.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "format_version": "1.0",
3
+ "framework": "PyTorch",
4
+ "task": "global-ocean-autoregressive-forecasting",
5
+ "model": "FuXi-Ocean",
6
+ "input_format": "sampled_tiles with deterministic full-grid enumeration and overlap crops",
7
+ "global_ocean_shape": [4, 105, 2160, 4320],
8
+ "atmosphere_shape": [5, 2160, 4320],
9
+ "output_shape": [105, 2160, 4320],
10
+ "time_step_hours": 6,
11
+ "paper_rollout_steps": 40,
12
+ "default_config": "conf/config.yaml",
13
+ "training": "scripts/train.py",
14
+ "inference": "scripts/inference.py",
15
+ "evaluation": "scripts/result.py"
16
+ }
model/fuxi_ocean.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Executable tile-streamed FuXi-Ocean reconstruction."""
2
+
3
+ import math
4
+
5
+ import numpy as np
6
+ import torch
7
+ from torch import nn
8
+ import torch.distributed as dist
9
+
10
+
11
+ FORMAT_VERSION = "fuxi_ocean_sampled_tiles_v2"
12
+ GLOBAL_SHAPE = (2160, 4320)
13
+ HISTORY_STEPS = 4
14
+ OCEAN_CHANNELS = 105
15
+ ATMOSPHERE_CHANNELS = 5
16
+ DEPTHS = (0, 2, 4, 6, 10, 15, 20, 30, 40, 50, 60, 70, 80, 90, 100, 125,
17
+ 150, 200, 300, 400, 500, 600, 800, 1000, 1250, 1500)
18
+
19
+
20
+ def enumerate_global_tiles(global_shape, tile_shape, overlap=0):
21
+ """Enumerate all windows and non-overlapping ownership crops in row-major order."""
22
+ height, width = map(int, global_shape)
23
+ tile_h, tile_w = map(int, tile_shape)
24
+ overlap = int(overlap)
25
+ if min(height, width, tile_h, tile_w) <= 0 or overlap < 0 or overlap >= min(tile_h, tile_w):
26
+ raise ValueError("invalid global/tile shape or overlap")
27
+
28
+ def starts(size, tile):
29
+ values = list(range(0, max(size - tile, 0) + 1, tile - overlap))
30
+ last = max(size - tile, 0)
31
+ if not values or values[-1] != last:
32
+ values.append(last)
33
+ return values
34
+
35
+ ys, xs = starts(height, tile_h), starts(width, tile_w)
36
+ records = []
37
+ for row, y0 in enumerate(ys):
38
+ for column, x0 in enumerate(xs):
39
+ y1, x1 = min(y0 + tile_h, height), min(x0 + tile_w, width)
40
+ top = 0 if row == 0 else (ys[row - 1] + tile_h + y0) // 2 - y0
41
+ bottom = y1 - y0 if row == len(ys) - 1 else (y1 + ys[row + 1]) // 2 - y0
42
+ left = 0 if column == 0 else (xs[column - 1] + tile_w + x0) // 2 - x0
43
+ right = x1 - x0 if column == len(xs) - 1 else (x1 + xs[column + 1]) // 2 - x0
44
+ records.append((y0, y1, x0, x1, top, bottom, left, right))
45
+ return np.asarray(records, dtype=np.int32)
46
+
47
+
48
+ def assemble_tiles(tiles, tile_records, global_shape, require_complete=True):
49
+ """Crop overlap by ownership records and assemble a dense field for verification/use."""
50
+ tiles = np.asarray(tiles)
51
+ records = np.asarray(tile_records)
52
+ height, width = map(int, global_shape)
53
+ output = np.zeros((*tiles.shape[1:-2], height, width), dtype=tiles.dtype)
54
+ coverage = np.zeros((height, width), dtype=np.uint8)
55
+ for tile, record in zip(tiles, records):
56
+ y0, _y1, x0, _x1, top, bottom, left, right = map(int, record)
57
+ output[..., y0 + top:y0 + bottom, x0 + left:x0 + right] = tile[..., top:bottom, left:right]
58
+ coverage[y0 + top:y0 + bottom, x0 + left:x0 + right] = 1
59
+ is_complete = bool(coverage.all())
60
+ if require_complete and not is_complete:
61
+ raise ValueError("tile set does not completely cover the global grid")
62
+ return output, coverage
63
+
64
+
65
+ def ocean_channel_depth_indices():
66
+ """Channel order: S26, T26, U26, V26, SSH."""
67
+ return np.asarray(list(range(26)) * 4 + [0], dtype=np.int64)
68
+
69
+
70
+ def channel_mask(depth_mask):
71
+ indices = torch.as_tensor(ocean_channel_depth_indices(), device=depth_mask.device)
72
+ return depth_mask.index_select(1, indices)
73
+
74
+
75
+ class ChannelLayerNorm(nn.Module):
76
+ def __init__(self, channels):
77
+ super().__init__()
78
+ self.norm = nn.LayerNorm(channels)
79
+
80
+ def forward(self, x):
81
+ return self.norm(x.movedim(1, -1)).movedim(-1, 1)
82
+
83
+
84
+ class PriorNetwork(nn.Module):
85
+ def __init__(self, embed_dim):
86
+ super().__init__()
87
+ self.spatial = nn.Conv2d(3, embed_dim, 1)
88
+ self.temporal = nn.Sequential(nn.Linear(6, embed_dim), nn.SiLU(), nn.Linear(embed_dim, embed_dim))
89
+
90
+ def forward(self, coordinates, bathymetry, time_features):
91
+ spatial = self.spatial(torch.cat((coordinates, bathymetry), dim=1))
92
+ hour, day, step = time_features.unbind(dim=1)
93
+ temporal = torch.stack((torch.sin(2 * math.pi * hour / 24), torch.cos(2 * math.pi * hour / 24),
94
+ torch.sin(2 * math.pi * day / 366), torch.cos(2 * math.pi * day / 366),
95
+ torch.sin(step / 40), torch.cos(step / 40)), dim=1)
96
+ return spatial, self.temporal(temporal)
97
+
98
+
99
+ class SharedPatchEncoder(nn.Module):
100
+ def __init__(self, in_channels, embed_dim, patch_size, levels):
101
+ super().__init__()
102
+ layers = []
103
+ channels = in_channels
104
+ self.skip_channels = []
105
+ for level in range(levels):
106
+ out = embed_dim * (2 ** level)
107
+ layers += [nn.Conv2d(channels, out, patch_size, patch_size), ChannelLayerNorm(out), nn.GELU()]
108
+ self.skip_channels.append(out)
109
+ channels = out
110
+ self.layers = nn.ModuleList(layers)
111
+ self.levels = levels
112
+
113
+ def forward(self, x, prior):
114
+ skips = []
115
+ for level in range(self.levels):
116
+ conv, norm, activation = self.layers[level * 3:level * 3 + 3]
117
+ x = conv(x)
118
+ local_prior = torch.nn.functional.interpolate(prior, size=x.shape[-2:], mode="bilinear", align_corners=False)
119
+ repeats = math.ceil(x.shape[1] / local_prior.shape[1])
120
+ modulation = local_prior.repeat(1, repeats, 1, 1)[:, :x.shape[1]]
121
+ x = activation(norm(x * (1.0 + torch.tanh(modulation))))
122
+ skips.append(x)
123
+ return x, skips
124
+
125
+
126
+ class AdaLNBlock(nn.Module):
127
+ def __init__(self, dim, heads):
128
+ super().__init__()
129
+ self.norm1, self.norm2 = nn.LayerNorm(dim), nn.LayerNorm(dim)
130
+ self.modulation = nn.Sequential(nn.SiLU(), nn.Linear(dim, dim * 4))
131
+ self.attention = nn.MultiheadAttention(dim, heads, batch_first=True)
132
+ self.ffn = nn.Sequential(nn.Linear(dim, dim * 4), nn.GELU(), nn.Linear(dim * 4, dim))
133
+
134
+ def forward(self, x, context):
135
+ scale1, shift1, scale2, shift2 = self.modulation(context).chunk(4, dim=-1)
136
+ q = self.norm1(x) * (1 + scale1[:, None]) + shift1[:, None]
137
+ x = x + self.attention(q, q, q, need_weights=False)[0]
138
+ q = self.norm2(x) * (1 + scale2[:, None]) + shift2[:, None]
139
+ return x + self.ffn(q)
140
+
141
+
142
+ class FuXiOcean(nn.Module):
143
+ def __init__(self, embed_dim=24, patch_size=2, encoder_levels=2, attention_layers=2, attention_heads=4):
144
+ super().__init__()
145
+ self.prior = PriorNetwork(embed_dim)
146
+ self.encoder = SharedPatchEncoder(OCEAN_CHANNELS + ATMOSPHERE_CHANNELS, embed_dim, patch_size, encoder_levels)
147
+ latent = embed_dim * 2 ** (encoder_levels - 1)
148
+ self.fusion = nn.Sequential(nn.Conv2d(HISTORY_STEPS * latent, latent, 1), ChannelLayerNorm(latent), nn.GELU())
149
+ self.blocks = nn.ModuleList(AdaLNBlock(latent, attention_heads) for _ in range(attention_layers))
150
+ self.context_projection = nn.Linear(embed_dim, latent)
151
+ decoder = []
152
+ for level in reversed(range(encoder_levels)):
153
+ skip = embed_dim * 2 ** level
154
+ out = embed_dim * 2 ** max(level - 1, 0)
155
+ decoder.append(nn.Sequential(nn.ConvTranspose2d(latent + skip, out, patch_size, patch_size),
156
+ ChannelLayerNorm(out), nn.GELU()))
157
+ latent = out
158
+ self.decoder = nn.ModuleList(decoder)
159
+ self.output = nn.Conv2d(latent, OCEAN_CHANNELS, 1)
160
+
161
+ def forward(self, ocean_history, atmosphere, coordinates, bathymetry, depth_mask, time_features):
162
+ if ocean_history.ndim != 5 or ocean_history.shape[1:3] != (HISTORY_STEPS, OCEAN_CHANNELS):
163
+ raise ValueError("ocean_history must be [B,4,105,tile_h,tile_w]")
164
+ spatial_prior, temporal_prior = self.prior(coordinates, bathymetry, time_features)
165
+ encoded, all_skips = [], []
166
+ for index in range(HISTORY_STEPS):
167
+ state = torch.cat((ocean_history[:, index], atmosphere), dim=1)
168
+ latent, skips = self.encoder(state, spatial_prior)
169
+ encoded.append(latent)
170
+ all_skips.append(skips)
171
+ x = self.fusion(torch.cat(encoded, dim=1))
172
+ shape = x.shape
173
+ tokens = x.flatten(2).transpose(1, 2)
174
+ context = self.context_projection(temporal_prior)
175
+ for block in self.blocks:
176
+ tokens = block(tokens, context)
177
+ x = tokens.transpose(1, 2).reshape(shape)
178
+ for decoder_index, decoder in enumerate(self.decoder):
179
+ level = len(self.decoder) - 1 - decoder_index
180
+ skip = torch.stack([values[level] for values in all_skips]).mean(dim=0)
181
+ x = decoder(torch.cat((x, skip), dim=1))
182
+ prediction = self.output(x)
183
+ return prediction * channel_mask(depth_mask).to(prediction.dtype)
184
+
185
+
186
+ def latitude_weighted_charbonnier(prediction, target, latitude_deg, valid_mask, epsilon=1e-6):
187
+ weights = torch.cos(torch.deg2rad(latitude_deg)).clamp_min(0)[:, None, :, None]
188
+ mask = valid_mask.to(prediction.dtype)
189
+ numerator = (torch.sqrt((prediction - target) ** 2 + epsilon ** 2) * weights * mask).sum()
190
+ denominator = (weights * mask).sum()
191
+ if dist.is_available() and dist.is_initialized():
192
+ global_numerator = numerator.detach().clone()
193
+ global_denominator = denominator.detach().clone()
194
+ dist.all_reduce(global_numerator, op=dist.ReduceOp.SUM)
195
+ dist.all_reduce(global_denominator, op=dist.ReduceOp.SUM)
196
+ # DDP averages gradients, so this yields the gradient of the global ratio.
197
+ return numerator * dist.get_world_size() / global_denominator.clamp_min(1.0)
198
+ return numerator / denominator.clamp_min(1.0)
199
+
200
+
201
+ def weighted_error_sums(prediction, target, latitude_deg, valid_mask):
202
+ weights = np.cos(np.deg2rad(latitude_deg))[:, None, :, None] * valid_mask
203
+ error = prediction - target
204
+ return ((weights * error ** 2).sum(axis=(0, 2, 3)),
205
+ (weights * error).sum(axis=(0, 2, 3)), weights.sum(axis=(0, 2, 3)))
scripts/fake_data.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Create small physical-coordinate tiles without allocating a global dense sample."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import numpy as np
7
+ import yaml
8
+
9
+ import sys
10
+
11
+
12
+ ROOT = Path(__file__).resolve().parents[1]
13
+ sys.path.insert(0, str(ROOT))
14
+ from model.fuxi_ocean import enumerate_global_tiles
15
+
16
+
17
+ def main():
18
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
19
+ data = config["data"]
20
+ rng = np.random.default_rng(config["seed"])
21
+ count = data["train_samples"] + data["test_samples"]
22
+ th, tw = data["tile_height"], data["tile_width"]
23
+ history, channels, atmosphere_channels = data["history_steps"], data["ocean_channels"], data["atmosphere_channels"]
24
+ global_records = enumerate_global_tiles((data["global_height"], data["global_width"]), (th, tw), data["tile_overlap"])
25
+ selected_indices = np.linspace(0, len(global_records) - 1, count, dtype=np.int64)
26
+ selected_records = global_records[selected_indices]
27
+ origins = selected_records[:, (0, 2)]
28
+ ocean = np.empty((count, history, channels, th, tw), dtype=np.float32)
29
+ targets = np.empty((count, channels, th, tw), dtype=np.float32)
30
+ atmosphere = np.empty((count, atmosphere_channels, th, tw), dtype=np.float32)
31
+ latitude = np.empty((count, th), dtype=np.float32)
32
+ longitude = np.empty((count, tw), dtype=np.float32)
33
+ bathymetry = np.empty((count, 1, th, tw), dtype=np.float32)
34
+ depth_mask = np.empty((count, 26, th, tw), dtype=np.float32)
35
+ time_features = np.empty((count, 3), dtype=np.float32)
36
+ yy, xx = np.meshgrid(np.arange(th), np.arange(tw), indexing="ij")
37
+ channel_scale = np.linspace(0.2, 1.0, channels, dtype=np.float32)[:, None, None]
38
+ for sample, (y0, x0) in enumerate(origins):
39
+ lat = 90.0 - (y0 + np.arange(th) + 0.5) * 180.0 / data["global_height"]
40
+ lon = (x0 + np.arange(tw) + 0.5) * 360.0 / data["global_width"]
41
+ latitude[sample], longitude[sample] = lat, lon
42
+ wave = np.sin(np.deg2rad(lat))[:, None] + 0.5 * np.cos(np.deg2rad(lon))[None, :]
43
+ for history_index in range(history):
44
+ ocean[sample, history_index] = channel_scale * (wave + 0.025 * history_index) + rng.normal(0, 0.005, (channels, th, tw))
45
+ atmosphere[sample] = np.stack([wave + 0.03 * k for k in range(atmosphere_channels)])
46
+ bathymetry[sample, 0] = 200 + 1400 * (0.5 + 0.5 * np.sin((xx + x0) / 12))
47
+ for depth, depth_m in enumerate(data["depth_levels_m"]):
48
+ depth_mask[sample, depth] = bathymetry[sample, 0] >= depth_m
49
+ targets[sample] = ocean[sample, -1] + channel_scale * (0.015 * atmosphere[sample, 0] - 0.006 * atmosphere[sample, 1])
50
+ time_features[sample] = ((sample * 6) % 24, 100 + sample // 4, 0)
51
+ channel_depth = np.asarray(list(range(26)) * 4 + [0])
52
+ targets *= depth_mask[:, channel_depth]
53
+ path = ROOT / data["path"]
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ input_shape = np.asarray([history, channels, data["global_height"], data["global_width"]])
56
+ atmosphere_shape = np.asarray([atmosphere_channels, data["global_height"], data["global_width"]])
57
+ output_shape = np.asarray([channels, data["global_height"], data["global_width"]])
58
+ owned_pixels = sum((r[5] - r[4]) * (r[7] - r[6]) for r in selected_records)
59
+ coverage_fraction = owned_pixels / (data["global_height"] * data["global_width"])
60
+ np.savez_compressed(path, format_version=data["format_version"], input_shape=input_shape,
61
+ atmosphere_shape=atmosphere_shape, output_shape=output_shape,
62
+ global_tile_records=global_records, selected_tile_indices=selected_indices,
63
+ selected_tile_records=selected_records, tile_origins=origins,
64
+ tile_record_fields=np.asarray(["y0", "y1", "x0", "x1", "crop_top", "crop_bottom", "crop_left", "crop_right"]),
65
+ tile_order="row-major", overlap_crop_semantics="midpoint ownership; crops partition global grid exactly",
66
+ coverage_fraction=coverage_fraction, is_complete_global=False, synthetic=True,
67
+ ocean=ocean, atmosphere=atmosphere, targets=targets, latitude_deg=latitude,
68
+ longitude_deg=longitude, bathymetry_m=bathymetry, depth_mask=depth_mask,
69
+ time_features=time_features, train_count=data["train_samples"])
70
+ print(json.dumps({"path": str(path.relative_to(ROOT)), "input_shape": input_shape.tolist(),
71
+ "tile_shape": list(ocean.shape), "global_tile_count": len(global_records),
72
+ "coverage_fraction": coverage_fraction, "bytes": path.stat().st_size}))
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
scripts/inference.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autoregressive tile inference with the unchanged scientific global-grid contract."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ import numpy as np
8
+ import torch
9
+ import yaml
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(ROOT))
13
+ sys.path.insert(0, str(ROOT / "scripts"))
14
+ from model.fuxi_ocean import FORMAT_VERSION, FuXiOcean, assemble_tiles
15
+ from train import TileDataset, validate_data_contract
16
+
17
+
18
+ def main():
19
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
20
+ torch.set_num_threads(config["runtime"]["num_threads"])
21
+ device = torch.device("cuda" if torch.cuda.is_available() and config["runtime"]["device"] != "cpu" else "cpu")
22
+ checkpoint = torch.load(ROOT / config["paths"]["checkpoint"], map_location=device, weights_only=False)
23
+ required = {"model", "model_config", "format_version"}
24
+ if not required.issubset(checkpoint):
25
+ raise ValueError(f"checkpoint missing keys: {sorted(required - checkpoint.keys())}")
26
+ model_config = checkpoint["model_config"]
27
+ model_args = model_config["architecture"]
28
+ model = FuXiOcean(**model_args).to(device)
29
+ model.load_state_dict(checkpoint["model"]); model.eval()
30
+ data = np.load(ROOT / config["data"]["path"])
31
+ validate_data_contract(data, config)
32
+ shape_keys = ("input_shape", "atmosphere_shape", "output_shape")
33
+ if checkpoint["format_version"] != FORMAT_VERSION or model_config["data_format_version"] != str(data["format_version"]) or any(model_config[key] != data[key].tolist() for key in shape_keys):
34
+ raise ValueError("checkpoint, model configuration, and data contract are incompatible")
35
+ predictions, truths, initial, latitudes, masks, origins = [], [], [], [], [], []
36
+ with torch.no_grad():
37
+ for index in range(int(data["train_count"]), len(data["ocean"])):
38
+ batch = TileDataset(data, [index])[0]
39
+ ocean, atmosphere, coordinates, bathymetry, mask, time_info, target, latitude = [value[None].to(device) for value in batch]
40
+ history = ocean; sample_predictions, sample_truths = [], []
41
+ for lead in range(config["inference"]["rollout_steps"]):
42
+ time_info[:, 2] = lead
43
+ prediction = model(history, atmosphere, coordinates, bathymetry, mask, time_info)
44
+ truth = target + lead * 0.005
45
+ sample_predictions.append(prediction.cpu().numpy()[0]); sample_truths.append(truth.cpu().numpy()[0])
46
+ history = torch.cat((history[:, 1:], prediction[:, None]), dim=1)
47
+ predictions.append(sample_predictions); truths.append(sample_truths); initial.append(ocean[0, -1].cpu().numpy())
48
+ latitudes.append(latitude[0].cpu().numpy()); masks.append(mask[0].cpu().numpy()); origins.append(data["tile_origins"][index])
49
+ output = ROOT / config["paths"]["inference"]; output.parent.mkdir(parents=True, exist_ok=True)
50
+ records = data["selected_tile_records"][int(data["train_count"]):]
51
+ # Exercise overlap-crop assembly on a bounded canvas without allocating the global field.
52
+ test_tile = np.ones((1, 1, config["data"]["tile_height"], config["data"]["tile_width"]), dtype=np.float32)
53
+ _, stitch_coverage = assemble_tiles(test_tile, [[0, config["data"]["tile_height"], 0, config["data"]["tile_width"], 0,
54
+ config["data"]["tile_height"], 0, config["data"]["tile_width"]]],
55
+ (config["data"]["tile_height"], config["data"]["tile_width"]))
56
+ coverage_fraction = float(sum((r[5] - r[4]) * (r[7] - r[6]) for r in records) /
57
+ (config["data"]["global_height"] * config["data"]["global_width"]))
58
+ checkpoint_source = str(config["paths"]["checkpoint"])
59
+ np.savez_compressed(output, output_kind="sampled_tiles", format_version=FORMAT_VERSION,
60
+ checkpoint_source=checkpoint_source, sample_count=len(predictions), synthetic=True,
61
+ coverage_fraction=coverage_fraction, is_complete_global=False,
62
+ variable_groups=json.dumps({"S": {"channels": [0, 26], "unit": "psu"}, "T": {"channels": [26, 52], "unit": "degC"}, "U": {"channels": [52, 78], "unit": "m s-1"}, "V": {"channels": [78, 104], "unit": "m s-1"}, "SSH": {"channels": [104, 105], "unit": "m"}}),
63
+ prediction=np.asarray(predictions), truth=np.asarray(truths), initial=np.asarray(initial),
64
+ latitude_deg=np.asarray(latitudes), depth_mask=np.asarray(masks), tile_origins=np.asarray(origins),
65
+ tile_records=records, output_shape=data["output_shape"],
66
+ lead_hours=config["data"]["time_step_hours"] * np.arange(1, config["inference"]["rollout_steps"] + 1))
67
+ metadata = {"output_kind": "sampled_tiles", "format_version": FORMAT_VERSION,
68
+ "checkpoint_source": checkpoint_source, "sample_count": len(predictions), "synthetic": True,
69
+ "output_shape": data["output_shape"].tolist(), "paper_rollout_steps": config["paper_model"]["rollout_steps"],
70
+ "executed_rollout_steps": config["inference"]["rollout_steps"], "tile_streaming": True,
71
+ "tile_origins": np.asarray(origins).tolist(), "coverage_fraction": coverage_fraction,
72
+ "is_complete_global": False, "stitch_interface_verified": bool(stitch_coverage.all()),
73
+ "coverage_semantics": "fraction of global cells owned after deterministic overlap crops",
74
+ "variable_groups": json.loads(str(np.load(output)["variable_groups"]))}
75
+ metadata_path = ROOT / config["paths"]["inference_metadata"]
76
+ metadata_path.write_text(json.dumps(metadata, indent=2) + "\n")
77
+ print(f"predictions={output.relative_to(ROOT)} shape={np.asarray(predictions).shape}")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
scripts/result.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compute per-lead latitude-weighted model and persistence RMSE/MBE."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ import sys
6
+
7
+ import matplotlib.pyplot as plt
8
+ import numpy as np
9
+ import yaml
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(ROOT))
13
+ from model.fuxi_ocean import ocean_channel_depth_indices, weighted_error_sums
14
+
15
+
16
+ def aggregate(prediction, truth, latitude, depth_mask):
17
+ channel_depth = ocean_channel_depth_indices()
18
+ mask = np.take(depth_mask, channel_depth, axis=1)
19
+ squared, bias, weight = weighted_error_sums(prediction, truth, latitude, mask)
20
+ return np.sqrt(squared / np.maximum(weight, 1)), bias / np.maximum(weight, 1)
21
+
22
+
23
+ def main():
24
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
25
+ data = np.load(ROOT / config["paths"]["inference"])
26
+ model_rmse, model_mbe, persistence_rmse, persistence_mbe = [], [], [], []
27
+ for lead in range(data["prediction"].shape[1]):
28
+ rmse, mbe = aggregate(data["prediction"][:, lead], data["truth"][:, lead], data["latitude_deg"], data["depth_mask"])
29
+ prmse, pmbe = aggregate(data["initial"], data["truth"][:, lead], data["latitude_deg"], data["depth_mask"])
30
+ model_rmse.append(rmse); model_mbe.append(mbe); persistence_rmse.append(prmse); persistence_mbe.append(pmbe)
31
+ arrays = [np.asarray(value) for value in (model_rmse, model_mbe, persistence_rmse, persistence_mbe)]
32
+ lead_hours = data["lead_hours"].tolist()
33
+ groups = json.loads(str(data["variable_groups"]))
34
+ def grouped(values, i):
35
+ return {name: {"unit": spec["unit"], "rmse": float(np.mean(values[0][i, slice(*spec["channels"])])),
36
+ "mbe": float(np.mean(values[1][i, slice(*spec["channels"])]))} for name, spec in groups.items()}
37
+ metrics = {"output_kind": str(data["output_kind"]), "format_version": str(data["format_version"]),
38
+ "checkpoint_source": str(data["checkpoint_source"]), "sample_count": int(data["sample_count"]),
39
+ "coverage_fraction": float(data["coverage_fraction"]), "is_complete_global": bool(data["is_complete_global"]),
40
+ "synthetic": bool(data["synthetic"]), "aggregation": "latitude-weighted sampled tiles; never across unit groups",
41
+ "output_shape": data["output_shape"].tolist(), "variable_groups": groups,
42
+ "per_lead": [{"lead_hours": hour, "model_by_group": grouped(arrays[:2], i),
43
+ "persistence_by_group": grouped(arrays[2:], i),
44
+ "model_by_channel": {"rmse": arrays[0][i].tolist(), "mbe": arrays[1][i].tolist()},
45
+ "persistence_by_channel": {"rmse": arrays[2][i].tolist(), "mbe": arrays[3][i].tolist()}}
46
+ for i, hour in enumerate(lead_hours)]}
47
+ path = ROOT / config["paths"]["evaluation_metrics"]; path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_text(json.dumps(metrics, indent=2) + "\n")
49
+ figure, axes = plt.subplots(1, 5, figsize=(18, 3.5))
50
+ for axis, (name, spec) in zip(axes, groups.items()):
51
+ channel_slice = slice(*spec["channels"])
52
+ axis.plot(lead_hours, arrays[0][:, channel_slice].mean(1), "o-", label="model")
53
+ axis.plot(lead_hours, arrays[2][:, channel_slice].mean(1), "s--", label="persistence")
54
+ axis.set(title=name, xlabel="Lead (h)", ylabel=f"RMSE ({spec['unit']})"); axis.legend()
55
+ figure.tight_layout()
56
+ plot = ROOT / config["paths"]["evaluation_plot"]; figure.savefig(plot, dpi=160); plt.close(figure)
57
+ print(f"metrics={path.relative_to(ROOT)} plot={plot.relative_to(ROOT)} leads={lead_hours}")
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
scripts/train.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train FuXi-Ocean on globally indexed tiles, with optional torchrun DDP."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import random
7
+ import sys
8
+
9
+ import numpy as np
10
+ import torch
11
+ from torch.nn.parallel import DistributedDataParallel
12
+ from torch.utils.data import DataLoader, Dataset
13
+ from torch.utils.data.distributed import DistributedSampler
14
+ import yaml
15
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+ sys.path.insert(0, str(ROOT))
18
+ from model.fuxi_ocean import FORMAT_VERSION, FuXiOcean, channel_mask, latitude_weighted_charbonnier
19
+
20
+
21
+ class TileDataset(Dataset):
22
+ def __init__(self, data, indices):
23
+ self.data, self.indices = data, list(indices)
24
+
25
+ def __len__(self):
26
+ return len(self.indices)
27
+
28
+ def __getitem__(self, item):
29
+ index = self.indices[item]
30
+ lat, lon = self.data["latitude_deg"][index], self.data["longitude_deg"][index]
31
+ coordinates = np.stack(np.meshgrid(lon / 180 - 1, lat / 90, indexing="xy"))
32
+ names = ("ocean", "atmosphere", "bathymetry_m", "depth_mask", "time_features", "targets")
33
+ values = [self.data[name][index] for name in names]
34
+ values[2] = values[2] / 5000
35
+ return tuple(torch.as_tensor(value, dtype=torch.float32) for value in (*values[:2], coordinates, *values[2:], lat))
36
+
37
+
38
+ def validate_data_contract(data, config):
39
+ expected = config["data"]
40
+ checks = {"format_version": str(data["format_version"]) == config["data"]["format_version"],
41
+ "input_shape": data["input_shape"].tolist() == expected["input_shape"],
42
+ "atmosphere_shape": data["atmosphere_shape"].tolist() == expected["atmosphere_shape"],
43
+ "output_shape": data["output_shape"].tolist() == expected["output_shape"]}
44
+ if not all(checks.values()):
45
+ raise ValueError(f"data contract mismatch: {checks}")
46
+
47
+
48
+ def main():
49
+ config = yaml.safe_load((ROOT / "conf/config.yaml").read_text())
50
+ torch.set_num_threads(config["runtime"]["num_threads"])
51
+ random.seed(config["seed"]); np.random.seed(config["seed"]); torch.manual_seed(config["seed"])
52
+ world = int(os.environ.get("WORLD_SIZE", "1")); distributed = world > 1
53
+ if distributed:
54
+ torch.distributed.init_process_group(config["runtime"]["ddp_backend"])
55
+ rank = torch.distributed.get_rank() if distributed else 0
56
+ local_rank = int(os.environ.get("LOCAL_RANK", "0"))
57
+ available_devices = torch.cuda.device_count() if torch.cuda.is_available() else 0
58
+ requested_auto_gpu = config["runtime"]["device"] != "cpu" and available_devices >= world
59
+ use_cuda = requested_auto_gpu and local_rank < available_devices
60
+ device = torch.device(f"cuda:{local_rank}" if use_cuda else "cpu")
61
+ data = np.load(ROOT / config["data"]["path"])
62
+ validate_data_contract(data, config)
63
+ if config["data"]["format_version"] != FORMAT_VERSION:
64
+ raise ValueError("configuration/model format version mismatch")
65
+ model_args = {key: value for key, value in config["model"].items() if key != "epsilon"}
66
+ model = FuXiOcean(**model_args).to(device)
67
+ if distributed:
68
+ model = DistributedDataParallel(model, device_ids=[local_rank] if use_cuda else None)
69
+ optimizer = torch.optim.AdamW(model.parameters(), lr=config["training"]["learning_rate"],
70
+ weight_decay=config["training"]["weight_decay"])
71
+ losses = []
72
+ train_count = int(data["train_count"])
73
+ dataset = TileDataset(data, range(train_count))
74
+ sampler = DistributedSampler(dataset, num_replicas=world, rank=rank, shuffle=True, seed=config["seed"]) if distributed else None
75
+ loader = DataLoader(dataset, batch_size=config["training"]["batch_size"], sampler=sampler,
76
+ shuffle=sampler is None, drop_last=False)
77
+ for epoch in range(config["training"]["epochs"]):
78
+ if sampler is not None:
79
+ sampler.set_epoch(epoch)
80
+ for batch in loader:
81
+ ocean, atmosphere, coordinates, bathymetry, mask, time_info, target, latitude = [value.to(device) for value in batch]
82
+ history = ocean
83
+ step_losses = []
84
+ for step in range(config["training"]["multistep_rollout"]):
85
+ time_info[:, 2] = step
86
+ prediction = model(history, atmosphere, coordinates, bathymetry, mask, time_info)
87
+ step_target = target + step * 0.005
88
+ step_losses.append(latitude_weighted_charbonnier(prediction, step_target, latitude,
89
+ channel_mask(mask), config["model"]["epsilon"]))
90
+ history = torch.cat((history[:, 1:], prediction[:, None]), dim=1)
91
+ loss = torch.stack(step_losses).mean()
92
+ optimizer.zero_grad(); loss.backward(); optimizer.step()
93
+ losses.append(float(loss.detach()))
94
+ local = torch.tensor([sum(losses), len(losses)], dtype=torch.float64, device=device)
95
+ if distributed:
96
+ torch.distributed.all_reduce(local)
97
+ if rank == 0:
98
+ raw_model = model.module if distributed else model
99
+ checkpoint_path = ROOT / config["paths"]["checkpoint"]
100
+ checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
101
+ model_config = {"architecture": model_args, "data_format_version": FORMAT_VERSION,
102
+ "input_shape": data["input_shape"].tolist(), "atmosphere_shape": data["atmosphere_shape"].tolist(),
103
+ "output_shape": data["output_shape"].tolist()}
104
+ torch.save({"model": raw_model.state_dict(), "model_config": model_config, "format_version": FORMAT_VERSION,
105
+ "optimizer": optimizer.state_dict()}, checkpoint_path)
106
+ metrics_path = ROOT / config["paths"]["training_metrics"]
107
+ metrics_path.parent.mkdir(parents=True, exist_ok=True)
108
+ metrics_path.write_text(json.dumps({"mean_loss": local[0].item() / local[1].item(), "world_size": world,
109
+ "backward_pass": True, "global_loss_all_reduce": distributed,
110
+ "batch_size": config["training"]["batch_size"],
111
+ "input_shape": data["input_shape"].tolist(), "synthetic": True}, indent=2) + "\n")
112
+ print(f"checkpoint={checkpoint_path.relative_to(ROOT)} loss={local[0].item() / local[1].item():.6f}")
113
+ if distributed:
114
+ torch.distributed.destroy_process_group()
115
+
116
+
117
+ if __name__ == "__main__":
118
+ main()
weight/.gitkeep ADDED
File without changes