"""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), }