Aeroelastic Unsteady Ring VLM Solver with First-Order Deformation Plotting

"""Demonstrates running Ptera Software's
AeroelasticUnsteadyRingVortexLatticeMethodSolver across a sweep of parameters.

The wing-tip twist histories from each run are overlaid in a plot.
"""

import matplotlib.pyplot as plt
import numpy as np

import pterasoftware as ps

# The element to extract from each entry of the deformation angle time series. The
# example's Wing has 16 spanwise panels, so index 16 corresponds to the wing-tip
# WingCrossSection.
TIP_INDEX = 16

# These are the default values used when a parameter is not being swept.
DEFAULT_K_RAD = 1000.0
DEFAULT_B_RAD = 1000.0
DEFAULT_DENSITY = 6.0

# The prescribed flapping motion's x-component amplitude (degrees), period (seconds),
# and phase (degrees). The two aeroelastic wing movements are constructed with these
# values, and the plotted flap-position overlay is computed from them.
FLAP_AMPLITUDE = 15.0
FLAP_PERIOD = 1.0
FLAP_PHASE = 169.0

# Populate exactly ONE of these lists to sweep that parameter while holding the others
# at their defaults. Leave the other two as empty lists.
K_VALUES_RAD: list[float] = [100.0, 1000.0, 10000.0, 20000.0]
B_VALUES_RAD: list[float] = []
DENSITY_VALUES: list[float] = []


