yushuang88 commited on
Commit
b871dba
·
verified ·
1 Parent(s): 4778b5d

Upload folder using huggingface_hub

Browse files
README.md ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: mit
3
+ language:
4
+ - en
5
+ tags:
6
+ - OneScience
7
+ - fluid-mechanics
8
+ - flow-field-prediction
9
+ - point-cloud
10
+ frameworks: PyTorch
11
+ ---
12
+ <p align="center">
13
+ <strong>
14
+ <span style="font-size: 30px;">PointNetCFD</span>
15
+ </strong>
16
+ </p>
17
+
18
+ # Model Introduction
19
+
20
+ PointNetCFD is a point-cloud model for flow-field prediction proposed by Ali Kashefi, Davis Rempe, and Leonidas J. Guibas. It directly represents nodes of unstructured CFD meshes in irregular geometries as point clouds, uses PointNet to encode geometry and spatial position, and predicts two velocity components and pressure at each point. This repository is an independent reproduction based on the paper and implemented through the OneScience skill workflow.
21
+
22
+ Paper: [A Point-Cloud Deep Learning Framework for Prediction of Fluid Flow Fields on Irregular Geometries](https://arxiv.org/abs/2010.09469)
23
+
24
+ # Model Description
25
+
26
+ PointNetCFD is a pointwise CFD regression model for unstructured meshes. Each sample contains 1,024 points. It takes node coordinates `(x, y)` as input and predicts `(u, v, p)` at every point. T-Nets align the inputs and features; shared MLPs and global max pooling extract local and global features; and a `512 → 256 → 128 → 128 → 3` decoder predicts the flow field from the fused representation. Coordinates retain their original physical scale, while output variables are normalized to `[0, 1]` using training-set statistics.
27
+
28
+ ## Use Cases
29
+
30
+ | Use case | Description |
31
+ | --- | --- |
32
+ | CFD flow-field prediction | Predicts velocity components `(u, v)` and pressure `p` point by point from the two-dimensional coordinates `(x, y)` of unstructured-mesh nodes. |
33
+ | Irregular-geometry modeling | Directly represents object boundaries and unstructured meshes as point clouds without interpolating CFD data onto a regular grid. |
34
+ | Geometry generalization | The corresponding experiment evaluates predictive performance on previously unseen geometries. |
35
+
36
+ # Usage
37
+
38
+ ## 1. Using OneCode
39
+
40
+ Try intelligent, one-click AI4S programming in the OneCode online environment:
41
+
42
+ [Try intelligent, one-click AI4S programming](https://web-2069360198568017922-iaaj.ksai.scnet.cn:58043/home)
43
+
44
+ ## 2. Manual Installation and Usage
45
+
46
+ **Hardware requirements**
47
+
48
+ - A GPU or DCU is recommended.
49
+ - A CPU can be used for import checks and small-scale connectivity tests, but full training and inference will be slow.
50
+ - DCU users must install DTK in advance. DTK 25.04.2 or later, or the OneScience-recommended version for the current cluster, is recommended.
51
+
52
+ ### Download the Model Package
53
+
54
+ ```bash
55
+ modelscope download --model OneScience/PointNetCFD --local_dir ./PointNetCFD
56
+ cd PointNetCFD
57
+ ```
58
+
59
+ ### Set Up the Runtime Environment
60
+
61
+ **DCU environment**
62
+
63
+ ```bash
64
+ # Activate DTK and Conda first
65
+ conda create -n onescience311 python=3.11 -y
66
+ conda activate onescience311
67
+ # Installation with uv is also supported
68
+ pip install onescience[cfd-dcu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
69
+ ```
70
+
71
+ **GPU environment**
72
+
73
+ ```bash
74
+ # Activate Conda first
75
+ conda create -n onescience311 python=3.11 -y libstdcxx-ng=12 libgcc-ng=12 gcc_linux-64=12 gxx_linux-64=12
76
+ conda activate onescience311
77
+ # Installation with uv is also supported
78
+ pip install onescience[cfd-gpu] -i http://mirrors.onescience.ai:3141/pypi/simple/ --trusted-host mirrors.onescience.ai
79
+ ```
80
+
81
+ ### Training Data
82
+
83
+ The OneScience community provides PointNetCFD training data. Download it with the command below, then make sure `paths.data_dir` in `config/config.yaml` points to the downloaded data directory:
84
+
85
+ ```bash
86
+ modelscope download --dataset OneScience/pointnet_cfd --local_dir ./data
87
+ ```
88
+
89
+ Each sample in `CFDdata.npy` is a 1,024 × 5 point-cloud data matrix with columns `[x, y, p, u, v]`. Index files for the training, validation, and test sets are also provided.
90
+
91
+ ### Training
92
+
93
+ The default `config/config.yaml` corresponds to the main experimental setup in the paper.
94
+
95
+ ```bash
96
+ python scripts/train.py --config config/config.yaml
97
+ ```
98
+
99
+ During training, the training loss, validation loss, and evaluation metrics for each epoch are printed to standard output. The checkpoint with the best validation MSE is saved to:
100
+
101
+ ```text
102
+ weight/best_model.pth
103
+ ```
104
+
105
+ The training history, effective configuration, and training summary are stored in the `results/` directory. For an environment-connectivity check, run the minimal smoke test, which uses a separate output path:
106
+
107
+ ```bash
108
+ python scripts/train.py --smoke-test
109
+ ```
110
+
111
+ ### Trained Weights
112
+
113
+ The `weight/` directory contains weights pretrained on the PointNetCFD data and ready for inference.
114
+
115
+ ### Inference
116
+
117
+ Before running inference, make sure the data path in `config/config.yaml` is valid and `weight/best_model.pth` exists. The following command runs inference on the fixed test set and prints the normalized MSE, RMSE for each physical variable, and relative L2 error in real time:
118
+
119
+ ```bash
120
+ python scripts/inference.py \
121
+ --config config/config.yaml \
122
+ --checkpoint weight/best_model.pth \
123
+ --device auto \
124
+ --output-dir results
125
+ ```
126
+
127
+ Inference outputs are saved as:
128
+
129
+ - `results/test_metrics.json`: test metrics and reference metrics from the paper;
130
+ - `results/predictions.npz`: coordinates, predictions, ground truth, and sample indices.
131
+
132
+ ### Evaluation and Visualization
133
+
134
+ Numerical evaluation is performed by `scripts/inference.py` during inference. The visualization script depends on `results/predictions.npz`, so complete the inference step above before running:
135
+
136
+ ```bash
137
+ python scripts/result.py \
138
+ --predictions results/predictions.npz \
139
+ --output-dir results/figures \
140
+ --num-cases 3
141
+ ```
142
+
143
+ # Official OneScience Resources
144
+
145
+ | Platform | OneScience Main Repository | Skills Repository |
146
+ | --- | --- | --- |
147
+ | Gitee | https://gitee.com/onescience-ai/onescience | https://gitee.com/onescience-ai/oneskills |
148
+ | GitHub | https://github.com/onescience-ai/OneScience | https://github.com/onescience-ai/oneskills |
149
+
150
+ # Citation and License
151
+
152
+ - Paper: [A Point-Cloud Deep Learning Framework for Prediction of Fluid Flow Fields on Irregular Geometries](https://arxiv.org/abs/2010.09469), [DOI: 10.1063/5.0033376](https://doi.org/10.1063/5.0033376)
153
+ - This repository preserves the attribution and copyright information of the original paper and official implementation. The official code is licensed under the MIT License; the paper, dataset, and other related resources remain subject to their respective copyright notices and terms of use.
config/config.yaml ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # PointCFD main experiment from arXiv:2010.09469.
2
+ experiment:
3
+ name: pointcfd_main
4
+ paper: https://arxiv.org/pdf/2010.09469
5
+
6
+ paths:
7
+ data_dir: /public/share/sugonhpcapp01/onestore/onedatasets/PointNetCFD_data
8
+ data_file: CFDdata.npy
9
+ train_indices: training_idx.npy
10
+ validation_indices: validation_idx.npy
11
+ test_indices: test_idx.npy
12
+ checkpoint: weight/best_model.pth
13
+ results_dir: results
14
+
15
+ data:
16
+ num_points: 1024
17
+ source_channels: [x, y, p, u, v]
18
+ input_indices: [0, 1]
19
+ target_indices: [3, 4, 2]
20
+ input_names: [x, y]
21
+ target_names: [u, v, p]
22
+ coordinate_normalization: none
23
+ target_normalization: train_minmax
24
+
25
+ model:
26
+ input_dim: 2
27
+ output_dim: 3
28
+ global_feature_dim: 1024
29
+ expected_paper_parameters: 3552588
30
+
31
+ training:
32
+ # The paper does not report a random seed; zero is the reproducible default.
33
+ seed: 0
34
+ epochs: 4000
35
+ batch_size: 256
36
+ num_workers: 0
37
+ optimizer: adam
38
+ learning_rate: 0.0005
39
+ beta1: 0.9
40
+ beta2: 0.999
41
+ epsilon: 0.000001
42
+ weight_decay: 0.0
43
+ scheduler: none
44
+ precision: float32
45
+ validation_interval: 1
46
+ log_every_batches: 1
47
+ # The paper validates each epoch but does not define checkpoint selection.
48
+ best_metric: val_mse
49
+
50
+ evaluation:
51
+ relative_l2_epsilon: 1.0e-12
52
+ visualization_cases: 3
53
+ paper_reference_mean_relative_l2:
54
+ u: 0.0449666
55
+ v: 0.0370540
56
+ p: 0.0271661
models/PointNetCFD.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Paper-faithful PointNet architecture for PointCFD field regression.
2
+
3
+ The model follows Figure 5 of Kashefi, Rempe, and Guibas (2021): an input
4
+ transform, a feature transform, symmetric max aggregation, and a point-wise
5
+ decoder for the nondimensional velocity and pressure fields.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Tuple, Union
11
+
12
+ import torch
13
+ from torch import Tensor, nn
14
+
15
+
16
+ class ConvBNReLU(nn.Sequential):
17
+ """A shared point-wise fully connected layer with BN and ReLU."""
18
+
19
+ def __init__(self, in_channels: int, out_channels: int) -> None:
20
+ super().__init__(
21
+ nn.Conv1d(in_channels, out_channels, kernel_size=1, bias=True),
22
+ nn.BatchNorm1d(out_channels),
23
+ nn.ReLU(inplace=True),
24
+ )
25
+
26
+
27
+ class LinearBNReLU(nn.Sequential):
28
+ """A fully connected layer with BN and ReLU."""
29
+
30
+ def __init__(self, in_features: int, out_features: int) -> None:
31
+ super().__init__(
32
+ nn.Linear(in_features, out_features, bias=True),
33
+ nn.BatchNorm1d(out_features),
34
+ nn.ReLU(inplace=True),
35
+ )
36
+
37
+
38
+ class TransformNet(nn.Module):
39
+ """PointNet transformation network for input or intermediate features."""
40
+
41
+ def __init__(self, k: int) -> None:
42
+ super().__init__()
43
+ if k <= 0:
44
+ raise ValueError(f"k must be positive, got {k}")
45
+ self.k = int(k)
46
+ self.point_mlp = nn.Sequential(
47
+ ConvBNReLU(self.k, 64),
48
+ ConvBNReLU(64, 128),
49
+ ConvBNReLU(128, 1024),
50
+ )
51
+ self.global_mlp = nn.Sequential(
52
+ LinearBNReLU(1024, 512),
53
+ LinearBNReLU(512, 256),
54
+ )
55
+ self.transform = nn.Linear(256, self.k * self.k, bias=True)
56
+
57
+ # The paper adopts PointNet's canonical identity initialization.
58
+ nn.init.zeros_(self.transform.weight)
59
+ nn.init.zeros_(self.transform.bias)
60
+
61
+ def forward(self, features: Tensor) -> Tensor:
62
+ """Predict a transform from channel-first features ``[B, k, N]``."""
63
+ if features.ndim != 3 or features.shape[1] != self.k:
64
+ raise ValueError(
65
+ f"TransformNet({self.k}) expects [B,{self.k},N], "
66
+ f"got {tuple(features.shape)}"
67
+ )
68
+ encoded = self.point_mlp(features)
69
+ global_feature = torch.amax(encoded, dim=2)
70
+ transform_delta = self.transform(self.global_mlp(global_feature))
71
+ identity = torch.eye(
72
+ self.k, dtype=features.dtype, device=features.device
73
+ ).reshape(1, self.k * self.k)
74
+ return (transform_delta + identity).reshape(-1, self.k, self.k)
75
+
76
+
77
+ class PointNetCFD(nn.Module):
78
+ """Regress normalized ``(u, v, p)`` at every input point."""
79
+
80
+ def __init__(self, input_dim: int = 2, output_dim: int = 3) -> None:
81
+ super().__init__()
82
+ if input_dim <= 0 or output_dim <= 0:
83
+ raise ValueError("input_dim and output_dim must be positive")
84
+ self.input_dim = int(input_dim)
85
+ self.output_dim = int(output_dim)
86
+
87
+ self.input_transform = TransformNet(self.input_dim)
88
+ self.input_mlp = nn.Sequential(
89
+ ConvBNReLU(self.input_dim, 64),
90
+ ConvBNReLU(64, 64),
91
+ )
92
+ self.feature_transform = TransformNet(64)
93
+ self.global_mlp = nn.Sequential(
94
+ ConvBNReLU(64, 64),
95
+ ConvBNReLU(64, 128),
96
+ ConvBNReLU(128, 1024),
97
+ )
98
+ self.decoder = nn.Sequential(
99
+ ConvBNReLU(64 + 1024, 512),
100
+ ConvBNReLU(512, 256),
101
+ ConvBNReLU(256, 128),
102
+ ConvBNReLU(128, 128),
103
+ nn.Conv1d(128, self.output_dim, kernel_size=1, bias=True),
104
+ nn.Sigmoid(),
105
+ )
106
+
107
+ def forward(
108
+ self, points: Tensor, return_transforms: bool = False
109
+ ) -> Union[Tensor, Tuple[Tensor, Tensor, Tensor]]:
110
+ """Run point-wise regression.
111
+
112
+ Args:
113
+ points: Physical coordinates shaped ``[batch, points, input_dim]``.
114
+ return_transforms: Also return input and feature transform matrices.
115
+ """
116
+ if points.ndim != 3 or points.shape[-1] != self.input_dim:
117
+ raise ValueError(
118
+ f"PointNetCFD expects [B,N,{self.input_dim}], got {tuple(points.shape)}"
119
+ )
120
+
121
+ channel_first = points.transpose(1, 2).contiguous()
122
+ input_transform = self.input_transform(channel_first)
123
+ transformed_points = torch.bmm(points, input_transform)
124
+
125
+ local_feature = self.input_mlp(
126
+ transformed_points.transpose(1, 2).contiguous()
127
+ )
128
+ feature_transform = self.feature_transform(local_feature)
129
+ transformed_local = torch.bmm(
130
+ local_feature.transpose(1, 2), feature_transform
131
+ ).transpose(1, 2).contiguous()
132
+
133
+ encoded = self.global_mlp(transformed_local)
134
+ global_feature = torch.amax(encoded, dim=2, keepdim=True)
135
+ global_repeated = global_feature.expand(-1, -1, points.shape[1])
136
+ decoded_input = torch.cat((transformed_local, global_repeated), dim=1)
137
+ prediction = self.decoder(decoded_input).transpose(1, 2).contiguous()
138
+
139
+ if return_transforms:
140
+ return prediction, input_transform, feature_transform
141
+ return prediction
142
+
143
+
144
+ def count_trainable_parameters(model: nn.Module) -> int:
145
+ """Return the number of parameters updated by gradient descent."""
146
+ return sum(parameter.numel() for parameter in model.parameters() if parameter.requires_grad)
models/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """PointCFD model package."""
2
+
3
+ from .PointNetCFD import PointNetCFD, TransformNet, count_trainable_parameters
4
+
5
+ __all__ = ["PointNetCFD", "TransformNet", "count_trainable_parameters"]
models/__pycache__/PointNetCFD.cpython-310.pyc ADDED
Binary file (5.44 kB). View file
 
models/__pycache__/PointNetCFD.cpython-311.pyc ADDED
Binary file (10.3 kB). View file
 
models/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (264 Bytes). View file
 
models/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (374 Bytes). View file
 
scripts/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Executable and shared utilities for the PointCFD reproduction."""
scripts/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (261 Bytes). View file
 
scripts/__pycache__/common.cpython-310.pyc ADDED
Binary file (17.8 kB). View file
 
scripts/__pycache__/common.cpython-311.pyc ADDED
Binary file (34.7 kB). View file
 
scripts/__pycache__/inference.cpython-310.pyc ADDED
Binary file (5.35 kB). View file
 
scripts/__pycache__/inference.cpython-311.pyc ADDED
Binary file (10.4 kB). View file
 
scripts/__pycache__/result.cpython-310.pyc ADDED
Binary file (4.86 kB). View file
 
scripts/__pycache__/result.cpython-311.pyc ADDED
Binary file (10.1 kB). View file
 
scripts/__pycache__/train.cpython-310.pyc ADDED
Binary file (8.58 kB). View file
 
scripts/__pycache__/train.cpython-311.pyc ADDED
Binary file (17.1 kB). View file
 
scripts/common.py ADDED
@@ -0,0 +1,492 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared data, metric, configuration, and serialization utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import random
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple
10
+
11
+ import numpy as np
12
+ import torch
13
+ import yaml
14
+ from torch import Tensor, nn
15
+ from torch.utils.data import DataLoader, Dataset
16
+
17
+
18
+ PAPER_SAMPLE_COUNT = 2595
19
+ AVAILABLE_SAMPLE_COUNT = 2215
20
+
21
+
22
+ def load_config(config_path: Path) -> Dict[str, Any]:
23
+ """Load and minimally validate the experiment YAML."""
24
+ config_path = Path(config_path).expanduser().resolve()
25
+ if not config_path.is_file():
26
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
27
+ with config_path.open("r", encoding="utf-8") as handle:
28
+ config = yaml.safe_load(handle)
29
+ if not isinstance(config, dict):
30
+ raise ValueError(f"Configuration must be a mapping: {config_path}")
31
+ required_sections = ("experiment", "paths", "data", "model", "training", "evaluation")
32
+ missing = [section for section in required_sections if section not in config]
33
+ if missing:
34
+ raise ValueError(f"Configuration is missing sections: {missing}")
35
+
36
+ data_config = config["data"]
37
+ if data_config.get("coordinate_normalization") != "none":
38
+ raise ValueError("Paper fidelity requires coordinate_normalization: none")
39
+ if data_config.get("target_normalization") != "train_minmax":
40
+ raise ValueError("Paper fidelity requires target_normalization: train_minmax")
41
+ if list(data_config.get("target_names", [])) != ["u", "v", "p"]:
42
+ raise ValueError("Model target order must be [u, v, p]")
43
+ if config["training"].get("scheduler") != "none":
44
+ raise ValueError("The paper does not specify a learning-rate scheduler")
45
+ return config
46
+
47
+
48
+ def resolve_path(project_root: Path, configured_path: str) -> Path:
49
+ """Resolve an absolute path or a path relative to the project root."""
50
+ path = Path(configured_path).expanduser()
51
+ return path.resolve() if path.is_absolute() else (project_root / path).resolve()
52
+
53
+
54
+ def configured_paths(config: Mapping[str, Any], project_root: Path) -> Dict[str, Path]:
55
+ """Resolve all data and output paths without consulting environment variables."""
56
+ paths = config["paths"]
57
+ data_dir = resolve_path(project_root, str(paths["data_dir"]))
58
+ return {
59
+ "data": data_dir / str(paths["data_file"]),
60
+ "train_indices": data_dir / str(paths["train_indices"]),
61
+ "validation_indices": data_dir / str(paths["validation_indices"]),
62
+ "test_indices": data_dir / str(paths["test_indices"]),
63
+ "checkpoint": resolve_path(project_root, str(paths["checkpoint"])),
64
+ "results_dir": resolve_path(project_root, str(paths["results_dir"])),
65
+ }
66
+
67
+
68
+ def set_deterministic_seed(seed: int) -> None:
69
+ """Seed every RNG used by this project."""
70
+ random.seed(seed)
71
+ np.random.seed(seed)
72
+ torch.manual_seed(seed)
73
+ if torch.cuda.is_available():
74
+ torch.cuda.manual_seed_all(seed)
75
+ if hasattr(torch.backends, "cudnn"):
76
+ torch.backends.cudnn.deterministic = True
77
+ torch.backends.cudnn.benchmark = False
78
+ try:
79
+ torch.use_deterministic_algorithms(True, warn_only=True)
80
+ except (AttributeError, TypeError):
81
+ pass
82
+
83
+
84
+ def choose_device(requested: str) -> torch.device:
85
+ """Resolve ``auto``, CPU, or an explicit CUDA device."""
86
+ requested = requested.strip().lower()
87
+ if requested == "auto":
88
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
89
+ device = torch.device(requested)
90
+ if device.type == "cuda" and not torch.cuda.is_available():
91
+ raise RuntimeError(f"CUDA device requested but CUDA is unavailable: {requested}")
92
+ return device
93
+
94
+
95
+ def _load_index_array(path: Path) -> np.ndarray:
96
+ if not path.is_file():
97
+ raise FileNotFoundError(f"Split index file not found: {path}")
98
+ indices = np.load(path, allow_pickle=False)
99
+ if indices.ndim != 1 or not np.issubdtype(indices.dtype, np.integer):
100
+ raise ValueError(f"Split indices must be a one-dimensional integer array: {path}")
101
+ return np.asarray(indices, dtype=np.int64)
102
+
103
+
104
+ def load_data_and_splits(
105
+ config: Mapping[str, Any], project_root: Path
106
+ ) -> Tuple[np.ndarray, Dict[str, np.ndarray], Dict[str, Path]]:
107
+ """Load the CFD array read-only and validate the fixed supplied splits."""
108
+ paths = configured_paths(config, project_root)
109
+ if not paths["data"].is_file():
110
+ raise FileNotFoundError(f"CFD data file not found: {paths['data']}")
111
+ data = np.load(paths["data"], mmap_mode="r", allow_pickle=False)
112
+ expected_points = int(config["data"]["num_points"])
113
+ expected_channels = len(config["data"]["source_channels"])
114
+ if data.ndim != 3 or data.shape[1:] != (expected_points, expected_channels):
115
+ raise ValueError(
116
+ f"Expected CFD array [cases,{expected_points},{expected_channels}], "
117
+ f"got {data.shape}"
118
+ )
119
+ if data.dtype != np.float32:
120
+ raise ValueError(f"Expected float32 CFD data, got {data.dtype}")
121
+ if not np.isfinite(data).all():
122
+ raise ValueError("CFD data contains NaN or infinite values")
123
+
124
+ splits = {
125
+ "train": _load_index_array(paths["train_indices"]),
126
+ "validation": _load_index_array(paths["validation_indices"]),
127
+ "test": _load_index_array(paths["test_indices"]),
128
+ }
129
+ total_cases = int(data.shape[0])
130
+ for name, indices in splits.items():
131
+ if indices.size == 0:
132
+ raise ValueError(f"Split {name} is empty")
133
+ if indices.min() < 0 or indices.max() >= total_cases:
134
+ raise ValueError(f"Split {name} contains out-of-range indices")
135
+ if np.unique(indices).size != indices.size:
136
+ raise ValueError(f"Split {name} contains duplicate indices")
137
+ split_sets = {name: set(values.tolist()) for name, values in splits.items()}
138
+ if split_sets["train"] & split_sets["validation"]:
139
+ raise ValueError("Training and validation splits overlap")
140
+ if split_sets["train"] & split_sets["test"]:
141
+ raise ValueError("Training and test splits overlap")
142
+ if split_sets["validation"] & split_sets["test"]:
143
+ raise ValueError("Validation and test splits overlap")
144
+ covered = split_sets["train"] | split_sets["validation"] | split_sets["test"]
145
+ if covered != set(range(total_cases)):
146
+ raise ValueError("Fixed splits do not cover the dataset exactly")
147
+ return data, splits, paths
148
+
149
+
150
+ def fit_target_minmax(
151
+ data: np.ndarray, train_indices: np.ndarray, target_indices: Sequence[int]
152
+ ) -> Tuple[np.ndarray, np.ndarray]:
153
+ """Fit per-variable extrema using the training split and no other cases."""
154
+ training_cases = np.asarray(data[train_indices], dtype=np.float32)
155
+ training_targets = np.take(training_cases, target_indices, axis=-1)
156
+ target_min = training_targets.min(axis=(0, 1)).astype(np.float32)
157
+ target_max = training_targets.max(axis=(0, 1)).astype(np.float32)
158
+ if not np.isfinite(target_min).all() or not np.isfinite(target_max).all():
159
+ raise ValueError("Target normalization extrema are not finite")
160
+ if np.any(target_max <= target_min):
161
+ raise ValueError(
162
+ f"Every target must have positive min-max span: min={target_min}, max={target_max}"
163
+ )
164
+ return target_min, target_max
165
+
166
+
167
+ class PointCFDDataset(Dataset):
168
+ """A fixed case-index view of the supplied PointCFD NumPy array."""
169
+
170
+ def __init__(
171
+ self,
172
+ data: np.ndarray,
173
+ case_indices: np.ndarray,
174
+ input_indices: Sequence[int],
175
+ target_indices: Sequence[int],
176
+ target_min: np.ndarray,
177
+ target_max: np.ndarray,
178
+ ) -> None:
179
+ self.data = data
180
+ self.case_indices = np.asarray(case_indices, dtype=np.int64)
181
+ self.input_indices = tuple(int(index) for index in input_indices)
182
+ self.target_indices = tuple(int(index) for index in target_indices)
183
+ self.target_min = np.asarray(target_min, dtype=np.float32)
184
+ self.target_max = np.asarray(target_max, dtype=np.float32)
185
+ if self.target_min.shape != (len(self.target_indices),):
186
+ raise ValueError("target_min has the wrong shape")
187
+ if self.target_max.shape != self.target_min.shape:
188
+ raise ValueError("target_max has the wrong shape")
189
+ self.target_span = self.target_max - self.target_min
190
+ if np.any(self.target_span <= 0):
191
+ raise ValueError("Target min-max span must be positive")
192
+
193
+ def __len__(self) -> int:
194
+ return int(self.case_indices.size)
195
+
196
+ def __getitem__(self, item: int) -> Tuple[Tensor, Tensor, Tensor]:
197
+ case_index = int(self.case_indices[item])
198
+ sample = self.data[case_index]
199
+ coordinates = np.ascontiguousarray(sample[:, self.input_indices], dtype=np.float32)
200
+ targets = np.ascontiguousarray(sample[:, self.target_indices], dtype=np.float32)
201
+ normalized = np.ascontiguousarray(
202
+ (targets - self.target_min) / self.target_span, dtype=np.float32
203
+ )
204
+ return (
205
+ torch.from_numpy(coordinates),
206
+ torch.from_numpy(normalized),
207
+ torch.tensor(case_index, dtype=torch.int64),
208
+ )
209
+
210
+
211
+ def make_loader(
212
+ dataset: Dataset,
213
+ batch_size: int,
214
+ shuffle: bool,
215
+ num_workers: int,
216
+ seed: int,
217
+ pin_memory: bool,
218
+ ) -> DataLoader:
219
+ if batch_size <= 0:
220
+ raise ValueError("batch_size must be positive")
221
+ if num_workers < 0:
222
+ raise ValueError("num_workers cannot be negative")
223
+ generator = torch.Generator()
224
+ generator.manual_seed(seed)
225
+
226
+ def seed_worker(worker_id: int) -> None:
227
+ worker_seed = (seed + worker_id) % (2**32)
228
+ np.random.seed(worker_seed)
229
+ random.seed(worker_seed)
230
+
231
+ return DataLoader(
232
+ dataset,
233
+ batch_size=batch_size,
234
+ shuffle=shuffle,
235
+ num_workers=num_workers,
236
+ pin_memory=pin_memory,
237
+ drop_last=False,
238
+ generator=generator,
239
+ worker_init_fn=seed_worker if num_workers else None,
240
+ )
241
+
242
+
243
+ def inverse_target_minmax(
244
+ normalized: np.ndarray, target_min: np.ndarray, target_max: np.ndarray
245
+ ) -> np.ndarray:
246
+ return normalized * (target_max - target_min) + target_min
247
+
248
+
249
+ def compute_field_metrics(
250
+ predictions_normalized: np.ndarray,
251
+ targets_normalized: np.ndarray,
252
+ target_min: np.ndarray,
253
+ target_max: np.ndarray,
254
+ target_names: Sequence[str],
255
+ relative_l2_epsilon: float,
256
+ ) -> Tuple[Dict[str, Any], np.ndarray, np.ndarray]:
257
+ """Compute paper-style field error metrics after inverse normalization."""
258
+ predictions_normalized = np.asarray(predictions_normalized, dtype=np.float32)
259
+ targets_normalized = np.asarray(targets_normalized, dtype=np.float32)
260
+ if predictions_normalized.shape != targets_normalized.shape:
261
+ raise ValueError("Prediction and target shapes do not match")
262
+ if predictions_normalized.ndim != 3:
263
+ raise ValueError("Evaluation arrays must be [cases, points, variables]")
264
+ if predictions_normalized.shape[-1] != len(target_names):
265
+ raise ValueError("Target names do not match the evaluated variables")
266
+ if relative_l2_epsilon <= 0:
267
+ raise ValueError("relative_l2_epsilon must be positive")
268
+
269
+ normalized_error = predictions_normalized - targets_normalized
270
+ normalized_mse = float(np.mean(np.square(normalized_error), dtype=np.float64))
271
+ predictions = inverse_target_minmax(predictions_normalized, target_min, target_max)
272
+ targets = inverse_target_minmax(targets_normalized, target_min, target_max)
273
+ error = predictions - targets
274
+
275
+ rmse: Dict[str, float] = {}
276
+ relative_l2: Dict[str, Dict[str, float]] = {}
277
+ for channel, name in enumerate(target_names):
278
+ channel_error = error[:, :, channel].astype(np.float64)
279
+ channel_target = targets[:, :, channel].astype(np.float64)
280
+ rmse[str(name)] = float(np.sqrt(np.mean(np.square(channel_error))))
281
+ numerator = np.linalg.norm(channel_error, axis=1)
282
+ denominator = np.maximum(
283
+ np.linalg.norm(channel_target, axis=1), relative_l2_epsilon
284
+ )
285
+ per_case = numerator / denominator
286
+ relative_l2[str(name)] = {
287
+ "mean": float(np.mean(per_case)),
288
+ "max": float(np.max(per_case)),
289
+ "min": float(np.min(per_case)),
290
+ }
291
+ metrics = {
292
+ "normalized_mse": normalized_mse,
293
+ "rmse": rmse,
294
+ "relative_l2": relative_l2,
295
+ }
296
+ return metrics, predictions.astype(np.float32), targets.astype(np.float32)
297
+
298
+
299
+ def evaluate_model(
300
+ model: nn.Module,
301
+ loader: DataLoader,
302
+ device: torch.device,
303
+ target_min: np.ndarray,
304
+ target_max: np.ndarray,
305
+ target_names: Sequence[str],
306
+ relative_l2_epsilon: float,
307
+ ) -> Tuple[Dict[str, Any], Dict[str, np.ndarray]]:
308
+ """Evaluate a complete split and retain arrays needed for reporting."""
309
+ model.eval()
310
+ coordinates_batches = []
311
+ prediction_batches = []
312
+ target_batches = []
313
+ index_batches = []
314
+ with torch.no_grad():
315
+ for coordinates, targets, case_indices in loader:
316
+ predictions = model(coordinates.to(device, non_blocking=True))
317
+ coordinates_batches.append(coordinates.numpy())
318
+ prediction_batches.append(predictions.cpu().numpy())
319
+ target_batches.append(targets.numpy())
320
+ index_batches.append(case_indices.numpy())
321
+ if not prediction_batches:
322
+ raise ValueError("Evaluation loader produced no batches")
323
+ coordinates_array = np.concatenate(coordinates_batches, axis=0)
324
+ predictions_normalized = np.concatenate(prediction_batches, axis=0)
325
+ targets_normalized = np.concatenate(target_batches, axis=0)
326
+ case_indices_array = np.concatenate(index_batches, axis=0)
327
+ metrics, predictions, targets = compute_field_metrics(
328
+ predictions_normalized,
329
+ targets_normalized,
330
+ target_min,
331
+ target_max,
332
+ target_names,
333
+ relative_l2_epsilon,
334
+ )
335
+ arrays = {
336
+ "coordinates": coordinates_array.astype(np.float32),
337
+ "predictions": predictions,
338
+ "targets": targets,
339
+ "predictions_normalized": predictions_normalized.astype(np.float32),
340
+ "targets_normalized": targets_normalized.astype(np.float32),
341
+ "case_indices": case_indices_array.astype(np.int64),
342
+ }
343
+ return metrics, arrays
344
+
345
+
346
+ def _json_safe(value: Any) -> Any:
347
+ if isinstance(value, Mapping):
348
+ return {str(key): _json_safe(item) for key, item in value.items()}
349
+ if isinstance(value, (list, tuple)):
350
+ return [_json_safe(item) for item in value]
351
+ if isinstance(value, Path):
352
+ return str(value)
353
+ if isinstance(value, np.ndarray):
354
+ return value.tolist()
355
+ if isinstance(value, np.generic):
356
+ return value.item()
357
+ if isinstance(value, torch.device):
358
+ return str(value)
359
+ if isinstance(value, float) and not np.isfinite(value):
360
+ raise ValueError("JSON output cannot contain NaN or Infinity")
361
+ return value
362
+
363
+
364
+ def write_json(path: Path, payload: Mapping[str, Any]) -> None:
365
+ """Atomically write strict, human-readable JSON."""
366
+ path = Path(path)
367
+ path.parent.mkdir(parents=True, exist_ok=True)
368
+ temporary = path.with_name(path.name + ".tmp")
369
+ with temporary.open("w", encoding="utf-8") as handle:
370
+ json.dump(_json_safe(payload), handle, indent=2, sort_keys=True, allow_nan=False)
371
+ handle.write("\n")
372
+ os.replace(temporary, path)
373
+
374
+
375
+ def append_jsonl(path: Path, payload: Mapping[str, Any]) -> None:
376
+ path = Path(path)
377
+ path.parent.mkdir(parents=True, exist_ok=True)
378
+ with path.open("a", encoding="utf-8") as handle:
379
+ handle.write(json.dumps(_json_safe(payload), sort_keys=True, allow_nan=False) + "\n")
380
+ handle.flush()
381
+
382
+
383
+ def write_npz(path: Path, **arrays: np.ndarray) -> None:
384
+ """Atomically write a compressed NumPy archive."""
385
+ path = Path(path)
386
+ path.parent.mkdir(parents=True, exist_ok=True)
387
+ temporary = path.with_name(path.name + ".tmp.npz")
388
+ np.savez_compressed(temporary, **arrays)
389
+ os.replace(temporary, path)
390
+
391
+
392
+ def save_checkpoint(path: Path, payload: Mapping[str, Any]) -> None:
393
+ path = Path(path)
394
+ path.parent.mkdir(parents=True, exist_ok=True)
395
+ temporary = path.with_name(path.name + ".tmp")
396
+ torch.save(dict(payload), temporary)
397
+ os.replace(temporary, path)
398
+
399
+
400
+ def load_checkpoint(path: Path, device: torch.device) -> Dict[str, Any]:
401
+ path = Path(path).expanduser().resolve()
402
+ if not path.is_file():
403
+ raise FileNotFoundError(f"Checkpoint not found: {path}")
404
+ try:
405
+ checkpoint = torch.load(path, map_location=device, weights_only=False)
406
+ except TypeError:
407
+ checkpoint = torch.load(path, map_location=device)
408
+ if not isinstance(checkpoint, dict) or "model_state_dict" not in checkpoint:
409
+ raise ValueError(f"Unsupported checkpoint payload: {path}")
410
+ return checkpoint
411
+
412
+
413
+ def rng_state() -> Dict[str, Any]:
414
+ state: Dict[str, Any] = {
415
+ "python": random.getstate(),
416
+ "numpy": np.random.get_state(),
417
+ "torch": torch.get_rng_state(),
418
+ }
419
+ if torch.cuda.is_available():
420
+ state["cuda"] = torch.cuda.get_rng_state_all()
421
+ return state
422
+
423
+
424
+ def restore_rng_state(state: Optional[Mapping[str, Any]]) -> None:
425
+ if not state:
426
+ return
427
+ random.setstate(state["python"])
428
+ np.random.set_state(state["numpy"])
429
+ torch.set_rng_state(state["torch"])
430
+ if torch.cuda.is_available() and "cuda" in state:
431
+ torch.cuda.set_rng_state_all(state["cuda"])
432
+
433
+
434
+ def checkpoint_metadata_matches(
435
+ checkpoint: Mapping[str, Any],
436
+ config: Mapping[str, Any],
437
+ target_min: np.ndarray,
438
+ target_max: np.ndarray,
439
+ ) -> None:
440
+ """Reject silent channel or normalization changes on resume."""
441
+ expected = {
442
+ "source_channels": list(config["data"]["source_channels"]),
443
+ "input_names": list(config["data"]["input_names"]),
444
+ "target_names": list(config["data"]["target_names"]),
445
+ }
446
+ for key, value in expected.items():
447
+ if list(checkpoint.get(key, [])) != value:
448
+ raise ValueError(f"Checkpoint {key} metadata does not match the configuration")
449
+ if not np.allclose(np.asarray(checkpoint.get("target_min")), target_min, rtol=0, atol=0):
450
+ raise ValueError("Checkpoint target_min differs from the current training split")
451
+ if not np.allclose(np.asarray(checkpoint.get("target_max")), target_max, rtol=0, atol=0):
452
+ raise ValueError("Checkpoint target_max differs from the current training split")
453
+
454
+
455
+ def selected_indices(indices: np.ndarray, maximum_cases: Optional[int]) -> np.ndarray:
456
+ if maximum_cases is None:
457
+ return indices
458
+ if maximum_cases <= 0:
459
+ raise ValueError("maximum_cases must be positive")
460
+ return indices[: min(maximum_cases, indices.size)]
461
+
462
+
463
+ def prepare_datasets(
464
+ config: Mapping[str, Any],
465
+ project_root: Path,
466
+ smoke_test: bool = False,
467
+ ) -> Tuple[Dict[str, PointCFDDataset], np.ndarray, np.ndarray, Dict[str, Path], Dict[str, int]]:
468
+ """Build all split datasets under one normalization contract."""
469
+ data, splits, paths = load_data_and_splits(config, project_root)
470
+ if smoke_test:
471
+ active_splits = {
472
+ "train": selected_indices(splits["train"], 4),
473
+ "validation": selected_indices(splits["validation"], 2),
474
+ "test": selected_indices(splits["test"], 2),
475
+ }
476
+ else:
477
+ active_splits = splits
478
+ target_indices = tuple(int(i) for i in config["data"]["target_indices"])
479
+ target_min, target_max = fit_target_minmax(data, active_splits["train"], target_indices)
480
+ datasets = {
481
+ name: PointCFDDataset(
482
+ data,
483
+ indices,
484
+ config["data"]["input_indices"],
485
+ target_indices,
486
+ target_min,
487
+ target_max,
488
+ )
489
+ for name, indices in active_splits.items()
490
+ }
491
+ counts = {name: len(dataset) for name, dataset in datasets.items()}
492
+ return datasets, target_min, target_max, paths, counts
scripts/inference.py ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Evaluate a PointCFD checkpoint on the fixed test split."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict
10
+
11
+ import numpy as np
12
+ import torch
13
+
14
+
15
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
16
+ project_root_string = str(PROJECT_ROOT)
17
+ if project_root_string in sys.path:
18
+ sys.path.remove(project_root_string)
19
+ sys.path.insert(0, project_root_string)
20
+
21
+ from models import PointNetCFD, count_trainable_parameters # noqa: E402
22
+ from scripts.common import ( # noqa: E402
23
+ AVAILABLE_SAMPLE_COUNT,
24
+ PAPER_SAMPLE_COUNT,
25
+ PointCFDDataset,
26
+ choose_device,
27
+ configured_paths,
28
+ evaluate_model,
29
+ load_checkpoint,
30
+ load_config,
31
+ load_data_and_splits,
32
+ make_loader,
33
+ resolve_path,
34
+ selected_indices,
35
+ set_deterministic_seed,
36
+ write_json,
37
+ write_npz,
38
+ )
39
+
40
+
41
+ def parse_args() -> argparse.Namespace:
42
+ parser = argparse.ArgumentParser(description=__doc__)
43
+ parser.add_argument(
44
+ "--config", type=Path, default=PROJECT_ROOT / "config" / "config.yaml"
45
+ )
46
+ parser.add_argument(
47
+ "--checkpoint",
48
+ type=Path,
49
+ default=None,
50
+ help="Checkpoint path (default: paths.checkpoint from config)",
51
+ )
52
+ parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or cuda:N")
53
+ parser.add_argument("--batch-size", type=int, default=None)
54
+ parser.add_argument("--num-workers", type=int, default=None)
55
+ parser.add_argument(
56
+ "--output-dir",
57
+ type=Path,
58
+ default=None,
59
+ help="Output directory (default: paths.results_dir from config)",
60
+ )
61
+ parser.add_argument(
62
+ "--max-cases",
63
+ type=int,
64
+ default=None,
65
+ help="Evaluate only the first N fixed test cases (smoke testing only)",
66
+ )
67
+ return parser.parse_args()
68
+
69
+
70
+ def validate_checkpoint_contract(checkpoint: Dict[str, Any], config: Dict[str, Any]) -> None:
71
+ expected_metadata = {
72
+ "source_channels": list(config["data"]["source_channels"]),
73
+ "input_names": list(config["data"]["input_names"]),
74
+ "target_names": list(config["data"]["target_names"]),
75
+ "input_indices": list(config["data"]["input_indices"]),
76
+ "target_indices": list(config["data"]["target_indices"]),
77
+ }
78
+ for key, expected in expected_metadata.items():
79
+ if list(checkpoint.get(key, [])) != expected:
80
+ raise ValueError(f"Checkpoint {key} metadata does not match config: {key}")
81
+ target_min = np.asarray(checkpoint.get("target_min"), dtype=np.float32)
82
+ target_max = np.asarray(checkpoint.get("target_max"), dtype=np.float32)
83
+ if target_min.shape != (3,) or target_max.shape != (3,):
84
+ raise ValueError("Checkpoint target normalization must contain three variables")
85
+ if np.any(target_max <= target_min):
86
+ raise ValueError("Checkpoint target normalization spans must be positive")
87
+
88
+
89
+ def main() -> None:
90
+ args = parse_args()
91
+ config = load_config(args.config)
92
+ seed = int(config["training"]["seed"])
93
+ set_deterministic_seed(seed)
94
+ device = choose_device(args.device)
95
+ paths = configured_paths(config, PROJECT_ROOT)
96
+ checkpoint_path = (
97
+ resolve_path(PROJECT_ROOT, str(args.checkpoint))
98
+ if args.checkpoint is not None
99
+ else paths["checkpoint"]
100
+ )
101
+ output_dir = (
102
+ resolve_path(PROJECT_ROOT, str(args.output_dir))
103
+ if args.output_dir is not None
104
+ else paths["results_dir"]
105
+ )
106
+
107
+ checkpoint = load_checkpoint(checkpoint_path, device)
108
+ validate_checkpoint_contract(checkpoint, config)
109
+ checkpoint_model_config = checkpoint.get("model_config", config["model"])
110
+ model = PointNetCFD(
111
+ input_dim=int(checkpoint_model_config["input_dim"]),
112
+ output_dim=int(checkpoint_model_config["output_dim"]),
113
+ ).to(device=device, dtype=torch.float32)
114
+ model.load_state_dict(checkpoint["model_state_dict"], strict=True)
115
+ print(
116
+ f"checkpoint={checkpoint_path} epoch={checkpoint.get('epoch')} device={device} "
117
+ f"trainable_parameters={count_trainable_parameters(model)}",
118
+ flush=True,
119
+ )
120
+
121
+ data, splits, _ = load_data_and_splits(config, PROJECT_ROOT)
122
+ test_indices = selected_indices(splits["test"], args.max_cases)
123
+ target_min = np.asarray(checkpoint["target_min"], dtype=np.float32)
124
+ target_max = np.asarray(checkpoint["target_max"], dtype=np.float32)
125
+ test_dataset = PointCFDDataset(
126
+ data,
127
+ test_indices,
128
+ config["data"]["input_indices"],
129
+ config["data"]["target_indices"],
130
+ target_min,
131
+ target_max,
132
+ )
133
+ batch_size = int(
134
+ args.batch_size if args.batch_size is not None else config["training"]["batch_size"]
135
+ )
136
+ num_workers = int(
137
+ args.num_workers if args.num_workers is not None else config["training"]["num_workers"]
138
+ )
139
+ test_loader = make_loader(
140
+ test_dataset,
141
+ batch_size=batch_size,
142
+ shuffle=False,
143
+ num_workers=num_workers,
144
+ seed=seed,
145
+ pin_memory=device.type == "cuda",
146
+ )
147
+ target_names = list(config["data"]["target_names"])
148
+ metrics, arrays = evaluate_model(
149
+ model,
150
+ test_loader,
151
+ device,
152
+ target_min,
153
+ target_max,
154
+ target_names,
155
+ float(config["evaluation"]["relative_l2_epsilon"]),
156
+ )
157
+
158
+ metrics_payload: Dict[str, Any] = {
159
+ "checkpoint": str(checkpoint_path),
160
+ "checkpoint_epoch": int(checkpoint.get("epoch", -1)),
161
+ "device": str(device),
162
+ "evaluated_test_cases": len(test_dataset),
163
+ "fixed_test_split_cases": int(splits["test"].size),
164
+ "available_sample_count": AVAILABLE_SAMPLE_COUNT,
165
+ "paper_sample_count": PAPER_SAMPLE_COUNT,
166
+ "metrics": metrics,
167
+ "paper_reference_mean_relative_l2": config["evaluation"][
168
+ "paper_reference_mean_relative_l2"
169
+ ],
170
+ "dataset_limitation": (
171
+ "The supplied dataset has 2215 cases rather than the paper's 2595; "
172
+ "these metrics are a best-available subset reproduction."
173
+ ),
174
+ }
175
+ output_dir.mkdir(parents=True, exist_ok=True)
176
+ metrics_path = output_dir / "test_metrics.json"
177
+ predictions_path = output_dir / "predictions.npz"
178
+ write_json(metrics_path, metrics_payload)
179
+ write_npz(
180
+ predictions_path,
181
+ coordinates=arrays["coordinates"],
182
+ predictions=arrays["predictions"],
183
+ targets=arrays["targets"],
184
+ case_indices=arrays["case_indices"],
185
+ target_names=np.asarray(target_names),
186
+ target_min=target_min,
187
+ target_max=target_max,
188
+ )
189
+ print(
190
+ "normalized_mse={:.9e}".format(metrics["normalized_mse"]), flush=True
191
+ )
192
+ for name in target_names:
193
+ relative = metrics["relative_l2"][name]
194
+ print(
195
+ f"variable={name} rmse={metrics['rmse'][name]:.9e} "
196
+ f"relative_l2_mean={relative['mean']:.9e} "
197
+ f"relative_l2_max={relative['max']:.9e} "
198
+ f"relative_l2_min={relative['min']:.9e}",
199
+ flush=True,
200
+ )
201
+ print(f"metrics={metrics_path}", flush=True)
202
+ print(f"predictions={predictions_path}", flush=True)
203
+
204
+
205
+ if __name__ == "__main__":
206
+ main()
scripts/result.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Render PointCFD ground truth, prediction, and absolute field errors."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List
10
+
11
+ import matplotlib
12
+
13
+ matplotlib.use("Agg")
14
+ import matplotlib.pyplot as plt # noqa: E402
15
+ import numpy as np # noqa: E402
16
+
17
+
18
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
19
+ project_root_string = str(PROJECT_ROOT)
20
+ if project_root_string in sys.path:
21
+ sys.path.remove(project_root_string)
22
+ sys.path.insert(0, project_root_string)
23
+
24
+ from scripts.common import resolve_path, write_json # noqa: E402
25
+
26
+
27
+ def parse_args() -> argparse.Namespace:
28
+ parser = argparse.ArgumentParser(description=__doc__)
29
+ parser.add_argument(
30
+ "--predictions",
31
+ type=Path,
32
+ default=PROJECT_ROOT / "results" / "predictions.npz",
33
+ )
34
+ parser.add_argument(
35
+ "--output-dir",
36
+ type=Path,
37
+ default=PROJECT_ROOT / "results" / "figures",
38
+ )
39
+ parser.add_argument("--num-cases", type=int, default=3)
40
+ parser.add_argument("--case-offset", type=int, default=0)
41
+ return parser.parse_args()
42
+
43
+
44
+ def main() -> None:
45
+ args = parse_args()
46
+ predictions_path = resolve_path(PROJECT_ROOT, str(args.predictions))
47
+ output_dir = resolve_path(PROJECT_ROOT, str(args.output_dir))
48
+ if not predictions_path.is_file():
49
+ raise FileNotFoundError(f"Prediction archive not found: {predictions_path}")
50
+ if args.num_cases <= 0:
51
+ raise ValueError("num-cases must be positive")
52
+ if args.case_offset < 0:
53
+ raise ValueError("case-offset cannot be negative")
54
+
55
+ with np.load(predictions_path, allow_pickle=False) as archive:
56
+ required = {"coordinates", "predictions", "targets", "case_indices", "target_names"}
57
+ missing = required - set(archive.files)
58
+ if missing:
59
+ raise ValueError(f"Prediction archive is missing keys: {sorted(missing)}")
60
+ coordinates = np.asarray(archive["coordinates"], dtype=np.float32)
61
+ predictions = np.asarray(archive["predictions"], dtype=np.float32)
62
+ targets = np.asarray(archive["targets"], dtype=np.float32)
63
+ case_indices = np.asarray(archive["case_indices"], dtype=np.int64)
64
+ target_names = [str(name) for name in archive["target_names"].tolist()]
65
+ if coordinates.ndim != 3 or coordinates.shape[-1] != 2:
66
+ raise ValueError(f"coordinates must be [cases,points,2], got {coordinates.shape}")
67
+ if predictions.shape != targets.shape or predictions.ndim != 3:
68
+ raise ValueError("predictions and targets must share [cases,points,variables]")
69
+ if coordinates.shape[:2] != predictions.shape[:2]:
70
+ raise ValueError("Coordinate and field case/point dimensions do not match")
71
+ if predictions.shape[-1] != len(target_names):
72
+ raise ValueError("target_names does not match prediction channels")
73
+ if not (np.isfinite(coordinates).all() and np.isfinite(predictions).all() and np.isfinite(targets).all()):
74
+ raise ValueError("Visualization inputs contain NaN or Infinity")
75
+
76
+ stop = min(args.case_offset + args.num_cases, coordinates.shape[0])
77
+ if args.case_offset >= stop:
78
+ raise ValueError("case-offset is beyond the available predictions")
79
+ output_dir.mkdir(parents=True, exist_ok=True)
80
+ generated: List[str] = []
81
+ case_summaries: List[Dict[str, Any]] = []
82
+ for local_index in range(args.case_offset, stop):
83
+ xy = coordinates[local_index]
84
+ case_prediction = predictions[local_index]
85
+ case_target = targets[local_index]
86
+ absolute_error = np.abs(case_prediction - case_target)
87
+ rows = len(target_names)
88
+ figure, axes = plt.subplots(rows, 3, figsize=(13.5, 4.1 * rows), squeeze=False)
89
+ variable_summary: Dict[str, Any] = {}
90
+ for channel, name in enumerate(target_names):
91
+ lower = float(min(case_target[:, channel].min(), case_prediction[:, channel].min()))
92
+ upper = float(max(case_target[:, channel].max(), case_prediction[:, channel].max()))
93
+ if upper <= lower:
94
+ upper = lower + 1.0e-12
95
+ panels = (
96
+ (case_target[:, channel], "Ground truth", lower, upper, "viridis"),
97
+ (case_prediction[:, channel], "Prediction", lower, upper, "viridis"),
98
+ (absolute_error[:, channel], "Absolute error", 0.0, None, "magma"),
99
+ )
100
+ for column, (values, title, vmin, vmax, color_map) in enumerate(panels):
101
+ axis = axes[channel, column]
102
+ scatter = axis.scatter(
103
+ xy[:, 0],
104
+ xy[:, 1],
105
+ c=values,
106
+ s=8,
107
+ marker="o",
108
+ linewidths=0,
109
+ cmap=color_map,
110
+ vmin=vmin,
111
+ vmax=vmax,
112
+ )
113
+ axis.set_aspect("equal", adjustable="box")
114
+ axis.set_xlabel("x")
115
+ axis.set_ylabel("y")
116
+ axis.set_title(f"{name}: {title}")
117
+ figure.colorbar(scatter, ax=axis, fraction=0.046, pad=0.04)
118
+ variable_summary[name] = {
119
+ "mean_absolute_error": float(np.mean(absolute_error[:, channel])),
120
+ "max_absolute_error": float(np.max(absolute_error[:, channel])),
121
+ }
122
+ case_index = int(case_indices[local_index])
123
+ figure.suptitle(f"PointCFD fixed test case {case_index}")
124
+ figure.tight_layout()
125
+ output_path = output_dir / f"case_{case_index:04d}_fields.png"
126
+ figure.savefig(output_path, dpi=180, bbox_inches="tight")
127
+ plt.close(figure)
128
+ generated.append(str(output_path))
129
+ case_summaries.append({"case_index": case_index, "variables": variable_summary})
130
+ print(f"figure={output_path}", flush=True)
131
+
132
+ summary = {
133
+ "predictions": str(predictions_path),
134
+ "visualization_method": (
135
+ "direct point scatter without triangulation or interpolation because mesh topology "
136
+ "and obstacle boundaries are not provided"
137
+ ),
138
+ "generated_files": generated,
139
+ "cases": case_summaries,
140
+ }
141
+ summary_path = output_dir / "visualization_summary.json"
142
+ write_json(summary_path, summary)
143
+ print(f"visualization_summary={summary_path}", flush=True)
144
+
145
+
146
+ if __name__ == "__main__":
147
+ main()
scripts/train.py ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Train the paper-faithful PointCFD main experiment."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import copy
8
+ import sys
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ import numpy as np
14
+ import torch
15
+ import yaml
16
+ from torch import nn
17
+
18
+
19
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
20
+ project_root_string = str(PROJECT_ROOT)
21
+ if project_root_string in sys.path:
22
+ sys.path.remove(project_root_string)
23
+ sys.path.insert(0, project_root_string)
24
+
25
+ from models import PointNetCFD, count_trainable_parameters # noqa: E402
26
+ from scripts.common import ( # noqa: E402
27
+ AVAILABLE_SAMPLE_COUNT,
28
+ PAPER_SAMPLE_COUNT,
29
+ append_jsonl,
30
+ checkpoint_metadata_matches,
31
+ choose_device,
32
+ configured_paths,
33
+ evaluate_model,
34
+ load_checkpoint,
35
+ load_config,
36
+ make_loader,
37
+ prepare_datasets,
38
+ resolve_path,
39
+ restore_rng_state,
40
+ rng_state,
41
+ save_checkpoint,
42
+ set_deterministic_seed,
43
+ write_json,
44
+ )
45
+
46
+
47
+ def parse_args() -> argparse.Namespace:
48
+ parser = argparse.ArgumentParser(description=__doc__)
49
+ parser.add_argument(
50
+ "--config",
51
+ type=Path,
52
+ default=PROJECT_ROOT / "config" / "config.yaml",
53
+ help="Experiment YAML (default: project config/config.yaml)",
54
+ )
55
+ parser.add_argument("--device", default="auto", help="auto, cpu, cuda, or cuda:N")
56
+ parser.add_argument("--epochs", type=int, default=None, help="Override configured epochs")
57
+ parser.add_argument(
58
+ "--batch-size", type=int, default=None, help="Override configured batch size"
59
+ )
60
+ parser.add_argument(
61
+ "--num-workers", type=int, default=None, help="Override DataLoader workers"
62
+ )
63
+ parser.add_argument("--seed", type=int, default=None, help="Override configured seed")
64
+ parser.add_argument(
65
+ "--resume",
66
+ type=Path,
67
+ default=None,
68
+ help="Resume model, optimizer, epoch, metrics, and RNG state",
69
+ )
70
+ parser.add_argument(
71
+ "--smoke-test",
72
+ action="store_true",
73
+ help="Use tiny fixed splits and isolated smoke output paths",
74
+ )
75
+ return parser.parse_args()
76
+
77
+
78
+ def build_effective_config(config: Dict[str, Any], args: argparse.Namespace) -> Dict[str, Any]:
79
+ effective = copy.deepcopy(config)
80
+ training = effective["training"]
81
+ if args.epochs is not None:
82
+ training["epochs"] = args.epochs
83
+ elif args.smoke_test:
84
+ training["epochs"] = 1
85
+ if args.batch_size is not None:
86
+ training["batch_size"] = args.batch_size
87
+ elif args.smoke_test:
88
+ training["batch_size"] = 2
89
+ if args.num_workers is not None:
90
+ training["num_workers"] = args.num_workers
91
+ if args.seed is not None:
92
+ training["seed"] = args.seed
93
+ if int(training["epochs"]) <= 0:
94
+ raise ValueError("epochs must be positive")
95
+ if int(training["batch_size"]) <= 0:
96
+ raise ValueError("batch_size must be positive")
97
+ if int(training["num_workers"]) < 0:
98
+ raise ValueError("num_workers cannot be negative")
99
+ if int(training["validation_interval"]) != 1:
100
+ raise ValueError("The paper validates after every epoch")
101
+ if str(training["optimizer"]).lower() != "adam":
102
+ raise ValueError("The paper uses Adam")
103
+ if str(training["precision"]).lower() != "float32":
104
+ raise ValueError("This reproduction uses the paper-compatible float32 path")
105
+ return effective
106
+
107
+
108
+ def metric_line(metrics: Dict[str, Any]) -> str:
109
+ rmse = metrics["rmse"]
110
+ relative = metrics["relative_l2"]
111
+ return (
112
+ f"val_mse={metrics['normalized_mse']:.9e} "
113
+ f"rmse_u={rmse['u']:.9e} rmse_v={rmse['v']:.9e} rmse_p={rmse['p']:.9e} "
114
+ f"rel_l2_u={relative['u']['mean']:.9e} "
115
+ f"rel_l2_v={relative['v']['mean']:.9e} "
116
+ f"rel_l2_p={relative['p']['mean']:.9e}"
117
+ )
118
+
119
+
120
+ def main() -> None:
121
+ args = parse_args()
122
+ base_config = load_config(args.config)
123
+ config = build_effective_config(base_config, args)
124
+ training = config["training"]
125
+ seed = int(training["seed"])
126
+ set_deterministic_seed(seed)
127
+ device = choose_device(args.device)
128
+
129
+ datasets, target_min, target_max, resolved_paths, split_counts = prepare_datasets(
130
+ config, PROJECT_ROOT, smoke_test=args.smoke_test
131
+ )
132
+ batch_size = int(training["batch_size"])
133
+ num_workers = int(training["num_workers"])
134
+ pin_memory = device.type == "cuda"
135
+ train_loader = make_loader(
136
+ datasets["train"], batch_size, True, num_workers, seed, pin_memory
137
+ )
138
+ validation_loader = make_loader(
139
+ datasets["validation"], batch_size, False, num_workers, seed, pin_memory
140
+ )
141
+
142
+ model = PointNetCFD(
143
+ input_dim=int(config["model"]["input_dim"]),
144
+ output_dim=int(config["model"]["output_dim"]),
145
+ ).to(device=device, dtype=torch.float32)
146
+ parameter_count = count_trainable_parameters(model)
147
+ paper_parameter_count = int(config["model"]["expected_paper_parameters"])
148
+ print(
149
+ f"device={device} trainable_parameters={parameter_count} "
150
+ f"paper_reference_parameters={paper_parameter_count}",
151
+ flush=True,
152
+ )
153
+ optimizer = torch.optim.Adam(
154
+ model.parameters(),
155
+ lr=float(training["learning_rate"]),
156
+ betas=(float(training["beta1"]), float(training["beta2"])),
157
+ eps=float(training["epsilon"]),
158
+ weight_decay=float(training["weight_decay"]),
159
+ )
160
+ criterion = nn.MSELoss(reduction="mean")
161
+
162
+ if args.smoke_test:
163
+ checkpoint_path = PROJECT_ROOT / "weight" / "smoke_best_model.pth"
164
+ results_dir = PROJECT_ROOT / "results" / "smoke"
165
+ else:
166
+ checkpoint_path = resolved_paths["checkpoint"]
167
+ results_dir = resolved_paths["results_dir"]
168
+ results_dir.mkdir(parents=True, exist_ok=True)
169
+ checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
170
+ history_path = results_dir / "train_history.jsonl"
171
+ if args.resume is None and history_path.exists():
172
+ history_path.unlink()
173
+
174
+ effective_config_path = results_dir / "effective_config.yaml"
175
+ with effective_config_path.open("w", encoding="utf-8") as handle:
176
+ yaml.safe_dump(config, handle, sort_keys=False)
177
+
178
+ start_epoch = 1
179
+ best_validation_mse = float("inf")
180
+ if args.resume is not None:
181
+ resume_path = resolve_path(PROJECT_ROOT, str(args.resume))
182
+ checkpoint = load_checkpoint(resume_path, device)
183
+ checkpoint_metadata_matches(checkpoint, config, target_min, target_max)
184
+ model.load_state_dict(checkpoint["model_state_dict"], strict=True)
185
+ optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
186
+ start_epoch = int(checkpoint["epoch"]) + 1
187
+ best_validation_mse = float(checkpoint["best_validation_mse"])
188
+ restore_rng_state(checkpoint.get("rng_state"))
189
+ if checkpoint.get("train_loader_generator_state") is not None:
190
+ train_loader.generator.set_state(checkpoint["train_loader_generator_state"])
191
+ print(
192
+ f"resumed_from={resume_path} start_epoch={start_epoch} "
193
+ f"best_val_mse={best_validation_mse:.9e}",
194
+ flush=True,
195
+ )
196
+
197
+ final_epoch = int(training["epochs"])
198
+ if start_epoch > final_epoch:
199
+ raise ValueError(
200
+ f"Resume checkpoint epoch {start_epoch - 1} already reaches requested epoch {final_epoch}"
201
+ )
202
+ target_names = list(config["data"]["target_names"])
203
+ relative_l2_epsilon = float(config["evaluation"]["relative_l2_epsilon"])
204
+ log_every = int(training["log_every_batches"])
205
+ if log_every <= 0:
206
+ raise ValueError("log_every_batches must be positive")
207
+
208
+ run_started = time.time()
209
+ for epoch in range(start_epoch, final_epoch + 1):
210
+ epoch_started = time.time()
211
+ model.train()
212
+ squared_error_sum = 0.0
213
+ element_count = 0
214
+ for batch_number, (coordinates, targets, _) in enumerate(train_loader, start=1):
215
+ coordinates = coordinates.to(device, non_blocking=True)
216
+ targets = targets.to(device, non_blocking=True)
217
+ optimizer.zero_grad(set_to_none=True)
218
+ predictions = model(coordinates)
219
+ loss = criterion(predictions, targets)
220
+ loss.backward()
221
+ optimizer.step()
222
+
223
+ batch_elements = targets.numel()
224
+ squared_error_sum += float(loss.detach().item()) * batch_elements
225
+ element_count += batch_elements
226
+ if batch_number % log_every == 0 or batch_number == len(train_loader):
227
+ print(
228
+ f"epoch={epoch}/{final_epoch} "
229
+ f"batch={batch_number}/{len(train_loader)} "
230
+ f"train_loss={loss.detach().item():.9e}",
231
+ flush=True,
232
+ )
233
+ train_mse = squared_error_sum / element_count
234
+
235
+ validation_metrics, _ = evaluate_model(
236
+ model,
237
+ validation_loader,
238
+ device,
239
+ target_min,
240
+ target_max,
241
+ target_names,
242
+ relative_l2_epsilon,
243
+ )
244
+ validation_mse = float(validation_metrics["normalized_mse"])
245
+ elapsed = time.time() - epoch_started
246
+ print(
247
+ f"epoch={epoch}/{final_epoch} train_mse={train_mse:.9e} "
248
+ f"{metric_line(validation_metrics)} epoch_seconds={elapsed:.3f}",
249
+ flush=True,
250
+ )
251
+
252
+ history_record = {
253
+ "epoch": epoch,
254
+ "train_mse": train_mse,
255
+ "validation": validation_metrics,
256
+ "epoch_seconds": elapsed,
257
+ "learning_rate": float(optimizer.param_groups[0]["lr"]),
258
+ "seed": seed,
259
+ "smoke_test": bool(args.smoke_test),
260
+ }
261
+ append_jsonl(history_path, history_record)
262
+
263
+ if validation_mse < best_validation_mse:
264
+ best_validation_mse = validation_mse
265
+ checkpoint_payload: Dict[str, Any] = {
266
+ "format_version": "pointcfd-checkpoint-v1",
267
+ "epoch": epoch,
268
+ "model_state_dict": model.state_dict(),
269
+ "optimizer_state_dict": optimizer.state_dict(),
270
+ "best_validation_mse": best_validation_mse,
271
+ "target_min": target_min,
272
+ "target_max": target_max,
273
+ "source_channels": list(config["data"]["source_channels"]),
274
+ "input_names": list(config["data"]["input_names"]),
275
+ "target_names": target_names,
276
+ "input_indices": list(config["data"]["input_indices"]),
277
+ "target_indices": list(config["data"]["target_indices"]),
278
+ "model_config": copy.deepcopy(config["model"]),
279
+ "training_config": copy.deepcopy(training),
280
+ "data_paths": {key: str(value) for key, value in resolved_paths.items()},
281
+ "split_counts": split_counts,
282
+ "seed": seed,
283
+ "available_sample_count": AVAILABLE_SAMPLE_COUNT,
284
+ "paper_sample_count": PAPER_SAMPLE_COUNT,
285
+ "trainable_parameters": parameter_count,
286
+ "rng_state": rng_state(),
287
+ "train_loader_generator_state": train_loader.generator.get_state(),
288
+ "smoke_test": bool(args.smoke_test),
289
+ }
290
+ save_checkpoint(checkpoint_path, checkpoint_payload)
291
+ print(
292
+ f"saved_best_checkpoint={checkpoint_path} "
293
+ f"best_val_mse={best_validation_mse:.9e}",
294
+ flush=True,
295
+ )
296
+
297
+ summary = {
298
+ "status": "completed",
299
+ "start_epoch": start_epoch,
300
+ "final_epoch": final_epoch,
301
+ "best_validation_mse": best_validation_mse,
302
+ "checkpoint": str(checkpoint_path),
303
+ "history": str(history_path),
304
+ "effective_config": str(effective_config_path),
305
+ "trainable_parameters": parameter_count,
306
+ "paper_reference_parameters": paper_parameter_count,
307
+ "split_counts": split_counts,
308
+ "available_sample_count": AVAILABLE_SAMPLE_COUNT,
309
+ "paper_sample_count": PAPER_SAMPLE_COUNT,
310
+ "dataset_limitation": (
311
+ "The supplied dataset has 2215 cases rather than the paper's 2595; "
312
+ "this is a best-available subset reproduction."
313
+ ),
314
+ "elapsed_seconds": time.time() - run_started,
315
+ "device": str(device),
316
+ "smoke_test": bool(args.smoke_test),
317
+ }
318
+ write_json(results_dir / "training_summary.json", summary)
319
+ print(f"training_complete summary={results_dir / 'training_summary.json'}", flush=True)
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()
weight/best_model.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ba4d2d0903db703a91eca1b6542bfb336a01af21ed30bec55e645132ce50461d
3
+ size 42808802