Case 16 - CEDVAL A1-5 isolated building pollutant dispersion¶
Validation notebook for validation/wind_engineering/03_cedval_building/03_cedval_building.nassu.yaml (GitHub issue #652). An isolated rectangular building sits in a neutral atmospheric boundary layer (ABL); a near-ground tracer source on the leeward wall base emits a passive scalar pollutant. We compare the simulated dimensionless surface/near-wake concentration against the CEDVAL A1-5 wind-tunnel dataset (Univ. Hamburg EWTL) using the COST 732 / VDI 3783-9 CWE validation metrics.
This notebook is authored to run AFTER (a) a GPU run has produced the time-averaged result and (b) the maintainer has populated the reference data ``validation/wind_engineering/03_cedval_building/reference/c_star_points.csv`` from the password-protected EWTL dataset. It is intentionally NOT executed in CI.
What is validated¶
Scalar transport (advection-diffusion of the pollutant DDF).
The voxel surface source (on-body / near-ground emission), isolated cleanly in this simple geometry.
Impermeable building faces (voxel zero-flux scalar BC
ScalarHWBB).LES + turbulent Schmidt number coupling (
Sc_t = 0.7).
Scaling (model -> lattice)¶
CEDVAL A1-5 building L x W x H = 100 x 150 x 125 mm (model scale 1:200). The height is resolved with H = 50 lattice cells, giving a cell size dx = 125 mm / 50 = 2.5 mm/cell and L = 40, W = 60 lattice cells. The domain is ~15 H x 10 H x 6 H, rounded to 752 x 504 x 304 cells.
The dimensionless concentration follows the CEDVAL convention:
FLAGGED unverified parameters¶
The following must be confirmed against the EWTL dataset before the absolute comparison is meaningful:
``Q`` (emission rate): the case uses a unit Dirichlet wall value
phi_w = 1.0; the effectiveQis recovered from the integrated wall flux of the converged field (computed below). The physicalQfrom the dataset is still needed to placeCin absolute units.# VERIFY: CEDVAL A1-5 emission rate Q from the EWTL dataset``U_ref`` + reference height: the lattice
U_ref = 0.06is chosen for Ma/Re only; the dataset’s physicalU_refand reference height set thec*normalisation.# VERIFY: CEDVAL A1-5 U_ref value + reference height
Reference¶
CEDVAL A1-5, Univ. Hamburg EWTL (Leitl 1998/2000). Acceptance metrics per COST Action 732 / VDI 3783 Part 9 (Schatzmann et al. 2010; Chang & Hanna 2004): FAC2 >= 0.5, NMSE < 4, FB in [-0.3, 0.3], hit-rate q >= 0.66 (D = 0.25).
[ ]:
import pathlib
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
try:
import pyvista as pv
pv.OFF_SCREEN = True
except Exception: # pragma: no cover - pyvista optional for metric-only runs
pv = None
[ ]:
def _find_project_root() -> pathlib.Path:
here = pathlib.Path.cwd().resolve()
for cand in [here, *here.parents]:
if (cand / "pyproject.toml").exists() and (cand / "nassu").is_dir():
return cand
raise RuntimeError(f"Could not locate Nassu project root upward from {here}")
project_root = _find_project_root()
case_path = (
project_root / "validation/wind_engineering/03_cedval_building/03_cedval_building.nassu.yaml"
)
results_dir = project_root / "validation/wind_engineering/03_cedval_building/results"
comparison_dir = project_root / "validation/wind_engineering/03_cedval_building/reference"
ref_csv = comparison_dir / "c_star_points.csv"
print("case:", case_path)
print("results:", results_dir)
print("reference:", ref_csv)
Scaling constants¶
H, dx and the lattice reference velocity follow the YAML header. The physical U_ref and Q are FLAGGED placeholders to be confirmed from the dataset; for the dimensionless comparison we use the lattice values and the emission rate recovered from the wall flux.
[ ]:
# Lattice geometry (see the YAML header).
H_LAT = 50 # building height in lattice cells
L_LAT = 40 # along-wind length
W_LAT = 60 # cross-wind width
DX_MM = 2.5 # lattice cell size, mm/cell (model frame)
BODY_X = 250 # building centre x (lattice)
DOMAIN_W = 504 # domain width (lattice)
PLANE_H = 5.01 # ground plane height (lattice)
# Lattice reference velocity (top-of-profile). # VERIFY physical U_ref.
U_REF_LAT = 0.06
# Emission rate Q. The Dirichlet patch uses phi_w = 1.0; the effective
# lattice Q is the integrated scalar wall flux of the converged field,
# computed below. # VERIFY: absolute physical Q from the EWTL dataset.
Q_LAT = None # set from the integrated wall flux (see the flux cell)
Load the time-averaged result¶
Loads the exported statistics / instantaneous XDMF for the converged pollutant_phi field. Adjust the glob to the actual exported artefact name once the GPU run exists.
[ ]:
def load_timeavg_field():
"""Load the time-averaged pollutant field from the exported results.
Returns a pyvista MultiBlock (or dataset). The exact artefact name
depends on the export config; prefer the statistics mean export, else
fall back to the last instantaneous snapshot.
"""
if pv is None:
raise RuntimeError("pyvista is required to load the field")
candidates = sorted(results_dir.rglob("*.xdmf")) + sorted(results_dir.rglob("*.pvd"))
if not candidates:
raise FileNotFoundError(f"No XDMF/PVD result under {results_dir}. Run the GPU case first.")
# Prefer a statistics/mean artefact when present.
stats = [c for c in candidates if "stat" in c.name.lower() or "mean" in c.name.lower()]
target = stats[-1] if stats else candidates[-1]
print("loading:", target)
return pv.read(str(target))
# field = load_timeavg_field() # uncomment once results exist
Recover the effective emission rate Q from the wall flux¶
Scalar mass-balance check (acceptance criterion): the scalar emitted at the source equals what is advected out plus what accumulates. For a converged (statistically steady) field the accumulation term vanishes, so the emitted rate equals the net advective+diffusive outflux. We use the source wall flux as the effective lattice Q for the c* normalisation.
[ ]:
def effective_Q_from_field(field):
"""Estimate the effective lattice emission rate Q.
Integrates the scalar advective flux through a control surface enclosing
the source (or, equivalently, the net outflux through the outlet plane).
Implementation depends on the available exported macrs; placeholder
until the GPU result schema is fixed.
"""
raise NotImplementedError(
"Populate from the converged field: integrate the scalar flux "
"through a control surface around the source / outlet plane."
)
# Q_LAT = effective_Q_from_field(field)
Sample the predicted c* at the dataset sampling points¶
Maps the CEDVAL model-frame sampling coordinates (mm, building base origin) to lattice positions via dx and the building placement, samples the simulated pollutant_phi, and normalises to c* = C * U_ref * H^2 / Q.
[ ]:
def model_mm_to_lattice(x_mm, y_mm, z_mm):
"""Map CEDVAL model-frame coords (mm, building base origin) to lattice.
The building base is centred at (BODY_X, DOMAIN_W/2) on the ground
plane (z = PLANE_H). Model +x is along-wind, +y cross-wind, +z up.
"""
x = BODY_X + x_mm / DX_MM
y = DOMAIN_W / 2.0 + y_mm / DX_MM
z = PLANE_H + z_mm / DX_MM
return np.column_stack([x, y, z])
def predicted_c_star(field, ref_df, q_lat, u_ref=U_REF_LAT, h_lat=H_LAT):
pts = model_mm_to_lattice(
ref_df["x_model_mm"].to_numpy(),
ref_df["y_model_mm"].to_numpy(),
ref_df["z_model_mm"].to_numpy(),
)
poly = pv.PolyData(pts)
sampled = poly.sample(field)
C = np.asarray(sampled["pollutant_phi"])
return C * u_ref * h_lat**2 / q_lat
[ ]:
ref_df = pd.read_csv(ref_csv, comment="#")
print(f"reference sampling points: {len(ref_df)}")
if len(ref_df) == 0:
print(
"Reference CSV is empty (placeholder). Populate "
"validation/wind_engineering/03_cedval_building/reference/c_star_points.csv from the "
"EWTL dataset before running the comparison."
)
ref_df.head()
COST 732 / VDI 3783-9 validation metrics¶
FAC2, NMSE, FB, R and the hit-rate q (D = 0.25) over the paired (observed, predicted) c* samples. Definitions follow Chang & Hanna (2004) and VDI 3783 Part 9 (2005).
[ ]:
def cost732_metrics(obs, pred, hitrate_D=0.25):
obs = np.asarray(obs, dtype=float)
pred = np.asarray(pred, dtype=float)
eps = np.finfo(float).tiny
mo, mp = obs.mean(), pred.mean()
fb = (mo - mp) / (0.5 * (mo + mp))
nmse = np.mean((obs - pred) ** 2) / (mo * mp)
ratio = pred / np.where(np.abs(obs) < eps, eps, obs)
fac2 = np.mean((ratio >= 0.5) & (ratio <= 2.0))
r = np.corrcoef(obs, pred)[0, 1] if len(obs) > 1 else np.nan
# VDI 3783-9 hit-rate: relative tolerance D (absolute fallback W omitted).
q = np.mean(np.abs(pred - obs) <= hitrate_D * np.abs(obs))
return {"FAC2": fac2, "NMSE": nmse, "FB": fb, "R": r, "q": q}
[ ]:
# Run once results + reference exist:
#
# field = load_timeavg_field()
# Q_LAT = effective_Q_from_field(field)
# pred = predicted_c_star(field, ref_df, Q_LAT)
# obs = ref_df["c_star_obs"].to_numpy()
# metrics = cost732_metrics(obs, pred)
# print(metrics)
#
# assert metrics["FAC2"] >= 0.5, f"FAC2 {metrics['FAC2']:.3f} < 0.5"
# assert metrics["NMSE"] < 4.0, f"NMSE {metrics['NMSE']:.3f} >= 4"
# assert -0.3 <= metrics["FB"] <= 0.3, f"FB {metrics['FB']:.3f} outside [-0.3, 0.3]"
# assert metrics["q"] >= 0.66, f"hit-rate q {metrics['q']:.3f} < 0.66"
print("Metrics cell ready (commented out until GPU results + dataset exist).")
Plots¶
Surface / near-ground concentration field on the vertical symmetry plane and a pedestrian-level horizontal plane; (2) a scatter of predicted vs observed
c*with the FAC2 band.
[ ]:
def plot_scatter(obs, pred):
fig, ax = plt.subplots(figsize=(5, 5))
lo = min(obs.min(), pred.min())
hi = max(obs.max(), pred.max())
ax.scatter(obs, pred, s=20, alpha=0.7, label="sampling points")
ax.plot([lo, hi], [lo, hi], "k-", lw=1, label="1:1")
ax.plot([lo, hi], [0.5 * lo, 0.5 * hi], "k--", lw=0.8, label="FAC2 band")
ax.plot([lo, hi], [2.0 * lo, 2.0 * hi], "k--", lw=0.8)
ax.set_xlabel("observed c* (CEDVAL A1-5)")
ax.set_ylabel("predicted c* (Nassu)")
ax.set_title("Predicted vs observed dimensionless concentration")
ax.legend()
ax.set_aspect("equal", "box")
return fig
# fig = plot_scatter(obs, pred) # uncomment once results exist
print("Plot helpers ready.")
Version¶
[ ]:
from nassu.cfg.model import ConfigScheme
sim_cfg = next(iter(ConfigScheme.sim_cfgs_from_file_dct(str(case_path)).values()))
sim_info = sim_cfg.output.read_info()
nassu_commit = sim_info["commit"]
nassu_version = sim_info["version"]
print("Version:", nassu_version)
print("Commit hash:", nassu_commit)
Configuration¶
[ ]:
from IPython.display import Code
Code(filename=str(case_path))