def run_aeroelastic(
    spring_constant_rad: float = DEFAULT_K_RAD,
    damping_constant_rad: float = DEFAULT_B_RAD,
    wing_density: float = DEFAULT_DENSITY,
) -> tuple[list[np.ndarray], ps.problems.AeroelasticUnsteadyProblem]:
    """Run the aeroelastic solver and return the first Wing's deformation angle time
    series.

    :param spring_constant_rad: The torsional spring stiffness value.
    :param damping_constant_rad: The damping constant value.
    :param wing_density: The wing density per unit height (kg/m^2).
    :return: A tuple of the first Wing's cumulative torsional deformation angle time
        series (its entry of listDeformationAnglesYRad_Wcsp_to_Wcs_ixyz) and the solved
        AeroelasticUnsteadyProblem.
    """
    # Initialize the WingCrossSection parameters.
    num_spanwise_panels = 2
    Lp_Wcsp_Lpp_Offsets = (0.1, 0.5, 0.0)
    cross_section_chords = [1.75, 1.75, 1.75, 1.75, 1.65, 1.55, 1.4, 1.2, 1.0]
    wing_cross_sections = []

    for i in range(len(cross_section_chords)):
        wing_cross_sections.append(
            ps.geometry.wing_cross_section.WingCrossSection(
                num_spanwise_panels=(
                    num_spanwise_panels if i < len(cross_section_chords) - 1 else None
                ),
                chord=cross_section_chords[i],
                Lp_Wcsp_Lpp=Lp_Wcsp_Lpp_Offsets if i > 0 else (0.0, 0.0, 0.0),
                angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
                control_surface_symmetry_type="symmetric",
                control_surface_hinge_point=0.75,
                control_surface_deflection=0.0,
                spanwise_spacing=(
                    "uniform" if i < len(cross_section_chords) - 1 else None
                ),
                airfoil=ps.geometry.airfoil.Airfoil(
                    name="naca2412",
                    outline_A_lp=None,
                    resample=True,
                    n_points_per_side=400,
                ),
            )
        )

    wing_1 = ps.geometry.wing.Wing(
        wing_cross_sections=wing_cross_sections,
        name="Main Wing",
        Ler_Gs_Cgs=(0.0, 0.5, 0.0),
        angles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
        symmetric=True,
        mirror_only=False,
        symmetryNormal_G=(0.0, 1.0, 0.0),
        symmetryPoint_G_Cg=(0.0, 0.0, 0.0),
        explode_into_strips=True,
        num_chordwise_panels=6,
        chordwise_spacing="uniform",
    )

    example_airplane = ps.geometry.airplane.Airplane(
        wings=[
            wing_1,
            ps.geometry.wing.Wing(
                wing_cross_sections=[
                    ps.geometry.wing_cross_section.WingCrossSection(
                        num_spanwise_panels=8,
                        chord=1.5,
                        Lp_Wcsp_Lpp=(0.0, 0.0, 0.0),
                        angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
                        control_surface_symmetry_type="symmetric",
                        control_surface_hinge_point=0.75,
                        control_surface_deflection=0.0,
                        spanwise_spacing="uniform",
                        airfoil=ps.geometry.airfoil.Airfoil(
                            name="naca0012",
                            outline_A_lp=None,
                            resample=True,
                            n_points_per_side=400,
                        ),
                    ),
                    ps.geometry.wing_cross_section.WingCrossSection(
                        num_spanwise_panels=None,
                        chord=1.0,
                        Lp_Wcsp_Lpp=(0.5, 2.0, 0.0),
                        angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
                        control_surface_symmetry_type="symmetric",
                        control_surface_hinge_point=0.75,
                        control_surface_deflection=0.0,
                        spanwise_spacing=None,
                        airfoil=ps.geometry.airfoil.Airfoil(
                            name="naca0012",
                            outline_A_lp=None,
                            resample=True,
                            n_points_per_side=400,
                        ),
                    ),
                ],
                name="V-Tail",
                Ler_Gs_Cgs=(5.0, 0.0, 0.0),
                angles_Gs_to_Wn_ixyz=(0.0, -5.0, 0.0),
                symmetric=True,
                mirror_only=False,
                symmetryNormal_G=(0.0, 1.0, 0.0),
                symmetryPoint_G_Cg=(0.0, 0.0, 0.0),
                explode_into_strips=False,
                num_chordwise_panels=6,
                chordwise_spacing="uniform",
            ),
        ],
        name="Example Airplane",
        Cg_GP1_CgP1=(0.0, 0.0, 0.0),
        weight=0.0,
        c_ref=None,
        b_ref=None,
    )

    # Define the WingCrossSectionMovement parameters.
    dephase_x = 0.0
    period_x = 0.0
    amplitude_x = 0.0

    dephase_y = 0.0
    period_y = 0.0
    amplitude_y = 0.0

    dephase_z = 0.0
    period_z = 0.0
    amplitude_z = 0.0

    main_wcs_movements_list = []
    reflected_wcs_movements_list = []

    for i in range(len(example_airplane.wings[0].wing_cross_sections)):
        if i == 0:
            wcs_movement = ps.movements.aeroelastic_wing_cross_section_movement.AeroelasticWingCrossSectionMovement(
                base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[
                    i
                ],
            )
            main_wcs_movements_list.append(wcs_movement)
            reflected_wcs_movements_list.append(wcs_movement)

        else:
            wcs_movement = ps.movements.aeroelastic_wing_cross_section_movement.AeroelasticWingCrossSectionMovement(
                base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[
                    i
                ],
                ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
                periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
                spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
                phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
                ampAngles_Wcsp_to_Wcs_ixyz=(
                    amplitude_x,
                    amplitude_y,
                    amplitude_z,
                ),
                periodAngles_Wcsp_to_Wcs_ixyz=(period_x, period_y, period_z),
                spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
                phaseAngles_Wcsp_to_Wcs_ixyz=(dephase_x, dephase_y, dephase_z),
            )
            main_wcs_movements_list.append(wcs_movement)
            reflected_wcs_movements_list.append(wcs_movement)

    v_tail_root_wcs_movement = (
        ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
            base_wing_cross_section=example_airplane.wings[2].wing_cross_sections[0],
            ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
            periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
            spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
            phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
            ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
            periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
            spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
            phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
        )
    )
    v_tail_tip_wcs_movement = (
        ps.movements.wing_cross_section_movement.WingCrossSectionMovement(
            base_wing_cross_section=example_airplane.wings[2].wing_cross_sections[1],
            ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
            periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
            spacingLp_Wcsp_Lpp=("sine", "sine", "sine"),
            phaseLp_Wcsp_Lpp=(0.0, 0.0, 0.0),
            ampAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
            periodAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
            spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"),
            phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0),
        )
    )
    main_wing_movement = ps.movements.aeroelastic_wing_movement.AeroelasticWingMovement(
        base_wing=example_airplane.wings[0],
        wing_cross_section_movements=main_wcs_movements_list,
        ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
        periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
        spacingLer_Gs_Cgs=("sine", "sine", "sine"),
        phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
        ampAngles_Gs_to_Wn_ixyz=(FLAP_AMPLITUDE, 0.0, 0.0),
        periodAngles_Gs_to_Wn_ixyz=(FLAP_PERIOD, 0.0, 0.0),
        spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
        phaseAngles_Gs_to_Wn_ixyz=(FLAP_PHASE, 0.0, 0.0),
    )

    reflected_main_wing_movement = (
        ps.movements.aeroelastic_wing_movement.AeroelasticWingMovement(
            base_wing=example_airplane.wings[1],
            wing_cross_section_movements=reflected_wcs_movements_list,
            ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
            periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
            spacingLer_Gs_Cgs=("sine", "sine", "sine"),
            phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
            ampAngles_Gs_to_Wn_ixyz=(FLAP_AMPLITUDE, 0.0, 0.0),
            periodAngles_Gs_to_Wn_ixyz=(FLAP_PERIOD, 0.0, 0.0),
            spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
            phaseAngles_Gs_to_Wn_ixyz=(FLAP_PHASE, 0.0, 0.0),
        )
    )

    v_tail_wing_movement = ps.movements.wing_movement.WingMovement(
        base_wing=example_airplane.wings[2],
        wing_cross_section_movements=[
            v_tail_root_wcs_movement,
            v_tail_tip_wcs_movement,
        ],
        ampLer_Gs_Cgs=(0.0, 0.0, 0.0),
        periodLer_Gs_Cgs=(0.0, 0.0, 0.0),
        spacingLer_Gs_Cgs=("sine", "sine", "sine"),
        phaseLer_Gs_Cgs=(0.0, 0.0, 0.0),
        ampAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
        periodAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
        spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"),
        phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0),
    )

    example_airplane_movement = (
        ps.movements.aeroelastic_airplane_movement.AeroelasticAirplaneMovement(
            base_airplane=example_airplane,
            wing_movements=[
                main_wing_movement,
                reflected_main_wing_movement,
                v_tail_wing_movement,
            ],
            ampCg_GP1_CgP1=(0.0, 0.0, 0.0),
            periodCg_GP1_CgP1=(0.0, 0.0, 0.0),
            spacingCg_GP1_CgP1=("sine", "sine", "sine"),
            phaseCg_GP1_CgP1=(0.0, 0.0, 0.0),
        )
    )

    example_operating_point = ps.operating_point.OperatingPoint(
        rho=1.225, vCg__E=10.0, alpha=0.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6
    )

    example_operating_point_movement = (
        ps.movements.operating_point_movement.OperatingPointMovement(
            base_operating_point=example_operating_point,
            ampVCg__E=0.0,
            periodVCg__E=0.0,
            spacingVCg__E="sine",
        )
    )

    example_movement = ps.movements.aeroelastic_movement.AeroelasticMovement(
        airplane_movements=[example_airplane_movement],
        operating_point_movement=example_operating_point_movement,
        delta_time=0.03,
        num_steps=100,
    )

    example_problem = ps.problems.AeroelasticUnsteadyProblem(
        movement=example_movement,
        wing_density=wing_density,
        spring_constant_rad=spring_constant_rad,
        damping_constant_rad=damping_constant_rad,
        step_discards=5,
    )

    example_solver = ps.aeroelastic_unsteady_ring_vortex_lattice_method.AeroelasticUnsteadyRingVortexLatticeMethodSolver(
        aeroelastic_unsteady_problem=example_problem,
    )

    example_solver.run(
        prescribed_wake=True,
    )

    solved_problem = example_solver.unsteady_problem
    assert isinstance(solved_problem, ps.problems.AeroelasticUnsteadyProblem)

    return solved_problem.listDeformationAnglesYRad_Wcsp_to_Wcs_ixyz[0], solved_problem


