Hammerhead Launch Vehicle: Supersonic Mach Sweep¶
Reproduce the Hammerhead Launch Vehicle Flow360 validation case. Hammerhead launch vehicles pair a large payload fairing with a comparatively slender booster body; the abrupt diameter changes drive shock interactions, strong pressure gradients, and possible flow separation in the supersonic regime, which makes the configuration a valuable CFD validation case.
The study sweeps three supersonic operating points, Mach 1.187, 2.410, and
4.752, at a fixed angle of attack of 2 degrees, isolating the effect of Mach
number on the integrated loads. For each point we compute the corrected
axial-force coefficient (CA) and the normal-force coefficient (CN) and compare
them against wind-tunnel data.
The notebook runs top to bottom in a single kernel: load the geometry, submit the Mach sweep, wait for completion, then post-process the in-kernel case objects into the two published figures.
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 couple of plotting/data packages:
pip install matplotlib pandas
Run the cells top to bottom in a single kernel.
Imports¶
import math
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
Input data¶
The post-processing compares Flow360 against wind-tunnel measurements. Fetch that
reference file from the public benchmark bucket; this recreates a local
./ref_data/ directory that the plotting cells read from.
download_benchmark_assets("Launch_Vehicle_II", "ref_data")
Configuration¶
Case constants and the supersonic sweep definition. The geometry exposes named faces; the meshing refinements target the nose and body face groups, and the integrated loads use the body diameter as the reference length. The sweep holds the angle of attack at 2 degrees and varies Mach number with its matching free-stream temperature and density.
SOLVER_VERSION = os.environ.get("SOLVER_VERSION_OVERRIDE", "release-25.8")
BODY_DIAMETER_INCH = 93.65
ALPHA_DEG = 2.0
NOSE_FACES = [
"body00001_face00010",
"body00001_face00011",
"body00001_face00021",
"body00001_face00022",
"body00001_face00032",
"body00001_face00033",
"body00001_face00043",
"body00001_face00044",
]
BODY_FACES = [
"body00001_face00001",
"body00001_face00003",
"body00001_face00005",
"body00001_face00006",
"body00001_face00007",
"body00001_face00012",
"body00001_face00014",
"body00001_face00016",
"body00001_face00017",
"body00001_face00018",
"body00001_face00023",
"body00001_face00025",
"body00001_face00027",
"body00001_face00028",
"body00001_face00029",
"body00001_face00034",
"body00001_face00036",
"body00001_face00038",
"body00001_face00039",
"body00001_face00040",
]
OPERATING_CONDITIONS = [
{"mach": 1.187, "temperature_r": 443.6, "density_kg_m3": 0.981},
{"mach": 2.410, "temperature_r": 271.9, "density_kg_m3": 0.580},
{"mach": 4.752, "temperature_r": 111.6, "density_kg_m3": 0.279},
]
Load project¶
The root asset for this case is the vehicle geometry. Download it from the public
benchmark bucket and start a fresh project from it, no private project id is
needed. Grouping the faces by their faceId tag exposes the named face groups the
meshing refinements address.
root_asset_files = download_benchmark_assets("Launch_Vehicle_II", "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="Hammerhead Launch Vehicle")
geometry = project.geometry
geometry.group_faces_by_tag("faceId")
Meshing setup¶
Build the surface and volume meshing parameters. A global surface edge length and first-layer boundary-layer thickness set the baseline resolution, and two surface refinements tighten the mesh on the nose and body face groups where the shocks and geometry curvature are strongest. An automated far-field encloses the domain.
def build_meshing(farfield, geometry):
with fl.SI_unit_system:
return fl.MeshingParams(
defaults=fl.MeshingDefaults(
surface_max_edge_length=5 * fl.u.inch,
boundary_layer_first_layer_thickness=4e-5 * fl.u.inch,
boundary_layer_growth_rate=1.1,
curvature_resolution_angle=2 * fl.u.deg,
),
refinements=[
fl.SurfaceRefinement(
faces=[geometry[name] for name in NOSE_FACES],
curvature_resolution_angle=1 * fl.u.deg,
max_edge_length=0.35 * fl.u.inch,
),
fl.SurfaceRefinement(
faces=[geometry[name] for name in BODY_FACES],
curvature_resolution_angle=1 * fl.u.deg,
max_edge_length=1.5 * fl.u.inch,
),
],
volume_zones=[farfield],
)
Physics setup¶
The boundary conditions are a free-stream on the automated far-field and a viscous wall on the entire vehicle surface.
def build_models(farfield, geometry):
with fl.SI_unit_system:
return [
fl.Freestream(surfaces=farfield.farfield),
fl.Wall(surfaces=[geometry["*"]]),
]
Simulation Params¶
Assemble the full SimulationParams for one operating point. The operating
condition is set from Mach number with the measured free-stream temperature and
density at a fixed 2-degree incidence; a steady solve with an adaptive CFL runs up
to 4000 steps. The reference geometry uses the body diameter for moment length and
the corresponding frontal area for coefficient normalization.
def create_params(geometry, mach, temperature_r, density_kg_m3):
with fl.SI_unit_system:
farfield = fl.AutomatedFarfield()
return fl.SimulationParams(
meshing=build_meshing(farfield, geometry),
operating_condition=fl.AerospaceCondition.from_mach(
mach=mach,
alpha=ALPHA_DEG * fl.u.deg,
thermal_state=fl.ThermalState(
temperature=temperature_r * fl.u.R,
density=density_kg_m3 * fl.u.kg / fl.u.m**3,
),
),
time_stepping=fl.Steady(
max_steps=4000,
CFL=fl.AdaptiveCFL(convergence_limiting_factor=0.5),
),
models=build_models(farfield, geometry),
reference_geometry=fl.ReferenceGeometry(
moment_center=(0, 0, 0) * fl.u.m,
moment_length=(BODY_DIAMETER_INCH, BODY_DIAMETER_INCH, BODY_DIAMETER_INCH)
* fl.u.inch,
area=math.pi * (BODY_DIAMETER_INCH / 2) ** 2 * fl.u.inch**2,
),
)
Submit cases¶
Sweep the three supersonic operating points and submit each as a steady case to
Flow360 with the beta mesher. Each case is named
mach<mach>_alpha<alpha>_<solver_version>. We keep the submitted case objects so
the post-processing can read their results directly from this kernel.
submitted_cases = []
for condition in OPERATING_CONDITIONS:
mach = condition["mach"]
case = project.run_case(
name=f"mach{mach:.3f}_alpha{ALPHA_DEG:.1f}_{SOLVER_VERSION}",
params=create_params(geometry=geometry, **condition),
use_beta_mesher=True,
solver_version=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 meshes the geometry and runs a full steady supersonic solve.
for case in submitted_cases:
case.wait()
Postprocessing¶
The published results are two figures:
- Corrected axial-force coefficient:
CAvs Mach. - Normal-force coefficient:
CNvs Mach.
Both are compared against wind-tunnel data. The cells below read the results straight from the in-kernel case objects (no project is re-opened) and rebuild exactly these two figures.
Setup¶
Styling colors and the numeric helpers. average_coefficients reads a case's
integrated loads: the axial coefficient CA is the total CFx corrected by the
base-pressure contribution of the aft patches, and CN is the total CFz; both
are averaged over the last 10% of the convergence history. The base patches and
the reference-data path complete the setup.
FLOW360_COLOR = "#00643c" # Flow360 results
REFERENCE_COLOR = "black" # wind-tunnel reference
BASE_PATCHES = [
"body00001_face00001",
"body00001_face00012",
"body00001_face00023",
"body00001_face00034",
]
REFERENCE_DATA_PATH = Path("ref_data/exp_data.csv")
def scalar(value):
if hasattr(value, "item"):
try:
return float(value.item())
except ValueError:
pass
if isinstance(value, (list, tuple)):
return float(value[0])
return float(value)
def mach_key(value):
return f"{float(value):.3f}"
def average_coefficients(case):
total_loads = case.results.total_forces.as_dataframe()
surface_loads = case.results.surface_forces.as_dataframe()
base_pressure_force = surface_loads[
[f"farfield/{patch}_CFx" for patch in BASE_PATCHES]
].sum(axis=1)
averaging_window = max(1, int(len(total_loads) * 0.1))
ca = total_loads["CFx"] - base_pressure_force
cn = total_loads["CFz"]
return {
"mach": scalar(case.params.operating_condition.mach),
"avg_ca": ca.iloc[-averaging_window:].mean(),
"avg_cn": cn.iloc[-averaging_window:].mean(),
}
def plot_coefficient(plot_df, ylabel, ylim, path):
plt.figure(figsize=(7, 5))
plt.plot(plot_df["mach"], plot_df["flow360"], color=FLOW360_COLOR, marker="o", label="Flow360")
plt.plot(
plot_df["mach"],
plot_df["reference"],
color=REFERENCE_COLOR,
linestyle="None",
marker="x",
label="Experiment",
)
plt.xlabel("Mach")
plt.ylabel(ylabel)
plt.ylim(*ylim)
plt.grid(True)
plt.xticks(plot_df["mach"])
plt.legend()
plt.tight_layout()
plt.savefig(path, dpi=400)
plt.show()
Load the swept-case results¶
For each finished case, average the corrected CA and CN over the settled tail
of the run, then merge with the wind-tunnel reference on the Mach number so the two
series are directly comparable.
os.makedirs("results", exist_ok=True)
results_df = pd.DataFrame(
average_coefficients(case) for case in submitted_cases
).sort_values("mach")
reference_df = pd.read_csv(REFERENCE_DATA_PATH).sort_values("mach")
results_df["mach_key"] = results_df["mach"].map(mach_key)
reference_df["mach_key"] = reference_df["mach"].map(mach_key)
merged = results_df.merge(reference_df.drop(columns=["mach"]), on="mach_key", how="left")
if merged[["exp_ca", "exp_cn"]].isna().any().any():
raise RuntimeError("Reference data merge failed for one or more Mach points.")
merged = merged.drop(columns=["mach_key"])
Corrected axial-force coefficient vs Mach¶
Plot the base-pressure-corrected axial-force coefficient against Mach number, overlaid with the wind-tunnel data.
plot_coefficient(
merged[["mach", "avg_ca", "exp_ca"]].rename(
columns={"avg_ca": "flow360", "exp_ca": "reference"}
),
"CA",
(0, 1),
"results/ca_vs_mach.png",
)
Normal-force coefficient vs Mach¶
Plot the normal-force coefficient against Mach number, overlaid with the wind-tunnel data.
plot_coefficient(
merged[["mach", "avg_cn", "exp_cn"]].rename(
columns={"avg_cn": "flow360", "exp_cn": "reference"}
),
"CN",
(0, 0.3),
"results/cn_vs_mach.png",
)