HVAB Rotor Hover Validation¶
The Hover Validation and Acoustic Baseline (HVAB) rotor is a four-bladed, 11.08 ft diameter rotor tested in hover at NASA Ames. This notebook reproduces the Flow360 benchmark at five collective-pitch settings (4°, 6°, 8°, 10°, and 12°) and compares figure of merit against the experimental measurements and published Helios predictions.
Every collective uses an unsteady k-ω SST ZDES fork chain at 1250.39 RPM. The 4°-10° cases use a coarser 120-million-node mesh and run 15 revolutions at 6° per time step followed by 5 revolutions at 1° per time step. The 12° case uses a finer 200-million-node mesh and runs 15 revolutions at 6° per time step followed by 2 revolutions at 0.5° per time step. Blade collective, lag, and coning are applied through coordinate-system transformations before Geometry AI meshes the four imported blade CAD files.
The notebook runs top to bottom in one kernel: fetch the public inputs, create the geometry project, submit the sweep, wait for completion, and post-process the final stage of each chain into the published figure-of-merit curve.
Requirements¶
This notebook runs against the Flow360 Python API. Install it and configure your API key using the installation and setup guide.
The notebook targets the latest Flow360 client and uses the public benchmark asset helper introduced in 25.10.4:
pip install "flow360>=25.10.4"
Install the additional plotting and data packages:
pip install matplotlib pandas
Run the cells from top to bottom in a single kernel. The full sweep generates surface and volume meshes and submits ten unsteady cases, so it can take a long time and incur substantial cloud cost.
Imports¶
import math
import os
import re
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 and geometry¶
Download the four blade CAD files and the reference performance data from the public benchmark store. A fresh project is created from the CAD assembly, and body groups retain their source filenames so each blade can receive its own coordinate-system transformation.
BLADE_CAD_FILE_NAMES = [
"HVAB_BladeOML.egads",
"HVAB_BladeOML2.egads",
"HVAB_BladeOML3.egads",
"HVAB_BladeOML4.egads",
]
download_benchmark_assets("HVAB", "ref_data")
root_asset_files = download_benchmark_assets("HVAB", "root_assets")
geometry_files = [
next(path for path in root_asset_files if Path(path).name == file_name)
for file_name in BLADE_CAD_FILE_NAMES
]
project = fl.Project.from_geometry(geometry_files, name="HVAB Rotor Hover Validation")
geometry = project.geometry
geometry.group_faces_by_tag("groupByBodyId")
geometry.group_bodies_by_tag("groupByFile")
Study configuration¶
The mesh resolution, rotor operating point, collective sweep, blade deflections, and stage schedule match the benchmark run script. The 4°-10° cases use the coarser, approximately 120-million-node mesh family, built with a 540-cell circumferential target and 800,000 target surface nodes; their second stage uses 1° per time step. The 12° case uses the finer, approximately 200-million-node mesh family, built with a 720-cell circumferential target and one million target surface nodes; its second stage uses 0.5° per time step.
SOLVER_VERSION = os.environ.get("SOLVER_VERSION_OVERRIDE", "release-25.11")
SURFACE_MAX_EDGE_LENGTH = 5 * fl.u.mm
SURFACE_RESOLUTION_ANGLE = 12 * fl.u.deg
BOUNDARY_LAYER_FIRST_LAYER_THICKNESS = 2e-6 * fl.u.m
BOUNDARY_LAYER_GROWTH_RATE = 1.15
GEOMETRY_ACCURACY = 0.0001 * fl.u.m
COLLECTIVES = [angle * fl.u.deg for angle in (4, 6, 8, 10, 12)]
BLADE_DEFLECTION_DEGREES = {
4.0: {"cone": 0.0, "lag": 0.0},
6.0: {"cone": 0.4, "lag": 1.9},
8.0: {"cone": 0.8, "lag": 2.5},
10.0: {"cone": 1.3, "lag": 3.4},
12.0: {"cone": 0.0, "lag": 0.0},
}
THERMAL_STATE = fl.ThermalState.from_standard_atmosphere(altitude=0 * fl.u.m)
HVAB_DIAMETER = 11.08 * fl.u.ft
RPM = 1250.39 * fl.u.rpm
TIP_VELOCITY = (
HVAB_DIAMETER.value / 2 * 2 * math.pi * RPM.value / 60
) * fl.u.ft / fl.u.s
HVAB_PERIMETER = math.pi * HVAB_DIAMETER
CIRCUMFERENTIAL_REFINEMENT = HVAB_PERIMETER / 720
OTHER_COLLECTIVE_REFINEMENT = HVAB_PERIMETER / 540
DESIRED_SURFACE_NODE_COUNT = 1e6
OTHER_COLLECTIVE_SURFACE_NODE_COUNT = 0.8e6
STAGES_12_DEG = [
(6, 15, 15),
(0.5, 2, 20),
]
STAGES_OTHER_COLLECTIVES = [
(6, 15, 15),
(1, 5, 20),
]
SURFACE_FIELDS = ["Cp", "Cf", "CfVec", "yPlus"]
SLICE_FIELDS = ["Mach", "Cp", "primitiveVars", "mut", "mutRatio"]
Small helpers convert the azimuthal resolution and requested rotor revolutions into physical steps and dimensional time-step size. Surface averaging starts one revolution before the end of each stage.
def deflection_angles(current_collective):
collective_degrees = round(float(current_collective.value), 2)
deflection = BLADE_DEFLECTION_DEGREES[collective_degrees]
return deflection["cone"] * fl.u.deg, deflection["lag"] * fl.u.deg
def num_steps(deg_per_time_step, revolutions):
return int(360 * revolutions / deg_per_time_step)
def time_step(deg_per_time_step):
return deg_per_time_step / (abs(RPM.value) * 360 / 60) * fl.u.s
def averaging_start_step(deg_per_time_step, revolutions, previous_steps=0):
return (
previous_steps
+ num_steps(deg_per_time_step, revolutions)
- num_steps(deg_per_time_step, 1)
)
Meshing regions¶
A cylindrical rotating volume encloses the rotor. Nested cylindrical refinements resolve the blade tips and near wake, while a larger downstream cylinder resolves the far wake.
rotation_interface = fl.Cylinder(
name="rotationInterface",
center=(0, 0, 0) * fl.u.m,
axis=(0, 0, 1),
outer_radius=HVAB_DIAMETER / 2 * 1.1,
height=12 * fl.u.inch,
)
prop_near_refinement = fl.Cylinder(
name="Prop_Near_Wake_Refinement_2",
center=(0, 0, 0) * fl.u.m,
axis=(0, 0, 1),
outer_radius=HVAB_DIAMETER / 2,
height=12 * fl.u.inch * 1.1,
)
blade_tip = fl.Cylinder(
name="BladeTip_025",
center=(0, 0, -12 * 1.1) * fl.u.inch,
axis=(0, 0, 1),
inner_radius=HVAB_DIAMETER / 2 * 0.75,
outer_radius=HVAB_DIAMETER / 2 * 1.25,
height=12 * fl.u.inch * 1.1 * 3,
)
blade_tip_inner = fl.Cylinder(
name="BladeTip_01",
center=(0, 0, -12 * 1.1 / 2) * fl.u.inch,
axis=(0, 0, 1),
inner_radius=HVAB_DIAMETER / 2 * 0.9,
outer_radius=HVAB_DIAMETER / 2 * 1.1,
height=12 * fl.u.inch * 1.1 * 2,
)
prop_far_refinement = fl.Cylinder(
name="Prop_Far_Wake_Refinement",
center=(0, 0, -6) * fl.u.ft,
axis=(0, 0, 1),
outer_radius=HVAB_DIAMETER / 2 * 1.5,
height=15 * fl.u.ft,
)
Simulation parameters¶
Each stage uses the same hover operating condition and rotating volume. The fluid model is k-ω SST with rotation correction and ZDES shielding. The second 12° stage also sets kappa MUSCL to -0.33, matching the benchmark script.
def create_meshing_run_params(
draft,
deg_per_time_step,
revolutions,
mesh_refinement,
surface_node_count,
max_pseudo_steps,
previous_steps=0,
use_kappa=False,
):
with fl.SI_unit_system:
farfield = fl.AutomatedFarfield()
params = fl.SimulationParams(
meshing=fl.MeshingParams(
defaults=fl.MeshingDefaults(
geometry_accuracy=GEOMETRY_ACCURACY,
surface_max_edge_length=SURFACE_MAX_EDGE_LENGTH,
curvature_resolution_angle=SURFACE_RESOLUTION_ANGLE,
boundary_layer_first_layer_thickness=(
BOUNDARY_LAYER_FIRST_LAYER_THICKNESS
),
boundary_layer_growth_rate=BOUNDARY_LAYER_GROWTH_RATE,
preserve_thin_geometry=True,
remove_hidden_geometry=True,
remove_baffle_faces=True,
min_passage_size=10 * fl.u.mm,
target_surface_node_count=surface_node_count,
octree_spacing=fl.OctreeSpacing(base_spacing=mesh_refinement),
),
volume_zones=[
farfield,
fl.RotationVolume(
name="rotation_interface",
entities=[rotation_interface],
enclosed_entities=draft.surfaces["*"],
spacing_axial=mesh_refinement / 4,
spacing_radial=mesh_refinement / 4,
spacing_circumferential=mesh_refinement / 4,
),
],
refinements=[
fl.UniformRefinement(
name="Prop_Near_Refinement",
entities=[prop_near_refinement],
spacing=mesh_refinement,
),
fl.UniformRefinement(
name="donut",
entities=[blade_tip],
spacing=mesh_refinement / 2,
),
fl.UniformRefinement(
name="mini_donut",
entities=[blade_tip_inner],
spacing=mesh_refinement / 4,
),
fl.UniformRefinement(
name="Prop_Far_Refinement",
entities=[prop_far_refinement],
spacing=mesh_refinement * 2,
),
],
),
reference_geometry=fl.ReferenceGeometry(
moment_center=(0, 0, 0) * fl.u.ft,
moment_length=(
(HVAB_DIAMETER / 2).value,
(HVAB_DIAMETER / 2).value,
(HVAB_DIAMETER / 2).value,
)
* fl.u.ft,
area=math.pi * (HVAB_DIAMETER / 2) ** 2,
),
operating_condition=fl.AerospaceCondition(
velocity_magnitude=0 * fl.u.m / fl.u.s,
reference_velocity_magnitude=TIP_VELOCITY,
alpha=0 * fl.u.deg,
thermal_state=THERMAL_STATE,
),
time_stepping=fl.Unsteady(
step_size=time_step(deg_per_time_step),
max_pseudo_steps=max_pseudo_steps,
steps=num_steps(deg_per_time_step, revolutions),
CFL=fl.AdaptiveCFL(convergence_limiting_factor=0.75),
),
models=[
fl.Fluid(
navier_stokes_solver=fl.NavierStokesSolver(
relative_tolerance=1e-2,
numerical_dissipation_factor=0.2,
**({"kappa_MUSCL": -0.33} if use_kappa else {}),
),
turbulence_model_solver=fl.KOmegaSST(
relative_tolerance=1e-2,
rotation_correction=True,
hybrid_model=fl.DetachedEddySimulation(
shielding_function="ZDES"
),
),
),
fl.Wall(surfaces=[draft.surfaces["*"]], name="blades"),
fl.Freestream(surfaces=farfield.farfield, name="Freestream"),
fl.Rotation(
name="HVAB_prop",
volumes=rotation_interface,
spec=fl.AngularVelocity(RPM),
),
],
outputs=[
fl.TimeAverageSurfaceOutput(
output_fields=SURFACE_FIELDS,
output_format="both",
start_step=averaging_start_step(
deg_per_time_step, revolutions, previous_steps
),
frequency=-1,
surfaces=[draft.surfaces["*"]],
),
fl.SliceOutput(
slices=[
fl.Slice(
name="slice_x",
normal=(1, 0, 0),
origin=(0, 0, 0) * fl.u.m,
),
fl.Slice(
name="slice_y",
normal=(0, 1, 0),
origin=(0, 0, 0) * fl.u.m,
),
fl.Slice(
name="slice_z",
normal=(0, 0, 1),
origin=(0, 0, 0) * fl.u.m,
),
],
output_fields=SLICE_FIELDS,
output_format="both",
),
],
)
params.models[0].navier_stokes_solver.limit_pressure_density = True
params.models[0].navier_stokes_solver.limit_velocity = True
return params
Submit the collective sweep¶
For each collective, the four blades are rotated into their azimuthal positions and receive the prescribed lag, coning, and collective transforms. Geometry AI then generates the surface and volume meshes. Stage 2 forks from stage 1 so the fine-azimuth solution continues from the developed hover flow.
submitted_cases = []
for current_collective in COLLECTIVES:
cone_angle, lag_angle = deflection_angles(current_collective)
is_12_deg = float(current_collective.value) == 12
mesh_refinement = (
CIRCUMFERENTIAL_REFINEMENT
if is_12_deg
else OTHER_COLLECTIVE_REFINEMENT
)
surface_node_count = (
DESIRED_SURFACE_NODE_COUNT
if is_12_deg
else OTHER_COLLECTIVE_SURFACE_NODE_COUNT
)
run_stages = STAGES_12_DEG if is_12_deg else STAGES_OTHER_COLLECTIVES
with fl.create_draft(
new_run_from=geometry,
face_grouping="groupByBodyId",
) as draft:
for blade_number, (blade_name, blade_rotation) in enumerate(
zip(BLADE_CAD_FILE_NAMES, [0, 90, 180, 270]),
start=1,
):
parent = None
if blade_rotation:
parent = fl.CoordinateSystem(
name=f"blade_rotate_{blade_rotation}",
axis_of_rotation=(0, 0, 1),
angle_of_rotation=blade_rotation * fl.u.deg,
)
lag_system = fl.CoordinateSystem(
name=f"blade_lag_rotate{blade_number}",
axis_of_rotation=(0, 0, 1),
angle_of_rotation=lag_angle,
)
cone_system = fl.CoordinateSystem(
name=f"blade_cone_rotate{blade_number}",
axis_of_rotation=(0, -1, 0),
angle_of_rotation=cone_angle,
)
collective_system = fl.CoordinateSystem(
name=(
f"blade_collective_rotate"
f"{blade_number if blade_number > 1 else ''}"
),
axis_of_rotation=(1, 0, 0),
angle_of_rotation=current_collective,
)
if parent:
draft.coordinate_systems.add(
coordinate_system=lag_system,
parent=parent,
)
else:
draft.coordinate_systems.add(coordinate_system=lag_system)
draft.coordinate_systems.add(
coordinate_system=cone_system,
parent=lag_system,
)
draft.coordinate_systems.add(
coordinate_system=collective_system,
parent=cone_system,
)
draft.coordinate_systems.assign(
entities=draft.body_groups[blade_name],
coordinate_system=collective_system,
)
first_stage = run_stages[0]
params = create_meshing_run_params(
draft=draft,
deg_per_time_step=first_stage[0],
revolutions=first_stage[1],
mesh_refinement=mesh_refinement,
surface_node_count=surface_node_count,
max_pseudo_steps=first_stage[2],
)
with fl.warning_bypass("potential_length_scale_mismatch"):
project.generate_surface_mesh(
params=params,
use_geometry_AI=True,
use_beta_mesher=True,
solver_version=SOLVER_VERSION,
name=(
f"HVAB_collective_{current_collective.value}deg_"
f"cone:{cone_angle.value}_lag:{lag_angle.value}_"
f"surf_max_edge_length:{SURFACE_MAX_EDGE_LENGTH.value}_"
f"surface_mesh_node_count:{surface_node_count}_"
f"geom_accuracy:{GEOMETRY_ACCURACY.value}"
),
)
project.generate_volume_mesh(
params=params,
use_geometry_AI=True,
use_beta_mesher=True,
solver_version=SOLVER_VERSION,
name=(
f"HVAB_collective_{current_collective.value}deg_"
f"cone:{cone_angle.value}_lag:{lag_angle.value}_"
f"BL_first_layer_thickness:"
f"{BOUNDARY_LAYER_FIRST_LAYER_THICKNESS.value}_"
f"BL_GR={BOUNDARY_LAYER_GROWTH_RATE}_"
f"geometry_accuracy:{GEOMETRY_ACCURACY.value}"
),
)
previous_case = None
previous_steps = 0
for stage, (
deg_per_time_step,
revolutions,
max_pseudo_steps,
) in enumerate(run_stages, start=1):
params = create_meshing_run_params(
draft=draft,
deg_per_time_step=deg_per_time_step,
revolutions=revolutions,
mesh_refinement=mesh_refinement,
surface_node_count=surface_node_count,
max_pseudo_steps=max_pseudo_steps,
previous_steps=previous_steps,
use_kappa=is_12_deg and stage == 2,
)
collective_degrees = float(current_collective.value)
run_args = {
"params": params,
"name": (
f"HVAB_collective_{collective_degrees:g}deg_"
f"stage_{stage}_{deg_per_time_step:g}deg_"
f"{SOLVER_VERSION}"
),
"use_beta_mesher": True,
"use_geometry_AI": True,
"solver_version": SOLVER_VERSION,
}
if previous_case:
run_args["fork_from"] = previous_case
previous_case = project.run_case(**run_args)
submitted_cases.append(previous_case)
previous_steps += num_steps(deg_per_time_step, revolutions)
print(f"Submitted {len(submitted_cases)} cases")
Wait for completion¶
Wait for all ten cases before reading force histories. The final stage at each collective is used for validation.
for case in submitted_cases:
case.wait()
Postprocessing¶
Select the highest-numbered stage for each collective, average thrust and torque over the final averaging window, and compute rotorcraft thrust coefficient, torque coefficient, and figure of merit.
SOLIDITY = 0.1033
AVERAGE_CYCLES = 0.5
RESULTS_DIR = Path("results")
REFERENCE_DATA_PATH = Path("ref_data") / "reference_data.csv"
FLOW360_COLOR = "#00643c"
REFERENCE_COLOR = "black"
HELIOS_COLOR = "#666666"
FLOW360_LABEL = "Flow360: Rigid k-ω SST ZDES"
REFERENCE_LABELS = {
"2024 Helios elastic SST": "Helios: Elastic k-ω SST (2024)",
"2022 rigid Helios SST": "Helios: Rigid k-ω SST (2022)",
}
REFERENCE_STYLES = {
"Fully turbulent experiment": {
"color": REFERENCE_COLOR,
"linestyle": "--",
"linewidth": 3,
},
"Lower-surface trip experiment": {
"color": REFERENCE_COLOR,
"linestyle": "--",
"linewidth": 2,
},
"Upper/lower-surface trip experiment": {
"color": REFERENCE_COLOR,
"linestyle": ":",
"linewidth": 2,
},
"2024 Helios elastic SST": {
"color": HELIOS_COLOR,
"linestyle": "none",
"marker": "s",
},
"2022 rigid Helios SST": {
"color": HELIOS_COLOR,
"linestyle": "none",
"marker": "v",
},
}
COLLECTIVE_PATTERN = re.compile(
r"collective[:_](?P<collective>-?\d+(?:\.\d+)?)deg"
)
STAGE_PATTERN = re.compile(r"stage[:_](?P<stage>\d+)")
TIME_STEP_PATTERN = re.compile(
r"stage[:_]\d+_(?P<deg_per_time_step>\d+(?:\.\d+)?)deg"
)
def collective_from_name(name):
return float(COLLECTIVE_PATTERN.search(name).group("collective"))
def time_step_from_name(name):
return float(TIME_STEP_PATTERN.search(name).group("deg_per_time_step"))
def force_column(data, names):
lookup = {name.lower(): name for name in data.columns}
for name in names:
if name.lower() in lookup:
return lookup[name.lower()]
raise KeyError(f"Missing {names}; columns: {list(data.columns)}")
def averaged_forces(case):
forces = case.results.total_forces.as_dataframe()
if "physical_step" in forces:
forces = forces.drop_duplicates("physical_step", keep="last")
sample_count = int(360 * AVERAGE_CYCLES / time_step_from_name(case.name))
window = forces.tail(sample_count)
cfz = window[force_column(forces, ("CFz", "totalCFz"))].astype(float)
cmz = window[force_column(forces, ("CMz", "totalCMz"))].astype(float)
return cfz, cmz
def case_data(case):
collective = collective_from_name(case.name)
cfz, cmz = averaged_forces(case)
ct_samples = 0.5 * cfz
cq_samples = 0.5 * cmz
ct = float(ct_samples.mean())
cq = float(cq_samples.mean())
fom_samples = (
abs(ct_samples) ** 1.5 / (math.sqrt(2) * abs(cq_samples))
)
fom = abs(ct) ** 1.5 / (math.sqrt(2) * abs(cq))
return {
"case": case.name,
"collective": collective,
"CT": ct,
"CT_std": float(ct_samples.std()),
"CT_over_solidity": ct / SOLIDITY,
"CT_over_solidity_std": float(ct_samples.std()) / SOLIDITY,
"CQ": cq,
"CQ_std": float(cq_samples.std()),
"FoM": fom,
"FoM_std": float(fom_samples.std()),
}
final_cases = {}
for case in submitted_cases:
collective = collective_from_name(case.name)
stage = int(STAGE_PATTERN.search(case.name).group("stage"))
if collective not in final_cases or stage > final_cases[collective][0]:
final_cases[collective] = (stage, case)
rows = [
case_data(case)
for _, case in sorted(final_cases.values(), key=lambda item: item[1].name)
]
flow360_data = pd.DataFrame(rows).sort_values("collective").reset_index(drop=True)
flow360_data
Figure of merit versus thrust coefficient over solidity¶
The reference curves include the fully turbulent and tripped NASA experiments and the rigid and elastic Helios predictions. Flow360 results are shown as green markers. The published PNG is written beneath the local results directory.
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
reference_data = pd.read_csv(REFERENCE_DATA_PATH)
fig, ax = plt.subplots()
for series, style in REFERENCE_STYLES.items():
series_data = reference_data[
reference_data["series"] == series
].sort_values("CT_over_solidity")
ax.plot(
series_data["CT_over_solidity"],
series_data["FoM"],
label=REFERENCE_LABELS.get(series, series),
**style,
)
ax.plot(
flow360_data["CT_over_solidity"],
flow360_data["FoM"],
color=FLOW360_COLOR,
linestyle="none",
marker="o",
label=FLOW360_LABEL,
)
ax.set_xlim(0.02, 0.12)
ax.set_ylim(0.40, 0.8)
ax.set_xlabel(r"CT/$\sigma$")
ax.set_ylabel("FoM")
ax.grid(True)
ax.legend()
fig.tight_layout()
fig.savefig(RESULTS_DIR / "fom_vs_thrust.png", dpi=200)
plt.show()