Upload folder using huggingface_hub
Browse files- README.md +153 -0
- config/config.yaml +56 -0
- models/PointNetCFD.py +146 -0
- models/__init__.py +5 -0
- models/__pycache__/PointNetCFD.cpython-310.pyc +0 -0
- models/__pycache__/PointNetCFD.cpython-311.pyc +0 -0
- models/__pycache__/__init__.cpython-310.pyc +0 -0
- models/__pycache__/__init__.cpython-311.pyc +0 -0
- scripts/__init__.py +1 -0
- scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- scripts/__pycache__/common.cpython-310.pyc +0 -0
- scripts/__pycache__/common.cpython-311.pyc +0 -0
- scripts/__pycache__/inference.cpython-310.pyc +0 -0
- scripts/__pycache__/inference.cpython-311.pyc +0 -0
- scripts/__pycache__/result.cpython-310.pyc +0 -0
- scripts/__pycache__/result.cpython-311.pyc +0 -0
- scripts/__pycache__/train.cpython-310.pyc +0 -0
- scripts/__pycache__/train.cpython-311.pyc +0 -0
- scripts/common.py +492 -0
- scripts/inference.py +206 -0
- scripts/result.py +147 -0
- scripts/train.py +323 -0
- weight/best_model.pth +3 -0
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
|