NASA N2A Blended Wing Body¶

This case evaluates the static aerodynamic stability of the NASA N2A blended wing body at low speed. A steady Spalart-Allmaras RANS sweep covers angles of attack from -5 to 27.5 degrees at Mach 0.2 and a Reynolds number of 6.60 million. The post-processing compares lift, drag, and pitching moment with NASA wind-tunnel measurements and USM3D predictions.

Requirements¶

This notebook runs against the Flow360 Python API. Follow the installation and setup guide to install the client and configure access.

pip install "flow360>=25.10.4"
pip install matplotlib pandas

Run every cell from top to bottom in one kernel.

Imports¶

The notebook uses Flow360 for the simulation and pandas and Matplotlib for the published comparison plots.

In [ ]:
import os
from pathlib import Path

import flow360 as fl
import matplotlib.pyplot as plt
import pandas as pd

from flow360.examples import download_benchmark_assets

FLOW360_COLOR = "#00643c"
REFERENCE_COLOR = "black"

Input data¶

Download the reference measurements and USM3D data used in the comparison plots.

In [ ]:
download_benchmark_assets("N2A", "ref_data")

Load project¶

The benchmark snapshot supplies the public geometry asset used to create a new Flow360 project.

In [ ]:
root = download_benchmark_assets("N2A", "root_assets")
project = fl.Project.from_geometry(root)

Meshing, physics, and outputs¶

These constants and parameter builder define the mesh controls, freestream condition, steady RANS model, and requested outputs for every angle of attack.

In [ ]:
SOLVER_VERSION = os.environ.get("SOLVER_VERSION_OVERRIDE", "release-25.10")
ALPHAS_DEG = (-5, -2.5, 0, 2.5, 5, 8.36, 10, 13, 15, 16, 17.5, 20, 22.5, 27.5)

MACH = 0.2
REYNOLDS = 6.60e6
TEMPERATURE = 550 * fl.u.R
LENGTH_UNIT = 1 * fl.u.ft
REFERENCE_AREA = 462.6 * fl.u.m**2
REFERENCE_CHORD = 26.52 * fl.u.m
SPAN = 64.92 * fl.u.m
MOMENT_CENTER = (24.33, 0, 0) * fl.u.m
SYMMETRY_FACE = "body00001_face00003"
SLICE_LOCATIONS = (0.305, 0.51, 0.906)
REFINEMENT_BOX = fl.Box(
    name="Refinement Box",
    center=MOMENT_CENTER,
    size=(SPAN.value, SPAN.value * 1.1, 20) * fl.u.m,
)


def case_name(alpha_deg):
    return f"N2A_final_SA_alpha{alpha_deg:g}_{SOLVER_VERSION}"


def make_params(project, alpha_deg):
    geometry = project.geometry
    farfield = fl.AutomatedFarfield()
    aircraft_surfaces = [surface for surface in geometry["*"] if surface.name != SYMMETRY_FACE]
    slices = [
        fl.Slice(
            name=f"eta_{eta:g}",
            normal=(0, 1, 0),
            origin=(0, eta * SPAN.value / 2, 0) * fl.u.m,
        )
        for eta in SLICE_LOCATIONS
    ]

    with fl.SI_unit_system:
        return fl.SimulationParams(
            meshing=fl.MeshingParams(
                gap_treatment_strength=0.1,
                defaults=fl.MeshingDefaults(
                    surface_max_edge_length=1 * fl.u.ft,
                    curvature_resolution_angle=4 * fl.u.deg,
                    boundary_layer_first_layer_thickness=0.1 / 3 * fl.u.mm,
                    octree_spacing=fl.OctreeSpacing(base_spacing=0.5 * fl.u.ft),
                ),
                refinements=[
                    fl.UniformRefinement(
                        entities=REFINEMENT_BOX,
                        spacing=1 * fl.u.ft,
                    )
                ],
                volume_zones=[farfield],
            ),
            reference_geometry=fl.ReferenceGeometry(
                area=REFERENCE_AREA,
                moment_center=MOMENT_CENTER,
                moment_length=[SPAN, REFERENCE_CHORD, SPAN],
            ),
            operating_condition=fl.AerospaceCondition.from_mach_reynolds(
                mach=MACH,
                reynolds_mesh_unit=float(REYNOLDS * LENGTH_UNIT / REFERENCE_CHORD),
                project_length_unit=LENGTH_UNIT,
                alpha=alpha_deg * fl.u.deg,
                temperature=TEMPERATURE,
            ),
            time_stepping=fl.Steady(
                max_steps=4000,
                CFL=fl.AdaptiveCFL(convergence_limiting_factor=0.75),
            ),
            models=[
                fl.Wall(surfaces=aircraft_surfaces),
                fl.Freestream(surfaces=farfield.farfield),
                fl.SymmetryPlane(surfaces=farfield.symmetry_plane),
                fl.Fluid(
                    turbulence_model_solver=fl.SpalartAllmaras(absolute_tolerance=1e-8),
                    navier_stokes_solver=fl.NavierStokesSolver(
                        absolute_tolerance=1e-10,
                    ),
                ),
            ],
            outputs=[
                fl.SliceOutput(
                    slices=slices,
                    output_fields=["primitiveVars", "nuHat", "vorticityMagnitude"],
                ),
                fl.SurfaceOutput(
                    surfaces=aircraft_surfaces,
                    write_single_file=True,
                    output_fields=["Cp"],
                    output_format=["paraview"],
                ),
            ],
        )

Submit cases¶

Submit the complete angle-of-attack sweep. Each case forks from the preceding solution to retain the benchmark workflow.

In [ ]:
submitted_cases = []
parent_case = None

