{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Case 05: Stratified street canyon (Jiang & Yoshie 2018)\n", "\n", "> **STATUS: SCAFFOLD - unrun.** This notebook is analysis structure\n", "> only. It runs before the GPU result and the digitized reference\n", "> data exist (every loader is guarded), and it makes NO physics claim.\n", "> Reference-data digitization + GPU validation are pending (epic\n", "> #1034 P5, issue #1039).\n", "\n", "Validation of the stratified street-canyon case for\n", "`validation/wind_engineering/05_stratified_street_canyon/05_stratified_street_canyon.nassu.yaml`\n", "(`stratifiedCanyonJiangRiN015`). A 3-D urban canyon array under a\n", "weakly-unstable ABL (gradient Ri ~ -0.15), with a ground line source of\n", "a passive ethylene tracer and a buoyant temperature scalar driving the\n", "Monin-Obukhov ground wall model.\n", "\n", "**Reference:** Jiang, G. & Yoshie, R. (2018), *Building and\n", "Environment* 142, 47-57 (TPU stratified wind tunnel + Uehara et al.)." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Scaling and normalization\n", "\n", "Building height H = 60 mm, roof velocity U_H = 1.34 m/s, Re_H = 5400,\n", "gradient Ri ~ -0.15. In lattice units (level 0) H_lat = 24,\n", "U_H_lat = 0.05 (Ma = 0.087). Reported quantities:\n", "\n", "- velocity U / U_H,\n", "- temperature phi = (Theta - Theta_H) / dTheta, dTheta = 30 K,\n", "- concentration / C0 with C0 = q / (U_H H^2).\n", "\n", "The pollutant source magnitude is arbitrary up to the C0 normalization\n", "(the same rate is used when forming /C0); the mapping constant is\n", "recovered here once the run exists." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "import pathlib\n", "\n", "import numpy as np\n", "import pandas as pd\n", "import matplotlib.pyplot as plt\n", "\n", "\n", "def _find_project_root() -> pathlib.Path:\n", " here = pathlib.Path.cwd().resolve()\n", " for cand in [here, *here.parents]:\n", " if (cand / \"pyproject.toml\").exists() and (cand / \"nassu\").is_dir():\n", " return cand\n", " raise RuntimeError(f\"Could not locate Nassu project root from {here}\")\n", "\n", "\n", "project_root = _find_project_root()\n", "case = 'validation/wind_engineering/05_stratified_street_canyon'\n", "results_dir = project_root / 'output' / case\n", "reference_dir = project_root / case / 'reference'\n", "\n", "# Scaling constants (see the case YAML header).\n", "H_LAT = 24.0\n", "U_H_LAT = 0.05\n", "PLANE_HEIGHT = 5.01\n", "DTHETA_K = 30.0\n", "print('results dir:', results_dir)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Load model outputs (guarded)\n", "\n", "The case records the canyon-centre and driver vertical lines, the\n", "vertical/horizontal planes and the full-domain statistics. Wire these\n", "readers to the actual export format once the GPU run exists; until then\n", "they return `None` and every plot below degrades to the reference (or\n", "an empty axis) without error." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def load_series(export_name: str, entity: str):\n", " \"\"\"Return a model series (z_over_H, fields dict) or None if absent.\"\"\"\n", " if not results_dir.exists():\n", " return None\n", " # TODO(P5): point this at the real line/plane export for the run\n", " # (HDF5 / XDMF / CSV series) and time-average over the stats window.\n", " hits = list(results_dir.rglob(f'*{export_name}*{entity}*'))\n", " if not hits:\n", " return None\n", " raise NotImplementedError('Wire the series reader to the run output.')\n", "\n", "\n", "model_canyon = load_series('canyon_centre_profile', 'canyon_centre')\n", "model_driver = load_series('driver_inlet_profile', 'driver_inlet')\n", "have_model = model_canyon is not None\n", "print('model data present:', have_model)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Load digitized reference data (guarded)\n", "\n", "Reference CSVs live under `reference/`. They are EMPTY PLACEHOLDERS\n", "until digitized from the Jiang & Yoshie figures at P5 (see\n", "`reference/README.md`). Each carries a `source` column naming its\n", "figure; that source is used verbatim in every plot legend." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "def load_reference(name: str):\n", " path = reference_dir / name\n", " if not path.exists():\n", " return None\n", " df = pd.read_csv(path, comment='#')\n", " return df if not df.empty else None\n", "\n", "\n", "def source_label(df, fallback='Jiang & Yoshie (2018)'):\n", " \"\"\"Legend label read from the reference `source` column.\"\"\"\n", " if df is not None and 'source' in df and len(df):\n", " return str(df['source'].iloc[0])\n", " return fallback\n", "\n", "\n", "ref_canyon = load_reference('canyon_centre_profiles.csv')\n", "ref_driver = load_reference('driver_profiles.csv')\n", "ref_flux = load_reference('flux_decomposition.csv')\n", "have_ref = ref_canyon is not None\n", "print('reference data present:', have_ref)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Canyon-centre profiles (Fig. 6)\n", "\n", "Velocity, normalized temperature and normalized concentration at the\n", "canyon centre vs height, model against the digitized Jiang & Yoshie\n", "(2018) canyon-centre data (Uehara Rb = -0.19 overlaid where present).\n", "Plots only - no bare tables." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True)\n", "cols = ['U_over_UH', 'phi_theta', 'c_over_C0']\n", "titles = ['U / U_H', 'phi = (T - T_H)/dTheta', ' / C0']\n", "for ax, col, title in zip(axes, cols, titles):\n", " if have_model:\n", " # TODO(P5): plot model_canyon[col] vs its z_over_H.\n", " pass\n", " if ref_canyon is not None and col in ref_canyon:\n", " ax.plot(ref_canyon[col], ref_canyon['z_over_H'], 'ks',\n", " label=source_label(ref_canyon))\n", " ax.set_title(title)\n", " ax.set_xlabel(title)\n", " if not have_model and ref_canyon is None:\n", " ax.text(0.5, 0.5, 'pending GPU run +\\nreference digitization',\n", " ha='center', va='center', transform=ax.transAxes)\n", " if ax.get_legend_handles_labels()[0]:\n", " ax.legend()\n", "axes[0].set_ylabel('z / H')\n", "fig.suptitle('Canyon-centre profiles (Jiang & Yoshie 2018, Fig. 6) - SCAFFOLD')\n", "fig.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Driver (approach-flow) profiles (Fig. 4)\n", "\n", "Inlet U(z) and temperature(z) against the digitized Jiang & Yoshie\n", "driver profiles. Tests the provisional SEM + mean-T inflow (P5 may need\n", "a recycling/precursor inflow to carry the temperature fluctuations)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "fig, axes = plt.subplots(1, 2, figsize=(9, 4), sharey=True)\n", "for ax, col, title in zip(axes, ['U_over_UH', 'phi_theta'],\n", " ['U / U_H', 'phi_theta']):\n", " if ref_driver is not None and col in ref_driver:\n", " ax.plot(ref_driver[col], ref_driver['z_over_H'], 'k^',\n", " label=source_label(ref_driver))\n", " if have_model:\n", " pass # TODO(P5): overlay model_driver.\n", " ax.set_title(title)\n", " ax.set_xlabel(title)\n", " if ref_driver is None and not have_model:\n", " ax.text(0.5, 0.5, 'pending', ha='center', va='center',\n", " transform=ax.transAxes)\n", " if ax.get_legend_handles_labels()[0]:\n", " ax.legend()\n", "axes[0].set_ylabel('z / H')\n", "fig.suptitle('Driver profiles (Jiang & Yoshie 2018, Fig. 4) - SCAFFOLD')\n", "fig.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Canyon-top flux decomposition (Figs. 15-16)\n", "\n", "Mean vs turbulent contributions to the pollutant / airflow outflow\n", "across the canyon top face (the paper reports turbulence carrying\n", "~75% of the pollutant outflow). Shown as a grouped BAR chart (a scalar\n", "decomposition - never a bare table), model against the digitized\n", "Jiang & Yoshie values." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "fig, ax = plt.subplots(figsize=(7, 4))\n", "components = ['mean', 'turbulent', 'total']\n", "x = np.arange(len(components))\n", "w = 0.35\n", "if ref_flux is not None and 'component' in ref_flux:\n", " ref_map = dict(zip(ref_flux['component'], ref_flux['pollutant_flux_frac']))\n", " vals = [ref_map.get(c, np.nan) for c in components]\n", " ax.bar(x - w / 2, vals, w, label=source_label(ref_flux), color='0.4')\n", "if have_model:\n", " pass # TODO(P5): ax.bar(x + w/2, model_frac, w, label='Nassu').\n", "ax.set_xticks(x)\n", "ax.set_xticklabels(components)\n", "ax.set_ylabel('pollutant top-face outflow fraction')\n", "ax.set_title('Flux decomposition (Jiang & Yoshie 2018, Figs. 15-16) - SCAFFOLD')\n", "if ax.get_legend_handles_labels()[0]:\n", " ax.legend()\n", "if ref_flux is None and not have_model:\n", " ax.text(0.5, 0.5, 'pending GPU run + reference digitization',\n", " ha='center', va='center', transform=ax.transAxes)\n", "fig.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Fields: vertical + horizontal planes (Figs. 7, 9-12)\n", "\n", "Time-averaged u / T / c on the canyon vertical plane and the horizontal\n", "plane at z = H/6. Wire the plane readers to the run output at P5 and\n", "render contourf overlays (contours, not tables)." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# TODO(P5): load 'planes/canyon_vertical' and 'planes/horizontal_zH6'\n", "# and contourf u, temperature_phi and pollutant_phi (as /C0) here.\n", "print('Field plots: pending GPU result.')" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }