# tests/run_sah04_case9_grid_blockage_compare.py """Sah04 matrix case 9: compare inlet-near flow vs grid refinement vs blockage. Runs three D2Q9 setups anchored to case 9 (high tier, Re=200, ``u_max``=0.1): 1. **baseline** — matrix geometry: ``D=30``, ``nx=80*D+2``, ``ny=35``, cylinder ``(40*D+0.5, 17)``, ``r=D/2``. 2. **grid_2x** — double lattice resolution with the same **blockage** ``D/H`` and the same ``Lx/D=80`` convention: ``D=60``, ``nx=80*D+2``, ``ny=68``, ``center_y`` doubled with the channel, ``r=D/2``, ``Re`` still based on ``D``. 3. **radius_half** — same channel height as baseline but **cylinder diameter halved** in lattice units: ``D=15``, ``nx=80*D+2``, ``ny=35``, same ``center_y``, ``r=D/2``, ``Re`` based on the smaller ``D`` (lower blockage). Outputs under ``tests/output/sah04_case9_compare/`` by default: - ``compare_meta.json`` — per-variant grid, ``nu``, estimated ``beta=D/H`` (no full arrays; see ``compare_fields.npz``). - ``compare_fields.npz`` — full ``ux`` / ``uy`` per variant. - ``ux_vs_y_inlet.png`` — ``u_x`` vs normalized wall-normal ``eta`` at several ``x`` indices (all variants overlaid per panel). - ``omega_z_.png`` — final-step vorticity ``omega_z = d u_y/dx - d u_x/dy`` for each variant. Forward schedule: ``burn`` warm-up steps (may be ``0``), then ``steps`` more steps; the reported field is the state **after** ``burn + steps`` LBM steps. Usage:: conda run -n pycuda_3_10 python tests/run_sah04_case9_grid_blockage_compare.py --smoke conda run -n pycuda_3_10 python tests/run_sah04_case9_grid_blockage_compare.py \\ --steps 20000 --burn 0 --collision MRT Design:: Isolates whether an inlet-channel artifact scales with **mesh** (grid_2x) or with **blockage** (radius_half) while keeping the Sah04-style confined channel layout. Requires **matplotlib** for PNG output. """ from __future__ import annotations import argparse import json import os import sys import tempfile from dataclasses import dataclass from typing import Any, Dict, List, Tuple import numpy as np import pycuda.driver as cuda _REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) _DEFAULT_LBM = os.path.join( _REPO, "src", "CelerisLab", "configs", "config_lbm.json", ) def _load_json(path: str) -> dict: with open(path, "r", encoding="utf-8") as f: return json.load(f) def _write_json(path: str, d: dict) -> None: with open(path, "w", encoding="utf-8") as f: json.dump(d, f, indent=2) @dataclass(frozen=True) class Variant: """One lattice-resolved Sah04-like case (case 9 Re / u_max convention).""" key: str nx: int ny: int center: Tuple[float, float] d_lattice: float r_cyl: float re: float note: str def _variants() -> Tuple[Variant, Variant, Variant]: d0 = 30 ny0 = 35 cy0 = 17.0 v0 = Variant( key="baseline", nx=80 * d0 + 2, ny=ny0, center=(40.0 * d0 + 0.5, cy0), d_lattice=float(d0), r_cyl=0.5 * d0, re=200.0, note="Matrix case 9 (high tier): D=30, ny=35.", ) d2 = 60 ny2 = 2 * ny0 - 2 v1 = Variant( key="grid_2x", nx=80 * d2 + 2, ny=ny2, center=(40.0 * d2 + 0.5, 2.0 * cy0), d_lattice=float(d2), r_cyl=0.5 * d2, re=200.0, note="Double D and ny-2; Lx/D=80 unchanged; blockage D/H ~ baseline.", ) dh = 15 v2 = Variant( key="radius_half", nx=80 * dh + 2, ny=ny0, center=(40.0 * dh + 0.5, cy0), d_lattice=float(dh), r_cyl=0.5 * dh, re=200.0, note="Half cylinder diameter in lattice units; same ny as baseline.", ) return v0, v1, v2 def _vorticity_z_from_velocity(ux: np.ndarray, uy: np.ndarray) -> np.ndarray: """``omega_z = d u_y/dx - d u_x/dy`` on a 2D ``(ny, nx)`` slice (unit lattice spacing).""" ux = np.asarray(ux, dtype=np.float64) uy = np.asarray(uy, dtype=np.float64) duy_dx = np.gradient(uy, axis=1) dux_dy = np.gradient(ux, axis=0) return duy_dx - dux_dy def _save_vorticity_png( path: str, ux: np.ndarray, uy: np.ndarray, *, title: str, ) -> None: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt omega = _vorticity_z_from_velocity(ux, uy) abs_o = np.abs(omega[np.isfinite(omega)]) if abs_o.size: vmax = float(np.percentile(abs_o, 99.5)) if vmax <= 0.0: vmax = float(np.max(abs_o)) or 1.0 else: vmax = 1.0 ny, nx = omega.shape fw = min(18.0, max(8.0, nx / 100.0)) fh = min(12.0, max(3.0, ny / 40.0)) fig, ax = plt.subplots(figsize=(fw, fh)) im = ax.imshow( omega, origin="lower", aspect="equal", cmap="RdBu_r", vmin=-vmax, vmax=vmax, extent=(0, nx - 1, 0, ny - 1), ) ax.set_xlabel("x (lattice)") ax.set_ylabel("y (lattice)") ax.set_title(title) fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04, label="omega_z") fig.tight_layout() os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True) fig.savefig(path, dpi=150, bbox_inches="tight") plt.close(fig) def _run_variant( v: Variant, *, collision: str, outlet: str, u_max: float, burn: int, steps: int, ) -> Dict[str, Any]: u0_mean = u_max / 1.5 nu = u_max * float(v.d_lattice) / float(v.re) if not os.path.isfile(_DEFAULT_LBM): raise FileNotFoundError(_DEFAULT_LBM) cfg = _load_json(_DEFAULT_LBM) cfg["grid"]["nx"] = int(v.nx) cfg["grid"]["ny"] = int(v.ny) cfg["grid"]["nz"] = 1 cfg["physics"]["viscosity"] = float(nu) cfg["physics"]["velocity"] = float(u0_mean) cfg["physics"]["rho"] = 1.0 cfg["method"]["collision"] = collision cfg["method"]["streaming"] = "double_buffer" cfg["method"]["les"]["enabled"] = False cfg["method"]["outlet"]["mode"] = outlet body_doc = { "objects": [ { "type": "cylinder", "center": list(v.center), "radius": float(v.r_cyl), } ] } tmpd = tempfile.mkdtemp(prefix="celeris_sah09cmp_") lbm_tmp = os.path.join(tmpd, "config_lbm.json") body_tmp = os.path.join(tmpd, "config_body.json") _write_json(lbm_tmp, cfg) _write_json(body_tmp, body_doc) from CelerisLab import Simulation # noqa: WPS433 sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp) sim.initialize() stream = cuda.Stream() total = int(burn) + int(steps) if total < 1: sim.close() raise ValueError("burn + steps must be >= 1") for _step in range(1, total + 1): sim.bodies.zero_force_segment_async(stream) sim.stepper.step( 1, action_gpu=sim.bodies.action_gpu, obs_gpu=sim.bodies.obs_gpu, stream=stream, ) stream.synchronize() macro = sim.get_macroscopic() ux = np.asarray(macro["ux"], dtype=np.float64).reshape(v.ny, v.nx) uy = np.asarray(macro["uy"], dtype=np.float64).reshape(v.ny, v.nx) rho = np.asarray(macro["rho"], dtype=np.float64).reshape(v.ny, v.nx) sim.close() h_fluid = max(int(v.ny) - 2, 1) beta_est = float(v.d_lattice) / float(h_fluid) return { "key": v.key, "nx": int(v.nx), "ny": int(v.ny), "center": [float(v.center[0]), float(v.center[1])], "d_lattice": float(v.d_lattice), "r_cyl": float(v.r_cyl), "re": float(v.re), "nu": float(nu), "u_max": float(u_max), "u0_mean": float(u0_mean), "beta_est": beta_est, "note": v.note, "burn": int(burn), "steps": int(steps), "total_lbm_steps": int(total), "ux_shape": list(ux.shape), "rho_min": float(np.min(rho)), "rho_max": float(np.max(rho)), "ux": ux.tolist(), "uy": uy.tolist(), } def _eta_coords(ny: int) -> np.ndarray: """Wall-normal fractional coordinate in (0,1) for fluid rows y=1..ny-2.""" y = np.arange(ny, dtype=np.float64) h = max(float(ny - 2), 1.0) out = np.zeros_like(y) for yi in range(1, max(ny - 1, 1)): out[yi] = (float(yi) - 0.5) / h return out def _plot_ux_vs_y_inlet( path: str, results: List[Dict[str, Any]], x_indices: List[int], ) -> None: import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt n_x = len(x_indices) fig, axes = plt.subplots(1, n_x, figsize=(4.2 * n_x, 5.0), sharey=True) if n_x == 1: axes = [axes] colors = {"baseline": "C0", "grid_2x": "C1", "radius_half": "C2"} for ax, xi in zip(axes, x_indices): for r in results: nx = int(r["nx"]) if xi < 1 or xi >= nx - 1: continue ux = np.asarray(r["ux"], dtype=np.float64).reshape(r["ny"], r["nx"]) eta = _eta_coords(int(r["ny"])) y = np.arange(int(r["ny"])) ax.plot(ux[:, xi], eta, label=r["key"], color=colors.get(r["key"], "k")) ax.set_title(f"x = {xi}") ax.set_xlabel("u_x") ax.grid(True, alpha=0.3) axes[0].set_ylabel("eta = (y-0.5)/H (fluid band)") fig.suptitle("u_x vs wall-normal coordinate near inlet (several x)") axes[0].set_ylim(0.0, 1.0) axes[0].legend(loc="best", fontsize=8) fig.tight_layout() os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True) fig.savefig(path, dpi=150, bbox_inches="tight") plt.close(fig) def main() -> int: ap = argparse.ArgumentParser( description="Case 9 baseline vs finer grid vs half-radius: inlet-near u_x", ) ap.add_argument("--collision", default="MRT", help="SRT, TRT, or MRT") ap.add_argument("--outlet", default="neq_extrap") ap.add_argument("--u-max", type=float, default=0.1, dest="u_max") ap.add_argument( "--burn", type=int, default=0, help="Warm-up LBM steps before the --steps production segment (0 is valid).", ) ap.add_argument( "--steps", type=int, default=20_000, help="LBM steps after burn-in; final field is after burn+steps.", ) ap.add_argument( "--smoke", action="store_true", help="Short run: burn=0, steps=2500 (overrides --burn and --steps)", ) ap.add_argument( "--out-dir", default=os.path.join(_REPO, "tests", "output", "sah04_case9_compare"), ) ap.add_argument( "--inlet-x", type=str, default="1,2,3,5,8", help="Comma-separated x indices for u_x(y) panels (must exist for all nx)", ) args = ap.parse_args() if args.smoke: burn = 0 steps = 2500 else: burn = max(0, int(args.burn)) steps = max(1, int(args.steps)) out_dir = os.path.abspath(args.out_dir) os.makedirs(out_dir, exist_ok=True) v0, v1, v2 = _variants() variants = (v0, v1, v2) x_list = [int(s.strip()) for s in args.inlet_x.split(",") if s.strip()] min_nx = min(v.nx for v in variants) x_list = [x for x in x_list if 1 <= x < min_nx - 1] if not x_list: print("No valid --inlet-x indices common to all variants.", file=sys.stderr) return 2 results: List[Dict[str, Any]] = [] for v in variants: print(f"--- {v.key}: nx={v.nx} ny={v.ny} D={v.d_lattice} r={v.r_cyl} ---", flush=True) row = _run_variant( v, collision=str(args.collision).upper(), outlet=str(args.outlet), u_max=float(args.u_max), burn=burn, steps=steps, ) results.append(row) meta_path = os.path.join(out_dir, "compare_meta.json") slim = [] for r in results: slim.append({k: v for k, v in r.items() if k not in ("ux", "uy")}) _write_json( meta_path, { "variants_summary": slim, "inlet_x_used": x_list, "burn": burn, "steps": steps, "total_lbm_steps": burn + steps, "collision": str(args.collision).upper(), }, ) try: _plot_ux_vs_y_inlet( os.path.join(out_dir, "ux_vs_y_inlet.png"), results, x_list, ) for r in results: key = str(r["key"]) ux = np.asarray(r["ux"], dtype=np.float64).reshape(r["ny"], r["nx"]) uy = np.asarray(r["uy"], dtype=np.float64).reshape(r["ny"], r["nx"]) _save_vorticity_png( os.path.join(out_dir, f"omega_z_{key}.png"), ux, uy, title=f"omega_z final ({key}) nx={r['nx']} ny={r['ny']} " f"D={r['d_lattice']} burn={r['burn']} steps={r['steps']}", ) except ImportError: print( "matplotlib not installed; skipped PNG. Install matplotlib for figures.", file=sys.stderr, ) full_npz = os.path.join(out_dir, "compare_fields.npz") np.savez_compressed( full_npz, keys=np.array([r["key"] for r in results]), **{f"ux_{r['key']}": np.asarray(r["ux"], dtype=np.float64) for r in results}, **{f"uy_{r['key']}": np.asarray(r["uy"], dtype=np.float64) for r in results}, **{ f"omega_z_{r['key']}": _vorticity_z_from_velocity( np.asarray(r["ux"], dtype=np.float64).reshape(r["ny"], r["nx"]), np.asarray(r["uy"], dtype=np.float64).reshape(r["ny"], r["nx"]), ) for r in results }, ) print(f"Wrote: {meta_path}", flush=True) print(f"Wrote: {full_npz}", flush=True) print(f"Wrote: {os.path.join(out_dir, 'ux_vs_y_inlet.png')}", flush=True) for r in results: oz_path = os.path.join(out_dir, f"omega_z_{r['key']}.png") print(f"Wrote: {oz_path}", flush=True) return 0 if __name__ == "__main__": raise SystemExit(main())