Atmospheric Boundary Layer

Validation of the neutral atmospheric boundary layer (ABL) over the five terrain categories of EN 1991-1-4 (Eurocode 1), CAT 0 to CAT IV. The case reproduces the published AeroSim ABL validation study and follows the guided-case setup methodology:

A wind-tunnel-like arrangement is used: the Synthetic Eddy Method (SEM) imposes the target Eurocode logarithmic profile and turbulence at the inlet, and roughness fins on the floor maintain the profile along the fetch:

Inlet turbulence (SEM). The inflow is generated with the synthetic eddy method (SEM): each inlet node is given the target mean velocity profile and Reynolds stress tensor, built from the Eurocode 1 targets and the documented v/w heuristics (Iv = 0.75 Iu, Iw = 0.50 Iu, Lv = 0.25 Lu, Lw = 0.10 Lu; see eurocode.py).

This notebook must be re-executed on a validation-class GPU after the run; the analysis below is inlet-method agnostic (it reads the reference-station time series) and additionally reports the lateral and vertical turbulence intensities against the heuristic targets.

localimage

Setup

The floor is an IBM plane at 5.01 lattice units. One level-0 node corresponds to 8 m full-scale (scale 1/8) and the refined slab around the measurement axis runs at level 1, i.e. 4 m - a single resolution. The roughness fins are 6 m wide on a 16 m x 32 m grid; only the fin height changes per category (CAT I: 1 m, CAT II: 2 m, CAT III: 6 m, CAT IV: 10 m; CAT 0 has no fins). The ground plane applies an equilibrium log-law wall model with the category roughness length z0. The outlet is a Neumann condition and the lateral and top faces are free-slip.

Reference conditions: H = 150 m (18.75 lattice units), U_H = 0.06 (lattice), giving Re_H = U_H H / nu = 337,500. Statistics are collected over T / CTS = 128 convective time scales (CTS = H / U_H = 312.5 steps, so 40,000 steps) after a 32 CTS development time.

All quantities are reported at the reference station, 1200 m downstream of the inlet, sampled along a vertical line every 2 level-0 steps. Profiles stop at 200 m full-scale - the validity limit of the Eurocode 1 targets:

Quantity

Eurocode 1 target

Acceptance

Mean velocity U(z)

logarithmic profile

+-5 %

Turbulence intensity Iu(z)

1 / ln(z / z0)

+-10 %

Integral length scale Lu(z)

EN 1991-1-4 Annex B

qualitative

Energy spectra

von Karman

qualitative

[1]:
from nassu.cfg.model import ConfigScheme

filename = (
    "validation/wind_engineering/01_atmospheric_boundary_layer/01_atmospheric_flow_sem.nassu.yaml"
)

sim_cfgs = ConfigScheme.sim_cfgs_from_file_dct(filename)
[2]:
import pathlib

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

import nassu.viz as common
from nassu.cfg.schemes.simul import SimulationConfigs

common.use_style()

BASE_HEIGHT = 5.01  # IBM ground plane height (lattice units)
M_PER_NODE = 8.0  # full-scale metres per level-0 node (scale 1/8)
H_REF_M = 150.0  # reference height (m)
U_H = 0.06  # lattice velocity at the reference height
DEV_TIME = 10_000  # development steps discarded from every series
SAMPLE_DT = 2.0  # station series sampling interval (level-0 steps)
DEV_SAMPLE_DT = 50.0  # development series sampling interval (level-0 steps)

# sim name -> (label, z0 [m], Eurocode profile CSV)
CATEGORIES = {
    "category_0_s4m": ("CAT 0", 0.003, "profile_log_cat0_H150_Uh0.06"),
    "category_1_s4m": ("CAT I", 0.01, "profile_log_cat1_H150_Uh0.06"),
    "category_2_s4m": ("CAT II", 0.05, "profile_log_cat2_H150_Uh0.06"),
    "category_3_s4m": ("CAT III", 0.3, "profile_log_cat3_H150_Uh0.06"),
    "category_4_s4m": ("CAT IV", 1.0, "profile_log_cat4_H150_Uh0.06"),
}

Processing

The reference-station line records the ux time series at every height. From a single read all four reported quantities follow: the time mean gives U(z), the standard deviation gives Iu(z), the autocorrelation integrated to its first zero crossing gives the integral time scale (converted to Lu(z) with Taylor’s frozen-turbulence hypothesis), and Welch’s method gives the energy spectra. The statistics helpers live in validation/notebooks/common.py.

[3]:
_series_cache: dict = {}


