Laminar Flat-Plate Boundary Layer (Blasius)

This notebook validates the local skin-friction coefficient Cf(x) of a zero-pressure-gradient laminar boundary layer over a flat plate against the closed-form Blasius similarity solution. The plate is an immersed-boundary (IBM) body, and the wall friction is read from the lean data.body_nodes friction export: per source triangle the solver writes the aggregated viscous traction t = 2 rho nu (S . n), and nassu.viz.friction splits it into a wall-normal (pressure) and a wall-tangential (skin-friction) part. For a flat plate the wall is everywhere tangent to the flow, so the traction is essentially pure wall-shear stress and Cf(x) follows directly.

Results require a GPU run. This notebook is committed UNEXECUTED. Run the case on a GPU first:

uv run nassu run validation/external_aero/03_flat_plate_boundary_layer/03_flat_plate_boundary_layer.nassu.yaml

then run all cells; every figure regenerates from the parsed config and the results/ export.

Configuration and parameters

The notebook reads every parameter from the parsed YAML, so it always matches the file the solver ran. Nothing is hard-coded.

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

filename = (
    "validation/external_aero/03_flat_plate_boundary_layer/03_flat_plate_boundary_layer.nassu.yaml"
)

sim_cfgs = ConfigScheme.sim_cfgs_from_file_dct(filename)
sim_cfg = next(iter(sim_cfgs.values()))
sim_cfg.name
[1]:
'laminarFlatPlateBlasius'
[2]:
import numpy as np

# Freestream speed from the inlet BC and viscosity from the LBM model (lattice units).
u_inf = max(bc.params["ux"] for bc in sim_cfg.models.BC.BC_map if "ux" in bc.params)
nu = sim_cfg.models.LBM.kinematic_viscosity
rho_inf = 1.0

# Plate geometry, taken from the exact placed body so x_le / L match the run.
plate = sim_cfg.domain.bodies["plate"]
verts = np.asarray(plate.lagrangian_fmt.geometry.vertices)
x_le = float(verts[:, 0].min())  # leading-edge x (domain coordinate)
x_te = float(verts[:, 0].max())  # trailing-edge x
plate_length = x_te - x_le

Re_L = u_inf * plate_length / nu
print(f"U_inf = {u_inf}  nu = {nu:.5g}  Ma = {np.sqrt(3) * u_inf:.4f}")
print(f"plate: x_le = {x_le}  x_te = {x_te}  L = {plate_length}")
print(f"Re_L  = {Re_L:.0f}  (laminar; transition ~ 5e5)")
U_inf = 0.05  nu = 0.0032  Ma = 0.0866
plate: x_le = 40.0  x_te = 360.0  L = 320.0
Re_L  = 5000  (laminar; transition ~ 5e5)

Blasius reference solution

For a zero-pressure-gradient laminar boundary layer the local skin-friction coefficient is

\[C_f(x) = \frac{0.664}{\sqrt{\mathrm{Re}_x}}, \qquad \mathrm{Re}_x = \frac{U_\infty\, x}{\nu},\]

and the plate-length-averaged value is Cf_bar(Re_L) = 1.328 / sqrt(Re_L). These are the exact similarity constants (Schlichting & Gersten, Boundary-Layer Theory). The committed reference/blasius_cf.csv tabulates the same curve as a provenance record; here we evaluate the formula directly.

[3]:
def blasius_cf(Re_x):
    """Blasius local skin-friction coefficient."""
    return 0.664 / np.sqrt(Re_x)


def blasius_cf_bar(Re_L):
    """Blasius plate-averaged skin-friction coefficient."""
    return 1.328 / np.sqrt(Re_L)


print("Cf_bar(Re_L) =", blasius_cf_bar(Re_L))
Cf_bar(Re_L) = 0.018780756108314756

Read the friction export and compute Cf(x)

read_body_friction loads the per-triangle traction time series and the static centroid / normal / area topology. We time-average the traction over the settled steady window (time_mean_friction), then take the wall-tangential magnitude per triangle as the wall-shear stress and form Cf = tau_w / (0.5 rho U_inf^2) (skin_friction_coefficient). Each triangle’s centroid x maps to a local Re_x = U_inf (x - x_le) / nu.