# Determine which parameter is being swept.
active_sweeps = sum(1 for v in (K_VALUES_RAD, B_VALUES_RAD, DENSITY_VALUES) if v)
if active_sweeps > 1:
    raise ValueError(
        "Only one of K_VALUES_RAD, B_VALUES_RAD, or DENSITY_VALUES should be non-empty."
    )
if active_sweeps == 0:
    raise ValueError(
        "At least one of K_VALUES_RAD, B_VALUES_RAD, or DENSITY_VALUES must be "
        "non-empty."
    )

if K_VALUES_RAD:
    sweep_values = K_VALUES_RAD
    sweep_name = "Spring Constant"
    sweep_symbol = "k"
    sweep_kwarg = "spring_constant_rad"
elif B_VALUES_RAD:
    sweep_values = B_VALUES_RAD
    sweep_name = "Damping Constant"
    sweep_symbol = "b"
    sweep_kwarg = "damping_constant_rad"
else:
    sweep_values = DENSITY_VALUES
    sweep_name = "Wing Density"
    sweep_symbol = "rho"
    sweep_kwarg = "wing_density"

# Run for each swept value and collect the wing-tip twist history.
results = {}
flap_angle = None
for val in sweep_values:
    print(f"Running with {sweep_symbol}={val}...")
    deformationAnglesYRad_Wcsp_to_Wcs_ixyz, problem = run_aeroelastic(
        **{sweep_kwarg: val}
    )
    # Extract the wing-tip torsional angle from every time series entry (the first entry
    # is the pre-solve seed state, and each later entry is one time step's state),
    # converting it from radians to degrees.
    tip_twist = np.rad2deg(
        np.array(deformationAnglesYRad_Wcsp_to_Wcs_ixyz)[:, TIP_INDEX]
    ).tolist()
    results[val] = tip_twist
    print(f"  Completed {sweep_symbol}={val}, {len(tip_twist)} entries")

    # Compute the prescribed flap angle once (it is the same for all runs).
    if flap_angle is None:
        phase_rad = np.deg2rad(FLAP_PHASE)
        dt = problem.movement.delta_time
        num_steps = len(tip_twist)
        t = np.arange(num_steps) * dt
        flap_angle = (
            FLAP_AMPLITUDE * np.sin((2 * np.pi / FLAP_PERIOD) * t + phase_rad)
        ).tolist()

# Create an overlay plot of the wing-tip twist histories for all swept values.
assert flap_angle is not None
plt.figure(figsize=(12, 6), dpi=200)
for val, curve in results.items():
    plt.plot(range(len(curve)), curve, label=f"{sweep_symbol}={val}")
plt.plot(
    range(len(flap_angle)),
    flap_angle,
    label="Flap Position",
    color="black",
    linestyle="--",
)
plt.xlabel("Step")
plt.ylabel("Angle (degrees)")
plt.title(f"Wing-Tip Twist - Varying {sweep_name}")
plt.legend()
plt.grid(True)
filename = f"wing_tip_twist_{sweep_name.lower().replace(' ', '_')}.png"
plt.savefig(filename)
plt.show()

Output

Wing-tip twist histories overlaid for four torsional spring constants, with the prescribed flap position for reference.