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 numpy as np
import pandas as pd
import matplotlib.pyplot as plt


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.')