#!/usr/bin/env python3 """ Stability Matrix Test ===================== Tests three collision models (SRT/TRT/MRT) at low and high Re (with/without LES), plus Esoteric-Pull streaming at low Re with SRT. Outputs: - Flow-field images (velocity, vorticity, streamlines) for each case - Diagnostic JSON with stability metrics - EsoPull vs double-buffer comparison plots Usage: python3 tests/test_stability_matrix.py [--device 0] [--steps 2000] """ import argparse import json import math 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 # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- FLUID = 0x01 SOLID = 0x02 OBSTACLE = 0x20 # fixed: was 0x04 COLLISION_NAMES = {0: "SRT", 1: "TRT", 2: "MRT"} # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def compute_vis_omega(re, diameter, u0): vis = u0 * diameter / re omega = 1.0 / (3.0 * vis + 0.5) return vis, omega def lattice_weights(nq): if nq == 9: return np.array([4/9] + [1/9]*4 + [1/36]*4, dtype=np.float32) if nq == 19: return np.array([1/3] + [1/18]*6 + [1/36]*12, dtype=np.float32) raise ValueError(f"nq={nq}") def build_flags_2d(nx, ny, cx, cy, radius): flag = np.ones(nx * ny, dtype=np.uint8) * FLUID 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 elif (x - cx)**2 + (y - cy)**2 < radius**2: flag[k] = OBSTACLE return flag def set_macros(nx, ny, dim, nq, vis, u0, collision_model, use_les, streaming_model, omega_collision_max=1.999, inlet_profile=1, trt_magic_param=0.1875, les_cs=0.16): """Write config/*.h files used by kernel_v2.cu.""" cfg_dir = os.path.join(os.path.dirname(compiler.kernel_path("config.h")), "config") # config_grid.h with open(os.path.join(cfg_dir, "config_grid.h"), "w") as f: f.write(f"""\ // AUTO-GENERATED by test_stability_matrix.py #ifndef CELERIS_CONFIG_GRID_H #define CELERIS_CONFIG_GRID_H #define NT 128 #define MULT_GPU 0 #define NX {nx} #define NY {ny} #define NZ 1 #define DIM {dim} #define NQ {nq} #endif """) # config_physics.h with open(os.path.join(cfg_dir, "config_physics.h"), "w") as f: f.write(f"""\ // AUTO-GENERATED by test_stability_matrix.py #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 """) # config_method.h with open(os.path.join(cfg_dir, "config_method.h"), "w") as f: f.write(f"""\ // AUTO-GENERATED by test_stability_matrix.py #ifndef CELERIS_CONFIG_METHOD_H #define CELERIS_CONFIG_METHOD_H #define COLLISION_MODEL {collision_model} #define STREAMING_MODEL {streaming_model} #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 0 #define OUTLET_BLEND_ALPHA 0.700f #define OUTLET_BACKFLOW_CLAMP 1 #define OMEGA_COLLISION_MIN 0.01f #define OMEGA_COLLISION_MAX {float(omega_collision_max):.3f}f #define TRT_MAGIC_PARAM {float(trt_magic_param):.6f}f #endif """) # config_objects.h with open(os.path.join(cfg_dir, "config_objects.h"), "w") as f: f.write("""\ // AUTO-GENERATED by test_stability_matrix.py #ifndef CELERIS_CONFIG_OBJECTS_H #define CELERIS_CONFIG_OBJECTS_H #define N_OBJS 0 #endif """) def pack_d_params(nx, ny, omega, u0): """Pack LBMParams struct for __constant__ memory upload.""" return struct.pack( "IIIQfffffffI", nx, ny, 1, # Nx, Ny, Nz nx * ny, # N omega, # omega 1.1, # omega_bulk 0.0, 0.0, 0.0, # fx, fy, fz 1.0, # rho_ref u0, # u_inlet 0, # n_objects ) def impose_rest_on_nonfluid(flag, host_ddf, nq, nx, ny): w = lattice_weights(nq) f = host_ddf.reshape(nq, ny, nx) nonfluid = flag.reshape(ny, nx) != FLUID for i in range(nq): f[i, nonfluid] = w[i] return host_ddf def compute_macros_2d(host_ddf, nq, nx, ny, flag): """Compute rho, ux, uy from DDF.""" cx9 = [0, 1, -1, 0, 0, 1, -1, 1, -1] cy9 = [0, 0, 0, 1, -1, 1, -1, -1, 1] f = host_ddf.reshape(nq, ny, nx) rho = np.sum(f, axis=0) ux = np.zeros_like(rho) uy = np.zeros_like(rho) for i in range(nq): ux += cx9[i] * f[i] uy += cy9[i] * f[i] rho_safe = np.where(np.abs(rho) > 1e-12, rho, 1.0) ux /= rho_safe uy /= rho_safe return rho, ux, uy def diagnose(rho, ux, uy, flag, nx, ny): """Compute stability diagnostics.""" fluid = flag.reshape(ny, nx) == FLUID nan_count = int(np.isnan(rho).sum()) rho_min = float(np.nanmin(rho)) rho_max = float(np.nanmax(rho)) mass = float(np.nansum(rho[fluid])) vel = np.sqrt(ux**2 + uy**2) # Ma check ma_max = float(np.nanmax(vel[fluid])) * math.sqrt(3.0) if np.any(fluid) else 0.0 # Vorticity RMS in wake region vort = np.gradient(uy, axis=1) - np.gradient(ux, axis=0) wake_mask = fluid & (np.arange(nx)[None, :] > nx // 3) vort_rms = float(np.sqrt(np.nanmean(vort[wake_mask]**2))) if np.any(wake_mask) else 0.0 stable = nan_count == 0 and rho_min > 0.0 and rho_max < 2.0 return { "nan_count": nan_count, "rho_min": rho_min, "rho_max": rho_max, "mass": mass, "ma_max": ma_max, "vort_rms": vort_rms, "stable": stable, } def plot_flow(rho, ux, uy, flag, nx, ny, title, out_path): """Plot velocity magnitude, vorticity, and streamlines.""" fluid_mask = flag.reshape(ny, nx) != FLUID vel = np.sqrt(ux**2 + uy**2) vel_m = np.ma.array(vel, mask=fluid_mask) vort = np.gradient(uy, axis=1) - np.gradient(ux, axis=0) vort_m = np.ma.array(vort, mask=fluid_mask) fig, axes = plt.subplots(1, 3, figsize=(18, 5)) # Velocity magnitude 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") # Vorticity vals = vort[~fluid_mask] if vals.size > 0: vmax = max(float(np.percentile(np.abs(vals), 99)), 1e-8) else: vmax = 1e-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") # Streamlines X, Y = np.meshgrid(np.arange(nx), np.arange(ny)) ux_s = np.ma.array(ux, mask=fluid_mask) uy_s = np.ma.array(uy, mask=fluid_mask) speed = np.ma.sqrt(ux_s**2 + uy_s**2) 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(title, fontsize=13) fig.tight_layout() fig.savefig(out_path, dpi=150) plt.close(fig) return out_path # --------------------------------------------------------------------------- # Case runner: double-buffer # --------------------------------------------------------------------------- def run_double_buffer(device_id, cfg, out_dir): """Run a case with standard double-buffer streaming.""" nx, ny = cfg["nx"], cfg["ny"] nq = cfg["nq"] n = nx * ny set_macros(nx, ny, cfg["dim"], nq, cfg["vis"], cfg["u0"], cfg["collision_model"], cfg["use_les"], streaming_model=0, omega_collision_max=cfg.get("omega_max", 1.999), trt_magic_param=cfg.get("trt_magic", 0.1875)) 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") # Upload d_params params_ptr, params_size = mod.get_global("d_params") params_data = pack_d_params(nx, ny, cfg["omega"], cfg["u0"]) 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, 1) 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_on_nonfluid(cfg["flag"], host0, nq, nx, ny) cuda.memcpy_htod(d_fi, host0) cuda.memcpy_htod(d_fi2, host0) steps = cfg["steps"] report = max(steps // 5, 1) t0 = time.time() diverged_step = None for s in range(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 (s + 1) % report == 0: cuda.Context.synchronize() h = np.empty(n * nq, dtype=np.float32) cuda.memcpy_dtoh(h, d_fi) rho_c = h.reshape(nq, ny, nx).sum(axis=0) nc = int(np.isnan(rho_c).sum()) center = float(rho_c[ny // 2, nx // 2]) print(f" step {s+1:6d}: rho_center={center:.6f} nan={nc}") if nc > 0: diverged_step = s + 1 break cuda.Context.synchronize() elapsed = time.time() - t0 host = np.empty(n * nq, dtype=np.float32) cuda.memcpy_dtoh(host, d_fi) rho, ux, uy = compute_macros_2d(host, nq, nx, ny, cfg["flag"]) diag = diagnose(rho, ux, uy, cfg["flag"], nx, ny) diag["elapsed"] = elapsed diag["mlups"] = n * steps / elapsed / 1e6 if elapsed > 0 else 0 diag["diverged_step"] = diverged_step tag = cfg["tag"] plot_path = plot_flow(rho, ux, uy, cfg["flag"], nx, ny, tag, os.path.join(out_dir, f"{tag}.png")) diag["plot"] = plot_path return diag finally: ctx.pop() # --------------------------------------------------------------------------- # Case runner: Esoteric-Pull (single buffer) # --------------------------------------------------------------------------- def run_esopull(device_id, cfg, out_dir): """Run a case with Esoteric-Pull single-buffer streaming.""" nx, ny = cfg["nx"], cfg["ny"] nq = cfg["nq"] n = nx * ny set_macros(nx, ny, cfg["dim"], nq, cfg["vis"], cfg["u0"], cfg["collision_model"], cfg["use_les"], streaming_model=1, omega_collision_max=cfg.get("omega_max", 1.999), trt_magic_param=cfg.get("trt_magic", 0.1875)) 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("InitEsoPull") step_fn = mod.get_function("EsoPullStep") # Upload d_params params_ptr, params_size = mod.get_global("d_params") params_data = pack_d_params(nx, ny, cfg["omega"], cfg["u0"]) 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_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, 1) init_fn(d_flag, d_fi, block=block, grid=grid) cuda.memcpy_htod(d_flag, cfg["flag"]) # Note: for EsoPull, we don't impose_rest_on_nonfluid on the raw # DDF because the data is stored in esoteric layout. InitEsoPull # already stores rest equilibrium for solid nodes. steps = cfg["steps"] report = max(steps // 5, 1) t0 = time.time() diverged_step = None for s in range(steps): t_val = np.uint64(s) # timestep counter for load/store parity step_fn(d_fi, d_flag, d_indx, d_delta, d_action, d_obs, t_val, block=block, grid=grid) if (s + 1) % report == 0: cuda.Context.synchronize() # For diagnostics, download raw DDF and decode from esopull layout h = np.empty(n * nq, dtype=np.float32) cuda.memcpy_dtoh(h, d_fi) # Esoteric layout: at this point the DDF is in post-store layout # for timestep s. To compute macros we need to "undo" the esoteric # read pattern. A simpler approach: compute rho = sum(fi) per node. # Because sum is invariant under slot permutation, rho is correct. # But ux/uy need correct direction assignment. # For diagnostic, use a simple sum-based stability check. f_arr = h.reshape(nq, ny, nx) rho_c = f_arr.sum(axis=0) nc = int(np.isnan(rho_c).sum()) center = float(rho_c[ny // 2, nx // 2]) print(f" step {s+1:6d}: rho_center={center:.6f} nan={nc}") if nc > 0: diverged_step = s + 1 break cuda.Context.synchronize() elapsed = time.time() - t0 # For final macros, do one more step that also writes to rho/u arrays. # But we don't have UpdateMacro for EsoPull yet. Instead, use the # approach: run a "read-only" macro computation from the esoteric layout. # For correctness, we load from the proper esoteric positions on host. h = np.empty(n * nq, dtype=np.float32) cuda.memcpy_dtoh(h, d_fi) rho, ux, uy = _decode_esopull_macros(h, nq, nx, ny, cfg["flag"], steps) diag = diagnose(rho, ux, uy, cfg["flag"], nx, ny) diag["elapsed"] = elapsed diag["mlups"] = n * steps / elapsed / 1e6 if elapsed > 0 else 0 diag["diverged_step"] = diverged_step tag = cfg["tag"] plot_path = plot_flow(rho, ux, uy, cfg["flag"], nx, ny, tag, os.path.join(out_dir, f"{tag}.png")) diag["plot"] = plot_path return diag finally: ctx.pop() def _decode_esopull_macros(host_ddf, nq, nx, ny, flag, last_t): """Decode macroscopic quantities from esoteric-pull layout on host. After step t (0-based), the store was done at parity t. The next load would use parity t+1. To read correct DDFs we mimic load_f_esopull at t_read = last_t (the parity of the *next* step to execute). """ fi = host_ddf.reshape(nq, ny * nx) # fi[direction, node] t_read = last_t # parity for the load that would happen next cx9 = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.float32) cy9 = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.float32) # Compute neighbor table once j_table = np.zeros((nq, ny * nx), dtype=np.int64) for y in range(ny): for x in range(nx): k = y * nx + x xp = (x + 1) % nx xm = (x - 1) % nx yp = (y + 1) % ny ym = (y - 1) % ny j_table[0, k] = k j_table[1, k] = yp * nx + xp if nq > 1 else k # placeholder j_table[2, k] = ym * nx + xm if nq > 2 else k # D2Q9 neighbors: j[i] = neighbor in direction c_i if nq == 9: j_table[1, k] = y * nx + xp # +x j_table[2, k] = y * nx + xm # -x j_table[3, k] = yp * nx + x # +y j_table[4, k] = ym * nx + x # -y j_table[5, k] = yp * nx + xp # +x+y j_table[6, k] = ym * nx + xm # -x-y j_table[7, k] = ym * nx + xp # +x-y j_table[8, k] = yp * nx + xm # -x+y n = nx * ny f_decoded = np.zeros((nq, n), dtype=np.float32) f_decoded[0] = fi[0] for i in range(1, nq, 2): if t_read & 1: # Odd: f[i] from fi[n, i], f[i+1] from fi[j[i], i+1] f_decoded[i] = fi[i] f_decoded[i + 1] = fi[i + 1, j_table[i]] else: # Even: f[i] from fi[n, i+1], f[i+1] from fi[j[i], i] f_decoded[i] = fi[i + 1] f_decoded[i + 1] = fi[i, j_table[i]] f_decoded = f_decoded.reshape(nq, ny, nx) rho = f_decoded.sum(axis=0) rho_safe = np.where(np.abs(rho) > 1e-12, rho, 1.0) ux = np.zeros_like(rho) uy = np.zeros_like(rho) for i in range(nq): ux += cx9[i] * f_decoded[i] uy += cy9[i] * f_decoded[i] ux /= rho_safe uy /= rho_safe return rho, ux, uy # --------------------------------------------------------------------------- # Case builders # --------------------------------------------------------------------------- def build_cases(steps_low, steps_high): """Build the full test matrix.""" # Grid params (moderate size for fast testing) nx, ny = 384, 192 cx_ob, cy_ob, radius = 96.0, 96.0, 18.0 u0 = 0.04 cases = [] for re_val, re_label, n_steps, use_les in [ (100.0, "Re100", steps_low, False), (100.0, "Re100", steps_low, True), (3000.0, "Re3000", steps_high, False), (3000.0, "Re3000", steps_high, True), ]: for cm in (0, 1, 2): vis, omega = compute_vis_omega(re_val, 2.0 * radius, u0) les_tag = "LES" if use_les else "noLES" cm_name = COLLISION_NAMES[cm] tag = f"DB_{re_label}_{cm_name}_{les_tag}" cases.append({ "tag": tag, "nx": nx, "ny": ny, "dim": 2, "nq": 9, "cx": cx_ob, "cy": cy_ob, "radius": radius, "flag": build_flags_2d(nx, ny, cx_ob, cy_ob, radius), "u0": u0, "vis": vis, "omega": omega, "collision_model": cm, "use_les": use_les, "steps": n_steps, "streaming": "double_buffer", "omega_max": 1.999, "trt_magic": 0.1875, }) # EsoPull case: low Re, SRT only re_eso = 100.0 vis_eso, omega_eso = compute_vis_omega(re_eso, 2.0 * radius, u0) cases.append({ "tag": "EsoPull_Re100_SRT_noLES", "nx": nx, "ny": ny, "dim": 2, "nq": 9, "cx": cx_ob, "cy": cy_ob, "radius": radius, "flag": build_flags_2d(nx, ny, cx_ob, cy_ob, radius), "u0": u0, "vis": vis_eso, "omega": omega_eso, "collision_model": 0, "use_les": False, "steps": steps_low, "streaming": "esopull", "omega_max": 1.999, "trt_magic": 0.1875, }) return cases # --------------------------------------------------------------------------- # Comparison plot: EsoPull vs DoubleBuffer # --------------------------------------------------------------------------- def plot_comparison(results, out_dir): """Compare EsoPull and DoubleBuffer at matching Re/collision settings.""" eso_key = "EsoPull_Re100_SRT_noLES" db_key = "DB_Re100_SRT_noLES" eso = results.get(eso_key) db = results.get(db_key) if eso is None or db is None: return None fig, axes = plt.subplots(2, 3, figsize=(18, 10)) fig.suptitle("EsoPull vs DoubleBuffer — Re100 SRT noLES", fontsize=14) labels = ["DoubleBuffer", "EsoPull"] for row, (r, label) in enumerate([(db, labels[0]), (eso, labels[1])]): vel_img = plt.imread(r["plot"]) if os.path.exists(r["plot"]) else None if vel_img is not None: axes[row, 0].imshow(vel_img) axes[row, 0].set_title(f"{label}: flow field") axes[row, 0].axis("off") else: axes[row, 0].text(0.5, 0.5, f"No image for {label}", ha="center", va="center", transform=axes[row, 0].transAxes) axes[row, 0].set_title(label) # Metrics bar chart metrics = { "rho_min": r.get("rho_min", 0), "rho_max": r.get("rho_max", 0), "ma_max": r.get("ma_max", 0), "vort_rms": r.get("vort_rms", 0), } bars = list(metrics.keys()) vals = [float(metrics[b]) for b in bars] axes[row, 1].barh(bars, vals, color=["steelblue", "salmon", "green", "purple"]) axes[row, 1].set_title(f"{label}: diagnostics") # Stability text text_lines = [ f"stable: {r.get('stable', '?')}", f"nan_count: {r.get('nan_count', '?')}", f"mass: {r.get('mass', 0):.2f}", f"MLUPS: {r.get('mlups', 0):.1f}", f"diverged_step: {r.get('diverged_step', 'None')}", ] axes[row, 2].text(0.1, 0.5, "\n".join(text_lines), fontsize=12, family="monospace", va="center", transform=axes[row, 2].transAxes) axes[row, 2].set_title(f"{label}: summary") axes[row, 2].axis("off") fig.tight_layout() cmp_path = os.path.join(out_dir, "esopull_vs_doublebuffer.png") fig.savefig(cmp_path, dpi=150) plt.close(fig) return cmp_path # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): parser = argparse.ArgumentParser(description="Stability matrix test") parser.add_argument("--device", type=int, default=0) parser.add_argument("--steps-low", type=int, default=3000, help="Steps for low-Re cases") parser.add_argument("--steps-high", type=int, default=6000, help="Steps for high-Re cases") parser.add_argument("--only-esopull", action="store_true", help="Only run the EsoPull test") args = parser.parse_args() # Backup config/*.h files (kernel_v2.cu uses config.h, NOT macros.h) cfg_dir = os.path.join(os.path.dirname(compiler.kernel_path("config.h")), "config") config_files = ["config_grid.h", "config_physics.h", "config_method.h", "config_objects.h"] config_backups = {} for cf in config_files: path = os.path.join(cfg_dir, cf) with open(path, "r") as f: config_backups[path] = f.read() out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output", "stability_matrix") os.makedirs(out_dir, exist_ok=True) cases = build_cases(args.steps_low, args.steps_high) if args.only_esopull: cases = [c for c in cases if c["streaming"] == "esopull"] results = {} try: for i, cfg in enumerate(cases): tag = cfg["tag"] streaming = cfg["streaming"] print(f"\n[{i+1}/{len(cases)}] {tag}") print(f" Re={cfg['u0']*2*cfg['radius']/cfg['vis']:.0f}, " f"omega={cfg['omega']:.4f}, " f"collision={COLLISION_NAMES[cfg['collision_model']]}, " f"LES={cfg['use_les']}, streaming={streaming}") if streaming == "esopull": diag = run_esopull(args.device, cfg, out_dir) else: diag = run_double_buffer(args.device, cfg, out_dir) diag["tag"] = tag diag["streaming"] = streaming diag["collision"] = COLLISION_NAMES[cfg["collision_model"]] diag["use_les"] = cfg["use_les"] diag["re"] = cfg["u0"] * 2 * cfg["radius"] / cfg["vis"] results[tag] = diag status = "PASS" if diag["stable"] else "FAIL" print(f" => {status}: rho=[{diag['rho_min']:.4f}, {diag['rho_max']:.4f}], " f"nan={diag['nan_count']}, ma_max={diag['ma_max']:.4f}, " f"MLUPS={diag['mlups']:.1f}") # Comparison plot cmp_path = plot_comparison(results, out_dir) if cmp_path: print(f"\nComparison plot: {cmp_path}") # Summary table print("\n" + "=" * 100) print(f"{'Tag':<35s} {'Stream':<8s} {'Col':<5s} {'LES':<5s} " f"{'Re':>6s} {'Stable':>7s} {'rho_min':>9s} {'rho_max':>9s} " f"{'Ma_max':>8s} {'MLUPS':>7s}") print("-" * 100) for tag, r in results.items(): print(f"{tag:<35s} {r['streaming']:<8s} {r['collision']:<5s} " f"{'Y' if r['use_les'] else 'N':<5s} " f"{r['re']:6.0f} {'PASS' if r['stable'] else 'FAIL':>7s} " f"{r['rho_min']:9.5f} {r['rho_max']:9.5f} " f"{r['ma_max']:8.5f} {r['mlups']:7.1f}") print("=" * 100) # Save JSON json_path = os.path.join(out_dir, "stability_matrix_results.json") json_results = {} for k, v in results.items(): jr = {} for rk, rv in v.items(): if isinstance(rv, (np.integer, np.floating)): jr[rk] = float(rv) elif isinstance(rv, np.bool_): jr[rk] = bool(rv) else: jr[rk] = rv json_results[k] = jr with open(json_path, "w") as f: json.dump(json_results, f, indent=2) print(f"\nResults saved: {json_path}") finally: for path, content in config_backups.items(): with open(path, "w") as f: f.write(content) if __name__ == "__main__": main()