X-57 Blown-Wing Validation¶
This notebook reproduces the X-57 distributed-propulsion blown-wing benchmark using unsteady k-omega SST DDES with the Amplification Factor Transport transition model. Seven forked case sequences sweep angle of attack from 0 to 30 degrees while a five bladed propeller rotates at 4549 rpm. Wing lift, drag, and pitching-moment coefficients are averaged over each final revolution and compared with FlightStream and OVERFLOW predictions.
Requirements¶
This notebook runs against the Flow360 Python API. Install it and configure your API key by following the installation and setup guide.
The notebook targets the latest Flow360 client and uses download_benchmark_assets, available in 25.10.4 and newer:
pip install "flow360>=25.10.4"
Install the plotting and data dependencies:
pip install pandas matplotlib
Run the cells from top to bottom in one kernel.
Imports¶
import os
import re
from pathlib import Path
import flow360 as fl
import matplotlib
import matplotlib.pyplot as plt
import pandas as pd
from flow360 import u
from flow360.examples import download_benchmark_assets
Input data¶
Download the FlightStream and OVERFLOW reference coefficients used in the published comparisons. The helper recreates the local ref_data/ directory expected by the postprocessing cells.
download_benchmark_assets("X57_Blown_Wing", "ref_data")
Load project¶
Download the public snapshot of the benchmark geometry and create a fresh Flow360 project. Grouping faces by faceId preserves the entity names used by the meshing and boundary-condition setup.
root_asset_files = download_benchmark_assets("X57_Blown_Wing", "root_assets")
geometry_files = [
file
for file in root_asset_files
if "/results/" not in file
and "/logs/" not in file
and file.lower().endswith((".csm", ".egads", ".stp", ".step", ".iges", ".igs", ".stl"))
]
project = fl.Project.from_geometry(geometry_files, name="X-57 Blown-Wing Validation")
Case constants¶
The benchmark holds propeller speed, time-step size, freestream speed, and reference chord fixed. Angle of attack and the number of continuation runs vary across the seven sequences.
SOLVER_VERSION = os.environ.get("SOLVER_VERSION_OVERRIDE", "release-25.10.13")
MAX_REVOLUTIONS_BY_AOA = {0: 15, 5: 15, 10: 15, 15: 15, 20: 15, 25: 30, 30: 40}
VELOCITY = 29.8378
CHORD = 0.7141
TIME_STEP = 0.0002198285
STEPS_PER_REVOLUTION = 60
REVOLUTIONS_PER_RUN = 5
CONSERVATIVE_SETTINGS_FROM_REVOLUTION = {5: 15, 10: 5, 15: 10, 25: 5, 30: 10}
def convergence_limiting_factor(aoa, revolutions):
if revolutions >= 35:
return 0.7
if aoa == 10 or revolutions >= 25 or (aoa == 30 and revolutions >= 20):
return 0.75
return 1.0
Meshing setup¶
Each case builds an automated farfield, a rotating volume around the propellers, focused propeller and wing wake refinements, and leading- and trailing-edge surface refinements. Three spanwise mesh slices are included for inspection.
Physics setup¶
The flow model combines k-omega SST DDES, rotation correction, a quadratic constitutive relation, and AFT transition. Conservative solver settings are enabled for selected angles of attack, from an angle-specific revolution count onward.
Outputs setup¶
Surface pressure, wall-shear quantities, velocity, and Mach number are requested on all geometry surfaces and the three spanwise slices. Surface-force histories provide the wing coefficients used below.
Simulation Params¶
make_run_params assembles the complete meshing, reference geometry, operating condition, time stepping, physical models, and outputs for one angle and continuation stage.
def make_run_params(project, aoa, revolutions, numerical_dissipation_factor):
conservative_from = CONSERVATIVE_SETTINGS_FROM_REVOLUTION.get(aoa)
conservative = conservative_from is not None and revolutions >= conservative_from
geometry = project.geometry
geometry.group_faces_by_tag("faceId")
farfield = fl.AutomatedFarfield()
rotation_interface = fl.Cylinder(
name="Rotation_Interface",
center=(0, 0, 0) * u.ft,
axis=(1, 0, 0),
outer_radius=0.35 * u.m,
height=0.3 * u.m,
)
prop_wake_0 = fl.Cylinder(
name="Prop_Wake_0deg",
center=(0.65, 0, 0) * u.m,
axis=(1, 0, 0),
outer_radius=0.4 * u.m,
height=1.6 * u.m,
)
prop_wake_30 = fl.Cylinder(
name="Prop_Wake_30deg",
center=(0.65, 0, 0.4) * u.m,
axis=(1, 0, 0.57735),
outer_radius=0.4 * u.m,
height=1.6 * u.m,
)
wing_wake_0 = fl.Box(
name="Wing_Wake_0deg",
axis_of_rotation=(0, 1, 0),
angle_of_rotation=0 * u.deg,
center=(1.6, 0, 0.3) * u.m,
size=(3, 4, 1.2) * u.m,
)
wing_wake_30 = fl.Box(
name="Wing_Wake_30deg",
axis_of_rotation=(0, 1, 0),
angle_of_rotation=-30 * u.deg,
center=(1.6, 0, 0.7) * u.m,
size=(3, 4, 1.2) * u.m,
)
slices = [
fl.Slice(name="SliceY1", normal=(0, 1, 0), origin=(0, -1, 0) * u.m),
fl.Slice(name="SliceY2", normal=(0, 1, 0), origin=(0, 0, 0) * u.m),
fl.Slice(name="SliceY3", normal=(0, 1, 0), origin=(0, 1, 0) * u.m),
]
trailing_edge_faces = ["body00006_face00003", "body00006_face00006"]
leading_edges = [
"body00001_edge00006", "body00002_edge00006", "body00003_edge00006",
"body00004_edge00006", "body00005_edge00006", "body00006_edge00010",
]
propeller_faces = [f"body0000{i}_face0000{j}" for i in range(1, 6) for j in range(1, 7)]
with fl.SI_unit_system:
return fl.SimulationParams(
meshing=fl.MeshingParams(
gap_treatment_strength=0.5,
defaults=fl.MeshingDefaults(
surface_max_edge_length=0.005 * u.m,
curvature_resolution_angle=2 * u.deg,
boundary_layer_first_layer_thickness=0.000002 * u.m,
octree_spacing=fl.OctreeSpacing(base_spacing=1 * u.ft),
),
volume_zones=[
farfield,
fl.RotationVolume(
name="Rotation_Zone",
entities=[rotation_interface],
enclosed_entities=[geometry[name] for name in propeller_faces],
spacing_axial=0.01 * u.m,
spacing_radial=0.01 * u.m,
spacing_circumferential=0.01 * u.m,
),
],
refinements=[
fl.SurfaceRefinement(
name="TE_Refinement",
faces=[geometry[name] for name in trailing_edge_faces],
max_edge_length=0.001 * u.m,
),
fl.UniformRefinement(
name="Wake_Refinement",
entities=[prop_wake_0, prop_wake_30, wing_wake_0, wing_wake_30],
spacing=0.01 * u.m,
),
fl.SurfaceEdgeRefinement(
name="Leading_Edge_Refinement",
edges=[geometry[name] for name in leading_edges],
method=fl.AngleBasedRefinement(value=2 * u.deg),
),
],
outputs=[fl.MeshSliceOutput(slices=slices)],
),
reference_geometry=fl.ReferenceGeometry(
moment_center=(0.4376, 0, 0.1241) * u.m,
moment_length=(CHORD, CHORD, CHORD) * u.m,
area=2.1767 * u.m * u.m,
),
operating_condition=fl.AerospaceCondition(
velocity_magnitude=VELOCITY * u.m / u.s,
alpha=aoa * u.deg,
thermal_state=fl.ThermalState(
temperature=288.15 * u.K,
density=1.225 * u.kg / u.m**3,
),
),
time_stepping=fl.Unsteady(
max_pseudo_steps=20,
step_size=TIME_STEP,
steps=STEPS_PER_REVOLUTION * REVOLUTIONS_PER_RUN,
CFL=fl.AdaptiveCFL(
convergence_limiting_factor=convergence_limiting_factor(aoa, revolutions)
),
),
models=[
fl.Wall(surfaces=[geometry["*"]], name="Wall"),
fl.Freestream(surfaces=farfield.farfield, name="Freestream"),
fl.Fluid(
navier_stokes_solver=fl.NavierStokesSolver(
update_jacobian_frequency=1 if conservative else 4,
limit_velocity=conservative,
limit_pressure_density=conservative,
riemann_solver=fl.RoeFlux(
numerical_dissipation_factor=numerical_dissipation_factor
)
),
turbulence_model_solver=fl.KOmegaSST(
equation_evaluation_frequency=1 if conservative else 4,
CFL_multiplier=1.0 if conservative else 2.0,
update_jacobian_frequency=1 if conservative else 4,
rotation_correction=True,
quadratic_constitutive_relation=True,
reconstruction_gradient_limiter=0.5,
hybrid_model=fl.DetachedEddySimulation(shielding_function="DDES"),
),
transition_model_solver=fl.TransitionModelSolver(
N_crit=9, relative_tolerance=0
),
),
fl.Rotation(
name="Rotation",
entities=[rotation_interface],
spec=fl.AngularVelocity(-4549 * u.rpm),
),
],
outputs=[
fl.SurfaceOutput(
output_format=["tecplot"],
output_fields=["Cp", "yPlus", "Cf", "CfVec"],
surfaces=[geometry["*"]],
),
fl.SliceOutput(
slices=slices,
output_format=["tecplot"],
output_fields=["Cp", "velocity_m_per_s", "Mach"],
),
],
)
Submit cases¶
Submit the full seven-angle study. Every sequence starts with a five-revolution case, then forks in five-revolution increments until its angle-specific final revolution count is reached.
with fl.warning_bypass("potential_length_scale_mismatch"):
cases = []
for aoa, max_revolutions in MAX_REVOLUTIONS_BY_AOA.items():
parent = None
for step, revolutions in enumerate(
range(REVOLUTIONS_PER_RUN, max_revolutions + 1, REVOLUTIONS_PER_RUN), start=1
):
case = project.run_case(
params=make_run_params(
project, aoa, revolutions, 1.0 if parent is None else 0.2
),
fork_from=parent,
name=f"X57_Blown_Wing_AoA{aoa}_Rev{revolutions}_step{step}_{SOLVER_VERSION}",
solver_version=SOLVER_VERSION,
)
cases.append(case)
parent = case
Wait for completion¶
Wait for every submitted case before reading results. These unsteady DDES sequences can take a long time to complete.
for case in cases:
case.wait()
Postprocessing¶
Select the longest completed continuation for each angle of attack, sum the four wing-surface force contributions, and average the final propeller revolution. The resulting data drives all three published comparisons.
RESULTS_DIR = Path("results")
RESULTS_DIR.mkdir(exist_ok=True)
REFERENCE_PATH = Path("ref_data") / "Force_Comparison.csv"
WING_PATCHES = [
"body00006_face00003",
"body00006_face00004",
"body00006_face00005",
"body00006_face00006",
]
def last_pseudo_step(data):
if "physical_step" not in data:
return data
sort_columns = ["physical_step"]
if "pseudo_step" in data:
sort_columns.append("pseudo_step")
return data.sort_values(sort_columns).groupby("physical_step", as_index=False).tail(1)
def wing_coefficients(surface_forces):
coefficients = {}
for coefficient in ("CL", "CD", "CMy"):
columns = [f"stationaryBlock/{patch}_{coefficient}" for patch in WING_PATCHES]
missing = [column for column in columns if column not in surface_forces]
if missing:
raise RuntimeError(f"Missing wing surface-force columns: {', '.join(missing)}")
coefficients[coefficient] = surface_forces[columns].sum(axis=1)
return pd.DataFrame(coefficients)
final_cases = {}
for case in cases:
match = re.search(r"_AoA(-?\d+)_Rev(\d+)_step\d+_", case.name or "")
if match is None:
raise RuntimeError("Each X-57 case must include AoA, revolutions, and run step in its name.")
aoa, revolutions = map(int, match.groups())
if aoa not in final_cases or revolutions > final_cases[aoa][0]:
final_cases[aoa] = (revolutions, case)
loads = []
for aoa, (_, case) in sorted(final_cases.items()):
surface_forces = last_pseudo_step(case.results.surface_forces.as_dataframe())
coefficients = wing_coefficients(surface_forces)
loads.append(
{
"AoA": aoa,
**coefficients.tail(STEPS_PER_REVOLUTION)
.mean()
.add_suffix("_Flow360")
.to_dict(),
}
)
reference = pd.read_csv(REFERENCE_PATH)
reference = reference.rename(
columns={
"CL_OVERFLOW_VSPAEROPaper": "CL_VSPAERO",
"CD_OVERFLOW_VSPAEROPaper": "CD_VSPAERO",
}
)
comparison = reference.merge(pd.DataFrame(loads), on="AoA").sort_values("AoA")
Plot helper¶
Use the same axes, reference series, and line conventions as the benchmark postprocessor. Flow360 is shown in the company green, with external predictions in gray and black.
FLOW360_COLOR = "#00643c"
REFERENCE_COLOR = "black"
PLOT_LIMITS = {"CL": (0, 2.5), "CD": (0, 1), "CMy": (-0.4, 0)}
def plot_comparison(comparison, coefficient, output_path):
figure, axis = plt.subplots(figsize=(5, 3.5))
axis.plot(
comparison["AoA"],
comparison[f"{coefficient}_Flow360"],
color=FLOW360_COLOR,
marker="o",
label="Flow360 wing",
)
for source, style, color in (
("Flightstream", "-", "0.5"),
("OVERFLOW", "--", REFERENCE_COLOR),
):
column = f"{coefficient}_{source}"
if column in comparison:
axis.plot(
comparison["AoA"],
comparison[column],
color=color,
linestyle=style,
marker="o",
label=source,
)
axis.set(
xlabel="AoA (degrees)",
ylabel=coefficient,
xlim=(-2, 32),
ylim=PLOT_LIMITS[coefficient],
)
if coefficient == "CMy":
axis.yaxis.set_major_locator(matplotlib.ticker.MultipleLocator(0.1))
axis.set_xticks(range(0, 31, 5))
axis.grid(alpha=0.3)
axis.legend(fontsize=8)
figure.tight_layout()
figure.savefig(output_path, dpi=300)
plt.show()
Wing lift coefficient across the angle-of-attack sweep¶
plot_comparison(comparison, "CL", RESULTS_DIR / "wing_CL.png")
Wing drag coefficient across the angle-of-attack sweep¶
plot_comparison(comparison, "CD", RESULTS_DIR / "wing_CD.png")
Wing pitching-moment coefficient across the angle-of-attack sweep¶
plot_comparison(comparison, "CMy", RESULTS_DIR / "wing_CMy.png")