Only the resolved interior of the plate is compared: the leading-edge singularity (Cf -> inf as Re_x -> 0) and the IBM smearing over the first ~2-3 fine cells make x very close to the leading edge unreliable, so we restrict the overlay to 0.05 L <= x - x_le <= 0.95 L.

[4]:
import nassu.viz as common

common.use_style()

bf = common.read_body_friction(sim_cfg, "plate", series="friction")

# Time-mean traction over the settled window (drop the first half as transient).
stats_start = 0.5 * sim_cfg.n_steps
t_mean = common.time_mean_friction(bf, stats_start=stats_start)

# Per-triangle wall-shear stress and skin-friction coefficient.
tau_w = common.wall_shear_stress(t_mean, bf.normal)
cf_tri = common.skin_friction_coefficient(t_mean, bf.normal, rho_ref=rho_inf, u_ref=u_inf)

# Map each triangle centroid to its local Reynolds number.
x_local = bf.centroid[:, 0] - x_le
Re_x_tri = u_inf * x_local / nu

# Restrict to the resolved, singularity-free interior of the plate.
mask = (x_local >= 0.05 * plate_length) & (x_local <= 0.95 * plate_length)
order = np.argsort(x_local[mask])
Re_x_s = Re_x_tri[mask][order]
cf_s = cf_tri[mask][order]
print(f"{mask.sum()} of {bf.n_tri} triangles in the comparison window")
1976 of 2187 triangles in the comparison window

Cf(x) vs Blasius

The per-triangle simulated Cf (there are many triangles at each x across the span; they collapse onto a single curve for a 2-D boundary layer) overlaid on the Blasius 0.664 / sqrt(Re_x) line.

[5]:
import matplotlib.pyplot as plt

fig, ax = common.fig_single()

# Blasius reference over the sampled Re_x range.
Re_ref = np.linspace(max(Re_x_s.min(), 1.0), Re_x_s.max(), 200)
ax.plot(
    Re_ref,
    blasius_cf(Re_ref),
    **common.markers.exp_line(linestyle="-"),
    label="Blasius $0.664/\\sqrt{Re_x}$",
)

# Simulated per-triangle Cf from the body_nodes friction export.
ax.plot(Re_x_s, cf_s, **common.markers.sim(shape="o"), label="Nassu (IBM friction)")

ax.set_xlabel("$Re_x$")
ax.set_ylabel("$C_f$")
ax.set_xscale("log")
ax.set_yscale("log")
ax.legend()
plt.tight_layout()
plt.show(fig)
../../../_images/validation_external_aero_03_flat_plate_boundary_layer_03_flat_plate_boundary_layer_9_0.png

Relative error along the plate

The pointwise relative error |Cf_sim - Cf_blasius| / Cf_blasius against the analytical curve. We expect the error to be largest near the leading edge (thin, under-resolved boundary layer plus IBM smearing) and to settle to a few percent over the bulk of the plate.

[6]:
cf_blasius_s = blasius_cf(Re_x_s)
rel_err = np.abs(cf_s - cf_blasius_s) / cf_blasius_s

fig, ax = common.fig_single()
ax.plot(Re_x_s, 100 * rel_err, **common.markers.sim(shape="."), label="relative error")
ax.set_xlabel("$Re_x$")
ax.set_ylabel(r"$|C_{f,\mathrm{sim}} - C_{f,\mathrm{Blasius}}| / C_{f,\mathrm{Blasius}}$ [%]")
ax.set_xscale("log")
ax.legend()
plt.tight_layout()
plt.show(fig)

print(f"median relative error over the comparison window: {100 * np.median(rel_err):.1f}%")
../../../_images/validation_external_aero_03_flat_plate_boundary_layer_03_flat_plate_boundary_layer_11_0.png
median relative error over the comparison window: 1.2%

Summary

