CelerisLab/tests/run_sah04_case9_grid_blockage_compare.py

453 lines
14 KiB
Python

# 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_<variant>.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())