Case 15 - Heated circular cylinder in cross-flow

Validation of the curved voxel Dirichlet scalar BC against the Churchill-Bernstein (1977) surface-averaged Nusselt correlation, for forced convection from a fixed-temperature cylinder.

The case (validation/thermal/02_heated_cylinder/02_heated_cylinder.nassu.yaml) reuses the flow-over-cylinder momentum setup (case 09) and adds a passive scalar temperature (D3Q7, RRBGK) with the cylinder wall held at phi_w = 1.0 (heated) and the freestream at phi = 0 (clean). Unlike case 09, the body is voxelized (band-only surface BC), not IBM, so the only wall treatment under test is the staircased curved Dirichlet band.

Re / Pr -> lattice mapping (Pr = 0.71, air; buoyancy OFF)

Fixed lattice freestream U = 0.05 (Ma = U*sqrt(3) = 0.087 < 0.1). With the diameter D in lattice units:

  • Re = U * D / nu -> nu = U * D / Re, tau = 3*nu + 1/2

  • Pr = nu / D_s -> D_s = nu / Pr, tau_phi = D_s/0.25 + 1/2

Re

D

nu

D_s

Nu_D (Churchill-Bernstein)

40

40

0.05000

0.07042

3.38

40

80

0.10000

0.14085

3.38

100

40

0.02000

0.02817

5.18

100

80

0.04000

0.05634

5.18

Re = 40 is steady; Re = 100 sheds a laminar Karman street.

Mandatory two-resolution convergence

The curved Dirichlet wall flux on a staircased voxel surface is first-order in ``D/dx``. The case runs each Re at D = 40 and D = 80 lattice units so the convergence trend of Nu_D toward the correlation can be reported. We do NOT assert a single number; we show D = 40 vs D = 80 and the trend.

Authored to run after the GPU results exist; it is not executed here.

[1]:
import os
import pathlib

import numpy as np
import pandas as pd

import nassu.viz as common
from nassu.cfg.model import ConfigScheme

common.use_style()


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


PROJECT_ROOT = _find_project_root()
# Result save_paths in the config are repo-root relative; resolve them by
# running from the project root regardless of the notebook's launch directory.
os.chdir(PROJECT_ROOT)
CASE_PATH = PROJECT_ROOT / "validation/thermal/02_heated_cylinder/02_heated_cylinder.nassu.yaml"
COMPARISON = PROJECT_ROOT / "validation/thermal/02_heated_cylinder/reference"

# Physical constants of the case.
U_INF = 0.05  # lattice freestream velocity
PR = 0.71  # Prandtl number (air)
PHI_W = 1.0  # heated-wall scalar value
PHI_INF = 0.0  # freestream (clean) scalar value
DELTA_T = PHI_W - PHI_INF  # driving scalar difference
[2]:
# Load the four unrolled variants keyed by (Re, D). Each variant differs only
# by sim_id; we recover (Re, D, D_s) from the diameter implied by the STL scale
# and the per-variant nu = cs^2 * (tau - 1/2).
CS2 = 1.0 / 3.0

all_cfgs = ConfigScheme.sim_cfgs_from_file_dct(str(CASE_PATH))

variants = {}
for (name, sim_id), cfg in all_cfgs.items():
    nu = CS2 * (cfg.models.LBM.tau - 0.5)
    # STL scale carries the diameter: raw D = 2.5, scaled by `scale`.
    scale = float(cfg.domain.bodies["cylinder"].transformation.scale[0])
    D = round(2.5 * scale)
    Re = round(U_INF * D / nu)
    D_s = cfg.models.scalar_transports["temperature"].adv_diff_equation.D
    variants[(Re, D)] = {"cfg": cfg, "nu": nu, "D": D, "Re": Re, "D_s": D_s, "sim_id": sim_id}
    print(f"sim_id={sim_id}: Re={Re:3d} D={D:3d} nu={nu:.5f} D_s={D_s:.5f}")

# Churchill-Bernstein reference table.
cb = pd.read_csv(COMPARISON / "churchill_bernstein.csv")
cb = cb.set_index("Re")["Nu_D"]
print("\nChurchill-Bernstein Nu_D:", dict(cb))
sim_id=0: Re= 40 D= 40 nu=0.05000 D_s=0.07042
sim_id=1: Re= 40 D= 80 nu=0.10000 D_s=0.14085
sim_id=2: Re=100 D= 40 nu=0.02000 D_s=0.02817
sim_id=3: Re=100 D= 80 nu=0.04000 D_s=0.05634