def read_line_series(sim_cfg: SimulationConfigs, series: str, line_name: str):
    """Time series of ux at every point of a probe line (statistics window only)."""
    key = (sim_cfg.name, sim_cfg.sim_id, series, line_name)
    if key not in _series_cache:
        # Keep at most one simulation's series in memory.
        if any(k[:2] != key[:2] for k in _series_cache):
            _series_cache.clear()
        line = sim_cfg.output.exports[series].series.lines[line_name]
        df = line.read_full_data("ux")
        df = df[df["time_step"] >= DEV_TIME].sort_values("time_step")
        points = pd.read_csv(line.points_filename)
        _series_cache[key] = (df, points)
    return _series_cache[key]


def line_statistics(
    sim_cfg: SimulationConfigs,
    series: str = "station",
    line_name: str = "reference_station",
    sample_dt: float = SAMPLE_DT,
):
    """Per-height statistics of a vertical probe line: U, Iu and Lu vs height (m)."""
    df, points = read_line_series(sim_cfg, series, line_name)
    stats = []
    for idx, z in zip(points["idx"], points["z"]):
        u = df[str(idx)].to_numpy(dtype=float)
        u_mean = float(u.mean())
        z_m = (z - BASE_HEIGHT) * M_PER_NODE
        if z_m <= 0.0 or u_mean <= 0.0:
            continue
        stats.append(
            {
                "z_m": z_m,
                "u_mean": u_mean,
                "intensity": float(u.std()) / u_mean,
                "length_scale_m": common.integral_length_scale(u, sample_dt) * M_PER_NODE,
            }
        )
    return pd.DataFrame(stats).sort_values("z_m")


def station_spectrum(
    sim_cfg: SimulationConfigs, z_target_m: float, line_name: str = "reference_station"
):
    """Premultiplied, variance-normalised spectrum at the station point closest
    to ``z_target_m``, returned against the reduced frequency ``n L_u / U``.

    The reduced frequency uses the integral length scale ``L_u`` (not the height
    ``z``): that is the length scale the von Karman / EN 1991-1-4 spectrum is
    written in. By Taylor's frozen-turbulence hypothesis ``L_u / U = T_u`` (the
    integral time scale), so ``n L_u / U`` reduces to ``f * T_u``.
    """
    df, points = read_line_series(sim_cfg, "station", line_name)
    z_m = (points["z"] - BASE_HEIGHT) * M_PER_NODE
    row = points.iloc[int((z_m - z_target_m).abs().argmin())]
    u = df[str(int(row["idx"]))].to_numpy(dtype=float)
    f, spec = common.energy_spectrum(u, SAMPLE_DT)
    t_u = common.integral_time_scale(u, SAMPLE_DT)
    f_L = f * t_u  # reduced frequency n L_u / U (Taylor: L_u / U = T_u)
    return f_L, spec * f / u.var()  # premultiplied, variance-normalized: f S / sigma^2


def von_karman_spectrum(fn: np.ndarray) -> np.ndarray:
    """von Karman / EN 1991-1-4 streamwise spectrum in reduced frequency
    ``f_L = n L_u / U``: ``6.8 f_L / (1 + 10.2 f_L)^(5/3)`` (EN 1991-1-4 Eq. B.2)."""
    return 6.8 * fn / (1.0 + 10.2 * fn) ** (5.0 / 3.0)

The Eurocode 1 target curves are the same profiles used to drive the SEM inlet, generated from the EN 1991-1-4 logarithmic equation per category (fixture/SEM/category_vprofile).

[4]:
comparison_folder = pathlib.Path("fixture/SEM/category_vprofile")

df_eu = {}
for sim_name, (_, z0, csv_name) in CATEGORIES.items():
    df = pd.read_csv(comparison_folder / f"{csv_name}.csv")
    df["Ix"] = (df["Rxx"] / (df["ux"] ** 2)) ** 0.5
    df_eu[sim_name] = df
