Case 07: Stratified-ABL neutral-limit regression

STATUS: SCAFFOLD - unrun (P3). This notebook is analysis structure only. It runs before the GPU result exists (every loader is guarded and degrades to a ‘pending’ placeholder), and it makes NO physics claim. This case is a SELF-REGRESSION: the baseline run IS the reference. GPU validation is pending (epic #1034 P5, issue #1039).

Mandatory neutral-limit regression for the stratified-ABL feature (Monin-Obukhov IBM ground wall model + Boussinesq buoyancy). With the feature OFF (baseline) or at its INERT parameter (neutral_limit: surface_heat_flux = 0, beta = 0), the solver must recover its pre-feature behaviour to the solver noise floor. The same pair is run across a 2:1 refinement slab (baseline_mb, neutral_limit_mb) to prove the inert buoyancy / temperature field stays continuous across the interface.

Config: validation/wind_engineering/07_stratified_neutral_limit/07_stratified_neutral_limit.nassu.yaml.

Acceptance criterion (stated up front)

The GPU solver is NOT bit-reproducible run-to-run (atomicAdd ordering, ~1e-6 relative in float32), so we validate STATISTICS, not instantaneous fields, and prove parity against the solver’s own noise floor. Using the time-averaged mean fields over [10000, 20000] steps, x-y-averaged into z-profiles of u, rho and temperature:

  • Delta_feature = || neutral_limit(z) - baseline(z) ||

  • Delta_noise = || baseline_rerun(z) - baseline(z) || (solver noise)

The regression PASSES iff Delta_feature <~ Delta_noise (same order, ~1e-6 relative) for every quantity. For the multiblock variant, the Delta_feature bars must stay at the same solver-noise floor as the single-block case, showing the inert feature composes across the 2:1 refinement interface (F2C/C2F/border reconstruction) with no discontinuity.

[1]:
import glob
import pathlib

import h5py
import matplotlib.pyplot as plt
import numpy as np


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/07_stratified_neutral_limit"
results_dir = project_root / "output" / case

# The regression sims. The `_rerun` sims establish the solver noise floor.
SINGLE = ["baseline", "baseline_rerun", "neutral_limit"]
MULTIBLOCK = ["baseline_mb", "baseline_mb_rerun", "neutral_limit_mb"]
PROFILE_MACRS = ["u", "rho", "temperature_phi"]
print("results dir:", results_dir, "(exists:", results_dir.exists(), ")")
results dir: /home/waine/repos/nassu/output/validation/wind_engineering/07_stratified_neutral_limit (exists: True )

Load mean fields and reduce to z-profiles (guarded)

Each sim exports first-order stats of rho, u, temperature_phi (data.exports.mean_fields). x and y are periodic (homogeneous), so the z-profile is the x-y average of the time-mean field. Every loader returns None when the run is absent, so the plots below degrade to a ‘pending’ placeholder without error.

[2]:
def load_profile(sim_name: str):
    """Return {macr: z_profile_1d} for a sim, or None if the run is absent.

    z-profile = x-y average of the time-mean field over the stats window.
    """
    pat = str(
        results_dir / f"{sim_name}__000" / "outputs" / "mean_fields.volume.mean_fields.stats.*.h5"
    )
    files = sorted(glob.glob(pat))
    if not files:
        return None
    # The accumulated 1st-order mean field (level 0), one time group.
    # Axis order is Fortran (0=x, 1=y, 2=z); x, y are periodic homogeneous
    # directions, so the z-profile is the x-y average. 'u' <- ux (the
    # streamwise, forced component).
    src = {"u": "ux", "rho": "rho", "temperature_phi": "temperature_phi"}
    with h5py.File(files[-1], "r") as h:
        blk = h[sorted(h.keys())[-1]]["block0"]
        return {m: np.asarray(blk[src[m]][:]).mean(axis=(0, 1)) for m in PROFILE_MACRS}


def load_group(names):
    return {n: load_profile(n) for n in names}


def have(group):
    return group and all(v is not None for v in group.values())


single = load_group(SINGLE)
multiblock = load_group(MULTIBLOCK)
print("single-block results present:", have(single))
print("multiblock results present:", have(multiblock))
single-block results present: True
multiblock results present: True

Profile overlay: neutral_limit vs baseline (single block)

The feature-inert run (neutral_limit) overlaid on the feature-off reference (baseline) and the noise-floor rerun (baseline_rerun) for u(z), rho(z) and temperature(z). If the feature is truly inert the three curves are indistinguishable. Plots only.

[3]:
fig, axes = plt.subplots(1, 3, figsize=(12, 4), sharey=True)
titles = {"u": "u(z)", "rho": "rho(z)", "temperature_phi": "temperature(z)"}
styles = {
    "baseline": ("k-", "baseline (feature OFF)"),
    "baseline_rerun": ("0.6", "baseline rerun (noise floor)"),
    "neutral_limit": ("C1--", "neutral_limit (feature inert)"),
}
for ax, macr in zip(axes, PROFILE_MACRS):
    if have(single):
        for sim, (fmt, lbl) in styles.items():
            prof = single[sim][macr]
            z = np.arange(len(prof))
            ax.plot(prof, z, fmt, label=lbl)
    else:
        ax.text(0.5, 0.5, "pending GPU run", ha="center", va="center", transform=ax.transAxes)
    ax.set_title(titles[macr])
    ax.set_xlabel(titles[macr])
    if ax.get_legend_handles_labels()[0]:
        ax.legend(fontsize=8)
axes[0].set_ylabel("z (lattice)")
fig.suptitle("Neutral-limit regression: profile overlay (single block) - SCAFFOLD")
fig.tight_layout()
plt.show()
../../../_images/validation_wind_engineering_07_stratified_neutral_limit_07_stratified_neutral_limit_6_0.png

Parity bar chart: Delta_feature vs Delta_noise

For each quantity, the profile L2 difference of the feature-inert run against baseline (Delta_feature) next to the baseline-vs-rerun solver noise floor (Delta_noise). The regression PASSES when the orange bar does not exceed the grey bar (same order). A grouped bar chart - a scalar decomposition shown as bars, never a bare table.

[4]:
def profile_l2(a, b):
    a, b = np.asarray(a), np.asarray(b)
    denom = np.linalg.norm(b) or 1.0
    return float(np.linalg.norm(a - b) / denom)  # relative L2


def parity_bars(group, label):
    fig, ax = plt.subplots(figsize=(7, 4))
    x = np.arange(len(PROFILE_MACRS))
    w = 0.38
    if have(group):
        base = group[[k for k in group if k.startswith("baseline") and "rerun" not in k][0]]
        rerun = group[[k for k in group if "rerun" in k][0]]
        feat = group[[k for k in group if k.startswith("neutral")][0]]
        d_noise = [profile_l2(rerun[m], base[m]) for m in PROFILE_MACRS]
        d_feat = [profile_l2(feat[m], base[m]) for m in PROFILE_MACRS]
        ax.bar(x - w / 2, d_noise, w, label="Delta_noise (baseline rerun)", color="0.6")
        ax.bar(x + w / 2, d_feat, w, label="Delta_feature (neutral_limit)", color="C1")
        ax.set_yscale("log")
        ax.axhline(max(d_noise), ls=":", color="0.4", lw=1)
        ax.legend()
    else:
        ax.text(0.5, 0.5, "pending GPU run", ha="center", va="center", transform=ax.transAxes)
    ax.set_xticks(x)
    ax.set_xticklabels(["u", "rho", "temperature"])
    ax.set_ylabel("relative L2 profile difference")
    ax.set_title(f"Parity vs solver noise floor ({label}) - SCAFFOLD")
    fig.tight_layout()
    plt.show()


parity_bars(single, "single block")
../../../_images/validation_wind_engineering_07_stratified_neutral_limit_07_stratified_neutral_limit_8_0.png

Multiblock variant: parity across the 2:1 refinement

The same parity check with a 2:1 refinement slab present. If the Delta_feature bars sit at the same solver-noise floor as the single-block case, the inert feature composes across the refinement interface (F2C/C2F/border reconstruction) without introducing any discontinuity - a term applied in bulk collision but omitted from border reconstruction or the level-transfer rescale would push Delta_feature above the floor here. The multiblock parity is the robust interface-composition check (the level-0 stitched export layout makes a raw per-node interface profile unreliable, so parity-vs-noise is used rather than a hand-placed interface line).

[5]:
parity_bars(multiblock, "multiblock 2:1")
../../../_images/validation_wind_engineering_07_stratified_neutral_limit_07_stratified_neutral_limit_10_0.png

Verdict (populated at P5)

Once the run exists, the regression passes iff Delta_feature <~ Delta_noise for every quantity in BOTH the single-block and multiblock parity charts, and the multiblock temperature profile shows no sawtooth at the refinement interfaces. Any failure means the (supposedly inert) feature code path has moved the pre-feature physics and must be fixed before the 05 / 06 benchmark rungs are trusted.