Case 05: Stratified street canyon (Jiang & Yoshie 2018)

STATUS: SCAFFOLD - unrun. This notebook is analysis structure only. It runs before the GPU result and the digitized reference data exist (every loader is guarded), and it makes NO physics claim. Reference-data digitization + GPU validation are pending (epic #1034 P5, issue #1039).

Validation of the stratified street-canyon case for validation/wind_engineering/05_stratified_street_canyon/05_stratified_street_canyon.nassu.yaml (stratifiedCanyonJiangRiN015). A 3-D urban canyon array under a weakly-unstable ABL (gradient Ri ~ -0.15), with a ground line source of a passive ethylene tracer and a buoyant temperature scalar driving the Monin-Obukhov ground wall model.

Reference: Jiang, G. & Yoshie, R. (2018), Building and Environment 142, 47-57 (TPU stratified wind tunnel + Uehara et al.).

Scaling and normalization

Building height H = 60 mm, roof velocity U_H = 1.34 m/s, Re_H = 5400, gradient Ri ~ -0.15. In lattice units (level 0) H_lat = 24, U_H_lat = 0.05 (Ma = 0.087). Reported quantities:

  • velocity U / U_H,

  • temperature phi = (Theta - Theta_H) / dTheta, dTheta = 30 K,

  • concentration / C0 with C0 = q / (U_H H^2).

The pollutant source magnitude is arbitrary up to the C0 normalization (the same rate is used when forming /C0); the mapping constant is recovered here once the run exists.

[ ]:
import pathlib

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd


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 from {here}")


project_root = _find_project_root()
case = "validation/wind_engineering/05_stratified_street_canyon"
results_dir = project_root / "output" / case
reference_dir = project_root / case / "reference"

# Scaling constants (see the case YAML header).
H_LAT = 24.0
U_H_LAT = 0.05
PLANE_HEIGHT = 5.01
DTHETA_K = 30.0
print("results dir:", results_dir)

Load model outputs (guarded)

The case records the canyon-centre and driver vertical lines, the vertical/horizontal planes and the full-domain statistics. Wire these readers to the actual export format once the GPU run exists; until then they return None and every plot below degrades to the reference (or an empty axis) without error.

[ ]:
def load_series(export_name: str, entity: str):
    """Return a model series (z_over_H, fields dict) or None if absent."""
    if not results_dir.exists():
        return None
    # TODO(P5): point this at the real line/plane export for the run
    # (HDF5 / XDMF / CSV series) and time-average over the stats window.
    hits = list(results_dir.rglob(f"*{export_name}*{entity}*"))
    if not hits:
        return None
    raise NotImplementedError("Wire the series reader to the run output.")


model_canyon = load_series("canyon_centre_profile", "canyon_centre")
model_driver = load_series("driver_inlet_profile", "driver_inlet")
have_model = model_canyon is not None
print("model data present:", have_model)

Load digitized reference data (guarded)

Reference CSVs live under reference/. They are EMPTY PLACEHOLDERS until digitized from the Jiang & Yoshie figures at P5 (see reference/README.md). Each carries a source column naming its figure; that source is used verbatim in every plot legend.

[ ]:
def load_reference(name: str):
    path = reference_dir / name
    if not path.exists():
        return None
    df = pd.read_csv(path, comment="#")
    return df if not df.empty else None


def source_label(df, fallback="Jiang & Yoshie (2018)"):
    """Legend label read from the reference `source` column."""
    if df is not None and "source" in df and len(df):
        return str(df["source"].iloc[0])
    return fallback


ref_canyon = load_reference("canyon_centre_profiles.csv")
ref_driver = load_reference("driver_profiles.csv")
ref_flux = load_reference("flux_decomposition.csv")
have_ref = ref_canyon is not None
print("reference data present:", have_ref)

Canyon-centre profiles (Fig. 6)

Velocity, normalized temperature and normalized concentration at the canyon centre vs height, model against the digitized Jiang & Yoshie

  1. canyon-centre data (Uehara Rb = -0.19 overlaid where present). Plots only - no bare tables.

[ ]:
fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True)
cols = ["U_over_UH", "phi_theta", "c_over_C0"]
titles = ["U / U_H", "phi = (T - T_H)/dTheta", "<c> / C0"]
for ax, col, title in zip(axes, cols, titles):
    if have_model:
        # TODO(P5): plot model_canyon[col] vs its z_over_H.
        pass
    if ref_canyon is not None and col in ref_canyon:
        ax.plot(ref_canyon[col], ref_canyon["z_over_H"], "ks", label=source_label(ref_canyon))
    ax.set_title(title)
    ax.set_xlabel(title)
    if not have_model and ref_canyon is None:
        ax.text(
            0.5,
            0.5,
            "pending GPU run +\nreference digitization",
            ha="center",
            va="center",
            transform=ax.transAxes,
        )
    if ax.get_legend_handles_labels()[0]:
        ax.legend()