Success criteria for this validation case:

  • The simulated local skin-friction coefficient Cf(x) collapses onto the Blasius 0.664 / sqrt(Re_x) curve across the resolved plate (0.05 L <= x <= 0.95 L).

  • The median pointwise relative error over the comparison window is within a few percent.

  • The error grows toward the leading edge, consistent with the thin, less-resolved boundary layer there and the diffuse-interface IBM smearing of the wall - a documented limit of the method, not a solver error.

The case is the canonical flat-wall counterpart to 01_flow_over_sphere: it validates the data.body_nodes friction export on a geometry where the per-triangle traction reduces to a pure, analytically known wall-shear stress.

Version

[7]:
sim_info = sim_cfg.output.read_info()
print("Version:", sim_info["version"])
print("Commit hash:", sim_info["commit"])
Version: 2.0.0a7
Commit hash: 5e47f2762575c2d285254d36bfd354b6af09fda1

Configuration

[8]:
from IPython.display import Code

Code(filename=filename)
[8]:
# =============================================================================
# Validation case 03 - Laminar flat-plate boundary layer (Blasius)
# =============================================================================
#
# Zero-pressure-gradient laminar boundary layer over a flat plate represented as
# an immersed-boundary (IBM) body. This is the canonical SKIN-FRICTION benchmark
# for the lean `data.body_nodes` friction export (issue #879): the curved-body
# counterpart (drag split on a sphere) lives in 01_flow_over_sphere. Here the
# wall is flat, so the per-triangle viscous traction is a pure wall-shear stress
# and the local skin-friction coefficient can be checked point-by-point against
# the closed-form Blasius similarity solution.
#
# Reference solution (Blasius, ZPG laminar flat plate; Schlichting & Gersten,
# "Boundary-Layer Theory", 9th ed., Springer 2017):
#
#   Re_x  = U_inf * x / nu          (local Reynolds number, x from leading edge)
#   Cf(x) = 0.664 / sqrt(Re_x)      (local skin-friction coefficient)
#   delta(x) = 5.0 * x / sqrt(Re_x) (99% boundary-layer thickness)
#   Cf_bar(L) = 1.328 / sqrt(Re_L)  (plate-length-averaged friction coefficient)
#
# where Cf(x) = tau_w(x) / (0.5 * rho_inf * U_inf^2) and tau_w is the wall shear
# stress = magnitude of the wall-tangential viscous traction.
#
# Lattice-unit design (all YAML values are lattice units):
#   U_inf   = 0.05      -> Ma = sqrt(3)*U = 0.087 < 0.1 (weakly compressible OK)
#   L       = 320       (plate length, lattice units; leading edge at x = 40)
#   Re_L    = 5000      (solidly laminar, well below transition Re ~ 5e5)
#   nu      = U*L/Re_L = 0.0032  -> tau = 0.5 + 3*nu = 0.5096 (RRBGK, stable)
#   delta(L) ~ 22.6 lu (~45 fine cells across the BL at the trailing edge)
#
# The plate region is refined to level 1 (dx = 0.5) so the thin near-leading-edge
# boundary layer (delta ~ 5 lu at Re_x = 250) is resolved by >= 10 fine cells, the
# wall-resolved target. The IBM diffuse-interface delta smears the wall over ~2-3
# fine cells, so the very first ~1-2% of the plate (the leading-edge singularity,
# Cf -> inf as Re_x -> 0) is intentionally excluded from the comparison; the
# notebook samples Cf(x) over 0.05 L <= x <= 0.95 L.
#
# Geometry / orientation note: the source plane (fixture/stl/basic/plane.stl) is a
# single-sided x-z sheet whose outward normal points in -y. The `body_nodes`
# friction kernel samples the fluid ALONG that outward normal, so the boundary
# layer must develop on the -y side of the plate. The plate is therefore placed
# near the top of the domain (y = 72) and the fluid of interest fills y < 72; the
# boundary layer grows downward from the plate. This is a standard ZPG flat-plate
# boundary layer, mirrored in y - the Blasius solution is unchanged.
#
# Single simulation (no grid sweep): the spatial check is Cf vs x along the
# plate at one resolution, not an N-refinement convergence study.
# =============================================================================

