重构body api,性能分析,项目整理
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
# CelerisLab/tests/validation/run_kan99b_rotating_cylinder.py
|
||||
"""Kan99b MRT-only rotating-cylinder validation runner.
|
||||
|
||||
This script follows ``docs/validation_specs/Kan99b_validation.md`` for the current round:
|
||||
|
||||
- Primary matrix: K1-K5 with collision fixed to MRT.
|
||||
- Primary inlet: regularized (uniform profile).
|
||||
- Extra control: K2 with ``zou_he_local`` inlet for sensitivity only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, 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")
|
||||
|
||||
U_INF = 0.03
|
||||
D_LATTICE = 30.0
|
||||
R_LATTICE = 15.0
|
||||
|
||||
|
||||
KAN99B_ANCHOR = {
|
||||
"St": 0.1655,
|
||||
"mean_cl": -2.4881,
|
||||
"mean_cd": 1.1040,
|
||||
"amp_cl": 0.3631,
|
||||
"amp_cd": 0.0993,
|
||||
}
|
||||
|
||||
ANCHOR_BANDS = {
|
||||
"St": 0.03,
|
||||
"mean_cl": 0.04,
|
||||
"mean_cd": 0.05,
|
||||
"amp_cl": 0.08,
|
||||
"amp_cd": 0.10,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DomainSpec:
|
||||
key: str
|
||||
nx: int
|
||||
ny: int
|
||||
center: Tuple[float, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KanCase:
|
||||
case_id: str
|
||||
re: float
|
||||
alpha: float
|
||||
steps: int
|
||||
burn: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunSpec:
|
||||
case_id: str
|
||||
variant: str
|
||||
domain: str
|
||||
re: float
|
||||
alpha: float
|
||||
inlet_scheme: str
|
||||
steps: int
|
||||
burn: int
|
||||
|
||||
|
||||
CASES: Tuple[KanCase, ...] = (
|
||||
KanCase("K1", 100.0, 0.5, 200_000, 80_000),
|
||||
KanCase("K2", 100.0, 1.0, 200_000, 80_000),
|
||||
KanCase("K3", 60.0, 1.6, 240_000, 120_000),
|
||||
KanCase("K4", 100.0, 2.0, 240_000, 120_000),
|
||||
KanCase("K5", 160.0, 2.0, 240_000, 120_000),
|
||||
)
|
||||
|
||||
CASE_K0 = KanCase("K0", 100.0, 0.0, 180_000, 72_000)
|
||||
|
||||
|
||||
def _domain_specs() -> Dict[str, DomainSpec]:
|
||||
return {
|
||||
"S": DomainSpec("S", 1081, 481, (360.0, 240.0)),
|
||||
"M": DomainSpec("M", 1351, 601, (450.0, 300.0)),
|
||||
"L": DomainSpec("L", 1801, 721, (600.0, 360.0)),
|
||||
}
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: str, payload: dict) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
|
||||
def _nu_from_re(re: float) -> float:
|
||||
return U_INF * D_LATTICE / float(re)
|
||||
|
||||
|
||||
def _omega_body(alpha: float) -> float:
|
||||
return 2.0 * float(alpha) * U_INF / D_LATTICE
|
||||
|
||||
|
||||
def _run_id(spec: RunSpec) -> str:
|
||||
a = f"{spec.alpha:.3f}".replace(".", "p")
|
||||
return (
|
||||
f"{spec.case_id.lower()}_{spec.variant}_dom{spec.domain}_re{int(spec.re)}_a{a}_"
|
||||
f"{spec.inlet_scheme.lower()}_mrt"
|
||||
)
|
||||
|
||||
|
||||
def _build_cfg(
|
||||
base_cfg: dict,
|
||||
*,
|
||||
nx: int,
|
||||
ny: int,
|
||||
re: float,
|
||||
inlet_scheme: str,
|
||||
) -> dict:
|
||||
cfg = json.loads(json.dumps(base_cfg))
|
||||
cfg["grid"]["nx"] = int(nx)
|
||||
cfg["grid"]["ny"] = int(ny)
|
||||
cfg["grid"]["nz"] = 1
|
||||
cfg["physics"]["velocity"] = float(U_INF)
|
||||
cfg["physics"]["viscosity"] = float(_nu_from_re(re))
|
||||
cfg["physics"]["rho"] = 1.0
|
||||
cfg["method"]["collision"] = "MRT"
|
||||
cfg["method"]["streaming"] = "double_buffer"
|
||||
cfg["method"]["store_precision"] = "FP32"
|
||||
cfg["method"]["ddf_shifting"] = False
|
||||
cfg["method"]["les"]["enabled"] = False
|
||||
cfg["method"]["inlet"]["profile"] = "uniform"
|
||||
cfg["method"]["inlet"]["scheme"] = str(inlet_scheme)
|
||||
cfg["method"]["outlet"]["mode"] = "neq_extrap"
|
||||
cfg["method"]["y_wall_bc"] = "free_slip"
|
||||
return cfg
|
||||
|
||||
|
||||
def _body_doc(center: Tuple[float, float], alpha: float) -> dict:
|
||||
return {
|
||||
"objects": [
|
||||
{
|
||||
"type": "cylinder",
|
||||
"center": [float(center[0]), float(center[1])],
|
||||
"radius": float(R_LATTICE),
|
||||
"omega": float(_omega_body(alpha)),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _rfft_spectrum(x: np.ndarray, sample_dt: float) -> Tuple[np.ndarray, np.ndarray]:
|
||||
arr = np.asarray(x, dtype=np.float64)
|
||||
if arr.size < 64:
|
||||
return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
|
||||
arr = arr - np.mean(arr)
|
||||
spec = np.abs(np.fft.rfft(arr * np.hanning(arr.size))) ** 2
|
||||
freqs = np.fft.rfftfreq(arr.size, d=float(sample_dt))
|
||||
return freqs.astype(np.float64), spec.astype(np.float64)
|
||||
|
||||
|
||||
def _peak_freq_parabolic(freqs: np.ndarray, spec: np.ndarray, idx: int) -> float:
|
||||
i = int(np.clip(idx, 0, spec.size - 1))
|
||||
if i <= 0 or i + 1 >= spec.size:
|
||||
return float(freqs[i])
|
||||
y0 = np.log(spec[i - 1] + 1e-30)
|
||||
y1 = np.log(spec[i] + 1e-30)
|
||||
y2 = np.log(spec[i + 1] + 1e-30)
|
||||
den = y0 - 2.0 * y1 + y2
|
||||
if abs(den) < 1e-20:
|
||||
return float(freqs[i])
|
||||
delta = float(np.clip(0.5 * (y0 - y2) / den, -1.0, 1.0))
|
||||
return float(freqs[i]) + delta * float(freqs[i + 1] - freqs[i])
|
||||
|
||||
|
||||
def _st_from_lift(lift: np.ndarray, sample_dt: float) -> float:
|
||||
freqs, spec = _rfft_spectrum(lift, sample_dt=sample_dt)
|
||||
if freqs.size <= 1:
|
||||
return float("nan")
|
||||
idx = int(np.argmax(spec[1:])) + 1
|
||||
f_peak = _peak_freq_parabolic(freqs, spec, idx)
|
||||
return float(f_peak * D_LATTICE / U_INF)
|
||||
|
||||
|
||||
def _cycle_half_p2p(y: np.ndarray) -> float:
|
||||
arr = np.asarray(y, dtype=np.float64)
|
||||
if arr.size < 8:
|
||||
return float("nan")
|
||||
centered = arr - np.mean(arr)
|
||||
crossing = np.where((centered[:-1] <= 0.0) & (centered[1:] > 0.0))[0]
|
||||
if crossing.size >= 2:
|
||||
amps: List[float] = []
|
||||
for i in range(crossing.size - 1):
|
||||
seg = arr[crossing[i] + 1 : crossing[i + 1] + 1]
|
||||
if seg.size >= 3:
|
||||
amps.append(0.5 * (float(np.max(seg)) - float(np.min(seg))))
|
||||
if amps:
|
||||
return float(np.mean(amps))
|
||||
return 0.5 * (float(np.max(arr)) - float(np.min(arr)))
|
||||
|
||||
|
||||
def _vorticity_z(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
ux = np.asarray(ux, dtype=np.float64)
|
||||
uy = np.asarray(uy, dtype=np.float64)
|
||||
return np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
|
||||
|
||||
|
||||
def _save_vorticity_png(path: str, ux: np.ndarray, uy: np.ndarray, title: str) -> None:
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError:
|
||||
return
|
||||
omega = _vorticity_z(ux, uy)
|
||||
abs_o = np.abs(omega[np.isfinite(omega)])
|
||||
vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size else 1.0
|
||||
if vmax <= 0.0:
|
||||
vmax = 1.0
|
||||
ny, nx = omega.shape
|
||||
fig, ax = plt.subplots(figsize=(min(18.0, max(8.0, nx / 100.0)), min(12.0, max(3.0, ny / 40.0))))
|
||||
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()
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _run_one(
|
||||
spec: RunSpec,
|
||||
*,
|
||||
domain: DomainSpec,
|
||||
base_cfg: dict,
|
||||
out_dir: str,
|
||||
record_every: int,
|
||||
field_every: int,
|
||||
save_vorticity: bool,
|
||||
) -> Dict[str, Any]:
|
||||
cfg = _build_cfg(
|
||||
base_cfg,
|
||||
nx=domain.nx,
|
||||
ny=domain.ny,
|
||||
re=spec.re,
|
||||
inlet_scheme=spec.inlet_scheme,
|
||||
)
|
||||
body = _body_doc(domain.center, alpha=spec.alpha)
|
||||
|
||||
tmpd = tempfile.mkdtemp(prefix="celeris_kan99b_mrt_")
|
||||
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)
|
||||
|
||||
from CelerisLab import Simulation # noqa: WPS433
|
||||
|
||||
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
|
||||
if sim.bodies.count < 1:
|
||||
sim.close()
|
||||
raise RuntimeError("Expected one cylinder in body config.")
|
||||
sim.bodies.get(0).state.omega = np.float32(_omega_body(spec.alpha))
|
||||
sim.initialize()
|
||||
|
||||
stream = cuda.Stream()
|
||||
rec = max(1, int(record_every))
|
||||
total = int(spec.burn) + int(spec.steps)
|
||||
if total < 1:
|
||||
sim.close()
|
||||
raise ValueError("burn + steps must be >= 1")
|
||||
|
||||
step_hist: List[int] = []
|
||||
fx_hist: List[float] = []
|
||||
fy_hist: List[float] = []
|
||||
field_snapshots: List[str] = []
|
||||
run_id = _run_id(spec)
|
||||
snap_dir = os.path.join(out_dir, "fields", run_id)
|
||||
if field_every > 0:
|
||||
os.makedirs(snap_dir, exist_ok=True)
|
||||
|
||||
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,
|
||||
)
|
||||
if step % rec == 0 or step == total:
|
||||
stream.synchronize()
|
||||
sim.bodies.download_obs_full_async(stream)
|
||||
stream.synchronize()
|
||||
force = sim.bodies.read_force(0)
|
||||
fx = float(force[0])
|
||||
fy = float(force[1])
|
||||
if not np.isfinite(fx) or not np.isfinite(fy):
|
||||
sim.close()
|
||||
raise RuntimeError(f"NaN/Inf force at step {step}")
|
||||
step_hist.append(step)
|
||||
fx_hist.append(fx)
|
||||
fy_hist.append(fy)
|
||||
if field_every > 0 and (step % int(field_every) == 0 or step == total):
|
||||
stream.synchronize()
|
||||
macro = sim.get_macroscopic()
|
||||
snap_path = os.path.join(snap_dir, f"macro_step{step:08d}.npz")
|
||||
np.savez_compressed(
|
||||
snap_path,
|
||||
rho=np.asarray(macro["rho"], dtype=np.float32),
|
||||
ux=np.asarray(macro["ux"], dtype=np.float32),
|
||||
uy=np.asarray(macro["uy"], dtype=np.float32),
|
||||
)
|
||||
field_snapshots.append(snap_path)
|
||||
|
||||
stream.synchronize()
|
||||
macro_last = sim.get_macroscopic()
|
||||
ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(domain.ny, domain.nx)
|
||||
uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(domain.ny, domain.nx)
|
||||
rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(domain.ny, domain.nx)
|
||||
sim.close()
|
||||
|
||||
step_arr = np.asarray(step_hist, dtype=np.int64)
|
||||
fx_arr = np.asarray(fx_hist, dtype=np.float64)
|
||||
fy_arr = np.asarray(fy_hist, dtype=np.float64)
|
||||
burn_mask = step_arr >= int(spec.burn)
|
||||
if not np.any(burn_mask):
|
||||
burn_mask = np.ones_like(step_arr, dtype=bool)
|
||||
|
||||
cl = 2.0 * fy_arr / (U_INF**2 * D_LATTICE)
|
||||
cd = 2.0 * fx_arr / (U_INF**2 * D_LATTICE)
|
||||
cl_tail = cl[burn_mask]
|
||||
cd_tail = cd[burn_mask]
|
||||
st = _st_from_lift(cl_tail, sample_dt=float(rec))
|
||||
amp_cl = _cycle_half_p2p(cl_tail)
|
||||
amp_cd = _cycle_half_p2p(cd_tail)
|
||||
|
||||
csv_dir = os.path.join(out_dir, "force_csv")
|
||||
os.makedirs(csv_dir, exist_ok=True)
|
||||
csv_path = os.path.join(csv_dir, f"{run_id}.csv")
|
||||
with open(csv_path, "w", newline="", encoding="utf-8") as f:
|
||||
w = csv.writer(f)
|
||||
w.writerow(["step", "fx", "fy", "cd", "cl"])
|
||||
for i, s in enumerate(step_arr.tolist()):
|
||||
w.writerow([s, fx_arr[i], fy_arr[i], cd[i], cl[i]])
|
||||
|
||||
if save_vorticity:
|
||||
vdir = os.path.join(out_dir, "vorticity")
|
||||
os.makedirs(vdir, exist_ok=True)
|
||||
_save_vorticity_png(
|
||||
os.path.join(vdir, f"{run_id}.png"),
|
||||
ux_last,
|
||||
uy_last,
|
||||
title=(
|
||||
f"Kan99b {spec.case_id} {spec.variant} MRT dom={spec.domain} "
|
||||
f"Re={spec.re:.0f} alpha={spec.alpha:.3f} inlet={spec.inlet_scheme}"
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"case_id": spec.case_id,
|
||||
"variant": spec.variant,
|
||||
"collision": "MRT",
|
||||
"inlet_scheme": spec.inlet_scheme,
|
||||
"inlet_profile": "uniform",
|
||||
"domain": spec.domain,
|
||||
"re": float(spec.re),
|
||||
"alpha": float(spec.alpha),
|
||||
"omega_body": float(_omega_body(spec.alpha)),
|
||||
"nu": float(_nu_from_re(spec.re)),
|
||||
"steps": int(spec.steps),
|
||||
"burn_in": int(spec.burn),
|
||||
"total_steps": int(total),
|
||||
"record_every": int(rec),
|
||||
"n_samples": int(step_arr.size),
|
||||
"St": float(st),
|
||||
"st": float(st),
|
||||
"mean_cl": float(np.mean(cl_tail)),
|
||||
"mean_cd": float(np.mean(cd_tail)),
|
||||
"amp_cl": float(amp_cl),
|
||||
"amp_cd": float(amp_cd),
|
||||
"rho_min_final": float(np.min(rho_last)),
|
||||
"rho_max_final": float(np.max(rho_last)),
|
||||
"grid": {"nx": int(domain.nx), "ny": int(domain.ny), "diameter": int(D_LATTICE)},
|
||||
"beta_real": None,
|
||||
"Re_real": None,
|
||||
"re_real": None,
|
||||
"force_csv": csv_path,
|
||||
"field_snapshots": field_snapshots,
|
||||
}
|
||||
|
||||
|
||||
def _rel_err(measured: float, ref: float) -> Optional[float]:
|
||||
if not np.isfinite(measured) or ref == 0.0:
|
||||
return None
|
||||
return abs(float(measured) - float(ref)) / abs(float(ref))
|
||||
|
||||
|
||||
def _k2_anchor_gate(rows: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Evaluate K2 rows against Kan99b anchor tolerances."""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
if row.get("case_id") != "K2" or "error" in row:
|
||||
continue
|
||||
rel = {
|
||||
"St": _rel_err(row["St"], KAN99B_ANCHOR["St"]),
|
||||
"mean_cl": _rel_err(row["mean_cl"], KAN99B_ANCHOR["mean_cl"]),
|
||||
"mean_cd": _rel_err(row["mean_cd"], KAN99B_ANCHOR["mean_cd"]),
|
||||
"amp_cl": _rel_err(row["amp_cl"], KAN99B_ANCHOR["amp_cl"]),
|
||||
"amp_cd": _rel_err(row["amp_cd"], KAN99B_ANCHOR["amp_cd"]),
|
||||
}
|
||||
pass_bands = {
|
||||
key: (rel[key] is not None and rel[key] <= ANCHOR_BANDS[key]) for key in ANCHOR_BANDS
|
||||
}
|
||||
out.append(
|
||||
{
|
||||
"run_id": row["run_id"],
|
||||
"variant": row["variant"],
|
||||
"inlet_scheme": row["inlet_scheme"],
|
||||
"rel_err": rel,
|
||||
"pass_bands": pass_bands,
|
||||
"pass_all": bool(all(pass_bands.values())),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _build_runs(
|
||||
cases: Sequence[KanCase],
|
||||
*,
|
||||
domain: str,
|
||||
include_k2_control: bool,
|
||||
steps_override: int,
|
||||
burn_override: int,
|
||||
) -> List[RunSpec]:
|
||||
runs: List[RunSpec] = []
|
||||
for case in cases:
|
||||
steps = int(steps_override) if steps_override > 0 else int(case.steps)
|
||||
burn = int(burn_override) if burn_override > 0 else int(case.burn)
|
||||
runs.append(
|
||||
RunSpec(
|
||||
case_id=case.case_id,
|
||||
variant="baseline",
|
||||
domain=domain,
|
||||
re=case.re,
|
||||
alpha=case.alpha,
|
||||
inlet_scheme="regularized",
|
||||
steps=steps,
|
||||
burn=burn,
|
||||
)
|
||||
)
|
||||
if include_k2_control and case.case_id == "K2":
|
||||
runs.append(
|
||||
RunSpec(
|
||||
case_id=case.case_id,
|
||||
variant="k2_inlet_control",
|
||||
domain=domain,
|
||||
re=case.re,
|
||||
alpha=case.alpha,
|
||||
inlet_scheme="zou_he_local",
|
||||
steps=steps,
|
||||
burn=burn,
|
||||
)
|
||||
)
|
||||
return runs
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Kan99b MRT-only primary matrix runner")
|
||||
ap.add_argument("--case", default="all", help='Case id K1-K5/K0 or "all"')
|
||||
ap.add_argument("--include-k0", action="store_true", help="Include optional K0 baseline.")
|
||||
ap.add_argument("--no-k2-control", action="store_true", help="Disable K2 zou_he_local control run.")
|
||||
ap.add_argument("--domain", default="M", choices=("S", "M", "L"))
|
||||
ap.add_argument("--steps", type=int, default=0, help="Override run steps for all selected runs.")
|
||||
ap.add_argument("--burn", type=int, default=0, help="Override burn steps for all selected runs.")
|
||||
ap.add_argument("--record-every", type=int, default=100)
|
||||
ap.add_argument("--field-every", type=int, default=0, help="Dump macro field .npz every N steps (0 disables).")
|
||||
ap.add_argument("--out-dir", type=str, default=os.path.join(_REPO, "tests", "output", "kan99b_validation"))
|
||||
ap.add_argument("--smoke", action="store_true", help="Very short run for wiring checks.")
|
||||
ap.add_argument("--save-vorticity", action="store_true", help="Save final vorticity PNG per run.")
|
||||
ap.add_argument("--json-out", type=str, default="", help="Optional explicit summary JSON output path.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(_DEFAULT_LBM):
|
||||
print(f"Missing base config: {_DEFAULT_LBM}", file=sys.stderr)
|
||||
return 2
|
||||
base_cfg = _load_json(_DEFAULT_LBM)
|
||||
domains = _domain_specs()
|
||||
|
||||
sel = str(args.case).upper()
|
||||
allowed = {case.case_id for case in CASES} | {"K0", "ALL"}
|
||||
if sel not in allowed:
|
||||
print("--case must be K0,K1,K2,K3,K4,K5,all", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
selected: List[KanCase] = []
|
||||
if sel == "ALL":
|
||||
selected.extend(CASES)
|
||||
if args.include_k0:
|
||||
selected.insert(0, CASE_K0)
|
||||
elif sel == "K0":
|
||||
selected.append(CASE_K0)
|
||||
else:
|
||||
selected.extend(case for case in CASES if case.case_id == sel)
|
||||
|
||||
if not selected:
|
||||
print("No runs selected.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
steps_override = 2000 if args.smoke else max(0, int(args.steps))
|
||||
burn_override = 800 if args.smoke else max(0, int(args.burn))
|
||||
runs = _build_runs(
|
||||
selected,
|
||||
domain=args.domain,
|
||||
include_k2_control=not bool(args.no_k2_control),
|
||||
steps_override=steps_override,
|
||||
burn_override=burn_override,
|
||||
)
|
||||
|
||||
out_dir = os.path.abspath(args.out_dir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for spec in runs:
|
||||
print(
|
||||
f"--- {spec.case_id} {spec.variant} MRT dom={spec.domain} Re={spec.re:.0f} "
|
||||
f"alpha={spec.alpha:.3f} inlet={spec.inlet_scheme} burn={spec.burn} steps={spec.steps} ---",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
row = _run_one(
|
||||
spec,
|
||||
domain=domains[spec.domain],
|
||||
base_cfg=base_cfg,
|
||||
out_dir=out_dir,
|
||||
record_every=max(1, int(args.record_every)),
|
||||
field_every=max(0, int(args.field_every)),
|
||||
save_vorticity=bool(args.save_vorticity),
|
||||
)
|
||||
if spec.case_id == "K2":
|
||||
rel = _rel_err(row["St"], KAN99B_ANCHOR["St"])
|
||||
row["St_error_pct"] = 100.0 * rel if rel is not None else None
|
||||
else:
|
||||
row["St_error_pct"] = None
|
||||
rows.append(row)
|
||||
print(
|
||||
f" St={row['St']:.5f} mean_CL={row['mean_cl']:.4f} mean_CD={row['mean_cd']:.4f} "
|
||||
f"C'L={row['amp_cl']:.4f} C'D={row['amp_cd']:.4f}",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
rows.append(
|
||||
{
|
||||
"run_id": _run_id(spec),
|
||||
"case_id": spec.case_id,
|
||||
"variant": spec.variant,
|
||||
"collision": "MRT",
|
||||
"inlet_scheme": spec.inlet_scheme,
|
||||
"inlet_profile": "uniform",
|
||||
"domain": spec.domain,
|
||||
"re": float(spec.re),
|
||||
"alpha": float(spec.alpha),
|
||||
"steps": int(spec.steps),
|
||||
"burn_in": int(spec.burn),
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
print(f"FAILED: {exc}", flush=True)
|
||||
|
||||
k2_gate = _k2_anchor_gate(rows)
|
||||
print("\n=== Kan99b K2 gate summary ===", flush=True)
|
||||
print(json.dumps({"k2_runs": k2_gate}, indent=2), flush=True)
|
||||
|
||||
summary_csv = os.path.join(out_dir, "summary_runs.csv")
|
||||
csv_keys = [
|
||||
"run_id",
|
||||
"case_id",
|
||||
"variant",
|
||||
"collision",
|
||||
"inlet_scheme",
|
||||
"inlet_profile",
|
||||
"domain",
|
||||
"re",
|
||||
"alpha",
|
||||
"omega_body",
|
||||
"nu",
|
||||
"burn_in",
|
||||
"steps",
|
||||
"total_steps",
|
||||
"record_every",
|
||||
"n_samples",
|
||||
"St",
|
||||
"st",
|
||||
"St_error_pct",
|
||||
"mean_cl",
|
||||
"mean_cd",
|
||||
"amp_cl",
|
||||
"amp_cd",
|
||||
"rho_min_final",
|
||||
"rho_max_final",
|
||||
"force_csv",
|
||||
"error",
|
||||
]
|
||||
with open(summary_csv, "w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=csv_keys)
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow({k: row.get(k, "") for k in csv_keys})
|
||||
|
||||
summary = {
|
||||
"contract": {
|
||||
"collision": "MRT",
|
||||
"primary_inlet_scheme": "regularized",
|
||||
"k2_control_inlet_scheme": "zou_he_local",
|
||||
"inlet_profile": "uniform",
|
||||
"y_wall_bc": "free_slip",
|
||||
"outlet_mode": "neq_extrap",
|
||||
"streaming": "double_buffer",
|
||||
"store_precision": "FP32",
|
||||
"les_enabled": False,
|
||||
},
|
||||
"requested": {
|
||||
"case": args.case,
|
||||
"include_k0": bool(args.include_k0),
|
||||
"include_k2_control": not bool(args.no_k2_control),
|
||||
"domain": args.domain,
|
||||
"smoke": bool(args.smoke),
|
||||
"steps_override": int(steps_override),
|
||||
"burn_override": int(burn_override),
|
||||
"record_every": int(args.record_every),
|
||||
"field_every": int(args.field_every),
|
||||
"save_vorticity": bool(args.save_vorticity),
|
||||
},
|
||||
"counts": {
|
||||
"requested_runs": len(runs),
|
||||
"completed_runs": sum(1 for r in rows if "error" not in r),
|
||||
"failed_runs": sum(1 for r in rows if "error" in r),
|
||||
},
|
||||
"k2_gate": k2_gate,
|
||||
"rows": rows,
|
||||
}
|
||||
json_out = (
|
||||
os.path.abspath(args.json_out)
|
||||
if args.json_out.strip()
|
||||
else os.path.join(out_dir, "summary_runs.json")
|
||||
)
|
||||
json_out_dir = os.path.dirname(json_out)
|
||||
if json_out_dir:
|
||||
os.makedirs(json_out_dir, exist_ok=True)
|
||||
_write_json(json_out, summary)
|
||||
|
||||
print(f"Wrote: {summary_csv}", flush=True)
|
||||
print(f"Wrote: {json_out}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Performance baseline for minimal host-interference LBM stepping.
|
||||
|
||||
This script builds a temporary config, runs warmup + measured batches, and
|
||||
reports MLUPS under a FluidX3D-like benchmark mindset:
|
||||
|
||||
- keep the main loop on GPU (`stepper.step`)
|
||||
- avoid host downloads by default
|
||||
- selectively enable host-touch paths to quantify overhead
|
||||
- sweep `inlet.scheme` to check stability-sensitive combinations
|
||||
|
||||
Usage::
|
||||
python tests/run_perf_baseline.py
|
||||
python tests/run_perf_baseline.py --lattice-model D2Q9 --nx 384 --ny 192 --steps 30000
|
||||
python tests/run_perf_baseline.py --macro-every 500 --ddf-every 1000
|
||||
python tests/run_perf_baseline.py --with-cylinder --obs-every 50
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from typing import Any, Dict, List
|
||||
|
||||
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, payload: dict) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
|
||||
def _build_lbm_cfg(base: dict, args: argparse.Namespace) -> dict:
|
||||
cfg = json.loads(json.dumps(base))
|
||||
cfg["grid"]["lattice_model"] = args.lattice_model
|
||||
cfg["grid"]["nx"] = int(args.nx)
|
||||
cfg["grid"]["ny"] = int(args.ny)
|
||||
cfg["grid"]["nz"] = int(args.nz)
|
||||
|
||||
cfg["physics"]["viscosity"] = float(args.viscosity)
|
||||
cfg["physics"]["velocity"] = float(args.velocity)
|
||||
cfg["physics"]["rho"] = float(args.rho)
|
||||
|
||||
cfg["method"]["collision"] = str(args.collision).upper()
|
||||
cfg["method"]["streaming"] = str(args.streaming)
|
||||
cfg["method"]["store_precision"] = str(args.store_precision).upper()
|
||||
cfg["method"]["ddf_shifting"] = bool(args.ddf_shifting)
|
||||
cfg["method"]["les"]["enabled"] = bool(args.enable_les)
|
||||
cfg["method"]["outlet"]["mode"] = str(args.outlet_mode)
|
||||
cfg["method"]["inlet"]["profile"] = str(args.inlet_profile)
|
||||
# Expose inlet scheme as a benchmark axis; useful when stability depends
|
||||
# on collision/inlet coupling.
|
||||
cfg["method"]["inlet"]["scheme"] = str(args.inlet_scheme)
|
||||
cfg["method"]["y_wall_bc"] = str(args.y_wall_bc)
|
||||
|
||||
cfg["cuda"]["threads_per_block"] = int(args.threads_per_block)
|
||||
cfg["cuda"]["compute_capability"] = str(args.compute_capability)
|
||||
return cfg
|
||||
|
||||
|
||||
def _build_body_cfg(args: argparse.Namespace) -> dict:
|
||||
if not args.with_cylinder:
|
||||
return {"objects": []}
|
||||
cx = 0.5 * float(args.nx)
|
||||
cy = 0.5 * float(args.ny)
|
||||
radius = max(2.0, min(float(args.nx), float(args.ny)) * 0.08)
|
||||
return {
|
||||
"objects": [
|
||||
{
|
||||
"type": "cylinder",
|
||||
"center": [cx, cy],
|
||||
"radius": radius,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _maybe_probe_macroscopic(sim: Any, step: int, every: int, repeat: int) -> None:
|
||||
if every > 0 and step % every == 0:
|
||||
for _ in range(max(1, int(repeat))):
|
||||
_ = sim.get_macroscopic()
|
||||
|
||||
|
||||
def _maybe_probe_ddf(sim: Any, step: int, every: int, repeat: int) -> None:
|
||||
if every > 0 and step % every == 0:
|
||||
for _ in range(max(1, int(repeat))):
|
||||
_ = sim.get_ddf()
|
||||
|
||||
|
||||
def _maybe_checkpoint(sim: Any, step: int, every: int, out_dir: str) -> None:
|
||||
if every > 0 and step % every == 0:
|
||||
path = os.path.join(out_dir, f"checkpoint_step_{step:09d}.h5")
|
||||
sim.save_checkpoint(path)
|
||||
|
||||
|
||||
def _maybe_probe_obs(sim: Any, stream: cuda.Stream, step: int, every: int) -> None:
|
||||
if every <= 0 or step % every != 0:
|
||||
return
|
||||
if sim.bodies.count <= 0:
|
||||
return
|
||||
sim.bodies.download_obs_full_async(stream)
|
||||
stream.synchronize()
|
||||
_ = sim.bodies.read_force(0)
|
||||
|
||||
|
||||
def run(args: argparse.Namespace) -> Dict[str, Any]:
|
||||
if not os.path.isfile(_DEFAULT_LBM):
|
||||
raise FileNotFoundError(f"Base config missing: {_DEFAULT_LBM}")
|
||||
|
||||
base = _load_json(_DEFAULT_LBM)
|
||||
lbm_cfg = _build_lbm_cfg(base, args)
|
||||
body_cfg = _build_body_cfg(args)
|
||||
|
||||
tmpd = tempfile.mkdtemp(prefix="celeris_perf_baseline_")
|
||||
lbm_path = os.path.join(tmpd, "config_lbm.json")
|
||||
body_path = os.path.join(tmpd, "config_body.json")
|
||||
ckpt_dir = os.path.join(tmpd, "checkpoints")
|
||||
os.makedirs(ckpt_dir, exist_ok=True)
|
||||
_write_json(lbm_path, lbm_cfg)
|
||||
_write_json(body_path, body_cfg)
|
||||
|
||||
from CelerisLab import Simulation # noqa: WPS433
|
||||
|
||||
sim = Simulation(lbm_config_path=lbm_path, body_config_path=body_path, device_id=args.device_id)
|
||||
sim.initialize()
|
||||
|
||||
stream = cuda.Stream()
|
||||
total_cells = int(args.nx) * int(args.ny) * int(args.nz)
|
||||
|
||||
# Warmup before measurement window.
|
||||
warmup_done = 0
|
||||
while warmup_done < int(args.warmup_steps):
|
||||
chunk = min(int(args.batch_steps), int(args.warmup_steps) - warmup_done)
|
||||
sim.stepper.step(chunk, action_gpu=sim.bodies.action_gpu, obs_gpu=sim.bodies.obs_gpu, stream=stream)
|
||||
warmup_done += chunk
|
||||
stream.synchronize()
|
||||
|
||||
measured_batch_s: List[float] = []
|
||||
measured_steps = int(args.steps)
|
||||
done = 0
|
||||
t0 = time.perf_counter()
|
||||
while done < measured_steps:
|
||||
chunk = min(int(args.batch_steps), measured_steps - done)
|
||||
step_start = time.perf_counter()
|
||||
sim.stepper.step(chunk, action_gpu=sim.bodies.action_gpu, obs_gpu=sim.bodies.obs_gpu, stream=stream)
|
||||
stream.synchronize()
|
||||
step_end = time.perf_counter()
|
||||
measured_batch_s.append(step_end - step_start)
|
||||
|
||||
done += chunk
|
||||
global_step = sim.stepper.step_count
|
||||
_maybe_probe_macroscopic(sim, global_step, int(args.macro_every), int(args.macro_repeat))
|
||||
_maybe_probe_ddf(sim, global_step, int(args.ddf_every), int(args.ddf_repeat))
|
||||
_maybe_checkpoint(sim, global_step, int(args.checkpoint_every), ckpt_dir)
|
||||
_maybe_probe_obs(sim, stream, global_step, int(args.obs_every))
|
||||
|
||||
stream.synchronize()
|
||||
elapsed_s = time.perf_counter() - t0
|
||||
|
||||
# Optional final sanity readback (outside core timing path by default).
|
||||
if args.final_macro_snapshot:
|
||||
_ = sim.get_macroscopic()
|
||||
|
||||
sim.close()
|
||||
|
||||
mlups = (total_cells * measured_steps) / max(elapsed_s, 1e-12) / 1.0e6
|
||||
batch_ms = [1000.0 * x for x in measured_batch_s]
|
||||
batch_ms_sorted = sorted(batch_ms)
|
||||
p50 = batch_ms_sorted[len(batch_ms_sorted) // 2] if batch_ms_sorted else 0.0
|
||||
p90 = batch_ms_sorted[min(len(batch_ms_sorted) - 1, int(0.9 * (len(batch_ms_sorted) - 1)))] if batch_ms_sorted else 0.0
|
||||
|
||||
return {
|
||||
"benchmark": "celerislab_stepper_baseline",
|
||||
"device_id": int(args.device_id),
|
||||
"lattice_model": args.lattice_model,
|
||||
"grid": {"nx": int(args.nx), "ny": int(args.ny), "nz": int(args.nz)},
|
||||
"collision": str(args.collision).upper(),
|
||||
"inlet_scheme": str(args.inlet_scheme),
|
||||
"streaming": str(args.streaming),
|
||||
"store_precision": str(args.store_precision).upper(),
|
||||
"steps": measured_steps,
|
||||
"warmup_steps": int(args.warmup_steps),
|
||||
"batch_steps": int(args.batch_steps),
|
||||
"mlups": float(mlups),
|
||||
"elapsed_s": float(elapsed_s),
|
||||
"batch_ms_p50": float(p50),
|
||||
"batch_ms_p90": float(p90),
|
||||
"overhead_switches": {
|
||||
"macro_every": int(args.macro_every),
|
||||
"macro_repeat": int(args.macro_repeat),
|
||||
"ddf_every": int(args.ddf_every),
|
||||
"ddf_repeat": int(args.ddf_repeat),
|
||||
"checkpoint_every": int(args.checkpoint_every),
|
||||
"obs_every": int(args.obs_every),
|
||||
"with_cylinder": bool(args.with_cylinder),
|
||||
"final_macro_snapshot": bool(args.final_macro_snapshot),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
ap = argparse.ArgumentParser(description="CelerisLab pure-step performance baseline")
|
||||
ap.add_argument("--device-id", type=int, default=0)
|
||||
ap.add_argument("--lattice-model", choices=("D2Q9", "D3Q19"), default="D3Q19")
|
||||
ap.add_argument("--nx", type=int, default=256)
|
||||
ap.add_argument("--ny", type=int, default=256)
|
||||
ap.add_argument("--nz", type=int, default=256)
|
||||
ap.add_argument("--steps", type=int, default=3000)
|
||||
ap.add_argument("--warmup-steps", type=int, default=400)
|
||||
ap.add_argument("--batch-steps", type=int, default=100)
|
||||
|
||||
ap.add_argument("--collision", choices=("SRT", "TRT", "MRT"), default="SRT")
|
||||
ap.add_argument("--streaming", choices=("double_buffer", "esopull"), default="double_buffer")
|
||||
ap.add_argument("--store-precision", choices=("FP32", "FP16S"), default="FP32")
|
||||
ap.add_argument("--ddf-shifting", action="store_true")
|
||||
ap.add_argument("--enable-les", action="store_true")
|
||||
ap.add_argument("--outlet-mode", choices=("neq_extrap", "zero_gradient", "blended"), default="neq_extrap")
|
||||
ap.add_argument("--inlet-profile", choices=("uniform", "parabolic"), default="uniform")
|
||||
ap.add_argument(
|
||||
"--inlet-scheme",
|
||||
choices=("zou_he_local", "channel_stabilized", "equilibrium", "regularized"),
|
||||
default="zou_he_local",
|
||||
)
|
||||
ap.add_argument("--y-wall-bc", choices=("bounce_back", "free_slip"), default="bounce_back")
|
||||
|
||||
ap.add_argument("--viscosity", type=float, default=0.0035)
|
||||
ap.add_argument("--velocity", type=float, default=0.03)
|
||||
ap.add_argument("--rho", type=float, default=1.0)
|
||||
ap.add_argument("--threads-per-block", type=int, default=256)
|
||||
ap.add_argument("--compute-capability", type=str, default="auto")
|
||||
|
||||
# Overhead attribution toggles.
|
||||
ap.add_argument("--macro-every", type=int, default=0, help="Call get_macroscopic() every N steps (0=off)")
|
||||
ap.add_argument("--macro-repeat", type=int, default=1, help="Repeat get_macroscopic() calls per probe step")
|
||||
ap.add_argument("--ddf-every", type=int, default=0, help="Call get_ddf() every N steps (0=off)")
|
||||
ap.add_argument("--ddf-repeat", type=int, default=1, help="Repeat get_ddf() calls per probe step")
|
||||
ap.add_argument("--checkpoint-every", type=int, default=0, help="Save checkpoint every N steps (0=off)")
|
||||
ap.add_argument("--obs-every", type=int, default=0, help="Download object obs every N steps (0=off)")
|
||||
ap.add_argument("--with-cylinder", action="store_true", help="Inject one cylinder object (needed for obs probes)")
|
||||
ap.add_argument("--final-macro-snapshot", action="store_true")
|
||||
|
||||
ap.add_argument("--json-out", type=str, default="", help="Optional path to save metrics JSON")
|
||||
return ap.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
result = run(args)
|
||||
print(json.dumps(result, indent=2))
|
||||
if args.json_out.strip():
|
||||
out = os.path.abspath(args.json_out)
|
||||
os.makedirs(os.path.dirname(out), exist_ok=True)
|
||||
_write_json(out, result)
|
||||
print(f"Wrote: {out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,659 @@
|
||||
# CelerisLab/tests/validation/run_sah04_st_matrix.py
|
||||
"""Sah04 MRT-only Strouhal validation on S1-S4 anchors.
|
||||
|
||||
This runner implements the current validation contract in ``tests/Sah04_validation.md``:
|
||||
|
||||
- Cases: S1-S4 only (hard periodic anchors).
|
||||
- Collision: MRT only.
|
||||
- Inlet: parabolic + channel_stabilized.
|
||||
- Walls: no-slip channel (from base config + confined geometry).
|
||||
- Grid policy: S3/S4 can use configurable refined diameter for diagnostics.
|
||||
|
||||
Usage::
|
||||
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py
|
||||
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --case S3 --smoke
|
||||
conda run -n pycuda_3_10 python tests/run_sah04_st_matrix.py --gate-pct 10 --json-out tests/output/sah04_mrt/summary.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import numpy as np
|
||||
import pycuda.driver as cuda
|
||||
|
||||
_PKG_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_DEFAULT_LBM = os.path.join(_PKG_ROOT, "src", "CelerisLab", "configs", "config_lbm.json")
|
||||
|
||||
_BASE_D = 30.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Sah04Case:
|
||||
"""One hard benchmark case from Sah04_validation.md."""
|
||||
|
||||
case_id: str
|
||||
beta_nominal: float
|
||||
re_nominal: float
|
||||
target_st: float
|
||||
h_fluid: int
|
||||
steps: int
|
||||
burn: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CaseGeometry:
|
||||
"""Resolved lattice geometry for one case."""
|
||||
|
||||
diameter: float
|
||||
h_fluid: int
|
||||
nx: int
|
||||
ny: int
|
||||
center_x: float
|
||||
center_y: float
|
||||
radius: float
|
||||
beta_real: float
|
||||
wall_gap_cells: float
|
||||
|
||||
|
||||
CASES: Tuple[Sah04Case, ...] = (
|
||||
Sah04Case("S1", 0.3, 100.0, 0.2115, 100, 120_000, 45_000),
|
||||
Sah04Case("S2", 0.5, 200.0, 0.3513, 60, 120_000, 45_000),
|
||||
Sah04Case("S3", 0.8, 160.0, 0.5537, 38, 220_000, 99_000),
|
||||
Sah04Case("S4", 0.9, 200.0, 0.5314, 33, 220_000, 99_000),
|
||||
)
|
||||
|
||||
|
||||
def _load_json(path: str) -> dict:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: str, payload: dict) -> None:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
|
||||
def rfft_power_spectrum(samples: np.ndarray, *, sample_dt: float) -> Tuple[np.ndarray, np.ndarray]:
|
||||
"""Mean-subtracted Hanning-windowed signal to rFFT power spectrum."""
|
||||
x = np.asarray(samples, dtype=np.float64)
|
||||
x = x - np.mean(x)
|
||||
n = x.size
|
||||
if n < 64:
|
||||
return np.zeros(0, dtype=np.float64), np.zeros(0, dtype=np.float64)
|
||||
win = np.hanning(n)
|
||||
spec = np.abs(np.fft.rfft(x * win)) ** 2
|
||||
freqs = np.fft.rfftfreq(n, d=float(sample_dt))
|
||||
return freqs.astype(np.float64), spec.astype(np.float64)
|
||||
|
||||
|
||||
def _parabolic_peak_freq(freqs: np.ndarray, spec: np.ndarray, idx: int) -> float:
|
||||
"""Sub-bin frequency estimate with local log-parabolic interpolation."""
|
||||
i = int(np.clip(idx, 0, spec.size - 1))
|
||||
if i <= 0 or i + 1 >= spec.size:
|
||||
return float(freqs[i])
|
||||
y0 = np.log(spec[i - 1] + 1e-30)
|
||||
y1 = np.log(spec[i] + 1e-30)
|
||||
y2 = np.log(spec[i + 1] + 1e-30)
|
||||
den = y0 - 2.0 * y1 + y2
|
||||
if abs(den) < 1e-20:
|
||||
return float(freqs[i])
|
||||
delta = float(np.clip(0.5 * (y0 - y2) / den, -1.0, 1.0))
|
||||
return float(freqs[i]) + delta * float(freqs[i + 1] - freqs[i])
|
||||
|
||||
|
||||
def _strouhal_from_lift(
|
||||
lift: np.ndarray,
|
||||
*,
|
||||
diameter: float,
|
||||
u_max: float,
|
||||
sample_dt: float,
|
||||
f_hz_min: float,
|
||||
f_hz_max: float,
|
||||
) -> Tuple[float, float]:
|
||||
"""Return guided Strouhal and guided dominant frequency."""
|
||||
freqs, spec = rfft_power_spectrum(lift, sample_dt=sample_dt)
|
||||
if freqs.size == 0:
|
||||
return float("nan"), float("nan")
|
||||
band = (freqs >= float(f_hz_min)) & (freqs <= float(f_hz_max))
|
||||
if not np.any(band):
|
||||
return float("nan"), float("nan")
|
||||
f0 = 0.5 * (float(f_hz_min) + float(f_hz_max))
|
||||
sigma = max(1e-12, 0.18 * f0)
|
||||
weight = np.exp(-((freqs - f0) / sigma) ** 2)
|
||||
idx = int(np.argmax(spec * band.astype(np.float64) * weight))
|
||||
f_peak = _parabolic_peak_freq(freqs, spec, idx)
|
||||
return float(f_peak * diameter / u_max), float(f_peak)
|
||||
|
||||
|
||||
def shedding_freq_band_hz(
|
||||
target_st: float,
|
||||
u_max: float,
|
||||
diameter: float,
|
||||
*,
|
||||
half_width: float = 0.42,
|
||||
) -> Tuple[float, float]:
|
||||
"""Frequency band around target shedding frequency for robust FFT pick."""
|
||||
f0 = float(target_st) * float(u_max) / float(diameter)
|
||||
return max(1e-8, f0 * (1.0 - half_width)), f0 * (1.0 + half_width)
|
||||
|
||||
|
||||
def vorticity_z_from_velocity(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
|
||||
"""Return z-vorticity for 2D velocity fields."""
|
||||
ux = np.asarray(ux, dtype=np.float64)
|
||||
uy = np.asarray(uy, dtype=np.float64)
|
||||
return np.gradient(uy, axis=1) - np.gradient(ux, axis=0)
|
||||
|
||||
|
||||
def save_final_vorticity_png(path: str, ux: np.ndarray, uy: np.ndarray, *, title: str) -> None:
|
||||
"""Save final-step vorticity image; requires matplotlib."""
|
||||
try:
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("save_final_vorticity_png requires matplotlib.") from exc
|
||||
|
||||
omega = vorticity_z_from_velocity(ux, uy)
|
||||
abs_o = np.abs(omega[np.isfinite(omega)])
|
||||
vmax = float(np.percentile(abs_o, 99.5)) if abs_o.size else 1.0
|
||||
if vmax <= 0.0:
|
||||
vmax = 1.0
|
||||
ny, nx = omega.shape
|
||||
fig, ax = plt.subplots(figsize=(min(18.0, max(8.0, nx / 100.0)), min(12.0, max(3.0, ny / 40.0))))
|
||||
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()
|
||||
fig.savefig(path, dpi=150, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def _build_case_geometry(
|
||||
case: Sah04Case,
|
||||
*,
|
||||
refine_high_beta: bool,
|
||||
high_beta_diameter: float,
|
||||
diameter_override: Optional[float],
|
||||
h_fluid_override: Optional[int],
|
||||
) -> CaseGeometry:
|
||||
"""Map case to geometry, with optional high-beta refinement."""
|
||||
diameter = _BASE_D
|
||||
h_fluid = int(case.h_fluid)
|
||||
if diameter_override is not None or h_fluid_override is not None:
|
||||
if diameter_override is None or h_fluid_override is None:
|
||||
raise ValueError("Both --diameter-override and --h-fluid-override must be set together.")
|
||||
diameter = float(diameter_override)
|
||||
h_fluid = int(h_fluid_override)
|
||||
elif refine_high_beta and case.case_id in ("S3", "S4"):
|
||||
# Diagnostic default keeps high-blockage runs around D~80 for faster sweeps.
|
||||
diameter = float(high_beta_diameter)
|
||||
h_fluid = int(round(float(diameter) / float(case.beta_nominal)))
|
||||
|
||||
nx = int(80.0 * diameter + 2.0)
|
||||
ny = int(h_fluid + 2)
|
||||
center_x = 40.0 * diameter + 0.5
|
||||
center_y = 0.5 * float(h_fluid) + 0.5
|
||||
radius = 0.5 * diameter
|
||||
beta_real = float(diameter / float(h_fluid))
|
||||
wall_gap_cells = 0.5 * float(h_fluid - diameter)
|
||||
return CaseGeometry(
|
||||
diameter=diameter,
|
||||
h_fluid=h_fluid,
|
||||
nx=nx,
|
||||
ny=ny,
|
||||
center_x=center_x,
|
||||
center_y=center_y,
|
||||
radius=radius,
|
||||
beta_real=beta_real,
|
||||
wall_gap_cells=wall_gap_cells,
|
||||
)
|
||||
|
||||
|
||||
def _relative_error(measured: float, target: float) -> Optional[float]:
|
||||
if not np.isfinite(measured) or target <= 0.0:
|
||||
return None
|
||||
return abs(float(measured) - float(target)) / float(target)
|
||||
|
||||
|
||||
def _realized_umax(ux: np.ndarray, *, probe_x: int) -> float:
|
||||
"""Estimate developed centerline maximum from one downstream vertical profile."""
|
||||
# Skip top/bottom walls and sample at a downstream x station.
|
||||
prof = np.asarray(ux[1:-1, int(probe_x)], dtype=np.float64)
|
||||
if prof.size == 0:
|
||||
return float("nan")
|
||||
return float(np.max(prof))
|
||||
|
||||
|
||||
def run_one_simulation(
|
||||
case: Sah04Case,
|
||||
geometry: CaseGeometry,
|
||||
*,
|
||||
collision: str,
|
||||
outlet_mode: str,
|
||||
inlet_profile: str,
|
||||
inlet_scheme: str,
|
||||
u_max_nominal: float,
|
||||
steps: int,
|
||||
burn: int,
|
||||
record_every: int,
|
||||
f_hz_min: float,
|
||||
f_hz_max: float,
|
||||
dump_npz_path: Optional[str] = None,
|
||||
final_vorticity_png_path: Optional[str] = None,
|
||||
crash_dump_dir: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build config, run simulation, and return measured metrics."""
|
||||
nu = float(u_max_nominal * geometry.diameter / case.re_nominal)
|
||||
u0_mean = float(u_max_nominal / 1.5)
|
||||
|
||||
cfg = _load_json(_DEFAULT_LBM)
|
||||
cfg["grid"]["nx"] = int(geometry.nx)
|
||||
cfg["grid"]["ny"] = int(geometry.ny)
|
||||
cfg["grid"]["nz"] = 1
|
||||
cfg["physics"]["viscosity"] = float(nu)
|
||||
cfg["physics"]["velocity"] = float(u0_mean)
|
||||
cfg["physics"]["rho"] = 1.0
|
||||
cfg["method"]["collision"] = str(collision).upper()
|
||||
cfg["method"]["streaming"] = "double_buffer"
|
||||
cfg["method"]["les"]["enabled"] = False
|
||||
cfg["method"]["inlet"]["profile"] = str(inlet_profile)
|
||||
cfg["method"]["inlet"]["scheme"] = str(inlet_scheme)
|
||||
cfg["method"]["outlet"]["mode"] = str(outlet_mode)
|
||||
|
||||
body_doc = {
|
||||
"objects": [
|
||||
{
|
||||
"type": "cylinder",
|
||||
"center": [float(geometry.center_x), float(geometry.center_y)],
|
||||
"radius": float(geometry.radius),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
tmpd = tempfile.mkdtemp(prefix="celeris_sah04_mrt_")
|
||||
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()
|
||||
rec_every = max(1, int(record_every))
|
||||
lift_hist: List[float] = []
|
||||
fx_hist: List[float] = []
|
||||
step_hist: List[int] = []
|
||||
|
||||
n_curved = int(sim.field.n_curved)
|
||||
fallback_links = int(sim.bodies.fallback_link_count())
|
||||
low_q_links = int(sim.bodies.low_q_link_count())
|
||||
|
||||
for step in range(1, int(steps) + 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,
|
||||
)
|
||||
if step % rec_every == 0 or step == int(steps):
|
||||
stream.synchronize()
|
||||
sim.bodies.download_obs_full_async(stream)
|
||||
stream.synchronize()
|
||||
fvec = sim.bodies.read_force(0)
|
||||
lift = float(fvec[1])
|
||||
drag = float(fvec[0])
|
||||
if not np.isfinite(lift) or not np.isfinite(drag):
|
||||
crash_npz_path: Optional[str] = None
|
||||
crash_png_path: Optional[str] = None
|
||||
if crash_dump_dir:
|
||||
os.makedirs(crash_dump_dir, exist_ok=True)
|
||||
stream.synchronize()
|
||||
macro_bad = sim.get_macroscopic()
|
||||
ux_bad = np.asarray(macro_bad["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
|
||||
uy_bad = np.asarray(macro_bad["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
|
||||
rho_bad = np.asarray(macro_bad["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
|
||||
prefix = f"{case.case_id.lower()}_{str(collision).lower()}_crash_step{step}"
|
||||
crash_npz_path = os.path.join(crash_dump_dir, f"{prefix}.npz")
|
||||
np.savez_compressed(
|
||||
crash_npz_path,
|
||||
rho=rho_bad.astype(np.float32),
|
||||
ux=ux_bad.astype(np.float32),
|
||||
uy=uy_bad.astype(np.float32),
|
||||
step=np.array([int(step)], dtype=np.int64),
|
||||
case_id=np.array([case.case_id]),
|
||||
collision=np.array([str(collision)]),
|
||||
inlet_scheme=np.array([str(inlet_scheme)]),
|
||||
outlet_mode=np.array([str(outlet_mode)]),
|
||||
)
|
||||
crash_png_path = os.path.join(crash_dump_dir, f"{prefix}.png")
|
||||
try:
|
||||
save_final_vorticity_png(
|
||||
crash_png_path,
|
||||
ux_bad,
|
||||
uy_bad,
|
||||
title=(
|
||||
f"Sah04 {case.case_id} {collision} crash@{step} "
|
||||
f"D={int(geometry.diameter)} H={geometry.h_fluid}"
|
||||
),
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
crash_png_path = None
|
||||
sim.close()
|
||||
msg = f"NaN/Inf force at step {step}"
|
||||
if crash_npz_path:
|
||||
msg += f"; crash_npz={crash_npz_path}"
|
||||
if crash_png_path:
|
||||
msg += f"; crash_png={crash_png_path}"
|
||||
raise RuntimeError(msg)
|
||||
lift_hist.append(lift)
|
||||
fx_hist.append(drag)
|
||||
step_hist.append(step)
|
||||
|
||||
stream.synchronize()
|
||||
macro_last = sim.get_macroscopic()
|
||||
ux_last = np.asarray(macro_last["ux"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
|
||||
uy_last = np.asarray(macro_last["uy"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
|
||||
rho_last = np.asarray(macro_last["rho"], dtype=np.float64).reshape(geometry.ny, geometry.nx)
|
||||
sim.close()
|
||||
|
||||
if final_vorticity_png_path:
|
||||
out_dir = os.path.dirname(os.path.abspath(final_vorticity_png_path))
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
save_final_vorticity_png(
|
||||
final_vorticity_png_path,
|
||||
ux_last,
|
||||
uy_last,
|
||||
title=f"Sah04 {case.case_id} MRT Re={case.re_nominal:.0f} beta_nom={case.beta_nominal:.1f}",
|
||||
)
|
||||
|
||||
lift_arr = np.asarray(lift_hist, dtype=np.float64)
|
||||
fx_arr = np.asarray(fx_hist, dtype=np.float64)
|
||||
step_arr = np.asarray(step_hist, dtype=np.int64)
|
||||
burn_idx = min(int(burn) // rec_every, max(0, lift_arr.size - 16))
|
||||
lift_tail = lift_arr[burn_idx:]
|
||||
|
||||
st, f_peak = _strouhal_from_lift(
|
||||
lift_tail,
|
||||
diameter=float(geometry.diameter),
|
||||
u_max=float(u_max_nominal),
|
||||
sample_dt=float(rec_every),
|
||||
f_hz_min=float(f_hz_min),
|
||||
f_hz_max=float(f_hz_max),
|
||||
)
|
||||
mean_cd = (
|
||||
float(np.mean(fx_arr[burn_idx:]) * 2.0 / (u_max_nominal**2 * geometry.diameter))
|
||||
if fx_arr.size
|
||||
else float("nan")
|
||||
)
|
||||
|
||||
beta_real = float(geometry.beta_real)
|
||||
probe_x = min(geometry.nx - 2, max(2, geometry.nx - 10))
|
||||
u_max_real = _realized_umax(ux_last, probe_x=probe_x)
|
||||
re_real = float(u_max_real * geometry.diameter / nu) if np.isfinite(u_max_real) and nu > 0.0 else float("nan")
|
||||
|
||||
if dump_npz_path:
|
||||
freqs, power = rfft_power_spectrum(lift_tail, sample_dt=float(rec_every))
|
||||
out_dir = os.path.dirname(os.path.abspath(dump_npz_path))
|
||||
if out_dir:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
np.savez_compressed(
|
||||
dump_npz_path,
|
||||
lift_samples=lift_arr,
|
||||
drag_samples=fx_arr,
|
||||
sample_lbm_step=step_arr,
|
||||
burn_index_samples=int(burn_idx),
|
||||
record_every_lbm_steps=int(rec_every),
|
||||
freqs_hz_post_burn=freqs,
|
||||
power_post_burn=power,
|
||||
rho_final=rho_last.astype(np.float32),
|
||||
ux_final=ux_last.astype(np.float32),
|
||||
uy_final=uy_last.astype(np.float32),
|
||||
st=np.array([st], dtype=np.float64),
|
||||
f_peak=np.array([f_peak], dtype=np.float64),
|
||||
re_real=np.array([re_real], dtype=np.float64),
|
||||
beta_real=np.array([beta_real], dtype=np.float64),
|
||||
)
|
||||
|
||||
return {
|
||||
"collision": str(collision).upper(),
|
||||
"inlet_profile": inlet_profile,
|
||||
"inlet_scheme": inlet_scheme,
|
||||
"St": float(st),
|
||||
"f_peak_per_step": float(f_peak),
|
||||
"mean_Cd": float(mean_cd),
|
||||
"Re_real": float(re_real),
|
||||
"U_max_real": float(u_max_real),
|
||||
"beta_real": float(beta_real),
|
||||
"n_curved": n_curved,
|
||||
"fallback_links": fallback_links,
|
||||
"low_q_links": low_q_links,
|
||||
"rho_min_final": float(np.min(rho_last)),
|
||||
"rho_max_final": float(np.max(rho_last)),
|
||||
"n_lift_samples": int(lift_arr.size),
|
||||
}
|
||||
|
||||
|
||||
def evaluate_rows(rows: Sequence[Dict[str, Any]], *, gate_pct: float) -> Dict[str, Any]:
|
||||
"""Aggregate pass/fail summary for S1-S4 hard anchors."""
|
||||
valid_rows = [r for r in rows if "error" not in r]
|
||||
st_errs = [r.get("St_error_pct") for r in valid_rows if r.get("St_error_pct") is not None]
|
||||
pass_count = sum(1 for v in st_errs if float(v) <= float(gate_pct))
|
||||
return {
|
||||
"gate_pct": float(gate_pct),
|
||||
"cases_total": len(rows),
|
||||
"cases_completed": len(valid_rows),
|
||||
"cases_failed": sum(1 for r in rows if "error" in r),
|
||||
"cases_within_gate": int(pass_count),
|
||||
"pass_gate_all_completed": bool(len(valid_rows) > 0 and pass_count == len(valid_rows)),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Sah04 MRT-only S1-S4 validation runner")
|
||||
ap.add_argument("--case", default="all", help='S1-S4 or "all"')
|
||||
ap.add_argument("--collision", default="MRT", choices=("SRT", "TRT", "MRT"))
|
||||
ap.add_argument("--outlet", default="neq_extrap", choices=("neq_extrap", "zero_gradient", "blended"))
|
||||
ap.add_argument("--inlet-profile", default="parabolic", choices=("parabolic",))
|
||||
ap.add_argument(
|
||||
"--inlet-scheme",
|
||||
default="channel_stabilized",
|
||||
choices=("channel_stabilized", "regularized", "zou_he_local", "equilibrium"),
|
||||
)
|
||||
ap.add_argument("--record-every", type=int, default=5)
|
||||
ap.add_argument("--smoke", action="store_true", help="Short run for wiring checks.")
|
||||
ap.add_argument("--steps", type=int, default=None, help="Override case steps (ignored with --smoke).")
|
||||
ap.add_argument("--burn", type=int, default=None, help="Override case burn (ignored with --smoke).")
|
||||
ap.add_argument("--gate-pct", type=float, default=5.0, help="Pass gate for St relative error percent.")
|
||||
ap.add_argument("--json-out", type=str, default=None, help="Write summary JSON.")
|
||||
ap.add_argument("--dump-npz-dir", type=str, default=None, help="Optional directory for case NPZ dumps.")
|
||||
ap.add_argument("--final-vorticity-dir", type=str, default=None, help="Optional directory for final vorticity PNG.")
|
||||
ap.add_argument(
|
||||
"--crash-dump-dir",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Optional directory to dump full flowfield NPZ/PNG immediately before crash.",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--no-refine-high-beta",
|
||||
action="store_true",
|
||||
help="Disable default refined geometry for S3/S4 (debug only).",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--high-beta-diameter",
|
||||
type=float,
|
||||
default=80.0,
|
||||
help="Refined diameter for S3/S4 when high-beta refinement is enabled.",
|
||||
)
|
||||
ap.add_argument("--diameter-override", type=float, default=None, help="Override cylinder diameter for selected case.")
|
||||
ap.add_argument("--h-fluid-override", type=int, default=None, help="Override fluid height H for selected case.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if not os.path.isfile(_DEFAULT_LBM):
|
||||
print(f"Missing base config: {_DEFAULT_LBM}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
selected_case = str(args.case).upper()
|
||||
if selected_case != "ALL" and selected_case not in {c.case_id for c in CASES}:
|
||||
print("--case must be one of S1,S2,S3,S4,all", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cases_to_run = [c for c in CASES if selected_case == "ALL" or c.case_id == selected_case]
|
||||
if args.dump_npz_dir:
|
||||
os.makedirs(args.dump_npz_dir, exist_ok=True)
|
||||
if args.final_vorticity_dir:
|
||||
os.makedirs(args.final_vorticity_dir, exist_ok=True)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for case in cases_to_run:
|
||||
geometry = _build_case_geometry(
|
||||
case,
|
||||
refine_high_beta=not bool(args.no_refine_high_beta),
|
||||
high_beta_diameter=float(args.high_beta_diameter),
|
||||
diameter_override=args.diameter_override,
|
||||
h_fluid_override=args.h_fluid_override,
|
||||
)
|
||||
steps = 5000 if args.smoke else (int(args.steps) if args.steps is not None else case.steps)
|
||||
burn = 1500 if args.smoke else (int(args.burn) if args.burn is not None else case.burn)
|
||||
f_lo, f_hi = shedding_freq_band_hz(case.target_st, 0.1, geometry.diameter)
|
||||
|
||||
npz_path = os.path.join(args.dump_npz_dir, f"{case.case_id.lower()}_mrt.npz") if args.dump_npz_dir else None
|
||||
vort_path = (
|
||||
os.path.join(args.final_vorticity_dir, f"{case.case_id.lower()}_mrt_laststep.png")
|
||||
if args.final_vorticity_dir
|
||||
else None
|
||||
)
|
||||
|
||||
print(
|
||||
f"--- {case.case_id} {args.collision} beta_nom={case.beta_nominal:.1f} Re_nom={case.re_nominal:.0f} "
|
||||
f"D={int(geometry.diameter)} H={geometry.h_fluid} gap~{geometry.wall_gap_cells:.2f} "
|
||||
f"steps={steps} burn={burn} inlet={args.inlet_scheme}/{args.inlet_profile} ---",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
out = run_one_simulation(
|
||||
case,
|
||||
geometry,
|
||||
collision=args.collision,
|
||||
outlet_mode=args.outlet,
|
||||
inlet_profile=args.inlet_profile,
|
||||
inlet_scheme=args.inlet_scheme,
|
||||
u_max_nominal=0.1,
|
||||
steps=steps,
|
||||
burn=burn,
|
||||
record_every=int(args.record_every),
|
||||
f_hz_min=f_lo,
|
||||
f_hz_max=f_hi,
|
||||
dump_npz_path=npz_path,
|
||||
final_vorticity_png_path=vort_path,
|
||||
crash_dump_dir=args.crash_dump_dir,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
rows.append(
|
||||
{
|
||||
"case_id": case.case_id,
|
||||
"collision": str(args.collision).upper(),
|
||||
"inlet_scheme": args.inlet_scheme,
|
||||
"inlet_profile": args.inlet_profile,
|
||||
"outlet": args.outlet,
|
||||
"grid": {"nx": geometry.nx, "ny": geometry.ny, "diameter": int(geometry.diameter), "h_fluid": geometry.h_fluid},
|
||||
"error": str(exc),
|
||||
}
|
||||
)
|
||||
print(f"FAILED: {exc}", flush=True)
|
||||
continue
|
||||
|
||||
rel_err = _relative_error(out["St"], case.target_st)
|
||||
st_err_pct = (100.0 * rel_err) if rel_err is not None else None
|
||||
row = {
|
||||
"case_id": case.case_id,
|
||||
"collision": out["collision"],
|
||||
"inlet_scheme": out["inlet_scheme"],
|
||||
"inlet_profile": out["inlet_profile"],
|
||||
"grid": {"nx": geometry.nx, "ny": geometry.ny, "diameter": int(geometry.diameter), "h_fluid": geometry.h_fluid},
|
||||
"steps": int(steps),
|
||||
"burn_in": int(burn),
|
||||
"Re_nominal": float(case.re_nominal),
|
||||
"Re_real": out["Re_real"],
|
||||
"beta_nominal": float(case.beta_nominal),
|
||||
"beta_real": out["beta_real"],
|
||||
"wall_gap_cells": geometry.wall_gap_cells,
|
||||
"target_St": float(case.target_st),
|
||||
"St": out["St"],
|
||||
"St_error_pct": float(st_err_pct) if st_err_pct is not None and np.isfinite(st_err_pct) else None,
|
||||
"gate_pct": float(args.gate_pct),
|
||||
"gate_pass": bool(st_err_pct is not None and st_err_pct <= float(args.gate_pct)),
|
||||
"mean_Cd": out["mean_Cd"],
|
||||
"U_max_real": out["U_max_real"],
|
||||
"rho_min_final": out["rho_min_final"],
|
||||
"rho_max_final": out["rho_max_final"],
|
||||
"n_curved": out["n_curved"],
|
||||
"fallback_links": out["fallback_links"],
|
||||
"low_q_links": out["low_q_links"],
|
||||
"n_lift_samples": out["n_lift_samples"],
|
||||
}
|
||||
rows.append(row)
|
||||
st_err_txt = f"{row['St_error_pct']:.2f}%" if row["St_error_pct"] is not None else "n/a"
|
||||
re_real_txt = f"{row['Re_real']:.2f}" if np.isfinite(row["Re_real"]) else "nan"
|
||||
print(
|
||||
f" St={row['St']:.5f} target={row['target_St']:.5f} err={st_err_txt} "
|
||||
f"Re_real={re_real_txt} beta_real={row['beta_real']:.4f} "
|
||||
f"[{'PASS' if row['gate_pass'] else 'CHECK'}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
evaluation = evaluate_rows(rows, gate_pct=float(args.gate_pct))
|
||||
print("\n=== Sah04 MRT S1-S4 summary ===", flush=True)
|
||||
print(json.dumps(evaluation, indent=2), flush=True)
|
||||
|
||||
if args.json_out:
|
||||
json_out_path = os.path.abspath(args.json_out)
|
||||
json_out_dir = os.path.dirname(json_out_path)
|
||||
if json_out_dir:
|
||||
os.makedirs(json_out_dir, exist_ok=True)
|
||||
_write_json(
|
||||
json_out_path,
|
||||
{
|
||||
"requested": {
|
||||
"case": args.case,
|
||||
"outlet": args.outlet,
|
||||
"inlet_profile": args.inlet_profile,
|
||||
"inlet_scheme": args.inlet_scheme,
|
||||
"record_every": int(args.record_every),
|
||||
"smoke": bool(args.smoke),
|
||||
"steps_override": args.steps,
|
||||
"burn_override": args.burn,
|
||||
"gate_pct": float(args.gate_pct),
|
||||
},
|
||||
"rows": rows,
|
||||
"evaluation": evaluation,
|
||||
},
|
||||
)
|
||||
print(f"Wrote: {json_out_path}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,124 @@
|
||||
# CelerisLab/tests/validation/test_sensor_accuracy.py
|
||||
"""Sensor accuracy validation: compare sensor readings to direct flow field averages.
|
||||
|
||||
This script validates that the GPU sensor kernel accumulation matches a
|
||||
CPU-side manual average of the macroscopic field over the same cell footprint.
|
||||
|
||||
Usage::
|
||||
|
||||
conda run -n pycuda_3_10 python tests/validation/test_sensor_accuracy.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(_REPO / "src"))
|
||||
|
||||
from CelerisLab import Simulation
|
||||
|
||||
|
||||
def test_sensor_accuracy() -> dict:
|
||||
"""Run sensor accuracy validation with multiple sensor positions."""
|
||||
cfg = json.loads(
|
||||
(Path(_REPO) / "src" / "CelerisLab" / "configs" / "config_lbm.json").read_text()
|
||||
)
|
||||
cfg["grid"]["nx"] = 256
|
||||
cfg["grid"]["ny"] = 128
|
||||
cfg["grid"]["nz"] = 1
|
||||
cfg["physics"]["viscosity"] = 0.009
|
||||
cfg["physics"]["velocity"] = 0.03
|
||||
cfg["method"]["collision"] = "MRT"
|
||||
cfg["method"]["inlet"]["scheme"] = "regularized"
|
||||
cfg["method"]["inlet"]["profile"] = "uniform"
|
||||
cfg["method"]["y_wall_bc"] = "free_slip"
|
||||
|
||||
tmpd = tempfile.mkdtemp(prefix="sensor_test_")
|
||||
lbm_path = os.path.join(tmpd, "config_lbm.json")
|
||||
with open(lbm_path, "w") as f:
|
||||
json.dump(cfg, f)
|
||||
|
||||
sim = Simulation(lbm_config_path=lbm_path)
|
||||
sim.add_body("circle", center=(80, 64), radius=15)
|
||||
|
||||
positions = [(120, 50), (120, 64), (120, 78), (150, 64)]
|
||||
sensor_ids = []
|
||||
for cx, cy in positions:
|
||||
sid = sim.add_body("sensor", center=(cx, cy), radius=10)
|
||||
sensor_ids.append(sid)
|
||||
|
||||
sim.initialize()
|
||||
print(f"Initialized: nx={cfg['grid']['nx']} ny={cfg['grid']['ny']} "
|
||||
f"n_curved={sim.field.n_curved} n_sensor={sim.field.n_sensor}")
|
||||
|
||||
# Step to develop wake
|
||||
for _ in range(50):
|
||||
sim.run(20)
|
||||
|
||||
# Get macroscopic field after one more step (with sensor accumulation)
|
||||
import pycuda.driver as cuda
|
||||
stream = cuda.Stream()
|
||||
sim.bodies.zero_sensor_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 = macro["ux"]
|
||||
uy = macro["uy"]
|
||||
|
||||
results = {}
|
||||
all_pass = True
|
||||
for sid in sensor_ids:
|
||||
cells_arr, _ = sim.bodies.get(sid).get_sensor_list(
|
||||
sim.lbm_cfg.nx, sim.lbm_cfg.ny
|
||||
)
|
||||
cell_idx = np.asarray(cells_arr, dtype=np.int64)
|
||||
ux_rav = ux.ravel().astype(np.float64)
|
||||
uy_rav = uy.ravel().astype(np.float64)
|
||||
|
||||
sensor_ux_mean = float(np.mean(ux_rav[cell_idx]))
|
||||
sensor_uy_mean = float(np.mean(uy_rav[cell_idx]))
|
||||
|
||||
sensor_reading = sim.read_sensor(sid)
|
||||
sensor_reading_x = float(sensor_reading[0])
|
||||
sensor_reading_y = float(sensor_reading[1])
|
||||
|
||||
diff_ux = abs(sensor_reading_x - sensor_ux_mean)
|
||||
diff_uy = abs(sensor_reading_y - sensor_uy_mean)
|
||||
passed = diff_ux < 1e-4 and diff_uy < 1e-4
|
||||
if not passed:
|
||||
all_pass = False
|
||||
|
||||
results[f"sensor_{sid}_pos{positions[i]}"] = {
|
||||
"sensor_reading": [sensor_reading_x, sensor_reading_y],
|
||||
"manual_average": [sensor_ux_mean, sensor_uy_mean],
|
||||
"diff": [float(diff_ux), float(diff_uy)],
|
||||
"n_cells": int(len(cells_arr)),
|
||||
"pass": bool(passed),
|
||||
}
|
||||
status = "PASS" if passed else "FAIL"
|
||||
print(
|
||||
f" Sensor {sid} @ {positions[sid]}: "
|
||||
f"reading=({sensor_reading_x:.8f},{sensor_reading_y:.8f}) "
|
||||
f"manual=({sensor_ux_mean:.8f},{sensor_uy_mean:.8f}) "
|
||||
f"diff=({diff_ux:.2e},{diff_uy:.2e}) "
|
||||
f"cells={len(cells_arr)} [{status}]"
|
||||
)
|
||||
|
||||
sim.close()
|
||||
summary = {"all_pass": bool(all_pass), "results": results}
|
||||
print(f"\nSensor accuracy: {'ALL PASS' if all_pass else 'SOME FAILED'}")
|
||||
return summary
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = test_sensor_accuracy()
|
||||
sys.exit(0 if result["all_pass"] else 1)
|
||||
Reference in New Issue
Block a user