zou_he inlet无法保障稳定,加入新的inlet模式

This commit is contained in:
Frank14f
2026-05-18 17:51:46 +08:00
parent ce492f2794
commit 50b2b6a7ca
26 changed files with 2466 additions and 395 deletions
+75
View File
@@ -0,0 +1,75 @@
## Inlet module refactor plan
The inlet module should be treated as a source-state generator for west ghost nodes, not as a grab-bag of formulas inside one boundary file. In the current solver, inlet cells are `SOLID | BC_INLET` ghost nodes whose state is later pulled by the first interior column. That semantic should stay explicit in the code structure.
## Current target structure
- `boundary/inlet/common`
- shared profile logic such as `inlet_target_u(y)`
- shared rho-closure helpers for west velocity inlet
- helper stating whether a scheme requires post-BC ghost collision
- `boundary/inlet/zou_he_local`
- local on-site Zou-He source-state reconstruction
- D2Q9 and D3Q19 west inlet versions
- `boundary/inlet/channel_stabilized`
- donor-based stabilized inlet for high blockage or conservative production runs
- D2Q9 and D3Q19 west inlet versions
- `boundary/inlet/equilibrium`
- full `feq` source-state construction from local rho closure and target velocity
- D2Q9 and D3Q19 versions
- `boundary/inlet/regularized`
- local macro state plus damped donor NEQ on incoming directions
- D2Q9 and D3Q19 versions
- `boundary/outlet/pressure_neq`
- pressure outlet and zero-gradient outlet implementations
- `boundary/inlet_outlet`
- compile-time dispatch only
- no long method bodies
- streaming-specific donor assembly and method selection
## Design rule for each inlet scheme
Each scheme should answer the same question:
- given a west ghost node after pull loading, what source state should be stored there for the next interior pull
That makes the interface stable even when methods differ in how much donor information they use.
## Scheme meanings
| Scheme | Main idea | Best fit | Main caution |
|---|---|---|---|
| `zou_he_local` | textbook local algebraic closure | MRT, research comparisons, clean local baseline | in ghost-source semantics it requires post-BC ghost collision and can be noisy for high-omega SRT |
| `channel_stabilized` | donor-based stabilized inlet | high blockage, production robustness, conservative benchmark work | less pure as a local boundary method |
| `equilibrium` | write full `feq` source state | robust SRT baseline, simple ghost-source compatibility | may suppress inlet NEQ too aggressively for some validation targets |
| `regularized` | local macro state plus damped incoming donor NEQ | middle ground between `equilibrium` and donor-heavy methods | still an experimental family and may need tuning |
## Collision policy
Post-BC ghost collision must be owned by the scheme, not hard-coded as a general inlet rule.
Current policy:
- `zou_he_local` requires post-BC ghost collision
- `channel_stabilized` does not
- `equilibrium` does not
- `regularized` does not
This should remain encoded through a helper such as `inlet_scheme_uses_post_collision_ghost()` rather than scattered `INLET_SCHEME == ...` checks.
## Why this split matters
The earlier instability work showed that the main difficulty was not a single formula error. The real issue was mixing methods that assume different node semantics:
- local fluid-boundary formulas such as Zou-He
- ghost-source node architecture in the solver
- different collision-model tolerances, especially SRT versus MRT
Keeping each inlet method in its own file makes those assumptions visible and lowers the chance of mixing donor and ghost semantics by accident.
## Next cleanups worth doing later
1. Split outlet schemes into separate files as more outlet variants are added.
2. If inlet junction handling grows, move row-specific or corner-specific logic into dedicated helpers instead of embedding it inside the main schemes.
3. When validation settles, add a small test matrix document mapping recommended schemes to benchmark families and collision models.
4. If the solver later moves away from ghost-source inlet nodes, keep this folder layout but replace the per-scheme internals rather than rebuilding the whole dispatch layer.
+574
View File
@@ -0,0 +1,574 @@
# CelerisLab/tests/run_inlet_channel_diagnostic.py
"""Empty-channel inlet diagnostic: field snapshots and line profiles.
Runs no-cylinder channel flows to isolate inlet / wall / outlet effects before
adding a body. See user matrix in module docstring sections AC.
Usage::
conda run -n pycuda_3_10 python tests/run_inlet_channel_diagnostic.py --part all
conda run -n pycuda_3_10 python tests/run_inlet_channel_diagnostic.py --part a
conda run -n pycuda_3_10 python tests/run_inlet_channel_diagnostic.py --part b --nx 401 --ny 201
Output (default ``tests/output/inlet_channel_diag/``)::
A_baseline/{SRT,MRT}/snapshots/step_XXXXXX.{npz,png}
B_matrix/caseNN_.../snapshots/...
B_matrix/caseNN_.../lines/ux_lines_stepXXXXXX.png
summary.csv
"""
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, Iterable, 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")
# Default snapshot steps for parts A and B.
DEFAULT_SNAPSHOT_STEPS: Tuple[int, ...] = (100, 500, 1000, 1500, 2000)
@dataclass(frozen=True)
class CaseSpec:
"""One empty-channel configuration."""
case_id: str
label: str
inlet_scheme: str
y_wall_bc: str
outlet_mode: str
collision: str = "MRT"
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 vorticity_z(ux: np.ndarray, uy: np.ndarray) -> np.ndarray:
"""ωz = ∂uy/∂x ∂ux/∂y on (ny, nx) arrays."""
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 _line_y_indices(ny: int) -> List[Tuple[int, str]]:
return [
(1, "y1"),
(ny // 2, f"y{ny // 2}"),
(ny - 2, f"y{ny - 2}"),
]
def _build_cfg(
base: dict,
*,
nx: int,
ny: int,
collision: str,
inlet_scheme: str,
inlet_profile: str,
y_wall_bc: str,
outlet_mode: str,
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"] = str(inlet_profile)
cfg["method"]["inlet"]["scheme"] = str(inlet_scheme)
cfg["method"]["y_wall_bc"] = str(y_wall_bc)
cfg["method"]["outlet"]["mode"] = str(outlet_mode)
return cfg
def _field_stats(rho: np.ndarray, ux: np.ndarray, vort: np.ndarray) -> Dict[str, float]:
def _f(a: np.ndarray) -> float:
b = a[np.isfinite(a)]
return float("nan") if b.size == 0 else float(np.max(np.abs(b)))
return {
"rho_min": float(np.nanmin(rho)) if np.isfinite(rho).any() else float("nan"),
"rho_max": float(np.nanmax(rho)) if np.isfinite(rho).any() else float("nan"),
"ux_max": _f(ux),
"vort_max": _f(vort),
"finite": bool(np.isfinite(rho).all() and np.isfinite(ux).all()),
}
def _save_snapshot_npz(
path: str,
*,
step: int,
rho: np.ndarray,
ux: np.ndarray,
uy: np.ndarray,
vort: np.ndarray,
meta: dict,
) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
np.savez_compressed(
path,
rho=np.asarray(rho, dtype=np.float32),
ux=np.asarray(ux, dtype=np.float32),
uy=np.asarray(uy, dtype=np.float32),
vort=np.asarray(vort, dtype=np.float32),
step=np.int32(step),
meta_json=np.asarray(json.dumps(meta)),
)
def _save_field_pngs(
out_dir: str,
prefix: str,
*,
rho: np.ndarray,
ux: np.ndarray,
vort: np.ndarray,
title: str,
) -> List[str]:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return []
os.makedirs(out_dir, exist_ok=True)
ny, nx = rho.shape
extent = (0, nx - 1, 0, ny - 1)
paths: List[str] = []
def _one(arr: np.ndarray, name: str, cmap: str, sym: bool) -> None:
a = np.asarray(arr, dtype=np.float64)
fin = a[np.isfinite(a)]
if fin.size == 0:
vmin, vmax = -1.0, 1.0
elif sym:
v = float(np.percentile(np.abs(fin), 99.5)) or 1.0
vmin, vmax = -v, v
else:
vmin = float(np.percentile(fin, 0.5))
vmax = float(np.percentile(fin, 99.5))
if vmax <= vmin:
vmax = vmin + 1.0
fig, ax = plt.subplots(figsize=(min(16.0, max(8.0, nx / 80.0)), min(8.0, max(3.0, ny / 50.0))))
im = ax.imshow(a, origin="lower", aspect="auto", cmap=cmap, vmin=vmin, vmax=vmax, extent=extent)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title(f"{title}{name}")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.tight_layout()
p = os.path.join(out_dir, f"{prefix}_{name}.png")
fig.savefig(p, dpi=140, bbox_inches="tight")
plt.close(fig)
paths.append(p)
_one(rho, "rho", "viridis", sym=False)
_one(ux, "ux", "RdBu_r", sym=True)
_one(vort, "vort", "RdBu_r", sym=True)
return paths
def _save_line_plots(
path: str,
*,
rho: np.ndarray,
ux: np.ndarray,
step: int,
case_label: str,
y_rows: Sequence[Tuple[int, str]],
) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return
ny, nx = rho.shape
x = np.arange(nx, dtype=np.float64)
fig, axes = plt.subplots(2, 1, figsize=(min(14.0, max(8.0, nx / 60.0)), 7.0), sharex=True)
for y_idx, y_lab in y_rows:
y_idx = int(np.clip(y_idx, 0, ny - 1))
axes[0].plot(x, ux[y_idx, :], label=y_lab, linewidth=1.2)
axes[1].plot(x, rho[y_idx, :], label=y_lab, linewidth=1.2)
axes[0].set_ylabel("u_x")
axes[0].legend(loc="best", fontsize=8)
axes[0].grid(True, alpha=0.3)
axes[1].set_ylabel("rho")
axes[1].set_xlabel("x (lattice)")
axes[1].legend(loc="best", fontsize=8)
axes[1].grid(True, alpha=0.3)
fig.suptitle(f"{case_label} — line profiles at step {step}")
fig.tight_layout()
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def _run_channel(
case: CaseSpec,
*,
base_cfg: dict,
nx: int,
ny: int,
velocity: float,
viscosity: float,
out_root: str,
snapshot_steps: Sequence[int],
max_step: int,
save_png: bool,
save_lines: bool,
stop_on_nan: bool,
) -> List[Dict[str, Any]]:
"""Run one case; write snapshots and optional line plots. Return summary rows."""
sys.path.insert(0, os.path.join(_REPO, "src"))
from CelerisLab import Simulation # noqa: WPS433
cfg = _build_cfg(
base_cfg,
nx=nx,
ny=ny,
collision=case.collision,
inlet_scheme=case.inlet_scheme,
inlet_profile="uniform",
y_wall_bc=case.y_wall_bc,
outlet_mode=case.outlet_mode,
velocity=velocity,
viscosity=viscosity,
)
bdoc = {"objects": []}
run_dir = os.path.join(out_root, case.case_id)
snap_dir = os.path.join(run_dir, "snapshots")
line_dir = os.path.join(run_dir, "lines")
os.makedirs(snap_dir, exist_ok=True)
if save_lines:
os.makedirs(line_dir, exist_ok=True)
tmpd = tempfile.mkdtemp(prefix="inlet_diag_")
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, bdoc)
meta_base = {
"case_id": case.case_id,
"label": case.label,
"nx": nx,
"ny": ny,
"inlet_scheme": case.inlet_scheme,
"inlet_profile": "uniform",
"y_wall_bc": case.y_wall_bc,
"outlet_mode": case.outlet_mode,
"collision": case.collision,
"velocity": velocity,
"viscosity": viscosity,
}
_write_json(os.path.join(run_dir, "case_meta.json"), meta_base)
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
sim.initialize()
y_rows = _line_y_indices(ny)
want_steps = sorted({int(s) for s in snapshot_steps if 0 < int(s) <= max_step})
next_snap = 0
rows: List[Dict[str, Any]] = []
for step in range(1, max_step + 1):
sim.step(1)
if next_snap < len(want_steps) and step == want_steps[next_snap]:
macro = sim.get_macroscopic()
rho = np.asarray(macro["rho"], dtype=np.float64)
ux = np.asarray(macro["ux"], dtype=np.float64)
uy = np.asarray(macro["uy"], dtype=np.float64)
vort = vorticity_z(ux, uy)
stats = _field_stats(rho, ux, vort)
stem = f"step_{step:06d}"
meta = {**meta_base, "step": step, **stats}
npz_path = os.path.join(snap_dir, f"{stem}.npz")
_save_snapshot_npz(
npz_path,
step=step,
rho=rho,
ux=ux,
uy=uy,
vort=vort,
meta=meta,
)
if save_png:
_save_field_pngs(
snap_dir,
stem,
rho=rho,
ux=ux,
vort=vort,
title=f"{case.label} step {step}",
)
if save_lines:
line_png = os.path.join(line_dir, f"lines_{stem}.png")
_save_line_plots(
line_png,
rho=rho,
ux=ux,
step=step,
case_label=case.label,
y_rows=y_rows,
)
# Also save raw 1D data for replotting.
line_npz = os.path.join(line_dir, f"lines_{stem}.npz")
payload = {"x": np.arange(nx, dtype=np.float32)}
for y_idx, y_lab in y_rows:
payload[f"ux_{y_lab}"] = ux[y_idx, :].astype(np.float32)
payload[f"rho_{y_lab}"] = rho[y_idx, :].astype(np.float32)
payload["step"] = np.int32(step)
np.savez_compressed(line_npz, **payload)
rows.append(
{
"case_id": case.case_id,
"label": case.label,
"collision": case.collision,
"inlet_scheme": case.inlet_scheme,
"y_wall_bc": case.y_wall_bc,
"outlet_mode": case.outlet_mode,
"step": step,
**stats,
"npz": npz_path,
}
)
if stop_on_nan and not stats["finite"]:
sim.close()
rows[-1]["stopped_early"] = True
return rows
next_snap += 1
sim.close()
return rows
def _part_a_cases() -> List[CaseSpec]:
# Kan99b-style baseline: zou_he + free_slip + neq_extrap; SRT and MRT.
base = CaseSpec(
case_id="",
label="",
inlet_scheme="zou_he_local",
y_wall_bc="free_slip",
outlet_mode="neq_extrap",
)
out: List[CaseSpec] = []
for coll in ("SRT", "MRT"):
cid = f"A_{coll.lower()}_zouhe_fs_neq"
out.append(
CaseSpec(
case_id=cid,
label=f"A baseline {coll} zou_he / free_slip / neq_extrap",
inlet_scheme=base.inlet_scheme,
y_wall_bc=base.y_wall_bc,
outlet_mode=base.outlet_mode,
collision=coll,
)
)
return out
def _part_b_cases() -> List[CaseSpec]:
return [
CaseSpec(
case_id="B_case01_zouhe_fs_neq",
label="1 zou_he / free_slip / neq_extrap",
inlet_scheme="zou_he_local",
y_wall_bc="free_slip",
outlet_mode="neq_extrap",
collision="MRT",
),
CaseSpec(
case_id="B_case02_zouhe_bb_neq",
label="2 zou_he / bounce_back / neq_extrap",
inlet_scheme="zou_he_local",
y_wall_bc="bounce_back",
outlet_mode="neq_extrap",
collision="MRT",
),
CaseSpec(
case_id="B_case03_zouhe_fs_zgrad",
label="3 zou_he / free_slip / zero_gradient",
inlet_scheme="zou_he_local",
y_wall_bc="free_slip",
outlet_mode="zero_gradient",
collision="MRT",
),
CaseSpec(
case_id="B_case04_stab_fs_neq",
label="4 channel_stabilized / free_slip / neq_extrap",
inlet_scheme="channel_stabilized",
y_wall_bc="free_slip",
outlet_mode="neq_extrap",
collision="MRT",
),
]
def _write_summary_csv(path: str, rows: Sequence[Dict[str, Any]]) -> None:
if not rows:
return
keys = [
"case_id",
"label",
"collision",
"inlet_scheme",
"y_wall_bc",
"outlet_mode",
"step",
"finite",
"rho_min",
"rho_max",
"ux_max",
"vort_max",
"stopped_early",
]
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, extrasaction="ignore")
w.writeheader()
for r in rows:
w.writerow(r)
def main() -> int:
ap = argparse.ArgumentParser(description="Empty-channel inlet diagnostic (fields + line profiles)")
ap.add_argument(
"--part",
choices=("a", "b", "all"),
default="all",
help="A=baseline SRT/MRT; B=4-case matrix; all=both",
)
ap.add_argument("--nx", type=int, default=401, help="Channel length (lattice)")
ap.add_argument("--ny", type=int, default=201, help="Channel height (lattice)")
ap.add_argument("--velocity", type=float, default=0.03, help="Uniform inlet U0")
ap.add_argument("--viscosity", type=float, default=0.009, help="Kinematic viscosity")
ap.add_argument(
"--out-dir",
type=str,
default=os.path.join(_REPO, "tests", "output", "inlet_channel_diag"),
)
ap.add_argument(
"--steps",
type=str,
default="",
help="Comma-separated snapshot steps (default: 100,500,1000,1500,2000)",
)
ap.add_argument("--no-png", action="store_true", help="Skip rho/ux/vort PNG maps")
ap.add_argument("--no-lines", action="store_true", help="Skip ux/rho line-profile plots")
ap.add_argument(
"--continue-on-nan",
action="store_true",
help="Keep stepping after non-finite fields (default: stop case early)",
)
args = ap.parse_args()
if not os.path.isfile(_DEFAULT_LBM):
print(f"Missing config: {_DEFAULT_LBM}", file=sys.stderr)
return 2
if args.steps.strip():
snap_steps = tuple(int(s.strip()) for s in args.steps.split(",") if s.strip())
else:
snap_steps = DEFAULT_SNAPSHOT_STEPS
max_step = max(snap_steps)
base_cfg = _load_json(_DEFAULT_LBM)
out_dir = os.path.abspath(args.out_dir)
os.makedirs(out_dir, exist_ok=True)
cases: List[CaseSpec] = []
if args.part in ("a", "all"):
cases.extend(_part_a_cases())
if args.part in ("b", "all"):
cases.extend(_part_b_cases())
save_png = not args.no_png
# Part A: field maps only; Part B: fields + line plots.
all_rows: List[Dict[str, Any]] = []
for case in cases:
part = "A_baseline" if case.case_id.startswith("A_") else "B_matrix"
root = os.path.join(out_dir, part)
save_lines = not args.no_lines and part == "B_matrix"
print(f"--- {case.case_id}: {case.label} ({case.collision}) ---", flush=True)
rows = _run_channel(
case,
base_cfg=base_cfg,
nx=args.nx,
ny=args.ny,
velocity=args.velocity,
viscosity=args.viscosity,
out_root=root,
snapshot_steps=snap_steps,
max_step=max_step,
save_png=save_png,
save_lines=save_lines,
stop_on_nan=not args.continue_on_nan,
)
all_rows.extend(rows)
for r in rows:
fin = "OK" if r.get("finite") else "NONFINITE"
print(
f" step {r['step']:5d} {fin} rho=[{r['rho_min']:.4f},{r['rho_max']:.4f}] "
f"ux_max={r['ux_max']:.4f} vort_max={r['vort_max']:.4f}",
flush=True,
)
if rows and rows[-1].get("stopped_early"):
print(" (stopped early: non-finite fields)", flush=True)
summary_path = os.path.join(out_dir, "summary.csv")
_write_summary_csv(summary_path, all_rows)
manifest = {
"snapshot_steps": list(snap_steps),
"nx": args.nx,
"ny": args.ny,
"velocity": args.velocity,
"viscosity": args.viscosity,
"line_y_indices": [{"y": y, "label": lab} for y, lab in _line_y_indices(args.ny)],
"cases": [c.case_id for c in cases],
}
_write_json(os.path.join(out_dir, "manifest.json"), manifest)
print(f"Wrote: {summary_path}", flush=True)
print(f"Wrote: {os.path.join(out_dir, 'manifest.json')}", flush=True)
print(f"Output root: {out_dir}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+460
View File
@@ -0,0 +1,460 @@
# 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())
+587
View File
@@ -0,0 +1,587 @@
# CelerisLab/tests/run_inlet_scenario_fields.py
"""Three-scenario inlet field export: final or last-stable step at 5000 LBM steps.
Scenarios:
- empty_channel: zou_he_local × {SRT,MRT} × {free_slip,bounce_back}
- empty_channel_inlet_matrix: all inlet schemes × {SRT,MRT}, bounce_back only
- kan99b: zou_he_local × {SRT,MRT} × {free_slip,bounce_back}, Re=100, domain M
- sah04_case9: channel_stabilized × {SRT,MRT}, high-blockage case 9 geometry
Outputs per run (under ``--out-dir``):
fields/final_{rho,ux,vort}.png, fields/final.npz
lines/lines_ux_rho.png, lines/lines.npz
run_meta.json
Usage::
conda run -n pycuda_3_10 python tests/run_inlet_scenario_fields.py
conda run -n pycuda_3_10 python tests/run_inlet_scenario_fields.py --scenario empty_channel
conda run -n pycuda_3_10 python tests/run_inlet_scenario_fields.py --scenario empty_channel_inlet_matrix
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import tempfile
from dataclasses import dataclass, replace
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")
# Kan99b lattice contract
_KAN_U_INF = 0.03
_KAN_D = 30.0
_KAN_R = 15.0
_KAN_RE = 100.0
_KAN_ALPHA = 1.0
# Sah04 case 9 (high tier)
_SAH_D = 30
_SAH_NX = 80 * _SAH_D + 2
_SAH_NY = 35
_SAH_CX = 40.0 * _SAH_D + 0.5
_SAH_CY = 17.0
_SAH_RE = 200.0
_SAH_U_MAX = 0.1
@dataclass(frozen=True)
class RunSpec:
"""One simulation run specification."""
scenario: str
run_id: str
label: str
nx: int
ny: int
collision: str
inlet_scheme: str
inlet_profile: str
y_wall_bc: str
outlet_mode: str
velocity: float
viscosity: float
steps: int
has_cylinder: bool
cylinder_center: Tuple[float, float] = (0.0, 0.0)
cylinder_radius: float = 0.0
cylinder_omega: float = 0.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:
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 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 _line_y_indices(ny: int) -> List[Tuple[int, str]]:
return [(1, "y1"), (ny // 2, f"y{ny // 2}"), (ny - 2, f"y{ny - 2}")]
_INLET_SCHEMES = (
"zou_he_local",
"channel_stabilized",
"equilibrium",
"regularized",
)
def _empty_channel_inlet_matrix_specs() -> List[RunSpec]:
"""All inlet schemes on empty channel, bounce_back, SRT/MRT (5000-step field export)."""
specs: List[RunSpec] = []
for coll in ("SRT", "MRT"):
for scheme in _INLET_SCHEMES:
run_id = f"{coll.lower()}_{scheme}"
specs.append(
RunSpec(
scenario="empty_channel_inlet_matrix",
run_id=run_id,
label=f"empty {scheme} {coll} bounce_back",
nx=401,
ny=201,
collision=coll,
inlet_scheme=scheme,
inlet_profile="uniform",
y_wall_bc="bounce_back",
outlet_mode="neq_extrap",
velocity=0.03,
viscosity=0.009,
steps=5000,
has_cylinder=False,
)
)
return specs
def _all_specs() -> List[RunSpec]:
specs: List[RunSpec] = []
for coll in ("SRT", "MRT"):
for wall in ("free_slip", "bounce_back"):
wid = f"{coll.lower()}_{wall}"
specs.append(
RunSpec(
scenario="empty_channel",
run_id=wid,
label=f"empty zou_he {coll} {wall}",
nx=401,
ny=201,
collision=coll,
inlet_scheme="zou_he_local",
inlet_profile="uniform",
y_wall_bc=wall,
outlet_mode="neq_extrap",
velocity=0.03,
viscosity=0.009,
steps=5000,
has_cylinder=False,
)
)
dom_m = (1351, 601, (450.0, 300.0))
nu_k = _KAN_U_INF * _KAN_D / _KAN_RE
omega = 2.0 * _KAN_ALPHA * _KAN_U_INF / _KAN_D
for coll in ("SRT", "MRT"):
for wall in ("free_slip", "bounce_back"):
wid = f"{coll.lower()}_{wall}"
specs.append(
RunSpec(
scenario="kan99b",
run_id=wid,
label=f"kan99b zou_he {coll} {wall}",
nx=dom_m[0],
ny=dom_m[1],
collision=coll,
inlet_scheme="zou_he_local",
inlet_profile="uniform",
y_wall_bc=wall,
outlet_mode="neq_extrap",
velocity=_KAN_U_INF,
viscosity=nu_k,
steps=5000,
has_cylinder=True,
cylinder_center=dom_m[2],
cylinder_radius=_KAN_R,
cylinder_omega=omega,
)
)
u0_mean = _SAH_U_MAX / 1.5
nu_s = _SAH_U_MAX * _SAH_D / _SAH_RE
for coll in ("SRT", "MRT"):
wid = f"{coll.lower()}_channel_stab"
specs.append(
RunSpec(
scenario="sah04_case9",
run_id=wid,
label=f"sah04 case9 channel_stab {coll}",
nx=_SAH_NX,
ny=_SAH_NY,
collision=coll,
inlet_scheme="channel_stabilized",
inlet_profile="parabolic",
y_wall_bc="bounce_back",
outlet_mode="neq_extrap",
velocity=u0_mean,
viscosity=nu_s,
steps=5000,
has_cylinder=True,
cylinder_center=(_SAH_CX, _SAH_CY),
cylinder_radius=0.5 * _SAH_D,
cylinder_omega=0.0,
)
)
return specs
def _build_cfg(base: dict, spec: RunSpec) -> dict:
cfg = json.loads(json.dumps(base))
cfg["grid"]["nx"] = spec.nx
cfg["grid"]["ny"] = spec.ny
cfg["grid"]["nz"] = 1
cfg["physics"]["velocity"] = float(spec.velocity)
cfg["physics"]["viscosity"] = float(spec.viscosity)
cfg["physics"]["rho"] = 1.0
cfg["method"]["collision"] = spec.collision.upper()
cfg["method"]["streaming"] = "double_buffer"
cfg["method"]["store_precision"] = "FP32"
cfg["method"]["les"]["enabled"] = False
cfg["method"]["inlet"]["profile"] = spec.inlet_profile
cfg["method"]["inlet"]["scheme"] = spec.inlet_scheme
cfg["method"]["y_wall_bc"] = spec.y_wall_bc
cfg["method"]["outlet"]["mode"] = spec.outlet_mode
return cfg
def _body_doc(spec: RunSpec) -> dict:
if not spec.has_cylinder:
return {"objects": []}
return {
"objects": [
{
"type": "cylinder",
"center": [float(spec.cylinder_center[0]), float(spec.cylinder_center[1])],
"radius": float(spec.cylinder_radius),
"omega": float(spec.cylinder_omega),
}
]
}
def _save_field_pngs(
out_dir: str,
prefix: str,
*,
rho: np.ndarray,
ux: np.ndarray,
vort: np.ndarray,
title: str,
) -> List[str]:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return []
os.makedirs(out_dir, exist_ok=True)
ny, nx = rho.shape
extent = (0, nx - 1, 0, ny - 1)
paths: List[str] = []
def _one(arr: np.ndarray, name: str, cmap: str, sym: bool) -> None:
a = np.asarray(arr, dtype=np.float64)
fin = a[np.isfinite(a)]
if fin.size == 0:
vmin, vmax = -1.0, 1.0
elif sym:
v = float(np.percentile(np.abs(fin), 99.5)) or 1.0
vmin, vmax = -v, v
else:
vmin = float(np.percentile(fin, 0.5))
vmax = float(np.percentile(fin, 99.5))
if vmax <= vmin:
vmax = vmin + 1.0
fw = min(18.0, max(8.0, nx / 70.0))
fh = min(10.0, max(3.0, ny / 45.0))
fig, ax = plt.subplots(figsize=(fw, fh))
im = ax.imshow(a, origin="lower", aspect="auto", cmap=cmap, vmin=vmin, vmax=vmax, extent=extent)
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_title(f"{title}{name}")
fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
fig.tight_layout()
p = os.path.join(out_dir, f"{prefix}_{name}.png")
fig.savefig(p, dpi=150, bbox_inches="tight")
plt.close(fig)
paths.append(p)
_one(rho, "rho", "viridis", sym=False)
_one(ux, "ux", "RdBu_r", sym=True)
_one(vort, "vort", "RdBu_r", sym=True)
return paths
def _save_line_plots(
path: str,
*,
rho: np.ndarray,
ux: np.ndarray,
step: int,
label: str,
y_rows: Sequence[Tuple[int, str]],
) -> None:
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError:
return
ny, nx = rho.shape
x = np.arange(nx, dtype=np.float64)
fig, axes = plt.subplots(2, 1, figsize=(min(14.0, max(8.0, nx / 55.0)), 7.0), sharex=True)
for y_idx, y_lab in y_rows:
yi = int(np.clip(y_idx, 0, ny - 1))
axes[0].plot(x, ux[yi, :], label=y_lab, linewidth=1.0)
axes[1].plot(x, rho[yi, :], label=y_lab, linewidth=1.0)
axes[0].set_ylabel("u_x")
axes[0].legend(loc="best", fontsize=8)
axes[0].grid(True, alpha=0.3)
axes[1].set_ylabel("rho")
axes[1].set_xlabel("x (lattice)")
axes[1].legend(loc="best", fontsize=8)
axes[1].grid(True, alpha=0.3)
fig.suptitle(f"{label} — ux/rho lines at step {step}")
fig.tight_layout()
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
def _snapshot_from_sim(sim) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
macro = sim.get_macroscopic()
rho = np.asarray(macro["rho"], dtype=np.float64)
ux = np.asarray(macro["ux"], dtype=np.float64)
uy = np.asarray(macro["uy"], dtype=np.float64)
vort = vorticity_z(ux, uy)
return rho, ux, uy, vort
def _is_stable_fields(
rho: np.ndarray,
ux: np.ndarray,
*,
rho_lo: float = 0.85,
rho_hi: float = 1.25,
ux_cap: float = 0.15,
) -> bool:
"""Finite fields within a physically plausible band (reject pre-blow-up states)."""
if not (np.isfinite(rho).all() and np.isfinite(ux).all()):
return False
r0 = float(np.min(rho))
r1 = float(np.max(rho))
umax = float(np.max(np.abs(ux)))
return (rho_lo <= r0) and (r1 <= rho_hi) and (umax <= ux_cap)
def run_one(spec: RunSpec, base_cfg: dict, out_root: str) -> Dict[str, Any]:
sys.path.insert(0, os.path.join(_REPO, "src"))
import pycuda.driver as cuda
from CelerisLab import Simulation # noqa: WPS433
run_dir = os.path.join(out_root, spec.scenario, spec.run_id)
field_dir = os.path.join(run_dir, "fields")
line_dir = os.path.join(run_dir, "lines")
os.makedirs(field_dir, exist_ok=True)
os.makedirs(line_dir, exist_ok=True)
cfg = _build_cfg(base_cfg, spec)
tmpd = tempfile.mkdtemp(prefix="inlet_scenario_")
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(spec))
sim = Simulation(lbm_config_path=lbm_tmp, body_config_path=body_tmp)
if spec.has_cylinder and spec.cylinder_omega != 0.0:
sim.bodies.get(0).state.omega = np.float32(spec.cylinder_omega)
sim.initialize()
stream = cuda.Stream()
y_rows = _line_y_indices(spec.ny)
last_good: Optional[Dict[str, Any]] = None
first_bad_step: Optional[int] = None
force_bad_step: Optional[int] = None
print(f" [{spec.scenario}/{spec.run_id}] {spec.label} steps={spec.steps}", flush=True)
for step in range(1, spec.steps + 1):
if spec.has_cylinder:
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 % 100 == 0 or step == spec.steps:
stream.synchronize()
sim.bodies.download_obs_full_async(stream)
stream.synchronize()
fvec = sim.bodies.read_force(0)
if not (np.isfinite(fvec[0]) and np.isfinite(fvec[1])):
if force_bad_step is None:
force_bad_step = step
else:
sim.step(1)
rho, ux, uy, vort = _snapshot_from_sim(sim)
if _is_stable_fields(rho, ux):
last_good = {
"step": step,
"rho": rho.copy(),
"ux": ux.copy(),
"uy": uy.copy(),
"vort": vort.copy(),
}
elif first_bad_step is None:
first_bad_step = step
sim.close()
if last_good is None:
raise RuntimeError(f"No finite snapshot for {spec.run_id}")
out_step = int(last_good["step"])
rho = last_good["rho"]
ux = last_good["ux"]
uy = last_good["uy"]
vort = last_good["vort"]
requested_final = spec.steps
used_last_stable = out_step < requested_final
meta = {
"scenario": spec.scenario,
"run_id": spec.run_id,
"label": spec.label,
"nx": spec.nx,
"ny": spec.ny,
"collision": spec.collision,
"inlet_scheme": spec.inlet_scheme,
"inlet_profile": spec.inlet_profile,
"y_wall_bc": spec.y_wall_bc,
"outlet_mode": spec.outlet_mode,
"velocity": spec.velocity,
"viscosity": spec.viscosity,
"requested_steps": requested_final,
"output_step": out_step,
"used_last_stable": used_last_stable,
"first_nonfinite_step": first_bad_step,
"first_force_nonfinite_step": force_bad_step,
"rho_min": float(np.min(rho)),
"rho_max": float(np.max(rho)),
"ux_max": float(np.max(np.abs(ux))),
"vort_max": float(np.max(np.abs(vort[np.isfinite(vort)]))) if np.isfinite(vort).any() else float("nan"),
}
_write_json(os.path.join(run_dir, "run_meta.json"), meta)
stem = f"step_{out_step:06d}"
np.savez_compressed(
os.path.join(field_dir, "final.npz"),
rho=rho.astype(np.float32),
ux=ux.astype(np.float32),
uy=uy.astype(np.float32),
vort=vort.astype(np.float32),
step=np.int32(out_step),
)
title = f"{spec.label} (step {out_step}" + (", last stable" if used_last_stable else ", final") + ")"
pngs = _save_field_pngs(field_dir, "final", rho=rho, ux=ux, vort=vort, title=title)
_save_line_plots(
os.path.join(line_dir, "lines_ux_rho.png"),
rho=rho,
ux=ux,
step=out_step,
label=spec.label,
y_rows=y_rows,
)
line_payload: Dict[str, Any] = {"x": np.arange(spec.nx, dtype=np.float32), "step": np.int32(out_step)}
for y_idx, y_lab in y_rows:
yi = int(np.clip(y_idx, 0, spec.ny - 1))
line_payload[f"ux_{y_lab}"] = ux[yi, :].astype(np.float32)
line_payload[f"rho_{y_lab}"] = rho[yi, :].astype(np.float32)
np.savez_compressed(os.path.join(line_dir, "lines.npz"), **line_payload)
status = "last_stable" if used_last_stable else "final"
print(
f" -> {status} step {out_step} rho=[{meta['rho_min']:.4f},{meta['rho_max']:.4f}] "
f"ux_max={meta['ux_max']:.4f} force_bad={force_bad_step}",
flush=True,
)
return {**meta, "field_pngs": pngs, "run_dir": run_dir}
def main() -> int:
ap = argparse.ArgumentParser(description="Three-scenario inlet field export (5000 steps)")
ap.add_argument(
"--scenario",
choices=(
"empty_channel",
"empty_channel_inlet_matrix",
"kan99b",
"sah04_case9",
"all",
),
default="all",
)
ap.add_argument(
"--collision",
default="",
help="Optional filter: SRT or MRT only",
)
ap.add_argument("--steps", type=int, default=5000)
ap.add_argument(
"--out-dir",
default=os.path.join(_REPO, "tests", "output", "inlet_scenario_fields"),
)
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)
if args.scenario == "empty_channel_inlet_matrix":
specs = _empty_channel_inlet_matrix_specs()
elif args.scenario == "all":
specs = _all_specs() + _empty_channel_inlet_matrix_specs()
else:
specs = _all_specs()
if args.scenario != "all":
specs = [s for s in specs if s.scenario == args.scenario]
if args.collision.strip():
coll = args.collision.strip().upper()
specs = [s for s in specs if s.collision.upper() == coll]
specs = [replace(s, steps=int(args.steps)) for s in specs]
out_dir = os.path.abspath(args.out_dir)
os.makedirs(out_dir, exist_ok=True)
rows: List[Dict[str, Any]] = []
for spec in specs:
try:
row = run_one(spec, base, out_dir)
rows.append(row)
except Exception as e: # noqa: BLE001
print(f" FAILED {spec.scenario}/{spec.run_id}: {e}", flush=True)
rows.append(
{
"scenario": spec.scenario,
"run_id": spec.run_id,
"label": spec.label,
"error": str(e),
}
)
summary_path = os.path.join(out_dir, "summary.csv")
if rows:
keys: List[str] = []
for r in rows:
for k in r:
if k not in keys and k != "field_pngs":
keys.append(k)
with open(summary_path, "w", encoding="utf-8", newline="") as f:
w = csv.DictWriter(f, fieldnames=keys, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
_write_json(
os.path.join(out_dir, "manifest.json"),
{"steps": args.steps, "scenario_filter": args.scenario, "runs": [s.run_id for s in specs]},
)
print(f"Wrote: {summary_path}", flush=True)
print(f"Output: {out_dir}", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -46,7 +46,7 @@ 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__), "..", ".."))
_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")
# D=30 fixed; Lx_fluid = 80D per Sah04 confined setup