[5]:
def plot_profiles(sim_name: str):
    """U(z) and Iu(z) at the reference station vs the Eurocode 1 targets.

    The shaded bands are the +-5 % (velocity) and +-10 % (intensity) acceptance
    ranges of the published study. Profiles upstream of the station (x = 80 m
    and x = 600 m) show the boundary-layer development along the fetch.
    """
    label, z0, _ = CATEGORIES[sim_name]
    sim_cfg = sim_cfgs[sim_name, 0]
    target = df_eu[sim_name]
    z_t = target["z"].to_numpy()

    fig, ax = common.fig_double()

    ax[0].fill_betweenx(
        z_t,
        0.95 * target["ux"],
        1.05 * target["ux"],
        color=common.colors.exp,
        alpha=0.25,
        linewidth=0,
    )
    ax[0].plot(target["ux"], z_t, **common.markers.exp_line(linestyle="--"), label="Eurocode 1")
    ax[1].fill_betweenx(
        z_t,
        0.9 * target["Ix"],
        1.1 * target["Ix"],
        color=common.colors.exp,
        alpha=0.25,
        linewidth=0,
    )
    ax[1].plot(target["Ix"], z_t, **common.markers.exp_line(linestyle="--"), label="Eurocode 1")

    for series, line_name, line_label, alpha, dt in [
        ("development", "velocity_profile_010", "x = 80 m", 0.35, DEV_SAMPLE_DT),
        ("development", "velocity_profile_075", "x = 600 m", 0.35, DEV_SAMPLE_DT),
        ("station", "reference_station", "station (1200 m)", 1.0, SAMPLE_DT),
    ]:
        profile = line_statistics(sim_cfg, series, line_name, sample_dt=dt)
        kw = common.markers.sim_line(alpha=alpha)
        ax[0].plot(profile["u_mean"], profile["z_m"], **kw, label=line_label)
        ax[1].plot(profile["intensity"], profile["z_m"], **kw, label=line_label)

    ax[0].set_xlabel(r"$u_x$")
    ax[0].set_xlim(0, 0.07)
    ax[1].set_xlabel(r"$I_{u}$")
    ax[1].set_xlim(0, 0.5)
    ax[1].legend()
    for a in ax:
        a.set_ylabel(r"$z$ [m]")
        a.set_ylim(0, 200)
    fig.suptitle(label)
    plt.tight_layout()
    plt.show(fig)


def plot_length_scale(sim_name: str):
    """Integral length scale Lu(z) at the station vs EN 1991-1-4 Annex B."""
    label, z0, _ = CATEGORIES[sim_name]
    station = line_statistics(sim_cfgs[sim_name, 0])

    fig, ax = common.fig_single()
    z = np.linspace(1.0, 100.0, 200)
    ax.plot(
        common.eurocode_length_scale(z, z0),
        z,
        **common.markers.exp_line(linestyle="--"),
        label="EN 1991-1-4 Annex B",
    )
    ax.plot(
        station["length_scale_m"],
        station["z_m"],
        **common.markers.sim_line(),
        label="station (1200 m)",
    )
    ax.set_ylabel(r"$z$ [m]")
    ax.set_xlabel(r"$L_{u}$ [m]")
    ax.set_ylim(0, 100)
    ax.set_xlim(0, 250)
    ax.legend()
    ax.set_title(label)
    plt.tight_layout()
    plt.show(fig)


def plot_spectra(sim_name: str, heights_m=(15.0, 50.0, 150.0)):
    """Premultiplied, variance-normalised streamwise spectra at the station.

    Both axes are in similarity form so the curve is directly comparable to the
    von Karman model at any height: the frequency is normalised as ``f z / U``
    (Monin coordinate) and the energy as the premultiplied, variance-normalised
    spectrum ``f S_uu / sigma_u^2`` (unit area under d ln f). Axes are clipped to
    the resolved, energy-containing band - the far tail sits below the LES grid
    cutoff and carries no meaningful signal.
    """
    label, _, _ = CATEGORIES[sim_name]
    sim_cfg = sim_cfgs[sim_name, 0]

    fig, ax = common.fig_triple()
    floor = 1e-3
    for i, z_m in enumerate(heights_m):
        fn, spec_n = station_spectrum(sim_cfg, z_m)
        ax[i].plot(fn, spec_n, alpha=0.8, label="Nassu")
        ax[i].plot(
            fn,
            von_karman_spectrum(fn),
            color=common.colors.exp,
            linestyle="--",
            label="von Karman",
        )
        # Clip to the meaningful band: keep only where the resolved spectrum is
        # above the sub-cutoff noise floor.
        keep = (fn > 0) & (spec_n > floor)
        if keep.any():
            ax[i].set_xlim(float(fn[keep].min()), float(fn[keep].max()))
        ax[i].set_ylim(floor, 2.0)
        ax[i].set_title(f"{label}, z = {z_m:.0f} m")
        ax[i].set_xscale("log")
        ax[i].set_yscale("log")
        ax[i].set_xlabel(r"$f\,L_u / U$")
    ax[0].set_ylabel(r"$f\,S_{uu} / \sigma_{u}^{2}$")
    ax[2].legend(loc="lower left")
    fig.suptitle(
        "Premultiplied spectra, normalised by "
        r"$\sigma_{u}^{2}$ vs reduced frequency $f\,L_u/U$"
    )
    plt.tight_layout()
    plt.show(fig)

