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¶

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
for case in cases:
    case.wait()

Postprocessing¶

The published results are two figures:

  1. Longitudinal coefficients versus angle of attack - CL, CD, CM, and L/D across the sweep, compared against wind-tunnel experiment and published RANS.
  2. 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.

In [ ]:
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.

In [ ]:
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.

In [ ]:
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()