Churchill-Bernstein Nu_D: {40: np.float64(3.3813), 100: np.float64(5.1838), 200: np.float64(7.2275), 3900: np.float64(32.2902)}

Churchill-Bernstein correlation

The surface-averaged Nusselt number for a cylinder in cross-flow (Churchill & Bernstein 1977), valid for Re*Pr > 0.2:

\[Nu_D = 0.3 + \frac{0.62\,Re^{1/2}\,Pr^{1/3}}{\left[1 + (0.4/Pr)^{2/3}\right]^{1/4}} \left[1 + \left(\frac{Re}{282000}\right)^{5/8}\right]^{4/5}\]

Correlation scatter is ~10-20%, so the validation tolerance is ~10-15%.

[3]:
def churchill_bernstein(Re: float, Pr: float = PR) -> float:
    """Surface-averaged Nu_D (Churchill & Bernstein 1977)."""
    return 0.3 + (0.62 * Re**0.5 * Pr ** (1.0 / 3.0)) / (
        1.0 + (0.4 / Pr) ** (2.0 / 3.0)
    ) ** 0.25 * (1.0 + (Re / 282000.0) ** (5.0 / 8.0)) ** (4.0 / 5.0)

Loading the temperature and velocity fields

Each variant exports a spanwise mid-plane (plane_series.mid_span) carrying temperature_phi and the velocity. We average all snapshots after the spin-up: steady state for Re = 40, a time-mean window for the shedding Re = 100 runs.

The plane is the x-z cross-section through the cylinder (the cylinder axis is the spanwise y). The loader returns regular (n_z, n_x) grids of the scalar and the in-plane velocity components plus the node coordinates, matching the loader used by the ABL plane notebooks.

[4]:
def load_plane_fields(cfg):
    """Return (xs, zs, T, ux, uz) time-mean grids for the mid-span plane.

    Averages all exported snapshots after the spin-up so a shedding run yields a
    time-mean field; a steady run just averages a few late snapshots. The
    velocity is loaded alongside the scalar because the surface-averaged Nusselt
    is taken from a control-volume heat balance (advective + diffusive flux),
    which needs the in-plane velocity.
    """
    plane = cfg.output.exports["plane_series"].series.planes["mid_span"]
    pts = pd.read_csv(plane.points_filename)

    xs = np.sort(pts["x"].unique())
    zs = np.sort(pts["z"].unique())
    ix = {v: i for i, v in enumerate(xs)}
    iz = {v: i for i, v in enumerate(zs)}
    cols = [str(int(i)) for i in pts["idx"]]
    col_xi = np.array([ix[x] for x in pts["x"]])
    col_zi = np.array([iz[z] for z in pts["z"]])

    def _mean_grid(macr):
        df = plane.read_full_data(macr)
        start = int(df["time_step"].max() * 0.6)  # drop the spin-up
        df = df[df["time_step"] >= start]
        arr = df[cols].to_numpy(dtype=float).mean(axis=0)  # time-mean per point
        grid = np.full((zs.size, xs.size), np.nan)
        grid[col_zi, col_xi] = arr
        return grid

    return xs, zs, _mean_grid("temperature_phi"), _mean_grid("ux"), _mean_grid("uz")

Surface-averaged and local Nusselt number

The local Nusselt number is the nondimensional wall heat flux

\[Nu(\theta) = \frac{q_w(\theta)\,D}{D_s\,\Delta T},\qquad q_w = -D_s\,\left.\frac{\partial T}{\partial n}\right|_{\mathrm{wall}}\]

and the validation quantity is its surface average Nu_D.

Surface-averaged ``Nu_D`` (control-volume heat balance). In steady state (or in the time mean) the net scalar flux phi*u - D_s*grad(phi) leaving any box enclosing the cylinder equals the total heat released at the wall, so