Category 0

Sea or coastal terrain (z0 = 0.003 m): no fins on the plane, the profile is maintained by the wall model alone. The IBM diffusive layer occupies the first two nodes above the floor, so an elevated turbulence intensity is expected very close to the ground.

[6]:
plot_profiles("category_0_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_11_0.png
[7]:
plot_length_scale("category_0_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_12_0.png
[8]:
plot_spectra("category_0_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_13_0.png

Category I

Lakes or flat terrain (z0 = 0.01 m): 1 m fins. At the 4 m resolution the fins occupy a quarter node on the finest level, so their effect is mostly subgrid.

[9]:
plot_profiles("category_1_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_15_0.png
[10]:
plot_length_scale("category_1_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_16_0.png
[11]:
plot_spectra("category_1_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_17_0.png

Category II

Open terrain (z0 = 0.05 m): 2 m fins, half a node on the finest level.

[12]:
plot_profiles("category_2_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_19_0.png
[13]:
plot_length_scale("category_2_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_20_0.png
[14]:
plot_spectra("category_2_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_21_0.png

Category III

Suburban or industrial terrain (z0 = 0.3 m): 6 m fins, resolved by the grid.

[15]:
plot_profiles("category_3_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_23_0.png
[16]:
plot_length_scale("category_3_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_24_0.png
[17]:
plot_spectra("category_3_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_25_0.png

Category IV

Urban terrain (z0 = 1.0 m): 10 m fins, the tallest arrangement.

[18]:
plot_profiles("category_4_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_27_0.png
[19]:
plot_length_scale("category_4_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_28_0.png
[20]:
plot_spectra("category_4_s4m")
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_29_0.png

Streamwise development

Mean velocity along the fetch at the 15 m and 150 m full-scale heights, for every category. The profiles should be statistically homogeneous well before the reference station at 1200 m.

[21]:
def development_profile(sim_cfg: SimulationConfigs, line_name: str):
    """Time-mean ux along a streamwise probe line, vs x in metres."""
    df, points = read_line_series(sim_cfg, "development", line_name)
    x_m = points["x"].to_numpy(dtype=float) * M_PER_NODE
    u_mean = np.array([df[str(idx)].mean() for idx in points["idx"]], dtype=float)
    order = np.argsort(x_m)
    return x_m[order], u_mean[order]


fig, ax = common.fig_double()
for i, line_name in enumerate(["long_profile_15m", "long_profile_150m"]):
    for sim_name, (label, _, _) in CATEGORIES.items():
        x_m, u_mean = development_profile(sim_cfgs[sim_name, 0], line_name)
        ax[i].plot(x_m, u_mean, label=label)
    ax[i].axvline(1200.0, color=common.colors.refline, linewidth=1.0, alpha=0.7)
    ax[i].set_xlabel(r"$x$ [m]")
    ax[i].set_ylabel(r"$u_x$")
ax[0].set_title("z = 15 m")
ax[1].set_title("z = 150 m")
ax[1].legend()
plt.tight_layout()
plt.show(fig)
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_31_0.png

Streamwise development of density along the same probe lines. rho is not exported on the development lines (velocity only), so it is sampled from the instantaneous full-domain volume snapshot.

[22]:
import pyvista as pv

from nassu import viz


def development_profile_rho(sim_cfg: SimulationConfigs, line_name: str):
    """Instantaneous rho along a streamwise probe line, vs x in metres.

    rho is not exported on the development probe lines (velocity only), so it is
    sampled from the last full-domain volume snapshot along the same line points.
    """
    line = sim_cfg.output.exports["development"].series.lines[line_name]
    points = pd.read_csv(line.points_filename).sort_values("x")
    x_m = points["x"].to_numpy(dtype=float) * M_PER_NODE
    xyz = points[["x", "y", "z"]].to_numpy(dtype=float)
    src = viz.FieldSource.from_cfg(sim_cfg, export="full_domain")
    vol = src.read(max(src.steps), derived=False)
    grid = vol.combine() if hasattr(vol, "n_blocks") else vol
    rho = np.asarray(pv.PolyData(xyz).sample(grid)["rho"], dtype=float)
    return x_m, rho


fig, ax = common.fig_double()
for i, line_name in enumerate(["long_profile_15m", "long_profile_150m"]):
    for sim_name, (label, _, _) in CATEGORIES.items():
        x_m, rho = development_profile_rho(sim_cfgs[sim_name, 0], line_name)
        ax[i].plot(x_m, rho, label=label)
    ax[i].axvline(1200.0, color=common.colors.refline, linewidth=1.0, alpha=0.7)
    ax[i].set_xlabel(r"$x$ [m]")
    ax[i].set_ylabel(r"$\rho$")
ax[0].set_title("z = 15 m")
ax[1].set_title("z = 150 m")
ax[1].legend()
plt.tight_layout()
plt.show(fig)
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_33_0.png

Flow field

Instantaneous velocity magnitude on the coarse vertical mid-width plane (plane_series.mid_width, bounded to the boundary layer region), showing the turbulent boundary layer developing over the roughness fins.

[23]:

viz.enable_offscreen() PANEL = (1040, 240) plane_pos = 80.0 # mid-width (lattice units) FLOW_CATS = ("category_0_s4m", "category_2_s4m", "category_4_s4m") view = viz.frame_domain((600.0, 160.0, 40.0), "y", panel=PANEL, slice_coord=plane_pos) # Streamwise (x-z) mid-width plane coloured by velocity magnitude. One image per # category - a single side-by-side grid of these very wide panels is unreadable. for name in FLOW_CATS: source = viz.PlaneSource.from_cfg(sim_cfgs[name, 0], series="plane_series", plane="mid_width") panel = viz.Panel(CATEGORIES[name][0], source, view) plotter = viz.render_grid( [panel], steps=[max(source.steps)], scalar="u_mag", cmap="viridis", clim=(0.0, 0.08), bar_title="|u|", panel_size=PANEL, ) plotter.show()
/tmp/ipykernel_1248290/146349723.py:25: UserWarning: Using static image for notebook display.
Install trame for interactive backends: pip install "pyvista[jupyter]"
  plotter.show()
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_35_1.png
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_35_2.png
../../../_images/validation_wind_engineering_01_atmospheric_boundary_layer_01_atm_BL_sem_35_3.png

Version

[24]:
sim_cfg = sim_cfgs["category_4_s4m", 0]
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)
Version: 2.0.0a0
Commit hash: aaa6995a09d88707a467e1a8e1de7fc7f229de5b

Configuration

[25]:
from IPython.display import Code

Code(filename=filename)
[25]:
# Atmospheric boundary layer over five Eurocode 1 terrain categories
# (CAT 0-IV), aligned with the published validation study
# (https://docs.aerosim.io/validation/cases/vel_turb/abl.html) and the
# guided-case setup methodology
# (https://docs.aerosim.io/aerosim/guided-cases/ABL/setup.html).
#
# Scale 1/8: one level-0 lattice unit = 8 m full-scale; the refined slab
# around the measurement axis is 4 m. Reference height H = 150 m
# (18.75 lattice units), U_H = 0.06 -> Re_H = U_H * H / nu = 337,500.
#
# Reported quantities (notebook): mean velocity, turbulence intensity,
# integral length scales and energy spectra at the reference station,
# against the Eurocode 1 logarithmic profiles.

variables:
  simul:
    # Convective time scale CTS = H / U_H = 18.75 / 0.06 = 312.5 steps.
    # Statistics window: published T / CTS = 128 -> 40,000 steps, after a
    # 32 CTS development time.
    dev_time: 10000
    stats_time: 40000
    scale: !math 1/8
    plane_height: 5.01
    sigma_sem: 20
  domain:
    length: 600
    width: 160
    height: 112
  refine: &ANCHOR_VOLUMES_REFINE
    # Nested 3-level refinement wrapping the measurement axis (mirrors the
    # debug_DFSR ABL reference). Each level tightens around the station so the
    # near-ground ABL is resolved without a single wide coarse-fine interface
    # cutting the developing shear layer; the refinement starts just below the
    # IBM ground plane (z=2/3/4 < plane_height 5.01) and caps near 200 m.
    - start: [0.0, 65.0, 2.0]
      end: [250.0, 95.0, 25.0]
      lvl: 1
      is_abs: true
    - start: [0.0, 70.0, 3.0]
      end: [200.0, 90.0, 22.0]
      lvl: 2
      is_abs: true
    - start: [0.0, 73.0, 4.0]
      end: [170.0, 78.0, 10.0]
      lvl: 3
      is_abs: true
  var:
    # Reference station: 1200 m downstream of the inlet (canonical
    # measurement section of the published study).
    station_x: !math 1200 * ${simul.scale}
    # Probe heights above the IBM ground plane (full-scale metres * scale).
    z_15m: !math ${simul.plane_height} + (15 * ${simul.scale})
    z_150m: !math ${simul.plane_height} + (150 * ${simul.scale})
    # Profiles stop at 200 m full-scale - the validity limit of the
    # Eurocode 1 targets. Do not sample above this.
    z_200m: !math ${simul.plane_height} + (200 * ${simul.scale})
    # PODFS precursor plane extent. Unlike the Eurocode profiles above, the
    # precursor records the full inflow for replay, so it spans the whole ABL:
    # 200 m to each side of the spanwise centre and up to 400 m height.
    podfs_y_min: !math "0.5*${domain.width} - (200 * ${simul.scale})"
    podfs_y_max: !math "0.5*${domain.width} + (200 * ${simul.scale})"
    z_400m: !math "${simul.plane_height} + (400 * ${simul.scale})"

## Same variables to dependencies

simulations:
  - name: category_0_s4m
    save_path: ./validation/wind_engineering/01_atmospheric_boundary_layer/results/sem
    run_simul: true

    n_steps: !math "${simul.dev_time} + ${simul.stats_time}"

    report:
      frequency: 500
    checkpoint:
      export:
        interval: {frequency: 10000, start_step: !math "${simul.dev_time}"}
        finish_save: true
        keep_only_last_checkpoint: true

    data:
      monitors:
        fields:
          macrs_stats:
            macrs: [rho, u]
            stats: [min, max, mean]
            interval: {start_step: 0, frequency: 10}
      exports:
        full_domain:
          macrs: [rho, u]
          interval:
            frequency: 12500
            lvl: 0
          target:
            volume: {}
          outputs:
            instantaneous: true
        full_stats:
          macrs:
            - rho
            - u
          interval:
            frequency: 10
            start_step: !sub "${simul.dev_time}"
            lvl: 0
          target:
            volume: {}
          outputs:
            instantaneous: false
            stats:
              macrs_1st_order:
                - rho
                - u
              macrs_2nd_order:
                - u
        station:
          macrs: ["u"]
          interval: {frequency: 2, lvl: 0, start_step: !sub "${simul.dev_time}"}
          target:
            lines:
              reference_station:
                dist: 0.25
                start_pos: !math ["${var.station_x}", "0.5*${domain.width}", "${simul.plane_height}"]
                end_pos: !math ["${var.station_x}", "0.5*${domain.width}", "${var.z_200m}"]
          outputs:
            instantaneous: true
        development:
          macrs: ["u"]
          interval: {frequency: 50, lvl: 0, start_step: !sub "${simul.dev_time}"}
          target:
            lines:
              velocity_profile_010:
                dist: 0.25
                start_pos: !math [10, "0.5*${domain.width}", "${simul.plane_height}"]
                end_pos: !math [10, "0.5*${domain.width}", "${var.z_200m}"]
              velocity_profile_075:
                dist: 0.25
                start_pos: !math [75, "0.5*${domain.width}", "${simul.plane_height}"]
                end_pos: !math [75, "0.5*${domain.width}", "${var.z_200m}"]
              # Streamwise development at fixed full-scale heights.
              long_profile_15m:
                dist: 0.5
                start_pos: !math [0, "0.5*${domain.width}", "${var.z_15m}"]
                end_pos: !math [300, "0.5*${domain.width}", "${var.z_15m}"]
              long_profile_150m:
                dist: 0.5
                start_pos: !math [0, "0.5*${domain.width}", "${var.z_150m}"]
                end_pos: !math [300, "0.5*${domain.width}", "${var.z_150m}"]
          outputs:
            instantaneous: true
        plane_series:
          macrs: ["u"]
          interval: {frequency: 5000, lvl: 0}
          target:
            planes:
              # Coarse vertical streamwise overview plane at mid-width
              # (one node apart), bounded to the boundary layer region:
              # developing ABL over the roughness fins.
              mid_width:
                axis: y
                axis_pos: !math "0.5*${domain.width}"
                min: !math [0, 0]
                max: !math [600, 40]
                dist: 1
          # PODFS precursor recording. A dense, inlet-normal (YZ) plane at the
          # developed reference station: this is the precursor that the offline
          # `nassu podfs-build` compresses into a PODFS basis (POD in space +
          # Fourier series in time). The basis then replays as a `type: podfs`
          # inlet on a target case. SEM stays the default/interim inlet here -
          # this plane only records, it does not change the inflow.
          #
          # Sampled every 10 lvl-0 steps over the statistics window
          # (40,000 steps) -> ~4,000 snapshots, Fourier period T = 40,000 steps
          # (Nyquist period 20 steps). Lower the frequency to trade spectral
          # resolution for disk. Feed the resulting
          # `probes/precursor_inlet.planes.inlet_plane.xdmf` to a build-spec
          # `plane:` field (see 01_atmospheric_flow.podfs-build.yaml).
          outputs:
            instantaneous: true
        precursor_inlet:
          macrs: ["u"]
          interval: {frequency: 10, lvl: 0, start_step: !sub "${simul.dev_time}"}
          target:
            planes:
              # In-plane axes (y, z): 200 m to each side of the spanwise centre
              # (400 m span) and up to 400 m height, sampled at dist 0.25 lvl-0
              # nodes = 2 m full-scale -> a ~200 x 200 node analysis grid.
              inlet_plane:
                axis: x
                axis_pos: !math "${var.station_x}"
                min: !math ["${var.podfs_y_min}", "${simul.plane_height}"]
                max: !math ["${var.podfs_y_max}", "${var.z_400m}"]
                dist: 0.25

          outputs:
            instantaneous: true
    domain:
      domain_size:
        x: !sub "${domain.length}"
        y: !sub "${domain.width}"
        z: !math "${domain.height}"
      block_size: 8
      bodies:
        full_plane:
          IBM:
            order: 0
            cfg_use: plane_cfg
          geometry_path: fixture/stl/abl/ground.stl
          small_triangles: add
      global_transformations:
        - transformation:
            translation: !math [0, 0, "${simul.plane_height}"]
          point_clouds_apply: []
          bodies_apply: ["full_plane"]
          probes_apply: []
      refinement:
        static:
          lvl1:
            volumes_refine: *ANCHOR_VOLUMES_REFINE

    models:
      precision:
        default: single
      LBM:
        tau: 0.50001
        F:
          x: 0
          y: 0
          z: 0
        vel_set: D3Q27
        coll_oper: RRBGK
      initialization:
        inlet_field: true
      engine:
        name: CUDA
      BC:
        periodic_dims: [false, false, false]
        BC_map:
          - pos: E
            BC: RegularizedNeumannOutlet
            rho: 1.0
            wall_normal: E
            order: 2
          - pos: F
            BC: Neumann
            wall_normal: F
            order: 1
          - pos: B
            BC: RegularizedHWBB
            wall_normal: B
            order: 1
          - pos: N
            BC: Neumann
            wall_normal: N
            order: 0
          - pos: S
            BC: Neumann
            wall_normal: S
            order: 0
        inlet_turbulence:
          type: sem
          eddies:
            lengthscale: {x: !sub "${simul.sigma_sem}", y: !sub "${simul.sigma_sem}", z: !sub "${simul.sigma_sem}"}
            eddies_vol_density: 300
            domain_limits_yz:
              start: !math ["${simul.sigma_sem}", "-${simul.sigma_sem}"]
              end: !math ["${domain.width}-${simul.sigma_sem}", "${domain.height}-${simul.sigma_sem}"]
          profile:
            csv_profile_data: "./fixture/SEM/category_vprofile/profile_log_cat0_H150_Uh0.06.csv"
            z_offset: !math "${simul.plane_height}"
            length_mul: !math "${simul.scale}"

      LES:
        model: Smagorinsky
        sgs_cte: 0.17

      IBM:
        dirac_delta: 3_points
        forces_accomodate_time: 500
        body_cfgs:
          default:
            n_iterations: 3
            forces_factor: 1.0
          plane_cfg:
            n_iterations: 3
            forces_factor: 0.5
            wall_model:
              name: EqLog
              dist_ref: 3.125
              dist_shell: 0.125
              start_step: 300
              params:
                z0: !math "0.003*${simul.scale}"
                TDMA_max_error: 1e-04
                TDMA_max_iters: 10
                TDMA_min_div: 25
                TDMA_max_div: 25

      multiblock:
        overlap_F2C: 2

  - name: category_1_s4m
    parent: category_0_s4m
    run_simul: true

    domain:
      bodies:
        plates_obstacles:
          IBM:
            order: 1
          geometry_path: fixture/stl/abl/roughness_elements_cat1.stl
          volumes_limits:
            body_transformed:
              - start: !math [10, 20, 0]
                end: !math [300, "${domain.width} - 20", "0.5*${domain.height}"]
          small_triangles: add
          transformation:
            translation: [0, 1, 0]
            # abl/roughness_elements_cat*.stl are built at 1/10 scale (10 m
            # lattice) vs the old wind_tunnel plates' 1/32: factor 10/32.
            scale: !math ["20.0*${simul.scale}", "20.0*${simul.scale}", "10.0*${simul.scale}"] #1m
      global_transformations:
        - transformation: !not-inherit
            translation: !math [0, 0, "${simul.plane_height}"]
          point_clouds_apply: []
          bodies_apply: ["full_plane", "plates_obstacles"]
          probes_apply: []
    models:
      IBM:
        body_cfgs:
          plane_cfg:
            wall_model:
              params:
                z0: !math "0.01*${simul.scale}"
      BC:
        inlet_turbulence:
          type: sem
          profile:
            csv_profile_data: "./fixture/SEM/category_vprofile/profile_log_cat1_H150_Uh0.06.csv"

  - name: category_2_s4m
    parent: category_0_s4m
    run_simul: true

    domain:
      bodies:
        plates_obstacles:
          IBM:
            order: 1
          geometry_path: fixture/stl/abl/roughness_elements_cat2.stl
          volumes_limits:
            body_transformed:
              - start: !math [10, 20, 0]
                end: !math [300, "${domain.width} - 20", "${domain.height}"]
          small_triangles: add
          transformation:
            translation: [0, 1, 0]
            # abl/roughness_elements_cat*.stl are built at 1/10 scale (10 m
            # lattice) vs the old wind_tunnel plates' 1/32: factor 10/32.
            scale: !math ["20.0*${simul.scale}", "20.0*${simul.scale}", "10.0*${simul.scale}"] #2m
      global_transformations:
        - transformation: !not-inherit
            translation: !math [0, 0, "${simul.plane_height}"]
          point_clouds_apply: []
          bodies_apply: ["full_plane", "plates_obstacles"]
          probes_apply: []
    models:
      IBM:
        body_cfgs:
          plane_cfg:
            wall_model:
              params:
                z0: !math "0.05*${simul.scale}"
      BC:
        inlet_turbulence:
          type: sem
          profile:
            csv_profile_data: "./fixture/SEM/category_vprofile/profile_log_cat2_H150_Uh0.06.csv"

  - name: category_3_s4m
    parent: category_0_s4m
    run_simul: true

    domain:
      bodies:
        plates_obstacles:
          IBM:
            order: 1
          geometry_path: fixture/stl/abl/roughness_elements_cat2.stl
          volumes_limits:
            body_transformed:
              - start: !math [10, 20, 0]
                end: !math [300, "${domain.width} - 20", "${domain.height}"]
          small_triangles: add
          transformation:
            translation: [0, 1, 0]
            # abl/roughness_elements_cat*.stl are built at 1/10 scale (10 m
            # lattice) vs the old wind_tunnel plates' 1/32: factor 10/32.
            scale: !math ["20.0*${simul.scale}", "20.0*${simul.scale}", "3*10.0*${simul.scale}"] #6m
      global_transformations:
        - transformation: !not-inherit
            translation: !math [0, 0, "${simul.plane_height}"]
          point_clouds_apply: []
          bodies_apply: ["full_plane", "plates_obstacles"]
          probes_apply: []
    models:
      IBM:
        body_cfgs:
          plane_cfg:
            wall_model:
              params:
                z0: !math "0.3*${simul.scale}"
      BC:
        inlet_turbulence:
          type: sem
          profile:
            csv_profile_data: "./fixture/SEM/category_vprofile/profile_log_cat3_H150_Uh0.06.csv"

  - name: category_4_s4m
    parent: category_0_s4m
    run_simul: true

    domain:
      bodies:
        plates_obstacles:
          IBM:
            order: 1
          geometry_path: fixture/stl/abl/roughness_elements_cat2.stl
          volumes_limits:
            body_transformed:
              - start: !math [10, 20, 0]
                end: !math [300, "${domain.width} - 20", "${domain.height}"]
          small_triangles: add
          transformation:
            translation: [0, 1, 0]
            # abl/roughness_elements_cat*.stl are built at 1/10 scale (10 m
            # lattice) vs the old wind_tunnel plates' 1/32: factor 10/32.
            scale: !math ["20.0*${simul.scale}", "20.0*${simul.scale}", "5.0*10.0*${simul.scale}"] # 10m
      global_transformations:
        - transformation: !not-inherit
            translation: !math [0, 0, "${simul.plane_height}"]
          point_clouds_apply: []
          bodies_apply: ["full_plane", "plates_obstacles"]
          probes_apply: []
    models:
      IBM:
        body_cfgs:
          plane_cfg:
            wall_model:
              params:
                z0: !math "1.0*${simul.scale}"
      BC:
        inlet_turbulence:
          type: sem
          profile:
            csv_profile_data: "./fixture/SEM/category_vprofile/profile_log_cat4_H150_Uh0.06.csv"