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
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)
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}%")
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 Blasius0.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