Fifth-Generation High-Performance Aircraft Characterization¶
This case characterizes the Sydney Standard Aerodynamic Model (SSAM-Gen5), an open, representative geometry for modern fifth-generation high-performance aircraft. The flow is dominated by strong strake-vortex interactions and vortex breakdown at high angle of attack, which govern the stability and handling qualities of such configurations.
Flow360 runs a steady, half-body RANS (Spalart-Allmaras) analysis at a 20 m/s freestream and sweeps the angle of attack from -6 to 30 degrees in 2-degree increments (19 chained cases). Each case forks from the previous one to accelerate convergence across the sweep. The results are validated against wind-tunnel experiment and published RANS data, both in terms of the longitudinal force and moment coefficients and the spanwise surface-pressure distributions.
The notebook runs top-to-bottom in a single kernel: load the geometry, submit the 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 few numerical/plotting packages:
pip install numpy matplotlib pyvista
Run the cells top to bottom in a single kernel.
Imports¶
import os
import csv
import tarfile
import tempfile
import flow360 as fl
import numpy as np
import pyvista as pv
import matplotlib.pyplot as plt
from matplotlib.ticker import FormatStrFormatter
from flow360.examples import download_benchmark_assets
Input data¶
The post-processing compares Flow360 against wind-tunnel experiment and published RANS data.
Fetch those reference files from the public benchmark bucket; this recreates a local ./ref_data/
directory that the plotting cells read from.
download_benchmark_assets("SSAM", "ref_data")
Load project¶
The root asset for this case is the published SSAM-Gen5 geometry. Download it from the public benchmark bucket and start a fresh project from it - no private project id is needed.
root_asset_files = download_benchmark_assets("SSAM", "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="Fifth-Generation High-Performance Aircraft Characterization",
)
Sweep configuration and helpers¶
The sweep constants and the reference geometry dimensions, plus small helpers: case_name
builds a per-angle case name, body_boundaries splits the geometry surfaces into viscous walls
and the symmetry face, and station_slices defines the spanwise stations used both as a meshing
output and a solver slice output.
SOLVER_VERSION = os.environ.get("SOLVER_VERSION_OVERRIDE", "release-25.9")
ALPHAS = list(range(-6, 31, 2))
LENGTH = 0.75
MAC = 0.2265
SPAN = 0.5350
REF_AREA = 0.1091
SYMMETRY_FACE = "body00001_face00081"
X_OVER_L_STATIONS = (0.067, 0.167, 0.267, 0.367, 0.467, 0.567, 0.667, 0.767, 0.867, 0.967)
def alpha_token(alpha):
return f"{'m' if alpha < 0 else 'p'}{abs(alpha):02d}"
def case_name(alpha):
return f"ssam_alpha_{alpha_token(alpha)}_{SOLVER_VERSION}"
def body_boundaries(geometry):
walls = []
symmetry = []
for surf in geometry.entity_info.get_boundaries():
if "body00001" not in surf.name:
continue
if surf.name == SYMMETRY_FACE:
symmetry.append(surf)
else:
walls.append(surf)
return walls, symmetry
def station_slices():
return [
fl.Slice(
name=f"Slice_{round(x_over_l, 2)}",
normal=(1, 0, 0),
origin=(x_over_l * LENGTH, 0, 0) * fl.u.m,
)
for x_over_l in X_OVER_L_STATIONS
]
Meshing setup¶
The beta mesher builds the volume mesh from the geometry: fine surface resolution with a thin first boundary-layer cell, a uniform-refinement box around the body, an automated farfield, and the spanwise station slices emitted as a mesh output.
def build_meshing(farfield, slices, ref_box):
with fl.SI_unit_system:
return fl.MeshingParams(
defaults=fl.MeshingDefaults(
curvature_resolution_angle=2.5 * fl.u.deg,
surface_max_edge_length=0.004 * fl.u.m,
boundary_layer_first_layer_thickness=2.5 * 1e-6 * fl.u.m,
),
refinements=[
fl.UniformRefinement(entities=[ref_box], spacing=0.016),
],
volume_zones=[farfield],
outputs=[fl.MeshSliceOutput(slices=slices)],
)
Physics setup¶
The body surfaces are viscous walls, the automated farfield carries the freestream, and its symmetry plane closes the half-body model. The flow solver is a Spalart-Allmaras RANS with a tight Navier-Stokes tolerance and MUSCL / numerical-dissipation settings tuned for this case.
def build_models(walls, farfield):
with fl.SI_unit_system:
return [
fl.Wall(surfaces=walls),
fl.Freestream(surfaces=farfield.farfield),
fl.SymmetryPlane(surfaces=[farfield.symmetry_plane]),
fl.Fluid(
turbulence_model_solver=fl.SpalartAllmaras(
absolute_tolerance=1e-8,
),
navier_stokes_solver=fl.NavierStokesSolver(
absolute_tolerance=1e-10,
kappa_MUSCL=-0.33,
numerical_dissipation_factor=0.2,
),
),
]
Outputs setup¶
Two solver outputs are requested: slice outputs at the spanwise stations plus a y- and z-plane
(carrying primitive variables and vorticity for flow visualization), and a surface Cp output on
the walls that feeds the spanwise pressure-coefficient plot.
def build_outputs(walls, slices):
with fl.SI_unit_system:
return [
fl.SliceOutput(
slices=[
*slices,
fl.Slice(name="yslice", normal=(0, 1, 0), origin=(0, 0, 0)),
fl.Slice(name="zslice", normal=(0, 0, 1), origin=(0, 0, 0)),
],
output_fields=[
"primitiveVars",
"vorticityMagnitude",
"vorticity_x",
"vorticity_y",
"vorticity_z",
],
output_format="both",
),
fl.SurfaceOutput(
surfaces=walls,
output_fields=["Cp"],
output_format="paraview",
),
]
Simulation Params¶
create_params assembles the full SimulationParams for one angle of attack: the meshing, the
20 m/s operating condition at the given alpha, the boundary conditions and solvers, a steady
adaptive-CFL schedule (more conservative above 20 degrees where vortex breakdown makes the flow
harder to converge), the reference geometry, and the outputs.
def create_params(geometry, alpha):
walls, symmetry = body_boundaries(geometry)
with fl.SI_unit_system:
slices = station_slices()
farfield = fl.AutomatedFarfield()
ref_box = fl.Box(
name="Ref Box",
size=(1, 0.4, 0.5),
center=(0.4, 0.2, 0),
)
return fl.SimulationParams(
meshing=build_meshing(farfield, slices, ref_box),
operating_condition=fl.AerospaceCondition(
velocity_magnitude=20 * fl.u.m / fl.u.s,
alpha=alpha * fl.u.deg,
),
models=build_models(walls, farfield),
time_stepping=fl.Steady(
max_steps=5000,
CFL=fl.AdaptiveCFL(
convergence_limiting_factor=0.15 if alpha > 20 else 0.6,
),
),
reference_geometry=fl.ReferenceGeometry(
moment_center=(0.4385, 0, 0) * fl.u.m,
moment_length=(SPAN, MAC, SPAN) * fl.u.m,
area=REF_AREA * fl.u.m**2,
),
outputs=build_outputs(walls, slices),
)
Submit cases¶
Submit the 19-point angle-of-attack sweep. Each case is meshed with the beta mesher and forks
from the previous case (fork_from=base_case), so the sweep marches from -6 to 30 degrees
reusing the converged upstream solution. We keep the submitted case objects in cases so the
post-processing can read their results directly from this kernel.
cases = []
base_case = None
for alpha in ALPHAS:
case = project.run_case(
name=case_name(alpha),
params=create_params(project.geometry, alpha),
use_beta_mesher=True,
fork_from=base_case,
solver_version=SOLVER_VERSION,
)
cases.append(case)
base_case = case
Wait for completion¶
Block until every submitted case has finished. This can take a long time (minutes to hours), since the sweep is 19 chained steady solves of up to 5000 steps each.
for case in cases:
case.wait()
Postprocessing¶
The published results are two figures:
- Longitudinal coefficients versus angle of attack - CL, CD, CM, and L/D across the sweep, compared against wind-tunnel experiment and published RANS.
- Spanwise pressure coefficient at alpha = 30 degrees - Cp along seven spanwise stations at the highest-incidence case, compared against published RANS.
Both are built from the in-kernel cases objects (no project or case is re-opened).
Setup¶
Plot styling, series labels, the reference-data file locations, and two small helpers: deg
reads an angle in degrees from a case's operating condition, and mean_tail averages the last
10% of a convergence history to get a settled coefficient. The forces are scaled by 2 to convert
from the half-body model to the full configuration. Finally we order the swept cases by angle of
attack.
os.makedirs("results", exist_ok=True)
plt.rcParams.update(
{
"font.family": "serif",
"font.size": 9,
"mathtext.fontset": "dejavuserif",
"axes.edgecolor": "0.45",
}
)
FLOW360_COLOR = "#00643c" # Flow360 results
REFERENCE_COLOR = "black" # experimental / reference data
REF_DIR = "ref_data"
EXPERIMENT_PATH = os.path.join(REF_DIR, "experimentalData.csv")
FIGURE10_REFERENCE_PATH = os.path.join(REF_DIR, "figure10_reference.csv")
FIGURE12_REFERENCE_PATH = os.path.join(REF_DIR, "figure12_reference.csv")
FORCES_FIGURE = "results/definitive_coefs.png"
CP_FIGURE = "results/definitive_Cp.png"
LENGTH = 0.75
CP_ALPHA = 30.0
CP_LIMITS = (-4.0, 1.0)
COEFFICIENT_SCALE = 2.0
REFERENCE_SERIES = "SA"
FLOW360_LABEL = "Flow360 SA"
EXPERIMENT_LABEL = "USYD AMME WT"
REFERENCE_LABEL = "Giannelis et al. RANS"
CP_REFERENCE_LABEL = "Giannelis et al. RANS"
STATIONS = (
("a", 0.067),
("b", 0.167),
("c", 0.267),
("d", 0.367),
("e", 0.467),
("f", 0.567),
("g", 0.667),
)
VTK_SUFFIXES = (".pvtu", ".pvtp", ".vtp", ".vtu", ".vtk")
def deg(angle):
return float(angle.to("degree").value)
def mean_tail(data, column):
values = data[column].dropna()
return float(values.tail(max(1, int(len(values) * 0.1))).mean())
cases = sorted(cases, key=lambda case: deg(case.params.operating_condition.alpha))
Longitudinal coefficients versus angle of attack¶
For each case, read the total-forces convergence history and take the settled CL, CD, and CMy
(mean of the last 10% of steps, scaled to the full configuration); L/D follows. These Flow360
curves are plotted alongside the wind-tunnel experiment (with its uncertainty bars, from
experimentalData.csv) and the published SA RANS curves (from figure10_reference.csv) in a
four-panel figure - CL, CD, CM, and L/D versus angle of attack.
def force_row(case):
data = case.results.total_forces.as_dataframe()
sort_keys = [key for key in ("physical_step", "step", "pseudo_step") if key in data.columns]
if sort_keys:
data = data.sort_values(sort_keys)
cl = COEFFICIENT_SCALE * mean_tail(data, "CL")
cd = COEFFICIENT_SCALE * mean_tail(data, "CD")
cm = COEFFICIENT_SCALE * mean_tail(data, "CMy")
return {
"alpha": deg(case.params.operating_condition.alpha),
"CL": cl,
"CD": cd,
"CM": cm,
"L/D": cl / cd,
}
def experimental_rows():
rows = []
with open(EXPERIMENT_PATH, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
cl = float(row["CL"])
cd = float(row["CD"])
cl_err = float(row["CL_err"])
cd_err = float(row["CD_err"])
rows.append(
{
"alpha": float(row["AoA calibrated"]),
"CL": cl,
"CD": cd,
"CM": float(row["Cmy_cog"]),
"L/D": cl / cd,
"CL_err": cl_err,
"CD_err": cd_err,
"CM_err": float(row["Cmy_cog_err"]),
"L/D_err": ((cl_err / cd) ** 2 + (cl * cd_err / cd**2) ** 2) ** 0.5,
}
)
return sorted(rows, key=lambda row: row["alpha"])
def coefficient_reference_rows():
rows = {}
with open(FIGURE10_REFERENCE_PATH, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if row["series"] == REFERENCE_SERIES:
rows.setdefault(row["panel"], []).append(
{"alpha": float(row["aoa_deg"]), "value": float(row["value"])}
)
return rows
def plot_force_panel(ax, rows, experiment, reference, panel, key, ylabel, ylim, yticks, label):
ax.plot(
[row["alpha"] for row in rows],
[row[key] for row in rows],
"-s",
color=FLOW360_COLOR,
linewidth=1.5,
markersize=4,
label=FLOW360_LABEL,
)
ax.errorbar(
[row["alpha"] for row in experiment],
[row[key] for row in experiment],
yerr=[row[f"{key}_err"] for row in experiment],
fmt="none",
ecolor=REFERENCE_COLOR,
alpha=0.45,
capsize=2,
capthick=0.7,
elinewidth=0.7,
zorder=1,
)
ax.plot(
[row["alpha"] for row in experiment],
[row[key] for row in experiment],
"x",
color=REFERENCE_COLOR,
markersize=4,
label=EXPERIMENT_LABEL,
zorder=2,
)
values = reference.get(panel, [])
if values:
ax.plot(
[row["alpha"] for row in values],
[row["value"] for row in values],
color=REFERENCE_COLOR,
linestyle=":",
linewidth=1.2,
marker="o",
markersize=3,
markerfacecolor="none",
label=REFERENCE_LABEL,
zorder=4,
)
ax.set_xlim(-10, 35)
ax.set_ylim(*ylim)
ax.set_xticks(range(-10, 36, 5))
ax.set_yticks(yticks)
ax.set_xlabel("AoA (deg)", fontsize=8)
ax.set_ylabel(ylabel, fontsize=8)
ax.grid(True, linestyle=(0, (6, 4)), color="0.85", linewidth=0.8)
ax.tick_params(labelsize=8, color="0.35", width=0.8)
for spine in ax.spines.values():
spine.set_color("0.45")
spine.set_linewidth(0.8)
legend = ax.legend(
loc="lower right",
frameon=True,
fancybox=False,
framealpha=1,
edgecolor="0.45",
fontsize=8,
)
legend.get_frame().set_linewidth(0.8)
ax.text(0.02, 0.95, label, transform=ax.transAxes, va="top", fontsize=11)
def plot_forces(rows):
experiment = experimental_rows()
reference = coefficient_reference_rows()
fig, axs = plt.subplots(2, 2, figsize=(9.4, 5.4), sharex=False)
plot_force_panel(
axs[0, 0], rows, experiment, reference,
"a", "CL", "$C_L$", (-0.6, 1.6), [-0.5, 0, 0.5, 1.0, 1.5], "(a)",
)
plot_force_panel(
axs[0, 1], rows, experiment, reference,
"b", "CD", "$C_D$", (0.0, 0.9), [0, 0.2, 0.4, 0.6, 0.8], "(b)",
)
plot_force_panel(
axs[1, 0], rows, experiment, reference,
"c", "CM", "$C_{M_{cg}}$", (-0.06, 0.16), [-0.05, 0, 0.05, 0.10, 0.15], "(c)",
)
plot_force_panel(
axs[1, 1], rows, experiment, reference,
"d", "L/D", "$L/D$", (-7, 7), [-6, -4, -2, 0, 2, 4, 6], "(d)",
)
fig.tight_layout()
fig.savefig(FORCES_FIGURE, dpi=300, bbox_inches="tight")
return fig
plot_forces([force_row(case) for case in cases])
plt.show()
Spanwise pressure coefficient at alpha = 30 degrees¶
At the highest-incidence case, download the surface Cp output, slice it at seven spanwise
stations along the body, and normalize each cut's span. Flow360's Cp is plotted against the
published SA RANS distributions (from figure12_reference.csv) in a per-station grid, each panel
carrying a planform inset marking the slice location.
def surface_files(root):
files = []
for suffix in VTK_SUFFIXES:
files.extend(
sorted(
os.path.join(path, name)
for path, _dirs, names in os.walk(root)
for name in names
if name.endswith(suffix)
)
)
return files
def prepare_mesh(mesh):
if isinstance(mesh, pv.MultiBlock):
blocks = [prepare_mesh(block) for block in mesh if block is not None]
blocks = [block for block in blocks if block is not None and block.n_points]
if not blocks:
return None
return blocks[0] if len(blocks) == 1 else pv.merge(blocks, merge_points=False)
if mesh is None or not mesh.n_points:
return None
if "Cp" in mesh.cell_data and "Cp" not in mesh.point_data:
return mesh.cell_data_to_point_data(pass_cell_data=True)
return mesh
def load_surface_meshes(case):
with tempfile.TemporaryDirectory(prefix="ssam_surfaces_") as tmp_dir:
archive_path = os.path.join(tmp_dir, "surfaces.tar.gz")
extract_dir = os.path.join(tmp_dir, "surfaces")
os.makedirs(extract_dir, exist_ok=True)
case.results.surfaces.to_file(archive_path, overwrite=True)
with tarfile.open(archive_path, "r:*") as archive:
archive.extractall(extract_dir)
meshes = [prepare_mesh(pv.read(path)) for path in surface_files(extract_dir)]
meshes = [mesh for mesh in meshes if mesh is not None and "Cp" in mesh.point_data]
if not meshes:
raise RuntimeError(f"No Cp surface output found for {case.name}.")
return meshes
def bodies(mesh):
if not mesh.n_points:
return []
try:
labeled = mesh.connectivity()
region_ids = np.asarray(labeled.cell_data["RegionId"])
return [
labeled.extract_cells(np.where(region_ids == region)[0])
for region in np.unique(region_ids)
]
except (AttributeError, KeyError, ValueError):
return [mesh]
def planform(meshes):
points = np.vstack([mesh.points[:, :2] for mesh in meshes if mesh.n_points])
bounds = (
float(np.nanmin(points[:, 0])),
float(np.nanmax(points[:, 0])),
float(np.nanmin(points[:, 1])),
float(np.nanmax(points[:, 1])),
)
nx = 700
ny = max(120, int(nx * (bounds[3] - bounds[2]) / (bounds[1] - bounds[0])))
hist, _, _ = np.histogram2d(
points[:, 0],
points[:, 1],
bins=(nx, ny),
range=((bounds[0], bounds[1]), (bounds[2], bounds[3])),
)
mask = hist.T > 0
for _ in range(3):
padded = np.pad(mask, 1)
mask = (
padded[:-2, :-2]
| padded[:-2, 1:-1]
| padded[:-2, 2:]
| padded[1:-1, :-2]
| padded[1:-1, 1:-1]
| padded[1:-1, 2:]
| padded[2:, :-2]
| padded[2:, 1:-1]
| padded[2:, 2:]
)
image = np.zeros((ny, nx, 4), dtype=np.uint8)
image[mask] = (145, 145, 145, 255)
return image, bounds
def station_curves(meshes, x_over_l):
x = x_over_l * LENGTH
curves = []
for mesh in meshes:
if "Cp" not in mesh.point_data:
continue
cut = mesh.slice(normal=(1, 0, 0), origin=(x, 0, 0))
for body in bodies(cut):
y = body.points[:, 1]
cp = np.asarray(body.point_data["Cp"], dtype=float).reshape(-1)
keep = np.isfinite(y) & np.isfinite(cp)
if np.count_nonzero(keep) >= 2:
curves.append((y[keep], cp[keep]))
if not curves:
return x, []
y_all = np.concatenate([curve[0] for curve in curves])
y_min = float(np.nanmin(y_all))
y_span = float(np.nanmax(y_all) - y_min)
if y_span <= 0.0:
return x, []
return x, [((y - y_min) / y_span, cp) for y, cp in curves]
def cp_reference_rows():
station_by_panel = dict(STATIONS)
rows = []
with open(FIGURE12_REFERENCE_PATH, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
if row["series"] == REFERENCE_SERIES:
rows.append(
{
"panel": row["panel"],
"x_over_l": station_by_panel[row["panel"]],
"series": CP_REFERENCE_LABEL,
"span": float(row["x_norm_span"]),
"cp": float(row["cp"]),
}
)
ordered = []
for panel, _x_over_l in STATIONS:
panel_rows = sorted(
(row for row in rows if row["panel"] == panel),
key=lambda row: (row["span"], row["cp"]),
)
if not panel_rows:
continue
cp_scale = max(1.0, max(row["cp"] for row in panel_rows) - min(row["cp"] for row in panel_rows))
root = [row for row in panel_rows if abs(row["span"] - panel_rows[0]["span"]) < 1e-12]
lower = [min(root, key=lambda row: row["cp"])]
upper = [max(root, key=lambda row: row["cp"])]
panel_rows.remove(lower[0])
if upper[0] in panel_rows:
panel_rows.remove(upper[0])
for row in panel_rows:
lower_distance = (row["span"] - lower[-1]["span"]) ** 2 + ((row["cp"] - lower[-1]["cp"]) / cp_scale) ** 2
upper_distance = (row["span"] - upper[-1]["span"]) ** 2 + ((row["cp"] - upper[-1]["cp"]) / cp_scale) ** 2
(lower if lower_distance <= upper_distance else upper).append(row)
ordered.extend(lower + upper[::-1])
return ordered
def cp_flow360_data(meshes):
rows = []
curves_by_panel = {}
for panel, x_over_l in STATIONS:
_x, curves = station_curves(meshes, x_over_l)
curves_by_panel[panel] = curves
for span_values, cp_values in curves:
for span, cp in zip(span_values, cp_values):
if 0.0 <= span <= 1.05 and np.isfinite(cp):
rows.append(
{
"panel": panel,
"x_over_l": x_over_l,
"series": FLOW360_LABEL,
"span": float(span),
"cp": float(cp),
}
)
return sorted(rows, key=lambda row: (row["panel"], row["span"], row["cp"])), curves_by_panel
def plot_cp_panel(ax, rows, curves_by_panel, planform_data, panel, x_over_l, label):
reference = [row for row in rows if row["panel"] == panel and row["series"] == CP_REFERENCE_LABEL]
flow_label = FLOW360_LABEL
for span_values, cp_values in curves_by_panel.get(panel, []):
order = np.argsort(span_values)
ax.plot(
span_values[order],
cp_values[order],
color=FLOW360_COLOR,
linewidth=1.0,
alpha=0.95,
label=flow_label,
zorder=3,
)
flow_label = "_nolegend_"
if reference:
ax.plot(
[row["span"] for row in reference],
[row["cp"] for row in reference],
color=REFERENCE_COLOR,
linewidth=1.0,
alpha=0.45,
label=CP_REFERENCE_LABEL,
zorder=2,
)
ax.set_box_aspect(0.55)
ax.set_xlim(0.0, 1.2)
ax.set_ylim(*CP_LIMITS)
ax.set_xticks(np.arange(0.0, 1.21, 0.2))
ax.set_xticks(np.arange(0.0, 1.21, 0.1), minor=True)
ax.set_yticks(np.arange(-4.0, 1.01, 1.0))
ax.set_yticks(np.arange(-4.0, 1.01, 0.5), minor=True)
ax.grid(True, which="major", color="0.82", linestyle=(0, (7, 4)), linewidth=0.8)
ax.grid(True, which="minor", color="0.9", linestyle=(0, (7, 4)), linewidth=0.7)
ax.tick_params(which="both", direction="in", top=True, right=True, labelsize=7)
ax.tick_params(which="major", length=4, width=0.8)
ax.tick_params(which="minor", length=2.5, width=0.7)
ax.xaxis.set_major_formatter(FormatStrFormatter("%.1f"))
ax.yaxis.set_major_formatter(FormatStrFormatter("%.1f"))
for spine in ax.spines.values():
spine.set_linewidth(0.8)
spine.set_color("0.25")
ax.set_title(f"{label} X/L = {x_over_l:.3f}", fontsize=10)
image, bounds = planform_data
inset = ax.inset_axes([0.06, 0.18, 0.36, 0.33])
inset.imshow(image, extent=bounds, origin="lower")
inset.axvline(x_over_l * LENGTH, color="#cc0000", linewidth=1.5)
inset.set_xlim(bounds[0], bounds[1])
inset.set_ylim(bounds[2], bounds[3])
inset.set_aspect("equal", adjustable="box")
inset.set_xticks([])
inset.set_yticks([])
for spine in inset.spines.values():
spine.set_color("0.45")
spine.set_linewidth(0.6)
def plot_cp(case):
meshes = load_surface_meshes(case)
planform_data = planform(meshes)
flow_rows, curves_by_panel = cp_flow360_data(meshes)
rows = flow_rows + cp_reference_rows()
fig, axes = plt.subplots(4, 2, figsize=(9, 10), sharex=True, sharey=True)
axes = axes.ravel()
for axis, (panel, x_over_l) in zip(axes, STATIONS):
plot_cp_panel(axis, rows, curves_by_panel, planform_data, panel, x_over_l, f"({panel})")
for axis in axes[::2]:
axis.set_ylabel("$C_p$", fontsize=9)
for axis in axes[5:7]:
axis.set_xlabel("Normalised span", fontsize=9)
axes[-1].axis("off")
handles, labels = axes[0].get_legend_handles_labels()
axes[-1].legend(handles, labels, loc="center", frameon=True, fancybox=False, edgecolor="0.25", fontsize=8)
fig.tight_layout()
fig.savefig(CP_FIGURE, dpi=240)
return fig
def case_at_alpha(cases, alpha):
for case in cases:
if abs(deg(case.params.operating_condition.alpha) - alpha) < 1e-9:
return case
raise ValueError(f"No case found at alpha={alpha:g}.")
plot_cp(case_at_alpha(cases, CP_ALPHA))
plt.show()