for alpha_deg in ALPHAS_DEG:
    parent_case = project.run_case(
        params=make_params(project, alpha_deg),
        name=case_name(alpha_deg),
        fork_from=parent_case,
        use_beta_mesher=True,
        solver_version=SOLVER_VERSION,
    )
    submitted_cases.append(parent_case)

Wait for completion¶

Wait for every submitted case before retrieving its integrated force results.

In [ ]:
for case in submitted_cases:
    case.wait()

Postprocessing¶

Average the final 5 percent of each steady solution, then prepare the reference data shared by the three published plots.

In [ ]:
RESULTS_DIR = Path("results")
RESULTS_DIR.mkdir(exist_ok=True)
REFERENCE_DATA = Path("ref_data/cruise_drooped_experiment_usm3d_figures_16_17.csv")
AVERAGING_PERCENT = 5
MAX_ALPHA_DEG = 27.5
USM3D_COLOR = "#FC7A4C"
REFERENCE_STYLES = {
    "T597 R269 Closed Tunnel": ("o", "-", REFERENCE_COLOR, REFERENCE_COLOR, "NASA 14x22 WT: closed test section"),
    "T597 R273 Open Tunnel": ("s", "--", REFERENCE_COLOR, "white", "NASA 14x22 WT: open test section"),
    "USM3D Free Air": ("D", "None", USM3D_COLOR, USM3D_COLOR, "USM3D: aircraft only"),
    "USM3D w/Walls & Support": ("^", "None", USM3D_COLOR, "white", "USM3D: complete experimental setup"),
}


def load_flow360():
    rows = []
    for case in submitted_cases:
        forces = case.results.total_forces.as_dataframe()
        if "pseudo_step" in forces:
            forces = forces.sort_values("pseudo_step")
        tail = forces.iloc[-max(1, int(len(forces) * AVERAGING_PERCENT / 100)) :]
        rows.append(
            {
                "series": "Flow360 - SA - aircraft only",
                "alpha_deg": float(case.params.operating_condition.alpha.to("deg").value),
                "CL": tail["CL"].mean(),
                "CD": tail["CD"].mean(),
                "Cm": tail["CMy"].mean(),
            }
        )
    return pd.DataFrame(rows).query("alpha_deg <= @MAX_ALPHA_DEG").sort_values("alpha_deg")


def load_reference():
    data = pd.read_csv(REFERENCE_DATA)
    max_alpha = data.loc[data["alpha_deg"] >= MAX_ALPHA_DEG, "alpha_deg"].min()
    return data[data["alpha_deg"] <= max_alpha]


def reference_series(reference, coefficient):
    return reference[reference["coefficient"] == coefficient].rename(columns={"value": coefficient})


flow360 = load_flow360()
reference = load_reference()

Lift coefficient versus angle of attack¶

Compare the Flow360 lift curve with the wind-tunnel and USM3D reference series.

In [ ]:
def plot_vs_alpha(flow360, reference, coefficient, ylabel, path, ylim):
    figure, axis = plt.subplots(figsize=(8, 5))
    for series, points in reference_series(reference, coefficient).groupby("series"):
        points = points.sort_values("alpha_deg")
        marker, linestyle, color, markerfacecolor, label = REFERENCE_STYLES[series]
        axis.plot(
            points["alpha_deg"], points[coefficient], color=color, marker=marker,
            linestyle=linestyle, linewidth=1.2, markersize=4,
            markerfacecolor=markerfacecolor, markeredgecolor=color, label=label,
        )
    axis.plot(
        flow360["alpha_deg"], flow360[coefficient], color=FLOW360_COLOR, marker="o",
        linewidth=2, label="Flow360 - SA - aircraft only",
    )
    axis.set(xlabel=r"$\alpha$ (deg)", ylabel=ylabel, xlim=(-15, reference["alpha_deg"].max() + 1), ylim=ylim)
    axis.grid(alpha=0.35)
    axis.legend(fontsize=9, framealpha=0.9)
    figure.tight_layout()
    figure.savefig(path, dpi=300)
    plt.show()


plot_vs_alpha(flow360, reference, "CL", r"$C_L$", RESULTS_DIR / "lift_vs_alpha.png", (-0.4, 1))

Drag polar¶

Plot drag coefficient against lift coefficient for each reference series and the Flow360 sweep.

In [ ]:
figure, axis = plt.subplots(figsize=(8, 5))
lift = reference_series(reference, "CL")
drag = reference_series(reference, "CD")
for series, drag_points in drag.groupby("series"):
    points = drag_points.merge(lift[lift["series"] == series][["alpha_deg", "CL"]], on="alpha_deg")
    points = points.sort_values("alpha_deg")
    marker, linestyle, color, markerfacecolor, label = REFERENCE_STYLES[series]
    axis.plot(
        points["CD"], points["CL"], color=color, marker=marker, linestyle=linestyle,
        linewidth=1.2, markersize=4, markerfacecolor=markerfacecolor, markeredgecolor=color, label=label,
    )
axis.plot(flow360["CD"], flow360["CL"], color=FLOW360_COLOR, marker="o", linewidth=2, label="Flow360 - SA - aircraft only")
axis.set(xlabel=r"$C_D$", ylabel=r"$C_L$", ylim=(-0.4, 1))
axis.set_xlim(left=0)
axis.grid(alpha=0.35)
axis.legend(fontsize=9, framealpha=0.9)
figure.tight_layout()
figure.savefig(RESULTS_DIR / "drag_polar.png", dpi=300)
plt.show()

Pitching-moment coefficient versus angle of attack¶

Use the same comparison method for the pitching-moment coefficient.

In [ ]:
plot_vs_alpha(flow360, reference, "Cm", r"$C_m$", RESULTS_DIR / "pitching_moment_vs_alpha.png", (-0.05, 0.02))