Flow Over Stationary Sphere

The simulation of a laminar flow over a stationary sphere is used for the validation of force spreading aspects of the immersed boundary method (IBM). For that purpose, the drag coefficient is measured through the forces calculated at the Lagrangian mesh.

Total drag from the direct IBM force. The sphere drag reported below is the full drag (form pressure + skin friction), obtained by summing the IBM spreading force the solver applied to enforce the no-slip condition over the body’s Lagrangian nodes (nassu.viz.read_body_ibm_force). Each node’s force is rescaled from its level-local lattice units to global units - a (2^-lvl)^2 factor, the dx^2 area ratio - before summing (issue #1016). This direct force needs no surface-pressure or wall-stress reconstruction and matches the reference correlations to within a few percent. The decomposed pressure + viscous-traction route (nassu.viz.pressure_force_coefficients + nassu.viz.force_coefficients) is kept only as a diagnostic split; on this sphere it under-predicts the total by ~30%.

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

filename = "validation/external_aero/01_flow_over_sphere/01_flow_over_sphere.nassu.yaml"

sim_cfgs = ConfigScheme.sim_cfgs_from_file_dct(filename)

A multilevel configuration is adopted to allow a large ratio between the sphere’s and domain’s sizes.

[2]:
sim_laminar = [sim_cfg for (name, _), sim_cfg in sim_cfgs.items() if name.startswith("laminar")]
sim_turb = next(
    sim_cfg for (name, _), sim_cfg in sim_cfgs.items() if name == "turbulentFlowOverSphere"
)
sim_turb_les = next(sim_cfg for (name, _), sim_cfg in sim_cfgs.items() if name.endswith("LES"))
sim_cfgs_use = sim_laminar + [sim_turb] + [sim_turb_les]

Functions to use for flow over sphere processing

[3]:
import pathlib

import numpy as np
import pandas as pd

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


def get_experimental_profile_Cp(reynolds: float) -> pd.DataFrame:
    files_tau: dict[float, str] = {
        4200: "Cp_vs_theta_Re_4200.csv",
        50000: "Cp_vs_theta_Re_50000.csv",
        300000: "Cp_vs_theta_Re_300000.csv",
        400000: "Cp_vs_theta_Re_400000.csv",
        1140000: "Cp_vs_theta_Re_1140000.csv",
    }
    filename = (
        pathlib.Path("validation/external_aero/01_flow_over_sphere/reference")
        / files_tau[reynolds]
    )
    # ([theta], [Cp])
    return pd.read_csv(filename, delimiter=",")


def get_experimental_profile_Cd() -> pd.DataFrame:
    filename = (
        pathlib.Path("validation/external_aero/01_flow_over_sphere/reference")
        / "drag_coefficient.csv"
    )
    # ([Author], [Re], [Cd])
    return pd.read_csv(filename, delimiter=",")


def mean_pressure_form_drag(
    sim_cfg: SimulationConfigs,
    *,
    u_inf: float,
    normal: np.ndarray,
    area: np.ndarray,
    a_ref: float,
    rho_inf: float = 1.0,
    body_name: str = "sphere",
    series: str = "default_series",
    stats_start: float = 0.0,
):
    """Time-mean form (pressure) drag/lift coefficients from a body pressure series.

    Reads the surface-density history sampled over the body nodes, forms the
    per-node mean ``Cp = (rho - rho_inf) / (1.5 rho_inf u_inf**2)`` and integrates
    the pressure force ``C_F = -sum_i Cp_i n_i A_i / a_ref`` over the sphere with
    ``nassu.viz.pressure_force_coefficients``. The per-node outward ``normal`` and
    ``area`` come from the ``body_nodes`` friction export, which shares the exact
    same deterministic per-triangle ordering as the surface-pressure series (both
    keyed by the body-node index), so the form and viscous drag use identical
    geometry weights and add to the full drag. Returns ``(Cd_pressure, Cl_pressure)``.
    """
    hs = sim_cfg.output.exports[series].series.bodies[body_name]
    df = hs.read_full_data("rho")
    df = df[df["time_step"] >= stats_start].drop(columns="time_step")
    rho_mean = df.mean().to_numpy()  # (n_nodes,), body-node index order

    assert len(rho_mean) == len(area) == len(normal), (len(rho_mean), len(area), len(normal))
    cp = (rho_mean - rho_inf) / (1.5 * rho_inf * u_inf**2)
    coeffs = viz.pressure_force_coefficients(cp, normal, area, a_ref=a_ref)
    return float(coeffs["Cd_pressure"]), float(coeffs["Cl_pressure"])

Results

The drag coefficient reported here is the total drag from the direct IBM force: the spreading force summed over the sphere’s Lagrangian nodes (form pressure + skin friction together), matching the Schiller / Concha / Morrison correlations to within a few percent across the Reynolds sweep. The decomposed pressure + viscous-traction reconstruction is overlaid as a diagnostic; it systematically under-predicts the total by ~30% (issue #1016). The stacked bar shows the reconstruction split (form + friction + wall-normal viscous) with the direct-force total marked, so the shortfall is visible per Reynolds number.

[4]:
import matplotlib.pyplot as plt

import nassu.viz as common

common.use_style()

fig, ax = common.fig_single()

df_exp = get_experimental_profile_Cd()
authors = ["Schiller", "Concha"]
exp_shapes = ["o", "s"]
for author, shape in zip(authors, exp_shapes):
    ax.plot(df_exp["Re"], df_exp[author], **common.markers.exp(shape=shape), label=author)

# Continuous standard-drag reference (Morrison 2013 correlation), restricted to
# the laminar sweep's Reynolds range for a like-for-like overlay.
df_morrison = pd.read_csv(
    "validation/external_aero/01_flow_over_sphere/reference/sphere_drag_Cd_vs_Re.csv",
    comment="#",
)
m = (df_morrison["Re"] >= 20) & (df_morrison["Re"] <= 600)
ax.plot(
    df_morrison["Re"][m],
    df_morrison["Cd"][m],
    **common.markers.exp_line(linestyle="-"),
    label="Morrison (2013)",
)

# TOTAL drag from the DIRECT IBM force (issue #1016): the spreading force the
# solver applied to the fluid to enforce no-slip, summed over the sphere's
# Lagrangian nodes (form pressure + skin friction together, no reconstruction).
# `nassu.viz.read_body_ibm_force` rescales each node's force from its level-local
# lattice units to global units - (2^-lvl)^2, the dx^2 area ratio - and sums it;
# `body_force_coefficients` normalizes by q*A_ref. On this sphere the direct force
# matches Schiller / Concha / Morrison to within a few percent, whereas the
# decomposed pressure + viscous-traction reconstruction (overlaid as a diagnostic
# curve, and split in the bar chart below) under-predicts the total by ~30%.
RHO_INF = 1.0
num_vals = {
    "Re": [],
    "Cd_direct": [],
    "Cd_pressure": [],
    "Cd_viscous": [],
    "Cd_friction": [],
    "Cd_normal_viscous": [],
}
for sim_cfg in sim_laminar:
    bf = common.read_body_friction(sim_cfg, "sphere", series="friction")

    # Shared reference geometry (frontal area) from the source-triangle centroids.
    diameter = float(np.ptp(bf.centroid[:, 0]))
    a_ref = np.pi * diameter**2 / 4

    # Freestream speed from the inlet BC (lattice units).
    u_inf = max(bc.ux for bc in sim_cfg.models.BC.BC_map if "ux" in bc.model_dump())
    reynolds = u_inf * diameter / sim_cfg.models.LBM.kinematic_viscosity

    # Headline total drag: the direct IBM force over the steady window.
    bif = common.read_body_ibm_force(sim_cfg, "sphere", start_step=sim_cfg.n_steps // 2)
    f_body = common.time_mean_ibm_force(bif)
    cd_direct = float(
        common.body_force_coefficients(f_body, rho_ref=RHO_INF, u_ref=u_inf, a_ref=a_ref)["Cd"]
    )

    # Diagnostic reconstruction: viscous (skin-friction + wall-normal) drag from
    # the `body_nodes` traction split, plus the form (pressure) drag from the mean
    # surface Cp. Both use the friction export's per-triangle normals/areas.
    t_mean = common.time_mean_friction(bf)
    vcoef = common.force_coefficients(
        t_mean,
        bf.normal,
        bf.area,
        rho_ref=RHO_INF,
        u_ref=u_inf,
        a_ref=a_ref,
        flow_dir=np.array([1.0, 0.0, 0.0]),
    )
    cd_viscous = float(vcoef["Cd_total"])
    cd_pressure, _ = mean_pressure_form_drag(
        sim_cfg,
        u_inf=u_inf,
        normal=bf.normal,
        area=bf.area,
        a_ref=a_ref,
        rho_inf=RHO_INF,
        series="default_series",
        stats_start=sim_cfg.n_steps // 2,
    )

    num_vals["Re"].append(reynolds)
    num_vals["Cd_direct"].append(cd_direct)
    num_vals["Cd_pressure"].append(cd_pressure)
    num_vals["Cd_viscous"].append(cd_viscous)
    num_vals["Cd_friction"].append(float(vcoef["Cd_friction"]))
    num_vals["Cd_normal_viscous"].append(float(vcoef["Cd_normal_viscous"]))

order = np.argsort(num_vals["Re"])
Re_sorted = np.array(num_vals["Re"])[order]
ax.plot(
    Re_sorted,
    np.array(num_vals["Cd_direct"])[order],
    **common.markers.sim(shape="^"),
    label="AeroSim (direct force)",
)
# Diagnostic overlay: the decomposed pressure + viscous-traction reconstruction,
# which systematically under-predicts the direct-force total (issue #1016).
recon = np.array(num_vals["Cd_pressure"])[order] + np.array(num_vals["Cd_viscous"])[order]
ax.plot(
    Re_sorted,
    recon,
    **common.markers.sim_line(linestyle="--"),
    label="pressure+traction (diag.)",
)

ax.legend()
ax.set_xscale("log")
# Log axis: plain-number ticks (the AeroSim style only covers linear axes).
common.plain_number_axis(ax, "x", ticks=[20, 50, 100, 200, 500])
ax.set_xlabel("Re")
ax.set_ylabel("$C_D$")

plt.tight_layout()
plt.show(fig)

# Drag decomposition as a STACKED BAR per Reynolds number: the reconstruction
# split into form (pressure) + skin friction + wall-normal viscous traction, with
# the DIRECT-force total (the headline Cd) drawn as a marker. The gap between the
# stack top and the marker is the reconstruction shortfall (issue #1016).
fig_dec, ax_dec = common.fig_single()
re_labels = [f"{r:.0f}" for r in Re_sorted]
pres = np.array(num_vals["Cd_pressure"])[order]
fric = np.array(num_vals["Cd_friction"])[order]
nvis = np.array(num_vals["Cd_normal_viscous"])[order]
direct = np.array(num_vals["Cd_direct"])[order]
xb = np.arange(len(re_labels))
ax_dec.bar(xb, pres, label="form (pressure)", color=common.colors.sim)
ax_dec.bar(xb, fric, bottom=pres, label="skin friction", color=common.colors.blue)
ax_dec.bar(xb, nvis, bottom=pres + fric, label="wall-normal viscous", color=common.colors.green)
ax_dec.plot(
    xb, direct, "o", color=common.colors.exp, markersize=8, label="direct force (total)", zorder=5
)
for k, tot in enumerate(direct):
    ax_dec.text(xb[k], tot, f"{tot:.2f}", ha="center", va="bottom", fontsize=10)
ax_dec.set_xticks(xb)
ax_dec.set_xticklabels(re_labels)
common.bar_axis(ax_dec)
ax_dec.set_xlabel("Re")
ax_dec.set_ylabel("$C_D$ contribution")
ax_dec.set_title("Drag: direct force (total) vs decomposed reconstruction")
ax_dec.legend()

plt.tight_layout()
plt.show(fig_dec)
/tmp/ipykernel_3072641/476860046.py:59: UserWarning: IBM node export for body 'sphere' has no `lvl` field (pre-#1016 export); rescaling all force-bearing nodes at level 1. Exact only if the body sits entirely at that level; re-run to regenerate `lvl`.
  bif = common.read_body_ibm_force(sim_cfg, "sphere", start_step=sim_cfg.n_steps // 2)
/tmp/ipykernel_3072641/476860046.py:59: UserWarning: IBM node export for body 'sphere' has no `lvl` field (pre-#1016 export); rescaling all force-bearing nodes at level 1. Exact only if the body sits entirely at that level; re-run to regenerate `lvl`.
  bif = common.read_body_ibm_force(sim_cfg, "sphere", start_step=sim_cfg.n_steps // 2)
/tmp/ipykernel_3072641/476860046.py:59: UserWarning: IBM node export for body 'sphere' has no `lvl` field (pre-#1016 export); rescaling all force-bearing nodes at level 1. Exact only if the body sits entirely at that level; re-run to regenerate `lvl`.
  bif = common.read_body_ibm_force(sim_cfg, "sphere", start_step=sim_cfg.n_steps // 2)
../../../_images/validation_external_aero_01_flow_over_sphere_01.1_flow_over_sphere_8_1.png
../../../_images/validation_external_aero_01_flow_over_sphere_01.1_flow_over_sphere_8_2.png

The pressure coefficient for the case of turbulent flow around a stationary sphere is performed for a Re=4,200 simulation to check capacity of measuring the pressure coefficient using the historic series function for a body. A posterior LES simulation is then performed for a Re=50,000 to also verify the solver stability for high Reynolds simulations. In both cases, excellent agreement with experimental data was obtained.

[5]:
fig, ax = common.fig_double()


def post_proc_crossflow_sphere_Cp(
    sim_cfg: SimulationConfigs, ax, reynolds: float, u_inf: float = 0.05
):
    hs = sim_cfg.output.exports["default_series"].series.bodies["sphere"]

    df_points = pd.read_csv(hs.points_filename)
    df_hs = hs.read_full_data("rho")

    sphere_center = tuple(df_points[d].mean() for d in ("x", "y", "z"))

    # Use only points in z=0 to calculate Cp
    bool_arr = np.zeros((len(df_points),), dtype=np.bool_)
    df_points = df_points.loc[
        (df_points["z"] <= sphere_center[2] + 0.5) & (df_points["z"] >= sphere_center[2] - 0.5)
    ]
    bool_arr[df_points["idx"]] = True

    # TODO: This comes from sim_cfg, now hand written,
    # but update to calculate it automatically
    rho_inf = 1
    df_hs = df_hs[df_hs["time_step"] >= 10000]
    df_rho = df_hs.drop(columns="time_step")
    df_cp = (df_rho - rho_inf) / (1.5 * rho_inf * (u_inf**2))
    Cp_avg = df_cp.mean().to_numpy().T
    Cp_avg = Cp_avg[bool_arr]

    # Get angles for points, use acos because it goes from 0 to 180
    x = df_points["x"] - df_points.mean()["x"]
    y = df_points["y"] - df_points.mean()["y"]
    points_angles: np.ndarray = np.arccos(-x / (x**2 + y**2) ** 0.5)
    # Convert to angles
    points_angles *= 180 / np.pi
    points_angles = np.array(points_angles)

    # # Sort arrays
    argsort = points_angles.argsort()
    points_angles = points_angles[argsort]
    cp_points = Cp_avg[argsort]

    ax.plot(points_angles, cp_points, **common.markers.sim_line(linestyle="--"), label="AeroSim")

    df_exp = get_experimental_profile_Cp(reynolds)
    ax.plot(df_exp["theta"], df_exp["Cp"], **common.markers.exp(shape="o"), label="Experimental")

    ax.legend()


for i, (sim_cfg, reynolds) in enumerate([(sim_turb, 4200), (sim_turb_les, 50000)]):
    post_proc_crossflow_sphere_Cp(sim_cfg, ax[i], reynolds, u_inf=0.05)
    ax[i].set_xlim((0, 180))
    ax[i].set_ylim((-0.6, 1.2))
    ax[i].set_ylabel("$C_p$")
    ax[i].set_xlabel(r"$\theta$ (deg)")
    ax[i].set_title(f"$Re={reynolds}$")

plt.tight_layout()
plt.show(fig)
../../../_images/validation_external_aero_01_flow_over_sphere_01.1_flow_over_sphere_10_0.png

The pressure coefficient curve is very similar for both Re = 4200 and Re= 50,000. It can be seen better proximity of results in the LES simulation with a higher Reynolds number.

Force coefficients, peaks and shedding

The turbulent sphere’s mean drag is the total drag from the direct IBM force (nassu.viz.read_body_ibm_force), summed over the body and rescaled per refinement level (issue #1016). The fluctuating loads and the vortex-shedding signal still come from the surface-pressure force history \(C_d(t)\), \(C_l(t)\) - the instantaneous IBM force is too noisy to resolve them - so the design peak (Cook-Mayne / Gumbel via nassu.viz.design_peak) is the pressure signal’s fluctuation lifted onto the direct mean drag, and the Strouhal number comes from the lift spectrum.

[6]:
# --- Pressure-integrated force coefficients -------------------------------
# Drag and lift of the turbulent sphere are obtained by integrating the surface
# pressure over the body; the IBM spreading force is too noisy to resolve the
# fluctuating loads. For each body node i, Cp_i = (rho_i - rho_inf)/(1.5 rho_inf U^2),
# and the force-coefficient vector is C_F = -sum_i Cp_i n_i A_i / A_ref with
# A_ref = pi D^2 / 4 the frontal area. The surface pressure history comes from
# the body historic series (`default_series`); the outward unit normals and the
# per-node areas come from the `body_nodes` friction export (`bf`), which shares
# the same deterministic per-triangle ordering as the pressure series (both keyed
# by the body-node index), so the form and viscous drag use identical geometry
# weights.

U_INF = 0.05
RHO_INF = 1.0
STATS_START = 10000  # drop the initial transient (matches the Cp analysis above)


def force_coeff_series(sim_cfg, bf, body_name="sphere", series="default_series"):
    """Time series of the pressure-integrated force coefficient.

    Returns ``(time, C_F[:, 3], D)`` with ``C_F`` the (drag, y, z) coefficient
    components per exported time step and ``D`` the sphere diameter. The per-node
    outward ``normal`` and ``area`` come from the friction export ``bf``.
    """
    hs = sim_cfg.output.exports[series].series.bodies[body_name]
    df = hs.read_full_data("rho")
    df = df[df["time_step"] >= STATS_START]
    time = df["time_step"].to_numpy()
    rho = df.drop(columns="time_step").to_numpy()  # (n_t, n_nodes), body-node index order

    normal = bf.normal
    area = bf.area
    diameter = float(np.ptp(bf.centroid[:, 0]))
    assert rho.shape[1] == len(area) == len(normal), (rho.shape, len(area), len(normal))

    cp = (rho - RHO_INF) / (1.5 * RHO_INF * U_INF**2)  # (n_t, n_nodes)
    a_ref = np.pi * diameter**2 / 4
    cF = -np.einsum("tn,n,nd->td", cp, area, normal) / a_ref  # (n_t, 3)
    return time, cF, diameter
[7]:
from nassu.viz import design_peak

runs = [(sim_turb, 4200, "Re = 4200"), (sim_turb_les, 50000, "Re = 50000 (LES)")]

fig, ax = common.fig_double()
force_stats = {}
for i, (scfg, Re, title) in enumerate(runs):
    # Friction export: shared per-triangle normals/areas for the pressure-force
    # history and the diagnostic viscous drag split.
    bf = viz.read_body_friction(scfg, "sphere", series="friction")

    t, cF, D = force_coeff_series(scfg, bf)
    Cd_form = cF[:, 0]  # form (pressure) drag history
    Cl = np.hypot(cF[:, 1], cF[:, 2])  # transverse (lift) magnitude

    ax[i].plot(t - t[0], Cd_form, color=common.colors.sim, lw=1.0, label="$C_D$ (form)")
    ax[i].plot(t - t[0], Cl, color=common.colors.exp, lw=1.0, alpha=0.8, label="$C_L$")
    ax[i].set_title(title)
    ax[i].set_xlabel("time step")
    ax[i].set_ylabel("force coefficient")
    ax[i].legend()

    # Headline mean drag: the DIRECT IBM force summed over the body and rescaled
    # per refinement level (issue #1016), time-averaged over the steady window.
    a_ref = np.pi * D**2 / 4
    bif = viz.read_body_ibm_force(scfg, "sphere", start_step=STATS_START)
    f_body = viz.time_mean_ibm_force(bif)
    cd_direct = float(
        viz.body_force_coefficients(f_body, rho_ref=RHO_INF, u_ref=U_INF, a_ref=a_ref)["Cd"]
    )

    # Diagnostic reconstruction: viscous (skin-friction + wall-normal) drag from
    # the `body_nodes` traction, and the mean form drag from the pressure signal.
    vcoef = viz.force_coefficients(
        viz.time_mean_friction(bf, stats_start=STATS_START),
        bf.normal,
        bf.area,
        rho_ref=RHO_INF,
        u_ref=U_INF,
        a_ref=a_ref,
        flow_dir=np.array([1.0, 0.0, 0.0]),
    )
    cd_viscous = float(vcoef["Cd_total"])
    cd_form_mean = float(Cd_form.mean())

    # Cook-Mayne / Gumbel design peak of the fluctuating (form) loads. The
    # instantaneous IBM force is too noisy for the fluctuation, so the design peak
    # is the pressure signal's fluctuation about its own mean, lifted onto the
    # direct mean drag: Cd_peak = Cd_direct + (form_peak - form_mean).
    cd_peak = design_peak(Cd_form, n_epochs=10, sign=1)
    cl_peak = design_peak(Cl, n_epochs=10, sign=1)
    force_stats[Re] = {
        "Cd_mean": cd_direct,  # total drag from the direct IBM force
        "Cd_form_mean": cd_form_mean,  # diagnostic: pressure-only mean
        "Cd_viscous_mean": cd_viscous,  # diagnostic: traction viscous drag
        "Cd_rms": float(Cd_form.std()),
        "Cl_rms": float(Cl.std()),
        "Cd_peak": cd_direct + (cd_peak["peak"] - cd_form_mean),  # direct mean + form fluctuation
        "Cl_peak": cl_peak["peak"],
        "Cl_peak_factor": cl_peak["peak_factor"],
        "cF": cF,
        "time": t,
        "D": D,
    }

plt.tight_layout()
plt.show(fig)

# Summary force coefficients as GROUPED BARS per Reynolds number (mean/peak drag,
# its direct/form/viscous diagnostics, and the lift fluctuation/peak) - one bar
# group per Re. Each metric is shown with its conventional subscript notation.
fig_sum, ax_sum = common.fig_single()
metric_labels = {
    "Cd_mean": r"$\overline{C_D}$",
    "Cd_form_mean": r"$\overline{C_{D,\mathrm{form}}}$",
    "Cd_viscous_mean": r"$\overline{C_{D,\mathrm{visc}}}$",
    "Cd_peak": r"$C_{D,\mathrm{peak}}$",
    "Cd_rms": r"$C_{D,\mathrm{rms}}$",
    "Cl_rms": r"$C_{L,\mathrm{rms}}$",
    "Cl_peak": r"$C_{L,\mathrm{peak}}$",
}
metrics = list(metric_labels)
res = list(force_stats)
xg = np.arange(len(metrics))
width = 0.8 / len(res)
for j, Re in enumerate(res):
    vals = [force_stats[Re][mk] for mk in metrics]
    bars = ax_sum.bar(xg + j * width, vals, width, label=f"Re = {Re}")
    ax_sum.bar_label(bars, fmt="%.3f", fontsize=7, padding=2)
ax_sum.set_xticks(xg + width * (len(res) - 1) / 2)
ax_sum.set_xticklabels([metric_labels[mk] for mk in metrics])
common.bar_axis(ax_sum)
ax_sum.set_ylabel("coefficient")
ax_sum.set_title("Turbulent sphere force-coefficient summary")
ax_sum.legend()

plt.tight_layout()
plt.show(fig_sum)
/tmp/ipykernel_3072641/3450118258.py:26: UserWarning: IBM node export for body 'sphere' has no `lvl` field (pre-#1016 export); rescaling all force-bearing nodes at level 3. Exact only if the body sits entirely at that level; re-run to regenerate `lvl`.
  bif = viz.read_body_ibm_force(scfg, "sphere", start_step=STATS_START)
/tmp/ipykernel_3072641/3450118258.py:26: UserWarning: IBM node export for body 'sphere' has no `lvl` field (pre-#1016 export); rescaling all force-bearing nodes at level 3. Exact only if the body sits entirely at that level; re-run to regenerate `lvl`.
  bif = viz.read_body_ibm_force(scfg, "sphere", start_step=STATS_START)
../../../_images/validation_external_aero_01_flow_over_sphere_01.1_flow_over_sphere_14_1.png
../../../_images/validation_external_aero_01_flow_over_sphere_01.1_flow_over_sphere_14_2.png

Integral quantities (Re = 3700 benchmark)

The subcritical turbulent sphere has well-established integral quantities at Re = 3700 (DNS, LES and experiment). The run is at Re = 4200; these quantities are nearly Re-flat across that range, so the comparison carries a ~13% Reynolds offset (see reference/REFERENCES.md). The drag is the total (direct IBM force) drag from the run and the base pressure is taken from the run’s surface pressure; the recirculation length and separation angle need the wake field and are shown as references only.

[8]:
# Re=3700 integral-quantity benchmark (subcritical turbulent sphere) vs the
# Re=4200 run. The integral quantities are nearly Re-flat across this range, so
# the comparison carries a ~13% Re offset (see reference/REFERENCES.md).
df_int = pd.read_csv(
    "validation/external_aero/01_flow_over_sphere/reference/sphere_integral_Re3700.csv",
    comment="#",
)

# Run quantities at Re=4200: the total drag (direct IBM force, from force_stats)
# and the base pressure from the time-mean surface pressure.
hs = sim_turb.output.exports["default_series"].series.bodies["sphere"]
df_rho = hs.read_full_data("rho")
df_rho = df_rho[df_rho["time_step"] >= STATS_START].drop(columns="time_step")
cp_mean_nodes = (df_rho.mean().to_numpy() - RHO_INF) / (1.5 * RHO_INF * U_INF**2)
pts = pd.read_csv(hs.points_filename)
base_cp_run = float(cp_mean_nodes[pts["x"].to_numpy().argmax()])  # rearmost (base) node
run_vals = {"Cd": force_stats[4200]["Cd_mean"], "base_Cp": base_cp_run}

# Conventional-notation axis labels for each integral quantity.
q_labels = {"Cd": "$C_D$", "base_Cp": "$C_{p,\\mathrm{base}}$"}


def _short_ref(full: str) -> str:
    """Compact 'First-author et al. (year)' label from a full attribution string."""
    full = str(full).strip()
    year = ""
    if "(" in full and ")" in full:
        year = " " + full[full.index("(") : full.index(")") + 1]
        full = full[: full.index("(")].strip()
    surnames = [p.strip().split()[-1] for p in full.replace(" & ", ",").split(",") if p.strip()]
    if len(surnames) == 1:
        base = surnames[0]
    elif len(surnames) == 2:
        base = f"{surnames[0]} & {surnames[1]}"
    else:
        base = f"{surnames[0]} et al."
    return base + year


# The run only produces Cd and base_Cp from the surface data; recirculation length
# and separation angle need the wake field and are omitted here (references only).
# Each reference bar is labelled with its SOURCE (whose DNS/LES/experiment it is),
# read from the reference CSV's `reference` column - not a bare "DNS"/"LES".
fig_int, ax_int = common.fig_double()
for axq, q in zip(ax_int, ["Cd", "base_Cp"]):
    sub = df_int[df_int["quantity"] == q]
    labels = [f"{r.ref_type} - {_short_ref(r.reference)}" for r in sub.itertuples()]
    values = list(sub["value"])
    bar_colors = [common.colors.exp] * len(values)
    labels.append("AeroSim (Re = 4200)")
    values.append(run_vals[q])
    bar_colors.append(common.colors.sim)
    xq = np.arange(len(values))
    bars = axq.bar(xq, values, color=bar_colors)
    axq.bar_label(bars, fmt="%.3f", fontsize=8, padding=2)
    axq.axhline(0.0, color=common.colors.refline, lw=0.8)
    axq.set_xticks(xq)
    axq.set_xticklabels(labels, rotation=30, ha="right", fontsize=8)
    common.bar_axis(axq)
    axq.set_ylabel(q_labels[q])
    axq.set_title(f"{q_labels[q]}  (references at Re = 3700)")

plt.tight_layout()
plt.show(fig_int)
../../../_images/validation_external_aero_01_flow_over_sphere_01.1_flow_over_sphere_16_0.png

Vortex shedding and spectra

The lift-force power spectrum exposes the vortex-shedding peak. The low-mode Strouhal number plateaus near \(St \approx 0.19\) at high Reynolds number (Sakamoto & Haniu, 1990). The wake velocity probe gives an independent, velocity-based estimate of the same frequency.

[ ]:
from nassu.viz import energy_spectrum, read_series

# Low-mode shedding Strouhal reference (digitized Sakamoto & Haniu 1990 Fig. 7).
df_st = pd.read_csv(
    "validation/external_aero/01_flow_over_sphere/reference/sphere_strouhal.csv",
    comment="#",
)

fig, ax = common.fig_double()
for i, (scfg, Re, title) in enumerate(runs):
    s = force_stats[Re]
    cF, t, D = s["cF"], s["time"], s["D"]

    # The transverse (lift) force fluctuation carries the vortex-shedding signal.
    dt = float(np.median(np.diff(t)))
    f, S = energy_spectrum(cF[:, 2], dt)
    St = f * D / U_INF
    ax[i].loglog(St, S, color=common.colors.sim, alpha=0.8, label=r"$C_L$ (pressure force)")

    # Independent, velocity-based check: the wake velocity probe, a max-rate
    # instantaneous series export sampled every finest-level iteration (#1023);
    # time_step is in level-0 steps.
    try:
        probe = scfg.output.exports["spectrum"].series.points["point_velocity_wake"]
        dfw = read_series(probe, "ux", start_step=STATS_START)
        dtw = float(np.median(np.diff(dfw["time_step"])))
        ux_wake = dfw.drop(columns="time_step").iloc[:, 0].to_numpy()
        fw, Sw = energy_spectrum(ux_wake, dtw)
        ax[i].loglog(fw * D / U_INF, Sw, color=common.colors.exp, alpha=0.5, label=r"wake $u_x$")
    except (KeyError, AttributeError, FileNotFoundError, ValueError):
        pass

    # Reference low-mode Strouhal at this Re (interpolated off the digitized curve)
    # and the dominant peak of the lift spectrum.
    st_ref = float(np.interp(Re, df_st["Re"], df_st["St"]))
    st_peak = float(St[np.argmax(S)])
    ax[i].axvline(
        st_ref, color=common.colors.refline, ls="--", alpha=0.8, label=f"St ref = {st_ref:.2f}"
    )
    ax[i].axvline(
        st_peak, color=common.colors.sim, ls=":", alpha=0.8, label=f"St peak = {st_peak:.2f}"
    )

    ax[i].set_title(title)
    ax[i].set_xlabel(r"$St = f\,D/U$")
    ax[i].set_ylabel("PSD")
    ax[i].legend(fontsize="small")

plt.tight_layout()
plt.show(fig)

Flow field

Instantaneous velocity magnitude on the lateral plane through the sphere centre (plane_series.mid_sphere): wake view for the turbulent runs, framed from the sphere geometry.

[10]:
viz.enable_offscreen()

labels = {
    "turbulentFlowOverSphere": "turbulent",
    "turbulentFlowOverSphereLES": "turbulent LES",
}
cfg0 = sim_cfgs["turbulentFlowOverSphere", 0]
body, geom = viz.read_body(cfg0, "sphere")
view = viz.frame_body(geom, "y", slice_coord=64.0, downstream=2.0, half_extent=3.0)

panels = [
    viz.Panel(
        label,
        viz.PlaneSource.from_cfg(sim_cfgs[name, 0], series="plane_series", plane="mid_sphere"),
        view,
    )
    for name, label in labels.items()
]
steps = [panels[0].source.steps[-1]]
plotter = viz.render_grid(
    panels,
    steps=steps,
    scalar="u_mag",
    cmap="viridis",
    clim=(0.0, 0.08),
    bar_title="|u|",
    bodies=[body],
)
plotter.show()
/tmp/ipykernel_3072641/1996413703.py:29: UserWarning: Using static image for notebook display.
Install trame for interactive backends: pip install "pyvista[jupyter]"
  plotter.show()
../../../_images/validation_external_aero_01_flow_over_sphere_01.1_flow_over_sphere_20_1.png

Version

[11]:
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.0a1
Commit hash: a9e18ca8bb03b24ce4d631e70482ec8cf66e7cef

Configuration

[12]:
from IPython.display import Code

Code(filename=filename)
[12]:
simulations:
  - name: laminarFlowOverSphere
    save_path: ./validation/external_aero/01_flow_over_sphere/results/laminar

    n_steps: 40000

    report: {frequency: 1000}

    domain:
      domain_size:
        x: 480
        y: 160
        z: 160
      block_size: 8
      bodies:
        sphere:
          geometry_path: fixture/stl/basic/sphere.stl
          transformation:
            scale: [1.6, 1.6, 1.6]
            translation: [112, 72, 72]
      refinement:
        static:
          default:
            volumes_refine:
              - start: [96, 64, 64]
                end: [160, 96, 96]
                lvl: 1
                is_abs: true

    data:
      export_IBM_nodes:
        ibm_exp:
          body_name: sphere
          frequency: 5000
      body_nodes:
        # Per-source-triangle skin-friction primitives (issue #879): the friction
        # velocity `friction_u_tau` and `friction_y_plus`, plus the aggregated
        # viscous-traction vector and centroid/normal/area, written as a flat
        # CSV/HDF time series. `nassu.viz.friction` derives the form/friction drag
        # split from these (issue #876). Sampled late in the steady run. The
        # quantities are listed explicitly so the exported IBM wall values (y+,
        # u_tau, traction) are unambiguous.
        friction:
          body_name: sphere
          mode: triangle
          frequency: 5000
          quantities:
            - friction_u_tau
            - friction_y_plus
            - traction_x
            - traction_y
            - traction_z
            - area
            - rho_interp
      exports:
        default:
          macrs: [rho, u]
          interval:
            frequency: 10000
            lvl: 0
          target:
            volume: {}
          outputs:
            instantaneous: true
        # Surface-pressure history over the sphere nodes: the mechanical form
        # (pressure) drag is integrated from the mean surface `rho` (Cp), then
        # added to the viscous `body_nodes.friction` split for the full drag.
        # Named `default_series` so the analysis reads it through the same
        # accessor as the turbulent runs; the turbulent child fully overrides it.
        default_series:
          macrs: ["rho"]
          interval: {frequency: 1000, lvl: 0}
          target:
            bodies:
              sphere:
                body_name: sphere
                normal_offset: 0.25
          outputs:
            instantaneous: true
        plane_series:
          macrs: ["rho", "u"]
          interval: {frequency: 5000, lvl: 0}
          target:
            planes:
              # Lateral (y-normal) plane through the sphere centre: wake view.
              # Children override axis_pos for their own sphere position.
              mid_sphere:
                axis: y
                axis_pos: 80
                dist: 1

          outputs:
            instantaneous: true
    models:
      precision:
        default: single

      LBM:
        tau: 0.51
        vel_set: D3Q27
        coll_oper: RRBGK
      initialization:
        equations:
          rho: "1.0"
          ux: !unroll ["0.007854", "0.024583", "0.064583"]
          uy: "0"
          uz: "0"
      engine:
        name: CUDA

      BC:
        periodic_dims: [false, false, false]
        BC_map:
          - pos: N
            BC: Neumann
            wall_normal: N
            order: 0

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

          - pos: F
            BC: Neumann
            wall_normal: F
            order: 1

          - pos: B
            BC: Neumann
            wall_normal: B
            order: 1

          - pos: E
            BC: RegularizedNeumannOutlet
            rho: 1.0
            wall_normal: E
            order: 2

          - pos: W
            BC: UniformFlow
            wall_normal: W
            rho: 1
            ux: !unroll [0.007854, 0.024583, 0.064583]
            uy: 0
            uz: 0
            order: 2

          - pos: NF
            BC: Neumann
            wall_normal: N
            order: 0

          - pos: NB
            BC: Neumann
            wall_normal: N
            order: 0

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

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

          - pos: NF
            BC: Neumann
            wall_normal: F
            order: 1

          - pos: NB
            BC: Neumann
            wall_normal: B
            order: 1

          - pos: SF
            BC: Neumann
            wall_normal: F
            order: 1

          - pos: SB
            BC: Neumann
            wall_normal: B
            order: 1

      IBM:
        forces_accomodate_time: 1000
        body_cfgs:
          default: {}

      multiblock:
        overlap_F2C: 2

  - name: turbulentFlowOverSphere
    parent: laminarFlowOverSphere

    save_path: ./validation/external_aero/01_flow_over_sphere/results/turbulent

    n_steps: 100000

    domain:
      domain_size:
        x: 640
        y: 128
        z: 128
      block_size: 8

      bodies: !not-inherit
        sphere:
          small_triangles: "add"
          geometry_path: fixture/stl/basic/sphere.stl
          transformation:
            scale: [0.8, 0.8, 0.8]
            translation: [112, 60, 60]

      refinement:
        static:
          default:
            volumes_refine:
              - start: [96, 32, 32]
                end: [224, 96, 96]
                lvl: 1
                is_abs: true
              - start: [104, 56, 56]
                end: [144, 72, 72]
                lvl: 3
                is_abs: true

    data:
      exports:
        default:
          interval: {frequency: 25000}
        default_series:
          macrs: ["u", "rho"]
          interval: {frequency: 20, lvl: 0}
          target:
            bodies:
              sphere:
                body_name: sphere
                normal_offset: 0.25
          outputs:
            instantaneous: true
        plane_series:
          interval: {frequency: 10000}
          target:
            planes:
              # Sphere centre for this domain size.
              mid_sphere: {axis_pos: 64}
        spectrum:
          macrs: ["rho", "u"]
          interval:
            frequency: 0
            lvl: 0
          target:
            points:
              # Centreline wake point ~2 diameters downstream of the sphere
              # centre (~(116, 64, 64), D~8), inside the lvl-3 refinement box:
              # independent velocity-based shedding-frequency (Strouhal) check.
              point_velocity_wake:
                pos: [132, 64, 64]
          outputs:
            spectrum: true

    models:
      LBM:
        tau: 0.500285714285714
        vel_set: D3Q27
        coll_oper: RRBGK
      initialization: !not-inherit
        rho: 1.0
        u:
          x: 0.05
          y: 0
          z: 0
      IBM:
        forces_accomodate_time: 5000
      multiblock:
        overlap_F2C: 2

      BC:
        periodic_dims: [false, false, false]
        BC_map:
          - pos: N
            BC: Neumann
            wall_normal: N
            order: 0

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

          - pos: F
            BC: Neumann
            wall_normal: F
            order: 1

          - pos: B
            BC: Neumann
            wall_normal: B
            order: 1

          - pos: E
            BC: RegularizedNeumannOutlet
            rho: 1.0
            wall_normal: E
            order: 2

          - pos: W
            BC: UniformFlow
            wall_normal: W
            rho: 1
            ux: 0.05
            uy: 0
            uz: 0
            order: 2

  - name: turbulentFlowOverSphereLES
    parent: turbulentFlowOverSphere

    models:
      LBM:
        tau: 0.500024

      LES:
        model: Smagorinsky
        sgs_cte: 0.17