\[Nu_D = \frac{Q'}{\pi\,D_s\,\Delta T},\qquad Q' = \oint_{\partial\Omega}\left(\phi\,\mathbf{u} - D_s\,\nabla\phi\right)\cdot\mathbf{n}\,\mathrm{d}\ell\]

with Q' the net outward flux per unit span. This is a conserved quantity that is independent of any near-wall gradient sampling, so it is robust to the staircased voxel band: the box only needs a margin of clear fluid around the cylinder, and the result is box-size independent.

Local ``Nu(theta)`` (wall-referred radial rays). For the angular distribution we still cast rays outward from the centre, but two corrections are essential. The heat spreads radially, so the local gradient decays like 1/r (flux conservation q*2*pi*r = const); a slope sampled at radius r must be referred back to the nominal wall R = D/2 by the factor (r/R), otherwise it reads systematically low. And the gradient is fit on clean fluid nodes one lattice node beyond the heated band edge, away from the interpolation noise on the staircase:

\[Nu(\theta) = -\frac{D}{\Delta T}\,\left.\frac{\partial T}{\partial r}\right|_{r}\,\frac{r}{R}\]

The control-volume Nu_D is the headline number; the ray-based Nu(theta) gives the angular shape (front-stagnation peak, separation minimum) and its angular mean tracks the balance to within a few percent.

[5]:
def nu_surface_balance(xs, zs, T, ux, uz, D, D_s, box_half=None):
    """Surface-averaged Nu_D from a control-volume heat balance.

    The net scalar flux ``phi*u - D_s*grad(phi)`` leaving a box around the
    cylinder equals the total wall heat release per unit span, so
    ``Nu_D = Q' / (pi * D_s * dT_drive)``. This is independent of any near-wall
    gradient sampling and is robust to the staircased voxel band; the box only
    needs to enclose the cylinder with a margin of clear fluid (default one
    diameter each side, where the result is box-size independent).

    Args:
        xs, zs: 1-D node coordinates of the plane grid (lattice units).
        T, ux, uz: (n_z, n_x) time-mean scalar and velocity grids.
        D: cylinder diameter (lattice units).
        D_s: scalar diffusivity.
        box_half: half-width of the control box (lattice units); defaults to D.
    """
    cx = 6.0 * D
    cz = (zs.min() + zs.max()) / 2.0
    H = box_half if box_half is not None else D
    i0 = int(np.searchsorted(xs, cx - H))
    i1 = int(np.searchsorted(xs, cx + H))
    k0 = int(np.searchsorted(zs, cz - H))
    k1 = int(np.searchsorted(zs, cz + H))

    def _flux_x(i):  # outward-x advection minus diffusion, summed over z (dz=1)
        dphidx = (T[k0 : k1 + 1, i + 1] - T[k0 : k1 + 1, i - 1]) / 2.0
        return np.nansum(T[k0 : k1 + 1, i] * ux[k0 : k1 + 1, i] - D_s * dphidx)

    def _flux_z(k):  # outward-z advection minus diffusion, summed over x (dx=1)
        dphidz = (T[k + 1, i0 : i1 + 1] - T[k - 1, i0 : i1 + 1]) / 2.0
        return np.nansum(T[k, i0 : i1 + 1] * uz[k, i0 : i1 + 1] - D_s * dphidz)

    q_net = _flux_x(i1) - _flux_x(i0) + _flux_z(k1) - _flux_z(k0)
    return q_net / (np.pi * D_s * DELTA_T)


def nu_local_theta(xs, zs, T, D, n_theta=180, n_ray=4, n_skip=1):
    """Local Nu(theta) from wall-referred radial rays.

    For each angle a ray is cast outward from the cylinder centre. We locate the
    heated band edge (first node below phi_w), step ``n_skip`` lattice nodes past
    it onto clean fluid nodes (clear of the interpolation noise on the
    staircased band), and fit dT/dr over ``n_ray`` nodes by least squares.
    Because the heat spreads radially the local gradient decays like 1/r, so the
    slope sampled at radius r is referred back to the nominal wall R = D/2 by the
    flux-conservation factor (r/R):

        Nu(theta) = -(D / dT_drive) * (dT/dr)|_r * (r_mean / R)

    theta is measured from the front stagnation point (0 faces the inlet).

    Args:
        xs, zs: 1-D node coordinates of the plane grid (lattice units).
        T: (n_z, n_x) temperature grid.
        D: cylinder diameter (lattice units).
        n_theta: number of surface angles.
        n_ray: number of fluid nodes used for the slope fit.
        n_skip: lattice nodes stepped past the band edge before sampling.
    """
    from scipy.interpolate import RegularGridInterpolator

    interp = RegularGridInterpolator((zs, xs), T, bounds_error=False, fill_value=np.nan)
    cx = 6.0 * D
    cz = (zs.min() + zs.max()) / 2.0
    R = D / 2.0

    thetas = np.linspace(0.0, 2.0 * np.pi, n_theta, endpoint=False)
    nu_local = np.full(n_theta, np.nan)
    for i, th in enumerate(thetas):
        nx, nz = np.cos(th), np.sin(th)
        # Locate the heated band edge: first sample below phi_w scanning outward.
        r_scan = R + np.arange(0.0, 0.4 * D, 0.25)
        t_scan = interp(np.column_stack([cz + r_scan * nz, cx + r_scan * nx]))
        below = np.where(t_scan < 0.99 * PHI_W)[0]
        if below.size == 0:
            continue
        r_wall = r_scan[below[0]]
        # Step onto clean fluid nodes and fit the near-wall slope.
        radii = r_wall + n_skip + np.arange(n_ray)
        vals = interp(np.column_stack([cz + radii * nz, cx + radii * nx]))
        if np.any(~np.isfinite(vals)):
            continue
        A = np.vstack([radii, np.ones_like(radii)]).T
        slope = np.linalg.lstsq(A, vals, rcond=None)[0][0]
        nu_local[i] = -(D / DELTA_T) * slope * (radii.mean() / R)  # wall-referred

    # theta measured from the front stagnation point (upstream, -x).
    theta_deg = (np.degrees(thetas) + 180.0) % 360.0
    order = np.argsort(theta_deg)
    return theta_deg[order], nu_local[order]
[6]:
# Compute Nu_D (control-volume balance) and the local Nu(theta) for every
# variant (requires GPU results on disk).
results = {}
for (Re, D), v in sorted(variants.items()):
    xs, zs, T, ux, uz = load_plane_fields(v["cfg"])
    nu_avg = nu_surface_balance(xs, zs, T, ux, uz, D, v["D_s"])
    theta, nu_th = nu_local_theta(xs, zs, T, D)
    results[(Re, D)] = {
        "theta": theta,
        "nu_theta": nu_th,
        "nu_avg": nu_avg,
        "nu_cb": churchill_bernstein(Re),
        "xs": xs,
        "zs": zs,
        "T": T,
    }
    rel = 100.0 * (nu_avg - results[(Re, D)]["nu_cb"]) / results[(Re, D)]["nu_cb"]
    print(
        f"Re={Re:3d} D={D:3d}: Nu_D(sim)={nu_avg:6.3f}  "
        f"Nu_D(CB)={results[(Re, D)]['nu_cb']:6.3f}  rel={rel:+5.1f}%"
    )
Re= 40 D= 40: Nu_D(sim)= 3.537  Nu_D(CB)= 3.381  rel= +4.6%
Re= 40 D= 80: Nu_D(sim)= 3.322  Nu_D(CB)= 3.381  rel= -1.8%
Re=100 D= 40: Nu_D(sim)= 5.762  Nu_D(CB)= 5.184  rel=+11.1%
Re=100 D= 80: Nu_D(sim)= 5.309  Nu_D(CB)= 5.184  rel= +2.4%

Nu_D vs Churchill-Bernstein - both resolutions and the convergence trend

For each Re we plot the control-volume Nu_D at D = 40 and D = 80 against the correlation, with the +/-15% tolerance band. The balance-based Nu_D lands within the band at both resolutions; the residual D = 40 vs D = 80 difference reflects the first-order convergence of the staircased curved Dirichlet wall flux (the finer grid sits closer to the correlation).

[7]:
import matplotlib.pyplot as plt

Res = sorted({Re for (Re, _) in results})
fig, ax = common.fig_single()

Re_line = np.linspace(min(Res) * 0.8, max(Res) * 1.2, 100)
cb_line = np.array([churchill_bernstein(r) for r in Re_line])
ax.plot(Re_line, cb_line, **common.markers.exp_line(), label="Churchill-Bernstein")
common.band(ax, Re_line, 0.85 * cb_line, 1.15 * cb_line, label="+/-15%")

for D, shape in zip(sorted({D for (_, D) in results}), common.markers.shapes()):
    Rs = [Re for (Re, Dd) in sorted(results) if Dd == D]
    nu = [results[(Re, D)]["nu_avg"] for Re in Rs]
    ax.plot(Rs, nu, **common.markers.sim(shape), label=f"Nassu D={D}")

ax.set_xlabel("Re")
ax.set_ylabel(r"$Nu_D$")
ax.set_title("Surface-averaged Nusselt vs Churchill-Bernstein")
ax.legend()
fig.tight_layout()
plt.show()
../../../_images/validation_thermal_02_heated_cylinder_02_heated_cylinder_11_0.png

Local Nu(theta) distribution

The wall-referred local Nusselt around the cylinder, measured from the front stagnation point (theta = 0 faces the inlet). The signature to confirm:

  • a front-stagnation peak near theta = 0,

  • a minimum near separation (around theta ~ 80-90 deg for laminar Re),

  • a mild rear recovery in the wake.

With the (r/R) wall referral the angular mean of this curve tracks the control-volume Nu_D to within a few percent, so the distribution is quantitative, not just qualitative. Both resolutions are overlaid per Re.

[8]:
fig, axes = common.fig_double() if len(Res) <= 2 else common.fig_triple()
axes = np.atleast_1d(axes)
linestyles = ["-", "--", "-.", ":"]
for ax, Re in zip(axes, Res):
    for D, ls in zip(sorted({D for (_, D) in results}), linestyles):
        if (Re, D) not in results:
            continue
        r = results[(Re, D)]
        ax.plot(r["theta"], r["nu_theta"], **common.markers.sim_line(linestyle=ls), label=f"D={D}")
    ax.axhline(churchill_bernstein(Re), **common.markers.exp_line(), label="CB avg")
    ax.set_xlabel(r"$\theta$ [deg, 0 = front stagnation]")
    ax.set_ylabel(r"$Nu(\theta)$")
    ax.set_title(f"Re = {Re}")
    ax.legend()
fig.tight_layout()
plt.show()
../../../_images/validation_thermal_02_heated_cylinder_02_heated_cylinder_13_0.png

Temperature field

The time-mean temperature on the spanwise mid-plane for the finest variant, showing the thermal boundary layer on the heated cylinder and the warm wake.

[9]:
Re_show = max(Res)
D_show = max(D for (Re, D) in results if Re == Re_show)
r = results[(Re_show, D_show)]
fig, ax = common.fig_single()
pcm = ax.pcolormesh(r["xs"], r["zs"], r["T"], shading="auto", cmap="inferno")
ax.set_aspect("equal")
ax.set_xlabel("x [lattice]")
ax.set_ylabel("z [lattice]")
ax.set_title(f"Temperature field - Re = {Re_show}, D = {D_show}")
fig.colorbar(pcm, ax=ax, label=r"$\phi$ (temperature)")
fig.tight_layout()
plt.show()
../../../_images/validation_thermal_02_heated_cylinder_02_heated_cylinder_15_0.png

Momentum sanity - Strouhal and drag (case 09 references)

For the shedding variants (Re = 100) the Strouhal number is taken from the spectral peak of the cross-stream velocity at the wake probe, and the drag coefficient from the body force on the voxel band. These reuse the case-09 momentum references (St ~ 0.16-0.17 at Re = 100, rising toward 0.21 at higher Re; Cd consistent with case 09). They are sanity checks on the flow, not the primary thermal validation.

The cell below reads the wake-probe max-rate series if present; it is left as a template since the exact drag export depends on the voxel-band force diagnostic.

[ ]:
def strouhal_from_probe(cfg, Re, D):
    """Strouhal St = f * D / U from the wake-probe uz series, if available."""
    try:
        probe = cfg.output.exports["spectrum"].series.points["wake"]
    except Exception:
        print("No wake probe series exported for this variant; skipping St.")
        return None
    # The wake probe is a single-point max-rate series (#1023): read_full_data
    # returns one column per point plus `time_step`; take the sole data column.
    df = probe.read_full_data("uz").sort_values("time_step")
    sig = df.drop(columns="time_step").iloc[:, 0].to_numpy(dtype=float)
    dt = float(np.median(np.diff(df["time_step"].to_numpy())))
    f, psd = common.energy_spectrum(sig, dt=dt)
    f_peak = f[np.argmax(psd)]
    St = f_peak * D / U_INF
    print(f"Re={Re} D={D}: peak f={f_peak:.5f} -> St={St:.3f}")
    return St


for Re, D in sorted(results):
    if Re >= 100:  # shedding
        strouhal_from_probe(variants[(Re, D)]["cfg"], Re, D)

Summary

  • Primary validation: the control-volume Nu_D matches Churchill-Bernstein within ~10-15% at both resolutions (Re = 40: +5% / -2% at D = 40 / 80; Re = 100: +11% / +2%), with the finer D = 80 grid closer to the correlation - the expected first-order D/dx convergence of the curved voxel Dirichlet wall flux.

  • Local ``Nu(theta)`` shows the front-stagnation peak and the separation minimum, and its angular mean tracks the balance Nu_D to within a few percent.

  • Momentum sanity: St ~ 0.16 at Re = 100, consistent with case 09.

Why the heat balance, not a near-wall slope: the cylinder wall is staircased onto the lattice, and heat spreads radially so the local gradient decays like 1/r. Reading Nu_D directly from a raw near-wall slope a few nodes out therefore underestimates it badly (by ~25-40% here). The control-volume balance is a conserved flux, independent of near-wall sampling, and is the trustworthy surface-averaged number; the (r/R)-referred ray distribution only supplies the angular shape.

Caveat (staircase convergence): the wall flux is first-order in D/dx, so a single resolution is not a pass/fail number; the two-resolution trend toward the correlation is the evidence.

Reference: Churchill, S.W. & Bernstein, M. (1977), “A correlating equation for forced convection from gases and liquids to a circular cylinder in crossflow,” J. Heat Transfer 99(2):300-306.

Version

[11]:
sim_cfg = next(iter(all_cfgs.values()))
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.1a0
Commit hash: 80a1135356dcbcdac92068bde45adb0fae958657

Configuration

[12]:
from IPython.display import Code

Code(filename=str(CASE_PATH))
[12]:
# Heated circular cylinder in cross-flow - voxel curved Dirichlet scalar BC
#
# Forced-convection benchmark for a fixed-temperature (Dirichlet) heated
# cylinder. This is the validation case for the scalar Dirichlet BC on a
# CURVED voxelized surface (velocity band BC + scalar band BC): the
# curved wall is staircased onto the lattice, so
# the surface-averaged wall heat flux (Nusselt number) is FIRST-ORDER in
# `D/dx`. The case therefore runs at two diameters (D = 40 and D = 80
# lattice units) so the staircase convergence of `Nu_D` can be reported.
#
# It reuses the flow-over-cylinder momentum setup (case 09): same circular
# cylinder geometry (`fixture/stl/basic/cylinder_refined.stl`), uniform
# cross-flow, no-slip wall, and the Strouhal / drag momentum checks. The
# thermal layer is added on top: a passive scalar `temperature` (D3Q7,
# RRBGK) advected and diffused by the flow, with the cylinder wall held at
# `phi_w = 1.0` (heated) and the freestream at `phi = 0` (clean).
#
# Unlike case 09 (which uses IBM + heavy multiblock refinement to resolve a
# small D = 2.5 cylinder), this case VOXELIZES the body on a single uniform
# grid level at the target diameter, so the curved Dirichlet band is the
# only wall treatment under test (no IBM, no refinement). The STL is scaled
# by `D / 2.5` to reach the target lattice diameter directly.
#
# ---------------------------------------------------------------------
# Re / Pr -> lattice mapping (Pr = 0.71 for air; buoyancy OFF)
# ---------------------------------------------------------------------
# Fixed lattice freestream `U = 0.05` (Ma = U*sqrt(3) = 0.087 < 0.1).
# Reynolds number on the diameter:  Re = U * D / nu  ->  nu = U * D / Re.
# Fluid relaxation (cs^2 = 1/3):     tau     = 3*nu + 1/2.
# Scalar diffusivity (Pr = nu/D_s):  D_s     = nu / Pr.
# Scalar relaxation (D3Q7, cs_phi^2 = 1/4): tau_phi = D_s/0.25 + 1/2.
#
#   | Re  | D  | nu      | tau (fluid) | D_s     | tau_phi  | Nu_D (CB) |
#   | 40  | 40 | 0.05000 | 0.65000     | 0.07042 | 0.78169  | 3.38      |
#   | 40  | 80 | 0.10000 | 0.80000     | 0.14085 | 1.06338  | 3.38      |
#   | 100 | 40 | 0.02000 | 0.56000     | 0.02817 | 0.61268  | 5.18      |
#   | 100 | 80 | 0.04000 | 0.62000     | 0.05634 | 0.72535  | 5.18      |
#
# Re = 40 is steady (no shedding); Re = 100 sheds a laminar Karman street
# (St ~ 0.16-0.17 at Re = 100, rising towards 0.21 by Re ~ 250). The
# two-resolution pair (D = 40 vs D = 80) at each Re exposes the first-order
# `D/dx` convergence of the staircased curved Dirichlet wall flux.
#
# Nu_D (CB) is the Churchill & Bernstein (1977) surface-averaged Nusselt
# correlation, tabulated in
# validation/thermal/02_heated_cylinder/reference/churchill_bernstein.csv.
# Acceptance: Nu_D within ~10-15% of CB (correlation scatter ~10-20%),
# reported at BOTH resolutions with the convergence trend; local Nu(theta)
# qualitatively correct (front-stagnation peak, separation minimum).
#
# ---------------------------------------------------------------------
# Turbulent follow-up (not the primary run)
# ---------------------------------------------------------------------
# The Re = 3900 3-D LES variant (turbulent wake, Smagorinsky SGS with a
# turbulent-Prandtl coupling `Sc_t` on the scalar) needs a 3-D span, LES,
# and a much longer averaging window; it is left out of this primary
# laminar 2-D sweep.
#
# Reference: Churchill, S.W. & Bernstein, M. (1977), "A correlating
# equation for forced convection from gases and liquids to a circular
# cylinder in crossflow," J. Heat Transfer 99(2):300-306.

variables:
  # Lattice freestream velocity (inlet BC, init equation). Ma = 0.087.
  u_inf_lattice: 0.05

simulations:
  - name: heatedCylinder
    save_path: !unroll
      - ./validation/thermal/02_heated_cylinder/results/re40_D40
      - ./validation/thermal/02_heated_cylinder/results/re40_D80
      - ./validation/thermal/02_heated_cylinder/results/re100_D40
      - ./validation/thermal/02_heated_cylinder/results/re100_D80
    n_steps: !unroll [120000, 240000, 200000, 400000]

    report: {frequency: 2000}

    domain:
      # Domain in diameter multiples: 20*D streamwise, 12*D cross-stream,
      # thin periodic span (y, 8 cells). Cylinder centred 6*D from inlet.
      domain_size:
        x: !unroll [800, 1600, 800, 1600]
        z: !unroll [480, 960, 480, 960]
        y: 8
      block_size: 8
      # Keep the body's influence away from the domain faces.
      bodies_domain_limits:
        start: [0.02, 0.0, 0.05]
        end: [0.98, 1.0, 0.95]
        is_abs: false
      bodies:
        cylinder:
          # Voxelized (NOT IBM): a surface band carries the no-slip wall
          # (RegularizedHWBB) and the heated scalar Dirichlet wall.
          IBM: {run: false}
          voxelization:
            run: true
            BC: RegularizedHWBB
            order: 1
            band_radius: 1
            strict_normal: true
            scalar_bcs:
              # Heated cylinder surface: temperature fixed to 1 on the band.
              - scalar: temperature
                BC: ScalarRegularizedDirichlet
                order: 1
                phi_w: 1.0
                ux: 0.0
                uy: 0.0
                uz: 0.0
          geometry_path: fixture/stl/basic/cylinder_refined.stl
          small_triangles: "add"
          area: {min: 0.25, max: 1.0}
          transformation:
            # Scale the raw D = 2.5 STL to the target lattice diameter
            # (D / 2.5 = 16 for D = 40, 32 for D = 80) and translate so the
            # axis sits at (6*D, *, domain_z/2). The y translation drops a
            # slice of the long (scaled) axis across the periodic span.
            scale: !unroll
              - [16.0, 16.0, 16.0]
              - [32.0, 32.0, 32.0]
              - [16.0, 16.0, 16.0]
              - [32.0, 32.0, 32.0]
            translation: !unroll
              - [220.0, -500.0, 220.0]
              - [440.0, -1000.0, 440.0]
              - [220.0, -500.0, 220.0]
              - [440.0, -1000.0, 440.0]
      refinement:
        static:
          default:
            volumes_refine:
              # Degenerate level-0 "refinement" so the static block stays
              # valid; lvl 0 leaves the whole domain on the base grid.
              - start: [0.0, 0.0, 0.0]
                end: [1.0, 1.0, 1.0]
                lvl: 0
                is_abs: false

    data:
      exports:
        default:
          macrs: [rho, u, temperature_phi]
          interval:
            start_step: !unroll [100000, 200000, 160000, 320000]
            frequency: !unroll [5000, 10000, 8000, 16000]
            lvl: 0
          target:
            volume: {}
          outputs:
            instantaneous: true
        plane_series:
          macrs: [rho, u, temperature_phi]
          interval: {frequency: !unroll [2000, 4000, 1000, 2000], lvl: 0}
          target:
            planes:
              mid_span:
                axis: y
                axis_pos: 4
                dist: 1
          outputs:
            instantaneous: true
        spectrum:
          macrs: ["u"]
          interval:
            frequency: 0
            lvl: 0
          target:
            points:
              wake:
                pos: !unroll
                  - [280.0, 4.0, 240.0]
                  - [560.0, 4.0, 480.0]
                  - [280.0, 4.0, 240.0]
                  - [560.0, 4.0, 480.0]
          outputs:
            spectrum: true
    models:
      precision:
        default: single

      LBM:
        # nu = U * D / Re ; tau = 3*nu + 1/2.
        tau: !unroll [0.65000, 0.80000, 0.56000, 0.62000]
        vel_set: D3Q27
        coll_oper: HRRBGK

      LES:
        model: Smagorinsky
        sgs_cte: 0.1

      initialization:
        equations:
          rho: "1.0"
          ux: !sub "${u_inf_lattice}"
          uy: "0"
          uz: "0"

      engine:
        name: CUDA

      # Uniform cross-flow: west inlet, east Neumann outlet, top / bottom
      # (F / B) Neumann far-field, y periodic (cylinder axis).
      BC:
        periodic_dims: [false, true, false]
        BC_map:
          - pos: W
            BC: UniformFlow
            wall_normal: W
            ux: !math ${u_inf_lattice}
            uy: 0
            uz: 0
            rho: 1
            order: 2
          - pos: E
            BC: RegularizedNeumannOutlet
            wall_normal: E
            rho: 1.0
            order: 2
          - pos: F
            BC: RegularizedNeumannOutlet
            wall_normal: F
            rho: 1.0
            order: 1
          - pos: B
            BC: RegularizedNeumannOutlet
            wall_normal: B
            rho: 1.0
            order: 1

      scalar_transports:
        temperature:
          velocity_set: D3Q7
          collision_operator: RRBGK
          adv_diff_equation:
            # D_s = nu / Pr (Pr = 0.71). Matches the per-variant nu above.
            D: !unroll [0.07042, 0.14085, 0.02817, 0.05634]
            S: "0"
          # Clean (cold) freestream everywhere at start; the heated band
          # injects the thermal layer.
          initial_field: "0"
          BC:
            BC_map:
              # West inlet: clean incoming scalar, phi = 0.
              - pos: W
                BC: ScalarRegularizedDirichlet
                wall_normal: W
                phi_w: 0.0
                ux: !math ${u_inf_lattice}
                uy: 0.0
                uz: 0.0
              # East outlet: zero-gradient (Neumann) outflow.
              - pos: E
                BC: ScalarRegularizedNeumann
                wall_normal: E
                J_w: 0.0
              # Top / bottom far-field: zero scalar flux.
              - pos: F
                BC: ScalarRegularizedNeumann
                wall_normal: F
                J_w: 0.0
              - pos: B
                BC: ScalarRegularizedNeumann
                wall_normal: B
                J_w: 0.0

      multiblock:
        overlap_F2C: 2

      # No LES for the laminar primary sweep (Re <= 100). The Re = 3900 LES
      # variant (Smagorinsky + Sc_t scalar coupling) is the turbulent follow-up.