#!/usr/bin/env python3 """ High-Re Validation (kernel_v2) ============================== Unified validation script for high-Re runs with optional LES. Default targets: - 2D D2Q9: Re=5000 - 3D D3Q19: Re=3000 The script configures macros.h temporarily, compiles kernel_v2, runs the case, and restores macros.h automatically. """ import argparse import json import os import struct import sys import time sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np import pycuda.driver as cuda from CelerisLab.cuda import compiler FLUID_FLAG = 0x01 SOLID_FLAG = 0x02 OBSTACLE_FLAG = 0x04 OMEGA_COLLISION_MIN_DEFAULT = 0.01 LES_POST_FP_ITERS = 3 LES_POST_FP_RELAX = 0.70 LES_POST_NUT_MAX_RATIO = 20.0 def collision_name(model): return {0: "SRT", 1: "TRT", 2: "MRT"}.get(model, f"M{model}") def make_case_tag(cfg): les_tag = "LES" if cfg["use_les"] else "NoLES" inlet_tag = f"IP{int(cfg.get('inlet_profile', 0))}" lam_tag = f"LAM{float(cfg.get('trt_magic_param', 0.1875)):.4f}" return ( f"{cfg['name']}_Re{int(cfg['target_re'])}_" f"{collision_name(cfg['collision_model'])}_{les_tag}_" f"OM{int(cfg['outlet_mode'])}_{inlet_tag}_{lam_tag}_" f"WMAX{cfg['omega_collision_max']:.3f}" ) def validate_case(rho): nan_count = int(np.isnan(rho).sum()) if nan_count > 0: return False, "NaN detected" rho_min = float(np.min(rho)) rho_max = float(np.max(rho)) if rho_min <= 0.0: return False, "Non-positive density" if rho_max >= 2.0: return False, "Density blow-up" return True, "OK" def lattice_weights(nq): if nq == 9: return np.array( [4.0 / 9.0] + [1.0 / 9.0] * 4 + [1.0 / 36.0] * 4, dtype=np.float32, ) if nq == 19: return np.array( [1.0 / 3.0] + [1.0 / 18.0] * 6 + [1.0 / 36.0] * 12, dtype=np.float32, ) raise ValueError(f"Unsupported nq={nq}") def inlet_target_profile_1d(ny, u0, inlet_profile): if int(inlet_profile) == 0: return np.full(ny, float(u0), dtype=np.float32) # Mirror boundary/inlet_outlet.cuh::inlet_target_u for consistent diagnostics. y = np.arange(ny, dtype=np.float32) y_clamped = np.clip(y, 1.0, float(ny - 2)) H = max(float(ny - 2), 1.0) eta = (y_clamped - 0.5) / H shape = np.clip(4.0 * eta * (1.0 - eta), 0.0, None) return (float(u0) * 1.5 * shape).astype(np.float32) def impose_rest_state_on_nonfluid(cfg, host_ddf): nq = cfg["nq"] nx, ny, nz = cfg["nx"], cfg["ny"], cfg["nz"] w = lattice_weights(nq) if nq == 9: f = host_ddf.reshape(nq, ny, nx) nonfluid = cfg["flag"].reshape(ny, nx) != FLUID_FLAG for i in range(nq): f[i, nonfluid] = w[i] return host_ddf f = host_ddf.reshape(nq, nz, ny, nx) nonfluid = cfg["flag"].reshape(nz, ny, nx) != FLUID_FLAG for i in range(nq): f[i, nonfluid] = w[i] return host_ddf def compute_case_diagnostics(cfg, host_ddf): nq = cfg["nq"] nx, ny, nz = cfg["nx"], cfg["ny"], cfg["nz"] if nq == 9: f = host_ddf.reshape(nq, ny, nx) rho = np.sum(f, axis=0) ux = np.zeros_like(rho) uy = np.zeros_like(rho) cx = [0, 1, -1, 0, 0, 1, -1, 1, -1] cy = [0, 0, 0, 1, -1, 1, -1, -1, 1] for i in range(nq): ux += cx[i] * f[i] uy += cy[i] * f[i] rho_safe = np.where(np.abs(rho) > 1.0e-12, rho, 1.0) ux /= rho_safe uy /= rho_safe vel = np.sqrt(ux * ux + uy * uy) fluid = cfg["flag"].reshape(ny, nx) == FLUID_FLAG mass = float(np.nansum(rho[fluid])) x0 = 1 x1 = min(nx - 1, 33) inlet_window = np.zeros_like(fluid) inlet_window[1:ny - 1, x0:x1] = True win_mask = fluid & inlet_window inlet_var = float(np.nanvar(vel[win_mask])) if np.any(win_mask) else float("nan") # Quantify how well the first interior column follows the designed inlet profile. x_probe = 1 line_mask = fluid[:, x_probe] line_u = ux[:, x_probe] target = inlet_target_profile_1d(ny, cfg["u0"], cfg.get("inlet_profile", 0)) if np.any(line_mask): diff = line_u[line_mask] - target[line_mask] t_ref = float(np.max(np.abs(target[line_mask]))) denom = max(t_ref, 1.0e-8) inlet_rel_l2 = float(np.sqrt(np.mean(diff * diff)) / denom) inlet_rel_linf = float(np.max(np.abs(diff)) / denom) else: inlet_rel_l2 = float("nan") inlet_rel_linf = float("nan") # Inlet-plane-wave indicator: streamwise oscillation of column-averaged # macros in the pre-obstacle region. cx = float(cfg.get("cx", 0.25 * nx)) radius = float(cfg.get("radius", ny / 12.0)) x_pre0 = 1 x_pre1 = min(nx - 2, max(x_pre0 + 4, int(cx - 1.5 * radius))) col_u = [] col_r = [] for xp in range(x_pre0, x_pre1): col_mask = fluid[1:ny - 1, xp] if np.any(col_mask): col_u.append(float(np.mean(ux[1:ny - 1, xp][col_mask]))) col_r.append(float(np.mean(rho[1:ny - 1, xp][col_mask]))) target_full = inlet_target_profile_1d(ny, cfg["u0"], cfg.get("inlet_profile", 0)) target_int = target_full[1:ny - 1] u_target_mean = float(np.mean(target_int)) if target_int.size > 0 else float(cfg["u0"]) if len(col_u) >= 4: col_u_arr = np.array(col_u, dtype=np.float64) col_r_arr = np.array(col_r, dtype=np.float64) inlet_wave_ux_rel = float(np.std(col_u_arr - u_target_mean) / max(abs(u_target_mean), 1.0e-8)) rho_ref = max(abs(float(np.mean(col_r_arr))), 1.0e-8) inlet_wave_rho_rel = float(np.std(col_r_arr) / rho_ref) else: inlet_wave_ux_rel = float("nan") inlet_wave_rho_rel = float("nan") # TRT checker/grid-noise indicator: odd-even imbalance in wake ux field. xw0 = min(nx - 3, max(2, int(cx + 2.0 * radius))) xw1 = min(nx - 2, max(xw0 + 4, int(cx + 12.0 * radius))) yw0, yw1 = 2, ny - 2 if xw1 > xw0 + 2 and yw1 > yw0 + 2: reg = ux[yw0:yw1, xw0:xw1].astype(np.float64) reg_mask = fluid[yw0:yw1, xw0:xw1] if np.any(reg_mask): valid = reg[reg_mask] m = float(np.mean(valid)) centered = np.where(reg_mask, reg - m, np.nan) rms = float(np.sqrt(np.mean((valid - m) * (valid - m)))) yy_i, xx_i = np.indices(centered.shape) even_vals = centered[((xx_i + yy_i) & 1) == 0] odd_vals = centered[((xx_i + yy_i) & 1) == 1] even_vals = even_vals[np.isfinite(even_vals)] odd_vals = odd_vals[np.isfinite(odd_vals)] if rms > 1.0e-12 and even_vals.size > 8 and odd_vals.size > 8: wake_checker_rel = float(abs(np.mean(even_vals) - np.mean(odd_vals)) / rms) else: wake_checker_rel = float("nan") pair_mask = reg_mask[:, :-1] & reg_mask[:, 1:] a = centered[:, :-1][pair_mask] b = centered[:, 1:][pair_mask] if a.size > 16 and np.std(a) > 1.0e-12 and np.std(b) > 1.0e-12: corr = float(np.corrcoef(a, b)[0, 1]) wake_checker_anti_corr_x = float(max(0.0, -corr)) else: wake_checker_anti_corr_x = float("nan") else: wake_checker_rel = float("nan") wake_checker_anti_corr_x = float("nan") else: wake_checker_rel = float("nan") wake_checker_anti_corr_x = float("nan") return { "mass": mass, "inlet_var": inlet_var, "inlet_line_rel_l2": inlet_rel_l2, "inlet_line_rel_linf": inlet_rel_linf, "inlet_wave_ux_rel": inlet_wave_ux_rel, "inlet_wave_rho_rel": inlet_wave_rho_rel, "wake_checker_rel": wake_checker_rel, "wake_checker_anti_corr_x": wake_checker_anti_corr_x, } f = host_ddf.reshape(nq, nz, ny, nx) rho = np.sum(f, axis=0) ux = np.zeros_like(rho) uy = np.zeros_like(rho) uz = np.zeros_like(rho) cx = np.array([0, 1,-1, 0, 0, 0, 0, 1,-1, 1,-1, 0, 0, 1,-1, 1,-1, 0, 0]) cy = np.array([0, 0, 0, 1,-1, 0, 0, 1,-1, 0, 0, 1,-1,-1, 1, 0, 0, 1,-1]) cz = np.array([0, 0, 0, 0, 0, 1,-1, 0, 0, 1,-1, 1,-1, 0, 0,-1, 1,-1, 1]) for i in range(nq): ux += cx[i] * f[i] uy += cy[i] * f[i] uz += cz[i] * f[i] rho_safe = np.where(np.abs(rho) > 1.0e-12, rho, 1.0) ux /= rho_safe uy /= rho_safe uz /= rho_safe vel = np.sqrt(ux * ux + uy * uy + uz * uz) fluid = cfg["flag"].reshape(nz, ny, nx) == FLUID_FLAG mass = float(np.nansum(rho[fluid])) x0 = 1 x1 = min(nx - 1, 17) inlet_window = np.zeros_like(fluid) inlet_window[:, 1:ny - 1, x0:x1] = True win_mask = fluid & inlet_window inlet_var = float(np.nanvar(vel[win_mask])) if np.any(win_mask) else float("nan") cx = float(cfg.get("cx", 0.25 * nx)) radius = float(cfg.get("radius", ny / 12.0)) x_pre0 = 1 x_pre1 = min(nx - 2, max(x_pre0 + 4, int(cx - 1.5 * radius))) col_u = [] col_r = [] for xp in range(x_pre0, x_pre1): col_mask = fluid[:, 1:ny - 1, xp] if np.any(col_mask): col_u.append(float(np.mean(ux[:, 1:ny - 1, xp][col_mask]))) col_r.append(float(np.mean(rho[:, 1:ny - 1, xp][col_mask]))) target_full = inlet_target_profile_1d(ny, cfg["u0"], cfg.get("inlet_profile", 0)) target_int = target_full[1:ny - 1] u_target_mean = float(np.mean(target_int)) if target_int.size > 0 else float(cfg["u0"]) if len(col_u) >= 4: col_u_arr = np.array(col_u, dtype=np.float64) col_r_arr = np.array(col_r, dtype=np.float64) inlet_wave_ux_rel = float(np.std(col_u_arr - u_target_mean) / max(abs(u_target_mean), 1.0e-8)) rho_ref = max(abs(float(np.mean(col_r_arr))), 1.0e-8) inlet_wave_rho_rel = float(np.std(col_r_arr) / rho_ref) else: inlet_wave_ux_rel = float("nan") inlet_wave_rho_rel = float("nan") return { "mass": mass, "inlet_var": inlet_var, "inlet_wave_ux_rel": inlet_wave_ux_rel, "inlet_wave_rho_rel": inlet_wave_rho_rel, "wake_checker_rel": float("nan"), "wake_checker_anti_corr_x": float("nan"), "inlet_line_rel_l2": float("nan"), "inlet_line_rel_linf": float("nan"), } def compute_trt_les_fields_2d(cfg, host_ddf): if cfg["nq"] != 9: return None nx, ny = cfg["nx"], cfg["ny"] f = host_ddf.reshape(9, ny, nx).astype(np.float64) cx = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.float64).reshape(9, 1, 1) cy = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.float64).reshape(9, 1, 1) w = lattice_weights(9).astype(np.float64).reshape(9, 1, 1) rho = np.sum(f, axis=0) rho_safe = np.where(np.abs(rho) > 1.0e-12, rho, 1.0) ux = np.sum(f * cx, axis=0) / rho_safe uy = np.sum(f * cy, axis=0) / rho_safe u2 = ux * ux + uy * uy cu = 3.0 * (cx * ux[None, :, :] + cy * uy[None, :, :]) feq = w * rho[None, :, :] * (1.0 + cu + 0.5 * cu * cu - 1.5 * u2[None, :, :]) fneq = f - feq pixx = np.sum(fneq * cx * cx, axis=0) piyy = np.sum(fneq * cy * cy, axis=0) pixy = np.sum(fneq * cx * cy, axis=0) omega0 = float(cfg["omega"]) omega_min = float(cfg.get("omega_collision_min", OMEGA_COLLISION_MIN_DEFAULT)) omega_max = float(cfg["omega_collision_max"]) tau0 = max(1.0 / max(omega0, 1.0e-6), 0.500001) nu0 = (tau0 - 0.5) * (1.0 / 3.0) tau_max = 1.0 / max(omega_min, 1.0e-6) tau_eff = np.full((ny, nx), tau0, dtype=np.float64) nut = np.zeros((ny, nx), dtype=np.float64) rho_ref = np.maximum(rho_safe, 1.0e-12) cs2 = 1.0 / 3.0 nut_cap = max(0.0, LES_POST_NUT_MAX_RATIO * nu0) cs = float(cfg.get("les_cs", 0.16)) for _ in range(LES_POST_FP_ITERS): denom = 2.0 * rho_ref * cs2 * np.maximum(tau_eff, 0.500001) sxx = -pixx / denom syy = -piyy / denom sxy = -pixy / denom tr = 0.5 * (sxx + syy) sxx_dev = sxx - tr syy_dev = syy - tr sxy_dev = sxy s_mag = np.sqrt(np.maximum(0.0, 2.0 * (sxx_dev * sxx_dev + syy_dev * syy_dev + 2.0 * sxy_dev * sxy_dev))) nut = np.clip((cs * cs) * s_mag, 0.0, nut_cap) tau_new = np.clip(0.5 + 3.0 * (nu0 + nut), 0.500001, tau_max) tau_eff = LES_POST_FP_RELAX * tau_new + (1.0 - LES_POST_FP_RELAX) * tau_eff omega_plus = np.clip(1.0 / tau_eff, omega_min, omega_max) denom_odd = np.maximum(1.0 / omega_plus - 0.5, 1.0e-9) lam = float(cfg.get("trt_magic_param", 0.1875)) omega_minus = 1.0 / (lam / denom_odd + 0.5) fluid = cfg["flag"].reshape(ny, nx) == FLUID_FLAG rho_mean = float(np.mean(rho[fluid])) if np.any(fluid) else float(np.mean(rho)) rho_prime = rho - rho_mean return { "fluid": fluid, "omega_plus": omega_plus, "omega_minus": omega_minus, "nut": nut, "rho_prime": rho_prime, } def plot_trt_les_maps(cfg, host_ddf, out_dir): if cfg["nq"] != 9 or cfg["collision_model"] != 1 or not cfg["use_les"]: return None fields = compute_trt_les_fields_2d(cfg, host_ddf) if fields is None: return None fluid = fields["fluid"] tag = make_case_tag(cfg) out_path = os.path.join(out_dir, f"{tag}_trt_les_fields.png") fig, axes = plt.subplots(2, 2, figsize=(12, 9)) panels = [ ("omega_plus", r"$\omega_+$", "viridis", False), ("omega_minus", r"$\omega_-$", "viridis", False), ("nut", r"$\nu_t$", "magma", False), ("rho_prime", r"$\rho-\overline{\rho}$", "RdBu_r", True), ] for ax, (key, title, cmap, signed) in zip(axes.ravel(), panels): arr = fields[key] arr_m = np.ma.array(arr, mask=~fluid) finite_vals = arr[fluid] if finite_vals.size == 0: vmin, vmax = 0.0, 1.0 elif signed: span = np.percentile(np.abs(finite_vals), 99) span = max(float(span), 1.0e-12) vmin, vmax = -span, span else: q1 = float(np.percentile(finite_vals, 1)) q99 = float(np.percentile(finite_vals, 99)) if not np.isfinite(q1) or not np.isfinite(q99) or q99 <= q1: q1 = float(np.min(finite_vals)) q99 = float(np.max(finite_vals)) if q99 <= q1: q99 = q1 + 1.0e-12 vmin, vmax = q1, q99 im = ax.imshow(arr_m, origin="lower", aspect="auto", cmap=cmap, vmin=vmin, vmax=vmax) plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04) ax.set_title(title) ax.set_xlabel("x") ax.set_ylabel("y") fig.suptitle(f"{tag} TRT-LES diagnostics") fig.tight_layout() fig.savefig(out_path, dpi=160) plt.close(fig) return out_path def plot_case(cfg, host_ddf, out_dir): nq = cfg["nq"] nx, ny, nz = cfg["nx"], cfg["ny"], cfg["nz"] flag = cfg["flag"] tag = make_case_tag(cfg) out_path = os.path.join(out_dir, f"{tag}.png") if nq == 9: f = host_ddf.reshape(nq, ny, nx) rho = np.sum(f, axis=0) ux = np.zeros_like(rho) uy = np.zeros_like(rho) cx = [0, 1, -1, 0, 0, 1, -1, 1, -1] cy = [0, 0, 0, 1, -1, 1, -1, -1, 1] for i in range(nq): ux += cx[i] * f[i] uy += cy[i] * f[i] rho_safe = np.where(np.abs(rho) > 1.0e-12, rho, 1.0) ux /= rho_safe uy /= rho_safe vel = np.sqrt(ux * ux + uy * uy) mask = flag.reshape(ny, nx) != FLUID_FLAG vel_m = np.ma.array(vel, mask=mask) vort = np.gradient(uy, axis=1) - np.gradient(ux, axis=0) vort_m = np.ma.array(vort, mask=mask) fig, axes = plt.subplots(1, 3, figsize=(16, 5)) im0 = axes[0].imshow(vel_m, origin="lower", aspect="auto", cmap="turbo") plt.colorbar(im0, ax=axes[0], label="|u|") axes[0].set_title("Velocity Magnitude") vmax = np.percentile(np.abs(vort[~mask]), 99) if np.any(~mask) else 1e-6 vmax = max(vmax, 1.0e-6) im1 = axes[1].imshow(vort_m, origin="lower", aspect="auto", cmap="RdBu_r", vmin=-vmax, vmax=vmax) plt.colorbar(im1, ax=axes[1], label="vorticity") axes[1].set_title("Vorticity") X, Y = np.meshgrid(np.arange(nx), np.arange(ny)) ux_s = np.ma.array(ux, mask=mask) uy_s = np.ma.array(uy, mask=mask) speed = np.ma.sqrt(ux_s * ux_s + uy_s * uy_s) axes[2].streamplot(X, Y, ux_s, uy_s, color=speed, cmap="viridis", density=2.0, linewidth=0.7) axes[2].set_xlim(0, nx) axes[2].set_ylim(0, ny) axes[2].set_title("Streamlines") fig.suptitle(tag) fig.tight_layout() fig.savefig(out_path, dpi=150) plt.close(fig) return out_path # D3Q19: visualize mid-z slice f = host_ddf.reshape(nq, nz, ny, nx) z0 = nz // 2 fs = f[:, z0, :, :] rho = np.sum(fs, axis=0) ux = np.zeros_like(rho) uy = np.zeros_like(rho) uz = np.zeros_like(rho) cx = np.array([0, 1,-1, 0, 0, 0, 0, 1,-1, 1,-1, 0, 0, 1,-1, 1,-1, 0, 0]) cy = np.array([0, 0, 0, 1,-1, 0, 0, 1,-1, 0, 0, 1,-1,-1, 1, 0, 0, 1,-1]) cz = np.array([0, 0, 0, 0, 0, 1,-1, 0, 0, 1,-1, 1,-1, 0, 0,-1, 1,-1, 1]) for i in range(nq): ux += cx[i] * fs[i] uy += cy[i] * fs[i] uz += cz[i] * fs[i] rho_safe = np.where(np.abs(rho) > 1.0e-12, rho, 1.0) ux /= rho_safe uy /= rho_safe uz /= rho_safe vel = np.sqrt(ux * ux + uy * uy + uz * uz) mask3 = flag.reshape(nz, ny, nx)[z0] != FLUID_FLAG vel_m = np.ma.array(vel, mask=mask3) vort = np.gradient(uy, axis=1) - np.gradient(ux, axis=0) vort_m = np.ma.array(vort, mask=mask3) fig, axes = plt.subplots(1, 3, figsize=(16, 5)) im0 = axes[0].imshow(vel_m, origin="lower", aspect="auto", cmap="turbo") plt.colorbar(im0, ax=axes[0], label="|u|") axes[0].set_title("Velocity Magnitude (z-mid)") vmax = np.percentile(np.abs(vort[~mask3]), 99) if np.any(~mask3) else 1.0e-6 vmax = max(vmax, 1.0e-6) im1 = axes[1].imshow(vort_m, origin="lower", aspect="auto", cmap="RdBu_r", vmin=-vmax, vmax=vmax) plt.colorbar(im1, ax=axes[1], label="vorticity") axes[1].set_title("Vorticity (z-mid)") X, Y = np.meshgrid(np.arange(nx), np.arange(ny)) ux_s = np.ma.array(ux, mask=mask3) uy_s = np.ma.array(uy, mask=mask3) speed = np.ma.sqrt(ux_s * ux_s + uy_s * uy_s) axes[2].streamplot(X, Y, ux_s, uy_s, color=speed, cmap="viridis", density=2.0, linewidth=0.7) axes[2].set_xlim(0, nx) axes[2].set_ylim(0, ny) axes[2].set_title("Streamlines (z-mid)") fig.suptitle(tag) fig.tight_layout() fig.savefig(out_path, dpi=150) plt.close(fig) return out_path def compute_vis_omega(reynolds, diameter, u0): vis = u0 * diameter / reynolds omega = 1.0 / (3.0 * vis + 0.5) return vis, omega def set_macros(nx, ny, nz, dim, nq, vis, u0, collision_model, use_les, les_cs, outlet_mode, outlet_backflow_clamp, outlet_blend_alpha, omega_collision_max, inlet_profile, trt_magic_param): """Write kernel config headers (config/*.h) — kernel_v2.cu uses config.h, not macros.h.""" cfg_dir = os.path.join(compiler.kernel_path("config"), "") os.makedirs(cfg_dir, exist_ok=True) with open(compiler.kernel_path("config/config_grid.h"), "w") as f: f.write(f"""\ // AUTO-GENERATED by test_high_re_validation — DO NOT EDIT MANUALLY #ifndef CELERIS_CONFIG_GRID_H #define CELERIS_CONFIG_GRID_H #define NT 128 #define MULT_GPU False #define NX {nx} #define NY {ny} #define NZ {nz} #define DIM {dim} #define NQ {nq} #endif """) with open(compiler.kernel_path("config/config_physics.h"), "w") as f: f.write(f"""\ // AUTO-GENERATED by test_high_re_validation — DO NOT EDIT MANUALLY #ifndef CELERIS_CONFIG_PHYSICS_H #define CELERIS_CONFIG_PHYSICS_H #define LBtype float #define VIS {vis:.10f} #define RHO 1.0 #define U0 {u0} #define PI 3.141592653589793238 #define FLUID 0x01 #define SOLID 0x02 #define GAS 0x04 #define INTERFACE 0x08 #define SENSOR 0x10 #define OBSTACLE 0x20 #define V_TAYLOR 1 #endif """) with open(compiler.kernel_path("config/config_method.h"), "w") as f: f.write(f"""\ // AUTO-GENERATED by test_high_re_validation — DO NOT EDIT MANUALLY #ifndef CELERIS_CONFIG_METHOD_H #define CELERIS_CONFIG_METHOD_H #define COLLISION_MODEL {collision_model} #define STREAMING_MODEL 0 #define STORE_PRECISION 0 #define USE_DDF_SHIFTING 0 #define USE_LES {int(use_les)} #define LES_CS {les_cs:.6f}f #define INLET_PROFILE {int(inlet_profile)} #define OUTLET_MODE {int(outlet_mode)} #define OUTLET_BLEND_ALPHA {float(outlet_blend_alpha):.3f}f #define OUTLET_BACKFLOW_CLAMP {int(outlet_backflow_clamp)} #define OMEGA_COLLISION_MIN 0.01f #define OMEGA_COLLISION_MAX {float(omega_collision_max):.4f}f #define TRT_MAGIC_PARAM {float(trt_magic_param):.6f}f #endif """) with open(compiler.kernel_path("config/config_objects.h"), "w") as f: f.write("""\ // AUTO-GENERATED by test_high_re_validation — DO NOT EDIT MANUALLY #ifndef CELERIS_CONFIG_OBJECTS_H #define CELERIS_CONFIG_OBJECTS_H #define N_OBJS 0 #endif """) def build_flags_2d(nx, ny, cx, cy, radius): n = nx * ny flag = np.ones(n, dtype=np.uint8) * FLUID_FLAG for y in range(ny): for x in range(nx): k = y * nx + x if y == 0 or y == ny - 1 or x == 0 or x == nx - 1: flag[k] = SOLID_FLAG elif (x - cx) ** 2 + (y - cy) ** 2 < radius ** 2: flag[k] = OBSTACLE_FLAG return flag def build_flags_3d(nx, ny, nz, cx, cy, radius): n = nx * ny * nz flag = np.ones(n, dtype=np.uint8) * FLUID_FLAG for z in range(nz): for y in range(ny): for x in range(nx): k = z * ny * nx + y * nx + x if y == 0 or y == ny - 1 or x == 0 or x == nx - 1: flag[k] = SOLID_FLAG elif (x - cx) ** 2 + (y - cy) ** 2 < radius ** 2: flag[k] = OBSTACLE_FLAG return flag def run_case(device_id, cfg): nx, ny, nz = cfg["nx"], cfg["ny"], cfg["nz"] dim, nq = cfg["dim"], cfg["nq"] n = nx * ny * nz set_macros( nx=nx, ny=ny, nz=nz, dim=dim, nq=nq, vis=cfg["vis"], u0=cfg["u0"], collision_model=cfg["collision_model"], use_les=cfg["use_les"], les_cs=cfg["les_cs"], outlet_mode=cfg["outlet_mode"], outlet_backflow_clamp=cfg["outlet_backflow_clamp"], outlet_blend_alpha=cfg["outlet_blend_alpha"], omega_collision_max=cfg["omega_collision_max"], inlet_profile=cfg["inlet_profile"], trt_magic_param=cfg["trt_magic_param"], ) compiler.compile_kernel_v2() cuda.init() dev = cuda.Device(device_id) ctx = dev.make_context() try: mod = cuda.module_from_file(compiler.kernel_path("kernel_v2.ptx")) init_fn = mod.get_function("InitTubeFlow_v2") step_fn = mod.get_function("OneStep") params_ptr, params_size = mod.get_global("d_params") params_data = struct.pack( "IIIQfffffffI", nx, ny, nz, n, cfg["omega"], 1.1, 0.0, 0.0, 0.0, 1.0, cfg["u0"], 0, ) if len(params_data) < params_size: params_data += b"\x00" * (params_size - len(params_data)) cuda.memcpy_htod(params_ptr, params_data) fsize = n * nq * 4 d_fi = cuda.mem_alloc(fsize) d_fi2 = cuda.mem_alloc(fsize) d_flag = cuda.mem_alloc(n) d_indx = cuda.mem_alloc(n * 4) d_delta = cuda.mem_alloc(4) d_action = cuda.mem_alloc(4) d_obs = cuda.mem_alloc(4) cuda.memset_d32(d_indx, 0, n) cuda.memset_d32(d_delta, 0, 1) cuda.memset_d32(d_action, 0, 1) cuda.memset_d32(d_obs, 0, 1) block = (128, 1, 1) grid = ((nx + 127) // 128, ny, nz) init_fn(d_flag, d_fi, block=block, grid=grid) cuda.memcpy_htod(d_flag, cfg["flag"]) host0 = np.empty(n * nq, dtype=np.float32) cuda.memcpy_dtoh(host0, d_fi) host0 = impose_rest_state_on_nonfluid(cfg, host0) cuda.memcpy_htod(d_fi, host0) cuda.memcpy_htod(d_fi2, host0) diag0 = compute_case_diagnostics(cfg, host0) mass0 = diag0["mass"] inlet_var0 = diag0["inlet_var"] t0 = time.time() for step in range(cfg["steps"]): step_fn(d_flag, d_fi, d_fi2, d_indx, d_delta, d_action, d_obs, block=block, grid=grid) d_fi, d_fi2 = d_fi2, d_fi if (step + 1) % cfg["report_every"] == 0: cuda.Context.synchronize() host = np.empty(n * nq, dtype=np.float32) cuda.memcpy_dtoh(host, d_fi) if nq == 9: rho = host.reshape(nq, ny, nx).sum(axis=0) c = float(rho[ny // 2, nx // 2]) else: rho = host.reshape(nq, nz, ny, nx).sum(axis=0) c = float(rho[nz // 2, ny // 2, nx // 2]) nan_count = int(np.isnan(rho).sum()) print(f" step {step+1:7d}: rho_center={c:.6f}, nan={nan_count}") if nan_count > 0: break cuda.Context.synchronize() elapsed = time.time() - t0 host = np.empty(n * nq, dtype=np.float32) cuda.memcpy_dtoh(host, d_fi) if nq == 9: rho = host.reshape(nq, ny, nx).sum(axis=0) center = float(rho[ny // 2, nx // 2]) else: rho = host.reshape(nq, nz, ny, nx).sum(axis=0) center = float(rho[nz // 2, ny // 2, nx // 2]) ok, reason = validate_case(rho) diag_end = compute_case_diagnostics(cfg, host) if np.isfinite(mass0) and mass0 != 0.0 and np.isfinite(diag_end["mass"]): mass_drift = abs(diag_end["mass"] - mass0) / abs(mass0) else: mass_drift = float("nan") if np.isfinite(inlet_var0) and inlet_var0 > 0.0 and np.isfinite(diag_end["inlet_var"]): inlet_var_ratio_to_init = diag_end["inlet_var"] / inlet_var0 else: inlet_var_ratio_to_init = float("nan") plot_path = None if cfg.get("save_plot", True): plot_path = plot_case(cfg, host, cfg["out_dir"]) trt_les_map_path = None if cfg.get("save_trt_les_maps", True): trt_les_map_path = plot_trt_les_maps(cfg, host, cfg["out_dir"]) return { "case_tag": make_case_tag(cfg), "name": cfg["name"], "target_re": cfg["target_re"], "steps": cfg["steps"], "mlups": float(n * cfg["steps"] / elapsed / 1e6), "nan_count": int(np.isnan(rho).sum()), "rho_center": center, "rho_min": float(np.nanmin(rho)), "rho_max": float(np.nanmax(rho)), "mass0": float(mass0), "mass_end": float(diag_end["mass"]), "mass_drift": float(mass_drift), "inlet_var0": float(inlet_var0), "inlet_var_end": float(diag_end["inlet_var"]), "inlet_var_ratio_to_init": float(inlet_var_ratio_to_init), "inlet_line_rel_l2": float(diag_end.get("inlet_line_rel_l2", float("nan"))), "inlet_line_rel_linf": float(diag_end.get("inlet_line_rel_linf", float("nan"))), "inlet_wave_ux_rel": float(diag_end.get("inlet_wave_ux_rel", float("nan"))), "inlet_wave_rho_rel": float(diag_end.get("inlet_wave_rho_rel", float("nan"))), "wake_checker_rel": float(diag_end.get("wake_checker_rel", float("nan"))), "wake_checker_anti_corr_x": float(diag_end.get("wake_checker_anti_corr_x", float("nan"))), "omega": cfg["omega"], "vis": cfg["vis"], "collision_model": cfg["collision_model"], "use_les": bool(cfg["use_les"]), "les_cs": float(cfg["les_cs"]), "outlet_mode": int(cfg["outlet_mode"]), "outlet_backflow_clamp": int(cfg["outlet_backflow_clamp"]), "outlet_blend_alpha": float(cfg["outlet_blend_alpha"]), "inlet_profile": int(cfg["inlet_profile"]), "omega_collision_max": float(cfg["omega_collision_max"]), "trt_magic_param": float(cfg["trt_magic_param"]), "pass": bool(ok), "reason": reason, "plot_path": plot_path, "trt_les_map_path": trt_les_map_path, } finally: ctx.pop() def build_case_2d(re2d, steps2d, collision_model, use_les, les_cs, out_dir, outlet_mode, outlet_backflow_clamp, outlet_blend_alpha, omega_collision_max, inlet_profile, trt_magic_param): nx, ny, nz = 512, 256, 1 cx, cy, radius = 128.0, 128.0, 24.0 u0 = 0.03 vis, omega = compute_vis_omega(re2d, 2.0 * radius, u0) return { "name": "2D_D2Q9_highRe", "dim": 2, "nq": 9, "nx": nx, "ny": ny, "nz": nz, "cx": cx, "cy": cy, "radius": radius, "flag": build_flags_2d(nx, ny, cx, cy, radius), "u0": u0, "vis": vis, "omega": omega, "steps": steps2d, "report_every": max(steps2d // 10, 1), "collision_model": collision_model, "use_les": use_les, "les_cs": les_cs, "outlet_mode": int(outlet_mode), "outlet_backflow_clamp": int(outlet_backflow_clamp), "outlet_blend_alpha": float(outlet_blend_alpha), "inlet_profile": int(inlet_profile), "omega_collision_max": float(omega_collision_max), "trt_magic_param": float(trt_magic_param), "target_re": re2d, "save_plot": True, "save_trt_les_maps": True, "out_dir": out_dir, } def build_case_3d(re3d, steps3d, collision_model, use_les, les_cs, out_dir, outlet_mode, outlet_backflow_clamp, outlet_blend_alpha, omega_collision_max, inlet_profile, trt_magic_param): nx, ny, nz = 256, 128, 32 cx, cy, radius = 64.0, 64.0, 12.0 u0 = 0.04 vis, omega = compute_vis_omega(re3d, 2.0 * radius, u0) return { "name": "3D_D3Q19_highRe", "dim": 3, "nq": 19, "nx": nx, "ny": ny, "nz": nz, "cx": cx, "cy": cy, "radius": radius, "flag": build_flags_3d(nx, ny, nz, cx, cy, radius), "u0": u0, "vis": vis, "omega": omega, "steps": steps3d, "report_every": max(steps3d // 10, 1), "collision_model": collision_model, "use_les": use_les, "les_cs": les_cs, "outlet_mode": int(outlet_mode), "outlet_backflow_clamp": int(outlet_backflow_clamp), "outlet_blend_alpha": float(outlet_blend_alpha), "inlet_profile": int(inlet_profile), "omega_collision_max": float(omega_collision_max), "trt_magic_param": float(trt_magic_param), "target_re": re3d, "save_plot": True, "save_trt_les_maps": True, "out_dir": out_dir, } def build_comprehensive_cases(args, out_dir): cases = [] # Coverage matrix at moderate Re to verify all changed pathways. for cm in (0, 1, 2): for les in (0, 1): cases.append(build_case_2d(re2d=200.0, steps2d=args.matrix_steps2d, collision_model=cm, use_les=les, les_cs=args.les_cs, out_dir=out_dir, outlet_mode=args.outlet_mode, outlet_backflow_clamp=1, outlet_blend_alpha=args.outlet_blend_alpha, omega_collision_max=args.omega_collision_max, inlet_profile=args.inlet_profile, trt_magic_param=args.trt_magic_param)) cases.append(build_case_3d(re3d=200.0, steps3d=args.matrix_steps3d, collision_model=cm, use_les=les, les_cs=args.les_cs, out_dir=out_dir, outlet_mode=args.outlet_mode, outlet_backflow_clamp=1, outlet_blend_alpha=args.outlet_blend_alpha, omega_collision_max=args.omega_collision_max, inlet_profile=args.inlet_profile, trt_magic_param=args.trt_magic_param)) return cases def main(): parser = argparse.ArgumentParser(description="High-Re validation for kernel_v2") parser.add_argument("--device", type=int, default=0) parser.add_argument("--re2d", type=float, default=5000.0) parser.add_argument("--re3d", type=float, default=3000.0) parser.add_argument("--steps2d", type=int, default=10000) parser.add_argument("--steps3d", type=int, default=20000) parser.add_argument("--collision", type=int, default=1, choices=[0, 1, 2], help="0=SRT, 1=TRT, 2=MRT") parser.add_argument("--use-les", action="store_true", default=True, help="Enable Smagorinsky LES") parser.add_argument("--no-les", action="store_false", dest="use_les") parser.add_argument("--les-cs", type=float, default=0.16) parser.add_argument("--outlet-mode", type=int, default=0, choices=[0, 1, 2], help="0=non-equilibrium extrapolation, 1=zero-gradient copy, 2=damped blend") parser.add_argument("--inlet-profile", type=int, default=1, choices=[0, 1], help="0=uniform inlet, 1=parabolic inlet") parser.add_argument("--outlet-blend-alpha", type=float, default=0.70, help="Blend alpha for outlet-mode 2") parser.add_argument("--omega-collision-max", type=float, default=1.999, help="Upper clamp for collision omega") parser.add_argument("--trt-magic-param", type=float, default=0.002, help="TRT magic parameter Lambda used in omega- mapping") parser.add_argument("--only", choices=["2d", "3d", "both"], default="both") parser.add_argument("--comprehensive", action="store_true", help="Run coverage matrix: SRT/TRT/MRT x LES on/off for 2D and 3D") parser.add_argument("--matrix-steps2d", type=int, default=1000) parser.add_argument("--matrix-steps3d", type=int, default=600) args = parser.parse_args() # Backup config/*.h (kernel_v2.cu uses config.h, not macros.h) cfg_files = [ compiler.kernel_path("config/config_grid.h"), compiler.kernel_path("config/config_physics.h"), compiler.kernel_path("config/config_method.h"), compiler.kernel_path("config/config_objects.h"), ] cfg_backups = {} for p in cfg_files: try: with open(p) as f: cfg_backups[p] = f.read() except FileNotFoundError: cfg_backups[p] = None out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output") os.makedirs(out_dir, exist_ok=True) out_json = os.path.join(out_dir, "high_re_validation_summary.json") try: results = [] if args.only in ("2d", "both"): c2 = build_case_2d(args.re2d, args.steps2d, args.collision, args.use_les, args.les_cs, out_dir, args.outlet_mode, 1, args.outlet_blend_alpha, args.omega_collision_max, args.inlet_profile, args.trt_magic_param) print("\n=== Running 2D high-Re case ===") print(f" target Re={args.re2d:.1f}, vis={c2['vis']:.6e}, omega={c2['omega']:.6f}") results.append(run_case(args.device, c2)) if args.only in ("3d", "both"): c3 = build_case_3d(args.re3d, args.steps3d, args.collision, args.use_les, args.les_cs, out_dir, args.outlet_mode, 1, args.outlet_blend_alpha, args.omega_collision_max, args.inlet_profile, args.trt_magic_param) print("\n=== Running 3D high-Re case ===") print(f" target Re={args.re3d:.1f}, vis={c3['vis']:.6e}, omega={c3['omega']:.6f}") results.append(run_case(args.device, c3)) if args.comprehensive: print("\n=== Running comprehensive coverage matrix ===") for cfg in build_comprehensive_cases(args, out_dir): print(f" {cfg['name']} Re={cfg['target_re']:.1f} " f"{collision_name(cfg['collision_model'])} LES={int(cfg['use_les'])}") results.append(run_case(args.device, cfg)) with open(out_json, "w", encoding="utf-8") as f: json.dump(results, f, indent=2) print("\n=== Summary ===") n_pass = 0 for r in results: if r["pass"]: n_pass += 1 print(f"{r['name']}: nan={r['nan_count']}, rho_center={r['rho_center']:.6f}, " f"rho[min,max]=[{r['rho_min']:.6f}, {r['rho_max']:.6f}], " f"mass_drift={r['mass_drift']:.3e}, inlet_var_end={r['inlet_var_end']:.3e}, " f"inlet_relL2={r['inlet_line_rel_l2']:.3e}, inlet_relLinf={r['inlet_line_rel_linf']:.3e}, " f"waveUx={r['inlet_wave_ux_rel']:.3e}, waveRho={r['inlet_wave_rho_rel']:.3e}, " f"chkRel={r['wake_checker_rel']:.3e}, chkAntiX={r['wake_checker_anti_corr_x']:.3e}, " f"MLUPS={r['mlups']:.1f}, pass={r['pass']} ({r['reason']})") if r.get("plot_path"): print(f" plot: {r['plot_path']}") if r.get("trt_les_map_path"): print(f" trt_les_map: {r['trt_les_map_path']}") print(f"Pass rate: {n_pass}/{len(results)}") print(f"Saved: {out_json}") finally: for p, content in cfg_backups.items(): if content is not None: with open(p, "w") as f: f.write(content) if __name__ == "__main__": main()