simulations:
  - name: laminarFlatPlateBlasius
    save_path: ./validation/external_aero/03_flat_plate_boundary_layer/results/blasius

    # ~10 domain flow-throughs (384 / 0.05 = 7680 steps each) to flush the
    # transient and settle the steady laminar boundary layer.
    n_steps: 80000

    report: {frequency: 2000}

    domain:
      domain_size:
        x: 384   # 40 lu inlet clearance + 320 lu plate + 24 lu trailing wake
        y: 96    # wall-normal: BL grows to ~23 lu; plate at y=72, fluid below
        z: 32    # spanwise (periodic)
      block_size: 8

      bodies:
        plate:
          geometry_path: fixture/stl/basic/plane.stl
          small_triangles: add
          transformation:
            # plane.stl spans x[0,10], z[0,1], y=0 with normal -y.
            # scale to L=320 in x and span the full z extent; place the sheet at
            # y=72 with the leading edge at x=40 (trailing edge at x=360).
            scale: [32.0, 1.0, 32.0]
            translation: [40.0, 72.0, 0.0]

      refinement:
        static:
          default:
            volumes_refine:
              # Level-1 box hugging the plate underside, covering the whole plate
              # plus ~24 lu below it (the boundary-layer envelope) and a little
              # margin upstream of the leading edge and downstream of the wake.
              - start: [32, 40, 0]
                end: [368, 80, 32]
                lvl: 1
                is_abs: true

    data:
      # Per-source-triangle skin-friction primitives (issue #879): the aggregated
      # viscous-traction vector + centroid/normal/area, written as a flat CSV/HDF
      # time series. `nassu.viz.friction` derives tau_w and Cf(x) from these.
      # Sampled densely late in the run so the notebook can time-average over the
      # settled steady window.
      body_nodes:
        friction:
          body_name: plate
          mode: triangle
          frequency: 2000

      # A coarse IBM-node export for sanity-checking the body placement / loading.
      export_IBM_nodes:
        ibm_exp:
          body_name: plate
          frequency: 40000

      exports:
        # Sparse full-volume snapshots: inspect the developing BL profile u(x,y).
        default:
          macrs: [rho, u]
          interval:
            frequency: 20000
            lvl: 0
          target:
            volumes:
              default: {}
          outputs:
            instantaneous: true
        # Spanwise-mid plane series (z-normal): a clean 2-D view of the boundary
        # layer for the notebook's u(y) profile / delta(x) cross-check.
        plane_series:
          macrs: [rho, u]
          interval: {frequency: 10000, lvl: 0}
          target:
            planes:
              mid_span:
                axis: z
                axis_pos: 16
                dist: 1
          outputs:
            instantaneous: true

    models:
      precision:
        default: single

      LBM:
        tau: 0.5096   # nu = U*L/Re_L = 0.0032, tau = 0.5 + 3*nu
        vel_set: D3Q27
        coll_oper: RRBGK

      # Laminar boundary layer: LES is intentionally OFF (the flow is resolved,
      # not modeled). No wall model on the IBM body either - the no-slip wall is
      # enforced by the diffuse-interface IBM force spreading and the friction is
      # the genuine viscous wall-shear stress.

      initialization:
        rho: 1.0
        u:
          x: 0.05
          y: 0
          z: 0

      engine:
        name: CUDA

      BC:
        # x: inlet (W) / outlet (E); y: far-field below (S) / buffer top (N);
        # z: spanwise periodic.
        periodic_dims: [false, false, true]
        BC_map:
          - pos: W
            BC: UniformFlow
            wall_normal: W
            order: 2

            params:
              rho: 1.0
              ux: 0.05
              uy: 0
              uz: 0
          - pos: E
            BC: RegularizedNeumannOutlet
            wall_normal: E
            order: 2

            params:
              rho: 1.0
          - pos: N
            BC: Neumann
            wall_normal: N
            order: 0

          - pos: S
            BC: Neumann
            wall_normal: S
            order: 0

      IBM:
        forces_accomodate_time: 2000
        body_cfgs:
          default: {}

      multiblock:
        overlap_F2C: 2