453 lines
14 KiB
Python
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())
|