XV-15 Rotor Hover Validation¶
A multifidelity assessment of the XV-15 tiltrotor in hover, one of the most demanding rotor operating conditions. The rotor is widely documented experimentally, which makes it a strong validation case for rotor-performance prediction.
This notebook reproduces the benchmark end to end across five collective blade pitch settings (0, 3, 5, 10, 13 degrees) using three fidelity levels:
- BET disk — a steady blade-element-theory actuator disk (k-omega SST).
- BET line — an unsteady blade-element-theory line model (k-omega SST DDES), forked from the converged BET-disk solution.
- DDES — a fully unsteady blade-resolved Spalart-Allmaras DDES with a rotating region.
Thrust coefficient (CT), torque coefficient (CQ) and figure of merit (FoM) are gathered for every fidelity level and compared against experimental data. The notebook runs top to bottom in a single kernel: it submits every case, waits for completion, then post-processes 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 few plotting/data packages:
pip install numpy pandas matplotlib
Run the cells top to bottom in a single kernel.
Imports¶
Everything the study needs comes from flow360 plus the standard scientific
stack (numpy, pandas, matplotlib). download_benchmark_assets fetches the
public example assets (meshes, BET tables, reference data) so the notebook is
fully self-contained.
import os
import re
from pathlib import Path
import flow360 as fl
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from flow360.component.simulation.migration import BETDisk
from flow360.examples import download_benchmark_assets
Shared configuration¶
Solver version and the rotor kinematics shared by both studies. The rotor speed
OMEGA_RPM is derived from the tip Mach number and rotor radius and drives the
unsteady time-step sizing.
SOLVER_VERSION = os.environ.get("SOLVER_VERSION_OVERRIDE", "release-25.8")
# Working directory: benchmark assets are downloaded here (relative paths below).
SCRIPT_DIR = Path(".")
TIP_MACH = 0.691
SPEED_OF_SOUND = 340.3
ROTOR_RADIUS_M = 150 * 0.0254
OMEGA_RPM = SPEED_OF_SOUND * TIP_MACH / ROTOR_RADIUS_M * 60 / (2 * np.pi)
Input data¶
Fetch the on-disk input files the study reads: the BET blade-loading tables
(BET_JSONS/) consumed by the BET disk and line models, and the experimental
reference curves (ref_data/) used in post-processing. These land in the working
directory where the code below expects them.
download_benchmark_assets("XV15", "BET_JSONS")
download_benchmark_assets("XV15", "ref_data")
BET study — load, setup, submit¶
The blade-element-theory study loads a single shared XV-15 volume mesh once and runs two cases per pitch setting:
- a steady BET disk case (k-omega SST, adaptive CFL), then
- an unsteady BET line case (k-omega SST DDES) forked from the converged steady solution.
Ten cases in total (5 pitches x 2 models). The mesh is fetched from a public snapshot; the identifier below is a public example key, not a private handle.
# BET root mesh: public example snapshot key for the shared XV-15 volume mesh.
BET_PROJECT_ID = "prj-c018ee24-8484-4d2e-b34e-a87721162bbb"
# One BET blade-loading table per collective pitch setting (the matching
# "_line.json" table is used for the unsteady BET line case).
BET_JSON_FILES = [
"BET_JSONS/Flow360_hover_pitch0.json",
"BET_JSONS/Flow360_hover_pitch3.json",
"BET_JSONS/Flow360_hover_pitch5.json",
"BET_JSONS/Flow360_hover_pitch10.json",
"BET_JSONS/Flow360_hover_pitch13.json",
]
BET_TIME_STEP_DEG = 3
Simulation parameters for a BET case. The unsteady flag switches between the
steady BET-disk setup (adaptive CFL, 10000 steps) and the unsteady BET-line setup
(DDES hybrid model, MUSCL/dissipation tuning). Both share the reference geometry,
hover operating condition, and volume/slice outputs.
def create_bet_params(project, bet_model, unsteady, step_size):
with fl.SI_unit_system:
volume_mesh = project.volume_mesh
if not unsteady:
time_stepping = fl.Steady(
CFL=fl.AdaptiveCFL(convergence_limiting_factor=0.7),
max_steps=10000,
)
turbulence_model_solver = fl.KOmegaSST(absolute_tolerance=1e-8)
navier_stokes_solver = fl.NavierStokesSolver(absolute_tolerance=1e-10)
else:
time_stepping = fl.Unsteady(
CFL=fl.AdaptiveCFL(),
step_size=step_size,
max_pseudo_steps=50,
steps=120 * 25,
)
turbulence_model_solver = fl.KOmegaSST(
absolute_tolerance=1e-8,
relative_tolerance=1e-2,
hybrid_model=fl.DetachedEddySimulation(shielding_function="DDES"),
)
navier_stokes_solver = fl.NavierStokesSolver(
absolute_tolerance=1e-10,
relative_tolerance=1e-2,
kappa_MUSCL=-0.33,
numerical_dissipation_factor=0.2,
)
return fl.SimulationParams(
reference_geometry=fl.ReferenceGeometry(
area=45.604,
moment_center=[0, 0, 0],
moment_length=[3.81, 3.81, 3.81],
),
operating_condition=fl.AerospaceCondition.from_mach(
mach=0,
reference_mach=0.69,
),
time_stepping=time_stepping,
models=[
fl.Fluid(
turbulence_model_solver=turbulence_model_solver,
navier_stokes_solver=navier_stokes_solver,
),
fl.Freestream(surfaces=volume_mesh["1"]),
bet_model,
],
outputs=[
fl.VolumeOutput(
output_format="both",
output_fields=["primitiveVars", "Mach", "qcriterion"],
),
fl.SliceOutput(
slices=[
fl.Slice(name="slicex", normal=(1, 0, 0), origin=(0, 0, 0)),
fl.Slice(name="slicey", normal=(0, 1, 0), origin=(0, 0, 0)),
fl.Slice(name="slicez", normal=(0, 0, 1), origin=(0, 0, 0)),
],
output_format="tecplot",
output_fields=["primitiveVars"],
),
],
)
Submit the BET sweep. For each pitch the BET-disk table is read and run steadily, then the BET-line table is read and run unsteadily as a fork of the steady case.
bet_root_assets = download_benchmark_assets("XV15", "root_assets", BET_PROJECT_ID)
bet_volume_mesh_file = next(
f for f in bet_root_assets if f.removesuffix(".zst").endswith((".cgns", ".ugrid"))
)
bet_project = fl.Project.from_volume_mesh(
bet_volume_mesh_file, name="XV-15 Rotor Hover Validation"
)
bet_step_size = BET_TIME_STEP_DEG / (6 * OMEGA_RPM)
bet_cases = []
for json_rel_path in BET_JSON_FILES:
json_path = SCRIPT_DIR / json_rel_path
pitch_tag = json_path.stem.replace("Flow360_hover_", "")
# Steady BET disk
disk_model = BETDisk.read_single_v1_BETDisk(
file_path=str(json_path),
mesh_unit=fl.u.inch,
freestream_temperature=288.15 * fl.u.K,
)
disk_params = create_bet_params(
project=bet_project,
bet_model=disk_model,
unsteady=False,
step_size=bet_step_size,
)
disk_params.models[2].omega = OMEGA_RPM * fl.u.rpm
steady_case = bet_project.run_case(
params=disk_params,
name=f"bet_disk_{pitch_tag}_{SOLVER_VERSION}",
solver_version=SOLVER_VERSION,
tags=[SOLVER_VERSION, "XV15", "BET"],
)
bet_cases.append(steady_case)
# Unsteady BET line, forked from the converged steady solution
line_model = BETDisk.read_single_v1_BETDisk(
file_path=str(json_path).replace(".json", "_line.json"),
mesh_unit=fl.u.inch,
freestream_temperature=288.15 * fl.u.K,
)
line_params = create_bet_params(
project=bet_project,
bet_model=line_model,
unsteady=True,
step_size=bet_step_size,
)
line_params.models[2].omega = OMEGA_RPM * fl.u.rpm
line_case = bet_project.run_case(
params=line_params,
name=f"bet_line_{pitch_tag}_{SOLVER_VERSION}",
fork_from=steady_case,
solver_version=SOLVER_VERSION,
tags=[SOLVER_VERSION, "XV15", "BET"],
)
bet_cases.append(line_case)
print(f"Submitted {len(bet_cases)} BET cases")
DDES study — load, setup, submit¶
The blade-resolved DDES study runs one case per collective pitch. Each pitch has its own volume mesh, so the loop loads a separate public mesh snapshot for each pitch, sets up a rotating region (Spalart-Allmaras DDES with rotation correction), and submits an unsteady case (6 degrees of azimuth per step, 600 steps).
# DDES root meshes: one public example snapshot key per collective pitch.
DDES_PROJECT_IDS = {
"pitch_0": "prj-885e6a13-b04e-4657-b9ee-de7f4ed3563d",
"pitch_3": "prj-236024fd-72db-4bfe-bd29-1b87284df11e",
"pitch_5": "prj-ae900e6c-ab8b-4213-ae5b-2162cfe9fb8d",
"pitch_10": "prj-369b54b4-65cd-4414-a372-808a326b8120",
"pitch_13": "prj-4a4e5639-2f6a-4de1-b21b-6d064424add7",
}
DDES_TIME_STEP_DEG = 6
DDES_STEPS = 60 * 10
Simulation parameters for a DDES case: a rotating region about the (0, 0, -1) axis, Spalart-Allmaras DDES with rotation correction, and volume + surface outputs on the blade.
def create_ddes_params(volume_mesh, kappa_muscl, time_step_deg, steps, omega_rpm):
with fl.SI_unit_system:
rotation_zone = volume_mesh["rotationField"]
rotation_zone.center = (0, 0, 0) * fl.u.m
rotation_zone.axis = (0, 0, -1)
return fl.SimulationParams(
reference_geometry=fl.ReferenceGeometry(
area=45.604,
moment_center=[0, 0, 0],
moment_length=[3.81, 3.81, 3.81],
),
operating_condition=fl.AerospaceCondition.from_mach(
mach=0,
reference_mach=0.691,
),
time_stepping=fl.Unsteady(
CFL=fl.AdaptiveCFL(),
step_size=time_step_deg / (6 * omega_rpm),
max_pseudo_steps=50,
steps=steps,
),
models=[
fl.Fluid(
turbulence_model_solver=fl.SpalartAllmaras(
hybrid_model=fl.DetachedEddySimulation(shielding_function="DDES"),
rotation_correction=True,
relative_tolerance=1e-2,
),
navier_stokes_solver=fl.NavierStokesSolver(
absolute_tolerance=1e-10,
relative_tolerance=1e-2,
kappa_MUSCL=kappa_muscl,
),
),
fl.Freestream(surfaces=volume_mesh["stationaryField/farfield"]),
fl.Rotation(
name="Rotating Region",
volumes=rotation_zone,
spec=fl.AngularVelocity(omega_rpm * fl.u.rpm),
),
fl.Wall(surfaces=volume_mesh["rotationField/blade"]),
],
outputs=[
fl.VolumeOutput(
output_format="both",
output_fields=["primitiveVars", "Mach", "qcriterion"],
),
fl.SurfaceOutput(
output_fields=["primitiveVars", "Cp", "Cf", "yPlus"],
surfaces=[volume_mesh["*"]],
output_format="both",
),
],
)
Submit the DDES sweep — one case per pitch, each on its own mesh.
ddes_cases = []
for pitch_tag, project_id in DDES_PROJECT_IDS.items():
ddes_root_assets = download_benchmark_assets("XV15", "root_assets", project_id)
ddes_volume_mesh_file = next(
f for f in ddes_root_assets if f.removesuffix(".zst").endswith((".cgns", ".ugrid"))
)
project = fl.Project.from_volume_mesh(
ddes_volume_mesh_file, name="XV-15 Rotor Hover Validation"
)
params = create_ddes_params(
volume_mesh=project.volume_mesh,
kappa_muscl=-1,
time_step_deg=DDES_TIME_STEP_DEG,
steps=DDES_STEPS,
omega_rpm=OMEGA_RPM,
)
case = project.run_case(
params=params,
name=f"ddes_{pitch_tag}_{SOLVER_VERSION}",
solver_version=SOLVER_VERSION,
tags=[SOLVER_VERSION, "XV15", "DDES"],
)
ddes_cases.append(case)
print(f"Submitted {len(ddes_cases)} DDES cases")
Wait for completion¶
Block until every submitted case has finished. This can take a long time (the unsteady BET-line and DDES cases are the expensive ones).
submitted_cases = bet_cases + ddes_cases
for case in submitted_cases:
case.wait()
Postprocessing¶
Gather CT, CQ and figure of merit from the completed BET-disk, BET-line and DDES cases (reusing the in-kernel case objects), read the experimental reference curves, and produce the two published figures:
- CT vs CQ across BET disk, BET line, DDES, and experiment.
- CT vs FoM across BET disk, BET line, DDES, and experiment.
Post-processing constants and per-series plot styling. The non-dimensionalization uses the (different) reference rotor speeds for the BET and DDES studies.
FLOW360_COLOR = "#00643c"
REFERENCE_COLOR = "black"
RESULTS_DIR = SCRIPT_DIR / "results"
CQ_REFERENCE_DATA_PATH = SCRIPT_DIR / "ref_data" / "exp_data.csv"
FOM_REFERENCE_DATA_PATH = SCRIPT_DIR / "ref_data" / "fom_data.csv"
RHO = 1.225
SPEED_OF_SOUND = 340.3
GRID_LENGTH_M = 0.0254
ROTOR_RADIUS_M = 150 * GRID_LENGTH_M
ROTOR_AREA_M2 = np.pi * ROTOR_RADIUS_M**2
BET_OMEGA_RAD_S = 61.6237
DDES_REFERENCE_MACH = 0.691
DDES_REFERENCE_VELOCITY = DDES_REFERENCE_MACH * SPEED_OF_SOUND
DDES_OMEGA_RAD_S = DDES_REFERENCE_VELOCITY / ROTOR_RADIUS_M
SERIES_STYLES = {
"BET Disk": {"color": FLOW360_COLOR, "linestyle": "-", "marker": "o"},
"BET Line": {"color": "tab:orange", "linestyle": "--", "marker": "s"},
"DDES": {"color": "tab:blue", "linestyle": "-.", "marker": "^"},
}
Helpers that turn a completed case into a CT/CQ/FoM row. BET forces come from the BET-disk/line force output (time-averaged over the final rotor revolution for the unsteady line case); DDES forces come from the integrated total-force history.
def extract_pitch(case_name):
match = re.search(r"pitch[_ ]?(\d+)", case_name)
return int(match.group(1)) if match else -1
def compute_fom(ct, cq):
return (ct**1.5) / (np.sqrt(2) * cq)
def get_bet_forces(case):
if "bet_line_" in case.name.lower():
bet_forces = case.results.bet_forces.as_dataframe()
step_col = bet_forces.columns[0]
pseudo_col = bet_forces.columns[1]
last_pseudo = (
bet_forces.sort_values([step_col, pseudo_col]).groupby(step_col).tail(1)
)
last_samples = last_pseudo.tail(120)
disk_moment_z = float(last_samples["Disk0_Moment_z"].mean())
disk_force_z = float(last_samples["Disk0_Force_z"].mean())
else:
bet_forces = case.results.bet_forces.get_averages(1 / 25)
disk_moment_z = float(bet_forces["Disk0_Moment_z"])
disk_force_z = float(bet_forces["Disk0_Force_z"])
return disk_moment_z, disk_force_z
def build_bet_row(case):
disk_moment_z, disk_force_z = get_bet_forces(case)
torque = disk_moment_z * RHO * (SPEED_OF_SOUND**2) * (GRID_LENGTH_M**3)
thrust = disk_force_z * RHO * (SPEED_OF_SOUND**2) * (GRID_LENGTH_M**2)
cq = torque / (RHO * ((BET_OMEGA_RAD_S * ROTOR_RADIUS_M) ** 2) * ROTOR_AREA_M2 * ROTOR_RADIUS_M)
ct = thrust / (RHO * ((BET_OMEGA_RAD_S * ROTOR_RADIUS_M) ** 2) * ROTOR_AREA_M2)
return {
"series": "BET Line" if "bet_line_" in case.name.lower() else "BET Disk",
"case_name": case.name,
"pitch_deg": extract_pitch(case.name),
"CT": ct,
"CQ": cq,
"FoM": compute_fom(ct, cq),
}
def get_ddes_forces(case):
total_forces = case.results.total_forces.as_dataframe()
step_col = total_forces.columns[0]
pseudo_col = total_forces.columns[1]
last_pseudo = (
total_forces.sort_values([step_col, pseudo_col]).groupby(step_col).tail(1)
)
last_rotation = last_pseudo.tail(60)
cmz = float(last_rotation["CMz"].mean())
cfz = float(last_rotation["CFz"].mean())
return cmz, cfz
def build_ddes_row(case):
cmz, cfz = get_ddes_forces(case)
thrust = cfz * (0.5 * RHO * (DDES_REFERENCE_VELOCITY**2) * ROTOR_AREA_M2)
torque = cmz * (0.5 * RHO * (DDES_REFERENCE_VELOCITY**2) * ROTOR_AREA_M2 * ROTOR_RADIUS_M)
cq = torque / (RHO * ((DDES_OMEGA_RAD_S * ROTOR_RADIUS_M) ** 2) * ROTOR_AREA_M2 * ROTOR_RADIUS_M)
ct = thrust / (RHO * ((DDES_OMEGA_RAD_S * ROTOR_RADIUS_M) ** 2) * ROTOR_AREA_M2)
return {
"series": "DDES",
"case_name": case.name,
"pitch_deg": extract_pitch(case.name),
"CT": ct,
"CQ": cq,
"FoM": compute_fom(ct, cq),
}
def load_ct_cq_experimental_data():
exp_df = pd.read_csv(CQ_REFERENCE_DATA_PATH, header=None, names=["CT_raw", "CQ_raw"])
exp_df["CT"] = pd.to_numeric(exp_df["CT_raw"], errors="coerce") * 0.089
exp_df["CQ"] = pd.to_numeric(exp_df["CQ_raw"], errors="coerce") * 0.089
exp_df = exp_df.dropna(subset=["CT", "CQ"]).copy()
return exp_df[["CT", "CQ"]]
def load_ct_fom_experimental_data():
exp_df = pd.read_csv(FOM_REFERENCE_DATA_PATH, header=None, names=["CT", "FoM"])
exp_df["CT"] = pd.to_numeric(exp_df["CT"], errors="coerce")
exp_df["FoM"] = pd.to_numeric(exp_df["FoM"], errors="coerce")
exp_df = exp_df.dropna(subset=["CT", "FoM"]).copy()
return exp_df[["CT", "FoM"]]
def plot_ct_comparison(sim_df, exp_df, y_col, output_name):
fig, ax = plt.subplots(figsize=(7, 5.5))
for series_name, style in SERIES_STYLES.items():
series_df = sim_df[sim_df["series"] == series_name].sort_values("pitch_deg")
if series_df.empty:
continue
ax.plot(
series_df["CT"],
series_df[y_col],
color=style["color"],
linestyle=style["linestyle"],
marker=style["marker"],
linewidth=2,
markersize=6,
label=series_name,
)
ax.scatter(
exp_df["CT"],
exp_df[y_col],
color=REFERENCE_COLOR,
marker="x",
s=35,
label="Experiment",
zorder=5,
)
ax.set_xlabel("CT")
ax.set_ylabel(y_col)
ax.grid(True, linestyle="--", alpha=0.4)
ax.legend()
fig.tight_layout()
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
fig.savefig(RESULTS_DIR / output_name, dpi=200)
plt.show()
Assemble the simulation table from the completed cases and load the experimental references.
sim_rows = []
for case in submitted_cases:
case_name = (case.name or "").lower()
if case_name.startswith("bet_"):
sim_rows.append(build_bet_row(case))
elif case_name.startswith("ddes_"):
sim_rows.append(build_ddes_row(case))
sim_df = pd.DataFrame(sim_rows).sort_values(["series", "pitch_deg"]).reset_index(drop=True)
exp_ct_cq_df = load_ct_cq_experimental_data()
exp_ct_fom_df = load_ct_fom_experimental_data()
sim_df
CT vs CQ¶
Torque coefficient against thrust coefficient across the three fidelity levels and experiment.
plot_ct_comparison(sim_df=sim_df, exp_df=exp_ct_cq_df, y_col="CQ", output_name="DDES_BET.png")
CT vs FoM¶
Figure of merit against thrust coefficient across the three fidelity levels and experiment.
plot_ct_comparison(sim_df=sim_df, exp_df=exp_ct_fom_df, y_col="FoM", output_name="CT_FoM.png")