Blue Scout Launch Vehicle¶
This case validates Flow360 on the Blue Scout launch vehicle, a classic slender-body rocket configuration long used as an external-aerodynamics benchmark. The study is a fully automated transonic sweep of 3 Mach numbers (0.6, 0.9, 1.03) x 2 angles of attack (0 deg, 6 deg) = 6 steady Spalart-Allmaras runs. To replicate the wind-tunnel setup, each case fixes both the freestream Mach number and the mesh-unit Reynolds number.
The transonic regime is where this configuration is most challenging: shock formation and shock/boundary-layer interaction strongly affect the integrated forces and moments. The notebook runs top-to-bottom in a single kernel: it starts a project from the published geometry, submits the sweep, waits for completion, then post-processes the in-kernel case objects into the published axial-force-coefficient comparison against the experimental data of Kelly et al. (NASA TN D-1958).
Requirements¶
This notebook runs against the Flow360 Python API. Install it and configure your API key by following the installation & setup guide.
This notebook targets the latest Flow360 Python client. It fetches the case's
root asset and reference data with flow360.examples.download_benchmark_assets,
added in 25.10.4. Install the client (25.10.4 or newer) before running:
pip install "flow360>=25.10.4"
Beyond flow360, the notebook needs a few plotting/data packages:
pip install numpy pandas matplotlib
Run the cells top to bottom in a single kernel.
Imports¶
import os
from pathlib import Path
import flow360 as fl
from flow360 import u
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from flow360.examples import download_benchmark_assets
Input data¶
The post-processing compares Flow360 against the experimental coefficient curves. Fetch those reference files from the public benchmark bucket; this recreates a local ./ref_data/ directory that the plotting cell reads from.
download_benchmark_assets("Launch_Vehicle_I", "ref_data")
Configuration¶
The solver version, the operating-condition sweep (paired Mach / mesh-unit Reynolds / temperature and the two angles of attack), the solver output fields, and the geometry faces that receive local surface refinement.
SOLVER_VERSION = os.environ.get("SOLVER_VERSION_OVERRIDE", "release-25.8")
REYNOLDS_PER_FOOT = [3180000, 3977500, 4120000]
REYNOLDS_PER_MESH_UNIT = [value / 12 for value in REYNOLDS_PER_FOOT]
MACH_VALUES = [0.6, 0.9, 1.03]
TEMPERATURES_K = [315.958, 291.485, 279.419]
ANGLES_OF_ATTACK = [0.0, 6.0]
SURFACE_FIELDS = ["Cp", "yPlus", "Cf", "CfVec", "primitiveVars"]
SLICE_FIELDS = ["Cp", "Cpt", "Mach", "primitiveVars", "T"]
REFINEMENT_SURFACES = [
"body00001_face00001",
"body00001_face00004",
"body00001_face00005",
"body00001_face00007",
"body00001_face00014",
"body00001_face00017",
"body00001_face00022",
"body00001_face00032",
"body00001_face00040",
"body00001_face00050",
"body00001_face00051",
"body00001_face00052",
"body00001_face00053",
"body00001_face00054",
"body00001_face00055",
"body00001_face00056",
]
Load project¶
The root asset for this case is the published CAD geometry. Download it from the public benchmark bucket and start a fresh project from it, then group the faces by their faceId tag so the meshing refinements and boundary conditions can address individual faces by name.
root_asset_files = download_benchmark_assets("Launch_Vehicle_I", "root_assets")
# The snapshot includes processed copies under results/; select the source
# CAD file(s) to rebuild the geometry project from.
geometry_files = [
f for f in root_asset_files
if "/results/" not in f and "/logs/" not in f
and f.lower().endswith((".csm", ".egads", ".stp", ".step", ".iges", ".igs", ".stl"))
]
project = fl.Project.from_geometry(geometry_files, name="Blue Scout Launch Vehicle")
geometry = project.geometry
geometry.group_faces_by_tag("faceId")
Case naming¶
Small helpers that turn each Mach / angle-of-attack pair into a filesystem-safe case name.
def slug_number(value):
return f"{value:g}".replace(".", "p").replace("-", "m")
def build_case_name(mach, alpha):
return f"mach{slug_number(mach)}_alpha{slug_number(alpha)}_{SOLVER_VERSION}"
Meshing setup¶
Every case is meshed identically with the beta mesher: an automated farfield, a surface size driven by curvature with a thin first boundary-layer cell, extra surface refinement on the launch-vehicle faces listed above, and a uniform refinement inside a cylinder aligned with the body to resolve the near-body wake.
def build_meshing(farfield_zone, reference_cylinder, geometry):
with fl.SI_unit_system:
return fl.MeshingParams(
defaults=fl.MeshingDefaults(
surface_max_edge_length=0.75 * fl.u.inch,
curvature_resolution_angle=5 * fl.u.deg,
boundary_layer_first_layer_thickness=0.000015 * fl.u.inch,
),
volume_zones=[farfield_zone],
refinements=[
fl.SurfaceRefinement(
faces=[geometry[name] for name in REFINEMENT_SURFACES],
max_edge_length=0.1 * fl.u.inch,
curvature_resolution_angle=1.5 * fl.u.deg,
),
fl.UniformRefinement(
entities=reference_cylinder,
spacing=1.5 * fl.u.inch,
),
],
)
Physics setup¶
The vehicle surface is a viscous wall and the automated farfield is a freestream. The flow solver is compressible Navier-Stokes with velocity and pressure/density limiters for transonic robustness, closed by the Spalart-Allmaras turbulence model with rotation correction and the quadratic constitutive relation.
def build_models(all_surfaces, farfield_zone):
with fl.SI_unit_system:
return [
fl.Wall(surfaces=all_surfaces, name="Launch_Vehicle"),
fl.Freestream(surfaces=farfield_zone.farfield, name="Freestream"),
fl.Fluid(
navier_stokes_solver=fl.NavierStokesSolver(
limit_velocity=True,
limit_pressure_density=True,
),
turbulence_model_solver=fl.SpalartAllmaras(
equation_evaluation_frequency=1,
rotation_correction=True,
quadratic_constitutive_relation=True,
),
),
]
Outputs setup¶
Each case writes a surface output over the whole vehicle and two orthogonal field slices through the origin, in ParaView format. The surface output carries the pressure and skin-friction fields used to build the integrated axial force.
def build_outputs(all_surfaces):
with fl.SI_unit_system:
return [
fl.SurfaceOutput(
output_format="paraview",
output_fields=SURFACE_FIELDS,
surfaces=all_surfaces,
),
fl.SliceOutput(
slices=[
fl.Slice(name="SliceY", normal=(0, 1, 0), origin=(0, 0, 0) * u.inch),
fl.Slice(name="SliceZ", normal=(0, 0, 1), origin=(0, 0, 0) * u.inch),
],
output_format="paraview",
output_fields=SLICE_FIELDS,
),
]
Simulation Params¶
make_run_params assembles the full SimulationParams for one (Mach, angle) case: the shared meshing, the reference geometry (moment center and lengths, reference area), the paired Mach/Reynolds/temperature operating condition at the requested angle of attack, a steady solve of up to 2000 steps with an adaptive CFL, and the models and outputs from the builders above.
def make_run_params(geometry, mach_idx, alpha_idx):
farfield_zone = fl.AutomatedFarfield()
reference_cylinder = fl.Cylinder(
name="Ref",
axis=(1, 0, 0),
center=(30, 0, 0) * fl.u.inch,
height=60 * fl.u.inch,
outer_radius=5 * fl.u.inch,
)
all_surfaces = geometry["body*"]
with fl.SI_unit_system:
return fl.SimulationParams(
meshing=build_meshing(farfield_zone, reference_cylinder, geometry),
reference_geometry=fl.ReferenceGeometry(
moment_center=(38.29, 0.0, 0.0) * u.inch,
moment_length=(2.668, 2.668, 2.668) * u.inch,
area=0.0388 * u.ft * u.ft,
),
operating_condition=fl.AerospaceCondition.from_mach_reynolds(
mach=MACH_VALUES[mach_idx],
reynolds_mesh_unit=REYNOLDS_PER_MESH_UNIT[mach_idx],
temperature=TEMPERATURES_K[mach_idx] * u.K,
project_length_unit=1 * u.inch,
alpha=ANGLES_OF_ATTACK[alpha_idx] * u.deg,
),
time_stepping=fl.Steady(
max_steps=2000,
CFL=fl.AdaptiveCFL(convergence_limiting_factor=0.35),
),
models=build_models(all_surfaces, farfield_zone),
outputs=build_outputs(all_surfaces),
)
Submit cases¶
Sweep the three Mach numbers over the two angles of attack and submit all six cases to Flow360 with the beta mesher. We keep the submitted case objects so the post-processing can read their results directly, without re-opening the project.
submitted_cases = []
for mach_idx, mach in enumerate(MACH_VALUES):
for alpha_idx, alpha in enumerate(ANGLES_OF_ATTACK):
case = project.run_case(
params=make_run_params(geometry, mach_idx, alpha_idx),
name=build_case_name(mach, alpha),
use_beta_mesher=True,
solver_version=SOLVER_VERSION,
tags=["Launch_Vehicle_I", SOLVER_VERSION],
)
submitted_cases.append(case)
Wait for completion¶
Block until every submitted case has finished. This can take a long time (minutes to hours), since each case is a full steady solve including meshing.
for case in submitted_cases:
case.wait()
Postprocessing¶
The published result is a single figure: the axial-force coefficient CA vs Mach, with the alpha = 0 deg and alpha = 6 deg sweeps shown side by side and compared against experiment. The cells below read the forces straight from the in-kernel case objects (no project is re-opened) and rebuild exactly that figure.
Setup¶
Plot colors (Flow360 green, experiment black), the reference-data location, and the farfield base patches whose axial force is subtracted from the total so the reported CA excludes the base region, matching the experimental definition.
FLOW360_COLOR = "#00643c" # Flow360 results
REFERENCE_COLOR = "black" # experimental reference
REFERENCE_DIR = Path("ref_data")
BASE_PATCHES = [
"body00001_face00001",
"body00001_face00005",
"body00001_face00004",
"body00001_face00014",
]
os.makedirs("results", exist_ok=True)
Compute the axial-force coefficient¶
For each finished case, take the settled loads (mean of the last 10% of steps). The axial force CA is the total streamwise force coefficient with the farfield base-patch contributions removed. We read each case's Mach and angle of attack back from its operating condition, then merge the Flow360 predictions with the experimental reference curve on (Mach, alpha).
def compute_axial_force(case):
total_forces = case.results.total_forces.as_dataframe()
surface_forces = case.results.surface_forces.as_dataframe()
base_cfx = [surface_forces[f"farfield/{patch}_CFx"].to_numpy() for patch in BASE_PATCHES]
axial = total_forces["CFx"] - np.sum(base_cfx, axis=0)
last_count = max(1, int(len(total_forces) * 0.1))
return float(axial.iloc[-last_count:].mean())
def build_prediction_dataframe(cases):
rows = []
for case in cases:
mach = float(np.asarray(case.params.operating_condition.mach).flatten()[0])
alpha = float(np.asarray(case.params.operating_condition.alpha).flatten()[0])
rows.append({"mach": mach, "alpha": alpha, "CA": compute_axial_force(case)})
return pd.DataFrame(rows).sort_values(["mach", "alpha"]).reset_index(drop=True)
def load_reference_dataframe():
df = pd.read_csv(REFERENCE_DIR / "CA_alpha0_6.csv")
return pd.DataFrame(
{
"mach": list(df["mach"].astype(float)) + list(df["mach"].astype(float)),
"alpha": [0.0] * len(df) + [6.0] * len(df),
"reference": list(df["CA_alpha0"].astype(float)) + list(df["CA_alpha6"].astype(float)),
}
)
predictions = build_prediction_dataframe(submitted_cases)
flow360_df = predictions[["mach", "alpha", "CA"]].rename(columns={"CA": "flow360"})
ca_df = (
flow360_df.merge(load_reference_dataframe(), on=["mach", "alpha"], how="left")
.sort_values(["alpha", "mach"])
.reset_index(drop=True)
)
CA vs Mach¶
One panel per angle of attack: Flow360 (green circles) against the experimental measurements (black squares).
def plot_coefficient(df, ylabel):
alphas = sorted(df["alpha"].unique())
fig, axes = plt.subplots(1, len(alphas), figsize=(10, 4.5))
if len(alphas) == 1:
axes = [axes]
for ax, alpha in zip(axes, alphas):
subset = df[df["alpha"] == alpha]
ax.plot(
subset["mach"],
subset["flow360"],
color=FLOW360_COLOR,
marker="o",
linewidth=2,
label="Flow360",
)
ax.plot(
subset["mach"],
subset["reference"],
color=REFERENCE_COLOR,
marker="s",
linewidth=1.5,
label="Experiment",
)
ax.set_xlabel("Mach")
ax.set_ylabel(ylabel)
ax.text(
0.03,
0.97,
rf"$\alpha = {alpha:g}^\circ$",
transform=ax.transAxes,
ha="left",
va="top",
)
ax.grid(True, linestyle="--", alpha=0.35)
handles, labels = axes[0].get_legend_handles_labels()
fig.legend(handles, labels, loc="upper center", ncol=2, frameon=False)
fig.tight_layout(rect=(0, 0, 1, 0.92))
return fig
ca_fig = plot_coefficient(ca_df, "CA")
ca_fig.savefig("results/ca_mach.png", dpi=200)
plt.show()