yushuang88 commited on
Commit
07a5280
·
verified ·
1 Parent(s): 3c6eef1

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - OneScience
7
+ - fluid-mechanics
8
+ - flow-field-prediction
9
+ - neural-operator
10
+ - Navier-Stokes
11
+ frameworks: PyTorch
12
+ ---
13
+ <p align="center">
14
+ <strong>
15
+ <span style="font-size: 30px;">FNO</span>
16
+ </strong>
17
+ </p>
18
+
19
+ # Model Introduction
20
+
21
+ FNO (Fourier Neural Operator) is a class of neural operators for parameterized partial differential equations. It directly learns mappings from input functions to solution functions by parameterizing integral kernels in Fourier space. Using the OneScience skill workflow, this project independently reproduces the FNO-2D experiment for predicting the vorticity of two-dimensional incompressible Navier–Stokes flows.
22
+
23
+ Paper: [Fourier Neural Operator for Parametric Partial Differential Equations](https://arxiv.org/abs/2010.08895)
24
+
25
+ # Model Description
26
+
27
+ This implementation takes 10 consecutive 64 × 64 vorticity fields as input and autoregressively predicts the next 10 frames. It first maps the historical fields and two-dimensional periodic coordinates into a latent space of width 32, then applies four Fourier layers. Each layer retains 12 Fourier modes along each spatial dimension, adds a 1 × 1 local convolution to the spectral convolution, and applies BatchNorm and ReLU. A 32 → 128 → 1 projection head generates the vorticity field for the next time step. Each prediction is appended to the input window for closed-loop rollout inference.
28
+
29
+ ## Use Cases
30
+
31
+ | Use case | Description |
32
+ | --- | --- |
33
+ | Parameterized PDE operator learning | Learns mappings from PDE parameters, coefficient fields, or initial conditions to solution fields, especially when a PDE must be solved repeatedly for many parameter settings. |
34
+ | Burgers' equation prediction | Predicts future states from initial conditions for the one-dimensional Burgers' equation, demonstrating operator learning for nonlinear evolution equations. |
35
+ | Darcy flow prediction | Predicts steady-state solutions from two-dimensional diffusion or permeability coefficient fields for applications such as porous-media flow and groundwater seepage. |
36
+ | Navier–Stokes flow prediction | Autoregressively predicts the evolution of two-dimensional incompressible flow from historical vorticity fields. |
37
+
38
+ # Usage
39
+
40
+ ## 1. Using OneCode
41
+
42
+ Try intelligent, one-click AI4S programming in the OneCode online environment:
43
+
44
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
45
+
46
+ ## 2. Manual Installation and Usage
47
+
48
+ **Hardware requirements**
49
+
50
+ - A GPU or DCU is recommended.
51
+ - A CPU can be used for import checks and small-scale connectivity tests, but full training and inference will be slow.
52
+ - DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version for the current cluster, is recommended.
53
+
54
+ ### Download the Model Package
55
+
56
+ ```bash
57
+ modelscope download --model OneScience/FNO --local_dir ./FNO
58
+ cd FNO
59
+ ```
60
+
61
+ ### Set Up the Runtime Environment
62
+
63
+ **DCU environment**
64
+
65
+ ```bash
66
+ # Activate DTK and Conda first
67
+ conda create -n onescience311 python=3.11 -y
68
+ conda activate onescience311
69
+ # Installation with uv is also supported
70
+ pip install onescience[cfd-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
71
+ ```
72
+
73
+ **GPU environment**
74
+
75
+ ```bash
76
+ # Activate Conda first
77
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
78
+ conda activate onescience311
79
+ # Installation with uv is also supported
80
+ pip install onescience[cfd-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
81
+ ```
82
+
83
+ ### Training Data
84
+
85
+ The experiment uses `NavierStokes_V1e-5_N1200_T20.mat`, whose shape is `[N,H,W,T]=[1200,64,64,20]`. For each trajectory, the first 10 frames are inputs and the final 10 frames are prediction targets. The first 1,000 trajectories are used for training and the remaining 200 for testing. There is no separate validation set, and no normalization is applied.
86
+
87
+ Download the data with:
88
+
89
+ ```bash
90
+ modelscope download --dataset OneScience/fno --local_dir ./data
91
+ ```
92
+
93
+ After downloading, set `data.root` in `config/config.yaml` to the data directory and confirm that `data.file` matches the MAT filename above. The training script strictly validates the field name, shape, dtype, and finite values.
94
+
95
+ ### Training
96
+
97
+ The default configuration corresponds to the FNO-2D experiment with `ν=1e-5` and `T=20` in the paper. It trains for 500 epochs with a batch size of 20, uses Adam with an initial learning rate of `1e-3`, and halves the learning rate every 100 epochs.
98
+
99
+ ```bash
100
+ python scripts/train.py --config config/config.yaml --device auto
101
+ ```
102
+
103
+ The weights with the best full-trajectory relative L2 error on the training set are saved to `weight/best_model.pth`. The latest complete training state from every epoch is saved to `weight/last_model.pth`, and the training history is written to `results/train_history.json`.
104
+
105
+ ### Trained Weights
106
+
107
+ `weight/best_model.pth` contains the best weights from the full training run and can be used directly for inference.
108
+
109
+ ### Inference
110
+
111
+ Before running inference, make sure the configured data path is valid and `weight/best_model.pth` exists. The standard inference run uses the 200 configured test trajectories and a 10-step closed-loop rollout, printing batch progress and the final relative L2 error in real time:
112
+
113
+ ```bash
114
+ python scripts/inference.py --config config/config.yaml
115
+ ```
116
+
117
+ Inference outputs are saved as:
118
+
119
+ - `results/predictions.npz`: predictions, ground truth, sample indices, and prediction times;
120
+ - `results/metrics.json`: overall and per-step metrics, including comparisons with the reference values from the paper;
121
+ - `results/per_sample_metrics.csv`: per-sample relative L2 errors.
122
+
123
+ ### Evaluation and Visualization
124
+
125
+ After training and inference, run:
126
+
127
+ ```bash
128
+ python scripts/result.py --config config/config.yaml --sample-index 0
129
+ ```
130
+
131
+ The script recomputes metrics from `predictions.npz`, cross-checks the JSON and CSV outputs, best epoch, and prediction shape, and then generates:
132
+
133
+ - `results/training_curves.png`: training/test errors and training-objective curves;
134
+ - `results/sample_000_rollout.png`: ground truth, predictions, and absolute errors at `t=11, 16, 20` for a representative sample;
135
+ - `results/run_metadata.json`: configuration, runtime environment, assumptions, file hashes, and quality checks;
136
+ - `results/summary.md`: a summary of experimental results.
137
+
138
+ # Official OneScience Resources
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
+ - Paper: [Fourier Neural Operator for Parametric Partial Differential Equations](https://arxiv.org/abs/2010.08895)
148
+ - The code in this model package is licensed under the MIT License. Use of the model weights is also subject to the licenses and applicable terms of the training data and third-party dependencies.
config/config.yaml ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ paper:
2
+ title: "Fourier Neural Operator for Parametric Partial Differential Equations"
3
+ arxiv: "2010.08895"
4
+ experiment: "FNO-2D Navier-Stokes, nu=1e-5, T=20"
5
+ viscosity: 1.0e-5
6
+ reference_relative_l2: 0.1556
7
+ reference_parameter_count: 414517
8
+ reference_epoch_seconds_v100: 127.80
9
+
10
+ data:
11
+ root: "/public/share/sugonhpcapp01/onestore/onedatasets/FNO_data"
12
+ file: "NavierStokes_V1e-5_N1200_T20.mat"
13
+ key: "u"
14
+ layout: "N,H,W,T"
15
+ dtype: "float32"
16
+ expected_shape: [1200, 64, 64, 20]
17
+ resolution: [64, 64]
18
+ ntrain: 1000
19
+ ntest: 200
20
+ train_start: 0
21
+ test_start: 1000
22
+ history: 10
23
+ horizon: 10
24
+ recording_interval: 1.0
25
+ future_times: [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
26
+ normalization: "none"
27
+
28
+ model:
29
+ name: "FNO2d"
30
+ input_channels: 10
31
+ output_channels: 1
32
+ use_grid: true
33
+ grid_channels: 2
34
+ grid_include_endpoint: false
35
+ width: 32
36
+ modes1: 12
37
+ modes2: 12
38
+ num_layers: 4
39
+ projection_width: 128
40
+ activation: "relu"
41
+ normalization: "batch_norm"
42
+ block_order: "relu(batch_norm(spectral_plus_pointwise))"
43
+ fft_norm: "backward"
44
+ spectral_init: "scaled_uniform_complex"
45
+
46
+ training:
47
+ epochs: 500
48
+ batch_size: 20
49
+ optimizer: "adam"
50
+ learning_rate: 0.001
51
+ weight_decay: 0.0
52
+ scheduler: "step_lr"
53
+ scheduler_step_size: 100
54
+ scheduler_gamma: 0.5
55
+ seed: 0
56
+ dtype: "float32"
57
+ amp: false
58
+ gradient_clipping: null
59
+ ema: false
60
+ distributed: "single"
61
+ num_workers: 0
62
+ pin_memory: true
63
+ deterministic: true
64
+ relative_l2_epsilon: 1.0e-12
65
+ train_rollout_steps: 10
66
+ evaluation_rollout_steps: 10
67
+ checkpoint_monitor: "train_full_relative_l2"
68
+ checkpoint_mode: "min"
69
+ evaluate_test_every_epoch: true
70
+
71
+ inference:
72
+ batch_size: 20
73
+ seed: 0
74
+ dtype: "float32"
75
+ rollout_steps: 10
76
+
77
+ paths:
78
+ checkpoint: "weight/best_model.pth"
79
+ results_dir: "results"
80
+ train_history: "results/train_history.json"
81
+ predictions: "results/predictions.npz"
82
+ metrics: "results/metrics.json"
83
+ per_sample_metrics: "results/per_sample_metrics.csv"
84
+ training_curves: "results/training_curves.png"
85
+ rollout_figure: "results/sample_000_rollout.png"
86
+ run_metadata: "results/run_metadata.json"
87
+ summary: "results/summary.md"
88
+
89
+ assumptions:
90
+ - "The paper does not specify a validation split; the best checkpoint is selected using train full-trajectory relative L2, never test error."
91
+ - "The paper does not specify batch size or seed; batch_size=20 and seed=0 are explicit engineering assumptions."
92
+ - "The paper does not define the exact relative-L2 reduction; ratios are computed per sample and then averaged with epsilon=1e-12."
93
+ - "The paper does not specify the projection hidden width; projection_width=128 is configurable."
94
+ - "Coordinate-grid input is configurable and enabled; the periodic grid excludes the duplicated endpoint."
95
+ - "The block ordering is ReLU(BatchNorm(spectral + pointwise)); the paper states ReLU and batch normalization but not their exact order."
96
+ - "Adam uses weight_decay=0.0 because the paper does not state an additional weight-decay regularizer."
97
+
98
+ conflicts:
99
+ - "The paper states d_v=32 but reports 414,517 parameters without enough connection details to reproduce both uniquely. Width 32 takes precedence and the actual parameter count must be reported."
configuration.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {"framework":"Jax","task":"other"}
models/FNO.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paper-driven Fourier Neural Operator for 2-D Navier--Stokes rollout.
2
+
3
+ This is an independent implementation of Equations (2), (4), and (5) in
4
+ arXiv:2010.08895. It does not copy the authors' repository implementation.
5
+ The public interface is channel-last because the ten history frames are the
6
+ input function features; Fourier blocks operate channel-first internally.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from typing import Any
13
+
14
+ import torch
15
+ from torch import Tensor, nn
16
+
17
+
18
+ class SpectralConv2d(nn.Module):
19
+ """Truncated 2-D Fourier integral operator on a periodic grid."""
20
+
21
+ def __init__(
22
+ self,
23
+ in_channels: int,
24
+ out_channels: int,
25
+ modes1: int,
26
+ modes2: int,
27
+ fft_norm: str = "backward",
28
+ ) -> None:
29
+ super().__init__()
30
+ if min(in_channels, out_channels, modes1, modes2) <= 0:
31
+ raise ValueError("channels and retained Fourier modes must be positive")
32
+ if fft_norm not in {"backward", "forward", "ortho"}:
33
+ raise ValueError(f"Unsupported FFT normalization: {fft_norm}")
34
+
35
+ self.in_channels = int(in_channels)
36
+ self.out_channels = int(out_channels)
37
+ self.modes1 = int(modes1)
38
+ self.modes2 = int(modes2)
39
+ self.fft_norm = fft_norm
40
+
41
+ # MISSING in the paper: exact complex-weight initialization. The
42
+ # scale is explicit and seed-controlled by the caller's torch seed.
43
+ scale = 1.0 / (self.in_channels * self.out_channels)
44
+ shape = (self.in_channels, self.out_channels, self.modes1, self.modes2)
45
+ self.weight_positive = nn.Parameter(
46
+ scale * torch.complex(torch.rand(shape), torch.rand(shape))
47
+ )
48
+ self.weight_negative = nn.Parameter(
49
+ scale * torch.complex(torch.rand(shape), torch.rand(shape))
50
+ )
51
+
52
+ @staticmethod
53
+ def _multiply_modes(inputs: Tensor, weights: Tensor) -> Tensor:
54
+ return torch.einsum("bixy,ioxy->boxy", inputs, weights)
55
+
56
+ def forward(self, inputs: Tensor) -> Tensor:
57
+ if inputs.ndim != 4:
58
+ raise ValueError(f"SpectralConv2d expects [B,C,H,W], got {inputs.shape}")
59
+ batch, channels, height, width = inputs.shape
60
+ if channels != self.in_channels:
61
+ raise ValueError(
62
+ f"Expected {self.in_channels} channels, received {channels}"
63
+ )
64
+ if 2 * self.modes1 > height:
65
+ raise ValueError(
66
+ f"modes1={self.modes1} overlaps positive/negative bands for H={height}"
67
+ )
68
+ if self.modes2 > width // 2 + 1:
69
+ raise ValueError(
70
+ f"modes2={self.modes2} exceeds rFFT width {width // 2 + 1}"
71
+ )
72
+
73
+ spectrum = torch.fft.rfft2(inputs, norm=self.fft_norm)
74
+ output_spectrum = torch.zeros(
75
+ batch,
76
+ self.out_channels,
77
+ height,
78
+ width // 2 + 1,
79
+ device=inputs.device,
80
+ dtype=spectrum.dtype,
81
+ )
82
+ positive_weight = self.weight_positive.to(dtype=spectrum.dtype)
83
+ negative_weight = self.weight_negative.to(dtype=spectrum.dtype)
84
+ output_spectrum[:, :, : self.modes1, : self.modes2] = self._multiply_modes(
85
+ spectrum[:, :, : self.modes1, : self.modes2], positive_weight
86
+ )
87
+ output_spectrum[:, :, -self.modes1 :, : self.modes2] = self._multiply_modes(
88
+ spectrum[:, :, -self.modes1 :, : self.modes2], negative_weight
89
+ )
90
+ return torch.fft.irfft2(
91
+ output_spectrum, s=(height, width), norm=self.fft_norm
92
+ )
93
+
94
+
95
+ class FourierBlock2d(nn.Module):
96
+ """One paper Fourier layer with local W, batch norm, and ReLU."""
97
+
98
+ def __init__(self, width: int, modes1: int, modes2: int, fft_norm: str) -> None:
99
+ super().__init__()
100
+ self.spectral = SpectralConv2d(width, width, modes1, modes2, fft_norm)
101
+ self.pointwise = nn.Conv2d(width, width, kernel_size=1)
102
+ self.batch_norm = nn.BatchNorm2d(width)
103
+ self.activation = nn.ReLU()
104
+
105
+ def forward(self, inputs: Tensor) -> Tensor:
106
+ return self.activation(
107
+ self.batch_norm(self.spectral(inputs) + self.pointwise(inputs))
108
+ )
109
+
110
+
111
+ class FNO2d(nn.Module):
112
+ """Four-layer FNO-2D mapping ten vorticity frames to the next frame."""
113
+
114
+ def __init__(
115
+ self,
116
+ input_channels: int = 10,
117
+ output_channels: int = 1,
118
+ width: int = 32,
119
+ modes1: int = 12,
120
+ modes2: int = 12,
121
+ num_layers: int = 4,
122
+ projection_width: int = 128,
123
+ use_grid: bool = True,
124
+ grid_include_endpoint: bool = False,
125
+ expected_resolution: tuple[int, int] = (64, 64),
126
+ fft_norm: str = "backward",
127
+ ) -> None:
128
+ super().__init__()
129
+ if num_layers != 4:
130
+ raise ValueError(
131
+ f"The paper reproduction requires four Fourier layers, got {num_layers}"
132
+ )
133
+ if len(expected_resolution) != 2 or min(expected_resolution) <= 0:
134
+ raise ValueError("expected_resolution must contain two positive dimensions")
135
+ if min(input_channels, output_channels, width, projection_width) <= 0:
136
+ raise ValueError("model widths and channel counts must be positive")
137
+
138
+ self.input_channels = int(input_channels)
139
+ self.output_channels = int(output_channels)
140
+ self.width = int(width)
141
+ self.modes1 = int(modes1)
142
+ self.modes2 = int(modes2)
143
+ self.num_layers = int(num_layers)
144
+ self.projection_width = int(projection_width)
145
+ self.use_grid = bool(use_grid)
146
+ self.grid_include_endpoint = bool(grid_include_endpoint)
147
+ self.expected_resolution = tuple(int(value) for value in expected_resolution)
148
+
149
+ lifting_channels = self.input_channels + (2 if self.use_grid else 0)
150
+ self.lifting = nn.Linear(lifting_channels, self.width)
151
+ self.fourier_blocks = nn.ModuleList(
152
+ [
153
+ FourierBlock2d(self.width, self.modes1, self.modes2, fft_norm)
154
+ for _ in range(self.num_layers)
155
+ ]
156
+ )
157
+ self.projection_hidden = nn.Linear(self.width, self.projection_width)
158
+ self.projection_activation = nn.ReLU()
159
+ self.projection_output = nn.Linear(
160
+ self.projection_width, self.output_channels
161
+ )
162
+
163
+ def _grid(self, batch: int, height: int, width: int, inputs: Tensor) -> Tensor:
164
+ if self.grid_include_endpoint:
165
+ x = torch.linspace(0.0, 1.0, height, device=inputs.device, dtype=inputs.dtype)
166
+ y = torch.linspace(0.0, 1.0, width, device=inputs.device, dtype=inputs.dtype)
167
+ else:
168
+ x = torch.arange(height, device=inputs.device, dtype=inputs.dtype) / height
169
+ y = torch.arange(width, device=inputs.device, dtype=inputs.dtype) / width
170
+ grid_x, grid_y = torch.meshgrid(x, y, indexing="ij")
171
+ grid = torch.stack((grid_x, grid_y), dim=-1)
172
+ return grid.unsqueeze(0).expand(batch, -1, -1, -1)
173
+
174
+ def forward(self, inputs: Tensor) -> Tensor:
175
+ if inputs.ndim != 4:
176
+ raise ValueError(f"FNO2d expects [B,H,W,T_history], got {inputs.shape}")
177
+ batch, height, width, features = inputs.shape
178
+ if features != self.input_channels:
179
+ raise ValueError(
180
+ f"Expected {self.input_channels} history channels, received {features}"
181
+ )
182
+ if (height, width) != self.expected_resolution:
183
+ raise ValueError(
184
+ f"Expected resolution {self.expected_resolution}, received {(height, width)}"
185
+ )
186
+ if not inputs.is_floating_point():
187
+ raise TypeError(f"FNO2d expects floating-point input, got {inputs.dtype}")
188
+
189
+ lifted_inputs = inputs
190
+ if self.use_grid:
191
+ lifted_inputs = torch.cat(
192
+ (inputs, self._grid(batch, height, width, inputs)), dim=-1
193
+ )
194
+ hidden = self.lifting(lifted_inputs).permute(0, 3, 1, 2).contiguous()
195
+ for block in self.fourier_blocks:
196
+ hidden = block(hidden)
197
+ hidden = hidden.permute(0, 2, 3, 1).contiguous()
198
+ hidden = self.projection_activation(self.projection_hidden(hidden))
199
+ return self.projection_output(hidden)
200
+
201
+
202
+ def build_model_from_config(config: Mapping[str, Any]) -> FNO2d:
203
+ """Construct the exact paper-reproduction model from a parsed YAML mapping."""
204
+ model = config["model"]
205
+ data = config["data"]
206
+ resolution = tuple(int(value) for value in data["resolution"])
207
+ return FNO2d(
208
+ input_channels=int(model["input_channels"]),
209
+ output_channels=int(model["output_channels"]),
210
+ width=int(model["width"]),
211
+ modes1=int(model["modes1"]),
212
+ modes2=int(model["modes2"]),
213
+ num_layers=int(model["num_layers"]),
214
+ projection_width=int(model["projection_width"]),
215
+ use_grid=bool(model["use_grid"]),
216
+ grid_include_endpoint=bool(model.get("grid_include_endpoint", False)),
217
+ expected_resolution=resolution,
218
+ fft_norm=str(model.get("fft_norm", "backward")),
219
+ )
models/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """FNO-2D model exports."""
2
+
3
+ from .FNO import FNO2d, SpectralConv2d, build_model_from_config
4
+
5
+ __all__ = ["FNO2d", "SpectralConv2d", "build_model_from_config"]
scripts/inference.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Run strict closed-loop inference for the trained FNO-2D checkpoint."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import sys
12
+ import time
13
+ from datetime import datetime, timezone
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ import torch
19
+
20
+
21
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
22
+ if str(PROJECT_ROOT) not in sys.path:
23
+ sys.path.insert(0, str(PROJECT_ROOT))
24
+ if str(Path(__file__).resolve().parent) not in sys.path:
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
26
+
27
+ from models import build_model_from_config # noqa: E402
28
+ from train import ( # noqa: E402
29
+ atomic_write_json,
30
+ build_datasets,
31
+ build_loader,
32
+ data_file_from_config,
33
+ environment_metadata,
34
+ load_config,
35
+ resolve_project_path,
36
+ seed_everything,
37
+ select_device,
38
+ synchronize,
39
+ )
40
+
41
+
42
+ def parse_args() -> argparse.Namespace:
43
+ parser = argparse.ArgumentParser(
44
+ description="Evaluate a trained FNO-2D checkpoint on the fixed test split."
45
+ )
46
+ parser.add_argument(
47
+ "--config", type=Path, default=PROJECT_ROOT / "config" / "config.yaml"
48
+ )
49
+ parser.add_argument("--checkpoint", type=Path, default=None)
50
+ parser.add_argument("--output-dir", type=Path, default=None)
51
+ parser.add_argument("--device", default="auto")
52
+ parser.add_argument("--batch-size", type=int, default=None)
53
+ parser.add_argument("--max-test-samples", type=int, default=None, help="Smoke only.")
54
+ parser.add_argument("--rollout-steps", type=int, default=None, help="Smoke only.")
55
+ return parser.parse_args()
56
+
57
+
58
+ def load_checkpoint(path: Path, device: torch.device) -> dict[str, Any]:
59
+ if not path.is_file():
60
+ raise FileNotFoundError(f"Checkpoint does not exist: {path}")
61
+ try:
62
+ checkpoint = torch.load(path, map_location=device, weights_only=False)
63
+ except TypeError:
64
+ checkpoint = torch.load(path, map_location=device)
65
+ if not isinstance(checkpoint, dict):
66
+ raise TypeError("Checkpoint root must be a mapping")
67
+ required = {
68
+ "model_state_dict",
69
+ "config",
70
+ "epoch",
71
+ "parameter_count",
72
+ "monitor",
73
+ "test_selected",
74
+ }
75
+ missing = sorted(required.difference(checkpoint))
76
+ if missing:
77
+ raise KeyError(f"Checkpoint is missing required keys: {missing}")
78
+ if checkpoint["test_selected"] is not False:
79
+ raise ValueError("This reproduction forbids a test-selected checkpoint")
80
+ if checkpoint["monitor"] != "train_full_relative_l2":
81
+ raise ValueError(f"Unexpected checkpoint monitor: {checkpoint['monitor']}")
82
+ return checkpoint
83
+
84
+
85
+ def nested_value(mapping: dict[str, Any], dotted_key: str) -> Any:
86
+ value: Any = mapping
87
+ for key in dotted_key.split("."):
88
+ value = value[key]
89
+ return value
90
+
91
+
92
+ def validate_checkpoint_config(
93
+ current: dict[str, Any], checkpoint_config: dict[str, Any]
94
+ ) -> None:
95
+ keys = (
96
+ "data.key",
97
+ "data.layout",
98
+ "data.dtype",
99
+ "data.expected_shape",
100
+ "data.resolution",
101
+ "data.ntrain",
102
+ "data.ntest",
103
+ "data.test_start",
104
+ "data.history",
105
+ "data.horizon",
106
+ "data.normalization",
107
+ "model.input_channels",
108
+ "model.output_channels",
109
+ "model.use_grid",
110
+ "model.grid_include_endpoint",
111
+ "model.width",
112
+ "model.modes1",
113
+ "model.modes2",
114
+ "model.num_layers",
115
+ "model.projection_width",
116
+ "model.fft_norm",
117
+ "training.dtype",
118
+ "training.relative_l2_epsilon",
119
+ )
120
+ differences = []
121
+ for key in keys:
122
+ current_value = nested_value(current, key)
123
+ checkpoint_value = nested_value(checkpoint_config, key)
124
+ if current_value != checkpoint_value:
125
+ differences.append(f"{key}: current={current_value!r}, checkpoint={checkpoint_value!r}")
126
+ if differences:
127
+ raise ValueError("Checkpoint/config mismatch:\n" + "\n".join(differences))
128
+
129
+
130
+ def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
131
+ digest = hashlib.sha256()
132
+ with path.open("rb") as handle:
133
+ while chunk := handle.read(chunk_size):
134
+ digest.update(chunk)
135
+ return digest.hexdigest()
136
+
137
+
138
+ def compute_metrics(
139
+ prediction: np.ndarray, target: np.ndarray, epsilon: float
140
+ ) -> tuple[np.ndarray, np.ndarray]:
141
+ if prediction.shape != target.shape:
142
+ raise ValueError(f"Prediction/target mismatch: {prediction.shape} vs {target.shape}")
143
+ if prediction.ndim != 4:
144
+ raise ValueError(f"Expected [N,H,W,T], received {prediction.shape}")
145
+ if not np.isfinite(prediction).all() or not np.isfinite(target).all():
146
+ raise FloatingPointError("Prediction or target contains NaN/Inf")
147
+
148
+ difference = prediction.astype(np.float64) - target.astype(np.float64)
149
+ target64 = target.astype(np.float64)
150
+ full_numerator = np.linalg.norm(difference.reshape(prediction.shape[0], -1), axis=1)
151
+ full_denominator = np.linalg.norm(target64.reshape(target.shape[0], -1), axis=1)
152
+ full = full_numerator / (full_denominator + epsilon)
153
+
154
+ difference_by_time = np.moveaxis(difference, -1, 1).reshape(
155
+ prediction.shape[0], prediction.shape[-1], -1
156
+ )
157
+ target_by_time = np.moveaxis(target64, -1, 1).reshape(
158
+ target.shape[0], target.shape[-1], -1
159
+ )
160
+ per_lead = np.linalg.norm(difference_by_time, axis=2) / (
161
+ np.linalg.norm(target_by_time, axis=2) + epsilon
162
+ )
163
+ return full, per_lead
164
+
165
+
166
+ def atomic_save_npz(path: Path, **arrays: np.ndarray) -> None:
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ temporary = path.with_suffix(path.suffix + ".tmp")
169
+ with temporary.open("wb") as handle:
170
+ np.savez_compressed(handle, **arrays)
171
+ os.replace(temporary, path)
172
+
173
+
174
+ def atomic_write_csv(
175
+ path: Path,
176
+ sample_indices: np.ndarray,
177
+ full_metrics: np.ndarray,
178
+ lead_metrics: np.ndarray,
179
+ time_values: np.ndarray,
180
+ ) -> None:
181
+ path.parent.mkdir(parents=True, exist_ok=True)
182
+ temporary = path.with_suffix(path.suffix + ".tmp")
183
+ header = ["sample_index", "relative_l2_full"] + [
184
+ f"relative_l2_t{int(value)}" for value in time_values
185
+ ]
186
+ with temporary.open("w", encoding="utf-8", newline="") as handle:
187
+ writer = csv.writer(handle)
188
+ writer.writerow(header)
189
+ for row, sample_index in enumerate(sample_indices):
190
+ writer.writerow(
191
+ [int(sample_index), f"{full_metrics[row]:.17g}"]
192
+ + [f"{value:.17g}" for value in lead_metrics[row]]
193
+ )
194
+ os.replace(temporary, path)
195
+
196
+
197
+ def main() -> None:
198
+ args = parse_args()
199
+ config = load_config(args.config)
200
+ inference = config["inference"]
201
+ training = config["training"]
202
+ seed = int(inference.get("seed", training["seed"]))
203
+ seed_everything(seed, bool(training.get("deterministic", True)))
204
+ device = select_device(args.device)
205
+ checkpoint_path = (
206
+ resolve_project_path(config["paths"]["checkpoint"])
207
+ if args.checkpoint is None
208
+ else args.checkpoint.expanduser().resolve()
209
+ )
210
+ output_dir = (
211
+ resolve_project_path(config["paths"]["results_dir"])
212
+ if args.output_dir is None
213
+ else args.output_dir.expanduser().resolve()
214
+ )
215
+ output_dir.mkdir(parents=True, exist_ok=True)
216
+ checkpoint = load_checkpoint(checkpoint_path, device)
217
+ validate_checkpoint_config(config, checkpoint["config"])
218
+
219
+ checkpoint_run_type = str(checkpoint.get("run_type", "formal"))
220
+ if checkpoint_run_type == "formal" and (
221
+ args.max_test_samples is not None
222
+ or args.rollout_steps is not None
223
+ or args.output_dir is not None
224
+ or args.checkpoint is not None
225
+ ):
226
+ raise ValueError(
227
+ "Formal inference uses the exact configured checkpoint, test split, horizon, "
228
+ "and results path; overrides are only allowed for smoke checkpoints"
229
+ )
230
+ horizon = int(config["data"]["horizon"])
231
+ rollout_steps = horizon if args.rollout_steps is None else int(args.rollout_steps)
232
+ if not 1 <= rollout_steps <= horizon:
233
+ raise ValueError(f"rollout_steps must be in [1,{horizon}]")
234
+
235
+ _, test_dataset = build_datasets(
236
+ config,
237
+ max_train_samples=1,
238
+ max_test_samples=args.max_test_samples,
239
+ rollout_steps=rollout_steps,
240
+ )
241
+ batch_size = int(
242
+ inference["batch_size"] if args.batch_size is None else args.batch_size
243
+ )
244
+ test_loader = build_loader(
245
+ test_dataset,
246
+ batch_size=batch_size,
247
+ shuffle=False,
248
+ num_workers=int(training.get("num_workers", 0)),
249
+ pin_memory=bool(training.get("pin_memory", True)) and device.type == "cuda",
250
+ seed=seed,
251
+ )
252
+
253
+ # Preserve complex64 spectral parameters while moving the model to device.
254
+ model = build_model_from_config(config).to(device=device)
255
+ incompatible = model.load_state_dict(checkpoint["model_state_dict"], strict=True)
256
+ if incompatible.missing_keys or incompatible.unexpected_keys:
257
+ raise RuntimeError(f"Strict state load failed: {incompatible}")
258
+ parameter_count = sum(parameter.numel() for parameter in model.parameters())
259
+ if parameter_count != int(checkpoint["parameter_count"]):
260
+ raise ValueError(
261
+ f"Parameter count mismatch: model={parameter_count}, "
262
+ f"checkpoint={checkpoint['parameter_count']}"
263
+ )
264
+ model.eval()
265
+
266
+ predictions: list[np.ndarray] = []
267
+ targets: list[np.ndarray] = []
268
+ synchronize(device)
269
+ started = time.perf_counter()
270
+ processed = 0
271
+ with torch.inference_mode():
272
+ for batch_number, (history, target) in enumerate(test_loader, start=1):
273
+ history = history.to(device=device, dtype=torch.float32, non_blocking=True)
274
+ target_device = target.to(device=device, dtype=torch.float32, non_blocking=True)
275
+ window = history
276
+ batch_prediction: list[torch.Tensor] = []
277
+ for step in range(rollout_steps):
278
+ prediction_step = model(window)
279
+ if not torch.isfinite(prediction_step).all():
280
+ raise FloatingPointError(
281
+ f"Non-finite prediction at batch {batch_number}, step {step + 1}"
282
+ )
283
+ batch_prediction.append(prediction_step)
284
+ window = torch.cat((window[..., 1:], prediction_step), dim=-1)
285
+ prediction = torch.cat(batch_prediction, dim=-1)
286
+ predictions.append(prediction.cpu().numpy().astype(np.float32, copy=False))
287
+ targets.append(target_device.cpu().numpy().astype(np.float32, copy=False))
288
+ processed += int(history.shape[0])
289
+ print(
290
+ f"inference_batch={batch_number:03d}/{len(test_loader):03d} "
291
+ f"processed={processed}/{len(test_dataset)}",
292
+ flush=True,
293
+ )
294
+ synchronize(device)
295
+ duration = time.perf_counter() - started
296
+
297
+ prediction_array = np.concatenate(predictions, axis=0)
298
+ target_array = np.concatenate(targets, axis=0)
299
+ expected_shape = (
300
+ len(test_dataset),
301
+ int(config["data"]["resolution"][0]),
302
+ int(config["data"]["resolution"][1]),
303
+ rollout_steps,
304
+ )
305
+ if prediction_array.shape != expected_shape or target_array.shape != expected_shape:
306
+ raise ValueError(
307
+ f"Unexpected inference arrays: prediction={prediction_array.shape}, "
308
+ f"target={target_array.shape}, expected={expected_shape}"
309
+ )
310
+ epsilon = float(training["relative_l2_epsilon"])
311
+ full_metrics, lead_metrics = compute_metrics(prediction_array, target_array, epsilon)
312
+ test_start = int(config["data"]["test_start"])
313
+ sample_indices = np.arange(
314
+ test_start, test_start + len(test_dataset), dtype=np.int64
315
+ )
316
+ configured_times = np.asarray(config["data"]["future_times"], dtype=np.float32)
317
+ time_values = configured_times[:rollout_steps]
318
+
319
+ if args.output_dir is None:
320
+ predictions_path = resolve_project_path(config["paths"]["predictions"])
321
+ metrics_path = resolve_project_path(config["paths"]["metrics"])
322
+ csv_path = resolve_project_path(config["paths"]["per_sample_metrics"])
323
+ else:
324
+ predictions_path = output_dir / "predictions.npz"
325
+ metrics_path = output_dir / "metrics.json"
326
+ csv_path = output_dir / "per_sample_metrics.csv"
327
+
328
+ atomic_save_npz(
329
+ predictions_path,
330
+ prediction=prediction_array,
331
+ target=target_array,
332
+ sample_indices=sample_indices,
333
+ time_values=time_values,
334
+ )
335
+ atomic_write_csv(csv_path, sample_indices, full_metrics, lead_metrics, time_values)
336
+
337
+ paper_metric = float(config["paper"]["reference_relative_l2"])
338
+ mean_full = float(full_metrics.mean())
339
+ metrics_payload: dict[str, Any] = {
340
+ "schema_version": "fno-ns2d-metrics-v1",
341
+ "created_at": datetime.now(timezone.utc).isoformat(),
342
+ "run_type": checkpoint_run_type,
343
+ "config_path": str(args.config.expanduser().resolve()),
344
+ "checkpoint_path": str(checkpoint_path),
345
+ "checkpoint_sha256": sha256_file(checkpoint_path),
346
+ "checkpoint_epoch": int(checkpoint["epoch"]),
347
+ "checkpoint_monitor": checkpoint["monitor"],
348
+ "test_selected": bool(checkpoint["test_selected"]),
349
+ "data_path": str(data_file_from_config(config)),
350
+ "prediction_path": str(predictions_path),
351
+ "per_sample_metrics_path": str(csv_path),
352
+ "sample_count": int(len(test_dataset)),
353
+ "prediction_shape": list(prediction_array.shape),
354
+ "sample_indices": {"first": int(sample_indices[0]), "last": int(sample_indices[-1])},
355
+ "time_values": [float(value) for value in time_values],
356
+ "metric": {
357
+ "name": "samplewise_relative_l2",
358
+ "formula": "||prediction-target||_2/(||target||_2+epsilon), then arithmetic mean over samples",
359
+ "epsilon": epsilon,
360
+ "full_trajectory_mean": mean_full,
361
+ "full_trajectory_std": float(full_metrics.std(ddof=0)),
362
+ "mean_step_relative_l2": float(lead_metrics.mean()),
363
+ "per_lead_mean": [float(value) for value in lead_metrics.mean(axis=0)],
364
+ "per_lead_std": [float(value) for value in lead_metrics.std(axis=0, ddof=0)],
365
+ },
366
+ "paper_comparison": {
367
+ "paper_relative_l2": paper_metric,
368
+ "signed_difference": mean_full - paper_metric,
369
+ "absolute_difference": abs(mean_full - paper_metric),
370
+ },
371
+ "parameter_count": parameter_count,
372
+ "paper_parameter_count": int(config["paper"]["reference_parameter_count"]),
373
+ "parameter_count_difference": parameter_count
374
+ - int(config["paper"]["reference_parameter_count"]),
375
+ "runtime": {
376
+ **environment_metadata(device),
377
+ "duration_seconds": duration,
378
+ "batch_size": batch_size,
379
+ },
380
+ "assumptions": config.get("assumptions", []),
381
+ }
382
+ atomic_write_json(metrics_path, metrics_payload)
383
+ print(
384
+ f"inference_complete samples={len(test_dataset)} duration={duration:.3f}s "
385
+ f"full_relative_l2={mean_full:.8f} paper={paper_metric:.8f} "
386
+ f"absolute_difference={abs(mean_full-paper_metric):.8f}",
387
+ flush=True,
388
+ )
389
+
390
+
391
+ if __name__ == "__main__":
392
+ main()
scripts/result.py ADDED
@@ -0,0 +1,505 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Validate real FNO outputs and render paper-comparison figures."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import csv
8
+ import hashlib
9
+ import json
10
+ import os
11
+ import sys
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import matplotlib
17
+
18
+ matplotlib.use("Agg")
19
+ import matplotlib.pyplot as plt # noqa: E402
20
+ import numpy as np # noqa: E402
21
+
22
+
23
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
24
+ if str(PROJECT_ROOT) not in sys.path:
25
+ sys.path.insert(0, str(PROJECT_ROOT))
26
+ if str(Path(__file__).resolve().parent) not in sys.path:
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
28
+
29
+ from inference import compute_metrics # noqa: E402
30
+ from train import atomic_write_json, load_config, resolve_project_path # noqa: E402
31
+
32
+
33
+ def parse_args() -> argparse.Namespace:
34
+ parser = argparse.ArgumentParser(
35
+ description="Validate FNO inference artifacts and generate scientific figures."
36
+ )
37
+ parser.add_argument(
38
+ "--config", type=Path, default=PROJECT_ROOT / "config" / "config.yaml"
39
+ )
40
+ parser.add_argument("--output-dir", type=Path, default=None)
41
+ parser.add_argument(
42
+ "--sample-index", type=int, default=0, help="Local test-set index to visualize."
43
+ )
44
+ return parser.parse_args()
45
+
46
+
47
+ def read_json(path: Path) -> dict[str, Any]:
48
+ if not path.is_file():
49
+ raise FileNotFoundError(f"Required JSON artifact is missing: {path}")
50
+ with path.open("r", encoding="utf-8") as handle:
51
+ payload = json.load(handle)
52
+ if not isinstance(payload, dict):
53
+ raise TypeError(f"Expected a JSON mapping in {path}")
54
+ return payload
55
+
56
+
57
+ def sha256_file(path: Path, chunk_size: int = 1024 * 1024) -> str:
58
+ digest = hashlib.sha256()
59
+ with path.open("rb") as handle:
60
+ while chunk := handle.read(chunk_size):
61
+ digest.update(chunk)
62
+ return digest.hexdigest()
63
+
64
+
65
+ def atomic_save_figure(figure: plt.Figure, path: Path, dpi: int = 300) -> None:
66
+ path.parent.mkdir(parents=True, exist_ok=True)
67
+ temporary = path.with_suffix(path.suffix + ".tmp")
68
+ figure.savefig(temporary, format="png", dpi=dpi, bbox_inches="tight")
69
+ plt.close(figure)
70
+ os.replace(temporary, path)
71
+ if path.stat().st_size == 0:
72
+ raise RuntimeError(f"Generated an empty figure: {path}")
73
+
74
+
75
+ def atomic_write_text(path: Path, content: str) -> None:
76
+ path.parent.mkdir(parents=True, exist_ok=True)
77
+ temporary = path.with_suffix(path.suffix + ".tmp")
78
+ with temporary.open("w", encoding="utf-8") as handle:
79
+ handle.write(content)
80
+ os.replace(temporary, path)
81
+
82
+
83
+ def verify_csv(
84
+ path: Path,
85
+ sample_indices: np.ndarray,
86
+ full_metrics: np.ndarray,
87
+ lead_metrics: np.ndarray,
88
+ time_values: np.ndarray,
89
+ ) -> None:
90
+ if not path.is_file():
91
+ raise FileNotFoundError(f"Per-sample metrics CSV is missing: {path}")
92
+ expected_header = ["sample_index", "relative_l2_full"] + [
93
+ f"relative_l2_t{int(value)}" for value in time_values
94
+ ]
95
+ with path.open("r", encoding="utf-8", newline="") as handle:
96
+ rows = list(csv.reader(handle))
97
+ if not rows or rows[0] != expected_header:
98
+ raise ValueError(f"Unexpected CSV header in {path}: {rows[0] if rows else None}")
99
+ if len(rows) - 1 != len(sample_indices):
100
+ raise ValueError(f"Expected {len(sample_indices)} CSV rows, found {len(rows)-1}")
101
+ for row_index, row in enumerate(rows[1:]):
102
+ if int(row[0]) != int(sample_indices[row_index]):
103
+ raise ValueError(f"CSV sample order mismatch at row {row_index + 2}")
104
+ observed = np.asarray([float(value) for value in row[1:]], dtype=np.float64)
105
+ expected = np.concatenate(
106
+ ([full_metrics[row_index]], lead_metrics[row_index].astype(np.float64))
107
+ )
108
+ if not np.allclose(observed, expected, rtol=1e-12, atol=1e-12):
109
+ raise ValueError(f"CSV metric mismatch for sample {sample_indices[row_index]}")
110
+
111
+
112
+ def validate_history(history_payload: dict[str, Any]) -> list[dict[str, Any]]:
113
+ records = history_payload.get("history")
114
+ if not isinstance(records, list) or not records:
115
+ raise ValueError("Training history contains no epoch records")
116
+ formal = history_payload.get("run_type") == "formal"
117
+ requested = int(history_payload.get("epochs_requested", len(records)))
118
+ if formal and (requested != 500 or len(records) != 500):
119
+ raise ValueError(
120
+ f"Formal paper reproduction requires 500 epochs, got requested={requested}, "
121
+ f"records={len(records)}"
122
+ )
123
+ required = (
124
+ "epoch",
125
+ "learning_rate",
126
+ "duration_seconds",
127
+ "train_step_loss_sum",
128
+ "train_mean_step_relative_l2",
129
+ "train_full_relative_l2",
130
+ "test_mean_step_relative_l2",
131
+ "test_full_relative_l2",
132
+ "best",
133
+ )
134
+ for position, record in enumerate(records, start=1):
135
+ missing = [key for key in required if key not in record]
136
+ if missing:
137
+ raise KeyError(f"Epoch record {position} is missing {missing}")
138
+ if int(record["epoch"]) != position:
139
+ raise ValueError(f"Epoch sequence is not contiguous at record {position}")
140
+ numeric = [float(record[key]) for key in required[1:-1]]
141
+ if not np.isfinite(numeric).all():
142
+ raise FloatingPointError(f"Non-finite training history at epoch {position}")
143
+ return records
144
+
145
+
146
+ def make_training_figure(
147
+ records: list[dict[str, Any]], paper_metric: float, best_epoch: int
148
+ ) -> plt.Figure:
149
+ epochs = np.asarray([record["epoch"] for record in records], dtype=np.int64)
150
+ train_full = np.asarray(
151
+ [record["train_full_relative_l2"] for record in records], dtype=np.float64
152
+ )
153
+ test_full = np.asarray(
154
+ [record["test_full_relative_l2"] for record in records], dtype=np.float64
155
+ )
156
+ train_loss = np.asarray(
157
+ [record["train_step_loss_sum"] for record in records], dtype=np.float64
158
+ )
159
+ train_step = np.asarray(
160
+ [record["train_mean_step_relative_l2"] for record in records], dtype=np.float64
161
+ )
162
+ test_step = np.asarray(
163
+ [record["test_mean_step_relative_l2"] for record in records], dtype=np.float64
164
+ )
165
+
166
+ figure, axes = plt.subplots(1, 2, figsize=(12.5, 4.8), constrained_layout=True)
167
+ left = axes[0]
168
+ left.plot(epochs, train_full, label="Train full relative L2", linewidth=1.6)
169
+ left.plot(epochs, test_full, label="Test full relative L2", linewidth=1.6)
170
+ left.axhline(
171
+ paper_metric,
172
+ color="black",
173
+ linestyle="--",
174
+ linewidth=1.2,
175
+ label=f"Paper benchmark ({paper_metric:.4f})",
176
+ )
177
+ left.axvline(
178
+ best_epoch,
179
+ color="tab:green",
180
+ linestyle=":",
181
+ linewidth=1.2,
182
+ label=f"Best checkpoint epoch ({best_epoch})",
183
+ )
184
+ if np.all(train_full > 0) and np.all(test_full > 0):
185
+ left.set_yscale("log")
186
+ left.set_xlabel("Epoch")
187
+ left.set_ylabel("Full-trajectory relative L2")
188
+ left.set_title("FNO-2D rollout error")
189
+ left.grid(True, alpha=0.25)
190
+ left.legend(fontsize=8)
191
+
192
+ right = axes[1]
193
+ loss_line = right.plot(
194
+ epochs,
195
+ train_loss,
196
+ color="tab:blue",
197
+ label="Train 10-step loss sum",
198
+ linewidth=1.5,
199
+ )
200
+ right.set_xlabel("Epoch")
201
+ right.set_ylabel("Summed step relative L2", color="tab:blue")
202
+ right.tick_params(axis="y", labelcolor="tab:blue")
203
+ right.grid(True, alpha=0.25)
204
+ diagnostic = right.twinx()
205
+ train_line = diagnostic.plot(
206
+ epochs,
207
+ train_step,
208
+ color="tab:orange",
209
+ label="Train mean-step relative L2",
210
+ linewidth=1.3,
211
+ )
212
+ test_line = diagnostic.plot(
213
+ epochs,
214
+ test_step,
215
+ color="tab:red",
216
+ label="Test mean-step relative L2",
217
+ linewidth=1.3,
218
+ )
219
+ diagnostic.set_ylabel("Mean-step relative L2")
220
+ right.set_title("Training objective and step diagnostics")
221
+ lines = loss_line + train_line + test_line
222
+ right.legend(lines, [line.get_label() for line in lines], fontsize=8, loc="best")
223
+ return figure
224
+
225
+
226
+ def representative_leads(number_of_steps: int) -> list[int]:
227
+ if number_of_steps <= 0:
228
+ raise ValueError("At least one rollout step is required")
229
+ return sorted({0, number_of_steps // 2, number_of_steps - 1})
230
+
231
+
232
+ def make_rollout_figure(
233
+ prediction: np.ndarray,
234
+ target: np.ndarray,
235
+ sample_indices: np.ndarray,
236
+ time_values: np.ndarray,
237
+ lead_metrics: np.ndarray,
238
+ local_sample: int,
239
+ ) -> plt.Figure:
240
+ if not 0 <= local_sample < prediction.shape[0]:
241
+ raise IndexError(
242
+ f"sample-index {local_sample} is outside [0,{prediction.shape[0] - 1}]"
243
+ )
244
+ lead_indices = representative_leads(prediction.shape[-1])
245
+ figure, axes = plt.subplots(
246
+ len(lead_indices),
247
+ 3,
248
+ figsize=(11.5, 3.25 * len(lead_indices)),
249
+ squeeze=False,
250
+ constrained_layout=True,
251
+ )
252
+ global_sample = int(sample_indices[local_sample])
253
+ for row, lead_index in enumerate(lead_indices):
254
+ truth = target[local_sample, :, :, lead_index]
255
+ estimate = prediction[local_sample, :, :, lead_index]
256
+ absolute_error = np.abs(estimate - truth)
257
+ shared_limit = max(float(np.max(np.abs(truth))), float(np.max(np.abs(estimate))), 1e-12)
258
+ error_limit = max(float(np.max(absolute_error)), 1e-12)
259
+ time_value = int(time_values[lead_index])
260
+ relative_error = float(lead_metrics[local_sample, lead_index])
261
+
262
+ fields = (truth, estimate, absolute_error)
263
+ titles = (
264
+ f"Target vorticity w\nsample={global_sample}, t={time_value}",
265
+ f"Predicted vorticity w\nrelative L2={relative_error:.5f}",
266
+ f"Absolute error |prediction-target|\nt={time_value}",
267
+ )
268
+ for column, (field, title) in enumerate(zip(fields, titles)):
269
+ axis = axes[row, column]
270
+ if column < 2:
271
+ image = axis.imshow(
272
+ field,
273
+ origin="lower",
274
+ extent=(0.0, 1.0, 0.0, 1.0),
275
+ interpolation="nearest",
276
+ cmap="RdBu_r",
277
+ vmin=-shared_limit,
278
+ vmax=shared_limit,
279
+ )
280
+ color_label = "Vorticity w (unit not specified)"
281
+ else:
282
+ image = axis.imshow(
283
+ field,
284
+ origin="lower",
285
+ extent=(0.0, 1.0, 0.0, 1.0),
286
+ interpolation="nearest",
287
+ cmap="magma",
288
+ vmin=0.0,
289
+ vmax=error_limit,
290
+ )
291
+ color_label = "Absolute error"
292
+ axis.set_aspect("equal")
293
+ axis.set_xlabel("x")
294
+ axis.set_ylabel("y")
295
+ axis.set_title(title, fontsize=9)
296
+ colorbar = figure.colorbar(image, ax=axis, shrink=0.82)
297
+ colorbar.set_label(color_label, fontsize=8)
298
+ return figure
299
+
300
+
301
+ def main() -> None:
302
+ args = parse_args()
303
+ config = load_config(args.config)
304
+ output_dir = (
305
+ resolve_project_path(config["paths"]["results_dir"])
306
+ if args.output_dir is None
307
+ else args.output_dir.expanduser().resolve()
308
+ )
309
+ if args.output_dir is None:
310
+ history_path = resolve_project_path(config["paths"]["train_history"])
311
+ predictions_path = resolve_project_path(config["paths"]["predictions"])
312
+ metrics_path = resolve_project_path(config["paths"]["metrics"])
313
+ csv_path = resolve_project_path(config["paths"]["per_sample_metrics"])
314
+ training_figure_path = resolve_project_path(config["paths"]["training_curves"])
315
+ rollout_figure_path = resolve_project_path(config["paths"]["rollout_figure"])
316
+ metadata_path = resolve_project_path(config["paths"]["run_metadata"])
317
+ summary_path = resolve_project_path(config["paths"]["summary"])
318
+ else:
319
+ history_path = output_dir / "train_history.json"
320
+ predictions_path = output_dir / "predictions.npz"
321
+ metrics_path = output_dir / "metrics.json"
322
+ csv_path = output_dir / "per_sample_metrics.csv"
323
+ training_figure_path = output_dir / "training_curves.png"
324
+ rollout_figure_path = output_dir / "sample_000_rollout.png"
325
+ metadata_path = output_dir / "run_metadata.json"
326
+ summary_path = output_dir / "summary.md"
327
+
328
+ output_dir.mkdir(parents=True, exist_ok=True)
329
+ history_payload = read_json(history_path)
330
+ metrics_payload = read_json(metrics_path)
331
+ if metrics_payload.get("run_type") == "formal" and args.output_dir is not None:
332
+ raise ValueError("Formal result generation must use the configured results directory")
333
+ if history_payload.get("run_type") != metrics_payload.get("run_type"):
334
+ raise ValueError("Training history and inference metrics have different run types")
335
+ records = validate_history(history_payload)
336
+ if not predictions_path.is_file():
337
+ raise FileNotFoundError(f"Predictions artifact is missing: {predictions_path}")
338
+ with np.load(predictions_path, allow_pickle=False) as archive:
339
+ required_arrays = {"prediction", "target", "sample_indices", "time_values"}
340
+ missing_arrays = required_arrays.difference(archive.files)
341
+ if missing_arrays:
342
+ raise KeyError(f"Predictions NPZ is missing {sorted(missing_arrays)}")
343
+ prediction = archive["prediction"]
344
+ target = archive["target"]
345
+ sample_indices = archive["sample_indices"]
346
+ time_values = archive["time_values"]
347
+
348
+ if prediction.dtype != np.float32 or target.dtype != np.float32:
349
+ raise TypeError("Prediction and target arrays must be float32")
350
+ if prediction.shape != target.shape or prediction.ndim != 4:
351
+ raise ValueError(f"Invalid prediction/target shapes: {prediction.shape}, {target.shape}")
352
+ if sample_indices.shape != (prediction.shape[0],):
353
+ raise ValueError("sample_indices shape does not match predictions")
354
+ if time_values.shape != (prediction.shape[-1],):
355
+ raise ValueError("time_values shape does not match rollout horizon")
356
+ if not np.array_equal(sample_indices, np.arange(sample_indices[0], sample_indices[0] + len(sample_indices))):
357
+ raise ValueError("sample_indices must be unique, contiguous, and ordered")
358
+ if not np.all(np.diff(time_values.astype(np.float64)) > 0):
359
+ raise ValueError("time_values must be strictly increasing")
360
+
361
+ formal = metrics_payload.get("run_type") == "formal"
362
+ if formal:
363
+ expected_shape = (
364
+ int(config["data"]["ntest"]),
365
+ int(config["data"]["resolution"][0]),
366
+ int(config["data"]["resolution"][1]),
367
+ int(config["data"]["horizon"]),
368
+ )
369
+ if prediction.shape != expected_shape:
370
+ raise ValueError(f"Formal prediction shape must be {expected_shape}, got {prediction.shape}")
371
+ expected_indices = np.arange(
372
+ int(config["data"]["test_start"]),
373
+ int(config["data"]["test_start"]) + int(config["data"]["ntest"]),
374
+ )
375
+ if not np.array_equal(sample_indices, expected_indices):
376
+ raise ValueError("Formal sample indices do not match the fixed test split")
377
+
378
+ epsilon = float(config["training"]["relative_l2_epsilon"])
379
+ full_metrics, lead_metrics = compute_metrics(prediction, target, epsilon)
380
+ observed_mean = float(metrics_payload["metric"]["full_trajectory_mean"])
381
+ if not np.isclose(full_metrics.mean(), observed_mean, rtol=1e-8, atol=1e-8):
382
+ raise ValueError(
383
+ f"metrics.json full relative L2 mismatch: recomputed={full_metrics.mean()}, "
384
+ f"stored={observed_mean}"
385
+ )
386
+ stored_leads = np.asarray(metrics_payload["metric"]["per_lead_mean"], dtype=np.float64)
387
+ if not np.allclose(lead_metrics.mean(axis=0), stored_leads, rtol=1e-8, atol=1e-8):
388
+ raise ValueError("metrics.json per-lead values do not match predictions")
389
+ verify_csv(csv_path, sample_indices, full_metrics, lead_metrics, time_values)
390
+
391
+ best_epoch = int(history_payload["best_epoch"])
392
+ checkpoint_epoch = int(metrics_payload["checkpoint_epoch"])
393
+ if best_epoch != checkpoint_epoch:
394
+ raise ValueError(
395
+ f"History best epoch {best_epoch} does not match checkpoint epoch {checkpoint_epoch}"
396
+ )
397
+ paper_metric = float(config["paper"]["reference_relative_l2"])
398
+ training_figure = make_training_figure(records, paper_metric, best_epoch)
399
+ atomic_save_figure(training_figure, training_figure_path, dpi=300)
400
+ rollout_figure = make_rollout_figure(
401
+ prediction,
402
+ target,
403
+ sample_indices,
404
+ time_values,
405
+ lead_metrics,
406
+ args.sample_index,
407
+ )
408
+ atomic_save_figure(rollout_figure, rollout_figure_path, dpi=300)
409
+
410
+ artifact_paths = {
411
+ "train_history": history_path,
412
+ "predictions": predictions_path,
413
+ "metrics": metrics_path,
414
+ "per_sample_metrics": csv_path,
415
+ "training_curves": training_figure_path,
416
+ "rollout_figure": rollout_figure_path,
417
+ }
418
+ artifact_metadata = {
419
+ name: {
420
+ "path": str(path),
421
+ "size_bytes": path.stat().st_size,
422
+ "sha256": sha256_file(path),
423
+ }
424
+ for name, path in artifact_paths.items()
425
+ }
426
+ main_metric = float(full_metrics.mean())
427
+ run_metadata = {
428
+ "schema_version": "fno-ns2d-run-metadata-v1",
429
+ "created_at": datetime.now(timezone.utc).isoformat(),
430
+ "run_type": metrics_payload.get("run_type"),
431
+ "config_path": str(args.config.expanduser().resolve()),
432
+ "data_path": metrics_payload["data_path"],
433
+ "checkpoint_path": metrics_payload["checkpoint_path"],
434
+ "checkpoint_epoch": checkpoint_epoch,
435
+ "test_selected": metrics_payload["test_selected"],
436
+ "split": {
437
+ "train": int(history_payload.get("train_samples", 1000)),
438
+ "validation": 0,
439
+ "test": int(prediction.shape[0]),
440
+ },
441
+ "prediction_shape": list(prediction.shape),
442
+ "normalization": config["data"]["normalization"],
443
+ "metric_formula": metrics_payload["metric"]["formula"],
444
+ "full_trajectory_relative_l2": main_metric,
445
+ "paper_relative_l2": paper_metric,
446
+ "signed_difference": main_metric - paper_metric,
447
+ "absolute_difference": abs(main_metric - paper_metric),
448
+ "parameter_count": metrics_payload["parameter_count"],
449
+ "paper_parameter_count": metrics_payload["paper_parameter_count"],
450
+ "parameter_count_difference": metrics_payload["parameter_count_difference"],
451
+ "runtime": {**metrics_payload["runtime"], "matplotlib": matplotlib.__version__},
452
+ "assumptions": config.get("assumptions", []),
453
+ "conflicts": config.get("conflicts", []),
454
+ "artifacts": artifact_metadata,
455
+ "quality_checks": {
456
+ "all_values_finite": True,
457
+ "metrics_recomputed_from_npz": True,
458
+ "json_metrics_match": True,
459
+ "csv_metrics_match": True,
460
+ "best_epoch_matches_checkpoint": True,
461
+ "figures_nonempty": True,
462
+ },
463
+ }
464
+ atomic_write_json(metadata_path, run_metadata)
465
+
466
+ summary = f"""# FNO-2D Navier–Stokes reproduction result
467
+
468
+ ## Summary
469
+
470
+ - Run type: `{metrics_payload.get('run_type')}`
471
+ - Test trajectories: {prediction.shape[0]}
472
+ - Forecast shape: `{list(prediction.shape)}`
473
+ - Mean full-trajectory relative L2: **{main_metric:.8f}**
474
+ - Paper FNO-2D reference (`ν=1e-5`, `T=20`, 1000 train): **{paper_metric:.4f}**
475
+ - Absolute difference: **{abs(main_metric-paper_metric):.8f}**
476
+ - Checkpoint epoch: {checkpoint_epoch}; selected by train full relative L2 (`test_selected=false`).
477
+
478
+ ## Data and method
479
+
480
+ The model uses the fixed first 1000 trajectories for training and the final {prediction.shape[0]} trajectories for testing. Ten observed vorticity frames initialize a closed-loop rollout; every predicted frame updates the next input window. No target frame is used after initialization, and no data normalization, padding, augmentation, or PDE-residual loss is applied.
481
+
482
+ The reported metric is computed per sample as `||prediction-target||₂/(||target||₂+1e-12)` over the full space-time forecast and then averaged. It was recomputed directly from `predictions.npz` and cross-checked against JSON and CSV outputs.
483
+
484
+ ## Reproducibility limitations
485
+
486
+ The paper does not specify the exact relative-L2 reduction, batch size, random seed, projection hidden width, coordinate-input choice, block ordering, or checkpoint-selection protocol. These choices are recorded explicitly in `config/config.yaml` and `run_metadata.json`. The paper width 32 also cannot be uniquely reconciled with the reported 414,517 parameters from the published connection details; the actual parameter count is reported rather than hidden.
487
+
488
+ ## Artifacts
489
+
490
+ - `{training_figure_path.name}`: train/test rollout errors and step-loss diagnostics.
491
+ - `{rollout_figure_path.name}`: target, prediction, and absolute-error vorticity fields.
492
+ - `{predictions_path.name}`: full test prediction and target arrays.
493
+ - `{metrics_path.name}` and `{csv_path.name}`: aggregate and per-sample metrics.
494
+ - `{metadata_path.name}`: provenance, software, assumptions, hashes, and quality checks.
495
+ """
496
+ atomic_write_text(summary_path, summary)
497
+ print(
498
+ f"result_complete full_relative_l2={main_metric:.8f} "
499
+ f"training_figure={training_figure_path} rollout_figure={rollout_figure_path}",
500
+ flush=True,
501
+ )
502
+
503
+
504
+ if __name__ == "__main__":
505
+ main()
scripts/train.py ADDED
@@ -0,0 +1,763 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train the paper-specified recurrent FNO-2D Navier--Stokes model."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import os
9
+ import platform
10
+ import random
11
+ import sys
12
+ import time
13
+ from copy import deepcopy
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import numpy as np
19
+ import scipy
20
+ import scipy.io
21
+ import torch
22
+ import yaml
23
+ from torch import Tensor, nn
24
+ from torch.utils.data import DataLoader, TensorDataset
25
+
26
+
27
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
28
+ if str(PROJECT_ROOT) not in sys.path:
29
+ sys.path.insert(0, str(PROJECT_ROOT))
30
+
31
+ from models import build_model_from_config # noqa: E402
32
+
33
+
34
+ def parse_args() -> argparse.Namespace:
35
+ parser = argparse.ArgumentParser(
36
+ description="Train FNO-2D on the validated Navier-Stokes trajectory MAT file."
37
+ )
38
+ parser.add_argument(
39
+ "--config",
40
+ type=Path,
41
+ default=PROJECT_ROOT / "config" / "config.yaml",
42
+ help="YAML configuration path.",
43
+ )
44
+ parser.add_argument("--epochs", type=int, default=None, help="Override epochs (smoke only).")
45
+ parser.add_argument(
46
+ "--rollout-steps", type=int, default=None, help="Override rollout steps (smoke only)."
47
+ )
48
+ parser.add_argument(
49
+ "--max-train-samples", type=int, default=None, help="Limit train samples (smoke only)."
50
+ )
51
+ parser.add_argument(
52
+ "--max-test-samples", type=int, default=None, help="Limit test samples (smoke only)."
53
+ )
54
+ parser.add_argument("--batch-size", type=int, default=None, help="Override batch size.")
55
+ parser.add_argument(
56
+ "--device", default="auto", help="auto, cpu, cuda, or an explicit torch device."
57
+ )
58
+ parser.add_argument("--checkpoint", type=Path, default=None, help="Output checkpoint path.")
59
+ parser.add_argument(
60
+ "--resume",
61
+ type=Path,
62
+ default=None,
63
+ help="Resume from a formal training-state checkpoint.",
64
+ )
65
+ parser.add_argument("--output-dir", type=Path, default=None, help="Output directory.")
66
+ parser.add_argument(
67
+ "--run-type", choices=("formal", "smoke"), default="formal", help="Run provenance tag."
68
+ )
69
+ return parser.parse_args()
70
+
71
+
72
+ def load_config(path: Path) -> dict[str, Any]:
73
+ config_path = path.expanduser().resolve()
74
+ if not config_path.is_file():
75
+ raise FileNotFoundError(f"Configuration file does not exist: {config_path}")
76
+ with config_path.open("r", encoding="utf-8") as handle:
77
+ config = yaml.safe_load(handle)
78
+ if not isinstance(config, dict):
79
+ raise ValueError("Configuration root must be a mapping")
80
+ validate_config(config)
81
+ return config
82
+
83
+
84
+ def validate_config(config: dict[str, Any]) -> None:
85
+ for section in ("paper", "data", "model", "training", "paths"):
86
+ if section not in config or not isinstance(config[section], dict):
87
+ raise ValueError(f"Missing configuration section: {section}")
88
+
89
+ data = config["data"]
90
+ model = config["model"]
91
+ training = config["training"]
92
+ required_data = {
93
+ "root",
94
+ "file",
95
+ "key",
96
+ "layout",
97
+ "dtype",
98
+ "expected_shape",
99
+ "resolution",
100
+ "ntrain",
101
+ "ntest",
102
+ "test_start",
103
+ "history",
104
+ "horizon",
105
+ "normalization",
106
+ }
107
+ required_model = {
108
+ "input_channels",
109
+ "output_channels",
110
+ "width",
111
+ "modes1",
112
+ "modes2",
113
+ "num_layers",
114
+ "projection_width",
115
+ "use_grid",
116
+ }
117
+ required_training = {
118
+ "epochs",
119
+ "batch_size",
120
+ "optimizer",
121
+ "learning_rate",
122
+ "weight_decay",
123
+ "scheduler",
124
+ "scheduler_step_size",
125
+ "scheduler_gamma",
126
+ "seed",
127
+ "dtype",
128
+ "relative_l2_epsilon",
129
+ "checkpoint_monitor",
130
+ }
131
+ for label, mapping, required in (
132
+ ("data", data, required_data),
133
+ ("model", model, required_model),
134
+ ("training", training, required_training),
135
+ ):
136
+ missing = sorted(required.difference(mapping))
137
+ if missing:
138
+ raise ValueError(f"Missing {label} configuration keys: {missing}")
139
+
140
+ if data["layout"] != "N,H,W,T":
141
+ raise ValueError("This reproduction requires data.layout=N,H,W,T")
142
+ if data["dtype"] != "float32" or training["dtype"] != "float32":
143
+ raise ValueError("The audited reproduction requires float32 data and training")
144
+ if data["normalization"] != "none":
145
+ raise ValueError("The paper-faithful default requires normalization=none")
146
+ if int(data["history"]) != 10 or int(model["input_channels"]) != 10:
147
+ raise ValueError("The FNO-2D experiment requires ten history channels")
148
+ if int(data["horizon"]) != 10 or int(model["output_channels"]) != 1:
149
+ raise ValueError("The experiment requires a ten-step rollout of one-step outputs")
150
+ if int(model["width"]) != 32 or int(model["num_layers"]) != 4:
151
+ raise ValueError("Strict paper settings require width=32 and num_layers=4")
152
+ if int(model["modes1"]) != 12 or int(model["modes2"]) != 12:
153
+ raise ValueError("Strict paper settings require 12 retained modes per axis")
154
+ if str(training["optimizer"]).lower() != "adam":
155
+ raise ValueError("The paper specifies Adam")
156
+ if str(training["scheduler"]).lower() != "step_lr":
157
+ raise ValueError("The paper schedule is represented by StepLR")
158
+ if str(training["checkpoint_monitor"]) != "train_full_relative_l2":
159
+ raise ValueError("Test metrics must not select the checkpoint")
160
+
161
+
162
+ def resolve_project_path(value: str | Path) -> Path:
163
+ path = Path(value).expanduser()
164
+ return path.resolve() if path.is_absolute() else (PROJECT_ROOT / path).resolve()
165
+
166
+
167
+ def data_file_from_config(config: dict[str, Any]) -> Path:
168
+ data = config["data"]
169
+ path = Path(str(data["root"])).expanduser() / str(data["file"])
170
+ path = path.resolve()
171
+ if not path.is_file():
172
+ raise FileNotFoundError(f"Navier-Stokes MAT file does not exist: {path}")
173
+ return path
174
+
175
+
176
+ def load_trajectory_array(config: dict[str, Any]) -> np.ndarray:
177
+ data = config["data"]
178
+ path = data_file_from_config(config)
179
+ key = str(data["key"])
180
+ payload = scipy.io.loadmat(path, variable_names=[key])
181
+ if key not in payload:
182
+ raise KeyError(f"MAT field {key!r} is missing from {path}")
183
+ trajectories = payload[key]
184
+ expected_shape = tuple(int(value) for value in data["expected_shape"])
185
+ if trajectories.shape != expected_shape:
186
+ raise ValueError(
187
+ f"Expected {key} shape {expected_shape}, received {trajectories.shape}"
188
+ )
189
+ if trajectories.dtype != np.float32:
190
+ raise TypeError(f"Expected {key} dtype float32, received {trajectories.dtype}")
191
+ if not np.isfinite(trajectories).all():
192
+ raise ValueError("Trajectory array contains NaN or Inf")
193
+ return trajectories
194
+
195
+
196
+ def build_datasets(
197
+ config: dict[str, Any],
198
+ max_train_samples: int | None = None,
199
+ max_test_samples: int | None = None,
200
+ rollout_steps: int | None = None,
201
+ ) -> tuple[TensorDataset, TensorDataset]:
202
+ data = config["data"]
203
+ trajectories = load_trajectory_array(config)
204
+ history = int(data["history"])
205
+ horizon = int(data["horizon"])
206
+ steps = horizon if rollout_steps is None else int(rollout_steps)
207
+ if not 1 <= steps <= horizon:
208
+ raise ValueError(f"rollout_steps must be in [1,{horizon}], got {steps}")
209
+
210
+ ntrain = int(data["ntrain"])
211
+ ntest = int(data["ntest"])
212
+ train_start = int(data.get("train_start", 0))
213
+ test_start = int(data["test_start"])
214
+ train_count = ntrain if max_train_samples is None else min(ntrain, max_train_samples)
215
+ test_count = ntest if max_test_samples is None else min(ntest, max_test_samples)
216
+ if train_count <= 0 or test_count <= 0:
217
+ raise ValueError("Training and test sample counts must be positive")
218
+
219
+ train = trajectories[train_start : train_start + train_count]
220
+ test = trajectories[test_start : test_start + test_count]
221
+ train_history = torch.from_numpy(train[..., :history])
222
+ train_target = torch.from_numpy(train[..., history : history + steps])
223
+ test_history = torch.from_numpy(test[..., :history])
224
+ test_target = torch.from_numpy(test[..., history : history + steps])
225
+ expected_hw = tuple(int(value) for value in data["resolution"])
226
+ expected_history_shape = (expected_hw[0], expected_hw[1], history)
227
+ if tuple(train_history.shape[1:]) != expected_history_shape:
228
+ raise ValueError(f"Invalid train history shape: {train_history.shape}")
229
+ if tuple(test_history.shape[1:]) != expected_history_shape:
230
+ raise ValueError(f"Invalid test history shape: {test_history.shape}")
231
+ return TensorDataset(train_history, train_target), TensorDataset(test_history, test_target)
232
+
233
+
234
+ def seed_everything(seed: int, deterministic: bool) -> None:
235
+ random.seed(seed)
236
+ np.random.seed(seed)
237
+ torch.manual_seed(seed)
238
+ if torch.cuda.is_available():
239
+ torch.cuda.manual_seed_all(seed)
240
+ if hasattr(torch.backends, "cudnn"):
241
+ torch.backends.cudnn.deterministic = deterministic
242
+ torch.backends.cudnn.benchmark = not deterministic
243
+ if deterministic:
244
+ torch.use_deterministic_algorithms(True, warn_only=True)
245
+
246
+
247
+ def select_device(requested: str) -> torch.device:
248
+ if requested == "auto":
249
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
250
+ device = torch.device(requested)
251
+ if device.type == "cuda" and not torch.cuda.is_available():
252
+ raise RuntimeError(f"Requested {requested}, but torch reports no CUDA/DCU device")
253
+ return device
254
+
255
+
256
+ def relative_l2_per_sample(prediction: Tensor, target: Tensor, epsilon: float) -> Tensor:
257
+ if prediction.shape != target.shape:
258
+ raise ValueError(f"Relative L2 shape mismatch: {prediction.shape} vs {target.shape}")
259
+ if prediction.ndim < 2:
260
+ raise ValueError("Relative L2 inputs must include a batch and at least one feature axis")
261
+ difference = torch.linalg.vector_norm(
262
+ (prediction - target).reshape(prediction.shape[0], -1), dim=1
263
+ )
264
+ denominator = torch.linalg.vector_norm(target.reshape(target.shape[0], -1), dim=1)
265
+ return difference / (denominator + epsilon)
266
+
267
+
268
+ def autoregressive_rollout(
269
+ model: nn.Module,
270
+ history: Tensor,
271
+ target: Tensor,
272
+ epsilon: float,
273
+ ) -> tuple[Tensor, Tensor, Tensor]:
274
+ if history.ndim != 4 or target.ndim != 4:
275
+ raise ValueError("history and target must be channel-last four-dimensional tensors")
276
+ window = history
277
+ predictions: list[Tensor] = []
278
+ step_ratios: list[Tensor] = []
279
+ for step in range(target.shape[-1]):
280
+ prediction = model(window)
281
+ expected_step_shape = (*history.shape[:-1], 1)
282
+ if tuple(prediction.shape) != expected_step_shape:
283
+ raise ValueError(
284
+ f"Model returned {tuple(prediction.shape)}, expected {expected_step_shape}"
285
+ )
286
+ target_step = target[..., step : step + 1]
287
+ predictions.append(prediction)
288
+ step_ratios.append(relative_l2_per_sample(prediction, target_step, epsilon))
289
+ window = torch.cat((window[..., 1:], prediction), dim=-1)
290
+ rollout = torch.cat(predictions, dim=-1)
291
+ ratios = torch.stack(step_ratios, dim=1)
292
+ backward_loss = ratios.mean(dim=0).sum()
293
+ return rollout, ratios, backward_loss
294
+
295
+
296
+ def build_loader(
297
+ dataset: TensorDataset,
298
+ batch_size: int,
299
+ shuffle: bool,
300
+ num_workers: int,
301
+ pin_memory: bool,
302
+ seed: int,
303
+ ) -> DataLoader:
304
+ generator = torch.Generator()
305
+ generator.manual_seed(seed)
306
+ return DataLoader(
307
+ dataset,
308
+ batch_size=batch_size,
309
+ shuffle=shuffle,
310
+ num_workers=num_workers,
311
+ pin_memory=pin_memory,
312
+ drop_last=False,
313
+ generator=generator,
314
+ )
315
+
316
+
317
+ def run_epoch(
318
+ model: nn.Module,
319
+ loader: DataLoader,
320
+ device: torch.device,
321
+ epsilon: float,
322
+ optimizer: torch.optim.Optimizer | None,
323
+ ) -> dict[str, float]:
324
+ training = optimizer is not None
325
+ model.train(training)
326
+ total_samples = 0
327
+ total_step_ratio = 0.0
328
+ total_full_ratio = 0.0
329
+ steps_per_sample: int | None = None
330
+
331
+ context = torch.enable_grad() if training else torch.inference_mode()
332
+ with context:
333
+ for history, target in loader:
334
+ history = history.to(device=device, dtype=torch.float32, non_blocking=True)
335
+ target = target.to(device=device, dtype=torch.float32, non_blocking=True)
336
+ if training:
337
+ optimizer.zero_grad(set_to_none=True)
338
+ prediction, step_ratios, backward_loss = autoregressive_rollout(
339
+ model, history, target, epsilon
340
+ )
341
+ full_ratios = relative_l2_per_sample(prediction, target, epsilon)
342
+ if not torch.isfinite(backward_loss) or not torch.isfinite(full_ratios).all():
343
+ raise FloatingPointError("Non-finite training/evaluation loss encountered")
344
+ if training:
345
+ backward_loss.backward()
346
+ for name, parameter in model.named_parameters():
347
+ if parameter.grad is not None and not torch.isfinite(parameter.grad).all():
348
+ raise FloatingPointError(f"Non-finite gradient in parameter {name}")
349
+ optimizer.step()
350
+
351
+ batch_samples = int(history.shape[0])
352
+ total_samples += batch_samples
353
+ total_step_ratio += float(step_ratios.detach().sum().cpu())
354
+ total_full_ratio += float(full_ratios.detach().sum().cpu())
355
+ steps_per_sample = int(step_ratios.shape[1])
356
+
357
+ if total_samples == 0 or steps_per_sample is None:
358
+ raise RuntimeError("DataLoader produced no batches")
359
+ return {
360
+ "step_loss_sum": total_step_ratio / total_samples,
361
+ "mean_step_relative_l2": total_step_ratio / (total_samples * steps_per_sample),
362
+ "full_relative_l2": total_full_ratio / total_samples,
363
+ "samples": float(total_samples),
364
+ "rollout_steps": float(steps_per_sample),
365
+ }
366
+
367
+
368
+ def synchronize(device: torch.device) -> None:
369
+ if device.type == "cuda" and torch.cuda.is_available():
370
+ torch.cuda.synchronize(device)
371
+
372
+
373
+ def environment_metadata(device: torch.device) -> dict[str, Any]:
374
+ device_name = "cpu"
375
+ if device.type == "cuda" and torch.cuda.is_available():
376
+ device_name = torch.cuda.get_device_name(device)
377
+ return {
378
+ "python": platform.python_version(),
379
+ "pytorch": torch.__version__,
380
+ "numpy": np.__version__,
381
+ "scipy": scipy.__version__,
382
+ "pyyaml": yaml.__version__,
383
+ "device": str(device),
384
+ "device_name": device_name,
385
+ "torch_cuda_version": torch.version.cuda,
386
+ "hostname": platform.node(),
387
+ }
388
+
389
+
390
+ def atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
391
+ path.parent.mkdir(parents=True, exist_ok=True)
392
+ temporary = path.with_suffix(path.suffix + ".tmp")
393
+ with temporary.open("w", encoding="utf-8") as handle:
394
+ json.dump(payload, handle, indent=2, ensure_ascii=False)
395
+ handle.write("\n")
396
+ os.replace(temporary, path)
397
+
398
+
399
+ def atomic_torch_save(path: Path, payload: dict[str, Any]) -> None:
400
+ path.parent.mkdir(parents=True, exist_ok=True)
401
+ temporary = path.with_suffix(path.suffix + ".tmp")
402
+ torch.save(payload, temporary)
403
+ os.replace(temporary, path)
404
+
405
+
406
+ def load_training_checkpoint(path: Path, device: torch.device) -> dict[str, Any]:
407
+ checkpoint_path = path.expanduser().resolve()
408
+ if not checkpoint_path.is_file():
409
+ raise FileNotFoundError(f"Resume checkpoint does not exist: {checkpoint_path}")
410
+ try:
411
+ checkpoint = torch.load(checkpoint_path, map_location=device, weights_only=False)
412
+ except TypeError:
413
+ checkpoint = torch.load(checkpoint_path, map_location=device)
414
+ if not isinstance(checkpoint, dict):
415
+ raise TypeError("Resume checkpoint root must be a mapping")
416
+ required = {
417
+ "epoch",
418
+ "model_state_dict",
419
+ "optimizer_state_dict",
420
+ "scheduler_state_dict",
421
+ "best_train_full_relative_l2",
422
+ "monitor",
423
+ "test_selected",
424
+ "config",
425
+ "run_type",
426
+ }
427
+ missing = sorted(required.difference(checkpoint))
428
+ if missing:
429
+ raise KeyError(f"Resume checkpoint is missing required keys: {missing}")
430
+ if checkpoint["run_type"] != "formal":
431
+ raise ValueError("Formal training can only resume a formal checkpoint")
432
+ if checkpoint["monitor"] != "train_full_relative_l2":
433
+ raise ValueError(f"Unexpected checkpoint monitor: {checkpoint['monitor']}")
434
+ if checkpoint["test_selected"] is not False:
435
+ raise ValueError("This reproduction forbids resuming a test-selected checkpoint")
436
+ return checkpoint
437
+
438
+
439
+ def capture_rng_state(train_loader: DataLoader) -> dict[str, Any]:
440
+ state: dict[str, Any] = {
441
+ "python": random.getstate(),
442
+ "numpy": np.random.get_state(),
443
+ "torch": torch.get_rng_state(),
444
+ "train_loader_generator": train_loader.generator.get_state(),
445
+ }
446
+ if torch.cuda.is_available():
447
+ state["cuda"] = torch.cuda.get_rng_state_all()
448
+ return state
449
+
450
+
451
+ def restore_rng_state(state: dict[str, Any], train_loader: DataLoader) -> None:
452
+ random.setstate(state["python"])
453
+ np.random.set_state(state["numpy"])
454
+ torch.set_rng_state(state["torch"].cpu())
455
+ train_loader.generator.set_state(state["train_loader_generator"].cpu())
456
+ if torch.cuda.is_available() and "cuda" in state:
457
+ torch.cuda.set_rng_state_all([value.cpu() for value in state["cuda"]])
458
+
459
+
460
+ def main() -> None:
461
+ args = parse_args()
462
+ config = load_config(args.config)
463
+ training = config["training"]
464
+ seed = int(training["seed"])
465
+ seed_everything(seed, bool(training.get("deterministic", True)))
466
+ device = select_device(args.device)
467
+
468
+ epochs = int(training["epochs"] if args.epochs is None else args.epochs)
469
+ batch_size = int(training["batch_size"] if args.batch_size is None else args.batch_size)
470
+ horizon = int(config["data"]["horizon"])
471
+ rollout_steps = horizon if args.rollout_steps is None else int(args.rollout_steps)
472
+ if epochs <= 0 or batch_size <= 0:
473
+ raise ValueError("epochs and batch_size must be positive")
474
+ if args.run_type == "formal" and any(
475
+ value is not None
476
+ for value in (
477
+ args.epochs,
478
+ args.rollout_steps,
479
+ args.max_train_samples,
480
+ args.max_test_samples,
481
+ args.output_dir,
482
+ args.checkpoint,
483
+ )
484
+ ):
485
+ raise ValueError(
486
+ "Formal runs use the exact configured data, epochs, rollout, checkpoint, "
487
+ "and results paths; overrides require --run-type smoke"
488
+ )
489
+
490
+ output_dir = (
491
+ resolve_project_path(config["paths"]["results_dir"])
492
+ if args.output_dir is None
493
+ else args.output_dir.expanduser().resolve()
494
+ )
495
+ checkpoint_path = (
496
+ resolve_project_path(config["paths"]["checkpoint"])
497
+ if args.checkpoint is None
498
+ else args.checkpoint.expanduser().resolve()
499
+ )
500
+ latest_checkpoint_path = checkpoint_path.with_name("last_model.pth")
501
+ history_path = (
502
+ resolve_project_path(config["paths"]["train_history"])
503
+ if args.output_dir is None
504
+ else output_dir / "train_history.json"
505
+ )
506
+ output_dir.mkdir(parents=True, exist_ok=True)
507
+ checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
508
+
509
+ train_dataset, test_dataset = build_datasets(
510
+ config,
511
+ max_train_samples=args.max_train_samples,
512
+ max_test_samples=args.max_test_samples,
513
+ rollout_steps=rollout_steps,
514
+ )
515
+ pin_memory = bool(training.get("pin_memory", True)) and device.type == "cuda"
516
+ train_loader = build_loader(
517
+ train_dataset,
518
+ batch_size,
519
+ True,
520
+ int(training.get("num_workers", 0)),
521
+ pin_memory,
522
+ seed,
523
+ )
524
+ test_loader = build_loader(
525
+ test_dataset,
526
+ batch_size,
527
+ False,
528
+ int(training.get("num_workers", 0)),
529
+ pin_memory,
530
+ seed,
531
+ )
532
+
533
+ # Move devices without forcing a global dtype conversion: the pointwise
534
+ # parameters are float32 while spectral weights must remain complex64.
535
+ model = build_model_from_config(config).to(device=device)
536
+ parameter_count = sum(parameter.numel() for parameter in model.parameters())
537
+ paper_parameter_count = int(config["paper"]["reference_parameter_count"])
538
+ optimizer = torch.optim.Adam(
539
+ model.parameters(),
540
+ lr=float(training["learning_rate"]),
541
+ weight_decay=float(training["weight_decay"]),
542
+ )
543
+ scheduler = torch.optim.lr_scheduler.StepLR(
544
+ optimizer,
545
+ step_size=int(training["scheduler_step_size"]),
546
+ gamma=float(training["scheduler_gamma"]),
547
+ )
548
+ epsilon = float(training["relative_l2_epsilon"])
549
+
550
+ run_started = datetime.now(timezone.utc).isoformat()
551
+ history_payload: dict[str, Any] = {
552
+ "schema_version": "fno-ns2d-train-history-v1",
553
+ "run_type": args.run_type,
554
+ "started_at": run_started,
555
+ "completed_at": None,
556
+ "config_path": str(args.config.expanduser().resolve()),
557
+ "data_path": str(data_file_from_config(config)),
558
+ "checkpoint_path": str(checkpoint_path),
559
+ "config": deepcopy(config),
560
+ "environment": environment_metadata(device),
561
+ "parameter_count": parameter_count,
562
+ "paper_parameter_count": paper_parameter_count,
563
+ "parameter_count_difference": parameter_count - paper_parameter_count,
564
+ "test_selected": False,
565
+ "epochs_requested": epochs,
566
+ "rollout_steps": rollout_steps,
567
+ "train_samples": len(train_dataset),
568
+ "test_samples": len(test_dataset),
569
+ "history": [],
570
+ }
571
+
572
+ best_metric = float("inf")
573
+ start_epoch = 1
574
+ resume_exact_rng = True
575
+ if args.resume is not None:
576
+ resume_path = args.resume.expanduser().resolve()
577
+ resume_checkpoint = load_training_checkpoint(resume_path, device)
578
+ if resume_checkpoint["config"] != config:
579
+ raise ValueError("Resume checkpoint configuration differs from the current YAML")
580
+ resume_epoch = int(resume_checkpoint["epoch"])
581
+ if not 1 <= resume_epoch < epochs:
582
+ raise ValueError(
583
+ f"Resume epoch must be in [1,{epochs - 1}], received {resume_epoch}"
584
+ )
585
+ if not history_path.is_file():
586
+ raise FileNotFoundError(
587
+ f"Training history required for audited resume is missing: {history_path}"
588
+ )
589
+ with history_path.open("r", encoding="utf-8") as handle:
590
+ previous_history = json.load(handle)
591
+ if not isinstance(previous_history, dict):
592
+ raise TypeError("Existing training history root must be a mapping")
593
+ records = previous_history.get("history")
594
+ if not isinstance(records, list) or len(records) < resume_epoch:
595
+ raise ValueError(
596
+ f"Existing history has {len(records) if isinstance(records, list) else 0} "
597
+ f"records, fewer than resume epoch {resume_epoch}"
598
+ )
599
+ retained_records = records[:resume_epoch]
600
+ if int(retained_records[-1]["epoch"]) != resume_epoch:
601
+ raise ValueError("Existing training history is not contiguous at the resume epoch")
602
+ checkpoint_metric = float(resume_checkpoint["best_train_full_relative_l2"])
603
+ history_best = min(float(record["train_full_relative_l2"]) for record in retained_records)
604
+ if not np.isclose(checkpoint_metric, history_best, rtol=1e-7, atol=1e-9):
605
+ raise ValueError(
606
+ f"Resume checkpoint best metric {checkpoint_metric} differs from retained "
607
+ f"history best {history_best}"
608
+ )
609
+
610
+ model.load_state_dict(resume_checkpoint["model_state_dict"], strict=True)
611
+ optimizer.load_state_dict(resume_checkpoint["optimizer_state_dict"])
612
+ scheduler.load_state_dict(resume_checkpoint["scheduler_state_dict"])
613
+ if not bool(resume_checkpoint.get("scheduler_step_applied", False)):
614
+ scheduler.step()
615
+ rng_state = resume_checkpoint.get("rng_state")
616
+ if isinstance(rng_state, dict):
617
+ restore_rng_state(rng_state, train_loader)
618
+ else:
619
+ resume_exact_rng = False
620
+
621
+ discarded_records = len(records) - resume_epoch
622
+ history_payload = previous_history
623
+ history_payload["history"] = retained_records
624
+ history_payload["completed_at"] = None
625
+ history_payload["completed"] = False
626
+ history_payload["epochs_requested"] = epochs
627
+ history_payload["resume_exact_rng"] = resume_exact_rng
628
+ resume_events = history_payload.setdefault("resume_events", [])
629
+ resume_events.append(
630
+ {
631
+ "resumed_at": run_started,
632
+ "checkpoint_path": str(resume_path),
633
+ "checkpoint_epoch": resume_epoch,
634
+ "discarded_history_records": discarded_records,
635
+ "exact_rng_state_restored": resume_exact_rng,
636
+ }
637
+ )
638
+ best_metric = checkpoint_metric
639
+ start_epoch = resume_epoch + 1
640
+ atomic_write_json(history_path, history_payload)
641
+ print(
642
+ f"resume checkpoint={resume_path} epoch={resume_epoch} "
643
+ f"discarded_history_records={discarded_records} "
644
+ f"exact_rng_state_restored={'yes' if resume_exact_rng else 'no'}",
645
+ flush=True,
646
+ )
647
+ print(
648
+ f"device={device} parameters={parameter_count} "
649
+ f"paper_parameters={paper_parameter_count} delta={parameter_count-paper_parameter_count}",
650
+ flush=True,
651
+ )
652
+
653
+ if device.type == "cuda" and torch.cuda.is_available():
654
+ torch.cuda.reset_peak_memory_stats(device)
655
+ for epoch in range(start_epoch, epochs + 1):
656
+ synchronize(device)
657
+ epoch_start = time.perf_counter()
658
+ lr_used = float(optimizer.param_groups[0]["lr"])
659
+ train_metrics = run_epoch(model, train_loader, device, epsilon, optimizer)
660
+ test_metrics = run_epoch(model, test_loader, device, epsilon, optimizer=None)
661
+ synchronize(device)
662
+ duration = time.perf_counter() - epoch_start
663
+ peak_memory_bytes = (
664
+ int(torch.cuda.max_memory_allocated(device))
665
+ if device.type == "cuda" and torch.cuda.is_available()
666
+ else 0
667
+ )
668
+
669
+ monitor = float(train_metrics["full_relative_l2"])
670
+ is_best = monitor < best_metric
671
+ if is_best:
672
+ best_metric = monitor
673
+ checkpoint = {
674
+ "schema_version": "fno-ns2d-checkpoint-v1",
675
+ "epoch": epoch,
676
+ "model_state_dict": model.state_dict(),
677
+ "optimizer_state_dict": optimizer.state_dict(),
678
+ "scheduler_state_dict": scheduler.state_dict(),
679
+ "best_train_full_relative_l2": best_metric,
680
+ "test_full_relative_l2_at_best_epoch": float(
681
+ test_metrics["full_relative_l2"]
682
+ ),
683
+ "monitor": "train_full_relative_l2",
684
+ "test_selected": False,
685
+ "config": deepcopy(config),
686
+ "seed": seed,
687
+ "parameter_count": parameter_count,
688
+ "paper_parameter_count": paper_parameter_count,
689
+ "parameter_count_difference": parameter_count - paper_parameter_count,
690
+ "run_type": args.run_type,
691
+ "scheduler_step_applied": False,
692
+ "rng_state": capture_rng_state(train_loader),
693
+ }
694
+ atomic_torch_save(checkpoint_path, checkpoint)
695
+
696
+ epoch_record = {
697
+ "epoch": epoch,
698
+ "learning_rate": lr_used,
699
+ "duration_seconds": duration,
700
+ "train_step_loss_sum": float(train_metrics["step_loss_sum"]),
701
+ "train_mean_step_relative_l2": float(
702
+ train_metrics["mean_step_relative_l2"]
703
+ ),
704
+ "train_full_relative_l2": float(train_metrics["full_relative_l2"]),
705
+ "test_mean_step_relative_l2": float(test_metrics["mean_step_relative_l2"]),
706
+ "test_full_relative_l2": float(test_metrics["full_relative_l2"]),
707
+ "peak_accelerator_memory_bytes": peak_memory_bytes,
708
+ "best": is_best,
709
+ }
710
+ history_payload["history"].append(epoch_record)
711
+ history_payload["best_epoch"] = next(
712
+ record["epoch"]
713
+ for record in reversed(history_payload["history"])
714
+ if record["best"]
715
+ )
716
+ history_payload["best_train_full_relative_l2"] = best_metric
717
+ atomic_write_json(history_path, history_payload)
718
+ print(
719
+ f"epoch={epoch:04d}/{epochs:04d} time={duration:.3f}s lr={lr_used:.6g} "
720
+ f"train_step_loss_sum={train_metrics['step_loss_sum']:.8f} "
721
+ f"train_step_rel_l2={train_metrics['mean_step_relative_l2']:.8f} "
722
+ f"train_full_rel_l2={train_metrics['full_relative_l2']:.8f} "
723
+ f"test_step_rel_l2={test_metrics['mean_step_relative_l2']:.8f} "
724
+ f"test_full_rel_l2={test_metrics['full_relative_l2']:.8f} "
725
+ f"peak_mem_gib={peak_memory_bytes / (1024 ** 3):.3f} "
726
+ f"best={'yes' if is_best else 'no'}",
727
+ flush=True,
728
+ )
729
+ scheduler.step()
730
+ latest_checkpoint = {
731
+ "schema_version": "fno-ns2d-training-state-v1",
732
+ "epoch": epoch,
733
+ "model_state_dict": model.state_dict(),
734
+ "optimizer_state_dict": optimizer.state_dict(),
735
+ "scheduler_state_dict": scheduler.state_dict(),
736
+ "best_train_full_relative_l2": best_metric,
737
+ "latest_train_full_relative_l2": float(train_metrics["full_relative_l2"]),
738
+ "latest_test_full_relative_l2": float(test_metrics["full_relative_l2"]),
739
+ "monitor": "train_full_relative_l2",
740
+ "test_selected": False,
741
+ "config": deepcopy(config),
742
+ "seed": seed,
743
+ "parameter_count": parameter_count,
744
+ "paper_parameter_count": paper_parameter_count,
745
+ "parameter_count_difference": parameter_count - paper_parameter_count,
746
+ "run_type": args.run_type,
747
+ "scheduler_step_applied": True,
748
+ "rng_state": capture_rng_state(train_loader),
749
+ }
750
+ atomic_torch_save(latest_checkpoint_path, latest_checkpoint)
751
+
752
+ history_payload["completed_at"] = datetime.now(timezone.utc).isoformat()
753
+ history_payload["completed"] = True
754
+ atomic_write_json(history_path, history_payload)
755
+ print(
756
+ f"training_complete best_epoch={history_payload['best_epoch']} "
757
+ f"best_train_full_rel_l2={best_metric:.8f} checkpoint={checkpoint_path}",
758
+ flush=True,
759
+ )
760
+
761
+
762
+ if __name__ == "__main__":
763
+ main()
weight/best_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a99344d653c72dd75cda2625181964c9f83ce3b2b4ad132aa2373a0a86ba883
3
+ size 28489073
weight/last_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f51c51bb0b78668aaff0a530ed5ed699a4562c0cdd2577c83a4770a6105f56d0
3
+ size 28489137