axes[0].set_ylabel("z / H")
fig.suptitle("Canyon-centre profiles (Jiang & Yoshie 2018, Fig. 6) - SCAFFOLD")
fig.tight_layout()
plt.show()

Driver (approach-flow) profiles (Fig. 4)

Inlet U(z) and temperature(z) against the digitized Jiang & Yoshie driver profiles. Tests the provisional SEM + mean-T inflow (P5 may need a recycling/precursor inflow to carry the temperature fluctuations).

[ ]:
fig, axes = plt.subplots(1, 2, figsize=(9, 4), sharey=True)
for ax, col, title in zip(axes, ["U_over_UH", "phi_theta"], ["U / U_H", "phi_theta"]):
    if ref_driver is not None and col in ref_driver:
        ax.plot(ref_driver[col], ref_driver["z_over_H"], "k^", label=source_label(ref_driver))
    if have_model:
        pass  # TODO(P5): overlay model_driver.
    ax.set_title(title)
    ax.set_xlabel(title)
    if ref_driver is None and not have_model:
        ax.text(0.5, 0.5, "pending", ha="center", va="center", transform=ax.transAxes)
    if ax.get_legend_handles_labels()[0]:
        ax.legend()
axes[0].set_ylabel("z / H")
fig.suptitle("Driver profiles (Jiang & Yoshie 2018, Fig. 4) - SCAFFOLD")
fig.tight_layout()
plt.show()

Canyon-top flux decomposition (Figs. 15-16)

Mean vs turbulent contributions to the pollutant / airflow outflow across the canyon top face (the paper reports turbulence carrying ~75% of the pollutant outflow). Shown as a grouped BAR chart (a scalar decomposition - never a bare table), model against the digitized Jiang & Yoshie values.

[ ]:
fig, ax = plt.subplots(figsize=(7, 4))
components = ["mean", "turbulent", "total"]
x = np.arange(len(components))
w = 0.35
if ref_flux is not None and "component" in ref_flux:
    ref_map = dict(zip(ref_flux["component"], ref_flux["pollutant_flux_frac"]))
    vals = [ref_map.get(c, np.nan) for c in components]
    ax.bar(x - w / 2, vals, w, label=source_label(ref_flux), color="0.4")
if have_model:
    pass  # TODO(P5): ax.bar(x + w/2, model_frac, w, label='Nassu').
ax.set_xticks(x)
ax.set_xticklabels(components)
ax.set_ylabel("pollutant top-face outflow fraction")
ax.set_title("Flux decomposition (Jiang & Yoshie 2018, Figs. 15-16) - SCAFFOLD")
if ax.get_legend_handles_labels()[0]:
    ax.legend()
if ref_flux is None and not have_model:
    ax.text(
        0.5,
        0.5,
        "pending GPU run + reference digitization",
        ha="center",
        va="center",
        transform=ax.transAxes,
    )
fig.tight_layout()
plt.show()

Fields: vertical + horizontal planes (Figs. 7, 9-12)

Time-averaged u / T / c on the canyon vertical plane and the horizontal plane at z = H/6. Wire the plane readers to the run output at P5 and render contourf overlays (contours, not tables).

[ ]:
# TODO(P5): load 'planes/canyon_vertical' and 'planes/horizontal_zH6'
# and contourf u, temperature_phi and pollutant_phi (as <c>/C0) here.
print("Field plots: pending GPU result.")