461 lines
15 KiB
Python
461 lines
15 KiB
Python
# CelerisLab/tests/run_inlet_ghost_timing_experiment.py
|
|
"""Minimal ghost-inlet timing experiments (post field-diagnostic).
|
|
|
|
Experiment 1 — DDF time series at inlet center vs first interior column:
|
|
(x=0, y=NY/2) and (x=1, y=NY/2), steps 1..N.
|
|
Populations f1,f2,f5,f6,f7,f8 plus rho, ux (macro and sum f).
|
|
|
|
Experiment 2 — Same channel with ``inlet.collide=false`` vs ``true``:
|
|
When collide is on, inlet ghost nodes undergo collision after Zou-He BC.
|
|
Compare rho_max / ux_max vs step to test ghost-source timing hypothesis.
|
|
|
|
Usage::
|
|
|
|
conda run -n pycuda_3_10 python tests/run_inlet_ghost_timing_experiment.py
|
|
conda run -n pycuda_3_10 python tests/run_inlet_ghost_timing_experiment.py --exp 1 --steps 50
|
|
conda run -n pycuda_3_10 python tests/run_inlet_ghost_timing_experiment.py --exp 2 --steps 500
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
|
import numpy as np
|
|
|
|
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
_DEFAULT_LBM = os.path.join(_REPO, "src", "CelerisLab", "configs", "config_lbm.json")
|
|
|
|
# D2Q9 indices logged (see zou_he_velocity.cuh).
|
|
POP_IDX: Tuple[int, ...] = (1, 2, 5, 6, 7, 8)
|
|
POP_NAMES: Tuple[str, ...] = tuple(f"f{i}" for i in POP_IDX)
|
|
|
|
# cx, cy for macroscopic ux, uy from local f (matches descriptors.cuh D2Q9).
|
|
_CX = np.array([0, 1, -1, 0, 0, 1, -1, 1, -1], dtype=np.float64)
|
|
_CY = np.array([0, 0, 0, 1, -1, 1, -1, -1, 1], dtype=np.float64)
|
|
|
|
|
|
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:
|
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as f:
|
|
json.dump(payload, f, indent=2)
|
|
|
|
|
|
def _build_cfg(
|
|
base: dict,
|
|
*,
|
|
nx: int,
|
|
ny: int,
|
|
collision: str,
|
|
inlet_collide: bool,
|
|
velocity: float,
|
|
viscosity: float,
|
|
) -> dict:
|
|
cfg = json.loads(json.dumps(base))
|
|
cfg["grid"]["nx"] = int(nx)
|
|
cfg["grid"]["ny"] = int(ny)
|
|
cfg["grid"]["nz"] = 1
|
|
cfg["physics"]["velocity"] = float(velocity)
|
|
cfg["physics"]["viscosity"] = float(viscosity)
|
|
cfg["physics"]["rho"] = 1.0
|
|
cfg["method"]["collision"] = str(collision).upper()
|
|
cfg["method"]["streaming"] = "double_buffer"
|
|
cfg["method"]["store_precision"] = "FP32"
|
|
cfg["method"]["les"]["enabled"] = False
|
|
cfg["method"]["inlet"]["profile"] = "uniform"
|
|
cfg["method"]["inlet"]["scheme"] = "zou_he_local"
|
|
cfg["method"]["inlet"]["collide"] = bool(inlet_collide)
|
|
cfg["method"]["y_wall_bc"] = "free_slip"
|
|
cfg["method"]["outlet"]["mode"] = "neq_extrap"
|
|
return cfg
|
|
|
|
|
|
def _macro_from_f(f: np.ndarray) -> Tuple[float, float, float]:
|
|
f = np.asarray(f, dtype=np.float64)
|
|
rho = float(np.sum(f))
|
|
if abs(rho) < 1e-14:
|
|
return rho, 0.0, 0.0
|
|
ux = float(np.dot(f, _CX) / rho)
|
|
uy = float(np.dot(f, _CY) / rho)
|
|
return rho, ux, uy
|
|
|
|
|
|
def _sample_node(ddf_qnyx: np.ndarray, x: int, y: int) -> Dict[str, float]:
|
|
f = ddf_qnyx[:, y, x].astype(np.float64)
|
|
rho_m, ux_m, uy_m = _macro_from_f(f)
|
|
out: Dict[str, float] = {
|
|
"rho_sum": rho_m,
|
|
"ux_macro": ux_m,
|
|
"uy_macro": uy_m,
|
|
}
|
|
for i, name in zip(POP_IDX, POP_NAMES):
|
|
out[name] = float(f[i])
|
|
return out
|
|
|
|
|
|
def _run_steps(
|
|
cfg: dict,
|
|
*,
|
|
steps: int,
|
|
y_mid: int,
|
|
probe_x: Sequence[int] = (0, 1),
|
|
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
|
|
sys.path.insert(0, os.path.join(_REPO, "src"))
|
|
from CelerisLab import Simulation # noqa: WPS433
|
|
|
|
bdoc = {"objects": []}
|
|
tmpd = tempfile.mkdtemp(prefix="ghost_timing_")
|
|
lbm_tmp = os.path.join(tmpd, "config_lbm.json")
|
|
body_tmp = os.path.join(tmpd, "config_body.json")
|
|
with open(lbm_tmp, "w", encoding="utf-8") as f:
|
|
json.dump(cfg, f)
|
|
with open(body_tmp, "w", encoding="utf-8") as f:
|
|
json.dump(bdoc, f)
|
|
|
|
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
|
|
sim.initialize()
|
|
nx, ny = sim.lbm_cfg.nx, sim.lbm_cfg.ny
|
|
y_mid = int(np.clip(y_mid, 1, ny - 2))
|
|
|
|
rows: List[Dict[str, Any]] = []
|
|
for step in range(1, int(steps) + 1):
|
|
sim.step(1)
|
|
sim.field.download_ddf()
|
|
farr = sim.field.ddf.reshape(sim.lbm_cfg.nq, ny, nx)
|
|
|
|
rec: Dict[str, Any] = {"step": step}
|
|
for x in probe_x:
|
|
tag = "inlet" if x == 0 else "interior"
|
|
s = _sample_node(farr, int(x), y_mid)
|
|
for k, v in s.items():
|
|
rec[f"{tag}_{k}"] = v
|
|
|
|
# Pull semantics at interior: f[2] is read from stored f[2] at x=0 (same link index).
|
|
rec["cross_f2_match"] = abs(rec["interior_f2"] - rec["inlet_f2"]) < 1e-5
|
|
rec["cross_f1_inlet_to_int_pull"] = float(
|
|
farr[1, y_mid, 1]
|
|
) # after step, what x=1 holds in f1
|
|
rec["delta_inlet_f1"] = (
|
|
float("nan") if step == 1 else rec["inlet_f1"] - rows[-1]["inlet_f1"]
|
|
)
|
|
rec["delta_inlet_ux"] = (
|
|
float("nan")
|
|
if step == 1
|
|
else rec["inlet_ux_macro"] - rows[-1]["inlet_ux_macro"]
|
|
)
|
|
|
|
macro = sim.get_macroscopic()
|
|
rho_f = np.asarray(macro["rho"], dtype=np.float64)
|
|
ux_f = np.asarray(macro["ux"], dtype=np.float64)
|
|
rec["domain_rho_max"] = float(np.nanmax(rho_f))
|
|
rec["domain_rho_min"] = float(np.nanmin(rho_f))
|
|
rec["domain_ux_max"] = float(np.nanmax(np.abs(ux_f)))
|
|
rec["finite"] = bool(np.isfinite(rho_f).all() and np.isfinite(ux_f).all())
|
|
|
|
rows.append(rec)
|
|
|
|
meta = {
|
|
"nx": nx,
|
|
"ny": ny,
|
|
"y_mid": y_mid,
|
|
"probe_x": list(probe_x),
|
|
"inlet_collide": bool(cfg["method"]["inlet"].get("collide", False)),
|
|
"collision": cfg["method"]["collision"],
|
|
"inlet_scheme": cfg["method"]["inlet"]["scheme"],
|
|
"y_wall_bc": cfg["method"]["y_wall_bc"],
|
|
"outlet_mode": cfg["method"]["outlet"]["mode"],
|
|
"velocity": cfg["physics"]["velocity"],
|
|
"viscosity": cfg["physics"]["viscosity"],
|
|
}
|
|
sim.close()
|
|
return rows, meta
|
|
|
|
|
|
def _write_csv(path: str, rows: Sequence[Dict[str, Any]]) -> None:
|
|
if not rows:
|
|
return
|
|
keys: List[str] = []
|
|
for r in rows:
|
|
for k in r:
|
|
if k not in keys:
|
|
keys.append(k)
|
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
|
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
w = csv.DictWriter(f, fieldnames=keys)
|
|
w.writeheader()
|
|
w.writerows(rows)
|
|
|
|
|
|
def _plot_exp1(out_dir: str, rows: Sequence[Dict[str, Any]], y_mid: int) -> List[str]:
|
|
try:
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
except ImportError:
|
|
return []
|
|
|
|
steps = [int(r["step"]) for r in rows]
|
|
paths: List[str] = []
|
|
|
|
def _ts(key: str, label: str, ax, **kw):
|
|
ax.plot(steps, [r[key] for r in rows], label=label, **kw)
|
|
|
|
# Populations
|
|
fig, axes = plt.subplots(2, 1, figsize=(11, 7), sharex=True)
|
|
for name in POP_NAMES:
|
|
_ts(f"inlet_{name}", f"inlet {name}", axes[0], linewidth=1.0)
|
|
axes[0].set_ylabel("f at x=0")
|
|
axes[0].legend(ncol=3, fontsize=7, loc="best")
|
|
axes[0].grid(True, alpha=0.3)
|
|
for name in POP_NAMES:
|
|
_ts(f"interior_{name}", f"int {name}", axes[1], linewidth=1.0)
|
|
axes[1].set_ylabel("f at x=1")
|
|
axes[1].set_xlabel("step")
|
|
axes[1].legend(ncol=3, fontsize=7, loc="best")
|
|
axes[1].grid(True, alpha=0.3)
|
|
fig.suptitle(f"Exp1 populations (y={y_mid})")
|
|
fig.tight_layout()
|
|
p1 = os.path.join(out_dir, "exp1_populations.png")
|
|
fig.savefig(p1, dpi=150, bbox_inches="tight")
|
|
plt.close(fig)
|
|
paths.append(p1)
|
|
|
|
# rho / ux + step-to-step deltas
|
|
fig, axes = plt.subplots(3, 1, figsize=(11, 8), sharex=True)
|
|
_ts("inlet_ux_macro", "inlet ux", axes[0])
|
|
_ts("interior_ux_macro", "interior ux", axes[0])
|
|
axes[0].axhline(0.03, color="k", ls="--", lw=0.8, label="U0")
|
|
axes[0].set_ylabel("u_x")
|
|
axes[0].legend(fontsize=8)
|
|
axes[0].grid(True, alpha=0.3)
|
|
_ts("inlet_rho_sum", "inlet rho", axes[1])
|
|
_ts("interior_rho_sum", "interior rho", axes[1])
|
|
axes[1].set_ylabel("rho")
|
|
axes[1].grid(True, alpha=0.3)
|
|
_ts("delta_inlet_f1", "|Δf1| inlet", axes[2])
|
|
axes[2].set_ylabel("Δf1")
|
|
axes[2].set_xlabel("step")
|
|
axes[2].grid(True, alpha=0.3)
|
|
fig.suptitle(f"Exp1 macro / inlet f1 increment (y={y_mid})")
|
|
fig.tight_layout()
|
|
p2 = os.path.join(out_dir, "exp1_macro_delta.png")
|
|
fig.savefig(p2, dpi=150, bbox_inches="tight")
|
|
plt.close(fig)
|
|
paths.append(p2)
|
|
|
|
return paths
|
|
|
|
|
|
def _plot_exp2(out_dir: str, rows_a: Sequence[Dict[str, Any]], rows_b: Sequence[Dict[str, Any]]) -> List[str]:
|
|
try:
|
|
import matplotlib
|
|
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
except ImportError:
|
|
return []
|
|
|
|
fig, axes = plt.subplots(2, 1, figsize=(10, 6), sharex=True)
|
|
for rows, lab, c in (
|
|
(rows_a, "ghost (no collide)", "C0"),
|
|
(rows_b, "inlet collide", "C1"),
|
|
):
|
|
steps = [int(r["step"]) for r in rows]
|
|
axes[0].plot(steps, [r["domain_rho_max"] for r in rows], label=lab, color=c)
|
|
axes[1].plot(steps, [r["domain_ux_max"] for r in rows], label=lab, color=c)
|
|
axes[0].set_ylabel("rho_max")
|
|
axes[0].legend()
|
|
axes[0].grid(True, alpha=0.3)
|
|
axes[1].set_ylabel("|ux|_max")
|
|
axes[1].set_xlabel("step")
|
|
axes[1].legend()
|
|
axes[1].grid(True, alpha=0.3)
|
|
fig.suptitle("Exp2: ghost vs inlet collide")
|
|
fig.tight_layout()
|
|
p = os.path.join(out_dir, "exp2_stability_compare.png")
|
|
fig.savefig(p, dpi=150, bbox_inches="tight")
|
|
plt.close(fig)
|
|
return [p]
|
|
|
|
|
|
def _oscillation_summary(rows: Sequence[Dict[str, Any]], *, last_n: int = 20) -> Dict[str, float]:
|
|
"""High-frequency proxy: std of inlet f1 and ux over the last *last_n* steps."""
|
|
if len(rows) < 2:
|
|
return {}
|
|
tail = rows[-min(last_n, len(rows)) :]
|
|
f1 = np.array([r["inlet_f1"] for r in tail], dtype=np.float64)
|
|
ux = np.array([r["inlet_ux_macro"] for r in tail], dtype=np.float64)
|
|
d1 = np.array([r["delta_inlet_f1"] for r in tail if np.isfinite(r["delta_inlet_f1"])], dtype=np.float64)
|
|
return {
|
|
"std_inlet_f1_last": float(np.std(f1)),
|
|
"std_inlet_ux_last": float(np.std(ux)),
|
|
"mean_abs_delta_f1_last": float(np.mean(np.abs(d1))) if d1.size else float("nan"),
|
|
}
|
|
|
|
|
|
def run_exp1(
|
|
base: dict,
|
|
*,
|
|
out_dir: str,
|
|
nx: int,
|
|
ny: int,
|
|
steps: int,
|
|
collision: str,
|
|
velocity: float,
|
|
viscosity: float,
|
|
) -> None:
|
|
y_mid = ny // 2
|
|
cfg = _build_cfg(
|
|
base,
|
|
nx=nx,
|
|
ny=ny,
|
|
collision=collision,
|
|
inlet_collide=False,
|
|
velocity=velocity,
|
|
viscosity=viscosity,
|
|
)
|
|
print(f"Exp1: zou_he ghost inlet, y_mid={y_mid}, steps={steps}", flush=True)
|
|
rows, meta = _run_steps(cfg, steps=steps, y_mid=y_mid)
|
|
meta["oscillation"] = _oscillation_summary(rows)
|
|
|
|
exp_dir = os.path.join(out_dir, "exp1_ddf_timeseries")
|
|
os.makedirs(exp_dir, exist_ok=True)
|
|
_write_csv(os.path.join(exp_dir, "timeseries.csv"), rows)
|
|
_write_json(os.path.join(exp_dir, "meta.json"), meta)
|
|
plots = _plot_exp1(exp_dir, rows, y_mid)
|
|
for p in plots:
|
|
print(f" plot: {p}", flush=True)
|
|
|
|
# Console summary for quick read
|
|
print(" last 5 steps (inlet center):", flush=True)
|
|
for r in rows[-5:]:
|
|
print(
|
|
f" step {r['step']:3d} f1={r['inlet_f1']:.6f} ux={r['inlet_ux_macro']:.6f} "
|
|
f"Δf1={r['delta_inlet_f1']:.2e} rho_max={r['domain_rho_max']:.4f} finite={r['finite']}",
|
|
flush=True,
|
|
)
|
|
print(f" oscillation: {meta['oscillation']}", flush=True)
|
|
print(f"Wrote: {exp_dir}/timeseries.csv", flush=True)
|
|
|
|
|
|
def run_exp2(
|
|
base: dict,
|
|
*,
|
|
out_dir: str,
|
|
nx: int,
|
|
ny: int,
|
|
steps: int,
|
|
collision: str,
|
|
velocity: float,
|
|
viscosity: float,
|
|
) -> None:
|
|
y_mid = ny // 2
|
|
exp_dir = os.path.join(out_dir, "exp2_inlet_collide")
|
|
os.makedirs(exp_dir, exist_ok=True)
|
|
|
|
summaries: Dict[str, Any] = {}
|
|
all_rows: Dict[str, List[Dict[str, Any]]] = {}
|
|
|
|
for collide, tag in ((False, "ghost_no_collide"), (True, "ghost_with_collide")):
|
|
cfg = _build_cfg(
|
|
base,
|
|
nx=nx,
|
|
ny=ny,
|
|
collision=collision,
|
|
inlet_collide=collide,
|
|
velocity=velocity,
|
|
viscosity=viscosity,
|
|
)
|
|
print(f"Exp2 [{tag}]: inlet.collide={collide}, steps={steps}", flush=True)
|
|
rows, meta = _run_steps(cfg, steps=steps, y_mid=y_mid)
|
|
all_rows[tag] = rows
|
|
_write_csv(os.path.join(exp_dir, f"{tag}.csv"), rows)
|
|
|
|
last_finite = next(
|
|
(int(r["step"]) for r in rows if not r.get("finite", True)),
|
|
None,
|
|
)
|
|
summaries[tag] = {
|
|
**meta,
|
|
"first_nonfinite_step": last_finite,
|
|
"final_rho_max": rows[-1]["domain_rho_max"] if rows else None,
|
|
"final_finite": rows[-1].get("finite") if rows else None,
|
|
"oscillation": _oscillation_summary(rows),
|
|
}
|
|
print(
|
|
f" final rho_max={summaries[tag]['final_rho_max']:.4f} "
|
|
f"finite={summaries[tag]['final_finite']} "
|
|
f"first_nonfinite={summaries[tag]['first_nonfinite_step']}",
|
|
flush=True,
|
|
)
|
|
|
|
_write_json(os.path.join(exp_dir, "summary.json"), summaries)
|
|
plots = _plot_exp2(exp_dir, all_rows["ghost_no_collide"], all_rows["ghost_with_collide"])
|
|
for p in plots:
|
|
print(f" plot: {p}", flush=True)
|
|
print(f"Wrote: {exp_dir}/summary.json", flush=True)
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description="Ghost inlet timing experiments")
|
|
ap.add_argument("--exp", choices=("1", "2", "all"), default="all")
|
|
ap.add_argument("--steps", type=int, default=50, help="Steps for exp1 (default 50)")
|
|
ap.add_argument("--steps2", type=int, default=500, help="Steps for exp2 (default 500)")
|
|
ap.add_argument("--nx", type=int, default=401)
|
|
ap.add_argument("--ny", type=int, default=201)
|
|
ap.add_argument("--collision", default="MRT", choices=("SRT", "TRT", "MRT"))
|
|
ap.add_argument("--velocity", type=float, default=0.03)
|
|
ap.add_argument("--viscosity", type=float, default=0.009)
|
|
ap.add_argument(
|
|
"--out-dir",
|
|
default=os.path.join(_REPO, "tests", "output", "inlet_ghost_timing"),
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
if not os.path.isfile(_DEFAULT_LBM):
|
|
print(f"Missing {_DEFAULT_LBM}", file=sys.stderr)
|
|
return 2
|
|
|
|
base = _load_json(_DEFAULT_LBM)
|
|
out_dir = os.path.abspath(args.out_dir)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
if args.exp in ("1", "all"):
|
|
run_exp1(
|
|
base,
|
|
out_dir=out_dir,
|
|
nx=args.nx,
|
|
ny=args.ny,
|
|
steps=args.steps,
|
|
collision=args.collision,
|
|
velocity=args.velocity,
|
|
viscosity=args.viscosity,
|
|
)
|
|
if args.exp in ("2", "all"):
|
|
run_exp2(
|
|
base,
|
|
out_dir=out_dir,
|
|
nx=args.nx,
|
|
ny=args.ny,
|
|
steps=args.steps2,
|
|
collision=args.collision,
|
|
velocity=args.velocity,
|
|
viscosity=args.viscosity,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|