From 70acab89c4b1f8db61349e65420a91d2805b2832 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 17 Nov 2025 17:44:20 -0500 Subject: [PATCH 01/40] working replacement of same --- examples/demos/demo_wing.py | 328 +++ pterasoftware/__init__.py | 4 + pterasoftware/_functions.py | 12 +- ...led_unsteady_ring_vortex_lattice_method.py | 1763 +++++++++++++++++ pterasoftware/output.py | 30 +- pterasoftware/problems.py | 145 ++ .../unsteady_ring_vortex_lattice_method.py | 3 - 7 files changed, 2273 insertions(+), 12 deletions(-) create mode 100644 examples/demos/demo_wing.py create mode 100644 pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py diff --git a/examples/demos/demo_wing.py b/examples/demos/demo_wing.py new file mode 100644 index 000000000..ccef73f7f --- /dev/null +++ b/examples/demos/demo_wing.py @@ -0,0 +1,328 @@ +"""This is script is an example of how to run Ptera Software's +UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static +Movement.""" + +# First, import the software's main package. Note that if you wished to import this +# software into another package, you would first install it by running "pip install +# pterasoftware" in your terminal. +import pterasoftware as ps + +# Create an Airplane with our custom geometry. I am going to declare every parameter +# for Airplane, even though most of them have usable default values. This is for +# educational purposes, but keep in mind that it makes the code much longer than it +# needs to be. For details about each parameter, read the detailed class docstring. +# The same caveats apply to the other classes, methods, and functions I call in this +# script. + + +# offsets for the spacing +num_spanwise_panels = 1 +Lp_Wcsp_Lpp_Offsets = (0.1, 0.5, 0.1) + +# Wing cross section initialization +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="cosine" 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, + ), + ) + ) + + +example_airplane = ps.geometry.airplane.Airplane( + wings=[ + 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), + num_chordwise_panels=6, + chordwise_spacing="uniform", + ), + 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, 1.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), + num_chordwise_panels=6, + chordwise_spacing="uniform", + ), + ], + name="Example Airplane", + Cg_E_CgP1=(0.0, 0.0, 0.0), + angles_E_to_B_izyx=(0.0, 0.0, 0.0), + weight=0.0, + c_ref=None, + b_ref=None, +) + +# The main Wing was defined to have symmetric=True, mirror_only=False, and with a +# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, +# that Wing had type 5 symmetry (see the Wing class documentation for more details on +# symmetry types). Therefore, it was actually split into two Wings, the with the +# second Wing being a reflected version of the first. Therefore, we need to define a +# WingMovement for this reflected Wing. To start, we'll first define the reflected +# main wing's root and tip WingCrossSections' WingCrossSectionMovements. + +# defintions for wing movement parameters +dephase_x = 0.0 +period_x = 1.0 +amplitude_x = 2.0 + +dephase_y = 0.0 +period_y = 1.0 +amplitude_y = 3.0 + +# dephase_x = 0.0 +# period_x = 1.0 +# amplitude_x = 5.0 + +# dephase_y = 0.0 +# period_y = 1.0 +# amplitude_y = 10.0 + +dephase_z = 0.0 +period_z = 0.0 +amplitude_z = 0.0 + +# A list of movements for the main wing +main_movements_list = [] + +# A list of movements for the reflected wing +reflected_movements_list = [] + +for i in range(len(example_airplane.wings[0].wing_cross_sections)): + if i == 0: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], + ) + main_movements_list.append(movement) + reflected_movements_list.append(movement) + else: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + 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_movements_list.append(movement) + reflected_movements_list.append(movement) + + +# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. +v_tail_root_wing_cross_section_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_wing_cross_section_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), + ) +) + +# Now define the main wing's WingMovement, the reflected main wing's WingMovement and +# the v-tail's WingMovement. +main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[0], + wing_cross_section_movements=main_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), +) +reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[1], + wing_cross_section_movements=reflected_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), +) +v_tail_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[2], + wing_cross_section_movements=[ + v_tail_root_wing_cross_section_movement, + v_tail_tip_wing_cross_section_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), +) + +# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now +# contained within the WingMovements. This is optional, but it can make debugging +# easier. +del v_tail_root_wing_cross_section_movement +del v_tail_tip_wing_cross_section_movement + +# Now define the example airplane's AirplaneMovement. +airplane_movement = ps.movements.airplane_movement.AirplaneMovement( + base_airplane=example_airplane, + wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_movement], + ampCg_E_CgP1=(0.0, 0.0, 0.0), + periodCg_E_CgP1=(0.0, 0.0, 0.0), + spacingCg_E_CgP1=("sine", "sine", "sine"), + phaseCg_E_CgP1=(0.0, 0.0, 0.0), + ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), + phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), +) + +# Delete the extraneous pointers to the WingMovements. +del main_wing_movement +del reflected_main_wing_movement +del v_tail_movement + +# Define a new OperatingPoint. +example_operating_point = ps.operating_point.OperatingPoint( + rho=1.225, vCg__E=10.0, alpha=1.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" +) + +# Delete the extraneous pointer. +del example_operating_point + +# Define the Movement. This contains the AirplaneMovement and the +# OperatingPointMovement. +movement = ps.movements.movement.Movement( + airplane_movements=[airplane_movement], + operating_point_movement=operating_point_movement, + delta_time=0.03, + num_cycles=2, + num_chords=None, + num_steps=None, +) + +# Delete the extraneous pointers. +del airplane_movement +del operating_point_movement + +# Define the UnsteadyProblem. +example_problem = ps.problems.CoupledUnsteadyProblem( + movement=movement, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, +) + +# Delete the extraneous pointer. +del example_problem + +# Run the solver. +example_solver.run( + logging_level="Warning", + prescribed_wake=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=False, +) diff --git a/pterasoftware/__init__.py b/pterasoftware/__init__.py index d50e26dea..34c75ada6 100644 --- a/pterasoftware/__init__.py +++ b/pterasoftware/__init__.py @@ -34,6 +34,9 @@ unsteady_ring_vortex_lattice_method.py: This module contains the class definition of this package's unsteady ring vortex lattice solver. + + coupled_unsteady_ring_vortex_lattice_method.py: This module contains the class definition + of this package's unsteady ring vortex lattice solver. """ import pterasoftware.geometry @@ -46,3 +49,4 @@ import pterasoftware.steady_ring_vortex_lattice_method import pterasoftware.trim import pterasoftware.unsteady_ring_vortex_lattice_method +import pterasoftware.coupled_unsteady_ring_vortex_lattice_method diff --git a/pterasoftware/_functions.py b/pterasoftware/_functions.py index b899a79f7..95a1a4998 100644 --- a/pterasoftware/_functions.py +++ b/pterasoftware/_functions.py @@ -13,6 +13,7 @@ from . import steady_horseshoe_vortex_lattice_method from . import steady_ring_vortex_lattice_method from . import unsteady_ring_vortex_lattice_method +from . import coupled_unsteady_ring_vortex_lattice_method # TEST: Consider adding unit tests for this function. @@ -96,13 +97,14 @@ def numba_centroid_of_quadrilateral( return np.array([x_average, y_average, z_average]) - +# TODO: make subclassing of solver for ease # TEST: Consider adding unit tests for this function. def calculate_streamlines( solver: ( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver | steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver | unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ), num_steps=25, delta_time=0.02, @@ -199,6 +201,7 @@ def process_solver_loads( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver | steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver | unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ), stackPanelForces_GP1, stackPanelMoments_GP1_CgP1, @@ -241,8 +244,10 @@ def process_solver_loads( these_airplanes = solver.airplanes this_operating_point = solver.operating_point elif isinstance( - solver, - unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + solver, ( + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, + ) ): these_airplanes = solver.current_airplanes this_operating_point = solver.current_operating_point @@ -350,6 +355,7 @@ def update_ring_vortex_solvers_panel_attributes( ring_vortex_solver: ( steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver | unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ), global_panel_position: int, panel: _panel.Panel, diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py new file mode 100644 index 000000000..c6dfe8f75 --- /dev/null +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,1763 @@ +"""This module contains the class definition of this package's unsteady ring vortex +lattice solver. + +This module contains the following classes: + UnsteadyRingVortexLatticeMethodSolver: This is an aerodynamics solver that uses + an unsteady ring vortex lattice method. + +This module contains the following functions: + None +""" + +import logging +from typing import cast + +import numpy as np +from tqdm import tqdm + +from . import _aerodynamics, operating_point, movements +from . import _functions +from . import _parameter_validation +from . import _panel +from . import geometry +from . import problems + + +# TEST: Consider adding unit tests for this function. +# TEST: Assess how comprehensive this function's integration tests are and update or +# extend them if needed. +class CoupledUnsteadyRingVortexLatticeMethodSolver(): + """This is an aerodynamics solver that uses an unsteady ring vortex lattice method. + + This class contains the following public methods: + + run: This method runs the solver on the UnsteadyProblem. + + calculate_solution_velocity: This function takes in a group of points (in the + first Airplane's geometry axes, relative to the first Airplane's CG). At + every point, it finds the fluid velocity (in the first Airplane's geometry + axes, observed from the Earth frame) at that point due to the freestream + velocity and the induced velocity from every RingVortex. + + This class contains the following class attributes: + None + + """ + + def __init__(self, coupled_unsteady_problem): + """This is the initialization method. + + :param unsteady_problem: UnsteadyProblem + This is the UnsteadyProblem to be solved. + :return: None + """ + if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): + raise TypeError("unsteady_problem must be an UnsteadyProblem.") + self.coupled_unsteady_problem: problems.CoupledUnsteadyProblem = coupled_unsteady_problem + + self.num_steps = self.coupled_unsteady_problem.num_steps + self.delta_time = self.coupled_unsteady_problem.delta_time + self.first_results_step = self.coupled_unsteady_problem.first_results_step + self._first_averaging_step = self.coupled_unsteady_problem.first_averaging_step + self._current_step = None + + self.steady_problems = [] + + self.current_airplanes = None + self.current_operating_point = None + first_steady_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(0) + self.num_airplanes = len(first_steady_problem.airplanes) + num_panels = 0 + airplane: geometry.airplane.Airplane + for airplane in first_steady_problem.airplanes: + num_panels += airplane.num_panels + self.num_panels = num_panels + + # Initialize attributes to hold aerodynamic data that pertain to the simulation. + self._currentVInf_GP1__E = None + self._currentStackFreestreamWingInfluences__E = None + self._currentGridWingWingInfluences__E = None + self._currentStackWakeWingInfluences__E = None + self._current_bound_vortex_strengths = None + self._last_bound_vortex_strengths = None + + # Initialize attributes to hold geometric data that pertain to this + # UnsteadyProblem. + self.panels = None + self.stackUnitNormals_GP1 = None + self.panel_areas = None + + # The current and last time step's collocation panel points (in the first + # Airplane's geometry axes, relative to the first Airplane's CG). + self.stackCpp_GP1_CgP1 = None + self._stackLastCpp_GP1_CgP1 = None + + # The current and last time step's back-right, front-right, front-left, + # and back-left bound RingVortex points (in the first Airplane's geometry + # axes, relative to the first Airplane's CG). + self.stackBrbrvp_GP1_CgP1 = None + self.stackFrbrvp_GP1_CgP1 = None + self.stackFlbrvp_GP1_CgP1 = None + self.stackBlbrvp_GP1_CgP1 = None + self._lastStackBrbrvp_GP1_CgP1 = None + self._lastStackFrbrvp_GP1_CgP1 = None + self._lastStackFlbrvp_GP1_CgP1 = None + self._lastStackBlbrvp_GP1_CgP1 = None + + # The current and last time step's center bound LineVortex points for the + # right, front, left, and back legs (in the first Airplane's geometry axes, + # relative to the first Airplane's CG). + self.stackCblvpr_GP1_CgP1 = None + self.stackCblvpf_GP1_CgP1 = None + self.stackCblvpl_GP1_CgP1 = None + self.stackCblvpb_GP1_CgP1 = None + self._lastStackCblvpr_GP1_CgP1 = None + self._lastStackCblvpf_GP1_CgP1 = None + self._lastStackCblvpl_GP1_CgP1 = None + self._lastStackCblvpb_GP1_CgP1 = None + + # Right, front, left, and back bound RingVortex vectors (in the first + # Airplane's geometry axes). + self.stackRbrv_GP1 = None + self.stackFbrv_GP1 = None + self.stackLbrv_GP1 = None + self.stackBbrv_GP1 = None + + # Initialize variables to hold aerodynamic data that pertains details about + # each Panel's location on its Wing. + self.panel_is_trailing_edge = None + self.panel_is_leading_edge = None + self.panel_is_left_edge = None + self.panel_is_right_edge = None + + # Initialize variables to hold aerodynamic data that pertains to the wake at + # the current time step. + self._current_wake_vortex_strengths = None + self._current_wake_vortex_ages = None + + # The current time step's back-right, front-right, front-left, and back-left + # wake RingVortex points (in the first Airplane's geometry axes, relative to + # the first Airplane's CG). + self._currentStackBrwrvp_GP1_CgP1 = None + self._currentStackFrwrvp_GP1_CgP1 = None + self._currentStackFlwrvp_GP1_CgP1 = None + self._currentStackBlwrvp_GP1_CgP1 = None + + # Initialize lists to store aerodynamic data about the wake at each time + # step. These attributes are used by the output module to animate the wake. + self.list_num_wake_vortices = [] + # TODO: Determine if these private attributes are needed and if not + # delete them. + self._list_wake_vortex_strengths = [] + self._list_wake_vortex_ages = [] + self.listStackBrwrvp_GP1_CgP1 = [] + self.listStackFrwrvp_GP1_CgP1 = [] + self.listStackFlwrvp_GP1_CgP1 = [] + self.listStackBlwrvp_GP1_CgP1 = [] + + self.stackSeedPoints_GP1_CgP1 = None + self.gridStreamlinePoints_GP1_CgP1 = None + + def run( + self, + logging_level="Warning", + prescribed_wake=True, + calculate_streamlines=True, + ): + """This method runs the solver on the UnsteadyProblem. + + :param logging_level: str, optional + + This parameter determines the detail of information that the solver's + logger will output while running. The options are, in order of detail and + severity, "Debug", "Info", "Warning", "Error", "Critical". The default + value is "Warning". + + :param prescribed_wake: boolLike, optional + + This parameter determines if the solver uses a prescribed wake model. If + False it will use a free-wake, which may be more accurate but will make + the solver significantly slower. The default is True. It can be a boolean + or a NumPy boolean and will be converted internally to a boolean. + + :param calculate_streamlines: boolLike, optional + + This parameter determines if the solver calculates streamlines emanating + from the back of the wing after running the solver. It can be a boolean + or a NumPy boolean and will be converted internally to a boolean. + + :return: None + """ + logging_level = _parameter_validation.string_return_string( + logging_level, "logging_level" + ) + logging_level_value = _functions.convert_logging_level_name_to_value( + logging_level + ) + logging.basicConfig(level=logging_level_value) + + prescribed_wake = _parameter_validation.boolLike_return_bool( + prescribed_wake, "prescribed_wake" + ) + calculate_streamlines = _parameter_validation.boolLike_return_bool( + calculate_streamlines, "calculate_streamlines" + ) + # Initialize list that will hold the approximate, relative times. This has + # one more element than the number of time steps, because I will also use the + # progress bar during the simulation initialization. + approx_times = np.zeros(self.num_steps + 1, dtype=float) + + # Here we calculate all of our values from our first ariplane to start our main run loop + this_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(0) + these_airplanes = this_problem.airplanes + + # Loop through this time step's Airplanes to create a list of their Wings. + # Additionally, to get the total number of wing panels. + these_wings = [] + num_wing_panels = 0 + airplane: geometry.airplane.Airplane + for airplane in these_airplanes: + these_wings.append(airplane.wings) + num_wing_panels += airplane.num_panels + + # Iterate through the Wings to get the total number of spanwise Panels. + this_num_spanwise_panels = 0 + for this_wing_set in these_wings: + this_wing: geometry.wing.Wing + for this_wing in this_wing_set: + this_num_spanwise_panels += this_wing.num_spanwise_panels + + for step in range(self.num_steps): + # The number of wake RingVortices is the time step number multiplied by + # the number of spanwise Panels. This works because the first time step + # number is 0. + this_num_wake_ring_vortices = step * this_num_spanwise_panels + + # Allocate the ndarrays for this time step. + this_wake_ring_vortex_strengths = np.zeros( + this_num_wake_ring_vortices, dtype=float + ) + this_wake_ring_vortex_ages = np.zeros( + this_num_wake_ring_vortices, dtype=float + ) + thisStackBrwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + thisStackFrwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + thisStackFlwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + thisStackBlwrvp_GP1_CgP1 = np.zeros( + (this_num_wake_ring_vortices, 3), dtype=float + ) + # Append this time step's ndarrays to the lists of ndarrays. + self.list_num_wake_vortices.append(this_num_wake_ring_vortices) + self._list_wake_vortex_strengths.append(this_wake_ring_vortex_strengths) + self._list_wake_vortex_ages.append(this_wake_ring_vortex_ages) + self.listStackBrwrvp_GP1_CgP1.append(thisStackBrwrvp_GP1_CgP1) + self.listStackFrwrvp_GP1_CgP1.append(thisStackFrwrvp_GP1_CgP1) + self.listStackFlwrvp_GP1_CgP1.append(thisStackFlwrvp_GP1_CgP1) + self.listStackBlwrvp_GP1_CgP1.append(thisStackBlwrvp_GP1_CgP1) + + if step != 0: + # The following loop attempts to predict how much time each time step will + # take, relative to the other time steps. This data will be used to generate + # estimates of how much longer a simulation will take, and create a smoothly + # advancing progress bar. + num_ring_vortices = num_wing_panels + this_num_wake_ring_vortices + # The following constant multipliers were determined empirically. Thus + # far, they seem to provide for adequately smooth progress bar updating. + if step == 1: + approx_times[step] = num_ring_vortices * 70 + elif step == 2: + approx_times[step] = num_ring_vortices * 30 + else: + approx_times[step] = num_ring_vortices * 3 + + approx_partial_time = np.sum(approx_times) + approx_times[0] = round(approx_partial_time / 100) + approx_total_time = np.sum(approx_times) + + # Unless the logging level is at or above Warning, run the simulation with a + # progress bar. + with tqdm( + total=approx_total_time, + unit="", + unit_scale=True, + ncols=100, + desc="Simulating", + disable=logging_level_value != logging.WARNING, + bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " + "Remaining: {remaining}", + ) as bar: + # Update the progress bar based on the initialization step's predicted + # approximate, relative computing time. + bar.update(n=float(approx_times[0])) + + # Iterate through the time steps. + for step in range(self.num_steps): + # Save attributes to hold the current step, Airplanes, + # and OperatingPoint, and freestream velocity (in the first + # Airplane's geometry axes, observed from the Earth frame). + self._current_step = step + current_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(self._current_step) + # Initialize this Airplanes' bound RingVortices. + self._initialize_panel_vortex(current_problem, step) + self.current_airplanes = current_problem.airplanes + self.current_operating_point: operating_point.OperatingPoint = ( + current_problem.operating_point + ) + self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E + logging.info( + "Beginning time step " + + str(self._current_step) + + " out of " + + str(self.num_steps - 1) + + "." + ) + + # TODO: I think these steps are redundant, at least during the first + # time step. Consider dropping them. + # Initialize attributes to hold aerodynamic data that pertain to the + # simulation at this time step. + self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E + self._currentStackFreestreamWingInfluences__E = np.zeros( + self.num_panels, dtype=float + ) + self._currentGridWingWingInfluences__E = np.zeros( + (self.num_panels, self.num_panels), dtype=float + ) + self._currentStackWakeWingInfluences__E = np.zeros( + self.num_panels, dtype=float + ) + self._current_bound_vortex_strengths = np.ones( + self.num_panels, dtype=float + ) + self._last_bound_vortex_strengths = np.zeros( + self.num_panels, dtype=float + ) + + # Initialize attributes to hold geometric data that pertain to this + # UnsteadyProblem. + self.panels = np.empty(self.num_panels, dtype=object) + self.stackUnitNormals_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.panel_areas = np.zeros(self.num_panels, dtype=float) + + self.stackCpp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self._stackLastCpp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + + self.stackBrbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackFrbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackFlbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackBlbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self._lastStackBrbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackFrbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackFlbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackBlbrvp_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + + self.stackCblvpr_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpf_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpl_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpb_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self._lastStackCblvpr_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackCblvpf_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackCblvpl_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + self._lastStackCblvpb_GP1_CgP1 = np.zeros( + (self.num_panels, 3), dtype=float + ) + + self.stackRbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackFbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackLbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackBbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) + + # Initialize variables to hold details about each Panel's location on + # its Wing. + self.panel_is_trailing_edge = np.zeros(self.num_panels, dtype=bool) + self.panel_is_leading_edge = np.zeros(self.num_panels, dtype=bool) + self.panel_is_left_edge = np.zeros(self.num_panels, dtype=bool) + self.panel_is_right_edge = np.zeros(self.num_panels, dtype=bool) + + # Get the pre-allocated (but still all zero) arrays of wake + # information that are associated with this time step. + self._current_wake_vortex_strengths = self._list_wake_vortex_strengths[ + step + ] + self._current_wake_vortex_ages = self._list_wake_vortex_ages[step] + self._currentStackBrwrvp_GP1_CgP1 = self.listStackBrwrvp_GP1_CgP1[step] + self._currentStackFrwrvp_GP1_CgP1 = self.listStackFrwrvp_GP1_CgP1[step] + self._currentStackFlwrvp_GP1_CgP1 = self.listStackFlwrvp_GP1_CgP1[step] + self._currentStackBlwrvp_GP1_CgP1 = self.listStackBlwrvp_GP1_CgP1[step] + + self.stackSeedPoints_GP1_CgP1 = np.zeros((0, 3), dtype=float) + + # Collapse the geometry matrices into 1D ndarrays of attributes. + logging.info("Collapsing the geometry.") + self._collapse_geometry() + + # Find the matrix of Wing-Wing influence coefficients associated with + # the Airplanes' geometries at this time step. + logging.info("Calculating the Wing-Wing influences.") + self._calculate_wing_wing_influences() + + # Find the normal velocity (in the first Airplane's geometry axes, + # observed from the Earth frame) at every collocation point due + # solely to the freestream. + logging.info("Calculating the freestream-Wing influences.") + self._calculate_freestream_wing_influences() + + # Find the normal velocity (in the first Airplane's geometry axes, + # observed from the Earth frame) at every collocation point due + # solely to the wake RingVortices. + logging.info("Calculating the wake-Wing influences.") + self._calculate_wake_wing_influences() + + # Solve for each bound RingVortex's strength. + logging.info("Calculating bound RingVortex strengths.") + self._calculate_vortex_strengths() + + # Solve for the forces (in the first Airplane's geometry axes) and + # moments (in the first Airplane's geometry axes, relative to the + # first Airplane's CG) on each Panel. + if self._current_step >= self.first_results_step: + logging.info("Calculating forces and moments.") + self._calculate_loads() + + # Check if the current time step is not the last step. + if self._current_step < self.num_steps - 1: + self.coupled_unsteady_problem.initialize_next_problem() + self._initialize_panel_vortex(self.coupled_unsteady_problem.get_steady_problem(step + 1), step + 1) + # Shed RingVortices into the wake. + logging.info("Shedding RingVortices into the wake.") + self._populate_next_airplanes_wake(prescribed_wake=prescribed_wake) + + # Update the progress bar based on this time step's predicted + # approximate, relative computing time. + self.steady_problems.append(self.coupled_unsteady_problem.get_steady_problem(step)) + bar.update(n=float(approx_times[step + 1])) + + logging.info("Calculating averaged or final forces and moments.") + self._finalize_loads() + + # Solve for the location of the streamlines coming off the Wings' trailing + # edges, if requested. + if calculate_streamlines: + logging.info("Calculating streamlines.") + _functions.calculate_streamlines(self) + + def _initialize_panel_vortex(self, steady_problem: problems.SteadyProblem, steady_problem_id: int): + """This method calculates the locations of the Airplanes' bound RingVortices' + points, and then initializes the bound RingVortices. + + Every Panel has a RingVortex, which is a quadrangle whose front leg is a + LineVortex at the Panel's quarter chord. The left and right legs are + LineVortices running along the Panel's left and right legs. If the Panel is + not along the trailing edge, they extend backwards and meet the back + LineVortex, at the rear Panel's quarter chord. Otherwise, they extend + backwards and meet the back LineVortex one quarter chord back from the + Panel's back leg. + + :return: None + """ + + # Find the freestream velocity (in the first Airplane's geometry axes, + # observed from the Earth frame) at this time step. + this_operating_point: operating_point.OperatingPoint = ( + steady_problem.operating_point + ) + vInf_GP1__E = this_operating_point.vInf_GP1__E + + # Iterate through this SteadyProblem's Airplanes' Wings. + airplane: geometry.airplane.Airplane + for airplane_id, airplane in enumerate(steady_problem.airplanes): + wing: geometry.wing.Wing + for wing_id, wing in enumerate(airplane.wings): + + # Iterate through the Wing's chordwise and spanwise positions. + for chordwise_position in range(wing.num_chordwise_panels): + for spanwise_position in range(wing.num_spanwise_panels): + # Pull the Panel out of the Wing's 2D ndarray of Panels. + panel: _panel.Panel = wing.panels[ + chordwise_position, spanwise_position + ] + + # Find the location of this Panel's front-left and + # front-right RingVortex points (in the first Airplane's + # geometry axes, relative to the first Airplane's CG). + Flrvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1 + Frrvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1 + + # Define the location of the back-left and back-right + # RingVortex points based on whether the Panel is along + # the trailing edge or not. + if not panel.is_trailing_edge: + next_chordwise_panel = wing.panels[ + chordwise_position + 1, spanwise_position + ] + Blrvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1 + Brrvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1 + else: + # As these vertices are directly behind the trailing + # edge, they are spaced back from their Panel's + # vertex by one quarter of the distance traveled by + # the trailing edge during a time step. This is to + # more accurately predict drag. More information can + # be found on pages 37-39 of "Modeling of aerodynamic + # forces in flapping flight with the Unsteady Vortex + # Lattice Method" by Thomas Lambert. + if steady_problem_id == 0: + Blrvp_GP1_CgP1 = ( + panel.Blpp_GP1_CgP1 + + vInf_GP1__E * self.delta_time * 0.25 + ) + Brrvp_GP1_CgP1 = ( + panel.Brpp_GP1_CgP1 + + vInf_GP1__E * self.delta_time * 0.25 + ) + else: + last_steady_problem = self.coupled_unsteady_problem.get_steady_problem( + steady_problem_id - 1 + ) + last_airplane = last_steady_problem.airplanes[ + airplane_id + ] + last_wing = last_airplane.wings[wing_id] + last_panel = last_wing.panels[ + chordwise_position, spanwise_position + ] + + thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 + lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1 + + # We subtract (thisBlpp_GP1_CgP1 - + # lastBlpp_GP1_CgP1) / self.delta_time from + # vInf_GP1__E, because we want the apparent fluid + # velocity due to motion (observed in the Earth + # frame, in the first Airplane's geometry axes). + # This is the vector pointing opposite the + # velocity from motion. + Blrvp_GP1_CgP1 = ( + thisBlpp_GP1_CgP1 + + ( + vInf_GP1__E + - (thisBlpp_GP1_CgP1 - lastBlpp_GP1_CgP1) + / self.delta_time + ) + * self.delta_time + * 0.25 + ) + + thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 + lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1 + + # The comment from above about apparent fluid + # velocity due to motion applies here as well. + Brrvp_GP1_CgP1 = ( + thisBrpp_GP1_CgP1 + + ( + vInf_GP1__E + - (thisBrpp_GP1_CgP1 - lastBrpp_GP1_CgP1) + / self.delta_time + ) + * self.delta_time + * 0.25 + ) + + # Initialize the Panel's RingVortex. + panel.ring_vortex = _aerodynamics.RingVortex( + Flrvp_GP1_CgP1=Flrvp_GP1_CgP1, + Frrvp_GP1_CgP1=Frrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brrvp_GP1_CgP1, + strength=None, + ) + + def _collapse_geometry(self): + """This method converts attributes of the UnsteadyProblem's geometry into 1D + ndarrays. This facilitates vectorization, which speeds up the solver. + + :return: None + """ + # Initialize variables to hold the global position of the Panel and the wake + # RingVortex as we iterate through them. + global_panel_position = 0 + global_wake_ring_vortex_position = 0 + + # Iterate through the current time step's Airplanes' Wings. + airplane: geometry.airplane.Airplane + for airplane in self.current_airplanes: + wing: geometry.wing.Wing + for wing in airplane.wings: + + # Convert this Wing's 2D ndarray of Panels and wake RingVortices into + # 1D ndarrays. + panels = np.ravel(wing.panels) + wake_ring_vortices = np.ravel(wing.wake_ring_vortices) + + # Iterate through the 1D ndarray of this Wing's Panels. + panel: _panel.Panel + for panel in panels: + # Update the solver's list of attributes with this Panel's + # attributes. + _functions.update_ring_vortex_solvers_panel_attributes( + ring_vortex_solver=self, + global_panel_position=global_panel_position, + panel=panel, + ) + + # Increment the global Panel position variable. + global_panel_position += 1 + + # Iterate through the 1D ndarray of this Wing's wake RingVortices. + wake_ring_vortex: _aerodynamics.RingVortex + for wake_ring_vortex in wake_ring_vortices: + # Update the solver's list of attributes with this wake + # RingVortex's attributes. + self._current_wake_vortex_strengths[ + global_wake_ring_vortex_position + ] = wake_ring_vortex.strength + self._current_wake_vortex_ages[global_wake_ring_vortex_position] = ( + wake_ring_vortex.age + ) + self._currentStackFrwrvp_GP1_CgP1[ + global_wake_ring_vortex_position, : + ] = wake_ring_vortex.Frrvp_GP1_CgP1 + self._currentStackFlwrvp_GP1_CgP1[ + global_wake_ring_vortex_position, : + ] = wake_ring_vortex.Flrvp_GP1_CgP1 + self._currentStackBlwrvp_GP1_CgP1[ + global_wake_ring_vortex_position, : + ] = wake_ring_vortex.Blrvp_GP1_CgP1 + self._currentStackBrwrvp_GP1_CgP1[ + global_wake_ring_vortex_position, : + ] = wake_ring_vortex.Brrvp_GP1_CgP1 + + # Increment the global wake RingVortex position variable. + global_wake_ring_vortex_position += 1 + + if self._current_step > 0: + + # Reset the global Panel position variable. + global_panel_position = 0 + + last_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem( + self._current_step - 1 + ) + last_airplanes = last_problem.airplanes + + # Iterate through the last time step's Airplanes' Wings. + last_airplane: geometry.airplane.Airplane + for last_airplane in last_airplanes: + wing: geometry.wing.Wing + for wing in last_airplane.wings: + + # Convert this Wing's 2D ndarray of Panels into a 1D ndarray. + panels = np.ravel(wing.panels) + + # Iterate through the 1D ndarray of this Wing's Panels. + panel: _panel.Panel + for panel in panels: + # Update the solver's list of attributes with this Panel's + # attributes. + self._stackLastCpp_GP1_CgP1[global_panel_position, :] = ( + panel.Cpp_GP1_CgP1 + ) + + this_ring_vortex: _aerodynamics.RingVortex = panel.ring_vortex + self._last_bound_vortex_strengths[global_panel_position] = ( + this_ring_vortex.strength + ) + + # TODO: Test if we can replace the calls to LineVortex + # attributes with calls to RingVortex attributes. + self._lastStackBrbrvp_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.right_leg.Slvp_GP1_CgP1 + ) + self._lastStackFrbrvp_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.right_leg.Elvp_GP1_CgP1 + ) + self._lastStackFlbrvp_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.left_leg.Slvp_GP1_CgP1 + ) + self._lastStackBlbrvp_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.left_leg.Elvp_GP1_CgP1 + ) + self._lastStackCblvpr_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.right_leg.Clvp_GP1_CgP1 + ) + self._lastStackCblvpf_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.front_leg.Clvp_GP1_CgP1 + ) + self._lastStackCblvpl_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.left_leg.Clvp_GP1_CgP1 + ) + self._lastStackCblvpb_GP1_CgP1[global_panel_position, :] = ( + this_ring_vortex.back_leg.Clvp_GP1_CgP1 + ) + + # Increment the global Panel position variable. + global_panel_position += 1 + + def _calculate_wing_wing_influences(self): + """This method finds the 2d ndarray of Wing-Wing influence coefficients ( + observed from the Earth frame). + + :return: None + """ + # Find the 2D ndarray of normalized velocities (in the first Airplane's + # geometry axes, observed from the Earth frame) induced at each Panel's + # collocation point by each bound RingVortex. The answer is normalized + # because the solver's list of bound RingVortex strengths was initialized to + # all be 1.0. This will be updated once the correct strengths are calculated. + gridNormVIndCpp_GP1_E = _aerodynamics.expanded_velocities_from_ring_vortices( + stackP_GP1_CgP1=self.stackCpp_GP1_CgP1, + stackBrrvp_GP1_CgP1=self.stackBrbrvp_GP1_CgP1, + stackFrrvp_GP1_CgP1=self.stackFrbrvp_GP1_CgP1, + stackFlrvp_GP1_CgP1=self.stackFlbrvp_GP1_CgP1, + stackBlrvp_GP1_CgP1=self.stackBlbrvp_GP1_CgP1, + strengths=self._current_bound_vortex_strengths, + ages=None, + nu=self.current_operating_point.nu, + ) + + # Take the batch dot product of the normalized induced velocities (in the + # first Airplane's geometry axes, observed from the Earth frame) with each + # Panel's unit normal direction (in the first Airplane's geometry axes). This + # is now the 2D ndarray of Wing-Wing influence coefficients (observed from + # the Earth frame). + self._currentGridWingWingInfluences__E = np.einsum( + "...k,...k->...", + gridNormVIndCpp_GP1_E, + np.expand_dims(self.stackUnitNormals_GP1, axis=1), + ) + + def _calculate_freestream_wing_influences(self): + """This method finds the 1D ndarray of freestream-Wing influence coefficients + (observed from the Earth frame). + + Note: This method also includes the influence coefficients due to motion + defined in Movement (observed from the Earth frame) at every collocation point. + + :return: None + """ + # Find the normal components of the freestream-only-Wing influence + # coefficients (observed from the Earth frame) at each Panel's collocation + # point by taking a batch dot product. + currentStackFreestreamOnlyWingInfluences__E = np.einsum( + "ij,j->i", + self.stackUnitNormals_GP1, + self._currentVInf_GP1__E, + ) + + # Get the current apparent velocities at each Panel's collocation point due + # to any motion defined in Movement (in the first Airplane's geometry axes, + # observed from the Earth frame). + currentStackMovementV_GP1_E = ( + self._calculate_current_movement_velocities_at_collocation_points() + ) + + # Get the current motion influence coefficients at each Panel's collocation + # point (observed from the Earth frame) by taking a batch dot product. + currentStackMovementInfluences__E = np.einsum( + "ij,ij->i", + self.stackUnitNormals_GP1, + currentStackMovementV_GP1_E, + ) + + # Calculate the total current freestream-Wing influence coefficients by + # summing the freestream-only influence coefficients and the motion influence + # coefficients (all observed from the Earth frame). + self._currentStackFreestreamWingInfluences__E = ( + currentStackFreestreamOnlyWingInfluences__E + + currentStackMovementInfluences__E + ) + + def _calculate_wake_wing_influences(self): + """This method finds the 1D ndarray of the wake-Wing influence coefficients ( + observed from the Earth frame) associated with the UnsteadyProblem at the + current time step. + + Note: If the current time step is the first time step, no wake has been shed, + so this method will return zero for all the wake-Wing influence coefficients + (observed from the Earth frame). + + :return: None + """ + if self._current_step > 0: + # Get the velocities (in the first Airplane's geometry axes, observed + # from the Earth frame) induced by the wake RingVortices at each Panel's + # collocation point. + currentStackWakeV_GP1_E = ( + _aerodynamics.collapsed_velocities_from_ring_vortices( + stackP_GP1_CgP1=self.stackCpp_GP1_CgP1, + stackBrrvp_GP1_CgP1=self._currentStackBrwrvp_GP1_CgP1, + stackFrrvp_GP1_CgP1=self._currentStackFrwrvp_GP1_CgP1, + stackFlrvp_GP1_CgP1=self._currentStackFlwrvp_GP1_CgP1, + stackBlrvp_GP1_CgP1=self._currentStackBlwrvp_GP1_CgP1, + strengths=self._current_wake_vortex_strengths, + ages=self._current_wake_vortex_ages, + nu=self.current_operating_point.nu, + ) + ) + + # Get the current wake-Wing influence coefficients (observed from the + # Earth frame) by taking a batch dot product with each Panel's normal + # vector (in the first Airplane's geometry axes). + self._currentStackWakeWingInfluences__E = np.einsum( + "ij,ij->i", currentStackWakeV_GP1_E, self.stackUnitNormals_GP1 + ) + + else: + # If this is the first time step, set all the current Wake-wing influence + # coefficients to 0.0 (observed from the Earth frame) because no wake + # RingVortices have been shed. + self._currentStackWakeWingInfluences__E = np.zeros( + self.num_panels, dtype=float + ) + + def _calculate_vortex_strengths(self): + """Solve for the strength of each Panel's bound RingVortex. + + :return: None + """ + self._current_bound_vortex_strengths = np.linalg.solve( + self._currentGridWingWingInfluences__E, + -self._currentStackWakeWingInfluences__E + - self._currentStackFreestreamWingInfluences__E, + ) + + # Update the bound RingVortices' strengths. + for panel_num in range(self.panels.size): + panel: _panel.Panel = self.panels[panel_num] + this_ring_vortex: _aerodynamics.RingVortex = panel.ring_vortex + + this_ring_vortex.update_strength( + self._current_bound_vortex_strengths[panel_num] + ) + + def calculate_solution_velocity(self, stackP_GP1_CgP1): + """This function takes in a group of points (in the first Airplane's geometry + axes, relative to the first Airplane's CG). At every point, it finds the + fluid velocity (in the first Airplane's geometry axes, observed from the + Earth frame) at that point due to the freestream velocity and the induced + velocity from every RingVortex. + + Note: This method assumes that the correct strengths for the RingVortices and + HorseshoeVortices have already been calculated and set. This method also does + not include the velocity due to the Movement's motion at any of the points + provided, as it has no way of knowing if any of the points lie on panels. + + :param stackP_GP1_CgP1: (N,3) array-like of numbers + + Positions of the evaluation points (in the first Airplane's geometry + axes, relative to the first Airplane's CG). Can be any array-like object + (tuple, list, or ndarray) with size (N, 3) that has numeric elements (int + or float). Values are converted to floats internally. The units are in + meters. + + :return: (N,3) ndarray of floats + + The velocity (in the first Airplane's geometry axes, observed from the + Earth frame) at every evaluation point due to the summed effects of the + freestream velocity and the induced velocity from every RingVortex. The + units are in meters per second. + """ + stackP_GP1_CgP1 = ( + _parameter_validation.arrayLike_of_threeD_number_vectorLikes_return_float( + stackP_GP1_CgP1, "stackP_GP1_CgP1" + ) + ) + + stackBoundRingVInd_GP1_E = ( + _aerodynamics.collapsed_velocities_from_ring_vortices( + stackP_GP1_CgP1=stackP_GP1_CgP1, + stackBrrvp_GP1_CgP1=self.stackBrbrvp_GP1_CgP1, + stackFrrvp_GP1_CgP1=self.stackFrbrvp_GP1_CgP1, + stackFlrvp_GP1_CgP1=self.stackFlbrvp_GP1_CgP1, + stackBlrvp_GP1_CgP1=self.stackBlbrvp_GP1_CgP1, + strengths=self._current_bound_vortex_strengths, + ages=None, + nu=self.current_operating_point.nu, + ) + ) + stackWakeRingVInd_GP1_E = _aerodynamics.collapsed_velocities_from_ring_vortices( + stackP_GP1_CgP1=stackP_GP1_CgP1, + stackBrrvp_GP1_CgP1=self._currentStackBrwrvp_GP1_CgP1, + stackFrrvp_GP1_CgP1=self._currentStackFrwrvp_GP1_CgP1, + stackFlrvp_GP1_CgP1=self._currentStackFlwrvp_GP1_CgP1, + stackBlrvp_GP1_CgP1=self._currentStackBlwrvp_GP1_CgP1, + strengths=self._current_wake_vortex_strengths, + ages=self._current_wake_vortex_ages, + nu=self.current_operating_point.nu, + ) + + return ( + stackBoundRingVInd_GP1_E + + stackWakeRingVInd_GP1_E + + self._currentVInf_GP1__E + ) + + def _calculate_loads(self): + """Calculate the forces (in the first Airplane's geometry axes) and moments ( + in the first Airplane's geometry axes, relative to the first Airplane's CG) + on every Panel. + + Citation: This method uses logic described on pages 9-11 of "Modeling of + aerodynamic forces in flapping flight with the Unsteady Vortex Lattice + Method" by Thomas Lambert. + + Note: This method assumes that the correct strengths for the RingVortices and + HorseshoeVortices have already been calculated and set. + + :return: None + """ + # Initialize a variable to hold the global Panel position as we iterate + # through them. + global_panel_position = 0 + + # Initialize three 1D ndarrays to hold the effective strength of the Panels' + # RingVortices' LineVortices. + effective_right_vortex_line_strengths = np.zeros(self.num_panels, dtype=float) + effective_front_vortex_line_strengths = np.zeros(self.num_panels, dtype=float) + effective_left_vortex_line_strengths = np.zeros(self.num_panels, dtype=float) + + # Iterate through the Airplanes' Wings. + airplane: geometry.airplane.Airplane + for airplane in self.current_airplanes: + wing: geometry.wing.Wing + for wing in airplane.wings: + # Convert this Wing's 2D ndarray of Panels into a 1D ndarray. + panels = np.ravel(wing.panels) + + # Iterate through this Wing's 1D ndarray of Panels. + panel: _panel.Panel + for panel in panels: + + # FIXME: After rereading pages 9-10 of "Modeling of aerodynamic + # forces in flapping flight with the Unsteady Vortex Lattice + # Method" by Thomas Lambert, I think our implementation here is + # critically wrong. Consider we have a wing with a (1,2) ndarray + # of Panels. Let's call them Panel A and Panel B. With our + # current method, we calculate the force on Panel A's right + # LineVortex as though it had a strength of Gamma_a - Gamma_b, + # and the force on Panel B's left LineVortex as Gamma_b - + # Gamma_a. I think these forces will precisely cancel-out! + + if panel.is_right_edge: + # Set the effective right LineVortex strength to this Panel's + # RingVortex's strength. + effective_right_vortex_line_strengths[global_panel_position] = ( + self._current_bound_vortex_strengths[global_panel_position] + ) + else: + panel_to_right: _panel.Panel = wing.panels[ + panel.local_chordwise_position, + panel.local_spanwise_position + 1, + ] + ring_vortex_to_right: _aerodynamics.RingVortex = ( + panel_to_right.ring_vortex + ) + + # Set the effective right LineVortex strength to the + # difference between this Panel's RingVortex's strength, + # and the RingVortex's strength of the Panel to the right. + effective_right_vortex_line_strengths[global_panel_position] = ( + self._current_bound_vortex_strengths[global_panel_position] + - ring_vortex_to_right.strength + ) + + if panel.is_leading_edge: + # Set the effective front LineVortex strength to this Panel's + # RingVortex's strength. + effective_front_vortex_line_strengths[global_panel_position] = ( + self._current_bound_vortex_strengths[global_panel_position] + ) + else: + panel_to_front: _panel.Panel = wing.panels[ + panel.local_chordwise_position - 1, + panel.local_spanwise_position, + ] + ring_vortex_to_front: _aerodynamics.RingVortex = ( + panel_to_front.ring_vortex + ) + + # Set the effective front LineVortex strength to the + # difference between this Panel's RingVortex's strength, + # and the RingVortex's strength of the Panel in front of it. + effective_front_vortex_line_strengths[global_panel_position] = ( + self._current_bound_vortex_strengths[global_panel_position] + - ring_vortex_to_front.strength + ) + + if panel.is_left_edge: + # Set the effective left LineVortex strength to this Panel's + # RingVortex's strength. + effective_left_vortex_line_strengths[global_panel_position] = ( + self._current_bound_vortex_strengths[global_panel_position] + ) + else: + panel_to_left: _panel.Panel = wing.panels[ + panel.local_chordwise_position, + panel.local_spanwise_position - 1, + ] + ring_vortex_to_left: _aerodynamics.RingVortex = ( + panel_to_left.ring_vortex + ) + + # Set the effective left LineVortex strength to the + # difference between this Panel's RingVortex's strength, + # and the RingVortex's strength of the Panel to the left. + effective_left_vortex_line_strengths[global_panel_position] = ( + self._current_bound_vortex_strengths[global_panel_position] + - ring_vortex_to_left.strength + ) + + # Increment the global Panel position variable. + global_panel_position += 1 + + # Calculate the velocity (in the first Airplane's geometry axes, observed + # from the Earth frame) at the center of every Panels' RingVortex's right + # LineVortex, front LineVortex, and left LineVortex. + stackVelocityRightLineVortexCenters_GP1_E = ( + self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpr_GP1_CgP1) + + self._calculate_current_movement_velocities_at_right_leg_centers() + ) + stackVelocityFrontLineVortexCenters_GP1_E = ( + self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpf_GP1_CgP1) + + self._calculate_current_movement_velocities_at_front_leg_centers() + ) + stackVelocityLeftLineVortexCenters_GP1_E = ( + self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpl_GP1_CgP1) + + self._calculate_current_movement_velocities_at_left_leg_centers() + ) + + # Using the effective LineVortex strengths and the Kutta-Joukowski theorem, + # find the forces (in the first Airplane's geometry axes) on the Panels' + # RingVortex's right LineVortex, front LineVortex, and left LineVortex using + # the effective vortex strengths. + rightLegForces_GP1 = ( + self.current_operating_point.rho + * np.expand_dims(effective_right_vortex_line_strengths, axis=1) + * _functions.numba_1d_explicit_cross( + stackVelocityRightLineVortexCenters_GP1_E, + self.stackRbrv_GP1, + ) + ) + frontLegForces_GP1 = ( + self.current_operating_point.rho + * np.expand_dims(effective_front_vortex_line_strengths, axis=1) + * _functions.numba_1d_explicit_cross( + stackVelocityFrontLineVortexCenters_GP1_E, + self.stackFbrv_GP1, + ) + ) + leftLegForces_GP1 = ( + self.current_operating_point.rho + * np.expand_dims(effective_left_vortex_line_strengths, axis=1) + * _functions.numba_1d_explicit_cross( + stackVelocityLeftLineVortexCenters_GP1_E, + self.stackLbrv_GP1, + ) + ) + + # The unsteady force calculation below includes a negative sign to account for a + # sign convention mismatch between Ptera Software and the reference literature. + # Ptera Software defines RingVortices with counter-clockwise (CCW) vertex + # ordering, while the references use clockwise (CW) ordering. Both define panel + # normals as pointing upward. This convention difference only affects the + # unsteady force term because it depends on both vortex strength and the normal + # vector. When converting from CCW to CW, the strength changes sign but the + # normal vector does not, requiring a sign correction. In contrast, steady + # Kutta-Joukowski forces depend on the strength and the LineVortex vectors. Both + # have flipped signs, causing the negatives to cancel. See issue #27: + # https://github.com/camUrban/PteraSoftware/issues/27 + + # Calculate the unsteady component of the force on each Panel (in geometry + # axes), which is derived from the unsteady Bernoulli equation. + unsteady_forces_GP1 = -( + self.current_operating_point.rho + * np.expand_dims( + ( + self._current_bound_vortex_strengths + - self._last_bound_vortex_strengths + ), + axis=1, + ) + * np.expand_dims(self.panel_areas, axis=1) + * self.stackUnitNormals_GP1 + / self.delta_time + ) + + forces_GP1 = ( + rightLegForces_GP1 + + frontLegForces_GP1 + + leftLegForces_GP1 + + unsteady_forces_GP1 + ) + + # Find the moments (in the first Airplane's geometry axes, relative to the + # first Airplane's CG) on the Panels' RingVortex's right LineVortex, + # front LineVortex, and left LineVortex. + rightLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( + self.stackCblvpr_GP1_CgP1, + rightLegForces_GP1, + ) + frontLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( + self.stackCblvpf_GP1_CgP1, + frontLegForces_GP1, + ) + leftLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( + self.stackCblvpl_GP1_CgP1, + leftLegForces_GP1, + ) + + # The unsteady moment is calculated at the collocation point because the + # unsteady force acts on the bound RingVortex, whose center is at the + # collocation point, not at the Panel's centroid. + + # Find the moments (in the first Airplane's geometry axes, relative to the + # first Airplane's CG) due to the unsteady component of the force on each Panel. + unsteady_moments_GP1_CgP1 = _functions.numba_1d_explicit_cross( + self.stackCpp_GP1_CgP1, + unsteady_forces_GP1, + ) + + moments_GP1_CgP1 = ( + rightLegMoments_GP1_CgP1 + + frontLegMoments_GP1_CgP1 + + leftLegMoments_GP1_CgP1 + + unsteady_moments_GP1_CgP1 + ) + + # TODO: Transform forces_GP1 and moments_GP1_CgP1 to each Airplane's local + # geometry axes before passing to process_solver_loads. + _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) + + def _populate_next_airplanes_wake(self, prescribed_wake=True): + """This method updates the next time step's Airplanes' wakes. + + :param prescribed_wake: Bool, optional + + This parameter determines if the solver uses a prescribed wake model. If + false it will use a free-wake, which may be more accurate but will make + the solver significantly slower. The default is True. + + :return: None + """ + # Populate the locations of the next time step's Airplanes' wake RingVortex + # points. + self._populate_next_airplanes_wake_vortex_points( + prescribed_wake=prescribed_wake + ) + + # Populate the locations of the next time step's Airplanes' wake RingVortices. + self._populate_next_airplanes_wake_vortices() + + def _populate_next_airplanes_wake_vortex_points(self, prescribed_wake=True): + """This method populates the locations of the next time step's Airplanes' + wake RingVortex points. + + This method is not vectorized but its loops only consume 1.1% of the runtime, + so I have kept it as is for increased readability. + + :param prescribed_wake: Bool, optional + + This parameter determines if the solver uses a prescribed wake model. If + false it will use a free-wake, which may be more accurate but will make + the solver significantly slower. The default is True. + + :return: None + """ + # Get the next time step's Airplanes. + next_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem( + self._current_step + 1 + ) + next_airplanes = next_problem.airplanes + + # Get the current Airplanes' combined number of Wings. + num_wings = 0 + airplane: geometry.airplane.Airplane + for airplane in self.current_airplanes: + num_wings += len(airplane.wings) + + # Iterate through this time step's Airplanes' successor objects. + next_airplane: geometry.airplane.Airplane + for airplane_id, next_airplane in enumerate(next_airplanes): + + # Iterate through the next Airplane's Wings. + next_wing: geometry.wing.Wing + for wing_id, next_wing in enumerate(next_airplane.wings): + + # Get the Wings at this position from the current Airplane. + this_airplane: geometry.airplane.Airplane = self.current_airplanes[ + airplane_id + ] + this_wing: geometry.wing.Wing = this_airplane.wings[wing_id] + + # Check if this is the first time step. + if self._current_step == 0: + + # Get the current Wing's number of chordwise and spanwise + # panels. + num_spanwise_panels = this_wing.num_spanwise_panels + num_chordwise_panels = this_wing.num_chordwise_panels + + # Set the chordwise position to be at the trailing edge. + chordwise_panel_id = num_chordwise_panels - 1 + + # Initialize a ndarray to hold the points of the new row of + # wake RingVortices (in the first Airplane's geometry axes, + # relative to the first Airplane's CG). + newRowWrvp_GP1_CgP1 = np.zeros( + (1, num_spanwise_panels + 1, 3), dtype=float + ) + + # Iterate through the spanwise Panel positions. + for spanwise_panel_id in range(num_spanwise_panels): + # Get the next time step's Wing's Panel at this location. + next_panel: _panel.Panel = next_wing.panels[ + chordwise_panel_id, spanwise_panel_id + ] + # The position of the new front left wake RingVortex's + # point is the next time step's Panel's bound + # RingVortex's back left point. + next_ring_vortex: _aerodynamics.RingVortex = ( + next_panel.ring_vortex + ) + + newFlwrvp_GP1_CgP1 = next_ring_vortex.Blrvp_GP1_CgP1 + + # Add this to the row of new wake RingVortex points. + newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( + newFlwrvp_GP1_CgP1 + ) + + # If the Panel is at the right edge of the Wing, add its + # back right bound RingVortex point to the row of new + # wake RingVortex points. + if spanwise_panel_id == (num_spanwise_panels - 1): + newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( + next_ring_vortex.Brrvp_GP1_CgP1 + ) + + # Set the next time step's Wing's grid of wake RingVortex + # points to a copy of the row of new wake RingVortex points. + # This is correct because it is currently the first time step. + next_wing.gridWrvp_GP1_CgP1 = np.copy(newRowWrvp_GP1_CgP1) + + # Initialize variables to hold the number of spanwise wake + # RingVortex points. + num_spanwise_points = num_spanwise_panels + 1 + + # Initialize a new ndarray to hold the second new row of wake + # RingVortex points (in the first Airplane's geometry axes, + # relative to the first Airplane's CG). + secondNewRowWrvp_GP1_CgP1 = np.zeros( + (1, num_spanwise_panels + 1, 3), dtype=float + ) + + # Iterate through the spanwise points. + for spanwise_point_id in range(num_spanwise_points): + # Get the corresponding point from the first row. + Wrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ + 0, spanwise_point_id + ] + + # If the wake is prescribed, set the velocity at this + # point to the freestream velocity (in the first + # Airplane's geometry axes, observed from the Earth + # frame). Otherwise, set the velocity to the solution + # velocity at this point (in the first Airplane's + # geometry axes, observed from the Earth frame). + if prescribed_wake: + vWrvp_GP1__E = self._currentVInf_GP1__E + else: + vWrvp_GP1__E = self.calculate_solution_velocity( + np.expand_dims(Wrvp_GP1_CgP1, axis=0) + ) + + # Update the second new row with the interpolated + # position of the first point. + secondNewRowWrvp_GP1_CgP1[0, spanwise_point_id] = ( + Wrvp_GP1_CgP1 + vWrvp_GP1__E * self.delta_time + ) + + # Update the next time step's Wing's grid of wake RingVortex + # points by vertically stacking the new second row below it. + next_wing.gridWrvp_GP1_CgP1 = np.vstack( + ( + next_wing.gridWrvp_GP1_CgP1, + secondNewRowWrvp_GP1_CgP1, + ) + ) + + # If this isn't the first time step, then do this. + else: + # Set the next time step's Wing's grid of wake RingVortex + # points to a copy of this time step's Wing's grid of wake + # RingVortex points. + next_wing.gridWrvp_GP1_CgP1 = np.copy( + this_wing.gridWrvp_GP1_CgP1 + ) + + # Get the number of chordwise and spanwise points. + num_chordwise_points = next_wing.gridWrvp_GP1_CgP1.shape[0] + num_spanwise_points = next_wing.gridWrvp_GP1_CgP1.shape[1] + + # Iterate through the chordwise and spanwise point positions. + for chordwise_point_id in range(num_chordwise_points): + for spanwise_point_id in range(num_spanwise_points): + # Get the wake RingVortex point at this position. + Wrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ + chordwise_point_id, + spanwise_point_id, + ] + + # If the wake is prescribed, set the velocity at this + # point to the freestream velocity (in the first + # Airplane's geometry axes, observed from the Earth + # frame). Otherwise, set the velocity to the solution + # velocity at this point (in the first Airplane's + # geometry axes, observed from the Earth frame). + if prescribed_wake: + vWrvp_GP1__E = self._currentVInf_GP1__E + else: + vWrvp_GP1__E = np.squeeze( + self.calculate_solution_velocity( + np.expand_dims(Wrvp_GP1_CgP1, axis=0) + ) + ) + + # Update this point with its interpolated position. + next_wing.gridWrvp_GP1_CgP1[ + chordwise_point_id, spanwise_point_id + ] += (vWrvp_GP1__E * self.delta_time) + + # Find the chordwise position of the Wing's trailing edge. + chordwise_panel_id = this_wing.num_chordwise_panels - 1 + + # Initialize a new ndarray to hold the new row of wake + # RingVortex vertices. + newRowWrvp_GP1_CgP1 = np.zeros( + (1, this_wing.num_spanwise_panels + 1, 3), dtype=float + ) + + # Iterate spanwise through the trailing edge Panels. + for spanwise_panel_id in range(this_wing.num_spanwise_panels): + # Get the Panel at this location on the next time step's + # Airplane's Wing. + next_panel: _panel.Panel = next_wing.panels[ + chordwise_panel_id, spanwise_panel_id + ] + + # Add the Panel's back left bound RingVortex point to the + # grid of new wake RingVortex points. + next_ring_vortex: _aerodynamics.RingVortex = ( + next_panel.ring_vortex + ) + newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( + next_ring_vortex.Blrvp_GP1_CgP1 + ) + + # If the Panel is at the right edge of the Wing, add its + # back right bound RingVortex point to the grid of new + # wake RingVortex vertices. + if spanwise_panel_id == (this_wing.num_spanwise_panels - 1): + newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( + next_ring_vortex.Brrvp_GP1_CgP1 + ) + + # Stack the new row of wake RingVortex points above the + # Wing's grid of wake RingVortex points. + next_wing.gridWrvp_GP1_CgP1 = np.vstack( + ( + newRowWrvp_GP1_CgP1, + next_wing.gridWrvp_GP1_CgP1, + ) + ) + + def _populate_next_airplanes_wake_vortices(self): + """This method populates the locations and strengths of the next time step's + wake RingVortices. + + This method is not vectorized but its loops only consume 0.4% of the runtime, + so I have kept it as is for increased readability. + + :return: None + """ + # Get the next time step's Airplanes. + next_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem( + self._current_step + 1 + ) + next_airplanes = next_problem.airplanes + + # Iterate through the next time step's Airplanes. + next_airplane: geometry.airplane.Airplane + for airplane_id, next_airplane in enumerate(next_airplanes): + + # For a given Airplane in the next time step, iterate through its + # predecessor's Wings. + this_wing: geometry.wing.Wing + for wing_id, this_wing in enumerate( + self.current_airplanes[airplane_id].wings + ): + next_wing: geometry.wing.Wing = next_airplane.wings[wing_id] + + # Get the next time step's Wing's grid of wake RingVortex points. + nextGridWrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1 + + # Find the number of chordwise and spanwise points in the next + # Wing's grid of wake RingVortex points. + num_chordwise_points = nextGridWrvp_GP1_CgP1.shape[0] + num_spanwise_points = nextGridWrvp_GP1_CgP1.shape[1] + + this_wing_wake_ring_vortices = ( + self.current_airplanes[airplane_id] + .wings[wing_id] + .wake_ring_vortices + ) + + # Initialize a new ndarray to hold the new row of wake RingVortices. + new_row_of_wake_ring_vortices = np.empty( + (1, num_spanwise_points - 1), dtype=object + ) + + # Create a new ndarray by stacking the new row of wake + # RingVortices on top of the current Wing's grid of wake + # RingVortices and assign it to the next time step's Wing. + next_wing.wake_ring_vortices = np.vstack( + (new_row_of_wake_ring_vortices, this_wing_wake_ring_vortices) + ) + + # Iterate through the wake RingVortex point positions. + for chordwise_point_id in range(num_chordwise_points): + for spanwise_point_id in range(num_spanwise_points): + # Set booleans to determine if this point is on the right + # and/or trailing edge of the wake. + has_point_to_right = ( + spanwise_point_id + 1 + ) < num_spanwise_points + has_point_behind = ( + chordwise_point_id + 1 + ) < num_chordwise_points + + if has_point_to_right and has_point_behind: + # If this point isn't on the right or trailing edge + # of the wake, get the four points that will be + # associated with the corresponding RingVortex at + # this position (in the first Airplane's geometry + # axes, relative to the first Airplane's CG), + # for the next time step. + Flwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ + chordwise_point_id, spanwise_point_id + ] + Frwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ + chordwise_point_id, + spanwise_point_id + 1, + ] + Blwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ + chordwise_point_id + 1, + spanwise_point_id, + ] + Brwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ + chordwise_point_id + 1, + spanwise_point_id + 1, + ] + + if chordwise_point_id > 0: + # If this isn't the front of the wake, update the + # position of the wake RingVortex at this + # location for the next time step. + next_wake_ring_vortices = ( + next_wing.wake_ring_vortices + ) + next_wake_ring_vortex_obj = cast( + object, + next_wake_ring_vortices[ + chordwise_point_id, spanwise_point_id + ], + ) + next_wake_ring_vortex = cast( + _aerodynamics.RingVortex, + next_wake_ring_vortex_obj, + ) + + next_wake_ring_vortex.update_position( + Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, + Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, + ) + + # Also, update the age of the wake RingVortex at + # this position for the next time step. + if self._current_step == 0: + next_wake_ring_vortex.age = self.delta_time + else: + next_wake_ring_vortex.age += self.delta_time + + if chordwise_point_id == 0: + # If this position corresponds to the front of + # the wake, get the strength from the Panel's + # bound RingVortex. + this_panel: _panel.Panel = this_wing.panels[ + this_wing.num_chordwise_panels - 1, + spanwise_point_id, + ] + this_ring_vortex: _aerodynamics.RingVortex = ( + this_panel.ring_vortex + ) + this_strength_copy = this_ring_vortex.strength + + # Then, for the next time step, make a new wake + # RingVortex at this position in the wake, + # with that bound RingVortex's strength, and add + # it to the grid of the next time step's wake + # RingVortices. + next_wing.wake_ring_vortices[ + chordwise_point_id, + spanwise_point_id, + ] = _aerodynamics.RingVortex( + Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, + Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, + strength=this_strength_copy, + ) + + def _calculate_current_movement_velocities_at_collocation_points(self): + """Get the current apparent velocities (in the first Airplane's geometry + axes, observed from the Earth frame) at each Panel's collocation point due to + any motion defined in Movement. + + Note: At each point, any apparent velocity due to Movement is opposite the + motion due to Movement. + + :return: (M, 3) ndarray of floats + + This is a ndarray containing the apparent velocity (in the first + Airplane's geometry axes, observed from the Earth frame) at each Panel's + collocation point due to any motion defined in Movement. Its units are in + meters per second. If the current time step is the first time step, + these velocities will all be all zeros. + """ + # Check if this is the current time step. If so, return all zeros. + if self._current_step < 1: + return np.zeros((self.num_panels, 3), dtype=float) + + return -(self.stackCpp_GP1_CgP1 - self._stackLastCpp_GP1_CgP1) / self.delta_time + + def _calculate_current_movement_velocities_at_right_leg_centers(self): + """Get the current apparent velocities (in the first Airplane's geometry + axes, observed from the Earth frame) at the center point of each bound + RingVortex's right leg due to any motion defined in Movement. + + Note: At each point, any apparent velocity due to Movement is opposite the + motion due to Movement. + + :return: (M, 3) ndarray of floats + + This is a ndarray containing the apparent velocity (in the first + Airplane's geometry axes, observed from the Earth frame) at the center + point of each bound RingVortex's right leg due to any motion defined in + Movement. Its units are in meters per second. If the current time step is + the first time step, these velocities will all be all zeros. + """ + # Check if this is the current time step. If so, return all zeros. + if self._current_step < 1: + return np.zeros((self.num_panels, 3), dtype=float) + + return ( + -(self.stackCblvpr_GP1_CgP1 - self._lastStackCblvpr_GP1_CgP1) + / self.delta_time + ) + + def _calculate_current_movement_velocities_at_front_leg_centers(self): + """Get the current apparent velocities (in the first Airplane's geometry + axes, observed from the Earth frame) at the center point of each bound + RingVortex's front leg due to any motion defined in Movement. + + Note: At each point, any apparent velocity due to Movement is opposite the + motion due to Movement. + + :return: (M, 3) ndarray of floats + + This is a ndarray containing the apparent velocity (in the first + Airplane's geometry axes, observed from the Earth frame) at the center + point of each bound RingVortex's front leg due to any motion defined in + Movement. Its units are in meters per second. If the current time step is + the first time step, these velocities will all be all zeros. + """ + # Check if this is the current time step. If so, return all zeros. + if self._current_step < 1: + return np.zeros((self.num_panels, 3), dtype=float) + + return ( + -(self.stackCblvpf_GP1_CgP1 - self._lastStackCblvpf_GP1_CgP1) + / self.delta_time + ) + + def _calculate_current_movement_velocities_at_left_leg_centers(self): + """Get the current apparent velocities (in the first Airplane's geometry + axes, observed from the Earth frame) at the center point of each bound + RingVortex's left leg due to any motion defined in Movement. + + Note: At each point, any apparent velocity due to Movement is opposite the + motion due to Movement. + + :return: (M, 3) ndarray of floats + + This is a ndarray containing the apparent velocity (in the first + Airplane's geometry axes, observed from the Earth frame) at the center + point of each bound RingVortex's left leg due to any motion defined in + Movement. Its units are in meters per second. If the current time step is + the first time step, these velocities will all be all zeros. + """ + # Check if this is the current time step. If so, return all zeros. + if self._current_step < 1: + return np.zeros((self.num_panels, 3), dtype=float) + + return ( + -(self.stackCblvpl_GP1_CgP1 - self._lastStackCblvpl_GP1_CgP1) + / self.delta_time + ) + + def _finalize_loads(self): + """For cases with static geometry, this function finds the final loads and + load coefficients for each of the SteadyProblem's Airplanes. For cases with + variable geometry, it finds the final cycle-averaged and + cycle-root-mean-squared loads and load coefficients for each of the + SteadyProblem's Airplanes. + + :return: None + """ + # Get this solver's time step characteristics. Note that the first time step + # ( time step 0), occurs at 0 seconds. + num_steps_to_average = self.num_steps - self._first_averaging_step + + # Determine if this SteadyProblem's geometry is static or variable. + this_movement: movements.movement.Movement = ( + self.coupled_unsteady_problem.movement + ) + static = this_movement.static + + # Initialize ndarrays to hold each Airplane's loads and load coefficients at + # each of the time steps that calculated the loads. + forces_W = np.zeros((self.num_airplanes, 3, num_steps_to_average), dtype=float) + force_coefficients_W = np.zeros( + (self.num_airplanes, 3, num_steps_to_average), dtype=float + ) + moments_W_CgP1 = np.zeros( + (self.num_airplanes, 3, num_steps_to_average), dtype=float + ) + moment_coefficients_W_CgP1 = np.zeros( + (self.num_airplanes, 3, num_steps_to_average), dtype=float + ) + + # Initialize a variable to track position in the loads ndarrays. + results_step = 0 + + # Iterate through the time steps with loads and add the loads to their + # respective ndarrays. + for step in range(self._first_averaging_step, self.num_steps): + + # Get the Airplanes from the SteadyProblem at this time step. + this_steady_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(step) + these_airplanes = this_steady_problem.airplanes + + # Iterate through this time step's Airplanes. + airplane: geometry.airplane.Airplane + for airplane_id, airplane in enumerate(these_airplanes): + forces_W[airplane_id, :, results_step] = airplane.forces_W + force_coefficients_W[airplane_id, :, results_step] = ( + airplane.forceCoefficients_W + ) + moments_W_CgP1[airplane_id, :, results_step] = airplane.moments_W_CgP1 + moment_coefficients_W_CgP1[airplane_id, :, results_step] = ( + airplane.momentCoefficients_W_CgP1 + ) + + results_step += 1 + + # For each Airplane, calculate and then save the final or cycle-averaged and + # RMS loads and load coefficients. + airplane: geometry.airplane.Airplane + first_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(0) + for airplane_id, airplane in enumerate(first_problem.airplanes): + if static: + self.coupled_unsteady_problem.finalForces_W.append( + forces_W[airplane_id, :, -1] + ) + self.coupled_unsteady_problem.finalForceCoefficients_W.append( + force_coefficients_W[airplane_id, :, -1] + ) + self.coupled_unsteady_problem.finalMoments_W_CgP1.append( + moments_W_CgP1[airplane_id, :, -1] + ) + self.coupled_unsteady_problem.finalMomentCoefficients_W_CgP1.append( + moment_coefficients_W_CgP1[airplane_id, :, -1] + ) + else: + self.coupled_unsteady_problem.finalMeanForces_W.append( + np.mean(forces_W[airplane_id], axis=-1) + ) + self.coupled_unsteady_problem.finalMeanForceCoefficients_W.append( + np.mean(force_coefficients_W[airplane_id], axis=-1) + ) + self.coupled_unsteady_problem.finalMeanMoments_W_CgP1.append( + np.mean(moments_W_CgP1[airplane_id], axis=-1) + ) + self.coupled_unsteady_problem.finalMeanMomentCoefficients_W_CgP1.append( + np.mean(moment_coefficients_W_CgP1[airplane_id], axis=-1) + ) + + self.coupled_unsteady_problem.finalRmsForces_W.append( + np.sqrt( + np.mean( + np.square(forces_W[airplane_id]), + axis=-1, + ) + ) + ) + self.coupled_unsteady_problem.finalRmsForceCoefficients_W.append( + np.sqrt( + np.mean( + np.square(force_coefficients_W[airplane_id]), + axis=-1, + ) + ) + ) + self.coupled_unsteady_problem.finalRmsMoments_W_CgP1.append( + np.sqrt( + np.mean( + np.square(moments_W_CgP1[airplane_id]), + axis=-1, + ) + ) + ) + self.coupled_unsteady_problem.finalRmsMomentCoefficients_W_CgP1.append( + np.sqrt( + np.mean( + np.square(moment_coefficients_W_CgP1[airplane_id]), + axis=-1, + ) + ) + ) diff --git a/pterasoftware/output.py b/pterasoftware/output.py index a2cf29928..d5a009b26 100644 --- a/pterasoftware/output.py +++ b/pterasoftware/output.py @@ -29,6 +29,7 @@ from . import steady_horseshoe_vortex_lattice_method from . import steady_ring_vortex_lattice_method from . import unsteady_ring_vortex_lattice_method +from . import coupled_unsteady_ring_vortex_lattice_method # Define the color and colormaps used by the visualization functions. _sequential_color_map = "speed" @@ -96,6 +97,7 @@ def draw( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver | steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver | unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ), scalar_type=None, show_streamlines: bool = False, @@ -154,6 +156,7 @@ def draw( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver, steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver, unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, ), ): raise TypeError( @@ -188,6 +191,7 @@ def draw( if isinstance( solver, unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, ): draw_step = solver.num_steps - 1 @@ -333,7 +337,9 @@ def draw( # TEST: Assess how comprehensive this function's integration tests are and update or # extend them if needed. def animate( - unsteady_solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + unsteady_solver: + (unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver), scalar_type=None, show_wake_vortices: bool = False, save: bool = False, @@ -375,7 +381,10 @@ def animate( """ if not isinstance( unsteady_solver, - (unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver,), + ( + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, + ), ): raise TypeError( "unsteady_solver must be an UnsteadyRingVortexLatticeMethodSolver." @@ -632,7 +641,8 @@ def animate( # TEST: Assess how comprehensive this function's integration tests are and update or # extend them if needed. def plot_results_versus_time( - unsteady_solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + unsteady_solver: (unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver), show: bool = True, save: bool = False, ): @@ -658,7 +668,10 @@ def plot_results_versus_time( """ if not isinstance( unsteady_solver, - unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + ( + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver + ) ): raise TypeError( "unsteady_solver must be an " "UnsteadyRingVortexLatticeMethodSolver." @@ -963,6 +976,7 @@ def print_results( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver | steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver | unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ), ): """This function prints the load and load coefficients calculated by a solver. @@ -985,7 +999,10 @@ def print_results( solver_type = "steady" elif isinstance( solver, - unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + ( + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, + ) ): these_airplanes = solver.current_airplanes if solver.unsteady_problem.movement.static: @@ -1215,7 +1232,8 @@ def _get_panel_surfaces( # TEST: Consider adding unit tests for this function. def _get_wake_ring_vortex_surfaces( - solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + solver: (unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver + | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver), step: int, ): """This function returns the PolyData representation of surfaces an diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 0f127028e..842c200d4 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -196,3 +196,148 @@ def __init__(self, movement, only_final_results=False): # Append this SteadyProblem to the list of SteadyProblems. self.steady_problems.append(this_steady_problem) + +class CoupledUnsteadyProblem(): + """This is a class for coupled unsteady problems. + + This class contains the following public methods: + None + + This class contains the following class attributes: + None + """ + + def __init__(self, movement, only_final_results=False): + """This is the initialization method. + + :param movement: Movement + + This is the Movement that contains this UnsteadyProblem's + OperatingPointMovement and AirplaneMovements. + + :param only_final_results: boolLike, optional + + If set to True, the Solver will only calculate forces, moments, + and pressures for the final complete cycle (of the Movement's + sub-Movement with the longest period), which increases simulation speed. + The default value is False. + """ + if not isinstance(movement, movements.movement.Movement): + raise TypeError("movement must be a Movement.") + self.movement = movement + self.only_final_results = _parameter_validation.boolLike_return_bool( + only_final_results, "only_final_results" + ) + + self.num_steps = self.movement.num_steps + self.delta_time = self.movement.delta_time + + # For UnsteadyProblems with a static Movement, users are typically interested + # in the final time step's forces and moments, which, assuming convergence, + # will be the most accurate. For UnsteadyProblems with cyclic movement, + # (e.g. flapping wings) users are typically interested in the forces and + # moments averaged over the last cycle simulated. Therefore, determine which + # time step will be the first with relevant results based on if the Movement + # is static or cyclic. + _movement_max_period = self.movement.max_period + if _movement_max_period == 0: + self.first_averaging_step = self.num_steps - 1 + else: + self.first_averaging_step = max( + 0, + math.floor(self.num_steps - (_movement_max_period / self.delta_time)), + ) + + # If the user only wants to calculate forces and moments for the final cycle + # (for a cyclic Movement) or for the final time step (for a static Movement) + # set the first step to calculate results to the first averaging step. + # Otherwise, set it to the zero, which is the first time step. + if self.only_final_results: + self.first_results_step = self.first_averaging_step + else: + self.first_results_step = 0 + + # Initialize empty lists to hold the final loads and load coefficients each + # Airplane experiences. These will only be populated if this + # UnsteadyProblem's Movement is static. + self.finalForces_W = [] + self.finalForceCoefficients_W = [] + self.finalMoments_W_CgP1 = [] + self.finalMomentCoefficients_W_CgP1 = [] + + # Initialize empty lists to hold the final cycle-averaged loads and load + # coefficients each Airplane experiences. These will only be populated if + # this UnsteadyProblem's Movement is cyclic. + self.finalMeanForces_W = [] + self.finalMeanForceCoefficients_W = [] + self.finalMeanMoments_W_CgP1 = [] + self.finalMeanMomentCoefficients_W_CgP1 = [] + + # Initialize empty lists to hold the final cycle-root-mean-squared loads and + # load coefficients each airplane object experiences. These will only be + # populated for variable geometry problems. + self.finalRmsForces_W = [] + self.finalRmsForceCoefficients_W = [] + self.finalRmsMoments_W_CgP1 = [] + self.finalRmsMomentCoefficients_W_CgP1 = [] + + # this set of steady problems should essnetially be treated as private + # and the getter method should be used to obtain it + self._steady_problems = [ + SteadyProblem( + [self.movement.airplane_movements[0].base_airplane], + self.movement.operating_point_movement.base_operating_point, + ) + ] + + # Initialize an empty list to hold the SteadyProblems. + self.steady_problems = [] + + # Iterate through the UnsteadyProblem's time steps. + for step_id in range(self.num_steps): + + # Get the Airplanes and the OperatingPoint associated with this time step. + these_airplanes = [] + for this_base_airplane in movement.airplanes: + these_airplanes.append(this_base_airplane[step_id]) + this_operating_point = movement.operating_points[step_id] + + # Initialize the SteadyProblem at this time step. + this_steady_problem = SteadyProblem( + airplanes=these_airplanes, operating_point=this_operating_point + ) + + # Append this SteadyProblem to the list of SteadyProblems. + self.steady_problems.append(this_steady_problem) + + def get_steady_problem(self, step): + """ + Return the steady-state problem associated with the given step index. + + Parameters + ---------- + step : int + Index of the steady problem to retrieve. + + Returns + ------- + Any + The steady-state problem object stored at the specified index. + + Raises + ------ + Exception + If `step` is greater than or equal to the number of initialized + steady problems. + """ + # Ensure the requested step index is valid. + if step >= len(self._steady_problems): + raise Exception( + f"Step index {step} is out of range of the number of initialized problems" + ) + + # Return the corresponding steady-state problem. + return self._steady_problems[step] + + def initialize_next_problem(self): + self._steady_problems.append(self.steady_problems[len(self._steady_problems)]) diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 3c8c572e4..4c1c3f344 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -41,9 +41,6 @@ class UnsteadyRingVortexLatticeMethodSolver: This class contains the following class attributes: None - - Subclassing: - This class is not meant to be subclassed. """ def __init__(self, unsteady_problem): From 63ad145d0f987301174bfc7e0881387765851756 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Sat, 29 Nov 2025 23:08:46 -0500 Subject: [PATCH 02/40] add steps towards coupling --- examples/demos/demo_wing.py | 22 +- ...led_unsteady_ring_vortex_lattice_method.py | 6 +- .../single_step_airplane_movement.py | 359 +++++++++++++++ .../single_step/single_step_movement.py | 0 .../single_step_operating_point_movement.py | 0 ...single_step_wing_cross_section_movement.py | 399 +++++++++++++++++ .../single_step/single_step_wing_movement.py | 421 ++++++++++++++++++ pterasoftware/problems.py | 242 +++++++++- 8 files changed, 1435 insertions(+), 14 deletions(-) create mode 100644 pterasoftware/movements/single_step/single_step_airplane_movement.py create mode 100644 pterasoftware/movements/single_step/single_step_movement.py create mode 100644 pterasoftware/movements/single_step/single_step_operating_point_movement.py create mode 100644 pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py create mode 100644 pterasoftware/movements/single_step/single_step_wing_movement.py diff --git a/examples/demos/demo_wing.py b/examples/demos/demo_wing.py index ccef73f7f..1dcb55f7d 100644 --- a/examples/demos/demo_wing.py +++ b/examples/demos/demo_wing.py @@ -123,21 +123,21 @@ # main wing's root and tip WingCrossSections' WingCrossSectionMovements. # defintions for wing movement parameters -dephase_x = 0.0 -period_x = 1.0 -amplitude_x = 2.0 - -dephase_y = 0.0 -period_y = 1.0 -amplitude_y = 3.0 - # dephase_x = 0.0 # period_x = 1.0 -# amplitude_x = 5.0 +# amplitude_x = 2.0 # dephase_y = 0.0 # period_y = 1.0 -# amplitude_y = 10.0 +# amplitude_y = 3.0 + +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 @@ -296,7 +296,7 @@ del operating_point_movement # Define the UnsteadyProblem. -example_problem = ps.problems.CoupledUnsteadyProblem( +example_problem = ps.problems.AeroelasticUnsteadyProblem( movement=movement, ) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index c6dfe8f75..6f8d8ca92 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -443,7 +443,9 @@ def run( # Check if the current time step is not the last step. if self._current_step < self.num_steps - 1: - self.coupled_unsteady_problem.initialize_next_problem() + self.coupled_unsteady_problem.initialize_next_problem( + self + ) self._initialize_panel_vortex(self.coupled_unsteady_problem.get_steady_problem(step + 1), step + 1) # Shed RingVortices into the wake. logging.info("Shedding RingVortices into the wake.") @@ -463,7 +465,7 @@ def run( logging.info("Calculating streamlines.") _functions.calculate_streamlines(self) - def _initialize_panel_vortex(self, steady_problem: problems.SteadyProblem, steady_problem_id: int): + def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int): """This method calculates the locations of the Airplanes' bound RingVortices' points, and then initializes the bound RingVortices. diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py new file mode 100644 index 000000000..4e441d8ea --- /dev/null +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -0,0 +1,359 @@ +import numpy as np + +from _parameter_validation import ( + threeD_number_vectorLike_return_float, + threeD_spacing_vectorLike_return_tuple, + positive_number_return_float, +) + +from _functions import ( + oscillating_sinspaces, + oscillating_linspaces, + oscillating_customspaces +) + +from geometry.airplane import Airplane + + +class SingleStepAirplaneMovement: + def __init__( + self, + single_step_wing_movements, + ampCg_E_CgP1=(0.0, 0.0, 0.0), + periodCg_E_CgP1=(0.0, 0.0, 0.0), + spacingCg_E_CgP1=("sine", "sine", "sine"), + phaseCg_E_CgP1=(0.0, 0.0, 0.0), + ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), + phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + ): + + """ + :param wing_movements: list of SingleStepWingMovement + + This is a list of the WingMovement associated with each of the base + Airplane's Wings. It must have the same length as the base Airplane's + list of Wings. + + :param ampCg_E_CgP1: array-like of 3 numbers, optional + + The amplitudes of the AirplaneMovement's changes in its Airplanes' + Cg_E_CgP1 parameters. Can be a tuple, list, or numpy array of + non-negative numbers (int or float). Also, each amplitude must be low + enough that it doesn't drive its base value out of the range of valid + values. Otherwise, this AirplaneMovement will try to create Airplanes + with invalid parameters values. Because the first Airplane's Cg_E_CgP1 + parameter must be all zeros, this means that the first Airplane's + ampCg_E_CgP1 parameter must also be all zeros. Values are converted to + floats internally. The default value is (0.0, 0.0, 0.0). The units are in + meters. + + :param periodCg_E_CgP1: array-like of 3 numbers, optional + + The periods of the AirplaneMovement's changes in its Airplanes' Cg_E_CgP1 + parameters. Can be a tuple, list, or numpy array of non-negative numbers + (int or float). Values are converted to floats internally. The default + value is (0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding + element in ampCg_E_CgP1 is 0.0 and non-zero if not. The units are in + seconds. + + :param spacingCg_E_CgP1: array-like of 3 strs or callables, optional + + The value determines the spacing of the AirplaneMovement's change in its + Airplanes' Cg_E_CgP1 parameters. Can be a tuple, list, or numpy array. + Each element can be the string "sine", the string "uniform", + or a callable custom spacing function. Custom spacing functions are for + advanced users and must start at 0, return to 0 after one period of 2*pi + radians, have amplitude of 1, be periodic, return finite values only, + and accept a ndarray as input and return a ndarray of the same shape. The + custom function is scaled by ampCg_E_CgP1, shifted horizontally by + phaseCg_E_CgP1, and vertically by the base value, with the period + controlled by periodCg_E_CgP1. The default value is ("sine", "sine", + "sine"). + + :param phaseCg_E_CgP1: array-like of 3 numbers, optional + + The phase offsets of the elements in the first time step's Airplane's + Cg_E_CgP1 parameter relative to the base Airplane's Cg_E_CgP1 parameter. + Can be a tuple, list, or numpy array of non-negative numbers (int or + float) in the range (-180.0, 180.0]. Values are converted to floats + internally. The default value is (0.0, 0.0, 0.0). Each element must be + 0.0 if the corresponding element in ampCg_E_CgP1 is 0.0 and non-zero if + not. The units are in degrees. + + :param ampAngles_E_to_B_izyx: array-like of 3 numbers, optional + + The amplitudes of the AirplaneMovement's changes in its Airplanes' + angles_E_to_B_izyx parameters. Can be a tuple, list, or numpy array of + numbers (int or float) in the range [0.0, 360.0). Also, each amplitude + must be low enough that it doesn't drive its base value out of the range + of valid values. Otherwise, this AirplaneMovement will try to create + Airplanes with invalid parameters values. Values are converted to floats + internally. The default value is (0.0, 0.0, 0.0). The units are in degrees. + + :param periodAngles_E_to_B_izyx: array-like of 3 numbers, optional + + The periods of the AirplaneMovement's changes in its Airplanes' + angles_E_to_B_izyx parameters. Can be a tuple, list, or numpy array of + non-negative numbers (int or float). Values are converted to floats + internally. The default value is (0.0, 0.0, 0.0). Each element must be + 0.0 if the corresponding element in ampAngles_E_to_B_izyx is 0.0 and + non-zero if not. The units are in seconds. + + :param spacingAngles_E_to_B_izyx: array-like of 3 strs or callables, optional + + The value determines the spacing of the AirplaneMovement's change in its + Airplanes' angles_E_to_B_izyx parameters. Can be a tuple, list, or numpy + array. Each element can be the string "sine", the string "uniform", + or a callable custom spacing function. Custom spacing functions are for + advanced users and must start at 0, return to 0 after one period of 2*pi + radians, have amplitude of 1, be periodic, return finite values only, + and accept a ndarray as input and return a ndarray of the same shape. The + custom function is scaled by ampAngles_E_to_B_izyx, shifted horizontally + by phaseAngles_E_to_B_izyx, and vertically by the base value, with the + period controlled by periodAngles_E_to_B_izyx. The default value is ( + "sine", "sine", "sine"). + + :param phaseAngles_E_to_B_izyx: array-like of 3 numbers, optional + + The phase offsets of the elements in the first time step's Airplane's + angles_E_to_B_izyx parameter relative to the base Airplane's + angles_E_to_B_izyx parameter. Can be a tuple, list, or numpy array of + numbers (int or float) in the range (-180.0, 180.0]. Values are converted to + floats internally. The default value is (0.0, 0.0, 0.0). Each element + must be 0.0 if the corresponding element in ampAngles_E_to_B_izyx is 0.0 + and non-zero if not. The units are in degrees. + """ + + self.wing_movements = single_step_wing_movements + + ampCg_E_CgP1 = threeD_number_vectorLike_return_float( + ampCg_E_CgP1, "ampCg_E_CgP1" + ) + if not np.all(ampCg_E_CgP1 >= 0.0): + raise ValueError("All elements in ampCg_E_CgP1 must be non-negative.") + self.ampCg_E_CgP1 = ampCg_E_CgP1 + + periodCg_E_CgP1 = threeD_number_vectorLike_return_float( + periodCg_E_CgP1, "periodCg_E_CgP1" + ) + if not np.all(periodCg_E_CgP1 >= 0.0): + raise ValueError("All elements in periodCg_E_CgP1 must be non-negative.") + for period_index, period in enumerate(periodCg_E_CgP1): + amp = self.ampCg_E_CgP1[period_index] + if amp == 0 and period != 0: + raise ValueError( + "If an element in ampCg_E_CgP1 is 0.0, the corresponding element in periodCg_E_CgP1 must be also be 0.0." + ) + self.periodCg_E_CgP1 = periodCg_E_CgP1 + + spacingCg_E_CgP1 = threeD_spacing_vectorLike_return_tuple( + spacingCg_E_CgP1, "spacingCg_E_CgP1" + ) + self.spacingCg_E_CgP1 = spacingCg_E_CgP1 + + phaseCg_E_CgP1 = threeD_number_vectorLike_return_float( + phaseCg_E_CgP1, "phaseCg_E_CgP1" + ) + if not (np.all(phaseCg_E_CgP1 > -180.0) and np.all(phaseCg_E_CgP1 <= 180.0)): + raise ValueError( + "All elements in phaseCg_E_CgP1 must be in the range (-180.0, 180.0]." + ) + for phase_index, phase in enumerate(phaseCg_E_CgP1): + amp = self.ampCg_E_CgP1[phase_index] + if amp == 0 and phase != 0: + raise ValueError( + "If an element in ampCg_E_CgP1 is 0.0, the corresponding element in phaseCg_E_CgP1 must be also be 0.0." + ) + self.phaseCg_E_CgP1 = phaseCg_E_CgP1 + + ampAngles_E_to_B_izyx = ( + threeD_number_vectorLike_return_float( + ampAngles_E_to_B_izyx, "ampAngles_E_to_B_izyx" + ) + ) + if not ( + np.all(ampAngles_E_to_B_izyx >= 0.0) + and np.all(ampAngles_E_to_B_izyx < 360.0) + ): + raise ValueError( + "All elements in ampAngles_E_to_B_izyx must be in the range [0.0, 360.0)." + ) + self.ampAngles_E_to_B_izyx = ampAngles_E_to_B_izyx + + periodAngles_E_to_B_izyx = ( + threeD_number_vectorLike_return_float( + periodAngles_E_to_B_izyx, "periodAngles_E_to_B_izyx" + ) + ) + if not np.all(periodAngles_E_to_B_izyx >= 0.0): + raise ValueError( + "All elements in periodAngles_E_to_B_izyx must be non-negative." + ) + for period_index, period in enumerate(periodAngles_E_to_B_izyx): + amp = self.ampAngles_E_to_B_izyx[period_index] + if amp == 0 and period != 0: + raise ValueError( + "If an element in ampAngles_E_to_B_izyx is 0.0, the corresponding element in periodAngles_E_to_B_izyx must be also be 0.0." + ) + self.periodAngles_E_to_B_izyx = periodAngles_E_to_B_izyx + + spacingAngles_E_to_B_izyx = ( + threeD_spacing_vectorLike_return_tuple( + spacingAngles_E_to_B_izyx, + "spacingAngles_E_to_B_izyx", + ) + ) + self.spacingAngles_E_to_B_izyx = spacingAngles_E_to_B_izyx + + phaseAngles_E_to_B_izyx = ( + threeD_number_vectorLike_return_float( + phaseAngles_E_to_B_izyx, "phaseAngles_E_to_B_izyx" + ) + ) + if not ( + np.all(phaseAngles_E_to_B_izyx > -180.0) + and np.all(phaseAngles_E_to_B_izyx <= 180.0) + ): + raise ValueError( + "All elements in phaseAngles_E_to_B_izyx must be in the range (-180.0, 180.0]." + ) + for phase_index, phase in enumerate(phaseAngles_E_to_B_izyx): + amp = self.ampAngles_E_to_B_izyx[phase_index] + if amp == 0 and phase != 0: + raise ValueError( + "If an element in ampAngles_E_to_B_izyx is 0.0, the corresponding element in phaseAngles_E_to_B_izyx must be also be 0.0." + ) + self.phaseAngles_E_to_B_izyx = phaseAngles_E_to_B_izyx + + def generate_next_airplane(self, base_airplane: Airplane, delta_time): + """Creates the Airplane at the next timestep + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + angles_E_to_B_izyx + :return: Airplanes + + This is the Airplanes associated with this AirplaneMovement and deformation. + """ + num_steps = 2 + delta_time = positive_number_return_float( + delta_time, "delta_time" + ) + + # Generate oscillating values for each dimension of Cg_E_CgP1. + listCg_E_CgP1 = np.zeros((3, num_steps), dtype=float) + for dim in range(3): + spacing = self.spacingCg_E_CgP1[dim] + if spacing == "sine": + listCg_E_CgP1[dim, :] = oscillating_sinspaces( + amps=self.ampCg_E_CgP1[dim], + periods=self.periodCg_E_CgP1[dim], + phases=self.phaseCg_E_CgP1[dim], + bases=base_airplane.Cg_E_CgP1[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif spacing == "uniform": + listCg_E_CgP1[dim, :] = oscillating_linspaces( + amps=self.ampCg_E_CgP1[dim], + periods=self.periodCg_E_CgP1[dim], + phases=self.phaseCg_E_CgP1[dim], + bases=base_airplane.Cg_E_CgP1[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif callable(spacing): + listCg_E_CgP1[dim, :] = oscillating_customspaces( + amps=self.ampCg_E_CgP1[dim], + periods=self.periodCg_E_CgP1[dim], + phases=self.phaseCg_E_CgP1[dim], + bases=base_airplane.Cg_E_CgP1[dim], + num_steps=num_steps, + delta_time=delta_time, + custom_function=spacing, + ) + else: + raise ValueError(f"Invalid spacing value: {spacing}") + + # Generate oscillating values for each dimension of angles_E_to_B_izyx. + listAngles_E_to_B_izyx = np.zeros((3, num_steps), dtype=float) + for dim in range(3): + spacing = self.spacingAngles_E_to_B_izyx[dim] + if spacing == "sine": + listAngles_E_to_B_izyx[dim, :] = oscillating_sinspaces( + amps=self.ampAngles_E_to_B_izyx[dim], + periods=self.periodAngles_E_to_B_izyx[dim], + phases=self.phaseAngles_E_to_B_izyx[dim], + bases=base_airplane.angles_E_to_B_izyx[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif spacing == "uniform": + listAngles_E_to_B_izyx[dim, :] = oscillating_linspaces( + amps=self.ampAngles_E_to_B_izyx[dim], + periods=self.periodAngles_E_to_B_izyx[dim], + phases=self.phaseAngles_E_to_B_izyx[dim], + bases=base_airplane.angles_E_to_B_izyx[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif callable(spacing): + listAngles_E_to_B_izyx[dim, :] = oscillating_customspaces( + amps=self.ampAngles_E_to_B_izyx[dim], + periods=self.periodAngles_E_to_B_izyx[dim], + phases=self.phaseAngles_E_to_B_izyx[dim], + bases=base_airplane.angles_E_to_B_izyx[dim], + num_steps=num_steps, + delta_time=delta_time, + custom_function=spacing, + ) + else: + raise ValueError(f"Invalid spacing value: {spacing}") + + # Create an empty 2D ndarray that will hold each of the Airplane's Wing's vector + # of Wings representing its changing state at each time step. The first index + # denotes a particular base Wing, and the second index denotes the time step. + wings = np.empty((len(self.wing_movements)), dtype=object) + + # Iterate through the WingMovements. + for wing_movement_id, wing_movement in enumerate(self.wing_movements): + + # Generate this Wing's vector of Wings representing its changing state at + # each time step. + this_wings_list_of_wings = np.array( + wing_movement.generate_next_wing(delta_time=delta_time) + ) + + # Add this vector the Airplane's 2D ndarray of Wings' Wings. + wings[wing_movement_id, :] = this_wings_list_of_wings + + # Create an empty list to hold each time step's Airplane. + airplanes = [] + + # Get the non-changing Airplane attributes. + this_name = base_airplane.name + this_weight = base_airplane.weight + + # the 1 is for not the base step, but 1 step deep + thisCg_E_CgP1 = listCg_E_CgP1[:, 1] + theseAngles_E_to_B_izyx = listAngles_E_to_B_izyx[:, 1] + these_wings = list(wings[:, 1]) + + # Make a new Airplane for this time step. + this_airplane = Airplane( + wings=these_wings, + name=this_name, + Cg_E_CgP1=thisCg_E_CgP1, + angles_E_to_B_izyx=theseAngles_E_to_B_izyx, + weight=this_weight, + ) + + # Add this new Airplane to the list of Airplanes. + airplanes.append(this_airplane) + + return airplanes diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py new file mode 100644 index 000000000..e69de29bb diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py new file mode 100644 index 000000000..e69de29bb diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py new file mode 100644 index 000000000..a3956520c --- /dev/null +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -0,0 +1,399 @@ +"""This module contains the WingCrossSectionMovement class. + +This module contains the following classes: + WingCrossSectionMovement: This is a class used to contain the WingCrossSection + movements. + +This module contains the following functions: + None +""" + +import numpy as np + + +from _parameter_validation import ( + threeD_number_vectorLike_return_float, + threeD_spacing_vectorLike_return_tuple, + positive_number_return_float, +) + +from _functions import ( + oscillating_sinspaces, + oscillating_linspaces, + oscillating_customspaces, +) + +from geometry.wing_cross_section import WingCrossSection + + +class SingleStepWingCrossSectionMovement: + """This is a class used to contain the WingCrossSection movements. + + This class contains the following public methods: + + generate_wing_cross_sections: Creates the WingCrossSection at each time step, + and returns them in a list. + + max_period: Defines a property for the longest period of + WingCrossSectionMovement's own motion. + + This class contains the following class attributes: + None + + Subclassing: + This class is not meant to be subclassed. + """ + + def __init__( + self, + 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), + ): + """This is the initialization method. + + :param base_wing_cross_section: WingCrossSection + + This is the base WingCrossSection, from which the WingCrossSection at + each time step will be created. + + :param ampLp_Wcsp_Lpp: array-like of 3 numbers, optional + + The amplitudes of the WingCrossSectionMovement's changes in its + WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, or numpy + array of non-negative numbers (int or float). Also, each amplitude must + be low enough that it doesn't drive its base value out of the range of + valid values. Otherwise, this WingCrossSectionMovement will try to create + WingCrossSections with invalid parameters values. Values are converted to + floats internally. The default value is (0.0, 0.0, 0.0). The units are in + meters. + + :param periodLp_Wcsp_Lpp: array-like of 3 numbers, optional + + The periods of the WingCrossSectionMovement's changes in its + WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, or numpy + array of non-negative numbers (int or float). Values are converted to + floats internally. The default value is (0.0, 0.0, 0.0). Each element + must be 0.0 if the corresponding element in ampLp_Wcsp_Lpp is 0.0 and + non-zero if not. The units are in seconds. + + :param spacingLp_Wcsp_Lpp: array-like of 3 strs or callables, optional + + The value determines the spacing of the WingCrossSectionMovement's change + in its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, + or numpy array. Each element can be the string "sine", the string + "uniform", or a callable custom spacing function. Custom spacing functions + are for advanced users and must start at 0, return to 0 after one period + of 2*pi radians, have amplitude of 1, be periodic, return finite values + only, and accept a ndarray as input and return a ndarray of the same + shape. The custom function is scaled by ampLp_Wcsp_Lpp, shifted + horizontally by phaseLp_Wcsp_Lpp, and vertically by the base value, with + the period controlled by periodLp_Wcsp_Lpp. The default value is ("sine", + "sine", "sine"). + + :param phaseLp_Wcsp_Lpp: array-like of 3 numbers, optional + + The phase offsets of the elements in the first time step's + WingCrossSection's Lp_Wcsp_Lpp parameter relative to the base + WingCrossSection's Lp_Wcsp_Lpp parameter. Can be a tuple, list, or numpy + array of non-negative numbers (int or float) in the range (-180.0, + 180.0]. Values are converted to floats internally. The default value is ( + 0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding element in + ampLp_Wcsp_Lpp is 0.0 and non-zero if not. The units are in degrees. + + :param ampAngles_Wcsp_to_Wcs_ixyz: array-like of 3 numbers, optional + + The amplitudes of the WingCrossSectionMovement's changes in its + WingCrossSections' angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, + list, or numpy array of numbers (int or float) in the range [0.0, + 180.0]. Also, each amplitude must be low enough that it doesn't drive its + base value out of the range of valid values. Otherwise, + this WingCrossSectionMovement will try to create WingCrossSections with + invalid parameters values. Values are converted to floats internally. The + default value is (0.0, 0.0, 0.0). The units are in degrees. + + :param periodAngles_Wcsp_to_Wcs_ixyz: array-like of 3 numbers, optional + + The periods of the WingCrossSectionMovement's changes in its + WingCrossSections' angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, + list, or numpy array of non-negative numbers (int or float). Values are + converted to floats internally. The default value is (0.0, 0.0, + 0.0). Each element must be 0.0 if the corresponding element in + ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non-zero if not. The units are in + seconds. + + :param spacingAngles_Wcsp_to_Wcs_ixyz: array-like of 3 strs or callables, optional + + The value determines the spacing of the WingCrossSectionMovement's change + in its WingCrossSections' angles_Wcsp_to_Wcs_ixyz parameters. Can be a + tuple, list, or numpy array. Each element can be the string "sine", + the string "uniform", or a callable custom spacing function. Custom + spacing functions are for advanced users and must start at 0, return to 0 + after one period of 2*pi radians, have amplitude of 1, be periodic, + return finite values only, and accept a ndarray as input and return a + ndarray of the same shape. The custom function is scaled by + ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally by + phaseAngles_Wcsp_to_Wcs_ixyz, and vertically by the base value, with the + period controlled by periodAngles_Wcsp_to_Wcs_ixyz. The default value is + ("sine", "sine", "sine"). + + :param phaseAngles_Wcsp_to_Wcs_ixyz: array-like of 3 numbers, optional + + The phase offsets of the elements in the first time step's + WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter relative to the base + WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter. Can be a tuple, + list, or numpy array of numbers (int or float) in the range (-180.0, + 180.0]. Values are converted to floats internally. The default value is ( + 0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding element in + ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non-zero if not. The units are in + degrees. + """ + + + ampLp_Wcsp_Lpp = threeD_number_vectorLike_return_float( + ampLp_Wcsp_Lpp, "ampLp_Wcsp_Lpp" + ) + if not np.all(ampLp_Wcsp_Lpp >= 0.0): + raise ValueError("All elements in ampLp_Wcsp_Lpp must be non-negative.") + self.ampLp_Wcsp_Lpp = ampLp_Wcsp_Lpp + + periodLp_Wcsp_Lpp = threeD_number_vectorLike_return_float( + periodLp_Wcsp_Lpp, "periodLp_Wcsp_Lpp" + ) + if not np.all(periodLp_Wcsp_Lpp >= 0.0): + raise ValueError("All elements in periodLp_Wcsp_Lpp must be non-negative.") + for period_index, period in enumerate(periodLp_Wcsp_Lpp): + amp = self.ampLp_Wcsp_Lpp[period_index] + if amp == 0 and period != 0: + raise ValueError( + "If an element in ampLp_Wcsp_Lpp is 0.0, the corresponding element in periodLp_Wcsp_Lpp must be also be 0.0." + ) + self.periodLp_Wcsp_Lpp = periodLp_Wcsp_Lpp + + spacingLp_Wcsp_Lpp = ( + threeD_spacing_vectorLike_return_tuple( + spacingLp_Wcsp_Lpp, "spacingLp_Wcsp_Lpp" + ) + ) + self.spacingLp_Wcsp_Lpp = spacingLp_Wcsp_Lpp + + phaseLp_Wcsp_Lpp = threeD_number_vectorLike_return_float( + phaseLp_Wcsp_Lpp, "phaseLp_Wcsp_Lpp" + ) + if not ( + np.all(phaseLp_Wcsp_Lpp > -180.0) and np.all(phaseLp_Wcsp_Lpp <= 180.0) + ): + raise ValueError( + "All elements in phaseLp_Wcsp_Lpp must be in the range (-180.0, 180.0]." + ) + for phase_index, phase in enumerate(phaseLp_Wcsp_Lpp): + amp = self.ampLp_Wcsp_Lpp[phase_index] + if amp == 0 and phase != 0: + raise ValueError( + "If an element in ampLp_Wcsp_Lpp is 0.0, the corresponding element in phaseLp_Wcsp_Lpp must be also be 0.0." + ) + self.phaseLp_Wcsp_Lpp = phaseLp_Wcsp_Lpp + + ampAngles_Wcsp_to_Wcs_ixyz = ( + threeD_number_vectorLike_return_float( + ampAngles_Wcsp_to_Wcs_ixyz, "ampAngles_Wcsp_to_Wcs_ixyz" + ) + ) + if not ( + np.all(ampAngles_Wcsp_to_Wcs_ixyz >= 0.0) + and np.all(ampAngles_Wcsp_to_Wcs_ixyz <= 180.0) + ): + raise ValueError( + "All elements in ampAngles_Wcsp_to_Wcs_ixyz must be in the range [0.0, 180.0]." + ) + self.ampAngles_Wcsp_to_Wcs_ixyz = ampAngles_Wcsp_to_Wcs_ixyz + + periodAngles_Wcsp_to_Wcs_ixyz = ( + threeD_number_vectorLike_return_float( + periodAngles_Wcsp_to_Wcs_ixyz, "periodAngles_Wcsp_to_Wcs_ixyz" + ) + ) + if not np.all(periodAngles_Wcsp_to_Wcs_ixyz >= 0.0): + raise ValueError( + "All elements in periodAngles_Wcsp_to_Wcs_ixyz must be non-negative." + ) + for period_index, period in enumerate(periodAngles_Wcsp_to_Wcs_ixyz): + amp = self.ampAngles_Wcsp_to_Wcs_ixyz[period_index] + if amp == 0 and period != 0: + raise ValueError( + "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, the corresponding element in periodAngles_Wcsp_to_Wcs_ixyz must be also be 0.0." + ) + self.periodAngles_Wcsp_to_Wcs_ixyz = periodAngles_Wcsp_to_Wcs_ixyz + + spacingAngles_Wcsp_to_Wcs_ixyz = ( + threeD_spacing_vectorLike_return_tuple( + spacingAngles_Wcsp_to_Wcs_ixyz, + "spacingAngles_Wcsp_to_Wcs_ixyz", + ) + ) + self.spacingAngles_Wcsp_to_Wcs_ixyz = spacingAngles_Wcsp_to_Wcs_ixyz + + phaseAngles_Wcsp_to_Wcs_ixyz = ( + threeD_number_vectorLike_return_float( + phaseAngles_Wcsp_to_Wcs_ixyz, "phaseAngles_Wcsp_to_Wcs_ixyz" + ) + ) + if not ( + np.all(phaseAngles_Wcsp_to_Wcs_ixyz > -180.0) + and np.all(phaseAngles_Wcsp_to_Wcs_ixyz <= 180.0) + ): + raise ValueError( + "All elements in phaseAngles_Wcsp_to_Wcs_ixyz must be in the range (-180.0, 180.0]." + ) + for phase_index, phase in enumerate(phaseAngles_Wcsp_to_Wcs_ixyz): + amp = self.ampAngles_Wcsp_to_Wcs_ixyz[phase_index] + if amp == 0 and phase != 0: + raise ValueError( + "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, the corresponding element in phaseAngles_Wcsp_to_Wcs_ixyz must be also be 0.0." + ) + self.phaseAngles_Wcsp_to_Wcs_ixyz = phaseAngles_Wcsp_to_Wcs_ixyz + + def generate_wing_cross_sections( + self, + num_steps, + delta_time, + ): + """Creates the WingCrossSection at each time step, and returns them in a list. + + :param num_steps: int + + This is the number of time steps in this movement. It must be a positive + int. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + + :return: list of WingCrossSections + + This is the list of WingCrossSections associated with this + WingCrossSectionMovement. + """ + num_steps = 2 + delta_time = positive_number_return_float( + delta_time, "delta_time" + ) + + # Generate oscillating values for each dimension of Lp_Wcsp_Lpp. + listLp_Wcsp_Lpp = np.zeros((3, num_steps), dtype=float) + for dim in range(3): + spacing = self.spacingLp_Wcsp_Lpp[dim] + if spacing == "sine": + listLp_Wcsp_Lpp[dim, :] = oscillating_sinspaces( + amps=self.ampLp_Wcsp_Lpp[dim], + periods=self.periodLp_Wcsp_Lpp[dim], + phases=self.phaseLp_Wcsp_Lpp[dim], + bases=self.base_wing_cross_section.Lp_Wcsp_Lpp[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif spacing == "uniform": + listLp_Wcsp_Lpp[dim, :] = oscillating_linspaces( + amps=self.ampLp_Wcsp_Lpp[dim], + periods=self.periodLp_Wcsp_Lpp[dim], + phases=self.phaseLp_Wcsp_Lpp[dim], + bases=self.base_wing_cross_section.Lp_Wcsp_Lpp[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif callable(spacing): + listLp_Wcsp_Lpp[dim, :] = oscillating_customspaces( + amps=self.ampLp_Wcsp_Lpp[dim], + periods=self.periodLp_Wcsp_Lpp[dim], + phases=self.phaseLp_Wcsp_Lpp[dim], + bases=self.base_wing_cross_section.Lp_Wcsp_Lpp[dim], + num_steps=num_steps, + delta_time=delta_time, + custom_function=spacing, + ) + else: + raise ValueError(f"Invalid spacing value: {spacing}") + + # Generate oscillating values for each dimension of angles_Wcsp_to_Wcs_ixyz. + listAngles_Wcsp_to_Wcs_ixyz = np.zeros((3, num_steps), dtype=float) + for dim in range(3): + spacing = self.spacingAngles_Wcsp_to_Wcs_ixyz[dim] + if spacing == "sine": + listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_sinspaces( + amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], + periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], + phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], + bases=self.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif spacing == "uniform": + listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_linspaces( + amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], + periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], + phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], + bases=self.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif callable(spacing): + listAngles_Wcsp_to_Wcs_ixyz[dim, :] = ( + oscillating_customspaces( + amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], + periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], + phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], + bases=self.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], + num_steps=num_steps, + delta_time=delta_time, + custom_function=spacing, + ) + ) + else: + raise ValueError(f"Invalid spacing value: {spacing}") + + # Create an empty list to hold each time step's WingCrossSection. + wing_cross_sections = [] + + # Get the non-changing WingCrossSectionAttributes. + this_airfoil = self.base_wing_cross_section.airfoil + this_num_spanwise_panels = self.base_wing_cross_section.num_spanwise_panels + this_chord = self.base_wing_cross_section.chord + this_control_surface_symmetry_type = ( + self.base_wing_cross_section.control_surface_symmetry_type + ) + this_control_surface_hinge_point = ( + self.base_wing_cross_section.control_surface_hinge_point + ) + this_control_surface_deflection = ( + self.base_wing_cross_section.control_surface_deflection + ) + this_spanwise_spacing = self.base_wing_cross_section.spanwise_spacing + + # Iterate through the time steps. + thisLp_Wcsp_Lpp = listLp_Wcsp_Lpp[:, 1] + theseAngles_Wcsp_to_Wcs_ixyz = listAngles_Wcsp_to_Wcs_ixyz[:, 1] + + # Make a new WingCrossSection for this time step. + this_wing_cross_section = WingCrossSection( + airfoil=this_airfoil, + num_spanwise_panels=this_num_spanwise_panels, + chord=this_chord, + Lp_Wcsp_Lpp=thisLp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=theseAngles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=this_control_surface_symmetry_type, + control_surface_hinge_point=this_control_surface_hinge_point, + control_surface_deflection=this_control_surface_deflection, + spanwise_spacing=this_spanwise_spacing, + ) + + # Add this new WingCrossSection to the list of WingCrossSections. + wing_cross_sections.append(this_wing_cross_section) + + return wing_cross_sections diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py new file mode 100644 index 000000000..1e0be96e3 --- /dev/null +++ b/pterasoftware/movements/single_step/single_step_wing_movement.py @@ -0,0 +1,421 @@ +"""This module contains the WingMovement class. + +This module contains the following classes: + WingMovement: This is a class used to contain the Wing movements. + +This module contains the following functions: + None +""" + +import numpy as np + +from _parameter_validation import ( + threeD_number_vectorLike_return_float, + threeD_spacing_vectorLike_return_tuple, + positive_number_return_float, +) + +from _functions import ( + oscillating_sinspaces, + oscillating_linspaces, + oscillating_customspaces, +) + +from geometry.wing import Wing + +class SingleStepWingMovement: + """This is a class used to contain the Wing movements. + + Note: Wings cannot undergo motion that causes them to switch symmetry types. A + transition between types could change the number of Wings and the panel + structure, which is incompatible with the unsteady solver. This happens when a + WingMovement defines motion that causes its base Wing's wing axes' yz-plane and + its symmetry plane to transition from coincident to non-coincident, or vice + versa. This is checked by this WingMovement's parent AirplaneMovement's parent + Movement. + + This class contains the following public methods: + + generate_wings: Creates the Wing at each time step, and returns them in a list. + + max_period: Defines a property for the longest period of WingMovement's own + motion and that of its sub-movement objects. + + This class contains the following class attributes: + None + + Subclassing: + This class is not meant to be subclassed. + """ + + def __init__( + self, + single_step_wing_cross_section_movements, + 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), + ): + """This is the initialization method. + + :param base_wing: Wing + + This is the base Wing, from which the Wing at each time step will be + created. + + :param wing_cross_section_movements: list of WingCrossSectionMovements + + This is a list of the WingCrossSectionMovements associated with each of + the base Wing's WingCrossSections. It must have the same length as the + base Wing's list of WingCrossSections. + + :param ampLer_Gs_Cgs: array-like of 3 numbers, optional + + The amplitudes of the WingMovement's changes in its Wings' Ler_Gs_Cgs + parameters. Can be a tuple, list, or numpy array of non-negative numbers + (int or float). Also, each amplitude must be low enough that it doesn't + drive its base value out of the range of valid values. Otherwise, + this WingMovement will try to create Wings with invalid parameters + values. Values are converted to floats internally. The default value is ( + 0.0, 0.0, 0.0). The units are in meters. + + :param periodLer_Gs_Cgs: array-like of 3 numbers, optional + + The periods of the WingMovement's changes in its Wings' Ler_Gs_Cgs + parameters. Can be a tuple, list, or numpy array of non-negative numbers + (int or float). Values are converted to floats internally. The default + value is (0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding + element in ampLer_Gs_Cgs is 0.0 and non-zero if not. The units are in + seconds. + + :param spacingLer_Gs_Cgs: array-like of 3 strs or callables, optional + + The value determines the spacing of the WingMovement's change in its + Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or numpy array. Each + element can be the string "sine", the string "uniform", or a callable + custom spacing function. Custom spacing functions are for advanced users + and must start at 0, return to 0 after one period of 2*pi radians, + have amplitude of 1, be periodic, return finite values only, and accept + a ndarray as input and return a ndarray of the same shape. The custom + function is scaled by ampLer_Gs_Cgs, shifted horizontally by + phaseLer_Gs_Cgs, and vertically by the base value, with the period + controlled by periodLer_Gs_Cgs. The default value is ("sine", "sine", + "sine"). + + :param phaseLer_Gs_Cgs: array-like of 3 numbers, optional + + The phase offsets of the elements in the first time step's Wing's + Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs parameter. + Can be a tuple, list, or numpy array of non-negative numbers (int or + float) in the range (-180.0, 180.0]. Values are converted to floats + internally. The default value is (0.0, 0.0, 0.0). Each element must be + 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and non-zero if + not. The units are in degrees. + + :param ampAngles_Gs_to_Wn_ixyz: array-like of 3 numbers, optional + + The amplitudes of the WingMovement's changes in its Wings' + angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or numpy array of + numbers (int or float) in the range [0.0, 180.0]. Also, each amplitude + must be low enough that it doesn't drive its base value out of the range + of valid values. Otherwise, this WingMovement will try to create Wings + with invalid parameters values. Values are converted to floats + internally. The default value is (0.0, 0.0, 0.0). The units are in degrees. + + :param periodAngles_Gs_to_Wn_ixyz: array-like of 3 numbers, optional + + The periods of the WingMovement's changes in its Wings' + angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or numpy array of + non-negative numbers (int or float). Values are converted to floats + internally. The default value is (0.0, 0.0, 0.0). Each element must be + 0.0 if the corresponding element in ampAngles_Gs_to_Wn_ixyz is 0.0 and + non-zero if not. The units are in seconds. + + :param spacingAngles_Gs_to_Wn_ixyz: array-like of 3 strs or callables, optional + + The value determines the spacing of the WingMovement's change in its + Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or numpy + array. Each element can be the string "sine", the string "uniform", + or a callable custom spacing function. Custom spacing functions are for + advanced users and must start at 0, return to 0 after one period of 2*pi + radians, have amplitude of 1, be periodic, return finite values only, + and accept a ndarray as input and return a ndarray of the same shape. + The custom function is scaled by ampAngles_Gs_to_Wn_ixyz, shifted + horizontally by phaseAngles_Gs_to_Wn_ixyz, and vertically by the base + value, with the period controlled by periodAngles_Gs_to_Wn_ixyz. The + default value is ("sine", "sine", "sine"). + + :param phaseAngles_Gs_to_Wn_ixyz: array-like of 3 numbers, optional + + The phase offsets of the elements in the first time step's Wing's + angles_Gs_to_Wn_ixyz parameter relative to the base Wing's + angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or numpy array of + numbers (int or float) in the range (-180.0, 180.0]. Values are converted + to floats internally. The default value is (0.0, 0.0, 0.0). Each element + must be 0.0 if the corresponding element in ampAngles_Gs_to_Wn_ixyz is + 0.0 and non-zero if not. The units are in degrees. + """ + self.wing_cross_section_movements = single_step_wing_cross_section_movements + + ampLer_Gs_Cgs = threeD_number_vectorLike_return_float( + ampLer_Gs_Cgs, "ampLer_Gs_Cgs" + ) + if not np.all(ampLer_Gs_Cgs >= 0.0): + raise ValueError("All elements in ampLer_Gs_Cgs must be non-negative.") + self.ampLer_Gs_Cgs = ampLer_Gs_Cgs + + periodLer_Gs_Cgs = threeD_number_vectorLike_return_float( + periodLer_Gs_Cgs, "periodLer_Gs_Cgs" + ) + if not np.all(periodLer_Gs_Cgs >= 0.0): + raise ValueError("All elements in periodLer_Gs_Cgs must be non-negative.") + for period_index, period in enumerate(periodLer_Gs_Cgs): + amp = self.ampLer_Gs_Cgs[period_index] + if amp == 0 and period != 0: + raise ValueError( + "If an element in ampLer_Gs_Cgs is 0.0, the corresponding element in periodLer_Gs_Cgs must be also be 0.0." + ) + self.periodLer_Gs_Cgs = periodLer_Gs_Cgs + + spacingLer_Gs_Cgs = ( + threeD_spacing_vectorLike_return_tuple( + spacingLer_Gs_Cgs, + "spacingLer_Gs_Cgs", + ) + ) + self.spacingLer_Gs_Cgs = spacingLer_Gs_Cgs + + phaseLer_Gs_Cgs = threeD_number_vectorLike_return_float( + phaseLer_Gs_Cgs, "phaseLer_Gs_Cgs" + ) + if not (np.all(phaseLer_Gs_Cgs > -180.0) and np.all(phaseLer_Gs_Cgs <= 180.0)): + raise ValueError( + "All elements in phaseLer_Gs_Cgs must be in the range (-180.0, 180.0]." + ) + for phase_index, phase in enumerate(phaseLer_Gs_Cgs): + amp = self.ampLer_Gs_Cgs[phase_index] + if amp == 0 and phase != 0: + raise ValueError( + "If an element in ampLer_Gs_Cgs is 0.0, the corresponding element in phaseLer_Gs_Cgs must be also be 0.0." + ) + self.phaseLer_Gs_Cgs = phaseLer_Gs_Cgs + + ampAngles_Gs_to_Wn_ixyz = ( + threeD_number_vectorLike_return_float( + ampAngles_Gs_to_Wn_ixyz, "ampAngles_Gs_to_Wn_ixyz" + ) + ) + if not ( + np.all(ampAngles_Gs_to_Wn_ixyz >= 0.0) + and np.all(ampAngles_Gs_to_Wn_ixyz <= 180.0) + ): + raise ValueError( + "All elements in ampAngles_Gs_to_Wn_ixyz must be in the range [0.0, 180.0]." + ) + self.ampAngles_Gs_to_Wn_ixyz = ampAngles_Gs_to_Wn_ixyz + + periodAngles_Gs_to_Wn_ixyz = ( + threeD_number_vectorLike_return_float( + periodAngles_Gs_to_Wn_ixyz, "periodAngles_Gs_to_Wn_ixyz" + ) + ) + if not np.all(periodAngles_Gs_to_Wn_ixyz >= 0.0): + raise ValueError( + "All elements in periodAngles_Gs_to_Wn_ixyz must be non-negative." + ) + for period_index, period in enumerate(periodAngles_Gs_to_Wn_ixyz): + amp = self.ampAngles_Gs_to_Wn_ixyz[period_index] + if amp == 0 and period != 0: + raise ValueError( + "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, the corresponding element in periodAngles_Gs_to_Wn_ixyz must be also be 0.0." + ) + self.periodAngles_Gs_to_Wn_ixyz = periodAngles_Gs_to_Wn_ixyz + + spacingAngles_Gs_to_Wn_ixyz = ( + threeD_spacing_vectorLike_return_tuple( + spacingAngles_Gs_to_Wn_ixyz, + "spacingAngles_Gs_to_Wn_ixyz", + ) + ) + self.spacingAngles_Gs_to_Wn_ixyz = spacingAngles_Gs_to_Wn_ixyz + + phaseAngles_Gs_to_Wn_ixyz = ( + threeD_number_vectorLike_return_float( + phaseAngles_Gs_to_Wn_ixyz, "phaseAngles_Gs_to_Wn_ixyz" + ) + ) + if not ( + np.all(phaseAngles_Gs_to_Wn_ixyz > -180.0) + and np.all(phaseAngles_Gs_to_Wn_ixyz <= 180.0) + ): + raise ValueError( + "All elements in phaseAngles_Gs_to_Wn_ixyz must be in the range (-180.0, 180.0]." + ) + for phase_index, phase in enumerate(phaseAngles_Gs_to_Wn_ixyz): + amp = self.ampAngles_Gs_to_Wn_ixyz[phase_index] + if amp == 0 and phase != 0: + raise ValueError( + "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, the corresponding element in phaseAngles_Gs_to_Wn_ixyz must be also be 0.0." + ) + self.phaseAngles_Gs_to_Wn_ixyz = phaseAngles_Gs_to_Wn_ixyz + + def generate_next_wing(self, base_wing: Wing, delta_time): + """Creates the Wing at each time step, and returns them in a list. + + :param num_steps: int + + This is the number of time steps in this movement. It must be a positive + int. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + + :return: list of Wings + + This is the list of Wings associated with this WingMovement. + """ + num_steps = 2 + delta_time = positive_number_return_float( + delta_time, "delta_time" + ) + + # Generate oscillating values for each dimension of Ler_Gs_Cgs. + listLer_Gs_Cgs = np.zeros((3, num_steps), dtype=float) + for dim in range(3): + spacing = self.spacingLer_Gs_Cgs[dim] + if spacing == "sine": + listLer_Gs_Cgs[dim, :] = oscillating_sinspaces( + amps=self.ampLer_Gs_Cgs[dim], + periods=self.periodLer_Gs_Cgs[dim], + phases=self.phaseLer_Gs_Cgs[dim], + bases=base_wing.Ler_Gs_Cgs[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif spacing == "uniform": + listLer_Gs_Cgs[dim, :] = oscillating_linspaces( + amps=self.ampLer_Gs_Cgs[dim], + periods=self.periodLer_Gs_Cgs[dim], + phases=self.phaseLer_Gs_Cgs[dim], + bases=base_wing.Ler_Gs_Cgs[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif callable(spacing): + listLer_Gs_Cgs[dim, :] = oscillating_customspaces( + amps=self.ampLer_Gs_Cgs[dim], + periods=self.periodLer_Gs_Cgs[dim], + phases=self.phaseLer_Gs_Cgs[dim], + bases=base_wing.Ler_Gs_Cgs[dim], + num_steps=num_steps, + delta_time=delta_time, + custom_function=spacing, + ) + else: + raise ValueError(f"Invalid spacing value: {spacing}") + + # Generate oscillating values for each dimension of angles_Gs_to_Wn_ixyz. + listAngles_Gs_to_Wn_ixyz = np.zeros((3, num_steps), dtype=float) + for dim in range(3): + spacing = self.spacingAngles_Gs_to_Wn_ixyz[dim] + if spacing == "sine": + listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_sinspaces( + amps=self.ampAngles_Gs_to_Wn_ixyz[dim], + periods=self.periodAngles_Gs_to_Wn_ixyz[dim], + phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], + bases=base_wing.angles_Gs_to_Wn_ixyz[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif spacing == "uniform": + listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_linspaces( + amps=self.ampAngles_Gs_to_Wn_ixyz[dim], + periods=self.periodAngles_Gs_to_Wn_ixyz[dim], + phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], + bases=base_wing.angles_Gs_to_Wn_ixyz[dim], + num_steps=num_steps, + delta_time=delta_time, + ) + elif callable(spacing): + listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_customspaces( + amps=self.ampAngles_Gs_to_Wn_ixyz[dim], + periods=self.periodAngles_Gs_to_Wn_ixyz[dim], + phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], + bases=base_wing.angles_Gs_to_Wn_ixyz[dim], + num_steps=num_steps, + delta_time=delta_time, + custom_function=spacing, + ) + else: + raise ValueError(f"Invalid spacing value: {spacing}") + + # Create an empty 2D ndarray that will hold each of the Wings's + # WingCrossSection's vector of WingCrossSections representing its changing + # state at each time step. The first index denotes a particular base + # WingCrossSection, and the second index denotes the time step. + wing_cross_sections = np.empty( + (len(self.wing_cross_section_movements), num_steps), dtype=object + ) + + # Iterate through the WingCrossSectionMovements. + for ( + wing_cross_section_movement_id, + wing_cross_section_movement, + ) in enumerate(self.wing_cross_section_movements): + + # Generate this WingCrossSection's vector of WingCrossSections + # representing its changing state at each time step. + this_wing_cross_sections_list_of_wing_cross_sections = np.array( + wing_cross_section_movement.generate_next_wing_cross_sections( + delta_time=delta_time + ) + ) + + # Add this vector the Wing's 2D ndarray of WingCrossSections' + # WingCrossSections. + wing_cross_sections[wing_cross_section_movement_id, :] = ( + this_wing_cross_sections_list_of_wing_cross_sections + ) + + # Create an empty list to hold each time step's Wing. + wings = [] + + # Get the non-changing Wing attributes. + this_name = base_wing.name + this_symmetric = base_wing.symmetric + this_mirror_only = base_wing.mirror_only + this_symmetryNormal_G = base_wing.symmetryNormal_G + this_symmetryPoint_G_Cg = base_wing.symmetryPoint_G_Cg + this_num_chordwise_panels = base_wing.num_chordwise_panels + this_chordwise_spacing = base_wing.chordwise_spacing + + + thisLer_Gs_Cgs = listLer_Gs_Cgs[:, 1] + theseAngles_Gs_to_Wn_ixyz = listAngles_Gs_to_Wn_ixyz[:, 1] + these_wing_cross_sections = list(wing_cross_sections[:, 1]) + + # Make a new Wing for this time step. + this_wing = Wing( + wing_cross_sections=these_wing_cross_sections, + name=this_name, + Ler_Gs_Cgs=thisLer_Gs_Cgs, + angles_Gs_to_Wn_ixyz=theseAngles_Gs_to_Wn_ixyz, + symmetric=this_symmetric, + mirror_only=this_mirror_only, + symmetryNormal_G=this_symmetryNormal_G, + symmetryPoint_G_Cg=this_symmetryPoint_G_Cg, + num_chordwise_panels=this_num_chordwise_panels, + chordwise_spacing=this_chordwise_spacing, + ) + + # Add this new Wing to the list of Wings. + wings.append(this_wing) + + return wings diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 842c200d4..7ea692fc5 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -17,6 +17,9 @@ from . import _parameter_validation from . import _transformations from . import operating_point as op +from coupled_unsteady_ring_vortex_lattice_method import CoupledUnsteadyRingVortexLatticeMethodSolver +from copy import deepcopy +from scipy.integrate import quad class SteadyProblem: @@ -339,5 +342,242 @@ def get_steady_problem(self, step): # Return the corresponding steady-state problem. return self._steady_problems[step] - def initialize_next_problem(self): + def initialize_next_problem(self, solver): self._steady_problems.append(self.steady_problems[len(self._steady_problems)]) + +class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): + def __init__(self, movement, only_final_results=False): + super().__init__(movement, only_final_results) + self.prev_velocities = None + self.G = 1e8 + self.I_area = 1e-14 + + def define_mass_matrix(self, M_wing, airplane): + """ + Currently treats all panels as having equal mass. This will + + :param M_wing: float + This parameter is the total mass of one wing in (kg). + :param _geometry.airplane.Airplane: + The current + + :return numpy.ndarray + A 3D array of shape (num_spanwise_panels, num_chordwise_panels) + containing a float value for the mass of each panel + """ + # yes it's bad practice to have this in both functions, but I intend to update + # this with more complex methods + num_spanwise_panels = airplane.wings[0].num_spanwise_panels + num_chordwise_panels = airplane.wings[0].num_chordwise_panels + point_mass = M_wing / (num_spanwise_panels * num_chordwise_panels) + + return ( + np.ones((num_chordwise_panels, num_spanwise_panels, 3), dtype=float) + * point_mass + ) + + def calculate_wing_panel_accelerations(self, solver: CoupledUnsteadyRingVortexLatticeMethodSolver, num_panels): + dt = self.movement.delta_time + if len(self._steady_problems) - len(): + self.forces_per_timestep.append(solver.stackCpp_GP1_CgP1) + + wing_panel_veloctiy = solver.calculate_solution_velocity() + return + + def initialize_next_problem(self, solver: CoupledUnsteadyRingVortexLatticeMethodSolver): + curr_problem: SteadyProblem = self._steady_problems[-1] + airplane = curr_problem.airplanes[0] + + wing = airplane.wings[0] + mass_matrix = self.define_mass_matrix(0.02, airplane) + + # Panel number definitions + num_chordwise_panels = wing.num_chordwise_panels + num_spanwise_panels = wing.num_spanwise_panels + num_panels = num_chordwise_panels * num_spanwise_panels + + current_torsion_aero = np.zeros(num_chordwise_panels) + current_torsion_inertia = np.zeros(num_chordwise_panels) + span_torsion_angles = np.zeros(int(num_spanwise_panels)) + chord_torsion_angles = np.zeros(int(num_spanwise_panels)) + torsion_matrix = np.zeros((num_chordwise_panels, num_spanwise_panels)) + + points = np.array(self.stackCpp_G_Cg)[:num_panels, :] + x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ + :num_panels, :, 0 + ] + panelAeroForces_G = np.stack( + [o.forces_G for o in np.ravel(wing.panels)] + ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) + + panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels) * mass_matrix + # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element + # Force across spanwise panel is distinct + for span_panel in range(num_spanwise_panels): + # Force on each chordwise panel from LE to TE + # from each spanwise point is added to produce torsion at LE + for chord_panel in range(num_chordwise_panels): + # Torsion due to UVLM aero forces on LE + + current_torsion_aero[chord_panel] = ( + panelAeroForces_G[chord_panel][span_panel][2] + ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) + # Torsion due to panel inertia + current_torsion_inertia[chord_panel] = ( + panelInertialForces[chord_panel][span_panel][2] + ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) + # Total torsion on LE due to chordwise panel. Aero and Inertial Torsion are oriented in opposite sense + ct_angle = quad( + self.d_alpha_dy_air_static, + 0, + 0.01, + args=( + -(current_torsion_aero[chord_panel]) + + (current_torsion_inertia[chord_panel]), + self.G * self.I_area, + ), + )[0] + + chord_torsion_angles[span_panel] += ct_angle + torsion_matrix[chord_panel][span_panel] = ct_angle + # Torsion on span-wise collection of panels + span_torsion_angles[span_panel] = ( + span_torsion_angles[span_panel - 1] + chord_torsion_angles[span_panel] + if span_panel > 0 + else chord_torsion_angles[span_panel] + ) + + # Inserting torsion of static span-wise collection of panels at wing root + span_torsion_angles = np.insert(span_torsion_angles, 0, 0) + + # ### ********************** Convergence of torsion angle ***************************** ### + # # Error in torsion angle (radians) + # if self.last_torsion_angles is None: + # self.last_torsion_angles = np.zeros(num_spanwise_panels + 1) + # error_torsion = abs(span_torsion_angles - self.last_torsion_angles) + + # if step < 0.5 * self.num_steps / 3: + # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM + # error_exceeded_air = False + # else: + # # Error threshold in subsequeny timesteps is set to 0.01 + # # TODO : Determine appropriate error threshold by running test cases on changing wing twist + # error_threshold = 0.01 * np.pi / 180 # + # # Boolean to determine if change in torsion on any panel exceeds error threshold + # error_exceeded_air = np.any(error_torsion[1:] > error_threshold) + + # # If error exceeds the given threshold, the current step is re-run + # if error_exceeded_air: + # return True + # else: + # # Move to next timestep + # return False + + # Create new wing based on calculated aero-elastic response + self.create_new_wing( + wing=wing, + airplane=airplane, + deformation_matrices=span_torsion_angles * 180 / np.pi, + freestream_velocity=op.vInf_G__E, + num_chordwise_panels=num_chordwise_panels, + num_spanwise_panels=num_spanwise_panels, + ) + + # self.last_torsion_angles = span_torsion_angles + + self._steady_problems.append() + + + def create_new_wing( + self, + wing, + airplane, + deformation_matrices, + freestream_velocity, + num_chordwise_panels, + num_spanwise_panels, + ): + """This method redefines the current airplane by defining : + 1. new wing cross-section objects, each cross-section's twist = calculated torsion angle + 2. new wing object + + :param wing: Wing object + :param airplane : Airplane object + :param torsion_angle : A map from wing cross section to deformations + :param freestream_velocity : float, current freestream velocity + + :return: None + """ + # Initialize variable to hold new cross-section objects + these_cross_sections = [] + + # Normalizes the deformation matrix + if np.max((np.abs(deformation_matrices))) > 1: + deformation_matrices = deformation_matrices / np.max( + (np.abs(deformation_matrices)) + ) + + # # Create new cross-section objects with calculated torsion angle as wing twist + for i in range(len(deformation_matrices)): + this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( + # Every wing cross section has an airfoil object. + airfoil=geometry.airfoil.Airfoil( + name="naca0012", + outline_A_lp=None, + resample=True, + n_points_per_side=400, + ), + num_spanwise_panels=wing.wing_cross_sections[i].num_spanwise_panels, + chord=wing.wing_cross_sections[i].chord, + Lp_Wcsp_Lpp=wing.wing_cross_sections[i].Lp_Wcsp_Lpp, + # Every cross-section's twist is due to aero-elastic response + angles_Wcsp_to_Wcs_ixyz=( + np.array([0.0, 0.0, 0.0]) + if i == 0 + else np.array([0.0, deformation_matrices[i], 0.0]) + ), + control_surface_symmetry_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + spanwise_spacing=wing.wing_cross_sections[i].spanwise_spacing, + ) + these_cross_sections.append(this_wing_cross_section) + + # Define new Wing object with determined cross-sections + this_wing = geometry.wing.Wing( + name="Main Wing", + # Wing root position remains same + Ler_Gs_Cgs=wing.Ler_Gs_Cgs, + angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, + # Wing cross section is redefined + wing_cross_sections=these_cross_sections, + symmetric=True, + mirror_only=False, + symmetryNormal_G=(0.0, 1.0, 0.0), + symmetryPoint_G_Cg=(0.0, 0.0, 0.0), + num_chordwise_panels=wing.num_chordwise_panels, + chordwise_spacing=wing.chordwise_spacing, + ) + + # Create new Airplane object from new Wing objects + these_wings = [this_wing] + this_airplane = geometry.airplane.Airplane( + name="Example Airplane", + wings=these_wings, + Cg_E_CgP1=airplane.Cg_E_CgP1, + angles_E_to_B_izyx=airplane.angles_E_to_B_izyx, + weight=airplane.weight, + ) + print("sup", airplane.wings, this_airplane.wings) + # Redefine airplane at current timestep with newly created Airplane object + self.current_airplanes[0] = this_airplane + self.steady_problems[self._current_step] = SteadyProblem( + airplanes=self.current_airplanes, operating_point=self.current_operating_point + ) + + + def d_alpha_dy_air_static(self, y, tau_torsion, GI): + return (tau_torsion * y) / GI + + def rotational_inertia(self, m, x, theta): + return (1 / 3) * m * (x / np.cos(theta)) ** 3 From c64ea81145c1acb2d694308a9f5325a951931437 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Sun, 30 Nov 2025 18:26:37 -0500 Subject: [PATCH 03/40] push closer to single step --- ...led_unsteady_ring_vortex_lattice_method.py | 2 +- .../movements/single_step/__init__.py | 0 .../single_step/single_step_movement.py | 171 ++++++++++++++++++ pterasoftware/problems.py | 55 +++--- 4 files changed, 204 insertions(+), 24 deletions(-) create mode 100644 pterasoftware/movements/single_step/__init__.py diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 6f8d8ca92..b200b27a4 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -1055,6 +1055,7 @@ def _calculate_loads(self): # find the forces (in the first Airplane's geometry axes) on the Panels' # RingVortex's right LineVortex, front LineVortex, and left LineVortex using # the effective vortex strengths. + print("\nhi:", self._calculate_current_movement_velocities_at_right_leg_centers()) rightLegForces_GP1 = ( self.current_operating_point.rho * np.expand_dims(effective_right_vortex_line_strengths, axis=1) @@ -1079,7 +1080,6 @@ def _calculate_loads(self): self.stackLbrv_GP1, ) ) - # The unsteady force calculation below includes a negative sign to account for a # sign convention mismatch between Ptera Software and the reference literature. # Ptera Software defines RingVortices with counter-clockwise (CCW) vertex diff --git a/pterasoftware/movements/single_step/__init__.py b/pterasoftware/movements/single_step/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index e69de29bb..42547e868 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -0,0 +1,171 @@ +"""This module contains the Movement class. + +This module contains the following classes: + Movement: This is a class used to contain an UnsteadyProblem's movement. + +This module contains the following functions: + None +""" + +import math + +from single_step_airplane_movement import SingleStepAirplaneMovement +from single_step_operating_point_movement import SingleStepOperatingPointMovement + +import _parameter_validation + +class SingleStepMovement: + """This is a class used to contain an UnsteadyProblem's movement. + + This class contains the following public methods: + + max_period: Defines a property for the longest period of Movement's own + motion and that of its sub-movement objects, sub-sub-movement objects, etc. + + static: Defines a property to flag if all the Movement itself, and all of its + sub-movement objects, sub-sub-movement objects, etc. represent no motion. + + This class contains the following class attributes: + None + + Subclassing: + This class is not meant to be subclassed. + """ + + def __init__( + self, + single_step_airplane_movements, + single_step_operating_point_movement, + delta_time=None, + num_cycles=None, + num_chords=None, + num_steps=None, + ): + """This is the initialization method. + + Note: This method checks that all Wings maintain their symmetry type across + all time steps. See the WingMovement class documentation for more details on + this requirement, and the Wing class documentation for more information on + symmetry types. + + :param airplane_movements: list of AirplaneMovements + + This is a list of objects which characterize the movement of each + of the airplanes in the UnsteadyProblem. + + :param operating_point_movement: OperatingPointMovement + + This object characterizes changes to the UnsteadyProblem's the operating + point. + + :param delta_time: number or None, optional + + delta_time is the time, in seconds, between each time step. If left as + None, which is the default value, Movement will calculate a value such + that RingVortices shed from the first Wing will have roughly the same + chord length as the RingVortices on the first Wing. This is based on + first base Airplane's reference chord length, its first Wing's number of + chordwise panels, and its base OperatingPoint's velocity. If set, + delta_time must be a positive number (int or float). It will be converted + internally to a float. + + :param num_cycles: int or None, optional + + num_cycles is the number of cycles of the maximum period motion used to + calculate a non-populated num_steps parameter if Movement isn't static. + If num_steps is set or Movement is static, this must be left as None, + which is the default value. If num_steps isn't set and Movement isn't + static, num_cycles must be a positive int. In that case, I recommend + setting num_cycles to 3. + + :param num_chords: int or None, optional + + num_chords is the number of chord lengths used to calculate a + non-populated num_steps parameter if Movement is static. If num_steps is + set or Movement isn't static, this must be left as None, which is the + default value. If num_steps isn't set and Movement is static, num_chords + must be a positive int. In that case, I recommend setting num_chords to + 10. For cases with multiple Airplanes, the num_chords will reference the + largest reference chord length. + + :param num_steps: int or None, optional + + num_steps is the number of time steps of the unsteady simulation. It must + be a positive int. The default value is None. If left as None, + and Movement isn't static, Movement will calculate a value such that the + simulation will cover some number of cycles of the maximum period of all + the motion described in Movement's sub-movement objects, sub-sub-movement + objects, etc. If num_steps is left as None, and Movement is static, + it will default to the number of time steps such that the wake extends + back by some number of reference chord lengths. + """ + if not isinstance(single_step_airplane_movements, list): + raise TypeError("single_step_airplane_movements must be a list.") + if len(single_step_airplane_movements) < 1: + raise ValueError( + "single_step_airplane_movements must have at least one element." + ) + for airplane_movement in single_step_airplane_movements: + if not isinstance(airplane_movement, SingleStepAirplaneMovement): + raise TypeError( + "Every element in single_step_airplane_movements must be an SingleStepAirplaneMovement." + ) + self.airplane_movements = single_step_airplane_movements + + if not isinstance( + single_step_operating_point_movement, SingleStepOperatingPointMovement + ): + raise TypeError( + "single_step_operating_point_movement must be an SingleStepOperatingPointMovement." + ) + self.operating_point_movement = single_step_operating_point_movement + + if delta_time is not None: + delta_time = _parameter_validation.positive_number_return_float( + delta_time, "delta_time" + ) + else: + + # FIXME: Automatic delta_time calculation gives very poor results if the + # motion has a high Strouhal number (i.e. a large ratio of + # flapping-motion to forward velocity). This is because the calculation + # assumes that the forward velocity is dominant. A better approach is + # needed. + + delta_times = [] + for airplane_movement in self.airplane_movements: + # TODO: Consider making this also average across each Airplane's Wings. + # For a given Airplane, the ideal time step length is that which + # sheds RingVortices off the first Wing that have roughly the same + # chord length as the RingVortices on the first Wing. This is based + # on the base Airplane's reference chord length, its first Wing's + # number of chordwise panels, and its base OperatingPoint's velocity. + delta_times.append( + airplane_movement.base_airplane.c_ref + / airplane_movement.base_airplane.wings[0].num_chordwise_panels + / single_step_operating_point_movement.base_operating_point.vCg__E + ) + + # Set the delta_time to be the average of the Airplanes' ideal delta times. + delta_time = sum(delta_times) / len(delta_times) + self.delta_time = delta_time + + self.num_steps = num_steps + + # Generate a list of lists of Airplanes that are the steps through each + # AirplaneMovement. The first index identifies the AirplaneMovement, and the + # second index identifies the time step. + + def generate_next_movement(self): + airplanes = [] + for airplane_movement in self.airplane_movements: + airplanes.append( + airplane_movement.generate_next_airplane( + num_steps=self.num_steps, delta_time=self.delta_time + ) + ) + + operating_point = self.operating_point_movement.generate_next_operating_point( + num_steps=self.num_steps, delta_time=self.delta_time + ) + return airplanes, operating_point diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 7ea692fc5..baf4cd41c 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -17,9 +17,9 @@ from . import _parameter_validation from . import _transformations from . import operating_point as op -from coupled_unsteady_ring_vortex_lattice_method import CoupledUnsteadyRingVortexLatticeMethodSolver from copy import deepcopy from scipy.integrate import quad +from movements.single_step.single_step_movement import SingleStepMovement class SteadyProblem: @@ -348,7 +348,7 @@ def initialize_next_problem(self, solver): class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): def __init__(self, movement, only_final_results=False): super().__init__(movement, only_final_results) - self.prev_velocities = None + self.prev_velocities = [] self.G = 1e8 self.I_area = 1e-14 @@ -376,15 +376,21 @@ def define_mass_matrix(self, M_wing, airplane): * point_mass ) - def calculate_wing_panel_accelerations(self, solver: CoupledUnsteadyRingVortexLatticeMethodSolver, num_panels): + def calculate_wing_panel_accelerations(self, solver, num_panels): dt = self.movement.delta_time - if len(self._steady_problems) - len(): - self.forces_per_timestep.append(solver.stackCpp_GP1_CgP1) - wing_panel_veloctiy = solver.calculate_solution_velocity() - return + if len(self.prev_velocities) < 1: + # Set the flapping velocities to be zero for all points. Then, return the + # flapping velocities. + return np.zeros((num_panels, 3)) - def initialize_next_problem(self, solver: CoupledUnsteadyRingVortexLatticeMethodSolver): + curr_wing_panel_veloctiy = solver.calculate_solution_velocity( + solver.stackCpp_GP1_CgP1 + ) + + return (curr_wing_panel_veloctiy - self.prev_velocities[-1]) / dt + + def initialize_next_problem(self, solver): curr_problem: SteadyProblem = self._steady_problems[-1] airplane = curr_problem.airplanes[0] @@ -402,15 +408,15 @@ def initialize_next_problem(self, solver: CoupledUnsteadyRingVortexLatticeMethod chord_torsion_angles = np.zeros(int(num_spanwise_panels)) torsion_matrix = np.zeros((num_chordwise_panels, num_spanwise_panels)) - points = np.array(self.stackCpp_G_Cg)[:num_panels, :] + points = np.array(solver.stackCpp_GP1_CgP1)[:num_panels, :] x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ :num_panels, :, 0 ] panelAeroForces_G = np.stack( - [o.forces_G for o in np.ravel(wing.panels)] + [o.forces_W for o in np.ravel(wing.panels)] ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) - panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels) * mass_matrix + panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element # Force across spanwise panel is distinct for span_panel in range(num_spanwise_panels): @@ -478,22 +484,24 @@ def initialize_next_problem(self, solver: CoupledUnsteadyRingVortexLatticeMethod wing=wing, airplane=airplane, deformation_matrices=span_torsion_angles * 180 / np.pi, - freestream_velocity=op.vInf_G__E, + op=solver.current_operating_point, num_chordwise_panels=num_chordwise_panels, num_spanwise_panels=num_spanwise_panels, ) # self.last_torsion_angles = span_torsion_angles - - self._steady_problems.append() - + # TODO: add logic for when to append vs overwrite + if (True): + self.prev_velocities.append( + solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) + ) def create_new_wing( self, wing, airplane, deformation_matrices, - freestream_velocity, + op, num_chordwise_panels, num_spanwise_panels, ): @@ -560,9 +568,9 @@ def create_new_wing( ) # Create new Airplane object from new Wing objects - these_wings = [this_wing] + these_wings = [this_wing] + airplane.wings[2:] this_airplane = geometry.airplane.Airplane( - name="Example Airplane", + name=airplane.name, wings=these_wings, Cg_E_CgP1=airplane.Cg_E_CgP1, angles_E_to_B_izyx=airplane.angles_E_to_B_izyx, @@ -570,12 +578,13 @@ def create_new_wing( ) print("sup", airplane.wings, this_airplane.wings) # Redefine airplane at current timestep with newly created Airplane object - self.current_airplanes[0] = this_airplane - self.steady_problems[self._current_step] = SteadyProblem( - airplanes=self.current_airplanes, operating_point=self.current_operating_point - ) + new_problem = SteadyProblem( + airplanes=[this_airplane], + operating_point=op, + ) + self._steady_problems.append(new_problem) + - def d_alpha_dy_air_static(self, y, tau_torsion, GI): return (tau_torsion * y) / GI From 47ca3e6b779f4d77c0c948176f62e2fd6a42e232 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Sun, 30 Nov 2025 23:02:54 -0500 Subject: [PATCH 04/40] add working random deformation --- examples/demos/demo_single_step.py | 453 ++++++++++++++++++ ...led_unsteady_ring_vortex_lattice_method.py | 1 - .../movements/single_step/__init__.py | 5 + .../single_step_airplane_movement.py | 147 +++--- .../single_step/single_step_movement.py | 49 +- .../single_step_operating_point_movement.py | 237 +++++++++ ...single_step_wing_cross_section_movement.py | 209 +++++--- .../single_step/single_step_wing_movement.py | 195 +++++--- pterasoftware/problems.py | 399 +++++++-------- 9 files changed, 1287 insertions(+), 408 deletions(-) create mode 100644 examples/demos/demo_single_step.py diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py new file mode 100644 index 000000000..a5e0e3428 --- /dev/null +++ b/examples/demos/demo_single_step.py @@ -0,0 +1,453 @@ +"""This is script is an example of how to run Ptera Software's +UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static +Movement.""" + +# First, import the software's main package. Note that if you wished to import this +# software into another package, you would first install it by running "pip install +# pterasoftware" in your terminal. +import pterasoftware as ps + +# Create an Airplane with our custom geometry. I am going to declare every parameter +# for Airplane, even though most of them have usable default values. This is for +# educational purposes, but keep in mind that it makes the code much longer than it +# needs to be. For details about each parameter, read the detailed class docstring. +# The same caveats apply to the other classes, methods, and functions I call in this +# script. + + +# offsets for the spacing +num_spanwise_panels = 1 +Lp_Wcsp_Lpp_Offsets = (0.1, 0.5, 0.1) + +# Wing cross section initialization +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="cosine" 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, + ), + ) + ) + + +example_airplane = ps.geometry.airplane.Airplane( + wings=[ + 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), + num_chordwise_panels=6, + chordwise_spacing="uniform", + ), + 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, 1.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), + num_chordwise_panels=6, + chordwise_spacing="uniform", + ), + ], + name="Example Airplane", + Cg_E_CgP1=(0.0, 0.0, 0.0), + angles_E_to_B_izyx=(0.0, 0.0, 0.0), + weight=0.0, + c_ref=None, + b_ref=None, +) + +# The main Wing was defined to have symmetric=True, mirror_only=False, and with a +# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, +# that Wing had type 5 symmetry (see the Wing class documentation for more details on +# symmetry types). Therefore, it was actually split into two Wings, the with the +# second Wing being a reflected version of the first. Therefore, we need to define a +# WingMovement for this reflected Wing. To start, we'll first define the reflected +# main wing's root and tip WingCrossSections' WingCrossSectionMovements. + +# defintions for wing movement parameters +# dephase_x = 0.0 +# period_x = 1.0 +# amplitude_x = 2.0 + +# dephase_y = 0.0 +# period_y = 1.0 +# amplitude_y = 3.0 + +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 + +# A list of movements for the main wing +main_movements_list = [] +main_single_step_movements_list = [] + +# A list of movements for the reflected wing +reflected_movements_list = [] +reflected_single_step_movements_list = [] + +for i in range(len(example_airplane.wings[0].wing_cross_sections)): + if i == 0: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], + ) + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() + + main_movements_list.append(movement) + main_single_step_movements_list.append(single_step_movement) + reflected_movements_list.append(movement) + reflected_single_step_movements_list.append(single_step_movement) + + else: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + 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), + ) + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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_movements_list.append(movement) + main_single_step_movements_list.append(single_step_movement) + reflected_movements_list.append(movement) + reflected_single_step_movements_list.append(single_step_movement) + + +# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. +v_tail_root_wing_cross_section_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_wing_cross_section_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), + ) +) + +single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), +) +single_step_v_tail_tip_wing_cross_section_movement = ( + ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), + ) +) + + +# Now define the main wing's WingMovement, the reflected main wing's WingMovement and +# the v-tail's WingMovement. +main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[0], + wing_cross_section_movements=main_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), +) + +single_step_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=main_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), + ) +) + +reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[1], + wing_cross_section_movements=reflected_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), +) + +single_step_reflected_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), + ) +) + +v_tail_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[2], + wing_cross_section_movements=[ + v_tail_root_wing_cross_section_movement, + v_tail_tip_wing_cross_section_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), +) + +single_step_v_tail_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=[ + single_step_v_tail_root_wing_cross_section_movement, + single_step_v_tail_tip_wing_cross_section_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), + ) +) + +# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now +# contained within the WingMovements. This is optional, but it can make debugging +# easier. +del v_tail_root_wing_cross_section_movement +del v_tail_tip_wing_cross_section_movement + +# Now define the example airplane's AirplaneMovement. +airplane_movement = ps.movements.airplane_movement.AirplaneMovement( + base_airplane=example_airplane, + wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_movement], + ampCg_E_CgP1=(0.0, 0.0, 0.0), + periodCg_E_CgP1=(0.0, 0.0, 0.0), + spacingCg_E_CgP1=("sine", "sine", "sine"), + phaseCg_E_CgP1=(0.0, 0.0, 0.0), + ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), + phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), +) + +single_step_airplane_movement = ( + ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + single_step_wing_movements=[ + single_step_main_wing_movement, + single_step_reflected_main_wing_movement, + single_step_v_tail_movement, + ], + ampCg_E_CgP1=(0.0, 0.0, 0.0), + periodCg_E_CgP1=(0.0, 0.0, 0.0), + spacingCg_E_CgP1=("sine", "sine", "sine"), + phaseCg_E_CgP1=(0.0, 0.0, 0.0), + ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), + phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + ) +) + +# Delete the extraneous pointers to the WingMovements. +del main_wing_movement +del reflected_main_wing_movement +del v_tail_movement +del single_step_main_wing_movement +del single_step_reflected_main_wing_movement +del single_step_v_tail_movement + +# Define a new OperatingPoint. +example_operating_point = ps.operating_point.OperatingPoint( + rho=1.225, vCg__E=10.0, alpha=1.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" +) + +single_step_operating_point_movement = ( + ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" + ) +) + +# Delete the extraneous pointer. +del example_operating_point + +# Define the Movement. This contains the AirplaneMovement and the +# OperatingPointMovement. +movement = ps.movements.movement.Movement( + airplane_movements=[airplane_movement], + operating_point_movement=operating_point_movement, + delta_time=0.03, + num_cycles=2, + num_chords=None, + num_steps=None, +) + +single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( + single_step_airplane_movements=[single_step_airplane_movement], + single_step_operating_point_movement=single_step_operating_point_movement, + delta_time=0.03, + num_steps=movement.num_steps, +) + +# Delete the extraneous pointers. +del airplane_movement +del operating_point_movement + +# Define the UnsteadyProblem. +example_problem = ps.problems.AeroelasticUnsteadyProblem( + movement=movement, + single_step_movement=single_step_movement, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, +) + +# Delete the extraneous pointer. +del example_problem + +# Run the solver. +example_solver.run( + logging_level="Warning", + prescribed_wake=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=False, +) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index b200b27a4..e5a3640db 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -1055,7 +1055,6 @@ def _calculate_loads(self): # find the forces (in the first Airplane's geometry axes) on the Panels' # RingVortex's right LineVortex, front LineVortex, and left LineVortex using # the effective vortex strengths. - print("\nhi:", self._calculate_current_movement_velocities_at_right_leg_centers()) rightLegForces_GP1 = ( self.current_operating_point.rho * np.expand_dims(effective_right_vortex_line_strengths, axis=1) diff --git a/pterasoftware/movements/single_step/__init__.py b/pterasoftware/movements/single_step/__init__.py index e69de29bb..242d4d800 100644 --- a/pterasoftware/movements/single_step/__init__.py +++ b/pterasoftware/movements/single_step/__init__.py @@ -0,0 +1,5 @@ +import pterasoftware.movements.single_step.single_step_airplane_movement +import pterasoftware.movements.single_step.single_step_operating_point_movement +import pterasoftware.movements.single_step.single_step_movement +import pterasoftware.movements.single_step.single_step_wing_cross_section_movement +import pterasoftware.movements.single_step.single_step_wing_movement diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py index 4e441d8ea..8fb254905 100644 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -1,18 +1,19 @@ import numpy as np -from _parameter_validation import ( +from ..._parameter_validation import ( threeD_number_vectorLike_return_float, threeD_spacing_vectorLike_return_tuple, positive_number_return_float, + positive_int_return_int, ) -from _functions import ( +from .._functions import ( oscillating_sinspaces, oscillating_linspaces, oscillating_customspaces ) -from geometry.airplane import Airplane +from ... import geometry class SingleStepAirplaneMovement: @@ -227,7 +228,12 @@ def __init__( ) self.phaseAngles_E_to_B_izyx = phaseAngles_E_to_B_izyx - def generate_next_airplane(self, base_airplane: Airplane, delta_time): + self.listCg_E_CgP1 = None + self.listAngles_E_to_B_izyx = None + + def generate_next_airplane( + self, base_airplane, delta_time, num_steps, step + ): """Creates the Airplane at the next timestep :param delta_time: number @@ -240,17 +246,71 @@ def generate_next_airplane(self, base_airplane: Airplane, delta_time): This is the Airplanes associated with this AirplaneMovement and deformation. """ - num_steps = 2 + num_steps = positive_int_return_int( + num_steps, "num_steps" + ) delta_time = positive_number_return_float( delta_time, "delta_time" ) - # Generate oscillating values for each dimension of Cg_E_CgP1. - listCg_E_CgP1 = np.zeros((3, num_steps), dtype=float) + if self.listCg_E_CgP1 is None: + self._initialize_oscilating_dimensions(delta_time, num_steps, base_airplane) + + # Generate oscillating values for each dimension of angles_E_to_B_izyx. + if self.listAngles_E_to_B_izyx is None: + self._initialize_oscilating_angles(delta_time, num_steps, base_airplane) + + wings = [] + + # Iterate through the WingMovements. + for wing_movement_id, wing_movement in enumerate(self.wing_movements): + + # Add this vector the Airplane's 2D ndarray of Wings' Wings. + wings.append( + wing_movement.generate_next_wing( + base_wing=base_airplane.wings[wing_movement_id], + delta_time=delta_time, + num_steps=num_steps, + step=step, + ) + ) + + # Get the non-changing Airplane attributes. + this_name = base_airplane.name + this_weight = base_airplane.weight + + # the 1 is for not the base step, but 1 step deep + thisCg_E_CgP1 = self.listCg_E_CgP1[:, step] + theseAngles_E_to_B_izyx = self.listAngles_E_to_B_izyx[:, step] + + # Make a new Airplane for this time step. + this_airplane = geometry.airplane.Airplane( + wings=wings, + name=this_name, + Cg_E_CgP1=thisCg_E_CgP1, + angles_E_to_B_izyx=theseAngles_E_to_B_izyx, + weight=this_weight, + ) + + return this_airplane + + def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_airplane): + """Initializes the oscillating dimensions for Cg_E_CgP1 and angles_E_to_B_izyx. + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + :param num_steps: int + + This is the number of time steps in this movement. It must be a positive + int. + """ + self.listCg_E_CgP1 = np.zeros((3, num_steps), dtype=float) for dim in range(3): spacing = self.spacingCg_E_CgP1[dim] if spacing == "sine": - listCg_E_CgP1[dim, :] = oscillating_sinspaces( + self.listCg_E_CgP1[dim, :] = oscillating_sinspaces( amps=self.ampCg_E_CgP1[dim], periods=self.periodCg_E_CgP1[dim], phases=self.phaseCg_E_CgP1[dim], @@ -259,7 +319,7 @@ def generate_next_airplane(self, base_airplane: Airplane, delta_time): delta_time=delta_time, ) elif spacing == "uniform": - listCg_E_CgP1[dim, :] = oscillating_linspaces( + self.listCg_E_CgP1[dim, :] = oscillating_linspaces( amps=self.ampCg_E_CgP1[dim], periods=self.periodCg_E_CgP1[dim], phases=self.phaseCg_E_CgP1[dim], @@ -268,7 +328,7 @@ def generate_next_airplane(self, base_airplane: Airplane, delta_time): delta_time=delta_time, ) elif callable(spacing): - listCg_E_CgP1[dim, :] = oscillating_customspaces( + self.listCg_E_CgP1[dim, :] = oscillating_customspaces( amps=self.ampCg_E_CgP1[dim], periods=self.periodCg_E_CgP1[dim], phases=self.phaseCg_E_CgP1[dim], @@ -280,12 +340,26 @@ def generate_next_airplane(self, base_airplane: Airplane, delta_time): else: raise ValueError(f"Invalid spacing value: {spacing}") - # Generate oscillating values for each dimension of angles_E_to_B_izyx. - listAngles_E_to_B_izyx = np.zeros((3, num_steps), dtype=float) + def _initialize_oscilating_angles(self, delta_time, num_steps, base_airplane): + """Initializes the oscillating angles for angles_E_to_B_izyx. + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + :param num_steps: int + This is the number of time steps in this movement. It must be a positive + int. + :param base_airplane: Airplane + + This is the base Airplane from which the AirplaneMovement will generate + its Airplanes. + """ + self.listAngles_E_to_B_izyx = np.zeros((3, num_steps), dtype=float) for dim in range(3): spacing = self.spacingAngles_E_to_B_izyx[dim] if spacing == "sine": - listAngles_E_to_B_izyx[dim, :] = oscillating_sinspaces( + self.listAngles_E_to_B_izyx[dim, :] = oscillating_sinspaces( amps=self.ampAngles_E_to_B_izyx[dim], periods=self.periodAngles_E_to_B_izyx[dim], phases=self.phaseAngles_E_to_B_izyx[dim], @@ -294,7 +368,7 @@ def generate_next_airplane(self, base_airplane: Airplane, delta_time): delta_time=delta_time, ) elif spacing == "uniform": - listAngles_E_to_B_izyx[dim, :] = oscillating_linspaces( + self.listAngles_E_to_B_izyx[dim, :] = oscillating_linspaces( amps=self.ampAngles_E_to_B_izyx[dim], periods=self.periodAngles_E_to_B_izyx[dim], phases=self.phaseAngles_E_to_B_izyx[dim], @@ -303,7 +377,7 @@ def generate_next_airplane(self, base_airplane: Airplane, delta_time): delta_time=delta_time, ) elif callable(spacing): - listAngles_E_to_B_izyx[dim, :] = oscillating_customspaces( + self.listAngles_E_to_B_izyx[dim, :] = oscillating_customspaces( amps=self.ampAngles_E_to_B_izyx[dim], periods=self.periodAngles_E_to_B_izyx[dim], phases=self.phaseAngles_E_to_B_izyx[dim], @@ -314,46 +388,3 @@ def generate_next_airplane(self, base_airplane: Airplane, delta_time): ) else: raise ValueError(f"Invalid spacing value: {spacing}") - - # Create an empty 2D ndarray that will hold each of the Airplane's Wing's vector - # of Wings representing its changing state at each time step. The first index - # denotes a particular base Wing, and the second index denotes the time step. - wings = np.empty((len(self.wing_movements)), dtype=object) - - # Iterate through the WingMovements. - for wing_movement_id, wing_movement in enumerate(self.wing_movements): - - # Generate this Wing's vector of Wings representing its changing state at - # each time step. - this_wings_list_of_wings = np.array( - wing_movement.generate_next_wing(delta_time=delta_time) - ) - - # Add this vector the Airplane's 2D ndarray of Wings' Wings. - wings[wing_movement_id, :] = this_wings_list_of_wings - - # Create an empty list to hold each time step's Airplane. - airplanes = [] - - # Get the non-changing Airplane attributes. - this_name = base_airplane.name - this_weight = base_airplane.weight - - # the 1 is for not the base step, but 1 step deep - thisCg_E_CgP1 = listCg_E_CgP1[:, 1] - theseAngles_E_to_B_izyx = listAngles_E_to_B_izyx[:, 1] - these_wings = list(wings[:, 1]) - - # Make a new Airplane for this time step. - this_airplane = Airplane( - wings=these_wings, - name=this_name, - Cg_E_CgP1=thisCg_E_CgP1, - angles_E_to_B_izyx=theseAngles_E_to_B_izyx, - weight=this_weight, - ) - - # Add this new Airplane to the list of Airplanes. - airplanes.append(this_airplane) - - return airplanes diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index 42547e868..7f523152e 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -9,10 +9,13 @@ import math -from single_step_airplane_movement import SingleStepAirplaneMovement -from single_step_operating_point_movement import SingleStepOperatingPointMovement +from .single_step_airplane_movement import SingleStepAirplaneMovement +from .single_step_operating_point_movement import SingleStepOperatingPointMovement -import _parameter_validation +from ..._parameter_validation import ( + positive_number_return_float, + positive_int_return_int, +) class SingleStepMovement: """This is a class used to contain an UnsteadyProblem's movement. @@ -37,9 +40,7 @@ def __init__( single_step_airplane_movements, single_step_operating_point_movement, delta_time=None, - num_cycles=None, - num_chords=None, - num_steps=None, + num_steps=40, ): """This is the initialization method. @@ -121,7 +122,7 @@ def __init__( self.operating_point_movement = single_step_operating_point_movement if delta_time is not None: - delta_time = _parameter_validation.positive_number_return_float( + delta_time = positive_number_return_float( delta_time, "delta_time" ) else: @@ -150,22 +151,48 @@ def __init__( delta_time = sum(delta_times) / len(delta_times) self.delta_time = delta_time + + num_steps = positive_int_return_int( + num_steps, "num_steps" + ) + self.num_steps = num_steps # Generate a list of lists of Airplanes that are the steps through each # AirplaneMovement. The first index identifies the AirplaneMovement, and the # second index identifies the time step. - def generate_next_movement(self): + def generate_next_movement(self, base_airplanes, base_operating_point, step): + """Creates the Airplanes and OperatingPoint at the next time step. + :param base_airplanes: list of Airplanes + + This is the list of Airplanes at the base time step. + :param base_operating_point: OperatingPoint + + This is the OperatingPoint at the base time step. + :return: tuple (list of Airplanes, OperatingPoint) + + This is a tuple where the first element is the list of Airplanes at the + next time step, and the second element is the OperatingPoint at the next + time step. + """ + airplanes = [] - for airplane_movement in self.airplane_movements: + airplane_movement: SingleStepAirplaneMovement + for airplane_id, airplane_movement in enumerate(self.airplane_movements): airplanes.append( airplane_movement.generate_next_airplane( - num_steps=self.num_steps, delta_time=self.delta_time + delta_time=self.delta_time, + base_airplane=base_airplanes[airplane_id], + num_steps=self.num_steps, + step=step, ) ) operating_point = self.operating_point_movement.generate_next_operating_point( - num_steps=self.num_steps, delta_time=self.delta_time + delta_time=self.delta_time, + base_operating_point=base_operating_point, + num_steps=self.num_steps, + step=step, ) return airplanes, operating_point diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py index e69de29bb..392e696ee 100644 --- a/pterasoftware/movements/single_step/single_step_operating_point_movement.py +++ b/pterasoftware/movements/single_step/single_step_operating_point_movement.py @@ -0,0 +1,237 @@ +"""This module contains the OperatingPointMovement class. + +This module contains the following classes: + OperatingPointMovement: This is a class used to contain the OperatingPoint + movements. + +This module contains the following functions: + None +""" + +from .._functions import ( + oscillating_sinspaces, + oscillating_linspaces, + oscillating_customspaces, +) + +from ...operating_point import OperatingPoint +from ..._parameter_validation import ( + non_negative_number_return_float, + number_in_range_return_float, + positive_number_return_float, + positive_int_return_int, +) + + +class SingleStepOperatingPointMovement: + """This is a class used to contain the OperatingPoint movements. + + This class contains the following public methods: + + generate_operating_points: Creates the OperatingPoint at each time step, + and returns them in a list. + + max_period: Defines a property for the longest period of + OperatingPointMovement's own motion. + + This class contains the following class attributes: + None + + Subclassing: + This class is not meant to be subclassed. + """ + + def __init__( + self, + ampVCg__E=0.0, + periodVCg__E=0.0, + spacingVCg__E="sine", + phaseVCg__E=0.0, + ): + """This is the initialization method. + + :param base_operating_point: OperatingPoint + + This is the base OperatingPoint, from which the OperatingPoint at each + time step will be created. + + :param ampVCg__E: number, optional + + The amplitude of the OperatingPointMovement's changes in its + OperatingPoints' vCg__E parameters. Must be a non-negative number (int or + float), and is converted to a float internally. Also, the amplitude must + be low enough that it doesn't drive its base value out of the range of + valid values. Otherwise, this OperatingPointMovement will try to create + OperatingPoints with invalid parameters values.The default value is 0.0. + The units are in meters per second. + + :param periodVCg__E: number, optional + + The period of the OperatingPointMovement's changes in its + OperatingPoints' vCg__E parameter. Must be a non-negative number (int or + float), and is converted to a float internally. The default value is 0.0. + It must be 0.0 if ampVCg__E 0.0 and non-zero if not. The units are in + seconds. + + :param spacingVCg__E: string, optional + + The value determines the spacing of the OperatingPointMovement's change + in its OperatingPoints' vCg__E parameters. Must be either "sine", + "uniform", or a callable custom spacing function. Custom spacing + functions are for advanced users and must start at 0, return to 0 after + one period of 2*pi radians, have amplitude of 1, be periodic, + return finite values only, and accept a ndarray as input and return a + ndarray of the same shape. The custom function is scaled by ampVCg__E, + shifted horizontally by phaseVCg__E, and vertically by the base value, + with the period controlled by periodVCg__E. The default value is "sine". + + :param phaseVCg__E: number optional + + The phase offsets of the first time step's OperatingPoint's vCg__E + parameter relative to the base OperatingPoint's vCg__E parameter. Must be + a number (int or float) in the range (-180.0, 180.0], and is converted to a + float internally. The default value is 0.0. It must be 0.0 if ampVCg__E + is 0.0 and non-zero if not. The units are in degrees. + """ + + self.ampVCg__E = non_negative_number_return_float( + ampVCg__E, "ampVCg__E" + ) + + periodVCg__E = non_negative_number_return_float( + periodVCg__E, "periodVCg__E" + ) + if self.ampVCg__E == 0 and periodVCg__E != 0: + raise ValueError("If ampVCg__E is 0.0, then periodVCg__E must also be 0.0.") + self.periodVCg__E = periodVCg__E + + if isinstance(spacingVCg__E, str): + if spacingVCg__E not in ["sine", "uniform"]: + raise ValueError( + f"spacingVCg__E must be 'sine', 'uniform', or a callable, got string '{spacingVCg__E}'." + ) + elif not callable(spacingVCg__E): + raise TypeError( + f"spacingVCg__E must be 'sine', 'uniform', or a callable, got {type(spacingVCg__E).__name__}." + ) + self.spacingVCg__E = spacingVCg__E + + phaseVCg__E = number_in_range_return_float( + phaseVCg__E, "phaseVCg__E", -180.0, False, 180.0, True + ) + if self.ampVCg__E == 0 and phaseVCg__E != 0: + raise ValueError("If ampVCg__E is 0.0, then phaseVCg__E must also be 0.0.") + self.phaseVCg__E = phaseVCg__E + + self.listVCg__E = None + + def generate_next_operating_point(self, delta_time, base_operating_point: OperatingPoint, num_steps, step): + """Creates the OperatingPoint at each time step, and returns them in a list. + + :param num_steps: int + + This is the number of time steps in this movement. It must be a positive + int. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + + :return: list of OperatingPoints + + This is the list of OperatingPoints associated with this + OperatingPointMovement. + """ + num_steps = positive_int_return_int( + num_steps, "num_steps" + ) + delta_time = positive_number_return_float( + delta_time, "delta_time" + ) + + if self.listVCg__E is None: + self._initialize_oscillating_values( + delta_time=delta_time, + num_steps=num_steps, + base_operating_point=base_operating_point, + ) + + # Get the non-changing OperatingPoint attributes. + this_rho = base_operating_point.rho + this_alpha = base_operating_point.alpha + this_beta = base_operating_point.beta + thisExternalFX_W = base_operating_point.externalFX_W + this_nu = base_operating_point.nu + + # Make a new operating point object for this time step. + this_operating_point = OperatingPoint( + rho=this_rho, + vCg__E=self.listVCg__E[step], + alpha=this_alpha, + beta=this_beta, + externalFX_W=thisExternalFX_W, + nu=this_nu, + ) + + return this_operating_point + + @property + def max_period(self): + """Defines a property for the longest period of OperatingPointMovement's own + motion. + + :return: float + + The longest period in seconds. If the all the motion is static, this will + be 0.0. + """ + return self.periodVCg__E + + def _initialize_oscillating_values(self, delta_time, num_steps, base_operating_point): + """Pre-computes the oscillating values for faster access later. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + :param num_steps: int + This is the number of time steps in this movement. It must be a positive + int. + :param base_operating_point: OperatingPoint + This is the base OperatingPoint, from which the OperatingPoint at each + time step will be created. + """ + # Generate oscillating values for VCg__E. + if self.spacingVCg__E == "sine": + self.listVCg__E = oscillating_sinspaces( + amps=self.ampVCg__E, + periods=self.periodVCg__E, + phases=self.phaseVCg__E, + bases=base_operating_point.vCg__E, + num_steps=num_steps, + delta_time=delta_time, + ) + elif self.spacingVCg__E == "uniform": + self.listVCg__E = oscillating_linspaces( + amps=self.ampVCg__E, + periods=self.periodVCg__E, + phases=self.phaseVCg__E, + bases=base_operating_point.vCg__E, + num_steps=num_steps, + delta_time=delta_time, + ) + elif callable(self.spacingVCg__E): + self.listVCg__E = oscillating_customspaces( + amps=self.ampVCg__E, + periods=self.periodVCg__E, + phases=self.phaseVCg__E, + bases=base_operating_point.vCg__E, + num_steps=num_steps, + delta_time=delta_time, + custom_function=self.spacingVCg__E, + ) + else: + raise ValueError(f"Invalid spacing value: {self.spacingVCg__E}") diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index a3956520c..63a2f7efe 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -11,19 +11,20 @@ import numpy as np -from _parameter_validation import ( +from ..._parameter_validation import ( threeD_number_vectorLike_return_float, threeD_spacing_vectorLike_return_tuple, positive_number_return_float, + positive_int_return_int, ) -from _functions import ( +from .._functions import ( oscillating_sinspaces, oscillating_linspaces, oscillating_customspaces, ) -from geometry.wing_cross_section import WingCrossSection +from ... import geometry class SingleStepWingCrossSectionMovement: @@ -154,7 +155,6 @@ def __init__( degrees. """ - ampLp_Wcsp_Lpp = threeD_number_vectorLike_return_float( ampLp_Wcsp_Lpp, "ampLp_Wcsp_Lpp" ) @@ -258,10 +258,16 @@ def __init__( ) self.phaseAngles_Wcsp_to_Wcs_ixyz = phaseAngles_Wcsp_to_Wcs_ixyz - def generate_wing_cross_sections( + self.listLp_Wcsp_Lpp = None + self.listAngles_Wcsp_to_Wcs_ixyz = None + + def generate_next_wing_cross_sections( self, - num_steps, + base_wing_cross_section, delta_time, + num_steps, + step, + base=False, ): """Creates the WingCrossSection at each time step, and returns them in a list. @@ -281,39 +287,121 @@ def generate_wing_cross_sections( This is the list of WingCrossSections associated with this WingCrossSectionMovement. """ - num_steps = 2 + num_steps = positive_int_return_int( + num_steps, "num_steps" + ) delta_time = positive_number_return_float( delta_time, "delta_time" ) # Generate oscillating values for each dimension of Lp_Wcsp_Lpp. - listLp_Wcsp_Lpp = np.zeros((3, num_steps), dtype=float) + if self.listLp_Wcsp_Lpp is None: + self._initialize_oscillating_dimensions( + delta_time, + num_steps, + base_wing_cross_section, + ) + + # Generate oscillating values for each dimension of angles_Wcsp_to_Wcs_ixyz. + if self.listAngles_Wcsp_to_Wcs_ixyz is None: + self._initialize_oscillating_angles( + delta_time, + num_steps, + base_wing_cross_section, + ) + + # Create an empty list to hold each time step's WingCrossSection. + wing_cross_sections = [] + + # Get the non-changing WingCrossSectionAttributes. + this_airfoil = base_wing_cross_section.airfoil + this_num_spanwise_panels = base_wing_cross_section.num_spanwise_panels + this_chord = base_wing_cross_section.chord + this_control_surface_symmetry_type = ( + base_wing_cross_section.control_surface_symmetry_type + ) + this_control_surface_hinge_point = ( + base_wing_cross_section.control_surface_hinge_point + ) + this_control_surface_deflection = ( + base_wing_cross_section.control_surface_deflection + ) + this_spanwise_spacing = base_wing_cross_section.spanwise_spacing + + offset = np.zeros(3) if base else np.random.random_sample(3) * 0.1 + thisLp_Wcsp_Lpp = self.listLp_Wcsp_Lpp[:, step] + offset + theseAngles_Wcsp_to_Wcs_ixyz = self.listAngles_Wcsp_to_Wcs_ixyz[:, step] + # Make a new WingCrossSection for this time step. + this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( + airfoil=this_airfoil, + num_spanwise_panels=this_num_spanwise_panels, + chord=this_chord, + Lp_Wcsp_Lpp=thisLp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=theseAngles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=this_control_surface_symmetry_type, + control_surface_hinge_point=this_control_surface_hinge_point, + control_surface_deflection=this_control_surface_deflection, + spanwise_spacing=this_spanwise_spacing, + ) + + # Add this new WingCrossSection to the list of WingCrossSections. + wing_cross_sections.append(this_wing_cross_section) + + return wing_cross_sections + + def _initialize_oscillating_dimensions( + self, + delta_time, + num_steps, + base_wing_cross_section, + ): + """Initializes the oscillating dimensions for the WingCrossSectionMovement. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + + :param num_steps: int + + This is the number of time steps in this movement. It must be a positive + int. + + :param base_wing_cross_section: WingCrossSection + + This is the base WingCrossSection, from which the WingCrossSection at + each time step will be created. + """ + + # Generate oscillating values for each dimension of Lp_Wcsp_Lpp. + self.listLp_Wcsp_Lpp = np.zeros((3, num_steps), dtype=float) for dim in range(3): spacing = self.spacingLp_Wcsp_Lpp[dim] if spacing == "sine": - listLp_Wcsp_Lpp[dim, :] = oscillating_sinspaces( + self.listLp_Wcsp_Lpp[dim, :] = oscillating_sinspaces( amps=self.ampLp_Wcsp_Lpp[dim], periods=self.periodLp_Wcsp_Lpp[dim], phases=self.phaseLp_Wcsp_Lpp[dim], - bases=self.base_wing_cross_section.Lp_Wcsp_Lpp[dim], + bases=base_wing_cross_section.Lp_Wcsp_Lpp[dim], num_steps=num_steps, delta_time=delta_time, ) elif spacing == "uniform": - listLp_Wcsp_Lpp[dim, :] = oscillating_linspaces( + self.listLp_Wcsp_Lpp[dim, :] = oscillating_linspaces( amps=self.ampLp_Wcsp_Lpp[dim], periods=self.periodLp_Wcsp_Lpp[dim], phases=self.phaseLp_Wcsp_Lpp[dim], - bases=self.base_wing_cross_section.Lp_Wcsp_Lpp[dim], + bases=base_wing_cross_section.Lp_Wcsp_Lpp[dim], num_steps=num_steps, delta_time=delta_time, ) elif callable(spacing): - listLp_Wcsp_Lpp[dim, :] = oscillating_customspaces( + self.listLp_Wcsp_Lpp[dim, :] = oscillating_customspaces( amps=self.ampLp_Wcsp_Lpp[dim], periods=self.periodLp_Wcsp_Lpp[dim], phases=self.phaseLp_Wcsp_Lpp[dim], - bases=self.base_wing_cross_section.Lp_Wcsp_Lpp[dim], + bases=base_wing_cross_section.Lp_Wcsp_Lpp[dim], num_steps=num_steps, delta_time=delta_time, custom_function=spacing, @@ -321,79 +409,60 @@ def generate_wing_cross_sections( else: raise ValueError(f"Invalid spacing value: {spacing}") - # Generate oscillating values for each dimension of angles_Wcsp_to_Wcs_ixyz. - listAngles_Wcsp_to_Wcs_ixyz = np.zeros((3, num_steps), dtype=float) + def _initialize_oscillating_angles( + self, + delta_time, + num_steps, + base_wing_cross_section, + ): + """Initializes the oscillating angles for the WingCrossSectionMovement. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + + :param num_steps: int + + This is the number of time steps in this movement. It must be a positive + int. + + :param base_wing_cross_section: WingCrossSection + + This is the base WingCrossSection, from which the WingCrossSection at + each time step will be created. + """ + self.listAngles_Wcsp_to_Wcs_ixyz = np.zeros((3, num_steps), dtype=float) for dim in range(3): spacing = self.spacingAngles_Wcsp_to_Wcs_ixyz[dim] if spacing == "sine": - listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_sinspaces( + self.listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_sinspaces( amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], - bases=self.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], + bases=base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], num_steps=num_steps, delta_time=delta_time, ) elif spacing == "uniform": - listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_linspaces( + self.listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_linspaces( amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], - bases=self.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], + bases=base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], num_steps=num_steps, delta_time=delta_time, ) elif callable(spacing): - listAngles_Wcsp_to_Wcs_ixyz[dim, :] = ( - oscillating_customspaces( - amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], - periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], - phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], - bases=self.base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], - num_steps=num_steps, - delta_time=delta_time, - custom_function=spacing, - ) + self.listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_customspaces( + amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], + periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], + phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], + bases=base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], + num_steps=num_steps, + delta_time=delta_time, + custom_function=spacing, ) else: raise ValueError(f"Invalid spacing value: {spacing}") - - # Create an empty list to hold each time step's WingCrossSection. - wing_cross_sections = [] - - # Get the non-changing WingCrossSectionAttributes. - this_airfoil = self.base_wing_cross_section.airfoil - this_num_spanwise_panels = self.base_wing_cross_section.num_spanwise_panels - this_chord = self.base_wing_cross_section.chord - this_control_surface_symmetry_type = ( - self.base_wing_cross_section.control_surface_symmetry_type - ) - this_control_surface_hinge_point = ( - self.base_wing_cross_section.control_surface_hinge_point - ) - this_control_surface_deflection = ( - self.base_wing_cross_section.control_surface_deflection - ) - this_spanwise_spacing = self.base_wing_cross_section.spanwise_spacing - - # Iterate through the time steps. - thisLp_Wcsp_Lpp = listLp_Wcsp_Lpp[:, 1] - theseAngles_Wcsp_to_Wcs_ixyz = listAngles_Wcsp_to_Wcs_ixyz[:, 1] - - # Make a new WingCrossSection for this time step. - this_wing_cross_section = WingCrossSection( - airfoil=this_airfoil, - num_spanwise_panels=this_num_spanwise_panels, - chord=this_chord, - Lp_Wcsp_Lpp=thisLp_Wcsp_Lpp, - angles_Wcsp_to_Wcs_ixyz=theseAngles_Wcsp_to_Wcs_ixyz, - control_surface_symmetry_type=this_control_surface_symmetry_type, - control_surface_hinge_point=this_control_surface_hinge_point, - control_surface_deflection=this_control_surface_deflection, - spanwise_spacing=this_spanwise_spacing, - ) - - # Add this new WingCrossSection to the list of WingCrossSections. - wing_cross_sections.append(this_wing_cross_section) - - return wing_cross_sections diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py index 1e0be96e3..b001896b8 100644 --- a/pterasoftware/movements/single_step/single_step_wing_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_movement.py @@ -9,19 +9,20 @@ import numpy as np -from _parameter_validation import ( +from ..._parameter_validation import ( threeD_number_vectorLike_return_float, threeD_spacing_vectorLike_return_tuple, positive_number_return_float, + positive_int_return_int, ) -from _functions import ( +from .._functions import ( oscillating_sinspaces, oscillating_linspaces, oscillating_customspaces, ) -from geometry.wing import Wing +from ... import geometry class SingleStepWingMovement: """This is a class used to contain the Wing movements. @@ -263,7 +264,10 @@ def __init__( ) self.phaseAngles_Gs_to_Wn_ixyz = phaseAngles_Gs_to_Wn_ixyz - def generate_next_wing(self, base_wing: Wing, delta_time): + self.listLer_Gs_Cgs = None + self.listAngles_Gs_to_Wn_ixyz = None + + def generate_next_wing(self, base_wing, delta_time, num_steps, step): """Creates the Wing at each time step, and returns them in a list. :param num_steps: int @@ -281,17 +285,105 @@ def generate_next_wing(self, base_wing: Wing, delta_time): This is the list of Wings associated with this WingMovement. """ - num_steps = 2 + num_steps = positive_int_return_int( + num_steps, "num_steps" + ) delta_time = positive_number_return_float( delta_time, "delta_time" ) # Generate oscillating values for each dimension of Ler_Gs_Cgs. - listLer_Gs_Cgs = np.zeros((3, num_steps), dtype=float) + if self.listLer_Gs_Cgs is None: + self._initialize_oscilating_dimensions(delta_time, num_steps, base_wing) + + # Generate oscillating values for each dimension of angles_Gs_to_Wn_ixyz. + if self.listAngles_Gs_to_Wn_ixyz is None: + self._initialize_oscilating_angles(delta_time, num_steps, base_wing) + + # Create an empty 2D ndarray that will hold each of the Wings's + # WingCrossSection's vector of WingCrossSections representing its changing + # state at each time step. The first index denotes a particular base + # WingCrossSection, and the second index denotes the time step. + wing_cross_sections = np.empty( + (len(self.wing_cross_section_movements), num_steps), dtype=object + ) + + # Iterate through the WingCrossSectionMovements. + for ( + wing_cross_section_movement_id, + wing_cross_section_movement, + ) in enumerate(self.wing_cross_section_movements): + + # Generate this WingCrossSection's vector of WingCrossSections + # representing its changing state at each time step. + this_wing_cross_sections_list_of_wing_cross_sections = np.array( + wing_cross_section_movement.generate_next_wing_cross_sections( + base_wing_cross_section=base_wing.wing_cross_sections[ + wing_cross_section_movement_id + ], + delta_time=delta_time, + num_steps=num_steps, + step=step, + base=wing_cross_section_movement_id==0, + ) + ) + + # Add this vector the Wing's 2D ndarray of WingCrossSections' + # WingCrossSections. + wing_cross_sections[wing_cross_section_movement_id, :] = ( + this_wing_cross_sections_list_of_wing_cross_sections + ) + + # Get the non-changing Wing attributes. + this_name = base_wing.name + this_symmetric = base_wing.symmetric + this_mirror_only = base_wing.mirror_only + this_symmetryNormal_G = base_wing.symmetryNormal_G + this_symmetryPoint_G_Cg = base_wing.symmetryPoint_G_Cg + this_num_chordwise_panels = base_wing.num_chordwise_panels + this_chordwise_spacing = base_wing.chordwise_spacing + + thisLer_Gs_Cgs = self.listLer_Gs_Cgs[:, step] + theseAngles_Gs_to_Wn_ixyz = self.listAngles_Gs_to_Wn_ixyz[:, step] + these_wing_cross_sections = list(wing_cross_sections[:, step]) + + # Make a new Wing for this time step. + print(min(theseAngles_Gs_to_Wn_ixyz)) + this_wing = geometry.wing.Wing( + wing_cross_sections=these_wing_cross_sections, + name=this_name, + Ler_Gs_Cgs=thisLer_Gs_Cgs, + angles_Gs_to_Wn_ixyz=theseAngles_Gs_to_Wn_ixyz, + symmetric=this_symmetric, + mirror_only=this_mirror_only, + symmetryNormal_G=this_symmetryNormal_G, + symmetryPoint_G_Cg=this_symmetryPoint_G_Cg, + num_chordwise_panels=this_num_chordwise_panels, + chordwise_spacing=this_chordwise_spacing, + ) + + return this_wing + + def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_wing): + """Initializes the oscillating dimensions for Ler_Gs_Cgs and + angles_Gs_to_Wn_ixyz. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + + :param num_steps: int + + This is the number of time steps in this movement. It must be a positive + int. + """ + self.listLer_Gs_Cgs = np.zeros((3, num_steps), dtype=float) for dim in range(3): spacing = self.spacingLer_Gs_Cgs[dim] if spacing == "sine": - listLer_Gs_Cgs[dim, :] = oscillating_sinspaces( + self.listLer_Gs_Cgs[dim, :] = oscillating_sinspaces( amps=self.ampLer_Gs_Cgs[dim], periods=self.periodLer_Gs_Cgs[dim], phases=self.phaseLer_Gs_Cgs[dim], @@ -300,7 +392,7 @@ def generate_next_wing(self, base_wing: Wing, delta_time): delta_time=delta_time, ) elif spacing == "uniform": - listLer_Gs_Cgs[dim, :] = oscillating_linspaces( + self.listLer_Gs_Cgs[dim, :] = oscillating_linspaces( amps=self.ampLer_Gs_Cgs[dim], periods=self.periodLer_Gs_Cgs[dim], phases=self.phaseLer_Gs_Cgs[dim], @@ -309,7 +401,7 @@ def generate_next_wing(self, base_wing: Wing, delta_time): delta_time=delta_time, ) elif callable(spacing): - listLer_Gs_Cgs[dim, :] = oscillating_customspaces( + self.listLer_Gs_Cgs[dim, :] = oscillating_customspaces( amps=self.ampLer_Gs_Cgs[dim], periods=self.periodLer_Gs_Cgs[dim], phases=self.phaseLer_Gs_Cgs[dim], @@ -321,12 +413,23 @@ def generate_next_wing(self, base_wing: Wing, delta_time): else: raise ValueError(f"Invalid spacing value: {spacing}") - # Generate oscillating values for each dimension of angles_Gs_to_Wn_ixyz. - listAngles_Gs_to_Wn_ixyz = np.zeros((3, num_steps), dtype=float) + def _initialize_oscilating_angles(self, delta_time, num_steps, base_wing): + """Initializes the oscillating dimensions for angles_Gs_to_Wn_ixyz. + + :param delta_time: number + + This is the time between each time step. It must be a positive number ( + int or float), and will be converted internally to a float. The units are + in seconds. + :param num_steps: int + This is the number of time steps in this movement. It must be a positive + int. + """ + self.listAngles_Gs_to_Wn_ixyz = np.zeros((3, num_steps), dtype=float) for dim in range(3): spacing = self.spacingAngles_Gs_to_Wn_ixyz[dim] if spacing == "sine": - listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_sinspaces( + self.listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_sinspaces( amps=self.ampAngles_Gs_to_Wn_ixyz[dim], periods=self.periodAngles_Gs_to_Wn_ixyz[dim], phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], @@ -335,7 +438,7 @@ def generate_next_wing(self, base_wing: Wing, delta_time): delta_time=delta_time, ) elif spacing == "uniform": - listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_linspaces( + self.listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_linspaces( amps=self.ampAngles_Gs_to_Wn_ixyz[dim], periods=self.periodAngles_Gs_to_Wn_ixyz[dim], phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], @@ -344,7 +447,7 @@ def generate_next_wing(self, base_wing: Wing, delta_time): delta_time=delta_time, ) elif callable(spacing): - listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_customspaces( + self.listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_customspaces( amps=self.ampAngles_Gs_to_Wn_ixyz[dim], periods=self.periodAngles_Gs_to_Wn_ixyz[dim], phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], @@ -355,67 +458,3 @@ def generate_next_wing(self, base_wing: Wing, delta_time): ) else: raise ValueError(f"Invalid spacing value: {spacing}") - - # Create an empty 2D ndarray that will hold each of the Wings's - # WingCrossSection's vector of WingCrossSections representing its changing - # state at each time step. The first index denotes a particular base - # WingCrossSection, and the second index denotes the time step. - wing_cross_sections = np.empty( - (len(self.wing_cross_section_movements), num_steps), dtype=object - ) - - # Iterate through the WingCrossSectionMovements. - for ( - wing_cross_section_movement_id, - wing_cross_section_movement, - ) in enumerate(self.wing_cross_section_movements): - - # Generate this WingCrossSection's vector of WingCrossSections - # representing its changing state at each time step. - this_wing_cross_sections_list_of_wing_cross_sections = np.array( - wing_cross_section_movement.generate_next_wing_cross_sections( - delta_time=delta_time - ) - ) - - # Add this vector the Wing's 2D ndarray of WingCrossSections' - # WingCrossSections. - wing_cross_sections[wing_cross_section_movement_id, :] = ( - this_wing_cross_sections_list_of_wing_cross_sections - ) - - # Create an empty list to hold each time step's Wing. - wings = [] - - # Get the non-changing Wing attributes. - this_name = base_wing.name - this_symmetric = base_wing.symmetric - this_mirror_only = base_wing.mirror_only - this_symmetryNormal_G = base_wing.symmetryNormal_G - this_symmetryPoint_G_Cg = base_wing.symmetryPoint_G_Cg - this_num_chordwise_panels = base_wing.num_chordwise_panels - this_chordwise_spacing = base_wing.chordwise_spacing - - - thisLer_Gs_Cgs = listLer_Gs_Cgs[:, 1] - theseAngles_Gs_to_Wn_ixyz = listAngles_Gs_to_Wn_ixyz[:, 1] - these_wing_cross_sections = list(wing_cross_sections[:, 1]) - - # Make a new Wing for this time step. - this_wing = Wing( - wing_cross_sections=these_wing_cross_sections, - name=this_name, - Ler_Gs_Cgs=thisLer_Gs_Cgs, - angles_Gs_to_Wn_ixyz=theseAngles_Gs_to_Wn_ixyz, - symmetric=this_symmetric, - mirror_only=this_mirror_only, - symmetryNormal_G=this_symmetryNormal_G, - symmetryPoint_G_Cg=this_symmetryPoint_G_Cg, - num_chordwise_panels=this_num_chordwise_panels, - chordwise_spacing=this_chordwise_spacing, - ) - - # Add this new Wing to the list of Wings. - wings.append(this_wing) - - return wings diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index baf4cd41c..4d0308cb3 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -12,6 +12,7 @@ import numpy as np + from . import geometry from . import movements from . import _parameter_validation @@ -19,7 +20,7 @@ from . import operating_point as op from copy import deepcopy from scipy.integrate import quad -from movements.single_step.single_step_movement import SingleStepMovement +from .movements.single_step.single_step_movement import SingleStepMovement class SteadyProblem: @@ -346,11 +347,15 @@ def initialize_next_problem(self, solver): self._steady_problems.append(self.steady_problems[len(self._steady_problems)]) class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): - def __init__(self, movement, only_final_results=False): + def __init__(self, single_step_movement: SingleStepMovement, movement, only_final_results=False): + # TODO: fix this constructor to properly inherit from CoupledUnsteadyProblem super().__init__(movement, only_final_results) self.prev_velocities = [] + self.single_step_movement = single_step_movement self.G = 1e8 self.I_area = 1e-14 + self.curr_airplanes = [movement.airplane_movements[0].base_airplane] + self.curr_operating_point = movement.operating_point_movement.base_operating_point def define_mass_matrix(self, M_wing, airplane): """ @@ -391,199 +396,213 @@ def calculate_wing_panel_accelerations(self, solver, num_panels): return (curr_wing_panel_veloctiy - self.prev_velocities[-1]) / dt def initialize_next_problem(self, solver): - curr_problem: SteadyProblem = self._steady_problems[-1] - airplane = curr_problem.airplanes[0] - - wing = airplane.wings[0] - mass_matrix = self.define_mass_matrix(0.02, airplane) - - # Panel number definitions - num_chordwise_panels = wing.num_chordwise_panels - num_spanwise_panels = wing.num_spanwise_panels - num_panels = num_chordwise_panels * num_spanwise_panels - - current_torsion_aero = np.zeros(num_chordwise_panels) - current_torsion_inertia = np.zeros(num_chordwise_panels) - span_torsion_angles = np.zeros(int(num_spanwise_panels)) - chord_torsion_angles = np.zeros(int(num_spanwise_panels)) - torsion_matrix = np.zeros((num_chordwise_panels, num_spanwise_panels)) - - points = np.array(solver.stackCpp_GP1_CgP1)[:num_panels, :] - x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ - :num_panels, :, 0 - ] - panelAeroForces_G = np.stack( - [o.forces_W for o in np.ravel(wing.panels)] - ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) - - panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix - # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element - # Force across spanwise panel is distinct - for span_panel in range(num_spanwise_panels): - # Force on each chordwise panel from LE to TE - # from each spanwise point is added to produce torsion at LE - for chord_panel in range(num_chordwise_panels): - # Torsion due to UVLM aero forces on LE - - current_torsion_aero[chord_panel] = ( - panelAeroForces_G[chord_panel][span_panel][2] - ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) - # Torsion due to panel inertia - current_torsion_inertia[chord_panel] = ( - panelInertialForces[chord_panel][span_panel][2] - ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) - # Total torsion on LE due to chordwise panel. Aero and Inertial Torsion are oriented in opposite sense - ct_angle = quad( - self.d_alpha_dy_air_static, - 0, - 0.01, - args=( - -(current_torsion_aero[chord_panel]) - + (current_torsion_inertia[chord_panel]), - self.G * self.I_area, - ), - )[0] - - chord_torsion_angles[span_panel] += ct_angle - torsion_matrix[chord_panel][span_panel] = ct_angle - # Torsion on span-wise collection of panels - span_torsion_angles[span_panel] = ( - span_torsion_angles[span_panel - 1] + chord_torsion_angles[span_panel] - if span_panel > 0 - else chord_torsion_angles[span_panel] + self.curr_airplanes, self.curr_operating_point = ( + self.single_step_movement.generate_next_movement( + base_airplanes=self.curr_airplanes, + base_operating_point=self.curr_operating_point, + step=len(self._steady_problems), ) - - # Inserting torsion of static span-wise collection of panels at wing root - span_torsion_angles = np.insert(span_torsion_angles, 0, 0) - - # ### ********************** Convergence of torsion angle ***************************** ### - # # Error in torsion angle (radians) - # if self.last_torsion_angles is None: - # self.last_torsion_angles = np.zeros(num_spanwise_panels + 1) - # error_torsion = abs(span_torsion_angles - self.last_torsion_angles) - - # if step < 0.5 * self.num_steps / 3: - # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM - # error_exceeded_air = False - # else: - # # Error threshold in subsequeny timesteps is set to 0.01 - # # TODO : Determine appropriate error threshold by running test cases on changing wing twist - # error_threshold = 0.01 * np.pi / 180 # - # # Boolean to determine if change in torsion on any panel exceeds error threshold - # error_exceeded_air = np.any(error_torsion[1:] > error_threshold) - - # # If error exceeds the given threshold, the current step is re-run - # if error_exceeded_air: - # return True - # else: - # # Move to next timestep - # return False - - # Create new wing based on calculated aero-elastic response - self.create_new_wing( - wing=wing, - airplane=airplane, - deformation_matrices=span_torsion_angles * 180 / np.pi, - op=solver.current_operating_point, - num_chordwise_panels=num_chordwise_panels, - num_spanwise_panels=num_spanwise_panels, ) - - # self.last_torsion_angles = span_torsion_angles - # TODO: add logic for when to append vs overwrite - if (True): - self.prev_velocities.append( - solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) - ) - - def create_new_wing( - self, - wing, - airplane, - deformation_matrices, - op, - num_chordwise_panels, - num_spanwise_panels, - ): - """This method redefines the current airplane by defining : - 1. new wing cross-section objects, each cross-section's twist = calculated torsion angle - 2. new wing object - - :param wing: Wing object - :param airplane : Airplane object - :param torsion_angle : A map from wing cross section to deformations - :param freestream_velocity : float, current freestream velocity - - :return: None - """ - # Initialize variable to hold new cross-section objects - these_cross_sections = [] - - # Normalizes the deformation matrix - if np.max((np.abs(deformation_matrices))) > 1: - deformation_matrices = deformation_matrices / np.max( - (np.abs(deformation_matrices)) - ) - - # # Create new cross-section objects with calculated torsion angle as wing twist - for i in range(len(deformation_matrices)): - this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( - # Every wing cross section has an airfoil object. - airfoil=geometry.airfoil.Airfoil( - name="naca0012", - outline_A_lp=None, - resample=True, - n_points_per_side=400, - ), - num_spanwise_panels=wing.wing_cross_sections[i].num_spanwise_panels, - chord=wing.wing_cross_sections[i].chord, - Lp_Wcsp_Lpp=wing.wing_cross_sections[i].Lp_Wcsp_Lpp, - # Every cross-section's twist is due to aero-elastic response - angles_Wcsp_to_Wcs_ixyz=( - np.array([0.0, 0.0, 0.0]) - if i == 0 - else np.array([0.0, deformation_matrices[i], 0.0]) - ), - control_surface_symmetry_type="symmetric", - control_surface_hinge_point=0.75, - control_surface_deflection=0.0, - spanwise_spacing=wing.wing_cross_sections[i].spanwise_spacing, + self._steady_problems.append( + SteadyProblem( + airplanes=self.curr_airplanes, operating_point=self.curr_operating_point ) - these_cross_sections.append(this_wing_cross_section) - - # Define new Wing object with determined cross-sections - this_wing = geometry.wing.Wing( - name="Main Wing", - # Wing root position remains same - Ler_Gs_Cgs=wing.Ler_Gs_Cgs, - angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, - # Wing cross section is redefined - wing_cross_sections=these_cross_sections, - symmetric=True, - mirror_only=False, - symmetryNormal_G=(0.0, 1.0, 0.0), - symmetryPoint_G_Cg=(0.0, 0.0, 0.0), - num_chordwise_panels=wing.num_chordwise_panels, - chordwise_spacing=wing.chordwise_spacing, - ) - - # Create new Airplane object from new Wing objects - these_wings = [this_wing] + airplane.wings[2:] - this_airplane = geometry.airplane.Airplane( - name=airplane.name, - wings=these_wings, - Cg_E_CgP1=airplane.Cg_E_CgP1, - angles_E_to_B_izyx=airplane.angles_E_to_B_izyx, - weight=airplane.weight, ) - print("sup", airplane.wings, this_airplane.wings) - # Redefine airplane at current timestep with newly created Airplane object - new_problem = SteadyProblem( - airplanes=[this_airplane], - operating_point=op, - ) - self._steady_problems.append(new_problem) + def calculate_wing_deformation(self, solver, step): + pass + # curr_problem: SteadyProblem = self._steady_problems[-1] + # airplane = curr_problem.airplanes[0] + + # wing = airplane.wings[0] + # mass_matrix = self.define_mass_matrix(0.02, airplane) + + # # Panel number definitions + # num_chordwise_panels = wing.num_chordwise_panels + # num_spanwise_panels = wing.num_spanwise_panels + # num_panels = num_chordwise_panels * num_spanwise_panels + + # current_torsion_aero = np.zeros(num_chordwise_panels) + # current_torsion_inertia = np.zeros(num_chordwise_panels) + # span_torsion_angles = np.zeros(int(num_spanwise_panels)) + # chord_torsion_angles = np.zeros(int(num_spanwise_panels)) + # torsion_matrix = np.zeros((num_chordwise_panels, num_spanwise_panels)) + + # points = np.array(solver.stackCpp_GP1_CgP1)[:num_panels, :] + # x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ + # :num_panels, :, 0 + # ] + # panelAeroForces_G = np.stack( + # [o.forces_W for o in np.ravel(wing.panels)] + # ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) + + # panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix + # # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element + # # Force across spanwise panel is distinct + # for span_panel in range(num_spanwise_panels): + # # Force on each chordwise panel from LE to TE + # # from each spanwise point is added to produce torsion at LE + # for chord_panel in range(num_chordwise_panels): + # # Torsion due to UVLM aero forces on LE + + # current_torsion_aero[chord_panel] = ( + # panelAeroForces_G[chord_panel][span_panel][2] + # ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) + # # Torsion due to panel inertia + # current_torsion_inertia[chord_panel] = ( + # panelInertialForces[chord_panel][span_panel][2] + # ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) + # # Total torsion on LE due to chordwise panel. Aero and Inertial Torsion are oriented in opposite sense + # ct_angle = quad( + # self.d_alpha_dy_air_static, + # 0, + # 0.01, + # args=( + # -(current_torsion_aero[chord_panel]) + # + (current_torsion_inertia[chord_panel]), + # self.G * self.I_area, + # ), + # )[0] + + # chord_torsion_angles[span_panel] += ct_angle + # torsion_matrix[chord_panel][span_panel] = ct_angle + # # Torsion on span-wise collection of panels + # span_torsion_angles[span_panel] = ( + # span_torsion_angles[span_panel - 1] + chord_torsion_angles[span_panel] + # if span_panel > 0 + # else chord_torsion_angles[span_panel] + # ) + + # # Inserting torsion of static span-wise collection of panels at wing root + # span_torsion_angles = np.insert(span_torsion_angles, 0, 0) + + # # ### ********************** Convergence of torsion angle ***************************** ### + # # # Error in torsion angle (radians) + # # if self.last_torsion_angles is None: + # # self.last_torsion_angles = np.zeros(num_spanwise_panels + 1) + # # error_torsion = abs(span_torsion_angles - self.last_torsion_angles) + + # # if step < 0.5 * self.num_steps / 3: + # # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM + # # error_exceeded_air = False + # # else: + # # # Error threshold in subsequeny timesteps is set to 0.01 + # # # TODO : Determine appropriate error threshold by running test cases on changing wing twist + # # error_threshold = 0.01 * np.pi / 180 # + # # # Boolean to determine if change in torsion on any panel exceeds error threshold + # # error_exceeded_air = np.any(error_torsion[1:] > error_threshold) + + # # # If error exceeds the given threshold, the current step is re-run + # # if error_exceeded_air: + # # return True + # # else: + # # # Move to next timestep + # # return False + + # # Create new wing based on calculated aero-elastic response + # self.create_new_wing( + # wing=wing, + # airplane=airplane, + # deformation_matrices=span_torsion_angles * 180 / np.pi, + # op=solver.current_operating_point, + # num_chordwise_panels=num_chordwise_panels, + # num_spanwise_panels=num_spanwise_panels, + # ) + + # # self.last_torsion_angles = span_torsion_angles + # # TODO: add logic for when to append vs overwrite + # if (True): + # self.prev_velocities.append( + # solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) + # ) + + # def create_new_wing( + # self, + # wing, + # airplane, + # deformation_matrices, + # op, + # num_chordwise_panels, + # num_spanwise_panels, + # ): + # """This method redefines the current airplane by defining : + # 1. new wing cross-section objects, each cross-section's twist = calculated torsion angle + # 2. new wing object + + # :param wing: Wing object + # :param airplane : Airplane object + # :param torsion_angle : A map from wing cross section to deformations + # :param freestream_velocity : float, current freestream velocity + + # :return: None + # """ + # # Initialize variable to hold new cross-section objects + # these_cross_sections = [] + + # # Normalizes the deformation matrix + # if np.max((np.abs(deformation_matrices))) > 1: + # deformation_matrices = deformation_matrices / np.max( + # (np.abs(deformation_matrices)) + # ) + + # # # Create new cross-section objects with calculated torsion angle as wing twist + # for i in range(len(deformation_matrices)): + # this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( + # # Every wing cross section has an airfoil object. + # airfoil=geometry.airfoil.Airfoil( + # name="naca0012", + # outline_A_lp=None, + # resample=True, + # n_points_per_side=400, + # ), + # num_spanwise_panels=wing.wing_cross_sections[i].num_spanwise_panels, + # chord=wing.wing_cross_sections[i].chord, + # Lp_Wcsp_Lpp=wing.wing_cross_sections[i].Lp_Wcsp_Lpp, + # # Every cross-section's twist is due to aero-elastic response + # angles_Wcsp_to_Wcs_ixyz=( + # np.array([0.0, 0.0, 0.0]) + # if i == 0 + # else np.array([0.0, deformation_matrices[i], 0.0]) + # ), + # control_surface_symmetry_type="symmetric", + # control_surface_hinge_point=0.75, + # control_surface_deflection=0.0, + # spanwise_spacing=wing.wing_cross_sections[i].spanwise_spacing, + # ) + # these_cross_sections.append(this_wing_cross_section) + + # # Define new Wing object with determined cross-sections + # this_wing = geometry.wing.Wing( + # name="Main Wing", + # # Wing root position remains same + # Ler_Gs_Cgs=wing.Ler_Gs_Cgs, + # angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, + # # Wing cross section is redefined + # wing_cross_sections=these_cross_sections, + # symmetric=True, + # mirror_only=False, + # symmetryNormal_G=(0.0, 1.0, 0.0), + # symmetryPoint_G_Cg=(0.0, 0.0, 0.0), + # num_chordwise_panels=wing.num_chordwise_panels, + # chordwise_spacing=wing.chordwise_spacing, + # ) + + # # Create new Airplane object from new Wing objects + # these_wings = [this_wing] + airplane.wings[2:] + # this_airplane = geometry.airplane.Airplane( + # name=airplane.name, + # wings=these_wings, + # Cg_E_CgP1=airplane.Cg_E_CgP1, + # angles_E_to_B_izyx=airplane.angles_E_to_B_izyx, + # weight=airplane.weight, + # ) + # print("sup", airplane.wings, this_airplane.wings) + # # Redefine airplane at current timestep with newly created Airplane object + # new_problem = SteadyProblem( + # airplanes=[this_airplane], + # operating_point=op, + # ) + # self._steady_problems.append(new_problem) def d_alpha_dy_air_static(self, y, tau_torsion, GI): return (tau_torsion * y) / GI From 84ed085a9b8dc1fa9f3632dce638ebba3ce5c661 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 1 Dec 2025 20:33:23 -0500 Subject: [PATCH 05/40] add working overlly elastic system --- examples/demos/demo_single_step.py | 2 +- .../single_step_airplane_movement.py | 3 +- .../single_step/single_step_movement.py | 3 +- ...single_step_wing_cross_section_movement.py | 9 +- .../single_step/single_step_wing_movement.py | 11 +- pterasoftware/problems.py | 300 ++++++------------ 6 files changed, 121 insertions(+), 207 deletions(-) diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py index a5e0e3428..6c8959289 100644 --- a/examples/demos/demo_single_step.py +++ b/examples/demos/demo_single_step.py @@ -449,5 +449,5 @@ unsteady_solver=example_solver, scalar_type="lift", show_wake_vortices=True, - save=False, + save=True, ) diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py index 8fb254905..cd73faf96 100644 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -232,7 +232,7 @@ def __init__( self.listAngles_E_to_B_izyx = None def generate_next_airplane( - self, base_airplane, delta_time, num_steps, step + self, base_airplane, delta_time, num_steps, step, deformation_matrices ): """Creates the Airplane at the next timestep @@ -272,6 +272,7 @@ def generate_next_airplane( delta_time=delta_time, num_steps=num_steps, step=step, + deformation_matrices=deformation_matrices, ) ) diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index 7f523152e..030d18f5a 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -162,7 +162,7 @@ def __init__( # AirplaneMovement. The first index identifies the AirplaneMovement, and the # second index identifies the time step. - def generate_next_movement(self, base_airplanes, base_operating_point, step): + def generate_next_movement(self, base_airplanes, base_operating_point, step, deformation_matrices=None): """Creates the Airplanes and OperatingPoint at the next time step. :param base_airplanes: list of Airplanes @@ -186,6 +186,7 @@ def generate_next_movement(self, base_airplanes, base_operating_point, step): base_airplane=base_airplanes[airplane_id], num_steps=self.num_steps, step=step, + deformation_matrices=deformation_matrices, ) ) diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index 63a2f7efe..7d2412fa4 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -267,6 +267,7 @@ def generate_next_wing_cross_sections( delta_time, num_steps, step, + deformation_matrix, base=False, ): """Creates the WingCrossSection at each time step, and returns them in a list. @@ -328,9 +329,11 @@ def generate_next_wing_cross_sections( ) this_spanwise_spacing = base_wing_cross_section.spanwise_spacing - offset = np.zeros(3) if base else np.random.random_sample(3) * 0.1 - thisLp_Wcsp_Lpp = self.listLp_Wcsp_Lpp[:, step] + offset - theseAngles_Wcsp_to_Wcs_ixyz = self.listAngles_Wcsp_to_Wcs_ixyz[:, step] + thisLp_Wcsp_Lpp = self.listLp_Wcsp_Lpp[:, step] + theseAngles_Wcsp_to_Wcs_ixyz = self.listAngles_Wcsp_to_Wcs_ixyz[ + :, step + ] + np.array([0, deformation_matrix, 0]) + # Make a new WingCrossSection for this time step. this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( airfoil=this_airfoil, diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py index b001896b8..2d3530cf5 100644 --- a/pterasoftware/movements/single_step/single_step_wing_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_movement.py @@ -267,7 +267,7 @@ def __init__( self.listLer_Gs_Cgs = None self.listAngles_Gs_to_Wn_ixyz = None - def generate_next_wing(self, base_wing, delta_time, num_steps, step): + def generate_next_wing(self, base_wing, delta_time, num_steps, step, deformation_matrices): """Creates the Wing at each time step, and returns them in a list. :param num_steps: int @@ -291,6 +291,9 @@ def generate_next_wing(self, base_wing, delta_time, num_steps, step): delta_time = positive_number_return_float( delta_time, "delta_time" ) + # Account for null deformation_matrices input. + if deformation_matrices is None: + deformation_matrices = np.zeros(len(self.wing_cross_section_movements)) # Generate oscillating values for each dimension of Ler_Gs_Cgs. if self.listLer_Gs_Cgs is None: @@ -324,7 +327,10 @@ def generate_next_wing(self, base_wing, delta_time, num_steps, step): delta_time=delta_time, num_steps=num_steps, step=step, - base=wing_cross_section_movement_id==0, + base=wing_cross_section_movement_id == 0, + deformation_matrix=deformation_matrices[ + wing_cross_section_movement_id + ], ) ) @@ -348,7 +354,6 @@ def generate_next_wing(self, base_wing, delta_time, num_steps, step): these_wing_cross_sections = list(wing_cross_sections[:, step]) # Make a new Wing for this time step. - print(min(theseAngles_Gs_to_Wn_ixyz)) this_wing = geometry.wing.Wing( wing_cross_sections=these_wing_cross_sections, name=this_name, diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 4d0308cb3..b6b001a9a 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -384,23 +384,24 @@ def define_mass_matrix(self, M_wing, airplane): def calculate_wing_panel_accelerations(self, solver, num_panels): dt = self.movement.delta_time + curr_wing_panel_veloctiy = solver.calculate_solution_velocity( + solver.stackCpp_GP1_CgP1 + ) if len(self.prev_velocities) < 1: # Set the flapping velocities to be zero for all points. Then, return the # flapping velocities. return np.zeros((num_panels, 3)) - curr_wing_panel_veloctiy = solver.calculate_solution_velocity( - solver.stackCpp_GP1_CgP1 - ) - - return (curr_wing_panel_veloctiy - self.prev_velocities[-1]) / dt + return (curr_wing_panel_veloctiy - self.prev_velocities[-1])[:num_panels] / dt def initialize_next_problem(self, solver): + deformation_matrices = self.calculate_wing_deformation(solver, len(self._steady_problems)) self.curr_airplanes, self.curr_operating_point = ( self.single_step_movement.generate_next_movement( base_airplanes=self.curr_airplanes, base_operating_point=self.curr_operating_point, step=len(self._steady_problems), + deformation_matrices=deformation_matrices, ) ) self._steady_problems.append( @@ -410,199 +411,102 @@ def initialize_next_problem(self, solver): ) def calculate_wing_deformation(self, solver, step): - pass - # curr_problem: SteadyProblem = self._steady_problems[-1] - # airplane = curr_problem.airplanes[0] - - # wing = airplane.wings[0] - # mass_matrix = self.define_mass_matrix(0.02, airplane) - - # # Panel number definitions - # num_chordwise_panels = wing.num_chordwise_panels - # num_spanwise_panels = wing.num_spanwise_panels - # num_panels = num_chordwise_panels * num_spanwise_panels - - # current_torsion_aero = np.zeros(num_chordwise_panels) - # current_torsion_inertia = np.zeros(num_chordwise_panels) - # span_torsion_angles = np.zeros(int(num_spanwise_panels)) - # chord_torsion_angles = np.zeros(int(num_spanwise_panels)) - # torsion_matrix = np.zeros((num_chordwise_panels, num_spanwise_panels)) - - # points = np.array(solver.stackCpp_GP1_CgP1)[:num_panels, :] - # x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ - # :num_panels, :, 0 - # ] - # panelAeroForces_G = np.stack( - # [o.forces_W for o in np.ravel(wing.panels)] - # ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) - - # panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix - # # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element - # # Force across spanwise panel is distinct - # for span_panel in range(num_spanwise_panels): - # # Force on each chordwise panel from LE to TE - # # from each spanwise point is added to produce torsion at LE - # for chord_panel in range(num_chordwise_panels): - # # Torsion due to UVLM aero forces on LE - - # current_torsion_aero[chord_panel] = ( - # panelAeroForces_G[chord_panel][span_panel][2] - # ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) - # # Torsion due to panel inertia - # current_torsion_inertia[chord_panel] = ( - # panelInertialForces[chord_panel][span_panel][2] - # ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) - # # Total torsion on LE due to chordwise panel. Aero and Inertial Torsion are oriented in opposite sense - # ct_angle = quad( - # self.d_alpha_dy_air_static, - # 0, - # 0.01, - # args=( - # -(current_torsion_aero[chord_panel]) - # + (current_torsion_inertia[chord_panel]), - # self.G * self.I_area, - # ), - # )[0] - - # chord_torsion_angles[span_panel] += ct_angle - # torsion_matrix[chord_panel][span_panel] = ct_angle - # # Torsion on span-wise collection of panels - # span_torsion_angles[span_panel] = ( - # span_torsion_angles[span_panel - 1] + chord_torsion_angles[span_panel] - # if span_panel > 0 - # else chord_torsion_angles[span_panel] - # ) - - # # Inserting torsion of static span-wise collection of panels at wing root - # span_torsion_angles = np.insert(span_torsion_angles, 0, 0) - - # # ### ********************** Convergence of torsion angle ***************************** ### - # # # Error in torsion angle (radians) - # # if self.last_torsion_angles is None: - # # self.last_torsion_angles = np.zeros(num_spanwise_panels + 1) - # # error_torsion = abs(span_torsion_angles - self.last_torsion_angles) - - # # if step < 0.5 * self.num_steps / 3: - # # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM - # # error_exceeded_air = False - # # else: - # # # Error threshold in subsequeny timesteps is set to 0.01 - # # # TODO : Determine appropriate error threshold by running test cases on changing wing twist - # # error_threshold = 0.01 * np.pi / 180 # - # # # Boolean to determine if change in torsion on any panel exceeds error threshold - # # error_exceeded_air = np.any(error_torsion[1:] > error_threshold) - - # # # If error exceeds the given threshold, the current step is re-run - # # if error_exceeded_air: - # # return True - # # else: - # # # Move to next timestep - # # return False - - # # Create new wing based on calculated aero-elastic response - # self.create_new_wing( - # wing=wing, - # airplane=airplane, - # deformation_matrices=span_torsion_angles * 180 / np.pi, - # op=solver.current_operating_point, - # num_chordwise_panels=num_chordwise_panels, - # num_spanwise_panels=num_spanwise_panels, - # ) - - # # self.last_torsion_angles = span_torsion_angles - # # TODO: add logic for when to append vs overwrite - # if (True): - # self.prev_velocities.append( - # solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) - # ) - - # def create_new_wing( - # self, - # wing, - # airplane, - # deformation_matrices, - # op, - # num_chordwise_panels, - # num_spanwise_panels, - # ): - # """This method redefines the current airplane by defining : - # 1. new wing cross-section objects, each cross-section's twist = calculated torsion angle - # 2. new wing object - - # :param wing: Wing object - # :param airplane : Airplane object - # :param torsion_angle : A map from wing cross section to deformations - # :param freestream_velocity : float, current freestream velocity - - # :return: None - # """ - # # Initialize variable to hold new cross-section objects - # these_cross_sections = [] - - # # Normalizes the deformation matrix - # if np.max((np.abs(deformation_matrices))) > 1: - # deformation_matrices = deformation_matrices / np.max( - # (np.abs(deformation_matrices)) - # ) - - # # # Create new cross-section objects with calculated torsion angle as wing twist - # for i in range(len(deformation_matrices)): - # this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( - # # Every wing cross section has an airfoil object. - # airfoil=geometry.airfoil.Airfoil( - # name="naca0012", - # outline_A_lp=None, - # resample=True, - # n_points_per_side=400, - # ), - # num_spanwise_panels=wing.wing_cross_sections[i].num_spanwise_panels, - # chord=wing.wing_cross_sections[i].chord, - # Lp_Wcsp_Lpp=wing.wing_cross_sections[i].Lp_Wcsp_Lpp, - # # Every cross-section's twist is due to aero-elastic response - # angles_Wcsp_to_Wcs_ixyz=( - # np.array([0.0, 0.0, 0.0]) - # if i == 0 - # else np.array([0.0, deformation_matrices[i], 0.0]) - # ), - # control_surface_symmetry_type="symmetric", - # control_surface_hinge_point=0.75, - # control_surface_deflection=0.0, - # spanwise_spacing=wing.wing_cross_sections[i].spanwise_spacing, - # ) - # these_cross_sections.append(this_wing_cross_section) - - # # Define new Wing object with determined cross-sections - # this_wing = geometry.wing.Wing( - # name="Main Wing", - # # Wing root position remains same - # Ler_Gs_Cgs=wing.Ler_Gs_Cgs, - # angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, - # # Wing cross section is redefined - # wing_cross_sections=these_cross_sections, - # symmetric=True, - # mirror_only=False, - # symmetryNormal_G=(0.0, 1.0, 0.0), - # symmetryPoint_G_Cg=(0.0, 0.0, 0.0), - # num_chordwise_panels=wing.num_chordwise_panels, - # chordwise_spacing=wing.chordwise_spacing, - # ) - - # # Create new Airplane object from new Wing objects - # these_wings = [this_wing] + airplane.wings[2:] - # this_airplane = geometry.airplane.Airplane( - # name=airplane.name, - # wings=these_wings, - # Cg_E_CgP1=airplane.Cg_E_CgP1, - # angles_E_to_B_izyx=airplane.angles_E_to_B_izyx, - # weight=airplane.weight, - # ) - # print("sup", airplane.wings, this_airplane.wings) - # # Redefine airplane at current timestep with newly created Airplane object - # new_problem = SteadyProblem( - # airplanes=[this_airplane], - # operating_point=op, - # ) - # self._steady_problems.append(new_problem) + curr_problem: SteadyProblem = self._steady_problems[-1] + airplane = curr_problem.airplanes[0] + + wing = airplane.wings[0] + mass_matrix = self.define_mass_matrix(0.12, airplane) + + # Panel number definitions + num_chordwise_panels = wing.num_chordwise_panels + num_spanwise_panels = wing.num_spanwise_panels + num_panels = num_chordwise_panels * num_spanwise_panels + + current_torsion_aero = np.zeros(num_chordwise_panels) + current_torsion_inertia = np.zeros(num_chordwise_panels) + span_torsion_angles = np.zeros(int(num_spanwise_panels)) + chord_torsion_angles = np.zeros(int(num_spanwise_panels)) + torsion_matrix = np.zeros((num_chordwise_panels, num_spanwise_panels)) + + points = np.array(solver.stackCpp_GP1_CgP1)[:num_panels, :] + x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ + :num_panels, :, 0 + ] + panelAeroForces_G = np.stack( + [o.forces_W for o in np.ravel(wing.panels)] + ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) + + panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix + # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element + # Force across spanwise panel is distinct + for span_panel in range(num_spanwise_panels): + # Force on each chordwise panel from LE to TE + # from each spanwise point is added to produce torsion at LE + for chord_panel in range(num_chordwise_panels): + # Torsion due to UVLM aero forces on LE + + current_torsion_aero[chord_panel] = ( + panelAeroForces_G[chord_panel][span_panel][2] + ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) + # Torsion due to panel inertia + current_torsion_inertia[chord_panel] = ( + panelInertialForces[chord_panel][span_panel][2] + ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) + # Total torsion on LE due to chordwise panel. Aero and Inertial Torsion are oriented in opposite sense + ct_angle = quad( + self.d_alpha_dy_air_static, + 0, + 0.01, + args=( + -(current_torsion_aero[chord_panel]) + + (current_torsion_inertia[chord_panel]), + self.G * self.I_area, + ), + )[0] + + chord_torsion_angles[span_panel] += ct_angle + torsion_matrix[chord_panel][span_panel] = ct_angle + # Torsion on span-wise collection of panels + span_torsion_angles[span_panel] = ( + span_torsion_angles[span_panel - 1] + chord_torsion_angles[span_panel] + if span_panel > 0 + else chord_torsion_angles[span_panel] + ) + + # Inserting torsion of static span-wise collection of panels at wing root + span_torsion_angles = np.insert(span_torsion_angles, 0, 0) + + # ### ********************** Convergence of torsion angle ***************************** ### + # # Error in torsion angle (radians) + # if self.last_torsion_angles is None: + # self.last_torsion_angles = np.zeros(num_spanwise_panels + 1) + # error_torsion = abs(span_torsion_angles - self.last_torsion_angles) + + # if step < 0.5 * self.num_steps / 3: + # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM + # error_exceeded_air = False + # else: + # # Error threshold in subsequeny timesteps is set to 0.01 + # # TODO : Determine appropriate error threshold by running test cases on changing wing twist + # error_threshold = 0.01 * np.pi / 180 # + # # Boolean to determine if change in torsion on any panel exceeds error threshold + # error_exceeded_air = np.any(error_torsion[1:] > error_threshold) + + # # If error exceeds the given threshold, the current step is re-run + # if error_exceeded_air: + # return True + # else: + # # Move to next timestep + # return False + + # self.last_torsion_angles = span_torsion_angles + # TODO: add logic for when to append vs overwrite + if (True): + self.prev_velocities.append( + solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) + ) + span_torsion_angles = span_torsion_angles / max(abs(span_torsion_angles)) * 4 + return span_torsion_angles def d_alpha_dy_air_static(self, y, tau_torsion, GI): return (tau_torsion * y) / GI From eceb752ad41321fb4a05c5da6871edd31d573e42 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 1 Dec 2025 21:30:10 -0500 Subject: [PATCH 06/40] initial freakout then good --- pterasoftware/problems.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index b6b001a9a..84504f2c0 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -437,6 +437,9 @@ def calculate_wing_deformation(self, solver, step): ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix + + print("\nMaximums", max(panelAeroForces_G.flatten()), max(panelInertialForces.flatten())) + print("\nMinimums", min(panelAeroForces_G.flatten()), min(panelInertialForces.flatten())) # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element # Force across spanwise panel is distinct for span_panel in range(num_spanwise_panels): @@ -505,7 +508,7 @@ def calculate_wing_deformation(self, solver, step): self.prev_velocities.append( solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) ) - span_torsion_angles = span_torsion_angles / max(abs(span_torsion_angles)) * 4 + span_torsion_angles = span_torsion_angles / 12000 return span_torsion_angles def d_alpha_dy_air_static(self, y, tau_torsion, GI): From 808a53e2a90eadaed996405fe7542dd4afb6a2bc Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 22 Dec 2025 11:09:02 -0500 Subject: [PATCH 07/40] add newmark updates --- examples/demos/demo_single_step.py | 4 +- ...single_step_wing_cross_section_movement.py | 2 +- pterasoftware/problems.py | 526 +++++++++++++++++- 3 files changed, 505 insertions(+), 27 deletions(-) diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py index 6c8959289..ba8a347ca 100644 --- a/examples/demos/demo_single_step.py +++ b/examples/demos/demo_single_step.py @@ -403,7 +403,7 @@ airplane_movements=[airplane_movement], operating_point_movement=operating_point_movement, delta_time=0.03, - num_cycles=2, + num_cycles=4, num_chords=None, num_steps=None, ) @@ -420,7 +420,7 @@ del operating_point_movement # Define the UnsteadyProblem. -example_problem = ps.problems.AeroelasticUnsteadyProblem( +example_problem = ps.problems.BetterAeroelasticUnsteadyProblem( movement=movement, single_step_movement=single_step_movement, ) diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index 7d2412fa4..f1993091b 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -332,7 +332,7 @@ def generate_next_wing_cross_sections( thisLp_Wcsp_Lpp = self.listLp_Wcsp_Lpp[:, step] theseAngles_Wcsp_to_Wcs_ixyz = self.listAngles_Wcsp_to_Wcs_ixyz[ :, step - ] + np.array([0, deformation_matrix, 0]) + ] + deformation_matrix # Make a new WingCrossSection for this time step. this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 84504f2c0..8553c66f8 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -356,6 +356,10 @@ def __init__(self, single_step_movement: SingleStepMovement, movement, only_fina self.I_area = 1e-14 self.curr_airplanes = [movement.airplane_movements[0].base_airplane] self.curr_operating_point = movement.operating_point_movement.base_operating_point + self.last_torsion_angles = None + self.error_exceeded_air = False + self.storage_steady_problem = None + self.net_torsion_angles = np.zeros(9) def define_mass_matrix(self, M_wing, airplane): """ @@ -394,6 +398,12 @@ def calculate_wing_panel_accelerations(self, solver, num_panels): return (curr_wing_panel_veloctiy - self.prev_velocities[-1])[:num_panels] / dt + def get_steady_problem(self, step): + if self.error_exceeded_air: + return self.storage_steady_problem + else: + return super().get_steady_problem(step) + def initialize_next_problem(self, solver): deformation_matrices = self.calculate_wing_deformation(solver, len(self._steady_problems)) self.curr_airplanes, self.curr_operating_point = ( @@ -404,11 +414,16 @@ def initialize_next_problem(self, solver): deformation_matrices=deformation_matrices, ) ) - self._steady_problems.append( - SteadyProblem( + if self.error_exceeded_air: + self.storage_steady_problem = SteadyProblem( airplanes=self.curr_airplanes, operating_point=self.curr_operating_point ) - ) + else: + self._steady_problems.append( + SteadyProblem( + airplanes=self.curr_airplanes, operating_point=self.curr_operating_point + ) + ) def calculate_wing_deformation(self, solver, step): curr_problem: SteadyProblem = self._steady_problems[-1] @@ -432,9 +447,11 @@ def calculate_wing_deformation(self, solver, step): x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ :num_panels, :, 0 ] - panelAeroForces_G = np.stack( - [o.forces_W for o in np.ravel(wing.panels)] - ).reshape((num_chordwise_panels, num_spanwise_panels, 3)) + panelAeroForces_G = ( + np.stack([o.forces_GP1 for o in np.ravel(wing.panels)]).reshape( + (num_chordwise_panels, num_spanwise_panels, 3) + ) + ) / 10000 panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix @@ -480,27 +497,20 @@ def calculate_wing_deformation(self, solver, step): span_torsion_angles = np.insert(span_torsion_angles, 0, 0) # ### ********************** Convergence of torsion angle ***************************** ### - # # Error in torsion angle (radians) + # Error in torsion angle (radians) # if self.last_torsion_angles is None: # self.last_torsion_angles = np.zeros(num_spanwise_panels + 1) # error_torsion = abs(span_torsion_angles - self.last_torsion_angles) - # if step < 0.5 * self.num_steps / 3: - # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM - # error_exceeded_air = False - # else: - # # Error threshold in subsequeny timesteps is set to 0.01 - # # TODO : Determine appropriate error threshold by running test cases on changing wing twist - # error_threshold = 0.01 * np.pi / 180 # - # # Boolean to determine if change in torsion on any panel exceeds error threshold - # error_exceeded_air = np.any(error_torsion[1:] > error_threshold) - - # # If error exceeds the given threshold, the current step is re-run - # if error_exceeded_air: - # return True - # else: - # # Move to next timestep - # return False + # # if step < 0.5 * self.num_steps / 3: + # # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM + # # self.error_exceeded_air = False + # # else: + # # # Error threshold in subsequeny timesteps is set to 0.01 + # # # TODO : Determine appropriate error threshold by running test cases on changing wing twist + # error_threshold = 0.01 * np.pi / 180 # + # # Boolean to determine if change in torsion on any panel exceeds error threshold + # self.error_exceeded_air = np.any(error_torsion[1:] > error_threshold) # self.last_torsion_angles = span_torsion_angles # TODO: add logic for when to append vs overwrite @@ -508,7 +518,12 @@ def calculate_wing_deformation(self, solver, step): self.prev_velocities.append( solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) ) - span_torsion_angles = span_torsion_angles / 12000 + span_torsion_angles = span_torsion_angles / -40 + if step > 30: + self.net_torsion_angles += span_torsion_angles + print("Net torsion angles (deg): ", self.net_torsion_angles) + else: + span_torsion_angles = np.zeros_like(span_torsion_angles) return span_torsion_angles def d_alpha_dy_air_static(self, y, tau_torsion, GI): @@ -516,3 +531,466 @@ def d_alpha_dy_air_static(self, y, tau_torsion, GI): def rotational_inertia(self, m, x, theta): return (1 / 3) * m * (x / np.cos(theta)) ** 3 + + +class BetterAeroelasticUnsteadyProblem(CoupledUnsteadyProblem): + def __init__( + self, + single_step_movement: SingleStepMovement, + movement, + only_final_results=False, + ): + # TODO: fix this constructor to properly inherit from CoupledUnsteadyProblem + super().__init__(movement, only_final_results) + self.prev_velocities = [] + self.single_step_movement = single_step_movement + self.curr_airplanes = [movement.airplane_movements[0].base_airplane] + self.curr_operating_point = ( + movement.operating_point_movement.base_operating_point + ) + self.positions = [] + self.net_deformation = None + + # Tunable Parameters + self.wing_density = 0.012 # per unit height kg/m^2 + self.moment_scaling_factor = 1e-2 + self.spring_constant = 1e-1 + self.aero_scaling = 1.0 + self.new_integrand = False + + # self.wing_density = 0.012 # per unit height kg/m^2 + # self.moment_scaling_factor = 5 + # self.spring_constant = 1 + # self.aero_scaling = 0.0 + # self.new_integrand = True + + self.integration_method = getattr(self, "integration_method", "substep") # "substep" or "newmark" + self.max_delta_theta_per_substep = getattr(self, "max_delta_theta_per_substep", 0.005) + self.max_theta_abs = getattr(self, "max_theta_abs", np.deg2rad(30.0)) + self.max_integration_substeps = getattr(self, "max_integration_substeps", 200) + self.theta_under_relax = getattr(self, "theta_under_relax", 1.0) + # Newmark params + self.newmark_beta = getattr(self, "newmark_beta", 0.25) + self.newmark_gamma = getattr(self, "newmark_gamma", 0.5) + + def calculate_wing_panel_accelerations(self): + if len(self.positions) <= 2: + return np.zeros_like(self.positions[0]) + dt = self.movement.delta_time + return (self.positions[-1] - 2 * self.positions[-2] + self.positions[-3]) / (dt * dt) + + def initialize_next_problem(self, solver): + if self.new_integrand: + deformation_matrices = self.calculate_wing_deformation_new( + solver, len(self._steady_problems) + ) + else: + deformation_matrices = self.calculate_wing_deformation( + solver, len(self._steady_problems) + ) + self.curr_airplanes, self.curr_operating_point = ( + self.single_step_movement.generate_next_movement( + base_airplanes=self.curr_airplanes, + base_operating_point=self.curr_operating_point, + step=len(self._steady_problems), + deformation_matrices=deformation_matrices, + ) + ) + self._steady_problems.append( + SteadyProblem( + airplanes=self.curr_airplanes, + operating_point=self.curr_operating_point, + ) + ) + + def calculate_wing_deformation_new(self, solver, step): + """ + Improved torsional-strip update — minimal but correct structural reference axis, + inertial moment calculation using panel accelerations, and twist measured as + chord angle. Returns self.net_deformation (same shape as before). + """ + + curr_problem: SteadyProblem = self._steady_problems[-1] + airplane = curr_problem.airplanes[0] + wing: geometry.wing.Wing = airplane.wings[0] + + # panel counts + nc = wing.num_chordwise_panels + ns = wing.num_spanwise_panels + + # Gather arrays (vectorized) + aeroMoments = np.array([[p.moments_GP1_CgP1 for p in row] for row in wing.panels]) # (nc,ns,3) + # append current collocation positions to self.positions (same as you did) + self.positions.append(np.array([[p.Cpp_GP1_CgP1 for p in row] for row in wing.panels])) # (nc,ns,3) + areas = np.array([[p.area for p in row] for row in wing.panels]) # (nc,ns) + + # --- Build structural reference axis points (undeformed mid-chord) ---------- + undeformed_wing = self.steady_problems[-1].airplanes[0].wings[0] + # we'll compute LE and TE from panel corner properties for each undeformed panel + span_axis_points = np.zeros((ns, 3)) + span_y = np.zeros(ns) + for i in range(ns): + LE_pts = [] + TE_pts = [] + for j in range(nc): + p = undeformed_wing.panels[j][i] + LE = np.asarray(p.Flpp_GP1_CgP1) # front-left point + TE = np.asarray(p.Brpp_GP1_CgP1) + np.asarray(p.rightLeg_GP1) # back-right + rightLeg -> TE approx + LE_pts.append(LE) + TE_pts.append(TE) + LE_mean = np.mean(LE_pts, axis=0) + TE_mean = np.mean(TE_pts, axis=0) + span_axis_points[i, :] = 0.5 * (LE_mean + TE_mean) # mid-chord + span_y[i] = np.mean([pt[1] for pt in LE_pts]) + + # approximate dy + dy = np.mean(np.diff(span_y)) if ns > 1 else 1.0 + dt = float(self.movement.delta_time) + + # --- Panel accelerations & inertial forces -------------------------------- + panel_accels = self.calculate_wing_panel_accelerations() # (nc,ns,3) + panel_masses = areas * self.wing_density # (nc,ns) + F_inertial = panel_accels * panel_masses[:, :, np.newaxis] # (nc,ns,3) + + # --- reference points expanded to panel shape ----------------------------- + ref_points = np.repeat(span_axis_points[np.newaxis, :, :], nc, axis=0) # (nc,ns,3) + + # --- inertial moment per panel: r x F (vector) --------------------------- + curr_pos = self.positions[-1] # (nc,ns,3) + r = curr_pos - ref_points # vector from structural axis to panel CG + panel_inertial_moments = np.cross(r, F_inertial, axis=2) # (nc,ns,3) + + # --- Choose the correct moment component for torsion --------------------- + # GP1: +y is span direction. Torsion about span axis => use the y-component (index 1). + aero_M_y = aeroMoments[:, :, 1] # (nc,ns) + inertial_M_y = panel_inertial_moments[:, :, 1] # (nc,ns) + + # sum chordwise to get per-span driving moment + M_aero_span = np.sum(aero_M_y, axis=0) # (ns,) + M_inertial_span = np.sum(inertial_M_y, axis=0) # (ns,) + M_net = M_aero_span - M_inertial_span # (ns,) + + # --- Rotational inertia about span axis per strip ----------------------- + r_perp_sq = np.sum(r[..., [0, 2]] ** 2, axis=2) # (nc,ns) using x,z dist + I_theta = np.sum(panel_masses * r_perp_sq, axis=0) # (ns,) + + # --- Compute twist angle per span station from chord orientation ------- + # use undeformed LE/TE to get theta_ref if not already set + if not hasattr(self, "_theta_ref") or self._theta_ref is None: + self._theta_ref = np.zeros(ns) + for i in range(ns): + p_le = np.asarray(undeformed_wing.panels[0][i].Flpp_GP1_CgP1) + p_te = np.asarray(undeformed_wing.panels[-1][i].Brpp_GP1_CgP1) + np.asarray(undeformed_wing.panels[-1][i].rightLeg_GP1) + v0 = p_te[[0, 2]] - p_le[[0, 2]] # projected into x-z + self._theta_ref[i] = np.arctan2(v0[1], v0[0]) + theta_ref = self._theta_ref + + # initialize dynamic state if missing + if not hasattr(self, "_theta"): + self._theta = theta_ref.copy() + self._theta_dot = np.zeros_like(self._theta) + + # measure current chord-based theta (from current geometry) + current_theta = np.zeros(ns) + for i in range(ns): + # define LE/TE from current panels if corner data available, otherwise use Cpp proxies + try: + p_le = np.asarray(wing.panels[0][i].Flpp_GP1_CgP1) + p_te = np.asarray(wing.panels[-1][i].Brpp_GP1_CgP1) + np.asarray(wing.panels[-1][i].rightLeg_GP1) + except Exception: + p_le = curr_pos[0, i] + p_te = curr_pos[-1, i] + v = p_te[[0, 2]] - p_le[[0, 2]] + current_theta[i] = np.arctan2(v[1], v[0]) + + # structural parameters (tunable on self) + GJ = getattr(self, "GJ", 1e4) # torsional rigidity + c_theta = getattr(self, "c_theta", 1e2) # damping + k_theta = getattr(self, "k_theta", 0.0) # optional torsion spring per station (N·m/rad) + + # --- discrete laplacian (spanwise torsion coupling) --------------------- + lap = np.zeros(ns) + if ns > 1: + lap[1:-1] = (self._theta[2:] - 2.0 * self._theta[1:-1] + self._theta[0:-2]) / (dy * dy) + # boundaries: mirror (free-tip) or use clamped flags + if getattr(self, "root_clamped", False): + lap[0] = (self._theta[1] - 2.0 * self._theta[0] + theta_ref[0]) / (dy * dy) + else: + lap[0] = (self._theta[1] - 2.0 * self._theta[0] + self._theta[1]) / (dy * dy) + if getattr(self, "tip_clamped", False): + lap[-1] = (theta_ref[-1] - 2.0 * self._theta[-1] + self._theta[-2]) / (dy * dy) + else: + lap[-1] = (self._theta[-2] - 2.0 * self._theta[-1] + self._theta[-2]) / (dy * dy) + + # --- equation: I*theta_ddot + c*theta_dot + k_theta*(theta-theta_ref) - GJ*lap = M_net + eps = 1e-12 + I_safe = np.where(I_theta > eps, I_theta, eps) + torque_spring = k_theta * (self._theta - theta_ref) + theta_ddot = (M_net - torque_spring + GJ * lap - c_theta * self._theta_dot) / I_safe + + # ---------- SUBSTEPPING + CLIPPING (explicit, conservative) ---------- + if getattr(self, "integration_method", "substep") == "substep": + # discover / ensure arrays + dt = float(self.movement.delta_time) + max_delta = float(self.max_delta_theta_per_substep) + max_theta_abs = float(self.max_theta_abs) + under_relax = float(self.theta_under_relax) + max_substeps_limit = int(self.max_integration_substeps) + c_theta = float(getattr(self, "c_theta", c_theta)) + k_theta = np.asarray(getattr(self, "k_theta", k_theta)) + + # initial theta_ddot (from earlier formula) + # if you computed theta_ddot earlier as array use it, otherwise compute quickly: + theta_ddot_curr = (M_net - k_theta * (self._theta - theta_ref) + GJ * lap - c_theta * self._theta_dot) / I_safe + + # heuristic estimate of delta per macro step + delta_est = np.abs(theta_ddot_curr) * (dt * dt) + required_substeps = np.ceil(np.maximum(1.0, delta_est / (max_delta + 1e-12))).astype(int) + substeps = int(min(max(required_substeps.max(), 1), max_substeps_limit)) + dt_sub = dt / float(substeps) + + theta_local = self._theta.copy() + theta_dot_local = self._theta_dot.copy() + + for s in range(substeps): + # recompute lap using theta_local + if ns > 1: + lap_local = np.zeros(ns) + lap_local[1:-1] = (theta_local[2:] - 2.0 * theta_local[1:-1] + theta_local[0:-2]) / (dy * dy) + if getattr(self, "root_clamped", False): + lap_local[0] = (theta_local[1] - 2.0 * theta_local[0] + theta_ref[0]) / (dy * dy) + else: + lap_local[0] = (theta_local[1] - 2.0 * theta_local[0] + theta_local[1]) / (dy * dy) + if getattr(self, "tip_clamped", False): + lap_local[-1] = (theta_ref[-1] - 2.0 * theta_local[-1] + theta_local[-2]) / (dy * dy) + else: + lap_local[-1] = (theta_local[-2] - 2.0 * theta_local[-1] + theta_local[-2]) / (dy * dy) + else: + lap_local = np.zeros_like(theta_local) + + torque_spring_local = k_theta * (theta_local - theta_ref) + theta_ddot_local = (M_net - torque_spring_local + GJ * lap_local - c_theta * theta_dot_local) / I_safe + + # semi-implicit Euler for substep + theta_dot_new_local = theta_dot_local + dt_sub * theta_ddot_local + theta_new_local = theta_local + dt_sub * theta_dot_new_local + + # limit per-substep delta + delta = theta_new_local - theta_local + too_big = np.abs(delta) > max_delta + if np.any(too_big): + delta[too_big] = np.sign(delta[too_big]) * max_delta + + # under-relaxation + delta *= under_relax + + theta_local = theta_local + delta + # update local angular velocity consistent with delta + theta_dot_local = np.where(dt_sub > 0.0, delta / dt_sub, theta_dot_new_local) + + # absolute clipping + theta_local = np.clip(theta_local, -max_theta_abs, max_theta_abs) + clipped = (np.abs(theta_local) >= max_theta_abs - 1e-15) + if np.any(clipped): + theta_dot_local[clipped] = 0.0 + + # accept + theta_new = theta_local + theta_dot_new = theta_dot_local + + # update object + self._theta = theta_new + self._theta_dot = theta_dot_new + + # ---------- NEWMARK-BETA (implicit solver) ---------- + if getattr(self, "integration_method", "substep") == "newmark": + dt = float(self.movement.delta_time) + beta = float(self.newmark_beta) + gamma = float(self.newmark_gamma) + # diagonal mass and damping + M_diag = I_safe.copy() # shape (ns,) + C_diag = (getattr(self, "c_theta", c_theta) * np.ones(ns)).copy() + # build discrete second-derivative operator L_matrix (size ns x ns) + # L_matrix @ theta = (theta_{i+1} - 2 theta_i + theta_{i-1}) / dy^2 + L = np.zeros((ns, ns)) + if ns == 1: + L[0, 0] = 0.0 + else: + invdy2 = 1.0 / (dy * dy) + for i in range(ns): + if i == 0: + if getattr(self, "root_clamped", False): + # clamped: second derivative uses theta_ref later + L[i, i] = -2.0 * invdy2 + L[i, i + 1] = 1.0 * invdy2 + else: + L[i, i] = -2.0 * invdy2 + L[i, i + 1] = 2.0 * invdy2 # mirrored + elif i == ns - 1: + if getattr(self, "tip_clamped", False): + L[i, i - 1] = 1.0 * invdy2 + L[i, i] = -2.0 * invdy2 + else: + L[i, i - 1] = 2.0 * invdy2 + L[i, i] = -2.0 * invdy2 + else: + L[i, i - 1] = 1.0 * invdy2 + L[i, i] = -2.0 * invdy2 + L[i, i + 1] = 1.0 * invdy2 + + # static stiffness matrix: K_static = diag(k_theta) - GJ * L + k_theta_arr = np.asarray(getattr(self, "k_theta", k_theta * np.ones(ns))) + K_static = np.diag(k_theta_arr) - GJ * L # shape (ns, ns) + + # Effective matrices for Newmark + # K_eff = M/(beta dt^2) + C*(gamma/(beta dt)) + K_static + M_over = np.diag(M_diag / (beta * dt * dt)) + C_term = np.diag(C_diag * (gamma / (beta * dt))) + K_eff = M_over + C_term + K_static + + # right-hand side: F_eff = F_{n+1} + M * a_hat + C * v_hat + # where F_{n+1} = M_net + k_theta * theta_ref (note k_theta*theta_ref moves to RHS) + F_ext = M_net + k_theta_arr * theta_ref + + # compute acceleration / velocity predictors from current state + # assume self._theta_ddot exists or compute current accel: + if not hasattr(self, "_theta_ddot"): + # compute current theta_ddot using original formula (elementwise) + torque_spring = k_theta_arr * (self._theta - theta_ref) + self._theta_ddot = (M_net - torque_spring + GJ * lap - C_diag * self._theta_dot) / np.where(M_diag > 1e-12, M_diag, 1e-12) + + a_n = self._theta_ddot + v_n = self._theta_dot + u_n = self._theta + + a_hat = ( (1.0/(beta*dt*dt)) * u_n + + (1.0/(beta*dt)) * v_n + + (1.0/(2.0*beta) - 1.0) * a_n ) + v_hat = ( (gamma/(beta*dt)) * u_n + + (gamma/beta - 1.0) * v_n + + dt * (gamma/(2.0*beta) - 1.0) * a_n ) + + F_eff = F_ext + M_diag * a_hat + C_diag * v_hat + + # Handle Dirichlet BCs (clamped nodes) by modifying K_eff and F_eff: + # for clamped nodes, we enforce theta = theta_ref (Dirichlet) + clamp_nodes = [] + if getattr(self, "root_clamped", False): + clamp_nodes.append(0) + if getattr(self, "tip_clamped", False): + clamp_nodes.append(ns - 1) + # implement simple row/col replacement for clamp nodes + for node in clamp_nodes: + K_eff[node, :] = 0.0 + K_eff[node, node] = 1.0 + F_eff[node] = theta_ref[node] + + # Solve linear system for theta_{n+1} + theta_new = np.linalg.solve(K_eff, F_eff) + + # compute acceleration and velocity at n+1 + a_new = (1.0 / (beta * dt * dt)) * (theta_new - u_n) - (1.0 / (beta * dt)) * v_n - (1.0 / (2.0 * beta) - 1.0) * a_n + v_new = v_n + dt * ( (1.0 - gamma) * a_n + gamma * a_new ) + + # update state + self._theta = theta_new + self._theta_dot = v_new + self._theta_ddot = a_new + + # absolute clipping as a final guard + self._theta = np.clip(self._theta, -self.max_theta_abs, self.max_theta_abs) + self._theta_dot[np.abs(self._theta) >= self.max_theta_abs - 1e-15] = 0.0 + + # --- convert theta_new to net_deformation (backwards-compatible) ---------- + # small-angle vertical displacement approx at half-chord + half_chords = np.zeros(ns) + for i in range(ns): + p_le = np.asarray(undeformed_wing.panels[0][i].Flpp_GP1_CgP1) + p_te = np.asarray(undeformed_wing.panels[-1][i].Brpp_GP1_CgP1) + np.asarray(undeformed_wing.panels[-1][i].rightLeg_GP1) + half_chords[i] = 0.5 * abs(p_te[0] - p_le[0]) + + delta_theta = theta_new - theta_ref + step_deformation = np.zeros((ns, 3)) + # put small-angle z (vertical) displacement into index 1 as your original code did + step_deformation[:, 1] = half_chords * delta_theta * self.moment_scaling_factor + + step_deformation_full = np.insert(step_deformation, 0, np.zeros(3), axis=0) + if self.net_deformation is None: + self.net_deformation = np.zeros((ns + 1, 3)) + self.net_deformation += step_deformation_full + + self.net_deformation[:, 1] = np.clip( + self.net_deformation[:, 1], -90, 90) + + if step % 10 == 3: + print("Net deformation: ", self.net_deformation) + print("step deformation: ", step_deformation_full) + + return self.net_deformation + + def calculate_wing_deformation(self, solver, step): + curr_problem: SteadyProblem = self._steady_problems[-1] + airplane = curr_problem.airplanes[0] + + wing: geometry.wing.Wing = airplane.wings[0] + + # Panel number definitions + num_chordwise_panels = wing.num_chordwise_panels + num_spanwise_panels = wing.num_spanwise_panels + num_panels = num_chordwise_panels * num_spanwise_panels + + aeroMoments_GP1_CgP1 = np.array( + [[panel.moments_GP1_CgP1 for panel in row] for row in wing.panels] + ) * self.aero_scaling + self.positions.append(np.array( + [[panel.Cpp_GP1_CgP1 for panel in row] for row in wing.panels] + )) + areas = np.array( + [[panel.area for panel in row] for row in wing.panels] + ) + + inertial_forces = ( + self.calculate_wing_panel_accelerations() + * np.repeat(areas[:, :, None], 3, axis=2) + * self.wing_density + ) + inertial_moments = np.cross( + self.positions[-1] - self.positions[0], + inertial_forces, + axis=2 + ) + total_moments = aeroMoments_GP1_CgP1 - inertial_moments + + deformation_moments = total_moments[:, :, 2] # Z-axis moments + + if self.net_deformation is None: + self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) + + undeforemed_wing = self.steady_problems[-1].airplanes[0].wings[0] + undeformed_postions = np.array( + [[panel.Cpp_GP1_CgP1 for panel in row] for row in undeforemed_wing.panels] + ) + step_deformation = np.array( + [ + np.array( + [ + 0, + np.sum(deformation_moments[:, i]) * self.moment_scaling_factor + - self.spring_constant + * np.sum( + self.positions[-1][:, i, 2] - undeformed_postions[:, i, 2] + ), + 0, + ] + ) + for i in range(num_spanwise_panels) + ] + ) + step_deformation = np.insert(step_deformation, 0, np.array([0,0,0]), axis=0) + self.net_deformation -= step_deformation + + if step % 10 == 3: + print("Net deformation: ", self.net_deformation) + print("step deformation: ", step_deformation) + + return self.net_deformation From bd6c426b4777ccfcce167da8faa022a4a9a06127 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 5 Jan 2026 09:10:18 -0500 Subject: [PATCH 08/40] add space for main rebase --- pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index e5a3640db..74ea65f27 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -62,6 +62,7 @@ def __init__(self, coupled_unsteady_problem): self._current_step = None self.steady_problems = [] + self.current_airplanes = None self.current_operating_point = None From 44df7a50b69adf7015f972349477b4b48eb8aeef Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 5 Jan 2026 14:42:39 -0500 Subject: [PATCH 09/40] update to current main --- examples/demos/demo_single_step.py | 29 +- examples/demos/demo_wing.py | 15 +- pterasoftware/_functions.py | 1 + ...led_unsteady_ring_vortex_lattice_method.py | 1697 +++++++++-------- .../single_step_airplane_movement.py | 473 ++--- .../single_step/single_step_movement.py | 15 +- .../single_step_operating_point_movement.py | 20 +- ...single_step_wing_cross_section_movement.py | 12 +- .../single_step/single_step_wing_movement.py | 12 +- pterasoftware/problems.py | 2 +- 10 files changed, 1181 insertions(+), 1095 deletions(-) diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py index ba8a347ca..631add4ba 100644 --- a/examples/demos/demo_single_step.py +++ b/examples/demos/demo_single_step.py @@ -107,8 +107,7 @@ ), ], name="Example Airplane", - Cg_E_CgP1=(0.0, 0.0, 0.0), - angles_E_to_B_izyx=(0.0, 0.0, 0.0), + Cg_GP1_CgP1=(0.0, 0.0, 0.0), weight=0.0, c_ref=None, b_ref=None, @@ -342,31 +341,24 @@ airplane_movement = ps.movements.airplane_movement.AirplaneMovement( base_airplane=example_airplane, wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_movement], - ampCg_E_CgP1=(0.0, 0.0, 0.0), - periodCg_E_CgP1=(0.0, 0.0, 0.0), - spacingCg_E_CgP1=("sine", "sine", "sine"), - phaseCg_E_CgP1=(0.0, 0.0, 0.0), - ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), - phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + 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), ) single_step_airplane_movement = ( ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + single_step_wing_movements=[ single_step_main_wing_movement, single_step_reflected_main_wing_movement, single_step_v_tail_movement, ], - ampCg_E_CgP1=(0.0, 0.0, 0.0), - periodCg_E_CgP1=(0.0, 0.0, 0.0), - spacingCg_E_CgP1=("sine", "sine", "sine"), - phaseCg_E_CgP1=(0.0, 0.0, 0.0), - ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), - phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + 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), ) ) @@ -438,7 +430,6 @@ # Run the solver. example_solver.run( - logging_level="Warning", prescribed_wake=True, ) diff --git a/examples/demos/demo_wing.py b/examples/demos/demo_wing.py index 1dcb55f7d..c2777ea1d 100644 --- a/examples/demos/demo_wing.py +++ b/examples/demos/demo_wing.py @@ -107,8 +107,7 @@ ), ], name="Example Airplane", - Cg_E_CgP1=(0.0, 0.0, 0.0), - angles_E_to_B_izyx=(0.0, 0.0, 0.0), + Cg_GP1_CgP1=(0.0, 0.0, 0.0), weight=0.0, c_ref=None, b_ref=None, @@ -252,14 +251,10 @@ airplane_movement = ps.movements.airplane_movement.AirplaneMovement( base_airplane=example_airplane, wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_movement], - ampCg_E_CgP1=(0.0, 0.0, 0.0), - periodCg_E_CgP1=(0.0, 0.0, 0.0), - spacingCg_E_CgP1=("sine", "sine", "sine"), - phaseCg_E_CgP1=(0.0, 0.0, 0.0), - ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), - phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), + 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), ) # Delete the extraneous pointers to the WingMovements. diff --git a/pterasoftware/_functions.py b/pterasoftware/_functions.py index 12e91480e..f24dde9f5 100644 --- a/pterasoftware/_functions.py +++ b/pterasoftware/_functions.py @@ -192,6 +192,7 @@ def process_solver_loads( steady_horseshoe_vortex_lattice_method, steady_ring_vortex_lattice_method, unsteady_ring_vortex_lattice_method, + coupled_unsteady_ring_vortex_lattice_method, ) if isinstance( diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 74ea65f27..9736f5b09 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -1,222 +1,222 @@ -"""This module contains the class definition of this package's unsteady ring vortex -lattice solver. +"""Contains the CoupledUnsteadyRingVortexLatticeMethodSolver class. -This module contains the following classes: - UnsteadyRingVortexLatticeMethodSolver: This is an aerodynamics solver that uses - an unsteady ring vortex lattice method. +**Contains the following classes:** -This module contains the following functions: - None +CoupledUnsteadyRingVortexLatticeMethodSolver: A class used to solve CoupledUnsteadyProblems with +the unsteady ring vortex lattice method. + +**Contains the following functions:** + +None """ -import logging +from __future__ import annotations + +from collections.abc import Sequence from typing import cast import numpy as np from tqdm import tqdm -from . import _aerodynamics, operating_point, movements -from . import _functions -from . import _parameter_validation -from . import _panel -from . import geometry -from . import problems +from . import ( + _aerodynamics, + _functions, + _logging, + _panel, + _parameter_validation, + geometry, + movements, + operating_point, + problems, +) + +_logger = _logging.get_logger("unsteady_ring_vortex_lattice_method") # TEST: Consider adding unit tests for this function. # TEST: Assess how comprehensive this function's integration tests are and update or # extend them if needed. -class CoupledUnsteadyRingVortexLatticeMethodSolver(): - """This is an aerodynamics solver that uses an unsteady ring vortex lattice method. +class CoupledUnsteadyRingVortexLatticeMethodSolver: + """A class used to solve UnsteadyProblems with the unsteady ring vortex lattice + method. - This class contains the following public methods: + **Contains the following methods:** - run: This method runs the solver on the UnsteadyProblem. + run: Runs the solver on the UnsteadyProblem. - calculate_solution_velocity: This function takes in a group of points (in the - first Airplane's geometry axes, relative to the first Airplane's CG). At - every point, it finds the fluid velocity (in the first Airplane's geometry - axes, observed from the Earth frame) at that point due to the freestream - velocity and the induced velocity from every RingVortex. - - This class contains the following class attributes: - None + initialize_step_geometry: Initializes geometry for a specific step without solving. + calculate_solution_velocity: Finds the fluid velocity (in the first Airplane's + geometry axes, observed from the Earth frame) at one or more points (in the first + Airplane's geometry axes, relative to the first Airplane's CG) due to the freestream + velocity and the induced velocity from every RingVortex. """ - def __init__(self, coupled_unsteady_problem): - """This is the initialization method. + def __init__(self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem) -> None: + """The initialization method. - :param unsteady_problem: UnsteadyProblem - This is the UnsteadyProblem to be solved. + :param unsteady_problem: The UnsteadyProblem to be solved. :return: None """ if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): - raise TypeError("unsteady_problem must be an UnsteadyProblem.") - self.coupled_unsteady_problem: problems.CoupledUnsteadyProblem = coupled_unsteady_problem + raise TypeError("coupled_unsteady_problem must be a CoupledUnsteadyProblem.") + self.coupled_unsteady_problem = coupled_unsteady_problem self.num_steps = self.coupled_unsteady_problem.num_steps self.delta_time = self.coupled_unsteady_problem.delta_time self.first_results_step = self.coupled_unsteady_problem.first_results_step self._first_averaging_step = self.coupled_unsteady_problem.first_averaging_step - self._current_step = None + self._current_step: int = 0 + self._prescribed_wake: bool = True self.steady_problems = [] - - self.current_airplanes = None - self.current_operating_point = None - first_steady_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(0) - self.num_airplanes = len(first_steady_problem.airplanes) + first_steady_problem: problems.SteadyProblem = ( + self.coupled_unsteady_problem.get_steady_problem(0) + ) + + self.current_airplanes: list[geometry.airplane.Airplane] = [] + self.current_operating_point: operating_point.OperatingPoint = ( + first_steady_problem.operating_point + ) + self.num_airplanes: int = len(first_steady_problem.airplanes) + num_panels = 0 - airplane: geometry.airplane.Airplane for airplane in first_steady_problem.airplanes: num_panels += airplane.num_panels - self.num_panels = num_panels + self.num_panels: int = num_panels # Initialize attributes to hold aerodynamic data that pertain to the simulation. - self._currentVInf_GP1__E = None - self._currentStackFreestreamWingInfluences__E = None - self._currentGridWingWingInfluences__E = None - self._currentStackWakeWingInfluences__E = None - self._current_bound_vortex_strengths = None - self._last_bound_vortex_strengths = None + self._currentVInf_GP1__E: np.ndarray = ( + first_steady_problem.operating_point.vInf_GP1__E + ) + self._currentStackFreestreamWingInfluences__E: np.ndarray = np.empty( + 0, dtype=float + ) + self._currentGridWingWingInfluences__E: np.ndarray = np.empty(0, dtype=float) + self._currentStackWakeWingInfluences__E: np.ndarray = np.empty(0, dtype=float) + self._current_bound_vortex_strengths: np.ndarray = np.empty(0, dtype=float) + self._last_bound_vortex_strengths: np.ndarray = np.empty(0, dtype=float) # Initialize attributes to hold geometric data that pertain to this # UnsteadyProblem. - self.panels = None - self.stackUnitNormals_GP1 = None - self.panel_areas = None + self.panels: np.ndarray = np.empty(0, dtype=object) + self.stackUnitNormals_GP1: np.ndarray = np.empty(0, dtype=float) + self.panel_areas: np.ndarray = np.empty(0, dtype=float) # The current and last time step's collocation panel points (in the first # Airplane's geometry axes, relative to the first Airplane's CG). - self.stackCpp_GP1_CgP1 = None - self._stackLastCpp_GP1_CgP1 = None + self.stackCpp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._stackLastCpp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - # The current and last time step's back-right, front-right, front-left, - # and back-left bound RingVortex points (in the first Airplane's geometry + # The current and last time step's back right, front right, front left, + # and back left bound RingVortex points (in the first Airplane's geometry # axes, relative to the first Airplane's CG). - self.stackBrbrvp_GP1_CgP1 = None - self.stackFrbrvp_GP1_CgP1 = None - self.stackFlbrvp_GP1_CgP1 = None - self.stackBlbrvp_GP1_CgP1 = None - self._lastStackBrbrvp_GP1_CgP1 = None - self._lastStackFrbrvp_GP1_CgP1 = None - self._lastStackFlbrvp_GP1_CgP1 = None - self._lastStackBlbrvp_GP1_CgP1 = None + self.stackBrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.stackFrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.stackFlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.stackBlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackBrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackFrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackFlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackBlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) # The current and last time step's center bound LineVortex points for the # right, front, left, and back legs (in the first Airplane's geometry axes, # relative to the first Airplane's CG). - self.stackCblvpr_GP1_CgP1 = None - self.stackCblvpf_GP1_CgP1 = None - self.stackCblvpl_GP1_CgP1 = None - self.stackCblvpb_GP1_CgP1 = None - self._lastStackCblvpr_GP1_CgP1 = None - self._lastStackCblvpf_GP1_CgP1 = None - self._lastStackCblvpl_GP1_CgP1 = None - self._lastStackCblvpb_GP1_CgP1 = None + self.stackCblvpr_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.stackCblvpf_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.stackCblvpl_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.stackCblvpb_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackCblvpr_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackCblvpf_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackCblvpl_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._lastStackCblvpb_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) # Right, front, left, and back bound RingVortex vectors (in the first # Airplane's geometry axes). - self.stackRbrv_GP1 = None - self.stackFbrv_GP1 = None - self.stackLbrv_GP1 = None - self.stackBbrv_GP1 = None + self.stackRbrv_GP1: np.ndarray = np.empty(0, dtype=float) + self.stackFbrv_GP1: np.ndarray = np.empty(0, dtype=float) + self.stackLbrv_GP1: np.ndarray = np.empty(0, dtype=float) + self.stackBbrv_GP1: np.ndarray = np.empty(0, dtype=float) # Initialize variables to hold aerodynamic data that pertains details about # each Panel's location on its Wing. - self.panel_is_trailing_edge = None - self.panel_is_leading_edge = None - self.panel_is_left_edge = None - self.panel_is_right_edge = None + self.panel_is_trailing_edge: np.ndarray = np.empty(0, dtype=bool) + self.panel_is_leading_edge: np.ndarray = np.empty(0, dtype=bool) + self.panel_is_left_edge: np.ndarray = np.empty(0, dtype=bool) + self.panel_is_right_edge: np.ndarray = np.empty(0, dtype=bool) # Initialize variables to hold aerodynamic data that pertains to the wake at # the current time step. - self._current_wake_vortex_strengths = None - self._current_wake_vortex_ages = None + self._current_wake_vortex_strengths: np.ndarray = np.empty(0, dtype=float) + self._current_wake_vortex_ages: np.ndarray = np.empty(0, dtype=float) - # The current time step's back-right, front-right, front-left, and back-left + # The current time step's back right, front right, front left, and back left # wake RingVortex points (in the first Airplane's geometry axes, relative to # the first Airplane's CG). - self._currentStackBrwrvp_GP1_CgP1 = None - self._currentStackFrwrvp_GP1_CgP1 = None - self._currentStackFlwrvp_GP1_CgP1 = None - self._currentStackBlwrvp_GP1_CgP1 = None + self._currentStackBrwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._currentStackFrwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._currentStackFlwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self._currentStackBlwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) # Initialize lists to store aerodynamic data about the wake at each time # step. These attributes are used by the output module to animate the wake. - self.list_num_wake_vortices = [] + self.list_num_wake_vortices: list[int] = [] # TODO: Determine if these private attributes are needed and if not # delete them. - self._list_wake_vortex_strengths = [] - self._list_wake_vortex_ages = [] - self.listStackBrwrvp_GP1_CgP1 = [] - self.listStackFrwrvp_GP1_CgP1 = [] - self.listStackFlwrvp_GP1_CgP1 = [] - self.listStackBlwrvp_GP1_CgP1 = [] + self._list_wake_vortex_strengths: list[np.ndarray] = [] + self._list_wake_vortex_ages: list[np.ndarray] = [] + self.listStackBrwrvp_GP1_CgP1: list[np.ndarray] = [] + self.listStackFrwrvp_GP1_CgP1: list[np.ndarray] = [] + self.listStackFlwrvp_GP1_CgP1: list[np.ndarray] = [] + self.listStackBlwrvp_GP1_CgP1: list[np.ndarray] = [] - self.stackSeedPoints_GP1_CgP1 = None - self.gridStreamlinePoints_GP1_CgP1 = None + self.stackSeedPoints_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.gridStreamlinePoints_GP1_CgP1: np.ndarray = np.empty((0, 3), dtype=float) + + self.ran = False def run( self, - logging_level="Warning", - prescribed_wake=True, - calculate_streamlines=True, - ): - """This method runs the solver on the UnsteadyProblem. - - :param logging_level: str, optional - - This parameter determines the detail of information that the solver's - logger will output while running. The options are, in order of detail and - severity, "Debug", "Info", "Warning", "Error", "Critical". The default - value is "Warning". - - :param prescribed_wake: boolLike, optional - - This parameter determines if the solver uses a prescribed wake model. If - False it will use a free-wake, which may be more accurate but will make - the solver significantly slower. The default is True. It can be a boolean - or a NumPy boolean and will be converted internally to a boolean. - - :param calculate_streamlines: boolLike, optional - - This parameter determines if the solver calculates streamlines emanating - from the back of the wing after running the solver. It can be a boolean - or a NumPy boolean and will be converted internally to a boolean. - + prescribed_wake: bool | np.bool_ = True, + calculate_streamlines: bool | np.bool_ = True, + show_progress: bool | np.bool_ = True, + ) -> None: + """Runs the solver on the UnsteadyProblem. + + :param prescribed_wake: Set this to True to solve using a prescribed wake model. + Set to False to use a free-wake, which may be more accurate but will make + the fun method significantly slower. Can be a bool or a numpy bool and will + be converted internally to a bool. The default is True. + :param calculate_streamlines: Set this to True to calculate streamlines + emanating from the back of the wing after running the solver. It can be a + bool or a numpy bool and will be converted internally to a bool. The default + is True. + :param show_progress: Set this to True to show the TQDM progress bar. For + showing the progress bar and displaying log statements, set up logging using + the setup_logging function. It can be a bool or a numpy bool and will be + converted internally to a bool. The default is True. :return: None """ - logging_level = _parameter_validation.string_return_string( - logging_level, "logging_level" - ) - logging_level_value = _functions.convert_logging_level_name_to_value( - logging_level - ) - logging.basicConfig(level=logging_level_value) - - prescribed_wake = _parameter_validation.boolLike_return_bool( + self._prescribed_wake = _parameter_validation.boolLike_return_bool( prescribed_wake, "prescribed_wake" ) calculate_streamlines = _parameter_validation.boolLike_return_bool( calculate_streamlines, "calculate_streamlines" ) - # Initialize list that will hold the approximate, relative times. This has - # one more element than the number of time steps, because I will also use the - # progress bar during the simulation initialization. - approx_times = np.zeros(self.num_steps + 1, dtype=float) + show_progress = _parameter_validation.boolLike_return_bool( + show_progress, "show_progress" + ) + # Loop through this time step's Airplanes to create a list of their Wings. # Here we calculate all of our values from our first ariplane to start our main run loop - this_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(0) + this_problem: problems.SteadyProblem = ( + self.coupled_unsteady_problem.get_steady_problem(0) + ) these_airplanes = this_problem.airplanes - - # Loop through this time step's Airplanes to create a list of their Wings. - # Additionally, to get the total number of wing panels. - these_wings = [] num_wing_panels = 0 - airplane: geometry.airplane.Airplane + these_wings: list[list[geometry.wing.Wing]] = [] for airplane in these_airplanes: these_wings.append(airplane.wings) num_wing_panels += airplane.num_panels @@ -224,9 +224,11 @@ def run( # Iterate through the Wings to get the total number of spanwise Panels. this_num_spanwise_panels = 0 for this_wing_set in these_wings: - this_wing: geometry.wing.Wing for this_wing in this_wing_set: - this_num_spanwise_panels += this_wing.num_spanwise_panels + _this_wing_num_spanwise_panels = this_wing.num_spanwise_panels + assert _this_wing_num_spanwise_panels is not None + + this_num_spanwise_panels += _this_wing_num_spanwise_panels for step in range(self.num_steps): # The number of wake RingVortices is the time step number multiplied by @@ -253,6 +255,7 @@ def run( thisStackBlwrvp_GP1_CgP1 = np.zeros( (this_num_wake_ring_vortices, 3), dtype=float ) + # Append this time step's ndarrays to the lists of ndarrays. self.list_num_wake_vortices.append(this_num_wake_ring_vortices) self._list_wake_vortex_strengths.append(this_wake_ring_vortex_strengths) @@ -262,56 +265,69 @@ def run( self.listStackFlwrvp_GP1_CgP1.append(thisStackFlwrvp_GP1_CgP1) self.listStackBlwrvp_GP1_CgP1.append(thisStackBlwrvp_GP1_CgP1) - if step != 0: - # The following loop attempts to predict how much time each time step will - # take, relative to the other time steps. This data will be used to generate - # estimates of how much longer a simulation will take, and create a smoothly - # advancing progress bar. - num_ring_vortices = num_wing_panels + this_num_wake_ring_vortices - # The following constant multipliers were determined empirically. Thus - # far, they seem to provide for adequately smooth progress bar updating. - if step == 1: - approx_times[step] = num_ring_vortices * 70 - elif step == 2: - approx_times[step] = num_ring_vortices * 30 - else: - approx_times[step] = num_ring_vortices * 3 + # The following loop attempts to predict how much time each time step will + # take, relative to the other time steps. This data will be used to generate + # estimates of how much longer a simulation will take, and create a smoothly + # advancing progress bar. + + # Initialize list that will hold the approximate, relative times. This has + # one more element than the number of time steps, because I will also use the + # progress bar during the simulation initialization. + approx_times = np.zeros(self.num_steps + 1, dtype=float) + if step != 0: + # Calculate the total number of RingVortices analyzed during this step. + num_wing_ring_vortices = num_wing_panels + num_wake_ring_vortices = self.list_num_wake_vortices[step] + num_ring_vortices = num_wing_ring_vortices + num_wake_ring_vortices + + # The following constant multipliers were determined empirically. Thus + # far, they seem to provide for adequately smooth progress bar updating. + if step == 1: + approx_times[step] = num_ring_vortices * 70 + elif step == 2: + approx_times[step] = num_ring_vortices * 30 + else: + approx_times[step] = num_ring_vortices * 3 approx_partial_time = np.sum(approx_times) approx_times[0] = round(approx_partial_time / 100) approx_total_time = np.sum(approx_times) - # Unless the logging level is at or above Warning, run the simulation with a - # progress bar. with tqdm( total=approx_total_time, unit="", unit_scale=True, ncols=100, desc="Simulating", - disable=logging_level_value != logging.WARNING, + disable=not show_progress, bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " "Remaining: {remaining}", ) as bar: + # Initialize all the Airplanes' bound RingVortices. + _logger.debug("Initializing all Airplanes' bound RingVortices.") + # Update the progress bar based on the initialization step's predicted # approximate, relative computing time. bar.update(n=float(approx_times[0])) # Iterate through the time steps. for step in range(self.num_steps): + # Save attributes to hold the current step, Airplanes, # and OperatingPoint, and freestream velocity (in the first # Airplane's geometry axes, observed from the Earth frame). self._current_step = step - current_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(self._current_step) - # Initialize this Airplanes' bound RingVortices. + current_problem: problems.SteadyProblem = ( + self.coupled_unsteady_problem.get_steady_problem(self._current_step) + ) + + # Initialize all the current step's bound RingVortices. + _logger.debug(f"Initializing step {step}'s RingVortices") self._initialize_panel_vortex(current_problem, step) self.current_airplanes = current_problem.airplanes - self.current_operating_point: operating_point.OperatingPoint = ( - current_problem.operating_point - ) + self.current_operating_point = current_problem.operating_point self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E - logging.info( + _logger.debug( "Beginning time step " + str(self._current_step) + " out of " @@ -411,112 +427,167 @@ def run( self.stackSeedPoints_GP1_CgP1 = np.zeros((0, 3), dtype=float) # Collapse the geometry matrices into 1D ndarrays of attributes. - logging.info("Collapsing the geometry.") + _logger.debug("Collapsing the geometry.") self._collapse_geometry() - # Find the matrix of Wing-Wing influence coefficients associated with + # Find the matrix of Wing Wing influence coefficients associated with # the Airplanes' geometries at this time step. - logging.info("Calculating the Wing-Wing influences.") + _logger.debug("Calculating the Wing Wing influences.") self._calculate_wing_wing_influences() # Find the normal velocity (in the first Airplane's geometry axes, # observed from the Earth frame) at every collocation point due # solely to the freestream. - logging.info("Calculating the freestream-Wing influences.") + _logger.debug("Calculating the freestream Wing influences.") self._calculate_freestream_wing_influences() # Find the normal velocity (in the first Airplane's geometry axes, # observed from the Earth frame) at every collocation point due # solely to the wake RingVortices. - logging.info("Calculating the wake-Wing influences.") + _logger.debug("Calculating the wake Wing influences.") self._calculate_wake_wing_influences() # Solve for each bound RingVortex's strength. - logging.info("Calculating bound RingVortex strengths.") + _logger.debug("Calculating bound RingVortex strengths.") self._calculate_vortex_strengths() # Solve for the forces (in the first Airplane's geometry axes) and # moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) on each Panel. if self._current_step >= self.first_results_step: - logging.info("Calculating forces and moments.") + _logger.debug("Calculating forces and moments.") self._calculate_loads() + # Shed RingVortices into the wake. # Check if the current time step is not the last step. if self._current_step < self.num_steps - 1: - self.coupled_unsteady_problem.initialize_next_problem( - self + self.coupled_unsteady_problem.initialize_next_problem(self) + self._initialize_panel_vortex( + self.coupled_unsteady_problem.get_steady_problem(step + 1), + step + 1, ) - self._initialize_panel_vortex(self.coupled_unsteady_problem.get_steady_problem(step + 1), step + 1) # Shed RingVortices into the wake. - logging.info("Shedding RingVortices into the wake.") - self._populate_next_airplanes_wake(prescribed_wake=prescribed_wake) + _logger.debug("Shedding RingVortices into the wake.") + self._populate_next_airplanes_wake() # Update the progress bar based on this time step's predicted # approximate, relative computing time. self.steady_problems.append(self.coupled_unsteady_problem.get_steady_problem(step)) bar.update(n=float(approx_times[step + 1])) - logging.info("Calculating averaged or final forces and moments.") + _logger.debug("Calculating averaged or final forces and moments.") self._finalize_loads() # Solve for the location of the streamlines coming off the Wings' trailing # edges, if requested. if calculate_streamlines: - logging.info("Calculating streamlines.") + _logger.debug("Calculating streamlines.") _functions.calculate_streamlines(self) - def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int): - """This method calculates the locations of the Airplanes' bound RingVortices' - points, and then initializes the bound RingVortices. + # Mark that the solver has run. + self.ran = True - Every Panel has a RingVortex, which is a quadrangle whose front leg is a - LineVortex at the Panel's quarter chord. The left and right legs are - LineVortices running along the Panel's left and right legs. If the Panel is - not along the trailing edge, they extend backwards and meet the back - LineVortex, at the rear Panel's quarter chord. Otherwise, they extend - backwards and meet the back LineVortex one quarter chord back from the - Panel's back leg. + def initialize_step_geometry(self, step: int) -> None: + """Initializes geometry for a specific step without solving. + + Sets up bound RingVortices and wake RingVortices for the specified time step, + but does not solve the aerodynamic system. Use this for geometry only analysis + like delta_time optimization. + + This method must be called sequentially for each step starting from 0, as wake + vortices at step N depend on the geometry from step N - 1. + :param step: The time step to initialize geometry for. It is zero indexed. It + must be a non negative int and be less than the total number of steps. :return: None """ + step = _parameter_validation.int_in_range_return_int( + step, "step", 0, True, self.num_steps, False + ) + + # Initialize bound RingVortices for all steps on the first call. + if step == 0: + self._initialize_panel_vortex(self.coupled_unsteady_problem.get_steady_problem(0), 0) - # Find the freestream velocity (in the first Airplane's geometry axes, - # observed from the Earth frame) at this time step. - this_operating_point: operating_point.OperatingPoint = ( - steady_problem.operating_point + # Set the current step and related state. + self._current_step = step + current_problem: problems.SteadyProblem = ( + self.coupled_unsteady_problem.get_steady_problem(step) ) + self.current_airplanes = current_problem.airplanes + self.current_operating_point = current_problem.operating_point + self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E + + # Populate the wake for the next step (if not the last step). + if step < self.num_steps - 1: + self._populate_next_airplanes_wake_vortex_points() + self._populate_next_airplanes_wake_vortices() + + def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> None: + """Calculates the locations of the bound RingVortex vertices, and then + initializes them. + + Every Panel has a RingVortex, which is a quadrangle whose front leg is a + LineVortex at the Panel's quarter chord. The left and right legs are + LineVortices running along the Panel's left and right legs. If the Panel is not + along the trailing edge, they extend backwards and meet the back LineVortex, at + the rear Panel's quarter chord. Otherwise, they extend backwards and meet the + back LineVortex one quarter chord back from the Panel's back leg. + + :return: None + """ + this_operating_point = steady_problem.operating_point vInf_GP1__E = this_operating_point.vInf_GP1__E # Iterate through this SteadyProblem's Airplanes' Wings. - airplane: geometry.airplane.Airplane for airplane_id, airplane in enumerate(steady_problem.airplanes): - wing: geometry.wing.Wing for wing_id, wing in enumerate(airplane.wings): + _num_spanwise_panels = wing.num_spanwise_panels + assert _num_spanwise_panels is not None # Iterate through the Wing's chordwise and spanwise positions. for chordwise_position in range(wing.num_chordwise_panels): - for spanwise_position in range(wing.num_spanwise_panels): + for spanwise_position in range(_num_spanwise_panels): + _panels = wing.panels + assert _panels is not None + # Pull the Panel out of the Wing's 2D ndarray of Panels. - panel: _panel.Panel = wing.panels[ + panel: _panel.Panel = _panels[ chordwise_position, spanwise_position ] - # Find the location of this Panel's front-left and - # front-right RingVortex points (in the first Airplane's + _Flbvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1 + assert _Flbvp_GP1_CgP1 is not None + + _Frbvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1 + assert _Frbvp_GP1_CgP1 is not None + + # Find the location of this Panel's front left and + # front right RingVortex points (in the first Airplane's # geometry axes, relative to the first Airplane's CG). - Flrvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1 - Frrvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1 + Flrvp_GP1_CgP1 = _Flbvp_GP1_CgP1 + Frrvp_GP1_CgP1 = _Frbvp_GP1_CgP1 - # Define the location of the back-left and back-right + # Define the location of the back left and back right # RingVortex points based on whether the Panel is along # the trailing edge or not. if not panel.is_trailing_edge: - next_chordwise_panel = wing.panels[ + next_chordwise_panel: _panel.Panel = _panels[ chordwise_position + 1, spanwise_position ] - Blrvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1 - Brrvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1 + + _nextFlbvp_GP1_CgP1 = ( + next_chordwise_panel.Flbvp_GP1_CgP1 + ) + assert _nextFlbvp_GP1_CgP1 is not None + + _nextFrbvp_GP1_CgP1 = ( + next_chordwise_panel.Frbvp_GP1_CgP1 + ) + assert _nextFrbvp_GP1_CgP1 is not None + + Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1 + Brrvp_GP1_CgP1 = _nextFrbvp_GP1_CgP1 else: # As these vertices are directly behind the trailing # edge, they are spaced back from their Panel's @@ -527,12 +598,18 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int): # forces in flapping flight with the Unsteady Vortex # Lattice Method" by Thomas Lambert. if steady_problem_id == 0: + _Blpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 + assert _Blpp_GP1_CgP1 is not None + + _Brpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 + assert _Brpp_GP1_CgP1 is not None + Blrvp_GP1_CgP1 = ( - panel.Blpp_GP1_CgP1 + _Blpp_GP1_CgP1 + vInf_GP1__E * self.delta_time * 0.25 ) Brrvp_GP1_CgP1 = ( - panel.Brpp_GP1_CgP1 + _Brpp_GP1_CgP1 + vInf_GP1__E * self.delta_time * 0.25 ) else: @@ -543,12 +620,19 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int): airplane_id ] last_wing = last_airplane.wings[wing_id] - last_panel = last_wing.panels[ + + _last_panels = last_wing.panels + assert _last_panels is not None + + last_panel: _panel.Panel = _last_panels[ chordwise_position, spanwise_position ] - thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 - lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1 + _thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 + assert _thisBlpp_GP1_CgP1 is not None + + _lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1 + assert _lastBlpp_GP1_CgP1 is not None # We subtract (thisBlpp_GP1_CgP1 - # lastBlpp_GP1_CgP1) / self.delta_time from @@ -558,26 +642,29 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int): # This is the vector pointing opposite the # velocity from motion. Blrvp_GP1_CgP1 = ( - thisBlpp_GP1_CgP1 + _thisBlpp_GP1_CgP1 + ( vInf_GP1__E - - (thisBlpp_GP1_CgP1 - lastBlpp_GP1_CgP1) + - (_thisBlpp_GP1_CgP1 - _lastBlpp_GP1_CgP1) / self.delta_time ) * self.delta_time * 0.25 ) - thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 - lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1 + _thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 + assert _thisBrpp_GP1_CgP1 is not None + + _lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1 + assert _lastBrpp_GP1_CgP1 is not None # The comment from above about apparent fluid # velocity due to motion applies here as well. Brrvp_GP1_CgP1 = ( - thisBrpp_GP1_CgP1 + _thisBrpp_GP1_CgP1 + ( vInf_GP1__E - - (thisBrpp_GP1_CgP1 - lastBrpp_GP1_CgP1) + - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1) / self.delta_time ) * self.delta_time @@ -590,12 +677,13 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int): Frrvp_GP1_CgP1=Frrvp_GP1_CgP1, Blrvp_GP1_CgP1=Blrvp_GP1_CgP1, Brrvp_GP1_CgP1=Brrvp_GP1_CgP1, - strength=None, + strength=1.0, ) - def _collapse_geometry(self): - """This method converts attributes of the UnsteadyProblem's geometry into 1D - ndarrays. This facilitates vectorization, which speeds up the solver. + def _collapse_geometry(self) -> None: + """Converts attributes of the UnsteadyProblem's geometry into 1D ndarrays. + + This facilitates vectorization, which speeds up the solver. :return: None """ @@ -605,15 +693,18 @@ def _collapse_geometry(self): global_wake_ring_vortex_position = 0 # Iterate through the current time step's Airplanes' Wings. - airplane: geometry.airplane.Airplane for airplane in self.current_airplanes: - wing: geometry.wing.Wing for wing in airplane.wings: + _panels = wing.panels + assert _panels is not None + + _wake_ring_vortices = wing.wake_ring_vortices + assert _wake_ring_vortices is not None # Convert this Wing's 2D ndarray of Panels and wake RingVortices into # 1D ndarrays. - panels = np.ravel(wing.panels) - wake_ring_vortices = np.ravel(wing.wake_ring_vortices) + panels = np.ravel(_panels) + wake_ring_vortices = np.ravel(_wake_ring_vortices) # Iterate through the 1D ndarray of this Wing's Panels. panel: _panel.Panel @@ -661,67 +752,69 @@ def _collapse_geometry(self): # Reset the global Panel position variable. global_panel_position = 0 - last_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem( + last_problem = self.coupled_unsteady_problem.get_steady_problem( self._current_step - 1 ) last_airplanes = last_problem.airplanes # Iterate through the last time step's Airplanes' Wings. - last_airplane: geometry.airplane.Airplane for last_airplane in last_airplanes: - wing: geometry.wing.Wing - for wing in last_airplane.wings: + for last_wing in last_airplane.wings: + _last_panels = last_wing.panels + assert _last_panels is not None # Convert this Wing's 2D ndarray of Panels into a 1D ndarray. - panels = np.ravel(wing.panels) + last_panels = np.ravel(_last_panels) # Iterate through the 1D ndarray of this Wing's Panels. - panel: _panel.Panel - for panel in panels: + last_panel: _panel.Panel + for last_panel in last_panels: # Update the solver's list of attributes with this Panel's # attributes. self._stackLastCpp_GP1_CgP1[global_panel_position, :] = ( - panel.Cpp_GP1_CgP1 + last_panel.Cpp_GP1_CgP1 ) - this_ring_vortex: _aerodynamics.RingVortex = panel.ring_vortex + last_ring_vortex = last_panel.ring_vortex + assert last_ring_vortex is not None + self._last_bound_vortex_strengths[global_panel_position] = ( - this_ring_vortex.strength + last_ring_vortex.strength ) # TODO: Test if we can replace the calls to LineVortex # attributes with calls to RingVortex attributes. self._lastStackBrbrvp_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.right_leg.Slvp_GP1_CgP1 + last_ring_vortex.right_leg.Slvp_GP1_CgP1 ) self._lastStackFrbrvp_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.right_leg.Elvp_GP1_CgP1 + last_ring_vortex.right_leg.Elvp_GP1_CgP1 ) self._lastStackFlbrvp_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.left_leg.Slvp_GP1_CgP1 + last_ring_vortex.left_leg.Slvp_GP1_CgP1 ) self._lastStackBlbrvp_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.left_leg.Elvp_GP1_CgP1 + last_ring_vortex.left_leg.Elvp_GP1_CgP1 ) self._lastStackCblvpr_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.right_leg.Clvp_GP1_CgP1 + last_ring_vortex.right_leg.Clvp_GP1_CgP1 ) self._lastStackCblvpf_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.front_leg.Clvp_GP1_CgP1 + last_ring_vortex.front_leg.Clvp_GP1_CgP1 ) self._lastStackCblvpl_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.left_leg.Clvp_GP1_CgP1 + last_ring_vortex.left_leg.Clvp_GP1_CgP1 ) self._lastStackCblvpb_GP1_CgP1[global_panel_position, :] = ( - this_ring_vortex.back_leg.Clvp_GP1_CgP1 + last_ring_vortex.back_leg.Clvp_GP1_CgP1 ) # Increment the global Panel position variable. global_panel_position += 1 - def _calculate_wing_wing_influences(self): - """This method finds the 2d ndarray of Wing-Wing influence coefficients ( - observed from the Earth frame). + def _calculate_wing_wing_influences(self) -> None: + """Finds the current time step's SteadyProblem's 2D ndarray of Wing Wing + influence coefficients (observed from the Earth frame). :return: None """ @@ -744,7 +837,7 @@ def _calculate_wing_wing_influences(self): # Take the batch dot product of the normalized induced velocities (in the # first Airplane's geometry axes, observed from the Earth frame) with each # Panel's unit normal direction (in the first Airplane's geometry axes). This - # is now the 2D ndarray of Wing-Wing influence coefficients (observed from + # is now the 2D ndarray of Wing Wing influence coefficients (observed from # the Earth frame). self._currentGridWingWingInfluences__E = np.einsum( "...k,...k->...", @@ -752,16 +845,18 @@ def _calculate_wing_wing_influences(self): np.expand_dims(self.stackUnitNormals_GP1, axis=1), ) - def _calculate_freestream_wing_influences(self): - """This method finds the 1D ndarray of freestream-Wing influence coefficients - (observed from the Earth frame). + def _calculate_freestream_wing_influences(self) -> None: + """Finds the 1D ndarray of freestream Wing influence coefficients (observed from + the Earth frame) at the current time step. - Note: This method also includes the influence coefficients due to motion - defined in Movement (observed from the Earth frame) at every collocation point. + **Notes:** + + This method also includes the influence coefficients due to motion defined in + Movement (observed from the Earth frame) at every collocation point. :return: None """ - # Find the normal components of the freestream-only-Wing influence + # Find the normal components of the freestream only Wing influence # coefficients (observed from the Earth frame) at each Panel's collocation # point by taking a batch dot product. currentStackFreestreamOnlyWingInfluences__E = np.einsum( @@ -785,7 +880,7 @@ def _calculate_freestream_wing_influences(self): currentStackMovementV_GP1_E, ) - # Calculate the total current freestream-Wing influence coefficients by + # Calculate the total current freestream Wing influence coefficients by # summing the freestream-only influence coefficients and the motion influence # coefficients (all observed from the Earth frame). self._currentStackFreestreamWingInfluences__E = ( @@ -793,14 +888,15 @@ def _calculate_freestream_wing_influences(self): + currentStackMovementInfluences__E ) - def _calculate_wake_wing_influences(self): - """This method finds the 1D ndarray of the wake-Wing influence coefficients ( - observed from the Earth frame) associated with the UnsteadyProblem at the - current time step. + def _calculate_wake_wing_influences(self) -> None: + """Finds the 1D ndarray of the wake Wing influence coefficients (observed from + the Earth frame) at the current time step. + + **Notes:** - Note: If the current time step is the first time step, no wake has been shed, - so this method will return zero for all the wake-Wing influence coefficients - (observed from the Earth frame). + If the current time step is the first time step, no wake has been shed, so this + method will return zero for all the wake Wing influence coefficients (observed + from the Earth frame). :return: None """ @@ -821,7 +917,7 @@ def _calculate_wake_wing_influences(self): ) ) - # Get the current wake-Wing influence coefficients (observed from the + # Get the current wake Wing influence coefficients (observed from the # Earth frame) by taking a batch dot product with each Panel's normal # vector (in the first Airplane's geometry axes). self._currentStackWakeWingInfluences__E = np.einsum( @@ -836,8 +932,8 @@ def _calculate_wake_wing_influences(self): self.num_panels, dtype=float ) - def _calculate_vortex_strengths(self): - """Solve for the strength of each Panel's bound RingVortex. + def _calculate_vortex_strengths(self) -> None: + """Solves for the strength of each Panel's bound RingVortex. :return: None """ @@ -848,40 +944,45 @@ def _calculate_vortex_strengths(self): ) # Update the bound RingVortices' strengths. - for panel_num in range(self.panels.size): - panel: _panel.Panel = self.panels[panel_num] - this_ring_vortex: _aerodynamics.RingVortex = panel.ring_vortex + _panels = self.panels + assert _panels is not None + for panel_num in range(_panels.size): + panel: _panel.Panel = _panels[panel_num] + + this_ring_vortex = panel.ring_vortex + assert this_ring_vortex is not None this_ring_vortex.update_strength( self._current_bound_vortex_strengths[panel_num] ) - def calculate_solution_velocity(self, stackP_GP1_CgP1): - """This function takes in a group of points (in the first Airplane's geometry - axes, relative to the first Airplane's CG). At every point, it finds the - fluid velocity (in the first Airplane's geometry axes, observed from the - Earth frame) at that point due to the freestream velocity and the induced - velocity from every RingVortex. - - Note: This method assumes that the correct strengths for the RingVortices and - HorseshoeVortices have already been calculated and set. This method also does - not include the velocity due to the Movement's motion at any of the points - provided, as it has no way of knowing if any of the points lie on panels. - - :param stackP_GP1_CgP1: (N,3) array-like of numbers - - Positions of the evaluation points (in the first Airplane's geometry - axes, relative to the first Airplane's CG). Can be any array-like object - (tuple, list, or ndarray) with size (N, 3) that has numeric elements (int - or float). Values are converted to floats internally. The units are in - meters. - - :return: (N,3) ndarray of floats - - The velocity (in the first Airplane's geometry axes, observed from the - Earth frame) at every evaluation point due to the summed effects of the - freestream velocity and the induced velocity from every RingVortex. The - units are in meters per second. + def calculate_solution_velocity( + self, stackP_GP1_CgP1: np.ndarray | Sequence[Sequence[float | int]] + ) -> np.ndarray: + """Finds the fluid velocity (in the first Airplane's geometry axes, observed + from the Earth frame) at one or more points (in the first Airplane's geometry + axes, relative to the first Airplane's CG) due to the freestream velocity and + the induced velocity from every RingVortex. + + **Notes:** + + This method assumes that the correct strengths for the RingVortices have already + been calculated and set. + + This method also does not include the velocity due to the Movement's motion at + any of the points provided, as it has no way of knowing if any of the points lie + on panels. + + :param stackP_GP1_CgP1: An array-like object of numbers (int or float) with + shape (N,3) representing the positions of the evaluation points (in the + first Airplane's geometry axes, relative to the first Airplane's CG). Can be + a tuple, list, or ndarray. Values are converted to floats internally. The + units are in meters. + :return: A (N,3) ndarray of floats representing the velocity (in the first + Airplane's geometry axes, observed from the Earth frame) at each evaluation + point due to the summed effects of the freestream velocity and the induced + velocity from every RingVortex and HorseshoeVortex. The units are in meters + per second. """ stackP_GP1_CgP1 = ( _parameter_validation.arrayLike_of_threeD_number_vectorLikes_return_float( @@ -912,24 +1013,31 @@ def calculate_solution_velocity(self, stackP_GP1_CgP1): nu=self.current_operating_point.nu, ) - return ( + return cast( + np.ndarray, stackBoundRingVInd_GP1_E + stackWakeRingVInd_GP1_E - + self._currentVInf_GP1__E + + self._currentVInf_GP1__E, ) - def _calculate_loads(self): - """Calculate the forces (in the first Airplane's geometry axes) and moments ( - in the first Airplane's geometry axes, relative to the first Airplane's CG) - on every Panel. + def _calculate_loads(self) -> None: + """Calculates the forces (in the first Airplane's geometry axes) and moments (in + the first Airplane's geometry axes, relative to the first Airplane's CG) on + every Panel at the current time step. - Citation: This method uses logic described on pages 9-11 of "Modeling of - aerodynamic forces in flapping flight with the Unsteady Vortex Lattice - Method" by Thomas Lambert. + **Notes:** - Note: This method assumes that the correct strengths for the RingVortices and + This method assumes that the correct strengths for the RingVortices and HorseshoeVortices have already been calculated and set. + This method used to accidentally double-count the load on each Panel due to the + left and right LineVortex legs. Additionally, it didn't include contributions to + the load on each Panel from their back LineVortex legs. Thankfully, these issues + only introduced small errors in most typical simulations. They have both now + been fixed by (1) using a 1/2 factor for each "effective" vortex strength shared + between two Panels, and (2) including the effects each Panel's back LineVortex + with its own effective strength. + :return: None """ # Initialize a variable to hold the global Panel position as we iterate @@ -938,148 +1046,202 @@ def _calculate_loads(self): # Initialize three 1D ndarrays to hold the effective strength of the Panels' # RingVortices' LineVortices. - effective_right_vortex_line_strengths = np.zeros(self.num_panels, dtype=float) - effective_front_vortex_line_strengths = np.zeros(self.num_panels, dtype=float) - effective_left_vortex_line_strengths = np.zeros(self.num_panels, dtype=float) + effective_right_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) + effective_front_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) + effective_left_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) + effective_back_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) # Iterate through the Airplanes' Wings. - airplane: geometry.airplane.Airplane for airplane in self.current_airplanes: - wing: geometry.wing.Wing for wing in airplane.wings: + _panels = wing.panels + assert _panels is not None + # Convert this Wing's 2D ndarray of Panels into a 1D ndarray. - panels = np.ravel(wing.panels) + panels = np.ravel(_panels) # Iterate through this Wing's 1D ndarray of Panels. panel: _panel.Panel for panel in panels: + _local_chordwise_position = panel.local_chordwise_position + assert _local_chordwise_position is not None - # FIXME: After rereading pages 9-10 of "Modeling of aerodynamic - # forces in flapping flight with the Unsteady Vortex Lattice - # Method" by Thomas Lambert, I think our implementation here is - # critically wrong. Consider we have a wing with a (1,2) ndarray - # of Panels. Let's call them Panel A and Panel B. With our - # current method, we calculate the force on Panel A's right - # LineVortex as though it had a strength of Gamma_a - Gamma_b, - # and the force on Panel B's left LineVortex as Gamma_b - - # Gamma_a. I think these forces will precisely cancel-out! + _local_spanwise_position = panel.local_spanwise_position + assert _local_spanwise_position is not None if panel.is_right_edge: # Set the effective right LineVortex strength to this Panel's # RingVortex's strength. - effective_right_vortex_line_strengths[global_panel_position] = ( + effective_right_line_vortex_strengths[global_panel_position] = ( self._current_bound_vortex_strengths[global_panel_position] ) else: - panel_to_right: _panel.Panel = wing.panels[ - panel.local_chordwise_position, - panel.local_spanwise_position + 1, + panel_to_right: _panel.Panel = _panels[ + _local_chordwise_position, + _local_spanwise_position + 1, ] - ring_vortex_to_right: _aerodynamics.RingVortex = ( - panel_to_right.ring_vortex - ) - # Set the effective right LineVortex strength to the + ring_vortex_to_right = panel_to_right.ring_vortex + assert ring_vortex_to_right is not None + + # Set the effective right LineVortex strength to 1/2 the # difference between this Panel's RingVortex's strength, # and the RingVortex's strength of the Panel to the right. - effective_right_vortex_line_strengths[global_panel_position] = ( + effective_right_line_vortex_strengths[global_panel_position] = ( self._current_bound_vortex_strengths[global_panel_position] - ring_vortex_to_right.strength - ) + ) / 2 if panel.is_leading_edge: # Set the effective front LineVortex strength to this Panel's # RingVortex's strength. - effective_front_vortex_line_strengths[global_panel_position] = ( + effective_front_line_vortex_strengths[global_panel_position] = ( self._current_bound_vortex_strengths[global_panel_position] ) else: - panel_to_front: _panel.Panel = wing.panels[ - panel.local_chordwise_position - 1, - panel.local_spanwise_position, + panel_to_front: _panel.Panel = _panels[ + _local_chordwise_position - 1, + _local_spanwise_position, ] - ring_vortex_to_front: _aerodynamics.RingVortex = ( - panel_to_front.ring_vortex - ) - # Set the effective front LineVortex strength to the + ring_vortex_to_front = panel_to_front.ring_vortex + assert ring_vortex_to_front is not None + + # Set the effective front LineVortex strength to 1/2 the # difference between this Panel's RingVortex's strength, # and the RingVortex's strength of the Panel in front of it. - effective_front_vortex_line_strengths[global_panel_position] = ( + effective_front_line_vortex_strengths[global_panel_position] = ( self._current_bound_vortex_strengths[global_panel_position] - ring_vortex_to_front.strength - ) + ) / 2 if panel.is_left_edge: # Set the effective left LineVortex strength to this Panel's # RingVortex's strength. - effective_left_vortex_line_strengths[global_panel_position] = ( + effective_left_line_vortex_strengths[global_panel_position] = ( self._current_bound_vortex_strengths[global_panel_position] ) else: - panel_to_left: _panel.Panel = wing.panels[ - panel.local_chordwise_position, - panel.local_spanwise_position - 1, + panel_to_left: _panel.Panel = _panels[ + _local_chordwise_position, + _local_spanwise_position - 1, ] - ring_vortex_to_left: _aerodynamics.RingVortex = ( - panel_to_left.ring_vortex - ) - # Set the effective left LineVortex strength to the + ring_vortex_to_left = panel_to_left.ring_vortex + assert ring_vortex_to_left is not None + + # Set the effective left LineVortex strength to 1/2 the # difference between this Panel's RingVortex's strength, # and the RingVortex's strength of the Panel to the left. - effective_left_vortex_line_strengths[global_panel_position] = ( + effective_left_line_vortex_strengths[global_panel_position] = ( self._current_bound_vortex_strengths[global_panel_position] - ring_vortex_to_left.strength - ) + ) / 2 + + if panel.is_trailing_edge: + if self._current_step == 0: + # Set the effective back LineVortex strength to this + # Panel's RingVortex's strength, as, for the first time + # step, there isn't a wake RingVortex to cancel it out. + effective_back_line_vortex_strengths[ + global_panel_position + ] = self._current_bound_vortex_strengths[ + global_panel_position + ] + else: + # Set the effective back LineVortex strength to the + # difference between this Panel's RingVortex's strength and + # its strength at the last time step. This models the effect + # of the Panel's back LineVortex being partially cancelled + # out by the front LineVortex of the wake RingVortex + # immediately to this Panel's rear. This works because that + # wake RingVortex has the same strength this Panel's + # RingVortex had last time step. + effective_back_line_vortex_strengths[ + global_panel_position + ] = ( + self._current_bound_vortex_strengths[ + global_panel_position + ] + - self._last_bound_vortex_strengths[ + global_panel_position + ] + ) + else: + panel_to_back: _panel.Panel = _panels[ + _local_chordwise_position + 1, + _local_spanwise_position, + ] + + _ring_vortex_to_back = panel_to_back.ring_vortex + assert _ring_vortex_to_back is not None + + # Set the effective back LineVortex strength to 1/2 the + # difference between this Panel's RingVortex's strength, + # and the RingVortex's strength of the Panel to the back. + effective_back_line_vortex_strengths[global_panel_position] = ( + self._current_bound_vortex_strengths[global_panel_position] + - _ring_vortex_to_back.strength + ) / 2 # Increment the global Panel position variable. global_panel_position += 1 # Calculate the velocity (in the first Airplane's geometry axes, observed # from the Earth frame) at the center of every Panels' RingVortex's right - # LineVortex, front LineVortex, and left LineVortex. - stackVelocityRightLineVortexCenters_GP1_E = ( + # LineVortex, front LineVortex, left LineVortex, and back LineVortex. + stackVelocityRightLineVortexCenters_GP1__E = ( self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpr_GP1_CgP1) + self._calculate_current_movement_velocities_at_right_leg_centers() ) - stackVelocityFrontLineVortexCenters_GP1_E = ( + stackVelocityFrontLineVortexCenters_GP1__E = ( self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpf_GP1_CgP1) + self._calculate_current_movement_velocities_at_front_leg_centers() ) - stackVelocityLeftLineVortexCenters_GP1_E = ( + stackVelocityLeftLineVortexCenters_GP1__E = ( self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpl_GP1_CgP1) + self._calculate_current_movement_velocities_at_left_leg_centers() ) + stackVelocityBackLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpb_GP1_CgP1) + + self._calculate_current_movement_velocities_at_back_leg_centers() + ) # Using the effective LineVortex strengths and the Kutta-Joukowski theorem, # find the forces (in the first Airplane's geometry axes) on the Panels' - # RingVortex's right LineVortex, front LineVortex, and left LineVortex using - # the effective vortex strengths. + # RingVortex's right LineVortex, front LineVortex, left LineVortex, and back + # LineVortex using the effective vortex strengths. rightLegForces_GP1 = ( self.current_operating_point.rho - * np.expand_dims(effective_right_vortex_line_strengths, axis=1) + * np.expand_dims(effective_right_line_vortex_strengths, axis=1) * _functions.numba_1d_explicit_cross( - stackVelocityRightLineVortexCenters_GP1_E, - self.stackRbrv_GP1, + stackVelocityRightLineVortexCenters_GP1__E, self.stackRbrv_GP1 ) ) frontLegForces_GP1 = ( self.current_operating_point.rho - * np.expand_dims(effective_front_vortex_line_strengths, axis=1) + * np.expand_dims(effective_front_line_vortex_strengths, axis=1) * _functions.numba_1d_explicit_cross( - stackVelocityFrontLineVortexCenters_GP1_E, - self.stackFbrv_GP1, + stackVelocityFrontLineVortexCenters_GP1__E, self.stackFbrv_GP1 ) ) leftLegForces_GP1 = ( self.current_operating_point.rho - * np.expand_dims(effective_left_vortex_line_strengths, axis=1) + * np.expand_dims(effective_left_line_vortex_strengths, axis=1) * _functions.numba_1d_explicit_cross( - stackVelocityLeftLineVortexCenters_GP1_E, - self.stackLbrv_GP1, + stackVelocityLeftLineVortexCenters_GP1__E, self.stackLbrv_GP1 + ) + ) + backLegForces_GP1 = ( + self.current_operating_point.rho + * np.expand_dims(effective_back_line_vortex_strengths, axis=1) + * np.cross( + stackVelocityBackLineVortexCenters_GP1__E, + self.stackBbrv_GP1, + axis=-1, ) ) + # The unsteady force calculation below includes a negative sign to account for a # sign convention mismatch between Ptera Software and the reference literature. # Ptera Software defines RingVortices with counter-clockwise (CCW) vertex @@ -1112,23 +1274,24 @@ def _calculate_loads(self): rightLegForces_GP1 + frontLegForces_GP1 + leftLegForces_GP1 + + backLegForces_GP1 + unsteady_forces_GP1 ) # Find the moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) on the Panels' RingVortex's right LineVortex, - # front LineVortex, and left LineVortex. + # front LineVortex, left LineVortex, and back LineVortex. rightLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCblvpr_GP1_CgP1, - rightLegForces_GP1, + self.stackCblvpr_GP1_CgP1, rightLegForces_GP1 ) frontLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCblvpf_GP1_CgP1, - frontLegForces_GP1, + self.stackCblvpf_GP1_CgP1, frontLegForces_GP1 ) leftLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCblvpl_GP1_CgP1, - leftLegForces_GP1, + self.stackCblvpl_GP1_CgP1, leftLegForces_GP1 + ) + backLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( + self.stackCblvpb_GP1_CgP1, backLegForces_GP1 ) # The unsteady moment is calculated at the collocation point because the @@ -1138,14 +1301,14 @@ def _calculate_loads(self): # Find the moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) due to the unsteady component of the force on each Panel. unsteady_moments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCpp_GP1_CgP1, - unsteady_forces_GP1, + self.stackCpp_GP1_CgP1, unsteady_forces_GP1 ) moments_GP1_CgP1 = ( rightLegMoments_GP1_CgP1 + frontLegMoments_GP1_CgP1 + leftLegMoments_GP1_CgP1 + + backLegMoments_GP1_CgP1 + unsteady_moments_GP1_CgP1 ) @@ -1153,185 +1316,127 @@ def _calculate_loads(self): # geometry axes before passing to process_solver_loads. _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) - def _populate_next_airplanes_wake(self, prescribed_wake=True): - """This method updates the next time step's Airplanes' wakes. - - :param prescribed_wake: Bool, optional - - This parameter determines if the solver uses a prescribed wake model. If - false it will use a free-wake, which may be more accurate but will make - the solver significantly slower. The default is True. + def _populate_next_airplanes_wake(self) -> None: + """Updates the next time step's Airplanes' wakes. :return: None """ # Populate the locations of the next time step's Airplanes' wake RingVortex # points. - self._populate_next_airplanes_wake_vortex_points( - prescribed_wake=prescribed_wake - ) + self._populate_next_airplanes_wake_vortex_points() # Populate the locations of the next time step's Airplanes' wake RingVortices. self._populate_next_airplanes_wake_vortices() - def _populate_next_airplanes_wake_vortex_points(self, prescribed_wake=True): - """This method populates the locations of the next time step's Airplanes' - wake RingVortex points. + def _populate_next_airplanes_wake_vortex_points(self) -> None: + """Populates the locations of the next time step's Airplanes' wake RingVortex + points. - This method is not vectorized but its loops only consume 1.1% of the runtime, - so I have kept it as is for increased readability. + **Notes:** - :param prescribed_wake: Bool, optional - - This parameter determines if the solver uses a prescribed wake model. If - false it will use a free-wake, which may be more accurate but will make - the solver significantly slower. The default is True. + This method is not vectorized but its loops only consume 1.1% of the runtime, so + I have kept it as is for increased readability. :return: None """ - # Get the next time step's Airplanes. - next_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem( - self._current_step + 1 - ) - next_airplanes = next_problem.airplanes + # Check that this isn't the last time step. + if self._current_step < self.num_steps - 1: - # Get the current Airplanes' combined number of Wings. - num_wings = 0 - airplane: geometry.airplane.Airplane - for airplane in self.current_airplanes: - num_wings += len(airplane.wings) + # Get the next time step's Airplanes. + next_problem: problems.SteadyProblem = ( + self.coupled_unsteady_problem.get_steady_problem(self._current_step + 1) + ) + next_airplanes = next_problem.airplanes - # Iterate through this time step's Airplanes' successor objects. - next_airplane: geometry.airplane.Airplane - for airplane_id, next_airplane in enumerate(next_airplanes): + # Get the current Airplanes' combined number of Wings. + num_wings = 0 + for airplane in self.current_airplanes: + num_wings += len(airplane.wings) - # Iterate through the next Airplane's Wings. - next_wing: geometry.wing.Wing - for wing_id, next_wing in enumerate(next_airplane.wings): + # Iterate through this time step's Airplanes' successor objects. + for airplane_id, next_airplane in enumerate(next_airplanes): - # Get the Wings at this position from the current Airplane. - this_airplane: geometry.airplane.Airplane = self.current_airplanes[ - airplane_id - ] - this_wing: geometry.wing.Wing = this_airplane.wings[wing_id] + # Iterate through the next Airplane's Wings. + for wing_id, next_wing in enumerate(next_airplane.wings): - # Check if this is the first time step. - if self._current_step == 0: + # Get the Wings at this position from the current Airplane. + this_airplane = self.current_airplanes[airplane_id] + this_wing = this_airplane.wings[wing_id] - # Get the current Wing's number of chordwise and spanwise - # panels. - num_spanwise_panels = this_wing.num_spanwise_panels - num_chordwise_panels = this_wing.num_chordwise_panels + # Check if this is the first time step. + if self._current_step == 0: - # Set the chordwise position to be at the trailing edge. - chordwise_panel_id = num_chordwise_panels - 1 + # Get the current Wing's number of chordwise and spanwise + # panels. + num_spanwise_panels = this_wing.num_spanwise_panels + assert num_spanwise_panels is not None - # Initialize a ndarray to hold the points of the new row of - # wake RingVortices (in the first Airplane's geometry axes, - # relative to the first Airplane's CG). - newRowWrvp_GP1_CgP1 = np.zeros( - (1, num_spanwise_panels + 1, 3), dtype=float - ) + num_chordwise_panels = this_wing.num_chordwise_panels - # Iterate through the spanwise Panel positions. - for spanwise_panel_id in range(num_spanwise_panels): - # Get the next time step's Wing's Panel at this location. - next_panel: _panel.Panel = next_wing.panels[ - chordwise_panel_id, spanwise_panel_id - ] - # The position of the new front left wake RingVortex's - # point is the next time step's Panel's bound - # RingVortex's back left point. - next_ring_vortex: _aerodynamics.RingVortex = ( - next_panel.ring_vortex - ) - - newFlwrvp_GP1_CgP1 = next_ring_vortex.Blrvp_GP1_CgP1 + # Set the chordwise position to be at the trailing edge. + chordwise_panel_id = num_chordwise_panels - 1 - # Add this to the row of new wake RingVortex points. - newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( - newFlwrvp_GP1_CgP1 + # Initialize a ndarray to hold the points of the new row of + # wake RingVortices (in the first Airplane's geometry axes, + # relative to the first Airplane's CG). + newRowWrvp_GP1_CgP1 = np.zeros( + (1, num_spanwise_panels + 1, 3), dtype=float ) - # If the Panel is at the right edge of the Wing, add its - # back right bound RingVortex point to the row of new - # wake RingVortex points. - if spanwise_panel_id == (num_spanwise_panels - 1): - newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( - next_ring_vortex.Brrvp_GP1_CgP1 - ) - - # Set the next time step's Wing's grid of wake RingVortex - # points to a copy of the row of new wake RingVortex points. - # This is correct because it is currently the first time step. - next_wing.gridWrvp_GP1_CgP1 = np.copy(newRowWrvp_GP1_CgP1) + # Iterate through the spanwise Panel positions. + for spanwise_panel_id in range(num_spanwise_panels): + _next_panels = next_wing.panels + assert _next_panels is not None - # Initialize variables to hold the number of spanwise wake - # RingVortex points. - num_spanwise_points = num_spanwise_panels + 1 + # Get the next time step's Wing's Panel at this location. + next_panel: _panel.Panel = _next_panels[ + chordwise_panel_id, spanwise_panel_id + ] - # Initialize a new ndarray to hold the second new row of wake - # RingVortex points (in the first Airplane's geometry axes, - # relative to the first Airplane's CG). - secondNewRowWrvp_GP1_CgP1 = np.zeros( - (1, num_spanwise_panels + 1, 3), dtype=float - ) + # The position of the new front left wake RingVortex's + # point is the next time step's Panel's bound + # RingVortex's back left point. + next_ring_vortex = next_panel.ring_vortex + assert next_ring_vortex is not None - # Iterate through the spanwise points. - for spanwise_point_id in range(num_spanwise_points): - # Get the corresponding point from the first row. - Wrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ - 0, spanwise_point_id - ] + newFlwrvp_GP1_CgP1 = next_ring_vortex.Blrvp_GP1_CgP1 - # If the wake is prescribed, set the velocity at this - # point to the freestream velocity (in the first - # Airplane's geometry axes, observed from the Earth - # frame). Otherwise, set the velocity to the solution - # velocity at this point (in the first Airplane's - # geometry axes, observed from the Earth frame). - if prescribed_wake: - vWrvp_GP1__E = self._currentVInf_GP1__E - else: - vWrvp_GP1__E = self.calculate_solution_velocity( - np.expand_dims(Wrvp_GP1_CgP1, axis=0) + # Add this to the row of new wake RingVortex points. + newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( + newFlwrvp_GP1_CgP1 ) - # Update the second new row with the interpolated - # position of the first point. - secondNewRowWrvp_GP1_CgP1[0, spanwise_point_id] = ( - Wrvp_GP1_CgP1 + vWrvp_GP1__E * self.delta_time - ) + # If the Panel is at the right edge of the Wing, add its + # back right bound RingVortex point to the row of new + # wake RingVortex points. + if spanwise_panel_id == (num_spanwise_panels - 1): + newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( + next_ring_vortex.Brrvp_GP1_CgP1 + ) - # Update the next time step's Wing's grid of wake RingVortex - # points by vertically stacking the new second row below it. - next_wing.gridWrvp_GP1_CgP1 = np.vstack( - ( - next_wing.gridWrvp_GP1_CgP1, - secondNewRowWrvp_GP1_CgP1, - ) - ) + # Set the next time step's Wing's grid of wake RingVortex + # points to a copy of the row of new wake RingVortex points. + # This is correct because it is currently the first time step. + next_wing.gridWrvp_GP1_CgP1 = np.copy(newRowWrvp_GP1_CgP1) - # If this isn't the first time step, then do this. - else: - # Set the next time step's Wing's grid of wake RingVortex - # points to a copy of this time step's Wing's grid of wake - # RingVortex points. - next_wing.gridWrvp_GP1_CgP1 = np.copy( - this_wing.gridWrvp_GP1_CgP1 - ) + # Initialize variables to hold the number of spanwise wake + # RingVortex points. + num_spanwise_points = num_spanwise_panels + 1 - # Get the number of chordwise and spanwise points. - num_chordwise_points = next_wing.gridWrvp_GP1_CgP1.shape[0] - num_spanwise_points = next_wing.gridWrvp_GP1_CgP1.shape[1] + # Initialize a new ndarray to hold the second new row of wake + # RingVortex points (in the first Airplane's geometry axes, + # relative to the first Airplane's CG). + secondNewRowWrvp_GP1_CgP1 = np.zeros( + (1, num_spanwise_panels + 1, 3), dtype=float + ) - # Iterate through the chordwise and spanwise point positions. - for chordwise_point_id in range(num_chordwise_points): + # Iterate through the spanwise points. for spanwise_point_id in range(num_spanwise_points): - # Get the wake RingVortex point at this position. + # Get the corresponding point from the first row. Wrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ - chordwise_point_id, - spanwise_point_id, + 0, spanwise_point_id ] + assert Wrvp_GP1_CgP1 is not None # If the wake is prescribed, set the velocity at this # point to the freestream velocity (in the first @@ -1339,315 +1444,411 @@ def _populate_next_airplanes_wake_vortex_points(self, prescribed_wake=True): # frame). Otherwise, set the velocity to the solution # velocity at this point (in the first Airplane's # geometry axes, observed from the Earth frame). - if prescribed_wake: + if self._prescribed_wake: vWrvp_GP1__E = self._currentVInf_GP1__E else: - vWrvp_GP1__E = np.squeeze( - self.calculate_solution_velocity( - np.expand_dims(Wrvp_GP1_CgP1, axis=0) - ) + vWrvp_GP1__E = self.calculate_solution_velocity( + np.expand_dims(Wrvp_GP1_CgP1, axis=0) ) - # Update this point with its interpolated position. - next_wing.gridWrvp_GP1_CgP1[ - chordwise_point_id, spanwise_point_id - ] += (vWrvp_GP1__E * self.delta_time) + # Update the second new row with the interpolated + # position of the first point. + secondNewRowWrvp_GP1_CgP1[0, spanwise_point_id] = ( + Wrvp_GP1_CgP1 + vWrvp_GP1__E * self.delta_time + ) - # Find the chordwise position of the Wing's trailing edge. - chordwise_panel_id = this_wing.num_chordwise_panels - 1 + # Update the next time step's Wing's grid of wake RingVortex + # points by vertically stacking the new second row below it. + next_wing.gridWrvp_GP1_CgP1 = np.vstack( + ( + next_wing.gridWrvp_GP1_CgP1, + secondNewRowWrvp_GP1_CgP1, + ) + ) - # Initialize a new ndarray to hold the new row of wake - # RingVortex vertices. - newRowWrvp_GP1_CgP1 = np.zeros( - (1, this_wing.num_spanwise_panels + 1, 3), dtype=float - ) + # If this isn't the first time step, then do this. + else: + _thisGridWrvp_GP1_CgP1 = this_wing.gridWrvp_GP1_CgP1 + assert _thisGridWrvp_GP1_CgP1 is not None + + # Set the next time step's Wing's grid of wake RingVortex + # points to a copy of this time step's Wing's grid of wake + # RingVortex points. + next_wing.gridWrvp_GP1_CgP1 = np.copy(_thisGridWrvp_GP1_CgP1) + + # Get the number of chordwise and spanwise points. + num_chordwise_points = next_wing.gridWrvp_GP1_CgP1.shape[0] + num_spanwise_points = next_wing.gridWrvp_GP1_CgP1.shape[1] + + # Iterate through the chordwise and spanwise point positions. + for chordwise_point_id in range(num_chordwise_points): + for spanwise_point_id in range(num_spanwise_points): + # Get the wake RingVortex point at this position. + Wrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ + chordwise_point_id, + spanwise_point_id, + ] - # Iterate spanwise through the trailing edge Panels. - for spanwise_panel_id in range(this_wing.num_spanwise_panels): - # Get the Panel at this location on the next time step's - # Airplane's Wing. - next_panel: _panel.Panel = next_wing.panels[ - chordwise_panel_id, spanwise_panel_id - ] + # If the wake is prescribed, set the velocity at this + # point to the freestream velocity (in the first + # Airplane's geometry axes, observed from the Earth + # frame). Otherwise, set the velocity to the solution + # velocity at this point (in the first Airplane's + # geometry axes, observed from the Earth frame). + if self._prescribed_wake: + vWrvp_GP1__E = self._currentVInf_GP1__E + else: + vWrvp_GP1__E = np.squeeze( + self.calculate_solution_velocity( + np.expand_dims(Wrvp_GP1_CgP1, axis=0) + ) + ) - # Add the Panel's back left bound RingVortex point to the - # grid of new wake RingVortex points. - next_ring_vortex: _aerodynamics.RingVortex = ( - next_panel.ring_vortex - ) - newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( - next_ring_vortex.Blrvp_GP1_CgP1 + # Update this point with its interpolated position. + next_wing.gridWrvp_GP1_CgP1[ + chordwise_point_id, spanwise_point_id + ] += (vWrvp_GP1__E * self.delta_time) + + # Find the chordwise position of the Wing's trailing edge. + chordwise_panel_id = this_wing.num_chordwise_panels - 1 + + _num_spanwise_panels = this_wing.num_spanwise_panels + assert _num_spanwise_panels is not None + + # Initialize a new ndarray to hold the new row of wake + # RingVortex vertices. + newRowWrvp_GP1_CgP1 = np.zeros( + (1, _num_spanwise_panels + 1, 3), dtype=float ) - # If the Panel is at the right edge of the Wing, add its - # back right bound RingVortex point to the grid of new - # wake RingVortex vertices. - if spanwise_panel_id == (this_wing.num_spanwise_panels - 1): - newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( - next_ring_vortex.Brrvp_GP1_CgP1 + # Iterate spanwise through the trailing edge Panels. + for spanwise_panel_id in range(_num_spanwise_panels): + _next_panels = next_wing.panels + assert _next_panels is not None + + # Get the Panel at this location on the next time step's + # Airplane's Wing. + this_next_panel: _panel.Panel = _next_panels[ + chordwise_panel_id, spanwise_panel_id + ] + + # Add the Panel's back left bound RingVortex point to the + # grid of new wake RingVortex points. + next_ring_vortex = this_next_panel.ring_vortex + assert next_ring_vortex is not None + + newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( + next_ring_vortex.Blrvp_GP1_CgP1 ) - # Stack the new row of wake RingVortex points above the - # Wing's grid of wake RingVortex points. - next_wing.gridWrvp_GP1_CgP1 = np.vstack( - ( - newRowWrvp_GP1_CgP1, - next_wing.gridWrvp_GP1_CgP1, + # If the Panel is at the right edge of the Wing, add its + # back right bound RingVortex point to the grid of new + # wake RingVortex vertices. + if spanwise_panel_id == (_num_spanwise_panels - 1): + newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( + next_ring_vortex.Brrvp_GP1_CgP1 + ) + + # Stack the new row of wake RingVortex points above the + # Wing's grid of wake RingVortex points. + next_wing.gridWrvp_GP1_CgP1 = np.vstack( + ( + newRowWrvp_GP1_CgP1, + next_wing.gridWrvp_GP1_CgP1, + ) ) - ) - def _populate_next_airplanes_wake_vortices(self): - """This method populates the locations and strengths of the next time step's - wake RingVortices. + def _populate_next_airplanes_wake_vortices(self) -> None: + """Populates the locations and strengths of the next time step's wake + RingVortices. - This method is not vectorized but its loops only consume 0.4% of the runtime, - so I have kept it as is for increased readability. + **Notes:** + + This method is not vectorized but its loops only consume 0.4% of the runtime, so + I have kept it as is for increased readability. :return: None """ - # Get the next time step's Airplanes. - next_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem( - self._current_step + 1 - ) - next_airplanes = next_problem.airplanes - - # Iterate through the next time step's Airplanes. - next_airplane: geometry.airplane.Airplane - for airplane_id, next_airplane in enumerate(next_airplanes): - - # For a given Airplane in the next time step, iterate through its - # predecessor's Wings. - this_wing: geometry.wing.Wing - for wing_id, this_wing in enumerate( - self.current_airplanes[airplane_id].wings - ): - next_wing: geometry.wing.Wing = next_airplane.wings[wing_id] - - # Get the next time step's Wing's grid of wake RingVortex points. - nextGridWrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1 - - # Find the number of chordwise and spanwise points in the next - # Wing's grid of wake RingVortex points. - num_chordwise_points = nextGridWrvp_GP1_CgP1.shape[0] - num_spanwise_points = nextGridWrvp_GP1_CgP1.shape[1] - - this_wing_wake_ring_vortices = ( - self.current_airplanes[airplane_id] - .wings[wing_id] - .wake_ring_vortices - ) + # Check if the current time step is not the last step. + if self._current_step < self.num_steps - 1: - # Initialize a new ndarray to hold the new row of wake RingVortices. - new_row_of_wake_ring_vortices = np.empty( - (1, num_spanwise_points - 1), dtype=object - ) + # Get the next time step's Airplanes. + next_problem = self.coupled_unsteady_problem.get_steady_problem( + self._current_step + 1 + ) + next_airplanes = next_problem.airplanes - # Create a new ndarray by stacking the new row of wake - # RingVortices on top of the current Wing's grid of wake - # RingVortices and assign it to the next time step's Wing. - next_wing.wake_ring_vortices = np.vstack( - (new_row_of_wake_ring_vortices, this_wing_wake_ring_vortices) - ) + # Iterate through the next time step's Airplanes. + for airplane_id, next_airplane in enumerate(next_airplanes): - # Iterate through the wake RingVortex point positions. - for chordwise_point_id in range(num_chordwise_points): - for spanwise_point_id in range(num_spanwise_points): - # Set booleans to determine if this point is on the right - # and/or trailing edge of the wake. - has_point_to_right = ( - spanwise_point_id + 1 - ) < num_spanwise_points - has_point_behind = ( - chordwise_point_id + 1 - ) < num_chordwise_points - - if has_point_to_right and has_point_behind: - # If this point isn't on the right or trailing edge - # of the wake, get the four points that will be - # associated with the corresponding RingVortex at - # this position (in the first Airplane's geometry - # axes, relative to the first Airplane's CG), - # for the next time step. - Flwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id, spanwise_point_id - ] - Frwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id, - spanwise_point_id + 1, - ] - Blwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id + 1, - spanwise_point_id, - ] - Brwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id + 1, - spanwise_point_id + 1, - ] + # For a given Airplane in the next time step, iterate through its + # predecessor's Wings. + for wing_id, this_wing in enumerate( + self.current_airplanes[airplane_id].wings + ): + next_wing = next_airplane.wings[wing_id] - if chordwise_point_id > 0: - # If this isn't the front of the wake, update the - # position of the wake RingVortex at this - # location for the next time step. - next_wake_ring_vortices = ( - next_wing.wake_ring_vortices - ) - next_wake_ring_vortex_obj = cast( - object, - next_wake_ring_vortices[ - chordwise_point_id, spanwise_point_id - ], - ) - next_wake_ring_vortex = cast( - _aerodynamics.RingVortex, - next_wake_ring_vortex_obj, - ) + # Get the next time step's Wing's grid of wake RingVortex points. + nextGridWrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1 + assert nextGridWrvp_GP1_CgP1 is not None - next_wake_ring_vortex.update_position( - Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, - Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, - ) + # Find the number of chordwise and spanwise points in the next + # Wing's grid of wake RingVortex points. + num_chordwise_points = nextGridWrvp_GP1_CgP1.shape[0] + num_spanwise_points = nextGridWrvp_GP1_CgP1.shape[1] - # Also, update the age of the wake RingVortex at - # this position for the next time step. - if self._current_step == 0: - next_wake_ring_vortex.age = self.delta_time - else: - next_wake_ring_vortex.age += self.delta_time - - if chordwise_point_id == 0: - # If this position corresponds to the front of - # the wake, get the strength from the Panel's - # bound RingVortex. - this_panel: _panel.Panel = this_wing.panels[ - this_wing.num_chordwise_panels - 1, - spanwise_point_id, + this_wing_wake_ring_vortices = ( + self.current_airplanes[airplane_id] + .wings[wing_id] + .wake_ring_vortices + ) + assert this_wing_wake_ring_vortices is not None + + # Initialize a new ndarray to hold the new row of wake RingVortices. + new_row_of_wake_ring_vortices = np.empty( + (1, num_spanwise_points - 1), dtype=object + ) + + # Create a new ndarray by stacking the new row of wake + # RingVortices on top of the current Wing's grid of wake + # RingVortices and assign it to the next time step's Wing. + next_wing.wake_ring_vortices = np.vstack( + (new_row_of_wake_ring_vortices, this_wing_wake_ring_vortices) + ) + + # Iterate through the wake RingVortex point positions. + for chordwise_point_id in range(num_chordwise_points): + for spanwise_point_id in range(num_spanwise_points): + # Set bools to determine if this point is on the right + # and/or trailing edge of the wake. + has_point_to_right = ( + spanwise_point_id + 1 + ) < num_spanwise_points + has_point_behind = ( + chordwise_point_id + 1 + ) < num_chordwise_points + + if has_point_to_right and has_point_behind: + # If this point isn't on the right or trailing edge + # of the wake, get the four points that will be + # associated with the corresponding RingVortex at + # this position (in the first Airplane's geometry + # axes, relative to the first Airplane's CG), + # for the next time step. + Flwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ + chordwise_point_id, spanwise_point_id ] - this_ring_vortex: _aerodynamics.RingVortex = ( - this_panel.ring_vortex - ) - this_strength_copy = this_ring_vortex.strength - - # Then, for the next time step, make a new wake - # RingVortex at this position in the wake, - # with that bound RingVortex's strength, and add - # it to the grid of the next time step's wake - # RingVortices. - next_wing.wake_ring_vortices[ + Frwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ chordwise_point_id, + spanwise_point_id + 1, + ] + Blwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ + chordwise_point_id + 1, spanwise_point_id, - ] = _aerodynamics.RingVortex( - Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, - Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, - strength=this_strength_copy, - ) + ] + Brwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ + chordwise_point_id + 1, + spanwise_point_id + 1, + ] - def _calculate_current_movement_velocities_at_collocation_points(self): - """Get the current apparent velocities (in the first Airplane's geometry - axes, observed from the Earth frame) at each Panel's collocation point due to - any motion defined in Movement. + if chordwise_point_id > 0: + # If this isn't the front of the wake, update the + # position of the wake RingVortex at this + # location for the next time step. + next_wake_ring_vortices = ( + next_wing.wake_ring_vortices + ) + assert next_wake_ring_vortices is not None + next_wake_ring_vortex = cast( + _aerodynamics.RingVortex, + next_wake_ring_vortices[ + chordwise_point_id, spanwise_point_id + ], + ) - Note: At each point, any apparent velocity due to Movement is opposite the - motion due to Movement. + next_wake_ring_vortex.update_position( + Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, + Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, + ) - :return: (M, 3) ndarray of floats + # Also, update the age of the wake RingVortex at + # this position for the next time step. + if self._current_step == 0: + next_wake_ring_vortex.age = self.delta_time + else: + next_wake_ring_vortex.age += self.delta_time + + if chordwise_point_id == 0: + _panels = this_wing.panels + assert _panels is not None + + # If this position corresponds to the front of + # the wake, get the strength from the Panel's + # bound RingVortex. + this_panel: _panel.Panel = _panels[ + this_wing.num_chordwise_panels - 1, + spanwise_point_id, + ] + + this_ring_vortex = this_panel.ring_vortex + assert this_ring_vortex is not None + + this_strength_copy = this_ring_vortex.strength + + # Then, for the next time step, make a new wake + # RingVortex at this position in the wake, + # with that bound RingVortex's strength, and add + # it to the grid of the next time step's wake + # RingVortices. + next_wing.wake_ring_vortices[ + chordwise_point_id, + spanwise_point_id, + ] = _aerodynamics.RingVortex( + Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, + Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, + strength=this_strength_copy, + ) - This is a ndarray containing the apparent velocity (in the first - Airplane's geometry axes, observed from the Earth frame) at each Panel's - collocation point due to any motion defined in Movement. Its units are in - meters per second. If the current time step is the first time step, - these velocities will all be all zeros. + def _calculate_current_movement_velocities_at_collocation_points( + self, + ) -> np.ndarray: + """Finds the apparent velocities (in the first Airplane's geometry axes, + observed from the Earth frame) at each Panel's collocation point due to any + motion defined in Movement at the current time step. + + **Notes:** + + At each point, any apparent velocity due to Movement is opposite the motion due + to Movement. + + :return: A (M, 3) ndarray of floats representing the apparent velocity (in the + first Airplane's geometry axes, observed from the Earth frame) at each + Panel's collocation point due to any motion defined in Movement. If the + current time step is the first time step, these velocities will all be all + zeros. Its units are in meters per second. """ # Check if this is the current time step. If so, return all zeros. if self._current_step < 1: return np.zeros((self.num_panels, 3), dtype=float) - return -(self.stackCpp_GP1_CgP1 - self._stackLastCpp_GP1_CgP1) / self.delta_time + return cast( + np.ndarray, + -(self.stackCpp_GP1_CgP1 - self._stackLastCpp_GP1_CgP1) / self.delta_time, + ) - def _calculate_current_movement_velocities_at_right_leg_centers(self): - """Get the current apparent velocities (in the first Airplane's geometry - axes, observed from the Earth frame) at the center point of each bound - RingVortex's right leg due to any motion defined in Movement. + def _calculate_current_movement_velocities_at_right_leg_centers(self) -> np.ndarray: + """Finds the apparent velocities (in the first Airplane's geometry axes, + observed from the Earth frame) at the center point of each bound RingVortex's + right leg due to any motion defined in Movement at the current time step. - Note: At each point, any apparent velocity due to Movement is opposite the - motion due to Movement. + **Notes:** - :return: (M, 3) ndarray of floats + At each point, any apparent velocity due to Movement is opposite the motion due + to Movement. - This is a ndarray containing the apparent velocity (in the first - Airplane's geometry axes, observed from the Earth frame) at the center + :return: A (M, 3) ndarray of floats representing the apparent velocity (in the + first Airplane's geometry axes, observed from the Earth frame) at the center point of each bound RingVortex's right leg due to any motion defined in - Movement. Its units are in meters per second. If the current time step is - the first time step, these velocities will all be all zeros. + Movement. If the current time step is the first time step, these velocities + will all be all zeros. Its units are in meters per second. """ # Check if this is the current time step. If so, return all zeros. if self._current_step < 1: return np.zeros((self.num_panels, 3), dtype=float) - return ( + return cast( + np.ndarray, -(self.stackCblvpr_GP1_CgP1 - self._lastStackCblvpr_GP1_CgP1) - / self.delta_time + / self.delta_time, ) - def _calculate_current_movement_velocities_at_front_leg_centers(self): - """Get the current apparent velocities (in the first Airplane's geometry - axes, observed from the Earth frame) at the center point of each bound - RingVortex's front leg due to any motion defined in Movement. + def _calculate_current_movement_velocities_at_front_leg_centers(self) -> np.ndarray: + """Finds the apparent velocities (in the first Airplane's geometry axes, + observed from the Earth frame) at the center point of each bound RingVortex's + front leg due to any motion defined in Movement at the current time step. - Note: At each point, any apparent velocity due to Movement is opposite the - motion due to Movement. + **Notes:** - :return: (M, 3) ndarray of floats + At each point, any apparent velocity due to Movement is opposite the motion due + to Movement. - This is a ndarray containing the apparent velocity (in the first - Airplane's geometry axes, observed from the Earth frame) at the center + :return: A (M, 3) ndarray of floats representing the apparent velocity (in the + first Airplane's geometry axes, observed from the Earth frame) at the center point of each bound RingVortex's front leg due to any motion defined in - Movement. Its units are in meters per second. If the current time step is - the first time step, these velocities will all be all zeros. + Movement. If the current time step is the first time step, these velocities + will all be all zeros. Its units are in meters per second. """ # Check if this is the current time step. If so, return all zeros. if self._current_step < 1: return np.zeros((self.num_panels, 3), dtype=float) - return ( + return cast( + np.ndarray, -(self.stackCblvpf_GP1_CgP1 - self._lastStackCblvpf_GP1_CgP1) - / self.delta_time + / self.delta_time, ) - def _calculate_current_movement_velocities_at_left_leg_centers(self): - """Get the current apparent velocities (in the first Airplane's geometry - axes, observed from the Earth frame) at the center point of each bound - RingVortex's left leg due to any motion defined in Movement. + def _calculate_current_movement_velocities_at_left_leg_centers(self) -> np.ndarray: + """Finds the apparent velocities (in the first Airplane's geometry axes, + observed from the Earth frame) at the center point of each bound RingVortex's + left leg due to any motion defined in Movement at the current time step. - Note: At each point, any apparent velocity due to Movement is opposite the - motion due to Movement. + **Notes:** - :return: (M, 3) ndarray of floats + At each point, any apparent velocity due to Movement is opposite the motion due + to Movement. - This is a ndarray containing the apparent velocity (in the first - Airplane's geometry axes, observed from the Earth frame) at the center + :return: A (M, 3) ndarray of floats representing the apparent velocity (in the + first Airplane's geometry axes, observed from the Earth frame) at the center point of each bound RingVortex's left leg due to any motion defined in - Movement. Its units are in meters per second. If the current time step is - the first time step, these velocities will all be all zeros. + Movement. If the current time step is the first time step, these velocities + will all be all zeros. Its units are in meters per second. """ # Check if this is the current time step. If so, return all zeros. if self._current_step < 1: return np.zeros((self.num_panels, 3), dtype=float) - return ( + return cast( + np.ndarray, -(self.stackCblvpl_GP1_CgP1 - self._lastStackCblvpl_GP1_CgP1) - / self.delta_time + / self.delta_time, ) - def _finalize_loads(self): - """For cases with static geometry, this function finds the final loads and - load coefficients for each of the SteadyProblem's Airplanes. For cases with - variable geometry, it finds the final cycle-averaged and - cycle-root-mean-squared loads and load coefficients for each of the - SteadyProblem's Airplanes. + def _calculate_current_movement_velocities_at_back_leg_centers(self) -> np.ndarray: + """Finds the apparent velocities (in the first Airplane's geometry axes, + observed from the Earth frame) at the center point of each bound RingVortex's + back leg due to any motion defined in Movement at the current time step. + + **Notes:** + + At each point, any apparent velocity due to Movement is opposite the motion due + to Movement. + + :return: A (M, 3) ndarray of floats representing the apparent velocity (in the + first Airplane's geometry axes, observed from the Earth frame) at the center + point of each bound RingVortex's back leg due to any motion defined in + Movement. If the current time step is the first time step, these velocities + will all be all zeros. Its units are in meters per second. + """ + # Check if this is the current time step. If so, return all zeros. + if self._current_step < 1: + return np.zeros((self.num_panels, 3), dtype=float) + + return cast( + np.ndarray, + -(self.stackCblvpb_GP1_CgP1 - self._lastStackCblvpb_GP1_CgP1) + / self.delta_time, + ) + + def _finalize_loads(self) -> None: + """For cases with static geometry, finds the final loads and load coefficients + for each of the SteadyProblem's Airplanes. For cases with variable geometry, + finds the final cycle-averaged and cycle-root-mean-squared loads and load + coefficients for each of the SteadyProblem's Airplanes. :return: None """ @@ -1686,7 +1887,6 @@ def _finalize_loads(self): these_airplanes = this_steady_problem.airplanes # Iterate through this time step's Airplanes. - airplane: geometry.airplane.Airplane for airplane_id, airplane in enumerate(these_airplanes): forces_W[airplane_id, :, results_step] = airplane.forces_W force_coefficients_W[airplane_id, :, results_step] = ( @@ -1701,13 +1901,10 @@ def _finalize_loads(self): # For each Airplane, calculate and then save the final or cycle-averaged and # RMS loads and load coefficients. - airplane: geometry.airplane.Airplane first_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(0) for airplane_id, airplane in enumerate(first_problem.airplanes): if static: - self.coupled_unsteady_problem.finalForces_W.append( - forces_W[airplane_id, :, -1] - ) + self.coupled_unsteady_problem.finalForces_W.append(forces_W[airplane_id, :, -1]) self.coupled_unsteady_problem.finalForceCoefficients_W.append( force_coefficients_W[airplane_id, :, -1] ) diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py index cd73faf96..ea24fb6e9 100644 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -1,271 +1,210 @@ +"""Contains the AirplaneMovement class. + +**Contains the following classes:** + +AirplaneMovement: A class used to contain an Airplane's movement. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +from collections.abc import Callable, Sequence + import numpy as np +from ... import geometry + from ..._parameter_validation import ( threeD_number_vectorLike_return_float, threeD_spacing_vectorLike_return_tuple, - positive_number_return_float, - positive_int_return_int, + int_in_range_return_int, + number_in_range_return_float, ) from .._functions import ( - oscillating_sinspaces, - oscillating_linspaces, - oscillating_customspaces + oscillating_sinspaces, + oscillating_linspaces, + oscillating_customspaces, ) -from ... import geometry - class SingleStepAirplaneMovement: + """A class used to contain an Airplane's movement. + + **Contains the following methods:** + + all_periods: All unique non zero periods from this AirplaneMovement, its + WingMovement(s), and their WingCrossSectionMovements. + + generate_airplanes: Creates the Airplane at each time step, and returns them in a + list. + + max_period: The longest period of AirplaneMovement's own motion, the motion(s) of + its sub movement object(s), and the motions of its sub sub movement objects. + """ + def __init__( self, single_step_wing_movements, - ampCg_E_CgP1=(0.0, 0.0, 0.0), - periodCg_E_CgP1=(0.0, 0.0, 0.0), - spacingCg_E_CgP1=("sine", "sine", "sine"), - phaseCg_E_CgP1=(0.0, 0.0, 0.0), - ampAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - periodAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - spacingAngles_E_to_B_izyx=("sine", "sine", "sine"), - phaseAngles_E_to_B_izyx=(0.0, 0.0, 0.0), - ): - - """ - :param wing_movements: list of SingleStepWingMovement - - This is a list of the WingMovement associated with each of the base - Airplane's Wings. It must have the same length as the base Airplane's + ampCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), + periodCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), + spacingCg_GP1_CgP1: ( + np.ndarray | Sequence[str | Callable[[np.ndarray], np.ndarray]] + ) = ( + "sine", + "sine", + "sine", + ), + phaseCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), + ) -> None: + """The initialization method. + + :param base_airplane: The base Airplane from which the Airplane at each time + step will be created. + :param wing_movements: A list of the WingMovements associated with each of the + base Airplane's Wings. It must have the same length as the base Airplane's list of Wings. - - :param ampCg_E_CgP1: array-like of 3 numbers, optional - - The amplitudes of the AirplaneMovement's changes in its Airplanes' - Cg_E_CgP1 parameters. Can be a tuple, list, or numpy array of - non-negative numbers (int or float). Also, each amplitude must be low - enough that it doesn't drive its base value out of the range of valid - values. Otherwise, this AirplaneMovement will try to create Airplanes - with invalid parameters values. Because the first Airplane's Cg_E_CgP1 - parameter must be all zeros, this means that the first Airplane's - ampCg_E_CgP1 parameter must also be all zeros. Values are converted to - floats internally. The default value is (0.0, 0.0, 0.0). The units are in - meters. - - :param periodCg_E_CgP1: array-like of 3 numbers, optional - - The periods of the AirplaneMovement's changes in its Airplanes' Cg_E_CgP1 - parameters. Can be a tuple, list, or numpy array of non-negative numbers - (int or float). Values are converted to floats internally. The default - value is (0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding - element in ampCg_E_CgP1 is 0.0 and non-zero if not. The units are in - seconds. - - :param spacingCg_E_CgP1: array-like of 3 strs or callables, optional - - The value determines the spacing of the AirplaneMovement's change in its - Airplanes' Cg_E_CgP1 parameters. Can be a tuple, list, or numpy array. - Each element can be the string "sine", the string "uniform", - or a callable custom spacing function. Custom spacing functions are for - advanced users and must start at 0, return to 0 after one period of 2*pi - radians, have amplitude of 1, be periodic, return finite values only, - and accept a ndarray as input and return a ndarray of the same shape. The - custom function is scaled by ampCg_E_CgP1, shifted horizontally by - phaseCg_E_CgP1, and vertically by the base value, with the period - controlled by periodCg_E_CgP1. The default value is ("sine", "sine", - "sine"). - - :param phaseCg_E_CgP1: array-like of 3 numbers, optional - - The phase offsets of the elements in the first time step's Airplane's - Cg_E_CgP1 parameter relative to the base Airplane's Cg_E_CgP1 parameter. - Can be a tuple, list, or numpy array of non-negative numbers (int or - float) in the range (-180.0, 180.0]. Values are converted to floats - internally. The default value is (0.0, 0.0, 0.0). Each element must be - 0.0 if the corresponding element in ampCg_E_CgP1 is 0.0 and non-zero if - not. The units are in degrees. - - :param ampAngles_E_to_B_izyx: array-like of 3 numbers, optional - - The amplitudes of the AirplaneMovement's changes in its Airplanes' - angles_E_to_B_izyx parameters. Can be a tuple, list, or numpy array of - numbers (int or float) in the range [0.0, 360.0). Also, each amplitude - must be low enough that it doesn't drive its base value out of the range - of valid values. Otherwise, this AirplaneMovement will try to create - Airplanes with invalid parameters values. Values are converted to floats - internally. The default value is (0.0, 0.0, 0.0). The units are in degrees. - - :param periodAngles_E_to_B_izyx: array-like of 3 numbers, optional - - The periods of the AirplaneMovement's changes in its Airplanes' - angles_E_to_B_izyx parameters. Can be a tuple, list, or numpy array of - non-negative numbers (int or float). Values are converted to floats - internally. The default value is (0.0, 0.0, 0.0). Each element must be - 0.0 if the corresponding element in ampAngles_E_to_B_izyx is 0.0 and - non-zero if not. The units are in seconds. - - :param spacingAngles_E_to_B_izyx: array-like of 3 strs or callables, optional - - The value determines the spacing of the AirplaneMovement's change in its - Airplanes' angles_E_to_B_izyx parameters. Can be a tuple, list, or numpy - array. Each element can be the string "sine", the string "uniform", - or a callable custom spacing function. Custom spacing functions are for - advanced users and must start at 0, return to 0 after one period of 2*pi - radians, have amplitude of 1, be periodic, return finite values only, - and accept a ndarray as input and return a ndarray of the same shape. The - custom function is scaled by ampAngles_E_to_B_izyx, shifted horizontally - by phaseAngles_E_to_B_izyx, and vertically by the base value, with the - period controlled by periodAngles_E_to_B_izyx. The default value is ( - "sine", "sine", "sine"). - - :param phaseAngles_E_to_B_izyx: array-like of 3 numbers, optional - - The phase offsets of the elements in the first time step's Airplane's - angles_E_to_B_izyx parameter relative to the base Airplane's - angles_E_to_B_izyx parameter. Can be a tuple, list, or numpy array of - numbers (int or float) in the range (-180.0, 180.0]. Values are converted to - floats internally. The default value is (0.0, 0.0, 0.0). Each element - must be 0.0 if the corresponding element in ampAngles_E_to_B_izyx is 0.0 - and non-zero if not. The units are in degrees. + :param ampCg_GP1_CgP1: An array-like object of non negative numbers (int or + float) with shape (3,) representing the amplitudes of the AirplaneMovement's + changes in its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or + ndarray. Values are converted to floats internally. Each amplitude must be + low enough that it doesn't drive its base value out of the range of valid + values. Otherwise, this AirplaneMovement will try to create Airplanes with + invalid parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter + must be all zeros, this means that the first Airplane's ampCg_GP1_CgP1 + parameter must also be all zeros. The units are in meters. The default is + (0.0, 0.0, 0.0). + :param periodCg_GP1_CgP1: An array-like object of non negative numbers (int or + float) with shape (3,) representing the periods of the AirplaneMovement's + changes in its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or + ndarray. Values are converted to floats internally. Each element must be 0.0 + if the corresponding element in ampCg_GP1_CgP1 is 0.0 and non zero if not. + The units are in seconds. The default is (0.0, 0.0, 0.0). + :param spacingCg_GP1_CgP1: An array-like object of strs or callables with shape + (3,) representing the spacing of the AirplaneMovement's changes in its + Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray. Each + element can be the str "sine", the str "uniform", or a callable custom + spacing function. Custom spacing functions are for advanced users and must + start at 0.0, return to 0.0 after one period of 2*pi radians, have amplitude + of 1.0, be periodic, return finite values only, and accept a ndarray as + input and return a ndarray of the same shape. Custom functions are scaled by + ampCg_GP1_CgP1, shifted horizontally and vertically by phaseCg_GP1_CgP1 and + the base value, and have a period set by periodCg_GP1_CgP1. The default is + ("sine", "sine", "sine"). + :param phaseCg_GP1_CgP1: An array-like object of numbers (int or float) with + shape (3,) representing the phase offsets of the elements in the first time + step's Airplane's Cg_GP1_CgP1 parameter relative to the base Airplane's + Cg_GP1_CgP1 parameter. Can be a tuple, list, or ndarray. Elements must lie + in the range (-180.0, 180.0]. Each element must be 0.0 if the corresponding + element in ampCg_GP1_CgP1 is 0.0 and non zero if not. Values are converted + to floats internally. The units are in degrees. The default is (0.0, 0.0, + 0.0). + :return: None """ - self.wing_movements = single_step_wing_movements - ampCg_E_CgP1 = threeD_number_vectorLike_return_float( - ampCg_E_CgP1, "ampCg_E_CgP1" - ) - if not np.all(ampCg_E_CgP1 >= 0.0): - raise ValueError("All elements in ampCg_E_CgP1 must be non-negative.") - self.ampCg_E_CgP1 = ampCg_E_CgP1 - - periodCg_E_CgP1 = threeD_number_vectorLike_return_float( - periodCg_E_CgP1, "periodCg_E_CgP1" - ) - if not np.all(periodCg_E_CgP1 >= 0.0): - raise ValueError("All elements in periodCg_E_CgP1 must be non-negative.") - for period_index, period in enumerate(periodCg_E_CgP1): - amp = self.ampCg_E_CgP1[period_index] - if amp == 0 and period != 0: - raise ValueError( - "If an element in ampCg_E_CgP1 is 0.0, the corresponding element in periodCg_E_CgP1 must be also be 0.0." - ) - self.periodCg_E_CgP1 = periodCg_E_CgP1 - - spacingCg_E_CgP1 = threeD_spacing_vectorLike_return_tuple( - spacingCg_E_CgP1, "spacingCg_E_CgP1" + ampCg_GP1_CgP1 = threeD_number_vectorLike_return_float( + ampCg_GP1_CgP1, "ampCg_GP1_CgP1" ) - self.spacingCg_E_CgP1 = spacingCg_E_CgP1 - phaseCg_E_CgP1 = threeD_number_vectorLike_return_float( - phaseCg_E_CgP1, "phaseCg_E_CgP1" - ) - if not (np.all(phaseCg_E_CgP1 > -180.0) and np.all(phaseCg_E_CgP1 <= 180.0)): - raise ValueError( - "All elements in phaseCg_E_CgP1 must be in the range (-180.0, 180.0]." - ) - for phase_index, phase in enumerate(phaseCg_E_CgP1): - amp = self.ampCg_E_CgP1[phase_index] - if amp == 0 and phase != 0: - raise ValueError( - "If an element in ampCg_E_CgP1 is 0.0, the corresponding element in phaseCg_E_CgP1 must be also be 0.0." - ) - self.phaseCg_E_CgP1 = phaseCg_E_CgP1 - - ampAngles_E_to_B_izyx = ( - threeD_number_vectorLike_return_float( - ampAngles_E_to_B_izyx, "ampAngles_E_to_B_izyx" - ) - ) - if not ( - np.all(ampAngles_E_to_B_izyx >= 0.0) - and np.all(ampAngles_E_to_B_izyx < 360.0) - ): - raise ValueError( - "All elements in ampAngles_E_to_B_izyx must be in the range [0.0, 360.0)." - ) - self.ampAngles_E_to_B_izyx = ampAngles_E_to_B_izyx + if not np.all(ampCg_GP1_CgP1 >= 0.0): + raise ValueError("All elements in ampCg_GP1_CgP1 must be non negative.") + self.ampCg_GP1_CgP1 = ampCg_GP1_CgP1 - periodAngles_E_to_B_izyx = ( - threeD_number_vectorLike_return_float( - periodAngles_E_to_B_izyx, "periodAngles_E_to_B_izyx" - ) + periodCg_GP1_CgP1 = threeD_number_vectorLike_return_float( + periodCg_GP1_CgP1, "periodCg_GP1_CgP1" ) - if not np.all(periodAngles_E_to_B_izyx >= 0.0): - raise ValueError( - "All elements in periodAngles_E_to_B_izyx must be non-negative." - ) - for period_index, period in enumerate(periodAngles_E_to_B_izyx): - amp = self.ampAngles_E_to_B_izyx[period_index] + if not np.all(periodCg_GP1_CgP1 >= 0.0): + raise ValueError("All elements in periodCg_GP1_CgP1 must be non negative.") + for period_index, period in enumerate(periodCg_GP1_CgP1): + amp = self.ampCg_GP1_CgP1[period_index] if amp == 0 and period != 0: raise ValueError( - "If an element in ampAngles_E_to_B_izyx is 0.0, the corresponding element in periodAngles_E_to_B_izyx must be also be 0.0." + "If an element in ampCg_GP1_CgP1 is 0.0, the corresponding element " + "in periodCg_GP1_CgP1 must be also be 0.0." ) - self.periodAngles_E_to_B_izyx = periodAngles_E_to_B_izyx + self.periodCg_GP1_CgP1 = periodCg_GP1_CgP1 - spacingAngles_E_to_B_izyx = ( + spacingCg_GP1_CgP1 = ( threeD_spacing_vectorLike_return_tuple( - spacingAngles_E_to_B_izyx, - "spacingAngles_E_to_B_izyx", + spacingCg_GP1_CgP1, "spacingCg_GP1_CgP1" ) ) - self.spacingAngles_E_to_B_izyx = spacingAngles_E_to_B_izyx + self.spacingCg_GP1_CgP1 = spacingCg_GP1_CgP1 - phaseAngles_E_to_B_izyx = ( - threeD_number_vectorLike_return_float( - phaseAngles_E_to_B_izyx, "phaseAngles_E_to_B_izyx" - ) + phaseCg_GP1_CgP1 = threeD_number_vectorLike_return_float( + phaseCg_GP1_CgP1, "phaseCg_GP1_CgP1" ) if not ( - np.all(phaseAngles_E_to_B_izyx > -180.0) - and np.all(phaseAngles_E_to_B_izyx <= 180.0) + np.all(phaseCg_GP1_CgP1 > -180.0) and np.all(phaseCg_GP1_CgP1 <= 180.0) ): raise ValueError( - "All elements in phaseAngles_E_to_B_izyx must be in the range (-180.0, 180.0]." + "All elements in phaseCg_GP1_CgP1 must be in the range (-180.0, 180.0]." ) - for phase_index, phase in enumerate(phaseAngles_E_to_B_izyx): - amp = self.ampAngles_E_to_B_izyx[phase_index] + for phase_index, phase in enumerate(phaseCg_GP1_CgP1): + amp = self.ampCg_GP1_CgP1[phase_index] if amp == 0 and phase != 0: raise ValueError( - "If an element in ampAngles_E_to_B_izyx is 0.0, the corresponding element in phaseAngles_E_to_B_izyx must be also be 0.0." + "If an element in ampCg_GP1_CgP1 is 0.0, the corresponding element " + "in phaseCg_GP1_CgP1 must be also be 0.0." ) - self.phaseAngles_E_to_B_izyx = phaseAngles_E_to_B_izyx + self.phaseCg_GP1_CgP1 = phaseCg_GP1_CgP1 + self.listCg_GP1_CgP1 = None - self.listCg_E_CgP1 = None - self.listAngles_E_to_B_izyx = None + @property + def all_periods(self) -> list[float]: + """All unique non zero periods from this AirplaneMovement, its WingMovement(s), + and their WingCrossSectionMovements. - def generate_next_airplane( - self, base_airplane, delta_time, num_steps, step, deformation_matrices - ): - """Creates the Airplane at the next timestep + :return: A list of all unique non zero periods in seconds. If all motion is + static, this will be an empty list. + """ + periods = [] - :param delta_time: number + # Collect all periods from WingMovement(s). + for wing_movement in self.wing_movements: + periods.extend(wing_movement.all_periods) - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - angles_E_to_B_izyx - :return: Airplanes + # Collect all periods from AirplaneMovement's own motion. + for period in self.periodCg_GP1_CgP1: + if period > 0.0: + periods.append(float(period)) + return periods - This is the Airplanes associated with this AirplaneMovement and deformation. + def generate_next_airplane( + self, base_airplane, delta_time: float | int, num_steps: int, step: int, deformation_matrices, + ) -> list[geometry.airplane.Airplane]: + """Creates the Airplane at each time step, and returns them in a list. + + :param num_steps: The number of time steps in this movement. It must be a + positive int. + :param delta_time: The time between each time step. It must be a positive number + (float or int), and will be converted internally to a float. The units are + in seconds. + :return: The list of Airplanes associated with this AirplaneMovement. """ - num_steps = positive_int_return_int( - num_steps, "num_steps" + num_steps = int_in_range_return_int( + num_steps, + "num_steps", + min_val=1, + min_inclusive=True, ) - delta_time = positive_number_return_float( - delta_time, "delta_time" + delta_time = number_in_range_return_float( + delta_time, "delta_time", min_val=0.0, min_inclusive=False ) + # Generate oscillating values for each dimension of Cg_E_CgP1. - if self.listCg_E_CgP1 is None: + if self.listCg_GP1_CgP1 is None: self._initialize_oscilating_dimensions(delta_time, num_steps, base_airplane) - # Generate oscillating values for each dimension of angles_E_to_B_izyx. - if self.listAngles_E_to_B_izyx is None: - self._initialize_oscilating_angles(delta_time, num_steps, base_airplane) - wings = [] # Iterate through the WingMovements. for wing_movement_id, wing_movement in enumerate(self.wing_movements): - # Add this vector the Airplane's 2D ndarray of Wings' Wings. wings.append( wing_movement.generate_next_wing( base_wing=base_airplane.wings[wing_movement_id], @@ -276,27 +215,24 @@ def generate_next_airplane( ) ) - # Get the non-changing Airplane attributes. + # Get the non changing Airplane attributes. this_name = base_airplane.name this_weight = base_airplane.weight - # the 1 is for not the base step, but 1 step deep - thisCg_E_CgP1 = self.listCg_E_CgP1[:, step] - theseAngles_E_to_B_izyx = self.listAngles_E_to_B_izyx[:, step] + thisCg_GP1_CgP1 = self.listCg_GP1_CgP1[:, step] # Make a new Airplane for this time step. this_airplane = geometry.airplane.Airplane( wings=wings, name=this_name, - Cg_E_CgP1=thisCg_E_CgP1, - angles_E_to_B_izyx=theseAngles_E_to_B_izyx, + Cg_GP1_CgP1=thisCg_GP1_CgP1, weight=this_weight, ) return this_airplane def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_airplane): - """Initializes the oscillating dimensions for Cg_E_CgP1 and angles_E_to_B_izyx. + """Initializes the oscillating dimensions for Cg_E_CgP1. :param delta_time: number This is the time between each time step. It must be a positive number ( @@ -307,33 +243,33 @@ def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_airplane This is the number of time steps in this movement. It must be a positive int. """ - self.listCg_E_CgP1 = np.zeros((3, num_steps), dtype=float) + self.listCg_GP1_CgP1 = np.zeros((3, num_steps), dtype=float) for dim in range(3): - spacing = self.spacingCg_E_CgP1[dim] + spacing = self.spacingCg_GP1_CgP1[dim] if spacing == "sine": - self.listCg_E_CgP1[dim, :] = oscillating_sinspaces( - amps=self.ampCg_E_CgP1[dim], - periods=self.periodCg_E_CgP1[dim], - phases=self.phaseCg_E_CgP1[dim], - bases=base_airplane.Cg_E_CgP1[dim], + self.listCg_GP1_CgP1[dim, :] = oscillating_sinspaces( + amps=self.ampCg_GP1_CgP1[dim], + periods=self.periodCg_GP1_CgP1[dim], + phases=self.phaseCg_GP1_CgP1[dim], + bases=base_airplane.Cg_GP1_CgP1[dim], num_steps=num_steps, delta_time=delta_time, ) elif spacing == "uniform": - self.listCg_E_CgP1[dim, :] = oscillating_linspaces( - amps=self.ampCg_E_CgP1[dim], - periods=self.periodCg_E_CgP1[dim], - phases=self.phaseCg_E_CgP1[dim], - bases=base_airplane.Cg_E_CgP1[dim], + self.listCg_GP1_CgP1[dim, :] = oscillating_linspaces( + amps=self.ampCg_GP1_CgP1[dim], + periods=self.periodCg_GP1_CgP1[dim], + phases=self.phaseCg_GP1_CgP1[dim], + bases=base_airplane.Cg_GP1_CgP1[dim], num_steps=num_steps, delta_time=delta_time, ) elif callable(spacing): - self.listCg_E_CgP1[dim, :] = oscillating_customspaces( - amps=self.ampCg_E_CgP1[dim], - periods=self.periodCg_E_CgP1[dim], - phases=self.phaseCg_E_CgP1[dim], - bases=base_airplane.Cg_E_CgP1[dim], + self.listCg_GP1_CgP1[dim, :] = oscillating_customspaces( + amps=self.ampCg_GP1_CgP1[dim], + periods=self.periodCg_GP1_CgP1[dim], + phases=self.phaseCg_GP1_CgP1[dim], + bases=base_airplane.Cg_GP1_CgP1[dim], num_steps=num_steps, delta_time=delta_time, custom_function=spacing, @@ -341,51 +277,22 @@ def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_airplane else: raise ValueError(f"Invalid spacing value: {spacing}") - def _initialize_oscilating_angles(self, delta_time, num_steps, base_airplane): - """Initializes the oscillating angles for angles_E_to_B_izyx. - :param delta_time: number - - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - :param num_steps: int - This is the number of time steps in this movement. It must be a positive - int. - :param base_airplane: Airplane + @property + def max_period(self) -> float: + """The longest period of AirplaneMovement's own motion, the motion(s) of its sub + movement object(s), and the motions of its sub sub movement objects. - This is the base Airplane from which the AirplaneMovement will generate - its Airplanes. + :return: The longest period in seconds. If all the motion is static, this will + be 0.0. """ - self.listAngles_E_to_B_izyx = np.zeros((3, num_steps), dtype=float) - for dim in range(3): - spacing = self.spacingAngles_E_to_B_izyx[dim] - if spacing == "sine": - self.listAngles_E_to_B_izyx[dim, :] = oscillating_sinspaces( - amps=self.ampAngles_E_to_B_izyx[dim], - periods=self.periodAngles_E_to_B_izyx[dim], - phases=self.phaseAngles_E_to_B_izyx[dim], - bases=base_airplane.angles_E_to_B_izyx[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif spacing == "uniform": - self.listAngles_E_to_B_izyx[dim, :] = oscillating_linspaces( - amps=self.ampAngles_E_to_B_izyx[dim], - periods=self.periodAngles_E_to_B_izyx[dim], - phases=self.phaseAngles_E_to_B_izyx[dim], - bases=base_airplane.angles_E_to_B_izyx[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif callable(spacing): - self.listAngles_E_to_B_izyx[dim, :] = oscillating_customspaces( - amps=self.ampAngles_E_to_B_izyx[dim], - periods=self.periodAngles_E_to_B_izyx[dim], - phases=self.phaseAngles_E_to_B_izyx[dim], - bases=base_airplane.angles_E_to_B_izyx[dim], - num_steps=num_steps, - delta_time=delta_time, - custom_function=spacing, - ) - else: - raise ValueError(f"Invalid spacing value: {spacing}") + wing_movement_max_periods = [] + for wing_movement in self.wing_movements: + wing_movement_max_periods.append(wing_movement.max_period) + max_wing_movement_period = max(wing_movement_max_periods) + + return float( + max( + max_wing_movement_period, + np.max(self.periodCg_GP1_CgP1), + ) + ) diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index 030d18f5a..7f6c590fd 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -13,8 +13,8 @@ from .single_step_operating_point_movement import SingleStepOperatingPointMovement from ..._parameter_validation import ( - positive_number_return_float, - positive_int_return_int, + number_in_range_return_float, + int_in_range_return_int, ) class SingleStepMovement: @@ -122,8 +122,8 @@ def __init__( self.operating_point_movement = single_step_operating_point_movement if delta_time is not None: - delta_time = positive_number_return_float( - delta_time, "delta_time" + delta_time = number_in_range_return_float( + delta_time, "delta_time", min_val=0.0, min_inclusive=False ) else: @@ -151,11 +151,8 @@ def __init__( delta_time = sum(delta_times) / len(delta_times) self.delta_time = delta_time - - num_steps = positive_int_return_int( - num_steps, "num_steps" - ) - + num_steps = int_in_range_return_int(num_steps, "num_steps", min_val=1, min_inclusive=True) + self.num_steps = num_steps # Generate a list of lists of Airplanes that are the steps through each diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py index 392e696ee..667a9ed07 100644 --- a/pterasoftware/movements/single_step/single_step_operating_point_movement.py +++ b/pterasoftware/movements/single_step/single_step_operating_point_movement.py @@ -16,10 +16,8 @@ from ...operating_point import OperatingPoint from ..._parameter_validation import ( - non_negative_number_return_float, number_in_range_return_float, - positive_number_return_float, - positive_int_return_int, + int_in_range_return_int, ) @@ -94,12 +92,12 @@ def __init__( is 0.0 and non-zero if not. The units are in degrees. """ - self.ampVCg__E = non_negative_number_return_float( - ampVCg__E, "ampVCg__E" + self.ampVCg__E = number_in_range_return_float( + ampVCg__E, "ampVCg__E", min_val=0.0, min_inclusive=True ) - periodVCg__E = non_negative_number_return_float( - periodVCg__E, "periodVCg__E" + periodVCg__E = number_in_range_return_float( + periodVCg__E, "periodVCg__E", min_val=0.0, min_inclusive=True ) if self.ampVCg__E == 0 and periodVCg__E != 0: raise ValueError("If ampVCg__E is 0.0, then periodVCg__E must also be 0.0.") @@ -144,11 +142,11 @@ def generate_next_operating_point(self, delta_time, base_operating_point: Operat This is the list of OperatingPoints associated with this OperatingPointMovement. """ - num_steps = positive_int_return_int( - num_steps, "num_steps" + num_steps = int_in_range_return_int( + num_steps, "num_steps", min_val=1, min_inclusive=True ) - delta_time = positive_number_return_float( - delta_time, "delta_time" + delta_time = number_in_range_return_float( + delta_time, "delta_time", min_val=0.0, min_inclusive=False ) if self.listVCg__E is None: diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index f1993091b..85af8dcc9 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -14,8 +14,8 @@ from ..._parameter_validation import ( threeD_number_vectorLike_return_float, threeD_spacing_vectorLike_return_tuple, - positive_number_return_float, - positive_int_return_int, + int_in_range_return_int, + number_in_range_return_float ) from .._functions import ( @@ -288,11 +288,11 @@ def generate_next_wing_cross_sections( This is the list of WingCrossSections associated with this WingCrossSectionMovement. """ - num_steps = positive_int_return_int( - num_steps, "num_steps" + num_steps = int_in_range_return_int( + num_steps, "num_steps", min_val=1, min_inclusive=True ) - delta_time = positive_number_return_float( - delta_time, "delta_time" + delta_time = number_in_range_return_float( + delta_time, "delta_time", min_val=0.0, min_inclusive=False ) # Generate oscillating values for each dimension of Lp_Wcsp_Lpp. diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py index 2d3530cf5..bc17f7e6e 100644 --- a/pterasoftware/movements/single_step/single_step_wing_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_movement.py @@ -12,8 +12,8 @@ from ..._parameter_validation import ( threeD_number_vectorLike_return_float, threeD_spacing_vectorLike_return_tuple, - positive_number_return_float, - positive_int_return_int, + int_in_range_return_int, + number_in_range_return_float, ) from .._functions import ( @@ -285,11 +285,11 @@ def generate_next_wing(self, base_wing, delta_time, num_steps, step, deformation This is the list of Wings associated with this WingMovement. """ - num_steps = positive_int_return_int( - num_steps, "num_steps" + num_steps = int_in_range_return_int( + num_steps, "num_steps", min_val=1, min_inclusive=True ) - delta_time = positive_number_return_float( - delta_time, "delta_time" + delta_time = number_in_range_return_float( + delta_time, "delta_time", min_val=0.0, min_inclusive=False ) # Account for null deformation_matrices input. if deformation_matrices is None: diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 74a238aec..30574116b 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -576,7 +576,7 @@ def __init__( self.wing_density = 0.012 # per unit height kg/m^2 self.moment_scaling_factor = 1e-2 self.spring_constant = 1e-1 - self.aero_scaling = 1.0 + self.aero_scaling = 0.0 self.new_integrand = False # self.wing_density = 0.012 # per unit height kg/m^2 From 9ea29ccbbe0e84dfbeaf85158bb9de79f492610f Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Tue, 6 Jan 2026 14:27:24 -0500 Subject: [PATCH 10/40] add slep calculations --- ...led_unsteady_ring_vortex_lattice_method.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 9736f5b09..b2312597e 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -82,8 +82,17 @@ def __init__(self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem) -> self.num_airplanes: int = len(first_steady_problem.airplanes) num_panels = 0 + panel_count = 0 + self.slep_point_indices = [] for airplane in first_steady_problem.airplanes: num_panels += airplane.num_panels + for wing in airplane.wings: + for wing_cross_section in wing.wing_cross_sections: + self.slep_point_indices.append(panel_count) + if wing_cross_section.num_spanwise_panels is not None: + panel_count += wing_cross_section.num_spanwise_panels + self.slep_point_indices = np.array(self.slep_point_indices, dtype=int) + self.num_panels: int = num_panels # Initialize attributes to hold aerodynamic data that pertain to the simulation. @@ -133,6 +142,21 @@ def __init__(self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem) -> self._lastStackCblvpl_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) self._lastStackCblvpb_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + # The current time step's center bound LineVortex points for the right, + # front, left, and back legs (in the first Airplane's geometry axes, + # relative to the local strip leading edge point). + self.stackCblvpr_GP1_Slep: np.ndarray = np.empty(0, dtype=float) + self.stackCblvpf_GP1_Slep: np.ndarray = np.empty(0, dtype=float) + self.stackCblvpl_GP1_Slep: np.ndarray = np.empty(0, dtype=float) + self.stackCblvpb_GP1_Slep: np.ndarray = np.empty(0, dtype=float) + + # The colocation panel points and the front left panel point (in the first Airplane's + # geometry axes, relative to the local strip leading edge point and the first + # Airplane's CG respectively). + self.stackCpp_GP1_Slep: np.ndarray = np.empty(0, dtype=float) + self.moments_GP1_Slep: np.ndarray = np.empty(0, dtype=float) + self.stackFlpp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + # Right, front, left, and back bound RingVortex vectors (in the first # Airplane's geometry axes). self.stackRbrv_GP1: np.ndarray = np.empty(0, dtype=float) @@ -401,6 +425,14 @@ def run( (self.num_panels, 3), dtype=float ) + self.stackCblvpr_GP1_Slep = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpf_GP1_Slep = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpl_GP1_Slep = np.zeros((self.num_panels, 3), dtype=float) + self.stackCblvpb_GP1_Slep = np.zeros((self.num_panels, 3), dtype=float) + self.stackCpp_GP1_Slep = np.zeros((self.num_panels, 3), dtype=float) + self.moments_GP1_Slep = np.zeros((self.num_panels, 3), dtype=float) + self.stackFlpp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) + self.stackRbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) self.stackFbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) self.stackLbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) @@ -1316,6 +1348,40 @@ def _calculate_loads(self) -> None: # geometry axes before passing to process_solver_loads. _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) + # TODO: Remove the duplicated code below by refactoring + self._update_bound_vortex_positions_relative_to_slep_points() + + rightLegMoments_GP1_Slep = _functions.numba_1d_explicit_cross( + self.stackCblvpr_GP1_Slep, rightLegForces_GP1 + ) + frontLegMoments_GP1_Slep = _functions.numba_1d_explicit_cross( + self.stackCblvpf_GP1_Slep, frontLegForces_GP1 + ) + leftLegMoments_GP1_Slep = _functions.numba_1d_explicit_cross( + self.stackCblvpl_GP1_Slep, leftLegForces_GP1 + ) + backLegMoments_GP1_Slep = _functions.numba_1d_explicit_cross( + self.stackCblvpb_GP1_Slep, backLegForces_GP1 + ) + + # The unsteady moment is calculated at the collocation point because the + # unsteady force acts on the bound RingVortex, whose center is at the + # collocation point, not at the Panel's centroid. + + # Find the moments (in the first Airplane's geometry axes, relative to the + # first Airplane's CG) due to the unsteady component of the force on each Panel. + unsteady_moments_GP1_Slep = _functions.numba_1d_explicit_cross( + self.stackCpp_GP1_Slep, unsteady_forces_GP1 + ) + + self.moments_GP1_Slep = ( + rightLegMoments_GP1_Slep + + frontLegMoments_GP1_Slep + + leftLegMoments_GP1_Slep + + backLegMoments_GP1_Slep + + unsteady_moments_GP1_Slep + ) + def _populate_next_airplanes_wake(self) -> None: """Updates the next time step's Airplanes' wakes. @@ -1960,3 +2026,30 @@ def _finalize_loads(self) -> None: ) ) ) + + def _update_bound_vortex_positions_relative_to_slep_points(self) -> None: + """Updates the bound RingVortex position variables to be relative to the + Airplane's SLEP points. + + :return: None + """ + # Find the bound RingVortex leg center positions relative to the SLEP points. + for panel_num, panel in enumerate(self.panels): + self.stackFlpp_GP1_CgP1[panel_num] = panel.Flpp_GP1_CgP1 + slep_points = self.stackFlpp_GP1_CgP1[self.slep_point_indices] + slep_map = np.searchsorted(self.slep_point_indices, np.arange(self.num_panels), side="right") - 1 + stack_leading_edge_points = np.array([ + slep_points[i] for i in slep_map + ]) + self.stackCblvpr_GP1_Slep = self.stackCblvpr_GP1_CgP1 - stack_leading_edge_points + self.stackCblvpf_GP1_Slep = self.stackCblvpf_GP1_CgP1 - stack_leading_edge_points + self.stackCblvpl_GP1_Slep = self.stackCblvpl_GP1_CgP1 - stack_leading_edge_points + self.stackCblvpb_GP1_Slep = self.stackCblvpb_GP1_CgP1 - stack_leading_edge_points + + # Find the collocation point positions relative to the SLEP points. + slep_points = self.stackCpp_GP1_CgP1[self.slep_point_indices] + slep_map = np.searchsorted(self.slep_point_indices, np.arange(self.num_panels), side="right") - 1 + stack_leading_edge_points = np.array([ + slep_points[i] for i in slep_map + ]) + self.stackCpp_GP1_Slep = self.stackCpp_GP1_CgP1 - stack_leading_edge_points From c68388b5406c109e3e0081e274fa3e4015ffa926 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 14 Jan 2026 15:31:48 -0500 Subject: [PATCH 11/40] add viz and better deformation --- examples/demos/demo_single_step.py | 4 +- examples/demos/demo_single_step_flat.py | 444 ++++++++++++++ ...led_unsteady_ring_vortex_lattice_method.py | 21 +- pterasoftware/problems.py | 579 +++++++----------- 4 files changed, 672 insertions(+), 376 deletions(-) create mode 100644 examples/demos/demo_single_step_flat.py diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py index 631add4ba..80006b658 100644 --- a/examples/demos/demo_single_step.py +++ b/examples/demos/demo_single_step.py @@ -372,7 +372,7 @@ # Define a new OperatingPoint. example_operating_point = ps.operating_point.OperatingPoint( - rho=1.225, vCg__E=10.0, alpha=1.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 + rho=1.225, vCg__E=10.0, alpha=0.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 ) # Define the operating point's OperatingPointMovement. @@ -395,7 +395,7 @@ airplane_movements=[airplane_movement], operating_point_movement=operating_point_movement, delta_time=0.03, - num_cycles=4, + num_cycles=3, num_chords=None, num_steps=None, ) diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py new file mode 100644 index 000000000..f57930215 --- /dev/null +++ b/examples/demos/demo_single_step_flat.py @@ -0,0 +1,444 @@ +"""This is script is an example of how to run Ptera Software's +UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static +Movement.""" + +# First, import the software's main package. Note that if you wished to import this +# software into another package, you would first install it by running "pip install +# pterasoftware" in your terminal. +import pterasoftware as ps + +# Create an Airplane with our custom geometry. I am going to declare every parameter +# for Airplane, even though most of them have usable default values. This is for +# educational purposes, but keep in mind that it makes the code much longer than it +# needs to be. For details about each parameter, read the detailed class docstring. +# The same caveats apply to the other classes, methods, and functions I call in this +# script. + + +# offsets for the spacing +num_spanwise_panels = 1 +Lp_Wcsp_Lpp_Offsets = (0.1, 0.5, 0.0) + +# Wing cross section initialization +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="cosine" 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, + ), + ) + ) + + +example_airplane = ps.geometry.airplane.Airplane( + wings=[ + 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), + num_chordwise_panels=6, + chordwise_spacing="uniform", + ), + 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), + 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, +) + +# The main Wing was defined to have symmetric=True, mirror_only=False, and with a +# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, +# that Wing had type 5 symmetry (see the Wing class documentation for more details on +# symmetry types). Therefore, it was actually split into two Wings, the with the +# second Wing being a reflected version of the first. Therefore, we need to define a +# WingMovement for this reflected Wing. To start, we'll first define the reflected +# main wing's root and tip WingCrossSections' WingCrossSectionMovements. + +# defintions for wing movement parameters +# dephase_x = 0.0 +# period_x = 1.0 +# amplitude_x = 2.0 + +# dephase_y = 0.0 +# period_y = 1.0 +# amplitude_y = 3.0 + +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 + +# A list of movements for the main wing +main_movements_list = [] +main_single_step_movements_list = [] + +# A list of movements for the reflected wing +reflected_movements_list = [] +reflected_single_step_movements_list = [] + +for i in range(len(example_airplane.wings[0].wing_cross_sections)): + if i == 0: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], + ) + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() + + main_movements_list.append(movement) + main_single_step_movements_list.append(single_step_movement) + reflected_movements_list.append(movement) + reflected_single_step_movements_list.append(single_step_movement) + + else: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + 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), + ) + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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_movements_list.append(movement) + main_single_step_movements_list.append(single_step_movement) + reflected_movements_list.append(movement) + reflected_single_step_movements_list.append(single_step_movement) + + +# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. +v_tail_root_wing_cross_section_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_wing_cross_section_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), + ) +) + +single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), +) +single_step_v_tail_tip_wing_cross_section_movement = ( + ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), + ) +) + + +# Now define the main wing's WingMovement, the reflected main wing's WingMovement and +# the v-tail's WingMovement. +main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[0], + wing_cross_section_movements=main_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), +) + +single_step_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=main_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), + ) +) + +reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[1], + wing_cross_section_movements=reflected_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), +) + +single_step_reflected_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), + ) +) + +v_tail_movement = ps.movements.wing_movement.WingMovement( + base_wing=example_airplane.wings[2], + wing_cross_section_movements=[ + v_tail_root_wing_cross_section_movement, + v_tail_tip_wing_cross_section_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), +) + +single_step_v_tail_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=[ + single_step_v_tail_root_wing_cross_section_movement, + single_step_v_tail_tip_wing_cross_section_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), + ) +) + +# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now +# contained within the WingMovements. This is optional, but it can make debugging +# easier. +del v_tail_root_wing_cross_section_movement +del v_tail_tip_wing_cross_section_movement + +# Now define the example airplane's AirplaneMovement. +airplane_movement = ps.movements.airplane_movement.AirplaneMovement( + base_airplane=example_airplane, + wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_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), +) + +single_step_airplane_movement = ( + ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + + single_step_wing_movements=[ + single_step_main_wing_movement, + single_step_reflected_main_wing_movement, + single_step_v_tail_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), + ) +) + +# Delete the extraneous pointers to the WingMovements. +del main_wing_movement +del reflected_main_wing_movement +del v_tail_movement +del single_step_main_wing_movement +del single_step_reflected_main_wing_movement +del single_step_v_tail_movement + +# Define a new OperatingPoint. +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 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" +) + +single_step_operating_point_movement = ( + ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" + ) +) + +# Delete the extraneous pointer. +del example_operating_point + +# Define the Movement. This contains the AirplaneMovement and the +# OperatingPointMovement. +movement = ps.movements.movement.Movement( + airplane_movements=[airplane_movement], + operating_point_movement=operating_point_movement, + delta_time=0.06, + num_cycles=3, + num_chords=None, + num_steps=None, +) + +single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( + single_step_airplane_movements=[single_step_airplane_movement], + single_step_operating_point_movement=single_step_operating_point_movement, + delta_time=0.03, + num_steps=movement.num_steps, +) + +# Delete the extraneous pointers. +del airplane_movement +del operating_point_movement + +# Define the UnsteadyProblem. +example_problem = ps.problems.BetterAeroelasticUnsteadyProblem( + movement=movement, + single_step_movement=single_step_movement, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, +) + +# Delete the extraneous pointer. +del example_problem + +# Run the solver. +example_solver.run( + prescribed_wake=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=True, +) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index b2312597e..bb9ca8dfa 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -154,8 +154,10 @@ def __init__(self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem) -> # geometry axes, relative to the local strip leading edge point and the first # Airplane's CG respectively). self.stackCpp_GP1_Slep: np.ndarray = np.empty(0, dtype=float) + # Leading edge of the panel points + self.stack_Flpp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) self.moments_GP1_Slep: np.ndarray = np.empty(0, dtype=float) - self.stackFlpp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) + self.stack_leading_edge_points: np.ndarray = np.empty(0, dtype=float) # Right, front, left, and back bound RingVortex vectors (in the first # Airplane's geometry axes). @@ -2038,18 +2040,13 @@ def _update_bound_vortex_positions_relative_to_slep_points(self) -> None: self.stackFlpp_GP1_CgP1[panel_num] = panel.Flpp_GP1_CgP1 slep_points = self.stackFlpp_GP1_CgP1[self.slep_point_indices] slep_map = np.searchsorted(self.slep_point_indices, np.arange(self.num_panels), side="right") - 1 - stack_leading_edge_points = np.array([ + self.stack_leading_edge_points = np.array([ slep_points[i] for i in slep_map ]) - self.stackCblvpr_GP1_Slep = self.stackCblvpr_GP1_CgP1 - stack_leading_edge_points - self.stackCblvpf_GP1_Slep = self.stackCblvpf_GP1_CgP1 - stack_leading_edge_points - self.stackCblvpl_GP1_Slep = self.stackCblvpl_GP1_CgP1 - stack_leading_edge_points - self.stackCblvpb_GP1_Slep = self.stackCblvpb_GP1_CgP1 - stack_leading_edge_points + self.stackCblvpr_GP1_Slep = self.stackCblvpr_GP1_CgP1 - self.stack_leading_edge_points + self.stackCblvpf_GP1_Slep = self.stackCblvpf_GP1_CgP1 - self.stack_leading_edge_points + self.stackCblvpl_GP1_Slep = self.stackCblvpl_GP1_CgP1 - self.stack_leading_edge_points + self.stackCblvpb_GP1_Slep = self.stackCblvpb_GP1_CgP1 - self.stack_leading_edge_points # Find the collocation point positions relative to the SLEP points. - slep_points = self.stackCpp_GP1_CgP1[self.slep_point_indices] - slep_map = np.searchsorted(self.slep_point_indices, np.arange(self.num_panels), side="right") - 1 - stack_leading_edge_points = np.array([ - slep_points[i] for i in slep_map - ]) - self.stackCpp_GP1_Slep = self.stackCpp_GP1_CgP1 - stack_leading_edge_points + self.stackCpp_GP1_Slep = self.stackCpp_GP1_CgP1 - self.stack_leading_edge_points diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 30574116b..821341e20 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -18,9 +18,9 @@ import numpy as np - from copy import deepcopy -from scipy.integrate import quad +from scipy.integrate import quad, solve_ivp +import matplotlib.pyplot as plt from .movements.single_step.single_step_movement import SingleStepMovement from . import _parameter_validation, _transformations, geometry, movements from . import operating_point as operating_point_mod @@ -571,13 +571,16 @@ def __init__( ) self.positions = [] self.net_deformation = None + self.angluar_velocities = None # Tunable Parameters - self.wing_density = 0.012 # per unit height kg/m^2 - self.moment_scaling_factor = 1e-2 - self.spring_constant = 1e-1 + self.wing_density = 0.12 # per unit height kg/m^2 + self.moment_scaling_factor = 1.0 + self.spring_constant = 2.0 + self.damping_constant = 1.0 self.aero_scaling = 0.0 - self.new_integrand = False + self.numerical_integration = True # use numerical integration or closed form solution + self.damping_eps = 1e-3 # critical damping tolerance # self.wing_density = 0.012 # per unit height kg/m^2 # self.moment_scaling_factor = 5 @@ -585,6 +588,15 @@ def __init__( # self.aero_scaling = 0.0 # self.new_integrand = True + self.per_step_data = [] + self.net_data = [] + self.angluar_velocity_data = [] + self.per_step_inertial = [] + self.per_step_aero = [] + self.per_step_spring = [] + self.base_wing_positions = None + self.flap_points = [] + self.integration_method = getattr(self, "integration_method", "substep") # "substep" or "newmark" self.max_delta_theta_per_substep = getattr(self, "max_delta_theta_per_substep", 0.005) self.max_theta_abs = getattr(self, "max_theta_abs", np.deg2rad(30.0)) @@ -600,15 +612,18 @@ def calculate_wing_panel_accelerations(self): dt = self.movement.delta_time return (self.positions[-1] - 2 * self.positions[-2] + self.positions[-3]) / (dt * dt) + def calculate_mass_matrix(self, wing): + areas = np.array([[panel.area for panel in row] for row in wing.panels]) + return ( + np.repeat(areas[:, :, None], 3, axis=2) + * self.wing_density + ) + def initialize_next_problem(self, solver): - if self.new_integrand: - deformation_matrices = self.calculate_wing_deformation_new( - solver, len(self._steady_problems) - ) - else: - deformation_matrices = self.calculate_wing_deformation( - solver, len(self._steady_problems) - ) + + deformation_matrices = self.calculate_wing_deformation( + solver, len(self._steady_problems) + ) self.curr_airplanes, self.curr_operating_point = ( self.single_step_movement.generate_next_movement( base_airplanes=self.curr_airplanes, @@ -624,331 +639,6 @@ def initialize_next_problem(self, solver): ) ) - def calculate_wing_deformation_new(self, solver, step): - """ - Improved torsional-strip update — minimal but correct structural reference axis, - inertial moment calculation using panel accelerations, and twist measured as - chord angle. Returns self.net_deformation (same shape as before). - """ - - curr_problem: SteadyProblem = self._steady_problems[-1] - airplane = curr_problem.airplanes[0] - wing: geometry.wing.Wing = airplane.wings[0] - - # panel counts - nc = wing.num_chordwise_panels - ns = wing.num_spanwise_panels - - # Gather arrays (vectorized) - aeroMoments = np.array([[p.moments_GP1_CgP1 for p in row] for row in wing.panels]) # (nc,ns,3) - # append current collocation positions to self.positions (same as you did) - self.positions.append(np.array([[p.Cpp_GP1_CgP1 for p in row] for row in wing.panels])) # (nc,ns,3) - areas = np.array([[p.area for p in row] for row in wing.panels]) # (nc,ns) - - # --- Build structural reference axis points (undeformed mid-chord) ---------- - undeformed_wing = self.steady_problems[-1].airplanes[0].wings[0] - # we'll compute LE and TE from panel corner properties for each undeformed panel - span_axis_points = np.zeros((ns, 3)) - span_y = np.zeros(ns) - for i in range(ns): - LE_pts = [] - TE_pts = [] - for j in range(nc): - p = undeformed_wing.panels[j][i] - LE = np.asarray(p.Flpp_GP1_CgP1) # front-left point - TE = np.asarray(p.Brpp_GP1_CgP1) + np.asarray(p.rightLeg_GP1) # back-right + rightLeg -> TE approx - LE_pts.append(LE) - TE_pts.append(TE) - LE_mean = np.mean(LE_pts, axis=0) - TE_mean = np.mean(TE_pts, axis=0) - span_axis_points[i, :] = 0.5 * (LE_mean + TE_mean) # mid-chord - span_y[i] = np.mean([pt[1] for pt in LE_pts]) - - # approximate dy - dy = np.mean(np.diff(span_y)) if ns > 1 else 1.0 - dt = float(self.movement.delta_time) - - # --- Panel accelerations & inertial forces -------------------------------- - panel_accels = self.calculate_wing_panel_accelerations() # (nc,ns,3) - panel_masses = areas * self.wing_density # (nc,ns) - F_inertial = panel_accels * panel_masses[:, :, np.newaxis] # (nc,ns,3) - - # --- reference points expanded to panel shape ----------------------------- - ref_points = np.repeat(span_axis_points[np.newaxis, :, :], nc, axis=0) # (nc,ns,3) - - # --- inertial moment per panel: r x F (vector) --------------------------- - curr_pos = self.positions[-1] # (nc,ns,3) - r = curr_pos - ref_points # vector from structural axis to panel CG - panel_inertial_moments = np.cross(r, F_inertial, axis=2) # (nc,ns,3) - - # --- Choose the correct moment component for torsion --------------------- - # GP1: +y is span direction. Torsion about span axis => use the y-component (index 1). - aero_M_y = aeroMoments[:, :, 1] # (nc,ns) - inertial_M_y = panel_inertial_moments[:, :, 1] # (nc,ns) - - # sum chordwise to get per-span driving moment - M_aero_span = np.sum(aero_M_y, axis=0) # (ns,) - M_inertial_span = np.sum(inertial_M_y, axis=0) # (ns,) - M_net = M_aero_span - M_inertial_span # (ns,) - - # --- Rotational inertia about span axis per strip ----------------------- - r_perp_sq = np.sum(r[..., [0, 2]] ** 2, axis=2) # (nc,ns) using x,z dist - I_theta = np.sum(panel_masses * r_perp_sq, axis=0) # (ns,) - - # --- Compute twist angle per span station from chord orientation ------- - # use undeformed LE/TE to get theta_ref if not already set - if not hasattr(self, "_theta_ref") or self._theta_ref is None: - self._theta_ref = np.zeros(ns) - for i in range(ns): - p_le = np.asarray(undeformed_wing.panels[0][i].Flpp_GP1_CgP1) - p_te = np.asarray(undeformed_wing.panels[-1][i].Brpp_GP1_CgP1) + np.asarray(undeformed_wing.panels[-1][i].rightLeg_GP1) - v0 = p_te[[0, 2]] - p_le[[0, 2]] # projected into x-z - self._theta_ref[i] = np.arctan2(v0[1], v0[0]) - theta_ref = self._theta_ref - - # initialize dynamic state if missing - if not hasattr(self, "_theta"): - self._theta = theta_ref.copy() - self._theta_dot = np.zeros_like(self._theta) - - # measure current chord-based theta (from current geometry) - current_theta = np.zeros(ns) - for i in range(ns): - # define LE/TE from current panels if corner data available, otherwise use Cpp proxies - try: - p_le = np.asarray(wing.panels[0][i].Flpp_GP1_CgP1) - p_te = np.asarray(wing.panels[-1][i].Brpp_GP1_CgP1) + np.asarray(wing.panels[-1][i].rightLeg_GP1) - except Exception: - p_le = curr_pos[0, i] - p_te = curr_pos[-1, i] - v = p_te[[0, 2]] - p_le[[0, 2]] - current_theta[i] = np.arctan2(v[1], v[0]) - - # structural parameters (tunable on self) - GJ = getattr(self, "GJ", 1e4) # torsional rigidity - c_theta = getattr(self, "c_theta", 1e2) # damping - k_theta = getattr(self, "k_theta", 0.0) # optional torsion spring per station (N·m/rad) - - # --- discrete laplacian (spanwise torsion coupling) --------------------- - lap = np.zeros(ns) - if ns > 1: - lap[1:-1] = (self._theta[2:] - 2.0 * self._theta[1:-1] + self._theta[0:-2]) / (dy * dy) - # boundaries: mirror (free-tip) or use clamped flags - if getattr(self, "root_clamped", False): - lap[0] = (self._theta[1] - 2.0 * self._theta[0] + theta_ref[0]) / (dy * dy) - else: - lap[0] = (self._theta[1] - 2.0 * self._theta[0] + self._theta[1]) / (dy * dy) - if getattr(self, "tip_clamped", False): - lap[-1] = (theta_ref[-1] - 2.0 * self._theta[-1] + self._theta[-2]) / (dy * dy) - else: - lap[-1] = (self._theta[-2] - 2.0 * self._theta[-1] + self._theta[-2]) / (dy * dy) - - # --- equation: I*theta_ddot + c*theta_dot + k_theta*(theta-theta_ref) - GJ*lap = M_net - eps = 1e-12 - I_safe = np.where(I_theta > eps, I_theta, eps) - torque_spring = k_theta * (self._theta - theta_ref) - theta_ddot = (M_net - torque_spring + GJ * lap - c_theta * self._theta_dot) / I_safe - - # ---------- SUBSTEPPING + CLIPPING (explicit, conservative) ---------- - if getattr(self, "integration_method", "substep") == "substep": - # discover / ensure arrays - dt = float(self.movement.delta_time) - max_delta = float(self.max_delta_theta_per_substep) - max_theta_abs = float(self.max_theta_abs) - under_relax = float(self.theta_under_relax) - max_substeps_limit = int(self.max_integration_substeps) - c_theta = float(getattr(self, "c_theta", c_theta)) - k_theta = np.asarray(getattr(self, "k_theta", k_theta)) - - # initial theta_ddot (from earlier formula) - # if you computed theta_ddot earlier as array use it, otherwise compute quickly: - theta_ddot_curr = (M_net - k_theta * (self._theta - theta_ref) + GJ * lap - c_theta * self._theta_dot) / I_safe - - # heuristic estimate of delta per macro step - delta_est = np.abs(theta_ddot_curr) * (dt * dt) - required_substeps = np.ceil(np.maximum(1.0, delta_est / (max_delta + 1e-12))).astype(int) - substeps = int(min(max(required_substeps.max(), 1), max_substeps_limit)) - dt_sub = dt / float(substeps) - - theta_local = self._theta.copy() - theta_dot_local = self._theta_dot.copy() - - for s in range(substeps): - # recompute lap using theta_local - if ns > 1: - lap_local = np.zeros(ns) - lap_local[1:-1] = (theta_local[2:] - 2.0 * theta_local[1:-1] + theta_local[0:-2]) / (dy * dy) - if getattr(self, "root_clamped", False): - lap_local[0] = (theta_local[1] - 2.0 * theta_local[0] + theta_ref[0]) / (dy * dy) - else: - lap_local[0] = (theta_local[1] - 2.0 * theta_local[0] + theta_local[1]) / (dy * dy) - if getattr(self, "tip_clamped", False): - lap_local[-1] = (theta_ref[-1] - 2.0 * theta_local[-1] + theta_local[-2]) / (dy * dy) - else: - lap_local[-1] = (theta_local[-2] - 2.0 * theta_local[-1] + theta_local[-2]) / (dy * dy) - else: - lap_local = np.zeros_like(theta_local) - - torque_spring_local = k_theta * (theta_local - theta_ref) - theta_ddot_local = (M_net - torque_spring_local + GJ * lap_local - c_theta * theta_dot_local) / I_safe - - # semi-implicit Euler for substep - theta_dot_new_local = theta_dot_local + dt_sub * theta_ddot_local - theta_new_local = theta_local + dt_sub * theta_dot_new_local - - # limit per-substep delta - delta = theta_new_local - theta_local - too_big = np.abs(delta) > max_delta - if np.any(too_big): - delta[too_big] = np.sign(delta[too_big]) * max_delta - - # under-relaxation - delta *= under_relax - - theta_local = theta_local + delta - # update local angular velocity consistent with delta - theta_dot_local = np.where(dt_sub > 0.0, delta / dt_sub, theta_dot_new_local) - - # absolute clipping - theta_local = np.clip(theta_local, -max_theta_abs, max_theta_abs) - clipped = (np.abs(theta_local) >= max_theta_abs - 1e-15) - if np.any(clipped): - theta_dot_local[clipped] = 0.0 - - # accept - theta_new = theta_local - theta_dot_new = theta_dot_local - - # update object - self._theta = theta_new - self._theta_dot = theta_dot_new - - # ---------- NEWMARK-BETA (implicit solver) ---------- - if getattr(self, "integration_method", "substep") == "newmark": - dt = float(self.movement.delta_time) - beta = float(self.newmark_beta) - gamma = float(self.newmark_gamma) - # diagonal mass and damping - M_diag = I_safe.copy() # shape (ns,) - C_diag = (getattr(self, "c_theta", c_theta) * np.ones(ns)).copy() - # build discrete second-derivative operator L_matrix (size ns x ns) - # L_matrix @ theta = (theta_{i+1} - 2 theta_i + theta_{i-1}) / dy^2 - L = np.zeros((ns, ns)) - if ns == 1: - L[0, 0] = 0.0 - else: - invdy2 = 1.0 / (dy * dy) - for i in range(ns): - if i == 0: - if getattr(self, "root_clamped", False): - # clamped: second derivative uses theta_ref later - L[i, i] = -2.0 * invdy2 - L[i, i + 1] = 1.0 * invdy2 - else: - L[i, i] = -2.0 * invdy2 - L[i, i + 1] = 2.0 * invdy2 # mirrored - elif i == ns - 1: - if getattr(self, "tip_clamped", False): - L[i, i - 1] = 1.0 * invdy2 - L[i, i] = -2.0 * invdy2 - else: - L[i, i - 1] = 2.0 * invdy2 - L[i, i] = -2.0 * invdy2 - else: - L[i, i - 1] = 1.0 * invdy2 - L[i, i] = -2.0 * invdy2 - L[i, i + 1] = 1.0 * invdy2 - - # static stiffness matrix: K_static = diag(k_theta) - GJ * L - k_theta_arr = np.asarray(getattr(self, "k_theta", k_theta * np.ones(ns))) - K_static = np.diag(k_theta_arr) - GJ * L # shape (ns, ns) - - # Effective matrices for Newmark - # K_eff = M/(beta dt^2) + C*(gamma/(beta dt)) + K_static - M_over = np.diag(M_diag / (beta * dt * dt)) - C_term = np.diag(C_diag * (gamma / (beta * dt))) - K_eff = M_over + C_term + K_static - - # right-hand side: F_eff = F_{n+1} + M * a_hat + C * v_hat - # where F_{n+1} = M_net + k_theta * theta_ref (note k_theta*theta_ref moves to RHS) - F_ext = M_net + k_theta_arr * theta_ref - - # compute acceleration / velocity predictors from current state - # assume self._theta_ddot exists or compute current accel: - if not hasattr(self, "_theta_ddot"): - # compute current theta_ddot using original formula (elementwise) - torque_spring = k_theta_arr * (self._theta - theta_ref) - self._theta_ddot = (M_net - torque_spring + GJ * lap - C_diag * self._theta_dot) / np.where(M_diag > 1e-12, M_diag, 1e-12) - - a_n = self._theta_ddot - v_n = self._theta_dot - u_n = self._theta - - a_hat = ( (1.0/(beta*dt*dt)) * u_n + - (1.0/(beta*dt)) * v_n + - (1.0/(2.0*beta) - 1.0) * a_n ) - v_hat = ( (gamma/(beta*dt)) * u_n + - (gamma/beta - 1.0) * v_n + - dt * (gamma/(2.0*beta) - 1.0) * a_n ) - - F_eff = F_ext + M_diag * a_hat + C_diag * v_hat - - # Handle Dirichlet BCs (clamped nodes) by modifying K_eff and F_eff: - # for clamped nodes, we enforce theta = theta_ref (Dirichlet) - clamp_nodes = [] - if getattr(self, "root_clamped", False): - clamp_nodes.append(0) - if getattr(self, "tip_clamped", False): - clamp_nodes.append(ns - 1) - # implement simple row/col replacement for clamp nodes - for node in clamp_nodes: - K_eff[node, :] = 0.0 - K_eff[node, node] = 1.0 - F_eff[node] = theta_ref[node] - - # Solve linear system for theta_{n+1} - theta_new = np.linalg.solve(K_eff, F_eff) - - # compute acceleration and velocity at n+1 - a_new = (1.0 / (beta * dt * dt)) * (theta_new - u_n) - (1.0 / (beta * dt)) * v_n - (1.0 / (2.0 * beta) - 1.0) * a_n - v_new = v_n + dt * ( (1.0 - gamma) * a_n + gamma * a_new ) - - # update state - self._theta = theta_new - self._theta_dot = v_new - self._theta_ddot = a_new - - # absolute clipping as a final guard - self._theta = np.clip(self._theta, -self.max_theta_abs, self.max_theta_abs) - self._theta_dot[np.abs(self._theta) >= self.max_theta_abs - 1e-15] = 0.0 - - # --- convert theta_new to net_deformation (backwards-compatible) ---------- - # small-angle vertical displacement approx at half-chord - half_chords = np.zeros(ns) - for i in range(ns): - p_le = np.asarray(undeformed_wing.panels[0][i].Flpp_GP1_CgP1) - p_te = np.asarray(undeformed_wing.panels[-1][i].Brpp_GP1_CgP1) + np.asarray(undeformed_wing.panels[-1][i].rightLeg_GP1) - half_chords[i] = 0.5 * abs(p_te[0] - p_le[0]) - - delta_theta = theta_new - theta_ref - step_deformation = np.zeros((ns, 3)) - # put small-angle z (vertical) displacement into index 1 as your original code did - step_deformation[:, 1] = half_chords * delta_theta * self.moment_scaling_factor - - step_deformation_full = np.insert(step_deformation, 0, np.zeros(3), axis=0) - if self.net_deformation is None: - self.net_deformation = np.zeros((ns + 1, 3)) - self.net_deformation += step_deformation_full - - self.net_deformation[:, 1] = np.clip( - self.net_deformation[:, 1], -90, 90) - - if step % 10 == 3: - print("Net deformation: ", self.net_deformation) - print("step deformation: ", step_deformation_full) - - return self.net_deformation - def calculate_wing_deformation(self, solver, step): curr_problem: SteadyProblem = self._steady_problems[-1] airplane = curr_problem.airplanes[0] @@ -960,58 +650,223 @@ def calculate_wing_deformation(self, solver, step): num_spanwise_panels = wing.num_spanwise_panels num_panels = num_chordwise_panels * num_spanwise_panels - aeroMoments_GP1_CgP1 = np.array( - [[panel.moments_GP1_CgP1 for panel in row] for row in wing.panels] + aeroMoments_GP1_Slep = np.array(solver.moments_GP1_Slep[:num_panels]).reshape( + num_chordwise_panels, num_spanwise_panels, 3 ) * self.aero_scaling + self.positions.append(np.array( [[panel.Cpp_GP1_CgP1 for panel in row] for row in wing.panels] )) - areas = np.array( - [[panel.area for panel in row] for row in wing.panels] - ) + + mass_matrix = self.calculate_mass_matrix(wing) inertial_forces = ( self.calculate_wing_panel_accelerations() - * np.repeat(areas[:, :, None], 3, axis=2) - * self.wing_density + * mass_matrix ) + inertial_moments = np.cross( - self.positions[-1] - self.positions[0], - inertial_forces, - axis=2 + self.positions[-1] - solver.stack_leading_edge_points[:num_panels].reshape((num_chordwise_panels, num_spanwise_panels, 3)), inertial_forces, axis=2 ) - total_moments = aeroMoments_GP1_CgP1 - inertial_moments + undeforemed_wing = self.steady_problems[step].airplanes[0].wings[0] + undeformed_postions = np.array( + [[panel.Cpp_GP1_CgP1 for panel in row] for row in undeforemed_wing.panels] + ) + + thetas, self.angluar_velocities, spring_moments = self.calculate_spring_moments(num_spanwise_panels, wing, mass_matrix) + if self.base_wing_positions is None: + self.base_wing_positions = np.array(undeformed_postions) + + self.flap_points.append(np.array(undeformed_postions) - self.base_wing_positions) + self.per_step_inertial.append(inertial_moments.copy()) + self.per_step_aero.append(aeroMoments_GP1_Slep.copy()) + self.per_step_spring.append(spring_moments.copy()) + + total_moments = aeroMoments_GP1_Slep - inertial_moments #+ spring_moments deformation_moments = total_moments[:, :, 2] # Z-axis moments if self.net_deformation is None: self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) + self.angluar_velocities = np.zeros((num_spanwise_panels + 1, 3)) - undeforemed_wing = self.steady_problems[-1].airplanes[0].wings[0] - undeformed_postions = np.array( - [[panel.Cpp_GP1_CgP1 for panel in row] for row in undeforemed_wing.panels] - ) step_deformation = np.array( [ np.array( [ 0, - np.sum(deformation_moments[:, i]) * self.moment_scaling_factor - - self.spring_constant - * np.sum( - self.positions[-1][:, i, 2] - undeformed_postions[:, i, 2] - ), + (np.sum(deformation_moments[:, i]) - self.net_deformation[i + 1][1] * self.spring_constant) * self.moment_scaling_factor, 0, ] ) for i in range(num_spanwise_panels) ] ) + step_deformation = np.insert(step_deformation, 0, np.array([0,0,0]), axis=0) - self.net_deformation -= step_deformation + if step > 5: + self.net_deformation += step_deformation + + self.per_step_data.append(step_deformation) + self.net_data.append(self.net_deformation.copy()) + self.angluar_velocity_data.append(self.angluar_velocities.copy()) if step % 10 == 3: - print("Net deformation: ", self.net_deformation) - print("step deformation: ", step_deformation) + # print("Net deformation: ", self.net_deformation) + # print("step deformation: ", step_deformation) + aeroMoments_GP1_Slep = np.array(solver.moments_GP1_Slep[:num_panels]).reshape(num_chordwise_panels, num_spanwise_panels, 3) + + print("Aero Moments Slep", self.net_deformation) + + if step == self.num_steps - 1: + zero_curve = np.zeros((1, np.array(self.per_step_inertial).shape[0])) + print(np.array(self.per_step_inertial).shape) + print(np.array(self.per_step_aero).shape) + print(np.array(self.per_step_spring).shape) + print(np.array(self.per_step_data).shape) + print(np.array(self.net_data).shape) + plot_curves(np.array(self.per_step_data)[:, :, 1].T.tolist(), "Per Step Deformation") + plot_curves(np.array(self.net_data)[:, :, 1].T.tolist(), "Net Deformation") + plot_curves(np.vstack((zero_curve, np.array(self.per_step_inertial)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Inertial Moments") + plot_curves(np.vstack((zero_curve, np.array(self.per_step_aero)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Aero Moments") + plot_curves(np.vstack((zero_curve, np.array(self.per_step_spring)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Spring Moments") + plot_curves( + np.vstack( + ( + zero_curve, + np.array(self.flap_points)[:, :, :, 2].sum(axis=1).T, + ) + ).tolist(), + "Flap Points Z", + ) return self.net_deformation + + def calculate_spring_moments(self, num_spanwise_panels, wing, mass_matrix): + spring_moments = np.zeros((num_spanwise_panels, 3)) + thetas = np.zeros(num_spanwise_panels) + omegas = np.zeros(num_spanwise_panels) + + for span_panel in range(num_spanwise_panels): + if span_panel == 0: + theta0 = 0.0 + omega0 = 0.0 + else: + theta0 = self.net_deformation[span_panel][1] / self.moment_scaling_factor + omega0 = self.angluar_velocities[span_panel][1] # TODO: compute from previous deformation steps + + dt = self.movement.delta_time + + theta, omega, moment = self.calculate_torsional_spring_moment( + dt, + # 1/2 * M * L^2 + I=mass_matrix[:, span_panel, :].sum() * (wing.panels[0][span_panel].chord_length ** 2) / 2, + theta0=theta0, + omega0=omega0, + ) + + thetas[span_panel] = theta[-1] + omegas[span_panel] = omega[-1] + spring_moments[span_panel] = np.array([0, moment[-1], 0]) + + return thetas, omegas, spring_moments + + def calculate_torsional_spring_moment(self, dt, I, theta0, omega0, num_steps=50): + k = self.spring_constant + c = self.damping_constant + + t = np.linspace(0, dt, num_steps) + + if self.numerical_integration: + # ---- Numerical integration ---- + def ode(_, y): + theta, omega = y + return [ + omega, + (-c * omega - k * theta) / I + ] + + sol = solve_ivp( + ode, + (t[0], t[-1]), + [theta0, omega0], + t_eval=t, + rtol=1e-9, + atol=1e-12 + ) + + theta = sol.y[0] + omega = sol.y[1] + + else: + # ---- Closed-form (robust) ---- + omega_n = np.sqrt(k / I) + zeta = c / (2 * np.sqrt(k * I)) + + if zeta < 1.0 - self.damping_eps: + # ---- Underdamped ---- + omega_d = omega_n * np.sqrt(1 - zeta**2) + + A = theta0 + B = (omega0 + zeta * omega_n * theta0) / omega_d + + exp_term = np.exp(-zeta * omega_n * t) + + theta = exp_term * ( + A * np.cos(omega_d * t) + + B * np.sin(omega_d * t) + ) + + omega = exp_term * ( + -zeta * omega_n * (A * np.cos(omega_d * t) + B * np.sin(omega_d * t)) + - A * omega_d * np.sin(omega_d * t) + + B * omega_d * np.cos(omega_d * t) + ) + + elif zeta > 1.0 + self.damping_eps: + # ---- Overdamped ---- + s = np.sqrt(zeta**2 - 1) + r1 = -omega_n * (zeta - s) + r2 = -omega_n * (zeta + s) + + A = (omega0 - r2 * theta0) / (r1 - r2) + B = theta0 - A + + theta = A * np.exp(r1 * t) + B * np.exp(r2 * t) + omega = A * r1 * np.exp(r1 * t) + B * r2 * np.exp(r2 * t) + + else: + # ---- Critically damped (limit form) ---- + A = theta0 + B = omega0 + omega_n * theta0 + + exp_term = np.exp(-omega_n * t) + + theta = (A + B * t) * exp_term + omega = (B - omega_n * (A + B * t)) * exp_term + + # ---- Spring-damper moment ---- + moment = -k * theta - c * omega + + return theta, omega, moment + + +def plot_curves(data, title, flap_cycle=None): + """ + data: list of lists + each inner list is a curve + """ + plt.figure(figsize=(12, 6), dpi=200) + + for i, curve in enumerate(data): + x = range(len(curve)) + plt.plot(x, curve, label=f"Curve {i}") + if flap_cycle is not None: + plt.plot(range(len(flap_cycle)), flap_cycle, label=f"Flap Cycle", color="black") + plt.xlabel("Step") + plt.ylabel("Value") + plt.title(title) + plt.legend() + plt.grid(True) + plt.savefig(f"{title.replace(' ', '_')}.png") + plt.show() From 800f270bf18af106094564bc03d9ee6f626d9446 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Tue, 20 Jan 2026 16:47:44 -0500 Subject: [PATCH 12/40] add working ODE solver deformation --- examples/demos/demo_single_step_flat.py | 13 +- pterasoftware/problems.py | 292 ++++++++++++++++-------- 2 files changed, 208 insertions(+), 97 deletions(-) diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index f57930215..0044ea452 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -241,9 +241,10 @@ ) ) - +dephase = 169.0 # Now define the main wing's WingMovement, the reflected main wing's WingMovement and # the v-tail's WingMovement. +# TODO: refactor to reduce redundancy with single step wing movement main_wing_movement = ps.movements.wing_movement.WingMovement( base_wing=example_airplane.wings[0], wing_cross_section_movements=main_movements_list, @@ -254,7 +255,7 @@ ampAngles_Gs_to_Wn_ixyz=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), ) single_step_main_wing_movement = ( @@ -267,7 +268,7 @@ ampAngles_Gs_to_Wn_ixyz=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), ) ) @@ -281,7 +282,7 @@ ampAngles_Gs_to_Wn_ixyz=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), ) single_step_reflected_main_wing_movement = ( @@ -294,7 +295,7 @@ ampAngles_Gs_to_Wn_ixyz=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (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), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), ) ) @@ -394,7 +395,7 @@ movement = ps.movements.movement.Movement( airplane_movements=[airplane_movement], operating_point_movement=operating_point_movement, - delta_time=0.06, + delta_time=0.03, num_cycles=3, num_chords=None, num_steps=None, diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 821341e20..284bb5a03 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -16,6 +16,7 @@ import math import numpy as np +import scipy.signal as sp_sig from copy import deepcopy @@ -559,6 +560,7 @@ def __init__( self, single_step_movement: SingleStepMovement, movement, + custom_spacing_second_derivative=None, only_final_results=False, ): # TODO: fix this constructor to properly inherit from CoupledUnsteadyProblem @@ -574,11 +576,11 @@ def __init__( self.angluar_velocities = None # Tunable Parameters - self.wing_density = 0.12 # per unit height kg/m^2 - self.moment_scaling_factor = 1.0 - self.spring_constant = 2.0 - self.damping_constant = 1.0 - self.aero_scaling = 0.0 + self.wing_density = 0.024 # per unit height kg/m^2 + self.moment_scaling_factor = 1 + self.spring_constant = 0 + self.damping_constant = 1 + self.aero_scaling = 3.0gi self.numerical_integration = True # use numerical integration or closed form solution self.damping_eps = 1e-3 # critical damping tolerance @@ -597,14 +599,8 @@ def __init__( self.base_wing_positions = None self.flap_points = [] - self.integration_method = getattr(self, "integration_method", "substep") # "substep" or "newmark" - self.max_delta_theta_per_substep = getattr(self, "max_delta_theta_per_substep", 0.005) - self.max_theta_abs = getattr(self, "max_theta_abs", np.deg2rad(30.0)) - self.max_integration_substeps = getattr(self, "max_integration_substeps", 200) - self.theta_under_relax = getattr(self, "theta_under_relax", 1.0) - # Newmark params - self.newmark_beta = getattr(self, "newmark_beta", 0.25) - self.newmark_gamma = getattr(self, "newmark_gamma", 0.5) + # For custom spacing defined in movement. + self.custom_spacing_second_derivative = custom_spacing_second_derivative def calculate_wing_panel_accelerations(self): if len(self.positions) <= 2: @@ -649,6 +645,9 @@ def calculate_wing_deformation(self, solver, step): num_chordwise_panels = wing.num_chordwise_panels num_spanwise_panels = wing.num_spanwise_panels num_panels = num_chordwise_panels * num_spanwise_panels + if self.net_deformation is None: + self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) + self.angluar_velocities = np.zeros((num_spanwise_panels + 1, 3)) aeroMoments_GP1_Slep = np.array(solver.moments_GP1_Slep[:num_panels]).reshape( num_chordwise_panels, num_spanwise_panels, 3 @@ -674,7 +673,14 @@ def calculate_wing_deformation(self, solver, step): [[panel.Cpp_GP1_CgP1 for panel in row] for row in undeforemed_wing.panels] ) - thetas, self.angluar_velocities, spring_moments = self.calculate_spring_moments(num_spanwise_panels, wing, mass_matrix) + thetas, omegas, spring_moments = self.calculate_spring_moments( + num_spanwise_panels=num_spanwise_panels, + wing=wing, + mass_matrix=mass_matrix, + aero_moments=aeroMoments_GP1_Slep, + step=step, + ) + self.angluar_velocities[:, 1] = omegas if self.base_wing_positions is None: self.base_wing_positions = np.array(undeformed_postions) @@ -686,16 +692,13 @@ def calculate_wing_deformation(self, solver, step): total_moments = aeroMoments_GP1_Slep - inertial_moments #+ spring_moments deformation_moments = total_moments[:, :, 2] # Z-axis moments - if self.net_deformation is None: - self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) - self.angluar_velocities = np.zeros((num_spanwise_panels + 1, 3)) - step_deformation = np.array( [ np.array( [ 0, - (np.sum(deformation_moments[:, i]) - self.net_deformation[i + 1][1] * self.spring_constant) * self.moment_scaling_factor, + # (np.sum(deformation_moments[:, i]) - self.net_deformation[i + 1][1] * self.spring_constant) * self.moment_scaling_factor, + thetas[i + 1] * self.moment_scaling_factor, 0, ] ) @@ -705,7 +708,7 @@ def calculate_wing_deformation(self, solver, step): step_deformation = np.insert(step_deformation, 0, np.array([0,0,0]), axis=0) if step > 5: - self.net_deformation += step_deformation + self.net_deformation = step_deformation self.per_step_data.append(step_deformation) self.net_data.append(self.net_deformation.copy()) @@ -716,7 +719,8 @@ def calculate_wing_deformation(self, solver, step): # print("step deformation: ", step_deformation) aeroMoments_GP1_Slep = np.array(solver.moments_GP1_Slep[:num_panels]).reshape(num_chordwise_panels, num_spanwise_panels, 3) - print("Aero Moments Slep", self.net_deformation) + # print("Aero Moments Slep", self.net_deformation[:, 1]) + print("Thetas: ", thetas) if step == self.num_steps - 1: zero_curve = np.zeros((1, np.array(self.per_step_inertial).shape[0])) @@ -729,7 +733,7 @@ def calculate_wing_deformation(self, solver, step): plot_curves(np.array(self.net_data)[:, :, 1].T.tolist(), "Net Deformation") plot_curves(np.vstack((zero_curve, np.array(self.per_step_inertial)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Inertial Moments") plot_curves(np.vstack((zero_curve, np.array(self.per_step_aero)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Aero Moments") - plot_curves(np.vstack((zero_curve, np.array(self.per_step_spring)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Spring Moments") + plot_curves(np.vstack((zero_curve, np.array(self.per_step_spring)[:, :, 2].sum(axis=1).T)).tolist(), "Per Step Spring Moments") plot_curves( np.vstack( ( @@ -742,113 +746,219 @@ def calculate_wing_deformation(self, solver, step): return self.net_deformation - def calculate_spring_moments(self, num_spanwise_panels, wing, mass_matrix): + def calculate_spring_moments(self, num_spanwise_panels, wing, mass_matrix, aero_moments, step): spring_moments = np.zeros((num_spanwise_panels, 3)) - thetas = np.zeros(num_spanwise_panels) - omegas = np.zeros(num_spanwise_panels) - + thetas = np.zeros(num_spanwise_panels + 1) + omegas = np.zeros(num_spanwise_panels + 1) + d = 0.0 # distance from flapping axis to panel centroid for span_panel in range(num_spanwise_panels): + aero_span_moment = np.sum(aero_moments[:, span_panel, 2]) if span_panel == 0: theta0 = 0.0 omega0 = 0.0 else: - theta0 = self.net_deformation[span_panel][1] / self.moment_scaling_factor - omega0 = self.angluar_velocities[span_panel][1] # TODO: compute from previous deformation steps + theta0 = self.net_deformation[span_panel][1] + omega0 = self.angluar_velocities[span_panel][1] dt = self.movement.delta_time - + mass = mass_matrix[:, span_panel, :].sum() + # Equation for rotational inertia of rectangular prism about flapping axis + # Considers two factors, the first is the rotational inertial of a rectangular + # prism about its centroid, the second is the parallel axis theorem to + # account for distance from flapping axis to the panel centroid + L = ( + wing.wing_cross_sections[span_panel].chord + + wing.wing_cross_sections[span_panel + 1].chord + ) / 2 + W = np.linalg.norm(wing.panels[0][span_panel].frontLeg_G) + d += W / 2 + span_I = 1/12 * mass * (L ** 2 + W ** 2) + mass * (d ** 2) + # span_I = d * mass * L theta, omega, moment = self.calculate_torsional_spring_moment( dt, # 1/2 * M * L^2 - I=mass_matrix[:, span_panel, :].sum() * (wing.panels[0][span_panel].chord_length ** 2) / 2, + # I=mass * (wing.wing_cross_sections[span_panel].chord ** 2) / 2, + # I= 4/3 * mass * (L ** 2), + I=span_I, theta0=theta0, omega0=omega0, + aero_span_moment=aero_span_moment, + step=step, + span_I=span_I, ) - - thetas[span_panel] = theta[-1] - omegas[span_panel] = omega[-1] - spring_moments[span_panel] = np.array([0, moment[-1], 0]) + d += W / 2 + print("Theta", theta, "Omega", omega, "Moment", moment) + thetas[span_panel + 1] = theta + omegas[span_panel + 1] = omega + spring_moments[span_panel] = np.array([0, moment, 0]) return thetas, omegas, spring_moments - def calculate_torsional_spring_moment(self, dt, I, theta0, omega0, num_steps=50): + def calculate_torsional_spring_moment( + self, dt, I, theta0, omega0, aero_span_moment, step, span_I, num_steps=2, + ): k = self.spring_constant c = self.damping_constant - t = np.linspace(0, dt, num_steps) + t = np.linspace(dt * (step - 1), dt * step, num_steps) if self.numerical_integration: - # ---- Numerical integration ---- - def ode(_, y): - theta, omega = y - return [ - omega, - (-c * omega - k * theta) / I - ] - - sol = solve_ivp( - ode, - (t[0], t[-1]), - [theta0, omega0], - t_eval=t, - rtol=1e-9, - atol=1e-12 + # ---- Forced numerical integration ---- + theta, omega = self.spring_numerical_ode(t, k, c, I, theta0, omega0, aero_span_moment, self.generate_inertial_torque_function(span_I)) + + else: + # ---- Closed-form with constant torque ---- + const_tau = aero_span_moment + self.generate_inertial_torque_function(span_I)(t[0]) + theta, omega = self.closed_form_spring_ode( + t, k, c, I, theta0, omega0, const_tau=const_tau ) - theta = sol.y[0] - omega = sol.y[1] + # ---- Internal spring-damper moment ---- + spring_moment = -k * theta - c * omega - else: - # ---- Closed-form (robust) ---- - omega_n = np.sqrt(k / I) - zeta = c / (2 * np.sqrt(k * I)) + # ---- Net moment (optional, depending on sign convention) ---- + # net_moment = spring_moment + tau(t) + + return theta, omega, spring_moment + + def generate_inertial_torque_function(self, span_I): + """ + Docstring for generate_inertial_torque_function + + :param span_I: float + The rotational inertia of the wing span section about alpha (the flapping axis). + """ + spacing = ( + self.single_step_movement.airplane_movements[0] + .wing_movements[0] + .spacingAngles_Gs_to_Wn_ixyz[0] + ) + wing_movement = self.single_step_movement.airplane_movements[0].wing_movements[0] + amp = wing_movement.ampAngles_Gs_to_Wn_ixyz[0] + b = 2 * np.pi / wing_movement.periodAngles_Gs_to_Wn_ixyz[0] + h = np.deg2rad(wing_movement.phaseAngles_Gs_to_Wn_ixyz[0]) + if spacing == "sine": + torque_func = lambda time: -1 * (b ** 2) * np.sin(b * time + h) * amp * span_I + elif spacing == "uniform": + raise ValueError("Sawtooth function (uniform spacing) is not differentiable, cannot be used for inertial torque function.") + elif callable(spacing): + if self.custom_spacing_second_derivative is not None: + torque_func = lambda time: self.custom_spacing_second_derivative(time) * span_I + else: + raise ValueError("Custom spacing function provided without second derivative function for inertial torque calculation.") + + return torque_func + + def spring_numerical_ode(self, t, k, c, I, theta0, omega0, aero_torque, inertial_torque_func): + """ Numerical solution to the spring-damper ODE with arbitrary torque input. + t: numpy.ndarray + Time array. + k: float + Spring constant. + c: float + Damping constant. + I: float + Rotational inertia. + theta0: float + Initial angular displacement. + omega0: float + Initial angular velocity.""" + def tau(time): + return aero_torque + inertial_torque_func(time) + def ode(time, y): + theta, omega = y + return [omega, (tau(time) - c * omega - k * theta) / I] + + sol = solve_ivp( + ode, (t[0], t[-1]), [theta0, omega0], t_eval=t, rtol=1e-9, atol=1e-12 + ) - if zeta < 1.0 - self.damping_eps: - # ---- Underdamped ---- - omega_d = omega_n * np.sqrt(1 - zeta**2) + theta = sol.y[0][-1] + omega = sol.y[1][-1] - A = theta0 - B = (omega0 + zeta * omega_n * theta0) / omega_d + return theta, omega - exp_term = np.exp(-zeta * omega_n * t) + def closed_form_spring_ode( + self, + t, + k, + c, + I, + theta0, + omega0, + const_tau=0.0, + ): + """ Closed-form solution to the spring-damper ODE with constant torque input. + t: numpy.ndarray + Time array. + k: float + Spring constant. + c: float + Damping constant. + I: float + Rotational inertia. + theta0: float + Initial angular displacement. + omega0: float + Initial angular velocity. + const_tau: float, optional + Constant torque input. Default is 0.0. + """ + # equilibrium shift + theta_eq = const_tau / k - theta = exp_term * ( - A * np.cos(omega_d * t) + - B * np.sin(omega_d * t) - ) + theta0s = theta0 - theta_eq + omega0s = omega0 - omega = exp_term * ( - -zeta * omega_n * (A * np.cos(omega_d * t) + B * np.sin(omega_d * t)) - - A * omega_d * np.sin(omega_d * t) - + B * omega_d * np.cos(omega_d * t) - ) + omega_n = np.sqrt(k / I) + zeta = c / (2 * np.sqrt(k * I)) - elif zeta > 1.0 + self.damping_eps: - # ---- Overdamped ---- - s = np.sqrt(zeta**2 - 1) - r1 = -omega_n * (zeta - s) - r2 = -omega_n * (zeta + s) + # ---- Underdamped ---- + if zeta < 1.0 - self.damping_eps: + omega_d = omega_n * np.sqrt(1 - zeta**2) - A = (omega0 - r2 * theta0) / (r1 - r2) - B = theta0 - A + A = theta0s + B = (omega0s + zeta * omega_n * theta0s) / omega_d - theta = A * np.exp(r1 * t) + B * np.exp(r2 * t) - omega = A * r1 * np.exp(r1 * t) + B * r2 * np.exp(r2 * t) + exp_term = np.exp(-zeta * omega_n * t) - else: - # ---- Critically damped (limit form) ---- - A = theta0 - B = omega0 + omega_n * theta0 + phi = exp_term * ( + A * np.cos(omega_d * t) + + B * np.sin(omega_d * t) + ) + + omega = exp_term * ( + -zeta * omega_n * (A * np.cos(omega_d * t) + B * np.sin(omega_d * t)) + - A * omega_d * np.sin(omega_d * t) + + B * omega_d * np.cos(omega_d * t) + ) + + elif zeta > 1.0 + self.damping_eps: + # ---- Overdamped ---- + s = np.sqrt(zeta**2 - 1) + r1 = -omega_n * (zeta - s) + r2 = -omega_n * (zeta + s) + + A = (omega0s - r2 * theta0s) / (r1 - r2) + B = theta0s - A + + phi = A * np.exp(r1 * t) + B * np.exp(r2 * t) + omega = A * r1 * np.exp(r1 * t) + B * r2 * np.exp(r2 * t) + + else: + # ---- Critically damped (limit form) ---- + A = theta0s + B = omega0s + omega_n * theta0s - exp_term = np.exp(-omega_n * t) + exp_term = np.exp(-omega_n * t) - theta = (A + B * t) * exp_term - omega = (B - omega_n * (A + B * t)) * exp_term + phi = (A + B * t) * exp_term + omega = (B - omega_n * (A + B * t)) * exp_term - # ---- Spring-damper moment ---- - moment = -k * theta - c * omega + # shift back to physical angle + theta = phi + theta_eq - return theta, omega, moment + return theta[-1], omega[-1] def plot_curves(data, title, flap_cycle=None): From 742596f6d89cca7fb6c8c982af8b6738d7ccad41 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Tue, 20 Jan 2026 16:48:24 -0500 Subject: [PATCH 13/40] add working ODE solver deformation --- pterasoftware/problems.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 284bb5a03..d140522a2 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -580,7 +580,7 @@ def __init__( self.moment_scaling_factor = 1 self.spring_constant = 0 self.damping_constant = 1 - self.aero_scaling = 3.0gi + self.aero_scaling = 3.0 self.numerical_integration = True # use numerical integration or closed form solution self.damping_eps = 1e-3 # critical damping tolerance From 3a1d61c76efdca845179310bc5be32299b6aceef Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 21 Jan 2026 13:24:58 -0500 Subject: [PATCH 14/40] add deforming elongated pterosaur --- examples/demos/demo_pterosaur.py | 351 +++++++++++++++++++++++++++++++ pterasoftware/problems.py | 10 +- 2 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 examples/demos/demo_pterosaur.py diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py new file mode 100644 index 000000000..2c07d0d81 --- /dev/null +++ b/examples/demos/demo_pterosaur.py @@ -0,0 +1,351 @@ +import pterasoftware as ps +import numpy as np +from scipy.spatial.transform import Rotation as R + +def get_relative_transform(Point1, Unit1, Point2, Unit2): + + Point1 = np.array(Point1, dtype=float) + Unit1 = np.array(Unit1, dtype=float) + Point2 = np.array(Point2, dtype=float) + Unit2 = np.array(Unit2, dtype=float) + + Xp = Unit1 / np.linalg.norm(Unit1) + Zp = np.array([0, 0, 1]) + Yp = np.cross(Zp, Xp) + Yp /= np.linalg.norm(Yp) + Zp = np.cross(Xp, Yp) + Zp /= np.linalg.norm(Zp) + Rp = np.column_stack((Xp, Yp, Zp)) + + Xe = Unit2 / np.linalg.norm(Unit2) + Ze = np.array([0, 0, 1]) + Ye = np.cross(Ze, Xe) + Ye /= np.linalg.norm(Ye) + Ze = np.cross(Xe, Ye) + Ze /= np.linalg.norm(Ze) + Re = np.column_stack((Xe, Ye, Ze)) + + Rrel = Rp.T @ Re + + rot = R.from_matrix(Rrel) + angles_Wcsp_to_Wcs_ixyz = rot.as_euler('xyz', degrees=True) + + Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) + + return Lp_Wcsp_Lpp, angles_Wcsp_to_Wcs_ixyz + + +wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=0.25, + 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, + ), + ) + +wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=np.linalg.norm((0.1559,0,-0.0931)), + Lp_Wcsp_Lpp=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[1], + 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, + ), + ) + +wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=np.linalg.norm((0.2864,0,-0.1878)), + Lp_Wcsp_Lpp=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[1], + 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, + ), + ) + +wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=np.linalg.norm((0.322,0,-0.2256)), + Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], + 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, + ), + ) + +wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=None, + chord=np.linalg.norm((0.005,0,-0.0003)), + Lp_Wcsp_Lpp=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[1], + 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, + ), + ) + + +pterasaure = ps.geometry.airplane.Airplane( + wings=[ + ps.geometry.wing.Wing( + wing_cross_sections=[wing_cross_section_1, wing_cross_section_2, wing_cross_section_3, wing_cross_section_4, wing_cross_section_5], + name="Main Wing", + Ler_Gs_Cgs= [0.0, 0.025, 0.0], + angles_Gs_to_Wn_ixyz= [4, 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), + num_chordwise_panels=5, + chordwise_spacing="uniform", + ), + ], + name="Pterosaur", + Cg_GP1_CgP1=(0.0, 0.0, 0.0), + weight=0, + s_ref=None, + c_ref=None, + b_ref=None, +) + +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 + +# A list of movements for the main wing +main_movements_list = [] +main_single_step_movements_list = [] + +# A list of movements for the reflected wing +reflected_movements_list = [] +reflected_single_step_movements_list = [] + +for i in range(len(pterasaure.wings[0].wing_cross_sections)): + if i == 0: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=pterasaure.wings[0].wing_cross_sections[i], + ) + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() + + main_movements_list.append(movement) + main_single_step_movements_list.append(single_step_movement) + reflected_movements_list.append(movement) + reflected_single_step_movements_list.append(single_step_movement) + + else: + movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=pterasaure.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), + ) + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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_movements_list.append(movement) + main_single_step_movements_list.append(single_step_movement) + reflected_movements_list.append(movement) + reflected_single_step_movements_list.append(single_step_movement) + + +main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=pterasaure.wings[0], + wing_cross_section_movements= main_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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), +) + +single_step_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=main_single_step_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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), + ) +) + +reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=pterasaure.wings[1], + wing_cross_section_movements=reflected_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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), +) + +single_step_reflected_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + single_step_wing_cross_section_movements=reflected_single_step_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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), + ) +) + +# Now define the example airplane's AirplaneMovement. For now, no movement of the airplane is possible. +pterasaure_movement = ps.movements.airplane_movement.AirplaneMovement( + base_airplane=pterasaure, + wing_movements=[main_wing_movement, reflected_main_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), +) + +pterasaure_single_step_movement = ( + ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + single_step_wing_movements=[ + single_step_main_wing_movement, + single_step_reflected_main_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), + ) +) + +# Define a new OperatingPoint. +example_operating_point = ps.operating_point.OperatingPoint( + rho=1.225, vCg__E=30.0, alpha=5.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point) + +single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" +) + +# Define the Movement. This contains the AirplaneMovement and the +# OperatingPointMovement. +movement = ps.movements.movement.Movement( + airplane_movements=[pterasaure_movement], + operating_point_movement=operating_point_movement, + delta_time=None, + num_cycles=2, + num_chords=None, + num_steps=None, +) + +single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( + single_step_airplane_movements=[pterasaure_single_step_movement], + single_step_operating_point_movement=single_step_operating_point_movement, + delta_time=movement.delta_time, + num_steps=movement.num_steps, +) + +# Define the UnsteadyProblem. +example_problem = ps.problems.BetterAeroelasticUnsteadyProblem( + movement=movement, + single_step_movement=single_step_movement, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ( + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, + ) +) + + +# Run the solver. +example_solver.run( + prescribed_wake=True, + show_progress=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. + +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=True, +) + +# ps.output.print_results(example_solver) + +# ps.output.plot_wing_loads_versus_time(unsteady_solver=example_solver, show=True) diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index d140522a2..14970cabc 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -576,11 +576,11 @@ def __init__( self.angluar_velocities = None # Tunable Parameters - self.wing_density = 0.024 # per unit height kg/m^2 - self.moment_scaling_factor = 1 - self.spring_constant = 0 - self.damping_constant = 1 - self.aero_scaling = 3.0 + self.wing_density = 12 # per unit height kg/m^2 + self.moment_scaling_factor = 3.0 + self.spring_constant = 0.1 + self.damping_constant = 0.1 + self.aero_scaling = 0.0 self.numerical_integration = True # use numerical integration or closed form solution self.damping_eps = 1e-3 # critical damping tolerance From ab5c897915edd02ae705cb66491d397c5d8a1f24 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 28 Jan 2026 17:19:56 -0500 Subject: [PATCH 15/40] add wing explosion --- examples/demos/Pterosaure.py | 257 +++++++++++++++++++++++++++++++ examples/demos/demo_pterosaur.py | 176 ++++++++++++++++++--- pterasoftware/problems.py | 9 +- 3 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 examples/demos/Pterosaure.py diff --git a/examples/demos/Pterosaure.py b/examples/demos/Pterosaure.py new file mode 100644 index 000000000..bcdd9b1f3 --- /dev/null +++ b/examples/demos/Pterosaure.py @@ -0,0 +1,257 @@ +import pterasoftware as ps +import numpy as np +from scipy.spatial.transform import Rotation as R + +def get_relative_transform(Point1, Unit1, Point2, Unit2): + + Point1 = np.array(Point1, dtype=float) + Unit1 = np.array(Unit1, dtype=float) + Point2 = np.array(Point2, dtype=float) + Unit2 = np.array(Unit2, dtype=float) + + Xp = Unit1 / np.linalg.norm(Unit1) + Zp = np.array([0, 0, 1]) + Yp = np.cross(Zp, Xp) + Yp /= np.linalg.norm(Yp) + Zp = np.cross(Xp, Yp) + Zp /= np.linalg.norm(Zp) + Rp = np.column_stack((Xp, Yp, Zp)) + + Xe = Unit2 / np.linalg.norm(Unit2) + Ze = np.array([0, 0, 1]) + Ye = np.cross(Ze, Xe) + Ye /= np.linalg.norm(Ye) + Ze = np.cross(Xe, Ye) + Ze /= np.linalg.norm(Ze) + Re = np.column_stack((Xe, Ye, Ze)) + + Rrel = Rp.T @ Re + + rot = R.from_matrix(Rrel) + angles_Wcsp_to_Wcs_ixyz = rot.as_euler('xyz', degrees=True) + + Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) + + return Lp_Wcsp_Lpp, angles_Wcsp_to_Wcs_ixyz + + +wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=3, + chord=0.25, + 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, + ), + ) + +wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=np.linalg.norm((0.1559,0,-0.0931)), + Lp_Wcsp_Lpp=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[1], + 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, + ), + ) + +wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=np.linalg.norm((0.2864,0,-0.1878)), + Lp_Wcsp_Lpp=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[1], + 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, + ), + ) + +wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=3, + chord=np.linalg.norm((0.322,0,-0.2256)), + Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], + 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, + ), + ) + +wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=None, + chord=np.linalg.norm((0.005,0,-0.0003)), + Lp_Wcsp_Lpp=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[1], + 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, + ), + ) + +wing = ps.geometry.wing.Wing( + wing_cross_sections=[ + wing_cross_section_1, + wing_cross_section_2, + wing_cross_section_3, + wing_cross_section_4, + wing_cross_section_5, + ], + name="Main Wing", + Ler_Gs_Cgs=[0.0, 0.025, 0.0], + angles_Gs_to_Wn_ixyz=[4, 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), + num_chordwise_panels=5, + chordwise_spacing="uniform", + ) + + +pterasaure = ps.geometry.airplane.Airplane( + wings=[ + wing, + ], + name="Pterosaure", + Cg_GP1_CgP1=(0.0, 0.0, 0.0), + weight=0, + s_ref=None, + c_ref=None, + b_ref=None, +) + + +# Define the airplane's AirplaneMovement. +wing_movements = [] +for wing in pterasaure.wings: + wing_cross_section_movements = [] + for wing_cross_section in wing.wing_cross_sections: + wing_cross_section_movements.append(ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=wing_cross_section) + ) + wing_movements.append(wing_cross_section_movements) + +main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=pterasaure.wings[0], + wing_cross_section_movements= wing_movements[0], + 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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), +) + +reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=pterasaure.wings[1], + wing_cross_section_movements=wing_movements[1], + 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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), +) + +# Now define the example airplane's AirplaneMovement. For now, no movement of the airplane is possible. +pterasaure_movement = ps.movements.airplane_movement.AirplaneMovement( + base_airplane=pterasaure, + wing_movements=[main_wing_movement, reflected_main_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), +) + +# Define a new OperatingPoint. +example_operating_point = ps.operating_point.OperatingPoint( + rho=1.225, vCg__E=10.0, alpha=5.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point) + +# Define the Movement. This contains the AirplaneMovement and the +# OperatingPointMovement. +movement = ps.movements.movement.Movement( + airplane_movements=[pterasaure_movement], + operating_point_movement=operating_point_movement, + delta_time=None, + num_cycles=1, + num_chords=None, + num_steps=None, +) + +# Define the UnsteadyProblem. +example_problem = ps.problems.UnsteadyProblem( + movement=movement, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ( + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( + unsteady_problem=example_problem, + ) +) + + +# Run the solver. +example_solver.run( + prescribed_wake=True, + show_progress=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. + +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=True, +) + +# ps.output.print_results(example_solver) + +# ps.output.plot_wing_loads_versus_time(unsteady_solver=example_solver, show=True) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index 2c07d0d81..105c4e754 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -35,8 +35,88 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): return Lp_Wcsp_Lpp, angles_Wcsp_to_Wcs_ixyz +def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): + """ + Wing cross section panels are between the line of wcs1 and wcs2. + When exploding a wing to 1 spanwise panel per cross section, + we need to interpolate the intermediate cross sections. + """ + + interpolated = [] + + if first_wcs: + interpolated.append( + ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=wcs1.chord, + Lp_Wcsp_Lpp=wcs1.Lp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=wcs1.angles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=wcs1.control_surface_symmetry_type, + control_surface_hinge_point=wcs1.control_surface_hinge_point, + control_surface_deflection=wcs1.control_surface_deflection, + spanwise_spacing="uniform", + airfoil=wcs1.airfoil, + ) + ) + + N = wcs1.num_spanwise_panels + + for i in range(N): + t = (i + 1) / N # interpolation parameter between 0 and 1 + + chord = (1 - t) * wcs1.chord + t * wcs2.chord + Lp_Wcsp_Lpp = tuple(np.array(wcs2.Lp_Wcsp_Lpp) / N) + # angles_Wcsp_to_Wcs_ixyz = tuple((1 - t) * np.array(wcs1.angles_Wcsp_to_Wcs_ixyz) + t * np.array(wcs2.angles_Wcsp_to_Wcs_ixyz)) + angles_Wcsp_to_Wcs_ixyz = wcs2.angles_Wcsp_to_Wcs_ixyz / N + is_final_section = wcs2.num_spanwise_panels is None and i == N - 1 + + interpolated.append( + ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=None if is_final_section else 1, + chord=chord, + Lp_Wcsp_Lpp=Lp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=angles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=wcs1.control_surface_symmetry_type, + control_surface_hinge_point=wcs1.control_surface_hinge_point, + control_surface_deflection=wcs1.control_surface_deflection, + spanwise_spacing=None if is_final_section else "uniform", + airfoil=wcs1.airfoil, + ) + ) + return interpolated + +def explode_wing(wing): + """ + Takes a ps.geometry.wing.Wing and returns a NEW Wing + where all cross sections have num_spanwise_panels = 1. + """ + + new_cross_sections = [] + + for i in range(len(wing.wing_cross_sections) - 1): + new_cross_sections.extend( + interpolate_between_wing_cross_sections( + wing.wing_cross_sections[i], wing.wing_cross_sections[i + 1], i == 0 + ) + ) + + # Rebuild the wing (copying everything else verbatim) + return ps.geometry.wing.Wing( + wing_cross_sections=new_cross_sections, + name=wing.name, + Ler_Gs_Cgs=wing.Ler_Gs_Cgs, + angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, + symmetric=wing.symmetric, + mirror_only=wing.mirror_only, + symmetryNormal_G=wing.symmetryNormal_G, + symmetryPoint_G_Cg=wing.symmetryPoint_G_Cg, + num_chordwise_panels=wing.num_chordwise_panels, + chordwise_spacing=wing.chordwise_spacing, + ) + + wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, + num_spanwise_panels=3, chord=0.25, Lp_Wcsp_Lpp=(0.0, 0.0, 0.0), angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0), @@ -52,6 +132,18 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): ), ) +# points = [(0.0, 0.0, 0.0), +# (0.0889,0.2249, 0.0955), +# (-0.0521, 0.5749, 0.1940), +# (-0.0946, 0.8282, 0.2345), +# (0.1829, 2.4373, 0.0266)] + +# vectors = [(1.0, 0.0, 0.0), +# (0.1559,0,-0.0931), +# (0.2864,0,-0.1878), +# (0.3291,0,-0.2154), +# (0.1829, 2.4373, 0.0266)] + wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( num_spanwise_panels=1, chord=np.linalg.norm((0.1559,0,-0.0931)), @@ -87,7 +179,7 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): ) wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, + num_spanwise_panels=3, chord=np.linalg.norm((0.322,0,-0.2256)), Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], @@ -102,6 +194,49 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): n_points_per_side=400, ), ) +wing_cross_section_4_5 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=(0.39316581743584983 + 0.005008991914547277) / 2, + Lp_Wcsp_Lpp= 0.0 * np.array((-0.05774876, 0.2533, 0.01056319)) + 0.5 * np.array((0.34656431, 1.6091, -0.01103809)), + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + (0.1829, 2.4373, 0.0266), + (0.005, 0, -0.0003), + )[1] * 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, + ), +) + +wing_cross_section_new_5 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=None, + chord=np.linalg.norm((0.005, 0, -0.0003)), + Lp_Wcsp_Lpp=np.array([0.34656431, 1.6091, -0.01103809]) / 2, + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + (0.1829, 2.4373, 0.0266), + (0.005, 0, -0.0003), + )[1], + 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, + ), +) wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( num_spanwise_panels=None, @@ -120,22 +255,28 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): ), ) +original_wing = ps.geometry.wing.Wing( + wing_cross_sections=[ + wing_cross_section_1, + wing_cross_section_2, + wing_cross_section_3, + wing_cross_section_4, + wing_cross_section_5, + ], + name="Main Wing", + Ler_Gs_Cgs=[0.0, 0.025, 0.0], + angles_Gs_to_Wn_ixyz=[4, 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), + num_chordwise_panels=5, + chordwise_spacing="uniform", + ) +exploded_wing = explode_wing(original_wing) pterasaure = ps.geometry.airplane.Airplane( - wings=[ - ps.geometry.wing.Wing( - wing_cross_sections=[wing_cross_section_1, wing_cross_section_2, wing_cross_section_3, wing_cross_section_4, wing_cross_section_5], - name="Main Wing", - Ler_Gs_Cgs= [0.0, 0.025, 0.0], - angles_Gs_to_Wn_ixyz= [4, 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), - num_chordwise_panels=5, - chordwise_spacing="uniform", - ), - ], + wings=[exploded_wing], name="Pterosaur", Cg_GP1_CgP1=(0.0, 0.0, 0.0), weight=0, @@ -300,7 +441,7 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): airplane_movements=[pterasaure_movement], operating_point_movement=operating_point_movement, delta_time=None, - num_cycles=2, + num_cycles=1, num_chords=None, num_steps=None, ) @@ -328,7 +469,6 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): ) ) - # Run the solver. example_solver.run( prescribed_wake=True, diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 14970cabc..4a000b4fc 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -576,10 +576,10 @@ def __init__( self.angluar_velocities = None # Tunable Parameters - self.wing_density = 12 # per unit height kg/m^2 - self.moment_scaling_factor = 3.0 - self.spring_constant = 0.1 - self.damping_constant = 0.1 + self.wing_density = 0.012 # per unit height kg/m^2 + self.moment_scaling_factor = 1.0 + self.spring_constant = 1 + self.damping_constant = 1 self.aero_scaling = 0.0 self.numerical_integration = True # use numerical integration or closed form solution self.damping_eps = 1e-3 # critical damping tolerance @@ -787,7 +787,6 @@ def calculate_spring_moments(self, num_spanwise_panels, wing, mass_matrix, aero_ span_I=span_I, ) d += W / 2 - print("Theta", theta, "Omega", omega, "Moment", moment) thetas[span_panel + 1] = theta omegas[span_panel + 1] = omega spring_moments[span_panel] = np.array([0, moment, 0]) From 9b324cca0ea180e98d6ff77073572bdffa51be77 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 20 Feb 2026 15:31:51 -0500 Subject: [PATCH 16/40] implement single step full movement --- examples/demos/Pterosaure.py | 257 ----------- examples/demos/demo_pterosaur.py | 10 +- examples/demos/demo_single_step.py | 5 +- examples/demos/demo_single_step_flat.py | 153 ++----- examples/demos/demo_wing.py | 323 -------------- .../single_step_airplane_movement.py | 17 + .../single_step/single_step_movement.py | 55 +-- .../single_step_operating_point_movement.py | 11 +- ...single_step_wing_cross_section_movement.py | 18 +- .../single_step/single_step_wing_movement.py | 32 +- pterasoftware/problems.py | 408 +++--------------- 11 files changed, 185 insertions(+), 1104 deletions(-) delete mode 100644 examples/demos/Pterosaure.py delete mode 100644 examples/demos/demo_wing.py diff --git a/examples/demos/Pterosaure.py b/examples/demos/Pterosaure.py deleted file mode 100644 index bcdd9b1f3..000000000 --- a/examples/demos/Pterosaure.py +++ /dev/null @@ -1,257 +0,0 @@ -import pterasoftware as ps -import numpy as np -from scipy.spatial.transform import Rotation as R - -def get_relative_transform(Point1, Unit1, Point2, Unit2): - - Point1 = np.array(Point1, dtype=float) - Unit1 = np.array(Unit1, dtype=float) - Point2 = np.array(Point2, dtype=float) - Unit2 = np.array(Unit2, dtype=float) - - Xp = Unit1 / np.linalg.norm(Unit1) - Zp = np.array([0, 0, 1]) - Yp = np.cross(Zp, Xp) - Yp /= np.linalg.norm(Yp) - Zp = np.cross(Xp, Yp) - Zp /= np.linalg.norm(Zp) - Rp = np.column_stack((Xp, Yp, Zp)) - - Xe = Unit2 / np.linalg.norm(Unit2) - Ze = np.array([0, 0, 1]) - Ye = np.cross(Ze, Xe) - Ye /= np.linalg.norm(Ye) - Ze = np.cross(Xe, Ye) - Ze /= np.linalg.norm(Ze) - Re = np.column_stack((Xe, Ye, Ze)) - - Rrel = Rp.T @ Re - - rot = R.from_matrix(Rrel) - angles_Wcsp_to_Wcs_ixyz = rot.as_euler('xyz', degrees=True) - - Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) - - return Lp_Wcsp_Lpp, angles_Wcsp_to_Wcs_ixyz - - -wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=3, - chord=0.25, - 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, - ), - ) - -wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.1559,0,-0.0931)), - Lp_Wcsp_Lpp=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[1], - 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, - ), - ) - -wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.2864,0,-0.1878)), - Lp_Wcsp_Lpp=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[1], - 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, - ), - ) - -wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=3, - chord=np.linalg.norm((0.322,0,-0.2256)), - Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], - 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, - ), - ) - -wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=None, - chord=np.linalg.norm((0.005,0,-0.0003)), - Lp_Wcsp_Lpp=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[1], - 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, - ), - ) - -wing = ps.geometry.wing.Wing( - wing_cross_sections=[ - wing_cross_section_1, - wing_cross_section_2, - wing_cross_section_3, - wing_cross_section_4, - wing_cross_section_5, - ], - name="Main Wing", - Ler_Gs_Cgs=[0.0, 0.025, 0.0], - angles_Gs_to_Wn_ixyz=[4, 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), - num_chordwise_panels=5, - chordwise_spacing="uniform", - ) - - -pterasaure = ps.geometry.airplane.Airplane( - wings=[ - wing, - ], - name="Pterosaure", - Cg_GP1_CgP1=(0.0, 0.0, 0.0), - weight=0, - s_ref=None, - c_ref=None, - b_ref=None, -) - - -# Define the airplane's AirplaneMovement. -wing_movements = [] -for wing in pterasaure.wings: - wing_cross_section_movements = [] - for wing_cross_section in wing.wing_cross_sections: - wing_cross_section_movements.append(ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - base_wing_cross_section=wing_cross_section) - ) - wing_movements.append(wing_cross_section_movements) - -main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=pterasaure.wings[0], - wing_cross_section_movements= wing_movements[0], - 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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), -) - -reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=pterasaure.wings[1], - wing_cross_section_movements=wing_movements[1], - 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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), -) - -# Now define the example airplane's AirplaneMovement. For now, no movement of the airplane is possible. -pterasaure_movement = ps.movements.airplane_movement.AirplaneMovement( - base_airplane=pterasaure, - wing_movements=[main_wing_movement, reflected_main_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), -) - -# Define a new OperatingPoint. -example_operating_point = ps.operating_point.OperatingPoint( - rho=1.225, vCg__E=10.0, alpha=5.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 -) - -# Define the operating point's OperatingPointMovement. -operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( - base_operating_point=example_operating_point) - -# Define the Movement. This contains the AirplaneMovement and the -# OperatingPointMovement. -movement = ps.movements.movement.Movement( - airplane_movements=[pterasaure_movement], - operating_point_movement=operating_point_movement, - delta_time=None, - num_cycles=1, - num_chords=None, - num_steps=None, -) - -# Define the UnsteadyProblem. -example_problem = ps.problems.UnsteadyProblem( - movement=movement, -) - -# Define a new solver. The available solver classes are -# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, -# and UnsteadyRingVortexLatticeMethodSolver. We'll create an -# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. -example_solver = ( - ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( - unsteady_problem=example_problem, - ) -) - - -# Run the solver. -example_solver.run( - prescribed_wake=True, - show_progress=True, -) - -# Call the animate function on the solver. This produces a GIF of the wake being -# shed. The GIF is saved in the same directory as this script. Press "q", -# after orienting the view, to begin the animation. - -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=True, -) - -# ps.output.print_results(example_solver) - -# ps.output.plot_wing_loads_versus_time(unsteady_solver=example_solver, show=True) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index 105c4e754..d7a3ea0a8 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -116,7 +116,7 @@ def explode_wing(wing): wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=3, + num_spanwise_panels=4, chord=0.25, Lp_Wcsp_Lpp=(0.0, 0.0, 0.0), angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0), @@ -179,7 +179,7 @@ def explode_wing(wing): ) wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=3, + num_spanwise_panels=1, chord=np.linalg.norm((0.322,0,-0.2256)), Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], @@ -454,9 +454,13 @@ def explode_wing(wing): ) # Define the UnsteadyProblem. -example_problem = ps.problems.BetterAeroelasticUnsteadyProblem( +example_problem = ps.problems.AeroelasticUnsteadyProblem( movement=movement, single_step_movement=single_step_movement, + wing_density=1, + spring_constant=0.1, + plot_flap_cycle=True, + damping_constant=1.0, ) # Define a new solver. The available solver classes are diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py index 80006b658..a84fe1ee3 100644 --- a/examples/demos/demo_single_step.py +++ b/examples/demos/demo_single_step.py @@ -412,9 +412,12 @@ del operating_point_movement # Define the UnsteadyProblem. -example_problem = ps.problems.BetterAeroelasticUnsteadyProblem( +example_problem = ps.problems.AeroelasticUnsteadyProblem( movement=movement, single_step_movement=single_step_movement, + wing_density=0.012, + spring_constant=1.0, + damping_constant=1.0, ) # Define a new solver. The available solver classes are diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index 0044ea452..1db461332 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -152,29 +152,15 @@ for i in range(len(example_airplane.wings[0].wing_cross_sections)): if i == 0: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], ) - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() - - main_movements_list.append(movement) main_single_step_movements_list.append(single_step_movement) - reflected_movements_list.append(movement) reflected_single_step_movements_list.append(single_step_movement) else: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - 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), - ) single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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"), @@ -184,41 +170,13 @@ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"), phaseAngles_Wcsp_to_Wcs_ixyz=(dephase_x, dephase_y, dephase_z), ) - main_movements_list.append(movement) main_single_step_movements_list.append(single_step_movement) - reflected_movements_list.append(movement) reflected_single_step_movements_list.append(single_step_movement) # Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. -v_tail_root_wing_cross_section_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_wing_cross_section_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), - ) -) - single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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"), @@ -230,6 +188,7 @@ ) single_step_v_tail_tip_wing_cross_section_movement = ( ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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"), @@ -242,24 +201,12 @@ ) dephase = 169.0 -# Now define the main wing's WingMovement, the reflected main wing's WingMovement and -# the v-tail's WingMovement. -# TODO: refactor to reduce redundancy with single step wing movement -main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[0], - wing_cross_section_movements=main_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), -) +# Now define the main wing's SingleStepWingMovement, the reflected main wing's SingleStepWingMovement and +# the v-tail's SingleStepWingMovement. single_step_main_wing_movement = ( ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[0], single_step_wing_cross_section_movements=main_single_step_movements_list, ampLer_Gs_Cgs=(0.0, 0.0, 0.0), periodLer_Gs_Cgs=(0.0, 0.0, 0.0), @@ -272,51 +219,24 @@ ) ) -reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[1], - wing_cross_section_movements=reflected_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), -) - single_step_reflected_main_wing_movement = ( ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[1], single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + ampAngles_Gs_to_Wn_ixyz=(15.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), ) ) -v_tail_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[2], - wing_cross_section_movements=[ - v_tail_root_wing_cross_section_movement, - v_tail_tip_wing_cross_section_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), -) - single_step_v_tail_movement = ( ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[2], single_step_wing_cross_section_movements=[ single_step_v_tail_root_wing_cross_section_movement, single_step_v_tail_tip_wing_cross_section_movement, @@ -335,22 +255,13 @@ # Delete the extraneous pointers to the WingCrossSectionMovements, as these are now # contained within the WingMovements. This is optional, but it can make debugging # easier. -del v_tail_root_wing_cross_section_movement -del v_tail_tip_wing_cross_section_movement - -# Now define the example airplane's AirplaneMovement. -airplane_movement = ps.movements.airplane_movement.AirplaneMovement( - base_airplane=example_airplane, - wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_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), -) +del single_step_v_tail_root_wing_cross_section_movement +del single_step_v_tail_tip_wing_cross_section_movement +# Now define the example airplane's SingleStepAirplaneMovement. single_step_airplane_movement = ( ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( - + base_airplane=example_airplane, single_step_wing_movements=[ single_step_main_wing_movement, single_step_reflected_main_wing_movement, @@ -364,9 +275,6 @@ ) # Delete the extraneous pointers to the WingMovements. -del main_wing_movement -del reflected_main_wing_movement -del v_tail_movement del single_step_main_wing_movement del single_step_reflected_main_wing_movement del single_step_v_tail_movement @@ -381,41 +289,36 @@ base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" ) -single_step_operating_point_movement = ( - ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( - ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" - ) +single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + base_operating_point=example_operating_point, + ampVCg__E=0.0, + periodVCg__E=0.0, + spacingVCg__E="sine", ) # Delete the extraneous pointer. del example_operating_point -# Define the Movement. This contains the AirplaneMovement and the -# OperatingPointMovement. -movement = ps.movements.movement.Movement( - airplane_movements=[airplane_movement], - operating_point_movement=operating_point_movement, - delta_time=0.03, - num_cycles=3, - num_chords=None, - num_steps=None, -) +# Define the SingleStepMovement. This contains the SingleStepAirplaneMovement and the +# SingleStepOperatingPointMovement. single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( single_step_airplane_movements=[single_step_airplane_movement], single_step_operating_point_movement=single_step_operating_point_movement, delta_time=0.03, - num_steps=movement.num_steps, + num_cycles=3, ) # Delete the extraneous pointers. -del airplane_movement del operating_point_movement # Define the UnsteadyProblem. -example_problem = ps.problems.BetterAeroelasticUnsteadyProblem( - movement=movement, +example_problem = ps.problems.AeroelasticUnsteadyProblem( single_step_movement=single_step_movement, + plot_flap_cycle=True, + wing_density=0.012, + spring_constant=1.0, + damping_constant=1.0, ) # Define a new solver. The available solver classes are diff --git a/examples/demos/demo_wing.py b/examples/demos/demo_wing.py deleted file mode 100644 index c2777ea1d..000000000 --- a/examples/demos/demo_wing.py +++ /dev/null @@ -1,323 +0,0 @@ -"""This is script is an example of how to run Ptera Software's -UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static -Movement.""" - -# First, import the software's main package. Note that if you wished to import this -# software into another package, you would first install it by running "pip install -# pterasoftware" in your terminal. -import pterasoftware as ps - -# Create an Airplane with our custom geometry. I am going to declare every parameter -# for Airplane, even though most of them have usable default values. This is for -# educational purposes, but keep in mind that it makes the code much longer than it -# needs to be. For details about each parameter, read the detailed class docstring. -# The same caveats apply to the other classes, methods, and functions I call in this -# script. - - -# offsets for the spacing -num_spanwise_panels = 1 -Lp_Wcsp_Lpp_Offsets = (0.1, 0.5, 0.1) - -# Wing cross section initialization -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="cosine" 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, - ), - ) - ) - - -example_airplane = ps.geometry.airplane.Airplane( - wings=[ - 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), - num_chordwise_panels=6, - chordwise_spacing="uniform", - ), - 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, 1.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), - 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, -) - -# The main Wing was defined to have symmetric=True, mirror_only=False, and with a -# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, -# that Wing had type 5 symmetry (see the Wing class documentation for more details on -# symmetry types). Therefore, it was actually split into two Wings, the with the -# second Wing being a reflected version of the first. Therefore, we need to define a -# WingMovement for this reflected Wing. To start, we'll first define the reflected -# main wing's root and tip WingCrossSections' WingCrossSectionMovements. - -# defintions for wing movement parameters -# dephase_x = 0.0 -# period_x = 1.0 -# amplitude_x = 2.0 - -# dephase_y = 0.0 -# period_y = 1.0 -# amplitude_y = 3.0 - -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 - -# A list of movements for the main wing -main_movements_list = [] - -# A list of movements for the reflected wing -reflected_movements_list = [] - -for i in range(len(example_airplane.wings[0].wing_cross_sections)): - if i == 0: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], - ) - main_movements_list.append(movement) - reflected_movements_list.append(movement) - else: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - 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_movements_list.append(movement) - reflected_movements_list.append(movement) - - -# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. -v_tail_root_wing_cross_section_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_wing_cross_section_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), - ) -) - -# Now define the main wing's WingMovement, the reflected main wing's WingMovement and -# the v-tail's WingMovement. -main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[0], - wing_cross_section_movements=main_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), -) -reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[1], - wing_cross_section_movements=reflected_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), -) -v_tail_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[2], - wing_cross_section_movements=[ - v_tail_root_wing_cross_section_movement, - v_tail_tip_wing_cross_section_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), -) - -# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now -# contained within the WingMovements. This is optional, but it can make debugging -# easier. -del v_tail_root_wing_cross_section_movement -del v_tail_tip_wing_cross_section_movement - -# Now define the example airplane's AirplaneMovement. -airplane_movement = ps.movements.airplane_movement.AirplaneMovement( - base_airplane=example_airplane, - wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_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), -) - -# Delete the extraneous pointers to the WingMovements. -del main_wing_movement -del reflected_main_wing_movement -del v_tail_movement - -# Define a new OperatingPoint. -example_operating_point = ps.operating_point.OperatingPoint( - rho=1.225, vCg__E=10.0, alpha=1.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 -) - -# Define the operating point's OperatingPointMovement. -operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( - base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" -) - -# Delete the extraneous pointer. -del example_operating_point - -# Define the Movement. This contains the AirplaneMovement and the -# OperatingPointMovement. -movement = ps.movements.movement.Movement( - airplane_movements=[airplane_movement], - operating_point_movement=operating_point_movement, - delta_time=0.03, - num_cycles=2, - num_chords=None, - num_steps=None, -) - -# Delete the extraneous pointers. -del airplane_movement -del operating_point_movement - -# Define the UnsteadyProblem. -example_problem = ps.problems.AeroelasticUnsteadyProblem( - movement=movement, -) - -# Define a new solver. The available solver classes are -# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, -# and UnsteadyRingVortexLatticeMethodSolver. We'll create an -# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. -example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( - coupled_unsteady_problem=example_problem, -) - -# Delete the extraneous pointer. -del example_problem - -# Run the solver. -example_solver.run( - logging_level="Warning", - prescribed_wake=True, -) - -# Call the animate function on the solver. This produces a GIF of the wake being -# shed. The GIF is saved in the same directory as this script. Press "q", -# after orienting the view, to begin the animation. -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=False, -) diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py index ea24fb6e9..071c4383a 100644 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -15,6 +15,8 @@ import numpy as np + +from ..airplane_movement import AirplaneMovement from ... import geometry from ..._parameter_validation import ( @@ -49,6 +51,7 @@ class SingleStepAirplaneMovement: def __init__( self, single_step_wing_movements, + base_airplane, ampCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), periodCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), spacingCg_GP1_CgP1: ( @@ -152,6 +155,20 @@ def __init__( "in phaseCg_GP1_CgP1 must be also be 0.0." ) self.phaseCg_GP1_CgP1 = phaseCg_GP1_CgP1 + + # Create the corresponding AirplaneMovement, which will remove redundancy as Coupled + # unsteady problems require both a SingleStepAirplaneMovement and an AirplaneMovement + # with the same parameters. + corresponding_wing_movements = [wm.corresponding_wing_movement for wm in self.wing_movements] + self.corresponding_airplane_movement = AirplaneMovement( + base_airplane=base_airplane, + wing_movements=corresponding_wing_movements, + ampCg_GP1_CgP1=ampCg_GP1_CgP1, + periodCg_GP1_CgP1=periodCg_GP1_CgP1, + spacingCg_GP1_CgP1=spacingCg_GP1_CgP1, + phaseCg_GP1_CgP1=phaseCg_GP1_CgP1, + ) + self.listCg_GP1_CgP1 = None @property diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index 7f6c590fd..75327beed 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -7,7 +7,7 @@ None """ -import math +from ..movement import Movement from .single_step_airplane_movement import SingleStepAirplaneMovement from .single_step_operating_point_movement import SingleStepOperatingPointMovement @@ -39,8 +39,10 @@ def __init__( self, single_step_airplane_movements, single_step_operating_point_movement, - delta_time=None, - num_steps=40, + delta_time: float | int | str | None = None, + num_cycles: int | None = None, + num_chords: int | None = None, + num_steps: int | None = None, ): """This is the initialization method. @@ -121,43 +123,18 @@ def __init__( ) self.operating_point_movement = single_step_operating_point_movement - if delta_time is not None: - delta_time = number_in_range_return_float( - delta_time, "delta_time", min_val=0.0, min_inclusive=False - ) - else: - - # FIXME: Automatic delta_time calculation gives very poor results if the - # motion has a high Strouhal number (i.e. a large ratio of - # flapping-motion to forward velocity). This is because the calculation - # assumes that the forward velocity is dominant. A better approach is - # needed. - - delta_times = [] - for airplane_movement in self.airplane_movements: - # TODO: Consider making this also average across each Airplane's Wings. - # For a given Airplane, the ideal time step length is that which - # sheds RingVortices off the first Wing that have roughly the same - # chord length as the RingVortices on the first Wing. This is based - # on the base Airplane's reference chord length, its first Wing's - # number of chordwise panels, and its base OperatingPoint's velocity. - delta_times.append( - airplane_movement.base_airplane.c_ref - / airplane_movement.base_airplane.wings[0].num_chordwise_panels - / single_step_operating_point_movement.base_operating_point.vCg__E - ) - - # Set the delta_time to be the average of the Airplanes' ideal delta times. - delta_time = sum(delta_times) / len(delta_times) - self.delta_time = delta_time - - num_steps = int_in_range_return_int(num_steps, "num_steps", min_val=1, min_inclusive=True) - - self.num_steps = num_steps + corresponding_airplane_movements = [airplane_movement.corresponding_airplane_movement for airplane_movement in self.airplane_movements] + self.corresponding_movement = Movement( + airplane_movements=corresponding_airplane_movements, + operating_point_movement=self.operating_point_movement.corresponding_operating_point_movement, + delta_time=delta_time, + num_chords=num_chords, + num_cycles=num_cycles, + num_steps=num_steps, + ) - # Generate a list of lists of Airplanes that are the steps through each - # AirplaneMovement. The first index identifies the AirplaneMovement, and the - # second index identifies the time step. + self.delta_time = self.corresponding_movement.delta_time + self.num_steps = self.corresponding_movement.num_steps def generate_next_movement(self, base_airplanes, base_operating_point, step, deformation_matrices=None): """Creates the Airplanes and OperatingPoint at the next time step. diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py index 667a9ed07..88bd028bf 100644 --- a/pterasoftware/movements/single_step/single_step_operating_point_movement.py +++ b/pterasoftware/movements/single_step/single_step_operating_point_movement.py @@ -7,7 +7,7 @@ This module contains the following functions: None """ - +from ..operating_point_movement import OperatingPointMovement from .._functions import ( oscillating_sinspaces, oscillating_linspaces, @@ -41,6 +41,7 @@ class SingleStepOperatingPointMovement: def __init__( self, + base_operating_point: OperatingPoint, ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine", @@ -123,6 +124,14 @@ def __init__( self.listVCg__E = None + self.corresponding_operating_point_movement = OperatingPointMovement( + base_operating_point=base_operating_point, + ampVCg__E=ampVCg__E, + periodVCg__E=periodVCg__E, + spacingVCg__E=spacingVCg__E, + phaseVCg__E=phaseVCg__E, + ) + def generate_next_operating_point(self, delta_time, base_operating_point: OperatingPoint, num_steps, step): """Creates the OperatingPoint at each time step, and returns them in a list. diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index 85af8dcc9..b0d142c2e 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -10,6 +10,7 @@ import numpy as np +from ..wing_cross_section_movement import WingCrossSectionMovement from ..._parameter_validation import ( threeD_number_vectorLike_return_float, @@ -47,6 +48,7 @@ class SingleStepWingCrossSectionMovement: def __init__( self, + base_wing_cross_section, ampLp_Wcsp_Lpp=(0.0, 0.0, 0.0), periodLp_Wcsp_Lpp=(0.0, 0.0, 0.0), spacingLp_Wcsp_Lpp=("sine", "sine", "sine"), @@ -261,6 +263,21 @@ def __init__( self.listLp_Wcsp_Lpp = None self.listAngles_Wcsp_to_Wcs_ixyz = None + # Create the corresponding WingCrossSectionMovement, which will remove redundancy as + # Coupled unsteady problems require both a SingleStepWingCrossSectionMovement and a + # WingCrossSectionMovement with the same parameters. + self.corresponding_wcs_movement = WingCrossSectionMovement( + base_wing_cross_section=base_wing_cross_section, + ampLp_Wcsp_Lpp=ampLp_Wcsp_Lpp, + periodLp_Wcsp_Lpp=periodLp_Wcsp_Lpp, + spacingLp_Wcsp_Lpp=spacingLp_Wcsp_Lpp, + phaseLp_Wcsp_Lpp=phaseLp_Wcsp_Lpp, + ampAngles_Wcsp_to_Wcs_ixyz=ampAngles_Wcsp_to_Wcs_ixyz, + periodAngles_Wcsp_to_Wcs_ixyz=periodAngles_Wcsp_to_Wcs_ixyz, + spacingAngles_Wcsp_to_Wcs_ixyz=spacingAngles_Wcsp_to_Wcs_ixyz, + phaseAngles_Wcsp_to_Wcs_ixyz=phaseAngles_Wcsp_to_Wcs_ixyz, + ) + def generate_next_wing_cross_sections( self, base_wing_cross_section, @@ -268,7 +285,6 @@ def generate_next_wing_cross_sections( num_steps, step, deformation_matrix, - base=False, ): """Creates the WingCrossSection at each time step, and returns them in a list. diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py index bc17f7e6e..f9266a3ed 100644 --- a/pterasoftware/movements/single_step/single_step_wing_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_movement.py @@ -9,6 +9,8 @@ import numpy as np +from ..wing_movement import WingMovement + from ..._parameter_validation import ( threeD_number_vectorLike_return_float, threeD_spacing_vectorLike_return_tuple, @@ -51,6 +53,7 @@ class SingleStepWingMovement: def __init__( self, + base_wing, single_step_wing_cross_section_movements, ampLer_Gs_Cgs=(0.0, 0.0, 0.0), periodLer_Gs_Cgs=(0.0, 0.0, 0.0), @@ -68,9 +71,9 @@ def __init__( This is the base Wing, from which the Wing at each time step will be created. - :param wing_cross_section_movements: list of WingCrossSectionMovements + :param single_step_wing_cross_section_movements: list of SingleStepWingCrossSectionMovements - This is a list of the WingCrossSectionMovements associated with each of + This is a list of the SingleStepWingCrossSectionMovements associated with each of the base Wing's WingCrossSections. It must have the same length as the base Wing's list of WingCrossSections. @@ -264,6 +267,30 @@ def __init__( ) self.phaseAngles_Gs_to_Wn_ixyz = phaseAngles_Gs_to_Wn_ixyz + # Create the corresponding WingMovement for this SingleStepWingMovement's base Wing, which + # will be used to generate the base Wing's WingCrossSections at each time step. This is + # done by using this SingleStepWingMovement's parameters and the corresponding parameters + # of its SingleStepWingCrossSectionMovements, which are stored in each + # SingleStepWingCrossSectionMovement's corresponding_wcs_movement attribute. + # This way, the user doesn't have to input redundant parameters for the base Wing's + # motion in both this SingleStepWingMovement and its SingleStepWingCrossSectionMovements. + corresponding_wcs_movement = [ + wcsm.corresponding_wcs_movement + for wcsm in single_step_wing_cross_section_movements + ] + self.corresponding_wing_movement = WingMovement( + base_wing=base_wing, + wing_cross_section_movements=corresponding_wcs_movement, + ampLer_Gs_Cgs=ampLer_Gs_Cgs, + periodLer_Gs_Cgs=periodLer_Gs_Cgs, + spacingLer_Gs_Cgs=spacingLer_Gs_Cgs, + phaseLer_Gs_Cgs=phaseLer_Gs_Cgs, + ampAngles_Gs_to_Wn_ixyz=ampAngles_Gs_to_Wn_ixyz, + periodAngles_Gs_to_Wn_ixyz=periodAngles_Gs_to_Wn_ixyz, + spacingAngles_Gs_to_Wn_ixyz=spacingAngles_Gs_to_Wn_ixyz, + phaseAngles_Gs_to_Wn_ixyz=phaseAngles_Gs_to_Wn_ixyz, + ) + self.listLer_Gs_Cgs = None self.listAngles_Gs_to_Wn_ixyz = None @@ -327,7 +354,6 @@ def generate_next_wing(self, base_wing, delta_time, num_steps, step, deformation delta_time=delta_time, num_steps=num_steps, step=step, - base=wing_cross_section_movement_id == 0, deformation_matrix=deformation_matrices[ wing_cross_section_movement_id ], diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 4a000b4fc..597f443a8 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -18,9 +18,7 @@ import numpy as np import scipy.signal as sp_sig - -from copy import deepcopy -from scipy.integrate import quad, solve_ivp +from scipy.integrate import solve_ivp import matplotlib.pyplot as plt from .movements.single_step.single_step_movement import SingleStepMovement from . import _parameter_validation, _transformations, geometry, movements @@ -233,12 +231,13 @@ class CoupledUnsteadyProblem(): None """ - def __init__(self, movement, only_final_results=False): + def __init__(self, single_step_movement, only_final_results=False): """This is the initialization method. - :param movement: Movement + :param single_step_movement: SingleStepMovement - This is the Movement that contains this UnsteadyProblem's + This is the SingleStepMovement that contains this CoupledUnsteadyProblem's + SingleStepOperatingPointMovement and SingleStepAirplaneMovements. OperatingPointMovement and AirplaneMovements. :param only_final_results: boolLike, optional @@ -248,13 +247,14 @@ def __init__(self, movement, only_final_results=False): sub-Movement with the longest period), which increases simulation speed. The default value is False. """ - if not isinstance(movement, movements.movement.Movement): - raise TypeError("movement must be a Movement.") - self.movement = movement + if not isinstance(single_step_movement, movements.single_step.single_step_movement.SingleStepMovement): + raise TypeError("single_step_movement must be a SingleStepMovement.") + self.single_step_movement = single_step_movement + self.movement = single_step_movement.corresponding_movement self.only_final_results = _parameter_validation.boolLike_return_bool( only_final_results, "only_final_results" ) - + print("Type of movement: ", type(self.movement)) self.num_steps = self.movement.num_steps self.delta_time = self.movement.delta_time @@ -324,9 +324,10 @@ def __init__(self, movement, only_final_results=False): # Get the Airplanes and the OperatingPoint associated with this time step. these_airplanes = [] - for this_base_airplane in movement.airplanes: + print(f"Step ID: {step_id}") + for this_base_airplane in self.movement.airplanes: these_airplanes.append(this_base_airplane[step_id]) - this_operating_point = movement.operating_points[step_id] + this_operating_point = self.movement.operating_points[step_id] # Initialize the SteadyProblem at this time step. this_steady_problem = SteadyProblem( @@ -369,226 +370,39 @@ def initialize_next_problem(self, solver): self._steady_problems.append(self.steady_problems[len(self._steady_problems)]) class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): - def __init__(self, single_step_movement: SingleStepMovement, movement, only_final_results=False): - # TODO: fix this constructor to properly inherit from CoupledUnsteadyProblem - super().__init__(movement, only_final_results) - self.prev_velocities = [] - self.single_step_movement = single_step_movement - self.G = 1e8 - self.I_area = 1e-14 - self.curr_airplanes = [movement.airplane_movements[0].base_airplane] - self.curr_operating_point = movement.operating_point_movement.base_operating_point - self.last_torsion_angles = None - self.error_exceeded_air = False - self.storage_steady_problem = None - self.net_torsion_angles = np.zeros(9) - - def define_mass_matrix(self, M_wing, airplane): - """ - Currently treats all panels as having equal mass. This will - - :param M_wing: float - This parameter is the total mass of one wing in (kg). - :param _geometry.airplane.Airplane: - The current - - :return numpy.ndarray - A 3D array of shape (num_spanwise_panels, num_chordwise_panels) - containing a float value for the mass of each panel - """ - # yes it's bad practice to have this in both functions, but I intend to update - # this with more complex methods - num_spanwise_panels = airplane.wings[0].num_spanwise_panels - num_chordwise_panels = airplane.wings[0].num_chordwise_panels - point_mass = M_wing / (num_spanwise_panels * num_chordwise_panels) - - return ( - np.ones((num_chordwise_panels, num_spanwise_panels, 3), dtype=float) - * point_mass - ) - - def calculate_wing_panel_accelerations(self, solver, num_panels): - dt = self.movement.delta_time - - curr_wing_panel_veloctiy = solver.calculate_solution_velocity( - solver.stackCpp_GP1_CgP1 - ) - if len(self.prev_velocities) < 1: - # Set the flapping velocities to be zero for all points. Then, return the - # flapping velocities. - return np.zeros((num_panels, 3)) - - return (curr_wing_panel_veloctiy - self.prev_velocities[-1])[:num_panels] / dt - - def get_steady_problem(self, step): - if self.error_exceeded_air: - return self.storage_steady_problem - else: - return super().get_steady_problem(step) - - def initialize_next_problem(self, solver): - deformation_matrices = self.calculate_wing_deformation(solver, len(self._steady_problems)) - self.curr_airplanes, self.curr_operating_point = ( - self.single_step_movement.generate_next_movement( - base_airplanes=self.curr_airplanes, - base_operating_point=self.curr_operating_point, - step=len(self._steady_problems), - deformation_matrices=deformation_matrices, - ) - ) - if self.error_exceeded_air: - self.storage_steady_problem = SteadyProblem( - airplanes=self.curr_airplanes, operating_point=self.curr_operating_point - ) - else: - self._steady_problems.append( - SteadyProblem( - airplanes=self.curr_airplanes, operating_point=self.curr_operating_point - ) - ) - - def calculate_wing_deformation(self, solver, step): - curr_problem: SteadyProblem = self._steady_problems[-1] - airplane = curr_problem.airplanes[0] - - wing = airplane.wings[0] - mass_matrix = self.define_mass_matrix(0.12, airplane) - - # Panel number definitions - num_chordwise_panels = wing.num_chordwise_panels - num_spanwise_panels = wing.num_spanwise_panels - num_panels = num_chordwise_panels * num_spanwise_panels - - current_torsion_aero = np.zeros(num_chordwise_panels) - current_torsion_inertia = np.zeros(num_chordwise_panels) - span_torsion_angles = np.zeros(int(num_spanwise_panels)) - chord_torsion_angles = np.zeros(int(num_spanwise_panels)) - torsion_matrix = np.zeros((num_chordwise_panels, num_spanwise_panels)) - - points = np.array(solver.stackCpp_GP1_CgP1)[:num_panels, :] - x_values = points.reshape((num_chordwise_panels, num_spanwise_panels, 3))[ - :num_panels, :, 0 - ] - panelAeroForces_G = ( - np.stack([o.forces_GP1 for o in np.ravel(wing.panels)]).reshape( - (num_chordwise_panels, num_spanwise_panels, 3) - ) - ) / 10000 - - panelInertialForces = self.calculate_wing_panel_accelerations(solver, num_panels).reshape(num_chordwise_panels, num_spanwise_panels, 3) * mass_matrix - print("\nMaximums", max(panelAeroForces_G.flatten()), max(panelInertialForces.flatten())) - print("\nMinimums", min(panelAeroForces_G.flatten()), min(panelInertialForces.flatten())) - # Iterate over spanwise and chordwise panels to find cumulative torsion due to force on each mesh element - # Force across spanwise panel is distinct - for span_panel in range(num_spanwise_panels): - # Force on each chordwise panel from LE to TE - # from each spanwise point is added to produce torsion at LE - for chord_panel in range(num_chordwise_panels): - # Torsion due to UVLM aero forces on LE - - current_torsion_aero[chord_panel] = ( - panelAeroForces_G[chord_panel][span_panel][2] - ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) - # Torsion due to panel inertia - current_torsion_inertia[chord_panel] = ( - panelInertialForces[chord_panel][span_panel][2] - ) * (x_values[chord_panel][span_panel] - x_values[0][span_panel]) - # Total torsion on LE due to chordwise panel. Aero and Inertial Torsion are oriented in opposite sense - ct_angle = quad( - self.d_alpha_dy_air_static, - 0, - 0.01, - args=( - -(current_torsion_aero[chord_panel]) - + (current_torsion_inertia[chord_panel]), - self.G * self.I_area, - ), - )[0] - - chord_torsion_angles[span_panel] += ct_angle - torsion_matrix[chord_panel][span_panel] = ct_angle - # Torsion on span-wise collection of panels - span_torsion_angles[span_panel] = ( - span_torsion_angles[span_panel - 1] + chord_torsion_angles[span_panel] - if span_panel > 0 - else chord_torsion_angles[span_panel] - ) - - # Inserting torsion of static span-wise collection of panels at wing root - span_torsion_angles = np.insert(span_torsion_angles, 0, 0) - - # ### ********************** Convergence of torsion angle ***************************** ### - # Error in torsion angle (radians) - # if self.last_torsion_angles is None: - # self.last_torsion_angles = np.zeros(num_spanwise_panels + 1) - # error_torsion = abs(span_torsion_angles - self.last_torsion_angles) - - # # if step < 0.5 * self.num_steps / 3: - # # # Error threshold for 1st half cycle is high to account for initial spike in forces in UVLM - # # self.error_exceeded_air = False - # # else: - # # # Error threshold in subsequeny timesteps is set to 0.01 - # # # TODO : Determine appropriate error threshold by running test cases on changing wing twist - # error_threshold = 0.01 * np.pi / 180 # - # # Boolean to determine if change in torsion on any panel exceeds error threshold - # self.error_exceeded_air = np.any(error_torsion[1:] > error_threshold) - - # self.last_torsion_angles = span_torsion_angles - # TODO: add logic for when to append vs overwrite - if (True): - self.prev_velocities.append( - solver.calculate_solution_velocity(solver.stackCpp_GP1_CgP1) - ) - span_torsion_angles = span_torsion_angles / -40 - if step > 30: - self.net_torsion_angles += span_torsion_angles - print("Net torsion angles (deg): ", self.net_torsion_angles) - else: - span_torsion_angles = np.zeros_like(span_torsion_angles) - return span_torsion_angles - - def d_alpha_dy_air_static(self, y, tau_torsion, GI): - return (tau_torsion * y) / GI - - def rotational_inertia(self, m, x, theta): - return (1 / 3) * m * (x / np.cos(theta)) ** 3 - - -class BetterAeroelasticUnsteadyProblem(CoupledUnsteadyProblem): def __init__( self, single_step_movement: SingleStepMovement, - movement, + wing_density, + spring_constant, + damping_constant, + aero_scaling=1.0, + moment_scaling_factor=1.0, + damping_eps=1e-3, + plot_flap_cycle=False, custom_spacing_second_derivative=None, only_final_results=False, ): - # TODO: fix this constructor to properly inherit from CoupledUnsteadyProblem - super().__init__(movement, only_final_results) + super().__init__(single_step_movement=single_step_movement, only_final_results=only_final_results) + self.plot_flap_cycle = plot_flap_cycle self.prev_velocities = [] - self.single_step_movement = single_step_movement - self.curr_airplanes = [movement.airplane_movements[0].base_airplane] + self.curr_airplanes = [self.movement.airplane_movements[0].base_airplane] self.curr_operating_point = ( - movement.operating_point_movement.base_operating_point + self.movement.operating_point_movement.base_operating_point ) self.positions = [] self.net_deformation = None self.angluar_velocities = None # Tunable Parameters - self.wing_density = 0.012 # per unit height kg/m^2 - self.moment_scaling_factor = 1.0 - self.spring_constant = 1 - self.damping_constant = 1 - self.aero_scaling = 0.0 + self.wing_density = wing_density # per unit height kg/m^2 + self.moment_scaling_factor = moment_scaling_factor + self.spring_constant = spring_constant + self.damping_constant = damping_constant + self.aero_scaling = aero_scaling self.numerical_integration = True # use numerical integration or closed form solution - self.damping_eps = 1e-3 # critical damping tolerance - - # self.wing_density = 0.012 # per unit height kg/m^2 - # self.moment_scaling_factor = 5 - # self.spring_constant = 1 - # self.aero_scaling = 0.0 - # self.new_integrand = True + self.damping_eps = damping_eps # critical damping tolerance self.per_step_data = [] self.net_data = [] @@ -689,15 +503,11 @@ def calculate_wing_deformation(self, solver, step): self.per_step_aero.append(aeroMoments_GP1_Slep.copy()) self.per_step_spring.append(spring_moments.copy()) - total_moments = aeroMoments_GP1_Slep - inertial_moments #+ spring_moments - deformation_moments = total_moments[:, :, 2] # Z-axis moments - step_deformation = np.array( [ np.array( [ 0, - # (np.sum(deformation_moments[:, i]) - self.net_deformation[i + 1][1] * self.spring_constant) * self.moment_scaling_factor, thetas[i + 1] * self.moment_scaling_factor, 0, ] @@ -714,27 +524,14 @@ def calculate_wing_deformation(self, solver, step): self.net_data.append(self.net_deformation.copy()) self.angluar_velocity_data.append(self.angluar_velocities.copy()) - if step % 10 == 3: - # print("Net deformation: ", self.net_deformation) - # print("step deformation: ", step_deformation) - aeroMoments_GP1_Slep = np.array(solver.moments_GP1_Slep[:num_panels]).reshape(num_chordwise_panels, num_spanwise_panels, 3) - - # print("Aero Moments Slep", self.net_deformation[:, 1]) - print("Thetas: ", thetas) - - if step == self.num_steps - 1: + if self.plot_flap_cycle and step == self.num_steps - 1: zero_curve = np.zeros((1, np.array(self.per_step_inertial).shape[0])) - print(np.array(self.per_step_inertial).shape) - print(np.array(self.per_step_aero).shape) - print(np.array(self.per_step_spring).shape) - print(np.array(self.per_step_data).shape) - print(np.array(self.net_data).shape) - plot_curves(np.array(self.per_step_data)[:, :, 1].T.tolist(), "Per Step Deformation") - plot_curves(np.array(self.net_data)[:, :, 1].T.tolist(), "Net Deformation") - plot_curves(np.vstack((zero_curve, np.array(self.per_step_inertial)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Inertial Moments") - plot_curves(np.vstack((zero_curve, np.array(self.per_step_aero)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Aero Moments") - plot_curves(np.vstack((zero_curve, np.array(self.per_step_spring)[:, :, 2].sum(axis=1).T)).tolist(), "Per Step Spring Moments") - plot_curves( + self.plot_flap_cycle_curves(np.array(self.per_step_data)[:, :, 1].T.tolist(), "Per Step Deformation") + self.plot_flap_cycle_curves(np.array(self.net_data)[:, :, 1].T.tolist(), "Net Deformation") + self.plot_flap_cycle_curves(np.vstack((zero_curve, np.array(self.per_step_inertial)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Inertial Moments") + self.plot_flap_cycle_curves(np.vstack((zero_curve, np.array(self.per_step_aero)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Aero Moments") + self.plot_flap_cycle_curves(np.vstack((zero_curve, np.array(self.per_step_spring)[:, :, 2].sum(axis=1).T)).tolist(), "Per Step Spring Moments") + self.plot_flap_cycle_curves( np.vstack( ( zero_curve, @@ -801,16 +598,8 @@ def calculate_torsional_spring_moment( t = np.linspace(dt * (step - 1), dt * step, num_steps) - if self.numerical_integration: - # ---- Forced numerical integration ---- - theta, omega = self.spring_numerical_ode(t, k, c, I, theta0, omega0, aero_span_moment, self.generate_inertial_torque_function(span_I)) - - else: - # ---- Closed-form with constant torque ---- - const_tau = aero_span_moment + self.generate_inertial_torque_function(span_I)(t[0]) - theta, omega = self.closed_form_spring_ode( - t, k, c, I, theta0, omega0, const_tau=const_tau - ) + # ---- Forced numerical integration ---- + theta, omega = self.spring_numerical_ode(t, k, c, I, theta0, omega0, aero_span_moment, self.generate_inertial_torque_function(span_I)) # ---- Internal spring-damper moment ---- spring_moment = -k * theta - c * omega @@ -877,105 +666,22 @@ def ode(time, y): return theta, omega - def closed_form_spring_ode( - self, - t, - k, - c, - I, - theta0, - omega0, - const_tau=0.0, - ): - """ Closed-form solution to the spring-damper ODE with constant torque input. - t: numpy.ndarray - Time array. - k: float - Spring constant. - c: float - Damping constant. - I: float - Rotational inertia. - theta0: float - Initial angular displacement. - omega0: float - Initial angular velocity. - const_tau: float, optional - Constant torque input. Default is 0.0. + def plot_flap_cycle_curves(self, data, title, flap_cycle=None): """ - # equilibrium shift - theta_eq = const_tau / k - - theta0s = theta0 - theta_eq - omega0s = omega0 - - omega_n = np.sqrt(k / I) - zeta = c / (2 * np.sqrt(k * I)) - - # ---- Underdamped ---- - if zeta < 1.0 - self.damping_eps: - omega_d = omega_n * np.sqrt(1 - zeta**2) - - A = theta0s - B = (omega0s + zeta * omega_n * theta0s) / omega_d - - exp_term = np.exp(-zeta * omega_n * t) - - phi = exp_term * ( - A * np.cos(omega_d * t) + - B * np.sin(omega_d * t) - ) - - omega = exp_term * ( - -zeta * omega_n * (A * np.cos(omega_d * t) + B * np.sin(omega_d * t)) - - A * omega_d * np.sin(omega_d * t) - + B * omega_d * np.cos(omega_d * t) - ) - - elif zeta > 1.0 + self.damping_eps: - # ---- Overdamped ---- - s = np.sqrt(zeta**2 - 1) - r1 = -omega_n * (zeta - s) - r2 = -omega_n * (zeta + s) - - A = (omega0s - r2 * theta0s) / (r1 - r2) - B = theta0s - A - - phi = A * np.exp(r1 * t) + B * np.exp(r2 * t) - omega = A * r1 * np.exp(r1 * t) + B * r2 * np.exp(r2 * t) - - else: - # ---- Critically damped (limit form) ---- - A = theta0s - B = omega0s + omega_n * theta0s - - exp_term = np.exp(-omega_n * t) - - phi = (A + B * t) * exp_term - omega = (B - omega_n * (A + B * t)) * exp_term - - # shift back to physical angle - theta = phi + theta_eq - - return theta[-1], omega[-1] - - -def plot_curves(data, title, flap_cycle=None): - """ - data: list of lists - each inner list is a curve - """ - plt.figure(figsize=(12, 6), dpi=200) - - for i, curve in enumerate(data): - x = range(len(curve)) - plt.plot(x, curve, label=f"Curve {i}") - if flap_cycle is not None: - plt.plot(range(len(flap_cycle)), flap_cycle, label=f"Flap Cycle", color="black") - plt.xlabel("Step") - plt.ylabel("Value") - plt.title(title) - plt.legend() - plt.grid(True) - plt.savefig(f"{title.replace(' ', '_')}.png") - plt.show() + data: list of lists + each inner list is a curve + """ + plt.figure(figsize=(12, 6), dpi=200) + + for i, curve in enumerate(data): + x = range(len(curve)) + plt.plot(x, curve, label=f"Curve {i}") + if flap_cycle is not None: + plt.plot(range(len(flap_cycle)), flap_cycle, label=f"Flap Cycle", color="black") + plt.xlabel("Step") + plt.ylabel("Value") + plt.title(title) + plt.legend() + plt.grid(True) + plt.savefig(f"{title.replace(' ', '_')}.png") + plt.show() From f20d359f8dd507ee9d412545bc0fc600eacd6981 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 20 Feb 2026 16:19:09 -0500 Subject: [PATCH 17/40] update pterosaur demo --- examples/demos/demo_pterosaur.py | 85 ++++--------------------- examples/demos/demo_single_step_flat.py | 1 + pterasoftware/problems.py | 1 - 3 files changed, 15 insertions(+), 72 deletions(-) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index d7a3ea0a8..0e32bdcb1 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -1,3 +1,5 @@ +from email.mime import base + import pterasoftware as ps import numpy as np from scipy.spatial.transform import Rotation as R @@ -298,11 +300,9 @@ def explode_wing(wing): amplitude_z = 0.0 # A list of movements for the main wing -main_movements_list = [] main_single_step_movements_list = [] # A list of movements for the reflected wing -reflected_movements_list = [] reflected_single_step_movements_list = [] for i in range(len(pterasaure.wings[0].wing_cross_sections)): @@ -310,26 +310,16 @@ def explode_wing(wing): movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( base_wing_cross_section=pterasaure.wings[0].wing_cross_sections[i], ) - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + base_wing_cross_section=pterasaure.wings[0].wing_cross_sections[i], + ) - main_movements_list.append(movement) main_single_step_movements_list.append(single_step_movement) - reflected_movements_list.append(movement) reflected_single_step_movements_list.append(single_step_movement) else: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - base_wing_cross_section=pterasaure.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), - ) single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + base_wing_cross_section=pterasaure.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"), @@ -339,27 +329,13 @@ def explode_wing(wing): spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"), phaseAngles_Wcsp_to_Wcs_ixyz=(dephase_x, dephase_y, dephase_z), ) - main_movements_list.append(movement) + main_single_step_movements_list.append(single_step_movement) - reflected_movements_list.append(movement) reflected_single_step_movements_list.append(single_step_movement) - -main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=pterasaure.wings[0], - wing_cross_section_movements= main_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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), -) - single_step_main_wing_movement = ( ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=pterasaure.wings[0], single_step_wing_cross_section_movements=main_single_step_movements_list, ampLer_Gs_Cgs=(0.0, 0.0, 0.0), periodLer_Gs_Cgs=(0.0, 0.0, 0.0), @@ -372,21 +348,9 @@ def explode_wing(wing): ) ) -reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=pterasaure.wings[1], - wing_cross_section_movements=reflected_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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), -) - single_step_reflected_main_wing_movement = ( ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=pterasaure.wings[1], single_step_wing_cross_section_movements=reflected_single_step_movements_list, ampLer_Gs_Cgs=(0.0, 0.0, 0.0), periodLer_Gs_Cgs=(0.0, 0.0, 0.0), @@ -400,17 +364,9 @@ def explode_wing(wing): ) # Now define the example airplane's AirplaneMovement. For now, no movement of the airplane is possible. -pterasaure_movement = ps.movements.airplane_movement.AirplaneMovement( - base_airplane=pterasaure, - wing_movements=[main_wing_movement, reflected_main_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), -) - pterasaure_single_step_movement = ( ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + base_airplane=pterasaure, single_step_wing_movements=[ single_step_main_wing_movement, single_step_reflected_main_wing_movement, @@ -428,38 +384,25 @@ def explode_wing(wing): ) # Define the operating point's OperatingPointMovement. -operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( - base_operating_point=example_operating_point) - single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( - ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" + base_operating_point=example_operating_point, ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" ) # Define the Movement. This contains the AirplaneMovement and the # OperatingPointMovement. -movement = ps.movements.movement.Movement( - airplane_movements=[pterasaure_movement], - operating_point_movement=operating_point_movement, - delta_time=None, - num_cycles=1, - num_chords=None, - num_steps=None, -) - single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( single_step_airplane_movements=[pterasaure_single_step_movement], single_step_operating_point_movement=single_step_operating_point_movement, - delta_time=movement.delta_time, - num_steps=movement.num_steps, + delta_time=None, + num_cycles=2, ) # Define the UnsteadyProblem. example_problem = ps.problems.AeroelasticUnsteadyProblem( - movement=movement, single_step_movement=single_step_movement, wing_density=1, spring_constant=0.1, - plot_flap_cycle=True, + plot_flap_cycle=False, damping_constant=1.0, ) diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index 1db461332..3a504babd 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -318,6 +318,7 @@ plot_flap_cycle=True, wing_density=0.012, spring_constant=1.0, + moment_scaling_factor=0.0, damping_constant=1.0, ) diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 597f443a8..5340fe6e5 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -324,7 +324,6 @@ def __init__(self, single_step_movement, only_final_results=False): # Get the Airplanes and the OperatingPoint associated with this time step. these_airplanes = [] - print(f"Step ID: {step_id}") for this_base_airplane in self.movement.airplanes: these_airplanes.append(this_base_airplane[step_id]) this_operating_point = self.movement.operating_points[step_id] From 065a67e9eeb6806bca53842a6314627f811776d3 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 23 Mar 2026 10:34:31 -0400 Subject: [PATCH 18/40] working single step integration --- examples/demos/demo_pterosaur.py | 7 +- examples/demos/demo_single_step_flat.py | 4 +- ...g_vortex_lattice_method_solver_variable.py | 2 +- ...led_unsteady_ring_vortex_lattice_method.py | 97 +++++++++++++------ pterasoftware/geometry/airplane.py | 87 +++++++++++++++++ ...single_step_wing_cross_section_movement.py | 6 +- pterasoftware/problems.py | 2 +- .../unsteady_ring_vortex_lattice_method.py | 46 ++++++--- 8 files changed, 199 insertions(+), 52 deletions(-) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index 0e32bdcb1..efd068d34 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -276,15 +276,15 @@ def explode_wing(wing): chordwise_spacing="uniform", ) -exploded_wing = explode_wing(original_wing) pterasaure = ps.geometry.airplane.Airplane( - wings=[exploded_wing], + wings=[original_wing], name="Pterosaur", Cg_GP1_CgP1=(0.0, 0.0, 0.0), weight=0, s_ref=None, c_ref=None, b_ref=None, + single_step_wing=True, ) dephase_x = 0.0 @@ -307,9 +307,6 @@ def explode_wing(wing): for i in range(len(pterasaure.wings[0].wing_cross_sections)): if i == 0: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - base_wing_cross_section=pterasaure.wings[0].wing_cross_sections[i], - ) single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( base_wing_cross_section=pterasaure.wings[0].wing_cross_sections[i], ) diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index 3a504babd..bbfe1b833 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -315,10 +315,10 @@ # Define the UnsteadyProblem. example_problem = ps.problems.AeroelasticUnsteadyProblem( single_step_movement=single_step_movement, - plot_flap_cycle=True, + plot_flap_cycle=False, wing_density=0.012, spring_constant=1.0, - moment_scaling_factor=0.0, + moment_scaling_factor=1.0, damping_constant=1.0, ) diff --git a/examples/unsteady_ring_vortex_lattice_method_solver_variable.py b/examples/unsteady_ring_vortex_lattice_method_solver_variable.py index 32f8b05d1..6db559642 100644 --- a/examples/unsteady_ring_vortex_lattice_method_solver_variable.py +++ b/examples/unsteady_ring_vortex_lattice_method_solver_variable.py @@ -58,7 +58,7 @@ symmetryNormal_G=(0.0, 1.0, 0.0), symmetryPoint_G_Cg=(0.0, 0.0, 0.0), num_chordwise_panels=6, - chordwise_spacing="uniform", + chordwise_spacing="cosine", ), ps.geometry.wing.Wing( wing_cross_sections=[ diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index bb9ca8dfa..35dd2a5a2 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -2,7 +2,7 @@ **Contains the following classes:** -CoupledUnsteadyRingVortexLatticeMethodSolver: A class used to solve CoupledUnsteadyProblems with +CoupledUnsteadyRingVortexLatticeMethodSolver: A class used to solve CoupledUnsteadyProblems with the unsteady ring vortex lattice method. **Contains the following functions:** @@ -13,6 +13,7 @@ from __future__ import annotations from collections.abc import Sequence + from typing import cast import numpy as np @@ -30,13 +31,15 @@ problems, ) +from .unsteady_ring_vortex_lattice_method import UnsteadyRingVortexLatticeMethodSolver + _logger = _logging.get_logger("unsteady_ring_vortex_lattice_method") # TEST: Consider adding unit tests for this function. # TEST: Assess how comprehensive this function's integration tests are and update or # extend them if needed. -class CoupledUnsteadyRingVortexLatticeMethodSolver: +class CoupledUnsteadyRingVortexLatticeMethodSolver(UnsteadyRingVortexLatticeMethodSolver): """A class used to solve UnsteadyProblems with the unsteady ring vortex lattice method. @@ -52,16 +55,21 @@ class CoupledUnsteadyRingVortexLatticeMethodSolver: velocity and the induced velocity from every RingVortex. """ - def __init__(self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem) -> None: + def __init__( + self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem + ) -> None: """The initialization method. :param unsteady_problem: The UnsteadyProblem to be solved. :return: None """ if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): - raise TypeError("coupled_unsteady_problem must be a CoupledUnsteadyProblem.") + raise TypeError( + "coupled_unsteady_problem must be a CoupledUnsteadyProblem." + ) self.coupled_unsteady_problem = coupled_unsteady_problem - + # super().__init__(problems.UnsteadyProblem(coupled_unsteady_problem.movement)) + self.num_steps = self.coupled_unsteady_problem.num_steps self.delta_time = self.coupled_unsteady_problem.delta_time self.first_results_step = self.coupled_unsteady_problem.first_results_step @@ -90,7 +98,7 @@ def __init__(self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem) -> for wing_cross_section in wing.wing_cross_sections: self.slep_point_indices.append(panel_count) if wing_cross_section.num_spanwise_panels is not None: - panel_count += wing_cross_section.num_spanwise_panels + panel_count += wing_cross_section.num_spanwise_panels self.slep_point_indices = np.array(self.slep_point_indices, dtype=int) self.num_panels: int = num_panels @@ -506,7 +514,9 @@ def run( # Update the progress bar based on this time step's predicted # approximate, relative computing time. - self.steady_problems.append(self.coupled_unsteady_problem.get_steady_problem(step)) + self.steady_problems.append( + self.coupled_unsteady_problem.get_steady_problem(step) + ) bar.update(n=float(approx_times[step + 1])) _logger.debug("Calculating averaged or final forces and moments.") @@ -541,7 +551,9 @@ def initialize_step_geometry(self, step: int) -> None: # Initialize bound RingVortices for all steps on the first call. if step == 0: - self._initialize_panel_vortex(self.coupled_unsteady_problem.get_steady_problem(0), 0) + self._initialize_panel_vortex( + self.coupled_unsteady_problem.get_steady_problem(0), 0 + ) # Set the current step and related state. self._current_step = step @@ -610,14 +622,10 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No chordwise_position + 1, spanwise_position ] - _nextFlbvp_GP1_CgP1 = ( - next_chordwise_panel.Flbvp_GP1_CgP1 - ) + _nextFlbvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1 assert _nextFlbvp_GP1_CgP1 is not None - _nextFrbvp_GP1_CgP1 = ( - next_chordwise_panel.Frbvp_GP1_CgP1 - ) + _nextFrbvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1 assert _nextFrbvp_GP1_CgP1 is not None Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1 @@ -647,8 +655,10 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No + vInf_GP1__E * self.delta_time * 0.25 ) else: - last_steady_problem = self.coupled_unsteady_problem.get_steady_problem( - steady_problem_id - 1 + last_steady_problem = ( + self.coupled_unsteady_problem.get_steady_problem( + steady_problem_id - 1 + ) ) last_airplane = last_steady_problem.airplanes[ airplane_id @@ -1951,7 +1961,9 @@ def _finalize_loads(self) -> None: for step in range(self._first_averaging_step, self.num_steps): # Get the Airplanes from the SteadyProblem at this time step. - this_steady_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(step) + this_steady_problem: problems.SteadyProblem = ( + self.coupled_unsteady_problem.get_steady_problem(step) + ) these_airplanes = this_steady_problem.airplanes # Iterate through this time step's Airplanes. @@ -1969,10 +1981,14 @@ def _finalize_loads(self) -> None: # For each Airplane, calculate and then save the final or cycle-averaged and # RMS loads and load coefficients. - first_problem: problems.SteadyProblem = self.coupled_unsteady_problem.get_steady_problem(0) + first_problem: problems.SteadyProblem = ( + self.coupled_unsteady_problem.get_steady_problem(0) + ) for airplane_id, airplane in enumerate(first_problem.airplanes): if static: - self.coupled_unsteady_problem.finalForces_W.append(forces_W[airplane_id, :, -1]) + self.coupled_unsteady_problem.finalForces_W.append( + forces_W[airplane_id, :, -1] + ) self.coupled_unsteady_problem.finalForceCoefficients_W.append( force_coefficients_W[airplane_id, :, -1] ) @@ -2039,14 +2055,41 @@ def _update_bound_vortex_positions_relative_to_slep_points(self) -> None: for panel_num, panel in enumerate(self.panels): self.stackFlpp_GP1_CgP1[panel_num] = panel.Flpp_GP1_CgP1 slep_points = self.stackFlpp_GP1_CgP1[self.slep_point_indices] - slep_map = np.searchsorted(self.slep_point_indices, np.arange(self.num_panels), side="right") - 1 - self.stack_leading_edge_points = np.array([ - slep_points[i] for i in slep_map - ]) - self.stackCblvpr_GP1_Slep = self.stackCblvpr_GP1_CgP1 - self.stack_leading_edge_points - self.stackCblvpf_GP1_Slep = self.stackCblvpf_GP1_CgP1 - self.stack_leading_edge_points - self.stackCblvpl_GP1_Slep = self.stackCblvpl_GP1_CgP1 - self.stack_leading_edge_points - self.stackCblvpb_GP1_Slep = self.stackCblvpb_GP1_CgP1 - self.stack_leading_edge_points + slep_map = ( + np.searchsorted( + self.slep_point_indices, np.arange(self.num_panels), side="right" + ) + - 1 + ) + self.stack_leading_edge_points = np.array([slep_points[i] for i in slep_map]) + self.stackCblvpr_GP1_Slep = ( + self.stackCblvpr_GP1_CgP1 - self.stack_leading_edge_points + ) + self.stackCblvpf_GP1_Slep = ( + self.stackCblvpf_GP1_CgP1 - self.stack_leading_edge_points + ) + self.stackCblvpl_GP1_Slep = ( + self.stackCblvpl_GP1_CgP1 - self.stack_leading_edge_points + ) + self.stackCblvpb_GP1_Slep = ( + self.stackCblvpb_GP1_CgP1 - self.stack_leading_edge_points + ) # Find the collocation point positions relative to the SLEP points. self.stackCpp_GP1_Slep = self.stackCpp_GP1_CgP1 - self.stack_leading_edge_points + + def get_steady_problem_at(self, step: int) -> problems.SteadyProblem: + """Gets the SteadyProblem at a given time step. This is used for dynamic dispatch + with coupled unsteady problem as we want to have a different way of getting the steady + problem based on the solver type, but we want functions to work the same way regardless + of the solver type so that we don't need ot duplicate functionality across solvers. + + :param step: An int representing the time step of the desired SteadyProblem. It + must be between 0 and num_steps - 1, inclusive. + :return: The SteadyProblem at the given time step. + """ + if step < 0 or step >= self.num_steps: + raise ValueError( + f"Step must be between 0 and {self.num_steps - 1}, inclusive." + ) + return self.coupled_unsteady_problem.get_steady_problem(step) diff --git a/pterasoftware/geometry/airplane.py b/pterasoftware/geometry/airplane.py index 2c850c0cf..82809c34c 100644 --- a/pterasoftware/geometry/airplane.py +++ b/pterasoftware/geometry/airplane.py @@ -18,6 +18,7 @@ import numpy as np import pyvista as pv import webp +import pterasoftware as ps from .. import _parameter_validation, _transformations from . import airfoil as airfoil_mod @@ -79,6 +80,7 @@ def __init__( name: str = "Untitled Airplane", Cg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), weight: float | int = 0.0, + single_step_wing: bool | np.bool_ = False, s_ref: float | int | None = None, c_ref: float | int | None = None, b_ref: float | int | None = None, @@ -114,11 +116,17 @@ def __init__( internally. The units are meters. """ wings = _parameter_validation.non_empty_list_return_list(wings, "wings") + self.single_step_wing = _parameter_validation.boolLike_return_bool( + single_step_wing, "single_step_wing" + ) processed_wings: list[wing_mod.Wing] = [] + if single_step_wing: + wings = [self.explode_wing(wing) for wing in wings] for wing in wings: if not isinstance(wing, wing_mod.Wing): raise TypeError("Every element in wings must be a Wing") processed_wings.extend(self.process_wing_symmetry(wing)) + self.wings = processed_wings self.name = _parameter_validation.str_return_str(name, "name") @@ -769,3 +777,82 @@ def process_wing_symmetry(wing: wing_mod.Wing) -> list[wing_mod.Wing]: wing.generate_mesh(symmetry_type=1) reflected_wing.generate_mesh(symmetry_type=3) return [wing, reflected_wing] + + def interpolate_between_wing_cross_sections(self,wcs1, wcs2, first_wcs): + """ + Wing cross section panels are between the line of wcs1 and wcs2. + When exploding a wing to 1 spanwise panel per cross section, + we need to interpolate the intermediate cross sections. + """ + + interpolated = [] + + if first_wcs: + interpolated.append( + ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=wcs1.chord, + Lp_Wcsp_Lpp=wcs1.Lp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=wcs1.angles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=wcs1.control_surface_symmetry_type, + control_surface_hinge_point=wcs1.control_surface_hinge_point, + control_surface_deflection=wcs1.control_surface_deflection, + spanwise_spacing="uniform", + airfoil=wcs1.airfoil, + ) + ) + + N = wcs1.num_spanwise_panels + + for i in range(N): + t = (i + 1) / N # interpolation parameter between 0 and 1 + + chord = (1 - t) * wcs1.chord + t * wcs2.chord + Lp_Wcsp_Lpp = tuple(np.array(wcs2.Lp_Wcsp_Lpp) / N) + # angles_Wcsp_to_Wcs_ixyz = tuple((1 - t) * np.array(wcs1.angles_Wcsp_to_Wcs_ixyz) + t * np.array(wcs2.angles_Wcsp_to_Wcs_ixyz)) + angles_Wcsp_to_Wcs_ixyz = wcs2.angles_Wcsp_to_Wcs_ixyz / N + is_final_section = wcs2.num_spanwise_panels is None and i == N - 1 + + interpolated.append( + ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=None if is_final_section else 1, + chord=chord, + Lp_Wcsp_Lpp=Lp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=angles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=wcs1.control_surface_symmetry_type, + control_surface_hinge_point=wcs1.control_surface_hinge_point, + control_surface_deflection=wcs1.control_surface_deflection, + spanwise_spacing=None if is_final_section else "uniform", + airfoil=wcs1.airfoil, + ) + ) + return interpolated + + def explode_wing(self, wing): + """ + Takes a ps.geometry.wing.Wing and returns a NEW Wing + where all cross sections have num_spanwise_panels = 1. + """ + + new_cross_sections = [] + + for i in range(len(wing.wing_cross_sections) - 1): + new_cross_sections.extend( + self.interpolate_between_wing_cross_sections( + wing.wing_cross_sections[i], wing.wing_cross_sections[i + 1], i == 0 + ) + ) + + # Rebuild the wing (copying everything else verbatim) + return ps.geometry.wing.Wing( + wing_cross_sections=new_cross_sections, + name=wing.name, + Ler_Gs_Cgs=wing.Ler_Gs_Cgs, + angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, + symmetric=wing.symmetric, + mirror_only=wing.mirror_only, + symmetryNormal_G=wing.symmetryNormal_G, + symmetryPoint_G_Cg=wing.symmetryPoint_G_Cg, + num_chordwise_panels=wing.num_chordwise_panels, + chordwise_spacing=wing.chordwise_spacing, + ) diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index b0d142c2e..f92b90b01 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -263,8 +263,12 @@ def __init__( self.listLp_Wcsp_Lpp = None self.listAngles_Wcsp_to_Wcs_ixyz = None + if base_wing_cross_section.num_spanwise_panels is not None and base_wing_cross_section.num_spanwise_panels > 1: + print("base_wing_cross_section must have num_spanwise_panels equal to None or 1 to do deformation. " + \ + "This wing cross section has " + str(base_wing_cross_section.num_spanwise_panels) + " spanwise panels. Please be sure this is intended. " + \ + "Applications that make sense for this are tails and non-primary wings.") # Create the corresponding WingCrossSectionMovement, which will remove redundancy as - # Coupled unsteady problems require both a SingleStepWingCrossSectionMovement and a + # Coupled unsteady problems require both a SingleStepWingCrossSectionMovement and a # WingCrossSectionMovement with the same parameters. self.corresponding_wcs_movement = WingCrossSectionMovement( base_wing_cross_section=base_wing_cross_section, diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 5340fe6e5..c34226f85 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -221,7 +221,7 @@ def __init__( # Append this SteadyProblem to the list of SteadyProblems. self.steady_problems.append(this_steady_problem) -class CoupledUnsteadyProblem(): +class CoupledUnsteadyProblem(UnsteadyProblem): """This is a class for coupled unsteady problems. This class contains the following public methods: diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 55d461226..b796000f3 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -40,7 +40,7 @@ class UnsteadyRingVortexLatticeMethodSolver: """A class used to solve UnsteadyProblems with the unsteady ring vortex lattice method. - **Contains the following methods:** + **Contains the following methods:**coupled_unsteady_problem run: Runs the solver on the UnsteadyProblem. @@ -71,7 +71,7 @@ def __init__(self, unsteady_problem: problems.UnsteadyProblem) -> None: self.steady_problems = self.unsteady_problem.steady_problems - first_steady_problem: problems.SteadyProblem = self.steady_problems[0] + first_steady_problem: problems.SteadyProblem = self.get_steady_problem_at(0) self.current_airplanes: list[geometry.airplane.Airplane] = [] self.current_operating_point: operating_point.OperatingPoint = ( @@ -213,7 +213,7 @@ def run( # method eliminates the need for computationally expensive on-the-fly # allocation and object copying. for step in range(self.num_steps): - this_problem: problems.SteadyProblem = self.steady_problems[step] + this_problem: problems.SteadyProblem = self.get_steady_problem_at(step) these_airplanes = this_problem.airplanes # Loop through this time step's Airplanes to create a list of their Wings. @@ -274,7 +274,7 @@ def run( # progress bar during the simulation initialization. approx_times = np.zeros(self.num_steps + 1, dtype=float) for step in range(1, self.num_steps): - this_problem = self.steady_problems[step] + this_problem = self.get_steady_problem_at(step) these_airplanes = this_problem.airplanes # Iterate through this time step's Airplanes to get the total number of @@ -326,9 +326,9 @@ def run( # and OperatingPoint, and freestream velocity (in the first # Airplane's geometry axes, observed from the Earth frame). self._current_step = step - current_problem: problems.SteadyProblem = self.steady_problems[ + current_problem: problems.SteadyProblem = self.get_steady_problem_at( self._current_step - ] + ) self.current_airplanes = current_problem.airplanes self.current_operating_point = current_problem.operating_point self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E @@ -507,7 +507,7 @@ def initialize_step_geometry(self, step: int) -> None: # Set the current step and related state. self._current_step = step - current_problem: problems.SteadyProblem = self.steady_problems[step] + current_problem: problems.SteadyProblem = self.get_steady_problem_at(step) self.current_airplanes = current_problem.airplanes self.current_operating_point = current_problem.operating_point self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E @@ -610,9 +610,9 @@ def _initialize_panel_vortices(self) -> None: + vInf_GP1__E * self.delta_time * 0.25 ) else: - last_steady_problem = self.steady_problems[ + last_steady_problem = self.get_steady_problem_at( steady_problem_id - 1 - ] + ) last_airplane = last_steady_problem.airplanes[ airplane_id ] @@ -749,7 +749,7 @@ def _collapse_geometry(self) -> None: # Reset the global Panel position variable. global_panel_position = 0 - last_problem = self.steady_problems[self._current_step - 1] + last_problem = self.get_steady_problem_at(self._current_step - 1) last_airplanes = last_problem.airplanes # Iterate through the last time step's Airplanes' Wings. @@ -1338,9 +1338,9 @@ def _populate_next_airplanes_wake_vortex_points(self) -> None: if self._current_step < self.num_steps - 1: # Get the next time step's Airplanes. - next_problem: problems.SteadyProblem = self.steady_problems[ + next_problem: problems.SteadyProblem = self.get_steady_problem_at( self._current_step + 1 - ] + ) next_airplanes = next_problem.airplanes # Get the current Airplanes' combined number of Wings. @@ -1568,7 +1568,7 @@ def _populate_next_airplanes_wake_vortices(self) -> None: if self._current_step < self.num_steps - 1: # Get the next time step's Airplanes. - next_problem = self.steady_problems[self._current_step + 1] + next_problem = self.get_steady_problem_at(self._current_step + 1) next_airplanes = next_problem.airplanes # Iterate through the next time step's Airplanes. @@ -1874,7 +1874,7 @@ def _finalize_loads(self) -> None: for step in range(self._first_averaging_step, self.num_steps): # Get the Airplanes from the SteadyProblem at this time step. - this_steady_problem: problems.SteadyProblem = self.steady_problems[step] + this_steady_problem: problems.SteadyProblem = self.get_steady_problem_at(step) these_airplanes = this_steady_problem.airplanes # Iterate through this time step's Airplanes. @@ -1892,7 +1892,7 @@ def _finalize_loads(self) -> None: # For each Airplane, calculate and then save the final or cycle-averaged and # RMS loads and load coefficients. - first_problem: problems.SteadyProblem = self.steady_problems[0] + first_problem: problems.SteadyProblem = self.get_steady_problem_at(0) for airplane_id, airplane in enumerate(first_problem.airplanes): if static: self.unsteady_problem.finalForces_W.append(forces_W[airplane_id, :, -1]) @@ -1951,3 +1951,19 @@ def _finalize_loads(self) -> None: ) ) ) + + def get_steady_problem_at(self, step: int) -> problems.SteadyProblem: + """Gets the SteadyProblem at a given time step. This is used for dynamic dispatch + with coupled unsteady problem as we want to have a different way of getting the steady + problem based on the solver type, but we want functions to work the same way regardless + of the solver type so that we don't need ot duplicate functionality across solvers. + + :param step: An int representing the time step of the desired SteadyProblem. It + must be between 0 and num_steps - 1, inclusive. + :return: The SteadyProblem at the given time step. + """ + if step < 0 or step >= self.num_steps: + raise ValueError( + f"Step must be between 0 and {self.num_steps - 1}, inclusive." + ) + return self.steady_problems[step] From 72d00b3a717a1715c1c15520b990dc0b5f4e6293 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 23 Mar 2026 12:02:57 -0400 Subject: [PATCH 19/40] add working subclassing --- ...led_unsteady_ring_vortex_lattice_method.py | 1407 +---------------- pterasoftware/problems.py | 88 +- .../unsteady_ring_vortex_lattice_method.py | 27 +- 3 files changed, 56 insertions(+), 1466 deletions(-) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 35dd2a5a2..7b569d29b 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -13,7 +13,6 @@ from __future__ import annotations from collections.abc import Sequence - from typing import cast import numpy as np @@ -67,28 +66,23 @@ def __init__( raise TypeError( "coupled_unsteady_problem must be a CoupledUnsteadyProblem." ) + # self.coupled_unsteady_problem must be defined before the call to super().__init__() + # because the parent class's __init__ method calls methods that rely on self.coupled_unsteady_problem being defined. self.coupled_unsteady_problem = coupled_unsteady_problem - # super().__init__(problems.UnsteadyProblem(coupled_unsteady_problem.movement)) - + super().__init__(problems.UnsteadyProblem(coupled_unsteady_problem.movement)) + self.num_steps = self.coupled_unsteady_problem.num_steps self.delta_time = self.coupled_unsteady_problem.delta_time self.first_results_step = self.coupled_unsteady_problem.first_results_step self._first_averaging_step = self.coupled_unsteady_problem.first_averaging_step - self._current_step: int = 0 - self._prescribed_wake: bool = True self.steady_problems = [] first_steady_problem: problems.SteadyProblem = ( - self.coupled_unsteady_problem.get_steady_problem(0) - ) - - self.current_airplanes: list[geometry.airplane.Airplane] = [] - self.current_operating_point: operating_point.OperatingPoint = ( - first_steady_problem.operating_point + self.get_steady_problem_at(0) ) - self.num_airplanes: int = len(first_steady_problem.airplanes) + # number of panels overide and strip leading edge point initialization num_panels = 0 panel_count = 0 self.slep_point_indices = [] @@ -100,56 +94,8 @@ def __init__( if wing_cross_section.num_spanwise_panels is not None: panel_count += wing_cross_section.num_spanwise_panels self.slep_point_indices = np.array(self.slep_point_indices, dtype=int) - self.num_panels: int = num_panels - # Initialize attributes to hold aerodynamic data that pertain to the simulation. - self._currentVInf_GP1__E: np.ndarray = ( - first_steady_problem.operating_point.vInf_GP1__E - ) - self._currentStackFreestreamWingInfluences__E: np.ndarray = np.empty( - 0, dtype=float - ) - self._currentGridWingWingInfluences__E: np.ndarray = np.empty(0, dtype=float) - self._currentStackWakeWingInfluences__E: np.ndarray = np.empty(0, dtype=float) - self._current_bound_vortex_strengths: np.ndarray = np.empty(0, dtype=float) - self._last_bound_vortex_strengths: np.ndarray = np.empty(0, dtype=float) - - # Initialize attributes to hold geometric data that pertain to this - # UnsteadyProblem. - self.panels: np.ndarray = np.empty(0, dtype=object) - self.stackUnitNormals_GP1: np.ndarray = np.empty(0, dtype=float) - self.panel_areas: np.ndarray = np.empty(0, dtype=float) - - # The current and last time step's collocation panel points (in the first - # Airplane's geometry axes, relative to the first Airplane's CG). - self.stackCpp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._stackLastCpp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - - # The current and last time step's back right, front right, front left, - # and back left bound RingVortex points (in the first Airplane's geometry - # axes, relative to the first Airplane's CG). - self.stackBrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self.stackFrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self.stackFlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self.stackBlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackBrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackFrbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackFlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackBlbrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - - # The current and last time step's center bound LineVortex points for the - # right, front, left, and back legs (in the first Airplane's geometry axes, - # relative to the first Airplane's CG). - self.stackCblvpr_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self.stackCblvpf_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self.stackCblvpl_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self.stackCblvpb_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackCblvpr_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackCblvpf_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackCblvpl_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._lastStackCblvpb_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - # The current time step's center bound LineVortex points for the right, # front, left, and back legs (in the first Airplane's geometry axes, # relative to the local strip leading edge point). @@ -167,50 +113,6 @@ def __init__( self.moments_GP1_Slep: np.ndarray = np.empty(0, dtype=float) self.stack_leading_edge_points: np.ndarray = np.empty(0, dtype=float) - # Right, front, left, and back bound RingVortex vectors (in the first - # Airplane's geometry axes). - self.stackRbrv_GP1: np.ndarray = np.empty(0, dtype=float) - self.stackFbrv_GP1: np.ndarray = np.empty(0, dtype=float) - self.stackLbrv_GP1: np.ndarray = np.empty(0, dtype=float) - self.stackBbrv_GP1: np.ndarray = np.empty(0, dtype=float) - - # Initialize variables to hold aerodynamic data that pertains details about - # each Panel's location on its Wing. - self.panel_is_trailing_edge: np.ndarray = np.empty(0, dtype=bool) - self.panel_is_leading_edge: np.ndarray = np.empty(0, dtype=bool) - self.panel_is_left_edge: np.ndarray = np.empty(0, dtype=bool) - self.panel_is_right_edge: np.ndarray = np.empty(0, dtype=bool) - - # Initialize variables to hold aerodynamic data that pertains to the wake at - # the current time step. - self._current_wake_vortex_strengths: np.ndarray = np.empty(0, dtype=float) - self._current_wake_vortex_ages: np.ndarray = np.empty(0, dtype=float) - - # The current time step's back right, front right, front left, and back left - # wake RingVortex points (in the first Airplane's geometry axes, relative to - # the first Airplane's CG). - self._currentStackBrwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._currentStackFrwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._currentStackFlwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self._currentStackBlwrvp_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - - # Initialize lists to store aerodynamic data about the wake at each time - # step. These attributes are used by the output module to animate the wake. - self.list_num_wake_vortices: list[int] = [] - # TODO: Determine if these private attributes are needed and if not - # delete them. - self._list_wake_vortex_strengths: list[np.ndarray] = [] - self._list_wake_vortex_ages: list[np.ndarray] = [] - self.listStackBrwrvp_GP1_CgP1: list[np.ndarray] = [] - self.listStackFrwrvp_GP1_CgP1: list[np.ndarray] = [] - self.listStackFlwrvp_GP1_CgP1: list[np.ndarray] = [] - self.listStackBlwrvp_GP1_CgP1: list[np.ndarray] = [] - - self.stackSeedPoints_GP1_CgP1: np.ndarray = np.empty(0, dtype=float) - self.gridStreamlinePoints_GP1_CgP1: np.ndarray = np.empty((0, 3), dtype=float) - - self.ran = False - def run( self, prescribed_wake: bool | np.bool_ = True, @@ -724,643 +626,33 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No strength=1.0, ) - def _collapse_geometry(self) -> None: - """Converts attributes of the UnsteadyProblem's geometry into 1D ndarrays. - - This facilitates vectorization, which speeds up the solver. - - :return: None - """ - # Initialize variables to hold the global position of the Panel and the wake - # RingVortex as we iterate through them. - global_panel_position = 0 - global_wake_ring_vortex_position = 0 - - # Iterate through the current time step's Airplanes' Wings. - for airplane in self.current_airplanes: - for wing in airplane.wings: - _panels = wing.panels - assert _panels is not None - - _wake_ring_vortices = wing.wake_ring_vortices - assert _wake_ring_vortices is not None - - # Convert this Wing's 2D ndarray of Panels and wake RingVortices into - # 1D ndarrays. - panels = np.ravel(_panels) - wake_ring_vortices = np.ravel(_wake_ring_vortices) - - # Iterate through the 1D ndarray of this Wing's Panels. - panel: _panel.Panel - for panel in panels: - # Update the solver's list of attributes with this Panel's - # attributes. - _functions.update_ring_vortex_solvers_panel_attributes( - ring_vortex_solver=self, - global_panel_position=global_panel_position, - panel=panel, - ) - - # Increment the global Panel position variable. - global_panel_position += 1 - - # Iterate through the 1D ndarray of this Wing's wake RingVortices. - wake_ring_vortex: _aerodynamics.RingVortex - for wake_ring_vortex in wake_ring_vortices: - # Update the solver's list of attributes with this wake - # RingVortex's attributes. - self._current_wake_vortex_strengths[ - global_wake_ring_vortex_position - ] = wake_ring_vortex.strength - self._current_wake_vortex_ages[global_wake_ring_vortex_position] = ( - wake_ring_vortex.age - ) - self._currentStackFrwrvp_GP1_CgP1[ - global_wake_ring_vortex_position, : - ] = wake_ring_vortex.Frrvp_GP1_CgP1 - self._currentStackFlwrvp_GP1_CgP1[ - global_wake_ring_vortex_position, : - ] = wake_ring_vortex.Flrvp_GP1_CgP1 - self._currentStackBlwrvp_GP1_CgP1[ - global_wake_ring_vortex_position, : - ] = wake_ring_vortex.Blrvp_GP1_CgP1 - self._currentStackBrwrvp_GP1_CgP1[ - global_wake_ring_vortex_position, : - ] = wake_ring_vortex.Brrvp_GP1_CgP1 - - # Increment the global wake RingVortex position variable. - global_wake_ring_vortex_position += 1 - - if self._current_step > 0: - - # Reset the global Panel position variable. - global_panel_position = 0 - - last_problem = self.coupled_unsteady_problem.get_steady_problem( - self._current_step - 1 - ) - last_airplanes = last_problem.airplanes - - # Iterate through the last time step's Airplanes' Wings. - for last_airplane in last_airplanes: - for last_wing in last_airplane.wings: - _last_panels = last_wing.panels - assert _last_panels is not None - - # Convert this Wing's 2D ndarray of Panels into a 1D ndarray. - last_panels = np.ravel(_last_panels) - - # Iterate through the 1D ndarray of this Wing's Panels. - last_panel: _panel.Panel - for last_panel in last_panels: - # Update the solver's list of attributes with this Panel's - # attributes. - self._stackLastCpp_GP1_CgP1[global_panel_position, :] = ( - last_panel.Cpp_GP1_CgP1 - ) - - last_ring_vortex = last_panel.ring_vortex - assert last_ring_vortex is not None - - self._last_bound_vortex_strengths[global_panel_position] = ( - last_ring_vortex.strength - ) - - # TODO: Test if we can replace the calls to LineVortex - # attributes with calls to RingVortex attributes. - self._lastStackBrbrvp_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.right_leg.Slvp_GP1_CgP1 - ) - self._lastStackFrbrvp_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.right_leg.Elvp_GP1_CgP1 - ) - self._lastStackFlbrvp_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.left_leg.Slvp_GP1_CgP1 - ) - self._lastStackBlbrvp_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.left_leg.Elvp_GP1_CgP1 - ) - self._lastStackCblvpr_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.right_leg.Clvp_GP1_CgP1 - ) - self._lastStackCblvpf_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.front_leg.Clvp_GP1_CgP1 - ) - self._lastStackCblvpl_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.left_leg.Clvp_GP1_CgP1 - ) - self._lastStackCblvpb_GP1_CgP1[global_panel_position, :] = ( - last_ring_vortex.back_leg.Clvp_GP1_CgP1 - ) - - # Increment the global Panel position variable. - global_panel_position += 1 - - def _calculate_wing_wing_influences(self) -> None: - """Finds the current time step's SteadyProblem's 2D ndarray of Wing Wing - influence coefficients (observed from the Earth frame). - - :return: None - """ - # Find the 2D ndarray of normalized velocities (in the first Airplane's - # geometry axes, observed from the Earth frame) induced at each Panel's - # collocation point by each bound RingVortex. The answer is normalized - # because the solver's list of bound RingVortex strengths was initialized to - # all be 1.0. This will be updated once the correct strengths are calculated. - gridNormVIndCpp_GP1_E = _aerodynamics.expanded_velocities_from_ring_vortices( - stackP_GP1_CgP1=self.stackCpp_GP1_CgP1, - stackBrrvp_GP1_CgP1=self.stackBrbrvp_GP1_CgP1, - stackFrrvp_GP1_CgP1=self.stackFrbrvp_GP1_CgP1, - stackFlrvp_GP1_CgP1=self.stackFlbrvp_GP1_CgP1, - stackBlrvp_GP1_CgP1=self.stackBlbrvp_GP1_CgP1, - strengths=self._current_bound_vortex_strengths, - ages=None, - nu=self.current_operating_point.nu, - ) - - # Take the batch dot product of the normalized induced velocities (in the - # first Airplane's geometry axes, observed from the Earth frame) with each - # Panel's unit normal direction (in the first Airplane's geometry axes). This - # is now the 2D ndarray of Wing Wing influence coefficients (observed from - # the Earth frame). - self._currentGridWingWingInfluences__E = np.einsum( - "...k,...k->...", - gridNormVIndCpp_GP1_E, - np.expand_dims(self.stackUnitNormals_GP1, axis=1), - ) - - def _calculate_freestream_wing_influences(self) -> None: - """Finds the 1D ndarray of freestream Wing influence coefficients (observed from - the Earth frame) at the current time step. - - **Notes:** - - This method also includes the influence coefficients due to motion defined in - Movement (observed from the Earth frame) at every collocation point. - - :return: None - """ - # Find the normal components of the freestream only Wing influence - # coefficients (observed from the Earth frame) at each Panel's collocation - # point by taking a batch dot product. - currentStackFreestreamOnlyWingInfluences__E = np.einsum( - "ij,j->i", - self.stackUnitNormals_GP1, - self._currentVInf_GP1__E, - ) - - # Get the current apparent velocities at each Panel's collocation point due - # to any motion defined in Movement (in the first Airplane's geometry axes, - # observed from the Earth frame). - currentStackMovementV_GP1_E = ( - self._calculate_current_movement_velocities_at_collocation_points() - ) - - # Get the current motion influence coefficients at each Panel's collocation - # point (observed from the Earth frame) by taking a batch dot product. - currentStackMovementInfluences__E = np.einsum( - "ij,ij->i", - self.stackUnitNormals_GP1, - currentStackMovementV_GP1_E, - ) - - # Calculate the total current freestream Wing influence coefficients by - # summing the freestream-only influence coefficients and the motion influence - # coefficients (all observed from the Earth frame). - self._currentStackFreestreamWingInfluences__E = ( - currentStackFreestreamOnlyWingInfluences__E - + currentStackMovementInfluences__E - ) - - def _calculate_wake_wing_influences(self) -> None: - """Finds the 1D ndarray of the wake Wing influence coefficients (observed from - the Earth frame) at the current time step. - - **Notes:** - If the current time step is the first time step, no wake has been shed, so this - method will return zero for all the wake Wing influence coefficients (observed - from the Earth frame). - - :return: None - """ - if self._current_step > 0: - # Get the velocities (in the first Airplane's geometry axes, observed - # from the Earth frame) induced by the wake RingVortices at each Panel's - # collocation point. - currentStackWakeV_GP1_E = ( - _aerodynamics.collapsed_velocities_from_ring_vortices( - stackP_GP1_CgP1=self.stackCpp_GP1_CgP1, - stackBrrvp_GP1_CgP1=self._currentStackBrwrvp_GP1_CgP1, - stackFrrvp_GP1_CgP1=self._currentStackFrwrvp_GP1_CgP1, - stackFlrvp_GP1_CgP1=self._currentStackFlwrvp_GP1_CgP1, - stackBlrvp_GP1_CgP1=self._currentStackBlwrvp_GP1_CgP1, - strengths=self._current_wake_vortex_strengths, - ages=self._current_wake_vortex_ages, - nu=self.current_operating_point.nu, - ) - ) - - # Get the current wake Wing influence coefficients (observed from the - # Earth frame) by taking a batch dot product with each Panel's normal - # vector (in the first Airplane's geometry axes). - self._currentStackWakeWingInfluences__E = np.einsum( - "ij,ij->i", currentStackWakeV_GP1_E, self.stackUnitNormals_GP1 - ) - - else: - # If this is the first time step, set all the current Wake-wing influence - # coefficients to 0.0 (observed from the Earth frame) because no wake - # RingVortices have been shed. - self._currentStackWakeWingInfluences__E = np.zeros( - self.num_panels, dtype=float - ) - - def _calculate_vortex_strengths(self) -> None: - """Solves for the strength of each Panel's bound RingVortex. - - :return: None - """ - self._current_bound_vortex_strengths = np.linalg.solve( - self._currentGridWingWingInfluences__E, - -self._currentStackWakeWingInfluences__E - - self._currentStackFreestreamWingInfluences__E, - ) - - # Update the bound RingVortices' strengths. - _panels = self.panels - assert _panels is not None - for panel_num in range(_panels.size): - panel: _panel.Panel = _panels[panel_num] - - this_ring_vortex = panel.ring_vortex - assert this_ring_vortex is not None - - this_ring_vortex.update_strength( - self._current_bound_vortex_strengths[panel_num] - ) - def calculate_solution_velocity( - self, stackP_GP1_CgP1: np.ndarray | Sequence[Sequence[float | int]] + def _load_calculation_moment_processing_hook( + self, + rightLegForces_GP1, + frontLegForces_GP1, + leftLegForces_GP1, + backLegForces_GP1, + unsteady_forces_GP1, ) -> np.ndarray: - """Finds the fluid velocity (in the first Airplane's geometry axes, observed - from the Earth frame) at one or more points (in the first Airplane's geometry - axes, relative to the first Airplane's CG) due to the freestream velocity and - the induced velocity from every RingVortex. - - **Notes:** - - This method assumes that the correct strengths for the RingVortices have already - been calculated and set. - - This method also does not include the velocity due to the Movement's motion at - any of the points provided, as it has no way of knowing if any of the points lie - on panels. - - :param stackP_GP1_CgP1: An array-like object of numbers (int or float) with - shape (N,3) representing the positions of the evaluation points (in the - first Airplane's geometry axes, relative to the first Airplane's CG). Can be - a tuple, list, or ndarray. Values are converted to floats internally. The - units are in meters. - :return: A (N,3) ndarray of floats representing the velocity (in the first - Airplane's geometry axes, observed from the Earth frame) at each evaluation - point due to the summed effects of the freestream velocity and the induced - velocity from every RingVortex and HorseshoeVortex. The units are in meters - per second. - """ - stackP_GP1_CgP1 = ( - _parameter_validation.arrayLike_of_threeD_number_vectorLikes_return_float( - stackP_GP1_CgP1, "stackP_GP1_CgP1" - ) - ) - - stackBoundRingVInd_GP1_E = ( - _aerodynamics.collapsed_velocities_from_ring_vortices( - stackP_GP1_CgP1=stackP_GP1_CgP1, - stackBrrvp_GP1_CgP1=self.stackBrbrvp_GP1_CgP1, - stackFrrvp_GP1_CgP1=self.stackFrbrvp_GP1_CgP1, - stackFlrvp_GP1_CgP1=self.stackFlbrvp_GP1_CgP1, - stackBlrvp_GP1_CgP1=self.stackBlbrvp_GP1_CgP1, - strengths=self._current_bound_vortex_strengths, - ages=None, - nu=self.current_operating_point.nu, - ) - ) - stackWakeRingVInd_GP1_E = _aerodynamics.collapsed_velocities_from_ring_vortices( - stackP_GP1_CgP1=stackP_GP1_CgP1, - stackBrrvp_GP1_CgP1=self._currentStackBrwrvp_GP1_CgP1, - stackFrrvp_GP1_CgP1=self._currentStackFrwrvp_GP1_CgP1, - stackFlrvp_GP1_CgP1=self._currentStackFlwrvp_GP1_CgP1, - stackBlrvp_GP1_CgP1=self._currentStackBlwrvp_GP1_CgP1, - strengths=self._current_wake_vortex_strengths, - ages=self._current_wake_vortex_ages, - nu=self.current_operating_point.nu, - ) - - return cast( - np.ndarray, - stackBoundRingVInd_GP1_E - + stackWakeRingVInd_GP1_E - + self._currentVInf_GP1__E, - ) - - def _calculate_loads(self) -> None: - """Calculates the forces (in the first Airplane's geometry axes) and moments (in - the first Airplane's geometry axes, relative to the first Airplane's CG) on - every Panel at the current time step. - - **Notes:** - - This method assumes that the correct strengths for the RingVortices and - HorseshoeVortices have already been calculated and set. - - This method used to accidentally double-count the load on each Panel due to the - left and right LineVortex legs. Additionally, it didn't include contributions to - the load on each Panel from their back LineVortex legs. Thankfully, these issues - only introduced small errors in most typical simulations. They have both now - been fixed by (1) using a 1/2 factor for each "effective" vortex strength shared - between two Panels, and (2) including the effects each Panel's back LineVortex - with its own effective strength. - - :return: None - """ - # Initialize a variable to hold the global Panel position as we iterate - # through them. - global_panel_position = 0 - - # Initialize three 1D ndarrays to hold the effective strength of the Panels' - # RingVortices' LineVortices. - effective_right_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) - effective_front_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) - effective_left_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) - effective_back_line_vortex_strengths = np.zeros(self.num_panels, dtype=float) - - # Iterate through the Airplanes' Wings. - for airplane in self.current_airplanes: - for wing in airplane.wings: - _panels = wing.panels - assert _panels is not None - - # Convert this Wing's 2D ndarray of Panels into a 1D ndarray. - panels = np.ravel(_panels) - - # Iterate through this Wing's 1D ndarray of Panels. - panel: _panel.Panel - for panel in panels: - _local_chordwise_position = panel.local_chordwise_position - assert _local_chordwise_position is not None - - _local_spanwise_position = panel.local_spanwise_position - assert _local_spanwise_position is not None - - if panel.is_right_edge: - # Set the effective right LineVortex strength to this Panel's - # RingVortex's strength. - effective_right_line_vortex_strengths[global_panel_position] = ( - self._current_bound_vortex_strengths[global_panel_position] - ) - else: - panel_to_right: _panel.Panel = _panels[ - _local_chordwise_position, - _local_spanwise_position + 1, - ] - - ring_vortex_to_right = panel_to_right.ring_vortex - assert ring_vortex_to_right is not None - - # Set the effective right LineVortex strength to 1/2 the - # difference between this Panel's RingVortex's strength, - # and the RingVortex's strength of the Panel to the right. - effective_right_line_vortex_strengths[global_panel_position] = ( - self._current_bound_vortex_strengths[global_panel_position] - - ring_vortex_to_right.strength - ) / 2 - - if panel.is_leading_edge: - # Set the effective front LineVortex strength to this Panel's - # RingVortex's strength. - effective_front_line_vortex_strengths[global_panel_position] = ( - self._current_bound_vortex_strengths[global_panel_position] - ) - else: - panel_to_front: _panel.Panel = _panels[ - _local_chordwise_position - 1, - _local_spanwise_position, - ] - - ring_vortex_to_front = panel_to_front.ring_vortex - assert ring_vortex_to_front is not None - - # Set the effective front LineVortex strength to 1/2 the - # difference between this Panel's RingVortex's strength, - # and the RingVortex's strength of the Panel in front of it. - effective_front_line_vortex_strengths[global_panel_position] = ( - self._current_bound_vortex_strengths[global_panel_position] - - ring_vortex_to_front.strength - ) / 2 - - if panel.is_left_edge: - # Set the effective left LineVortex strength to this Panel's - # RingVortex's strength. - effective_left_line_vortex_strengths[global_panel_position] = ( - self._current_bound_vortex_strengths[global_panel_position] - ) - else: - panel_to_left: _panel.Panel = _panels[ - _local_chordwise_position, - _local_spanwise_position - 1, - ] - - ring_vortex_to_left = panel_to_left.ring_vortex - assert ring_vortex_to_left is not None - - # Set the effective left LineVortex strength to 1/2 the - # difference between this Panel's RingVortex's strength, - # and the RingVortex's strength of the Panel to the left. - effective_left_line_vortex_strengths[global_panel_position] = ( - self._current_bound_vortex_strengths[global_panel_position] - - ring_vortex_to_left.strength - ) / 2 - - if panel.is_trailing_edge: - if self._current_step == 0: - # Set the effective back LineVortex strength to this - # Panel's RingVortex's strength, as, for the first time - # step, there isn't a wake RingVortex to cancel it out. - effective_back_line_vortex_strengths[ - global_panel_position - ] = self._current_bound_vortex_strengths[ - global_panel_position - ] - else: - # Set the effective back LineVortex strength to the - # difference between this Panel's RingVortex's strength and - # its strength at the last time step. This models the effect - # of the Panel's back LineVortex being partially cancelled - # out by the front LineVortex of the wake RingVortex - # immediately to this Panel's rear. This works because that - # wake RingVortex has the same strength this Panel's - # RingVortex had last time step. - effective_back_line_vortex_strengths[ - global_panel_position - ] = ( - self._current_bound_vortex_strengths[ - global_panel_position - ] - - self._last_bound_vortex_strengths[ - global_panel_position - ] - ) - else: - panel_to_back: _panel.Panel = _panels[ - _local_chordwise_position + 1, - _local_spanwise_position, - ] - - _ring_vortex_to_back = panel_to_back.ring_vortex - assert _ring_vortex_to_back is not None - - # Set the effective back LineVortex strength to 1/2 the - # difference between this Panel's RingVortex's strength, - # and the RingVortex's strength of the Panel to the back. - effective_back_line_vortex_strengths[global_panel_position] = ( - self._current_bound_vortex_strengths[global_panel_position] - - _ring_vortex_to_back.strength - ) / 2 - - # Increment the global Panel position variable. - global_panel_position += 1 - - # Calculate the velocity (in the first Airplane's geometry axes, observed - # from the Earth frame) at the center of every Panels' RingVortex's right - # LineVortex, front LineVortex, left LineVortex, and back LineVortex. - stackVelocityRightLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpr_GP1_CgP1) - + self._calculate_current_movement_velocities_at_right_leg_centers() - ) - stackVelocityFrontLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpf_GP1_CgP1) - + self._calculate_current_movement_velocities_at_front_leg_centers() - ) - stackVelocityLeftLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpl_GP1_CgP1) - + self._calculate_current_movement_velocities_at_left_leg_centers() - ) - stackVelocityBackLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity(stackP_GP1_CgP1=self.stackCblvpb_GP1_CgP1) - + self._calculate_current_movement_velocities_at_back_leg_centers() - ) - - # Using the effective LineVortex strengths and the Kutta-Joukowski theorem, - # find the forces (in the first Airplane's geometry axes) on the Panels' - # RingVortex's right LineVortex, front LineVortex, left LineVortex, and back - # LineVortex using the effective vortex strengths. - rightLegForces_GP1 = ( - self.current_operating_point.rho - * np.expand_dims(effective_right_line_vortex_strengths, axis=1) - * _functions.numba_1d_explicit_cross( - stackVelocityRightLineVortexCenters_GP1__E, self.stackRbrv_GP1 - ) - ) - frontLegForces_GP1 = ( - self.current_operating_point.rho - * np.expand_dims(effective_front_line_vortex_strengths, axis=1) - * _functions.numba_1d_explicit_cross( - stackVelocityFrontLineVortexCenters_GP1__E, self.stackFbrv_GP1 - ) - ) - leftLegForces_GP1 = ( - self.current_operating_point.rho - * np.expand_dims(effective_left_line_vortex_strengths, axis=1) - * _functions.numba_1d_explicit_cross( - stackVelocityLeftLineVortexCenters_GP1__E, self.stackLbrv_GP1 - ) - ) - backLegForces_GP1 = ( - self.current_operating_point.rho - * np.expand_dims(effective_back_line_vortex_strengths, axis=1) - * np.cross( - stackVelocityBackLineVortexCenters_GP1__E, - self.stackBbrv_GP1, - axis=-1, - ) - ) - - # The unsteady force calculation below includes a negative sign to account for a - # sign convention mismatch between Ptera Software and the reference literature. - # Ptera Software defines RingVortices with counter-clockwise (CCW) vertex - # ordering, while the references use clockwise (CW) ordering. Both define panel - # normals as pointing upward. This convention difference only affects the - # unsteady force term because it depends on both vortex strength and the normal - # vector. When converting from CCW to CW, the strength changes sign but the - # normal vector does not, requiring a sign correction. In contrast, steady - # Kutta-Joukowski forces depend on the strength and the LineVortex vectors. Both - # have flipped signs, causing the negatives to cancel. See issue #27: - # https://github.com/camUrban/PteraSoftware/issues/27 - - # Calculate the unsteady component of the force on each Panel (in geometry - # axes), which is derived from the unsteady Bernoulli equation. - unsteady_forces_GP1 = -( - self.current_operating_point.rho - * np.expand_dims( - ( - self._current_bound_vortex_strengths - - self._last_bound_vortex_strengths - ), - axis=1, - ) - * np.expand_dims(self.panel_areas, axis=1) - * self.stackUnitNormals_GP1 - / self.delta_time - ) - - forces_GP1 = ( - rightLegForces_GP1 - + frontLegForces_GP1 - + leftLegForces_GP1 - + backLegForces_GP1 - + unsteady_forces_GP1 - ) + """Extends the parent implementation to also compute moments + about the SLEP point, stored in self.moments_GP1_Slep. + :return: moments_GP1_CgP1, a (N,3) ndarray of floats representing the moments + (in the first Airplane's geometry axes, relative to the first Airplane's CG) + on every Panel at the current time step.""" # Find the moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) on the Panels' RingVortex's right LineVortex, # front LineVortex, left LineVortex, and back LineVortex. - rightLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCblvpr_GP1_CgP1, rightLegForces_GP1 - ) - frontLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCblvpf_GP1_CgP1, frontLegForces_GP1 - ) - leftLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCblvpl_GP1_CgP1, leftLegForces_GP1 - ) - backLegMoments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCblvpb_GP1_CgP1, backLegForces_GP1 - ) - - # The unsteady moment is calculated at the collocation point because the - # unsteady force acts on the bound RingVortex, whose center is at the - # collocation point, not at the Panel's centroid. - - # Find the moments (in the first Airplane's geometry axes, relative to the - # first Airplane's CG) due to the unsteady component of the force on each Panel. - unsteady_moments_GP1_CgP1 = _functions.numba_1d_explicit_cross( - self.stackCpp_GP1_CgP1, unsteady_forces_GP1 - ) - - moments_GP1_CgP1 = ( - rightLegMoments_GP1_CgP1 - + frontLegMoments_GP1_CgP1 - + leftLegMoments_GP1_CgP1 - + backLegMoments_GP1_CgP1 - + unsteady_moments_GP1_CgP1 + moments_GP1_CgP1 = super()._load_calculation_moment_processing_hook( + rightLegForces_GP1, + frontLegForces_GP1, + leftLegForces_GP1, + backLegForces_GP1, + unsteady_forces_GP1, ) - # TODO: Transform forces_GP1 and moments_GP1_CgP1 to each Airplane's local - # geometry axes before passing to process_solver_loads. - _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) - - # TODO: Remove the duplicated code below by refactoring self._update_bound_vortex_positions_relative_to_slep_points() rightLegMoments_GP1_Slep = _functions.numba_1d_explicit_cross( @@ -1394,656 +686,7 @@ def _calculate_loads(self) -> None: + unsteady_moments_GP1_Slep ) - def _populate_next_airplanes_wake(self) -> None: - """Updates the next time step's Airplanes' wakes. - - :return: None - """ - # Populate the locations of the next time step's Airplanes' wake RingVortex - # points. - self._populate_next_airplanes_wake_vortex_points() - - # Populate the locations of the next time step's Airplanes' wake RingVortices. - self._populate_next_airplanes_wake_vortices() - - def _populate_next_airplanes_wake_vortex_points(self) -> None: - """Populates the locations of the next time step's Airplanes' wake RingVortex - points. - - **Notes:** - - This method is not vectorized but its loops only consume 1.1% of the runtime, so - I have kept it as is for increased readability. - - :return: None - """ - # Check that this isn't the last time step. - if self._current_step < self.num_steps - 1: - - # Get the next time step's Airplanes. - next_problem: problems.SteadyProblem = ( - self.coupled_unsteady_problem.get_steady_problem(self._current_step + 1) - ) - next_airplanes = next_problem.airplanes - - # Get the current Airplanes' combined number of Wings. - num_wings = 0 - for airplane in self.current_airplanes: - num_wings += len(airplane.wings) - - # Iterate through this time step's Airplanes' successor objects. - for airplane_id, next_airplane in enumerate(next_airplanes): - - # Iterate through the next Airplane's Wings. - for wing_id, next_wing in enumerate(next_airplane.wings): - - # Get the Wings at this position from the current Airplane. - this_airplane = self.current_airplanes[airplane_id] - this_wing = this_airplane.wings[wing_id] - - # Check if this is the first time step. - if self._current_step == 0: - - # Get the current Wing's number of chordwise and spanwise - # panels. - num_spanwise_panels = this_wing.num_spanwise_panels - assert num_spanwise_panels is not None - - num_chordwise_panels = this_wing.num_chordwise_panels - - # Set the chordwise position to be at the trailing edge. - chordwise_panel_id = num_chordwise_panels - 1 - - # Initialize a ndarray to hold the points of the new row of - # wake RingVortices (in the first Airplane's geometry axes, - # relative to the first Airplane's CG). - newRowWrvp_GP1_CgP1 = np.zeros( - (1, num_spanwise_panels + 1, 3), dtype=float - ) - - # Iterate through the spanwise Panel positions. - for spanwise_panel_id in range(num_spanwise_panels): - _next_panels = next_wing.panels - assert _next_panels is not None - - # Get the next time step's Wing's Panel at this location. - next_panel: _panel.Panel = _next_panels[ - chordwise_panel_id, spanwise_panel_id - ] - - # The position of the new front left wake RingVortex's - # point is the next time step's Panel's bound - # RingVortex's back left point. - next_ring_vortex = next_panel.ring_vortex - assert next_ring_vortex is not None - - newFlwrvp_GP1_CgP1 = next_ring_vortex.Blrvp_GP1_CgP1 - - # Add this to the row of new wake RingVortex points. - newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( - newFlwrvp_GP1_CgP1 - ) - - # If the Panel is at the right edge of the Wing, add its - # back right bound RingVortex point to the row of new - # wake RingVortex points. - if spanwise_panel_id == (num_spanwise_panels - 1): - newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( - next_ring_vortex.Brrvp_GP1_CgP1 - ) - - # Set the next time step's Wing's grid of wake RingVortex - # points to a copy of the row of new wake RingVortex points. - # This is correct because it is currently the first time step. - next_wing.gridWrvp_GP1_CgP1 = np.copy(newRowWrvp_GP1_CgP1) - - # Initialize variables to hold the number of spanwise wake - # RingVortex points. - num_spanwise_points = num_spanwise_panels + 1 - - # Initialize a new ndarray to hold the second new row of wake - # RingVortex points (in the first Airplane's geometry axes, - # relative to the first Airplane's CG). - secondNewRowWrvp_GP1_CgP1 = np.zeros( - (1, num_spanwise_panels + 1, 3), dtype=float - ) - - # Iterate through the spanwise points. - for spanwise_point_id in range(num_spanwise_points): - # Get the corresponding point from the first row. - Wrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ - 0, spanwise_point_id - ] - assert Wrvp_GP1_CgP1 is not None - - # If the wake is prescribed, set the velocity at this - # point to the freestream velocity (in the first - # Airplane's geometry axes, observed from the Earth - # frame). Otherwise, set the velocity to the solution - # velocity at this point (in the first Airplane's - # geometry axes, observed from the Earth frame). - if self._prescribed_wake: - vWrvp_GP1__E = self._currentVInf_GP1__E - else: - vWrvp_GP1__E = self.calculate_solution_velocity( - np.expand_dims(Wrvp_GP1_CgP1, axis=0) - ) - - # Update the second new row with the interpolated - # position of the first point. - secondNewRowWrvp_GP1_CgP1[0, spanwise_point_id] = ( - Wrvp_GP1_CgP1 + vWrvp_GP1__E * self.delta_time - ) - - # Update the next time step's Wing's grid of wake RingVortex - # points by vertically stacking the new second row below it. - next_wing.gridWrvp_GP1_CgP1 = np.vstack( - ( - next_wing.gridWrvp_GP1_CgP1, - secondNewRowWrvp_GP1_CgP1, - ) - ) - - # If this isn't the first time step, then do this. - else: - _thisGridWrvp_GP1_CgP1 = this_wing.gridWrvp_GP1_CgP1 - assert _thisGridWrvp_GP1_CgP1 is not None - - # Set the next time step's Wing's grid of wake RingVortex - # points to a copy of this time step's Wing's grid of wake - # RingVortex points. - next_wing.gridWrvp_GP1_CgP1 = np.copy(_thisGridWrvp_GP1_CgP1) - - # Get the number of chordwise and spanwise points. - num_chordwise_points = next_wing.gridWrvp_GP1_CgP1.shape[0] - num_spanwise_points = next_wing.gridWrvp_GP1_CgP1.shape[1] - - # Iterate through the chordwise and spanwise point positions. - for chordwise_point_id in range(num_chordwise_points): - for spanwise_point_id in range(num_spanwise_points): - # Get the wake RingVortex point at this position. - Wrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ - chordwise_point_id, - spanwise_point_id, - ] - - # If the wake is prescribed, set the velocity at this - # point to the freestream velocity (in the first - # Airplane's geometry axes, observed from the Earth - # frame). Otherwise, set the velocity to the solution - # velocity at this point (in the first Airplane's - # geometry axes, observed from the Earth frame). - if self._prescribed_wake: - vWrvp_GP1__E = self._currentVInf_GP1__E - else: - vWrvp_GP1__E = np.squeeze( - self.calculate_solution_velocity( - np.expand_dims(Wrvp_GP1_CgP1, axis=0) - ) - ) - - # Update this point with its interpolated position. - next_wing.gridWrvp_GP1_CgP1[ - chordwise_point_id, spanwise_point_id - ] += (vWrvp_GP1__E * self.delta_time) - - # Find the chordwise position of the Wing's trailing edge. - chordwise_panel_id = this_wing.num_chordwise_panels - 1 - - _num_spanwise_panels = this_wing.num_spanwise_panels - assert _num_spanwise_panels is not None - - # Initialize a new ndarray to hold the new row of wake - # RingVortex vertices. - newRowWrvp_GP1_CgP1 = np.zeros( - (1, _num_spanwise_panels + 1, 3), dtype=float - ) - - # Iterate spanwise through the trailing edge Panels. - for spanwise_panel_id in range(_num_spanwise_panels): - _next_panels = next_wing.panels - assert _next_panels is not None - - # Get the Panel at this location on the next time step's - # Airplane's Wing. - this_next_panel: _panel.Panel = _next_panels[ - chordwise_panel_id, spanwise_panel_id - ] - - # Add the Panel's back left bound RingVortex point to the - # grid of new wake RingVortex points. - next_ring_vortex = this_next_panel.ring_vortex - assert next_ring_vortex is not None - - newRowWrvp_GP1_CgP1[0, spanwise_panel_id] = ( - next_ring_vortex.Blrvp_GP1_CgP1 - ) - - # If the Panel is at the right edge of the Wing, add its - # back right bound RingVortex point to the grid of new - # wake RingVortex vertices. - if spanwise_panel_id == (_num_spanwise_panels - 1): - newRowWrvp_GP1_CgP1[0, spanwise_panel_id + 1] = ( - next_ring_vortex.Brrvp_GP1_CgP1 - ) - - # Stack the new row of wake RingVortex points above the - # Wing's grid of wake RingVortex points. - next_wing.gridWrvp_GP1_CgP1 = np.vstack( - ( - newRowWrvp_GP1_CgP1, - next_wing.gridWrvp_GP1_CgP1, - ) - ) - - def _populate_next_airplanes_wake_vortices(self) -> None: - """Populates the locations and strengths of the next time step's wake - RingVortices. - - **Notes:** - - This method is not vectorized but its loops only consume 0.4% of the runtime, so - I have kept it as is for increased readability. - - :return: None - """ - # Check if the current time step is not the last step. - if self._current_step < self.num_steps - 1: - - # Get the next time step's Airplanes. - next_problem = self.coupled_unsteady_problem.get_steady_problem( - self._current_step + 1 - ) - next_airplanes = next_problem.airplanes - - # Iterate through the next time step's Airplanes. - for airplane_id, next_airplane in enumerate(next_airplanes): - - # For a given Airplane in the next time step, iterate through its - # predecessor's Wings. - for wing_id, this_wing in enumerate( - self.current_airplanes[airplane_id].wings - ): - next_wing = next_airplane.wings[wing_id] - - # Get the next time step's Wing's grid of wake RingVortex points. - nextGridWrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1 - assert nextGridWrvp_GP1_CgP1 is not None - - # Find the number of chordwise and spanwise points in the next - # Wing's grid of wake RingVortex points. - num_chordwise_points = nextGridWrvp_GP1_CgP1.shape[0] - num_spanwise_points = nextGridWrvp_GP1_CgP1.shape[1] - - this_wing_wake_ring_vortices = ( - self.current_airplanes[airplane_id] - .wings[wing_id] - .wake_ring_vortices - ) - assert this_wing_wake_ring_vortices is not None - - # Initialize a new ndarray to hold the new row of wake RingVortices. - new_row_of_wake_ring_vortices = np.empty( - (1, num_spanwise_points - 1), dtype=object - ) - - # Create a new ndarray by stacking the new row of wake - # RingVortices on top of the current Wing's grid of wake - # RingVortices and assign it to the next time step's Wing. - next_wing.wake_ring_vortices = np.vstack( - (new_row_of_wake_ring_vortices, this_wing_wake_ring_vortices) - ) - - # Iterate through the wake RingVortex point positions. - for chordwise_point_id in range(num_chordwise_points): - for spanwise_point_id in range(num_spanwise_points): - # Set bools to determine if this point is on the right - # and/or trailing edge of the wake. - has_point_to_right = ( - spanwise_point_id + 1 - ) < num_spanwise_points - has_point_behind = ( - chordwise_point_id + 1 - ) < num_chordwise_points - - if has_point_to_right and has_point_behind: - # If this point isn't on the right or trailing edge - # of the wake, get the four points that will be - # associated with the corresponding RingVortex at - # this position (in the first Airplane's geometry - # axes, relative to the first Airplane's CG), - # for the next time step. - Flwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id, spanwise_point_id - ] - Frwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id, - spanwise_point_id + 1, - ] - Blwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id + 1, - spanwise_point_id, - ] - Brwrvp_GP1_CgP1 = nextGridWrvp_GP1_CgP1[ - chordwise_point_id + 1, - spanwise_point_id + 1, - ] - - if chordwise_point_id > 0: - # If this isn't the front of the wake, update the - # position of the wake RingVortex at this - # location for the next time step. - next_wake_ring_vortices = ( - next_wing.wake_ring_vortices - ) - assert next_wake_ring_vortices is not None - next_wake_ring_vortex = cast( - _aerodynamics.RingVortex, - next_wake_ring_vortices[ - chordwise_point_id, spanwise_point_id - ], - ) - - next_wake_ring_vortex.update_position( - Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, - Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, - ) - - # Also, update the age of the wake RingVortex at - # this position for the next time step. - if self._current_step == 0: - next_wake_ring_vortex.age = self.delta_time - else: - next_wake_ring_vortex.age += self.delta_time - - if chordwise_point_id == 0: - _panels = this_wing.panels - assert _panels is not None - - # If this position corresponds to the front of - # the wake, get the strength from the Panel's - # bound RingVortex. - this_panel: _panel.Panel = _panels[ - this_wing.num_chordwise_panels - 1, - spanwise_point_id, - ] - - this_ring_vortex = this_panel.ring_vortex - assert this_ring_vortex is not None - - this_strength_copy = this_ring_vortex.strength - - # Then, for the next time step, make a new wake - # RingVortex at this position in the wake, - # with that bound RingVortex's strength, and add - # it to the grid of the next time step's wake - # RingVortices. - next_wing.wake_ring_vortices[ - chordwise_point_id, - spanwise_point_id, - ] = _aerodynamics.RingVortex( - Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, - Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, - strength=this_strength_copy, - ) - - def _calculate_current_movement_velocities_at_collocation_points( - self, - ) -> np.ndarray: - """Finds the apparent velocities (in the first Airplane's geometry axes, - observed from the Earth frame) at each Panel's collocation point due to any - motion defined in Movement at the current time step. - - **Notes:** - - At each point, any apparent velocity due to Movement is opposite the motion due - to Movement. - - :return: A (M, 3) ndarray of floats representing the apparent velocity (in the - first Airplane's geometry axes, observed from the Earth frame) at each - Panel's collocation point due to any motion defined in Movement. If the - current time step is the first time step, these velocities will all be all - zeros. Its units are in meters per second. - """ - # Check if this is the current time step. If so, return all zeros. - if self._current_step < 1: - return np.zeros((self.num_panels, 3), dtype=float) - - return cast( - np.ndarray, - -(self.stackCpp_GP1_CgP1 - self._stackLastCpp_GP1_CgP1) / self.delta_time, - ) - - def _calculate_current_movement_velocities_at_right_leg_centers(self) -> np.ndarray: - """Finds the apparent velocities (in the first Airplane's geometry axes, - observed from the Earth frame) at the center point of each bound RingVortex's - right leg due to any motion defined in Movement at the current time step. - - **Notes:** - - At each point, any apparent velocity due to Movement is opposite the motion due - to Movement. - - :return: A (M, 3) ndarray of floats representing the apparent velocity (in the - first Airplane's geometry axes, observed from the Earth frame) at the center - point of each bound RingVortex's right leg due to any motion defined in - Movement. If the current time step is the first time step, these velocities - will all be all zeros. Its units are in meters per second. - """ - # Check if this is the current time step. If so, return all zeros. - if self._current_step < 1: - return np.zeros((self.num_panels, 3), dtype=float) - - return cast( - np.ndarray, - -(self.stackCblvpr_GP1_CgP1 - self._lastStackCblvpr_GP1_CgP1) - / self.delta_time, - ) - - def _calculate_current_movement_velocities_at_front_leg_centers(self) -> np.ndarray: - """Finds the apparent velocities (in the first Airplane's geometry axes, - observed from the Earth frame) at the center point of each bound RingVortex's - front leg due to any motion defined in Movement at the current time step. - - **Notes:** - - At each point, any apparent velocity due to Movement is opposite the motion due - to Movement. - - :return: A (M, 3) ndarray of floats representing the apparent velocity (in the - first Airplane's geometry axes, observed from the Earth frame) at the center - point of each bound RingVortex's front leg due to any motion defined in - Movement. If the current time step is the first time step, these velocities - will all be all zeros. Its units are in meters per second. - """ - # Check if this is the current time step. If so, return all zeros. - if self._current_step < 1: - return np.zeros((self.num_panels, 3), dtype=float) - - return cast( - np.ndarray, - -(self.stackCblvpf_GP1_CgP1 - self._lastStackCblvpf_GP1_CgP1) - / self.delta_time, - ) - - def _calculate_current_movement_velocities_at_left_leg_centers(self) -> np.ndarray: - """Finds the apparent velocities (in the first Airplane's geometry axes, - observed from the Earth frame) at the center point of each bound RingVortex's - left leg due to any motion defined in Movement at the current time step. - - **Notes:** - - At each point, any apparent velocity due to Movement is opposite the motion due - to Movement. - - :return: A (M, 3) ndarray of floats representing the apparent velocity (in the - first Airplane's geometry axes, observed from the Earth frame) at the center - point of each bound RingVortex's left leg due to any motion defined in - Movement. If the current time step is the first time step, these velocities - will all be all zeros. Its units are in meters per second. - """ - # Check if this is the current time step. If so, return all zeros. - if self._current_step < 1: - return np.zeros((self.num_panels, 3), dtype=float) - - return cast( - np.ndarray, - -(self.stackCblvpl_GP1_CgP1 - self._lastStackCblvpl_GP1_CgP1) - / self.delta_time, - ) - - def _calculate_current_movement_velocities_at_back_leg_centers(self) -> np.ndarray: - """Finds the apparent velocities (in the first Airplane's geometry axes, - observed from the Earth frame) at the center point of each bound RingVortex's - back leg due to any motion defined in Movement at the current time step. - - **Notes:** - - At each point, any apparent velocity due to Movement is opposite the motion due - to Movement. - - :return: A (M, 3) ndarray of floats representing the apparent velocity (in the - first Airplane's geometry axes, observed from the Earth frame) at the center - point of each bound RingVortex's back leg due to any motion defined in - Movement. If the current time step is the first time step, these velocities - will all be all zeros. Its units are in meters per second. - """ - # Check if this is the current time step. If so, return all zeros. - if self._current_step < 1: - return np.zeros((self.num_panels, 3), dtype=float) - - return cast( - np.ndarray, - -(self.stackCblvpb_GP1_CgP1 - self._lastStackCblvpb_GP1_CgP1) - / self.delta_time, - ) - - def _finalize_loads(self) -> None: - """For cases with static geometry, finds the final loads and load coefficients - for each of the SteadyProblem's Airplanes. For cases with variable geometry, - finds the final cycle-averaged and cycle-root-mean-squared loads and load - coefficients for each of the SteadyProblem's Airplanes. - - :return: None - """ - # Get this solver's time step characteristics. Note that the first time step - # ( time step 0), occurs at 0 seconds. - num_steps_to_average = self.num_steps - self._first_averaging_step - - # Determine if this SteadyProblem's geometry is static or variable. - this_movement: movements.movement.Movement = ( - self.coupled_unsteady_problem.movement - ) - static = this_movement.static - - # Initialize ndarrays to hold each Airplane's loads and load coefficients at - # each of the time steps that calculated the loads. - forces_W = np.zeros((self.num_airplanes, 3, num_steps_to_average), dtype=float) - force_coefficients_W = np.zeros( - (self.num_airplanes, 3, num_steps_to_average), dtype=float - ) - moments_W_CgP1 = np.zeros( - (self.num_airplanes, 3, num_steps_to_average), dtype=float - ) - moment_coefficients_W_CgP1 = np.zeros( - (self.num_airplanes, 3, num_steps_to_average), dtype=float - ) - - # Initialize a variable to track position in the loads ndarrays. - results_step = 0 - - # Iterate through the time steps with loads and add the loads to their - # respective ndarrays. - for step in range(self._first_averaging_step, self.num_steps): - - # Get the Airplanes from the SteadyProblem at this time step. - this_steady_problem: problems.SteadyProblem = ( - self.coupled_unsteady_problem.get_steady_problem(step) - ) - these_airplanes = this_steady_problem.airplanes - - # Iterate through this time step's Airplanes. - for airplane_id, airplane in enumerate(these_airplanes): - forces_W[airplane_id, :, results_step] = airplane.forces_W - force_coefficients_W[airplane_id, :, results_step] = ( - airplane.forceCoefficients_W - ) - moments_W_CgP1[airplane_id, :, results_step] = airplane.moments_W_CgP1 - moment_coefficients_W_CgP1[airplane_id, :, results_step] = ( - airplane.momentCoefficients_W_CgP1 - ) - - results_step += 1 - - # For each Airplane, calculate and then save the final or cycle-averaged and - # RMS loads and load coefficients. - first_problem: problems.SteadyProblem = ( - self.coupled_unsteady_problem.get_steady_problem(0) - ) - for airplane_id, airplane in enumerate(first_problem.airplanes): - if static: - self.coupled_unsteady_problem.finalForces_W.append( - forces_W[airplane_id, :, -1] - ) - self.coupled_unsteady_problem.finalForceCoefficients_W.append( - force_coefficients_W[airplane_id, :, -1] - ) - self.coupled_unsteady_problem.finalMoments_W_CgP1.append( - moments_W_CgP1[airplane_id, :, -1] - ) - self.coupled_unsteady_problem.finalMomentCoefficients_W_CgP1.append( - moment_coefficients_W_CgP1[airplane_id, :, -1] - ) - else: - self.coupled_unsteady_problem.finalMeanForces_W.append( - np.mean(forces_W[airplane_id], axis=-1) - ) - self.coupled_unsteady_problem.finalMeanForceCoefficients_W.append( - np.mean(force_coefficients_W[airplane_id], axis=-1) - ) - self.coupled_unsteady_problem.finalMeanMoments_W_CgP1.append( - np.mean(moments_W_CgP1[airplane_id], axis=-1) - ) - self.coupled_unsteady_problem.finalMeanMomentCoefficients_W_CgP1.append( - np.mean(moment_coefficients_W_CgP1[airplane_id], axis=-1) - ) - - self.coupled_unsteady_problem.finalRmsForces_W.append( - np.sqrt( - np.mean( - np.square(forces_W[airplane_id]), - axis=-1, - ) - ) - ) - self.coupled_unsteady_problem.finalRmsForceCoefficients_W.append( - np.sqrt( - np.mean( - np.square(force_coefficients_W[airplane_id]), - axis=-1, - ) - ) - ) - self.coupled_unsteady_problem.finalRmsMoments_W_CgP1.append( - np.sqrt( - np.mean( - np.square(moments_W_CgP1[airplane_id]), - axis=-1, - ) - ) - ) - self.coupled_unsteady_problem.finalRmsMomentCoefficients_W_CgP1.append( - np.sqrt( - np.mean( - np.square(moment_coefficients_W_CgP1[airplane_id]), - axis=-1, - ) - ) - ) + return moments_GP1_CgP1 def _update_bound_vortex_positions_relative_to_slep_points(self) -> None: """Updates the bound RingVortex position variables to be relative to the diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index c34226f85..5bb97898b 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -254,88 +254,18 @@ def __init__(self, single_step_movement, only_final_results=False): self.only_final_results = _parameter_validation.boolLike_return_bool( only_final_results, "only_final_results" ) - print("Type of movement: ", type(self.movement)) - self.num_steps = self.movement.num_steps - self.delta_time = self.movement.delta_time - - # For UnsteadyProblems with a static Movement, users are typically interested - # in the final time step's forces and moments, which, assuming convergence, - # will be the most accurate. For UnsteadyProblems with cyclic movement, - # (e.g. flapping wings) users are typically interested in the forces and - # moments averaged over the last cycle simulated. Therefore, determine which - # time step will be the first with relevant results based on if the Movement - # is static or cyclic. - _movement_max_period = self.movement.max_period - if _movement_max_period == 0: - self.first_averaging_step = self.num_steps - 1 - else: - self.first_averaging_step = max( - 0, - math.floor(self.num_steps - (_movement_max_period / self.delta_time)), - ) - # If the user only wants to calculate forces and moments for the final cycle - # (for a cyclic Movement) or for the final time step (for a static Movement) - # set the first step to calculate results to the first averaging step. - # Otherwise, set it to the zero, which is the first time step. - if self.only_final_results: - self.first_results_step = self.first_averaging_step - else: - self.first_results_step = 0 - - # Initialize empty lists to hold the final loads and load coefficients each - # Airplane experiences. These will only be populated if this - # UnsteadyProblem's Movement is static. - self.finalForces_W = [] - self.finalForceCoefficients_W = [] - self.finalMoments_W_CgP1 = [] - self.finalMomentCoefficients_W_CgP1 = [] - - # Initialize empty lists to hold the final cycle-averaged loads and load - # coefficients each Airplane experiences. These will only be populated if - # this UnsteadyProblem's Movement is cyclic. - self.finalMeanForces_W = [] - self.finalMeanForceCoefficients_W = [] - self.finalMeanMoments_W_CgP1 = [] - self.finalMeanMomentCoefficients_W_CgP1 = [] - - # Initialize empty lists to hold the final cycle-root-mean-squared loads and - # load coefficients each airplane object experiences. These will only be - # populated for variable geometry problems. - self.finalRmsForces_W = [] - self.finalRmsForceCoefficients_W = [] - self.finalRmsMoments_W_CgP1 = [] - self.finalRmsMomentCoefficients_W_CgP1 = [] + super().__init__(movement=self.movement, only_final_results=self.only_final_results) # this set of steady problems should essnetially be treated as private # and the getter method should be used to obtain it - self._steady_problems = [ + self.coupled_steady_problems = [ SteadyProblem( [self.movement.airplane_movements[0].base_airplane], self.movement.operating_point_movement.base_operating_point, ) ] - # Initialize an empty list to hold the SteadyProblems. - self.steady_problems = [] - - # Iterate through the UnsteadyProblem's time steps. - for step_id in range(self.num_steps): - - # Get the Airplanes and the OperatingPoint associated with this time step. - these_airplanes = [] - for this_base_airplane in self.movement.airplanes: - these_airplanes.append(this_base_airplane[step_id]) - this_operating_point = self.movement.operating_points[step_id] - - # Initialize the SteadyProblem at this time step. - this_steady_problem = SteadyProblem( - airplanes=these_airplanes, operating_point=this_operating_point - ) - - # Append this SteadyProblem to the list of SteadyProblems. - self.steady_problems.append(this_steady_problem) - def get_steady_problem(self, step): """ Return the steady-state problem associated with the given step index. @@ -357,16 +287,16 @@ def get_steady_problem(self, step): steady problems. """ # Ensure the requested step index is valid. - if step >= len(self._steady_problems): + if step >= len(self.coupled_steady_problems): raise Exception( f"Step index {step} is out of range of the number of initialized problems" ) # Return the corresponding steady-state problem. - return self._steady_problems[step] + return self.coupled_steady_problems[step] def initialize_next_problem(self, solver): - self._steady_problems.append(self.steady_problems[len(self._steady_problems)]) + self.coupled_steady_problems.append(self.steady_problems[len(self.coupled_steady_problems)]) class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): @@ -431,17 +361,17 @@ def calculate_mass_matrix(self, wing): def initialize_next_problem(self, solver): deformation_matrices = self.calculate_wing_deformation( - solver, len(self._steady_problems) + solver, len(self.coupled_steady_problems) ) self.curr_airplanes, self.curr_operating_point = ( self.single_step_movement.generate_next_movement( base_airplanes=self.curr_airplanes, base_operating_point=self.curr_operating_point, - step=len(self._steady_problems), + step=len(self.coupled_steady_problems), deformation_matrices=deformation_matrices, ) ) - self._steady_problems.append( + self.coupled_steady_problems.append( SteadyProblem( airplanes=self.curr_airplanes, operating_point=self.curr_operating_point, @@ -449,7 +379,7 @@ def initialize_next_problem(self, solver): ) def calculate_wing_deformation(self, solver, step): - curr_problem: SteadyProblem = self._steady_problems[-1] + curr_problem: SteadyProblem = self.coupled_steady_problems[-1] airplane = curr_problem.airplanes[0] wing: geometry.wing.Wing = airplane.wings[0] diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index b796000f3..bc88fc49b 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -40,7 +40,7 @@ class UnsteadyRingVortexLatticeMethodSolver: """A class used to solve UnsteadyProblems with the unsteady ring vortex lattice method. - **Contains the following methods:**coupled_unsteady_problem + **Contains the following methods:** run: Runs the solver on the UnsteadyProblem. @@ -954,7 +954,7 @@ def _calculate_vortex_strengths(self) -> None: def calculate_solution_velocity( self, stackP_GP1_CgP1: np.ndarray | Sequence[Sequence[float | int]] ) -> np.ndarray: - """Finds the fluid velocity (in the first Airplane's geometry axes, observed + """Finds the fluid velocity (in the fiirst Airplane's geometry axes, observed from the Earth frame) at one or more points (in the first Airplane's geometry axes, relative to the first Airplane's CG) due to the freestream velocity and the induced velocity from every RingVortex. @@ -1273,6 +1273,25 @@ def _calculate_loads(self) -> None: + unsteady_forces_GP1 ) + moments_GP1_CgP1 = self._load_calculation_moment_processing_hook( + rightLegForces_GP1, + frontLegForces_GP1, + leftLegForces_GP1, + backLegForces_GP1, + unsteady_forces_GP1 + ) + + # TODO: Transform forces_GP1 and moments_GP1_CgP1 to each Airplane's local + # geometry axes before passing to process_solver_loads. + _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) + + def _load_calculation_moment_processing_hook(self, rightLegForces_GP1, frontLegForces_GP1, leftLegForces_GP1, backLegForces_GP1, unsteady_forces_GP1) -> np.ndarray: + """A hook method for processing the moments calculated in _calculate_loads. + This is added to allow for overriding the moment calculation in a child class + + :return: moments_GP1_CgP1, a (N,3) ndarray of floats representing the moments + (in the first Airplane's geometry axes, relative to the first Airplane's CG) + on every Panel at the current time step.""" # Find the moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) on the Panels' RingVortex's right LineVortex, # front LineVortex, left LineVortex, and back LineVortex. @@ -1307,9 +1326,7 @@ def _calculate_loads(self) -> None: + unsteady_moments_GP1_CgP1 ) - # TODO: Transform forces_GP1 and moments_GP1_CgP1 to each Airplane's local - # geometry axes before passing to process_solver_loads. - _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) + return moments_GP1_CgP1 def _populate_next_airplanes_wake(self) -> None: """Updates the next time step's Airplanes' wakes. From a3f56ee3120f08ebff3aefe553028bcbe068f5a9 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 23 Mar 2026 13:09:52 -0400 Subject: [PATCH 20/40] add panel vortices minor tweak --- ...led_unsteady_ring_vortex_lattice_method.py | 168 +----------- .../unsteady_ring_vortex_lattice_method.py | 258 +++++++++--------- 2 files changed, 132 insertions(+), 294 deletions(-) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 7b569d29b..1d74149d3 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -12,21 +12,14 @@ from __future__ import annotations -from collections.abc import Sequence -from typing import cast - import numpy as np from tqdm import tqdm from . import ( - _aerodynamics, _functions, _logging, - _panel, _parameter_validation, geometry, - movements, - operating_point, problems, ) @@ -459,9 +452,7 @@ def initialize_step_geometry(self, step: int) -> None: # Set the current step and related state. self._current_step = step - current_problem: problems.SteadyProblem = ( - self.coupled_unsteady_problem.get_steady_problem(step) - ) + current_problem: problems.SteadyProblem = self.get_steady_problem_at(step) self.current_airplanes = current_problem.airplanes self.current_operating_point = current_problem.operating_point self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E @@ -471,163 +462,6 @@ def initialize_step_geometry(self, step: int) -> None: self._populate_next_airplanes_wake_vortex_points() self._populate_next_airplanes_wake_vortices() - def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> None: - """Calculates the locations of the bound RingVortex vertices, and then - initializes them. - - Every Panel has a RingVortex, which is a quadrangle whose front leg is a - LineVortex at the Panel's quarter chord. The left and right legs are - LineVortices running along the Panel's left and right legs. If the Panel is not - along the trailing edge, they extend backwards and meet the back LineVortex, at - the rear Panel's quarter chord. Otherwise, they extend backwards and meet the - back LineVortex one quarter chord back from the Panel's back leg. - - :return: None - """ - this_operating_point = steady_problem.operating_point - vInf_GP1__E = this_operating_point.vInf_GP1__E - - # Iterate through this SteadyProblem's Airplanes' Wings. - for airplane_id, airplane in enumerate(steady_problem.airplanes): - for wing_id, wing in enumerate(airplane.wings): - _num_spanwise_panels = wing.num_spanwise_panels - assert _num_spanwise_panels is not None - - # Iterate through the Wing's chordwise and spanwise positions. - for chordwise_position in range(wing.num_chordwise_panels): - for spanwise_position in range(_num_spanwise_panels): - _panels = wing.panels - assert _panels is not None - - # Pull the Panel out of the Wing's 2D ndarray of Panels. - panel: _panel.Panel = _panels[ - chordwise_position, spanwise_position - ] - - _Flbvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1 - assert _Flbvp_GP1_CgP1 is not None - - _Frbvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1 - assert _Frbvp_GP1_CgP1 is not None - - # Find the location of this Panel's front left and - # front right RingVortex points (in the first Airplane's - # geometry axes, relative to the first Airplane's CG). - Flrvp_GP1_CgP1 = _Flbvp_GP1_CgP1 - Frrvp_GP1_CgP1 = _Frbvp_GP1_CgP1 - - # Define the location of the back left and back right - # RingVortex points based on whether the Panel is along - # the trailing edge or not. - if not panel.is_trailing_edge: - next_chordwise_panel: _panel.Panel = _panels[ - chordwise_position + 1, spanwise_position - ] - - _nextFlbvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1 - assert _nextFlbvp_GP1_CgP1 is not None - - _nextFrbvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1 - assert _nextFrbvp_GP1_CgP1 is not None - - Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1 - Brrvp_GP1_CgP1 = _nextFrbvp_GP1_CgP1 - else: - # As these vertices are directly behind the trailing - # edge, they are spaced back from their Panel's - # vertex by one quarter of the distance traveled by - # the trailing edge during a time step. This is to - # more accurately predict drag. More information can - # be found on pages 37-39 of "Modeling of aerodynamic - # forces in flapping flight with the Unsteady Vortex - # Lattice Method" by Thomas Lambert. - if steady_problem_id == 0: - _Blpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 - assert _Blpp_GP1_CgP1 is not None - - _Brpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 - assert _Brpp_GP1_CgP1 is not None - - Blrvp_GP1_CgP1 = ( - _Blpp_GP1_CgP1 - + vInf_GP1__E * self.delta_time * 0.25 - ) - Brrvp_GP1_CgP1 = ( - _Brpp_GP1_CgP1 - + vInf_GP1__E * self.delta_time * 0.25 - ) - else: - last_steady_problem = ( - self.coupled_unsteady_problem.get_steady_problem( - steady_problem_id - 1 - ) - ) - last_airplane = last_steady_problem.airplanes[ - airplane_id - ] - last_wing = last_airplane.wings[wing_id] - - _last_panels = last_wing.panels - assert _last_panels is not None - - last_panel: _panel.Panel = _last_panels[ - chordwise_position, spanwise_position - ] - - _thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 - assert _thisBlpp_GP1_CgP1 is not None - - _lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1 - assert _lastBlpp_GP1_CgP1 is not None - - # We subtract (thisBlpp_GP1_CgP1 - - # lastBlpp_GP1_CgP1) / self.delta_time from - # vInf_GP1__E, because we want the apparent fluid - # velocity due to motion (observed in the Earth - # frame, in the first Airplane's geometry axes). - # This is the vector pointing opposite the - # velocity from motion. - Blrvp_GP1_CgP1 = ( - _thisBlpp_GP1_CgP1 - + ( - vInf_GP1__E - - (_thisBlpp_GP1_CgP1 - _lastBlpp_GP1_CgP1) - / self.delta_time - ) - * self.delta_time - * 0.25 - ) - - _thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 - assert _thisBrpp_GP1_CgP1 is not None - - _lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1 - assert _lastBrpp_GP1_CgP1 is not None - - # The comment from above about apparent fluid - # velocity due to motion applies here as well. - Brrvp_GP1_CgP1 = ( - _thisBrpp_GP1_CgP1 - + ( - vInf_GP1__E - - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1) - / self.delta_time - ) - * self.delta_time - * 0.25 - ) - - # Initialize the Panel's RingVortex. - panel.ring_vortex = _aerodynamics.RingVortex( - Flrvp_GP1_CgP1=Flrvp_GP1_CgP1, - Frrvp_GP1_CgP1=Frrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brrvp_GP1_CgP1, - strength=1.0, - ) - - - def _load_calculation_moment_processing_hook( self, rightLegForces_GP1, diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index bc88fc49b..317c80e3a 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -521,6 +521,15 @@ def _initialize_panel_vortices(self) -> None: """Calculates the locations of the bound RingVortex vertices, and then initializes them. + :return: None + """ + for steady_problem_id, steady_problem in enumerate(self.steady_problems): + self._initialize_panel_vortex(steady_problem, steady_problem_id) + + def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> None: + """Calculates the locations of the bound RingVortex vertices, and then + initializes them for a specific steady problem. + Every Panel has a RingVortex, which is a quadrangle whose front leg is a LineVortex at the Panel's quarter chord. The left and right legs are LineVortices running along the Panel's left and right legs. If the Panel is not @@ -530,152 +539,147 @@ def _initialize_panel_vortices(self) -> None: :return: None """ - for steady_problem_id, steady_problem in enumerate(self.steady_problems): - # Find the freestream velocity (in the first Airplane's geometry axes, - # observed from the Earth frame) at this time step. - this_operating_point = steady_problem.operating_point - vInf_GP1__E = this_operating_point.vInf_GP1__E - - # Iterate through this SteadyProblem's Airplanes' Wings. - for airplane_id, airplane in enumerate(steady_problem.airplanes): - for wing_id, wing in enumerate(airplane.wings): - _num_spanwise_panels = wing.num_spanwise_panels - assert _num_spanwise_panels is not None - - # Iterate through the Wing's chordwise and spanwise positions. - for chordwise_position in range(wing.num_chordwise_panels): - for spanwise_position in range(_num_spanwise_panels): - _panels = wing.panels - assert _panels is not None - - # Pull the Panel out of the Wing's 2D ndarray of Panels. - panel: _panel.Panel = _panels[ - chordwise_position, spanwise_position - ] + this_operating_point = steady_problem.operating_point + vInf_GP1__E = this_operating_point.vInf_GP1__E + + # Iterate through this SteadyProblem's Airplanes' Wings. + for airplane_id, airplane in enumerate(steady_problem.airplanes): + for wing_id, wing in enumerate(airplane.wings): + _num_spanwise_panels = wing.num_spanwise_panels + assert _num_spanwise_panels is not None + + # Iterate through the Wing's chordwise and spanwise positions. + for chordwise_position in range(wing.num_chordwise_panels): + for spanwise_position in range(_num_spanwise_panels): + _panels = wing.panels + assert _panels is not None + + # Pull the Panel out of the Wing's 2D ndarray of Panels. + panel: _panel.Panel = _panels[ + chordwise_position, spanwise_position + ] - _Flbvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1 - assert _Flbvp_GP1_CgP1 is not None + _Flbvp_GP1_CgP1 = panel.Flbvp_GP1_CgP1 + assert _Flbvp_GP1_CgP1 is not None - _Frbvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1 - assert _Frbvp_GP1_CgP1 is not None + _Frbvp_GP1_CgP1 = panel.Frbvp_GP1_CgP1 + assert _Frbvp_GP1_CgP1 is not None - # Find the location of this Panel's front left and - # front right RingVortex points (in the first Airplane's - # geometry axes, relative to the first Airplane's CG). - Flrvp_GP1_CgP1 = _Flbvp_GP1_CgP1 - Frrvp_GP1_CgP1 = _Frbvp_GP1_CgP1 + # Find the location of this Panel's front left and + # front right RingVortex points (in the first Airplane's + # geometry axes, relative to the first Airplane's CG). + Flrvp_GP1_CgP1 = _Flbvp_GP1_CgP1 + Frrvp_GP1_CgP1 = _Frbvp_GP1_CgP1 - # Define the location of the back left and back right - # RingVortex points based on whether the Panel is along - # the trailing edge or not. - if not panel.is_trailing_edge: - next_chordwise_panel: _panel.Panel = _panels[ - chordwise_position + 1, spanwise_position - ] + # Define the location of the back left and back right + # RingVortex points based on whether the Panel is along + # the trailing edge or not. + if not panel.is_trailing_edge: + next_chordwise_panel: _panel.Panel = _panels[ + chordwise_position + 1, spanwise_position + ] - _nextFlbvp_GP1_CgP1 = ( - next_chordwise_panel.Flbvp_GP1_CgP1 - ) - assert _nextFlbvp_GP1_CgP1 is not None + _nextFlbvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1 + assert _nextFlbvp_GP1_CgP1 is not None - _nextFrbvp_GP1_CgP1 = ( - next_chordwise_panel.Frbvp_GP1_CgP1 - ) - assert _nextFrbvp_GP1_CgP1 is not None + _nextFrbvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1 + assert _nextFrbvp_GP1_CgP1 is not None - Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1 - Brrvp_GP1_CgP1 = _nextFrbvp_GP1_CgP1 + Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1 + Brrvp_GP1_CgP1 = _nextFrbvp_GP1_CgP1 + else: + # As these vertices are directly behind the trailing + # edge, they are spaced back from their Panel's + # vertex by one quarter of the distance traveled by + # the trailing edge during a time step. This is to + # more accurately predict drag. More information can + # be found on pages 37-39 of "Modeling of aerodynamic + # forces in flapping flight with the Unsteady Vortex + # Lattice Method" by Thomas Lambert. + if steady_problem_id == 0: + _Blpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 + assert _Blpp_GP1_CgP1 is not None + + _Brpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 + assert _Brpp_GP1_CgP1 is not None + + Blrvp_GP1_CgP1 = ( + _Blpp_GP1_CgP1 + + vInf_GP1__E * self.delta_time * 0.25 + ) + Brrvp_GP1_CgP1 = ( + _Brpp_GP1_CgP1 + + vInf_GP1__E * self.delta_time * 0.25 + ) else: - # As these vertices are directly behind the trailing - # edge, they are spaced back from their Panel's - # vertex by one quarter of the distance traveled by - # the trailing edge during a time step. This is to - # more accurately predict drag. More information can - # be found on pages 37-39 of "Modeling of aerodynamic - # forces in flapping flight with the Unsteady Vortex - # Lattice Method" by Thomas Lambert. - if steady_problem_id == 0: - _Blpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 - assert _Blpp_GP1_CgP1 is not None - - _Brpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 - assert _Brpp_GP1_CgP1 is not None - - Blrvp_GP1_CgP1 = ( - _Blpp_GP1_CgP1 - + vInf_GP1__E * self.delta_time * 0.25 - ) - Brrvp_GP1_CgP1 = ( - _Brpp_GP1_CgP1 - + vInf_GP1__E * self.delta_time * 0.25 - ) - else: - last_steady_problem = self.get_steady_problem_at( + last_steady_problem = ( + self.coupled_unsteady_problem.get_steady_problem( steady_problem_id - 1 ) - last_airplane = last_steady_problem.airplanes[ - airplane_id - ] - last_wing = last_airplane.wings[wing_id] + ) + last_airplane = last_steady_problem.airplanes[ + airplane_id + ] + last_wing = last_airplane.wings[wing_id] - _last_panels = last_wing.panels - assert _last_panels is not None + _last_panels = last_wing.panels + assert _last_panels is not None - last_panel: _panel.Panel = _last_panels[ - chordwise_position, spanwise_position - ] + last_panel: _panel.Panel = _last_panels[ + chordwise_position, spanwise_position + ] - _thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 - assert _thisBlpp_GP1_CgP1 is not None - - _lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1 - assert _lastBlpp_GP1_CgP1 is not None - - # We subtract (thisBlpp_GP1_CgP1 - - # lastBlpp_GP1_CgP1) / self.delta_time from - # vInf_GP1__E, because we want the apparent fluid - # velocity due to motion (observed in the Earth - # frame, in the first Airplane's geometry axes). - # This is the vector pointing opposite the - # velocity from motion. - Blrvp_GP1_CgP1 = ( - _thisBlpp_GP1_CgP1 - + ( - vInf_GP1__E - - (_thisBlpp_GP1_CgP1 - _lastBlpp_GP1_CgP1) - / self.delta_time - ) - * self.delta_time - * 0.25 + _thisBlpp_GP1_CgP1 = panel.Blpp_GP1_CgP1 + assert _thisBlpp_GP1_CgP1 is not None + + _lastBlpp_GP1_CgP1 = last_panel.Blpp_GP1_CgP1 + assert _lastBlpp_GP1_CgP1 is not None + + # We subtract (thisBlpp_GP1_CgP1 - + # lastBlpp_GP1_CgP1) / self.delta_time from + # vInf_GP1__E, because we want the apparent fluid + # velocity due to motion (observed in the Earth + # frame, in the first Airplane's geometry axes). + # This is the vector pointing opposite the + # velocity from motion. + Blrvp_GP1_CgP1 = ( + _thisBlpp_GP1_CgP1 + + ( + vInf_GP1__E + - (_thisBlpp_GP1_CgP1 - _lastBlpp_GP1_CgP1) + / self.delta_time ) + * self.delta_time + * 0.25 + ) - _thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 - assert _thisBrpp_GP1_CgP1 is not None + _thisBrpp_GP1_CgP1 = panel.Brpp_GP1_CgP1 + assert _thisBrpp_GP1_CgP1 is not None - _lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1 - assert _lastBrpp_GP1_CgP1 is not None + _lastBrpp_GP1_CgP1 = last_panel.Brpp_GP1_CgP1 + assert _lastBrpp_GP1_CgP1 is not None - # The comment from above about apparent fluid - # velocity due to motion applies here as well. - Brrvp_GP1_CgP1 = ( - _thisBrpp_GP1_CgP1 - + ( - vInf_GP1__E - - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1) - / self.delta_time - ) - * self.delta_time - * 0.25 + # The comment from above about apparent fluid + # velocity due to motion applies here as well. + Brrvp_GP1_CgP1 = ( + _thisBrpp_GP1_CgP1 + + ( + vInf_GP1__E + - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1) + / self.delta_time ) + * self.delta_time + * 0.25 + ) - # Initialize the Panel's RingVortex. - panel.ring_vortex = _aerodynamics.RingVortex( - Flrvp_GP1_CgP1=Flrvp_GP1_CgP1, - Frrvp_GP1_CgP1=Frrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brrvp_GP1_CgP1, - strength=1.0, - ) + # Initialize the Panel's RingVortex. + panel.ring_vortex = _aerodynamics.RingVortex( + Flrvp_GP1_CgP1=Flrvp_GP1_CgP1, + Frrvp_GP1_CgP1=Frrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brrvp_GP1_CgP1, + strength=1.0, + ) def _collapse_geometry(self) -> None: """Converts attributes of the UnsteadyProblem's geometry into 1D ndarrays. From 8178bf44507cfc4b71f8bfb4112f2536b375bb89 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 23 Mar 2026 14:23:55 -0400 Subject: [PATCH 21/40] move single step to wing --- examples/demos/demo_pterosaur.py | 2 +- examples/demos/demo_single_step_flat.py | 13 ++-- pterasoftware/geometry/airplane.py | 6 -- pterasoftware/geometry/wing.py | 90 +++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index efd068d34..2e80b1cfb 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -272,6 +272,7 @@ def explode_wing(wing): mirror_only=False, symmetryNormal_G=(0.0, 1.0, 0.0), symmetryPoint_G_Cg=(0.0, 0.0, 0.0), + single_step_wing = True, num_chordwise_panels=5, chordwise_spacing="uniform", ) @@ -284,7 +285,6 @@ def explode_wing(wing): s_ref=None, c_ref=None, b_ref=None, - single_step_wing=True, ) dephase_x = 0.0 diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index bbfe1b833..0523936c4 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -45,10 +45,7 @@ ) ) - -example_airplane = ps.geometry.airplane.Airplane( - wings=[ - ps.geometry.wing.Wing( +wing_1 = ps.geometry.wing.Wing( wing_cross_sections=wing_cross_sections, name="Main Wing", Ler_Gs_Cgs=(0.0, 0.5, 0.0), @@ -57,9 +54,14 @@ mirror_only=False, symmetryNormal_G=(0.0, 1.0, 0.0), symmetryPoint_G_Cg=(0.0, 0.0, 0.0), + single_step_wing=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( @@ -102,6 +104,7 @@ mirror_only=False, symmetryNormal_G=(0.0, 1.0, 0.0), symmetryPoint_G_Cg=(0.0, 0.0, 0.0), + single_step_wing=False, num_chordwise_panels=6, chordwise_spacing="uniform", ), diff --git a/pterasoftware/geometry/airplane.py b/pterasoftware/geometry/airplane.py index 82809c34c..8a02b78ac 100644 --- a/pterasoftware/geometry/airplane.py +++ b/pterasoftware/geometry/airplane.py @@ -80,7 +80,6 @@ def __init__( name: str = "Untitled Airplane", Cg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), weight: float | int = 0.0, - single_step_wing: bool | np.bool_ = False, s_ref: float | int | None = None, c_ref: float | int | None = None, b_ref: float | int | None = None, @@ -116,12 +115,7 @@ def __init__( internally. The units are meters. """ wings = _parameter_validation.non_empty_list_return_list(wings, "wings") - self.single_step_wing = _parameter_validation.boolLike_return_bool( - single_step_wing, "single_step_wing" - ) processed_wings: list[wing_mod.Wing] = [] - if single_step_wing: - wings = [self.explode_wing(wing) for wing in wings] for wing in wings: if not isinstance(wing, wing_mod.Wing): raise TypeError("Every element in wings must be a Wing") diff --git a/pterasoftware/geometry/wing.py b/pterasoftware/geometry/wing.py index 049ac37df..b2e7bc2c1 100644 --- a/pterasoftware/geometry/wing.py +++ b/pterasoftware/geometry/wing.py @@ -118,6 +118,7 @@ def __init__( mirror_only: bool | np.bool_ = False, symmetryNormal_G: None | np.ndarray | Sequence[float | int] = None, symmetryPoint_G_Cg: None | np.ndarray | Sequence[float | int] = None, + single_step_wing: bool | np.bool_ = False, num_chordwise_panels: int = 8, chordwise_spacing: str = "cosine", ) -> None: @@ -178,6 +179,9 @@ def __init__( either are True. For more details on how this parameter interacts with symmetryNormal_G, symmetric, and mirror_only, see the class docstring. The units are meters. The default is None. + :param single_step_wing: Set this to True to have the explode_wing method called on + this Wing during initialization, which will return a NEW Wing where all + panels are broken into single strips for deformation. :param num_chordwise_panels: The number of chordwise panels to be used on this Wing, which must be set to a positive integer. The default is 8. :param chordwise_spacing: The type of spacing between the Wing's chordwise @@ -290,6 +294,12 @@ def __init__( raise ValueError('chordwise_spacing must be "cosine" or "uniform".') self.chordwise_spacing = chordwise_spacing + self.single_step_wing = _parameter_validation.boolLike_return_bool( + single_step_wing, "single_step_wing" + ) + if single_step_wing: + self.explode_wing() + # These attributes will be initialized or populated once this Wing's parent # Airplane calls generate_mesh. self.symmetry_type: int | None = None @@ -587,6 +597,86 @@ def get_plottable_data( return None + + def interpolate_between_wing_cross_sections(self, wcs1, wcs2, first_wcs): + """ + Wing cross section panels are between the line of wcs1 and wcs2. + When exploding a wing to 1 spanwise panel per cross section, + we need to interpolate the intermediate cross sections. + """ + + interpolated = [] + + if first_wcs: + interpolated.append( + wing_cross_section_mod.WingCrossSection( + num_spanwise_panels=1, + chord=wcs1.chord, + Lp_Wcsp_Lpp=wcs1.Lp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=wcs1.angles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=wcs1.control_surface_symmetry_type, + control_surface_hinge_point=wcs1.control_surface_hinge_point, + control_surface_deflection=wcs1.control_surface_deflection, + spanwise_spacing="uniform", + airfoil=wcs1.airfoil, + ) + ) + + N = wcs1.num_spanwise_panels + + for i in range(N): + t = (i + 1) / N # interpolation parameter between 0 and 1 + + chord = (1 - t) * wcs1.chord + t * wcs2.chord + Lp_Wcsp_Lpp = tuple(np.array(wcs2.Lp_Wcsp_Lpp) / N) + # angles_Wcsp_to_Wcs_ixyz = tuple((1 - t) * np.array(wcs1.angles_Wcsp_to_Wcs_ixyz) + t * np.array(wcs2.angles_Wcsp_to_Wcs_ixyz)) + angles_Wcsp_to_Wcs_ixyz = wcs2.angles_Wcsp_to_Wcs_ixyz / N + is_final_section = wcs2.num_spanwise_panels is None and i == N - 1 + + interpolated.append( + wing_cross_section_mod.WingCrossSection( + num_spanwise_panels=None if is_final_section else 1, + chord=chord, + Lp_Wcsp_Lpp=Lp_Wcsp_Lpp, + angles_Wcsp_to_Wcs_ixyz=angles_Wcsp_to_Wcs_ixyz, + control_surface_symmetry_type=wcs1.control_surface_symmetry_type, + control_surface_hinge_point=wcs1.control_surface_hinge_point, + control_surface_deflection=wcs1.control_surface_deflection, + spanwise_spacing=None if is_final_section else "uniform", + airfoil=wcs1.airfoil, + ) + ) + return interpolated + + def explode_wing(self): + """ + Takes a ps.geometry.wing.Wing and returns a NEW Wing + where all cross sections have num_spanwise_panels = 1. + """ + + new_cross_sections = [] + + for i in range(len(self.wing_cross_sections) - 1): + new_cross_sections.extend( + self.interpolate_between_wing_cross_sections( + self.wing_cross_sections[i], self.wing_cross_sections[i + 1], i == 0 + ) + ) + + # Rebuild the wing (copying everything else verbatim) + self.__init__( + wing_cross_sections=new_cross_sections, + name=self.name, + Ler_Gs_Cgs=self.Ler_Gs_Cgs, + angles_Gs_to_Wn_ixyz=self.angles_Gs_to_Wn_ixyz, + symmetric=self.symmetric, + mirror_only=self.mirror_only, + symmetryNormal_G=self.symmetryNormal_G, + symmetryPoint_G_Cg=self.symmetryPoint_G_Cg, + num_chordwise_panels=self.num_chordwise_panels, + chordwise_spacing=self.chordwise_spacing, + ) + @property def T_pas_G_Cg_to_Wn_Ler(self) -> None | np.ndarray: """The passive transformation matrix which maps in homogeneous coordinates from From 6efc6d9731431fce0254bc897b5124228788e897 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Mon, 23 Mar 2026 15:43:15 -0400 Subject: [PATCH 22/40] further fixes --- examples/demos/Pterosaure.py | 275 ++++++++++++++++++ examples/demos/demo_pterosaur.py | 31 +- pterasoftware/geometry/airplane.py | 79 ----- pterasoftware/geometry/wing.py | 56 ++-- .../unsteady_ring_vortex_lattice_method.py | 2 +- 5 files changed, 307 insertions(+), 136 deletions(-) create mode 100644 examples/demos/Pterosaure.py diff --git a/examples/demos/Pterosaure.py b/examples/demos/Pterosaure.py new file mode 100644 index 000000000..35bef6ecb --- /dev/null +++ b/examples/demos/Pterosaure.py @@ -0,0 +1,275 @@ +import pterasoftware as ps +import numpy as np +from scipy.spatial.transform import Rotation as R + +def get_relative_transform(Point1, Unit1, Point2, Unit2): + + Point1 = np.array(Point1, dtype=float) + Unit1 = np.array(Unit1, dtype=float) + Point2 = np.array(Point2, dtype=float) + Unit2 = np.array(Unit2, dtype=float) + + Xp = Unit1 / np.linalg.norm(Unit1) + Zp = np.array([0, 0, 1]) + Yp = np.cross(Zp, Xp) + Yp /= np.linalg.norm(Yp) + Zp = np.cross(Xp, Yp) + Zp /= np.linalg.norm(Zp) + Rp = np.column_stack((Xp, Yp, Zp)) + + Xe = Unit2 / np.linalg.norm(Unit2) + Ze = np.array([0, 0, 1]) + Ye = np.cross(Ze, Xe) + Ye /= np.linalg.norm(Ye) + Ze = np.cross(Xe, Ye) + Ze /= np.linalg.norm(Ze) + Re = np.column_stack((Xe, Ye, Ze)) + + Rrel = Rp.T @ Re + + rot = R.from_matrix(Rrel) + angles_Wcsp_to_Wcs_ixyz = rot.as_euler('xyz', degrees=True) + + Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) + + return Lp_Wcsp_Lpp, angles_Wcsp_to_Wcs_ixyz + + +wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=6, + chord=0.25, + 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, + ), + ) + +wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=6, + chord=np.linalg.norm((0.1559,0,-0.0931)), + Lp_Wcsp_Lpp=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[1], + 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, + ), + ) + +wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=6, + chord=np.linalg.norm((0.2864,0,-0.1878)), + Lp_Wcsp_Lpp=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[1], + 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, + ), + ) + +wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=6, + chord=np.linalg.norm((0.322,0,-0.2256)), + Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], + 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, + ), + ) + +wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=1, + chord=np.linalg.norm((0.005,0,-0.0003)), + Lp_Wcsp_Lpp=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[1], + 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, + ), + ) +wing_cross_section_6 = ps.geometry.wing_cross_section.WingCrossSection( + num_spanwise_panels=None, + chord=np.linalg.norm((0.005, 0, -0.0003)), + Lp_Wcsp_Lpp=get_relative_transform( + (0.322, 0, -0.2256), + (0.323, 0, -0.2251), + (0.005, 0, -0.0003), + (0.006, 0, -0.0002), + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + (0.1829, 2.4373, 0.0266), + (0.005, 0, -0.0003), + )[1], + 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, + ), +) + + +pterasaure = ps.geometry.airplane.Airplane( + wings=[ + ps.geometry.wing.Wing( + wing_cross_sections=[wing_cross_section_1, wing_cross_section_2, wing_cross_section_3, wing_cross_section_4, wing_cross_section_5, wing_cross_section_6], + name="Main Wing", + Ler_Gs_Cgs= [0.0, 0.025, 0.0], + angles_Gs_to_Wn_ixyz= [4, 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), + num_chordwise_panels=5, + chordwise_spacing="uniform", + ), + ], + name="Pterosaure", + Cg_GP1_CgP1=(0.0, 0.0, 0.0), + weight=0, + s_ref=None, + c_ref=None, + b_ref=None, +) + + +# Define the airplane's AirplaneMovement. +wing_movements = [] +for wing in pterasaure.wings: + wing_cross_section_movements = [] + for wing_cross_section in wing.wing_cross_sections: + wing_cross_section_movements.append(ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=wing_cross_section) + ) + wing_movements.append(wing_cross_section_movements) + +main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=pterasaure.wings[0], + wing_cross_section_movements= wing_movements[0], + 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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), +) + +reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( + base_wing=pterasaure.wings[1], + wing_cross_section_movements=wing_movements[1], + 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=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), +) + +# Now define the example airplane's AirplaneMovement. For now, no movement of the airplane is possible. +pterasaure_movement = ps.movements.airplane_movement.AirplaneMovement( + base_airplane=pterasaure, + wing_movements=[main_wing_movement, reflected_main_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), +) + +# Define a new OperatingPoint. +example_operating_point = ps.operating_point.OperatingPoint( + rho=1.225, vCg__E=30.0, alpha=5.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point) + +# Define the Movement. This contains the AirplaneMovement and the +# OperatingPointMovement. +movement = ps.movements.movement.Movement( + airplane_movements=[pterasaure_movement], + operating_point_movement=operating_point_movement, + delta_time=None, + num_cycles=1, + num_chords=None, + num_steps=None, +) + +# Define the UnsteadyProblem. +example_problem = ps.problems.UnsteadyProblem( + movement=movement, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ( + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( + unsteady_problem=example_problem, + ) +) + + +# Run the solver. +example_solver.run( + prescribed_wake=False, + show_progress=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. + +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=True, +) + +# ps.output.print_results(example_solver) + +# ps.output.plot_wing_loads_versus_time(unsteady_solver=example_solver, show=True) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index 2e80b1cfb..2da59ba4b 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -87,38 +87,9 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): ) return interpolated -def explode_wing(wing): - """ - Takes a ps.geometry.wing.Wing and returns a NEW Wing - where all cross sections have num_spanwise_panels = 1. - """ - - new_cross_sections = [] - - for i in range(len(wing.wing_cross_sections) - 1): - new_cross_sections.extend( - interpolate_between_wing_cross_sections( - wing.wing_cross_sections[i], wing.wing_cross_sections[i + 1], i == 0 - ) - ) - - # Rebuild the wing (copying everything else verbatim) - return ps.geometry.wing.Wing( - wing_cross_sections=new_cross_sections, - name=wing.name, - Ler_Gs_Cgs=wing.Ler_Gs_Cgs, - angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, - symmetric=wing.symmetric, - mirror_only=wing.mirror_only, - symmetryNormal_G=wing.symmetryNormal_G, - symmetryPoint_G_Cg=wing.symmetryPoint_G_Cg, - num_chordwise_panels=wing.num_chordwise_panels, - chordwise_spacing=wing.chordwise_spacing, - ) - wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=4, + num_spanwise_panels=1, chord=0.25, Lp_Wcsp_Lpp=(0.0, 0.0, 0.0), angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0), diff --git a/pterasoftware/geometry/airplane.py b/pterasoftware/geometry/airplane.py index 8a02b78ac..121bf4d62 100644 --- a/pterasoftware/geometry/airplane.py +++ b/pterasoftware/geometry/airplane.py @@ -771,82 +771,3 @@ def process_wing_symmetry(wing: wing_mod.Wing) -> list[wing_mod.Wing]: wing.generate_mesh(symmetry_type=1) reflected_wing.generate_mesh(symmetry_type=3) return [wing, reflected_wing] - - def interpolate_between_wing_cross_sections(self,wcs1, wcs2, first_wcs): - """ - Wing cross section panels are between the line of wcs1 and wcs2. - When exploding a wing to 1 spanwise panel per cross section, - we need to interpolate the intermediate cross sections. - """ - - interpolated = [] - - if first_wcs: - interpolated.append( - ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=wcs1.chord, - Lp_Wcsp_Lpp=wcs1.Lp_Wcsp_Lpp, - angles_Wcsp_to_Wcs_ixyz=wcs1.angles_Wcsp_to_Wcs_ixyz, - control_surface_symmetry_type=wcs1.control_surface_symmetry_type, - control_surface_hinge_point=wcs1.control_surface_hinge_point, - control_surface_deflection=wcs1.control_surface_deflection, - spanwise_spacing="uniform", - airfoil=wcs1.airfoil, - ) - ) - - N = wcs1.num_spanwise_panels - - for i in range(N): - t = (i + 1) / N # interpolation parameter between 0 and 1 - - chord = (1 - t) * wcs1.chord + t * wcs2.chord - Lp_Wcsp_Lpp = tuple(np.array(wcs2.Lp_Wcsp_Lpp) / N) - # angles_Wcsp_to_Wcs_ixyz = tuple((1 - t) * np.array(wcs1.angles_Wcsp_to_Wcs_ixyz) + t * np.array(wcs2.angles_Wcsp_to_Wcs_ixyz)) - angles_Wcsp_to_Wcs_ixyz = wcs2.angles_Wcsp_to_Wcs_ixyz / N - is_final_section = wcs2.num_spanwise_panels is None and i == N - 1 - - interpolated.append( - ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=None if is_final_section else 1, - chord=chord, - Lp_Wcsp_Lpp=Lp_Wcsp_Lpp, - angles_Wcsp_to_Wcs_ixyz=angles_Wcsp_to_Wcs_ixyz, - control_surface_symmetry_type=wcs1.control_surface_symmetry_type, - control_surface_hinge_point=wcs1.control_surface_hinge_point, - control_surface_deflection=wcs1.control_surface_deflection, - spanwise_spacing=None if is_final_section else "uniform", - airfoil=wcs1.airfoil, - ) - ) - return interpolated - - def explode_wing(self, wing): - """ - Takes a ps.geometry.wing.Wing and returns a NEW Wing - where all cross sections have num_spanwise_panels = 1. - """ - - new_cross_sections = [] - - for i in range(len(wing.wing_cross_sections) - 1): - new_cross_sections.extend( - self.interpolate_between_wing_cross_sections( - wing.wing_cross_sections[i], wing.wing_cross_sections[i + 1], i == 0 - ) - ) - - # Rebuild the wing (copying everything else verbatim) - return ps.geometry.wing.Wing( - wing_cross_sections=new_cross_sections, - name=wing.name, - Ler_Gs_Cgs=wing.Ler_Gs_Cgs, - angles_Gs_to_Wn_ixyz=wing.angles_Gs_to_Wn_ixyz, - symmetric=wing.symmetric, - mirror_only=wing.mirror_only, - symmetryNormal_G=wing.symmetryNormal_G, - symmetryPoint_G_Cg=wing.symmetryPoint_G_Cg, - num_chordwise_panels=wing.num_chordwise_panels, - chordwise_spacing=wing.chordwise_spacing, - ) diff --git a/pterasoftware/geometry/wing.py b/pterasoftware/geometry/wing.py index b2e7bc2c1..1ea068968 100644 --- a/pterasoftware/geometry/wing.py +++ b/pterasoftware/geometry/wing.py @@ -194,6 +194,11 @@ def __init__( wing_cross_sections = _parameter_validation.non_empty_list_return_list( wing_cross_sections, "wing_cross_sections" ) + self.single_step_wing = _parameter_validation.boolLike_return_bool( + single_step_wing, "single_step_wing" + ) + if single_step_wing: + wing_cross_sections = self.explode_wing(wing_cross_sections) num_wing_cross_sections = len(wing_cross_sections) if num_wing_cross_sections < 2: raise ValueError("wing_cross_sections must contain at least two elements.") @@ -294,12 +299,6 @@ def __init__( raise ValueError('chordwise_spacing must be "cosine" or "uniform".') self.chordwise_spacing = chordwise_spacing - self.single_step_wing = _parameter_validation.boolLike_return_bool( - single_step_wing, "single_step_wing" - ) - if single_step_wing: - self.explode_wing() - # These attributes will be initialized or populated once this Wing's parent # Airplane calls generate_mesh. self.symmetry_type: int | None = None @@ -597,12 +596,25 @@ def get_plottable_data( return None - - def interpolate_between_wing_cross_sections(self, wcs1, wcs2, first_wcs): + def interpolate_between_wing_cross_sections( + self, + wcs1: wing_cross_section_mod.WingCrossSection, + wcs2: wing_cross_section_mod.WingCrossSection, + first_wcs: bool, + ) -> list[wing_cross_section_mod.WingCrossSection]: """ Wing cross section panels are between the line of wcs1 and wcs2. When exploding a wing to 1 spanwise panel per cross section, we need to interpolate the intermediate cross sections. + + :param wcs1: The first WingCrossSection. + :param wcs2: The second WingCrossSection. + :param first_wcs: Whether wcs1 is the first WingCrossSection of the wing. If + True, the method will include a WingCrossSection with the same parameters + as wcs1 in the output list. If False, it will not, since it will have + already been included as the last interpolated WingCrossSection between + the previous pair of WingCrossSections. + :return: A list of WingCrossSections representing the interpolated cross sections """ interpolated = [] @@ -648,39 +660,31 @@ def interpolate_between_wing_cross_sections(self, wcs1, wcs2, first_wcs): ) return interpolated - def explode_wing(self): + + def explode_wing(self, wing_cross_sections: list[wing_cross_section_mod.WingCrossSection]) -> list[wing_cross_section_mod.WingCrossSection]: """ - Takes a ps.geometry.wing.Wing and returns a NEW Wing + Takes a list of WingCrossSections and returns a new list where all cross sections have num_spanwise_panels = 1. + + :param wing_cross_sections: The list of wing cross sections to explode. + :return: A new list of exploded wing cross sections. """ new_cross_sections = [] - for i in range(len(self.wing_cross_sections) - 1): + for i in range(len(wing_cross_sections) - 1): new_cross_sections.extend( self.interpolate_between_wing_cross_sections( - self.wing_cross_sections[i], self.wing_cross_sections[i + 1], i == 0 + wing_cross_sections[i], wing_cross_sections[i + 1], i == 0 ) ) - # Rebuild the wing (copying everything else verbatim) - self.__init__( - wing_cross_sections=new_cross_sections, - name=self.name, - Ler_Gs_Cgs=self.Ler_Gs_Cgs, - angles_Gs_to_Wn_ixyz=self.angles_Gs_to_Wn_ixyz, - symmetric=self.symmetric, - mirror_only=self.mirror_only, - symmetryNormal_G=self.symmetryNormal_G, - symmetryPoint_G_Cg=self.symmetryPoint_G_Cg, - num_chordwise_panels=self.num_chordwise_panels, - chordwise_spacing=self.chordwise_spacing, - ) + return new_cross_sections @property def T_pas_G_Cg_to_Wn_Ler(self) -> None | np.ndarray: """The passive transformation matrix which maps in homogeneous coordinates from - geometry axes relative to the CG to wing axes relative to the leading edge root + geometry axes relative to the CG to wing axes relative to the leading edge froroot point. Is None if the Wing's symmetry type hasn't been defined yet. :return: A (4,4) ndarray of floats representing the transformation matrix or diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 317c80e3a..2c67d08cf 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -613,7 +613,7 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No ) else: last_steady_problem = ( - self.coupled_unsteady_problem.get_steady_problem( + self.get_steady_problem_at( steady_problem_id - 1 ) ) From 442dc304aea2d426203db23a504911ac9e786c5d Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Tue, 24 Mar 2026 11:21:25 -0400 Subject: [PATCH 23/40] fix imports --- pterasoftware/__init__.py | 6 ++ ...led_unsteady_ring_vortex_lattice_method.py | 14 ++- pterasoftware/geometry/wing.py | 1 + pterasoftware/movements/__init__.py | 1 + pterasoftware/problems.py | 95 +++++++++---------- 5 files changed, 59 insertions(+), 58 deletions(-) diff --git a/pterasoftware/__init__.py b/pterasoftware/__init__.py index cde20a8c3..92ac8c66c 100644 --- a/pterasoftware/__init__.py +++ b/pterasoftware/__init__.py @@ -48,6 +48,12 @@ import pterasoftware.operating_point import pterasoftware.problems +# Expose eagerly imported modules as attributes +geometry = pterasoftware.geometry +movements = pterasoftware.movements +operating_point = pterasoftware.operating_point +problems = pterasoftware.problems + # Lazy imports configuration: modules loaded on first access. _LAZY_MODULES = { "convergence": "pterasoftware.convergence", diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 1d74149d3..8dc19e9d6 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -62,15 +62,13 @@ def __init__( # self.coupled_unsteady_problem must be defined before the call to super().__init__() # because the parent class's __init__ method calls methods that rely on self.coupled_unsteady_problem being defined. self.coupled_unsteady_problem = coupled_unsteady_problem - super().__init__(problems.UnsteadyProblem(coupled_unsteady_problem.movement)) + super().__init__(coupled_unsteady_problem) self.num_steps = self.coupled_unsteady_problem.num_steps self.delta_time = self.coupled_unsteady_problem.delta_time self.first_results_step = self.coupled_unsteady_problem.first_results_step self._first_averaging_step = self.coupled_unsteady_problem.first_averaging_step - self.steady_problems = [] - first_steady_problem: problems.SteadyProblem = ( self.get_steady_problem_at(0) ) @@ -141,7 +139,7 @@ def run( # Loop through this time step's Airplanes to create a list of their Wings. # Here we calculate all of our values from our first ariplane to start our main run loop this_problem: problems.SteadyProblem = ( - self.coupled_unsteady_problem.get_steady_problem(0) + self.get_steady_problem_at(0) ) these_airplanes = this_problem.airplanes num_wing_panels = 0 @@ -247,7 +245,7 @@ def run( # Airplane's geometry axes, observed from the Earth frame). self._current_step = step current_problem: problems.SteadyProblem = ( - self.coupled_unsteady_problem.get_steady_problem(self._current_step) + self.get_steady_problem_at(self._current_step) ) # Initialize all the current step's bound RingVortices. @@ -400,7 +398,7 @@ def run( if self._current_step < self.num_steps - 1: self.coupled_unsteady_problem.initialize_next_problem(self) self._initialize_panel_vortex( - self.coupled_unsteady_problem.get_steady_problem(step + 1), + self.get_steady_problem_at(step + 1), step + 1, ) # Shed RingVortices into the wake. @@ -410,7 +408,7 @@ def run( # Update the progress bar based on this time step's predicted # approximate, relative computing time. self.steady_problems.append( - self.coupled_unsteady_problem.get_steady_problem(step) + self.get_steady_problem_at(step) ) bar.update(n=float(approx_times[step + 1])) @@ -447,7 +445,7 @@ def initialize_step_geometry(self, step: int) -> None: # Initialize bound RingVortices for all steps on the first call. if step == 0: self._initialize_panel_vortex( - self.coupled_unsteady_problem.get_steady_problem(0), 0 + self.get_steady_problem_at(0), 0 ) # Set the current step and related state. diff --git a/pterasoftware/geometry/wing.py b/pterasoftware/geometry/wing.py index d637de2c3..f22faa656 100644 --- a/pterasoftware/geometry/wing.py +++ b/pterasoftware/geometry/wing.py @@ -153,6 +153,7 @@ class Wing: "mirror_only", "symmetryNormal_G", "symmetryPoint_G_Cg", + "single_step_wing", # Set once "_symmetry_type", "_num_spanwise_panels", diff --git a/pterasoftware/movements/__init__.py b/pterasoftware/movements/__init__.py index 0f1cfa102..a444d56c5 100644 --- a/pterasoftware/movements/__init__.py +++ b/pterasoftware/movements/__init__.py @@ -26,3 +26,4 @@ import pterasoftware.movements.operating_point_movement import pterasoftware.movements.wing_cross_section_movement import pterasoftware.movements.wing_movement +import pterasoftware.movements.single_step diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index de1aa4713..b62082040 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -14,16 +14,19 @@ from __future__ import annotations import math +from typing import TYPE_CHECKING import numpy as np import scipy.signal as sp_sig from scipy.integrate import solve_ivp import matplotlib.pyplot as plt -from .movements.single_step.single_step_movement import SingleStepMovement from . import _parameter_validation, _transformations, geometry, movements from . import operating_point as operating_point_mod +if TYPE_CHECKING: + from .movements.single_step.single_step_movement import SingleStepMovement + class SteadyProblem: """A class used to contain steady aerodynamics problems. @@ -312,84 +315,76 @@ def steady_problems(self) -> tuple[SteadyProblem, ...]: return self._steady_problems class CoupledUnsteadyProblem(UnsteadyProblem): - """This is a class for coupled unsteady problems. + """A class for coupled unsteady problems. - This class contains the following public methods: - None + This class extends UnsteadyProblem to manage multiple SteadyProblems for + coupled simulations where each time step has its own SteadyProblem. - This class contains the following class attributes: - None - """ + **Contains the following methods:** - def __init__(self, single_step_movement, only_final_results=False): - """This is the initialization method. + get_steady_problem: Gets the SteadyProblem at a specified step. + initialize_next_problem: Initializes the next step's problem. - :param single_step_movement: SingleStepMovement + **Contains the following class attributes:** - This is the SingleStepMovement that contains this CoupledUnsteadyProblem's - SingleStepOperatingPointMovement and SingleStepAirplaneMovements. - OperatingPointMovement and AirplaneMovements. + None + """ - :param only_final_results: boolLike, optional + def __init__(self, single_step_movement, only_final_results=False): + """The initialization method. - If set to True, the Solver will only calculate forces, moments, - and pressures for the final complete cycle (of the Movement's - sub-Movement with the longest period), which increases simulation speed. - The default value is False. + :param single_step_movement: SingleStepMovement containing the movement + and single-step aerodynamic definitions. + :param only_final_results: If True, only calculate forces/moments for the + final cycle. Defaults to False. + :return: None """ if not isinstance(single_step_movement, movements.single_step.single_step_movement.SingleStepMovement): raise TypeError("single_step_movement must be a SingleStepMovement.") + self.single_step_movement = single_step_movement - self.movement = single_step_movement.corresponding_movement - self.only_final_results = _parameter_validation.boolLike_return_bool( + movement = single_step_movement.corresponding_movement + only_final_results_bool = _parameter_validation.boolLike_return_bool( only_final_results, "only_final_results" ) - super().__init__(movement=self.movement, only_final_results=self.only_final_results) + # Call parent __init__ to properly initialize UnsteadyProblem attributes + # and create SteadyProblems. This is safe because there's no double-initialization. + super().__init__(movement=movement, only_final_results=only_final_results_bool) - # this set of steady problems should essnetially be treated as private - # and the getter method should be used to obtain it + # Coupled-specific state: list of steady problems for each coupled step + # We create an initial SteadyProblem using the base airplanes and operating point self.coupled_steady_problems = [ SteadyProblem( - [self.movement.airplane_movements[0].base_airplane], - self.movement.operating_point_movement.base_operating_point, + [movement.airplane_movements[0].base_airplane], + movement.operating_point_movement.base_operating_point, ) ] - def get_steady_problem(self, step): - """ - Return the steady-state problem associated with the given step index. - - Parameters - ---------- - step : int - Index of the steady problem to retrieve. - - Returns - ------- - Any - The steady-state problem object stored at the specified index. - - Raises - ------ - Exception - If `step` is greater than or equal to the number of initialized - steady problems. + def get_steady_problem(self, step: int) -> SteadyProblem: + """Get the SteadyProblem at a given time step. + + :param step: The time step index (0-indexed). + :return: The SteadyProblem at the specified step. + :raises Exception: If step is out of range. """ - # Ensure the requested step index is valid. if step >= len(self.coupled_steady_problems): raise Exception( f"Step index {step} is out of range of the number of initialized problems" ) - - # Return the corresponding steady-state problem. return self.coupled_steady_problems[step] - def initialize_next_problem(self, solver): - self.coupled_steady_problems.append(self.steady_problems[len(self.coupled_steady_problems)]) + def initialize_next_problem(self, solver) -> None: + """Initialize the next step's problem. + + :param solver: The solver instance managing this problem. + :return: None + """ + self.coupled_steady_problems.append( + self.steady_problems[len(self.coupled_steady_problems)] + ) class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): - def __init__( self, single_step_movement: SingleStepMovement, From 00b037029732e138c91680a5c2164d34ea934bc2 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Tue, 24 Mar 2026 12:16:45 -0400 Subject: [PATCH 24/40] working fast deformation --- ...led_unsteady_ring_vortex_lattice_method.py | 24 +++++++--- .../unsteady_ring_vortex_lattice_method.py | 47 +++++++++---------- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 8dc19e9d6..87da4016b 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -73,6 +73,11 @@ def __init__( self.get_steady_problem_at(0) ) + # We want to store the data from every steady problem we compute here + # but we don't want to override the initial steady problems until the + # data visualization stage so we store this and overwrite after run. + self.steady_problems_data_storage = [] + # number of panels overide and strip leading edge point initialization num_panels = 0 panel_count = 0 @@ -161,7 +166,12 @@ def run( # The number of wake RingVortices is the time step number multiplied by # the number of spanwise Panels. This works because the first time step # number is 0. - this_num_wake_ring_vortices = step * this_num_spanwise_panels + this_num_chordwise_wake_rows = step + if self._max_wake_rows is not None: + this_num_chordwise_wake_rows = min(step, self._max_wake_rows) + this_num_wake_ring_vortices = ( + this_num_chordwise_wake_rows * this_num_spanwise_panels + ) # Allocate the ndarrays for this time step. this_wake_ring_vortex_strengths = np.zeros( @@ -183,6 +193,8 @@ def run( (this_num_wake_ring_vortices, 3), dtype=float ) + this_wake_rc0s = np.zeros(this_num_wake_ring_vortices, dtype=float) + # Append this time step's ndarrays to the lists of ndarrays. self.list_num_wake_vortices.append(this_num_wake_ring_vortices) self._list_wake_vortex_strengths.append(this_wake_ring_vortex_strengths) @@ -191,6 +203,7 @@ def run( self.listStackFrwrvp_GP1_CgP1.append(thisStackFrwrvp_GP1_CgP1) self.listStackFlwrvp_GP1_CgP1.append(thisStackFlwrvp_GP1_CgP1) self.listStackBlwrvp_GP1_CgP1.append(thisStackBlwrvp_GP1_CgP1) + self._list_wake_rc0s.append(this_wake_rc0s) # The following loop attempts to predict how much time each time step will # take, relative to the other time steps. This data will be used to generate @@ -230,9 +243,6 @@ def run( bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " "Remaining: {remaining}", ) as bar: - # Initialize all the Airplanes' bound RingVortices. - _logger.debug("Initializing all Airplanes' bound RingVortices.") - # Update the progress bar based on the initialization step's predicted # approximate, relative computing time. bar.update(n=float(approx_times[0])) @@ -358,7 +368,8 @@ def run( self._currentStackFrwrvp_GP1_CgP1 = self.listStackFrwrvp_GP1_CgP1[step] self._currentStackFlwrvp_GP1_CgP1 = self.listStackFlwrvp_GP1_CgP1[step] self._currentStackBlwrvp_GP1_CgP1 = self.listStackBlwrvp_GP1_CgP1[step] - + self._currentStackBoundRc0s = np.zeros(self.num_panels, dtype=float) + self._currentStackWakeRc0s = self._list_wake_rc0s[step] self.stackSeedPoints_GP1_CgP1 = np.zeros((0, 3), dtype=float) # Collapse the geometry matrices into 1D ndarrays of attributes. @@ -407,7 +418,7 @@ def run( # Update the progress bar based on this time step's predicted # approximate, relative computing time. - self.steady_problems.append( + self.steady_problems_data_storage.append( self.get_steady_problem_at(step) ) bar.update(n=float(approx_times[step + 1])) @@ -422,6 +433,7 @@ def run( _functions.calculate_streamlines(self) # Mark that the solver has run. + self.steady_problems = self.steady_problems_data_storage self.ran = True def initialize_step_geometry(self, step: int) -> None: diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 128a98b7a..e3b0621c7 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -612,15 +612,6 @@ def _initialize_panel_vortices(self) -> None: """Calculates the locations of the bound RingVortex vertices, and then initializes them. - :return: None - """ - for steady_problem_id, steady_problem in enumerate(self.steady_problems): - self._initialize_panel_vortex(steady_problem, steady_problem_id) - - def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> None: - """Calculates the locations of the bound RingVortex vertices, and then - initializes them for a specific steady problem. - Every Panel has a RingVortex, which is a quadrangle whose front leg is a LineVortex at the Panel's quarter chord. The left and right legs are LineVortices running along the Panel's left and right legs. If the Panel is not @@ -630,6 +621,12 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No :return: None """ + for steady_problem_id, steady_problem in enumerate(self.steady_problems): + # Find the freestream velocity (in the first Airplane's geometry axes, + # observed from the Earth frame) at this time step. + self._initialize_panel_vortex(steady_problem, steady_problem_id) + + def _initialize_panel_vortex(self, steady_problem, steady_problem_id): this_operating_point = steady_problem.operating_point vInf_GP1__E = this_operating_point.vInf_GP1__E @@ -670,10 +667,14 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No chordwise_position + 1, spanwise_position ] - _nextFlbvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1 + _nextFlbvp_GP1_CgP1 = ( + next_chordwise_panel.Flbvp_GP1_CgP1 + ) assert _nextFlbvp_GP1_CgP1 is not None - _nextFrbvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1 + _nextFrbvp_GP1_CgP1 = ( + next_chordwise_panel.Frbvp_GP1_CgP1 + ) assert _nextFrbvp_GP1_CgP1 is not None Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1 @@ -703,10 +704,8 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No + vInf_GP1__E * self.delta_time * 0.25 ) else: - last_steady_problem = ( - self.get_steady_problem_at( - steady_problem_id - 1 - ) + last_steady_problem = self.get_steady_problem_at( + steady_problem_id - 1 ) last_airplane = last_steady_problem.airplanes[ airplane_id @@ -763,14 +762,14 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id: int) -> No * 0.25 ) - # Initialize the Panel's RingVortex. - panel.ring_vortex = _vortices.ring_vortex.RingVortex( - Flrvp_GP1_CgP1=Flrvp_GP1_CgP1, - Frrvp_GP1_CgP1=Frrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brrvp_GP1_CgP1, - strength=1.0, - ) + # Initialize the Panel's RingVortex. + panel.ring_vortex = _vortices.ring_vortex.RingVortex( + Flrvp_GP1_CgP1=Flrvp_GP1_CgP1, + Frrvp_GP1_CgP1=Frrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brrvp_GP1_CgP1, + strength=1.0, + ) def _collapse_geometry(self) -> None: """Converts attributes of the UnsteadyProblem's geometry into 1D ndarrays. @@ -2291,7 +2290,7 @@ def _finalize_loads(self) -> None: # For each Airplane, calculate and then save the final or cycle-averaged and # RMS loads and load coefficients. - first_problem: problems.SteadyProblem = self.steady_problems[0] + first_problem: problems.SteadyProblem = self.get_steady_problem_at(0) for airplane_id, airplane in enumerate(first_problem.airplanes): if static: self.unsteady_problem.finalForces_W.append(forces_W[airplane_id, :, -1]) From 8341ecf2ff9a5b6213f8def908f8e7e4727c0d2c Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Tue, 24 Mar 2026 14:18:04 -0400 Subject: [PATCH 25/40] fix timer --- ...led_unsteady_ring_vortex_lattice_method.py | 31 ++++++++++--------- pterasoftware/output.py | 25 +++------------ 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 87da4016b..50e6e9de3 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -214,25 +214,26 @@ def run( # one more element than the number of time steps, because I will also use the # progress bar during the simulation initialization. approx_times = np.zeros(self.num_steps + 1, dtype=float) - if step != 0: - # Calculate the total number of RingVortices analyzed during this step. - num_wing_ring_vortices = num_wing_panels - num_wake_ring_vortices = self.list_num_wake_vortices[step] - num_ring_vortices = num_wing_ring_vortices + num_wake_ring_vortices - - # The following constant multipliers were determined empirically. Thus - # far, they seem to provide for adequately smooth progress bar updating. - if step == 1: - approx_times[step] = num_ring_vortices * 70 - elif step == 2: - approx_times[step] = num_ring_vortices * 30 - else: - approx_times[step] = num_ring_vortices * 3 + for step in range(self.num_steps): + if step != 0: + # Calculate the total number of RingVortices analyzed during this step. + num_wing_ring_vortices = num_wing_panels + num_wake_ring_vortices = self.list_num_wake_vortices[step] + num_ring_vortices = num_wing_ring_vortices + num_wake_ring_vortices + + # The following constant multipliers were determined empirically. Thus + # far, they seem to provide for adequately smooth progress bar updating. + if step == 1: + approx_times[step] = num_ring_vortices * 70 + elif step == 2: + approx_times[step] = num_ring_vortices * 30 + else: + approx_times[step] = num_ring_vortices * 3 approx_partial_time = np.sum(approx_times) approx_times[0] = round(approx_partial_time / 100) approx_total_time = np.sum(approx_times) - + print(approx_times) with tqdm( total=approx_total_time, unit="", diff --git a/pterasoftware/output.py b/pterasoftware/output.py index 3b535ae8f..8fdcbf096 100644 --- a/pterasoftware/output.py +++ b/pterasoftware/output.py @@ -38,7 +38,6 @@ steady_horseshoe_vortex_lattice_method, steady_ring_vortex_lattice_method, unsteady_ring_vortex_lattice_method, - coupled_unsteady_ring_vortex_lattice_method, ) _logger = _logging.get_logger("output") @@ -112,7 +111,6 @@ def draw( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver | steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver | unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver - | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ), scalar_type: str | None = None, show_streamlines: bool | np.bool_ = False, @@ -156,7 +154,6 @@ def draw( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver, steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver, unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, - coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, ), ): raise TypeError( @@ -217,7 +214,6 @@ def draw( if isinstance( solver, unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, - coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, ): draw_step = solver.num_steps - 1 @@ -455,8 +451,7 @@ def draw( def animate( - unsteady_solver: (unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver - | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver), + unsteady_solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, scalar_type: str | None = None, show_wake_vortices: bool | np.bool_ = False, save: bool | np.bool_ = False, @@ -482,10 +477,7 @@ def animate( """ if not isinstance( unsteady_solver, - ( - unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, - coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, - ), + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, ): raise TypeError( "unsteady_solver must be an UnsteadyRingVortexLatticeMethodSolver." @@ -891,8 +883,7 @@ def animate( def plot_results_versus_time( - unsteady_solver: (unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver - | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver), + unsteady_solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, show: bool | np.bool_ = True, save: bool | np.bool_ = False, ) -> None: @@ -911,7 +902,6 @@ def plot_results_versus_time( unsteady_solver, ( unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, - coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ) ): raise TypeError( @@ -1221,7 +1211,6 @@ def log_results( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver | steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver | unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver - | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver ), ) -> None: """Logs a solver's load and load coefficients. @@ -1243,10 +1232,7 @@ def log_results( solver_type = "steady" elif isinstance( solver, - ( - unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, - coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, - ) + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, ): these_airplanes = solver.current_airplanes if solver.unsteady_problem.movement.static: @@ -1661,8 +1647,7 @@ def _mute_colormap( def _get_wake_ring_vortex_surfaces( - solver: (unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver - | coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver), + solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, step: int, ) -> pv.PolyData: """Returns the PolyData representation of the surfaces of an From e25afb31f5063d5535f237ec25fb7ee51d8b2d06 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 25 Mar 2026 10:12:14 -0400 Subject: [PATCH 26/40] clean up and make the official coupled unsteady example --- ...oupled_unsteady_first_order_deformation.py | 365 ++++++++++++++++++ examples/demos/demo_single_step_flat.py | 66 ++-- ...led_unsteady_ring_vortex_lattice_method.py | 2 +- 3 files changed, 406 insertions(+), 27 deletions(-) create mode 100644 examples/coupled_unsteady_first_order_deformation.py diff --git a/examples/coupled_unsteady_first_order_deformation.py b/examples/coupled_unsteady_first_order_deformation.py new file mode 100644 index 000000000..37fdc6824 --- /dev/null +++ b/examples/coupled_unsteady_first_order_deformation.py @@ -0,0 +1,365 @@ +"""This is script is an example of how to run Ptera Software's +UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static +Movement.""" + +# First, import the software's main package. Note that if you wished to import this +# software into another package, you would first install it by running "pip install +# pterasoftware" in your terminal. +import pterasoftware as ps + +# Create an Airplane with our custom geometry. I am going to declare every parameter +# for Airplane, even though most of them have usable default values. This is for +# educational purposes, but keep in mind that it makes the code much longer than it +# needs to be. For details about each parameter, read the detailed class docstring. +# The same caveats apply to the other classes, methods, and functions I call in this +# script. + +# Wing cross section initialization +# offsets for the spacing +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 = [] + +# Initialization loop for our wing cross sections. Here we are defining automatically +# wing cross sections with a variable set of chords. All of the wing cross sections for +# deformation simulation will eventually be defined to have num_spanwise_panels=1 +# (except the wing tip whichis always None). This is because we deform each strip of wing cross +# section independently by modeling them as torsional springs, and that model only really works +# if those strips are thin.If you want to go thinner for the same base definition, you can +# increase the num_spanwise_panels and ensure that in Wing you set the single_step_wing +# parameter to True, which will ensure that the wing is split back up into single strips +# for deformation. +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="cosine" 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, + ), + ) + ) + +# Primary wing definition. Note that the single_step_wing parameter is set to True, +# which means that the wing will be split into strips for deformation, and each +# strip will be modeled as a torsional spring. +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), + single_step_wing=True, + num_chordwise_panels=6, + chordwise_spacing="uniform", +) + +# Actually generating the airplane. A tail is added to the airplane, but it is not +# split into strips for deformation as currently only the first wing is considered +# for deformation in the codebase. Fututre versions of this feature could allow for +# the deformation of multiple wings. For now, it is convenient to not split the tail +# into single strip wing cross sections as it reduces the number of movement variables +# that need to be defined. +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), + single_step_wing=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, +) + +# The main Wing was defined to have symmetric=True, mirror_only=False, and with a +# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, +# that Wing had type 5 symmetry (see the Wing class documentation for more details on +# symmetry types). Therefore, it was actually split into two Wings, the with the +# second Wing being a reflected version of the first. Therefore, we need to define a +# WingMovement for this reflected Wing. To start, we'll first define the reflected +# main wing's root and tip WingCrossSections' WingCrossSectionMovements. + +# defintions for wing cross section movement 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 + +# A list of movements for the main wing +main_movements_list = [] +main_single_step_movements_list = [] + +# A list of movements for the reflected wing +reflected_movements_list = [] +reflected_single_step_movements_list = [] + +# A loop for defining the movement for the main wing and its reflected counterpart's wing +# cross sections. Here, we are defining single step wing cross movement, a movement class +# that functions differently from the standard Movement class, by giving the next +# position of wing cross section from the previous instead of attempting to precompute +# the entire movement beforehand as that is impossible in scenarios where the deformation +# is dependent on the aerodynamic loads. +for i in range(len(example_airplane.wings[0].wing_cross_sections)): + if i == 0: + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], + ) + main_single_step_movements_list.append(single_step_movement) + reflected_single_step_movements_list.append(single_step_movement) + + else: + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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_single_step_movements_list.append(single_step_movement) + reflected_single_step_movements_list.append(single_step_movement) + + +# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. +single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), +) +single_step_v_tail_tip_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), +) + +# This dephase parameter is used to make the wing start in a flat position +dephase = 169.0 + +# Now define the main wing's SingleStepWingMovement, the reflected main wing's SingleStepWingMovement and +# the v-tail's SingleStepWingMovement. +single_step_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[0], + single_step_wing_cross_section_movements=main_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), + ) +) + +single_step_reflected_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[1], + single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), + ) +) + +single_step_v_tail_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[2], + single_step_wing_cross_section_movements=[ + single_step_v_tail_root_wing_cross_section_movement, + single_step_v_tail_tip_wing_cross_section_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), + ) +) + +# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now +# contained within the WingMovements. This is optional, but it can make debugging +# easier. +del single_step_v_tail_root_wing_cross_section_movement +del single_step_v_tail_tip_wing_cross_section_movement + +# Now define the example airplane's SingleStepAirplaneMovement. +single_step_airplane_movement = ( + ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + base_airplane=example_airplane, + single_step_wing_movements=[ + single_step_main_wing_movement, + single_step_reflected_main_wing_movement, + single_step_v_tail_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), + ) +) + +# Delete the extraneous pointers to the WingMovements. +del single_step_main_wing_movement +del single_step_reflected_main_wing_movement +del single_step_v_tail_movement + +# Define a new OperatingPoint. +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 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" +) + +single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + base_operating_point=example_operating_point, + ampVCg__E=0.0, + periodVCg__E=0.0, + spacingVCg__E="sine", +) + +# Delete the extraneous pointer. +del example_operating_point + +# Define the SingleStepMovement. This contains the SingleStepAirplaneMovement and the +# SingleStepOperatingPointMovement. + +single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( + single_step_airplane_movements=[single_step_airplane_movement], + single_step_operating_point_movement=single_step_operating_point_movement, + delta_time=0.03, + num_cycles=3, +) + +# Delete the extraneous pointers. +del operating_point_movement + +# Define the UnsteadyProblem. +example_problem = ps.problems.AeroelasticUnsteadyProblem( + single_step_movement=single_step_movement, + plot_flap_cycle=False, + wing_density=0.012, + spring_constant=5.0, + moment_scaling_factor=1.0, + damping_constant=1.0, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, +) + +# Delete the extraneous pointer. +del example_problem + +# Run the solver. +example_solver.run( + prescribed_wake=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=True, +) diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index 0523936c4..3ce40d570 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -14,15 +14,21 @@ # The same caveats apply to the other classes, methods, and functions I call in this # script. - +# Wing cross section initialization # offsets for the spacing -num_spanwise_panels = 1 +num_spanwise_panels = 2 Lp_Wcsp_Lpp_Offsets = (0.1, 0.5, 0.0) - -# Wing cross section initialization cross_section_chords = [1.75, 1.75, 1.75, 1.75, 1.65, 1.55, 1.4, 1.2, 1.0] wing_cross_sections = [] +# Initialization loop for our wing cross sections. Here we are defining automatically +# wing cross sections with a variable set of chords. All of the wing cross sections for +# deformation simulation are defined to have num_spanwise_panels=1 (except the wing tip which +# is always None). This is because we deform each strip of wing cross section independently by +# modeling them as torsional springs, and that model only really works if those strips are thin. +# Note that if you want to go thinner for the same base definition, you can increase the number +# of spanwise panels and ensure that in Wing you set the single_step_wing parameter to True, +# which will ensure that the wing is split back up into single strips for deformation. for i in range(len(cross_section_chords)): wing_cross_sections.append( ps.geometry.wing_cross_section.WingCrossSection( @@ -45,20 +51,29 @@ ) ) +# Primary wing definition. Note that the single_step_wing parameter is set to True, +# which means that the wing will be split into strips for deformation, and each +# strip will be modeled as a torsional spring. 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), - single_step_wing=True, - num_chordwise_panels=6, - chordwise_spacing="uniform", - ) + 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), + single_step_wing=True, + num_chordwise_panels=6, + chordwise_spacing="uniform", +) +# Actually generating the airplane. A tail is added to the airplane, but it is not +# split into strips for deformation as currently only the first wing is considered +# for deformation in the codebase. Fututre versions of this feature could allow for +# the deformation of multiple wings. For now, it is convenient to not split the tail +# into single strip wing cross sections as it reduces the number of movement variables +# that need to be defined. example_airplane = ps.geometry.airplane.Airplane( wings=[ wing_1, @@ -124,15 +139,7 @@ # WingMovement for this reflected Wing. To start, we'll first define the reflected # main wing's root and tip WingCrossSections' WingCrossSectionMovements. -# defintions for wing movement parameters -# dephase_x = 0.0 -# period_x = 1.0 -# amplitude_x = 2.0 - -# dephase_y = 0.0 -# period_y = 1.0 -# amplitude_y = 3.0 - +# defintions for wing cross section movement parameters dephase_x = 0.0 period_x = 0.0 amplitude_x = 0.0 @@ -153,6 +160,12 @@ reflected_movements_list = [] reflected_single_step_movements_list = [] +# A loop for defining the movement for the main wing and its reflected counterpart's wing +# cross sections. Here, we are defining single step wing cross movement, a movement class +# that functions differently from the standard Movement class, by giving the next +# position of wing cross section from the previous instead of attempting to precompute +# the entire movement beforehand as that is impossible in scenarios where the deformation +# is dependent on the aerodynamic loads. for i in range(len(example_airplane.wings[0].wing_cross_sections)): if i == 0: single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( @@ -203,10 +216,11 @@ ) ) +# This dephase parameter is used to make the wing start in a flat position dephase = 169.0 + # Now define the main wing's SingleStepWingMovement, the reflected main wing's SingleStepWingMovement and # the v-tail's SingleStepWingMovement. - single_step_main_wing_movement = ( ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( base_wing=example_airplane.wings[0], diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 50e6e9de3..b30e1687b 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -233,7 +233,7 @@ def run( approx_partial_time = np.sum(approx_times) approx_times[0] = round(approx_partial_time / 100) approx_total_time = np.sum(approx_times) - print(approx_times) + with tqdm( total=approx_total_time, unit="", From 9252b7bb77888f3434085fe042354977a9e0b86b Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 25 Mar 2026 10:21:17 -0400 Subject: [PATCH 27/40] further comment new example --- ...oupled_unsteady_first_order_deformation.py | 25 +++++++++++------- examples/demos/demo_single_step_flat.py | 26 ++++++++++++------- 2 files changed, 33 insertions(+), 18 deletions(-) diff --git a/examples/coupled_unsteady_first_order_deformation.py b/examples/coupled_unsteady_first_order_deformation.py index 37fdc6824..c3ec07125 100644 --- a/examples/coupled_unsteady_first_order_deformation.py +++ b/examples/coupled_unsteady_first_order_deformation.py @@ -23,13 +23,12 @@ # Initialization loop for our wing cross sections. Here we are defining automatically # wing cross sections with a variable set of chords. All of the wing cross sections for -# deformation simulation will eventually be defined to have num_spanwise_panels=1 -# (except the wing tip whichis always None). This is because we deform each strip of wing cross -# section independently by modeling them as torsional springs, and that model only really works -# if those strips are thin.If you want to go thinner for the same base definition, you can -# increase the num_spanwise_panels and ensure that in Wing you set the single_step_wing -# parameter to True, which will ensure that the wing is split back up into single strips -# for deformation. +# deformation simulation are defined to have num_spanwise_panels=1 (except the wing tip which +# is always None). This is because we deform each strip of wing cross section independently by +# modeling them as torsional springs, and that model only really works if those strips are thin. +# Note that if you want to go thinner for the same base definition, you can increase the number +# of spanwise panels and ensure that in Wing you set the single_step_wing parameter to True, +# which will ensure that the wing is split back up into single strips for deformation. for i in range(len(cross_section_chords)): wing_cross_sections.append( ps.geometry.wing_cross_section.WingCrossSection( @@ -329,13 +328,21 @@ del operating_point_movement # Define the UnsteadyProblem. +# The deformation parameters are set here +# The wing_density, spring_constant and damping_constant are the primary parameters +# you should expect to change. The rest are more for considering numerical issues +# with our integrator and debugging. Plotting the flap cycle can give good data as well. example_problem = ps.problems.AeroelasticUnsteadyProblem( single_step_movement=single_step_movement, - plot_flap_cycle=False, wing_density=0.012, spring_constant=5.0, - moment_scaling_factor=1.0, damping_constant=1.0, + aero_scaling=1.0, + moment_scaling_factor=1.0, + damping_eps=1e-3, + plot_flap_cycle=False, + custom_spacing_second_derivative=None, + only_final_results=False, ) # Define a new solver. The available solver classes are diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index 3ce40d570..2113302d8 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -53,7 +53,7 @@ # Primary wing definition. Note that the single_step_wing parameter is set to True, # which means that the wing will be split into strips for deformation, and each -# strip will be modeled as a torsional spring. +# strip will be modeled as a torsional spring. wing_1 = ps.geometry.wing.Wing( wing_cross_sections=wing_cross_sections, name="Main Wing", @@ -160,12 +160,12 @@ reflected_movements_list = [] reflected_single_step_movements_list = [] -# A loop for defining the movement for the main wing and its reflected counterpart's wing -# cross sections. Here, we are defining single step wing cross movement, a movement class -# that functions differently from the standard Movement class, by giving the next +# A loop for defining the movement for the main wing and its reflected counterpart's wing +# cross sections. Here, we are defining single step wing cross movement, a movement class +# that functions differently from the standard Movement class, by giving the next # position of wing cross section from the previous instead of attempting to precompute -# the entire movement beforehand as that is impossible in scenarios where the deformation -# is dependent on the aerodynamic loads. +# the entire movement beforehand as that is impossible in scenarios where the deformation +# is dependent on the aerodynamic loads. for i in range(len(example_airplane.wings[0].wing_cross_sections)): if i == 0: single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( @@ -330,13 +330,21 @@ del operating_point_movement # Define the UnsteadyProblem. +# The deformation parameters are set here +# The wing_density, spring_constant and damping_constant are the primary parameters +# you should expect to change. The rest are more for considering numerical issues +# with our integrator and debugging. Plotting the flap cycle can give good data as well. example_problem = ps.problems.AeroelasticUnsteadyProblem( single_step_movement=single_step_movement, - plot_flap_cycle=False, wing_density=0.012, - spring_constant=1.0, - moment_scaling_factor=1.0, + spring_constant=5.0, damping_constant=1.0, + aero_scaling=1.0, + moment_scaling_factor=1.0, + damping_eps=1e-3, + plot_flap_cycle=False, + custom_spacing_second_derivative=None, + only_final_results=False, ) # Define a new solver. The available solver classes are From 4c82cbbc8f1105c6ec840d4618e602e28dfefc19 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 25 Mar 2026 11:22:09 -0400 Subject: [PATCH 28/40] update single step classes --- ...oupled_unsteady_first_order_deformation.py | 2 +- .../single_step_airplane_movement.py | 197 ++++---- .../single_step/single_step_movement.py | 162 ++++--- .../single_step_operating_point_movement.py | 213 ++++----- ...single_step_wing_cross_section_movement.py | 424 +++++++----------- .../single_step/single_step_wing_movement.py | 420 ++++++----------- 6 files changed, 548 insertions(+), 870 deletions(-) diff --git a/examples/coupled_unsteady_first_order_deformation.py b/examples/coupled_unsteady_first_order_deformation.py index c3ec07125..d69e8bb6a 100644 --- a/examples/coupled_unsteady_first_order_deformation.py +++ b/examples/coupled_unsteady_first_order_deformation.py @@ -335,7 +335,7 @@ example_problem = ps.problems.AeroelasticUnsteadyProblem( single_step_movement=single_step_movement, wing_density=0.012, - spring_constant=5.0, + spring_constant=15.0, damping_constant=1.0, aero_scaling=1.0, moment_scaling_factor=1.0, diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py index 071c4383a..5d84fff3b 100644 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -1,8 +1,10 @@ -"""Contains the AirplaneMovement class. +"""Contains the SingleStepAirplaneMovement class. **Contains the following classes:** -AirplaneMovement: A class used to contain an Airplane's movement. +SingleStepAirplaneMovement: A single step variant of AirplaneMovement that generates +one Airplane per time step instead of all at once. Uses composition to wrap an +AirplaneMovement. **Contains the following functions:** @@ -20,8 +22,6 @@ from ... import geometry from ..._parameter_validation import ( - threeD_number_vectorLike_return_float, - threeD_spacing_vectorLike_return_tuple, int_in_range_return_int, number_in_range_return_float, ) @@ -34,20 +34,35 @@ class SingleStepAirplaneMovement: - """A class used to contain an Airplane's movement. + """A single step variant of AirplaneMovement for coupled simulations. + + This class wraps an AirplaneMovement via composition and generates one Airplane per + time step (via generate_next_airplane) rather than generating all Airplanes at once. + The composed AirplaneMovement is accessible via corresponding_airplane_movement. **Contains the following methods:** - all_periods: All unique non zero periods from this AirplaneMovement, its - WingMovement(s), and their WingCrossSectionMovements. + all_periods: All unique non zero periods from this + SingleStepAirplaneMovement, its SingleStepWingMovements, and their + SingleStepWingCrossSectionMovements. - generate_airplanes: Creates the Airplane at each time step, and returns them in a - list. + generate_next_airplane: Creates the Airplane at a single time step. - max_period: The longest period of AirplaneMovement's own motion, the motion(s) of - its sub movement object(s), and the motions of its sub sub movement objects. + max_period: The longest period of this SingleStepAirplaneMovement's own motion, the + motion(s) of its sub movement object(s), and the motions of its sub sub movement + objects. """ + __slots__ = ( + "wing_movements", + "ampCg_GP1_CgP1", + "periodCg_GP1_CgP1", + "spacingCg_GP1_CgP1", + "phaseCg_GP1_CgP1", + "listCg_GP1_CgP1", + "corresponding_airplane_movement", + ) + def __init__( self, single_step_wing_movements, @@ -65,38 +80,40 @@ def __init__( ) -> None: """The initialization method. + :param single_step_wing_movements: A list of the SingleStepWingMovements + associated with each of the base Airplane's Wings. It must have the same + length as the base Airplane's list of Wings. :param base_airplane: The base Airplane from which the Airplane at each time step will be created. - :param wing_movements: A list of the WingMovements associated with each of the - base Airplane's Wings. It must have the same length as the base Airplane's - list of Wings. :param ampCg_GP1_CgP1: An array-like object of non negative numbers (int or - float) with shape (3,) representing the amplitudes of the AirplaneMovement's - changes in its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or - ndarray. Values are converted to floats internally. Each amplitude must be - low enough that it doesn't drive its base value out of the range of valid - values. Otherwise, this AirplaneMovement will try to create Airplanes with - invalid parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter - must be all zeros, this means that the first Airplane's ampCg_GP1_CgP1 - parameter must also be all zeros. The units are in meters. The default is - (0.0, 0.0, 0.0). + float) with shape (3,) representing the amplitudes of the + SingleStepAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1 + parameters. Can be a tuple, list, or ndarray. Values are converted to + floats internally. Each amplitude must be low enough that it doesn't drive + its base value out of the range of valid values. Otherwise, this + SingleStepAirplaneMovement will try to create Airplanes with invalid + parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter must + be all zeros, this means that the first Airplane's ampCg_GP1_CgP1 parameter + must also be all zeros. The units are in meters. The default is (0.0, 0.0, + 0.0). :param periodCg_GP1_CgP1: An array-like object of non negative numbers (int or - float) with shape (3,) representing the periods of the AirplaneMovement's - changes in its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or - ndarray. Values are converted to floats internally. Each element must be 0.0 - if the corresponding element in ampCg_GP1_CgP1 is 0.0 and non zero if not. - The units are in seconds. The default is (0.0, 0.0, 0.0). + float) with shape (3,) representing the periods of the + SingleStepAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1 + parameters. Can be a tuple, list, or ndarray. Values are converted to + floats internally. Each element must be 0.0 if the corresponding element in + ampCg_GP1_CgP1 is 0.0 and non zero if not. The units are in seconds. The + default is (0.0, 0.0, 0.0). :param spacingCg_GP1_CgP1: An array-like object of strs or callables with shape - (3,) representing the spacing of the AirplaneMovement's changes in its - Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray. Each - element can be the str "sine", the str "uniform", or a callable custom + (3,) representing the spacing of the SingleStepAirplaneMovement's changes + in its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray. + Each element can be the str "sine", the str "uniform", or a callable custom spacing function. Custom spacing functions are for advanced users and must - start at 0.0, return to 0.0 after one period of 2*pi radians, have amplitude - of 1.0, be periodic, return finite values only, and accept a ndarray as - input and return a ndarray of the same shape. Custom functions are scaled by - ampCg_GP1_CgP1, shifted horizontally and vertically by phaseCg_GP1_CgP1 and - the base value, and have a period set by periodCg_GP1_CgP1. The default is - ("sine", "sine", "sine"). + start at 0.0, return to 0.0 after one period of 2*pi radians, have + amplitude of 1.0, be periodic, return finite values only, and accept a + ndarray as input and return a ndarray of the same shape. Custom functions + are scaled by ampCg_GP1_CgP1, shifted horizontally and vertically by + phaseCg_GP1_CgP1 and the base value, and have a period set by + periodCg_GP1_CgP1. The default is ("sine", "sine", "sine"). :param phaseCg_GP1_CgP1: An array-like object of numbers (int or float) with shape (3,) representing the phase offsets of the elements in the first time step's Airplane's Cg_GP1_CgP1 parameter relative to the base Airplane's @@ -109,57 +126,11 @@ def __init__( """ self.wing_movements = single_step_wing_movements - ampCg_GP1_CgP1 = threeD_number_vectorLike_return_float( - ampCg_GP1_CgP1, "ampCg_GP1_CgP1" - ) - - if not np.all(ampCg_GP1_CgP1 >= 0.0): - raise ValueError("All elements in ampCg_GP1_CgP1 must be non negative.") - self.ampCg_GP1_CgP1 = ampCg_GP1_CgP1 - - periodCg_GP1_CgP1 = threeD_number_vectorLike_return_float( - periodCg_GP1_CgP1, "periodCg_GP1_CgP1" - ) - if not np.all(periodCg_GP1_CgP1 >= 0.0): - raise ValueError("All elements in periodCg_GP1_CgP1 must be non negative.") - for period_index, period in enumerate(periodCg_GP1_CgP1): - amp = self.ampCg_GP1_CgP1[period_index] - if amp == 0 and period != 0: - raise ValueError( - "If an element in ampCg_GP1_CgP1 is 0.0, the corresponding element " - "in periodCg_GP1_CgP1 must be also be 0.0." - ) - self.periodCg_GP1_CgP1 = periodCg_GP1_CgP1 - - spacingCg_GP1_CgP1 = ( - threeD_spacing_vectorLike_return_tuple( - spacingCg_GP1_CgP1, "spacingCg_GP1_CgP1" - ) - ) - self.spacingCg_GP1_CgP1 = spacingCg_GP1_CgP1 - - phaseCg_GP1_CgP1 = threeD_number_vectorLike_return_float( - phaseCg_GP1_CgP1, "phaseCg_GP1_CgP1" - ) - if not ( - np.all(phaseCg_GP1_CgP1 > -180.0) and np.all(phaseCg_GP1_CgP1 <= 180.0) - ): - raise ValueError( - "All elements in phaseCg_GP1_CgP1 must be in the range (-180.0, 180.0]." - ) - for phase_index, phase in enumerate(phaseCg_GP1_CgP1): - amp = self.ampCg_GP1_CgP1[phase_index] - if amp == 0 and phase != 0: - raise ValueError( - "If an element in ampCg_GP1_CgP1 is 0.0, the corresponding element " - "in phaseCg_GP1_CgP1 must be also be 0.0." - ) - self.phaseCg_GP1_CgP1 = phaseCg_GP1_CgP1 - - # Create the corresponding AirplaneMovement, which will remove redundancy as Coupled - # unsteady problems require both a SingleStepAirplaneMovement and an AirplaneMovement - # with the same parameters. - corresponding_wing_movements = [wm.corresponding_wing_movement for wm in self.wing_movements] + # Create the corresponding AirplaneMovement, which validates all oscillation + # parameters and is also needed by coupled unsteady problems. + corresponding_wing_movements = [ + wm.corresponding_wing_movement for wm in self.wing_movements + ] self.corresponding_airplane_movement = AirplaneMovement( base_airplane=base_airplane, wing_movements=corresponding_wing_movements, @@ -169,12 +140,18 @@ def __init__( phaseCg_GP1_CgP1=phaseCg_GP1_CgP1, ) + # Copy validated attributes from the corresponding AirplaneMovement. + self.ampCg_GP1_CgP1 = self.corresponding_airplane_movement.ampCg_GP1_CgP1 + self.periodCg_GP1_CgP1 = self.corresponding_airplane_movement.periodCg_GP1_CgP1 + self.spacingCg_GP1_CgP1 = self.corresponding_airplane_movement.spacingCg_GP1_CgP1 + self.phaseCg_GP1_CgP1 = self.corresponding_airplane_movement.phaseCg_GP1_CgP1 + self.listCg_GP1_CgP1 = None @property def all_periods(self) -> list[float]: - """All unique non zero periods from this AirplaneMovement, its WingMovement(s), - and their WingCrossSectionMovements. + """All unique non zero periods from this SingleStepAirplaneMovement, its + SingleStepWingMovements, and their SingleStepWingCrossSectionMovements. :return: A list of all unique non zero periods in seconds. If all motion is static, this will be an empty list. @@ -193,15 +170,20 @@ def all_periods(self) -> list[float]: def generate_next_airplane( self, base_airplane, delta_time: float | int, num_steps: int, step: int, deformation_matrices, - ) -> list[geometry.airplane.Airplane]: - """Creates the Airplane at each time step, and returns them in a list. + ) -> geometry.airplane.Airplane: + """Creates the Airplane at a single time step. - :param num_steps: The number of time steps in this movement. It must be a - positive int. + :param base_airplane: The base Airplane from which the new Airplane will be + created. :param delta_time: The time between each time step. It must be a positive number (float or int), and will be converted internally to a float. The units are in seconds. - :return: The list of Airplanes associated with this AirplaneMovement. + :param num_steps: The total number of time steps in this movement. It must be a + positive int. + :param step: The index of the current time step. + :param deformation_matrices: Deformation matrices to apply to the Wings, or + None. + :return: The Airplane at the specified time step. """ num_steps = int_in_range_return_int( num_steps, @@ -213,9 +195,9 @@ def generate_next_airplane( delta_time, "delta_time", min_val=0.0, min_inclusive=False ) - # Generate oscillating values for each dimension of Cg_E_CgP1. + # Generate oscillating values for each dimension of Cg_GP1_CgP1. if self.listCg_GP1_CgP1 is None: - self._initialize_oscilating_dimensions(delta_time, num_steps, base_airplane) + self._initialize_oscillating_dimensions(delta_time, num_steps, base_airplane) wings = [] @@ -248,17 +230,13 @@ def generate_next_airplane( return this_airplane - def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_airplane): - """Initializes the oscillating dimensions for Cg_E_CgP1. - :param delta_time: number + def _initialize_oscillating_dimensions(self, delta_time, num_steps, base_airplane): + """Pre computes the oscillating Cg_GP1_CgP1 values for all time steps. - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - :param num_steps: int - - This is the number of time steps in this movement. It must be a positive - int. + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps. + :param base_airplane: The base Airplane providing the base Cg_GP1_CgP1 values. + :return: None """ self.listCg_GP1_CgP1 = np.zeros((3, num_steps), dtype=float) for dim in range(3): @@ -296,8 +274,9 @@ def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_airplane @property def max_period(self) -> float: - """The longest period of AirplaneMovement's own motion, the motion(s) of its sub - movement object(s), and the motions of its sub sub movement objects. + """The longest period of this SingleStepAirplaneMovement's own motion, the + motion(s) of its sub movement object(s), and the motions of its sub sub movement + objects. :return: The longest period in seconds. If all the motion is static, this will be 0.0. diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index 75327beed..2208dffaa 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -1,10 +1,14 @@ -"""This module contains the Movement class. +"""Contains the SingleStepMovement class. -This module contains the following classes: - Movement: This is a class used to contain an UnsteadyProblem's movement. +**Contains the following classes:** -This module contains the following functions: - None +SingleStepMovement: A single step variant of Movement that generates Airplanes and +OperatingPoints one time step at a time instead of all at once. Uses composition to +wrap a Movement. + +**Contains the following functions:** + +None """ from ..movement import Movement @@ -18,23 +22,27 @@ ) class SingleStepMovement: - """This is a class used to contain an UnsteadyProblem's movement. - - This class contains the following public methods: - - max_period: Defines a property for the longest period of Movement's own - motion and that of its sub-movement objects, sub-sub-movement objects, etc. + """A single step variant of Movement for coupled simulations. - static: Defines a property to flag if all the Movement itself, and all of its - sub-movement objects, sub-sub-movement objects, etc. represent no motion. + This class wraps a Movement via composition and generates Airplanes and + OperatingPoints one time step at a time (via generate_next_movement) rather than + generating all of them at once. The composed Movement is accessible via + corresponding_movement. - This class contains the following class attributes: - None + **Contains the following methods:** - Subclassing: - This class is not meant to be subclassed. + generate_next_movement: Creates the Airplanes and OperatingPoint at a single time + step. """ + __slots__ = ( + "airplane_movements", + "operating_point_movement", + "corresponding_movement", + "delta_time", + "num_steps", + ) + def __init__( self, single_step_airplane_movements, @@ -44,63 +52,48 @@ def __init__( num_chords: int | None = None, num_steps: int | None = None, ): - """This is the initialization method. - - Note: This method checks that all Wings maintain their symmetry type across - all time steps. See the WingMovement class documentation for more details on - this requirement, and the Wing class documentation for more information on - symmetry types. - - :param airplane_movements: list of AirplaneMovements - - This is a list of objects which characterize the movement of each - of the airplanes in the UnsteadyProblem. - - :param operating_point_movement: OperatingPointMovement - - This object characterizes changes to the UnsteadyProblem's the operating - point. - - :param delta_time: number or None, optional - - delta_time is the time, in seconds, between each time step. If left as - None, which is the default value, Movement will calculate a value such - that RingVortices shed from the first Wing will have roughly the same - chord length as the RingVortices on the first Wing. This is based on - first base Airplane's reference chord length, its first Wing's number of - chordwise panels, and its base OperatingPoint's velocity. If set, - delta_time must be a positive number (int or float). It will be converted - internally to a float. - - :param num_cycles: int or None, optional - - num_cycles is the number of cycles of the maximum period motion used to - calculate a non-populated num_steps parameter if Movement isn't static. - If num_steps is set or Movement is static, this must be left as None, - which is the default value. If num_steps isn't set and Movement isn't - static, num_cycles must be a positive int. In that case, I recommend - setting num_cycles to 3. - - :param num_chords: int or None, optional - - num_chords is the number of chord lengths used to calculate a - non-populated num_steps parameter if Movement is static. If num_steps is - set or Movement isn't static, this must be left as None, which is the - default value. If num_steps isn't set and Movement is static, num_chords - must be a positive int. In that case, I recommend setting num_chords to - 10. For cases with multiple Airplanes, the num_chords will reference the - largest reference chord length. - - :param num_steps: int or None, optional - - num_steps is the number of time steps of the unsteady simulation. It must - be a positive int. The default value is None. If left as None, - and Movement isn't static, Movement will calculate a value such that the - simulation will cover some number of cycles of the maximum period of all - the motion described in Movement's sub-movement objects, sub-sub-movement - objects, etc. If num_steps is left as None, and Movement is static, - it will default to the number of time steps such that the wake extends - back by some number of reference chord lengths. + """The initialization method. + + :param single_step_airplane_movements: A list of SingleStepAirplaneMovements + characterizing the movement of each Airplane. + :param single_step_operating_point_movement: A SingleStepOperatingPointMovement + characterizing any changes to the operating conditions. + :param delta_time: The time between each time step. Accepts the following: None + (default): SingleStepMovement analytically estimates the delta_time that + produces wake RingVortices with roughly the same chord length as the bound + trailing edge RingVortices, accounting for both freestream and geometry + motion velocities. This provides good results across all Strouhal numbers. + "optimize": SingleStepMovement first runs the analytical estimation, then + uses that result as an initial guess for an iterative optimization that + minimizes the area mismatch between wake RingVortices and their parent + bound trailing edge RingVortices. This is slower but may produce slightly + more accurate results. Positive number (int or float): Use the specified + value directly. All values are converted internally to floats. The units + are in seconds. + :param num_cycles: The number of cycles of the maximum period motion used to + calculate a num_steps parameter initialized as None if the + SingleStepMovement isn't static. If num_steps is not None or if the + SingleStepMovement is static, this must be None. If num_steps is + initialized as None and the SingleStepMovement isn't static, num_cycles + must be a positive int. In that case, I recommend setting num_cycles to 3. + The default is None. + :param num_chords: The number of chord lengths used to calculate a num_steps + parameter initialized as None if the SingleStepMovement is static. If + num_steps is not None or if the SingleStepMovement isn't static, this must + be None. If num_steps is initialized as None and the SingleStepMovement is + static, num_chords must be a positive int. In that case, I recommend + setting num_chords to 10. For cases with multiple Airplanes, the num_chords + will reference the largest reference chord length. The default is None. + :param num_steps: The number of time steps of the unsteady simulation. If + initialized as None, and the SingleStepMovement isn't static, it will + calculate a value for num_steps such that the simulation will cover some + number of cycles of the maximum period of all the motion described in the + SingleStepMovement's sub movement objects, sub sub movement object(s), and + sub sub sub movement objects. If num_steps is initialized as None, and the + SingleStepMovement is static, it will calculate a value for num_steps such + that the simulation will result in a wake extending back by some number of + reference chord lengths. + :return: None """ if not isinstance(single_step_airplane_movements, list): raise TypeError("single_step_airplane_movements must be a list.") @@ -137,18 +130,15 @@ def __init__( self.num_steps = self.corresponding_movement.num_steps def generate_next_movement(self, base_airplanes, base_operating_point, step, deformation_matrices=None): - """Creates the Airplanes and OperatingPoint at the next time step. - :param base_airplanes: list of Airplanes - - This is the list of Airplanes at the base time step. - :param base_operating_point: OperatingPoint - - This is the OperatingPoint at the base time step. - :return: tuple (list of Airplanes, OperatingPoint) - - This is a tuple where the first element is the list of Airplanes at the - next time step, and the second element is the OperatingPoint at the next - time step. + """Creates the Airplanes and OperatingPoint at a single time step. + + :param base_airplanes: The list of Airplanes at the current time step. + :param base_operating_point: The OperatingPoint at the current time step. + :param step: The index of the time step to generate. + :param deformation_matrices: Deformation matrices to apply to the Wings, or + None. The default is None. + :return: A tuple of (list of Airplanes, OperatingPoint) at the specified time + step. """ airplanes = [] diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py index 88bd028bf..18f5ae1bf 100644 --- a/pterasoftware/movements/single_step/single_step_operating_point_movement.py +++ b/pterasoftware/movements/single_step/single_step_operating_point_movement.py @@ -1,11 +1,14 @@ -"""This module contains the OperatingPointMovement class. +"""Contains the SingleStepOperatingPointMovement class. -This module contains the following classes: - OperatingPointMovement: This is a class used to contain the OperatingPoint - movements. +**Contains the following classes:** -This module contains the following functions: - None +SingleStepOperatingPointMovement: A single step variant of OperatingPointMovement +that generates one OperatingPoint per time step instead of all at once. Uses +composition to wrap an OperatingPointMovement. + +**Contains the following functions:** + +None """ from ..operating_point_movement import OperatingPointMovement from .._functions import ( @@ -22,23 +25,30 @@ class SingleStepOperatingPointMovement: - """This is a class used to contain the OperatingPoint movements. - - This class contains the following public methods: + """A single step variant of OperatingPointMovement for coupled simulations. - generate_operating_points: Creates the OperatingPoint at each time step, - and returns them in a list. + This class wraps an OperatingPointMovement via composition and generates one + OperatingPoint per time step (via generate_next_operating_point) rather than + generating all OperatingPoints at once. The composed OperatingPointMovement is + accessible via corresponding_operating_point_movement. - max_period: Defines a property for the longest period of - OperatingPointMovement's own motion. + **Contains the following methods:** - This class contains the following class attributes: - None + generate_next_operating_point: Creates the OperatingPoint at a single time step. - Subclassing: - This class is not meant to be subclassed. + max_period: The longest period of this SingleStepOperatingPointMovement's own + motion. """ + __slots__ = ( + "ampVCg__E", + "periodVCg__E", + "spacingVCg__E", + "phaseVCg__E", + "listVCg__E", + "corresponding_operating_point_movement", + ) + def __init__( self, base_operating_point: OperatingPoint, @@ -47,83 +57,42 @@ def __init__( spacingVCg__E="sine", phaseVCg__E=0.0, ): - """This is the initialization method. - - :param base_operating_point: OperatingPoint - - This is the base OperatingPoint, from which the OperatingPoint at each - time step will be created. - - :param ampVCg__E: number, optional - - The amplitude of the OperatingPointMovement's changes in its - OperatingPoints' vCg__E parameters. Must be a non-negative number (int or - float), and is converted to a float internally. Also, the amplitude must - be low enough that it doesn't drive its base value out of the range of - valid values. Otherwise, this OperatingPointMovement will try to create - OperatingPoints with invalid parameters values.The default value is 0.0. - The units are in meters per second. - - :param periodVCg__E: number, optional - - The period of the OperatingPointMovement's changes in its - OperatingPoints' vCg__E parameter. Must be a non-negative number (int or - float), and is converted to a float internally. The default value is 0.0. - It must be 0.0 if ampVCg__E 0.0 and non-zero if not. The units are in - seconds. - - :param spacingVCg__E: string, optional - - The value determines the spacing of the OperatingPointMovement's change - in its OperatingPoints' vCg__E parameters. Must be either "sine", - "uniform", or a callable custom spacing function. Custom spacing - functions are for advanced users and must start at 0, return to 0 after - one period of 2*pi radians, have amplitude of 1, be periodic, - return finite values only, and accept a ndarray as input and return a - ndarray of the same shape. The custom function is scaled by ampVCg__E, - shifted horizontally by phaseVCg__E, and vertically by the base value, - with the period controlled by periodVCg__E. The default value is "sine". - - :param phaseVCg__E: number optional - - The phase offsets of the first time step's OperatingPoint's vCg__E - parameter relative to the base OperatingPoint's vCg__E parameter. Must be - a number (int or float) in the range (-180.0, 180.0], and is converted to a - float internally. The default value is 0.0. It must be 0.0 if ampVCg__E - is 0.0 and non-zero if not. The units are in degrees. + """The initialization method. + + :param base_operating_point: The base OperatingPoint from which the + OperatingPoint at each time step will be created. + :param ampVCg__E: The amplitude of the SingleStepOperatingPointMovement's + changes in its OperatingPoints' vCg__E parameters. Must be a non negative + number (int or float), and is converted to a float internally. The + amplitude must be low enough that it doesn't drive its base value out of + the range of valid values. Otherwise, this + SingleStepOperatingPointMovement will try to create OperatingPoints with + invalid parameter values. The units are in meters per second. The default + is 0.0. + :param periodVCg__E: The period of the SingleStepOperatingPointMovement's + changes in its OperatingPoints' vCg__E parameter. Must be a non negative + number (int or float), and is converted to a float internally. It must be + 0.0 if ampVCg__E is 0.0 and non zero if not. The units are in seconds. The + default is 0.0. + :param spacingVCg__E: Determines the spacing of the + SingleStepOperatingPointMovement's change in its OperatingPoints' vCg__E + parameters. Can be "sine", "uniform", or a callable custom spacing + function. Custom spacing functions are for advanced users and must start at + 0.0, return to 0.0 after one period of 2*pi radians, have amplitude of + 1.0, be periodic, return finite values only, and accept a ndarray as input + and return a ndarray of the same shape. The custom function is scaled by + ampVCg__E, shifted horizontally and vertically by phaseVCg__E and the base + value, and have a period set by periodVCg__E. The default is "sine". + :param phaseVCg__E: The phase offset of the first time step's OperatingPoint's + vCg__E parameter relative to the base OperatingPoint's vCg__E parameter. + Must be a number (int or float) in the range (-180.0, 180.0], and will be + converted to a float internally. It must be 0.0 if ampVCg__E is 0.0 and + non zero if not. The units are in degrees. The default is 0.0. + :return: None """ - self.ampVCg__E = number_in_range_return_float( - ampVCg__E, "ampVCg__E", min_val=0.0, min_inclusive=True - ) - - periodVCg__E = number_in_range_return_float( - periodVCg__E, "periodVCg__E", min_val=0.0, min_inclusive=True - ) - if self.ampVCg__E == 0 and periodVCg__E != 0: - raise ValueError("If ampVCg__E is 0.0, then periodVCg__E must also be 0.0.") - self.periodVCg__E = periodVCg__E - - if isinstance(spacingVCg__E, str): - if spacingVCg__E not in ["sine", "uniform"]: - raise ValueError( - f"spacingVCg__E must be 'sine', 'uniform', or a callable, got string '{spacingVCg__E}'." - ) - elif not callable(spacingVCg__E): - raise TypeError( - f"spacingVCg__E must be 'sine', 'uniform', or a callable, got {type(spacingVCg__E).__name__}." - ) - self.spacingVCg__E = spacingVCg__E - - phaseVCg__E = number_in_range_return_float( - phaseVCg__E, "phaseVCg__E", -180.0, False, 180.0, True - ) - if self.ampVCg__E == 0 and phaseVCg__E != 0: - raise ValueError("If ampVCg__E is 0.0, then phaseVCg__E must also be 0.0.") - self.phaseVCg__E = phaseVCg__E - - self.listVCg__E = None - + # Create the corresponding OperatingPointMovement, which validates all + # oscillation parameters and is also needed by coupled unsteady problems. self.corresponding_operating_point_movement = OperatingPointMovement( base_operating_point=base_operating_point, ampVCg__E=ampVCg__E, @@ -132,24 +101,23 @@ def __init__( phaseVCg__E=phaseVCg__E, ) - def generate_next_operating_point(self, delta_time, base_operating_point: OperatingPoint, num_steps, step): - """Creates the OperatingPoint at each time step, and returns them in a list. - - :param num_steps: int - - This is the number of time steps in this movement. It must be a positive - int. - - :param delta_time: number + # Copy validated attributes from the corresponding OperatingPointMovement. + self.ampVCg__E = self.corresponding_operating_point_movement.ampVCg__E + self.periodVCg__E = self.corresponding_operating_point_movement.periodVCg__E + self.spacingVCg__E = self.corresponding_operating_point_movement.spacingVCg__E + self.phaseVCg__E = self.corresponding_operating_point_movement.phaseVCg__E - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - - :return: list of OperatingPoints + self.listVCg__E = None - This is the list of OperatingPoints associated with this - OperatingPointMovement. + def generate_next_operating_point(self, delta_time, base_operating_point: OperatingPoint, num_steps, step): + """Creates the OperatingPoint at a single time step. + + :param delta_time: The time between each time step in seconds. + :param base_operating_point: The base OperatingPoint from which the new + OperatingPoint will be created. + :param num_steps: The total number of time steps in this movement. + :param step: The index of the current time step. + :return: The OperatingPoint at the specified time step. """ num_steps = int_in_range_return_int( num_steps, "num_steps", min_val=1, min_inclusive=True @@ -186,30 +154,21 @@ def generate_next_operating_point(self, delta_time, base_operating_point: Operat @property def max_period(self): - """Defines a property for the longest period of OperatingPointMovement's own - motion. + """The longest period of this SingleStepOperatingPointMovement's own motion. - :return: float - - The longest period in seconds. If the all the motion is static, this will - be 0.0. + :return: The longest period in seconds. If all the motion is static, this + will be 0.0. """ return self.periodVCg__E def _initialize_oscillating_values(self, delta_time, num_steps, base_operating_point): - """Pre-computes the oscillating values for faster access later. - - :param delta_time: number - - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - :param num_steps: int - This is the number of time steps in this movement. It must be a positive - int. - :param base_operating_point: OperatingPoint - This is the base OperatingPoint, from which the OperatingPoint at each - time step will be created. + """Pre computes the oscillating VCg__E values for all time steps. + + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps. + :param base_operating_point: The base OperatingPoint providing the base + VCg__E value. + :return: None """ # Generate oscillating values for VCg__E. if self.spacingVCg__E == "sine": diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index f92b90b01..c3c0e169e 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -1,11 +1,14 @@ -"""This module contains the WingCrossSectionMovement class. +"""Contains the SingleStepWingCrossSectionMovement class. -This module contains the following classes: - WingCrossSectionMovement: This is a class used to contain the WingCrossSection - movements. +**Contains the following classes:** -This module contains the following functions: - None +SingleStepWingCrossSectionMovement: A single step variant of +WingCrossSectionMovement that generates one WingCrossSection per time step instead of +all at once. Uses composition to wrap a WingCrossSectionMovement. + +**Contains the following functions:** + +None """ import numpy as np @@ -13,8 +16,6 @@ from ..wing_cross_section_movement import WingCrossSectionMovement from ..._parameter_validation import ( - threeD_number_vectorLike_return_float, - threeD_spacing_vectorLike_return_tuple, int_in_range_return_int, number_in_range_return_float ) @@ -29,23 +30,36 @@ class SingleStepWingCrossSectionMovement: - """This is a class used to contain the WingCrossSection movements. + """A single step variant of WingCrossSectionMovement for coupled simulations. - This class contains the following public methods: + This class wraps a WingCrossSectionMovement via composition and generates one + WingCrossSection per time step (via generate_next_wing_cross_sections) rather than + generating all WingCrossSections at once. The composed WingCrossSectionMovement is + accessible via corresponding_wing_cross_section_movement. - generate_wing_cross_sections: Creates the WingCrossSection at each time step, - and returns them in a list. + **Contains the following methods:** - max_period: Defines a property for the longest period of - WingCrossSectionMovement's own motion. + generate_next_wing_cross_sections: Creates the WingCrossSection at a single time + step. - This class contains the following class attributes: - None - - Subclassing: - This class is not meant to be subclassed. + max_period: The longest period of this SingleStepWingCrossSectionMovement's own + motion. """ + __slots__ = ( + "ampLp_Wcsp_Lpp", + "periodLp_Wcsp_Lpp", + "spacingLp_Wcsp_Lpp", + "phaseLp_Wcsp_Lpp", + "ampAngles_Wcsp_to_Wcs_ixyz", + "periodAngles_Wcsp_to_Wcs_ixyz", + "spacingAngles_Wcsp_to_Wcs_ixyz", + "phaseAngles_Wcsp_to_Wcs_ixyz", + "listLp_Wcsp_Lpp", + "listAngles_Wcsp_to_Wcs_ixyz", + "corresponding_wing_cross_section_movement", + ) + def __init__( self, base_wing_cross_section, @@ -58,219 +72,95 @@ def __init__( spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"), phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0), ): - """This is the initialization method. - - :param base_wing_cross_section: WingCrossSection - - This is the base WingCrossSection, from which the WingCrossSection at - each time step will be created. - - :param ampLp_Wcsp_Lpp: array-like of 3 numbers, optional - - The amplitudes of the WingCrossSectionMovement's changes in its - WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, or numpy - array of non-negative numbers (int or float). Also, each amplitude must - be low enough that it doesn't drive its base value out of the range of - valid values. Otherwise, this WingCrossSectionMovement will try to create - WingCrossSections with invalid parameters values. Values are converted to - floats internally. The default value is (0.0, 0.0, 0.0). The units are in - meters. - - :param periodLp_Wcsp_Lpp: array-like of 3 numbers, optional - - The periods of the WingCrossSectionMovement's changes in its - WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, or numpy - array of non-negative numbers (int or float). Values are converted to - floats internally. The default value is (0.0, 0.0, 0.0). Each element - must be 0.0 if the corresponding element in ampLp_Wcsp_Lpp is 0.0 and - non-zero if not. The units are in seconds. - - :param spacingLp_Wcsp_Lpp: array-like of 3 strs or callables, optional - - The value determines the spacing of the WingCrossSectionMovement's change - in its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, - or numpy array. Each element can be the string "sine", the string - "uniform", or a callable custom spacing function. Custom spacing functions - are for advanced users and must start at 0, return to 0 after one period - of 2*pi radians, have amplitude of 1, be periodic, return finite values - only, and accept a ndarray as input and return a ndarray of the same - shape. The custom function is scaled by ampLp_Wcsp_Lpp, shifted - horizontally by phaseLp_Wcsp_Lpp, and vertically by the base value, with - the period controlled by periodLp_Wcsp_Lpp. The default value is ("sine", + """The initialization method. + + :param base_wing_cross_section: The base WingCrossSection from which the + WingCrossSection at each time step will be created. + :param ampLp_Wcsp_Lpp: An array-like object of non negative numbers (int or + float) with shape (3,) representing the amplitudes of the + SingleStepWingCrossSectionMovement's changes in its WingCrossSections' + Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are + converted to floats internally. Each amplitude must be low enough that it + doesn't drive its base value out of the range of valid values. Otherwise, + this SingleStepWingCrossSectionMovement will try to create WingCrossSections + with invalid parameter values. The units are in meters. The default is + (0.0, 0.0, 0.0). + :param periodLp_Wcsp_Lpp: An array-like object of non negative numbers (int or + float) with shape (3,) representing the periods of the + SingleStepWingCrossSectionMovement's changes in its WingCrossSections' + Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are + converted to floats internally. Each element must be 0.0 if the + corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not. The + units are in seconds. The default is (0.0, 0.0, 0.0). + :param spacingLp_Wcsp_Lpp: An array-like object of strs or callables with shape + (3,) representing the spacing of the SingleStepWingCrossSectionMovement's + changes in its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, + list, or ndarray. Each element can be the str "sine", the str "uniform", or + a callable custom spacing function. Custom spacing functions are for + advanced users and must start at 0.0, return to 0.0 after one period of + 2*pi radians, have amplitude of 1.0, be periodic, return finite values + only, and accept a ndarray as input and return a ndarray of the same shape. + Custom functions are scaled by ampLp_Wcsp_Lpp, shifted horizontally and + vertically by phaseLp_Wcsp_Lpp and the base value, and have a period set by + periodLp_Wcsp_Lpp. The default is ("sine", "sine", "sine"). + :param phaseLp_Wcsp_Lpp: An array-like object of numbers (int or float) with + shape (3,) representing the phase offsets of the elements in the first time + step's WingCrossSection's Lp_Wcsp_Lpp parameter relative to the base + WingCrossSection's Lp_Wcsp_Lpp parameter. Can be a tuple, list, or ndarray. + Elements must lie in the range (-180.0, 180.0]. Each element must be 0.0 if + the corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not. + Values are converted to floats internally. The units are in degrees. The + default is (0.0, 0.0, 0.0). + :param ampAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative numbers + (int or float) with shape (3,) representing the amplitudes of the + SingleStepWingCrossSectionMovement's changes in its WingCrossSections' + angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. + Values are converted to floats internally. Each amplitude must be low enough + that it doesn't drive its base value out of the range of valid values. + Otherwise, this SingleStepWingCrossSectionMovement will try to create + WingCrossSections with invalid parameter values. The units are in degrees. + The default is (0.0, 0.0, 0.0). + :param periodAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative + numbers (int or float) with shape (3,) representing the periods of the + SingleStepWingCrossSectionMovement's changes in its WingCrossSections' + angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. + Values are converted to floats internally. Each element must be 0.0 if the + corresponding element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if + not. The units are in seconds. The default is (0.0, 0.0, 0.0). + :param spacingAngles_Wcsp_to_Wcs_ixyz: An array-like object of strs or + callables with shape (3,) representing the spacing of the + SingleStepWingCrossSectionMovement's changes in its WingCrossSections' + angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Each + element can be the str "sine", the str "uniform", or a callable custom + spacing function. Custom spacing functions are for advanced users and must + start at 0.0, return to 0.0 after one period of 2*pi radians, have + amplitude of 1.0, be periodic, return finite values only, and accept a + ndarray as input and return a ndarray of the same shape. Custom functions + are scaled by ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally and + vertically by phaseAngles_Wcsp_to_Wcs_ixyz and the base value, and have a + period set by periodAngles_Wcsp_to_Wcs_ixyz. The default is ("sine", "sine", "sine"). - - :param phaseLp_Wcsp_Lpp: array-like of 3 numbers, optional - - The phase offsets of the elements in the first time step's - WingCrossSection's Lp_Wcsp_Lpp parameter relative to the base - WingCrossSection's Lp_Wcsp_Lpp parameter. Can be a tuple, list, or numpy - array of non-negative numbers (int or float) in the range (-180.0, - 180.0]. Values are converted to floats internally. The default value is ( - 0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding element in - ampLp_Wcsp_Lpp is 0.0 and non-zero if not. The units are in degrees. - - :param ampAngles_Wcsp_to_Wcs_ixyz: array-like of 3 numbers, optional - - The amplitudes of the WingCrossSectionMovement's changes in its - WingCrossSections' angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, - list, or numpy array of numbers (int or float) in the range [0.0, - 180.0]. Also, each amplitude must be low enough that it doesn't drive its - base value out of the range of valid values. Otherwise, - this WingCrossSectionMovement will try to create WingCrossSections with - invalid parameters values. Values are converted to floats internally. The - default value is (0.0, 0.0, 0.0). The units are in degrees. - - :param periodAngles_Wcsp_to_Wcs_ixyz: array-like of 3 numbers, optional - - The periods of the WingCrossSectionMovement's changes in its - WingCrossSections' angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, - list, or numpy array of non-negative numbers (int or float). Values are - converted to floats internally. The default value is (0.0, 0.0, - 0.0). Each element must be 0.0 if the corresponding element in - ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non-zero if not. The units are in - seconds. - - :param spacingAngles_Wcsp_to_Wcs_ixyz: array-like of 3 strs or callables, optional - - The value determines the spacing of the WingCrossSectionMovement's change - in its WingCrossSections' angles_Wcsp_to_Wcs_ixyz parameters. Can be a - tuple, list, or numpy array. Each element can be the string "sine", - the string "uniform", or a callable custom spacing function. Custom - spacing functions are for advanced users and must start at 0, return to 0 - after one period of 2*pi radians, have amplitude of 1, be periodic, - return finite values only, and accept a ndarray as input and return a - ndarray of the same shape. The custom function is scaled by - ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally by - phaseAngles_Wcsp_to_Wcs_ixyz, and vertically by the base value, with the - period controlled by periodAngles_Wcsp_to_Wcs_ixyz. The default value is - ("sine", "sine", "sine"). - - :param phaseAngles_Wcsp_to_Wcs_ixyz: array-like of 3 numbers, optional - - The phase offsets of the elements in the first time step's - WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter relative to the base - WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter. Can be a tuple, - list, or numpy array of numbers (int or float) in the range (-180.0, - 180.0]. Values are converted to floats internally. The default value is ( - 0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding element in - ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non-zero if not. The units are in - degrees. + :param phaseAngles_Wcsp_to_Wcs_ixyz: An array-like object of numbers (int or + float) with shape (3,) representing the phase offsets of the elements in + the first time step's WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter + relative to the base WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter. + Can be a tuple, list, or ndarray. Elements must lie in the range (-180.0, + 180.0]. Each element must be 0.0 if the corresponding element in + ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if not. Values are converted + to floats internally. The units are in degrees. The default is (0.0, 0.0, + 0.0). + :return: None """ - ampLp_Wcsp_Lpp = threeD_number_vectorLike_return_float( - ampLp_Wcsp_Lpp, "ampLp_Wcsp_Lpp" - ) - if not np.all(ampLp_Wcsp_Lpp >= 0.0): - raise ValueError("All elements in ampLp_Wcsp_Lpp must be non-negative.") - self.ampLp_Wcsp_Lpp = ampLp_Wcsp_Lpp - - periodLp_Wcsp_Lpp = threeD_number_vectorLike_return_float( - periodLp_Wcsp_Lpp, "periodLp_Wcsp_Lpp" - ) - if not np.all(periodLp_Wcsp_Lpp >= 0.0): - raise ValueError("All elements in periodLp_Wcsp_Lpp must be non-negative.") - for period_index, period in enumerate(periodLp_Wcsp_Lpp): - amp = self.ampLp_Wcsp_Lpp[period_index] - if amp == 0 and period != 0: - raise ValueError( - "If an element in ampLp_Wcsp_Lpp is 0.0, the corresponding element in periodLp_Wcsp_Lpp must be also be 0.0." - ) - self.periodLp_Wcsp_Lpp = periodLp_Wcsp_Lpp - - spacingLp_Wcsp_Lpp = ( - threeD_spacing_vectorLike_return_tuple( - spacingLp_Wcsp_Lpp, "spacingLp_Wcsp_Lpp" - ) - ) - self.spacingLp_Wcsp_Lpp = spacingLp_Wcsp_Lpp - - phaseLp_Wcsp_Lpp = threeD_number_vectorLike_return_float( - phaseLp_Wcsp_Lpp, "phaseLp_Wcsp_Lpp" - ) - if not ( - np.all(phaseLp_Wcsp_Lpp > -180.0) and np.all(phaseLp_Wcsp_Lpp <= 180.0) - ): - raise ValueError( - "All elements in phaseLp_Wcsp_Lpp must be in the range (-180.0, 180.0]." - ) - for phase_index, phase in enumerate(phaseLp_Wcsp_Lpp): - amp = self.ampLp_Wcsp_Lpp[phase_index] - if amp == 0 and phase != 0: - raise ValueError( - "If an element in ampLp_Wcsp_Lpp is 0.0, the corresponding element in phaseLp_Wcsp_Lpp must be also be 0.0." - ) - self.phaseLp_Wcsp_Lpp = phaseLp_Wcsp_Lpp - - ampAngles_Wcsp_to_Wcs_ixyz = ( - threeD_number_vectorLike_return_float( - ampAngles_Wcsp_to_Wcs_ixyz, "ampAngles_Wcsp_to_Wcs_ixyz" - ) - ) - if not ( - np.all(ampAngles_Wcsp_to_Wcs_ixyz >= 0.0) - and np.all(ampAngles_Wcsp_to_Wcs_ixyz <= 180.0) - ): - raise ValueError( - "All elements in ampAngles_Wcsp_to_Wcs_ixyz must be in the range [0.0, 180.0]." - ) - self.ampAngles_Wcsp_to_Wcs_ixyz = ampAngles_Wcsp_to_Wcs_ixyz - - periodAngles_Wcsp_to_Wcs_ixyz = ( - threeD_number_vectorLike_return_float( - periodAngles_Wcsp_to_Wcs_ixyz, "periodAngles_Wcsp_to_Wcs_ixyz" - ) - ) - if not np.all(periodAngles_Wcsp_to_Wcs_ixyz >= 0.0): - raise ValueError( - "All elements in periodAngles_Wcsp_to_Wcs_ixyz must be non-negative." - ) - for period_index, period in enumerate(periodAngles_Wcsp_to_Wcs_ixyz): - amp = self.ampAngles_Wcsp_to_Wcs_ixyz[period_index] - if amp == 0 and period != 0: - raise ValueError( - "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, the corresponding element in periodAngles_Wcsp_to_Wcs_ixyz must be also be 0.0." - ) - self.periodAngles_Wcsp_to_Wcs_ixyz = periodAngles_Wcsp_to_Wcs_ixyz - - spacingAngles_Wcsp_to_Wcs_ixyz = ( - threeD_spacing_vectorLike_return_tuple( - spacingAngles_Wcsp_to_Wcs_ixyz, - "spacingAngles_Wcsp_to_Wcs_ixyz", - ) - ) - self.spacingAngles_Wcsp_to_Wcs_ixyz = spacingAngles_Wcsp_to_Wcs_ixyz - - phaseAngles_Wcsp_to_Wcs_ixyz = ( - threeD_number_vectorLike_return_float( - phaseAngles_Wcsp_to_Wcs_ixyz, "phaseAngles_Wcsp_to_Wcs_ixyz" - ) - ) - if not ( - np.all(phaseAngles_Wcsp_to_Wcs_ixyz > -180.0) - and np.all(phaseAngles_Wcsp_to_Wcs_ixyz <= 180.0) - ): - raise ValueError( - "All elements in phaseAngles_Wcsp_to_Wcs_ixyz must be in the range (-180.0, 180.0]." - ) - for phase_index, phase in enumerate(phaseAngles_Wcsp_to_Wcs_ixyz): - amp = self.ampAngles_Wcsp_to_Wcs_ixyz[phase_index] - if amp == 0 and phase != 0: - raise ValueError( - "If an element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0, the corresponding element in phaseAngles_Wcsp_to_Wcs_ixyz must be also be 0.0." - ) - self.phaseAngles_Wcsp_to_Wcs_ixyz = phaseAngles_Wcsp_to_Wcs_ixyz - - self.listLp_Wcsp_Lpp = None - self.listAngles_Wcsp_to_Wcs_ixyz = None - + # Warn about potential deformation issues with multiple spanwise panels. if base_wing_cross_section.num_spanwise_panels is not None and base_wing_cross_section.num_spanwise_panels > 1: print("base_wing_cross_section must have num_spanwise_panels equal to None or 1 to do deformation. " + \ "This wing cross section has " + str(base_wing_cross_section.num_spanwise_panels) + " spanwise panels. Please be sure this is intended. " + \ "Applications that make sense for this are tails and non-primary wings.") - # Create the corresponding WingCrossSectionMovement, which will remove redundancy as - # Coupled unsteady problems require both a SingleStepWingCrossSectionMovement and a - # WingCrossSectionMovement with the same parameters. - self.corresponding_wcs_movement = WingCrossSectionMovement( + + # Create the corresponding WingCrossSectionMovement, which validates all + # oscillation parameters and is also needed by coupled unsteady problems. + self.corresponding_wing_cross_section_movement = WingCrossSectionMovement( base_wing_cross_section=base_wing_cross_section, ampLp_Wcsp_Lpp=ampLp_Wcsp_Lpp, periodLp_Wcsp_Lpp=periodLp_Wcsp_Lpp, @@ -282,6 +172,19 @@ def __init__( phaseAngles_Wcsp_to_Wcs_ixyz=phaseAngles_Wcsp_to_Wcs_ixyz, ) + # Copy validated attributes from the corresponding WingCrossSectionMovement. + self.ampLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.ampLp_Wcsp_Lpp + self.periodLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.periodLp_Wcsp_Lpp + self.spacingLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.spacingLp_Wcsp_Lpp + self.phaseLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.phaseLp_Wcsp_Lpp + self.ampAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz + self.periodAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz + self.spacingAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.spacingAngles_Wcsp_to_Wcs_ixyz + self.phaseAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz + + self.listLp_Wcsp_Lpp = None + self.listAngles_Wcsp_to_Wcs_ixyz = None + def generate_next_wing_cross_sections( self, base_wing_cross_section, @@ -290,23 +193,16 @@ def generate_next_wing_cross_sections( step, deformation_matrix, ): - """Creates the WingCrossSection at each time step, and returns them in a list. - - :param num_steps: int - - This is the number of time steps in this movement. It must be a positive - int. - - :param delta_time: number - - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - - :return: list of WingCrossSections - - This is the list of WingCrossSections associated with this - WingCrossSectionMovement. + """Creates the WingCrossSection at a single time step. + + :param base_wing_cross_section: The base WingCrossSection from which the new + WingCrossSection will be created. + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps in this movement. + :param step: The index of the current time step. + :param deformation_matrix: Deformation matrix to apply to the + WingCrossSection's angles, or None. + :return: A list containing the WingCrossSection at the specified time step. """ num_steps = int_in_range_return_int( num_steps, "num_steps", min_val=1, min_inclusive=True @@ -378,23 +274,13 @@ def _initialize_oscillating_dimensions( num_steps, base_wing_cross_section, ): - """Initializes the oscillating dimensions for the WingCrossSectionMovement. - - :param delta_time: number - - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - - :param num_steps: int + """Pre computes the oscillating Lp_Wcsp_Lpp values for all time steps. - This is the number of time steps in this movement. It must be a positive - int. - - :param base_wing_cross_section: WingCrossSection - - This is the base WingCrossSection, from which the WingCrossSection at - each time step will be created. + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps. + :param base_wing_cross_section: The base WingCrossSection providing the base + Lp_Wcsp_Lpp values. + :return: None """ # Generate oscillating values for each dimension of Lp_Wcsp_Lpp. @@ -438,23 +324,13 @@ def _initialize_oscillating_angles( num_steps, base_wing_cross_section, ): - """Initializes the oscillating angles for the WingCrossSectionMovement. - - :param delta_time: number - - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - - :param num_steps: int - - This is the number of time steps in this movement. It must be a positive - int. - - :param base_wing_cross_section: WingCrossSection + """Pre computes the oscillating angles_Wcsp_to_Wcs_ixyz values for all time steps. - This is the base WingCrossSection, from which the WingCrossSection at - each time step will be created. + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps. + :param base_wing_cross_section: The base WingCrossSection providing the base + angles_Wcsp_to_Wcs_ixyz values. + :return: None """ self.listAngles_Wcsp_to_Wcs_ixyz = np.zeros((3, num_steps), dtype=float) for dim in range(3): diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py index f9266a3ed..29d237765 100644 --- a/pterasoftware/movements/single_step/single_step_wing_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_movement.py @@ -1,10 +1,13 @@ -"""This module contains the WingMovement class. +"""Contains the SingleStepWingMovement class. -This module contains the following classes: - WingMovement: This is a class used to contain the Wing movements. +**Contains the following classes:** -This module contains the following functions: - None +SingleStepWingMovement: A single step variant of WingMovement that generates one Wing +per time step instead of all at once. Uses composition to wrap a WingMovement. + +**Contains the following functions:** + +None """ import numpy as np @@ -12,8 +15,6 @@ from ..wing_movement import WingMovement from ..._parameter_validation import ( - threeD_number_vectorLike_return_float, - threeD_spacing_vectorLike_return_tuple, int_in_range_return_int, number_in_range_return_float, ) @@ -27,30 +28,38 @@ from ... import geometry class SingleStepWingMovement: - """This is a class used to contain the Wing movements. - - Note: Wings cannot undergo motion that causes them to switch symmetry types. A - transition between types could change the number of Wings and the panel - structure, which is incompatible with the unsteady solver. This happens when a - WingMovement defines motion that causes its base Wing's wing axes' yz-plane and - its symmetry plane to transition from coincident to non-coincident, or vice - versa. This is checked by this WingMovement's parent AirplaneMovement's parent - Movement. + """A single step variant of WingMovement for coupled simulations. - This class contains the following public methods: + This class wraps a WingMovement via composition and generates one Wing per time + step (via generate_next_wing) rather than generating all Wings at once. The + composed WingMovement is accessible via corresponding_wing_movement. - generate_wings: Creates the Wing at each time step, and returns them in a list. + Wings cannot undergo motion that causes them to switch symmetry types. See the + WingMovement class documentation for details. - max_period: Defines a property for the longest period of WingMovement's own - motion and that of its sub-movement objects. + **Contains the following methods:** - This class contains the following class attributes: - None + generate_next_wing: Creates the Wing at a single time step. - Subclassing: - This class is not meant to be subclassed. + max_period: The longest period of this SingleStepWingMovement's own motion and that + of its sub movement objects. """ + __slots__ = ( + "wing_cross_section_movements", + "ampLer_Gs_Cgs", + "periodLer_Gs_Cgs", + "spacingLer_Gs_Cgs", + "phaseLer_Gs_Cgs", + "ampAngles_Gs_to_Wn_ixyz", + "periodAngles_Gs_to_Wn_ixyz", + "spacingAngles_Gs_to_Wn_ixyz", + "phaseAngles_Gs_to_Wn_ixyz", + "listLer_Gs_Cgs", + "listAngles_Gs_to_Wn_ixyz", + "corresponding_wing_movement", + ) + def __init__( self, base_wing, @@ -64,223 +73,96 @@ def __init__( spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), ): - """This is the initialization method. - - :param base_wing: Wing + """The initialization method. - This is the base Wing, from which the Wing at each time step will be + :param base_wing: The base Wing from which the Wing at each time step will be created. - - :param single_step_wing_cross_section_movements: list of SingleStepWingCrossSectionMovements - - This is a list of the SingleStepWingCrossSectionMovements associated with each of - the base Wing's WingCrossSections. It must have the same length as the - base Wing's list of WingCrossSections. - - :param ampLer_Gs_Cgs: array-like of 3 numbers, optional - - The amplitudes of the WingMovement's changes in its Wings' Ler_Gs_Cgs - parameters. Can be a tuple, list, or numpy array of non-negative numbers - (int or float). Also, each amplitude must be low enough that it doesn't - drive its base value out of the range of valid values. Otherwise, - this WingMovement will try to create Wings with invalid parameters - values. Values are converted to floats internally. The default value is ( - 0.0, 0.0, 0.0). The units are in meters. - - :param periodLer_Gs_Cgs: array-like of 3 numbers, optional - - The periods of the WingMovement's changes in its Wings' Ler_Gs_Cgs - parameters. Can be a tuple, list, or numpy array of non-negative numbers - (int or float). Values are converted to floats internally. The default - value is (0.0, 0.0, 0.0). Each element must be 0.0 if the corresponding - element in ampLer_Gs_Cgs is 0.0 and non-zero if not. The units are in - seconds. - - :param spacingLer_Gs_Cgs: array-like of 3 strs or callables, optional - - The value determines the spacing of the WingMovement's change in its - Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or numpy array. Each - element can be the string "sine", the string "uniform", or a callable - custom spacing function. Custom spacing functions are for advanced users - and must start at 0, return to 0 after one period of 2*pi radians, - have amplitude of 1, be periodic, return finite values only, and accept - a ndarray as input and return a ndarray of the same shape. The custom - function is scaled by ampLer_Gs_Cgs, shifted horizontally by - phaseLer_Gs_Cgs, and vertically by the base value, with the period - controlled by periodLer_Gs_Cgs. The default value is ("sine", "sine", + :param single_step_wing_cross_section_movements: A list of + SingleStepWingCrossSectionMovements associated with each of the base Wing's + WingCrossSections. It must have the same length as the base Wing's list of + WingCrossSections. + :param ampLer_Gs_Cgs: An array-like object of non negative numbers (int or + float) with shape (3,) representing the amplitudes of the + SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can + be a tuple, list, or ndarray. Values are converted to floats internally. + Each amplitude must be low enough that it doesn't drive its base value out + of the range of valid values. Otherwise, this SingleStepWingMovement will + try to create Wings with invalid parameter values. The units are in meters. + The default is (0.0, 0.0, 0.0). + :param periodLer_Gs_Cgs: An array-like object of non negative numbers (int or + float) with shape (3,) representing the periods of the + SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can + be a tuple, list, or ndarray. Values are converted to floats internally. + Each element must be 0.0 if the corresponding element in ampLer_Gs_Cgs is + 0.0 and non zero if not. The units are in seconds. The default is (0.0, + 0.0, 0.0). + :param spacingLer_Gs_Cgs: An array-like object of strs or callables with shape + (3,) representing the spacing of the SingleStepWingMovement's change in its + Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or ndarray. Each + element can be the str "sine", the str "uniform", or a callable custom + spacing function. Custom spacing functions are for advanced users and must + start at 0.0, return to 0.0 after one period of 2*pi radians, have + amplitude of 1.0, be periodic, return finite values only, and accept a + ndarray as input and return a ndarray of the same shape. The custom function + is scaled by ampLer_Gs_Cgs, shifted horizontally and vertically by + phaseLer_Gs_Cgs and the base value, and have a period set by + periodLer_Gs_Cgs. The default is ("sine", "sine", "sine"). + :param phaseLer_Gs_Cgs: An array-like object of numbers (int or float) with + shape (3,) representing the phase offsets of the elements in the first time + step's Wing's Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs + parameter. Can be a tuple, list, or ndarray. Values must lie in the range + (-180.0, 180.0] and will be converted to floats internally. Each element + must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and non + zero if not. The units are in degrees. The default is (0.0, 0.0, 0.0). + :param ampAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float) + with shape (3,) representing the amplitudes of the + SingleStepWingMovement's changes in its Wings' angles_Gs_to_Wn_ixyz + parameters. Can be a tuple, list, or ndarray. Values must lie in the range + [0.0, 180.0] and will be converted to floats internally. Each amplitude + must be low enough that it doesn't drive its base value out of the range of + valid values. Otherwise, this SingleStepWingMovement will try to create + Wings with invalid parameter values. The units are in degrees. The default + is (0.0, 0.0, 0.0). + :param periodAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or + float) with shape (3,) representing the periods of the + SingleStepWingMovement's changes in its Wings' angles_Gs_to_Wn_ixyz + parameters. Can be a tuple, list, or ndarray. Values are converted to + floats internally. Each element must be 0.0 if the corresponding element in + ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in + seconds. The default is (0.0, 0.0, 0.0). + :param spacingAngles_Gs_to_Wn_ixyz: An array-like object of strs or callables + with shape (3,) representing the spacing of the SingleStepWingMovement's + change in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, + or ndarray. Each element can be the str "sine", the str "uniform", or a + callable custom spacing function. Custom spacing functions are for advanced + users and must start at 0.0, return to 0.0 after one period of 2*pi + radians, have amplitude of 1.0, be periodic, return finite values only, and + accept a ndarray as input and return a ndarray of the same shape. The custom + function is scaled by ampAngles_Gs_to_Wn_ixyz, shifted horizontally and + vertically by phaseAngles_Gs_to_Wn_ixyz and the base value, with the period + set by periodAngles_Gs_to_Wn_ixyz. The default is ("sine", "sine", "sine"). - - :param phaseLer_Gs_Cgs: array-like of 3 numbers, optional - - The phase offsets of the elements in the first time step's Wing's - Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs parameter. - Can be a tuple, list, or numpy array of non-negative numbers (int or - float) in the range (-180.0, 180.0]. Values are converted to floats - internally. The default value is (0.0, 0.0, 0.0). Each element must be - 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and non-zero if - not. The units are in degrees. - - :param ampAngles_Gs_to_Wn_ixyz: array-like of 3 numbers, optional - - The amplitudes of the WingMovement's changes in its Wings' - angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or numpy array of - numbers (int or float) in the range [0.0, 180.0]. Also, each amplitude - must be low enough that it doesn't drive its base value out of the range - of valid values. Otherwise, this WingMovement will try to create Wings - with invalid parameters values. Values are converted to floats - internally. The default value is (0.0, 0.0, 0.0). The units are in degrees. - - :param periodAngles_Gs_to_Wn_ixyz: array-like of 3 numbers, optional - - The periods of the WingMovement's changes in its Wings' - angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or numpy array of - non-negative numbers (int or float). Values are converted to floats - internally. The default value is (0.0, 0.0, 0.0). Each element must be - 0.0 if the corresponding element in ampAngles_Gs_to_Wn_ixyz is 0.0 and - non-zero if not. The units are in seconds. - - :param spacingAngles_Gs_to_Wn_ixyz: array-like of 3 strs or callables, optional - - The value determines the spacing of the WingMovement's change in its - Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or numpy - array. Each element can be the string "sine", the string "uniform", - or a callable custom spacing function. Custom spacing functions are for - advanced users and must start at 0, return to 0 after one period of 2*pi - radians, have amplitude of 1, be periodic, return finite values only, - and accept a ndarray as input and return a ndarray of the same shape. - The custom function is scaled by ampAngles_Gs_to_Wn_ixyz, shifted - horizontally by phaseAngles_Gs_to_Wn_ixyz, and vertically by the base - value, with the period controlled by periodAngles_Gs_to_Wn_ixyz. The - default value is ("sine", "sine", "sine"). - - :param phaseAngles_Gs_to_Wn_ixyz: array-like of 3 numbers, optional - - The phase offsets of the elements in the first time step's Wing's - angles_Gs_to_Wn_ixyz parameter relative to the base Wing's - angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or numpy array of - numbers (int or float) in the range (-180.0, 180.0]. Values are converted - to floats internally. The default value is (0.0, 0.0, 0.0). Each element - must be 0.0 if the corresponding element in ampAngles_Gs_to_Wn_ixyz is - 0.0 and non-zero if not. The units are in degrees. + :param phaseAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or + float) with shape (3,) representing the phase offsets of the elements in + the first time step's Wing's angles_Gs_to_Wn_ixyz parameter relative to + the base Wing's angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or + ndarray. Values must lie in the range (-180.0, 180.0] and will be converted + to floats internally. Each element must be 0.0 if the corresponding element + in ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in + degrees. The default is (0.0, 0.0, 0.0). + :return: None """ self.wing_cross_section_movements = single_step_wing_cross_section_movements - ampLer_Gs_Cgs = threeD_number_vectorLike_return_float( - ampLer_Gs_Cgs, "ampLer_Gs_Cgs" - ) - if not np.all(ampLer_Gs_Cgs >= 0.0): - raise ValueError("All elements in ampLer_Gs_Cgs must be non-negative.") - self.ampLer_Gs_Cgs = ampLer_Gs_Cgs - - periodLer_Gs_Cgs = threeD_number_vectorLike_return_float( - periodLer_Gs_Cgs, "periodLer_Gs_Cgs" - ) - if not np.all(periodLer_Gs_Cgs >= 0.0): - raise ValueError("All elements in periodLer_Gs_Cgs must be non-negative.") - for period_index, period in enumerate(periodLer_Gs_Cgs): - amp = self.ampLer_Gs_Cgs[period_index] - if amp == 0 and period != 0: - raise ValueError( - "If an element in ampLer_Gs_Cgs is 0.0, the corresponding element in periodLer_Gs_Cgs must be also be 0.0." - ) - self.periodLer_Gs_Cgs = periodLer_Gs_Cgs - - spacingLer_Gs_Cgs = ( - threeD_spacing_vectorLike_return_tuple( - spacingLer_Gs_Cgs, - "spacingLer_Gs_Cgs", - ) - ) - self.spacingLer_Gs_Cgs = spacingLer_Gs_Cgs - - phaseLer_Gs_Cgs = threeD_number_vectorLike_return_float( - phaseLer_Gs_Cgs, "phaseLer_Gs_Cgs" - ) - if not (np.all(phaseLer_Gs_Cgs > -180.0) and np.all(phaseLer_Gs_Cgs <= 180.0)): - raise ValueError( - "All elements in phaseLer_Gs_Cgs must be in the range (-180.0, 180.0]." - ) - for phase_index, phase in enumerate(phaseLer_Gs_Cgs): - amp = self.ampLer_Gs_Cgs[phase_index] - if amp == 0 and phase != 0: - raise ValueError( - "If an element in ampLer_Gs_Cgs is 0.0, the corresponding element in phaseLer_Gs_Cgs must be also be 0.0." - ) - self.phaseLer_Gs_Cgs = phaseLer_Gs_Cgs - - ampAngles_Gs_to_Wn_ixyz = ( - threeD_number_vectorLike_return_float( - ampAngles_Gs_to_Wn_ixyz, "ampAngles_Gs_to_Wn_ixyz" - ) - ) - if not ( - np.all(ampAngles_Gs_to_Wn_ixyz >= 0.0) - and np.all(ampAngles_Gs_to_Wn_ixyz <= 180.0) - ): - raise ValueError( - "All elements in ampAngles_Gs_to_Wn_ixyz must be in the range [0.0, 180.0]." - ) - self.ampAngles_Gs_to_Wn_ixyz = ampAngles_Gs_to_Wn_ixyz - - periodAngles_Gs_to_Wn_ixyz = ( - threeD_number_vectorLike_return_float( - periodAngles_Gs_to_Wn_ixyz, "periodAngles_Gs_to_Wn_ixyz" - ) - ) - if not np.all(periodAngles_Gs_to_Wn_ixyz >= 0.0): - raise ValueError( - "All elements in periodAngles_Gs_to_Wn_ixyz must be non-negative." - ) - for period_index, period in enumerate(periodAngles_Gs_to_Wn_ixyz): - amp = self.ampAngles_Gs_to_Wn_ixyz[period_index] - if amp == 0 and period != 0: - raise ValueError( - "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, the corresponding element in periodAngles_Gs_to_Wn_ixyz must be also be 0.0." - ) - self.periodAngles_Gs_to_Wn_ixyz = periodAngles_Gs_to_Wn_ixyz - - spacingAngles_Gs_to_Wn_ixyz = ( - threeD_spacing_vectorLike_return_tuple( - spacingAngles_Gs_to_Wn_ixyz, - "spacingAngles_Gs_to_Wn_ixyz", - ) - ) - self.spacingAngles_Gs_to_Wn_ixyz = spacingAngles_Gs_to_Wn_ixyz - - phaseAngles_Gs_to_Wn_ixyz = ( - threeD_number_vectorLike_return_float( - phaseAngles_Gs_to_Wn_ixyz, "phaseAngles_Gs_to_Wn_ixyz" - ) - ) - if not ( - np.all(phaseAngles_Gs_to_Wn_ixyz > -180.0) - and np.all(phaseAngles_Gs_to_Wn_ixyz <= 180.0) - ): - raise ValueError( - "All elements in phaseAngles_Gs_to_Wn_ixyz must be in the range (-180.0, 180.0]." - ) - for phase_index, phase in enumerate(phaseAngles_Gs_to_Wn_ixyz): - amp = self.ampAngles_Gs_to_Wn_ixyz[phase_index] - if amp == 0 and phase != 0: - raise ValueError( - "If an element in ampAngles_Gs_to_Wn_ixyz is 0.0, the corresponding element in phaseAngles_Gs_to_Wn_ixyz must be also be 0.0." - ) - self.phaseAngles_Gs_to_Wn_ixyz = phaseAngles_Gs_to_Wn_ixyz - - # Create the corresponding WingMovement for this SingleStepWingMovement's base Wing, which - # will be used to generate the base Wing's WingCrossSections at each time step. This is - # done by using this SingleStepWingMovement's parameters and the corresponding parameters - # of its SingleStepWingCrossSectionMovements, which are stored in each - # SingleStepWingCrossSectionMovement's corresponding_wcs_movement attribute. - # This way, the user doesn't have to input redundant parameters for the base Wing's - # motion in both this SingleStepWingMovement and its SingleStepWingCrossSectionMovements. - corresponding_wcs_movement = [ - wcsm.corresponding_wcs_movement - for wcsm in single_step_wing_cross_section_movements + # Create the corresponding WingMovement, which validates all oscillation + # parameters and is also needed by coupled unsteady problems. + corresponding_wing_cross_section_movements = [ + wing_cross_section_movement.corresponding_wing_cross_section_movement + for wing_cross_section_movement in single_step_wing_cross_section_movements ] self.corresponding_wing_movement = WingMovement( base_wing=base_wing, - wing_cross_section_movements=corresponding_wcs_movement, + wing_cross_section_movements=corresponding_wing_cross_section_movements, ampLer_Gs_Cgs=ampLer_Gs_Cgs, periodLer_Gs_Cgs=periodLer_Gs_Cgs, spacingLer_Gs_Cgs=spacingLer_Gs_Cgs, @@ -291,26 +173,29 @@ def __init__( phaseAngles_Gs_to_Wn_ixyz=phaseAngles_Gs_to_Wn_ixyz, ) + # Copy validated attributes from the corresponding WingMovement. + self.ampLer_Gs_Cgs = self.corresponding_wing_movement.ampLer_Gs_Cgs + self.periodLer_Gs_Cgs = self.corresponding_wing_movement.periodLer_Gs_Cgs + self.spacingLer_Gs_Cgs = self.corresponding_wing_movement.spacingLer_Gs_Cgs + self.phaseLer_Gs_Cgs = self.corresponding_wing_movement.phaseLer_Gs_Cgs + self.ampAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.ampAngles_Gs_to_Wn_ixyz + self.periodAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.periodAngles_Gs_to_Wn_ixyz + self.spacingAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.spacingAngles_Gs_to_Wn_ixyz + self.phaseAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.phaseAngles_Gs_to_Wn_ixyz + self.listLer_Gs_Cgs = None self.listAngles_Gs_to_Wn_ixyz = None def generate_next_wing(self, base_wing, delta_time, num_steps, step, deformation_matrices): - """Creates the Wing at each time step, and returns them in a list. - - :param num_steps: int - - This is the number of time steps in this movement. It must be a positive - int. - - :param delta_time: number - - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - - :return: list of Wings - - This is the list of Wings associated with this WingMovement. + """Creates the Wing at a single time step. + + :param base_wing: The base Wing from which the new Wing will be created. + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps in this movement. + :param step: The index of the current time step. + :param deformation_matrices: Deformation matrices to apply to the + WingCrossSections, or None. + :return: The Wing at the specified time step. """ num_steps = int_in_range_return_int( num_steps, "num_steps", min_val=1, min_inclusive=True @@ -324,11 +209,11 @@ def generate_next_wing(self, base_wing, delta_time, num_steps, step, deformation # Generate oscillating values for each dimension of Ler_Gs_Cgs. if self.listLer_Gs_Cgs is None: - self._initialize_oscilating_dimensions(delta_time, num_steps, base_wing) + self._initialize_oscillating_dimensions(delta_time, num_steps, base_wing) # Generate oscillating values for each dimension of angles_Gs_to_Wn_ixyz. if self.listAngles_Gs_to_Wn_ixyz is None: - self._initialize_oscilating_angles(delta_time, num_steps, base_wing) + self._initialize_oscillating_angles(delta_time, num_steps, base_wing) # Create an empty 2D ndarray that will hold each of the Wings's # WingCrossSection's vector of WingCrossSections representing its changing @@ -395,20 +280,13 @@ def generate_next_wing(self, base_wing, delta_time, num_steps, step, deformation return this_wing - def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_wing): - """Initializes the oscillating dimensions for Ler_Gs_Cgs and - angles_Gs_to_Wn_ixyz. - - :param delta_time: number - - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. + def _initialize_oscillating_dimensions(self, delta_time, num_steps, base_wing): + """Pre computes the oscillating Ler_Gs_Cgs values for all time steps. - :param num_steps: int - - This is the number of time steps in this movement. It must be a positive - int. + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps. + :param base_wing: The base Wing providing the base Ler_Gs_Cgs values. + :return: None """ self.listLer_Gs_Cgs = np.zeros((3, num_steps), dtype=float) for dim in range(3): @@ -444,17 +322,13 @@ def _initialize_oscilating_dimensions(self, delta_time, num_steps, base_wing): else: raise ValueError(f"Invalid spacing value: {spacing}") - def _initialize_oscilating_angles(self, delta_time, num_steps, base_wing): - """Initializes the oscillating dimensions for angles_Gs_to_Wn_ixyz. - - :param delta_time: number + def _initialize_oscillating_angles(self, delta_time, num_steps, base_wing): + """Pre computes the oscillating angles_Gs_to_Wn_ixyz values for all time steps. - This is the time between each time step. It must be a positive number ( - int or float), and will be converted internally to a float. The units are - in seconds. - :param num_steps: int - This is the number of time steps in this movement. It must be a positive - int. + :param delta_time: The time between each time step in seconds. + :param num_steps: The total number of time steps. + :param base_wing: The base Wing providing the base angles_Gs_to_Wn_ixyz values. + :return: None """ self.listAngles_Gs_to_Wn_ixyz = np.zeros((3, num_steps), dtype=float) for dim in range(3): From b049af04c83d32e819267a748f212f7206defff4 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 25 Mar 2026 11:32:17 -0400 Subject: [PATCH 29/40] clean up and comment __init__ files --- pterasoftware/__init__.py | 7 +----- pterasoftware/_functions.py | 6 ++--- pterasoftware/movements/__init__.py | 2 +- .../movements/single_step/__init__.py | 23 +++++++++++++++++++ pterasoftware/output.py | 6 ++--- 5 files changed, 29 insertions(+), 15 deletions(-) diff --git a/pterasoftware/__init__.py b/pterasoftware/__init__.py index 92ac8c66c..c251524b2 100644 --- a/pterasoftware/__init__.py +++ b/pterasoftware/__init__.py @@ -15,7 +15,7 @@ convergence.py: Contains functions for analyzing the convergence of SteadyProblems and UnsteadyProblems. -unsteady_ring_vortex_lattice_method.py: Contains the +coupled_unsteady_ring_vortex_lattice_method.py: Contains the CoupledUnsteadyRingVortexLatticeMethodSolver class. operating_point.py: Contains the OperatingPoint class. @@ -48,11 +48,6 @@ import pterasoftware.operating_point import pterasoftware.problems -# Expose eagerly imported modules as attributes -geometry = pterasoftware.geometry -movements = pterasoftware.movements -operating_point = pterasoftware.operating_point -problems = pterasoftware.problems # Lazy imports configuration: modules loaded on first access. _LAZY_MODULES = { diff --git a/pterasoftware/_functions.py b/pterasoftware/_functions.py index 2c4ddc902..180b27ea7 100644 --- a/pterasoftware/_functions.py +++ b/pterasoftware/_functions.py @@ -147,7 +147,6 @@ def numba_centroid_of_quadrilateral( return np.array([x_average, y_average, z_average]) - def calculate_streamlines( solver: ( steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver @@ -261,9 +260,8 @@ def process_solver_loads( assert solver.operating_point is not None this_operating_point = solver.operating_point elif isinstance( - solver, ( - unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, - ) + solver, + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, ): assert solver.current_airplanes is not None these_airplanes = solver.current_airplanes diff --git a/pterasoftware/movements/__init__.py b/pterasoftware/movements/__init__.py index a444d56c5..35b1bf184 100644 --- a/pterasoftware/movements/__init__.py +++ b/pterasoftware/movements/__init__.py @@ -2,7 +2,7 @@ **Contains the following subpackages:** -None +single_step: Contains the single step movement classes. **Contains the following directories:** diff --git a/pterasoftware/movements/single_step/__init__.py b/pterasoftware/movements/single_step/__init__.py index 242d4d800..fbb9051cb 100644 --- a/pterasoftware/movements/single_step/__init__.py +++ b/pterasoftware/movements/single_step/__init__.py @@ -1,3 +1,26 @@ +"""Contains the single step movement classes. + +**Contains the following subpackages:** + +None + +**Contains the following directories:** + +None + +**Contains the following modules:** + +single_step_airplane_movement.py: Contains the SingleStepAirplaneMovement class. + +single_step_operating_point_movement.py: Contains the SingleStepOperatingPointMovement class. + +single_step_movement.py: Contains the SingleStepMovement class. + +single_step_wing_cross_section_movement.py: Contains the SingleStepWingCrossSectionMovement class. + +single_step_wing_movement.py: Contains the SingleStepWingMovement class. +""" + import pterasoftware.movements.single_step.single_step_airplane_movement import pterasoftware.movements.single_step.single_step_operating_point_movement import pterasoftware.movements.single_step.single_step_movement diff --git a/pterasoftware/output.py b/pterasoftware/output.py index 8fdcbf096..a0fbab89c 100644 --- a/pterasoftware/output.py +++ b/pterasoftware/output.py @@ -451,7 +451,7 @@ def draw( def animate( - unsteady_solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + unsteady_solver: unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, scalar_type: str | None = None, show_wake_vortices: bool | np.bool_ = False, save: bool | np.bool_ = False, @@ -900,9 +900,7 @@ def plot_results_versus_time( """ if not isinstance( unsteady_solver, - ( - unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, - ) + unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, ): raise TypeError( "unsteady_solver must be an " "UnsteadyRingVortexLatticeMethodSolver." From 82463cb5fe379bc44dd5b557268fa9765b02d702 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 25 Mar 2026 11:34:20 -0400 Subject: [PATCH 30/40] clean up little typos --- examples/unsteady_ring_vortex_lattice_method_solver_variable.py | 2 +- pterasoftware/geometry/airplane.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/unsteady_ring_vortex_lattice_method_solver_variable.py b/examples/unsteady_ring_vortex_lattice_method_solver_variable.py index 6db559642..32f8b05d1 100644 --- a/examples/unsteady_ring_vortex_lattice_method_solver_variable.py +++ b/examples/unsteady_ring_vortex_lattice_method_solver_variable.py @@ -58,7 +58,7 @@ symmetryNormal_G=(0.0, 1.0, 0.0), symmetryPoint_G_Cg=(0.0, 0.0, 0.0), num_chordwise_panels=6, - chordwise_spacing="cosine", + chordwise_spacing="uniform", ), ps.geometry.wing.Wing( wing_cross_sections=[ diff --git a/pterasoftware/geometry/airplane.py b/pterasoftware/geometry/airplane.py index 33f88e132..da8c5f559 100644 --- a/pterasoftware/geometry/airplane.py +++ b/pterasoftware/geometry/airplane.py @@ -19,7 +19,6 @@ import numpy as np import pyvista as pv import webp -import pterasoftware as ps from .. import _parameter_validation, _transformations from . import wing as wing_mod From 0af66bcfb47297117d338aa46b89f6520d50ab7b Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 25 Mar 2026 13:24:36 -0400 Subject: [PATCH 31/40] clean up problems.py --- ...oupled_unsteady_first_order_deformation.py | 2 +- pterasoftware/problems.py | 707 ++++++++++++++---- 2 files changed, 562 insertions(+), 147 deletions(-) diff --git a/examples/coupled_unsteady_first_order_deformation.py b/examples/coupled_unsteady_first_order_deformation.py index d69e8bb6a..49ab199ea 100644 --- a/examples/coupled_unsteady_first_order_deformation.py +++ b/examples/coupled_unsteady_first_order_deformation.py @@ -335,7 +335,7 @@ example_problem = ps.problems.AeroelasticUnsteadyProblem( single_step_movement=single_step_movement, wing_density=0.012, - spring_constant=15.0, + spring_constant=20.0, damping_constant=1.0, aero_scaling=1.0, moment_scaling_factor=1.0, diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index b62082040..732ba6b16 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -274,14 +274,13 @@ def __init__( airplanes=these_airplanes, operating_point=this_operating_point ) - # Append this SteadyProblem to the list of SteadyProblems. # Append this SteadyProblem to the temporary list. steady_problems_temp.append(this_steady_problem) # Store as tuple to prevent external mutation via .append(), .pop(), etc. self._steady_problems: tuple[SteadyProblem, ...] = tuple(steady_problems_temp) - # --- Immutable: read only properties --- + # --- Immutable: read only properties --- @property def movement(self) -> movements.movement.Movement: return self._movement @@ -314,10 +313,11 @@ def max_wake_rows(self) -> int | None: def steady_problems(self) -> tuple[SteadyProblem, ...]: return self._steady_problems + class CoupledUnsteadyProblem(UnsteadyProblem): """A class for coupled unsteady problems. - This class extends UnsteadyProblem to manage multiple SteadyProblems for + This class extends UnsteadyProblem to manage multiple SteadyProblems for coupled simulations where each time step has its own SteadyProblem. **Contains the following methods:** @@ -330,18 +330,29 @@ class CoupledUnsteadyProblem(UnsteadyProblem): None """ - def __init__(self, single_step_movement, only_final_results=False): + def __init__( + self, + single_step_movement: movements.single_step.single_step_movement.SingleStepMovement, + only_final_results: bool | np.bool_ = False, + ) -> None: """The initialization method. - :param single_step_movement: SingleStepMovement containing the movement - and single-step aerodynamic definitions. - :param only_final_results: If True, only calculate forces/moments for the - final cycle. Defaults to False. + Initializes the aeroelastic problem with structural parameters and motion definitions. + Sets up storage for aerodynamic loads, wing deformations, moments, and solver state. + + :param single_step_movement: A SingleStepMovement object containing the prescribed + motion and aerodynamic setup for the coupled simulation. + :param only_final_results: If True, only calculate forces and moments for the final + motion cycle. Can be a bool or numpy bool and will be converted to bool internally. + The default is False. :return: None """ - if not isinstance(single_step_movement, movements.single_step.single_step_movement.SingleStepMovement): + if not isinstance( + single_step_movement, + movements.single_step.single_step_movement.SingleStepMovement, + ): raise TypeError("single_step_movement must be a SingleStepMovement.") - + self.single_step_movement = single_step_movement movement = single_step_movement.corresponding_movement only_final_results_bool = _parameter_validation.boolLike_return_bool( @@ -375,30 +386,99 @@ def get_steady_problem(self, step: int) -> SteadyProblem: return self.coupled_steady_problems[step] def initialize_next_problem(self, solver) -> None: - """Initialize the next step's problem. - - :param solver: The solver instance managing this problem. + """Initialize the next time step's problem with updated wing deformations. + + Computes cumulative wing deformations from aerodynamic and inertial loads, + then creates the next SteadyProblem with deformed airplanes. Updates the + current airplane and operating point state. + + :param solver: The solver instance providing aerodynamic moment data. :return: None """ self.coupled_steady_problems.append( self.steady_problems[len(self.coupled_steady_problems)] ) + class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): + """A subclass of CoupledUnsteadyProblem used to couple aeroelastic wing deformations with unsteady aerodynamics. + + This class couples aerodynamic loads with wing structural dynamics (spring-mass-damper system) to + simulate aeroelastic deformation. Each time step, wing deformations are calculated based on the + combined effects of aerodynamic moments, inertial forces, and spring-damper restoring forces. + + **Contains the following methods:** + + calculate_wing_panel_accelerations: Computes panel accelerations from finite difference of positions. + + calculate_mass_matrix: Generates the mass distribution matrix for wing panels. + + calculate_wing_deformation: Computes cumulative wing deformation for the current step. + + calculate_spring_moments: Calculates spring-damper moments acting on each spanwise section. + + calculate_torsional_spring_moment: Solves the torsional spring-damper ODE for a single span section. + + generate_inertial_torque_function: Creates a torque function from prescribed wing motion. + + spring_numerical_ode: Numerically integrates the spring-damper differential equation. + + plot_flap_cycle_curves: Visualizes moment and deformation time histories. + + **Notes:** + + The aeroelastic coupling assumes a torsional spring-mass-damper model for each spanwise section. + Wing motion is prescribed through wing flapping, and aerodynamic moments from the solver are + combined with inertial and spring restoring forces via ODE integration to produce structural + deformations. + """ + def __init__( self, single_step_movement: SingleStepMovement, - wing_density, - spring_constant, - damping_constant, - aero_scaling=1.0, - moment_scaling_factor=1.0, - damping_eps=1e-3, - plot_flap_cycle=False, + wing_density: float, + spring_constant: float, + damping_constant: float, + aero_scaling: float = 1.0, + moment_scaling_factor: float = 1.0, + damping_eps: float = 1e-3, + plot_flap_cycle: bool = False, custom_spacing_second_derivative=None, - only_final_results=False, - ): - super().__init__(single_step_movement=single_step_movement, only_final_results=only_final_results) + only_final_results: bool | np.bool_ = False, + ) -> None: + """The initialization method. + + Sets up the aeroelastic problem with structural parameters for the torsional + spring-mass-damper model applied to each wing spanwise section. Initializes + storage for aerodynamic loads, deformations, moments, and solver state. + + See CoupledUnsteadyProblem's initialization method for descriptions of inherited + parameters (single_step_movement and only_final_results). + + :param wing_density: The mass per unit span area of the wing (kg/m^2). Used + to distribute wing mass across panels for inertial calculations. + :param spring_constant: The torsional spring stiffness for the spring-mass-damper + model (N*m/rad). Controls the restoring torque opposing deformation. + :param damping_constant: The torsional damping coefficient (N*m*s/rad). Controls + the viscous damping in the spring-mass-damper system. + :param aero_scaling: A scaling factor applied to aerodynamic moments (unitless). + The default is 1.0. Use values less than 1 to reduce aerodynamic influence. + :param moment_scaling_factor: A scaling factor applied to the computed wing + deformation angles (unitless). The default is 1.0. Useful for adjusting the + magnitude of structural response. + :param damping_eps: The critical damping tolerance used for diagnostics (unitless). + The default is 1e-3. This parameter is not currently used in the solver. + :param plot_flap_cycle: If True, plots time histories of moments and deformations + at the end of the simulation. The default is False. + :param custom_spacing_second_derivative: An optional callable function of time + that returns the second time derivative of a custom wing motion spacing function. + Required if custom (non-sinusoidal) wing motion spacing is used. The default is None. + :return: None + """ + super().__init__( + single_step_movement=single_step_movement, + only_final_results=only_final_results, + ) self.plot_flap_cycle = plot_flap_cycle self.prev_velocities = [] self.curr_airplanes = [self.movement.airplane_movements[0].base_airplane] @@ -415,9 +495,21 @@ def __init__( self.spring_constant = spring_constant self.damping_constant = damping_constant self.aero_scaling = aero_scaling - self.numerical_integration = True # use numerical integration or closed form solution self.damping_eps = damping_eps # critical damping tolerance + # Permanent parameters + self.step_discards = ( + 5 # number of initial steps to discard for numerical stability + ) + self.spacing = ( + self.single_step_movement.airplane_movements[0] + .wing_movements[0] + .spacingAngles_Gs_to_Wn_ixyz[0] + ) + self.wing_movement = self.single_step_movement.airplane_movements[ + 0 + ].wing_movements[0] + self.per_step_data = [] self.net_data = [] self.angluar_velocity_data = [] @@ -430,18 +522,40 @@ def __init__( # For custom spacing defined in movement. self.custom_spacing_second_derivative = custom_spacing_second_derivative - def calculate_wing_panel_accelerations(self): + def calculate_wing_panel_accelerations(self) -> np.ndarray: + """Compute panel accelerations using finite difference of stored positions. + + Calculates second-order accelerations using the finite difference formula: + a = (p[n] - 2*p[n-1] + p[n-2]) / dt^2. + + :return: An (N_chordwise, N_spanwise, 3) ndarray of floats representing panel + center accelerations in the global frame. Returns zeros if fewer than 3 + position snapshots are available. + """ if len(self.positions) <= 2: return np.zeros_like(self.positions[0]) dt = self.movement.delta_time - return (self.positions[-1] - 2 * self.positions[-2] + self.positions[-3]) / (dt * dt) + # If given a relatively large dt value, the finite difference calculation can produce + # very large accelerations that cause numerical instability in the spring ODE integration. + # A higher order model may be useful if this is the case. + return (self.positions[-1] - 2 * self.positions[-2] + self.positions[-3]) / ( + dt * dt + ) + + def calculate_mass_matrix(self, wing: geometry.wing.Wing) -> np.ndarray: + """Generate the mass distribution matrix for all wing panels. + + Distributes the total spanwise mass (wing_density) across panel areas to form + a panel-by-panel mass matrix. Each panel's mass is proportional to its area + times the specified wing_density. - def calculate_mass_matrix(self, wing): + :param wing: A Wing object whose panels define the mass distribution. + :return: An (N_chordwise, N_spanwise, 3) ndarray of floats representing the + mass at each panel. The three components are identical (mass scalar replicated + for x, y, z axes). + """ areas = np.array([[panel.area for panel in row] for row in wing.panels]) - return ( - np.repeat(areas[:, :, None], 3, axis=2) - * self.wing_density - ) + return np.repeat(areas[:, :, None], 3, axis=2) * self.wing_density def initialize_next_problem(self, solver): @@ -463,44 +577,46 @@ def initialize_next_problem(self, solver): ) ) - def calculate_wing_deformation(self, solver, step): + def calculate_wing_deformation( + self, + solver, + step: int, + ) -> np.ndarray: + """Compute cumulative wing deformation for the current time step. + + Orchestrates the calculation of inertial moments, spring moments, and + cumulative deformation. Updates internal state and optionally generates plots. + + :param solver: The solver instance providing aerodynamic moment data (moments_GP1_Slep). + :param step: The current time step index (0-indexed). + :return: An (N_spanwise+1, 3) ndarray of floats representing cumulative deformation + angles at each spanwise station. The y-component (index 1) contains torsional + angles in radians; x and z components are zero. + """ curr_problem: SteadyProblem = self.coupled_steady_problems[-1] airplane = curr_problem.airplanes[0] - wing: geometry.wing.Wing = airplane.wings[0] - # Panel number definitions + # Compute panel parameters and mass matrix once num_chordwise_panels = wing.num_chordwise_panels num_spanwise_panels = wing.num_spanwise_panels num_panels = num_chordwise_panels * num_spanwise_panels + mass_matrix = self.calculate_mass_matrix(wing) + + # Initialize deformation state if needed if self.net_deformation is None: self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) self.angluar_velocities = np.zeros((num_spanwise_panels + 1, 3)) - aeroMoments_GP1_Slep = np.array(solver.moments_GP1_Slep[:num_panels]).reshape( - num_chordwise_panels, num_spanwise_panels, 3 - ) * self.aero_scaling - - self.positions.append(np.array( - [[panel.Cpp_GP1_CgP1 for panel in row] for row in wing.panels] - )) - - mass_matrix = self.calculate_mass_matrix(wing) - - inertial_forces = ( - self.calculate_wing_panel_accelerations() - * mass_matrix + # Extract aerodynamic and inertial moments + aeroMoments_GP1_Slep = self._extract_aero_moments( + solver, num_chordwise_panels, num_spanwise_panels, num_panels ) - - inertial_moments = np.cross( - self.positions[-1] - solver.stack_leading_edge_points[:num_panels].reshape((num_chordwise_panels, num_spanwise_panels, 3)), inertial_forces, axis=2 - ) - - undeforemed_wing = self.steady_problems[step].airplanes[0].wings[0] - undeformed_postions = np.array( - [[panel.Cpp_GP1_CgP1 for panel in row] for row in undeforemed_wing.panels] + inertial_moments = self._calculate_inertial_moments( + solver, wing, mass_matrix, num_chordwise_panels, num_spanwise_panels, num_panels ) + # Calculate spring moments and deformation via ODE integration thetas, omegas, spring_moments = self.calculate_spring_moments( num_spanwise_panels=num_spanwise_panels, wing=wing, @@ -508,15 +624,109 @@ def calculate_wing_deformation(self, solver, step): aero_moments=aeroMoments_GP1_Slep, step=step, ) - self.angluar_velocities[:, 1] = omegas - if self.base_wing_positions is None: - self.base_wing_positions = np.array(undeformed_postions) - self.flap_points.append(np.array(undeformed_postions) - self.base_wing_positions) - self.per_step_inertial.append(inertial_moments.copy()) - self.per_step_aero.append(aeroMoments_GP1_Slep.copy()) - self.per_step_spring.append(spring_moments.copy()) + # Build deformation vector and update state + step_deformation = self._build_deformation_vector(thetas, num_spanwise_panels) + self._apply_moment_updates( + step=step, + step_deformation=step_deformation, + omegas=omegas, + inertial_moments=inertial_moments, + aeroMoments_GP1_Slep=aeroMoments_GP1_Slep, + spring_moments=spring_moments, + wing=wing, + ) + + # Plot results at end of simulation if enabled + if self.plot_flap_cycle and step == self.num_steps - 1: + self._plot_aeroelastic_results() + + return self.net_deformation + + def _extract_aero_moments( + self, + solver, + num_chordwise_panels: int, + num_spanwise_panels: int, + num_panels: int, + ) -> np.ndarray: + """Extract and scale aerodynamic moments from the solver output. + + Uses the strip leading edge points as the reference point for moment + calculations, consistent with the assumption of a torsional spring at the + leading edge. + + :param solver: The solver instance with moments_GP1_Slep data. + :param num_chordwise_panels: Number of chordwise panel rows. + :param num_spanwise_panels: Number of spanwise panel rows. + :param num_panels: Total number of panels (num_chordwise * num_spanwise). + :return: An (N_chordwise, N_spanwise, 3) ndarray of scaled aerodynamic moments + in the global panel frame. + """ + aeroMoments_GP1_Slep = ( + np.array(solver.moments_GP1_Slep[:num_panels]).reshape( + num_chordwise_panels, num_spanwise_panels, 3 + ) + * self.aero_scaling + ) + return aeroMoments_GP1_Slep + + def _calculate_inertial_moments( + self, + solver, + wing: geometry.wing.Wing, + mass_matrix: np.ndarray, + num_chordwise_panels: int, + num_spanwise_panels: int, + num_panels: int, + ) -> np.ndarray: + """Calculate inertial moments from panel accelerations and mass distribution. + + Computes panel accelerations via finite difference, multiplies by mass to get + forces, then calculates moments about the leading edge reference point using + cross products. + + :param solver: The solver instance providing leading edge point positions. + :param wing: The Wing object containing panel definitions. + :param mass_matrix: An (N_chordwise, N_spanwise, 3) ndarray of panel masses. + :param num_chordwise_panels: Number of chordwise panel rows. + :param num_spanwise_panels: Number of spanwise panel rows. + :param num_panels: Total number of panels (num_chordwise * num_spanwise). + :return: An (N_chordwise, N_spanwise, 3) ndarray of inertial moment vectors. + """ + # Store current panel center positions + self.positions.append( + np.array([[panel.Cpp_GP1_CgP1 for panel in row] for row in wing.panels]) + ) + + # Calculate panel accelerations and inertial forces + inertial_forces = self.calculate_wing_panel_accelerations() * mass_matrix + + # Calculate moments about leading edge points via cross product + inertial_moments = np.cross( + self.positions[-1] + - solver.stack_leading_edge_points[:num_panels].reshape( + (num_chordwise_panels, num_spanwise_panels, 3) + ), + inertial_forces, + axis=2, + ) + return inertial_moments + + def _build_deformation_vector( + self, thetas: np.ndarray, num_spanwise_panels: int + ) -> np.ndarray: + """Construct the step deformation vector from torsional angles. + + Converts the torsional angles output from the spring-damper ODE (one per + spanwise section) into a full (N_spanwise+1, 3) deformation vector with + scaling applied to the y-component (torsional angle). + :param thetas: An (N_spanwise+1,) ndarray of torsional angles in radians. + :param num_spanwise_panels: Number of spanwise panel rows. + :return: An (N_spanwise+1, 3) ndarray with zero-valued x and z components and + scaled torsional angles in the y component. + """ step_deformation = np.array( [ np.array( @@ -529,47 +739,161 @@ def calculate_wing_deformation(self, solver, step): for i in range(num_spanwise_panels) ] ) + step_deformation = np.insert(step_deformation, 0, np.array([0, 0, 0]), axis=0) + return step_deformation + + def _apply_moment_updates( + self, + step: int, + step_deformation: np.ndarray, + omegas: np.ndarray, + inertial_moments: np.ndarray, + aeroMoments_GP1_Slep: np.ndarray, + spring_moments: np.ndarray, + wing: geometry.wing.Wing, + ) -> None: + """Update internal moment and deformation state arrays. + + Stores per-step moment and deformation data, updates the cumulative net + deformation (with discarding of early unstable steps), and tracks wing + deflection points relative to the undeformed baseline. + + :param step: The current time step index. + :param step_deformation: The (N_spanwise+1, 3) deformation vector for this step. + :param omegas: An (N_spanwise+1,) ndarray of angular velocities. + :param inertial_moments: An (N_chordwise, N_spanwise, 3) ndarray of inertial moments. + :param aeroMoments_GP1_Slep: An (N_chordwise, N_spanwise, 3) ndarray of aero moments. + :param spring_moments: An (N_spanwise, 3) ndarray of spring-damper moments. + :param wing: The Wing object for accessing undeformed geometry. + :return: None + """ + # Update angular velocity state + self.angluar_velocities[:, 1] = omegas + + # Initialize baseline wing positions for flap point tracking + undeformed_wing = self.steady_problems[step].airplanes[0].wings[0] + undeformed_positions = np.array( + [[panel.Cpp_GP1_CgP1 for panel in row] for row in undeformed_wing.panels] + ) + if self.base_wing_positions is None: + self.base_wing_positions = np.array(undeformed_positions) + + # Track wing deflection relative to undeformed baseline + self.flap_points.append( + np.array(undeformed_positions) - self.base_wing_positions + ) - step_deformation = np.insert(step_deformation, 0, np.array([0,0,0]), axis=0) - if step > 5: + # Store per-step moment components for later analysis/plotting + self.per_step_inertial.append(inertial_moments.copy()) + self.per_step_aero.append(aeroMoments_GP1_Slep.copy()) + self.per_step_spring.append(spring_moments.copy()) + + # Update cumulative deformation (with numerical stability discarding) + # Accounts for numerical instability causing large aerodynamic forces in initial steps + if step > self.step_discards: self.net_deformation = step_deformation + # Store deformation and angular velocity history self.per_step_data.append(step_deformation) self.net_data.append(self.net_deformation.copy()) self.angluar_velocity_data.append(self.angluar_velocities.copy()) - if self.plot_flap_cycle and step == self.num_steps - 1: - zero_curve = np.zeros((1, np.array(self.per_step_inertial).shape[0])) - self.plot_flap_cycle_curves(np.array(self.per_step_data)[:, :, 1].T.tolist(), "Per Step Deformation") - self.plot_flap_cycle_curves(np.array(self.net_data)[:, :, 1].T.tolist(), "Net Deformation") - self.plot_flap_cycle_curves(np.vstack((zero_curve, np.array(self.per_step_inertial)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Inertial Moments") - self.plot_flap_cycle_curves(np.vstack((zero_curve, np.array(self.per_step_aero)[:, :, :, 2].sum(axis=1).T)).tolist(), "Per Step Aero Moments") - self.plot_flap_cycle_curves(np.vstack((zero_curve, np.array(self.per_step_spring)[:, :, 2].sum(axis=1).T)).tolist(), "Per Step Spring Moments") - self.plot_flap_cycle_curves( - np.vstack( - ( - zero_curve, - np.array(self.flap_points)[:, :, :, 2].sum(axis=1).T, - ) - ).tolist(), - "Flap Points Z", - ) + def _plot_aeroelastic_results(self) -> None: + """Generate and display time-history plots of aeroelastic results. - return self.net_deformation + Creates plots of per-step and cumulative deformations, moment components + (inertial, aerodynamic, spring), and wing deflection points. Useful for + visualizing the aeroelastic coupling behavior. + + :return: None + """ + zero_curve = np.zeros((1, np.array(self.per_step_inertial).shape[0])) + + # Deformation time histories + self.plot_flap_cycle_curves( + np.array(self.per_step_data)[:, :, 1].T.tolist(), "Per Step Deformation" + ) + self.plot_flap_cycle_curves( + np.array(self.net_data)[:, :, 1].T.tolist(), "Net Deformation" + ) + + # Moment component time histories + self.plot_flap_cycle_curves( + np.vstack( + ( + zero_curve, + np.array(self.per_step_inertial)[:, :, :, 2].sum(axis=1).T, + ) + ).tolist(), + "Per Step Inertial Moments", + ) + self.plot_flap_cycle_curves( + np.vstack( + (zero_curve, np.array(self.per_step_aero)[:, :, :, 2].sum(axis=1).T) + ).tolist(), + "Per Step Aero Moments", + ) + self.plot_flap_cycle_curves( + np.vstack( + (zero_curve, np.array(self.per_step_spring)[:, :, 2].sum(axis=1).T) + ).tolist(), + "Per Step Spring Moments", + ) - def calculate_spring_moments(self, num_spanwise_panels, wing, mass_matrix, aero_moments, step): + # Wing deflection tracking + self.plot_flap_cycle_curves( + np.vstack( + ( + zero_curve, + np.array(self.flap_points)[:, :, :, 2].sum(axis=1).T, + ) + ).tolist(), + "Flap Points Z", + ) + + def calculate_spring_moments( + self, + num_spanwise_panels: int, + wing: geometry.wing.Wing, + mass_matrix: np.ndarray, + aero_moments: np.ndarray, + step: int, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Calculate spring-damper moments and angular states for each spanwise section. + + Solves the torsional spring-damper ODE independently for each spanwise section, + accounting for aerodynamic moments, inertial forces, and structural properties. + Uses the parallel axis theorem to compute rotational inertia about the flapping axis. + + :param num_spanwise_panels: Number of spanwise panel rows in the wing. + :param wing: The Wing object containing geometric and structural definitions. + :param mass_matrix: An (N_chordwise, N_spanwise, 3) ndarray of panel masses. + :param aero_moments: An (N_chordwise, N_spanwise, 3) ndarray of aerodynamic moments + from the aerodynamic solver. + :param step: The current time step index. + :return: A tuple of three ndarrays: + - thetas: (N_spanwise+1,) ndarray of torsional angles (radians) at each station. + - omegas: (N_spanwise+1,) ndarray of angular velocities (rad/s) at each station. + - spring_moments: (N_spanwise, 3) ndarray of spring-damper moment vectors. + + **Notes:** + + The rotational inertia is computed as: I = (1/12)*M*(L^2 + W^2) + M*d^2, + where M is panel mass, L is chord, W is span width, and d is distance from + the flapping axis (computed cumulatively using the parallel axis theorem). + """ spring_moments = np.zeros((num_spanwise_panels, 3)) thetas = np.zeros(num_spanwise_panels + 1) omegas = np.zeros(num_spanwise_panels + 1) - d = 0.0 # distance from flapping axis to panel centroid + d = 0.0 # distance from flapping axis to panel centroid (computed in half-span increments) for span_panel in range(num_spanwise_panels): aero_span_moment = np.sum(aero_moments[:, span_panel, 2]) if span_panel == 0: theta0 = 0.0 omega0 = 0.0 else: - theta0 = self.net_deformation[span_panel][1] - omega0 = self.angluar_velocities[span_panel][1] + theta0 = self.net_deformation[span_panel][1] + omega0 = self.angluar_velocities[span_panel][1] dt = self.movement.delta_time mass = mass_matrix[:, span_panel, :].sum() @@ -583,14 +907,13 @@ def calculate_spring_moments(self, num_spanwise_panels, wing, mass_matrix, aero_ ) / 2 W = np.linalg.norm(wing.panels[0][span_panel].frontLeg_G) d += W / 2 - span_I = 1/12 * mass * (L ** 2 + W ** 2) + mass * (d ** 2) - # span_I = d * mass * L + span_I = 1 / 12 * mass * (L**2 + W**2) + mass * (d**2) theta, omega, moment = self.calculate_torsional_spring_moment( dt, - # 1/2 * M * L^2 + # A potential knob to tweak in representation of the torsional inertia # I=mass * (wing.wing_cross_sections[span_panel].chord ** 2) / 2, - # I= 4/3 * mass * (L ** 2), - I=span_I, + I= 1/2 * mass * (L ** 2), + # I=span_I, theta0=theta0, omega0=omega0, aero_span_moment=aero_span_moment, @@ -605,69 +928,139 @@ def calculate_spring_moments(self, num_spanwise_panels, wing, mass_matrix, aero_ return thetas, omegas, spring_moments def calculate_torsional_spring_moment( - self, dt, I, theta0, omega0, aero_span_moment, step, span_I, num_steps=2, - ): + self, + dt: float, + I: float, + theta0: float, + omega0: float, + aero_span_moment: float, + step: int, + span_I: float, + num_steps: int = 2, + ) -> tuple[float, float, float]: + """Solve the torsional spring-damper ODE for a single wing section. + + Integrates the forced torsional damped harmonic oscillator equation: + I*dω/dt = τ_aero + τ_inertial - k*θ - c*ω + + Returns the angular displacement and velocity at the end of the time step, + along with the spring-damper restoring moment. + + :param dt: The time step duration (seconds). + :param I: The rotational inertia about the flapping axis (kg*m^2). + :param theta0: Initial torsional angle at the start of the time step (radians). + :param omega0: Initial angular velocity at the start of the time step (rad/s). + :param aero_span_moment: The z-component aerodynamic moment summed over chordwise + panels for this spanwise section (N*m). + :param step: The current time step index (used for inertial torque evaluation). + :param span_I: The rotational inertia including parallel axis theorem (kg*m^2). + This is the actual inertia used in the ODE solver. + :param num_steps: Number of time sub-steps for numerical integration. The default is 2. + :return: A tuple of (theta, omega, spring_moment) where: + - theta: Final torsional angle (radians). + - omega: Final angular velocity (rad/s). + - spring_moment: The z-component spring-damper moment τ = -k*θ - c*ω (N*m). + """ k = self.spring_constant c = self.damping_constant - t = np.linspace(dt * (step - 1), dt * step, num_steps) - # ---- Forced numerical integration ---- - theta, omega = self.spring_numerical_ode(t, k, c, I, theta0, omega0, aero_span_moment, self.generate_inertial_torque_function(span_I)) + # Forced numerical integration of the spring-damper ODE + theta, omega = self.spring_numerical_ode( + t, + k, + c, + I, + theta0, + omega0, + aero_span_moment, + self.generate_inertial_torque_function(span_I), + ) - # ---- Internal spring-damper moment ---- + # Internal spring-damper moment (restoring force from structural springs/dampers) spring_moment = -k * theta - c * omega - # ---- Net moment (optional, depending on sign convention) ---- - # net_moment = spring_moment + tau(t) - return theta, omega, spring_moment - def generate_inertial_torque_function(self, span_I): - """ - Docstring for generate_inertial_torque_function - - :param span_I: float - The rotational inertia of the wing span section about alpha (the flapping axis). + def generate_inertial_torque_function(self, span_I: float): + """Generate the prescribed wing motion inertial torque function. + + Extracts the prescribed flapping motion from the wing_movement definition and + creates a callable inertial torque function τ_inertial = I * d²θ_prescribed/dt². + Supports sinusoidal and custom spacing functions. + + :param span_I: The rotational inertia of the wing span section about the flapping + axis (kg*m^2). + :return: A callable function that accepts time and returns the inertial torque + (N*m) due to the prescribed wing motion acceleration. + + **Notes:** + + For sinusoidal spacing: τ = -I * b^2 * sin(b*t + h) * A, + where b = 2π/period, h = phase, A = amplitude. + For custom spacing, requires custom_spacing_second_derivative to be defined. """ - spacing = ( - self.single_step_movement.airplane_movements[0] - .wing_movements[0] - .spacingAngles_Gs_to_Wn_ixyz[0] - ) - wing_movement = self.single_step_movement.airplane_movements[0].wing_movements[0] - amp = wing_movement.ampAngles_Gs_to_Wn_ixyz[0] - b = 2 * np.pi / wing_movement.periodAngles_Gs_to_Wn_ixyz[0] - h = np.deg2rad(wing_movement.phaseAngles_Gs_to_Wn_ixyz[0]) - if spacing == "sine": - torque_func = lambda time: -1 * (b ** 2) * np.sin(b * time + h) * amp * span_I - elif spacing == "uniform": - raise ValueError("Sawtooth function (uniform spacing) is not differentiable, cannot be used for inertial torque function.") - elif callable(spacing): + amp = self.wing_movement.ampAngles_Gs_to_Wn_ixyz[0] + b = 2 * np.pi / self.wing_movement.periodAngles_Gs_to_Wn_ixyz[0] + h = np.deg2rad(self.wing_movement.phaseAngles_Gs_to_Wn_ixyz[0]) + if self.spacing == "sine": + torque_func = lambda time: -1 * (b**2) * np.sin(b * time + h) * amp * span_I + elif self.spacing == "uniform": + raise ValueError( + "Sawtooth function (uniform spacing) is not differentiable, " + "cannot be used for inertial torque function." + ) + elif callable(self.spacing): if self.custom_spacing_second_derivative is not None: - torque_func = lambda time: self.custom_spacing_second_derivative(time) * span_I + torque_func = ( + lambda time: self.custom_spacing_second_derivative(time) * span_I + ) else: - raise ValueError("Custom spacing function provided without second derivative function for inertial torque calculation.") - - return torque_func - - def spring_numerical_ode(self, t, k, c, I, theta0, omega0, aero_torque, inertial_torque_func): - """ Numerical solution to the spring-damper ODE with arbitrary torque input. - t: numpy.ndarray - Time array. - k: float - Spring constant. - c: float - Damping constant. - I: float - Rotational inertia. - theta0: float - Initial angular displacement. - omega0: float - Initial angular velocity.""" - def tau(time): - return aero_torque + inertial_torque_func(time) - def ode(time, y): + raise ValueError( + "Custom spacing function provided without second derivative function " + "for inertial torque calculation." + ) + + return torque_func + + def spring_numerical_ode( + self, + t: np.ndarray, + k: float, + c: float, + I: float, + theta0: float, + omega0: float, + aero_torque: float, + inertial_torque_func, + ) -> tuple[float, float]: + """Numerically integrate the torsional spring-damper ODE. + + Solves the second-order forced ODE: + I * d²θ/dt² = τ_aero + τ_inertial(t) - k*θ - c*dθ/dt + + using scipy.integrate.solve_ivp with strict tolerances. + + :param t: A (N,) ndarray of time points for integration evaluation. + :param k: Spring constant (N*m/rad). + :param c: Damping constant (N*m*s/rad). + :param I: Rotational inertia (kg*m^2). This parameter is present for API consistency + but is not used in the ODE solver; use only the solution state. + :param theta0: Initial angular displacement (radians). + :param omega0: Initial angular velocity (rad/s). + :param aero_torque: Constant aerodynamic torque acting on the section (N*m). + :param inertial_torque_func: A callable function of time that returns the + inertial torque from prescribed motion acceleration (N*m). + :return: A tuple of (theta, omega) representing the final angle and angular + velocity at the last time point in t. + """ + + def tau(time: float) -> float: + """Total external torque (aerodynamic + inertial from prescribed motion).""" + return aero_torque + inertial_torque_func(time) + + def ode(time: float, y: list[float]) -> list[float]: + """ODE system: dθ/dt = ω, dω/dt = (τ - c*ω - k*θ)/I.""" theta, omega = y return [omega, (tau(time) - c * omega - k * theta) / I] @@ -680,10 +1073,30 @@ def ode(time, y): return theta, omega - def plot_flap_cycle_curves(self, data, title, flap_cycle=None): - """ - data: list of lists - each inner list is a curve + def plot_flap_cycle_curves( + self, + data: list, + title: str, + flap_cycle=None, + ) -> None: + """Visualize time histories of moments, deformations, or forces. + + Creates a multi-curve line plot showing moment or deformation values across + all time steps, with optional overlay of a reference flap cycle. + + :param data: A list of lists where each inner list represents a curve to plot. + Values in each curve are plotted against step number. + :param title: The title for the plot and the output PNG filename (spaces replaced + with underscores). + :param flap_cycle: Optional reference curve to overlay on the plot. If provided, + should be a list of values to plot with label "Flap Cycle" in black. The + default is None. + :return: None + + **Notes:** + + The plot is saved as a PNG file with the title as the filename. The plot window + is displayed to the user. Figure size is 12x6 inches at 200 DPI. """ plt.figure(figsize=(12, 6), dpi=200) @@ -691,7 +1104,9 @@ def plot_flap_cycle_curves(self, data, title, flap_cycle=None): x = range(len(curve)) plt.plot(x, curve, label=f"Curve {i}") if flap_cycle is not None: - plt.plot(range(len(flap_cycle)), flap_cycle, label=f"Flap Cycle", color="black") + plt.plot( + range(len(flap_cycle)), flap_cycle, label=f"Flap Cycle", color="black" + ) plt.xlabel("Step") plt.ylabel("Value") plt.title(title) From dd5de0acceee71ad8522e6c4a55af914f11cc066 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Wed, 25 Mar 2026 13:44:59 -0400 Subject: [PATCH 32/40] clean up coupled unsteady vortex lattice method --- ...led_unsteady_ring_vortex_lattice_method.py | 121 +++++++++++++----- .../unsteady_ring_vortex_lattice_method.py | 19 ++- 2 files changed, 100 insertions(+), 40 deletions(-) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index b30e1687b..21b9bb919 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -2,8 +2,10 @@ **Contains the following classes:** -CoupledUnsteadyRingVortexLatticeMethodSolver: A class used to solve CoupledUnsteadyProblems with -the unsteady ring vortex lattice method. +CoupledUnsteadyRingVortexLatticeMethodSolver: A subclass of UnsteadyRingVortexLatticeMethodSolver +that solves CoupledUnsteadyProblems using the unsteady ring vortex lattice method. This solver +handles step-by-step geometry initialization and computes aerodynamic loads relative to strip +leading edge points (SLEP) in addition to the standard center-of-gravity frame. **Contains the following functions:** @@ -32,27 +34,50 @@ # TEST: Assess how comprehensive this function's integration tests are and update or # extend them if needed. class CoupledUnsteadyRingVortexLatticeMethodSolver(UnsteadyRingVortexLatticeMethodSolver): - """A class used to solve UnsteadyProblems with the unsteady ring vortex lattice - method. + """A subclass of UnsteadyRingVortexLatticeMethodSolver that solves CoupledUnsteadyProblems. - **Contains the following methods:** + This solver handles CoupledUnsteadyProblems where geometry is initialized and updated on a + per-step basis (step-by-step), rather than being fully precomputed. It extends the parent + class with Strip Leading Edge Point (SLEP) functionality for computing aerodynamic moments + about the strip leading edge, which is important for analyzing wing deformations and local + loading characteristics. - run: Runs the solver on the UnsteadyProblem. + **Key differences from parent UnsteadyRingVortexLatticeMethodSolver:** - initialize_step_geometry: Initializes geometry for a specific step without solving. + - Inherits core aerodynamic solver logic from parent (wall-built inheritance) + - Overrides get_steady_problem_at() to dynamically retrieve problems during iteration + - Extends moment calculations to include SLEP-based moments via _load_calculation_moment_processing_hook() + - Computes bound vortex positions relative to strip leading edge points + + **Inherited methods (used directly from parent):** + + calculate_solution_velocity: Finds the fluid velocity (in the first Airplane's geometry + axes, observed from the Earth frame) at one or more points due to freestream and induced + velocity from every RingVortex. + + All movement velocity calculation methods and aerodynamic influence calculation methods. + + **Custom methods:** - calculate_solution_velocity: Finds the fluid velocity (in the first Airplane's - geometry axes, observed from the Earth frame) at one or more points (in the first - Airplane's geometry axes, relative to the first Airplane's CG) due to the freestream - velocity and the induced velocity from every RingVortex. + run: Runs the solver on the CoupledUnsteadyProblem with per-step geometry initialization. + + initialize_step_geometry: Initializes geometry for a specific step without solving. + + get_steady_problem_at: Overridden abstraction point for dynamic problem retrieval. """ def __init__( self, coupled_unsteady_problem: problems.CoupledUnsteadyProblem ) -> None: - """The initialization method. + """Initialize the solver for a CoupledUnsteadyProblem. + + Sets up the solver infrastructure and initializes SLEP (Strip Leading Edge Point) + related attributes. The coupled_unsteady_problem is stored before calling the parent's + __init__() because the parent's initialization calls methods that depend on accessing + this attribute. - :param unsteady_problem: The UnsteadyProblem to be solved. + :param coupled_unsteady_problem: The CoupledUnsteadyProblem to be solved. Steps are + retrieved dynamically from this problem during iteration via get_steady_problem_at(). :return: None """ if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): @@ -60,7 +85,8 @@ def __init__( "coupled_unsteady_problem must be a CoupledUnsteadyProblem." ) # self.coupled_unsteady_problem must be defined before the call to super().__init__() - # because the parent class's __init__ method calls methods that rely on self.coupled_unsteady_problem being defined. + # because the parent class's __init__ method calls methods that rely on + # self.coupled_unsteady_problem being defined. self.coupled_unsteady_problem = coupled_unsteady_problem super().__init__(coupled_unsteady_problem) @@ -73,12 +99,14 @@ def __init__( self.get_steady_problem_at(0) ) - # We want to store the data from every steady problem we compute here - # but we don't want to override the initial steady problems until the - # data visualization stage so we store this and overwrite after run. + # Store computed steady problems for each time step to be assigned to the + # CoupledUnsteadyProblem after solve completes. This avoids overwriting the + # initial steady problems until data visualization/post-processing stage. self.steady_problems_data_storage = [] - # number of panels overide and strip leading edge point initialization + # Initialize SLEP (Strip Leading Edge Point) information. For each airplane and wing, + # we track the panel index where each new spanwise strip begins. This allows efficient + # computation of moments about the strip leading edge (wing root to tip). num_panels = 0 panel_count = 0 self.slep_point_indices = [] @@ -86,6 +114,7 @@ def __init__( num_panels += airplane.num_panels for wing in airplane.wings: for wing_cross_section in wing.wing_cross_sections: + # Record the first panel index for this wing cross-section (start of strip) self.slep_point_indices.append(panel_count) if wing_cross_section.num_spanwise_panels is not None: panel_count += wing_cross_section.num_spanwise_panels @@ -115,7 +144,7 @@ def run( calculate_streamlines: bool | np.bool_ = True, show_progress: bool | np.bool_ = True, ) -> None: - """Runs the solver on the UnsteadyProblem. + """Runs the solver on the CoupledUnsteadyProblem. :param prescribed_wake: Set this to True to solve using a prescribed wake model. Set to False to use a free-wake, which may be more accurate but will make @@ -141,8 +170,9 @@ def run( show_progress, "show_progress" ) - # Loop through this time step's Airplanes to create a list of their Wings. - # Here we calculate all of our values from our first ariplane to start our main run loop + # Cache the wings and compute spanwise panel counts from the initial geometry. + # Unlike the parent class (which precomputes all steps), this coupled solver + # retrieves each step's geometry dynamically via get_steady_problem_at(). this_problem: problems.SteadyProblem = ( self.get_steady_problem_at(0) ) @@ -294,8 +324,8 @@ def run( self.num_panels, dtype=float ) - # Initialize attributes to hold geometric data that pertain to this - # UnsteadyProblem. + # Initialize attributes to hold geometric data that pertain to the current + # time step of this CoupledUnsteadyProblem. self.panels = np.empty(self.num_panels, dtype=object) self.stackUnitNormals_GP1 = np.zeros((self.num_panels, 3), dtype=float) self.panel_areas = np.zeros(self.num_panels, dtype=float) @@ -481,12 +511,23 @@ def _load_calculation_moment_processing_hook( backLegForces_GP1, unsteady_forces_GP1, ) -> np.ndarray: - """Extends the parent implementation to also compute moments - about the SLEP point, stored in self.moments_GP1_Slep. + """Override parent to compute moments about both center-of-gravity and SLEP. + + This hook extends the parent class's moment calculation by additionally computing + moments about each panel's Strip Leading Edge Point (SLEP). This is used for + analyzing wing loading and deformation characteristics relative to the wing root. + + The method: + 1. Calls parent's implementation to get CG-based moments + 2. Updates bound vortex positions relative to SLEP points + 3. Recalculates all moment contributions in the SLEP frame + 4. Stores SLEP moments in self.moments_GP1_Slep :return: moments_GP1_CgP1, a (N,3) ndarray of floats representing the moments - (in the first Airplane's geometry axes, relative to the first Airplane's CG) - on every Panel at the current time step.""" + (in the first Airplane's geometry axes, relative to the first Airplane's CG) + on every Panel at the current time step. SLEP moments are stored separately + in self.moments_GP1_Slep. + """ # Find the moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) on the Panels' RingVortex's right LineVortex, # front LineVortex, left LineVortex, and back LineVortex. @@ -534,8 +575,16 @@ def _load_calculation_moment_processing_hook( return moments_GP1_CgP1 def _update_bound_vortex_positions_relative_to_slep_points(self) -> None: - """Updates the bound RingVortex position variables to be relative to the - Airplane's SLEP points. + """Transform bound RingVortex leg center positions from CG-relative to SLEP-relative. + + For each panel, this method: + 1. Gets the front-left panel point (leading edge) from each panel + 2. Maps panels to their corresponding strip's leading edge point using slep_point_indices + 3. Subtracts the SLEP position from all vortex leg center positions + 4. Subtracts the SLEP position from collocation points + + This prepares positions for computing moments about the strip leading edge, which is + important for analyzing local wing loading and deformations. :return: None """ @@ -567,14 +616,18 @@ def _update_bound_vortex_positions_relative_to_slep_points(self) -> None: self.stackCpp_GP1_Slep = self.stackCpp_GP1_CgP1 - self.stack_leading_edge_points def get_steady_problem_at(self, step: int) -> problems.SteadyProblem: - """Gets the SteadyProblem at a given time step. This is used for dynamic dispatch - with coupled unsteady problem as we want to have a different way of getting the steady - problem based on the solver type, but we want functions to work the same way regardless - of the solver type so that we don't need ot duplicate functionality across solvers. + """Get the SteadyProblem at a given time step via the CoupledUnsteadyProblem. + + This is a KEY ABSTRACTION POINT that enables inheritance. The parent + UnsteadyRingVortexLatticeMethodSolver has nearly identical code that calls this + method, but retrieves from self.steady_problems[step]. This coupled solver + retrieves from self.coupled_unsteady_problem.get_steady_problem(step), enabling + dynamic step-by-step geometry updates rather than precomputed steps. :param step: An int representing the time step of the desired SteadyProblem. It must be between 0 and num_steps - 1, inclusive. - :return: The SteadyProblem at the given time step. + :return: The SteadyProblem at the given time step, retrieved from the + CoupledUnsteadyProblem's step sequence. """ if step < 0 or step >= self.num_steps: raise ValueError( diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index e3b0621c7..bd3f2d1dd 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -612,6 +612,17 @@ def _initialize_panel_vortices(self) -> None: """Calculates the locations of the bound RingVortex vertices, and then initializes them. + :return: None + """ + for steady_problem_id, steady_problem in enumerate(self.steady_problems): + # Find the freestream velocity (in the first Airplane's geometry axes, + # observed from the Earth frame) at this time step. + self._initialize_panel_vortex(steady_problem, steady_problem_id) + + def _initialize_panel_vortex(self, steady_problem, steady_problem_id): + """ + Initializes the bound RingVortex for each Panel in the given SteadyProblem. + Every Panel has a RingVortex, which is a quadrangle whose front leg is a LineVortex at the Panel's quarter chord. The left and right legs are LineVortices running along the Panel's left and right legs. If the Panel is not @@ -619,14 +630,10 @@ def _initialize_panel_vortices(self) -> None: the rear Panel's quarter chord. Otherwise, they extend backwards and meet the back LineVortex one quarter chord back from the Panel's back leg. + :param steady_problem: The SteadyProblem for which to initialize the bound RingVortices. + :param steady_problem_id: The index of the given SteadyProblem in the list of SteadyProblems. :return: None """ - for steady_problem_id, steady_problem in enumerate(self.steady_problems): - # Find the freestream velocity (in the first Airplane's geometry axes, - # observed from the Earth frame) at this time step. - self._initialize_panel_vortex(steady_problem, steady_problem_id) - - def _initialize_panel_vortex(self, steady_problem, steady_problem_id): this_operating_point = steady_problem.operating_point vInf_GP1__E = this_operating_point.vInf_GP1__E From 1e71acffab85352f24be50a620fa3b5194a83744 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Thu, 26 Mar 2026 11:43:03 -0400 Subject: [PATCH 33/40] fix pre-commit hook issues --- ...oupled_unsteady_first_order_deformation.py | 2 +- examples/demos/Pterosaure.py | 228 +++++++++------- examples/demos/demo_pterosaur.py | 249 ++++++++++-------- examples/demos/demo_single_step.py | 33 ++- examples/demos/demo_single_step_flat.py | 24 +- pterasoftware/__init__.py | 1 - ...led_unsteady_ring_vortex_lattice_method.py | 118 ++++----- pterasoftware/geometry/wing.py | 36 +-- pterasoftware/movements/__init__.py | 2 +- .../movements/single_step/__init__.py | 8 +- .../single_step_airplane_movement.py | 63 +++-- .../single_step/single_step_movement.py | 48 ++-- .../single_step_operating_point_movement.py | 63 ++--- ...single_step_wing_cross_section_movement.py | 132 ++++++---- .../single_step/single_step_wing_movement.py | 121 +++++---- pterasoftware/problems.py | 220 ++++++++-------- .../unsteady_ring_vortex_lattice_method.py | 52 ++-- 17 files changed, 769 insertions(+), 631 deletions(-) diff --git a/examples/coupled_unsteady_first_order_deformation.py b/examples/coupled_unsteady_first_order_deformation.py index 49ab199ea..67d1b9d3a 100644 --- a/examples/coupled_unsteady_first_order_deformation.py +++ b/examples/coupled_unsteady_first_order_deformation.py @@ -139,7 +139,7 @@ # WingMovement for this reflected Wing. To start, we'll first define the reflected # main wing's root and tip WingCrossSections' WingCrossSectionMovements. -# defintions for wing cross section movement parameters +# definitions for wing cross section movement parameters dephase_x = 0.0 period_x = 0.0 amplitude_x = 0.0 diff --git a/examples/demos/Pterosaure.py b/examples/demos/Pterosaure.py index 35bef6ecb..5d747f397 100644 --- a/examples/demos/Pterosaure.py +++ b/examples/demos/Pterosaure.py @@ -1,13 +1,15 @@ -import pterasoftware as ps import numpy as np from scipy.spatial.transform import Rotation as R +import pterasoftware as ps + + def get_relative_transform(Point1, Unit1, Point2, Unit2): Point1 = np.array(Point1, dtype=float) - Unit1 = np.array(Unit1, dtype=float) + Unit1 = np.array(Unit1, dtype=float) Point2 = np.array(Point2, dtype=float) - Unit2 = np.array(Unit2, dtype=float) + Unit2 = np.array(Unit2, dtype=float) Xp = Unit1 / np.linalg.norm(Unit1) Zp = np.array([0, 0, 1]) @@ -28,7 +30,7 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): Rrel = Rp.T @ Re rot = R.from_matrix(Rrel) - angles_Wcsp_to_Wcs_ixyz = rot.as_euler('xyz', degrees=True) + angles_Wcsp_to_Wcs_ixyz = rot.as_euler("xyz", degrees=True) Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) @@ -36,89 +38,123 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=0.25, - 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, - ), - ) + num_spanwise_panels=6, + chord=0.25, + 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, + ), +) wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=np.linalg.norm((0.1559,0,-0.0931)), - Lp_Wcsp_Lpp=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[1], - 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, - ), - ) + num_spanwise_panels=6, + chord=np.linalg.norm((0.1559, 0, -0.0931)), + Lp_Wcsp_Lpp=get_relative_transform( + (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) + )[1], + 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, + ), +) wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=np.linalg.norm((0.2864,0,-0.1878)), - Lp_Wcsp_Lpp=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[1], - 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, - ), - ) + num_spanwise_panels=6, + chord=np.linalg.norm((0.2864, 0, -0.1878)), + Lp_Wcsp_Lpp=get_relative_transform( + (0.0889, 0.2249, 0.0955), + (0.1559, 0, -0.0931), + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (0.0889, 0.2249, 0.0955), + (0.1559, 0, -0.0931), + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + )[1], + 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, + ), +) wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=np.linalg.norm((0.322,0,-0.2256)), - Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], - 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, - ), - ) + num_spanwise_panels=6, + chord=np.linalg.norm((0.322, 0, -0.2256)), + Lp_Wcsp_Lpp=get_relative_transform( + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + )[1], + 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, + ), +) wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.005,0,-0.0003)), - Lp_Wcsp_Lpp=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[1], - 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, - ), - ) + num_spanwise_panels=1, + chord=np.linalg.norm((0.005, 0, -0.0003)), + Lp_Wcsp_Lpp=get_relative_transform( + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + (0.1829, 2.4373, 0.0266), + (0.005, 0, -0.0003), + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + (0.1829, 2.4373, 0.0266), + (0.005, 0, -0.0003), + )[1], + 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, + ), +) wing_cross_section_6 = ps.geometry.wing_cross_section.WingCrossSection( num_spanwise_panels=None, chord=np.linalg.norm((0.005, 0, -0.0003)), @@ -150,10 +186,17 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): pterasaure = ps.geometry.airplane.Airplane( wings=[ ps.geometry.wing.Wing( - wing_cross_sections=[wing_cross_section_1, wing_cross_section_2, wing_cross_section_3, wing_cross_section_4, wing_cross_section_5, wing_cross_section_6], + wing_cross_sections=[ + wing_cross_section_1, + wing_cross_section_2, + wing_cross_section_3, + wing_cross_section_4, + wing_cross_section_5, + wing_cross_section_6, + ], name="Main Wing", - Ler_Gs_Cgs= [0.0, 0.025, 0.0], - angles_Gs_to_Wn_ixyz= [4, 0.0, 0.0], + Ler_Gs_Cgs=[0.0, 0.025, 0.0], + angles_Gs_to_Wn_ixyz=[4, 0.0, 0.0], symmetric=True, mirror_only=False, symmetryNormal_G=(0.0, 1.0, 0.0), @@ -176,20 +219,22 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): for wing in pterasaure.wings: wing_cross_section_movements = [] for wing_cross_section in wing.wing_cross_sections: - wing_cross_section_movements.append(ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - base_wing_cross_section=wing_cross_section) + wing_cross_section_movements.append( + ps.movements.wing_cross_section_movement.WingCrossSectionMovement( + base_wing_cross_section=wing_cross_section + ) ) wing_movements.append(wing_cross_section_movements) main_wing_movement = ps.movements.wing_movement.WingMovement( base_wing=pterasaure.wings[0], - wing_cross_section_movements= wing_movements[0], + wing_cross_section_movements=wing_movements[0], 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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + ampAngles_Gs_to_Wn_ixyz=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), ) @@ -201,8 +246,8 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): 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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1/3, 0.0, 0.0), + ampAngles_Gs_to_Wn_ixyz=(30.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), ) @@ -224,7 +269,8 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): # Define the operating point's OperatingPointMovement. operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( - base_operating_point=example_operating_point) + base_operating_point=example_operating_point +) # Define the Movement. This contains the AirplaneMovement and the # OperatingPointMovement. @@ -239,7 +285,7 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): # Define the UnsteadyProblem. example_problem = ps.problems.UnsteadyProblem( - movement=movement, + movement=movement, ) # Define a new solver. The available solver classes are diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index 2da59ba4b..42489d7bc 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -1,15 +1,17 @@ from email.mime import base -import pterasoftware as ps import numpy as np from scipy.spatial.transform import Rotation as R +import pterasoftware as ps + + def get_relative_transform(Point1, Unit1, Point2, Unit2): Point1 = np.array(Point1, dtype=float) - Unit1 = np.array(Unit1, dtype=float) + Unit1 = np.array(Unit1, dtype=float) Point2 = np.array(Point2, dtype=float) - Unit2 = np.array(Unit2, dtype=float) + Unit2 = np.array(Unit2, dtype=float) Xp = Unit1 / np.linalg.norm(Unit1) Zp = np.array([0, 0, 1]) @@ -30,7 +32,7 @@ def get_relative_transform(Point1, Unit1, Point2, Unit2): Rrel = Rp.T @ Re rot = R.from_matrix(Rrel) - angles_Wcsp_to_Wcs_ixyz = rot.as_euler('xyz', degrees=True) + angles_Wcsp_to_Wcs_ixyz = rot.as_euler("xyz", degrees=True) Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) @@ -70,7 +72,7 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): Lp_Wcsp_Lpp = tuple(np.array(wcs2.Lp_Wcsp_Lpp) / N) # angles_Wcsp_to_Wcs_ixyz = tuple((1 - t) * np.array(wcs1.angles_Wcsp_to_Wcs_ixyz) + t * np.array(wcs2.angles_Wcsp_to_Wcs_ixyz)) angles_Wcsp_to_Wcs_ixyz = wcs2.angles_Wcsp_to_Wcs_ixyz / N - is_final_section = wcs2.num_spanwise_panels is None and i == N - 1 + is_final_section = wcs2.num_spanwise_panels is None and i == N - 1 interpolated.append( ps.geometry.wing_cross_section.WingCrossSection( @@ -89,21 +91,21 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=0.25, - 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, - ), - ) + num_spanwise_panels=1, + chord=0.25, + 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, + ), +) # points = [(0.0, 0.0, 0.0), # (0.0889,0.2249, 0.0955), @@ -118,65 +120,91 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): # (0.1829, 2.4373, 0.0266)] wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.1559,0,-0.0931)), - Lp_Wcsp_Lpp=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889,0.2249, 0.0955), (0.1559,0,-0.0931))[1], - 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, - ), - ) + num_spanwise_panels=1, + chord=np.linalg.norm((0.1559, 0, -0.0931)), + Lp_Wcsp_Lpp=get_relative_transform( + (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) + )[1], + 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, + ), +) wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.2864,0,-0.1878)), - Lp_Wcsp_Lpp=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((0.0889,0.2249, 0.0955), (0.1559,0,-0.0931), (-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878))[1], - 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, - ), - ) + num_spanwise_panels=1, + chord=np.linalg.norm((0.2864, 0, -0.1878)), + Lp_Wcsp_Lpp=get_relative_transform( + (0.0889, 0.2249, 0.0955), + (0.1559, 0, -0.0931), + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (0.0889, 0.2249, 0.0955), + (0.1559, 0, -0.0931), + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + )[1], + 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, + ), +) wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.322,0,-0.2256)), - Lp_Wcsp_Lpp=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0521, 0.5749, 0.1940), (0.2864,0,-0.1878), (-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256))[1], - 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, - ), - ) + num_spanwise_panels=1, + chord=np.linalg.norm((0.322, 0, -0.2256)), + Lp_Wcsp_Lpp=get_relative_transform( + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (-0.0521, 0.5749, 0.1940), + (0.2864, 0, -0.1878), + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + )[1], + 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, + ), +) wing_cross_section_4_5 = ps.geometry.wing_cross_section.WingCrossSection( num_spanwise_panels=1, chord=(0.39316581743584983 + 0.005008991914547277) / 2, - Lp_Wcsp_Lpp= 0.0 * np.array((-0.05774876, 0.2533, 0.01056319)) + 0.5 * np.array((0.34656431, 1.6091, -0.01103809)), + Lp_Wcsp_Lpp=0.0 * np.array((-0.05774876, 0.2533, 0.01056319)) + + 0.5 * np.array((0.34656431, 1.6091, -0.01103809)), angles_Wcsp_to_Wcs_ixyz=get_relative_transform( (-0.0946, 0.8282, 0.2345), (0.322, 0, -0.2256), (0.1829, 2.4373, 0.0266), (0.005, 0, -0.0003), - )[1] * 0, + )[1] + * 0, control_surface_symmetry_type="symmetric", control_surface_hinge_point=0.75, control_surface_deflection=0.0, @@ -212,41 +240,51 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): ) wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=None, - chord=np.linalg.norm((0.005,0,-0.0003)), - Lp_Wcsp_Lpp=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform((-0.0946, 0.8282, 0.2345), (0.322,0,-0.2256), (0.1829, 2.4373, 0.0266), (0.005,0,-0.0003))[1], - 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, - ), - ) + num_spanwise_panels=None, + chord=np.linalg.norm((0.005, 0, -0.0003)), + Lp_Wcsp_Lpp=get_relative_transform( + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + (0.1829, 2.4373, 0.0266), + (0.005, 0, -0.0003), + )[0], + angles_Wcsp_to_Wcs_ixyz=get_relative_transform( + (-0.0946, 0.8282, 0.2345), + (0.322, 0, -0.2256), + (0.1829, 2.4373, 0.0266), + (0.005, 0, -0.0003), + )[1], + 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, + ), +) original_wing = ps.geometry.wing.Wing( - wing_cross_sections=[ - wing_cross_section_1, - wing_cross_section_2, - wing_cross_section_3, - wing_cross_section_4, - wing_cross_section_5, - ], - name="Main Wing", - Ler_Gs_Cgs=[0.0, 0.025, 0.0], - angles_Gs_to_Wn_ixyz=[4, 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), - single_step_wing = True, - num_chordwise_panels=5, - chordwise_spacing="uniform", - ) + wing_cross_sections=[ + wing_cross_section_1, + wing_cross_section_2, + wing_cross_section_3, + wing_cross_section_4, + wing_cross_section_5, + ], + name="Main Wing", + Ler_Gs_Cgs=[0.0, 0.025, 0.0], + angles_Gs_to_Wn_ixyz=[4, 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), + single_step_wing=True, + num_chordwise_panels=5, + chordwise_spacing="uniform", +) pterasaure = ps.geometry.airplane.Airplane( wings=[original_wing], @@ -353,7 +391,10 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): # Define the operating point's OperatingPointMovement. single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( - base_operating_point=example_operating_point, ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" + base_operating_point=example_operating_point, + ampVCg__E=0.0, + periodVCg__E=0.0, + spacingVCg__E="sine", ) # Define the Movement. This contains the AirplaneMovement and the @@ -378,10 +419,8 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): # SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, # and UnsteadyRingVortexLatticeMethodSolver. We'll create an # UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. -example_solver = ( - ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( - coupled_unsteady_problem=example_problem, - ) +example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, ) # Run the solver. diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py index a84fe1ee3..592f55be7 100644 --- a/examples/demos/demo_single_step.py +++ b/examples/demos/demo_single_step.py @@ -121,7 +121,7 @@ # WingMovement for this reflected Wing. To start, we'll first define the reflected # main wing's root and tip WingCrossSections' WingCrossSectionMovements. -# defintions for wing movement parameters +# definitions for wing movement parameters # dephase_x = 0.0 # period_x = 1.0 # amplitude_x = 2.0 @@ -155,7 +155,9 @@ movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], ) - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() + single_step_movement = ( + ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() + ) main_movements_list.append(movement) main_single_step_movements_list.append(single_step_movement) @@ -228,17 +230,15 @@ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"), phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0), ) -single_step_v_tail_tip_wing_cross_section_movement = ( - ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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), - ) +single_step_v_tail_tip_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), ) @@ -349,7 +349,6 @@ single_step_airplane_movement = ( ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( - single_step_wing_movements=[ single_step_main_wing_movement, single_step_reflected_main_wing_movement, @@ -380,10 +379,8 @@ base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" ) -single_step_operating_point_movement = ( - ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( - ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" - ) +single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" ) # Delete the extraneous pointer. diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index 2113302d8..fa7d31645 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -139,7 +139,7 @@ # WingMovement for this reflected Wing. To start, we'll first define the reflected # main wing's root and tip WingCrossSections' WingCrossSectionMovements. -# defintions for wing cross section movement parameters +# definitions for wing cross section movement parameters dephase_x = 0.0 period_x = 0.0 amplitude_x = 0.0 @@ -202,18 +202,16 @@ spacingAngles_Wcsp_to_Wcs_ixyz=("sine", "sine", "sine"), phaseAngles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0), ) -single_step_v_tail_tip_wing_cross_section_movement = ( - ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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), - ) +single_step_v_tail_tip_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), ) # This dephase parameter is used to make the wing start in a flat position diff --git a/pterasoftware/__init__.py b/pterasoftware/__init__.py index c251524b2..961cbf32e 100644 --- a/pterasoftware/__init__.py +++ b/pterasoftware/__init__.py @@ -48,7 +48,6 @@ import pterasoftware.operating_point import pterasoftware.problems - # Lazy imports configuration: modules loaded on first access. _LAZY_MODULES = { "convergence": "pterasoftware.convergence", diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index 21b9bb919..e8d706fae 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -2,10 +2,11 @@ **Contains the following classes:** -CoupledUnsteadyRingVortexLatticeMethodSolver: A subclass of UnsteadyRingVortexLatticeMethodSolver -that solves CoupledUnsteadyProblems using the unsteady ring vortex lattice method. This solver -handles step-by-step geometry initialization and computes aerodynamic loads relative to strip -leading edge points (SLEP) in addition to the standard center-of-gravity frame. +CoupledUnsteadyRingVortexLatticeMethodSolver: A subclass of +UnsteadyRingVortexLatticeMethodSolver that solves CoupledUnsteadyProblems using the +unsteady ring vortex lattice method. This solver handles step-by-step geometry +initialization and computes aerodynamic loads relative to strip leading edge points +(SLEP) in addition to the standard center-of-gravity frame. **Contains the following functions:** @@ -24,7 +25,6 @@ geometry, problems, ) - from .unsteady_ring_vortex_lattice_method import UnsteadyRingVortexLatticeMethodSolver _logger = _logging.get_logger("unsteady_ring_vortex_lattice_method") @@ -33,36 +33,42 @@ # TEST: Consider adding unit tests for this function. # TEST: Assess how comprehensive this function's integration tests are and update or # extend them if needed. -class CoupledUnsteadyRingVortexLatticeMethodSolver(UnsteadyRingVortexLatticeMethodSolver): - """A subclass of UnsteadyRingVortexLatticeMethodSolver that solves CoupledUnsteadyProblems. - - This solver handles CoupledUnsteadyProblems where geometry is initialized and updated on a - per-step basis (step-by-step), rather than being fully precomputed. It extends the parent - class with Strip Leading Edge Point (SLEP) functionality for computing aerodynamic moments - about the strip leading edge, which is important for analyzing wing deformations and local - loading characteristics. +class CoupledUnsteadyRingVortexLatticeMethodSolver( + UnsteadyRingVortexLatticeMethodSolver +): + """A subclass of UnsteadyRingVortexLatticeMethodSolver that solves + CoupledUnsteadyProblems. + + This solver handles CoupledUnsteadyProblems where geometry is initialized and + updated on a per-step basis (step-by-step), rather than being fully precomputed. It + extends the parent class with Strip Leading Edge Point (SLEP) functionality for + computing aerodynamic moments about the strip leading edge, which is important for + analyzing wing deformations and local loading characteristics. **Key differences from parent UnsteadyRingVortexLatticeMethodSolver:** - - Inherits core aerodynamic solver logic from parent (wall-built inheritance) - - Overrides get_steady_problem_at() to dynamically retrieve problems during iteration - - Extends moment calculations to include SLEP-based moments via _load_calculation_moment_processing_hook() - - Computes bound vortex positions relative to strip leading edge points + - Inherits core aerodynamic solver logic from parent (wall-built inheritance) - + Overrides get_steady_problem_at() to dynamically retrieve problems during iteration + - Extends moment calculations to include SLEP-based moments via + _load_calculation_moment_processing_hook() - Computes bound vortex positions + relative to strip leading edge points **Inherited methods (used directly from parent):** - calculate_solution_velocity: Finds the fluid velocity (in the first Airplane's geometry - axes, observed from the Earth frame) at one or more points due to freestream and induced - velocity from every RingVortex. - - All movement velocity calculation methods and aerodynamic influence calculation methods. + calculate_solution_velocity: Finds the fluid velocity (in the first Airplane's + geometry axes, observed from the Earth frame) at one or more points due to + freestream and induced velocity from every RingVortex. + + All movement velocity calculation methods and aerodynamic influence calculation + methods. **Custom methods:** - run: Runs the solver on the CoupledUnsteadyProblem with per-step geometry initialization. + run: Runs the solver on the CoupledUnsteadyProblem with per-step geometry + initialization. initialize_step_geometry: Initializes geometry for a specific step without solving. - + get_steady_problem_at: Overridden abstraction point for dynamic problem retrieval. """ @@ -71,13 +77,14 @@ def __init__( ) -> None: """Initialize the solver for a CoupledUnsteadyProblem. - Sets up the solver infrastructure and initializes SLEP (Strip Leading Edge Point) - related attributes. The coupled_unsteady_problem is stored before calling the parent's - __init__() because the parent's initialization calls methods that depend on accessing - this attribute. + Sets up the solver infrastructure and initializes SLEP (Strip Leading Edge + Point) related attributes. The coupled_unsteady_problem is stored before calling + the parent's __init__() because the parent's initialization calls methods that + depend on accessing this attribute. - :param coupled_unsteady_problem: The CoupledUnsteadyProblem to be solved. Steps are - retrieved dynamically from this problem during iteration via get_steady_problem_at(). + :param coupled_unsteady_problem: The CoupledUnsteadyProblem to be solved. Steps + are retrieved dynamically from this problem during iteration via + get_steady_problem_at(). :return: None """ if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): @@ -85,7 +92,7 @@ def __init__( "coupled_unsteady_problem must be a CoupledUnsteadyProblem." ) # self.coupled_unsteady_problem must be defined before the call to super().__init__() - # because the parent class's __init__ method calls methods that rely on + # because the parent class's __init__ method calls methods that rely on # self.coupled_unsteady_problem being defined. self.coupled_unsteady_problem = coupled_unsteady_problem super().__init__(coupled_unsteady_problem) @@ -95,9 +102,7 @@ def __init__( self.first_results_step = self.coupled_unsteady_problem.first_results_step self._first_averaging_step = self.coupled_unsteady_problem.first_averaging_step - first_steady_problem: problems.SteadyProblem = ( - self.get_steady_problem_at(0) - ) + first_steady_problem: problems.SteadyProblem = self.get_steady_problem_at(0) # Store computed steady problems for each time step to be assigned to the # CoupledUnsteadyProblem after solve completes. This avoids overwriting the @@ -173,9 +178,7 @@ def run( # Cache the wings and compute spanwise panel counts from the initial geometry. # Unlike the parent class (which precomputes all steps), this coupled solver # retrieves each step's geometry dynamically via get_steady_problem_at(). - this_problem: problems.SteadyProblem = ( - self.get_steady_problem_at(0) - ) + this_problem: problems.SteadyProblem = self.get_steady_problem_at(0) these_airplanes = this_problem.airplanes num_wing_panels = 0 these_wings: list[list[geometry.wing.Wing]] = [] @@ -263,7 +266,7 @@ def run( approx_partial_time = np.sum(approx_times) approx_times[0] = round(approx_partial_time / 100) approx_total_time = np.sum(approx_times) - + with tqdm( total=approx_total_time, unit="", @@ -285,8 +288,8 @@ def run( # and OperatingPoint, and freestream velocity (in the first # Airplane's geometry axes, observed from the Earth frame). self._current_step = step - current_problem: problems.SteadyProblem = ( - self.get_steady_problem_at(self._current_step) + current_problem: problems.SteadyProblem = self.get_steady_problem_at( + self._current_step ) # Initialize all the current step's bound RingVortices. @@ -487,9 +490,7 @@ def initialize_step_geometry(self, step: int) -> None: # Initialize bound RingVortices for all steps on the first call. if step == 0: - self._initialize_panel_vortex( - self.get_steady_problem_at(0), 0 - ) + self._initialize_panel_vortex(self.get_steady_problem_at(0), 0) # Set the current step and related state. self._current_step = step @@ -513,15 +514,14 @@ def _load_calculation_moment_processing_hook( ) -> np.ndarray: """Override parent to compute moments about both center-of-gravity and SLEP. - This hook extends the parent class's moment calculation by additionally computing - moments about each panel's Strip Leading Edge Point (SLEP). This is used for - analyzing wing loading and deformation characteristics relative to the wing root. + This hook extends the parent class's moment calculation by additionally + computing moments about each panel's Strip Leading Edge Point (SLEP). This is + used for analyzing wing loading and deformation characteristics relative to the + wing root. - The method: - 1. Calls parent's implementation to get CG-based moments - 2. Updates bound vortex positions relative to SLEP points - 3. Recalculates all moment contributions in the SLEP frame - 4. Stores SLEP moments in self.moments_GP1_Slep + The method: 1. Calls parent's implementation to get CG-based moments 2. Updates + bound vortex positions relative to SLEP points 3. Recalculates all moment + contributions in the SLEP frame 4. Stores SLEP moments in self.moments_GP1_Slep :return: moments_GP1_CgP1, a (N,3) ndarray of floats representing the moments (in the first Airplane's geometry axes, relative to the first Airplane's CG) @@ -575,16 +575,16 @@ def _load_calculation_moment_processing_hook( return moments_GP1_CgP1 def _update_bound_vortex_positions_relative_to_slep_points(self) -> None: - """Transform bound RingVortex leg center positions from CG-relative to SLEP-relative. + """Transform bound RingVortex leg center positions from CG-relative to SLEP- + relative. - For each panel, this method: - 1. Gets the front-left panel point (leading edge) from each panel - 2. Maps panels to their corresponding strip's leading edge point using slep_point_indices - 3. Subtracts the SLEP position from all vortex leg center positions - 4. Subtracts the SLEP position from collocation points + For each panel, this method: 1. Gets the front-left panel point (leading edge) + from each panel 2. Maps panels to their corresponding strip's leading edge point + using slep_point_indices 3. Subtracts the SLEP position from all vortex leg + center positions 4. Subtracts the SLEP position from collocation points - This prepares positions for computing moments about the strip leading edge, which is - important for analyzing local wing loading and deformations. + This prepares positions for computing moments about the strip leading edge, + which is important for analyzing local wing loading and deformations. :return: None """ diff --git a/pterasoftware/geometry/wing.py b/pterasoftware/geometry/wing.py index f22faa656..2fa72ab2a 100644 --- a/pterasoftware/geometry/wing.py +++ b/pterasoftware/geometry/wing.py @@ -252,8 +252,8 @@ def __init__( either are True. For more details on how this parameter interacts with symmetryNormal_G, symmetric, and mirror_only, see the class docstring. The units are meters. The default is None. - :param single_step_wing: Set this to True to have the explode_wing method called on - this Wing during initialization, which will return a NEW Wing where all + :param single_step_wing: Set this to True to have the explode_wing method called + on this Wing during initialization, which will return a NEW Wing where all panels are broken into single strips for deformation. :param num_chordwise_panels: The number of chordwise panels to be used on this Wing, which must be set to a positive integer. The default is 8. @@ -1275,7 +1275,7 @@ class docstring for details on how to interpret the symmetry types. # Generate the wing's mesh, which populates the Panels attribute. _meshing.mesh_wing(self) - + def get_plottable_data( self, show: bool | np.bool_ = False ) -> list[list[np.ndarray]] | None: @@ -1519,19 +1519,19 @@ def interpolate_between_wing_cross_sections( wcs2: wing_cross_section_mod.WingCrossSection, first_wcs: bool, ) -> list[wing_cross_section_mod.WingCrossSection]: - """ - Wing cross section panels are between the line of wcs1 and wcs2. - When exploding a wing to 1 spanwise panel per cross section, - we need to interpolate the intermediate cross sections. + """Wing cross section panels are between the line of wcs1 and wcs2. When + exploding a wing to 1 spanwise panel per cross section, we need to interpolate + the intermediate cross sections. :param wcs1: The first WingCrossSection. :param wcs2: The second WingCrossSection. :param first_wcs: Whether wcs1 is the first WingCrossSection of the wing. If - True, the method will include a WingCrossSection with the same parameters - as wcs1 in the output list. If False, it will not, since it will have - already been included as the last interpolated WingCrossSection between - the previous pair of WingCrossSections. - :return: A list of WingCrossSections representing the interpolated cross sections + True, the method will include a WingCrossSection with the same parameters as + wcs1 in the output list. If False, it will not, since it will have already + been included as the last interpolated WingCrossSection between the previous + pair of WingCrossSections. + :return: A list of WingCrossSections representing the interpolated cross + sections """ interpolated = [] @@ -1577,11 +1577,12 @@ def interpolate_between_wing_cross_sections( ) return interpolated - def explode_wing(self, wing_cross_sections: list[wing_cross_section_mod.WingCrossSection]) -> list[wing_cross_section_mod.WingCrossSection]: - """ - Takes a list of WingCrossSections and returns a new list - where all cross sections have num_spanwise_panels = 1. - + def explode_wing( + self, wing_cross_sections: list[wing_cross_section_mod.WingCrossSection] + ) -> list[wing_cross_section_mod.WingCrossSection]: + """Takes a list of WingCrossSections and returns a new list where all cross + sections have num_spanwise_panels = 1. + :param wing_cross_sections: The list of wing cross sections to explode. :return: A new list of exploded wing cross sections. """ @@ -1597,6 +1598,7 @@ def explode_wing(self, wing_cross_sections: list[wing_cross_section_mod.WingCros return new_cross_sections + def _assert_T_not_none(T: np.ndarray | None) -> np.ndarray: """Assert that a transformation matrix is not None and return it. diff --git a/pterasoftware/movements/__init__.py b/pterasoftware/movements/__init__.py index 35b1bf184..67d2c9cca 100644 --- a/pterasoftware/movements/__init__.py +++ b/pterasoftware/movements/__init__.py @@ -24,6 +24,6 @@ import pterasoftware.movements.airplane_movement import pterasoftware.movements.movement import pterasoftware.movements.operating_point_movement +import pterasoftware.movements.single_step import pterasoftware.movements.wing_cross_section_movement import pterasoftware.movements.wing_movement -import pterasoftware.movements.single_step diff --git a/pterasoftware/movements/single_step/__init__.py b/pterasoftware/movements/single_step/__init__.py index fbb9051cb..8cc1dcbf1 100644 --- a/pterasoftware/movements/single_step/__init__.py +++ b/pterasoftware/movements/single_step/__init__.py @@ -12,17 +12,19 @@ single_step_airplane_movement.py: Contains the SingleStepAirplaneMovement class. -single_step_operating_point_movement.py: Contains the SingleStepOperatingPointMovement class. +single_step_operating_point_movement.py: Contains the SingleStepOperatingPointMovement +class. single_step_movement.py: Contains the SingleStepMovement class. -single_step_wing_cross_section_movement.py: Contains the SingleStepWingCrossSectionMovement class. +single_step_wing_cross_section_movement.py: Contains the +SingleStepWingCrossSectionMovement class. single_step_wing_movement.py: Contains the SingleStepWingMovement class. """ import pterasoftware.movements.single_step.single_step_airplane_movement -import pterasoftware.movements.single_step.single_step_operating_point_movement import pterasoftware.movements.single_step.single_step_movement +import pterasoftware.movements.single_step.single_step_operating_point_movement import pterasoftware.movements.single_step.single_step_wing_cross_section_movement import pterasoftware.movements.single_step.single_step_wing_movement diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py index 5d84fff3b..dc06f5ed3 100644 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -2,8 +2,8 @@ **Contains the following classes:** -SingleStepAirplaneMovement: A single step variant of AirplaneMovement that generates -one Airplane per time step instead of all at once. Uses composition to wrap an +SingleStepAirplaneMovement: A single step variant of AirplaneMovement that generates one +Airplane per time step instead of all at once. Uses composition to wrap an AirplaneMovement. **Contains the following functions:** @@ -17,20 +17,17 @@ import numpy as np - -from ..airplane_movement import AirplaneMovement from ... import geometry - from ..._parameter_validation import ( int_in_range_return_int, number_in_range_return_float, ) - from .._functions import ( - oscillating_sinspaces, - oscillating_linspaces, oscillating_customspaces, + oscillating_linspaces, + oscillating_sinspaces, ) +from ..airplane_movement import AirplaneMovement class SingleStepAirplaneMovement: @@ -42,9 +39,8 @@ class SingleStepAirplaneMovement: **Contains the following methods:** - all_periods: All unique non zero periods from this - SingleStepAirplaneMovement, its SingleStepWingMovements, and their - SingleStepWingCrossSectionMovements. + all_periods: All unique non zero periods from this SingleStepAirplaneMovement, its + SingleStepWingMovements, and their SingleStepWingCrossSectionMovements. generate_next_airplane: Creates the Airplane at a single time step. @@ -88,32 +84,32 @@ def __init__( :param ampCg_GP1_CgP1: An array-like object of non negative numbers (int or float) with shape (3,) representing the amplitudes of the SingleStepAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1 - parameters. Can be a tuple, list, or ndarray. Values are converted to - floats internally. Each amplitude must be low enough that it doesn't drive - its base value out of the range of valid values. Otherwise, this + parameters. Can be a tuple, list, or ndarray. Values are converted to floats + internally. Each amplitude must be low enough that it doesn't drive its base + value out of the range of valid values. Otherwise, this SingleStepAirplaneMovement will try to create Airplanes with invalid - parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter must - be all zeros, this means that the first Airplane's ampCg_GP1_CgP1 parameter + parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter must be + all zeros, this means that the first Airplane's ampCg_GP1_CgP1 parameter must also be all zeros. The units are in meters. The default is (0.0, 0.0, 0.0). :param periodCg_GP1_CgP1: An array-like object of non negative numbers (int or float) with shape (3,) representing the periods of the SingleStepAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1 - parameters. Can be a tuple, list, or ndarray. Values are converted to - floats internally. Each element must be 0.0 if the corresponding element in + parameters. Can be a tuple, list, or ndarray. Values are converted to floats + internally. Each element must be 0.0 if the corresponding element in ampCg_GP1_CgP1 is 0.0 and non zero if not. The units are in seconds. The default is (0.0, 0.0, 0.0). :param spacingCg_GP1_CgP1: An array-like object of strs or callables with shape - (3,) representing the spacing of the SingleStepAirplaneMovement's changes - in its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray. + (3,) representing the spacing of the SingleStepAirplaneMovement's changes in + its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray. Each element can be the str "sine", the str "uniform", or a callable custom spacing function. Custom spacing functions are for advanced users and must - start at 0.0, return to 0.0 after one period of 2*pi radians, have - amplitude of 1.0, be periodic, return finite values only, and accept a - ndarray as input and return a ndarray of the same shape. Custom functions - are scaled by ampCg_GP1_CgP1, shifted horizontally and vertically by - phaseCg_GP1_CgP1 and the base value, and have a period set by - periodCg_GP1_CgP1. The default is ("sine", "sine", "sine"). + start at 0.0, return to 0.0 after one period of 2*pi radians, have amplitude + of 1.0, be periodic, return finite values only, and accept a ndarray as + input and return a ndarray of the same shape. Custom functions are scaled by + ampCg_GP1_CgP1, shifted horizontally and vertically by phaseCg_GP1_CgP1 and + the base value, and have a period set by periodCg_GP1_CgP1. The default is + ("sine", "sine", "sine"). :param phaseCg_GP1_CgP1: An array-like object of numbers (int or float) with shape (3,) representing the phase offsets of the elements in the first time step's Airplane's Cg_GP1_CgP1 parameter relative to the base Airplane's @@ -143,7 +139,9 @@ def __init__( # Copy validated attributes from the corresponding AirplaneMovement. self.ampCg_GP1_CgP1 = self.corresponding_airplane_movement.ampCg_GP1_CgP1 self.periodCg_GP1_CgP1 = self.corresponding_airplane_movement.periodCg_GP1_CgP1 - self.spacingCg_GP1_CgP1 = self.corresponding_airplane_movement.spacingCg_GP1_CgP1 + self.spacingCg_GP1_CgP1 = ( + self.corresponding_airplane_movement.spacingCg_GP1_CgP1 + ) self.phaseCg_GP1_CgP1 = self.corresponding_airplane_movement.phaseCg_GP1_CgP1 self.listCg_GP1_CgP1 = None @@ -169,7 +167,12 @@ def all_periods(self) -> list[float]: return periods def generate_next_airplane( - self, base_airplane, delta_time: float | int, num_steps: int, step: int, deformation_matrices, + self, + base_airplane, + delta_time: float | int, + num_steps: int, + step: int, + deformation_matrices, ) -> geometry.airplane.Airplane: """Creates the Airplane at a single time step. @@ -197,7 +200,9 @@ def generate_next_airplane( # Generate oscillating values for each dimension of Cg_GP1_CgP1. if self.listCg_GP1_CgP1 is None: - self._initialize_oscillating_dimensions(delta_time, num_steps, base_airplane) + self._initialize_oscillating_dimensions( + delta_time, num_steps, base_airplane + ) wings = [] diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index 2208dffaa..565afe8f1 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -3,23 +3,22 @@ **Contains the following classes:** SingleStepMovement: A single step variant of Movement that generates Airplanes and -OperatingPoints one time step at a time instead of all at once. Uses composition to -wrap a Movement. +OperatingPoints one time step at a time instead of all at once. Uses composition to wrap +a Movement. **Contains the following functions:** None """ -from ..movement import Movement - -from .single_step_airplane_movement import SingleStepAirplaneMovement -from .single_step_operating_point_movement import SingleStepOperatingPointMovement - from ..._parameter_validation import ( - number_in_range_return_float, int_in_range_return_int, + number_in_range_return_float, ) +from ..movement import Movement +from .single_step_airplane_movement import SingleStepAirplaneMovement +from .single_step_operating_point_movement import SingleStepOperatingPointMovement + class SingleStepMovement: """A single step variant of Movement for coupled simulations. @@ -65,25 +64,25 @@ def __init__( motion velocities. This provides good results across all Strouhal numbers. "optimize": SingleStepMovement first runs the analytical estimation, then uses that result as an initial guess for an iterative optimization that - minimizes the area mismatch between wake RingVortices and their parent - bound trailing edge RingVortices. This is slower but may produce slightly - more accurate results. Positive number (int or float): Use the specified - value directly. All values are converted internally to floats. The units - are in seconds. + minimizes the area mismatch between wake RingVortices and their parent bound + trailing edge RingVortices. This is slower but may produce slightly more + accurate results. Positive number (int or float): Use the specified value + directly. All values are converted internally to floats. The units are in + seconds. :param num_cycles: The number of cycles of the maximum period motion used to calculate a num_steps parameter initialized as None if the SingleStepMovement isn't static. If num_steps is not None or if the - SingleStepMovement is static, this must be None. If num_steps is - initialized as None and the SingleStepMovement isn't static, num_cycles - must be a positive int. In that case, I recommend setting num_cycles to 3. - The default is None. + SingleStepMovement is static, this must be None. If num_steps is initialized + as None and the SingleStepMovement isn't static, num_cycles must be a + positive int. In that case, I recommend setting num_cycles to 3. The default + is None. :param num_chords: The number of chord lengths used to calculate a num_steps parameter initialized as None if the SingleStepMovement is static. If num_steps is not None or if the SingleStepMovement isn't static, this must be None. If num_steps is initialized as None and the SingleStepMovement is - static, num_chords must be a positive int. In that case, I recommend - setting num_chords to 10. For cases with multiple Airplanes, the num_chords - will reference the largest reference chord length. The default is None. + static, num_chords must be a positive int. In that case, I recommend setting + num_chords to 10. For cases with multiple Airplanes, the num_chords will + reference the largest reference chord length. The default is None. :param num_steps: The number of time steps of the unsteady simulation. If initialized as None, and the SingleStepMovement isn't static, it will calculate a value for num_steps such that the simulation will cover some @@ -116,7 +115,10 @@ def __init__( ) self.operating_point_movement = single_step_operating_point_movement - corresponding_airplane_movements = [airplane_movement.corresponding_airplane_movement for airplane_movement in self.airplane_movements] + corresponding_airplane_movements = [ + airplane_movement.corresponding_airplane_movement + for airplane_movement in self.airplane_movements + ] self.corresponding_movement = Movement( airplane_movements=corresponding_airplane_movements, operating_point_movement=self.operating_point_movement.corresponding_operating_point_movement, @@ -129,7 +131,9 @@ def __init__( self.delta_time = self.corresponding_movement.delta_time self.num_steps = self.corresponding_movement.num_steps - def generate_next_movement(self, base_airplanes, base_operating_point, step, deformation_matrices=None): + def generate_next_movement( + self, base_airplanes, base_operating_point, step, deformation_matrices=None + ): """Creates the Airplanes and OperatingPoint at a single time step. :param base_airplanes: The list of Airplanes at the current time step. diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py index 18f5ae1bf..7215d3e90 100644 --- a/pterasoftware/movements/single_step/single_step_operating_point_movement.py +++ b/pterasoftware/movements/single_step/single_step_operating_point_movement.py @@ -2,26 +2,26 @@ **Contains the following classes:** -SingleStepOperatingPointMovement: A single step variant of OperatingPointMovement -that generates one OperatingPoint per time step instead of all at once. Uses -composition to wrap an OperatingPointMovement. +SingleStepOperatingPointMovement: A single step variant of OperatingPointMovement that +generates one OperatingPoint per time step instead of all at once. Uses composition to +wrap an OperatingPointMovement. **Contains the following functions:** None """ -from ..operating_point_movement import OperatingPointMovement -from .._functions import ( - oscillating_sinspaces, - oscillating_linspaces, - oscillating_customspaces, -) -from ...operating_point import OperatingPoint from ..._parameter_validation import ( - number_in_range_return_float, int_in_range_return_int, + number_in_range_return_float, ) +from ...operating_point import OperatingPoint +from .._functions import ( + oscillating_customspaces, + oscillating_linspaces, + oscillating_sinspaces, +) +from ..operating_point_movement import OperatingPointMovement class SingleStepOperatingPointMovement: @@ -63,12 +63,11 @@ def __init__( OperatingPoint at each time step will be created. :param ampVCg__E: The amplitude of the SingleStepOperatingPointMovement's changes in its OperatingPoints' vCg__E parameters. Must be a non negative - number (int or float), and is converted to a float internally. The - amplitude must be low enough that it doesn't drive its base value out of - the range of valid values. Otherwise, this - SingleStepOperatingPointMovement will try to create OperatingPoints with - invalid parameter values. The units are in meters per second. The default - is 0.0. + number (int or float), and is converted to a float internally. The amplitude + must be low enough that it doesn't drive its base value out of the range of + valid values. Otherwise, this SingleStepOperatingPointMovement will try to + create OperatingPoints with invalid parameter values. The units are in + meters per second. The default is 0.0. :param periodVCg__E: The period of the SingleStepOperatingPointMovement's changes in its OperatingPoints' vCg__E parameter. Must be a non negative number (int or float), and is converted to a float internally. It must be @@ -76,18 +75,18 @@ def __init__( default is 0.0. :param spacingVCg__E: Determines the spacing of the SingleStepOperatingPointMovement's change in its OperatingPoints' vCg__E - parameters. Can be "sine", "uniform", or a callable custom spacing - function. Custom spacing functions are for advanced users and must start at - 0.0, return to 0.0 after one period of 2*pi radians, have amplitude of - 1.0, be periodic, return finite values only, and accept a ndarray as input - and return a ndarray of the same shape. The custom function is scaled by + parameters. Can be "sine", "uniform", or a callable custom spacing function. + Custom spacing functions are for advanced users and must start at 0.0, + return to 0.0 after one period of 2*pi radians, have amplitude of 1.0, be + periodic, return finite values only, and accept a ndarray as input and + return a ndarray of the same shape. The custom function is scaled by ampVCg__E, shifted horizontally and vertically by phaseVCg__E and the base value, and have a period set by periodVCg__E. The default is "sine". :param phaseVCg__E: The phase offset of the first time step's OperatingPoint's vCg__E parameter relative to the base OperatingPoint's vCg__E parameter. Must be a number (int or float) in the range (-180.0, 180.0], and will be - converted to a float internally. It must be 0.0 if ampVCg__E is 0.0 and - non zero if not. The units are in degrees. The default is 0.0. + converted to a float internally. It must be 0.0 if ampVCg__E is 0.0 and non + zero if not. The units are in degrees. The default is 0.0. :return: None """ @@ -109,7 +108,9 @@ def __init__( self.listVCg__E = None - def generate_next_operating_point(self, delta_time, base_operating_point: OperatingPoint, num_steps, step): + def generate_next_operating_point( + self, delta_time, base_operating_point: OperatingPoint, num_steps, step + ): """Creates the OperatingPoint at a single time step. :param delta_time: The time between each time step in seconds. @@ -156,18 +157,20 @@ def generate_next_operating_point(self, delta_time, base_operating_point: Operat def max_period(self): """The longest period of this SingleStepOperatingPointMovement's own motion. - :return: The longest period in seconds. If all the motion is static, this - will be 0.0. + :return: The longest period in seconds. If all the motion is static, this will + be 0.0. """ return self.periodVCg__E - def _initialize_oscillating_values(self, delta_time, num_steps, base_operating_point): + def _initialize_oscillating_values( + self, delta_time, num_steps, base_operating_point + ): """Pre computes the oscillating VCg__E values for all time steps. :param delta_time: The time between each time step in seconds. :param num_steps: The total number of time steps. - :param base_operating_point: The base OperatingPoint providing the base - VCg__E value. + :param base_operating_point: The base OperatingPoint providing the base VCg__E + value. :return: None """ # Generate oscillating values for VCg__E. diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py index c3c0e169e..1b80d0dd3 100644 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py @@ -2,9 +2,9 @@ **Contains the following classes:** -SingleStepWingCrossSectionMovement: A single step variant of -WingCrossSectionMovement that generates one WingCrossSection per time step instead of -all at once. Uses composition to wrap a WingCrossSectionMovement. +SingleStepWingCrossSectionMovement: A single step variant of WingCrossSectionMovement +that generates one WingCrossSection per time step instead of all at once. Uses +composition to wrap a WingCrossSectionMovement. **Contains the following functions:** @@ -13,20 +13,17 @@ import numpy as np -from ..wing_cross_section_movement import WingCrossSectionMovement - +from ... import geometry from ..._parameter_validation import ( int_in_range_return_int, - number_in_range_return_float + number_in_range_return_float, ) - from .._functions import ( - oscillating_sinspaces, - oscillating_linspaces, oscillating_customspaces, + oscillating_linspaces, + oscillating_sinspaces, ) - -from ... import geometry +from ..wing_cross_section_movement import WingCrossSectionMovement class SingleStepWingCrossSectionMovement: @@ -83,8 +80,8 @@ def __init__( converted to floats internally. Each amplitude must be low enough that it doesn't drive its base value out of the range of valid values. Otherwise, this SingleStepWingCrossSectionMovement will try to create WingCrossSections - with invalid parameter values. The units are in meters. The default is - (0.0, 0.0, 0.0). + with invalid parameter values. The units are in meters. The default is (0.0, + 0.0, 0.0). :param periodLp_Wcsp_Lpp: An array-like object of non negative numbers (int or float) with shape (3,) representing the periods of the SingleStepWingCrossSectionMovement's changes in its WingCrossSections' @@ -97,11 +94,11 @@ def __init__( changes in its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Each element can be the str "sine", the str "uniform", or a callable custom spacing function. Custom spacing functions are for - advanced users and must start at 0.0, return to 0.0 after one period of - 2*pi radians, have amplitude of 1.0, be periodic, return finite values - only, and accept a ndarray as input and return a ndarray of the same shape. - Custom functions are scaled by ampLp_Wcsp_Lpp, shifted horizontally and - vertically by phaseLp_Wcsp_Lpp and the base value, and have a period set by + advanced users and must start at 0.0, return to 0.0 after one period of 2*pi + radians, have amplitude of 1.0, be periodic, return finite values only, and + accept a ndarray as input and return a ndarray of the same shape. Custom + functions are scaled by ampLp_Wcsp_Lpp, shifted horizontally and vertically + by phaseLp_Wcsp_Lpp and the base value, and have a period set by periodLp_Wcsp_Lpp. The default is ("sine", "sine", "sine"). :param phaseLp_Wcsp_Lpp: An array-like object of numbers (int or float) with shape (3,) representing the phase offsets of the elements in the first time @@ -114,35 +111,34 @@ def __init__( :param ampAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative numbers (int or float) with shape (3,) representing the amplitudes of the SingleStepWingCrossSectionMovement's changes in its WingCrossSections' - angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. - Values are converted to floats internally. Each amplitude must be low enough - that it doesn't drive its base value out of the range of valid values. - Otherwise, this SingleStepWingCrossSectionMovement will try to create - WingCrossSections with invalid parameter values. The units are in degrees. - The default is (0.0, 0.0, 0.0). + angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values + are converted to floats internally. Each amplitude must be low enough that + it doesn't drive its base value out of the range of valid values. Otherwise, + this SingleStepWingCrossSectionMovement will try to create WingCrossSections + with invalid parameter values. The units are in degrees. The default is + (0.0, 0.0, 0.0). :param periodAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative numbers (int or float) with shape (3,) representing the periods of the SingleStepWingCrossSectionMovement's changes in its WingCrossSections' - angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. - Values are converted to floats internally. Each element must be 0.0 if the + angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values + are converted to floats internally. Each element must be 0.0 if the corresponding element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if not. The units are in seconds. The default is (0.0, 0.0, 0.0). - :param spacingAngles_Wcsp_to_Wcs_ixyz: An array-like object of strs or - callables with shape (3,) representing the spacing of the + :param spacingAngles_Wcsp_to_Wcs_ixyz: An array-like object of strs or callables + with shape (3,) representing the spacing of the SingleStepWingCrossSectionMovement's changes in its WingCrossSections' angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Each element can be the str "sine", the str "uniform", or a callable custom spacing function. Custom spacing functions are for advanced users and must - start at 0.0, return to 0.0 after one period of 2*pi radians, have - amplitude of 1.0, be periodic, return finite values only, and accept a - ndarray as input and return a ndarray of the same shape. Custom functions - are scaled by ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally and - vertically by phaseAngles_Wcsp_to_Wcs_ixyz and the base value, and have a - period set by periodAngles_Wcsp_to_Wcs_ixyz. The default is ("sine", - "sine", "sine"). + start at 0.0, return to 0.0 after one period of 2*pi radians, have amplitude + of 1.0, be periodic, return finite values only, and accept a ndarray as + input and return a ndarray of the same shape. Custom functions are scaled by + ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally and vertically by + phaseAngles_Wcsp_to_Wcs_ixyz and the base value, and have a period set by + periodAngles_Wcsp_to_Wcs_ixyz. The default is ("sine", "sine", "sine"). :param phaseAngles_Wcsp_to_Wcs_ixyz: An array-like object of numbers (int or - float) with shape (3,) representing the phase offsets of the elements in - the first time step's WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter + float) with shape (3,) representing the phase offsets of the elements in the + first time step's WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter relative to the base WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter. Can be a tuple, list, or ndarray. Elements must lie in the range (-180.0, 180.0]. Each element must be 0.0 if the corresponding element in @@ -153,10 +149,17 @@ def __init__( """ # Warn about potential deformation issues with multiple spanwise panels. - if base_wing_cross_section.num_spanwise_panels is not None and base_wing_cross_section.num_spanwise_panels > 1: - print("base_wing_cross_section must have num_spanwise_panels equal to None or 1 to do deformation. " + \ - "This wing cross section has " + str(base_wing_cross_section.num_spanwise_panels) + " spanwise panels. Please be sure this is intended. " + \ - "Applications that make sense for this are tails and non-primary wings.") + if ( + base_wing_cross_section.num_spanwise_panels is not None + and base_wing_cross_section.num_spanwise_panels > 1 + ): + print( + "base_wing_cross_section must have num_spanwise_panels equal to None or 1 to do deformation. " + + "This wing cross section has " + + str(base_wing_cross_section.num_spanwise_panels) + + " spanwise panels. Please be sure this is intended. " + + "Applications that make sense for this are tails and non-primary wings." + ) # Create the corresponding WingCrossSectionMovement, which validates all # oscillation parameters and is also needed by coupled unsteady problems. @@ -173,14 +176,30 @@ def __init__( ) # Copy validated attributes from the corresponding WingCrossSectionMovement. - self.ampLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.ampLp_Wcsp_Lpp - self.periodLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.periodLp_Wcsp_Lpp - self.spacingLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.spacingLp_Wcsp_Lpp - self.phaseLp_Wcsp_Lpp = self.corresponding_wing_cross_section_movement.phaseLp_Wcsp_Lpp - self.ampAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz - self.periodAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz - self.spacingAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.spacingAngles_Wcsp_to_Wcs_ixyz - self.phaseAngles_Wcsp_to_Wcs_ixyz = self.corresponding_wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz + self.ampLp_Wcsp_Lpp = ( + self.corresponding_wing_cross_section_movement.ampLp_Wcsp_Lpp + ) + self.periodLp_Wcsp_Lpp = ( + self.corresponding_wing_cross_section_movement.periodLp_Wcsp_Lpp + ) + self.spacingLp_Wcsp_Lpp = ( + self.corresponding_wing_cross_section_movement.spacingLp_Wcsp_Lpp + ) + self.phaseLp_Wcsp_Lpp = ( + self.corresponding_wing_cross_section_movement.phaseLp_Wcsp_Lpp + ) + self.ampAngles_Wcsp_to_Wcs_ixyz = ( + self.corresponding_wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz + ) + self.periodAngles_Wcsp_to_Wcs_ixyz = ( + self.corresponding_wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz + ) + self.spacingAngles_Wcsp_to_Wcs_ixyz = ( + self.corresponding_wing_cross_section_movement.spacingAngles_Wcsp_to_Wcs_ixyz + ) + self.phaseAngles_Wcsp_to_Wcs_ixyz = ( + self.corresponding_wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz + ) self.listLp_Wcsp_Lpp = None self.listAngles_Wcsp_to_Wcs_ixyz = None @@ -200,8 +219,8 @@ def generate_next_wing_cross_sections( :param delta_time: The time between each time step in seconds. :param num_steps: The total number of time steps in this movement. :param step: The index of the current time step. - :param deformation_matrix: Deformation matrix to apply to the - WingCrossSection's angles, or None. + :param deformation_matrix: Deformation matrix to apply to the WingCrossSection's + angles, or None. :return: A list containing the WingCrossSection at the specified time step. """ num_steps = int_in_range_return_int( @@ -245,10 +264,10 @@ def generate_next_wing_cross_sections( ) this_spanwise_spacing = base_wing_cross_section.spanwise_spacing - thisLp_Wcsp_Lpp = self.listLp_Wcsp_Lpp[:, step] - theseAngles_Wcsp_to_Wcs_ixyz = self.listAngles_Wcsp_to_Wcs_ixyz[ - :, step - ] + deformation_matrix + thisLp_Wcsp_Lpp = self.listLp_Wcsp_Lpp[:, step] + theseAngles_Wcsp_to_Wcs_ixyz = ( + self.listAngles_Wcsp_to_Wcs_ixyz[:, step] + deformation_matrix + ) # Make a new WingCrossSection for this time step. this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( @@ -324,7 +343,8 @@ def _initialize_oscillating_angles( num_steps, base_wing_cross_section, ): - """Pre computes the oscillating angles_Wcsp_to_Wcs_ixyz values for all time steps. + """Pre computes the oscillating angles_Wcsp_to_Wcs_ixyz values for all time + steps. :param delta_time: The time between each time step in seconds. :param num_steps: The total number of time steps. diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py index 29d237765..fbf6f2c93 100644 --- a/pterasoftware/movements/single_step/single_step_wing_movement.py +++ b/pterasoftware/movements/single_step/single_step_wing_movement.py @@ -12,27 +12,25 @@ import numpy as np -from ..wing_movement import WingMovement - +from ... import geometry from ..._parameter_validation import ( int_in_range_return_int, number_in_range_return_float, ) - from .._functions import ( - oscillating_sinspaces, - oscillating_linspaces, oscillating_customspaces, + oscillating_linspaces, + oscillating_sinspaces, ) +from ..wing_movement import WingMovement -from ... import geometry class SingleStepWingMovement: """A single step variant of WingMovement for coupled simulations. - This class wraps a WingMovement via composition and generates one Wing per time - step (via generate_next_wing) rather than generating all Wings at once. The - composed WingMovement is accessible via corresponding_wing_movement. + This class wraps a WingMovement via composition and generates one Wing per time step + (via generate_next_wing) rather than generating all Wings at once. The composed + WingMovement is accessible via corresponding_wing_movement. Wings cannot undergo motion that causes them to switch symmetry types. See the WingMovement class documentation for details. @@ -83,30 +81,29 @@ def __init__( WingCrossSections. :param ampLer_Gs_Cgs: An array-like object of non negative numbers (int or float) with shape (3,) representing the amplitudes of the - SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can - be a tuple, list, or ndarray. Values are converted to floats internally. - Each amplitude must be low enough that it doesn't drive its base value out - of the range of valid values. Otherwise, this SingleStepWingMovement will - try to create Wings with invalid parameter values. The units are in meters. - The default is (0.0, 0.0, 0.0). + SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can be + a tuple, list, or ndarray. Values are converted to floats internally. Each + amplitude must be low enough that it doesn't drive its base value out of the + range of valid values. Otherwise, this SingleStepWingMovement will try to + create Wings with invalid parameter values. The units are in meters. The + default is (0.0, 0.0, 0.0). :param periodLer_Gs_Cgs: An array-like object of non negative numbers (int or float) with shape (3,) representing the periods of the - SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can - be a tuple, list, or ndarray. Values are converted to floats internally. - Each element must be 0.0 if the corresponding element in ampLer_Gs_Cgs is - 0.0 and non zero if not. The units are in seconds. The default is (0.0, - 0.0, 0.0). + SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can be + a tuple, list, or ndarray. Values are converted to floats internally. Each + element must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and + non zero if not. The units are in seconds. The default is (0.0, 0.0, 0.0). :param spacingLer_Gs_Cgs: An array-like object of strs or callables with shape (3,) representing the spacing of the SingleStepWingMovement's change in its - Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or ndarray. Each - element can be the str "sine", the str "uniform", or a callable custom - spacing function. Custom spacing functions are for advanced users and must - start at 0.0, return to 0.0 after one period of 2*pi radians, have - amplitude of 1.0, be periodic, return finite values only, and accept a - ndarray as input and return a ndarray of the same shape. The custom function - is scaled by ampLer_Gs_Cgs, shifted horizontally and vertically by - phaseLer_Gs_Cgs and the base value, and have a period set by - periodLer_Gs_Cgs. The default is ("sine", "sine", "sine"). + Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or ndarray. Each element + can be the str "sine", the str "uniform", or a callable custom spacing + function. Custom spacing functions are for advanced users and must start at + 0.0, return to 0.0 after one period of 2*pi radians, have amplitude of 1.0, + be periodic, return finite values only, and accept a ndarray as input and + return a ndarray of the same shape. The custom function is scaled by + ampLer_Gs_Cgs, shifted horizontally and vertically by phaseLer_Gs_Cgs and + the base value, and have a period set by periodLer_Gs_Cgs. The default is + ("sine", "sine", "sine"). :param phaseLer_Gs_Cgs: An array-like object of numbers (int or float) with shape (3,) representing the phase offsets of the elements in the first time step's Wing's Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs @@ -115,19 +112,18 @@ def __init__( must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and non zero if not. The units are in degrees. The default is (0.0, 0.0, 0.0). :param ampAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float) - with shape (3,) representing the amplitudes of the - SingleStepWingMovement's changes in its Wings' angles_Gs_to_Wn_ixyz - parameters. Can be a tuple, list, or ndarray. Values must lie in the range - [0.0, 180.0] and will be converted to floats internally. Each amplitude - must be low enough that it doesn't drive its base value out of the range of - valid values. Otherwise, this SingleStepWingMovement will try to create - Wings with invalid parameter values. The units are in degrees. The default - is (0.0, 0.0, 0.0). + with shape (3,) representing the amplitudes of the SingleStepWingMovement's + changes in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, + or ndarray. Values must lie in the range [0.0, 180.0] and will be converted + to floats internally. Each amplitude must be low enough that it doesn't + drive its base value out of the range of valid values. Otherwise, this + SingleStepWingMovement will try to create Wings with invalid parameter + values. The units are in degrees. The default is (0.0, 0.0, 0.0). :param periodAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float) with shape (3,) representing the periods of the SingleStepWingMovement's changes in its Wings' angles_Gs_to_Wn_ixyz - parameters. Can be a tuple, list, or ndarray. Values are converted to - floats internally. Each element must be 0.0 if the corresponding element in + parameters. Can be a tuple, list, or ndarray. Values are converted to floats + internally. Each element must be 0.0 if the corresponding element in ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in seconds. The default is (0.0, 0.0, 0.0). :param spacingAngles_Gs_to_Wn_ixyz: An array-like object of strs or callables @@ -135,20 +131,19 @@ def __init__( change in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, or ndarray. Each element can be the str "sine", the str "uniform", or a callable custom spacing function. Custom spacing functions are for advanced - users and must start at 0.0, return to 0.0 after one period of 2*pi - radians, have amplitude of 1.0, be periodic, return finite values only, and - accept a ndarray as input and return a ndarray of the same shape. The custom - function is scaled by ampAngles_Gs_to_Wn_ixyz, shifted horizontally and - vertically by phaseAngles_Gs_to_Wn_ixyz and the base value, with the period - set by periodAngles_Gs_to_Wn_ixyz. The default is ("sine", "sine", - "sine"). - :param phaseAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or - float) with shape (3,) representing the phase offsets of the elements in - the first time step's Wing's angles_Gs_to_Wn_ixyz parameter relative to - the base Wing's angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or - ndarray. Values must lie in the range (-180.0, 180.0] and will be converted - to floats internally. Each element must be 0.0 if the corresponding element - in ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in + users and must start at 0.0, return to 0.0 after one period of 2*pi radians, + have amplitude of 1.0, be periodic, return finite values only, and accept a + ndarray as input and return a ndarray of the same shape. The custom function + is scaled by ampAngles_Gs_to_Wn_ixyz, shifted horizontally and vertically by + phaseAngles_Gs_to_Wn_ixyz and the base value, with the period set by + periodAngles_Gs_to_Wn_ixyz. The default is ("sine", "sine", "sine"). + :param phaseAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float) + with shape (3,) representing the phase offsets of the elements in the first + time step's Wing's angles_Gs_to_Wn_ixyz parameter relative to the base + Wing's angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or ndarray. + Values must lie in the range (-180.0, 180.0] and will be converted to floats + internally. Each element must be 0.0 if the corresponding element in + ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in degrees. The default is (0.0, 0.0, 0.0). :return: None """ @@ -178,15 +173,25 @@ def __init__( self.periodLer_Gs_Cgs = self.corresponding_wing_movement.periodLer_Gs_Cgs self.spacingLer_Gs_Cgs = self.corresponding_wing_movement.spacingLer_Gs_Cgs self.phaseLer_Gs_Cgs = self.corresponding_wing_movement.phaseLer_Gs_Cgs - self.ampAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.ampAngles_Gs_to_Wn_ixyz - self.periodAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.periodAngles_Gs_to_Wn_ixyz - self.spacingAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.spacingAngles_Gs_to_Wn_ixyz - self.phaseAngles_Gs_to_Wn_ixyz = self.corresponding_wing_movement.phaseAngles_Gs_to_Wn_ixyz + self.ampAngles_Gs_to_Wn_ixyz = ( + self.corresponding_wing_movement.ampAngles_Gs_to_Wn_ixyz + ) + self.periodAngles_Gs_to_Wn_ixyz = ( + self.corresponding_wing_movement.periodAngles_Gs_to_Wn_ixyz + ) + self.spacingAngles_Gs_to_Wn_ixyz = ( + self.corresponding_wing_movement.spacingAngles_Gs_to_Wn_ixyz + ) + self.phaseAngles_Gs_to_Wn_ixyz = ( + self.corresponding_wing_movement.phaseAngles_Gs_to_Wn_ixyz + ) self.listLer_Gs_Cgs = None self.listAngles_Gs_to_Wn_ixyz = None - def generate_next_wing(self, base_wing, delta_time, num_steps, step, deformation_matrices): + def generate_next_wing( + self, base_wing, delta_time, num_steps, step, deformation_matrices + ): """Creates the Wing at a single time step. :param base_wing: The base Wing from which the new Wing will be created. diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 732ba6b16..41162e22b 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -16,11 +16,10 @@ import math from typing import TYPE_CHECKING +import matplotlib.pyplot as plt import numpy as np -import scipy.signal as sp_sig - from scipy.integrate import solve_ivp -import matplotlib.pyplot as plt + from . import _parameter_validation, _transformations, geometry, movements from . import operating_point as operating_point_mod @@ -317,8 +316,8 @@ def steady_problems(self) -> tuple[SteadyProblem, ...]: class CoupledUnsteadyProblem(UnsteadyProblem): """A class for coupled unsteady problems. - This class extends UnsteadyProblem to manage multiple SteadyProblems for - coupled simulations where each time step has its own SteadyProblem. + This class extends UnsteadyProblem to manage multiple SteadyProblems for coupled + simulations where each time step has its own SteadyProblem. **Contains the following methods:** @@ -337,14 +336,15 @@ def __init__( ) -> None: """The initialization method. - Initializes the aeroelastic problem with structural parameters and motion definitions. - Sets up storage for aerodynamic loads, wing deformations, moments, and solver state. + Initializes the aeroelastic problem with structural parameters and motion + definitions. Sets up storage for aerodynamic loads, wing deformations, moments, + and solver state. - :param single_step_movement: A SingleStepMovement object containing the prescribed - motion and aerodynamic setup for the coupled simulation. - :param only_final_results: If True, only calculate forces and moments for the final - motion cycle. Can be a bool or numpy bool and will be converted to bool internally. - The default is False. + :param single_step_movement: A SingleStepMovement object containing the + prescribed motion and aerodynamic setup for the coupled simulation. + :param only_final_results: If True, only calculate forces and moments for the + final motion cycle. Can be a bool or numpy bool and will be converted to + bool internally. The default is False. :return: None """ if not isinstance( @@ -388,9 +388,9 @@ def get_steady_problem(self, step: int) -> SteadyProblem: def initialize_next_problem(self, solver) -> None: """Initialize the next time step's problem with updated wing deformations. - Computes cumulative wing deformations from aerodynamic and inertial loads, - then creates the next SteadyProblem with deformed airplanes. Updates the - current airplane and operating point state. + Computes cumulative wing deformations from aerodynamic and inertial loads, then + creates the next SteadyProblem with deformed airplanes. Updates the current + airplane and operating point state. :param solver: The solver instance providing aerodynamic moment data. :return: None @@ -401,36 +401,44 @@ def initialize_next_problem(self, solver) -> None: class AeroelasticUnsteadyProblem(CoupledUnsteadyProblem): - """A subclass of CoupledUnsteadyProblem used to couple aeroelastic wing deformations with unsteady aerodynamics. + """A subclass of CoupledUnsteadyProblem used to couple aeroelastic wing deformations + with unsteady aerodynamics. - This class couples aerodynamic loads with wing structural dynamics (spring-mass-damper system) to - simulate aeroelastic deformation. Each time step, wing deformations are calculated based on the - combined effects of aerodynamic moments, inertial forces, and spring-damper restoring forces. + This class couples aerodynamic loads with wing structural dynamics (spring-mass- + damper system) to simulate aeroelastic deformation. Each time step, wing + deformations are calculated based on the combined effects of aerodynamic moments, + inertial forces, and spring-damper restoring forces. **Contains the following methods:** - calculate_wing_panel_accelerations: Computes panel accelerations from finite difference of positions. + calculate_wing_panel_accelerations: Computes panel accelerations from finite + difference of positions. calculate_mass_matrix: Generates the mass distribution matrix for wing panels. - calculate_wing_deformation: Computes cumulative wing deformation for the current step. + calculate_wing_deformation: Computes cumulative wing deformation for the current + step. - calculate_spring_moments: Calculates spring-damper moments acting on each spanwise section. + calculate_spring_moments: Calculates spring-damper moments acting on each spanwise + section. - calculate_torsional_spring_moment: Solves the torsional spring-damper ODE for a single span section. + calculate_torsional_spring_moment: Solves the torsional spring-damper ODE for a + single span section. - generate_inertial_torque_function: Creates a torque function from prescribed wing motion. + generate_inertial_torque_function: Creates a torque function from prescribed wing + motion. - spring_numerical_ode: Numerically integrates the spring-damper differential equation. + spring_numerical_ode: Numerically integrates the spring-damper differential + equation. plot_flap_cycle_curves: Visualizes moment and deformation time histories. **Notes:** - The aeroelastic coupling assumes a torsional spring-mass-damper model for each spanwise section. - Wing motion is prescribed through wing flapping, and aerodynamic moments from the solver are - combined with inertial and spring restoring forces via ODE integration to produce structural - deformations. + The aeroelastic coupling assumes a torsional spring-mass-damper model for each + spanwise section. Wing motion is prescribed through wing flapping, and aerodynamic + moments from the solver are combined with inertial and spring restoring forces via + ODE integration to produce structural deformations. """ def __init__( @@ -455,10 +463,10 @@ def __init__( See CoupledUnsteadyProblem's initialization method for descriptions of inherited parameters (single_step_movement and only_final_results). - :param wing_density: The mass per unit span area of the wing (kg/m^2). Used - to distribute wing mass across panels for inertial calculations. - :param spring_constant: The torsional spring stiffness for the spring-mass-damper - model (N*m/rad). Controls the restoring torque opposing deformation. + :param wing_density: The mass per unit span area of the wing (kg/m^2). Used to + distribute wing mass across panels for inertial calculations. + :param spring_constant: The torsional spring stiffness for the spring-mass- + damper model (N*m/rad). Controls the restoring torque opposing deformation. :param damping_constant: The torsional damping coefficient (N*m*s/rad). Controls the viscous damping in the spring-mass-damper system. :param aero_scaling: A scaling factor applied to aerodynamic moments (unitless). @@ -466,13 +474,15 @@ def __init__( :param moment_scaling_factor: A scaling factor applied to the computed wing deformation angles (unitless). The default is 1.0. Useful for adjusting the magnitude of structural response. - :param damping_eps: The critical damping tolerance used for diagnostics (unitless). - The default is 1e-3. This parameter is not currently used in the solver. - :param plot_flap_cycle: If True, plots time histories of moments and deformations - at the end of the simulation. The default is False. + :param damping_eps: The critical damping tolerance used for diagnostics + (unitless). The default is 1e-3. This parameter is not currently used in the + solver. + :param plot_flap_cycle: If True, plots time histories of moments and + deformations at the end of the simulation. The default is False. :param custom_spacing_second_derivative: An optional callable function of time - that returns the second time derivative of a custom wing motion spacing function. - Required if custom (non-sinusoidal) wing motion spacing is used. The default is None. + that returns the second time derivative of a custom wing motion spacing + function. Required if custom (non-sinusoidal) wing motion spacing is used. + The default is None. :return: None """ super().__init__( @@ -525,8 +535,8 @@ def __init__( def calculate_wing_panel_accelerations(self) -> np.ndarray: """Compute panel accelerations using finite difference of stored positions. - Calculates second-order accelerations using the finite difference formula: - a = (p[n] - 2*p[n-1] + p[n-2]) / dt^2. + Calculates second-order accelerations using the finite difference formula: a = + (p[n] - 2*p[n-1] + p[n-2]) / dt^2. :return: An (N_chordwise, N_spanwise, 3) ndarray of floats representing panel center accelerations in the global frame. Returns zeros if fewer than 3 @@ -545,13 +555,13 @@ def calculate_wing_panel_accelerations(self) -> np.ndarray: def calculate_mass_matrix(self, wing: geometry.wing.Wing) -> np.ndarray: """Generate the mass distribution matrix for all wing panels. - Distributes the total spanwise mass (wing_density) across panel areas to form - a panel-by-panel mass matrix. Each panel's mass is proportional to its area - times the specified wing_density. + Distributes the total spanwise mass (wing_density) across panel areas to form a + panel-by-panel mass matrix. Each panel's mass is proportional to its area times + the specified wing_density. :param wing: A Wing object whose panels define the mass distribution. - :return: An (N_chordwise, N_spanwise, 3) ndarray of floats representing the - mass at each panel. The three components are identical (mass scalar replicated + :return: An (N_chordwise, N_spanwise, 3) ndarray of floats representing the mass + at each panel. The three components are identical (mass scalar replicated for x, y, z axes). """ areas = np.array([[panel.area for panel in row] for row in wing.panels]) @@ -584,14 +594,15 @@ def calculate_wing_deformation( ) -> np.ndarray: """Compute cumulative wing deformation for the current time step. - Orchestrates the calculation of inertial moments, spring moments, and - cumulative deformation. Updates internal state and optionally generates plots. + Orchestrates the calculation of inertial moments, spring moments, and cumulative + deformation. Updates internal state and optionally generates plots. - :param solver: The solver instance providing aerodynamic moment data (moments_GP1_Slep). + :param solver: The solver instance providing aerodynamic moment data + (moments_GP1_Slep). :param step: The current time step index (0-indexed). - :return: An (N_spanwise+1, 3) ndarray of floats representing cumulative deformation - angles at each spanwise station. The y-component (index 1) contains torsional - angles in radians; x and z components are zero. + :return: An (N_spanwise+1, 3) ndarray of floats representing cumulative + deformation angles at each spanwise station. The y-component (index 1) + contains torsional angles in radians; x and z components are zero. """ curr_problem: SteadyProblem = self.coupled_steady_problems[-1] airplane = curr_problem.airplanes[0] @@ -613,7 +624,12 @@ def calculate_wing_deformation( solver, num_chordwise_panels, num_spanwise_panels, num_panels ) inertial_moments = self._calculate_inertial_moments( - solver, wing, mass_matrix, num_chordwise_panels, num_spanwise_panels, num_panels + solver, + wing, + mass_matrix, + num_chordwise_panels, + num_spanwise_panels, + num_panels, ) # Calculate spring moments and deformation via ODE integration @@ -719,8 +735,8 @@ def _build_deformation_vector( """Construct the step deformation vector from torsional angles. Converts the torsional angles output from the spring-damper ODE (one per - spanwise section) into a full (N_spanwise+1, 3) deformation vector with - scaling applied to the y-component (torsional angle). + spanwise section) into a full (N_spanwise+1, 3) deformation vector with scaling + applied to the y-component (torsional angle). :param thetas: An (N_spanwise+1,) ndarray of torsional angles in radians. :param num_spanwise_panels: Number of spanwise panel rows. @@ -761,8 +777,10 @@ def _apply_moment_updates( :param step: The current time step index. :param step_deformation: The (N_spanwise+1, 3) deformation vector for this step. :param omegas: An (N_spanwise+1,) ndarray of angular velocities. - :param inertial_moments: An (N_chordwise, N_spanwise, 3) ndarray of inertial moments. - :param aeroMoments_GP1_Slep: An (N_chordwise, N_spanwise, 3) ndarray of aero moments. + :param inertial_moments: An (N_chordwise, N_spanwise, 3) ndarray of inertial + moments. + :param aeroMoments_GP1_Slep: An (N_chordwise, N_spanwise, 3) ndarray of aero + moments. :param spring_moments: An (N_spanwise, 3) ndarray of spring-damper moments. :param wing: The Wing object for accessing undeformed geometry. :return: None @@ -863,24 +881,22 @@ def calculate_spring_moments( Solves the torsional spring-damper ODE independently for each spanwise section, accounting for aerodynamic moments, inertial forces, and structural properties. - Uses the parallel axis theorem to compute rotational inertia about the flapping axis. + Uses the parallel axis theorem to compute rotational inertia about the flapping + axis. :param num_spanwise_panels: Number of spanwise panel rows in the wing. :param wing: The Wing object containing geometric and structural definitions. :param mass_matrix: An (N_chordwise, N_spanwise, 3) ndarray of panel masses. - :param aero_moments: An (N_chordwise, N_spanwise, 3) ndarray of aerodynamic moments - from the aerodynamic solver. + :param aero_moments: An (N_chordwise, N_spanwise, 3) ndarray of aerodynamic + moments from the aerodynamic solver. :param step: The current time step index. - :return: A tuple of three ndarrays: - - thetas: (N_spanwise+1,) ndarray of torsional angles (radians) at each station. - - omegas: (N_spanwise+1,) ndarray of angular velocities (rad/s) at each station. - - spring_moments: (N_spanwise, 3) ndarray of spring-damper moment vectors. - - **Notes:** - - The rotational inertia is computed as: I = (1/12)*M*(L^2 + W^2) + M*d^2, - where M is panel mass, L is chord, W is span width, and d is distance from - the flapping axis (computed cumulatively using the parallel axis theorem). + :return: A tuple of three ndarrays: - thetas: (N_spanwise+1,) ndarray of + torsional angles (radians) at each station. - omegas: (N_spanwise+1,) + ndarray of angular velocities (rad/s) at each station. - spring_moments: + (N_spanwise, 3) ndarray of spring-damper moment vectors. **Notes:** The + rotational inertia is computed as: I = (1/12)*M*(L^2 + W^2) + M*d^2, where M + is panel mass, L is chord, W is span width, and d is distance from the + flapping axis (computed cumulatively using the parallel axis theorem). """ spring_moments = np.zeros((num_spanwise_panels, 3)) thetas = np.zeros(num_spanwise_panels + 1) @@ -912,7 +928,7 @@ def calculate_spring_moments( dt, # A potential knob to tweak in representation of the torsional inertia # I=mass * (wing.wing_cross_sections[span_panel].chord ** 2) / 2, - I= 1/2 * mass * (L ** 2), + I=1 / 2 * mass * (L**2), # I=span_I, theta0=theta0, omega0=omega0, @@ -940,26 +956,26 @@ def calculate_torsional_spring_moment( ) -> tuple[float, float, float]: """Solve the torsional spring-damper ODE for a single wing section. - Integrates the forced torsional damped harmonic oscillator equation: - I*dω/dt = τ_aero + τ_inertial - k*θ - c*ω + Integrates the forced torsional damped harmonic oscillator equation: I*dω/dt = + τ_aero + τ_inertial - k*θ - c*ω - Returns the angular displacement and velocity at the end of the time step, - along with the spring-damper restoring moment. + Returns the angular displacement and velocity at the end of the time step, along + with the spring-damper restoring moment. :param dt: The time step duration (seconds). :param I: The rotational inertia about the flapping axis (kg*m^2). :param theta0: Initial torsional angle at the start of the time step (radians). :param omega0: Initial angular velocity at the start of the time step (rad/s). - :param aero_span_moment: The z-component aerodynamic moment summed over chordwise - panels for this spanwise section (N*m). + :param aero_span_moment: The z-component aerodynamic moment summed over + chordwise panels for this spanwise section (N*m). :param step: The current time step index (used for inertial torque evaluation). :param span_I: The rotational inertia including parallel axis theorem (kg*m^2). This is the actual inertia used in the ODE solver. - :param num_steps: Number of time sub-steps for numerical integration. The default is 2. - :return: A tuple of (theta, omega, spring_moment) where: - - theta: Final torsional angle (radians). - - omega: Final angular velocity (rad/s). - - spring_moment: The z-component spring-damper moment τ = -k*θ - c*ω (N*m). + :param num_steps: Number of time sub-steps for numerical integration. The + default is 2. + :return: A tuple of (theta, omega, spring_moment) where: - theta: Final + torsional angle (radians). - omega: Final angular velocity (rad/s). - + spring_moment: The z-component spring-damper moment τ = -k*θ - c*ω (N*m). """ k = self.spring_constant c = self.damping_constant @@ -989,16 +1005,13 @@ def generate_inertial_torque_function(self, span_I: float): creates a callable inertial torque function τ_inertial = I * d²θ_prescribed/dt². Supports sinusoidal and custom spacing functions. - :param span_I: The rotational inertia of the wing span section about the flapping - axis (kg*m^2). + :param span_I: The rotational inertia of the wing span section about the + flapping axis (kg*m^2). :return: A callable function that accepts time and returns the inertial torque - (N*m) due to the prescribed wing motion acceleration. - - **Notes:** - - For sinusoidal spacing: τ = -I * b^2 * sin(b*t + h) * A, - where b = 2π/period, h = phase, A = amplitude. - For custom spacing, requires custom_spacing_second_derivative to be defined. + (N*m) due to the prescribed wing motion acceleration. **Notes:** For + sinusoidal spacing: τ = -I * b^2 * sin(b*t + h) * A, where b = 2π/period, h + = phase, A = amplitude. For custom spacing, requires + custom_spacing_second_derivative to be defined. """ amp = self.wing_movement.ampAngles_Gs_to_Wn_ixyz[0] b = 2 * np.pi / self.wing_movement.periodAngles_Gs_to_Wn_ixyz[0] @@ -1036,16 +1049,16 @@ def spring_numerical_ode( ) -> tuple[float, float]: """Numerically integrate the torsional spring-damper ODE. - Solves the second-order forced ODE: - I * d²θ/dt² = τ_aero + τ_inertial(t) - k*θ - c*dθ/dt + Solves the second-order forced ODE: I * d²θ/dt² = τ_aero + τ_inertial(t) - k*θ - + c*dθ/dt using scipy.integrate.solve_ivp with strict tolerances. :param t: A (N,) ndarray of time points for integration evaluation. :param k: Spring constant (N*m/rad). :param c: Damping constant (N*m*s/rad). - :param I: Rotational inertia (kg*m^2). This parameter is present for API consistency - but is not used in the ODE solver; use only the solution state. + :param I: Rotational inertia (kg*m^2). This parameter is present for potential + alternative models of inertia. :param theta0: Initial angular displacement (radians). :param omega0: Initial angular velocity (rad/s). :param aero_torque: Constant aerodynamic torque acting on the section (N*m). @@ -1081,22 +1094,19 @@ def plot_flap_cycle_curves( ) -> None: """Visualize time histories of moments, deformations, or forces. - Creates a multi-curve line plot showing moment or deformation values across - all time steps, with optional overlay of a reference flap cycle. + Creates a multi-curve line plot showing moment or deformation values across all + time steps, with optional overlay of a reference flap cycle. :param data: A list of lists where each inner list represents a curve to plot. Values in each curve are plotted against step number. - :param title: The title for the plot and the output PNG filename (spaces replaced - with underscores). + :param title: The title for the plot and the output PNG filename (spaces + replaced with underscores). :param flap_cycle: Optional reference curve to overlay on the plot. If provided, should be a list of values to plot with label "Flap Cycle" in black. The default is None. - :return: None - - **Notes:** - - The plot is saved as a PNG file with the title as the filename. The plot window - is displayed to the user. Figure size is 12x6 inches at 200 DPI. + :return: None **Notes:** The plot is saved as a PNG file with the title as the + filename. The plot window is displayed to the user. Figure size is 12x6 + inches at 200 DPI. """ plt.figure(figsize=(12, 6), dpi=200) diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index bd3f2d1dd..08d46d1fa 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -620,8 +620,7 @@ def _initialize_panel_vortices(self) -> None: self._initialize_panel_vortex(steady_problem, steady_problem_id) def _initialize_panel_vortex(self, steady_problem, steady_problem_id): - """ - Initializes the bound RingVortex for each Panel in the given SteadyProblem. + """Initializes the bound RingVortex for each Panel in the given SteadyProblem. Every Panel has a RingVortex, which is a quadrangle whose front leg is a LineVortex at the Panel's quarter chord. The left and right legs are @@ -630,8 +629,10 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id): the rear Panel's quarter chord. Otherwise, they extend backwards and meet the back LineVortex one quarter chord back from the Panel's back leg. - :param steady_problem: The SteadyProblem for which to initialize the bound RingVortices. - :param steady_problem_id: The index of the given SteadyProblem in the list of SteadyProblems. + :param steady_problem: The SteadyProblem for which to initialize the bound + RingVortices. + :param steady_problem_id: The index of the given SteadyProblem in the list of + SteadyProblems. :return: None """ this_operating_point = steady_problem.operating_point @@ -674,14 +675,10 @@ def _initialize_panel_vortex(self, steady_problem, steady_problem_id): chordwise_position + 1, spanwise_position ] - _nextFlbvp_GP1_CgP1 = ( - next_chordwise_panel.Flbvp_GP1_CgP1 - ) + _nextFlbvp_GP1_CgP1 = next_chordwise_panel.Flbvp_GP1_CgP1 assert _nextFlbvp_GP1_CgP1 is not None - _nextFrbvp_GP1_CgP1 = ( - next_chordwise_panel.Frbvp_GP1_CgP1 - ) + _nextFrbvp_GP1_CgP1 = next_chordwise_panel.Frbvp_GP1_CgP1 assert _nextFrbvp_GP1_CgP1 is not None Blrvp_GP1_CgP1 = _nextFlbvp_GP1_CgP1 @@ -1608,20 +1605,28 @@ def _calculate_loads(self) -> None: frontLegForces_GP1, leftLegForces_GP1, backLegForces_GP1, - unsteady_forces_GP1 + unsteady_forces_GP1, ) # TODO: Transform forces_GP1 and moments_GP1_CgP1 to each Airplane's local # geometry axes before passing to process_solver_loads. _functions.process_solver_loads(self, forces_GP1, moments_GP1_CgP1) - def _load_calculation_moment_processing_hook(self, rightLegForces_GP1, frontLegForces_GP1, leftLegForces_GP1, backLegForces_GP1, unsteady_forces_GP1) -> np.ndarray: - """A hook method for processing the moments calculated in _calculate_loads. - This is added to allow for overriding the moment calculation in a child class + def _load_calculation_moment_processing_hook( + self, + rightLegForces_GP1, + frontLegForces_GP1, + leftLegForces_GP1, + backLegForces_GP1, + unsteady_forces_GP1, + ) -> np.ndarray: + """A hook method for processing the moments calculated in _calculate_loads. This + is added to allow for overriding the moment calculation in a child class. - :return: moments_GP1_CgP1, a (N,3) ndarray of floats representing the moments - (in the first Airplane's geometry axes, relative to the first Airplane's CG) - on every Panel at the current time step.""" + :return: moments_GP1_CgP1, a (N,3) ndarray of floats representing the moments + (in the first Airplane's geometry axes, relative to the first Airplane's CG) + on every Panel at the current time step. + """ # Find the moments (in the first Airplane's geometry axes, relative to the # first Airplane's CG) on the Panels' RingVortex's right LineVortex, # front LineVortex, left LineVortex, and back LineVortex. @@ -2279,7 +2284,9 @@ def _finalize_loads(self) -> None: for step in range(self._first_averaging_step, self.num_steps): # Get the Airplanes from the SteadyProblem at this time step. - this_steady_problem: problems.SteadyProblem = self.get_steady_problem_at(step) + this_steady_problem: problems.SteadyProblem = self.get_steady_problem_at( + step + ) these_airplanes = this_steady_problem.airplanes # Iterate through this time step's Airplanes. @@ -2368,10 +2375,11 @@ def _finalize_loads(self) -> None: ) def get_steady_problem_at(self, step: int) -> problems.SteadyProblem: - """Gets the SteadyProblem at a given time step. This is used for dynamic dispatch - with coupled unsteady problem as we want to have a different way of getting the steady - problem based on the solver type, but we want functions to work the same way regardless - of the solver type so that we don't need ot duplicate functionality across solvers. + """Gets the SteadyProblem at a given time step. This is used for dynamic + dispatch with coupled unsteady problem as we want to have a different way of + getting the steady problem based on the solver type, but we want functions to + work the same way regardless of the solver type so that we don't need to + duplicate functionality across solvers. :param step: An int representing the time step of the desired SteadyProblem. It must be between 0 and num_steps - 1, inclusive. From 60649113bf2f9e9b65bcf48160a5fa26755947fd Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Thu, 26 Mar 2026 15:19:35 -0400 Subject: [PATCH 34/40] mypy fixes --- ...led_unsteady_ring_vortex_lattice_method.py | 14 ++-- pterasoftware/geometry/wing.py | 1 + .../single_step_airplane_movement.py | 2 + .../single_step_operating_point_movement.py | 1 + pterasoftware/problems.py | 73 +++++++++++-------- .../unsteady_ring_vortex_lattice_method.py | 2 +- 6 files changed, 54 insertions(+), 39 deletions(-) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py index e8d706fae..304781984 100644 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -107,23 +107,25 @@ def __init__( # Store computed steady problems for each time step to be assigned to the # CoupledUnsteadyProblem after solve completes. This avoids overwriting the # initial steady problems until data visualization/post-processing stage. - self.steady_problems_data_storage = [] + self.steady_problems_data_storage: list[problems.SteadyProblem] = [] # Initialize SLEP (Strip Leading Edge Point) information. For each airplane and wing, # we track the panel index where each new spanwise strip begins. This allows efficient # computation of moments about the strip leading edge (wing root to tip). num_panels = 0 panel_count = 0 - self.slep_point_indices = [] + slep_point_indices_list: list[int] = [] for airplane in first_steady_problem.airplanes: num_panels += airplane.num_panels for wing in airplane.wings: for wing_cross_section in wing.wing_cross_sections: # Record the first panel index for this wing cross-section (start of strip) - self.slep_point_indices.append(panel_count) + slep_point_indices_list.append(panel_count) if wing_cross_section.num_spanwise_panels is not None: panel_count += wing_cross_section.num_spanwise_panels - self.slep_point_indices = np.array(self.slep_point_indices, dtype=int) + self.slep_point_indices: np.ndarray = np.array( + slep_point_indices_list, dtype=int + ) self.num_panels: int = num_panels # The current time step's center bound LineVortex points for the right, @@ -181,7 +183,7 @@ def run( this_problem: problems.SteadyProblem = self.get_steady_problem_at(0) these_airplanes = this_problem.airplanes num_wing_panels = 0 - these_wings: list[list[geometry.wing.Wing]] = [] + these_wings: list[tuple[geometry.wing.Wing, ...]] = [] for airplane in these_airplanes: these_wings.append(airplane.wings) num_wing_panels += airplane.num_panels @@ -467,7 +469,7 @@ def run( _functions.calculate_streamlines(self) # Mark that the solver has run. - self.steady_problems = self.steady_problems_data_storage + self.steady_problems = tuple(self.steady_problems_data_storage) self.ran = True def initialize_step_geometry(self, step: int) -> None: diff --git a/pterasoftware/geometry/wing.py b/pterasoftware/geometry/wing.py index 2fa72ab2a..d0620da18 100644 --- a/pterasoftware/geometry/wing.py +++ b/pterasoftware/geometry/wing.py @@ -1552,6 +1552,7 @@ def interpolate_between_wing_cross_sections( ) N = wcs1.num_spanwise_panels + assert N is not None, "wcs1.num_spanwise_panels must not be None" for i in range(N): t = (i + 1) / N # interpolation parameter between 0 and 1 diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py index dc06f5ed3..6722d3020 100644 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ b/pterasoftware/movements/single_step/single_step_airplane_movement.py @@ -204,6 +204,8 @@ def generate_next_airplane( delta_time, num_steps, base_airplane ) + assert self.listCg_GP1_CgP1 is not None + wings = [] # Iterate through the WingMovements. diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py index 7215d3e90..13a0c3566 100644 --- a/pterasoftware/movements/single_step/single_step_operating_point_movement.py +++ b/pterasoftware/movements/single_step/single_step_operating_point_movement.py @@ -134,6 +134,7 @@ def generate_next_operating_point( base_operating_point=base_operating_point, ) + assert self.listVCg__E is not None # Get the non-changing OperatingPoint attributes. this_rho = base_operating_point.rho this_alpha = base_operating_point.alpha diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 41162e22b..c536a1a92 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -490,14 +490,14 @@ def __init__( only_final_results=only_final_results, ) self.plot_flap_cycle = plot_flap_cycle - self.prev_velocities = [] + self.prev_velocities: list[np.ndarray] = [] self.curr_airplanes = [self.movement.airplane_movements[0].base_airplane] self.curr_operating_point = ( self.movement.operating_point_movement.base_operating_point ) - self.positions = [] - self.net_deformation = None - self.angluar_velocities = None + self.positions: list[np.ndarray] = [] + self.net_deformation: np.ndarray = np.zeros((0, 3)) + self.angluar_velocities: np.ndarray = np.zeros((0, 3)) # Tunable Parameters self.wing_density = wing_density # per unit height kg/m^2 @@ -520,14 +520,14 @@ def __init__( 0 ].wing_movements[0] - self.per_step_data = [] - self.net_data = [] - self.angluar_velocity_data = [] - self.per_step_inertial = [] - self.per_step_aero = [] - self.per_step_spring = [] - self.base_wing_positions = None - self.flap_points = [] + self.per_step_data: list[np.ndarray] = [] + self.net_data: list[np.ndarray] = [] + self.angluar_velocity_data: list[np.ndarray] = [] + self.per_step_inertial: list[np.ndarray] = [] + self.per_step_aero: list[np.ndarray] = [] + self.per_step_spring: list[np.ndarray] = [] + self.base_wing_positions: np.ndarray = np.zeros(0) + self.flap_points: list[np.ndarray] = [] # For custom spacing defined in movement. self.custom_spacing_second_derivative = custom_spacing_second_derivative @@ -543,14 +543,17 @@ def calculate_wing_panel_accelerations(self) -> np.ndarray: position snapshots are available. """ if len(self.positions) <= 2: + if len(self.positions) == 0: + return np.zeros(1) return np.zeros_like(self.positions[0]) dt = self.movement.delta_time # If given a relatively large dt value, the finite difference calculation can produce # very large accelerations that cause numerical instability in the spring ODE integration. # A higher order model may be useful if this is the case. - return (self.positions[-1] - 2 * self.positions[-2] + self.positions[-3]) / ( - dt * dt - ) + pos_m1: np.ndarray = self.positions[-1] + pos_m2: np.ndarray = self.positions[-2] + pos_m3: np.ndarray = self.positions[-3] + return np.array((pos_m1 - 2 * pos_m2 + pos_m3) / (dt * dt)) def calculate_mass_matrix(self, wing: geometry.wing.Wing) -> np.ndarray: """Generate the mass distribution matrix for all wing panels. @@ -564,6 +567,7 @@ def calculate_mass_matrix(self, wing: geometry.wing.Wing) -> np.ndarray: at each panel. The three components are identical (mass scalar replicated for x, y, z axes). """ + assert wing.panels is not None areas = np.array([[panel.area for panel in row] for row in wing.panels]) return np.repeat(areas[:, :, None], 3, axis=2) * self.wing_density @@ -611,11 +615,12 @@ def calculate_wing_deformation( # Compute panel parameters and mass matrix once num_chordwise_panels = wing.num_chordwise_panels num_spanwise_panels = wing.num_spanwise_panels + assert num_spanwise_panels is not None, "num_spanwise_panels must not be None" num_panels = num_chordwise_panels * num_spanwise_panels mass_matrix = self.calculate_mass_matrix(wing) # Initialize deformation state if needed - if self.net_deformation is None: + if self.net_deformation.size == 0: self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) self.angluar_velocities = np.zeros((num_spanwise_panels + 1, 3)) @@ -650,7 +655,6 @@ def calculate_wing_deformation( inertial_moments=inertial_moments, aeroMoments_GP1_Slep=aeroMoments_GP1_Slep, spring_moments=spring_moments, - wing=wing, ) # Plot results at end of simulation if enabled @@ -711,6 +715,7 @@ def _calculate_inertial_moments( :return: An (N_chordwise, N_spanwise, 3) ndarray of inertial moment vectors. """ # Store current panel center positions + assert wing.panels is not None self.positions.append( np.array([[panel.Cpp_GP1_CgP1 for panel in row] for row in wing.panels]) ) @@ -727,7 +732,7 @@ def _calculate_inertial_moments( inertial_forces, axis=2, ) - return inertial_moments + return np.array(inertial_moments) def _build_deformation_vector( self, thetas: np.ndarray, num_spanwise_panels: int @@ -766,7 +771,6 @@ def _apply_moment_updates( inertial_moments: np.ndarray, aeroMoments_GP1_Slep: np.ndarray, spring_moments: np.ndarray, - wing: geometry.wing.Wing, ) -> None: """Update internal moment and deformation state arrays. @@ -782,7 +786,6 @@ def _apply_moment_updates( :param aeroMoments_GP1_Slep: An (N_chordwise, N_spanwise, 3) ndarray of aero moments. :param spring_moments: An (N_spanwise, 3) ndarray of spring-damper moments. - :param wing: The Wing object for accessing undeformed geometry. :return: None """ # Update angular velocity state @@ -790,10 +793,11 @@ def _apply_moment_updates( # Initialize baseline wing positions for flap point tracking undeformed_wing = self.steady_problems[step].airplanes[0].wings[0] + assert undeformed_wing.panels is not None undeformed_positions = np.array( [[panel.Cpp_GP1_CgP1 for panel in row] for row in undeformed_wing.panels] ) - if self.base_wing_positions is None: + if self.base_wing_positions.size == 0: self.base_wing_positions = np.array(undeformed_positions) # Track wing deflection relative to undeformed baseline @@ -904,10 +908,9 @@ def calculate_spring_moments( d = 0.0 # distance from flapping axis to panel centroid (computed in half-span increments) for span_panel in range(num_spanwise_panels): aero_span_moment = np.sum(aero_moments[:, span_panel, 2]) - if span_panel == 0: - theta0 = 0.0 - omega0 = 0.0 - else: + theta0: float = 0.0 + omega0: float = 0.0 + if span_panel != 0: theta0 = self.net_deformation[span_panel][1] omega0 = self.angluar_velocities[span_panel][1] @@ -921,7 +924,8 @@ def calculate_spring_moments( wing.wing_cross_sections[span_panel].chord + wing.wing_cross_sections[span_panel + 1].chord ) / 2 - W = np.linalg.norm(wing.panels[0][span_panel].frontLeg_G) + assert wing.panels is not None + W: float = float(np.linalg.norm(wing.panels[0][span_panel].frontLeg_G)) d += W / 2 span_I = 1 / 12 * mass * (L**2 + W**2) + mass * (d**2) theta, omega, moment = self.calculate_torsional_spring_moment( @@ -1070,19 +1074,24 @@ def spring_numerical_ode( def tau(time: float) -> float: """Total external torque (aerodynamic + inertial from prescribed motion).""" - return aero_torque + inertial_torque_func(time) + return float(aero_torque + inertial_torque_func(time)) - def ode(time: float, y: list[float]) -> list[float]: + def ode(time: float, y: np.ndarray) -> np.ndarray: """ODE system: dθ/dt = ω, dω/dt = (τ - c*ω - k*θ)/I.""" theta, omega = y - return [omega, (tau(time) - c * omega - k * theta) / I] + return np.array([omega, (tau(time) - c * omega - k * theta) / I]) sol = solve_ivp( - ode, (t[0], t[-1]), [theta0, omega0], t_eval=t, rtol=1e-9, atol=1e-12 + ode, + (t[0], t[-1]), + np.array([theta0, omega0]), + t_eval=t, + rtol=1e-9, + atol=1e-12, ) - theta = sol.y[0][-1] - omega = sol.y[1][-1] + theta = float(sol.y[0][-1]) + omega = float(sol.y[1][-1]) return theta, omega diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 08d46d1fa..85879c260 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -1661,7 +1661,7 @@ def _load_calculation_moment_processing_hook( + unsteady_moments_GP1_CgP1 ) - return moments_GP1_CgP1 + return np.array(moments_GP1_CgP1) def _populate_next_airplanes_wake(self) -> None: """Updates the next time step's Airplanes' wakes. From 4c8d2aee49c8d496838db6584e7181d538bd5cc1 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Thu, 26 Mar 2026 15:27:16 -0400 Subject: [PATCH 35/40] more mypy fixes --- .../single_step/single_step_movement.py | 18 ++++++++++++------ .../unsteady_ring_vortex_lattice_method.py | 4 +++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py index 565afe8f1..210823cee 100644 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ b/pterasoftware/movements/single_step/single_step_movement.py @@ -11,10 +11,12 @@ None """ -from ..._parameter_validation import ( - int_in_range_return_int, - number_in_range_return_float, -) +from __future__ import annotations + +import numpy as np + +from ... import geometry +from ...operating_point import OperatingPoint from ..movement import Movement from .single_step_airplane_movement import SingleStepAirplaneMovement from .single_step_operating_point_movement import SingleStepOperatingPointMovement @@ -132,8 +134,12 @@ def __init__( self.num_steps = self.corresponding_movement.num_steps def generate_next_movement( - self, base_airplanes, base_operating_point, step, deformation_matrices=None - ): + self, + base_airplanes: list[geometry.airplane.Airplane], + base_operating_point: OperatingPoint, + step: int, + deformation_matrices: np.ndarray | None = None, + ) -> tuple[list[geometry.airplane.Airplane], OperatingPoint]: """Creates the Airplanes and OperatingPoint at a single time step. :param base_airplanes: The list of Airplanes at the current time step. diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 85879c260..4a1458aa8 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -619,7 +619,9 @@ def _initialize_panel_vortices(self) -> None: # observed from the Earth frame) at this time step. self._initialize_panel_vortex(steady_problem, steady_problem_id) - def _initialize_panel_vortex(self, steady_problem, steady_problem_id): + def _initialize_panel_vortex( + self, steady_problem: problems.SteadyProblem, steady_problem_id: int + ) -> None: """Initializes the bound RingVortex for each Panel in the given SteadyProblem. Every Panel has a RingVortex, which is a quadrangle whose front leg is a From 88b556c2c9f8b9777fb73c7031bca24833977ca2 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 17 Apr 2026 12:55:26 -0400 Subject: [PATCH 36/40] tweak examples --- examples/demos/demo_pterosaur.py | 6 +- examples/demos/demo_single_step_flat.py | 2 +- examples/demos/demo_single_step_plot.py | 390 ++++++++++++++++++ examples/demos/demo_single_step_wing_pitch.py | 372 +++++++++++++++++ 4 files changed, 766 insertions(+), 4 deletions(-) create mode 100644 examples/demos/demo_single_step_plot.py create mode 100644 examples/demos/demo_single_step_wing_pitch.py diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index 42489d7bc..d2e2275d3 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -91,7 +91,7 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, + num_spanwise_panels=3, chord=0.25, Lp_Wcsp_Lpp=(0.0, 0.0, 0.0), angles_Wcsp_to_Wcs_ixyz=(0.0, 0.0, 0.0), @@ -120,7 +120,7 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): # (0.1829, 2.4373, 0.0266)] wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, + num_spanwise_panels=3, chord=np.linalg.norm((0.1559, 0, -0.0931)), Lp_Wcsp_Lpp=get_relative_transform( (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) @@ -141,7 +141,7 @@ def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): ) wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, + num_spanwise_panels=3, chord=np.linalg.norm((0.2864, 0, -0.1878)), Lp_Wcsp_Lpp=get_relative_transform( (0.0889, 0.2249, 0.0955), diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py index fa7d31645..67d1b9d3a 100644 --- a/examples/demos/demo_single_step_flat.py +++ b/examples/demos/demo_single_step_flat.py @@ -335,7 +335,7 @@ example_problem = ps.problems.AeroelasticUnsteadyProblem( single_step_movement=single_step_movement, wing_density=0.012, - spring_constant=5.0, + spring_constant=20.0, damping_constant=1.0, aero_scaling=1.0, moment_scaling_factor=1.0, diff --git a/examples/demos/demo_single_step_plot.py b/examples/demos/demo_single_step_plot.py new file mode 100644 index 000000000..b7b3f14a9 --- /dev/null +++ b/examples/demos/demo_single_step_plot.py @@ -0,0 +1,390 @@ +"""This script runs the coupled aeroelastic UVLM solver sweeping one parameter +at a time (spring constant k, damping constant b, or wing density) and overlays +the Curve 16 Net Deformation from each run.""" + +import matplotlib.pyplot as plt +import numpy as np + +import pterasoftware as ps + +# The curve index to extract from each Net Deformation result. Curve 16 corresponds +# to the wing-tip spanwise station. +CURVE_INDEX = 16 + +# Default values used when a parameter is not being swept +DEFAULT_K = 1.0 +DEFAULT_B = 40.0 +DEFAULT_DENSITY = 0.012 + +# 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: list[float] = [] +B_VALUES: list[float] = [] +DENSITY_VALUES: list[float] = [0.012, 0.12, 0.3] + + +def run_single_step( + spring_constant: float = DEFAULT_K, + damping_constant: float = DEFAULT_B, + wing_density: float = DEFAULT_DENSITY, +) -> tuple[list, object]: + """Run the coupled aeroelastic solver and return the net deformation data. + + :param spring_constant: The torsional spring stiffness value. + :param damping_constant: The damping constant value. + :param wing_density: The wing density per unit height (kg/m^2). + :return: A tuple of (net_data list, solved problem object). + """ + + # Wing cross section initialization + 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=( + "cosine" 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), + single_step_wing=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), + single_step_wing=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, + ) + + # Wing cross section movement 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_single_step_movements_list = [] + reflected_single_step_movements_list = [] + + for i in range(len(example_airplane.wings[0].wing_cross_sections)): + if i == 0: + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[ + i + ], + ) + main_single_step_movements_list.append(single_step_movement) + reflected_single_step_movements_list.append(single_step_movement) + + else: + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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_single_step_movements_list.append(single_step_movement) + reflected_single_step_movements_list.append(single_step_movement) + + single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), + ) + single_step_v_tail_tip_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), + ) + + dephase = 169.0 + + single_step_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[0], + single_step_wing_cross_section_movements=main_single_step_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=(15.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), + ) + ) + + single_step_reflected_main_wing_movement = ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[1], + single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), + ) + + single_step_v_tail_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[2], + single_step_wing_cross_section_movements=[ + single_step_v_tail_root_wing_cross_section_movement, + single_step_v_tail_tip_wing_cross_section_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), + ) + ) + + single_step_airplane_movement = ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + base_airplane=example_airplane, + single_step_wing_movements=[ + single_step_main_wing_movement, + single_step_reflected_main_wing_movement, + single_step_v_tail_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 + ) + + single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + base_operating_point=example_operating_point, + ampVCg__E=0.0, + periodVCg__E=0.0, + spacingVCg__E="sine", + ) + + single_step_movement = ( + ps.movements.single_step.single_step_movement.SingleStepMovement( + single_step_airplane_movements=[single_step_airplane_movement], + single_step_operating_point_movement=single_step_operating_point_movement, + delta_time=0.03, + num_cycles=3, + ) + ) + + example_problem = ps.problems.AeroelasticUnsteadyProblem( + single_step_movement=single_step_movement, + wing_density=wing_density, + spring_constant=spring_constant, + damping_constant=damping_constant, + aero_scaling=1.0, + moment_scaling_factor=1.0, + damping_eps=1e-3, + plot_flap_cycle=False, + custom_spacing_second_derivative=None, + only_final_results=False, + ) + + example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, + ) + + example_solver.run( + prescribed_wake=True, + ) + + problem = example_solver.coupled_unsteady_problem + + # ps.output.animate( + # unsteady_solver=example_solver, + # scalar_type="lift", + # show_wake_vortices=True, + # save=True, + # ) + return problem.net_data, problem + + +# Determine which parameter is being swept +active_sweeps = sum(1 for v in (K_VALUES, B_VALUES, DENSITY_VALUES) if v) +if active_sweeps > 1: + raise ValueError( + "Only one of K_VALUES, B_VALUES, or DENSITY_VALUES should be non-empty." + ) +if active_sweeps == 0: + raise ValueError( + "At least one of K_VALUES, B_VALUES, or DENSITY_VALUES must be non-empty." + ) + +if K_VALUES: + sweep_values = K_VALUES + sweep_name = "Spring Constant" + sweep_symbol = "k" + sweep_kwarg = "spring_constant" +elif B_VALUES: + sweep_values = B_VALUES + sweep_name = "Damping Constant" + sweep_symbol = "b" + sweep_kwarg = "damping_constant" +else: + sweep_values = DENSITY_VALUES + sweep_name = "Wing Density" + sweep_symbol = "ρ" + sweep_kwarg = "wing_density" + +# Run for each swept value and collect Curve 16 of the Net Deformation +results = {} +flap_angle = None +for val in sweep_values: + print(f"Running with {sweep_symbol}={val}...") + net_data, problem = run_single_step(**{sweep_kwarg: val}) + # Extract y-component (torsional angle) for Curve 16 across all time steps + curve_16 = np.array(net_data)[:, CURVE_INDEX, 1].tolist() + results[val] = curve_16 + print(f" Completed {sweep_symbol}={val}, {len(curve_16)} steps") + + # Compute the prescribed flap angle once (it is the same for all runs) + if flap_angle is None: + wm = problem.wing_movement + amp = wm.ampAngles_Gs_to_Wn_ixyz[0] + period = wm.periodAngles_Gs_to_Wn_ixyz[0] + phase = np.deg2rad(wm.phaseAngles_Gs_to_Wn_ixyz[0]) + dt = problem.movement.delta_time + num_steps = len(curve_16) + t = np.arange(num_steps) * dt + flap_angle = (amp * np.sin((2 * np.pi / period) * t + phase)).tolist() + +# Overlay plot of Curve 16 Net Deformation for all swept values +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"Net Deformation (Curve {CURVE_INDEX}) — Varying {sweep_name}") +plt.legend() +plt.grid(True) +filename = f"Net_Deformation_Curve_{CURVE_INDEX}_{sweep_name.replace(' ', '_')}.png" +plt.savefig(filename) +plt.show() diff --git a/examples/demos/demo_single_step_wing_pitch.py b/examples/demos/demo_single_step_wing_pitch.py new file mode 100644 index 000000000..060c6d9ca --- /dev/null +++ b/examples/demos/demo_single_step_wing_pitch.py @@ -0,0 +1,372 @@ +"""This is script is an example of how to run Ptera Software's +UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static +Movement.""" + +# First, import the software's main package. Note that if you wished to import this +# software into another package, you would first install it by running "pip install +# pterasoftware" in your terminal. +import pterasoftware as ps + +# Create an Airplane with our custom geometry. I am going to declare every parameter +# for Airplane, even though most of them have usable default values. This is for +# educational purposes, but keep in mind that it makes the code much longer than it +# needs to be. For details about each parameter, read the detailed class docstring. +# The same caveats apply to the other classes, methods, and functions I call in this +# script. + +# Wing cross section initialization +# offsets for the spacing +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 = [] + +# Initialization loop for our wing cross sections. Here we are defining automatically +# wing cross sections with a variable set of chords. All of the wing cross sections for +# deformation simulation are defined to have num_spanwise_panels=1 (except the wing tip which +# is always None). This is because we deform each strip of wing cross section independently by +# modeling them as torsional springs, and that model only really works if those strips are thin. +# Note that if you want to go thinner for the same base definition, you can increase the number +# of spanwise panels and ensure that in Wing you set the single_step_wing parameter to True, +# which will ensure that the wing is split back up into single strips for deformation. +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="cosine" 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, + ), + ) + ) + +# Primary wing definition. Note that the single_step_wing parameter is set to True, +# which means that the wing will be split into strips for deformation, and each +# strip will be modeled as a torsional spring. +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), + single_step_wing=True, + num_chordwise_panels=6, + chordwise_spacing="uniform", +) + +# Actually generating the airplane. A tail is added to the airplane, but it is not +# split into strips for deformation as currently only the first wing is considered +# for deformation in the codebase. Fututre versions of this feature could allow for +# the deformation of multiple wings. For now, it is convenient to not split the tail +# into single strip wing cross sections as it reduces the number of movement variables +# that need to be defined. +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), + single_step_wing=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, +) + +# The main Wing was defined to have symmetric=True, mirror_only=False, and with a +# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, +# that Wing had type 5 symmetry (see the Wing class documentation for more details on +# symmetry types). Therefore, it was actually split into two Wings, the with the +# second Wing being a reflected version of the first. Therefore, we need to define a +# WingMovement for this reflected Wing. To start, we'll first define the reflected +# main wing's root and tip WingCrossSections' WingCrossSectionMovements. + +# definitions for wing cross section movement 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 + +# A list of movements for the main wing +main_movements_list = [] +main_single_step_movements_list = [] + +# A list of movements for the reflected wing +reflected_movements_list = [] +reflected_single_step_movements_list = [] + +# A loop for defining the movement for the main wing and its reflected counterpart's wing +# cross sections. Here, we are defining single step wing cross movement, a movement class +# that functions differently from the standard Movement class, by giving the next +# position of wing cross section from the previous instead of attempting to precompute +# the entire movement beforehand as that is impossible in scenarios where the deformation +# is dependent on the aerodynamic loads. +for i in range(len(example_airplane.wings[0].wing_cross_sections)): + if i == 0: + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], + ) + main_single_step_movements_list.append(single_step_movement) + reflected_single_step_movements_list.append(single_step_movement) + + else: + single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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_single_step_movements_list.append(single_step_movement) + reflected_single_step_movements_list.append(single_step_movement) + + +# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. +single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), +) +single_step_v_tail_tip_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( + 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), +) + +# This dephase parameter is used to make the wing start in a flat position +dephase = 169.0 + +# Now define the main wing's SingleStepWingMovement, the reflected main wing's SingleStepWingMovement and +# the v-tail's SingleStepWingMovement. +single_step_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[0], + single_step_wing_cross_section_movements=main_single_step_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=(15.0, 23.0, 0.0), # (0.0, 0.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 0.0), # (0.0, 0.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(dephase, -90.0, 0.0), + ) +) + +single_step_reflected_main_wing_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[1], + single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 23.0, 0.0), + periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 0.0), + spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), + phaseAngles_Gs_to_Wn_ixyz=(dephase, -90.0, 0.0), + ) +) + +single_step_v_tail_movement = ( + ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( + base_wing=example_airplane.wings[2], + single_step_wing_cross_section_movements=[ + single_step_v_tail_root_wing_cross_section_movement, + single_step_v_tail_tip_wing_cross_section_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), + ) +) + +# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now +# contained within the WingMovements. This is optional, but it can make debugging +# easier. +del single_step_v_tail_root_wing_cross_section_movement +del single_step_v_tail_tip_wing_cross_section_movement + +# Now define the example airplane's SingleStepAirplaneMovement. +single_step_airplane_movement = ( + ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( + base_airplane=example_airplane, + single_step_wing_movements=[ + single_step_main_wing_movement, + single_step_reflected_main_wing_movement, + single_step_v_tail_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), + ) +) + +# Delete the extraneous pointers to the WingMovements. +del single_step_main_wing_movement +del single_step_reflected_main_wing_movement +del single_step_v_tail_movement + +# Define a new OperatingPoint. +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 +) + +# Define the operating point's OperatingPointMovement. +operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( + base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" +) + +single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( + base_operating_point=example_operating_point, + ampVCg__E=0.0, + periodVCg__E=0.0, + spacingVCg__E="sine", +) + +# Delete the extraneous pointer. +del example_operating_point + +# Define the SingleStepMovement. This contains the SingleStepAirplaneMovement and the +# SingleStepOperatingPointMovement. + +single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( + single_step_airplane_movements=[single_step_airplane_movement], + single_step_operating_point_movement=single_step_operating_point_movement, + delta_time=0.03, + num_cycles=3, +) + +# Delete the extraneous pointers. +del operating_point_movement + +# Define the UnsteadyProblem. +# The deformation parameters are set here +# The wing_density, spring_constant and damping_constant are the primary parameters +# you should expect to change. The rest are more for considering numerical issues +# with our integrator and debugging. Plotting the flap cycle can give good data as well. +example_problem = ps.problems.AeroelasticUnsteadyProblem( + single_step_movement=single_step_movement, + wing_density=0.012, + spring_constant=20.0, + damping_constant=1.0, + aero_scaling=1.0, + moment_scaling_factor=1.0, + damping_eps=1e-3, + plot_flap_cycle=False, + custom_spacing_second_derivative=None, + only_final_results=False, +) + +# Define a new solver. The available solver classes are +# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, +# and UnsteadyRingVortexLatticeMethodSolver. We'll create an +# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. +example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem=example_problem, +) + +# Delete the extraneous pointer. +del example_problem + +# Run the solver. +example_solver.run( + prescribed_wake=True, +) + +# Call the animate function on the solver. This produces a GIF of the wake being +# shed. The GIF is saved in the same directory as this script. Press "q", +# after orienting the view, to begin the animation. +ps.output.animate( + unsteady_solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=True, +) From 9206f441fceeb1380ed50c2a5be3fc72d8e3c14e Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 17 Apr 2026 23:10:19 -0400 Subject: [PATCH 37/40] remove erronious import --- examples/demos/demo_pterosaur.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py index d2e2275d3..ba0b0a943 100644 --- a/examples/demos/demo_pterosaur.py +++ b/examples/demos/demo_pterosaur.py @@ -1,5 +1,3 @@ -from email.mime import base - import numpy as np from scipy.spatial.transform import Rotation as R From 4d9e713be84d5b60a4631a1ec6b4a003d08ae032 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 17 Apr 2026 23:33:42 -0400 Subject: [PATCH 38/40] fix mypy --- ...astic_unsteady_first_order_deformation.py} | 0 ...oelastic_unsteady_first_order_plotting.py} | 6 +- examples/demos/Pterosaure.py | 321 ------------- examples/demos/demo_pterosaur.py | 443 ----------------- examples/demos/demo_single_step.py | 444 ------------------ examples/demos/demo_single_step_flat.py | 357 -------------- examples/demos/demo_single_step_wing_pitch.py | 372 --------------- pterasoftware/_core.py | 24 + .../movements/single_step/__init__.py | 30 -- .../single_step_airplane_movement.py | 301 ------------ .../single_step/single_step_movement.py | 173 ------- .../single_step_operating_point_movement.py | 207 -------- ...single_step_wing_cross_section_movement.py | 387 --------------- .../single_step/single_step_wing_movement.py | 370 --------------- .../unsteady_ring_vortex_lattice_method.py | 2 +- 15 files changed, 28 insertions(+), 3409 deletions(-) rename examples/{coupled_unsteady_first_order_deformation.py => aeroelastic_unsteady_first_order_deformation.py} (100%) rename examples/{demos/demo_single_step_plot.py => aeroelastic_unsteady_first_order_plotting.py} (98%) delete mode 100644 examples/demos/Pterosaure.py delete mode 100644 examples/demos/demo_pterosaur.py delete mode 100644 examples/demos/demo_single_step.py delete mode 100644 examples/demos/demo_single_step_flat.py delete mode 100644 examples/demos/demo_single_step_wing_pitch.py delete mode 100644 pterasoftware/movements/single_step/__init__.py delete mode 100644 pterasoftware/movements/single_step/single_step_airplane_movement.py delete mode 100644 pterasoftware/movements/single_step/single_step_movement.py delete mode 100644 pterasoftware/movements/single_step/single_step_operating_point_movement.py delete mode 100644 pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py delete mode 100644 pterasoftware/movements/single_step/single_step_wing_movement.py diff --git a/examples/coupled_unsteady_first_order_deformation.py b/examples/aeroelastic_unsteady_first_order_deformation.py similarity index 100% rename from examples/coupled_unsteady_first_order_deformation.py rename to examples/aeroelastic_unsteady_first_order_deformation.py diff --git a/examples/demos/demo_single_step_plot.py b/examples/aeroelastic_unsteady_first_order_plotting.py similarity index 98% rename from examples/demos/demo_single_step_plot.py rename to examples/aeroelastic_unsteady_first_order_plotting.py index a67108470..12d521d95 100644 --- a/examples/demos/demo_single_step_plot.py +++ b/examples/aeroelastic_unsteady_first_order_plotting.py @@ -23,12 +23,12 @@ DENSITY_VALUES: list[float] = [0.012, 0.12, 0.3] -def run_single_step( +def run_aeroelastic( spring_constant: float = DEFAULT_K, damping_constant: float = DEFAULT_B, wing_density: float = DEFAULT_DENSITY, ) -> tuple[list, object]: - """Run the coupled aeroelastic solver and return the net deformation data. + """Run the aeroelastic solver and return the net deformation data. :param spring_constant: The torsional spring stiffness value. :param damping_constant: The damping constant value. @@ -352,7 +352,7 @@ def run_single_step( flap_angle = None for val in sweep_values: print(f"Running with {sweep_symbol}={val}...") - net_data, problem = run_single_step(**{sweep_kwarg: val}) + net_data, problem = run_aeroelastic(**{sweep_kwarg: val}) # Extract y-component (torsional angle) for Curve 16 across all time steps curve_16 = np.array(net_data)[:, CURVE_INDEX, 1].tolist() results[val] = curve_16 diff --git a/examples/demos/Pterosaure.py b/examples/demos/Pterosaure.py deleted file mode 100644 index 5d747f397..000000000 --- a/examples/demos/Pterosaure.py +++ /dev/null @@ -1,321 +0,0 @@ -import numpy as np -from scipy.spatial.transform import Rotation as R - -import pterasoftware as ps - - -def get_relative_transform(Point1, Unit1, Point2, Unit2): - - Point1 = np.array(Point1, dtype=float) - Unit1 = np.array(Unit1, dtype=float) - Point2 = np.array(Point2, dtype=float) - Unit2 = np.array(Unit2, dtype=float) - - Xp = Unit1 / np.linalg.norm(Unit1) - Zp = np.array([0, 0, 1]) - Yp = np.cross(Zp, Xp) - Yp /= np.linalg.norm(Yp) - Zp = np.cross(Xp, Yp) - Zp /= np.linalg.norm(Zp) - Rp = np.column_stack((Xp, Yp, Zp)) - - Xe = Unit2 / np.linalg.norm(Unit2) - Ze = np.array([0, 0, 1]) - Ye = np.cross(Ze, Xe) - Ye /= np.linalg.norm(Ye) - Ze = np.cross(Xe, Ye) - Ze /= np.linalg.norm(Ze) - Re = np.column_stack((Xe, Ye, Ze)) - - Rrel = Rp.T @ Re - - rot = R.from_matrix(Rrel) - angles_Wcsp_to_Wcs_ixyz = rot.as_euler("xyz", degrees=True) - - Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) - - return Lp_Wcsp_Lpp, angles_Wcsp_to_Wcs_ixyz - - -wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=0.25, - 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, - ), -) - -wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=np.linalg.norm((0.1559, 0, -0.0931)), - Lp_Wcsp_Lpp=get_relative_transform( - (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) - )[1], - 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, - ), -) - -wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=np.linalg.norm((0.2864, 0, -0.1878)), - Lp_Wcsp_Lpp=get_relative_transform( - (0.0889, 0.2249, 0.0955), - (0.1559, 0, -0.0931), - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (0.0889, 0.2249, 0.0955), - (0.1559, 0, -0.0931), - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - )[1], - 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, - ), -) - -wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=6, - chord=np.linalg.norm((0.322, 0, -0.2256)), - Lp_Wcsp_Lpp=get_relative_transform( - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - )[1], - 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, - ), -) - -wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.005, 0, -0.0003)), - Lp_Wcsp_Lpp=get_relative_transform( - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - (0.1829, 2.4373, 0.0266), - (0.005, 0, -0.0003), - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - (0.1829, 2.4373, 0.0266), - (0.005, 0, -0.0003), - )[1], - 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, - ), -) -wing_cross_section_6 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=None, - chord=np.linalg.norm((0.005, 0, -0.0003)), - Lp_Wcsp_Lpp=get_relative_transform( - (0.322, 0, -0.2256), - (0.323, 0, -0.2251), - (0.005, 0, -0.0003), - (0.006, 0, -0.0002), - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - (0.1829, 2.4373, 0.0266), - (0.005, 0, -0.0003), - )[1], - 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, - ), -) - - -pterasaure = ps.geometry.airplane.Airplane( - wings=[ - ps.geometry.wing.Wing( - wing_cross_sections=[ - wing_cross_section_1, - wing_cross_section_2, - wing_cross_section_3, - wing_cross_section_4, - wing_cross_section_5, - wing_cross_section_6, - ], - name="Main Wing", - Ler_Gs_Cgs=[0.0, 0.025, 0.0], - angles_Gs_to_Wn_ixyz=[4, 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), - num_chordwise_panels=5, - chordwise_spacing="uniform", - ), - ], - name="Pterosaure", - Cg_GP1_CgP1=(0.0, 0.0, 0.0), - weight=0, - s_ref=None, - c_ref=None, - b_ref=None, -) - - -# Define the airplane's AirplaneMovement. -wing_movements = [] -for wing in pterasaure.wings: - wing_cross_section_movements = [] - for wing_cross_section in wing.wing_cross_sections: - wing_cross_section_movements.append( - ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - base_wing_cross_section=wing_cross_section - ) - ) - wing_movements.append(wing_cross_section_movements) - -main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=pterasaure.wings[0], - wing_cross_section_movements=wing_movements[0], - 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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), -) - -reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=pterasaure.wings[1], - wing_cross_section_movements=wing_movements[1], - 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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), -) - -# Now define the example airplane's AirplaneMovement. For now, no movement of the airplane is possible. -pterasaure_movement = ps.movements.airplane_movement.AirplaneMovement( - base_airplane=pterasaure, - wing_movements=[main_wing_movement, reflected_main_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), -) - -# Define a new OperatingPoint. -example_operating_point = ps.operating_point.OperatingPoint( - rho=1.225, vCg__E=30.0, alpha=5.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 -) - -# Define the operating point's OperatingPointMovement. -operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( - base_operating_point=example_operating_point -) - -# Define the Movement. This contains the AirplaneMovement and the -# OperatingPointMovement. -movement = ps.movements.movement.Movement( - airplane_movements=[pterasaure_movement], - operating_point_movement=operating_point_movement, - delta_time=None, - num_cycles=1, - num_chords=None, - num_steps=None, -) - -# Define the UnsteadyProblem. -example_problem = ps.problems.UnsteadyProblem( - movement=movement, -) - -# Define a new solver. The available solver classes are -# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, -# and UnsteadyRingVortexLatticeMethodSolver. We'll create an -# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. -example_solver = ( - ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( - unsteady_problem=example_problem, - ) -) - - -# Run the solver. -example_solver.run( - prescribed_wake=False, - show_progress=True, -) - -# Call the animate function on the solver. This produces a GIF of the wake being -# shed. The GIF is saved in the same directory as this script. Press "q", -# after orienting the view, to begin the animation. - -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=True, -) - -# ps.output.print_results(example_solver) - -# ps.output.plot_wing_loads_versus_time(unsteady_solver=example_solver, show=True) diff --git a/examples/demos/demo_pterosaur.py b/examples/demos/demo_pterosaur.py deleted file mode 100644 index ba0b0a943..000000000 --- a/examples/demos/demo_pterosaur.py +++ /dev/null @@ -1,443 +0,0 @@ -import numpy as np -from scipy.spatial.transform import Rotation as R - -import pterasoftware as ps - - -def get_relative_transform(Point1, Unit1, Point2, Unit2): - - Point1 = np.array(Point1, dtype=float) - Unit1 = np.array(Unit1, dtype=float) - Point2 = np.array(Point2, dtype=float) - Unit2 = np.array(Unit2, dtype=float) - - Xp = Unit1 / np.linalg.norm(Unit1) - Zp = np.array([0, 0, 1]) - Yp = np.cross(Zp, Xp) - Yp /= np.linalg.norm(Yp) - Zp = np.cross(Xp, Yp) - Zp /= np.linalg.norm(Zp) - Rp = np.column_stack((Xp, Yp, Zp)) - - Xe = Unit2 / np.linalg.norm(Unit2) - Ze = np.array([0, 0, 1]) - Ye = np.cross(Ze, Xe) - Ye /= np.linalg.norm(Ye) - Ze = np.cross(Xe, Ye) - Ze /= np.linalg.norm(Ze) - Re = np.column_stack((Xe, Ye, Ze)) - - Rrel = Rp.T @ Re - - rot = R.from_matrix(Rrel) - angles_Wcsp_to_Wcs_ixyz = rot.as_euler("xyz", degrees=True) - - Lp_Wcsp_Lpp = Rp.T @ (Point2 - Point1) - - return Lp_Wcsp_Lpp, angles_Wcsp_to_Wcs_ixyz - - -def interpolate_between_wing_cross_sections(wcs1, wcs2, first_wcs): - """ - Wing cross section panels are between the line of wcs1 and wcs2. - When exploding a wing to 1 spanwise panel per cross section, - we need to interpolate the intermediate cross sections. - """ - - interpolated = [] - - if first_wcs: - interpolated.append( - ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=wcs1.chord, - Lp_Wcsp_Lpp=wcs1.Lp_Wcsp_Lpp, - angles_Wcsp_to_Wcs_ixyz=wcs1.angles_Wcsp_to_Wcs_ixyz, - control_surface_symmetry_type=wcs1.control_surface_symmetry_type, - control_surface_hinge_point=wcs1.control_surface_hinge_point, - control_surface_deflection=wcs1.control_surface_deflection, - spanwise_spacing="uniform", - airfoil=wcs1.airfoil, - ) - ) - - N = wcs1.num_spanwise_panels - - for i in range(N): - t = (i + 1) / N # interpolation parameter between 0 and 1 - - chord = (1 - t) * wcs1.chord + t * wcs2.chord - Lp_Wcsp_Lpp = tuple(np.array(wcs2.Lp_Wcsp_Lpp) / N) - # angles_Wcsp_to_Wcs_ixyz = tuple((1 - t) * np.array(wcs1.angles_Wcsp_to_Wcs_ixyz) + t * np.array(wcs2.angles_Wcsp_to_Wcs_ixyz)) - angles_Wcsp_to_Wcs_ixyz = wcs2.angles_Wcsp_to_Wcs_ixyz / N - is_final_section = wcs2.num_spanwise_panels is None and i == N - 1 - - interpolated.append( - ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=None if is_final_section else 1, - chord=chord, - Lp_Wcsp_Lpp=Lp_Wcsp_Lpp, - angles_Wcsp_to_Wcs_ixyz=angles_Wcsp_to_Wcs_ixyz, - control_surface_symmetry_type=wcs1.control_surface_symmetry_type, - control_surface_hinge_point=wcs1.control_surface_hinge_point, - control_surface_deflection=wcs1.control_surface_deflection, - spanwise_spacing=None if is_final_section else "uniform", - airfoil=wcs1.airfoil, - ) - ) - return interpolated - - -wing_cross_section_1 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=3, - chord=0.25, - 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, - ), -) - -# points = [(0.0, 0.0, 0.0), -# (0.0889,0.2249, 0.0955), -# (-0.0521, 0.5749, 0.1940), -# (-0.0946, 0.8282, 0.2345), -# (0.1829, 2.4373, 0.0266)] - -# vectors = [(1.0, 0.0, 0.0), -# (0.1559,0,-0.0931), -# (0.2864,0,-0.1878), -# (0.3291,0,-0.2154), -# (0.1829, 2.4373, 0.0266)] - -wing_cross_section_2 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=3, - chord=np.linalg.norm((0.1559, 0, -0.0931)), - Lp_Wcsp_Lpp=get_relative_transform( - (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (0.0889, 0.2249, 0.0955), (0.1559, 0, -0.0931) - )[1], - 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, - ), -) - -wing_cross_section_3 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=3, - chord=np.linalg.norm((0.2864, 0, -0.1878)), - Lp_Wcsp_Lpp=get_relative_transform( - (0.0889, 0.2249, 0.0955), - (0.1559, 0, -0.0931), - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (0.0889, 0.2249, 0.0955), - (0.1559, 0, -0.0931), - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - )[1], - 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, - ), -) - -wing_cross_section_4 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=np.linalg.norm((0.322, 0, -0.2256)), - Lp_Wcsp_Lpp=get_relative_transform( - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (-0.0521, 0.5749, 0.1940), - (0.2864, 0, -0.1878), - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - )[1], - 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, - ), -) -wing_cross_section_4_5 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=1, - chord=(0.39316581743584983 + 0.005008991914547277) / 2, - Lp_Wcsp_Lpp=0.0 * np.array((-0.05774876, 0.2533, 0.01056319)) - + 0.5 * np.array((0.34656431, 1.6091, -0.01103809)), - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - (0.1829, 2.4373, 0.0266), - (0.005, 0, -0.0003), - )[1] - * 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, - ), -) - -wing_cross_section_new_5 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=None, - chord=np.linalg.norm((0.005, 0, -0.0003)), - Lp_Wcsp_Lpp=np.array([0.34656431, 1.6091, -0.01103809]) / 2, - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - (0.1829, 2.4373, 0.0266), - (0.005, 0, -0.0003), - )[1], - 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, - ), -) - -wing_cross_section_5 = ps.geometry.wing_cross_section.WingCrossSection( - num_spanwise_panels=None, - chord=np.linalg.norm((0.005, 0, -0.0003)), - Lp_Wcsp_Lpp=get_relative_transform( - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - (0.1829, 2.4373, 0.0266), - (0.005, 0, -0.0003), - )[0], - angles_Wcsp_to_Wcs_ixyz=get_relative_transform( - (-0.0946, 0.8282, 0.2345), - (0.322, 0, -0.2256), - (0.1829, 2.4373, 0.0266), - (0.005, 0, -0.0003), - )[1], - 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, - ), -) - -original_wing = ps.geometry.wing.Wing( - wing_cross_sections=[ - wing_cross_section_1, - wing_cross_section_2, - wing_cross_section_3, - wing_cross_section_4, - wing_cross_section_5, - ], - name="Main Wing", - Ler_Gs_Cgs=[0.0, 0.025, 0.0], - angles_Gs_to_Wn_ixyz=[4, 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), - single_step_wing=True, - num_chordwise_panels=5, - chordwise_spacing="uniform", -) - -pterasaure = ps.geometry.airplane.Airplane( - wings=[original_wing], - name="Pterosaur", - Cg_GP1_CgP1=(0.0, 0.0, 0.0), - weight=0, - s_ref=None, - c_ref=None, - b_ref=None, -) - -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 - -# A list of movements for the main wing -main_single_step_movements_list = [] - -# A list of movements for the reflected wing -reflected_single_step_movements_list = [] - -for i in range(len(pterasaure.wings[0].wing_cross_sections)): - if i == 0: - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - base_wing_cross_section=pterasaure.wings[0].wing_cross_sections[i], - ) - - main_single_step_movements_list.append(single_step_movement) - reflected_single_step_movements_list.append(single_step_movement) - - else: - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - base_wing_cross_section=pterasaure.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_single_step_movements_list.append(single_step_movement) - reflected_single_step_movements_list.append(single_step_movement) - -single_step_main_wing_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - base_wing=pterasaure.wings[0], - single_step_wing_cross_section_movements=main_single_step_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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), - ) -) - -single_step_reflected_main_wing_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - base_wing=pterasaure.wings[1], - single_step_wing_cross_section_movements=reflected_single_step_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=(30.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1 / 3, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(0.0, 0.0, 0.0), - ) -) - -# Now define the example airplane's AirplaneMovement. For now, no movement of the airplane is possible. -pterasaure_single_step_movement = ( - ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( - base_airplane=pterasaure, - single_step_wing_movements=[ - single_step_main_wing_movement, - single_step_reflected_main_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), - ) -) - -# Define a new OperatingPoint. -example_operating_point = ps.operating_point.OperatingPoint( - rho=1.225, vCg__E=30.0, alpha=5.0, beta=0.0, externalFX_W=0.0, nu=15.06e-6 -) - -# Define the operating point's OperatingPointMovement. -single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( - base_operating_point=example_operating_point, - ampVCg__E=0.0, - periodVCg__E=0.0, - spacingVCg__E="sine", -) - -# Define the Movement. This contains the AirplaneMovement and the -# OperatingPointMovement. -single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( - single_step_airplane_movements=[pterasaure_single_step_movement], - single_step_operating_point_movement=single_step_operating_point_movement, - delta_time=None, - num_cycles=2, -) - -# Define the UnsteadyProblem. -example_problem = ps.problems.AeroelasticUnsteadyProblem( - single_step_movement=single_step_movement, - wing_density=1, - spring_constant=0.1, - plot_flap_cycle=False, - damping_constant=1.0, -) - -# Define a new solver. The available solver classes are -# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, -# and UnsteadyRingVortexLatticeMethodSolver. We'll create an -# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. -example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( - coupled_unsteady_problem=example_problem, -) - -# Run the solver. -example_solver.run( - prescribed_wake=True, - show_progress=True, -) - -# Call the animate function on the solver. This produces a GIF of the wake being -# shed. The GIF is saved in the same directory as this script. Press "q", -# after orienting the view, to begin the animation. - -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=True, -) - -# ps.output.print_results(example_solver) - -# ps.output.plot_wing_loads_versus_time(unsteady_solver=example_solver, show=True) diff --git a/examples/demos/demo_single_step.py b/examples/demos/demo_single_step.py deleted file mode 100644 index 592f55be7..000000000 --- a/examples/demos/demo_single_step.py +++ /dev/null @@ -1,444 +0,0 @@ -"""This is script is an example of how to run Ptera Software's -UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static -Movement.""" - -# First, import the software's main package. Note that if you wished to import this -# software into another package, you would first install it by running "pip install -# pterasoftware" in your terminal. -import pterasoftware as ps - -# Create an Airplane with our custom geometry. I am going to declare every parameter -# for Airplane, even though most of them have usable default values. This is for -# educational purposes, but keep in mind that it makes the code much longer than it -# needs to be. For details about each parameter, read the detailed class docstring. -# The same caveats apply to the other classes, methods, and functions I call in this -# script. - - -# offsets for the spacing -num_spanwise_panels = 1 -Lp_Wcsp_Lpp_Offsets = (0.1, 0.5, 0.1) - -# Wing cross section initialization -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="cosine" 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, - ), - ) - ) - - -example_airplane = ps.geometry.airplane.Airplane( - wings=[ - 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), - num_chordwise_panels=6, - chordwise_spacing="uniform", - ), - 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, 1.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), - 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, -) - -# The main Wing was defined to have symmetric=True, mirror_only=False, and with a -# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, -# that Wing had type 5 symmetry (see the Wing class documentation for more details on -# symmetry types). Therefore, it was actually split into two Wings, the with the -# second Wing being a reflected version of the first. Therefore, we need to define a -# WingMovement for this reflected Wing. To start, we'll first define the reflected -# main wing's root and tip WingCrossSections' WingCrossSectionMovements. - -# definitions for wing movement parameters -# dephase_x = 0.0 -# period_x = 1.0 -# amplitude_x = 2.0 - -# dephase_y = 0.0 -# period_y = 1.0 -# amplitude_y = 3.0 - -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 - -# A list of movements for the main wing -main_movements_list = [] -main_single_step_movements_list = [] - -# A list of movements for the reflected wing -reflected_movements_list = [] -reflected_single_step_movements_list = [] - -for i in range(len(example_airplane.wings[0].wing_cross_sections)): - if i == 0: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], - ) - single_step_movement = ( - ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement() - ) - - main_movements_list.append(movement) - main_single_step_movements_list.append(single_step_movement) - reflected_movements_list.append(movement) - reflected_single_step_movements_list.append(single_step_movement) - - else: - movement = ps.movements.wing_cross_section_movement.WingCrossSectionMovement( - 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), - ) - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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_movements_list.append(movement) - main_single_step_movements_list.append(single_step_movement) - reflected_movements_list.append(movement) - reflected_single_step_movements_list.append(single_step_movement) - - -# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. -v_tail_root_wing_cross_section_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_wing_cross_section_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), - ) -) - -single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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), -) -single_step_v_tail_tip_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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), -) - - -# Now define the main wing's WingMovement, the reflected main wing's WingMovement and -# the v-tail's WingMovement. -main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[0], - wing_cross_section_movements=main_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), -) - -single_step_main_wing_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - single_step_wing_cross_section_movements=main_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), - ) -) - -reflected_main_wing_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[1], - wing_cross_section_movements=reflected_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), -) - -single_step_reflected_main_wing_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(130.0, 0.0, 0.0), - ) -) - -v_tail_movement = ps.movements.wing_movement.WingMovement( - base_wing=example_airplane.wings[2], - wing_cross_section_movements=[ - v_tail_root_wing_cross_section_movement, - v_tail_tip_wing_cross_section_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), -) - -single_step_v_tail_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - single_step_wing_cross_section_movements=[ - single_step_v_tail_root_wing_cross_section_movement, - single_step_v_tail_tip_wing_cross_section_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), - ) -) - -# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now -# contained within the WingMovements. This is optional, but it can make debugging -# easier. -del v_tail_root_wing_cross_section_movement -del v_tail_tip_wing_cross_section_movement - -# Now define the example airplane's AirplaneMovement. -airplane_movement = ps.movements.airplane_movement.AirplaneMovement( - base_airplane=example_airplane, - wing_movements=[main_wing_movement, reflected_main_wing_movement, v_tail_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), -) - -single_step_airplane_movement = ( - ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( - single_step_wing_movements=[ - single_step_main_wing_movement, - single_step_reflected_main_wing_movement, - single_step_v_tail_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), - ) -) - -# Delete the extraneous pointers to the WingMovements. -del main_wing_movement -del reflected_main_wing_movement -del v_tail_movement -del single_step_main_wing_movement -del single_step_reflected_main_wing_movement -del single_step_v_tail_movement - -# Define a new OperatingPoint. -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 -) - -# Define the operating point's OperatingPointMovement. -operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( - base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" -) - -single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( - ampVCg__E=0.0, periodVCg__E=0.0, spacingVCg__E="sine" -) - -# Delete the extraneous pointer. -del example_operating_point - -# Define the Movement. This contains the AirplaneMovement and the -# OperatingPointMovement. -movement = ps.movements.movement.Movement( - airplane_movements=[airplane_movement], - operating_point_movement=operating_point_movement, - delta_time=0.03, - num_cycles=3, - num_chords=None, - num_steps=None, -) - -single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( - single_step_airplane_movements=[single_step_airplane_movement], - single_step_operating_point_movement=single_step_operating_point_movement, - delta_time=0.03, - num_steps=movement.num_steps, -) - -# Delete the extraneous pointers. -del airplane_movement -del operating_point_movement - -# Define the UnsteadyProblem. -example_problem = ps.problems.AeroelasticUnsteadyProblem( - movement=movement, - single_step_movement=single_step_movement, - wing_density=0.012, - spring_constant=1.0, - damping_constant=1.0, -) - -# Define a new solver. The available solver classes are -# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, -# and UnsteadyRingVortexLatticeMethodSolver. We'll create an -# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. -example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( - coupled_unsteady_problem=example_problem, -) - -# Delete the extraneous pointer. -del example_problem - -# Run the solver. -example_solver.run( - prescribed_wake=True, -) - -# Call the animate function on the solver. This produces a GIF of the wake being -# shed. The GIF is saved in the same directory as this script. Press "q", -# after orienting the view, to begin the animation. -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=True, -) diff --git a/examples/demos/demo_single_step_flat.py b/examples/demos/demo_single_step_flat.py deleted file mode 100644 index 4a2b22c79..000000000 --- a/examples/demos/demo_single_step_flat.py +++ /dev/null @@ -1,357 +0,0 @@ -"""This is script is an example of how to run Ptera Software's -UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static -Movement.""" - -# First, import the software's main package. Note that if you wished to import this -# software into another package, you would first install it by running "pip install -# pterasoftware" in your terminal. -import pterasoftware as ps - -# Create an Airplane with our custom geometry. I am going to declare every parameter -# for Airplane, even though most of them have usable default values. This is for -# educational purposes, but keep in mind that it makes the code much longer than it -# needs to be. For details about each parameter, read the detailed class docstring. -# The same caveats apply to the other classes, methods, and functions I call in this -# script. - -# Wing cross section initialization -# offsets for the spacing -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 = [] - -# Initialization loop for our wing cross sections. Here we are defining automatically -# wing cross sections with a variable set of chords. All of the wing cross sections for -# deformation simulation are defined to have num_spanwise_panels=1 (except the wing tip which -# is always None). This is because we deform each strip of wing cross section independently by -# modeling them as torsional springs, and that model only really works if those strips are thin. -# Note that if you want to go thinner for the same base definition, you can increase the number -# of spanwise panels and ensure that in Wing you set the single_step_wing parameter to True, -# which will ensure that the wing is split back up into single strips for deformation. -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="cosine" 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, - ), - ) - ) - -# Primary wing definition. Note that the single_step_wing parameter is set to True, -# which means that the wing will be split into strips for deformation, and each -# strip will be modeled as a torsional spring. -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), - single_step_wing=True, - num_chordwise_panels=6, - chordwise_spacing="uniform", -) - -# Actually generating the airplane. A tail is added to the airplane, but it is not -# split into strips for deformation as currently only the first wing is considered -# for deformation in the codebase. Fututre versions of this feature could allow for -# the deformation of multiple wings. For now, it is convenient to not split the tail -# into single strip wing cross sections as it reduces the number of movement variables -# that need to be defined. -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), - single_step_wing=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, -) - -# The main Wing was defined to have symmetric=True, mirror_only=False, and with a -# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, -# that Wing had type 5 symmetry (see the Wing class documentation for more details on -# symmetry types). Therefore, it was actually split into two Wings, the with the -# second Wing being a reflected version of the first. Therefore, we need to define a -# WingMovement for this reflected Wing. To start, we'll first define the reflected -# main wing's root and tip WingCrossSections' WingCrossSectionMovements. - -# definitions for wing cross section movement 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 - -# A list of wing cross section movements for the main wing -main_wcs_movements_list = [] - -# A list of wing cross section movements for the reflected wing -reflected_wcs_movements_list = [] - -# A loop for defining the movement for the main wing and its reflected counterpart's wing -# cross sections. Each wing cross section has its own AeroelasticWingCrossSectionMovement -# which allows the solver to apply deformation angles at each time step based on the -# aerodynamic loads. -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) - - -# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. -v_tail_root_wcs_movement = ps.movements.aeroelastic_wing_cross_section_movement.AeroelasticWingCrossSectionMovement( - 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.aeroelastic_wing_cross_section_movement.AeroelasticWingCrossSectionMovement( - 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), -) - -# This dephase parameter is used to make the wing start in a flat position -dephase = 169.0 - -# Now define the main wing's AeroelasticWingMovement, the reflected main wing's -# AeroelasticWingMovement, and the v-tail's AeroelasticWingMovement. -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=(15.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(dephase, 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=(15.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(dephase, 0.0, 0.0), - ) -) - -v_tail_wing_movement = ps.movements.aeroelastic_wing_movement.AeroelasticWingMovement( - 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), -) - -# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now -# contained within the WingMovements. This is optional, but it can make debugging -# easier. -del v_tail_root_wcs_movement -del v_tail_tip_wcs_movement - -# Now define the example airplane's AeroelasticAirplaneMovement. -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), - ) -) - -# Delete the extraneous pointers to the WingMovements. -del main_wing_movement -del reflected_main_wing_movement -del v_tail_wing_movement - -# Define a new OperatingPoint. -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 -) - -# Define the operating point's AeroelasticOperatingPointMovement. -example_operating_point_movement = ( - ps.movements.aeroelastic_operating_point_movement.AeroelasticOperatingPointMovement( - base_operating_point=example_operating_point, - ampVCg__E=0.0, - periodVCg__E=0.0, - spacingVCg__E="sine", - ) -) - -# Delete the extraneous pointer. -del example_operating_point - -# Define the AeroelasticMovement. This contains the AeroelasticAirplaneMovement and -# the AeroelasticOperatingPointMovement. The delta_time and num_steps must be specified -# explicitly. With a flapping period of 1.0s, 3 cycles at dt=0.03s gives 100 steps. -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, -) - -# Define the AeroelasticUnsteadyProblem. -# The deformation parameters are set here. -# The wing_density, spring_constant and damping_constant are the primary parameters -# you should expect to change. The rest are more for considering numerical issues -# with our integrator and debugging. Plotting the flap cycle can give good data as well. -example_problem = ps.problems.AeroelasticUnsteadyProblem( - movement=example_movement, - wing_density=0.012, - spring_constant=20.0, - damping_constant=1.0, - aero_scaling=1.0, - moment_scaling_factor=1.0, - damping_eps=1e-3, - plot_flap_cycle=False, - custom_spacing_second_derivative=None, - only_final_results=False, -) - -# Define a new solver. We'll create an AeroelasticUnsteadyRingVortexLatticeMethodSolver, -# which requires an AeroelasticUnsteadyProblem. -example_solver = ps.aeroelastic_unsteady_ring_vortex_lattice_method.AeroelasticUnsteadyRingVortexLatticeMethodSolver( - aeroelastic_unsteady_problem=example_problem, -) - -# Delete the extraneous pointer. -del example_problem - -# Run the solver. -example_solver.run( - prescribed_wake=True, -) - -# Call the animate function on the solver. This produces a GIF of the wake being -# shed. The GIF is saved in the same directory as this script. Press "q", -# after orienting the view, to begin the animation. -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=True, -) diff --git a/examples/demos/demo_single_step_wing_pitch.py b/examples/demos/demo_single_step_wing_pitch.py deleted file mode 100644 index 060c6d9ca..000000000 --- a/examples/demos/demo_single_step_wing_pitch.py +++ /dev/null @@ -1,372 +0,0 @@ -"""This is script is an example of how to run Ptera Software's -UnsteadyRingVortexLatticeMethodSolver with a custom Airplane with a non-static -Movement.""" - -# First, import the software's main package. Note that if you wished to import this -# software into another package, you would first install it by running "pip install -# pterasoftware" in your terminal. -import pterasoftware as ps - -# Create an Airplane with our custom geometry. I am going to declare every parameter -# for Airplane, even though most of them have usable default values. This is for -# educational purposes, but keep in mind that it makes the code much longer than it -# needs to be. For details about each parameter, read the detailed class docstring. -# The same caveats apply to the other classes, methods, and functions I call in this -# script. - -# Wing cross section initialization -# offsets for the spacing -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 = [] - -# Initialization loop for our wing cross sections. Here we are defining automatically -# wing cross sections with a variable set of chords. All of the wing cross sections for -# deformation simulation are defined to have num_spanwise_panels=1 (except the wing tip which -# is always None). This is because we deform each strip of wing cross section independently by -# modeling them as torsional springs, and that model only really works if those strips are thin. -# Note that if you want to go thinner for the same base definition, you can increase the number -# of spanwise panels and ensure that in Wing you set the single_step_wing parameter to True, -# which will ensure that the wing is split back up into single strips for deformation. -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="cosine" 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, - ), - ) - ) - -# Primary wing definition. Note that the single_step_wing parameter is set to True, -# which means that the wing will be split into strips for deformation, and each -# strip will be modeled as a torsional spring. -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), - single_step_wing=True, - num_chordwise_panels=6, - chordwise_spacing="uniform", -) - -# Actually generating the airplane. A tail is added to the airplane, but it is not -# split into strips for deformation as currently only the first wing is considered -# for deformation in the codebase. Fututre versions of this feature could allow for -# the deformation of multiple wings. For now, it is convenient to not split the tail -# into single strip wing cross sections as it reduces the number of movement variables -# that need to be defined. -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), - single_step_wing=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, -) - -# The main Wing was defined to have symmetric=True, mirror_only=False, and with a -# symmetry plane offset non-coincident with the Wing's axes yz-plane. Therefore, -# that Wing had type 5 symmetry (see the Wing class documentation for more details on -# symmetry types). Therefore, it was actually split into two Wings, the with the -# second Wing being a reflected version of the first. Therefore, we need to define a -# WingMovement for this reflected Wing. To start, we'll first define the reflected -# main wing's root and tip WingCrossSections' WingCrossSectionMovements. - -# definitions for wing cross section movement 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 - -# A list of movements for the main wing -main_movements_list = [] -main_single_step_movements_list = [] - -# A list of movements for the reflected wing -reflected_movements_list = [] -reflected_single_step_movements_list = [] - -# A loop for defining the movement for the main wing and its reflected counterpart's wing -# cross sections. Here, we are defining single step wing cross movement, a movement class -# that functions differently from the standard Movement class, by giving the next -# position of wing cross section from the previous instead of attempting to precompute -# the entire movement beforehand as that is impossible in scenarios where the deformation -# is dependent on the aerodynamic loads. -for i in range(len(example_airplane.wings[0].wing_cross_sections)): - if i == 0: - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[i], - ) - main_single_step_movements_list.append(single_step_movement) - reflected_single_step_movements_list.append(single_step_movement) - - else: - single_step_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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_single_step_movements_list.append(single_step_movement) - reflected_single_step_movements_list.append(single_step_movement) - - -# Now define the v-tail's root and tip WingCrossSections' WingCrossSectionMovements. -single_step_v_tail_root_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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), -) -single_step_v_tail_tip_wing_cross_section_movement = ps.movements.single_step.single_step_wing_cross_section_movement.SingleStepWingCrossSectionMovement( - 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), -) - -# This dephase parameter is used to make the wing start in a flat position -dephase = 169.0 - -# Now define the main wing's SingleStepWingMovement, the reflected main wing's SingleStepWingMovement and -# the v-tail's SingleStepWingMovement. -single_step_main_wing_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - base_wing=example_airplane.wings[0], - single_step_wing_cross_section_movements=main_single_step_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=(15.0, 23.0, 0.0), # (0.0, 0.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 0.0), # (0.0, 0.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(dephase, -90.0, 0.0), - ) -) - -single_step_reflected_main_wing_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - base_wing=example_airplane.wings[1], - single_step_wing_cross_section_movements=reflected_single_step_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=(15.0, 23.0, 0.0), - periodAngles_Gs_to_Wn_ixyz=(1.0, 1.0, 0.0), - spacingAngles_Gs_to_Wn_ixyz=("sine", "sine", "sine"), - phaseAngles_Gs_to_Wn_ixyz=(dephase, -90.0, 0.0), - ) -) - -single_step_v_tail_movement = ( - ps.movements.single_step.single_step_wing_movement.SingleStepWingMovement( - base_wing=example_airplane.wings[2], - single_step_wing_cross_section_movements=[ - single_step_v_tail_root_wing_cross_section_movement, - single_step_v_tail_tip_wing_cross_section_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), - ) -) - -# Delete the extraneous pointers to the WingCrossSectionMovements, as these are now -# contained within the WingMovements. This is optional, but it can make debugging -# easier. -del single_step_v_tail_root_wing_cross_section_movement -del single_step_v_tail_tip_wing_cross_section_movement - -# Now define the example airplane's SingleStepAirplaneMovement. -single_step_airplane_movement = ( - ps.movements.single_step.single_step_airplane_movement.SingleStepAirplaneMovement( - base_airplane=example_airplane, - single_step_wing_movements=[ - single_step_main_wing_movement, - single_step_reflected_main_wing_movement, - single_step_v_tail_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), - ) -) - -# Delete the extraneous pointers to the WingMovements. -del single_step_main_wing_movement -del single_step_reflected_main_wing_movement -del single_step_v_tail_movement - -# Define a new OperatingPoint. -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 -) - -# Define the operating point's OperatingPointMovement. -operating_point_movement = ps.movements.operating_point_movement.OperatingPointMovement( - base_operating_point=example_operating_point, periodVCg__E=0.0, spacingVCg__E="sine" -) - -single_step_operating_point_movement = ps.movements.single_step.single_step_operating_point_movement.SingleStepOperatingPointMovement( - base_operating_point=example_operating_point, - ampVCg__E=0.0, - periodVCg__E=0.0, - spacingVCg__E="sine", -) - -# Delete the extraneous pointer. -del example_operating_point - -# Define the SingleStepMovement. This contains the SingleStepAirplaneMovement and the -# SingleStepOperatingPointMovement. - -single_step_movement = ps.movements.single_step.single_step_movement.SingleStepMovement( - single_step_airplane_movements=[single_step_airplane_movement], - single_step_operating_point_movement=single_step_operating_point_movement, - delta_time=0.03, - num_cycles=3, -) - -# Delete the extraneous pointers. -del operating_point_movement - -# Define the UnsteadyProblem. -# The deformation parameters are set here -# The wing_density, spring_constant and damping_constant are the primary parameters -# you should expect to change. The rest are more for considering numerical issues -# with our integrator and debugging. Plotting the flap cycle can give good data as well. -example_problem = ps.problems.AeroelasticUnsteadyProblem( - single_step_movement=single_step_movement, - wing_density=0.012, - spring_constant=20.0, - damping_constant=1.0, - aero_scaling=1.0, - moment_scaling_factor=1.0, - damping_eps=1e-3, - plot_flap_cycle=False, - custom_spacing_second_derivative=None, - only_final_results=False, -) - -# Define a new solver. The available solver classes are -# SteadyHorseshoeVortexLatticeMethodSolver, SteadyRingVortexLatticeMethodSolver, -# and UnsteadyRingVortexLatticeMethodSolver. We'll create an -# UnsteadyRingVortexLatticeMethodSolver, which requires a UnsteadyProblem. -example_solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( - coupled_unsteady_problem=example_problem, -) - -# Delete the extraneous pointer. -del example_problem - -# Run the solver. -example_solver.run( - prescribed_wake=True, -) - -# Call the animate function on the solver. This produces a GIF of the wake being -# shed. The GIF is saved in the same directory as this script. Press "q", -# after orienting the view, to begin the animation. -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=True, -) diff --git a/pterasoftware/_core.py b/pterasoftware/_core.py index f10529625..16c2cd631 100644 --- a/pterasoftware/_core.py +++ b/pterasoftware/_core.py @@ -5,12 +5,16 @@ import copy import math from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING import numpy as np from . import _oscillation, _parameter_validation, _transformations, geometry from . import operating_point as operating_point_mod +if TYPE_CHECKING: + from . import problems as problems_mod + def lcm(a: float, b: float) -> float: """Calculates the least common multiple of two numbers. @@ -2361,3 +2365,23 @@ def first_results_step(self) -> int: @property def max_wake_rows(self) -> int | None: return self._max_wake_rows + + @property + def steady_problems(self) -> tuple[problems_mod.SteadyProblem, ...]: + """The SteadyProblems for each time step. + + Subclasses must override. + """ + raise NotImplementedError( + "Subclasses of CoreUnsteadyProblem must implement steady_problems." + ) + + @property + def movement(self) -> CoreMovement: + """The movement that defines the motion parameters. + + Subclasses must override. + """ + raise NotImplementedError( + "Subclasses of CoreUnsteadyProblem must implement movement." + ) diff --git a/pterasoftware/movements/single_step/__init__.py b/pterasoftware/movements/single_step/__init__.py deleted file mode 100644 index 8cc1dcbf1..000000000 --- a/pterasoftware/movements/single_step/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Contains the single step movement classes. - -**Contains the following subpackages:** - -None - -**Contains the following directories:** - -None - -**Contains the following modules:** - -single_step_airplane_movement.py: Contains the SingleStepAirplaneMovement class. - -single_step_operating_point_movement.py: Contains the SingleStepOperatingPointMovement -class. - -single_step_movement.py: Contains the SingleStepMovement class. - -single_step_wing_cross_section_movement.py: Contains the -SingleStepWingCrossSectionMovement class. - -single_step_wing_movement.py: Contains the SingleStepWingMovement class. -""" - -import pterasoftware.movements.single_step.single_step_airplane_movement -import pterasoftware.movements.single_step.single_step_movement -import pterasoftware.movements.single_step.single_step_operating_point_movement -import pterasoftware.movements.single_step.single_step_wing_cross_section_movement -import pterasoftware.movements.single_step.single_step_wing_movement diff --git a/pterasoftware/movements/single_step/single_step_airplane_movement.py b/pterasoftware/movements/single_step/single_step_airplane_movement.py deleted file mode 100644 index 6722d3020..000000000 --- a/pterasoftware/movements/single_step/single_step_airplane_movement.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Contains the SingleStepAirplaneMovement class. - -**Contains the following classes:** - -SingleStepAirplaneMovement: A single step variant of AirplaneMovement that generates one -Airplane per time step instead of all at once. Uses composition to wrap an -AirplaneMovement. - -**Contains the following functions:** - -None -""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence - -import numpy as np - -from ... import geometry -from ..._parameter_validation import ( - int_in_range_return_int, - number_in_range_return_float, -) -from .._functions import ( - oscillating_customspaces, - oscillating_linspaces, - oscillating_sinspaces, -) -from ..airplane_movement import AirplaneMovement - - -class SingleStepAirplaneMovement: - """A single step variant of AirplaneMovement for coupled simulations. - - This class wraps an AirplaneMovement via composition and generates one Airplane per - time step (via generate_next_airplane) rather than generating all Airplanes at once. - The composed AirplaneMovement is accessible via corresponding_airplane_movement. - - **Contains the following methods:** - - all_periods: All unique non zero periods from this SingleStepAirplaneMovement, its - SingleStepWingMovements, and their SingleStepWingCrossSectionMovements. - - generate_next_airplane: Creates the Airplane at a single time step. - - max_period: The longest period of this SingleStepAirplaneMovement's own motion, the - motion(s) of its sub movement object(s), and the motions of its sub sub movement - objects. - """ - - __slots__ = ( - "wing_movements", - "ampCg_GP1_CgP1", - "periodCg_GP1_CgP1", - "spacingCg_GP1_CgP1", - "phaseCg_GP1_CgP1", - "listCg_GP1_CgP1", - "corresponding_airplane_movement", - ) - - def __init__( - self, - single_step_wing_movements, - base_airplane, - ampCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), - periodCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), - spacingCg_GP1_CgP1: ( - np.ndarray | Sequence[str | Callable[[np.ndarray], np.ndarray]] - ) = ( - "sine", - "sine", - "sine", - ), - phaseCg_GP1_CgP1: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0), - ) -> None: - """The initialization method. - - :param single_step_wing_movements: A list of the SingleStepWingMovements - associated with each of the base Airplane's Wings. It must have the same - length as the base Airplane's list of Wings. - :param base_airplane: The base Airplane from which the Airplane at each time - step will be created. - :param ampCg_GP1_CgP1: An array-like object of non negative numbers (int or - float) with shape (3,) representing the amplitudes of the - SingleStepAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1 - parameters. Can be a tuple, list, or ndarray. Values are converted to floats - internally. Each amplitude must be low enough that it doesn't drive its base - value out of the range of valid values. Otherwise, this - SingleStepAirplaneMovement will try to create Airplanes with invalid - parameter values. Because the first Airplane's Cg_GP1_CgP1 parameter must be - all zeros, this means that the first Airplane's ampCg_GP1_CgP1 parameter - must also be all zeros. The units are in meters. The default is (0.0, 0.0, - 0.0). - :param periodCg_GP1_CgP1: An array-like object of non negative numbers (int or - float) with shape (3,) representing the periods of the - SingleStepAirplaneMovement's changes in its Airplanes' Cg_GP1_CgP1 - parameters. Can be a tuple, list, or ndarray. Values are converted to floats - internally. Each element must be 0.0 if the corresponding element in - ampCg_GP1_CgP1 is 0.0 and non zero if not. The units are in seconds. The - default is (0.0, 0.0, 0.0). - :param spacingCg_GP1_CgP1: An array-like object of strs or callables with shape - (3,) representing the spacing of the SingleStepAirplaneMovement's changes in - its Airplanes' Cg_GP1_CgP1 parameters. Can be a tuple, list, or ndarray. - Each element can be the str "sine", the str "uniform", or a callable custom - spacing function. Custom spacing functions are for advanced users and must - start at 0.0, return to 0.0 after one period of 2*pi radians, have amplitude - of 1.0, be periodic, return finite values only, and accept a ndarray as - input and return a ndarray of the same shape. Custom functions are scaled by - ampCg_GP1_CgP1, shifted horizontally and vertically by phaseCg_GP1_CgP1 and - the base value, and have a period set by periodCg_GP1_CgP1. The default is - ("sine", "sine", "sine"). - :param phaseCg_GP1_CgP1: An array-like object of numbers (int or float) with - shape (3,) representing the phase offsets of the elements in the first time - step's Airplane's Cg_GP1_CgP1 parameter relative to the base Airplane's - Cg_GP1_CgP1 parameter. Can be a tuple, list, or ndarray. Elements must lie - in the range (-180.0, 180.0]. Each element must be 0.0 if the corresponding - element in ampCg_GP1_CgP1 is 0.0 and non zero if not. Values are converted - to floats internally. The units are in degrees. The default is (0.0, 0.0, - 0.0). - :return: None - """ - self.wing_movements = single_step_wing_movements - - # Create the corresponding AirplaneMovement, which validates all oscillation - # parameters and is also needed by coupled unsteady problems. - corresponding_wing_movements = [ - wm.corresponding_wing_movement for wm in self.wing_movements - ] - self.corresponding_airplane_movement = AirplaneMovement( - base_airplane=base_airplane, - wing_movements=corresponding_wing_movements, - ampCg_GP1_CgP1=ampCg_GP1_CgP1, - periodCg_GP1_CgP1=periodCg_GP1_CgP1, - spacingCg_GP1_CgP1=spacingCg_GP1_CgP1, - phaseCg_GP1_CgP1=phaseCg_GP1_CgP1, - ) - - # Copy validated attributes from the corresponding AirplaneMovement. - self.ampCg_GP1_CgP1 = self.corresponding_airplane_movement.ampCg_GP1_CgP1 - self.periodCg_GP1_CgP1 = self.corresponding_airplane_movement.periodCg_GP1_CgP1 - self.spacingCg_GP1_CgP1 = ( - self.corresponding_airplane_movement.spacingCg_GP1_CgP1 - ) - self.phaseCg_GP1_CgP1 = self.corresponding_airplane_movement.phaseCg_GP1_CgP1 - - self.listCg_GP1_CgP1 = None - - @property - def all_periods(self) -> list[float]: - """All unique non zero periods from this SingleStepAirplaneMovement, its - SingleStepWingMovements, and their SingleStepWingCrossSectionMovements. - - :return: A list of all unique non zero periods in seconds. If all motion is - static, this will be an empty list. - """ - periods = [] - - # Collect all periods from WingMovement(s). - for wing_movement in self.wing_movements: - periods.extend(wing_movement.all_periods) - - # Collect all periods from AirplaneMovement's own motion. - for period in self.periodCg_GP1_CgP1: - if period > 0.0: - periods.append(float(period)) - return periods - - def generate_next_airplane( - self, - base_airplane, - delta_time: float | int, - num_steps: int, - step: int, - deformation_matrices, - ) -> geometry.airplane.Airplane: - """Creates the Airplane at a single time step. - - :param base_airplane: The base Airplane from which the new Airplane will be - created. - :param delta_time: The time between each time step. It must be a positive number - (float or int), and will be converted internally to a float. The units are - in seconds. - :param num_steps: The total number of time steps in this movement. It must be a - positive int. - :param step: The index of the current time step. - :param deformation_matrices: Deformation matrices to apply to the Wings, or - None. - :return: The Airplane at the specified time step. - """ - num_steps = int_in_range_return_int( - num_steps, - "num_steps", - min_val=1, - min_inclusive=True, - ) - delta_time = number_in_range_return_float( - delta_time, "delta_time", min_val=0.0, min_inclusive=False - ) - - # Generate oscillating values for each dimension of Cg_GP1_CgP1. - if self.listCg_GP1_CgP1 is None: - self._initialize_oscillating_dimensions( - delta_time, num_steps, base_airplane - ) - - assert self.listCg_GP1_CgP1 is not None - - wings = [] - - # Iterate through the WingMovements. - for wing_movement_id, wing_movement in enumerate(self.wing_movements): - - wings.append( - wing_movement.generate_next_wing( - base_wing=base_airplane.wings[wing_movement_id], - delta_time=delta_time, - num_steps=num_steps, - step=step, - deformation_matrices=deformation_matrices, - ) - ) - - # Get the non changing Airplane attributes. - this_name = base_airplane.name - this_weight = base_airplane.weight - - thisCg_GP1_CgP1 = self.listCg_GP1_CgP1[:, step] - - # Make a new Airplane for this time step. - this_airplane = geometry.airplane.Airplane( - wings=wings, - name=this_name, - Cg_GP1_CgP1=thisCg_GP1_CgP1, - weight=this_weight, - ) - - return this_airplane - - def _initialize_oscillating_dimensions(self, delta_time, num_steps, base_airplane): - """Pre computes the oscillating Cg_GP1_CgP1 values for all time steps. - - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps. - :param base_airplane: The base Airplane providing the base Cg_GP1_CgP1 values. - :return: None - """ - self.listCg_GP1_CgP1 = np.zeros((3, num_steps), dtype=float) - for dim in range(3): - spacing = self.spacingCg_GP1_CgP1[dim] - if spacing == "sine": - self.listCg_GP1_CgP1[dim, :] = oscillating_sinspaces( - amps=self.ampCg_GP1_CgP1[dim], - periods=self.periodCg_GP1_CgP1[dim], - phases=self.phaseCg_GP1_CgP1[dim], - bases=base_airplane.Cg_GP1_CgP1[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif spacing == "uniform": - self.listCg_GP1_CgP1[dim, :] = oscillating_linspaces( - amps=self.ampCg_GP1_CgP1[dim], - periods=self.periodCg_GP1_CgP1[dim], - phases=self.phaseCg_GP1_CgP1[dim], - bases=base_airplane.Cg_GP1_CgP1[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif callable(spacing): - self.listCg_GP1_CgP1[dim, :] = oscillating_customspaces( - amps=self.ampCg_GP1_CgP1[dim], - periods=self.periodCg_GP1_CgP1[dim], - phases=self.phaseCg_GP1_CgP1[dim], - bases=base_airplane.Cg_GP1_CgP1[dim], - num_steps=num_steps, - delta_time=delta_time, - custom_function=spacing, - ) - else: - raise ValueError(f"Invalid spacing value: {spacing}") - - @property - def max_period(self) -> float: - """The longest period of this SingleStepAirplaneMovement's own motion, the - motion(s) of its sub movement object(s), and the motions of its sub sub movement - objects. - - :return: The longest period in seconds. If all the motion is static, this will - be 0.0. - """ - wing_movement_max_periods = [] - for wing_movement in self.wing_movements: - wing_movement_max_periods.append(wing_movement.max_period) - max_wing_movement_period = max(wing_movement_max_periods) - - return float( - max( - max_wing_movement_period, - np.max(self.periodCg_GP1_CgP1), - ) - ) diff --git a/pterasoftware/movements/single_step/single_step_movement.py b/pterasoftware/movements/single_step/single_step_movement.py deleted file mode 100644 index 210823cee..000000000 --- a/pterasoftware/movements/single_step/single_step_movement.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Contains the SingleStepMovement class. - -**Contains the following classes:** - -SingleStepMovement: A single step variant of Movement that generates Airplanes and -OperatingPoints one time step at a time instead of all at once. Uses composition to wrap -a Movement. - -**Contains the following functions:** - -None -""" - -from __future__ import annotations - -import numpy as np - -from ... import geometry -from ...operating_point import OperatingPoint -from ..movement import Movement -from .single_step_airplane_movement import SingleStepAirplaneMovement -from .single_step_operating_point_movement import SingleStepOperatingPointMovement - - -class SingleStepMovement: - """A single step variant of Movement for coupled simulations. - - This class wraps a Movement via composition and generates Airplanes and - OperatingPoints one time step at a time (via generate_next_movement) rather than - generating all of them at once. The composed Movement is accessible via - corresponding_movement. - - **Contains the following methods:** - - generate_next_movement: Creates the Airplanes and OperatingPoint at a single time - step. - """ - - __slots__ = ( - "airplane_movements", - "operating_point_movement", - "corresponding_movement", - "delta_time", - "num_steps", - ) - - def __init__( - self, - single_step_airplane_movements, - single_step_operating_point_movement, - delta_time: float | int | str | None = None, - num_cycles: int | None = None, - num_chords: int | None = None, - num_steps: int | None = None, - ): - """The initialization method. - - :param single_step_airplane_movements: A list of SingleStepAirplaneMovements - characterizing the movement of each Airplane. - :param single_step_operating_point_movement: A SingleStepOperatingPointMovement - characterizing any changes to the operating conditions. - :param delta_time: The time between each time step. Accepts the following: None - (default): SingleStepMovement analytically estimates the delta_time that - produces wake RingVortices with roughly the same chord length as the bound - trailing edge RingVortices, accounting for both freestream and geometry - motion velocities. This provides good results across all Strouhal numbers. - "optimize": SingleStepMovement first runs the analytical estimation, then - uses that result as an initial guess for an iterative optimization that - minimizes the area mismatch between wake RingVortices and their parent bound - trailing edge RingVortices. This is slower but may produce slightly more - accurate results. Positive number (int or float): Use the specified value - directly. All values are converted internally to floats. The units are in - seconds. - :param num_cycles: The number of cycles of the maximum period motion used to - calculate a num_steps parameter initialized as None if the - SingleStepMovement isn't static. If num_steps is not None or if the - SingleStepMovement is static, this must be None. If num_steps is initialized - as None and the SingleStepMovement isn't static, num_cycles must be a - positive int. In that case, I recommend setting num_cycles to 3. The default - is None. - :param num_chords: The number of chord lengths used to calculate a num_steps - parameter initialized as None if the SingleStepMovement is static. If - num_steps is not None or if the SingleStepMovement isn't static, this must - be None. If num_steps is initialized as None and the SingleStepMovement is - static, num_chords must be a positive int. In that case, I recommend setting - num_chords to 10. For cases with multiple Airplanes, the num_chords will - reference the largest reference chord length. The default is None. - :param num_steps: The number of time steps of the unsteady simulation. If - initialized as None, and the SingleStepMovement isn't static, it will - calculate a value for num_steps such that the simulation will cover some - number of cycles of the maximum period of all the motion described in the - SingleStepMovement's sub movement objects, sub sub movement object(s), and - sub sub sub movement objects. If num_steps is initialized as None, and the - SingleStepMovement is static, it will calculate a value for num_steps such - that the simulation will result in a wake extending back by some number of - reference chord lengths. - :return: None - """ - if not isinstance(single_step_airplane_movements, list): - raise TypeError("single_step_airplane_movements must be a list.") - if len(single_step_airplane_movements) < 1: - raise ValueError( - "single_step_airplane_movements must have at least one element." - ) - for airplane_movement in single_step_airplane_movements: - if not isinstance(airplane_movement, SingleStepAirplaneMovement): - raise TypeError( - "Every element in single_step_airplane_movements must be an SingleStepAirplaneMovement." - ) - self.airplane_movements = single_step_airplane_movements - - if not isinstance( - single_step_operating_point_movement, SingleStepOperatingPointMovement - ): - raise TypeError( - "single_step_operating_point_movement must be an SingleStepOperatingPointMovement." - ) - self.operating_point_movement = single_step_operating_point_movement - - corresponding_airplane_movements = [ - airplane_movement.corresponding_airplane_movement - for airplane_movement in self.airplane_movements - ] - self.corresponding_movement = Movement( - airplane_movements=corresponding_airplane_movements, - operating_point_movement=self.operating_point_movement.corresponding_operating_point_movement, - delta_time=delta_time, - num_chords=num_chords, - num_cycles=num_cycles, - num_steps=num_steps, - ) - - self.delta_time = self.corresponding_movement.delta_time - self.num_steps = self.corresponding_movement.num_steps - - def generate_next_movement( - self, - base_airplanes: list[geometry.airplane.Airplane], - base_operating_point: OperatingPoint, - step: int, - deformation_matrices: np.ndarray | None = None, - ) -> tuple[list[geometry.airplane.Airplane], OperatingPoint]: - """Creates the Airplanes and OperatingPoint at a single time step. - - :param base_airplanes: The list of Airplanes at the current time step. - :param base_operating_point: The OperatingPoint at the current time step. - :param step: The index of the time step to generate. - :param deformation_matrices: Deformation matrices to apply to the Wings, or - None. The default is None. - :return: A tuple of (list of Airplanes, OperatingPoint) at the specified time - step. - """ - - airplanes = [] - airplane_movement: SingleStepAirplaneMovement - for airplane_id, airplane_movement in enumerate(self.airplane_movements): - airplanes.append( - airplane_movement.generate_next_airplane( - delta_time=self.delta_time, - base_airplane=base_airplanes[airplane_id], - num_steps=self.num_steps, - step=step, - deformation_matrices=deformation_matrices, - ) - ) - - operating_point = self.operating_point_movement.generate_next_operating_point( - delta_time=self.delta_time, - base_operating_point=base_operating_point, - num_steps=self.num_steps, - step=step, - ) - return airplanes, operating_point diff --git a/pterasoftware/movements/single_step/single_step_operating_point_movement.py b/pterasoftware/movements/single_step/single_step_operating_point_movement.py deleted file mode 100644 index 13a0c3566..000000000 --- a/pterasoftware/movements/single_step/single_step_operating_point_movement.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Contains the SingleStepOperatingPointMovement class. - -**Contains the following classes:** - -SingleStepOperatingPointMovement: A single step variant of OperatingPointMovement that -generates one OperatingPoint per time step instead of all at once. Uses composition to -wrap an OperatingPointMovement. - -**Contains the following functions:** - -None -""" - -from ..._parameter_validation import ( - int_in_range_return_int, - number_in_range_return_float, -) -from ...operating_point import OperatingPoint -from .._functions import ( - oscillating_customspaces, - oscillating_linspaces, - oscillating_sinspaces, -) -from ..operating_point_movement import OperatingPointMovement - - -class SingleStepOperatingPointMovement: - """A single step variant of OperatingPointMovement for coupled simulations. - - This class wraps an OperatingPointMovement via composition and generates one - OperatingPoint per time step (via generate_next_operating_point) rather than - generating all OperatingPoints at once. The composed OperatingPointMovement is - accessible via corresponding_operating_point_movement. - - **Contains the following methods:** - - generate_next_operating_point: Creates the OperatingPoint at a single time step. - - max_period: The longest period of this SingleStepOperatingPointMovement's own - motion. - """ - - __slots__ = ( - "ampVCg__E", - "periodVCg__E", - "spacingVCg__E", - "phaseVCg__E", - "listVCg__E", - "corresponding_operating_point_movement", - ) - - def __init__( - self, - base_operating_point: OperatingPoint, - ampVCg__E=0.0, - periodVCg__E=0.0, - spacingVCg__E="sine", - phaseVCg__E=0.0, - ): - """The initialization method. - - :param base_operating_point: The base OperatingPoint from which the - OperatingPoint at each time step will be created. - :param ampVCg__E: The amplitude of the SingleStepOperatingPointMovement's - changes in its OperatingPoints' vCg__E parameters. Must be a non negative - number (int or float), and is converted to a float internally. The amplitude - must be low enough that it doesn't drive its base value out of the range of - valid values. Otherwise, this SingleStepOperatingPointMovement will try to - create OperatingPoints with invalid parameter values. The units are in - meters per second. The default is 0.0. - :param periodVCg__E: The period of the SingleStepOperatingPointMovement's - changes in its OperatingPoints' vCg__E parameter. Must be a non negative - number (int or float), and is converted to a float internally. It must be - 0.0 if ampVCg__E is 0.0 and non zero if not. The units are in seconds. The - default is 0.0. - :param spacingVCg__E: Determines the spacing of the - SingleStepOperatingPointMovement's change in its OperatingPoints' vCg__E - parameters. Can be "sine", "uniform", or a callable custom spacing function. - Custom spacing functions are for advanced users and must start at 0.0, - return to 0.0 after one period of 2*pi radians, have amplitude of 1.0, be - periodic, return finite values only, and accept a ndarray as input and - return a ndarray of the same shape. The custom function is scaled by - ampVCg__E, shifted horizontally and vertically by phaseVCg__E and the base - value, and have a period set by periodVCg__E. The default is "sine". - :param phaseVCg__E: The phase offset of the first time step's OperatingPoint's - vCg__E parameter relative to the base OperatingPoint's vCg__E parameter. - Must be a number (int or float) in the range (-180.0, 180.0], and will be - converted to a float internally. It must be 0.0 if ampVCg__E is 0.0 and non - zero if not. The units are in degrees. The default is 0.0. - :return: None - """ - - # Create the corresponding OperatingPointMovement, which validates all - # oscillation parameters and is also needed by coupled unsteady problems. - self.corresponding_operating_point_movement = OperatingPointMovement( - base_operating_point=base_operating_point, - ampVCg__E=ampVCg__E, - periodVCg__E=periodVCg__E, - spacingVCg__E=spacingVCg__E, - phaseVCg__E=phaseVCg__E, - ) - - # Copy validated attributes from the corresponding OperatingPointMovement. - self.ampVCg__E = self.corresponding_operating_point_movement.ampVCg__E - self.periodVCg__E = self.corresponding_operating_point_movement.periodVCg__E - self.spacingVCg__E = self.corresponding_operating_point_movement.spacingVCg__E - self.phaseVCg__E = self.corresponding_operating_point_movement.phaseVCg__E - - self.listVCg__E = None - - def generate_next_operating_point( - self, delta_time, base_operating_point: OperatingPoint, num_steps, step - ): - """Creates the OperatingPoint at a single time step. - - :param delta_time: The time between each time step in seconds. - :param base_operating_point: The base OperatingPoint from which the new - OperatingPoint will be created. - :param num_steps: The total number of time steps in this movement. - :param step: The index of the current time step. - :return: The OperatingPoint at the specified time step. - """ - num_steps = int_in_range_return_int( - num_steps, "num_steps", min_val=1, min_inclusive=True - ) - delta_time = number_in_range_return_float( - delta_time, "delta_time", min_val=0.0, min_inclusive=False - ) - - if self.listVCg__E is None: - self._initialize_oscillating_values( - delta_time=delta_time, - num_steps=num_steps, - base_operating_point=base_operating_point, - ) - - assert self.listVCg__E is not None - # Get the non-changing OperatingPoint attributes. - this_rho = base_operating_point.rho - this_alpha = base_operating_point.alpha - this_beta = base_operating_point.beta - thisExternalFX_W = base_operating_point.externalFX_W - this_nu = base_operating_point.nu - - # Make a new operating point object for this time step. - this_operating_point = OperatingPoint( - rho=this_rho, - vCg__E=self.listVCg__E[step], - alpha=this_alpha, - beta=this_beta, - externalFX_W=thisExternalFX_W, - nu=this_nu, - ) - - return this_operating_point - - @property - def max_period(self): - """The longest period of this SingleStepOperatingPointMovement's own motion. - - :return: The longest period in seconds. If all the motion is static, this will - be 0.0. - """ - return self.periodVCg__E - - def _initialize_oscillating_values( - self, delta_time, num_steps, base_operating_point - ): - """Pre computes the oscillating VCg__E values for all time steps. - - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps. - :param base_operating_point: The base OperatingPoint providing the base VCg__E - value. - :return: None - """ - # Generate oscillating values for VCg__E. - if self.spacingVCg__E == "sine": - self.listVCg__E = oscillating_sinspaces( - amps=self.ampVCg__E, - periods=self.periodVCg__E, - phases=self.phaseVCg__E, - bases=base_operating_point.vCg__E, - num_steps=num_steps, - delta_time=delta_time, - ) - elif self.spacingVCg__E == "uniform": - self.listVCg__E = oscillating_linspaces( - amps=self.ampVCg__E, - periods=self.periodVCg__E, - phases=self.phaseVCg__E, - bases=base_operating_point.vCg__E, - num_steps=num_steps, - delta_time=delta_time, - ) - elif callable(self.spacingVCg__E): - self.listVCg__E = oscillating_customspaces( - amps=self.ampVCg__E, - periods=self.periodVCg__E, - phases=self.phaseVCg__E, - bases=base_operating_point.vCg__E, - num_steps=num_steps, - delta_time=delta_time, - custom_function=self.spacingVCg__E, - ) - else: - raise ValueError(f"Invalid spacing value: {self.spacingVCg__E}") diff --git a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py b/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py deleted file mode 100644 index 1b80d0dd3..000000000 --- a/pterasoftware/movements/single_step/single_step_wing_cross_section_movement.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Contains the SingleStepWingCrossSectionMovement class. - -**Contains the following classes:** - -SingleStepWingCrossSectionMovement: A single step variant of WingCrossSectionMovement -that generates one WingCrossSection per time step instead of all at once. Uses -composition to wrap a WingCrossSectionMovement. - -**Contains the following functions:** - -None -""" - -import numpy as np - -from ... import geometry -from ..._parameter_validation import ( - int_in_range_return_int, - number_in_range_return_float, -) -from .._functions import ( - oscillating_customspaces, - oscillating_linspaces, - oscillating_sinspaces, -) -from ..wing_cross_section_movement import WingCrossSectionMovement - - -class SingleStepWingCrossSectionMovement: - """A single step variant of WingCrossSectionMovement for coupled simulations. - - This class wraps a WingCrossSectionMovement via composition and generates one - WingCrossSection per time step (via generate_next_wing_cross_sections) rather than - generating all WingCrossSections at once. The composed WingCrossSectionMovement is - accessible via corresponding_wing_cross_section_movement. - - **Contains the following methods:** - - generate_next_wing_cross_sections: Creates the WingCrossSection at a single time - step. - - max_period: The longest period of this SingleStepWingCrossSectionMovement's own - motion. - """ - - __slots__ = ( - "ampLp_Wcsp_Lpp", - "periodLp_Wcsp_Lpp", - "spacingLp_Wcsp_Lpp", - "phaseLp_Wcsp_Lpp", - "ampAngles_Wcsp_to_Wcs_ixyz", - "periodAngles_Wcsp_to_Wcs_ixyz", - "spacingAngles_Wcsp_to_Wcs_ixyz", - "phaseAngles_Wcsp_to_Wcs_ixyz", - "listLp_Wcsp_Lpp", - "listAngles_Wcsp_to_Wcs_ixyz", - "corresponding_wing_cross_section_movement", - ) - - def __init__( - self, - base_wing_cross_section, - 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), - ): - """The initialization method. - - :param base_wing_cross_section: The base WingCrossSection from which the - WingCrossSection at each time step will be created. - :param ampLp_Wcsp_Lpp: An array-like object of non negative numbers (int or - float) with shape (3,) representing the amplitudes of the - SingleStepWingCrossSectionMovement's changes in its WingCrossSections' - Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are - converted to floats internally. Each amplitude must be low enough that it - doesn't drive its base value out of the range of valid values. Otherwise, - this SingleStepWingCrossSectionMovement will try to create WingCrossSections - with invalid parameter values. The units are in meters. The default is (0.0, - 0.0, 0.0). - :param periodLp_Wcsp_Lpp: An array-like object of non negative numbers (int or - float) with shape (3,) representing the periods of the - SingleStepWingCrossSectionMovement's changes in its WingCrossSections' - Lp_Wcsp_Lpp parameters. Can be a tuple, list, or ndarray. Values are - converted to floats internally. Each element must be 0.0 if the - corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not. The - units are in seconds. The default is (0.0, 0.0, 0.0). - :param spacingLp_Wcsp_Lpp: An array-like object of strs or callables with shape - (3,) representing the spacing of the SingleStepWingCrossSectionMovement's - changes in its WingCrossSections' Lp_Wcsp_Lpp parameters. Can be a tuple, - list, or ndarray. Each element can be the str "sine", the str "uniform", or - a callable custom spacing function. Custom spacing functions are for - advanced users and must start at 0.0, return to 0.0 after one period of 2*pi - radians, have amplitude of 1.0, be periodic, return finite values only, and - accept a ndarray as input and return a ndarray of the same shape. Custom - functions are scaled by ampLp_Wcsp_Lpp, shifted horizontally and vertically - by phaseLp_Wcsp_Lpp and the base value, and have a period set by - periodLp_Wcsp_Lpp. The default is ("sine", "sine", "sine"). - :param phaseLp_Wcsp_Lpp: An array-like object of numbers (int or float) with - shape (3,) representing the phase offsets of the elements in the first time - step's WingCrossSection's Lp_Wcsp_Lpp parameter relative to the base - WingCrossSection's Lp_Wcsp_Lpp parameter. Can be a tuple, list, or ndarray. - Elements must lie in the range (-180.0, 180.0]. Each element must be 0.0 if - the corresponding element in ampLp_Wcsp_Lpp is 0.0 and non zero if not. - Values are converted to floats internally. The units are in degrees. The - default is (0.0, 0.0, 0.0). - :param ampAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative numbers - (int or float) with shape (3,) representing the amplitudes of the - SingleStepWingCrossSectionMovement's changes in its WingCrossSections' - angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values - are converted to floats internally. Each amplitude must be low enough that - it doesn't drive its base value out of the range of valid values. Otherwise, - this SingleStepWingCrossSectionMovement will try to create WingCrossSections - with invalid parameter values. The units are in degrees. The default is - (0.0, 0.0, 0.0). - :param periodAngles_Wcsp_to_Wcs_ixyz: An array-like object of non negative - numbers (int or float) with shape (3,) representing the periods of the - SingleStepWingCrossSectionMovement's changes in its WingCrossSections' - angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Values - are converted to floats internally. Each element must be 0.0 if the - corresponding element in ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if - not. The units are in seconds. The default is (0.0, 0.0, 0.0). - :param spacingAngles_Wcsp_to_Wcs_ixyz: An array-like object of strs or callables - with shape (3,) representing the spacing of the - SingleStepWingCrossSectionMovement's changes in its WingCrossSections' - angles_Wcsp_to_Wcs_ixyz parameters. Can be a tuple, list, or ndarray. Each - element can be the str "sine", the str "uniform", or a callable custom - spacing function. Custom spacing functions are for advanced users and must - start at 0.0, return to 0.0 after one period of 2*pi radians, have amplitude - of 1.0, be periodic, return finite values only, and accept a ndarray as - input and return a ndarray of the same shape. Custom functions are scaled by - ampAngles_Wcsp_to_Wcs_ixyz, shifted horizontally and vertically by - phaseAngles_Wcsp_to_Wcs_ixyz and the base value, and have a period set by - periodAngles_Wcsp_to_Wcs_ixyz. The default is ("sine", "sine", "sine"). - :param phaseAngles_Wcsp_to_Wcs_ixyz: An array-like object of numbers (int or - float) with shape (3,) representing the phase offsets of the elements in the - first time step's WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter - relative to the base WingCrossSection's angles_Wcsp_to_Wcs_ixyz parameter. - Can be a tuple, list, or ndarray. Elements must lie in the range (-180.0, - 180.0]. Each element must be 0.0 if the corresponding element in - ampAngles_Wcsp_to_Wcs_ixyz is 0.0 and non zero if not. Values are converted - to floats internally. The units are in degrees. The default is (0.0, 0.0, - 0.0). - :return: None - """ - - # Warn about potential deformation issues with multiple spanwise panels. - if ( - base_wing_cross_section.num_spanwise_panels is not None - and base_wing_cross_section.num_spanwise_panels > 1 - ): - print( - "base_wing_cross_section must have num_spanwise_panels equal to None or 1 to do deformation. " - + "This wing cross section has " - + str(base_wing_cross_section.num_spanwise_panels) - + " spanwise panels. Please be sure this is intended. " - + "Applications that make sense for this are tails and non-primary wings." - ) - - # Create the corresponding WingCrossSectionMovement, which validates all - # oscillation parameters and is also needed by coupled unsteady problems. - self.corresponding_wing_cross_section_movement = WingCrossSectionMovement( - base_wing_cross_section=base_wing_cross_section, - ampLp_Wcsp_Lpp=ampLp_Wcsp_Lpp, - periodLp_Wcsp_Lpp=periodLp_Wcsp_Lpp, - spacingLp_Wcsp_Lpp=spacingLp_Wcsp_Lpp, - phaseLp_Wcsp_Lpp=phaseLp_Wcsp_Lpp, - ampAngles_Wcsp_to_Wcs_ixyz=ampAngles_Wcsp_to_Wcs_ixyz, - periodAngles_Wcsp_to_Wcs_ixyz=periodAngles_Wcsp_to_Wcs_ixyz, - spacingAngles_Wcsp_to_Wcs_ixyz=spacingAngles_Wcsp_to_Wcs_ixyz, - phaseAngles_Wcsp_to_Wcs_ixyz=phaseAngles_Wcsp_to_Wcs_ixyz, - ) - - # Copy validated attributes from the corresponding WingCrossSectionMovement. - self.ampLp_Wcsp_Lpp = ( - self.corresponding_wing_cross_section_movement.ampLp_Wcsp_Lpp - ) - self.periodLp_Wcsp_Lpp = ( - self.corresponding_wing_cross_section_movement.periodLp_Wcsp_Lpp - ) - self.spacingLp_Wcsp_Lpp = ( - self.corresponding_wing_cross_section_movement.spacingLp_Wcsp_Lpp - ) - self.phaseLp_Wcsp_Lpp = ( - self.corresponding_wing_cross_section_movement.phaseLp_Wcsp_Lpp - ) - self.ampAngles_Wcsp_to_Wcs_ixyz = ( - self.corresponding_wing_cross_section_movement.ampAngles_Wcsp_to_Wcs_ixyz - ) - self.periodAngles_Wcsp_to_Wcs_ixyz = ( - self.corresponding_wing_cross_section_movement.periodAngles_Wcsp_to_Wcs_ixyz - ) - self.spacingAngles_Wcsp_to_Wcs_ixyz = ( - self.corresponding_wing_cross_section_movement.spacingAngles_Wcsp_to_Wcs_ixyz - ) - self.phaseAngles_Wcsp_to_Wcs_ixyz = ( - self.corresponding_wing_cross_section_movement.phaseAngles_Wcsp_to_Wcs_ixyz - ) - - self.listLp_Wcsp_Lpp = None - self.listAngles_Wcsp_to_Wcs_ixyz = None - - def generate_next_wing_cross_sections( - self, - base_wing_cross_section, - delta_time, - num_steps, - step, - deformation_matrix, - ): - """Creates the WingCrossSection at a single time step. - - :param base_wing_cross_section: The base WingCrossSection from which the new - WingCrossSection will be created. - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps in this movement. - :param step: The index of the current time step. - :param deformation_matrix: Deformation matrix to apply to the WingCrossSection's - angles, or None. - :return: A list containing the WingCrossSection at the specified time step. - """ - num_steps = int_in_range_return_int( - num_steps, "num_steps", min_val=1, min_inclusive=True - ) - delta_time = number_in_range_return_float( - delta_time, "delta_time", min_val=0.0, min_inclusive=False - ) - - # Generate oscillating values for each dimension of Lp_Wcsp_Lpp. - if self.listLp_Wcsp_Lpp is None: - self._initialize_oscillating_dimensions( - delta_time, - num_steps, - base_wing_cross_section, - ) - - # Generate oscillating values for each dimension of angles_Wcsp_to_Wcs_ixyz. - if self.listAngles_Wcsp_to_Wcs_ixyz is None: - self._initialize_oscillating_angles( - delta_time, - num_steps, - base_wing_cross_section, - ) - - # Create an empty list to hold each time step's WingCrossSection. - wing_cross_sections = [] - - # Get the non-changing WingCrossSectionAttributes. - this_airfoil = base_wing_cross_section.airfoil - this_num_spanwise_panels = base_wing_cross_section.num_spanwise_panels - this_chord = base_wing_cross_section.chord - this_control_surface_symmetry_type = ( - base_wing_cross_section.control_surface_symmetry_type - ) - this_control_surface_hinge_point = ( - base_wing_cross_section.control_surface_hinge_point - ) - this_control_surface_deflection = ( - base_wing_cross_section.control_surface_deflection - ) - this_spanwise_spacing = base_wing_cross_section.spanwise_spacing - - thisLp_Wcsp_Lpp = self.listLp_Wcsp_Lpp[:, step] - theseAngles_Wcsp_to_Wcs_ixyz = ( - self.listAngles_Wcsp_to_Wcs_ixyz[:, step] + deformation_matrix - ) - - # Make a new WingCrossSection for this time step. - this_wing_cross_section = geometry.wing_cross_section.WingCrossSection( - airfoil=this_airfoil, - num_spanwise_panels=this_num_spanwise_panels, - chord=this_chord, - Lp_Wcsp_Lpp=thisLp_Wcsp_Lpp, - angles_Wcsp_to_Wcs_ixyz=theseAngles_Wcsp_to_Wcs_ixyz, - control_surface_symmetry_type=this_control_surface_symmetry_type, - control_surface_hinge_point=this_control_surface_hinge_point, - control_surface_deflection=this_control_surface_deflection, - spanwise_spacing=this_spanwise_spacing, - ) - - # Add this new WingCrossSection to the list of WingCrossSections. - wing_cross_sections.append(this_wing_cross_section) - - return wing_cross_sections - - def _initialize_oscillating_dimensions( - self, - delta_time, - num_steps, - base_wing_cross_section, - ): - """Pre computes the oscillating Lp_Wcsp_Lpp values for all time steps. - - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps. - :param base_wing_cross_section: The base WingCrossSection providing the base - Lp_Wcsp_Lpp values. - :return: None - """ - - # Generate oscillating values for each dimension of Lp_Wcsp_Lpp. - self.listLp_Wcsp_Lpp = np.zeros((3, num_steps), dtype=float) - for dim in range(3): - spacing = self.spacingLp_Wcsp_Lpp[dim] - if spacing == "sine": - self.listLp_Wcsp_Lpp[dim, :] = oscillating_sinspaces( - amps=self.ampLp_Wcsp_Lpp[dim], - periods=self.periodLp_Wcsp_Lpp[dim], - phases=self.phaseLp_Wcsp_Lpp[dim], - bases=base_wing_cross_section.Lp_Wcsp_Lpp[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif spacing == "uniform": - self.listLp_Wcsp_Lpp[dim, :] = oscillating_linspaces( - amps=self.ampLp_Wcsp_Lpp[dim], - periods=self.periodLp_Wcsp_Lpp[dim], - phases=self.phaseLp_Wcsp_Lpp[dim], - bases=base_wing_cross_section.Lp_Wcsp_Lpp[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif callable(spacing): - self.listLp_Wcsp_Lpp[dim, :] = oscillating_customspaces( - amps=self.ampLp_Wcsp_Lpp[dim], - periods=self.periodLp_Wcsp_Lpp[dim], - phases=self.phaseLp_Wcsp_Lpp[dim], - bases=base_wing_cross_section.Lp_Wcsp_Lpp[dim], - num_steps=num_steps, - delta_time=delta_time, - custom_function=spacing, - ) - else: - raise ValueError(f"Invalid spacing value: {spacing}") - - def _initialize_oscillating_angles( - self, - delta_time, - num_steps, - base_wing_cross_section, - ): - """Pre computes the oscillating angles_Wcsp_to_Wcs_ixyz values for all time - steps. - - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps. - :param base_wing_cross_section: The base WingCrossSection providing the base - angles_Wcsp_to_Wcs_ixyz values. - :return: None - """ - self.listAngles_Wcsp_to_Wcs_ixyz = np.zeros((3, num_steps), dtype=float) - for dim in range(3): - spacing = self.spacingAngles_Wcsp_to_Wcs_ixyz[dim] - if spacing == "sine": - self.listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_sinspaces( - amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], - periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], - phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], - bases=base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif spacing == "uniform": - self.listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_linspaces( - amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], - periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], - phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], - bases=base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif callable(spacing): - self.listAngles_Wcsp_to_Wcs_ixyz[dim, :] = oscillating_customspaces( - amps=self.ampAngles_Wcsp_to_Wcs_ixyz[dim], - periods=self.periodAngles_Wcsp_to_Wcs_ixyz[dim], - phases=self.phaseAngles_Wcsp_to_Wcs_ixyz[dim], - bases=base_wing_cross_section.angles_Wcsp_to_Wcs_ixyz[dim], - num_steps=num_steps, - delta_time=delta_time, - custom_function=spacing, - ) - else: - raise ValueError(f"Invalid spacing value: {spacing}") diff --git a/pterasoftware/movements/single_step/single_step_wing_movement.py b/pterasoftware/movements/single_step/single_step_wing_movement.py deleted file mode 100644 index fbf6f2c93..000000000 --- a/pterasoftware/movements/single_step/single_step_wing_movement.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Contains the SingleStepWingMovement class. - -**Contains the following classes:** - -SingleStepWingMovement: A single step variant of WingMovement that generates one Wing -per time step instead of all at once. Uses composition to wrap a WingMovement. - -**Contains the following functions:** - -None -""" - -import numpy as np - -from ... import geometry -from ..._parameter_validation import ( - int_in_range_return_int, - number_in_range_return_float, -) -from .._functions import ( - oscillating_customspaces, - oscillating_linspaces, - oscillating_sinspaces, -) -from ..wing_movement import WingMovement - - -class SingleStepWingMovement: - """A single step variant of WingMovement for coupled simulations. - - This class wraps a WingMovement via composition and generates one Wing per time step - (via generate_next_wing) rather than generating all Wings at once. The composed - WingMovement is accessible via corresponding_wing_movement. - - Wings cannot undergo motion that causes them to switch symmetry types. See the - WingMovement class documentation for details. - - **Contains the following methods:** - - generate_next_wing: Creates the Wing at a single time step. - - max_period: The longest period of this SingleStepWingMovement's own motion and that - of its sub movement objects. - """ - - __slots__ = ( - "wing_cross_section_movements", - "ampLer_Gs_Cgs", - "periodLer_Gs_Cgs", - "spacingLer_Gs_Cgs", - "phaseLer_Gs_Cgs", - "ampAngles_Gs_to_Wn_ixyz", - "periodAngles_Gs_to_Wn_ixyz", - "spacingAngles_Gs_to_Wn_ixyz", - "phaseAngles_Gs_to_Wn_ixyz", - "listLer_Gs_Cgs", - "listAngles_Gs_to_Wn_ixyz", - "corresponding_wing_movement", - ) - - def __init__( - self, - base_wing, - single_step_wing_cross_section_movements, - 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), - ): - """The initialization method. - - :param base_wing: The base Wing from which the Wing at each time step will be - created. - :param single_step_wing_cross_section_movements: A list of - SingleStepWingCrossSectionMovements associated with each of the base Wing's - WingCrossSections. It must have the same length as the base Wing's list of - WingCrossSections. - :param ampLer_Gs_Cgs: An array-like object of non negative numbers (int or - float) with shape (3,) representing the amplitudes of the - SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can be - a tuple, list, or ndarray. Values are converted to floats internally. Each - amplitude must be low enough that it doesn't drive its base value out of the - range of valid values. Otherwise, this SingleStepWingMovement will try to - create Wings with invalid parameter values. The units are in meters. The - default is (0.0, 0.0, 0.0). - :param periodLer_Gs_Cgs: An array-like object of non negative numbers (int or - float) with shape (3,) representing the periods of the - SingleStepWingMovement's changes in its Wings' Ler_Gs_Cgs parameters. Can be - a tuple, list, or ndarray. Values are converted to floats internally. Each - element must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and - non zero if not. The units are in seconds. The default is (0.0, 0.0, 0.0). - :param spacingLer_Gs_Cgs: An array-like object of strs or callables with shape - (3,) representing the spacing of the SingleStepWingMovement's change in its - Wings' Ler_Gs_Cgs parameters. Can be a tuple, list, or ndarray. Each element - can be the str "sine", the str "uniform", or a callable custom spacing - function. Custom spacing functions are for advanced users and must start at - 0.0, return to 0.0 after one period of 2*pi radians, have amplitude of 1.0, - be periodic, return finite values only, and accept a ndarray as input and - return a ndarray of the same shape. The custom function is scaled by - ampLer_Gs_Cgs, shifted horizontally and vertically by phaseLer_Gs_Cgs and - the base value, and have a period set by periodLer_Gs_Cgs. The default is - ("sine", "sine", "sine"). - :param phaseLer_Gs_Cgs: An array-like object of numbers (int or float) with - shape (3,) representing the phase offsets of the elements in the first time - step's Wing's Ler_Gs_Cgs parameter relative to the base Wing's Ler_Gs_Cgs - parameter. Can be a tuple, list, or ndarray. Values must lie in the range - (-180.0, 180.0] and will be converted to floats internally. Each element - must be 0.0 if the corresponding element in ampLer_Gs_Cgs is 0.0 and non - zero if not. The units are in degrees. The default is (0.0, 0.0, 0.0). - :param ampAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float) - with shape (3,) representing the amplitudes of the SingleStepWingMovement's - changes in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, - or ndarray. Values must lie in the range [0.0, 180.0] and will be converted - to floats internally. Each amplitude must be low enough that it doesn't - drive its base value out of the range of valid values. Otherwise, this - SingleStepWingMovement will try to create Wings with invalid parameter - values. The units are in degrees. The default is (0.0, 0.0, 0.0). - :param periodAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or - float) with shape (3,) representing the periods of the - SingleStepWingMovement's changes in its Wings' angles_Gs_to_Wn_ixyz - parameters. Can be a tuple, list, or ndarray. Values are converted to floats - internally. Each element must be 0.0 if the corresponding element in - ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in - seconds. The default is (0.0, 0.0, 0.0). - :param spacingAngles_Gs_to_Wn_ixyz: An array-like object of strs or callables - with shape (3,) representing the spacing of the SingleStepWingMovement's - change in its Wings' angles_Gs_to_Wn_ixyz parameters. Can be a tuple, list, - or ndarray. Each element can be the str "sine", the str "uniform", or a - callable custom spacing function. Custom spacing functions are for advanced - users and must start at 0.0, return to 0.0 after one period of 2*pi radians, - have amplitude of 1.0, be periodic, return finite values only, and accept a - ndarray as input and return a ndarray of the same shape. The custom function - is scaled by ampAngles_Gs_to_Wn_ixyz, shifted horizontally and vertically by - phaseAngles_Gs_to_Wn_ixyz and the base value, with the period set by - periodAngles_Gs_to_Wn_ixyz. The default is ("sine", "sine", "sine"). - :param phaseAngles_Gs_to_Wn_ixyz: An array-like object of numbers (int or float) - with shape (3,) representing the phase offsets of the elements in the first - time step's Wing's angles_Gs_to_Wn_ixyz parameter relative to the base - Wing's angles_Gs_to_Wn_ixyz parameter. Can be a tuple, list, or ndarray. - Values must lie in the range (-180.0, 180.0] and will be converted to floats - internally. Each element must be 0.0 if the corresponding element in - ampAngles_Gs_to_Wn_ixyz is 0.0 and non zero if not. The units are in - degrees. The default is (0.0, 0.0, 0.0). - :return: None - """ - self.wing_cross_section_movements = single_step_wing_cross_section_movements - - # Create the corresponding WingMovement, which validates all oscillation - # parameters and is also needed by coupled unsteady problems. - corresponding_wing_cross_section_movements = [ - wing_cross_section_movement.corresponding_wing_cross_section_movement - for wing_cross_section_movement in single_step_wing_cross_section_movements - ] - self.corresponding_wing_movement = WingMovement( - base_wing=base_wing, - wing_cross_section_movements=corresponding_wing_cross_section_movements, - ampLer_Gs_Cgs=ampLer_Gs_Cgs, - periodLer_Gs_Cgs=periodLer_Gs_Cgs, - spacingLer_Gs_Cgs=spacingLer_Gs_Cgs, - phaseLer_Gs_Cgs=phaseLer_Gs_Cgs, - ampAngles_Gs_to_Wn_ixyz=ampAngles_Gs_to_Wn_ixyz, - periodAngles_Gs_to_Wn_ixyz=periodAngles_Gs_to_Wn_ixyz, - spacingAngles_Gs_to_Wn_ixyz=spacingAngles_Gs_to_Wn_ixyz, - phaseAngles_Gs_to_Wn_ixyz=phaseAngles_Gs_to_Wn_ixyz, - ) - - # Copy validated attributes from the corresponding WingMovement. - self.ampLer_Gs_Cgs = self.corresponding_wing_movement.ampLer_Gs_Cgs - self.periodLer_Gs_Cgs = self.corresponding_wing_movement.periodLer_Gs_Cgs - self.spacingLer_Gs_Cgs = self.corresponding_wing_movement.spacingLer_Gs_Cgs - self.phaseLer_Gs_Cgs = self.corresponding_wing_movement.phaseLer_Gs_Cgs - self.ampAngles_Gs_to_Wn_ixyz = ( - self.corresponding_wing_movement.ampAngles_Gs_to_Wn_ixyz - ) - self.periodAngles_Gs_to_Wn_ixyz = ( - self.corresponding_wing_movement.periodAngles_Gs_to_Wn_ixyz - ) - self.spacingAngles_Gs_to_Wn_ixyz = ( - self.corresponding_wing_movement.spacingAngles_Gs_to_Wn_ixyz - ) - self.phaseAngles_Gs_to_Wn_ixyz = ( - self.corresponding_wing_movement.phaseAngles_Gs_to_Wn_ixyz - ) - - self.listLer_Gs_Cgs = None - self.listAngles_Gs_to_Wn_ixyz = None - - def generate_next_wing( - self, base_wing, delta_time, num_steps, step, deformation_matrices - ): - """Creates the Wing at a single time step. - - :param base_wing: The base Wing from which the new Wing will be created. - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps in this movement. - :param step: The index of the current time step. - :param deformation_matrices: Deformation matrices to apply to the - WingCrossSections, or None. - :return: The Wing at the specified time step. - """ - num_steps = int_in_range_return_int( - num_steps, "num_steps", min_val=1, min_inclusive=True - ) - delta_time = number_in_range_return_float( - delta_time, "delta_time", min_val=0.0, min_inclusive=False - ) - # Account for null deformation_matrices input. - if deformation_matrices is None: - deformation_matrices = np.zeros(len(self.wing_cross_section_movements)) - - # Generate oscillating values for each dimension of Ler_Gs_Cgs. - if self.listLer_Gs_Cgs is None: - self._initialize_oscillating_dimensions(delta_time, num_steps, base_wing) - - # Generate oscillating values for each dimension of angles_Gs_to_Wn_ixyz. - if self.listAngles_Gs_to_Wn_ixyz is None: - self._initialize_oscillating_angles(delta_time, num_steps, base_wing) - - # Create an empty 2D ndarray that will hold each of the Wings's - # WingCrossSection's vector of WingCrossSections representing its changing - # state at each time step. The first index denotes a particular base - # WingCrossSection, and the second index denotes the time step. - wing_cross_sections = np.empty( - (len(self.wing_cross_section_movements), num_steps), dtype=object - ) - - # Iterate through the WingCrossSectionMovements. - for ( - wing_cross_section_movement_id, - wing_cross_section_movement, - ) in enumerate(self.wing_cross_section_movements): - - # Generate this WingCrossSection's vector of WingCrossSections - # representing its changing state at each time step. - this_wing_cross_sections_list_of_wing_cross_sections = np.array( - wing_cross_section_movement.generate_next_wing_cross_sections( - base_wing_cross_section=base_wing.wing_cross_sections[ - wing_cross_section_movement_id - ], - delta_time=delta_time, - num_steps=num_steps, - step=step, - deformation_matrix=deformation_matrices[ - wing_cross_section_movement_id - ], - ) - ) - - # Add this vector the Wing's 2D ndarray of WingCrossSections' - # WingCrossSections. - wing_cross_sections[wing_cross_section_movement_id, :] = ( - this_wing_cross_sections_list_of_wing_cross_sections - ) - - # Get the non-changing Wing attributes. - this_name = base_wing.name - this_symmetric = base_wing.symmetric - this_mirror_only = base_wing.mirror_only - this_symmetryNormal_G = base_wing.symmetryNormal_G - this_symmetryPoint_G_Cg = base_wing.symmetryPoint_G_Cg - this_num_chordwise_panels = base_wing.num_chordwise_panels - this_chordwise_spacing = base_wing.chordwise_spacing - - thisLer_Gs_Cgs = self.listLer_Gs_Cgs[:, step] - theseAngles_Gs_to_Wn_ixyz = self.listAngles_Gs_to_Wn_ixyz[:, step] - these_wing_cross_sections = list(wing_cross_sections[:, step]) - - # Make a new Wing for this time step. - this_wing = geometry.wing.Wing( - wing_cross_sections=these_wing_cross_sections, - name=this_name, - Ler_Gs_Cgs=thisLer_Gs_Cgs, - angles_Gs_to_Wn_ixyz=theseAngles_Gs_to_Wn_ixyz, - symmetric=this_symmetric, - mirror_only=this_mirror_only, - symmetryNormal_G=this_symmetryNormal_G, - symmetryPoint_G_Cg=this_symmetryPoint_G_Cg, - num_chordwise_panels=this_num_chordwise_panels, - chordwise_spacing=this_chordwise_spacing, - ) - - return this_wing - - def _initialize_oscillating_dimensions(self, delta_time, num_steps, base_wing): - """Pre computes the oscillating Ler_Gs_Cgs values for all time steps. - - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps. - :param base_wing: The base Wing providing the base Ler_Gs_Cgs values. - :return: None - """ - self.listLer_Gs_Cgs = np.zeros((3, num_steps), dtype=float) - for dim in range(3): - spacing = self.spacingLer_Gs_Cgs[dim] - if spacing == "sine": - self.listLer_Gs_Cgs[dim, :] = oscillating_sinspaces( - amps=self.ampLer_Gs_Cgs[dim], - periods=self.periodLer_Gs_Cgs[dim], - phases=self.phaseLer_Gs_Cgs[dim], - bases=base_wing.Ler_Gs_Cgs[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif spacing == "uniform": - self.listLer_Gs_Cgs[dim, :] = oscillating_linspaces( - amps=self.ampLer_Gs_Cgs[dim], - periods=self.periodLer_Gs_Cgs[dim], - phases=self.phaseLer_Gs_Cgs[dim], - bases=base_wing.Ler_Gs_Cgs[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif callable(spacing): - self.listLer_Gs_Cgs[dim, :] = oscillating_customspaces( - amps=self.ampLer_Gs_Cgs[dim], - periods=self.periodLer_Gs_Cgs[dim], - phases=self.phaseLer_Gs_Cgs[dim], - bases=base_wing.Ler_Gs_Cgs[dim], - num_steps=num_steps, - delta_time=delta_time, - custom_function=spacing, - ) - else: - raise ValueError(f"Invalid spacing value: {spacing}") - - def _initialize_oscillating_angles(self, delta_time, num_steps, base_wing): - """Pre computes the oscillating angles_Gs_to_Wn_ixyz values for all time steps. - - :param delta_time: The time between each time step in seconds. - :param num_steps: The total number of time steps. - :param base_wing: The base Wing providing the base angles_Gs_to_Wn_ixyz values. - :return: None - """ - self.listAngles_Gs_to_Wn_ixyz = np.zeros((3, num_steps), dtype=float) - for dim in range(3): - spacing = self.spacingAngles_Gs_to_Wn_ixyz[dim] - if spacing == "sine": - self.listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_sinspaces( - amps=self.ampAngles_Gs_to_Wn_ixyz[dim], - periods=self.periodAngles_Gs_to_Wn_ixyz[dim], - phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], - bases=base_wing.angles_Gs_to_Wn_ixyz[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif spacing == "uniform": - self.listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_linspaces( - amps=self.ampAngles_Gs_to_Wn_ixyz[dim], - periods=self.periodAngles_Gs_to_Wn_ixyz[dim], - phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], - bases=base_wing.angles_Gs_to_Wn_ixyz[dim], - num_steps=num_steps, - delta_time=delta_time, - ) - elif callable(spacing): - self.listAngles_Gs_to_Wn_ixyz[dim, :] = oscillating_customspaces( - amps=self.ampAngles_Gs_to_Wn_ixyz[dim], - periods=self.periodAngles_Gs_to_Wn_ixyz[dim], - phases=self.phaseAngles_Gs_to_Wn_ixyz[dim], - bases=base_wing.angles_Gs_to_Wn_ixyz[dim], - num_steps=num_steps, - delta_time=delta_time, - custom_function=spacing, - ) - else: - raise ValueError(f"Invalid spacing value: {spacing}") diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 8ac28c671..4fc1b2008 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -2129,7 +2129,7 @@ def _finalize_loads(self) -> None: num_steps_to_average = self.num_steps - self._first_averaging_step # Determine if this SteadyProblem's geometry is static or variable. - this_movement: movements.movement.Movement = self.unsteady_problem.movement + this_movement = self.unsteady_problem.movement static = this_movement.static # Initialize ndarrays to hold each Airplane's loads and load coefficients at From 9b084da5ea77e11b1707811e199283d5a42248ba Mon Sep 17 00:00:00 2001 From: Cameron Urban Date: Tue, 5 May 2026 02:41:58 -0400 Subject: [PATCH 39/40] Delete duplicate coupled solver module The public coupled_unsteady_ring_vortex_lattice_method.py module duplicated the main-branch _coupled_unsteady_ring_vortex_lattice_method.py with a full run() override. Delete it and re-point the aeroelastic and free flight solvers to inherit from the main-branch coupled solver instead. Fix the example script to use the inherited unsteady_problem attribute rather than a nonexistent aeroelastic_unsteady_problem property. --- ...roelastic_unsteady_first_order_plotting.py | 2 +- pterasoftware/__init__.py | 4 - ...tic_unsteady_ring_vortex_lattice_method.py | 2 +- ...led_unsteady_ring_vortex_lattice_method.py | 498 ------------------ ...ght_unsteady_ring_vortex_lattice_method.py | 2 +- 5 files changed, 3 insertions(+), 505 deletions(-) delete mode 100644 pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py diff --git a/examples/aeroelastic_unsteady_first_order_plotting.py b/examples/aeroelastic_unsteady_first_order_plotting.py index ab58b87ba..18ce9b1ca 100644 --- a/examples/aeroelastic_unsteady_first_order_plotting.py +++ b/examples/aeroelastic_unsteady_first_order_plotting.py @@ -308,7 +308,7 @@ def run_aeroelastic( prescribed_wake=True, ) - problem = example_solver.aeroelastic_unsteady_problem + problem = example_solver.unsteady_problem # ps.output.animate( # unsteady_solver=example_solver, diff --git a/pterasoftware/__init__.py b/pterasoftware/__init__.py index 1614a44ec..bb0ee8f80 100644 --- a/pterasoftware/__init__.py +++ b/pterasoftware/__init__.py @@ -18,9 +18,6 @@ convergence.py: Contains functions for analyzing the convergence of SteadyProblems and UnsteadyProblems. -coupled_unsteady_ring_vortex_lattice_method.py: Contains the -CoupledUnsteadyRingVortexLatticeMethodSolver class. - free_flight_unsteady_ring_vortex_lattice_method.py: Contains the FreeFlightUnsteadyRingVortexLatticeMethodSolver class. @@ -67,7 +64,6 @@ "steady_ring_vortex_lattice_method": "pterasoftware.steady_ring_vortex_lattice_method", "trim": "pterasoftware.trim", "unsteady_ring_vortex_lattice_method": "pterasoftware.unsteady_ring_vortex_lattice_method", - "coupled_unsteady_ring_vortex_lattice_method": "pterasoftware.coupled_unsteady_ring_vortex_lattice_method", "free_flight_unsteady_ring_vortex_lattice_method": "pterasoftware.free_flight_unsteady_ring_vortex_lattice_method", } diff --git a/pterasoftware/aeroelastic_unsteady_ring_vortex_lattice_method.py b/pterasoftware/aeroelastic_unsteady_ring_vortex_lattice_method.py index b8ece8418..a9ba40d06 100644 --- a/pterasoftware/aeroelastic_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/aeroelastic_unsteady_ring_vortex_lattice_method.py @@ -18,7 +18,7 @@ import numpy as np from . import _functions, problems -from .coupled_unsteady_ring_vortex_lattice_method import ( +from ._coupled_unsteady_ring_vortex_lattice_method import ( CoupledUnsteadyRingVortexLatticeMethodSolver, ) diff --git a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py deleted file mode 100644 index b414d8129..000000000 --- a/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py +++ /dev/null @@ -1,498 +0,0 @@ -"""Contains the CoupledUnsteadyRingVortexLatticeMethodSolver class. - -**Contains the following classes:** - -CoupledUnsteadyRingVortexLatticeMethodSolver: A subclass of -UnsteadyRingVortexLatticeMethodSolver that solves _CoupledUnsteadyProblems using the -unsteady ring vortex lattice method. This solver handles step-by-step geometry -initialization and computes aerodynamic loads relative to strip leading edge points -(SLEP) in addition to the standard center-of-gravity frame. - -**Contains the following functions:** - -None -""" - -from __future__ import annotations - -import numpy as np -from tqdm import tqdm - -from . import ( - _functions, - _logging, - _parameter_validation, - geometry, - problems, -) -from .unsteady_ring_vortex_lattice_method import UnsteadyRingVortexLatticeMethodSolver - -_logger = _logging.get_logger("unsteady_ring_vortex_lattice_method") - - -# TEST: Consider adding unit tests for this function. -# TEST: Assess how comprehensive this function's integration tests are and update or -# extend them if needed. -class CoupledUnsteadyRingVortexLatticeMethodSolver( - UnsteadyRingVortexLatticeMethodSolver -): - """A subclass of UnsteadyRingVortexLatticeMethodSolver that solves - _CoupledUnsteadyProblems. - - This solver handles _CoupledUnsteadyProblems where geometry is initialized and - updated on a per-step basis (step-by-step), rather than being fully precomputed. It - extends the parent class with Strip Leading Edge Point (SLEP) functionality for - computing aerodynamic moments about the strip leading edge, which is important for - analyzing wing deformations and local loading characteristics. - - **Key differences from parent UnsteadyRingVortexLatticeMethodSolver:** - - - Inherits core aerodynamic solver logic from parent (wall-built inheritance) - - Overrides get_steady_problem_at() to dynamically retrieve problems during iteration - - Extends moment calculations to include SLEP-based moments via - _load_calculation_moment_processing_hook() - Computes bound vortex positions - relative to strip leading edge points - - **Inherited methods (used directly from parent):** - - calculate_solution_velocity: Finds the fluid velocity (in the first Airplane's - geometry axes, observed from the Earth frame) at one or more points due to - freestream and induced velocity from every RingVortex. - - All movement velocity calculation methods and aerodynamic influence calculation - methods. - - **Custom methods:** - - run: Runs the solver on the _CoupledUnsteadyProblem with per-step geometry - initialization. - - initialize_step_geometry: Initializes geometry for a specific step without solving. - - get_steady_problem_at: Overridden abstraction point for dynamic problem retrieval. - """ - - def __init__( - self, coupled_unsteady_problem: problems._CoupledUnsteadyProblem - ) -> None: - """Initialize the solver for a _CoupledUnsteadyProblem. - - Sets up the solver infrastructure and initializes SLEP (Strip Leading Edge - Point) related attributes. The coupled_unsteady_problem is stored before calling - the parent's __init__() because the parent's initialization calls methods that - depend on accessing this attribute. - - :param coupled_unsteady_problem: The _CoupledUnsteadyProblem to be solved. Steps - are retrieved dynamically from this problem during iteration via - get_steady_problem_at(). - :return: None - """ - if not isinstance(coupled_unsteady_problem, problems._CoupledUnsteadyProblem): - raise TypeError( - "coupled_unsteady_problem must be a _CoupledUnsteadyProblem." - ) - # self.coupled_unsteady_problem must be defined before the call to super().__init__() - # because the parent class's __init__ method calls methods that rely on - # self.coupled_unsteady_problem being defined. - self.coupled_unsteady_problem = coupled_unsteady_problem - super().__init__(coupled_unsteady_problem) - - self.num_steps = self.coupled_unsteady_problem.num_steps - self.delta_time = self.coupled_unsteady_problem.delta_time - self.first_results_step = self.coupled_unsteady_problem.first_results_step - self._first_averaging_step = self.coupled_unsteady_problem.first_averaging_step - - first_steady_problem: problems.SteadyProblem = self._get_steady_problem_at(0) - - # Store computed steady problems for each time step to be assigned to the - # _CoupledUnsteadyProblem after solve completes. This avoids overwriting the - # initial steady problems until data visualization/post-processing stage. - self.steady_problems_data_storage: list[problems.SteadyProblem] = [] - - # Compute the total number of panels across all airplanes. - num_panels = 0 - for airplane in first_steady_problem.airplanes: - num_panels += airplane.num_panels - self.num_panels: int = num_panels - - def run( - self, - prescribed_wake: bool | np.bool_ = True, - calculate_streamlines: bool | np.bool_ = True, - show_progress: bool | np.bool_ = True, - ) -> None: - """Runs the solver on the _CoupledUnsteadyProblem. - - :param prescribed_wake: Set this to True to solve using a prescribed wake model. - Set to False to use a free-wake, which may be more accurate but will make - the fun method significantly slower. Can be a bool or a numpy bool and will - be converted internally to a bool. The default is True. - :param calculate_streamlines: Set this to True to calculate streamlines - emanating from the back of the wing after running the solver. It can be a - bool or a numpy bool and will be converted internally to a bool. The default - is True. - :param show_progress: Set this to True to show the TQDM progress bar. For - showing the progress bar and displaying log statements, set up logging using - the setup_logging function. It can be a bool or a numpy bool and will be - converted internally to a bool. The default is True. - :return: None - """ - self._prescribed_wake = _parameter_validation.boolLike_return_bool( - prescribed_wake, "prescribed_wake" - ) - calculate_streamlines = _parameter_validation.boolLike_return_bool( - calculate_streamlines, "calculate_streamlines" - ) - show_progress = _parameter_validation.boolLike_return_bool( - show_progress, "show_progress" - ) - - # Cache the wings and compute spanwise panel counts from the initial geometry. - # Unlike the parent class (which precomputes all steps), this coupled solver - # retrieves each step's geometry dynamically via get_steady_problem_at(). - this_problem: problems.SteadyProblem = self._get_steady_problem_at(0) - these_airplanes = this_problem.airplanes - num_wing_panels = 0 - these_wings: list[tuple[geometry.wing.Wing, ...]] = [] - for airplane in these_airplanes: - these_wings.append(airplane.wings) - num_wing_panels += airplane.num_panels - - # Iterate through the Wings to get the total number of spanwise Panels. - this_num_spanwise_panels = 0 - for this_wing_set in these_wings: - for this_wing in this_wing_set: - _this_wing_num_spanwise_panels = this_wing.num_spanwise_panels - assert _this_wing_num_spanwise_panels is not None - - this_num_spanwise_panels += _this_wing_num_spanwise_panels - - for step in range(self.num_steps): - # The number of wake RingVortices is the time step number multiplied by - # the number of spanwise Panels. This works because the first time step - # number is 0. - this_num_chordwise_wake_rows = step - if self._max_wake_rows is not None: - this_num_chordwise_wake_rows = min(step, self._max_wake_rows) - this_num_wake_ring_vortices = ( - this_num_chordwise_wake_rows * this_num_spanwise_panels - ) - - # Allocate the ndarrays for this time step. - this_wake_ring_vortex_strengths = np.zeros( - this_num_wake_ring_vortices, dtype=float - ) - this_wake_ring_vortex_ages = np.zeros( - this_num_wake_ring_vortices, dtype=float - ) - thisStackBrwrvp_GP1_CgP1 = np.zeros( - (this_num_wake_ring_vortices, 3), dtype=float - ) - thisStackFrwrvp_GP1_CgP1 = np.zeros( - (this_num_wake_ring_vortices, 3), dtype=float - ) - thisStackFlwrvp_GP1_CgP1 = np.zeros( - (this_num_wake_ring_vortices, 3), dtype=float - ) - thisStackBlwrvp_GP1_CgP1 = np.zeros( - (this_num_wake_ring_vortices, 3), dtype=float - ) - - this_wake_rc0s = np.zeros(this_num_wake_ring_vortices, dtype=float) - - # Append this time step's ndarrays to the lists of ndarrays. - self.list_num_wake_vortices.append(this_num_wake_ring_vortices) - self._list_wake_vortex_strengths.append(this_wake_ring_vortex_strengths) - self._list_wake_vortex_ages.append(this_wake_ring_vortex_ages) - self.listStackBrwrvp_GP1_CgP1.append(thisStackBrwrvp_GP1_CgP1) - self.listStackFrwrvp_GP1_CgP1.append(thisStackFrwrvp_GP1_CgP1) - self.listStackFlwrvp_GP1_CgP1.append(thisStackFlwrvp_GP1_CgP1) - self.listStackBlwrvp_GP1_CgP1.append(thisStackBlwrvp_GP1_CgP1) - self._list_wake_rc0s.append(this_wake_rc0s) - - # The following loop attempts to predict how much time each time step will - # take, relative to the other time steps. This data will be used to generate - # estimates of how much longer a simulation will take, and create a smoothly - # advancing progress bar. - - # Initialize list that will hold the approximate, relative times. This has - # one more element than the number of time steps, because I will also use the - # progress bar during the simulation initialization. - approx_times = np.zeros(self.num_steps + 1, dtype=float) - for step in range(self.num_steps): - if step != 0: - # Calculate the total number of RingVortices analyzed during this step. - num_wing_ring_vortices = num_wing_panels - num_wake_ring_vortices = self.list_num_wake_vortices[step] - num_ring_vortices = num_wing_ring_vortices + num_wake_ring_vortices - - # The following constant multipliers were determined empirically. Thus - # far, they seem to provide for adequately smooth progress bar updating. - if step == 1: - approx_times[step] = num_ring_vortices * 70 - elif step == 2: - approx_times[step] = num_ring_vortices * 30 - else: - approx_times[step] = num_ring_vortices * 3 - - approx_partial_time = np.sum(approx_times) - approx_times[0] = round(approx_partial_time / 100) - approx_total_time = np.sum(approx_times) - - with tqdm( - total=approx_total_time, - unit="", - unit_scale=True, - ncols=100, - desc="Simulating", - disable=not show_progress, - bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " - "Remaining: {remaining}", - ) as bar: - # Update the progress bar based on the initialization step's predicted - # approximate, relative computing time. - bar.update(n=float(approx_times[0])) - - # Iterate through the time steps. - for step in range(self.num_steps): - - # Save attributes to hold the current step, Airplanes, - # and OperatingPoint, and freestream velocity (in the first - # Airplane's geometry axes, observed from the Earth frame). - self._current_step = step - current_problem: problems.SteadyProblem = self._get_steady_problem_at( - self._current_step - ) - - # Initialize all the current step's bound RingVortices. - _logger.debug(f"Initializing step {step}'s RingVortices") - self._initialize_panel_vortices_at(step) - self.current_airplanes = current_problem.airplanes - self.current_operating_point = current_problem.operating_point - self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E - _logger.debug( - "Beginning time step " - + str(self._current_step) - + " out of " - + str(self.num_steps - 1) - + "." - ) - - # TODO: I think these steps are redundant, at least during the first - # time step. Consider dropping them. - # Initialize attributes to hold aerodynamic data that pertain to the - # simulation at this time step. - self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E - self._currentStackFreestreamWingInfluences__E = np.zeros( - self.num_panels, dtype=float - ) - self._currentGridWingWingInfluences__E = np.zeros( - (self.num_panels, self.num_panels), dtype=float - ) - self._currentStackWakeWingInfluences__E = np.zeros( - self.num_panels, dtype=float - ) - self._current_bound_vortex_strengths = np.ones( - self.num_panels, dtype=float - ) - self._last_bound_vortex_strengths = np.zeros( - self.num_panels, dtype=float - ) - - # Initialize attributes to hold geometric data that pertain to the current - # time step of this _CoupledUnsteadyProblem. - self.panels = np.empty(self.num_panels, dtype=object) - self.stackUnitNormals_GP1 = np.zeros((self.num_panels, 3), dtype=float) - self.panel_areas = np.zeros(self.num_panels, dtype=float) - - self.stackCpp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self._stackLastCpp_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - - self.stackBrbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackFrbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackFlbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackBlbrvp_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self._lastStackBrbrvp_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - self._lastStackFrbrvp_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - self._lastStackFlbrvp_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - self._lastStackBlbrvp_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - - self.stackCblvpr_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackCblvpf_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackCblvpl_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackCblvpb_GP1_CgP1 = np.zeros((self.num_panels, 3), dtype=float) - self._lastStackCblvpr_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - self._lastStackCblvpf_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - self._lastStackCblvpl_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - self._lastStackCblvpb_GP1_CgP1 = np.zeros( - (self.num_panels, 3), dtype=float - ) - - self.stackRbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackFbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackLbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) - self.stackBbrv_GP1 = np.zeros((self.num_panels, 3), dtype=float) - - # Initialize variables to hold details about each Panel's location on - # its Wing. - self.panel_is_trailing_edge = np.zeros(self.num_panels, dtype=bool) - self.panel_is_leading_edge = np.zeros(self.num_panels, dtype=bool) - self.panel_is_left_edge = np.zeros(self.num_panels, dtype=bool) - self.panel_is_right_edge = np.zeros(self.num_panels, dtype=bool) - - # Hook for subclasses to reinitialize step-specific arrays. - self._reinitialize_step_arrays_hook() - - # Get the pre-allocated (but still all zero) arrays of wake - # information that are associated with this time step. - self._current_wake_vortex_strengths = self._list_wake_vortex_strengths[ - step - ] - self._current_wake_vortex_ages = self._list_wake_vortex_ages[step] - self._currentStackBrwrvp_GP1_CgP1 = self.listStackBrwrvp_GP1_CgP1[step] - self._currentStackFrwrvp_GP1_CgP1 = self.listStackFrwrvp_GP1_CgP1[step] - self._currentStackFlwrvp_GP1_CgP1 = self.listStackFlwrvp_GP1_CgP1[step] - self._currentStackBlwrvp_GP1_CgP1 = self.listStackBlwrvp_GP1_CgP1[step] - self._currentStackBoundRc0s = np.zeros(self.num_panels, dtype=float) - self._currentStackWakeRc0s = self._list_wake_rc0s[step] - self.stackSeedPoints_GP1_CgP1 = np.zeros((0, 3), dtype=float) - - # Collapse the geometry matrices into 1D ndarrays of attributes. - _logger.debug("Collapsing the geometry.") - self._collapse_geometry() - - # Find the matrix of Wing Wing influence coefficients associated with - # the Airplanes' geometries at this time step. - _logger.debug("Calculating the Wing Wing influences.") - self._calculate_wing_wing_influences() - - # Find the normal velocity (in the first Airplane's geometry axes, - # observed from the Earth frame) at every collocation point due - # solely to the freestream. - _logger.debug("Calculating the freestream Wing influences.") - self._calculate_freestream_wing_influences() - - # Find the normal velocity (in the first Airplane's geometry axes, - # observed from the Earth frame) at every collocation point due - # solely to the wake RingVortices. - _logger.debug("Calculating the wake Wing influences.") - self._calculate_wake_wing_influences() - - # Solve for each bound RingVortex's strength. - _logger.debug("Calculating bound RingVortex strengths.") - self._calculate_vortex_strengths() - - # Solve for the forces (in the first Airplane's geometry axes) and - # moments (in the first Airplane's geometry axes, relative to the - # first Airplane's CG) on each Panel. - if self._current_step >= self.first_results_step: - _logger.debug("Calculating forces and moments.") - self._calculate_loads() - - # Shed RingVortices into the wake. - # Check if the current time step is not the last step. - if self._current_step < self.num_steps - 1: - self.coupled_unsteady_problem.initialize_next_problem(self) - self._initialize_panel_vortices_at(step + 1) - # Shed RingVortices into the wake. - _logger.debug("Shedding RingVortices into the wake.") - self._populate_next_airplanes_wake() - - # Update the progress bar based on this time step's predicted - # approximate, relative computing time. - self.steady_problems_data_storage.append( - self._get_steady_problem_at(step) - ) - bar.update(n=float(approx_times[step + 1])) - - _logger.debug("Calculating averaged or final forces and moments.") - self._finalize_loads() - - # Solve for the location of the streamlines coming off the Wings' trailing - # edges, if requested. - if calculate_streamlines: - _logger.debug("Calculating streamlines.") - _functions.calculate_streamlines(self) - - # Mark that the solver has run. - self.steady_problems = tuple(self.steady_problems_data_storage) - self.ran = True - - def initialize_step_geometry(self, step: int) -> None: - """Initializes geometry for a specific step without solving. - - Sets up bound RingVortices and wake RingVortices for the specified time step, - but does not solve the aerodynamic system. Use this for geometry only analysis - like delta_time optimization. - - This method must be called sequentially for each step starting from 0, as wake - vortices at step N depend on the geometry from step N - 1. - - :param step: The time step to initialize geometry for. It is zero indexed. It - must be a non negative int and be less than the total number of steps. - :return: None - """ - step = _parameter_validation.int_in_range_return_int( - step, "step", 0, True, self.num_steps, False - ) - - # Initialize bound RingVortices for all steps on the first call. - if step == 0: - self._initialize_panel_vortices_at(0) - - # Set the current step and related state. - self._current_step = step - current_problem: problems.SteadyProblem = self._get_steady_problem_at(step) - self.current_airplanes = current_problem.airplanes - self.current_operating_point = current_problem.operating_point - self._currentVInf_GP1__E = self.current_operating_point.vInf_GP1__E - - # Populate the wake for the next step (if not the last step). - if step < self.num_steps - 1: - self._populate_next_airplanes_wake_vortex_points() - self._populate_next_airplanes_wake_vortices() - - def _reinitialize_step_arrays_hook(self) -> None: - """Hook for subclasses to reinitialize step-specific arrays. - - Called at the beginning of each time step in run(), after the standard arrays - are reinitialized. The default implementation is a no-op. - - :return: None - """ - - def _get_steady_problem_at(self, step: int) -> problems.SteadyProblem: - """Get the SteadyProblem at a given time step via the _CoupledUnsteadyProblem. - - This is a KEY ABSTRACTION POINT that enables inheritance. The parent - UnsteadyRingVortexLatticeMethodSolver has nearly identical code that calls this - method, but retrieves from self.steady_problems[step]. This coupled solver - retrieves from self.coupled_unsteady_problem.get_steady_problem(step), enabling - dynamic step-by-step geometry updates rather than precomputed steps. - - :param step: An int representing the time step of the desired SteadyProblem. It - must be between 0 and num_steps - 1, inclusive. - :return: The SteadyProblem at the given time step, retrieved from the - _CoupledUnsteadyProblem's step sequence. - """ - if step < 0 or step >= self.num_steps: - raise ValueError( - f"Step must be between 0 and {self.num_steps - 1}, inclusive." - ) - return self.coupled_unsteady_problem.get_steady_problem(step) diff --git a/pterasoftware/free_flight_unsteady_ring_vortex_lattice_method.py b/pterasoftware/free_flight_unsteady_ring_vortex_lattice_method.py index 4ec683e95..57dc9042b 100644 --- a/pterasoftware/free_flight_unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/free_flight_unsteady_ring_vortex_lattice_method.py @@ -14,7 +14,7 @@ from __future__ import annotations from . import problems -from .coupled_unsteady_ring_vortex_lattice_method import ( +from ._coupled_unsteady_ring_vortex_lattice_method import ( CoupledUnsteadyRingVortexLatticeMethodSolver, ) From e4e2f186a30c48d77543919f754bf38a6ffdb036 Mon Sep 17 00:00:00 2001 From: Cameron Urban Date: Tue, 5 May 2026 02:58:26 -0400 Subject: [PATCH 40/40] Add type annotations to aeroelastic code Annotate the solver parameter on AeroelasticUnsteadyProblem and FreeFlightUnsteadyProblem so mypy checks their bodies. Use cast() at the Liskov boundary in initialize_next_problem to narrow from the parent solver type to the aeroelastic solver type. Widen wing_deformation_angles_ixyz to accept per-element None values, matching actual usage. --- .../aeroelastic_airplane_movement.py | 2 +- .../movements/aeroelastic_movement.py | 2 +- pterasoftware/problems.py | 30 ++++++++++++++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/pterasoftware/movements/aeroelastic_airplane_movement.py b/pterasoftware/movements/aeroelastic_airplane_movement.py index 54f9f799b..9ad40f5e3 100644 --- a/pterasoftware/movements/aeroelastic_airplane_movement.py +++ b/pterasoftware/movements/aeroelastic_airplane_movement.py @@ -145,7 +145,7 @@ def generate_airplane_at_time_step( self, step: int, delta_time: float | int, - wing_deformation_angles_ixyz: list[np.ndarray] | None = None, + wing_deformation_angles_ixyz: list[np.ndarray | None] | None = None, ) -> geometry.airplane.Airplane: """Creates the Airplane at a single time step, optionally applying structural deformation to each Wing. diff --git a/pterasoftware/movements/aeroelastic_movement.py b/pterasoftware/movements/aeroelastic_movement.py index 2a0db51b8..72085ecd7 100644 --- a/pterasoftware/movements/aeroelastic_movement.py +++ b/pterasoftware/movements/aeroelastic_movement.py @@ -158,7 +158,7 @@ def generate_airplane_at_time_step( self, airplane_movement_index: int, step: int, - wing_deformation_angles_ixyz: list[np.ndarray] | None = None, + wing_deformation_angles_ixyz: list[np.ndarray | None] | None = None, ) -> geometry.airplane.Airplane: """Creates the Airplane at a single time step for a given AeroelasticAirplaneMovement, applying deformation from the solver's structural diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index d55903324..998c58d04 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -13,7 +13,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import matplotlib.pyplot as plt import numpy as np @@ -28,6 +28,9 @@ from ._coupled_unsteady_ring_vortex_lattice_method import ( CoupledUnsteadyRingVortexLatticeMethodSolver, ) + from .aeroelastic_unsteady_ring_vortex_lattice_method import ( + AeroelasticUnsteadyRingVortexLatticeMethodSolver, + ) class SteadyProblem: @@ -531,10 +534,21 @@ def calculate_mass_matrix(self, wing: geometry.wing.Wing) -> np.ndarray: areas = np.array([[panel.area for panel in row] for row in wing.panels]) return np.repeat(areas[:, :, None], 3, axis=2) * self.wing_density - def initialize_next_problem(self, solver): + def initialize_next_problem( + self, solver: CoupledUnsteadyRingVortexLatticeMethodSolver + ) -> None: + # Circular at module level: the aeroelastic solver imports problems.py. + # Needed at runtime for cast(). + from .aeroelastic_unsteady_ring_vortex_lattice_method import ( + AeroelasticUnsteadyRingVortexLatticeMethodSolver, + ) + + aeroelastic_solver = cast( + AeroelasticUnsteadyRingVortexLatticeMethodSolver, solver + ) step = len(self._steady_problems) - deformation_matrices = self.calculate_wing_deformation(solver, step) + deformation_matrices = self.calculate_wing_deformation(aeroelastic_solver, step) # Build the per-wing deformation list. The main wing (index 0) and its # symmetric reflection (index 1) both receive the same deformation angles. @@ -563,7 +577,7 @@ def initialize_next_problem(self, solver): def calculate_wing_deformation( self, - solver, + solver: AeroelasticUnsteadyRingVortexLatticeMethodSolver, step: int, ) -> np.ndarray: """Compute cumulative wing deformation for the current time step. @@ -635,7 +649,7 @@ def calculate_wing_deformation( def _extract_aero_moments( self, - solver, + solver: AeroelasticUnsteadyRingVortexLatticeMethodSolver, num_chordwise_panels: int, num_spanwise_panels: int, num_panels: int, @@ -663,7 +677,7 @@ def _extract_aero_moments( def _calculate_inertial_moments( self, - solver, + solver: AeroelasticUnsteadyRingVortexLatticeMethodSolver, wing: geometry.wing.Wing, mass_matrix: np.ndarray, num_chordwise_panels: int, @@ -1160,7 +1174,9 @@ def __init__( self._free_flight_movement = movement - def initialize_next_problem(self, solver) -> None: + def initialize_next_problem( + self, solver: CoupledUnsteadyRingVortexLatticeMethodSolver + ) -> None: """Initialize the next time step's problem. :param solver: The solver instance providing aerodynamic data from the current