BlidReview's picture
weights, code, eval script
bdce880 verified
Raw
History Blame Contribute Delete
5.18 kB
"""Shared feature + target construction for EZFlow v2 (OpenFOAM-RANS surrogate).
THIS IS THE SINGLE SOURCE OF TRUTH for node features and target transforms.
Both the training ETL (etl.py) and the serving path (infer_v5.py) import from
here, so a feature added in one place can never silently diverge from the other
(that duplication bug existed between the v1 data.py and inference.py).
Convention (non-dimensional, matches the CFD): U_inf=1, rho=1, L_ref=1, nu=1/Re.
Node features (NODE_FEATURE_DIM = 12), all per-node:
[0:3] inflow_dir = [cos(yaw), sin(yaw), 0]
[3] d_wall = nearest-surface distance / L_ref(=1) (TRUE scale,
NOT per-case-max normalized -- absolute BL scale)
[4] log1p(d_wall) = near-wall resolution where BL gradients are steep
[5] d_plus_proxy = log1p(clip(d_wall*sqrt(Re), 0, 3000)) Re-aware y+ surrogate
[6:9] nearest_wall_normal = outward unit normal of closest wall face (0 far away)
[9] normal_dot_inflow = stagnation(+1)/leeward(-1) indicator
[10] is_wall = 1 if node lies on the body surface
[11] local_mesh_scale = mean incident edge length (resolution awareness)
Global features (GLOBAL_DIM = 2): [log10(Re), yaw_rad]
Targets (TARGET_DIM = 7): [u, v, w, p, log1p(k), log(omega), log(nut*Re)]
velocity/pressure are z-scored downstream; the 3 turbulence channels are put in
log space here (heavy-tailed, span decades) and nut is made ~Re-invariant.
"""
from __future__ import annotations
import numpy as np
from scipy.spatial import cKDTree
NODE_FEATURE_DIM = 12
GLOBAL_DIM = 2
TARGET_DIM = 7
TARGET_NAMES = ["u", "v", "w", "p", "log1p_k", "log_omega", "log_nutRe"]
L_REF = 1.0
_EPS = 1e-12
def knn_edges(pos: np.ndarray, k: int = 8) -> np.ndarray:
"""Bidirectional kNN edge_index (2, E) via cKDTree (no torch_cluster dep)."""
tree = cKDTree(pos)
kq = min(k + 1, len(pos))
_, nbr = tree.query(pos, k=kq)
nbr = np.atleast_2d(nbr)
src = np.repeat(np.arange(len(pos)), nbr.shape[1] - 1)
dst = nbr[:, 1:].reshape(-1)
return np.stack([np.concatenate([src, dst]), np.concatenate([dst, src])]).astype(np.int64)
def local_mesh_scale(pos: np.ndarray, edge_index: np.ndarray) -> np.ndarray:
"""Mean incident edge length per node (resolution-awareness feature)."""
src, dst = edge_index
d = np.linalg.norm(pos[dst] - pos[src], axis=1)
n = pos.shape[0]
s = np.zeros(n); c = np.zeros(n)
np.add.at(s, dst, d); np.add.at(c, dst, 1.0)
return s / np.maximum(c, 1.0)
def build_node_features(pos: np.ndarray, wall_pts: np.ndarray, wall_normals: np.ndarray,
yaw_rad: float, Re: float, edge_index: np.ndarray,
is_wall: np.ndarray | None = None) -> np.ndarray:
"""Construct the (N, NODE_FEATURE_DIM) node feature matrix. Geometry is encoded
via the nearest wall point/normal (scale-consistent, L_ref=1)."""
n = pos.shape[0]
dx, dy = float(np.cos(yaw_rad)), float(np.sin(yaw_rad))
inflow = np.tile([dx, dy, 0.0], (n, 1)).astype(np.float32)
if wall_pts is not None and len(wall_pts) > 0:
tree = cKDTree(wall_pts)
d, idx = tree.query(pos, k=1)
d_wall = (d / L_REF).astype(np.float32)
nrm = wall_normals[idx].astype(np.float32) if wall_normals is not None \
else np.zeros((n, 3), np.float32)
else:
d_wall = np.zeros(n, np.float32)
nrm = np.zeros((n, 3), np.float32)
log_d = np.log1p(d_wall)
d_plus = np.log1p(np.clip(d_wall * np.sqrt(max(Re, 1.0)), 0.0, 3000.0)).astype(np.float32)
ndot = (nrm @ np.array([dx, dy, 0.0], np.float32)).astype(np.float32)
if is_wall is None:
is_wall = (d_wall < 1e-4).astype(np.float32)
scale = local_mesh_scale(pos, edge_index).astype(np.float32)
x = np.concatenate([
inflow, d_wall[:, None], log_d[:, None], d_plus[:, None],
nrm, ndot[:, None], is_wall.astype(np.float32)[:, None], scale[:, None],
], axis=1).astype(np.float32)
return x
def global_features(Re: float, yaw_rad: float) -> np.ndarray:
return np.array([[np.log10(max(Re, 1.0)), yaw_rad]], dtype=np.float32)
def transform_targets(U: np.ndarray, p: np.ndarray, k: np.ndarray,
omega: np.ndarray, nut: np.ndarray, Re: float) -> np.ndarray:
"""Raw CFD fields -> (N, 7) target array (turbulence channels in log space)."""
k = np.clip(k, 0.0, None)
omega = np.clip(omega, _EPS, None)
nut = np.clip(nut, 0.0, None)
return np.concatenate([
U.reshape(-1, 3),
p.reshape(-1, 1),
np.log1p(k).reshape(-1, 1),
np.log(omega).reshape(-1, 1),
np.log(nut * max(Re, 1.0) + _EPS).reshape(-1, 1),
], axis=1).astype(np.float32)
def inverse_targets(y: np.ndarray, Re: float) -> dict:
"""(N, 7) prediction -> physical fields dict {U, p, k, omega, nut}."""
y = np.asarray(y)
return {
"U": y[:, 0:3],
"p": y[:, 3],
"k": np.expm1(y[:, 4]).clip(0.0),
"omega": np.exp(y[:, 5]),
"nut": (np.exp(y[:, 6]) / max(Re, 1.0)).clip(0.0),
}