重构body api,性能分析,项目整理

This commit is contained in:
Frank14f
2026-05-31 01:42:58 +08:00
parent 4758eb3215
commit 2e052480c2
64 changed files with 5196 additions and 1693 deletions
@@ -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())
+269
View File
@@ -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())
+659
View File
@@ -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())
+124
View File
@@ -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)