From 6d4a327c2b82ec8f3c45ec51f0df873f024b0196 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 3 Apr 2026 12:01:35 -0400 Subject: [PATCH 1/3] add coupled unsteady problem and solver --- pterasoftware/__init__.py | 7 +- ...led_unsteady_ring_vortex_lattice_method.py | 421 +++ pterasoftware/problems.py | 163 +- ..._coupled_unsteady_vortex_lattice_method.py | 638 +++++ .../aeroelastic_problems.py | 1135 ++++++++ ...oelastic_unsteady_vortex_lattice_method.py | 2394 +++++++++++++++++ ..._coupled_unsteady_vortex_lattice_method.py | 2248 ++++++++++++++++ .../free_flight_problems.py | 497 ++++ .../unsteady_ring_vortex_lattice_method.py | 78 +- tests/unit/fixtures/problem_fixtures.py | 36 + tests/unit/fixtures/solver_fixtures.py | 18 + tests/unit/test_coupled_unsteady_problem.py | 244 ++ ...led_unsteady_ring_vortex_lattice_method.py | 167 ++ 13 files changed, 8030 insertions(+), 16 deletions(-) create mode 100644 pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py create mode 100644 pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py create mode 100644 pterasoftware/temporary_comparison_files/aeroelastic_problems.py create mode 100644 pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py create mode 100644 pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py create mode 100644 pterasoftware/temporary_comparison_files/free_flight_problems.py create mode 100644 tests/unit/test_coupled_unsteady_problem.py create mode 100644 tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py diff --git a/pterasoftware/__init__.py b/pterasoftware/__init__.py index 6518a6bc7..de3468d76 100644 --- a/pterasoftware/__init__.py +++ b/pterasoftware/__init__.py @@ -19,7 +19,8 @@ output.py: Contains functions for visualizing geometry and results. -problems.py: Contains the SteadyProblem and UnsteadyProblem classes. +problems.py: Contains the SteadyProblem, UnsteadyProblem, and CoupledUnsteadyProblem +classes. steady_horseshoe_vortex_lattice_method.py: Contains the SteadyHorseshoeVortexLatticeMethodSolver class. @@ -33,6 +34,9 @@ unsteady_ring_vortex_lattice_method.py: Contains the UnsteadyRingVortexLatticeMethodSolver class. +coupled_unsteady_ring_vortex_lattice_method.py: Contains the +CoupledUnsteadyRingVortexLatticeMethodSolver class. + **Contains the following functions:** load: Loads a Ptera Software object from a JSON file. @@ -52,6 +56,7 @@ # Lazy imports configuration: modules loaded on first access. _LAZY_MODULES = { "convergence": "pterasoftware.convergence", + "coupled_unsteady_ring_vortex_lattice_method": "pterasoftware.coupled_unsteady_ring_vortex_lattice_method", "output": "pterasoftware.output", "steady_horseshoe_vortex_lattice_method": "pterasoftware.steady_horseshoe_vortex_lattice_method", "steady_ring_vortex_lattice_method": "pterasoftware.steady_ring_vortex_lattice_method", 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..57e0e6c5b --- /dev/null +++ b/pterasoftware/coupled_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,421 @@ +"""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 supports two-way coupling between the aerodynamic solver and external +models. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +import logging + +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("coupled_unsteady_ring_vortex_lattice_method") + + +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 as + in the parent class. At each time step, the solver generates the current step's + geometry from the CoupledUnsteadyProblem, solves the aerodynamic system, then calls + the problem's initialize_next_problem method to generate the next step's geometry. + This enables two-way coupling with external models (structural deformation, rigid + body dynamics) that modify geometry based on the current aerodynamic solution. + + **Key differences from parent UnsteadyRingVortexLatticeMethodSolver:** + + - Inherits core aerodynamic solver logic from parent (wall-built inheritance). - + Overrides _get_steady_problem_at() to dynamically retrieve problems from the + CoupledUnsteadyProblem. - Initializes bound RingVortices per-step inside the loop, + rather than all-at-once before the loop. - Calls + CoupledUnsteadyProblem.initialize_next_problem() between time steps to allow + external models to update geometry. + + **Inherited methods (used directly from parent):** + + calculate_solution_velocity: Finds the fluid velocity at one or more points due to + freestream and induced velocity from every RingVortex. + + All movement velocity calculation methods, aerodynamic influence calculation + methods, vortex strength calculation methods, load calculation methods, and wake + population methods. + + **Custom methods:** + + run: Runs the solver on the CoupledUnsteadyProblem with per-step geometry + initialization and coupling hooks. + + initialize_step_geometry: Initializes geometry for a specific step without solving. + """ + + def __init__( + self, + coupled_unsteady_problem: problems.CoupledUnsteadyProblem, + ) -> None: + """The initialization method. + + :param coupled_unsteady_problem: The CoupledUnsteadyProblem to be solved. + SteadyProblems 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 + # _get_steady_problem_at(), which this class overrides to dispatch through + # self.coupled_unsteady_problem. + self.coupled_unsteady_problem = coupled_unsteady_problem + super().__init__(coupled_unsteady_problem) + + # Store computed SteadyProblems for each time step. After the solve completes, + # this list is converted to a tuple and assigned to self.steady_problems. + self.steady_problems_data_storage: list[problems.SteadyProblem] = [] + + 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. + + Unlike the parent class, which precomputes all geometry before the main loop, + this method initializes geometry per-step and calls the CoupledUnsteadyProblem's + initialize_next_problem method between steps to allow external models to update + the geometry. + + :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 run 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 wing geometry from the initial step. Unlike the parent class (which + # precomputes all steps), coupled geometry is only known for step 0 at this + # point. The number of panels is assumed constant across all steps. + 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 + + # Pre-allocate wake arrays for all time steps using the initial geometry's + # spanwise panel count (which is constant across steps). + for step in range(self.num_steps): + 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 + ) + + 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) + + 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) + + # Estimate progress bar timing using the initial geometry's panel count. + approx_times = np.zeros(self.num_steps + 1, dtype=float) + for step in range(self.num_steps): + if step != 0: + 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 + + 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: + bar.update(n=float(approx_times[0])) + + for step in range(self.num_steps): + + self._current_step = step + current_problem: problems.SteadyProblem = self._get_steady_problem_at( + self._current_step + ) + + # Initialize the current step's bound RingVortices per-step (not + # all-at-once like the parent). + _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) + + "." + ) + + # Initialize per-step aerodynamic and geometric arrays. + 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 + ) + + 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) + + 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) + + 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() + + _logger.debug("Calculating the Wing Wing influences.") + self._calculate_wing_wing_influences() + + _logger.debug("Calculating the freestream Wing influences.") + self._calculate_freestream_wing_influences() + + _logger.debug("Calculating the wake Wing influences.") + self._calculate_wake_wing_influences() + + _logger.debug("Calculating bound RingVortex strengths.") + self._calculate_vortex_strengths() + + if self._current_step >= self.first_results_step: + _logger.debug("Calculating forces and moments.") + self._calculate_loads() + + # Coupling: generate the next step's geometry from the + # CoupledUnsteadyProblem, then initialize its bound RingVortices + # before shedding wake. + if self._current_step < self.num_steps - 1: + self.coupled_unsteady_problem.initialize_next_problem(self) + self._initialize_panel_vortices_at(step + 1) + + _logger.debug("Shedding RingVortices into the wake.") + self._populate_next_airplanes_wake() + + 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() + + if calculate_streamlines: + _logger.debug("Calculating streamlines.") + _functions.calculate_streamlines(self) + + 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 + ) + + if step == 0: + self._initialize_panel_vortices_at(0) + + 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 + + if step < self.num_steps - 1: + self._populate_next_airplanes_wake_vortex_points() + self._populate_next_airplanes_wake_vortices() + + def _get_steady_problem_at(self, step: int) -> problems.SteadyProblem: + """Gets the SteadyProblem at a given time step via the CoupledUnsteadyProblem. + + This override is the key abstraction point that enables the coupled solver. The + parent retrieves from a pre-computed tuple; this retrieves from the + CoupledUnsteadyProblem's dynamically growing list. + + :param step: The time step of the desired SteadyProblem. It must be between 0 + and the number of problems initialized so far, exclusive. + :return: The SteadyProblem at the given time step. + """ + return self.coupled_unsteady_problem.get_steady_problem(step) diff --git a/pterasoftware/problems.py b/pterasoftware/problems.py index 080dc05aa..365697c2d 100644 --- a/pterasoftware/problems.py +++ b/pterasoftware/problems.py @@ -1,4 +1,4 @@ -"""Contains the SteadyProblem and UnsteadyProblem classes. +"""Contains the SteadyProblem, UnsteadyProblem, and CoupledUnsteadyProblem classes. **Contains the following classes:** @@ -6,6 +6,9 @@ UnsteadyProblem: A class used to contain unsteady aerodynamics problems. +CoupledUnsteadyProblem: A class used to contain coupled unsteady aerodynamics problems +where SteadyProblems are generated dynamically at each time step. + **Contains the following functions:** None @@ -13,11 +16,18 @@ from __future__ import annotations +from typing import TYPE_CHECKING + import numpy as np -from . import _core, _transformations, geometry, movements +from . import _core, _parameter_validation, _transformations, geometry, movements from . import operating_point as operating_point_mod +if TYPE_CHECKING: + from .unsteady_ring_vortex_lattice_method import ( + UnsteadyRingVortexLatticeMethodSolver, + ) + class SteadyProblem: """A class used to contain steady aerodynamics problems. @@ -229,3 +239,152 @@ def movement(self) -> movements.movement.Movement: @property def steady_problems(self) -> tuple[SteadyProblem, ...]: return self._steady_problems + + +class CoupledUnsteadyProblem(_core.CoreUnsteadyProblem): + """A class used to contain coupled unsteady aerodynamics problems. + + Unlike UnsteadyProblem, which pre-computes all SteadyProblems during initialization, + CoupledUnsteadyProblem generates SteadyProblems dynamically at each time step. This + enables two-way coupling between the aerodynamic solver and external models (e.g., + structural deformation, rigid body dynamics) where the geometry or operating + conditions at step N + 1 depend on the aerodynamic solution at step N. + + The base implementation generates each step's SteadyProblem from the movement's + prescribed geometry and operating conditions. Subclasses + (AeroelasticUnsteadyProblem, FreeFlightUnsteadyProblem) override + initialize_next_problem to incorporate feedback from the solver. + + **Contains the following methods:** + + movement: The CoreMovement that contains this CoupledUnsteadyProblem's + OperatingPointMovement and AirplaneMovements. + + steady_problems: A tuple of the SteadyProblems generated so far. + + get_steady_problem: Retrieves the SteadyProblem at a given time step. + + initialize_next_problem: Generates and appends the next time step's SteadyProblem. + """ + + __slots__ = ( + "_movement", + "_coupled_steady_problems", + ) + + def __init__( + self, + movement: _core.CoreMovement, + only_final_results: bool | np.bool_ = False, + ) -> None: + """The initialization method. + + :param movement: The CoreMovement (or subclass) that contains this + CoupledUnsteadyProblem's OperatingPointMovement and AirplaneMovements. It + must be an instance of CoreMovement. + :param only_final_results: Determines whether the solver will only calculate + loads for the final time step (for static movements) or will only calculate + loads for the time steps in the final complete motion cycle (for non-static + movements), which increases simulation speed. Can be a bool or a numpy bool + and will be converted internally to a bool. The default is False. + :return: None + """ + if not isinstance(movement, _core.CoreMovement): + raise TypeError("movement must be a CoreMovement or one of its subclasses.") + self._movement = movement + + # Delegate shared initialization (validation, first_averaging_step computation, + # load list initialization) to the core class. + super().__init__( + only_final_results=only_final_results, + delta_time=self._movement.delta_time, + num_steps=self._movement.num_steps, + max_wake_rows=self._movement.max_wake_rows, + lcm_period=self._movement.lcm_period, + ) + + # Generate the initial SteadyProblem for step 0 from the movement's prescribed + # geometry and operating conditions. + initial_airplanes: list[geometry.airplane.Airplane] = [] + for airplane_movement in self._movement.airplane_movements: + initial_airplanes.append( + airplane_movement.generate_airplane_at_time_step( + 0, self._movement.delta_time + ) + ) + initial_operating_point = self._movement.operating_point_movement.generate_operating_point_at_time_step( + 0, self._movement.delta_time + ) + initial_problem = SteadyProblem( + airplanes=initial_airplanes, + operating_point=initial_operating_point, + ) + + # Initialize the mutable list of SteadyProblems with the initial problem. + self._coupled_steady_problems: list[SteadyProblem] = [initial_problem] + + # --- Immutable: read only properties --- + @property + def movement(self) -> _core.CoreMovement: + return self._movement + + @property + def steady_problems(self) -> tuple[SteadyProblem, ...]: + """A tuple of the SteadyProblems generated so far. + + During simulation, this tuple grows as initialize_next_problem is called at each + time step. After simulation completes, it contains one SteadyProblem per time + step. + + :return: A tuple of SteadyProblems. + """ + return tuple(self._coupled_steady_problems) + + def get_steady_problem(self, step: int) -> SteadyProblem: + """Retrieves the SteadyProblem at a given time step. + + :param step: An int representing the time step index. It must be non-negative + and less than the number of SteadyProblems generated so far. + :return: The SteadyProblem at the given time step. + """ + if step < 0 or step >= len(self._coupled_steady_problems): + raise ValueError( + f"Step index {step} is out of range. Only" + f" {len(self._coupled_steady_problems)} problems have been" + " initialized so far." + ) + return self._coupled_steady_problems[step] + + def initialize_next_problem( + self, solver: UnsteadyRingVortexLatticeMethodSolver + ) -> None: + """Generates and appends the next time step's SteadyProblem. + + The base implementation generates the next SteadyProblem from the movement's + prescribed geometry and operating conditions. Subclasses override this method to + incorporate feedback from the solver (e.g., structural deformation angles, + dynamics-integrated operating conditions). + + :param solver: The solver instance, which provides access to the current + aerodynamic solution state. Subclasses use this to extract forces, moments, + or other quantities needed to compute the next step's geometry or operating + conditions. + :return: None + """ + next_step = len(self._coupled_steady_problems) + + next_airplanes: list[geometry.airplane.Airplane] = [] + for airplane_movement in self._movement.airplane_movements: + next_airplanes.append( + airplane_movement.generate_airplane_at_time_step( + next_step, self._movement.delta_time + ) + ) + next_operating_point = self._movement.operating_point_movement.generate_operating_point_at_time_step( + next_step, self._movement.delta_time + ) + next_problem = SteadyProblem( + airplanes=next_airplanes, + operating_point=next_operating_point, + ) + self._coupled_steady_problems.append(next_problem) diff --git a/pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py b/pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py new file mode 100644 index 000000000..304781984 --- /dev/null +++ b/pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py @@ -0,0 +1,638 @@ +"""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] = [] + + # 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 + 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) + 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.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, + # 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) + # 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.stack_leading_edge_points: np.ndarray = np.empty(0, dtype=float) + + 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_vortex(current_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 + _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.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) + 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._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_vortex( + self.get_steady_problem_at(step + 1), + 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_vortex(self.get_steady_problem_at(0), 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 _load_calculation_moment_processing_hook( + self, + rightLegForces_GP1, + frontLegForces_GP1, + leftLegForces_GP1, + backLegForces_GP1, + unsteady_forces_GP1, + ) -> 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. + + 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. 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. + moments_GP1_CgP1 = super()._load_calculation_moment_processing_hook( + rightLegForces_GP1, + frontLegForces_GP1, + leftLegForces_GP1, + backLegForces_GP1, + unsteady_forces_GP1, + ) + + 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 + ) + + 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. + + 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 + """ + # 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 + ) + 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: + """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/temporary_comparison_files/aeroelastic_problems.py b/pterasoftware/temporary_comparison_files/aeroelastic_problems.py new file mode 100644 index 000000000..c536a1a92 --- /dev/null +++ b/pterasoftware/temporary_comparison_files/aeroelastic_problems.py @@ -0,0 +1,1135 @@ +"""Contains the SteadyProblem and UnsteadyProblem classes. + +**Contains the following classes:** + +SteadyProblem: A class used to contain steady aerodynamics problems. + +UnsteadyProblem: A class used to contain unsteady aerodynamics problems. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import matplotlib.pyplot as plt +import numpy as np +from scipy.integrate import solve_ivp + +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. + + **Contains the following methods:** + + reynolds_numbers: A tuple of Reynolds numbers, one for each Airplane in the + SteadyProblem. + """ + + __slots__ = ( + "_airplanes", + "_operating_point", + "_reynolds_numbers", + ) + + def __init__( + self, + airplanes: list[geometry.airplane.Airplane], + operating_point: operating_point_mod.OperatingPoint, + ) -> None: + """The initialization method. + + :param airplanes: The list of the Airplanes for this SteadyProblem. + :param operating_point: The OperatingPoint for this SteadyProblem. + :return: None + """ + # Validate and store immutable attributes. + if not isinstance(airplanes, list): + raise TypeError("airplanes must be a list.") + if len(airplanes) < 1: + raise ValueError("airplanes must have at least one element.") + for airplane in airplanes: + if not isinstance(airplane, geometry.airplane.Airplane): + raise TypeError("Every element in airplanes must be an Airplane.") + # Store as tuple to prevent external mutation via .append(), .pop(), etc. + self._airplanes: tuple[geometry.airplane.Airplane, ...] = tuple(airplanes) + + if not isinstance(operating_point, operating_point_mod.OperatingPoint): + raise TypeError("operating_point must be an OperatingPoint.") + self._operating_point = operating_point + + # Initialize the caches for the properties derived from the immutable + # attributes. + self._reynolds_numbers: tuple[float, ...] | None = None + + # Validate that the first Airplane has Cg_GP1_CgP1 set to zeros. + self._airplanes[0].validate_first_airplane_constraints() + + # Populate GP1_CgP1 coordinates for all Airplanes' Panels. This finds the + # Panels' positions in the first Airplane's geometry axes, relative to the + # first Airplane's CG based on their locally defined positions. + for airplane in self._airplanes: + # Compute the passive transformation matrix from this Airplane's local + # geometry axes, relative to its CG, to the first Airplane's geometry axes, + # relative to the first Airplane's CG. + T_pas_G_Cg_to_GP1_CgP1 = airplane.T_pas_G_Cg_to_GP1_CgP1 + + for wing in airplane.wings: + assert wing.panels is not None + + for panel in np.ravel(wing.panels): + panel.Frpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Frpp_G_Cg, has_point=True + ) + panel.Flpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Flpp_G_Cg, has_point=True + ) + panel.Blpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Blpp_G_Cg, has_point=True + ) + panel.Brpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Brpp_G_Cg, has_point=True + ) + + # --- Immutable: read only properties --- + @property + def airplanes(self) -> tuple[geometry.airplane.Airplane, ...]: + return self._airplanes + + @property + def operating_point(self) -> operating_point_mod.OperatingPoint: + return self._operating_point + + # --- Immutable derived: manual lazy caching --- + @property + def reynolds_numbers(self) -> tuple[float, ...]: + """A tuple of Reynolds numbers, one for each Airplane in the SteadyProblem. + + **Notes:** + + The Reynolds number is calculated as: Re = (V x L) / nu, where V is the + freestream speed, observed from the Earth frame (vCg__E from OperatingPoint, + m/s), L is the characteristic length (c_ref from Airplane, m), and nu is the + kinematic viscosity (nu from OperatingPoint, m^2/s). + + These Reynolds numbers only consider the freestream speed, not any apparent + velocity due to prescribed motion, so be careful interpreting it for cases where + this SteadyProblem corresponds to one time step in an UnsteadyProblem. + + :return: A tuple of Reynolds numbers, one for each Airplane. + """ + if self._reynolds_numbers is None: + v = self._operating_point.vCg__E + nu = self._operating_point.nu + + reynolds_list = [] + for airplane in self._airplanes: + c_ref = airplane.c_ref + assert c_ref is not None, "Airplane c_ref must be set to calculate Re" + re = (v * c_ref) / nu + reynolds_list.append(re) + + # Store as tuple to prevent external mutation. + self._reynolds_numbers = tuple(reynolds_list) + return self._reynolds_numbers + + +class UnsteadyProblem: + """A class used to contain unsteady aerodynamics problems. + + **Contains the following methods:** + + None + """ + + __slots__ = ( + "_movement", + "_only_final_results", + "_num_steps", + "_delta_time", + "_max_wake_rows", + "_first_averaging_step", + "_first_results_step", + "finalForces_W", + "finalForceCoefficients_W", + "finalMoments_W_CgP1", + "finalMomentCoefficients_W_CgP1", + "finalMeanForces_W", + "finalMeanForceCoefficients_W", + "finalMeanMoments_W_CgP1", + "finalMeanMomentCoefficients_W_CgP1", + "finalRmsForces_W", + "finalRmsForceCoefficients_W", + "finalRmsMoments_W_CgP1", + "finalRmsMomentCoefficients_W_CgP1", + "_steady_problems", + ) + + def __init__( + self, + movement: movements.movement.Movement, + only_final_results: bool | np.bool_ = False, + ) -> None: + """The initialization method. + + :param movement: The Movement that contains this UnsteadyProblem's + OperatingPointMovement and AirplaneMovements. + :param only_final_results: Determines whether the Solver will only calculate + loads for the final time step (for static Movements) or (for non static + Movements) for will only calculate loads for the time steps in the final + complete motion cycle (of the Movement's sub Movement with the longest + period), which increases simulation speed. Can be a bool or a numpy bool and + will be converted internally to a bool. The default is False. + :return: None + """ + # Validate and store immutable attributes. + 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: int = self._movement.num_steps + self._delta_time: float = self._movement.delta_time + self._max_wake_rows: int | None = self._movement.max_wake_rows + + # For UnsteadyProblems with a static Movement, we 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) we are typically interested in the forces and moments averaged over the + # last cycle simulated. Use the LCM of all motion periods to ensure we average + # over a complete cycle of all motions. + _movement_lcm_period = self._movement.lcm_period + self._first_averaging_step: int + if _movement_lcm_period == 0: + self._first_averaging_step = self._num_steps - 1 + else: + self._first_averaging_step = max( + 0, + math.floor(self._num_steps - (_movement_lcm_period / self._delta_time)), + ) + + # If we 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. + self._first_results_step: int + 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. These are mutable and populated by the solver. + self.finalForces_W: list[np.ndarray] = [] + self.finalForceCoefficients_W: list[np.ndarray] = [] + self.finalMoments_W_CgP1: list[np.ndarray] = [] + self.finalMomentCoefficients_W_CgP1: list[np.ndarray] = [] + + # 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. These are mutable and populated by the + # solver. + self.finalMeanForces_W: list[np.ndarray] = [] + self.finalMeanForceCoefficients_W: list[np.ndarray] = [] + self.finalMeanMoments_W_CgP1: list[np.ndarray] = [] + self.finalMeanMomentCoefficients_W_CgP1: list[np.ndarray] = [] + + # 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. These are mutable and populated by + # the solver. + self.finalRmsForces_W: list[np.ndarray] = [] + self.finalRmsForceCoefficients_W: list[np.ndarray] = [] + self.finalRmsMoments_W_CgP1: list[np.ndarray] = [] + self.finalRmsMomentCoefficients_W_CgP1: list[np.ndarray] = [] + + # Initialize an empty list to hold the SteadyProblems as they are generated. + steady_problems_temp: list[SteadyProblem] = [] + + # 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 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 --- + @property + def movement(self) -> movements.movement.Movement: + return self._movement + + @property + def only_final_results(self) -> bool: + return self._only_final_results + + @property + def num_steps(self) -> int: + return self._num_steps + + @property + def delta_time(self) -> float: + return self._delta_time + + @property + def first_averaging_step(self) -> int: + return self._first_averaging_step + + @property + def first_results_step(self) -> int: + return self._first_results_step + + @property + def max_wake_rows(self) -> int | None: + return self._max_wake_rows + + @property + 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 coupled + simulations where each time step has its own SteadyProblem. + + **Contains the following methods:** + + get_steady_problem: Gets the SteadyProblem at a specified step. + initialize_next_problem: Initializes the next step's problem. + + **Contains the following class attributes:** + + None + """ + + def __init__( + self, + single_step_movement: movements.single_step.single_step_movement.SingleStepMovement, + only_final_results: bool | np.bool_ = False, + ) -> 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. + + :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, + ): + 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( + only_final_results, "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) + + # 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( + [movement.airplane_movements[0].base_airplane], + movement.operating_point_movement.base_operating_point, + ) + ] + + 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. + """ + if step >= len(self.coupled_steady_problems): + raise Exception( + f"Step index {step} is out of range of the number of initialized problems" + ) + return self.coupled_steady_problems[step] + + 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. + + :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: 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: 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: 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: 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 + self.moment_scaling_factor = moment_scaling_factor + self.spring_constant = spring_constant + self.damping_constant = damping_constant + self.aero_scaling = aero_scaling + 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: 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 + + 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: + 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. + 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. + + 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 + 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 + + def initialize_next_problem(self, solver): + + deformation_matrices = self.calculate_wing_deformation( + 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.coupled_steady_problems), + deformation_matrices=deformation_matrices, + ) + ) + self.coupled_steady_problems.append( + SteadyProblem( + airplanes=self.curr_airplanes, + operating_point=self.curr_operating_point, + ) + ) + + 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] + + # 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.size == 0: + self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) + self.angluar_velocities = np.zeros((num_spanwise_panels + 1, 3)) + + # Extract aerodynamic and inertial moments + aeroMoments_GP1_Slep = self._extract_aero_moments( + 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, + ) + + # Calculate spring moments and deformation via ODE integration + 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, + ) + + # 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, + ) + + # 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 + assert wing.panels is not None + 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 np.array(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( + [ + 0, + thetas[i + 1] * 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) + 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, + ) -> 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. + :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] + 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.size == 0: + 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 + ) + + # 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()) + + def _plot_aeroelastic_results(self) -> None: + """Generate and display time-history plots of aeroelastic results. + + 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", + ) + + # 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 (computed in half-span increments) + for span_panel in range(num_spanwise_panels): + aero_span_moment = np.sum(aero_moments[:, span_panel, 2]) + 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] + + 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 + 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( + 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=span_I, + theta0=theta0, + omega0=omega0, + aero_span_moment=aero_span_moment, + step=step, + span_I=span_I, + ) + d += W / 2 + 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: 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 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 (restoring force from structural springs/dampers) + spring_moment = -k * theta - c * omega + + return theta, omega, spring_moment + + 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. + """ + 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 + ) + else: + 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 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). + :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 float(aero_torque + inertial_torque_func(time)) + + def ode(time: float, y: np.ndarray) -> np.ndarray: + """ODE system: dθ/dt = ω, dω/dt = (τ - c*ω - k*θ)/I.""" + theta, omega = y + return np.array([omega, (tau(time) - c * omega - k * theta) / I]) + + sol = solve_ivp( + ode, + (t[0], t[-1]), + np.array([theta0, omega0]), + t_eval=t, + rtol=1e-9, + atol=1e-12, + ) + + theta = float(sol.y[0][-1]) + omega = float(sol.y[1][-1]) + + return theta, omega + + 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) + + 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() diff --git a/pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py b/pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py new file mode 100644 index 000000000..4a1458aa8 --- /dev/null +++ b/pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py @@ -0,0 +1,2394 @@ +"""Contains the UnsteadyRingVortexLatticeMethodSolver class. + +**Contains the following classes:** + +UnsteadyRingVortexLatticeMethodSolver: A class used to solve UnsteadyProblems with the +unsteady ring vortex lattice method. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import cast + +import numpy as np +from tqdm import tqdm + +from . import ( + _aerodynamics_functions, + _functions, + _logging, + _panel, + _parameter_validation, + _transformations, + _vortices, + geometry, + movements, + operating_point, + problems, +) + +_logger = _logging.get_logger("unsteady_ring_vortex_lattice_method") + + +# REFACTOR: Add unit tests for trapezoid-rule-based averages for the mean and RMS loads +# and load coefficients. +# 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 UnsteadyRingVortexLatticeMethodSolver: + """A class used to solve UnsteadyProblems with the unsteady ring vortex lattice + method. + + **Contains the following methods:** + + run: Runs the solver on the UnsteadyProblem. + + 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. + """ + + __slots__ = ( + "unsteady_problem", + "_max_wake_rows", + "num_steps", + "delta_time", + "first_results_step", + "_first_averaging_step", + "_current_step", + "_prescribed_wake", + "steady_problems", + "current_airplanes", + "current_operating_point", + "num_airplanes", + "num_panels", + "_currentVInf_GP1__E", + "_currentStackFreestreamWingInfluences__E", + "_currentGridWingWingInfluences__E", + "_currentStackWakeWingInfluences__E", + "_current_bound_vortex_strengths", + "_last_bound_vortex_strengths", + "panels", + "stackUnitNormals_GP1", + "panel_areas", + "stackCpp_GP1_CgP1", + "_stackLastCpp_GP1_CgP1", + "stackBrbrvp_GP1_CgP1", + "stackFrbrvp_GP1_CgP1", + "stackFlbrvp_GP1_CgP1", + "stackBlbrvp_GP1_CgP1", + "_lastStackBrbrvp_GP1_CgP1", + "_lastStackFrbrvp_GP1_CgP1", + "_lastStackFlbrvp_GP1_CgP1", + "_lastStackBlbrvp_GP1_CgP1", + "stackCblvpr_GP1_CgP1", + "stackCblvpf_GP1_CgP1", + "stackCblvpl_GP1_CgP1", + "stackCblvpb_GP1_CgP1", + "_lastStackCblvpr_GP1_CgP1", + "_lastStackCblvpf_GP1_CgP1", + "_lastStackCblvpl_GP1_CgP1", + "_lastStackCblvpb_GP1_CgP1", + "stackRbrv_GP1", + "stackFbrv_GP1", + "stackLbrv_GP1", + "stackBbrv_GP1", + "panel_is_trailing_edge", + "panel_is_leading_edge", + "panel_is_left_edge", + "panel_is_right_edge", + "_current_wake_vortex_strengths", + "_current_wake_vortex_ages", + "_currentStackBrwrvp_GP1_CgP1", + "_currentStackFrwrvp_GP1_CgP1", + "_currentStackFlwrvp_GP1_CgP1", + "_currentStackBlwrvp_GP1_CgP1", + "list_num_wake_vortices", + "_list_wake_vortex_strengths", + "_list_wake_vortex_ages", + "_list_wake_rc0s", + "listStackBrwrvp_GP1_CgP1", + "listStackFrwrvp_GP1_CgP1", + "listStackFlwrvp_GP1_CgP1", + "listStackBlwrvp_GP1_CgP1", + "_currentStackBoundRc0s", + "_currentStackWakeRc0s", + "stackSeedPoints_GP1_CgP1", + "gridStreamlinePoints_GP1_CgP1", + "ran", + ) + + def __init__(self, unsteady_problem: problems.UnsteadyProblem) -> None: + """The initialization method. + + :param unsteady_problem: The UnsteadyProblem to be solved. + :return: None + """ + if not isinstance(unsteady_problem, problems.UnsteadyProblem): + raise TypeError("unsteady_problem must be an UnsteadyProblem.") + self.unsteady_problem = unsteady_problem + + self._max_wake_rows = self.unsteady_problem.max_wake_rows + self.num_steps = self.unsteady_problem.num_steps + self.delta_time = self.unsteady_problem.delta_time + self.first_results_step = self.unsteady_problem.first_results_step + self._first_averaging_step = self.unsteady_problem.first_averaging_step + self._current_step: int = 0 + self._prescribed_wake: bool = True + + self.steady_problems = self.unsteady_problem.steady_problems + + first_steady_problem: problems.SteadyProblem = self.get_steady_problem_at(0) + + self.current_airplanes: tuple[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 + for airplane in first_steady_problem.airplanes: + num_panels += airplane.num_panels + 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) + + # 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._list_wake_rc0s: 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._currentStackBoundRc0s: np.ndarray = np.empty(0, dtype=float) + self._currentStackWakeRc0s: np.ndarray = np.empty(0, dtype=float) + + 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, + 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 + """ + 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" + ) + + # The following loop iterates through the time steps to populate currently + # empty attributes with lists of pre-allocated arrays. During the simulation, + # these arrays will be filled with data that describe the wake. Using this + # 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.get_steady_problem_at(step) + these_airplanes = this_problem.airplanes + + # Loop through this time step's Airplanes to gather their Wings. + these_wings: list[tuple[geometry.wing.Wing, ...]] = [] + for airplane in these_airplanes: + these_wings.append(airplane.wings) + + # 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 + + # 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. If wake truncation is enabled, cap the number of + # chordwise wake rows at max_wake_rows. + 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(1, self.num_steps): + 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 + # Wing Panels. + num_wing_panels = 0 + for airplane in these_airplanes: + num_wing_panels += airplane.num_panels + + # 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: + # Initialize all the Airplanes' bound RingVortices. + _logger.debug("Initializing all Airplanes' bound RingVortices.") + self._initialize_panel_vortices() + + # 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 + ) + 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 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._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. + _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. + 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.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() + + # 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 _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: 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 + 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. + + :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 + 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.get_steady_problem_at( + 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 = _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. + + 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: + _standard_mean_chord = wing.standard_mean_chord + assert _standard_mean_chord is not None + wing_r_c0 = 0.03 * _standard_mean_chord + + _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, + ) + self._currentStackBoundRc0s[global_panel_position] = wing_r_c0 + + # Increment the global Panel position variable. + global_panel_position += 1 + + # Iterate through the 1D ndarray of this Wing's wake RingVortices. + wake_ring_vortex: _vortices.ring_vortex.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 + self._currentStackWakeRc0s[global_wake_ring_vortex_position] = ( + wing_r_c0 + ) + + # 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.get_steady_problem_at(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). + + When an image surface is defined on the OperatingPoint, the influence + coefficients also include the contributions from image bound RingVortices + reflected across that surface. + + :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. + singularity_counts = np.zeros(4, dtype=np.int64) + gridNormVIndCpp_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackBoundRc0s, + singularity_counts=singularity_counts, + ages=None, + nu=self.current_operating_point.nu, + ) + ) + + # Add the image contribution if an image surface is defined. + surfaceReflect_T_act_GP1_CgP1 = ( + self.current_operating_point.surfaceReflect_T_act_GP1_CgP1 + ) + if surfaceReflect_T_act_GP1_CgP1 is not None: + stackReflectedCpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + surfaceReflect_T_act_GP1_CgP1, + self.stackCpp_GP1_CgP1, + has_point=True, + ) + gridImageVIndCpp_GP1__E = ( + _aerodynamics_functions.expanded_velocities_from_ring_vortices( + stackP_GP1_CgP1=stackReflectedCpp_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, + r_c0s=self._currentStackBoundRc0s, + singularity_counts=singularity_counts, + ages=None, + nu=self.current_operating_point.nu, + ) + ) + gridNormVIndCpp_GP1_E += _transformations.apply_T_to_vectors( + surfaceReflect_T_act_GP1_CgP1, + gridImageVIndCpp_GP1__E, + has_point=False, + ) + + unexpected_singularity_counts = np.copy(singularity_counts) + + _functions.log_unexpected_singularity_counts( + _logger, + logging.ERROR, + "_calculate_wing_wing_influences", + unexpected_singularity_counts, + ) + + # 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. + + When an image surface is defined on the OperatingPoint, the influence + coefficients also include the contributions from image wake RingVortices + reflected across that surface. + + **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. + singularity_counts = np.zeros(4, dtype=np.int64) + currentStackWakeV_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackWakeRc0s, + singularity_counts=singularity_counts, + ages=self._current_wake_vortex_ages, + nu=self.current_operating_point.nu, + ) + ) + + # Add the image contribution if an image surface is defined. + surfaceReflect_T_act_GP1_CgP1 = ( + self.current_operating_point.surfaceReflect_T_act_GP1_CgP1 + ) + if surfaceReflect_T_act_GP1_CgP1 is not None: + stackReflectedCpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + surfaceReflect_T_act_GP1_CgP1, + self.stackCpp_GP1_CgP1, + has_point=True, + ) + currentStackImageWakeV_GP1_E = ( + _aerodynamics_functions.collapsed_velocities_from_ring_vortices( + stackP_GP1_CgP1=stackReflectedCpp_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, + r_c0s=self._currentStackWakeRc0s, + singularity_counts=singularity_counts, + ages=self._current_wake_vortex_ages, + nu=self.current_operating_point.nu, + ) + ) + currentStackWakeV_GP1_E += _transformations.apply_T_to_vectors( + surfaceReflect_T_act_GP1_CgP1, + currentStackImageWakeV_GP1_E, + has_point=False, + ) + + unexpected_singularity_counts = np.copy(singularity_counts) + + _functions.log_unexpected_singularity_counts( + _logger, + logging.INFO, + "_calculate_wake_wing_influences", + unexpected_singularity_counts, + ) + + # 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.strength = self._current_bound_vortex_strengths[panel_num] + + def calculate_solution_velocity( + self, + stackP_GP1_CgP1: np.ndarray | Sequence[Sequence[float | int]], + bound_singularity_counts: np.ndarray | None = None, + wake_singularity_counts: np.ndarray | None = None, + ) -> np.ndarray: + """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. + + When an image surface is defined on the OperatingPoint, the returned velocity + also includes the induced velocity from image bound and wake RingVortices + reflected across that surface. + + **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. + :param bound_singularity_counts: An optional (4,) ndarray of int64 for + accumulating singularity event counts from bound RingVortices. If None, + counts are discarded. + :param wake_singularity_counts: An optional (4,) ndarray of int64 for + accumulating singularity event counts from wake RingVortices. If None, + counts are discarded. + :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" + ) + ) + + if bound_singularity_counts is None: + bound_singularity_counts = np.zeros(4, dtype=np.int64) + if wake_singularity_counts is None: + wake_singularity_counts = np.zeros(4, dtype=np.int64) + + stackBoundRingVInd_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackBoundRc0s, + singularity_counts=bound_singularity_counts, + ages=None, + nu=self.current_operating_point.nu, + ) + ) + stackWakeRingVInd_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackWakeRc0s, + singularity_counts=wake_singularity_counts, + ages=self._current_wake_vortex_ages, + nu=self.current_operating_point.nu, + ) + ) + + # Add the image contributions if an image surface is defined. + surfaceReflect_T_act_GP1_CgP1 = ( + self.current_operating_point.surfaceReflect_T_act_GP1_CgP1 + ) + if surfaceReflect_T_act_GP1_CgP1 is not None: + stackReflectedP_GP1_CgP1 = _transformations.apply_T_to_vectors( + surfaceReflect_T_act_GP1_CgP1, + stackP_GP1_CgP1, + has_point=True, + ) + stackImageBoundRingVInd_GP1_E = ( + _aerodynamics_functions.collapsed_velocities_from_ring_vortices( + stackP_GP1_CgP1=stackReflectedP_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, + r_c0s=self._currentStackBoundRc0s, + singularity_counts=bound_singularity_counts, + ages=None, + nu=self.current_operating_point.nu, + ) + ) + stackBoundRingVInd_GP1_E += _transformations.apply_T_to_vectors( + surfaceReflect_T_act_GP1_CgP1, + stackImageBoundRingVInd_GP1_E, + has_point=False, + ) + stackImageWakeRingVInd_GP1_E = ( + _aerodynamics_functions.collapsed_velocities_from_ring_vortices( + stackP_GP1_CgP1=stackReflectedP_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, + r_c0s=self._currentStackWakeRc0s, + singularity_counts=wake_singularity_counts, + ages=self._current_wake_vortex_ages, + nu=self.current_operating_point.nu, + ) + ) + stackWakeRingVInd_GP1_E += _transformations.apply_T_to_vectors( + surfaceReflect_T_act_GP1_CgP1, + stackImageWakeRingVInd_GP1_E, + has_point=False, + ) + + 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. + bound_singularity_counts = np.zeros(4, dtype=np.int64) + wake_singularity_counts = np.zeros(4, dtype=np.int64) + stackVelocityRightLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpr_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_right_leg_centers() + ) + stackVelocityFrontLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpf_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_front_leg_centers() + ) + stackVelocityLeftLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpl_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_left_leg_centers() + ) + stackVelocityBackLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpb_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_back_leg_centers() + ) + + unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) + unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) + + # Subtract the expected structural collinearity before logging. For each Wing + # with C chordwise and S spanwise Panels, the four leg center evaluations + # produce (8 * C * S - 2 * C - 2 * S) bound collinearity singularities from + # RingVortex self and adjacent shared edge pairs. When there is a wake (time + # step > 0), each trailing edge Panel's back leg center is also collinear with + # and on-filament for the first wake row's front leg, adding S wake collinearity + # singularities per Wing. + expected_bound_collinearity = 0 + expected_wake_collinearity = 0 + for airplane in self.current_airplanes: + for wing in airplane.wings: + num_chordwise = wing.num_chordwise_panels + num_spanwise = wing.num_spanwise_panels + assert num_spanwise is not None + n = num_chordwise * num_spanwise + expected_bound_collinearity += ( + 8 * n - 2 * num_chordwise - 2 * num_spanwise + ) + if self._current_step > 0: + expected_wake_collinearity += num_spanwise + unexpected_bound_singularity_counts[3] -= expected_bound_collinearity + unexpected_wake_singularity_counts[3] -= expected_wake_collinearity + _functions.log_unexpected_singularity_counts( + _logger, + logging.ERROR, + "_calculate_loads (bound)", + unexpected_bound_singularity_counts, + ) + _functions.log_unexpected_singularity_counts( + _logger, + logging.INFO, + "_calculate_loads (wake)", + unexpected_wake_singularity_counts, + ) + + # 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 + ) + + 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. + 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 + ) + + return np.array(moments_GP1_CgP1) + + 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: + bound_singularity_counts = np.zeros(4, dtype=np.int64) + wake_singularity_counts = np.zeros(4, dtype=np.int64) + + # Get the next time step's Airplanes. + 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. + 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), + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + # 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), + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + ) + + # 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, + ) + ) + + # If wake truncation is enabled, discard the oldest (most + # downstream) rows of wake points. The point grid has one more + # row than the vortex grid because points form vertices and + # vortices form cells. + if ( + self._max_wake_rows is not None + and next_wing.gridWrvp_GP1_CgP1.shape[0] + > self._max_wake_rows + 1 + ): + next_wing.gridWrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ + : self._max_wake_rows + 1 + ] + + unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) + unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) + + _functions.log_unexpected_singularity_counts( + _logger, + logging.DEBUG, + "_populate_next_airplanes_wake_vortex_points (bound)", + unexpected_bound_singularity_counts, + ) + _functions.log_unexpected_singularity_counts( + _logger, + logging.DEBUG, + "_populate_next_airplanes_wake_vortex_points (wake)", + unexpected_wake_singularity_counts, + ) + + 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.get_steady_problem_at(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 + + # If wake truncation is enabled, trim the oldest rows of + # wake RingVortices before adding the new row. This avoids + # creating RingVortex objects that would immediately be + # discarded. + if ( + self._max_wake_rows is not None + and this_wing_wake_ring_vortices.shape[0] >= self._max_wake_rows + ): + this_wing_wake_ring_vortices = this_wing_wake_ring_vortices[ + : self._max_wake_rows - 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 + ) + + # 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, create a + # new RingVortex with the convected position for + # the next time step. + next_wake_ring_vortices = ( + next_wing.wake_ring_vortices + ) + assert next_wake_ring_vortices is not None + old_wake_ring_vortex = cast( + _vortices.ring_vortex.RingVortex, + next_wake_ring_vortices[ + chordwise_point_id, spanwise_point_id + ], + ) + + # Compute the new age. + if self._current_step == 0: + new_age = self.delta_time + else: + new_age = ( + old_wake_ring_vortex.age + self.delta_time + ) + + # Create a new RingVortex with convected corners. + new_wake_ring_vortex = ( + _vortices.ring_vortex.RingVortex( + Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, + Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, + strength=old_wake_ring_vortex.strength, + ) + ) + new_wake_ring_vortex.age = new_age + + # Replace the old RingVortex in the array. + next_wake_ring_vortices[ + chordwise_point_id, spanwise_point_id + ] = new_wake_ring_vortex + + 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, + ] = _vortices.ring_vortex.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.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.get_steady_problem_at( + 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.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]) + self.unsteady_problem.finalForceCoefficients_W.append( + force_coefficients_W[airplane_id, :, -1] + ) + self.unsteady_problem.finalMoments_W_CgP1.append( + moments_W_CgP1[airplane_id, :, -1] + ) + self.unsteady_problem.finalMomentCoefficients_W_CgP1.append( + moment_coefficients_W_CgP1[airplane_id, :, -1] + ) + else: + # The number of intervals for the trapezoidal rule is one less + # than the number of samples. + num_intervals = num_steps_to_average - 1 + + self.unsteady_problem.finalMeanForces_W.append( + np.trapezoid(forces_W[airplane_id], axis=-1) / num_intervals + ) + self.unsteady_problem.finalMeanForceCoefficients_W.append( + np.trapezoid(force_coefficients_W[airplane_id], axis=-1) + / num_intervals + ) + self.unsteady_problem.finalMeanMoments_W_CgP1.append( + np.trapezoid(moments_W_CgP1[airplane_id], axis=-1) / num_intervals + ) + self.unsteady_problem.finalMeanMomentCoefficients_W_CgP1.append( + np.trapezoid(moment_coefficients_W_CgP1[airplane_id], axis=-1) + / num_intervals + ) + + self.unsteady_problem.finalRmsForces_W.append( + np.sqrt( + np.trapezoid( + np.square(forces_W[airplane_id]), + axis=-1, + ) + / num_intervals + ) + ) + self.unsteady_problem.finalRmsForceCoefficients_W.append( + np.sqrt( + np.trapezoid( + np.square(force_coefficients_W[airplane_id]), + axis=-1, + ) + / num_intervals + ) + ) + self.unsteady_problem.finalRmsMoments_W_CgP1.append( + np.sqrt( + np.trapezoid( + np.square(moments_W_CgP1[airplane_id]), + axis=-1, + ) + / num_intervals + ) + ) + self.unsteady_problem.finalRmsMomentCoefficients_W_CgP1.append( + np.sqrt( + np.trapezoid( + np.square(moment_coefficients_W_CgP1[airplane_id]), + axis=-1, + ) + / num_intervals + ) + ) + + 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 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. + :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] diff --git a/pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py b/pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py new file mode 100644 index 000000000..b1f386225 --- /dev/null +++ b/pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py @@ -0,0 +1,2248 @@ +"""Contains the CoupledUnsteadyRingVortexLatticeMethodSolver class. + +**Contains the following classes:** + +CoupledUnsteadyRingVortexLatticeMethodSolver: A class used to solve +CoupledUnsteadyProblems with the unsteady ring vortex lattice method. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import cast + +import numpy as np +from tqdm import tqdm + +from . import ( + _aerodynamics_functions, + _functions, + _logging, + _panel, + _parameter_validation, + _transformations, + _vortices, + operating_point, + problems, +) + +_logger = _logging.get_logger("coupled_unsteady_ring_vortex_lattice_method") + + +# TEST: Add unit tests for this class's initialization. +# TEST: Add integration tests for this class. +class CoupledUnsteadyRingVortexLatticeMethodSolver: + """A class used to solve CoupledUnsteadyProblems with the unsteady ring vortex + lattice method. + + **Notes:** + + Currently, coupled simulations can only be run with a single airplane. However, this + class still refers to things like "the first Airplane's geometry axes" or uses + variable suffixes like "GP1" and "CGP1". This is purely for consistency with the + rest of the codebase. + + **Contains the following methods:** + + run: Runs the solver on the CoupledUnsteadyProblem. + + calculate_solution_velocity: Finds the fluid velocity (in first Airplane's geometry + axes, observed from the Earth frame) at one or more points (in 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: problems.CoupledUnsteadyProblem, + ) -> None: + """The initialization method. + + :param coupled_unsteady_problem: The CoupledUnsteadyProblem to be solved. + :return: None + """ + if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): + raise TypeError( + "coupled_unsteady_problem must be a CoupledUnsteadyProblem." + ) + self.coupled_unsteady_problem: problems.CoupledUnsteadyProblem = ( + coupled_unsteady_problem + ) + + self.mujoco_model = self.coupled_unsteady_problem.mujoco_model + + self.num_steps = self.coupled_unsteady_problem.num_steps + self.delta_time = self.coupled_unsteady_problem.delta_time + self._current_step: int = 0 + self._prescribed_wake: bool = True + + self.coupled_steady_problems = ( + self.coupled_unsteady_problem.coupled_steady_problems + ) + + first_coupled_steady_problem = self.coupled_steady_problems[0] + + self.current_airplane = first_coupled_steady_problem.airplane + self.current_coupled_operating_point = ( + first_coupled_steady_problem.coupled_operating_point + ) + + self.num_panels = self.current_airplane.num_panels + + # Initialize attributes to hold aerodynamic data that pertain to the simulation. + self._currentVInf_GP1__E: np.ndarray = ( + first_coupled_steady_problem.coupled_operating_point.vInf_GP1__E + ) + self._currentOmegas_BP1__E: np.ndarray = ( + first_coupled_steady_problem.coupled_operating_point.omegas_BP1__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 + # CoupledUnsteadyProblem. + 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) + + # 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._list_wake_rc0s: 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._currentStackBoundRc0s: np.ndarray = np.empty(0, dtype=float) + self._currentStackWakeRc0s: np.ndarray = np.empty(0, dtype=float) + + # Initialize ndarrays to hold MuJoCo state history for visualization. + self.stackPosition_E_E = np.zeros((self.num_steps, 3), dtype=float) + self.stackR_pas_E_to_BP1 = np.zeros((self.num_steps, 3, 3), dtype=float) + + # Initialize attributes to hold state from MuJoCo for creating the next time + # step's CoupledSteadyProblem. + self._nextPosition_E_E: np.ndarray = np.empty(0, dtype=float) + self._nextR_pas_E_to_BP1: np.ndarray = np.empty(0, dtype=float) + self._nextVelocity_E__E: np.ndarray = np.empty(0, dtype=float) + self._nextOmegas_BP1__E: np.ndarray = np.empty(0, dtype=float) + + self.ran = False + + def run( + self, + prescribed_wake: 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 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" + ) + show_progress = _parameter_validation.boolLike_return_bool( + show_progress, "show_progress" + ) + + # REFACTOR: Before starting the loop, we should reset mujoco_model to the + # initial state, as defined by the CoupledOperatingPoint. + + # Store the initial state (before any MuJoCo time steps) for visualization. + # This ensures that stackPosition_E_E[i] and stackR_pas_E_to_BP1[i] contain + # the state at the beginning of time step i, matching airplanes[i]. + initial_state = self.mujoco_model.get_state() + self.stackPosition_E_E[0] = cast(np.ndarray, initial_state["position_E_E"]) + self.stackR_pas_E_to_BP1[0] = cast(np.ndarray, initial_state["R_pas_E_to_BP1"]) + + # Get the number of spanwise Panels and the total number of Wing Panels for + # this CoupledUnsteadyProblem's Airplane. + _num_wing_panels = self.coupled_steady_problems[0].airplane.num_panels + _num_spanwise_panels = 0 + + for _wing in self.coupled_steady_problems[0].airplane.wings: + _this_wing_num_spanwise_panels = _wing.num_spanwise_panels + assert _this_wing_num_spanwise_panels is not None + + _num_spanwise_panels += _this_wing_num_spanwise_panels + + # The following loop iterates through the time steps to populate currently + # empty attributes with lists of pre-allocated arrays. During the simulation, + # these arrays will be filled with data that describe the wake. Using this + # method eliminates the need for computationally expensive on-the-fly + # allocation and object copying. + 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 * _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._list_wake_rc0s.append(this_wake_rc0s) + 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) + + # 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. + approx_times = np.zeros(self.num_steps, dtype=float) + for step in range(self.num_steps): + # 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 + + # REFACTOR: Once the coupled solver is stable, tune these multipliers + # empirically make the progress bar smooth and accurate. + # The following constant multipliers were determined empirically. Thus + # far, they seem to provide for adequately smooth progress bar updating. + if step == 0: + approx_times[step] = num_ring_vortices * 70 + elif step == 1: + approx_times[step] = num_ring_vortices * 30 + else: + approx_times[step] = num_ring_vortices * 3 + + 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=not show_progress, + bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " + "Remaining: {remaining}", + ) as bar: + # Iterate through the time steps. + for step in range(self.num_steps): + # Save attributes to hold the current step, Airplane, OperatingPoint, + # and freestream velocity (in first Airplane's geometry axes, + # observed from the Earth frame). + self._current_step = step + current_coupled_steady_problem = self.coupled_steady_problems[ + self._current_step + ] + self.current_airplane = current_coupled_steady_problem.airplane + self.current_coupled_operating_point = ( + current_coupled_steady_problem.coupled_operating_point + ) + + self._currentVInf_GP1__E = ( + self.current_coupled_operating_point.vInf_GP1__E + ) + logging.info( + "Beginning time step " + + str(self._current_step) + + " out of " + + str(self.num_steps - 1) + + "." + ) + + logging.debug( + f" speed = {self.current_coupled_operating_point.vCg__E:.2e} m/s" + ) + logging.debug( + f" alpha = {self.current_coupled_operating_point.alpha:.2f} deg" + ) + logging.debug( + f" beta = {self.current_coupled_operating_point.beta:.2f} deg" + ) + _vCg_E__E = self.current_coupled_operating_point.vCg_E__E + logging.debug( + f" vCg_E__E = [{_vCg_E__E[0]:.2e}, {_vCg_E__E[1]:.2e}, " + f"{_vCg_E__E[2]:.2e}] m/s" + ) + + # 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_coupled_operating_point.vInf_GP1__E + ) + self._currentOmegas_BP1__E = ( + self.current_coupled_operating_point.omegas_BP1__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 + # 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) + + # 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] + + # Initialize this time step's bound RingVortices. + # Only do this at the beginning for the first time step. For subsequent + # steps, RingVortices are initialized after creating the next problem. + if self._current_step == 0: + logging.info( + " Initializing this time step's bound RingVortices." + ) + self._initialize_panel_vortices(step=0) + + # 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 Airplane's geometry 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 and the net loads on each Airplane. + logging.info(" Calculating forces and moments.") + self._calculate_loads() + + # Convert the loads to use MuJoCo's proper axes and reference points, + # and then pass them to the MuJoCo model. + logging.info(" Passing loads to MuJoCo.") + self._pass_loads_to_mujoco() + + # Step the MuJoCo simulation forward by one time step. + logging.info(" Stepping the MuJoCo simulation.") + self.mujoco_model.step() + + logging.info(" Processing the new states from MuJoCo.") + self._process_new_states_from_mujoco() + + logging.info(" Create the next time step's CoupledSteadyProblem.") + self._create_next_coupled_steady_problem() + + # Initialize the next time step's bound RingVortices (if a next step + # was created). This must be done before calling _populate_next_wake, + # which accesses the next step's Panels' RingVortices. + if self._current_step < self.num_steps - 1: + logging.info( + " Initializing the next time step's bound RingVortices." + ) + self._initialize_panel_vortices(self._current_step + 1) + + # Shed RingVortices into the wake. + logging.info( + " Shedding RingVortices into the wake of the next time step's " + "CoupledSteadyProblem." + ) + self._populate_next_airplanes_wake() + + # Update the progress bar based on this time step's predicted + # approximate, relative computing time. + bar.update(n=float(approx_times[step])) + + self.ran = True + + def _initialize_panel_vortices(self, step: int) -> None: + """Calculates the locations of the bound RingVortex vertices for a particular + time step, 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. + + :param step: The time step number. + :return: None + """ + this_coupled_steady_problem = self.coupled_steady_problems[step] + + this_airplane = this_coupled_steady_problem.airplane + thisVInf_GP1__E = ( + this_coupled_steady_problem.coupled_operating_point.vInf_GP1__E + ) + + # Iterate through the current SteadyProblem's Airplane's Wings. + for wing_id, wing in enumerate(this_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 step == 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 + + thisVInf_GP1__E * self.delta_time * 0.25 + ) + Brrvp_GP1_CgP1 = ( + _Brpp_GP1_CgP1 + + thisVInf_GP1__E * self.delta_time * 0.25 + ) + else: + last_coupled_steady_problem = self.coupled_steady_problems[ + step - 1 + ] + last_airplane = last_coupled_steady_problem.airplane + 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 thisVInf_GP1__E, because we want the + # apparent fluid velocity due to motion (in first Airplane's + # geometry axes, observed from the Earth frame). This is the + # vector pointing opposite the velocity from motion. + Blrvp_GP1_CgP1 = ( + _thisBlpp_GP1_CgP1 + + ( + thisVInf_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 + + ( + thisVInf_GP1__E + - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1) + / self.delta_time + ) + * self.delta_time + * 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, + ) + + def _collapse_geometry(self) -> None: + """Converts attributes of the CoupledUnsteadyProblem'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 Airplane's Wings. + for wing in self.current_airplane.wings: + _standard_mean_chord = wing.standard_mean_chord + assert _standard_mean_chord is not None + wing_r_c0 = 0.03 * _standard_mean_chord + + _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, + ) + self._currentStackBoundRc0s[global_panel_position] = wing_r_c0 + + # Increment the global Panel position variable. + global_panel_position += 1 + + # Iterate through the 1D ndarray of this Wing's wake RingVortices. + wake_ring_vortex: _vortices.ring_vortex.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 + self._currentStackWakeRc0s[global_wake_ring_vortex_position] = wing_r_c0 + + # 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_coupled_steady_problem: problems.CoupledSteadyProblem = ( + self.coupled_steady_problems[self._current_step - 1] + ) + last_airplane = last_coupled_steady_problem.airplane + + # Iterate through the last time step's Airplane's 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. + 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. + singularity_counts = np.zeros(4, dtype=np.int64) + gridNormVIndCpp_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackBoundRc0s, + singularity_counts=singularity_counts, + ages=None, + nu=self.current_coupled_operating_point.nu, + ) + ) + + unexpected_singularity_counts = np.copy(singularity_counts) + + _functions.log_unexpected_singularity_counts( + _logger, + logging.ERROR, + "_calculate_wing_wing_influences", + unexpected_singularity_counts, + ) + + # 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 + """ + # Transform angular velocity from the first Airplane's body axes to the first + # Airplane's geometry axes. + # + # Body and geometry axes differ by 180 degrees about Y: + # GP1_x = -BP1_x, GP1_y = BP1_y, GP1_z = -BP1_z + # This is equivalent to: R_pas_BP1_to_GP1 = [[-1, 0, 0], [0, 1, 0], [0, 0, -1]] + # For a free vector (angular velocity), applying this transformation: + # omegas_GP1__E = R_pas_BP1_to_GP1 @ omegas_BP1__E + # Which simplifies to negating the x and z components. + # REFACTOR: Consider making specific functions in _transformations.py for + # transformations between body and geometry axes. + omegas_GP1__E = self._currentOmegas_BP1__E * np.array([-1.0, 1.0, -1.0]) + + # Convert angular velocity from degrees per second to radians per second for the + # cross product calculation. + omegas_GP1__E_rad = np.deg2rad(omegas_GP1__E) + + # 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. Compute the total velocity at each + # collocation point (freestream + rotational). + # + # The velocity at a point due to rotation is: v = omega x r + # where r is the position vector from the rotation center (CG) to the point. + # This gives the velocity of the point in the rotating frame. + # The apparent wind velocity is opposite to this motion, hence the subtraction. + velocity_at_collocation_points_GP1__E = ( + self._currentVInf_GP1__E # Broadcasts from (3,) to (num_panels,3). + - np.cross(omegas_GP1__E_rad, self.stackCpp_GP1_CgP1) + ) + currentStackFreestreamOnlyWingInfluences__E = np.einsum( + "ij,ij->i", + self.stackUnitNormals_GP1, + velocity_at_collocation_points_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. + singularity_counts = np.zeros(4, dtype=np.int64) + currentStackWakeV_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackWakeRc0s, + singularity_counts=singularity_counts, + ages=self._current_wake_vortex_ages, + nu=self.current_coupled_operating_point.nu, + ) + ) + + unexpected_singularity_counts = np.copy(singularity_counts) + + _functions.log_unexpected_singularity_counts( + _logger, + logging.INFO, + "_calculate_wake_wing_influences", + unexpected_singularity_counts, + ) + + # 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.strength = self._current_bound_vortex_strengths[panel_num] + + def calculate_solution_velocity( + self, + stackP_GP1_CgP1: np.ndarray | Sequence[Sequence[float | int]], + bound_singularity_counts: np.ndarray | None = None, + wake_singularity_counts: np.ndarray | None = None, + ) -> np.ndarray: + """Finds the fluid velocity (in first Airplane's geometry axes, observed from + the Earth frame) at one or more points (in 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. + :param bound_singularity_counts: An optional (4,) ndarray of int64 for + accumulating singularity event counts from bound RingVortices. If None, + counts are discarded. + :param wake_singularity_counts: An optional (4,) ndarray of int64 for + accumulating singularity event counts from wake RingVortices. If None, + counts are discarded. + :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" + ) + ) + + if bound_singularity_counts is None: + bound_singularity_counts = np.zeros(4, dtype=np.int64) + if wake_singularity_counts is None: + wake_singularity_counts = np.zeros(4, dtype=np.int64) + + stackBoundRingVInd_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackBoundRc0s, + singularity_counts=bound_singularity_counts, + ages=None, + nu=self.current_coupled_operating_point.nu, + ) + ) + stackWakeRingVInd_GP1_E = ( + _aerodynamics_functions.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, + r_c0s=self._currentStackWakeRc0s, + singularity_counts=wake_singularity_counts, + ages=self._current_wake_vortex_ages, + nu=self.current_coupled_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 Airplane's Wings. + for wing in self.current_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. + bound_singularity_counts = np.zeros(4, dtype=np.int64) + wake_singularity_counts = np.zeros(4, dtype=np.int64) + stackVelocityRightLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpr_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_right_leg_centers() + ) + stackVelocityFrontLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpf_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_front_leg_centers() + ) + stackVelocityLeftLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpl_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_left_leg_centers() + ) + stackVelocityBackLineVortexCenters_GP1__E = ( + self.calculate_solution_velocity( + stackP_GP1_CgP1=self.stackCblvpb_GP1_CgP1, + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + self._calculate_current_movement_velocities_at_back_leg_centers() + ) + + unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) + unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) + + # Subtract the expected structural collinearity before logging. For each Wing + # with C chordwise and S spanwise Panels, the four leg center evaluations + # produce (8 * C * S - 2 * C - 2 * S) bound collinearity singularities from + # RingVortex self and adjacent shared edge pairs. When there is a wake (time + # step > 0), each trailing edge Panel's back leg center is also collinear with + # and on filament for the first wake row's front leg, adding S wake collinearity + # singularities per Wing. + expected_bound_collinearity = 0 + expected_wake_collinearity = 0 + for wing in self.current_airplane.wings: + num_chordwise = wing.num_chordwise_panels + num_spanwise = wing.num_spanwise_panels + assert num_spanwise is not None + n = num_chordwise * num_spanwise + expected_bound_collinearity += 8 * n - 2 * num_chordwise - 2 * num_spanwise + if self._current_step > 0: + expected_wake_collinearity += num_spanwise + unexpected_bound_singularity_counts[3] -= expected_bound_collinearity + unexpected_wake_singularity_counts[3] -= expected_wake_collinearity + _functions.log_unexpected_singularity_counts( + _logger, + logging.ERROR, + "_calculate_loads (bound)", + unexpected_bound_singularity_counts, + ) + _functions.log_unexpected_singularity_counts( + _logger, + logging.INFO, + "_calculate_loads (wake)", + unexpected_wake_singularity_counts, + ) + + # 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_coupled_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_coupled_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_coupled_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_coupled_operating_point.rho + * np.expand_dims(effective_back_line_vortex_strengths, axis=1) + * _functions.numba_1d_explicit_cross( + stackVelocityBackLineVortexCenters_GP1__E, + self.stackBbrv_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_coupled_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 + ) + + # 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 + ) + + # 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 _pass_loads_to_mujoco(self) -> None: + """Passes external and aerodynamic loads to MuJoCo for rigid body dynamics + integration. + + **Notes:** + + The loads are specified in wind axes, then transformed to Earth axes for + MuJoCo's world coordinate system. + + The transformation chain is: W_CgP1 > GP1_CgP1 > BP1_CgP1 > E_CgP1. + + :return: None + """ + # Get the forces (in wind axes) on the Airplane. + forces_W = self.current_airplane.forces_W + assert forces_W is not None + + # Get the moments (in wind axes, relative to the first Airplane's CG) on the + # Airplane. + moments_W_CgP1 = self.current_airplane.moments_W_CgP1 + assert moments_W_CgP1 is not None + + # Add the external wind x-axis force, specified in the CoupledOperatingPoint. + forces_W = forces_W + np.array( + [self.current_coupled_operating_point.externalFX_W, 0.0, 0.0], dtype=float + ) + + # If an external forces function is provided, call it and add the additional + # forces and moments (in wind axes). + external_forces_fn = self.coupled_unsteady_problem.external_forces_fn + if external_forces_fn is not None: + extra_forces_W, extra_moments_W_CgP1 = external_forces_fn( + self.current_coupled_operating_point, self.current_airplane + ) + forces_W = forces_W + extra_forces_W + moments_W_CgP1 = moments_W_CgP1 + extra_moments_W_CgP1 + + # Compose the full transformation. + T_pas_W_CgP1_to_E_CgP1 = ( + self.current_coupled_operating_point.T_pas_W_CgP1_to_E_CgP1 + ) + + # Transform the force, which is a free vector, so has_point=False. + forces_E = _transformations.apply_T_to_vectors( + T_pas_W_CgP1_to_E_CgP1, forces_W, has_point=False + ) + + # Find the unit vector for the direction of gravitational acceleration (in + # Earth axes). + thisG_E = self.current_coupled_operating_point.g_E + thisUnitG_E = thisG_E / np.linalg.norm(thisG_E) + + # Add the force from weight (in Earth axes). + forces_E = forces_E + self.current_airplane.weight * thisUnitG_E + + # Transform the moment (about the first Airplane's CG). Both W_CgP1 and + # E_CgP1 are relative to the same point (first airplane's CG), + # so the transformation only rotates axes. However, we use has_point=True to + # match the existing codebase pattern for moments. + moments_E_CgP1 = _transformations.apply_T_to_vectors( + T_pas_W_CgP1_to_E_CgP1, moments_W_CgP1, has_point=True + ) + + # Apply the loads to the MuJoCo model, if this time step is beyond the + # prescribed portion of the simulation. + if ( + self._current_step + >= self.coupled_unsteady_problem.coupled_movement.prescribed_num_steps + ): + self.mujoco_model.apply_loads(forces_E, moments_E_CgP1) + + def _process_new_states_from_mujoco(self) -> None: + """Processes the updated state from MuJoCo and creates a new + CoupledOperatingPoint. + + **Notes:** + + Retrieves the updated position, orientation, velocity, and angular velocity from + MuJoCo after the dynamics integration step. It transforms the velocity from + Earth axes to the first Airplane's geometry axes, computes the new angle of + attack and sideslip angle, and creates a new CoupledOperatingPoint for the next + time step. + + The transformation chain for velocity is: E > BP1 > GP1. + + Stores the state for use by _create_next_coupled_steady_problem(). + + :return: None + """ + # Get the updated state from MuJoCo. + state = self.mujoco_model.get_state() + + # Store the state for use in _create_next_coupled_steady_problem. + self._nextPosition_E_E = cast(np.ndarray, state["position_E_E"]) + self._nextR_pas_E_to_BP1 = cast(np.ndarray, state["R_pas_E_to_BP1"]) + self._nextVelocity_E__E = cast(np.ndarray, state["velocity_E__E"]) + self._nextOmegas_BP1__E = cast(np.ndarray, state["omegas_BP1__E"]) + + # Store the state in history ndarrays for visualization. The state after MuJoCo + # steps belongs to the next time step (current_step + 1), since this is the + # state at the beginning of that time step. We only store if there is a next + # time step. The initial state (for time step 0) is stored at the start of the + # run method. + if self._current_step < self.num_steps - 1: + self.stackPosition_E_E[self._current_step + 1] = ( + self._nextPosition_E_E.copy() + ) + self.stackR_pas_E_to_BP1[self._current_step + 1] = ( + self._nextR_pas_E_to_BP1.copy() + ) + + # REFACTOR: This method of extracting Euler angles from a rotation matrix should + # be converted into a function _transformations.py and then checked with unit + # tests. + # Extract Euler angles from the rotation matrix R_pas_E_to_BP1. + # For intrinsic z-y'-x" sequence (izyx), the passive rotation matrix is: + # R_pas = (Rz(angleZ) @ Ry(angleY) @ Rx(angleX)).T + # + # The matrix structure: + # R_pas[0,2] = -sin(angleY) + # R_pas[1,2] = cos(angleY) * sin(angleX) + # R_pas[2,2] = cos(angleY) * cos(angleX) + # R_pas[0,1] = sin(angleZ) * cos(angleY) + # R_pas[0,0] = cos(angleZ) * cos(angleY) + # + # Extraction formulas: + # angleY = asin(-R[0, 2]) + # angleX = atan2(R[1, 2], R[2, 2]) + # angleZ = atan2(R[0, 1], R[0, 0]) + # + # Gimbal lock occurs when angleY = +/- 90 degrees (cos(angleY) = 0). + R = self._nextR_pas_E_to_BP1 + # Extract pitch (angleY) with clamping to avoid numerical issues with asin. + sin_angleY = -R[0, 2] + sin_angleY = np.clip(sin_angleY, -1.0, 1.0) + angleY = np.rad2deg(np.arcsin(sin_angleY)) + # Check for gimbal lock (pitch near +/- 90 degrees). + if np.abs(sin_angleY) > 0.99999: + # Gimbal lock: set roll to zero and compute yaw from remaining elements. + # At gimbal lock, R[1,0] = -sin(angleZ) and R[1,1] = cos(angleZ). + angleX = 0.0 + angleZ = np.rad2deg(np.arctan2(-R[1, 0], R[1, 1])) + else: + # Normal case: extract roll and yaw. + angleX = np.rad2deg(np.arctan2(R[1, 2], R[2, 2])) + angleZ = np.rad2deg(np.arctan2(R[0, 1], R[0, 0])) + # Assemble the angles into the angles_E_to_BP1_izyx vector. + angles_E_to_BP1_izyx = np.array([angleX, angleY, angleZ], dtype=float) + + logging.debug(f" next angleX = {angleX:.2f} deg") + logging.debug(f" next angleY = {angleY:.2f} deg") + logging.debug(f" next angleZ = {angleZ:.2f} deg") + logging.debug( + f" next vCg_E__E = [{self._nextVelocity_E__E[0]:.2e}, " + f"{self._nextVelocity_E__E[1]:.2e}, {self._nextVelocity_E__E[2]:.2e}] m/s" + ) + + # Compute the speed (magnitude of velocity in Earth frame). + vCg__E = float(np.linalg.norm(self._nextVelocity_E__E)) + + # Compute the freestream velocity (in Earth axes, observed from Earth frame). + # Assuming still air, the apparent wind is opposite to the Airplane's motion. + vInf_E__E = -self._nextVelocity_E__E + + # Transform freestream velocity from Earth axes to the first Airplane's body + # axes. The rotation matrix R_pas_E_to_BP1 maps vectors from Earth to the + # first Airplane's body axes. + vInf_BP1__E = self._nextR_pas_E_to_BP1 @ vInf_E__E + + # REFACTOR: This method of extracting alpha and beta from vInf_BP1__E should be + # converted into a function _transformations.py and then checked with unit + # tests + # Compute alpha and beta from the freestream velocity in body axes. + # The freestream direction in body axes is used to determine the wind axes. + # Standard formulas: + # - alpha (angle of attack): angle between body x-axis and projection of + # freestream onto body xz-plane. + # - beta (sideslip angle): angle between freestream and body xz-plane. + u, v, w = vInf_BP1__E + alpha = np.rad2deg(np.arctan2(-w, -u)) + # Guard against numerical issues when computing beta. + v_normalized = v / (vCg__E + 1e-12) + v_normalized = np.clip(v_normalized, -1.0, 1.0) + beta = np.rad2deg(np.arcsin(v_normalized)) + + # Get the previous operating point's density and viscosity (these don't change). + rho = self.current_coupled_operating_point.rho + nu = self.current_coupled_operating_point.nu + externalFX_W = self.current_coupled_operating_point.externalFX_W + + # Create a new CoupledOperatingPoint with the updated state. + new_coupled_operating_point = operating_point.CoupledOperatingPoint( + rho=rho, + vCg__E=vCg__E, + omegas_BP1__E=self._nextOmegas_BP1__E, + angles_E_to_BP1_izyx=angles_E_to_BP1_izyx, + alpha=alpha, + beta=beta, + externalFX_W=externalFX_W, + nu=nu, + ) + + # Append the new CoupledOperatingPoint to the list. + self.coupled_unsteady_problem.coupled_movement.coupled_operating_points.append( + new_coupled_operating_point + ) + + def _create_next_coupled_steady_problem(self) -> None: + """Creates the next time step's CoupledSteadyProblem with updated geometry. + + **Notes:** + + Makes a new Airplane with the updated position and orientation from MuJoCo, but + preserves the Wing geometry from the prescribed motion (e.g., flapping). It then + creates a new CoupledSteadyProblem with the updated Airplane and the + CoupledOperatingPoint created in _process_new_states_from_mujoco(). + + The position and orientation are obtained from the state stored by + _process_new_states_from_mujoco(). + + :return: None + """ + # Check that we are not at the last time step. + if self._current_step >= self.num_steps - 1: + return + + # Get the pre-generated Airplane for the next time step from the + # CoupledMovement. This Airplane has the correct Wing geometry based on + # prescribed motion (e.g., flapping). + next_step_id = self._current_step + 1 + prescribed_airplane = self.coupled_unsteady_problem.coupled_movement.airplanes[ + next_step_id + ] + + # REFACTOR: Verify that reusing Wings from prescribed motion is correct. The + # relative geometry should be fine, but the wake RingVortices (stored in + # the Wings) have absolute coordinates in GP1_CgP1. Consider whether the + # wakes should be stored in the solver instead of in the Wings for coupled + # simulations, or whether wake positions need to be updated to account for + # the Airplane's new position and orientation. + # Create a deep copy of the prescribed Airplane with the correct CG position. + # This uses deep_copy_with_Cg_GP1_CgP1 to avoid re-processing wing symmetry + # (which would fail because the Wings are already meshed with immutable set + # once attributes). + updated_airplane = prescribed_airplane.deep_copy_with_Cg_GP1_CgP1( + np.array([0.0, 0.0, 0.0], dtype=float) + ) + + # Get the CoupledOperatingPoint that was created in + # _process_new_states_from_mujoco. + next_coupled_operating_point: operating_point.CoupledOperatingPoint = ( + self.coupled_unsteady_problem.coupled_movement.coupled_operating_points[ + next_step_id + ] + ) + + # Create a new CoupledSteadyProblem with the updated Airplane and + # CoupledOperatingPoint. + next_coupled_steady_problem = problems.CoupledSteadyProblem( + airplane=updated_airplane, + coupled_operating_point=next_coupled_operating_point, + ) + + # Append the new CoupledSteadyProblem to the list. + self.coupled_steady_problems.append(next_coupled_steady_problem) + + def _populate_next_airplanes_wake(self) -> None: + """Updates the next time step's Airplane's wake. + + :return: None + """ + # Populate the locations of the next time step's Airplane's wake RingVortex + # points. + self._populate_next_airplanes_wake_vortex_points() + + # Populate the locations of the next time step's Airplane's 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 Airplane's 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: + bound_singularity_counts = np.zeros(4, dtype=np.int64) + wake_singularity_counts = np.zeros(4, dtype=np.int64) + + # Get the next time step's Airplane. + next_problem = self.coupled_steady_problems[self._current_step + 1] + next_airplane = next_problem.airplane + + # 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_airplane + 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), + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + + # 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), + bound_singularity_counts=bound_singularity_counts, + wake_singularity_counts=wake_singularity_counts, + ) + ) + + # 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, + ) + ) + + unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) + unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) + + _functions.log_unexpected_singularity_counts( + _logger, + logging.DEBUG, + "_populate_next_airplanes_wake_vortex_points (bound)", + unexpected_bound_singularity_counts, + ) + _functions.log_unexpected_singularity_counts( + _logger, + logging.DEBUG, + "_populate_next_airplanes_wake_vortex_points (wake)", + unexpected_wake_singularity_counts, + ) + + 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_steady_problems[self._current_step + 1] + next_airplane = next_problem.airplane + + # Iterate through the next Airplane's predecessor's Wings. + for wing_id, this_wing in enumerate(self.current_airplane.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_airplane.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 + old_wake_ring_vortex = cast( + _vortices.ring_vortex.RingVortex, + next_wake_ring_vortices[ + chordwise_point_id, spanwise_point_id + ], + ) + + # Compute the updated age. + if self._current_step == 0: + new_age = self.delta_time + else: + new_age = old_wake_ring_vortex.age + self.delta_time + + # Replace with a new RingVortex at the updated + # position (RingVortex positions are immutable). + new_wake_ring_vortex = _vortices.ring_vortex.RingVortex( + Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, + Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, + Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, + Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, + strength=old_wake_ring_vortex.strength, + ) + new_wake_ring_vortex.age = new_age + next_wake_ring_vortices[ + chordwise_point_id, spanwise_point_id + ] = new_wake_ring_vortex + + 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, + ] = _vortices.ring_vortex.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, + ) diff --git a/pterasoftware/temporary_comparison_files/free_flight_problems.py b/pterasoftware/temporary_comparison_files/free_flight_problems.py new file mode 100644 index 000000000..5d76076a9 --- /dev/null +++ b/pterasoftware/temporary_comparison_files/free_flight_problems.py @@ -0,0 +1,497 @@ +"""Contains classes for aerodynamic problems. + +**Contains the following classes:** + +SteadyProblem: A class used to contain steady aerodynamics problems. + +UnsteadyProblem: A class used to contain unsteady aerodynamics problems. + +CoupledSteadyProblem: A class used to contain steady aerodynamics problems that +characterize each time step of a coupled unsteady simulation. + +CoupledUnsteadyProblem: A class used to contain unsteady aerodynamics problems that will +be used for coupled unsteady simulations. + +**Contains the following functions:** + +None +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Sequence + +import numpy as np + +from . import ( + _mujoco_model, + _parameter_validation, + _transformations, + geometry, + movements, +) +from . import operating_point as operating_point_mod + + +class SteadyProblem: + """A class used to contain steady aerodynamics problems. + + **Contains the following methods:** + + reynolds_numbers: A tuple of Reynolds numbers, one for each Airplane in the + SteadyProblem. + """ + + def __init__( + self, + airplanes: list[geometry.airplane.Airplane], + operating_point: operating_point_mod.OperatingPoint, + ) -> None: + """The initialization method. + + :param airplanes: The list of the Airplanes for this SteadyProblem. + :param operating_point: The OperatingPoint for this SteadyProblem. + :return: None + """ + # Validate and store immutable attributes. + if not isinstance(airplanes, list): + raise TypeError("airplanes must be a list.") + if len(airplanes) < 1: + raise ValueError("airplanes must have at least one element.") + for airplane in airplanes: + if not isinstance(airplane, geometry.airplane.Airplane): + raise TypeError("Every element in airplanes must be an Airplane.") + # Store as tuple to prevent external mutation via .append(), .pop(), etc. + self._airplanes: tuple[geometry.airplane.Airplane, ...] = tuple(airplanes) + + if not isinstance(operating_point, operating_point_mod.OperatingPoint): + raise TypeError("operating_point must be an OperatingPoint.") + self._operating_point = operating_point + + # Initialize the caches for the properties derived from the immutable + # attributes. + self._reynolds_numbers: tuple[float, ...] | None = None + + # Validate that the first Airplane has Cg_GP1_CgP1 set to zeros. + self._airplanes[0].validate_first_airplane_constraints() + + # Populate GP1_CgP1 coordinates for all Airplanes' Panels. This finds the + # Panels' positions in the first Airplane's geometry axes, relative to the + # first Airplane's CG based on their locally defined positions. + for airplane in self._airplanes: + # Compute the passive transformation matrix from this Airplane's local + # geometry axes, relative to its CG, to the first Airplane's geometry axes, + # relative to the first Airplane's CG. + T_pas_G_Cg_to_GP1_CgP1 = airplane.T_pas_G_Cg_to_GP1_CgP1 + + for wing in airplane.wings: + assert wing.panels is not None + + for panel in np.ravel(wing.panels): + panel.Frpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Frpp_G_Cg, has_point=True + ) + panel.Flpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Flpp_G_Cg, has_point=True + ) + panel.Blpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Blpp_G_Cg, has_point=True + ) + panel.Brpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Brpp_G_Cg, has_point=True + ) + + # --- Immutable: read only properties --- + @property + def airplanes(self) -> tuple[geometry.airplane.Airplane, ...]: + return self._airplanes + + @property + def operating_point(self) -> operating_point_mod.OperatingPoint: + return self._operating_point + + # --- Immutable derived: manual lazy caching --- + @property + def reynolds_numbers(self) -> tuple[float, ...]: + """A tuple of Reynolds numbers, one for each Airplane in the SteadyProblem. + + **Notes:** + + The Reynolds number is calculated as: Re = (V x L) / nu, where V is the + freestream speed, observed from the Earth frame (vCg__E from OperatingPoint, + m/s), L is the characteristic length (c_ref from Airplane, m), and nu is the + kinematic viscosity (nu from OperatingPoint, m^2/s). + + These Reynolds numbers only consider the freestream speed, not any apparent + velocity due to prescribed motion, so be careful interpreting it for cases where + this SteadyProblem corresponds to one time step in an UnsteadyProblem. + + :return: A tuple of Reynolds numbers, one for each Airplane. + """ + if self._reynolds_numbers is None: + v = self._operating_point.vCg__E + nu = self._operating_point.nu + + reynolds_list = [] + for airplane in self._airplanes: + c_ref = airplane.c_ref + assert c_ref is not None, "Airplane c_ref must be set to calculate Re" + re = (v * c_ref) / nu + reynolds_list.append(re) + + # Store as tuple to prevent external mutation. + self._reynolds_numbers = tuple(reynolds_list) + return self._reynolds_numbers + + +class UnsteadyProblem: + """A class used to contain unsteady aerodynamics problems. + + **Contains the following methods:** + + None + """ + + def __init__( + self, + movement: movements.movement.Movement, + only_final_results: bool | np.bool_ = False, + ) -> None: + """The initialization method. + + :param movement: The Movement that contains this UnsteadyProblem's + OperatingPointMovement and AirplaneMovements. + :param only_final_results: Determines whether the Solver will only calculate + loads for the final time step (for static Movements) or (for non static + Movements) for will only calculate loads for the time steps in the final + complete motion cycle (of the Movement's sub Movement with the longest + period), which increases simulation speed. Can be a bool or a numpy bool and + will be converted internally to a bool. The default is False. + :return: None + """ + # Validate and store immutable attributes. + 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: int = self._movement.num_steps + self._delta_time: float = self._movement.delta_time + self._max_wake_rows: int | None = self._movement.max_wake_rows + + # For UnsteadyProblems with a static Movement, we 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) we are typically interested in the forces and moments averaged over the + # last cycle simulated. Use the LCM of all motion periods to ensure we average + # over a complete cycle of all motions. + _movement_lcm_period = self._movement.lcm_period + self._first_averaging_step: int + if _movement_lcm_period == 0: + self._first_averaging_step = self._num_steps - 1 + else: + self._first_averaging_step = max( + 0, + math.floor(self._num_steps - (_movement_lcm_period / self._delta_time)), + ) + + # If we 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. + self._first_results_step: int + 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. These are mutable and populated by the solver. + self.finalForces_W: list[np.ndarray] = [] + self.finalForceCoefficients_W: list[np.ndarray] = [] + self.finalMoments_W_CgP1: list[np.ndarray] = [] + self.finalMomentCoefficients_W_CgP1: list[np.ndarray] = [] + + # 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. These are mutable and populated by the + # solver. + self.finalMeanForces_W: list[np.ndarray] = [] + self.finalMeanForceCoefficients_W: list[np.ndarray] = [] + self.finalMeanMoments_W_CgP1: list[np.ndarray] = [] + self.finalMeanMomentCoefficients_W_CgP1: list[np.ndarray] = [] + + # 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. These are mutable and populated by + # the solver. + self.finalRmsForces_W: list[np.ndarray] = [] + self.finalRmsForceCoefficients_W: list[np.ndarray] = [] + self.finalRmsMoments_W_CgP1: list[np.ndarray] = [] + self.finalRmsMomentCoefficients_W_CgP1: list[np.ndarray] = [] + + # Initialize an empty list to hold the SteadyProblems as they are generated. + steady_problems_temp: list[SteadyProblem] = [] + + # 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 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 --- + @property + def movement(self) -> movements.movement.Movement: + return self._movement + + @property + def only_final_results(self) -> bool: + return self._only_final_results + + @property + def num_steps(self) -> int: + return self._num_steps + + @property + def delta_time(self) -> float: + return self._delta_time + + @property + def first_averaging_step(self) -> int: + return self._first_averaging_step + + @property + def first_results_step(self) -> int: + return self._first_results_step + + @property + def max_wake_rows(self) -> int | None: + return self._max_wake_rows + + @property + def steady_problems(self) -> tuple[SteadyProblem, ...]: + return self._steady_problems + + +class CoupledSteadyProblem: + """A class used to contain steady aerodynamics problems that characterize each time + step of a coupled unsteady simulation. + + **Contains the following methods:** + + None + """ + + def __init__( + self, + airplane: geometry.airplane.Airplane, + coupled_operating_point: operating_point_mod.CoupledOperatingPoint, + ) -> None: + """The initialization method. + + :param airplane: The Airplane for this CoupledSteadyProblem. + :param coupled_operating_point: The CoupledOperatingPoint for this + CoupledSteadyProblem. + :return: None + """ + if not isinstance(airplane, geometry.airplane.Airplane): + raise TypeError("airplane must be an Airplane.") + self._airplane = airplane + + if not isinstance( + coupled_operating_point, operating_point_mod.CoupledOperatingPoint + ): + raise TypeError("coupled_operating_point must be a CoupledOperatingPoint.") + self._coupled_operating_point = coupled_operating_point + + # As CoupledSteadyProblems can only have one Airplane, they must have + # Cg_GP1_CgP1 set to zeros. + self._airplane.validate_first_airplane_constraints() + + # Populate the GP1_CgP1 coordinates for the Airplane's Panels. + T_pas_G_Cg_to_GP1_CgP1 = airplane.T_pas_G_Cg_to_GP1_CgP1 + for wing in airplane.wings: + _panels = wing.panels + assert _panels is not None + + for panel in np.ravel(_panels): + panel.Frpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Frpp_G_Cg, has_point=True + ) + panel.Flpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Flpp_G_Cg, has_point=True + ) + panel.Blpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Blpp_G_Cg, has_point=True + ) + panel.Brpp_GP1_CgP1 = _transformations.apply_T_to_vectors( + T_pas_G_Cg_to_GP1_CgP1, panel.Brpp_G_Cg, has_point=True + ) + + # --- Immutable: read only properties --- + @property + def airplane(self) -> geometry.airplane.Airplane: + return self._airplane + + @property + def coupled_operating_point(self) -> operating_point_mod.CoupledOperatingPoint: + return self._coupled_operating_point + + +class CoupledUnsteadyProblem: + """A class used to contain unsteady aerodynamics problems that will be used for + coupled unsteady simulations. + + **Contains the following methods:** + + None + """ + + def __init__( + self, + coupled_movement: movements.movement.CoupledMovement, + I_BP1_CgP1: np.ndarray | Sequence[Sequence[float | int]], + external_forces_fn: ( + Callable[ + [ + operating_point_mod.CoupledOperatingPoint, + geometry.airplane.Airplane, + ], + tuple[np.ndarray, np.ndarray], + ] + | None + ) = None, + extra_xml: dict[str, str] | None = None, + mujoco_assets: dict[str, bytes] | None = None, + ) -> None: + """The initialization method. + + :param coupled_movement: The CoupledMovement that contains this + CoupledUnsteadyProblem's CoupledOperatingPoints and AirplaneMovements. + :param I_BP1_CgP1: An array-like object of numbers (ints or floats) with shape + (3,3) for the inertia matrix of the airplane represented by + coupled_movement's AirplaneMovement. It is in the first Airplane's body + axes, relative to the first Airplane's CG. It can be a tuple, list, or + ndarray. Values will be converted internally to floats. Its units are in + kilogram square meters. + :param external_forces_fn: A callable that computes additional forces and + moments to apply to the Airplane during the coupled simulation. It takes a + CoupledOperatingPoint and an Airplane and returns a tuple of two (3,) + ndarrays of floats: the additional force (in wind axes, in Newtons) and the + additional moment (in wind axes, relative to the first Airplane's CG, in + Newton meters). Setting this to None applies no additional forces. The + default is None. + :param extra_xml: A dict mapping injection point names to XML fragment strings + to inject into the MuJoCo model's XML. Supported keys are "default", + "asset", "visual", "worldbody", and "body". Setting this to None injects no + extra XML. The default is None. + :param mujoco_assets: A dict mapping virtual filenames to their binary contents + for the MuJoCo model. Setting this to None provides no extra assets. The + default is None. + :return: None + """ + if not isinstance(coupled_movement, movements.movement.CoupledMovement): + raise TypeError("coupled_movement must be a CoupledMovement.") + self._coupled_movement = coupled_movement + + I_BP1_CgP1 = _parameter_validation.m_by_n_number_arrayLike_return_float( + I_BP1_CgP1, "I_BP1_CgP1", 3, 3 + ) + if not np.allclose(I_BP1_CgP1, I_BP1_CgP1.T): + raise ValueError("I_BP1_CgP1 must be symmetric.") + self._I_BP1_CgP1 = I_BP1_CgP1 + self._I_BP1_CgP1.flags.writeable = False + + if external_forces_fn is not None and not callable(external_forces_fn): + raise TypeError("external_forces_fn must be callable or None.") + self._external_forces_fn = external_forces_fn + + self._num_steps: int = self._coupled_movement.num_steps + self._delta_time: float = self._coupled_movement.delta_time + + # Initialize empty lists to hold the loads and load coefficients experienced by + # each time step's Airplane. + self.forces_W: list[np.ndarray] = [] + self.forceCoefficients_W: list[np.ndarray] = [] + self.moments_W_Cg: list[np.ndarray] = [] + self.momentCoefficients_W_Cg: list[np.ndarray] = [] + + # Get the tuple representing the Airplane at each time step. + self._airplanes = self._coupled_movement.airplanes + + # Initialize a list with the first time step's CoupledSteadyProblem. The + # CoupledUnsteadyRingVortexLatticeMethodSolver will append each subsequent time + # step's CoupledSteadyProblem to this list. + self.coupled_steady_problems = [ + CoupledSteadyProblem( + airplane=self._airplanes[0], + coupled_operating_point=self._coupled_movement.coupled_operating_points[ + 0 + ], + ) + ] + + self._mujoco_model = _mujoco_model.MuJoCoModel( + coupled_movement=self._coupled_movement, + I_BP1_CgP1=self._I_BP1_CgP1, + extra_xml=extra_xml, + mujoco_assets=mujoco_assets, + ) + + # --- Immutable: read only properties --- + @property + def coupled_movement(self) -> movements.movement.CoupledMovement: + return self._coupled_movement + + @property + def I_BP1_CgP1(self) -> np.ndarray: + return self._I_BP1_CgP1 + + @property + def external_forces_fn( + self, + ) -> ( + Callable[ + [ + operating_point_mod.CoupledOperatingPoint, + geometry.airplane.Airplane, + ], + tuple[np.ndarray, np.ndarray], + ] + | None + ): + return self._external_forces_fn + + @property + def num_steps(self) -> int: + return self._num_steps + + @property + def delta_time(self) -> float: + return self._delta_time + + @property + def airplanes(self) -> tuple[geometry.airplane.Airplane, ...]: + return self._airplanes + + @property + def mujoco_model(self) -> _mujoco_model.MuJoCoModel: + return self._mujoco_model diff --git a/pterasoftware/unsteady_ring_vortex_lattice_method.py b/pterasoftware/unsteady_ring_vortex_lattice_method.py index 692d4c887..59086e332 100644 --- a/pterasoftware/unsteady_ring_vortex_lattice_method.py +++ b/pterasoftware/unsteady_ring_vortex_lattice_method.py @@ -21,6 +21,7 @@ from . import ( _aerodynamics_functions, + _core, _functions, _logging, _panel, @@ -127,14 +128,20 @@ class UnsteadyRingVortexLatticeMethodSolver: "ran", ) - def __init__(self, unsteady_problem: problems.UnsteadyProblem) -> None: + def __init__(self, unsteady_problem: _core.CoreUnsteadyProblem) -> None: """The initialization method. - :param unsteady_problem: The UnsteadyProblem to be solved. + :param unsteady_problem: The CoreUnsteadyProblem to be solved. This can be an + UnsteadyProblem (with pre-computed SteadyProblems) or a + CoupledUnsteadyProblem (with dynamically generated SteadyProblems). The + problem must expose a steady_problems attribute and a movement property. :return: None """ - if not isinstance(unsteady_problem, problems.UnsteadyProblem): - raise TypeError("unsteady_problem must be an UnsteadyProblem.") + if not isinstance(unsteady_problem, _core.CoreUnsteadyProblem): + raise TypeError( + "unsteady_problem must be a CoreUnsteadyProblem or one of its" + " subclasses." + ) self.unsteady_problem = unsteady_problem self._max_wake_rows = self.unsteady_problem.max_wake_rows @@ -1449,6 +1456,54 @@ def _calculate_loads(self) -> None: + unsteady_forces_GP1 ) + # Calculate the moments via the hook method. The base implementation + # computes moments in the first Airplane's geometry axes, relative to the + # first Airplane's CG. Subclasses can override the hook to compute + # additional moment representations (e.g., about strip leading edge points). + 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: np.ndarray, + frontLegForces_GP1: np.ndarray, + leftLegForces_GP1: np.ndarray, + backLegForces_GP1: np.ndarray, + unsteady_forces_GP1: np.ndarray, + ) -> np.ndarray: + """Computes moments (in the first Airplane's geometry axes, relative to the + first Airplane's CG) from the per-leg and unsteady forces on every Panel. + + Subclasses can override this method to compute additional moment representations + (e.g., about strip leading edge points) while still returning the CG-based + moments for the standard load processing pipeline. + + :param rightLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's right LineVortex + leg. + :param frontLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's front LineVortex + leg. + :param leftLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's left LineVortex leg. + :param backLegForces_GP1: An (N, 3) ndarray of floats representing the forces + (in the first Airplane's geometry axes) on each Panel's back LineVortex leg. + :param unsteady_forces_GP1: An (N, 3) ndarray of floats representing the + unsteady component of the force (in the first Airplane's geometry axes) on + each Panel, derived from the unsteady Bernoulli equation. + :return: An (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. @@ -1470,23 +1525,21 @@ def _calculate_loads(self) -> None: # 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. + # 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 = ( + return cast( + np.ndarray, rightLegMoments_GP1_CgP1 + frontLegMoments_GP1_CgP1 + leftLegMoments_GP1_CgP1 + backLegMoments_GP1_CgP1 - + unsteady_moments_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) -> None: """Updates the next time step's Airplanes' wakes. @@ -2084,8 +2137,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 - static = this_movement.static + static = self.unsteady_problem.movement.static # Initialize ndarrays to hold each Airplane's loads and load coefficients at # each of the time steps that calculated the loads. diff --git a/tests/unit/fixtures/problem_fixtures.py b/tests/unit/fixtures/problem_fixtures.py index 970be1d62..7e5d2895f 100644 --- a/tests/unit/fixtures/problem_fixtures.py +++ b/tests/unit/fixtures/problem_fixtures.py @@ -105,3 +105,39 @@ def make_multi_airplane_unsteady_problem_fixture(): ) return multi_airplane_unsteady_problem_fixture + + +def make_basic_coupled_unsteady_problem_fixture(): + """This method makes a fixture that is a CoupledUnsteadyProblem for general testing. + + :return basic_coupled_unsteady_problem_fixture: CoupledUnsteadyProblem + This is the CoupledUnsteadyProblem configured for general testing. + """ + # Create a basic Movement. + basic_movement = movement_fixtures.make_basic_movement_fixture() + + # Create the CoupledUnsteadyProblem. + basic_coupled_unsteady_problem_fixture = ps.problems.CoupledUnsteadyProblem( + movement=basic_movement, + only_final_results=False, + ) + + return basic_coupled_unsteady_problem_fixture + + +def make_static_coupled_unsteady_problem_fixture(): + """This method makes a fixture that is a CoupledUnsteadyProblem with static motion. + + :return static_coupled_unsteady_problem_fixture: CoupledUnsteadyProblem + This is the CoupledUnsteadyProblem with static motion. + """ + # Create a static Movement. + static_movement = movement_fixtures.make_static_movement_fixture() + + # Create the CoupledUnsteadyProblem. + static_coupled_unsteady_problem_fixture = ps.problems.CoupledUnsteadyProblem( + movement=static_movement, + only_final_results=False, + ) + + return static_coupled_unsteady_problem_fixture diff --git a/tests/unit/fixtures/solver_fixtures.py b/tests/unit/fixtures/solver_fixtures.py index 6e29283fc..f0452cad3 100644 --- a/tests/unit/fixtures/solver_fixtures.py +++ b/tests/unit/fixtures/solver_fixtures.py @@ -53,3 +53,21 @@ def make_unsteady_ring_solver_fixture(): ) return solver + + +def make_coupled_unsteady_ring_solver_fixture(): + """This method makes a fixture that is a + CoupledUnsteadyRingVortexLatticeMethodSolver for general testing. + + :return solver: CoupledUnsteadyRingVortexLatticeMethodSolver + This is the CoupledUnsteadyRingVortexLatticeMethodSolver fixture. + """ + coupled_unsteady_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_unsteady_problem + ) + + return solver diff --git a/tests/unit/test_coupled_unsteady_problem.py b/tests/unit/test_coupled_unsteady_problem.py new file mode 100644 index 000000000..039aaca09 --- /dev/null +++ b/tests/unit/test_coupled_unsteady_problem.py @@ -0,0 +1,244 @@ +"""This module contains classes to test CoupledUnsteadyProblems.""" + +import unittest + +import numpy as np + +import pterasoftware as ps +from tests.unit.fixtures import ( + movement_fixtures, + problem_fixtures, +) + + +class TestCoupledUnsteadyProblemInitialization(unittest.TestCase): + """This is a class with functions to test CoupledUnsteadyProblem initialization.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures once for all CoupledUnsteadyProblem tests.""" + cls.basic_coupled_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + cls.static_coupled_problem = ( + problem_fixtures.make_static_coupled_unsteady_problem_fixture() + ) + + def test_initialization_returns_correct_type(self): + """Test that CoupledUnsteadyProblem initialization returns the correct type.""" + self.assertIsInstance( + self.basic_coupled_problem, + ps.problems.CoupledUnsteadyProblem, + ) + + def test_is_subclass_of_core(self): + """Test that CoupledUnsteadyProblem is a subclass of CoreUnsteadyProblem.""" + self.assertIsInstance( + self.basic_coupled_problem, + ps._core.CoreUnsteadyProblem, + ) + + def test_movement_property_returns_core_movement(self): + """Test that the movement property returns a CoreMovement instance.""" + self.assertIsInstance( + self.basic_coupled_problem.movement, + ps._core.CoreMovement, + ) + + def test_num_steps_matches_movement(self): + """Test that num_steps matches the movement's num_steps.""" + movement = movement_fixtures.make_basic_movement_fixture() + self.assertEqual(self.basic_coupled_problem.num_steps, movement.num_steps) + + def test_delta_time_matches_movement(self): + """Test that delta_time matches the movement's delta_time.""" + movement = movement_fixtures.make_basic_movement_fixture() + self.assertAlmostEqual( + self.basic_coupled_problem.delta_time, + movement.delta_time, + places=10, + ) + + def test_initial_steady_problems_count(self): + """Test that initialization creates exactly one SteadyProblem for step 0.""" + self.assertEqual(len(self.basic_coupled_problem.steady_problems), 1) + + def test_initial_steady_problem_is_steady_problem_type(self): + """Test that the initial SteadyProblem is a SteadyProblem instance.""" + self.assertIsInstance( + self.basic_coupled_problem.steady_problems[0], + ps.problems.SteadyProblem, + ) + + def test_initial_steady_problem_has_airplanes(self): + """Test that the initial SteadyProblem's Airplanes are populated.""" + initial_problem = self.basic_coupled_problem.steady_problems[0] + self.assertGreater(len(initial_problem.airplanes), 0) + for airplane in initial_problem.airplanes: + self.assertIsInstance(airplane, ps.geometry.airplane.Airplane) + + def test_initial_steady_problem_has_operating_point(self): + """Test that the initial SteadyProblem has an OperatingPoint.""" + initial_problem = self.basic_coupled_problem.steady_problems[0] + self.assertIsInstance( + initial_problem.operating_point, + ps.operating_point.OperatingPoint, + ) + + def test_only_final_results_default_false(self): + """Test that only_final_results defaults to False.""" + self.assertFalse(self.basic_coupled_problem.only_final_results) + + def test_only_final_results_true(self): + """Test that only_final_results can be set to True.""" + movement = movement_fixtures.make_basic_movement_fixture() + problem = ps.problems.CoupledUnsteadyProblem( + movement=movement, + only_final_results=True, + ) + self.assertTrue(problem.only_final_results) + + def test_static_movement_first_averaging_step(self): + """Test that static motion sets first_averaging_step to num_steps - 1.""" + self.assertEqual( + self.static_coupled_problem.first_averaging_step, + self.static_coupled_problem.num_steps - 1, + ) + + +class TestCoupledUnsteadyProblemParameterValidation(unittest.TestCase): + """Tests for CoupledUnsteadyProblem parameter validation.""" + + def test_movement_must_be_core_movement(self): + """Test that movement parameter must be a CoreMovement or subclass.""" + with self.assertRaises(TypeError): + ps.problems.CoupledUnsteadyProblem(movement="not_a_movement") + + def test_movement_rejects_none(self): + """Test that movement parameter rejects None.""" + with self.assertRaises(TypeError): + ps.problems.CoupledUnsteadyProblem(movement=None) + + def test_movement_rejects_invalid_types(self): + """Test that movement parameter rejects various invalid types.""" + invalid_movements = [123, [1, 2, 3], {"key": "value"}] + for invalid in invalid_movements: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + ps.problems.CoupledUnsteadyProblem(movement=invalid) + + +class TestCoupledUnsteadyProblemGetSteadyProblem(unittest.TestCase): + """Tests for CoupledUnsteadyProblem.get_steady_problem.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.coupled_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + + def test_get_step_zero_returns_steady_problem(self): + """Test that get_steady_problem(0) returns a SteadyProblem.""" + result = self.coupled_problem.get_steady_problem(0) + self.assertIsInstance(result, ps.problems.SteadyProblem) + + def test_get_step_zero_returns_initial_problem(self): + """Test that get_steady_problem(0) returns the problem created at init.""" + result = self.coupled_problem.get_steady_problem(0) + initial = self.coupled_problem.steady_problems[0] + self.assertIs(result, initial) + + def test_negative_step_raises_value_error(self): + """Test that negative step index raises ValueError.""" + with self.assertRaises(ValueError): + self.coupled_problem.get_steady_problem(-1) + + def test_out_of_range_step_raises_value_error(self): + """Test that step index beyond initialized problems raises ValueError.""" + with self.assertRaises(ValueError): + self.coupled_problem.get_steady_problem(1) + + +class TestCoupledUnsteadyProblemInitializeNextProblem(unittest.TestCase): + """Tests for CoupledUnsteadyProblem.initialize_next_problem.""" + + def test_initialize_next_problem_appends_problem(self): + """Test that initialize_next_problem adds one SteadyProblem.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + initial_count = len(coupled_problem.steady_problems) + + # Create a minimal solver to pass as the argument. + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + coupled_problem.initialize_next_problem(solver) + self.assertEqual(len(coupled_problem.steady_problems), initial_count + 1) + + def test_initialize_next_problem_creates_valid_steady_problem(self): + """Test that the appended SteadyProblem has the correct structure.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + coupled_problem.initialize_next_problem(solver) + new_problem = coupled_problem.get_steady_problem(1) + self.assertIsInstance(new_problem, ps.problems.SteadyProblem) + self.assertGreater(len(new_problem.airplanes), 0) + self.assertIsInstance( + new_problem.operating_point, + ps.operating_point.OperatingPoint, + ) + + def test_initialize_multiple_steps(self): + """Test that calling initialize_next_problem repeatedly grows the list.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + num_steps_to_add = 3 + for _ in range(num_steps_to_add): + coupled_problem.initialize_next_problem(solver) + + self.assertEqual(len(coupled_problem.steady_problems), 1 + num_steps_to_add) + + def test_get_steady_problem_after_initialize(self): + """Test that get_steady_problem works for newly added steps.""" + coupled_problem = problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + solver = ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + coupled_problem + ) + + coupled_problem.initialize_next_problem(solver) + result = coupled_problem.get_steady_problem(1) + self.assertIsInstance(result, ps.problems.SteadyProblem) + + +class TestCoupledUnsteadyProblemImmutability(unittest.TestCase): + """Tests for CoupledUnsteadyProblem attribute immutability.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.coupled_problem = ( + problem_fixtures.make_basic_coupled_unsteady_problem_fixture() + ) + + def test_immutable_movement_property(self): + """Test that movement property is read only.""" + with self.assertRaises(AttributeError): + self.coupled_problem.movement = None + + def test_steady_problems_returns_tuple(self): + """Test that steady_problems returns a tuple, not a mutable list.""" + result = self.coupled_problem.steady_problems + self.assertIsInstance(result, tuple) + + def test_steady_problems_tuple_prevents_mutation(self): + """Test that the tuple returned by steady_problems cannot be appended to.""" + result = self.coupled_problem.steady_problems + with self.assertRaises(AttributeError): + result.append(None) diff --git a/tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py b/tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py new file mode 100644 index 000000000..e2881fcfc --- /dev/null +++ b/tests/unit/test_coupled_unsteady_ring_vortex_lattice_method.py @@ -0,0 +1,167 @@ +"""This module contains classes to test CoupledUnsteadyRingVortexLatticeMethodSolver.""" + +import unittest + +import numpy as np + +import pterasoftware as ps +from tests.unit.fixtures import ( + problem_fixtures, + solver_fixtures, +) + + +class TestCoupledSolverInitialization(unittest.TestCase): + """Tests for CoupledUnsteadyRingVortexLatticeMethodSolver initialization.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + + def test_returns_correct_type(self): + """Test that the solver is the expected type.""" + self.assertIsInstance( + self.solver, + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver, + ) + + def test_inherits_from_base_solver(self): + """Test that the coupled solver inherits from the base solver.""" + self.assertIsInstance( + self.solver, + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver, + ) + + def test_has_coupled_unsteady_problem(self): + """Test that the solver has the coupled_unsteady_problem attribute.""" + self.assertIsInstance( + self.solver.coupled_unsteady_problem, + ps.problems.CoupledUnsteadyProblem, + ) + + def test_unsteady_problem_equals_coupled_unsteady_problem(self): + """Test that unsteady_problem and coupled_unsteady_problem are the same.""" + self.assertIs( + self.solver.unsteady_problem, + self.solver.coupled_unsteady_problem, + ) + + def test_has_not_run(self): + """Test that the solver has not run after initialization.""" + self.assertFalse(self.solver.ran) + + def test_has_empty_steady_problems_data_storage(self): + """Test that steady_problems_data_storage starts empty.""" + self.assertEqual(len(self.solver.steady_problems_data_storage), 0) + + +class TestCoupledSolverParameterValidation(unittest.TestCase): + """Tests for CoupledUnsteadyRingVortexLatticeMethodSolver parameter validation.""" + + def test_rejects_regular_unsteady_problem(self): + """Test that a regular UnsteadyProblem is rejected.""" + unsteady_problem = problem_fixtures.make_basic_unsteady_problem_fixture() + with self.assertRaises(TypeError): + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + unsteady_problem + ) + + def test_rejects_none(self): + """Test that None is rejected.""" + with self.assertRaises(TypeError): + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + None + ) + + def test_rejects_invalid_types(self): + """Test that various invalid types are rejected.""" + invalid_inputs = ["string", 42, [1, 2, 3]] + for invalid in invalid_inputs: + with self.subTest(invalid=invalid): + with self.assertRaises(TypeError): + ps.coupled_unsteady_ring_vortex_lattice_method.CoupledUnsteadyRingVortexLatticeMethodSolver( + invalid + ) + + +class TestCoupledSolverGetSteadyProblemAt(unittest.TestCase): + """Tests for the _get_steady_problem_at override.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures.""" + cls.solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + + def test_dispatches_to_coupled_problem(self): + """Test that _get_steady_problem_at returns the same object as the coupled + problem's get_steady_problem.""" + from_solver = self.solver._get_steady_problem_at(0) + from_problem = self.solver.coupled_unsteady_problem.get_steady_problem(0) + self.assertIs(from_solver, from_problem) + + def test_returns_steady_problem(self): + """Test that _get_steady_problem_at returns a SteadyProblem.""" + result = self.solver._get_steady_problem_at(0) + self.assertIsInstance(result, ps.problems.SteadyProblem) + + +class TestCoupledSolverRun(unittest.TestCase): + """Tests for running the coupled solver.""" + + @classmethod + def setUpClass(cls): + """Set up test fixtures by running the solver. + + This runs the solver once because each run is expensive. + """ + cls.solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + cls.solver.run( + prescribed_wake=True, + calculate_streamlines=False, + show_progress=False, + ) + + def test_ran_is_true(self): + """Test that the ran flag is True after running.""" + self.assertTrue(self.solver.ran) + + def test_steady_problems_populated(self): + """Test that steady_problems is populated after running.""" + self.assertIsInstance(self.solver.steady_problems, tuple) + self.assertEqual(len(self.solver.steady_problems), self.solver.num_steps) + + def test_steady_problems_are_steady_problem_instances(self): + """Test that each element of steady_problems is a SteadyProblem.""" + for problem in self.solver.steady_problems: + self.assertIsInstance(problem, ps.problems.SteadyProblem) + + def test_produces_nonzero_forces(self): + """Test that the solver produces nonzero force coefficients.""" + for airplane in self.solver.current_airplanes: + force_norm = np.linalg.norm(airplane.forceCoefficients_W) + self.assertGreater(force_norm, 0.0) + + def test_coupled_problem_has_all_steps(self): + """Test that the coupled problem contains the correct number of + SteadyProblems.""" + self.assertEqual( + len(self.solver.coupled_unsteady_problem.steady_problems), + self.solver.num_steps, + ) + + +class TestCoupledSolverInitializeStepGeometry(unittest.TestCase): + """Tests for the initialize_step_geometry method.""" + + def test_rejects_negative_step(self): + """Test that a negative step is rejected.""" + solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + with self.assertRaises(Exception): + solver.initialize_step_geometry(-1) + + def test_rejects_step_beyond_range(self): + """Test that a step beyond num_steps is rejected.""" + solver = solver_fixtures.make_coupled_unsteady_ring_solver_fixture() + with self.assertRaises(Exception): + solver.initialize_step_geometry(solver.num_steps) From a1641368392793760622e1fd5cfb59c922b74ce0 Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 3 Apr 2026 14:11:01 -0400 Subject: [PATCH 2/3] delete temp files --- ..._coupled_unsteady_vortex_lattice_method.py | 638 ----- .../aeroelastic_problems.py | 1135 -------- ...oelastic_unsteady_vortex_lattice_method.py | 2394 ----------------- ..._coupled_unsteady_vortex_lattice_method.py | 2248 ---------------- .../free_flight_problems.py | 497 ---- 5 files changed, 6912 deletions(-) delete mode 100644 pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py delete mode 100644 pterasoftware/temporary_comparison_files/aeroelastic_problems.py delete mode 100644 pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py delete mode 100644 pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py delete mode 100644 pterasoftware/temporary_comparison_files/free_flight_problems.py diff --git a/pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py b/pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py deleted file mode 100644 index 304781984..000000000 --- a/pterasoftware/temporary_comparison_files/aeroelastic_coupled_unsteady_vortex_lattice_method.py +++ /dev/null @@ -1,638 +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] = [] - - # 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 - 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) - 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.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, - # 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) - # 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.stack_leading_edge_points: np.ndarray = np.empty(0, dtype=float) - - 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_vortex(current_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 - _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.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) - 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._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_vortex( - self.get_steady_problem_at(step + 1), - 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_vortex(self.get_steady_problem_at(0), 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 _load_calculation_moment_processing_hook( - self, - rightLegForces_GP1, - frontLegForces_GP1, - leftLegForces_GP1, - backLegForces_GP1, - unsteady_forces_GP1, - ) -> 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. - - 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. 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. - moments_GP1_CgP1 = super()._load_calculation_moment_processing_hook( - rightLegForces_GP1, - frontLegForces_GP1, - leftLegForces_GP1, - backLegForces_GP1, - unsteady_forces_GP1, - ) - - 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 - ) - - 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. - - 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 - """ - # 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 - ) - 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: - """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/temporary_comparison_files/aeroelastic_problems.py b/pterasoftware/temporary_comparison_files/aeroelastic_problems.py deleted file mode 100644 index c536a1a92..000000000 --- a/pterasoftware/temporary_comparison_files/aeroelastic_problems.py +++ /dev/null @@ -1,1135 +0,0 @@ -"""Contains the SteadyProblem and UnsteadyProblem classes. - -**Contains the following classes:** - -SteadyProblem: A class used to contain steady aerodynamics problems. - -UnsteadyProblem: A class used to contain unsteady aerodynamics problems. - -**Contains the following functions:** - -None -""" - -from __future__ import annotations - -import math -from typing import TYPE_CHECKING - -import matplotlib.pyplot as plt -import numpy as np -from scipy.integrate import solve_ivp - -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. - - **Contains the following methods:** - - reynolds_numbers: A tuple of Reynolds numbers, one for each Airplane in the - SteadyProblem. - """ - - __slots__ = ( - "_airplanes", - "_operating_point", - "_reynolds_numbers", - ) - - def __init__( - self, - airplanes: list[geometry.airplane.Airplane], - operating_point: operating_point_mod.OperatingPoint, - ) -> None: - """The initialization method. - - :param airplanes: The list of the Airplanes for this SteadyProblem. - :param operating_point: The OperatingPoint for this SteadyProblem. - :return: None - """ - # Validate and store immutable attributes. - if not isinstance(airplanes, list): - raise TypeError("airplanes must be a list.") - if len(airplanes) < 1: - raise ValueError("airplanes must have at least one element.") - for airplane in airplanes: - if not isinstance(airplane, geometry.airplane.Airplane): - raise TypeError("Every element in airplanes must be an Airplane.") - # Store as tuple to prevent external mutation via .append(), .pop(), etc. - self._airplanes: tuple[geometry.airplane.Airplane, ...] = tuple(airplanes) - - if not isinstance(operating_point, operating_point_mod.OperatingPoint): - raise TypeError("operating_point must be an OperatingPoint.") - self._operating_point = operating_point - - # Initialize the caches for the properties derived from the immutable - # attributes. - self._reynolds_numbers: tuple[float, ...] | None = None - - # Validate that the first Airplane has Cg_GP1_CgP1 set to zeros. - self._airplanes[0].validate_first_airplane_constraints() - - # Populate GP1_CgP1 coordinates for all Airplanes' Panels. This finds the - # Panels' positions in the first Airplane's geometry axes, relative to the - # first Airplane's CG based on their locally defined positions. - for airplane in self._airplanes: - # Compute the passive transformation matrix from this Airplane's local - # geometry axes, relative to its CG, to the first Airplane's geometry axes, - # relative to the first Airplane's CG. - T_pas_G_Cg_to_GP1_CgP1 = airplane.T_pas_G_Cg_to_GP1_CgP1 - - for wing in airplane.wings: - assert wing.panels is not None - - for panel in np.ravel(wing.panels): - panel.Frpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Frpp_G_Cg, has_point=True - ) - panel.Flpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Flpp_G_Cg, has_point=True - ) - panel.Blpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Blpp_G_Cg, has_point=True - ) - panel.Brpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Brpp_G_Cg, has_point=True - ) - - # --- Immutable: read only properties --- - @property - def airplanes(self) -> tuple[geometry.airplane.Airplane, ...]: - return self._airplanes - - @property - def operating_point(self) -> operating_point_mod.OperatingPoint: - return self._operating_point - - # --- Immutable derived: manual lazy caching --- - @property - def reynolds_numbers(self) -> tuple[float, ...]: - """A tuple of Reynolds numbers, one for each Airplane in the SteadyProblem. - - **Notes:** - - The Reynolds number is calculated as: Re = (V x L) / nu, where V is the - freestream speed, observed from the Earth frame (vCg__E from OperatingPoint, - m/s), L is the characteristic length (c_ref from Airplane, m), and nu is the - kinematic viscosity (nu from OperatingPoint, m^2/s). - - These Reynolds numbers only consider the freestream speed, not any apparent - velocity due to prescribed motion, so be careful interpreting it for cases where - this SteadyProblem corresponds to one time step in an UnsteadyProblem. - - :return: A tuple of Reynolds numbers, one for each Airplane. - """ - if self._reynolds_numbers is None: - v = self._operating_point.vCg__E - nu = self._operating_point.nu - - reynolds_list = [] - for airplane in self._airplanes: - c_ref = airplane.c_ref - assert c_ref is not None, "Airplane c_ref must be set to calculate Re" - re = (v * c_ref) / nu - reynolds_list.append(re) - - # Store as tuple to prevent external mutation. - self._reynolds_numbers = tuple(reynolds_list) - return self._reynolds_numbers - - -class UnsteadyProblem: - """A class used to contain unsteady aerodynamics problems. - - **Contains the following methods:** - - None - """ - - __slots__ = ( - "_movement", - "_only_final_results", - "_num_steps", - "_delta_time", - "_max_wake_rows", - "_first_averaging_step", - "_first_results_step", - "finalForces_W", - "finalForceCoefficients_W", - "finalMoments_W_CgP1", - "finalMomentCoefficients_W_CgP1", - "finalMeanForces_W", - "finalMeanForceCoefficients_W", - "finalMeanMoments_W_CgP1", - "finalMeanMomentCoefficients_W_CgP1", - "finalRmsForces_W", - "finalRmsForceCoefficients_W", - "finalRmsMoments_W_CgP1", - "finalRmsMomentCoefficients_W_CgP1", - "_steady_problems", - ) - - def __init__( - self, - movement: movements.movement.Movement, - only_final_results: bool | np.bool_ = False, - ) -> None: - """The initialization method. - - :param movement: The Movement that contains this UnsteadyProblem's - OperatingPointMovement and AirplaneMovements. - :param only_final_results: Determines whether the Solver will only calculate - loads for the final time step (for static Movements) or (for non static - Movements) for will only calculate loads for the time steps in the final - complete motion cycle (of the Movement's sub Movement with the longest - period), which increases simulation speed. Can be a bool or a numpy bool and - will be converted internally to a bool. The default is False. - :return: None - """ - # Validate and store immutable attributes. - 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: int = self._movement.num_steps - self._delta_time: float = self._movement.delta_time - self._max_wake_rows: int | None = self._movement.max_wake_rows - - # For UnsteadyProblems with a static Movement, we 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) we are typically interested in the forces and moments averaged over the - # last cycle simulated. Use the LCM of all motion periods to ensure we average - # over a complete cycle of all motions. - _movement_lcm_period = self._movement.lcm_period - self._first_averaging_step: int - if _movement_lcm_period == 0: - self._first_averaging_step = self._num_steps - 1 - else: - self._first_averaging_step = max( - 0, - math.floor(self._num_steps - (_movement_lcm_period / self._delta_time)), - ) - - # If we 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. - self._first_results_step: int - 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. These are mutable and populated by the solver. - self.finalForces_W: list[np.ndarray] = [] - self.finalForceCoefficients_W: list[np.ndarray] = [] - self.finalMoments_W_CgP1: list[np.ndarray] = [] - self.finalMomentCoefficients_W_CgP1: list[np.ndarray] = [] - - # 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. These are mutable and populated by the - # solver. - self.finalMeanForces_W: list[np.ndarray] = [] - self.finalMeanForceCoefficients_W: list[np.ndarray] = [] - self.finalMeanMoments_W_CgP1: list[np.ndarray] = [] - self.finalMeanMomentCoefficients_W_CgP1: list[np.ndarray] = [] - - # 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. These are mutable and populated by - # the solver. - self.finalRmsForces_W: list[np.ndarray] = [] - self.finalRmsForceCoefficients_W: list[np.ndarray] = [] - self.finalRmsMoments_W_CgP1: list[np.ndarray] = [] - self.finalRmsMomentCoefficients_W_CgP1: list[np.ndarray] = [] - - # Initialize an empty list to hold the SteadyProblems as they are generated. - steady_problems_temp: list[SteadyProblem] = [] - - # 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 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 --- - @property - def movement(self) -> movements.movement.Movement: - return self._movement - - @property - def only_final_results(self) -> bool: - return self._only_final_results - - @property - def num_steps(self) -> int: - return self._num_steps - - @property - def delta_time(self) -> float: - return self._delta_time - - @property - def first_averaging_step(self) -> int: - return self._first_averaging_step - - @property - def first_results_step(self) -> int: - return self._first_results_step - - @property - def max_wake_rows(self) -> int | None: - return self._max_wake_rows - - @property - 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 coupled - simulations where each time step has its own SteadyProblem. - - **Contains the following methods:** - - get_steady_problem: Gets the SteadyProblem at a specified step. - initialize_next_problem: Initializes the next step's problem. - - **Contains the following class attributes:** - - None - """ - - def __init__( - self, - single_step_movement: movements.single_step.single_step_movement.SingleStepMovement, - only_final_results: bool | np.bool_ = False, - ) -> 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. - - :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, - ): - 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( - only_final_results, "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) - - # 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( - [movement.airplane_movements[0].base_airplane], - movement.operating_point_movement.base_operating_point, - ) - ] - - 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. - """ - if step >= len(self.coupled_steady_problems): - raise Exception( - f"Step index {step} is out of range of the number of initialized problems" - ) - return self.coupled_steady_problems[step] - - 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. - - :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: 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: 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: 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: 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 - self.moment_scaling_factor = moment_scaling_factor - self.spring_constant = spring_constant - self.damping_constant = damping_constant - self.aero_scaling = aero_scaling - 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: 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 - - 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: - 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. - 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. - - 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 - 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 - - def initialize_next_problem(self, solver): - - deformation_matrices = self.calculate_wing_deformation( - 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.coupled_steady_problems), - deformation_matrices=deformation_matrices, - ) - ) - self.coupled_steady_problems.append( - SteadyProblem( - airplanes=self.curr_airplanes, - operating_point=self.curr_operating_point, - ) - ) - - 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] - - # 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.size == 0: - self.net_deformation = np.zeros((num_spanwise_panels + 1, 3)) - self.angluar_velocities = np.zeros((num_spanwise_panels + 1, 3)) - - # Extract aerodynamic and inertial moments - aeroMoments_GP1_Slep = self._extract_aero_moments( - 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, - ) - - # Calculate spring moments and deformation via ODE integration - 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, - ) - - # 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, - ) - - # 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 - assert wing.panels is not None - 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 np.array(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( - [ - 0, - thetas[i + 1] * 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) - 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, - ) -> 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. - :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] - 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.size == 0: - 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 - ) - - # 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()) - - def _plot_aeroelastic_results(self) -> None: - """Generate and display time-history plots of aeroelastic results. - - 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", - ) - - # 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 (computed in half-span increments) - for span_panel in range(num_spanwise_panels): - aero_span_moment = np.sum(aero_moments[:, span_panel, 2]) - 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] - - 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 - 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( - 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=span_I, - theta0=theta0, - omega0=omega0, - aero_span_moment=aero_span_moment, - step=step, - span_I=span_I, - ) - d += W / 2 - 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: 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 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 (restoring force from structural springs/dampers) - spring_moment = -k * theta - c * omega - - return theta, omega, spring_moment - - 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. - """ - 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 - ) - else: - 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 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). - :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 float(aero_torque + inertial_torque_func(time)) - - def ode(time: float, y: np.ndarray) -> np.ndarray: - """ODE system: dθ/dt = ω, dω/dt = (τ - c*ω - k*θ)/I.""" - theta, omega = y - return np.array([omega, (tau(time) - c * omega - k * theta) / I]) - - sol = solve_ivp( - ode, - (t[0], t[-1]), - np.array([theta0, omega0]), - t_eval=t, - rtol=1e-9, - atol=1e-12, - ) - - theta = float(sol.y[0][-1]) - omega = float(sol.y[1][-1]) - - return theta, omega - - 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) - - 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() diff --git a/pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py b/pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py deleted file mode 100644 index 4a1458aa8..000000000 --- a/pterasoftware/temporary_comparison_files/aeroelastic_unsteady_vortex_lattice_method.py +++ /dev/null @@ -1,2394 +0,0 @@ -"""Contains the UnsteadyRingVortexLatticeMethodSolver class. - -**Contains the following classes:** - -UnsteadyRingVortexLatticeMethodSolver: A class used to solve UnsteadyProblems with the -unsteady ring vortex lattice method. - -**Contains the following functions:** - -None -""" - -from __future__ import annotations - -import logging -from collections.abc import Sequence -from typing import cast - -import numpy as np -from tqdm import tqdm - -from . import ( - _aerodynamics_functions, - _functions, - _logging, - _panel, - _parameter_validation, - _transformations, - _vortices, - geometry, - movements, - operating_point, - problems, -) - -_logger = _logging.get_logger("unsteady_ring_vortex_lattice_method") - - -# REFACTOR: Add unit tests for trapezoid-rule-based averages for the mean and RMS loads -# and load coefficients. -# 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 UnsteadyRingVortexLatticeMethodSolver: - """A class used to solve UnsteadyProblems with the unsteady ring vortex lattice - method. - - **Contains the following methods:** - - run: Runs the solver on the UnsteadyProblem. - - 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. - """ - - __slots__ = ( - "unsteady_problem", - "_max_wake_rows", - "num_steps", - "delta_time", - "first_results_step", - "_first_averaging_step", - "_current_step", - "_prescribed_wake", - "steady_problems", - "current_airplanes", - "current_operating_point", - "num_airplanes", - "num_panels", - "_currentVInf_GP1__E", - "_currentStackFreestreamWingInfluences__E", - "_currentGridWingWingInfluences__E", - "_currentStackWakeWingInfluences__E", - "_current_bound_vortex_strengths", - "_last_bound_vortex_strengths", - "panels", - "stackUnitNormals_GP1", - "panel_areas", - "stackCpp_GP1_CgP1", - "_stackLastCpp_GP1_CgP1", - "stackBrbrvp_GP1_CgP1", - "stackFrbrvp_GP1_CgP1", - "stackFlbrvp_GP1_CgP1", - "stackBlbrvp_GP1_CgP1", - "_lastStackBrbrvp_GP1_CgP1", - "_lastStackFrbrvp_GP1_CgP1", - "_lastStackFlbrvp_GP1_CgP1", - "_lastStackBlbrvp_GP1_CgP1", - "stackCblvpr_GP1_CgP1", - "stackCblvpf_GP1_CgP1", - "stackCblvpl_GP1_CgP1", - "stackCblvpb_GP1_CgP1", - "_lastStackCblvpr_GP1_CgP1", - "_lastStackCblvpf_GP1_CgP1", - "_lastStackCblvpl_GP1_CgP1", - "_lastStackCblvpb_GP1_CgP1", - "stackRbrv_GP1", - "stackFbrv_GP1", - "stackLbrv_GP1", - "stackBbrv_GP1", - "panel_is_trailing_edge", - "panel_is_leading_edge", - "panel_is_left_edge", - "panel_is_right_edge", - "_current_wake_vortex_strengths", - "_current_wake_vortex_ages", - "_currentStackBrwrvp_GP1_CgP1", - "_currentStackFrwrvp_GP1_CgP1", - "_currentStackFlwrvp_GP1_CgP1", - "_currentStackBlwrvp_GP1_CgP1", - "list_num_wake_vortices", - "_list_wake_vortex_strengths", - "_list_wake_vortex_ages", - "_list_wake_rc0s", - "listStackBrwrvp_GP1_CgP1", - "listStackFrwrvp_GP1_CgP1", - "listStackFlwrvp_GP1_CgP1", - "listStackBlwrvp_GP1_CgP1", - "_currentStackBoundRc0s", - "_currentStackWakeRc0s", - "stackSeedPoints_GP1_CgP1", - "gridStreamlinePoints_GP1_CgP1", - "ran", - ) - - def __init__(self, unsteady_problem: problems.UnsteadyProblem) -> None: - """The initialization method. - - :param unsteady_problem: The UnsteadyProblem to be solved. - :return: None - """ - if not isinstance(unsteady_problem, problems.UnsteadyProblem): - raise TypeError("unsteady_problem must be an UnsteadyProblem.") - self.unsteady_problem = unsteady_problem - - self._max_wake_rows = self.unsteady_problem.max_wake_rows - self.num_steps = self.unsteady_problem.num_steps - self.delta_time = self.unsteady_problem.delta_time - self.first_results_step = self.unsteady_problem.first_results_step - self._first_averaging_step = self.unsteady_problem.first_averaging_step - self._current_step: int = 0 - self._prescribed_wake: bool = True - - self.steady_problems = self.unsteady_problem.steady_problems - - first_steady_problem: problems.SteadyProblem = self.get_steady_problem_at(0) - - self.current_airplanes: tuple[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 - for airplane in first_steady_problem.airplanes: - num_panels += airplane.num_panels - 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) - - # 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._list_wake_rc0s: 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._currentStackBoundRc0s: np.ndarray = np.empty(0, dtype=float) - self._currentStackWakeRc0s: np.ndarray = np.empty(0, dtype=float) - - 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, - 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 - """ - 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" - ) - - # The following loop iterates through the time steps to populate currently - # empty attributes with lists of pre-allocated arrays. During the simulation, - # these arrays will be filled with data that describe the wake. Using this - # 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.get_steady_problem_at(step) - these_airplanes = this_problem.airplanes - - # Loop through this time step's Airplanes to gather their Wings. - these_wings: list[tuple[geometry.wing.Wing, ...]] = [] - for airplane in these_airplanes: - these_wings.append(airplane.wings) - - # 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 - - # 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. If wake truncation is enabled, cap the number of - # chordwise wake rows at max_wake_rows. - 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(1, self.num_steps): - 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 - # Wing Panels. - num_wing_panels = 0 - for airplane in these_airplanes: - num_wing_panels += airplane.num_panels - - # 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: - # Initialize all the Airplanes' bound RingVortices. - _logger.debug("Initializing all Airplanes' bound RingVortices.") - self._initialize_panel_vortices() - - # 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 - ) - 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 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._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. - _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. - 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.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() - - # 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 _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: 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 - 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. - - :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 - 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.get_steady_problem_at( - 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 = _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. - - 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: - _standard_mean_chord = wing.standard_mean_chord - assert _standard_mean_chord is not None - wing_r_c0 = 0.03 * _standard_mean_chord - - _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, - ) - self._currentStackBoundRc0s[global_panel_position] = wing_r_c0 - - # Increment the global Panel position variable. - global_panel_position += 1 - - # Iterate through the 1D ndarray of this Wing's wake RingVortices. - wake_ring_vortex: _vortices.ring_vortex.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 - self._currentStackWakeRc0s[global_wake_ring_vortex_position] = ( - wing_r_c0 - ) - - # 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.get_steady_problem_at(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). - - When an image surface is defined on the OperatingPoint, the influence - coefficients also include the contributions from image bound RingVortices - reflected across that surface. - - :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. - singularity_counts = np.zeros(4, dtype=np.int64) - gridNormVIndCpp_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackBoundRc0s, - singularity_counts=singularity_counts, - ages=None, - nu=self.current_operating_point.nu, - ) - ) - - # Add the image contribution if an image surface is defined. - surfaceReflect_T_act_GP1_CgP1 = ( - self.current_operating_point.surfaceReflect_T_act_GP1_CgP1 - ) - if surfaceReflect_T_act_GP1_CgP1 is not None: - stackReflectedCpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - surfaceReflect_T_act_GP1_CgP1, - self.stackCpp_GP1_CgP1, - has_point=True, - ) - gridImageVIndCpp_GP1__E = ( - _aerodynamics_functions.expanded_velocities_from_ring_vortices( - stackP_GP1_CgP1=stackReflectedCpp_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, - r_c0s=self._currentStackBoundRc0s, - singularity_counts=singularity_counts, - ages=None, - nu=self.current_operating_point.nu, - ) - ) - gridNormVIndCpp_GP1_E += _transformations.apply_T_to_vectors( - surfaceReflect_T_act_GP1_CgP1, - gridImageVIndCpp_GP1__E, - has_point=False, - ) - - unexpected_singularity_counts = np.copy(singularity_counts) - - _functions.log_unexpected_singularity_counts( - _logger, - logging.ERROR, - "_calculate_wing_wing_influences", - unexpected_singularity_counts, - ) - - # 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. - - When an image surface is defined on the OperatingPoint, the influence - coefficients also include the contributions from image wake RingVortices - reflected across that surface. - - **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. - singularity_counts = np.zeros(4, dtype=np.int64) - currentStackWakeV_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackWakeRc0s, - singularity_counts=singularity_counts, - ages=self._current_wake_vortex_ages, - nu=self.current_operating_point.nu, - ) - ) - - # Add the image contribution if an image surface is defined. - surfaceReflect_T_act_GP1_CgP1 = ( - self.current_operating_point.surfaceReflect_T_act_GP1_CgP1 - ) - if surfaceReflect_T_act_GP1_CgP1 is not None: - stackReflectedCpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - surfaceReflect_T_act_GP1_CgP1, - self.stackCpp_GP1_CgP1, - has_point=True, - ) - currentStackImageWakeV_GP1_E = ( - _aerodynamics_functions.collapsed_velocities_from_ring_vortices( - stackP_GP1_CgP1=stackReflectedCpp_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, - r_c0s=self._currentStackWakeRc0s, - singularity_counts=singularity_counts, - ages=self._current_wake_vortex_ages, - nu=self.current_operating_point.nu, - ) - ) - currentStackWakeV_GP1_E += _transformations.apply_T_to_vectors( - surfaceReflect_T_act_GP1_CgP1, - currentStackImageWakeV_GP1_E, - has_point=False, - ) - - unexpected_singularity_counts = np.copy(singularity_counts) - - _functions.log_unexpected_singularity_counts( - _logger, - logging.INFO, - "_calculate_wake_wing_influences", - unexpected_singularity_counts, - ) - - # 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.strength = self._current_bound_vortex_strengths[panel_num] - - def calculate_solution_velocity( - self, - stackP_GP1_CgP1: np.ndarray | Sequence[Sequence[float | int]], - bound_singularity_counts: np.ndarray | None = None, - wake_singularity_counts: np.ndarray | None = None, - ) -> np.ndarray: - """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. - - When an image surface is defined on the OperatingPoint, the returned velocity - also includes the induced velocity from image bound and wake RingVortices - reflected across that surface. - - **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. - :param bound_singularity_counts: An optional (4,) ndarray of int64 for - accumulating singularity event counts from bound RingVortices. If None, - counts are discarded. - :param wake_singularity_counts: An optional (4,) ndarray of int64 for - accumulating singularity event counts from wake RingVortices. If None, - counts are discarded. - :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" - ) - ) - - if bound_singularity_counts is None: - bound_singularity_counts = np.zeros(4, dtype=np.int64) - if wake_singularity_counts is None: - wake_singularity_counts = np.zeros(4, dtype=np.int64) - - stackBoundRingVInd_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackBoundRc0s, - singularity_counts=bound_singularity_counts, - ages=None, - nu=self.current_operating_point.nu, - ) - ) - stackWakeRingVInd_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackWakeRc0s, - singularity_counts=wake_singularity_counts, - ages=self._current_wake_vortex_ages, - nu=self.current_operating_point.nu, - ) - ) - - # Add the image contributions if an image surface is defined. - surfaceReflect_T_act_GP1_CgP1 = ( - self.current_operating_point.surfaceReflect_T_act_GP1_CgP1 - ) - if surfaceReflect_T_act_GP1_CgP1 is not None: - stackReflectedP_GP1_CgP1 = _transformations.apply_T_to_vectors( - surfaceReflect_T_act_GP1_CgP1, - stackP_GP1_CgP1, - has_point=True, - ) - stackImageBoundRingVInd_GP1_E = ( - _aerodynamics_functions.collapsed_velocities_from_ring_vortices( - stackP_GP1_CgP1=stackReflectedP_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, - r_c0s=self._currentStackBoundRc0s, - singularity_counts=bound_singularity_counts, - ages=None, - nu=self.current_operating_point.nu, - ) - ) - stackBoundRingVInd_GP1_E += _transformations.apply_T_to_vectors( - surfaceReflect_T_act_GP1_CgP1, - stackImageBoundRingVInd_GP1_E, - has_point=False, - ) - stackImageWakeRingVInd_GP1_E = ( - _aerodynamics_functions.collapsed_velocities_from_ring_vortices( - stackP_GP1_CgP1=stackReflectedP_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, - r_c0s=self._currentStackWakeRc0s, - singularity_counts=wake_singularity_counts, - ages=self._current_wake_vortex_ages, - nu=self.current_operating_point.nu, - ) - ) - stackWakeRingVInd_GP1_E += _transformations.apply_T_to_vectors( - surfaceReflect_T_act_GP1_CgP1, - stackImageWakeRingVInd_GP1_E, - has_point=False, - ) - - 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. - bound_singularity_counts = np.zeros(4, dtype=np.int64) - wake_singularity_counts = np.zeros(4, dtype=np.int64) - stackVelocityRightLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpr_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_right_leg_centers() - ) - stackVelocityFrontLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpf_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_front_leg_centers() - ) - stackVelocityLeftLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpl_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_left_leg_centers() - ) - stackVelocityBackLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpb_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_back_leg_centers() - ) - - unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) - unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) - - # Subtract the expected structural collinearity before logging. For each Wing - # with C chordwise and S spanwise Panels, the four leg center evaluations - # produce (8 * C * S - 2 * C - 2 * S) bound collinearity singularities from - # RingVortex self and adjacent shared edge pairs. When there is a wake (time - # step > 0), each trailing edge Panel's back leg center is also collinear with - # and on-filament for the first wake row's front leg, adding S wake collinearity - # singularities per Wing. - expected_bound_collinearity = 0 - expected_wake_collinearity = 0 - for airplane in self.current_airplanes: - for wing in airplane.wings: - num_chordwise = wing.num_chordwise_panels - num_spanwise = wing.num_spanwise_panels - assert num_spanwise is not None - n = num_chordwise * num_spanwise - expected_bound_collinearity += ( - 8 * n - 2 * num_chordwise - 2 * num_spanwise - ) - if self._current_step > 0: - expected_wake_collinearity += num_spanwise - unexpected_bound_singularity_counts[3] -= expected_bound_collinearity - unexpected_wake_singularity_counts[3] -= expected_wake_collinearity - _functions.log_unexpected_singularity_counts( - _logger, - logging.ERROR, - "_calculate_loads (bound)", - unexpected_bound_singularity_counts, - ) - _functions.log_unexpected_singularity_counts( - _logger, - logging.INFO, - "_calculate_loads (wake)", - unexpected_wake_singularity_counts, - ) - - # 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 - ) - - 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. - 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 - ) - - return np.array(moments_GP1_CgP1) - - 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: - bound_singularity_counts = np.zeros(4, dtype=np.int64) - wake_singularity_counts = np.zeros(4, dtype=np.int64) - - # Get the next time step's Airplanes. - 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. - 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), - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - - # 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), - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - ) - - # 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, - ) - ) - - # If wake truncation is enabled, discard the oldest (most - # downstream) rows of wake points. The point grid has one more - # row than the vortex grid because points form vertices and - # vortices form cells. - if ( - self._max_wake_rows is not None - and next_wing.gridWrvp_GP1_CgP1.shape[0] - > self._max_wake_rows + 1 - ): - next_wing.gridWrvp_GP1_CgP1 = next_wing.gridWrvp_GP1_CgP1[ - : self._max_wake_rows + 1 - ] - - unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) - unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) - - _functions.log_unexpected_singularity_counts( - _logger, - logging.DEBUG, - "_populate_next_airplanes_wake_vortex_points (bound)", - unexpected_bound_singularity_counts, - ) - _functions.log_unexpected_singularity_counts( - _logger, - logging.DEBUG, - "_populate_next_airplanes_wake_vortex_points (wake)", - unexpected_wake_singularity_counts, - ) - - 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.get_steady_problem_at(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 - - # If wake truncation is enabled, trim the oldest rows of - # wake RingVortices before adding the new row. This avoids - # creating RingVortex objects that would immediately be - # discarded. - if ( - self._max_wake_rows is not None - and this_wing_wake_ring_vortices.shape[0] >= self._max_wake_rows - ): - this_wing_wake_ring_vortices = this_wing_wake_ring_vortices[ - : self._max_wake_rows - 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 - ) - - # 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, create a - # new RingVortex with the convected position for - # the next time step. - next_wake_ring_vortices = ( - next_wing.wake_ring_vortices - ) - assert next_wake_ring_vortices is not None - old_wake_ring_vortex = cast( - _vortices.ring_vortex.RingVortex, - next_wake_ring_vortices[ - chordwise_point_id, spanwise_point_id - ], - ) - - # Compute the new age. - if self._current_step == 0: - new_age = self.delta_time - else: - new_age = ( - old_wake_ring_vortex.age + self.delta_time - ) - - # Create a new RingVortex with convected corners. - new_wake_ring_vortex = ( - _vortices.ring_vortex.RingVortex( - Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, - Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, - strength=old_wake_ring_vortex.strength, - ) - ) - new_wake_ring_vortex.age = new_age - - # Replace the old RingVortex in the array. - next_wake_ring_vortices[ - chordwise_point_id, spanwise_point_id - ] = new_wake_ring_vortex - - 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, - ] = _vortices.ring_vortex.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.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.get_steady_problem_at( - 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.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]) - self.unsteady_problem.finalForceCoefficients_W.append( - force_coefficients_W[airplane_id, :, -1] - ) - self.unsteady_problem.finalMoments_W_CgP1.append( - moments_W_CgP1[airplane_id, :, -1] - ) - self.unsteady_problem.finalMomentCoefficients_W_CgP1.append( - moment_coefficients_W_CgP1[airplane_id, :, -1] - ) - else: - # The number of intervals for the trapezoidal rule is one less - # than the number of samples. - num_intervals = num_steps_to_average - 1 - - self.unsteady_problem.finalMeanForces_W.append( - np.trapezoid(forces_W[airplane_id], axis=-1) / num_intervals - ) - self.unsteady_problem.finalMeanForceCoefficients_W.append( - np.trapezoid(force_coefficients_W[airplane_id], axis=-1) - / num_intervals - ) - self.unsteady_problem.finalMeanMoments_W_CgP1.append( - np.trapezoid(moments_W_CgP1[airplane_id], axis=-1) / num_intervals - ) - self.unsteady_problem.finalMeanMomentCoefficients_W_CgP1.append( - np.trapezoid(moment_coefficients_W_CgP1[airplane_id], axis=-1) - / num_intervals - ) - - self.unsteady_problem.finalRmsForces_W.append( - np.sqrt( - np.trapezoid( - np.square(forces_W[airplane_id]), - axis=-1, - ) - / num_intervals - ) - ) - self.unsteady_problem.finalRmsForceCoefficients_W.append( - np.sqrt( - np.trapezoid( - np.square(force_coefficients_W[airplane_id]), - axis=-1, - ) - / num_intervals - ) - ) - self.unsteady_problem.finalRmsMoments_W_CgP1.append( - np.sqrt( - np.trapezoid( - np.square(moments_W_CgP1[airplane_id]), - axis=-1, - ) - / num_intervals - ) - ) - self.unsteady_problem.finalRmsMomentCoefficients_W_CgP1.append( - np.sqrt( - np.trapezoid( - np.square(moment_coefficients_W_CgP1[airplane_id]), - axis=-1, - ) - / num_intervals - ) - ) - - 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 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. - :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] diff --git a/pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py b/pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py deleted file mode 100644 index b1f386225..000000000 --- a/pterasoftware/temporary_comparison_files/free_flight_coupled_unsteady_vortex_lattice_method.py +++ /dev/null @@ -1,2248 +0,0 @@ -"""Contains the CoupledUnsteadyRingVortexLatticeMethodSolver class. - -**Contains the following classes:** - -CoupledUnsteadyRingVortexLatticeMethodSolver: A class used to solve -CoupledUnsteadyProblems with the unsteady ring vortex lattice method. - -**Contains the following functions:** - -None -""" - -from __future__ import annotations - -import logging -from collections.abc import Sequence -from typing import cast - -import numpy as np -from tqdm import tqdm - -from . import ( - _aerodynamics_functions, - _functions, - _logging, - _panel, - _parameter_validation, - _transformations, - _vortices, - operating_point, - problems, -) - -_logger = _logging.get_logger("coupled_unsteady_ring_vortex_lattice_method") - - -# TEST: Add unit tests for this class's initialization. -# TEST: Add integration tests for this class. -class CoupledUnsteadyRingVortexLatticeMethodSolver: - """A class used to solve CoupledUnsteadyProblems with the unsteady ring vortex - lattice method. - - **Notes:** - - Currently, coupled simulations can only be run with a single airplane. However, this - class still refers to things like "the first Airplane's geometry axes" or uses - variable suffixes like "GP1" and "CGP1". This is purely for consistency with the - rest of the codebase. - - **Contains the following methods:** - - run: Runs the solver on the CoupledUnsteadyProblem. - - calculate_solution_velocity: Finds the fluid velocity (in first Airplane's geometry - axes, observed from the Earth frame) at one or more points (in 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: problems.CoupledUnsteadyProblem, - ) -> None: - """The initialization method. - - :param coupled_unsteady_problem: The CoupledUnsteadyProblem to be solved. - :return: None - """ - if not isinstance(coupled_unsteady_problem, problems.CoupledUnsteadyProblem): - raise TypeError( - "coupled_unsteady_problem must be a CoupledUnsteadyProblem." - ) - self.coupled_unsteady_problem: problems.CoupledUnsteadyProblem = ( - coupled_unsteady_problem - ) - - self.mujoco_model = self.coupled_unsteady_problem.mujoco_model - - self.num_steps = self.coupled_unsteady_problem.num_steps - self.delta_time = self.coupled_unsteady_problem.delta_time - self._current_step: int = 0 - self._prescribed_wake: bool = True - - self.coupled_steady_problems = ( - self.coupled_unsteady_problem.coupled_steady_problems - ) - - first_coupled_steady_problem = self.coupled_steady_problems[0] - - self.current_airplane = first_coupled_steady_problem.airplane - self.current_coupled_operating_point = ( - first_coupled_steady_problem.coupled_operating_point - ) - - self.num_panels = self.current_airplane.num_panels - - # Initialize attributes to hold aerodynamic data that pertain to the simulation. - self._currentVInf_GP1__E: np.ndarray = ( - first_coupled_steady_problem.coupled_operating_point.vInf_GP1__E - ) - self._currentOmegas_BP1__E: np.ndarray = ( - first_coupled_steady_problem.coupled_operating_point.omegas_BP1__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 - # CoupledUnsteadyProblem. - 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) - - # 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._list_wake_rc0s: 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._currentStackBoundRc0s: np.ndarray = np.empty(0, dtype=float) - self._currentStackWakeRc0s: np.ndarray = np.empty(0, dtype=float) - - # Initialize ndarrays to hold MuJoCo state history for visualization. - self.stackPosition_E_E = np.zeros((self.num_steps, 3), dtype=float) - self.stackR_pas_E_to_BP1 = np.zeros((self.num_steps, 3, 3), dtype=float) - - # Initialize attributes to hold state from MuJoCo for creating the next time - # step's CoupledSteadyProblem. - self._nextPosition_E_E: np.ndarray = np.empty(0, dtype=float) - self._nextR_pas_E_to_BP1: np.ndarray = np.empty(0, dtype=float) - self._nextVelocity_E__E: np.ndarray = np.empty(0, dtype=float) - self._nextOmegas_BP1__E: np.ndarray = np.empty(0, dtype=float) - - self.ran = False - - def run( - self, - prescribed_wake: 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 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" - ) - show_progress = _parameter_validation.boolLike_return_bool( - show_progress, "show_progress" - ) - - # REFACTOR: Before starting the loop, we should reset mujoco_model to the - # initial state, as defined by the CoupledOperatingPoint. - - # Store the initial state (before any MuJoCo time steps) for visualization. - # This ensures that stackPosition_E_E[i] and stackR_pas_E_to_BP1[i] contain - # the state at the beginning of time step i, matching airplanes[i]. - initial_state = self.mujoco_model.get_state() - self.stackPosition_E_E[0] = cast(np.ndarray, initial_state["position_E_E"]) - self.stackR_pas_E_to_BP1[0] = cast(np.ndarray, initial_state["R_pas_E_to_BP1"]) - - # Get the number of spanwise Panels and the total number of Wing Panels for - # this CoupledUnsteadyProblem's Airplane. - _num_wing_panels = self.coupled_steady_problems[0].airplane.num_panels - _num_spanwise_panels = 0 - - for _wing in self.coupled_steady_problems[0].airplane.wings: - _this_wing_num_spanwise_panels = _wing.num_spanwise_panels - assert _this_wing_num_spanwise_panels is not None - - _num_spanwise_panels += _this_wing_num_spanwise_panels - - # The following loop iterates through the time steps to populate currently - # empty attributes with lists of pre-allocated arrays. During the simulation, - # these arrays will be filled with data that describe the wake. Using this - # method eliminates the need for computationally expensive on-the-fly - # allocation and object copying. - 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 * _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._list_wake_rc0s.append(this_wake_rc0s) - 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) - - # 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. - approx_times = np.zeros(self.num_steps, dtype=float) - for step in range(self.num_steps): - # 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 - - # REFACTOR: Once the coupled solver is stable, tune these multipliers - # empirically make the progress bar smooth and accurate. - # The following constant multipliers were determined empirically. Thus - # far, they seem to provide for adequately smooth progress bar updating. - if step == 0: - approx_times[step] = num_ring_vortices * 70 - elif step == 1: - approx_times[step] = num_ring_vortices * 30 - else: - approx_times[step] = num_ring_vortices * 3 - - 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=not show_progress, - bar_format="{desc}:{percentage:3.0f}% |{bar}| Elapsed: {elapsed}, " - "Remaining: {remaining}", - ) as bar: - # Iterate through the time steps. - for step in range(self.num_steps): - # Save attributes to hold the current step, Airplane, OperatingPoint, - # and freestream velocity (in first Airplane's geometry axes, - # observed from the Earth frame). - self._current_step = step - current_coupled_steady_problem = self.coupled_steady_problems[ - self._current_step - ] - self.current_airplane = current_coupled_steady_problem.airplane - self.current_coupled_operating_point = ( - current_coupled_steady_problem.coupled_operating_point - ) - - self._currentVInf_GP1__E = ( - self.current_coupled_operating_point.vInf_GP1__E - ) - logging.info( - "Beginning time step " - + str(self._current_step) - + " out of " - + str(self.num_steps - 1) - + "." - ) - - logging.debug( - f" speed = {self.current_coupled_operating_point.vCg__E:.2e} m/s" - ) - logging.debug( - f" alpha = {self.current_coupled_operating_point.alpha:.2f} deg" - ) - logging.debug( - f" beta = {self.current_coupled_operating_point.beta:.2f} deg" - ) - _vCg_E__E = self.current_coupled_operating_point.vCg_E__E - logging.debug( - f" vCg_E__E = [{_vCg_E__E[0]:.2e}, {_vCg_E__E[1]:.2e}, " - f"{_vCg_E__E[2]:.2e}] m/s" - ) - - # 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_coupled_operating_point.vInf_GP1__E - ) - self._currentOmegas_BP1__E = ( - self.current_coupled_operating_point.omegas_BP1__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 - # 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) - - # 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] - - # Initialize this time step's bound RingVortices. - # Only do this at the beginning for the first time step. For subsequent - # steps, RingVortices are initialized after creating the next problem. - if self._current_step == 0: - logging.info( - " Initializing this time step's bound RingVortices." - ) - self._initialize_panel_vortices(step=0) - - # 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 Airplane's geometry 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 and the net loads on each Airplane. - logging.info(" Calculating forces and moments.") - self._calculate_loads() - - # Convert the loads to use MuJoCo's proper axes and reference points, - # and then pass them to the MuJoCo model. - logging.info(" Passing loads to MuJoCo.") - self._pass_loads_to_mujoco() - - # Step the MuJoCo simulation forward by one time step. - logging.info(" Stepping the MuJoCo simulation.") - self.mujoco_model.step() - - logging.info(" Processing the new states from MuJoCo.") - self._process_new_states_from_mujoco() - - logging.info(" Create the next time step's CoupledSteadyProblem.") - self._create_next_coupled_steady_problem() - - # Initialize the next time step's bound RingVortices (if a next step - # was created). This must be done before calling _populate_next_wake, - # which accesses the next step's Panels' RingVortices. - if self._current_step < self.num_steps - 1: - logging.info( - " Initializing the next time step's bound RingVortices." - ) - self._initialize_panel_vortices(self._current_step + 1) - - # Shed RingVortices into the wake. - logging.info( - " Shedding RingVortices into the wake of the next time step's " - "CoupledSteadyProblem." - ) - self._populate_next_airplanes_wake() - - # Update the progress bar based on this time step's predicted - # approximate, relative computing time. - bar.update(n=float(approx_times[step])) - - self.ran = True - - def _initialize_panel_vortices(self, step: int) -> None: - """Calculates the locations of the bound RingVortex vertices for a particular - time step, 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. - - :param step: The time step number. - :return: None - """ - this_coupled_steady_problem = self.coupled_steady_problems[step] - - this_airplane = this_coupled_steady_problem.airplane - thisVInf_GP1__E = ( - this_coupled_steady_problem.coupled_operating_point.vInf_GP1__E - ) - - # Iterate through the current SteadyProblem's Airplane's Wings. - for wing_id, wing in enumerate(this_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 step == 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 - + thisVInf_GP1__E * self.delta_time * 0.25 - ) - Brrvp_GP1_CgP1 = ( - _Brpp_GP1_CgP1 - + thisVInf_GP1__E * self.delta_time * 0.25 - ) - else: - last_coupled_steady_problem = self.coupled_steady_problems[ - step - 1 - ] - last_airplane = last_coupled_steady_problem.airplane - 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 thisVInf_GP1__E, because we want the - # apparent fluid velocity due to motion (in first Airplane's - # geometry axes, observed from the Earth frame). This is the - # vector pointing opposite the velocity from motion. - Blrvp_GP1_CgP1 = ( - _thisBlpp_GP1_CgP1 - + ( - thisVInf_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 - + ( - thisVInf_GP1__E - - (_thisBrpp_GP1_CgP1 - _lastBrpp_GP1_CgP1) - / self.delta_time - ) - * self.delta_time - * 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, - ) - - def _collapse_geometry(self) -> None: - """Converts attributes of the CoupledUnsteadyProblem'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 Airplane's Wings. - for wing in self.current_airplane.wings: - _standard_mean_chord = wing.standard_mean_chord - assert _standard_mean_chord is not None - wing_r_c0 = 0.03 * _standard_mean_chord - - _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, - ) - self._currentStackBoundRc0s[global_panel_position] = wing_r_c0 - - # Increment the global Panel position variable. - global_panel_position += 1 - - # Iterate through the 1D ndarray of this Wing's wake RingVortices. - wake_ring_vortex: _vortices.ring_vortex.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 - self._currentStackWakeRc0s[global_wake_ring_vortex_position] = wing_r_c0 - - # 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_coupled_steady_problem: problems.CoupledSteadyProblem = ( - self.coupled_steady_problems[self._current_step - 1] - ) - last_airplane = last_coupled_steady_problem.airplane - - # Iterate through the last time step's Airplane's 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. - 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. - singularity_counts = np.zeros(4, dtype=np.int64) - gridNormVIndCpp_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackBoundRc0s, - singularity_counts=singularity_counts, - ages=None, - nu=self.current_coupled_operating_point.nu, - ) - ) - - unexpected_singularity_counts = np.copy(singularity_counts) - - _functions.log_unexpected_singularity_counts( - _logger, - logging.ERROR, - "_calculate_wing_wing_influences", - unexpected_singularity_counts, - ) - - # 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 - """ - # Transform angular velocity from the first Airplane's body axes to the first - # Airplane's geometry axes. - # - # Body and geometry axes differ by 180 degrees about Y: - # GP1_x = -BP1_x, GP1_y = BP1_y, GP1_z = -BP1_z - # This is equivalent to: R_pas_BP1_to_GP1 = [[-1, 0, 0], [0, 1, 0], [0, 0, -1]] - # For a free vector (angular velocity), applying this transformation: - # omegas_GP1__E = R_pas_BP1_to_GP1 @ omegas_BP1__E - # Which simplifies to negating the x and z components. - # REFACTOR: Consider making specific functions in _transformations.py for - # transformations between body and geometry axes. - omegas_GP1__E = self._currentOmegas_BP1__E * np.array([-1.0, 1.0, -1.0]) - - # Convert angular velocity from degrees per second to radians per second for the - # cross product calculation. - omegas_GP1__E_rad = np.deg2rad(omegas_GP1__E) - - # 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. Compute the total velocity at each - # collocation point (freestream + rotational). - # - # The velocity at a point due to rotation is: v = omega x r - # where r is the position vector from the rotation center (CG) to the point. - # This gives the velocity of the point in the rotating frame. - # The apparent wind velocity is opposite to this motion, hence the subtraction. - velocity_at_collocation_points_GP1__E = ( - self._currentVInf_GP1__E # Broadcasts from (3,) to (num_panels,3). - - np.cross(omegas_GP1__E_rad, self.stackCpp_GP1_CgP1) - ) - currentStackFreestreamOnlyWingInfluences__E = np.einsum( - "ij,ij->i", - self.stackUnitNormals_GP1, - velocity_at_collocation_points_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. - singularity_counts = np.zeros(4, dtype=np.int64) - currentStackWakeV_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackWakeRc0s, - singularity_counts=singularity_counts, - ages=self._current_wake_vortex_ages, - nu=self.current_coupled_operating_point.nu, - ) - ) - - unexpected_singularity_counts = np.copy(singularity_counts) - - _functions.log_unexpected_singularity_counts( - _logger, - logging.INFO, - "_calculate_wake_wing_influences", - unexpected_singularity_counts, - ) - - # 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.strength = self._current_bound_vortex_strengths[panel_num] - - def calculate_solution_velocity( - self, - stackP_GP1_CgP1: np.ndarray | Sequence[Sequence[float | int]], - bound_singularity_counts: np.ndarray | None = None, - wake_singularity_counts: np.ndarray | None = None, - ) -> np.ndarray: - """Finds the fluid velocity (in first Airplane's geometry axes, observed from - the Earth frame) at one or more points (in 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. - :param bound_singularity_counts: An optional (4,) ndarray of int64 for - accumulating singularity event counts from bound RingVortices. If None, - counts are discarded. - :param wake_singularity_counts: An optional (4,) ndarray of int64 for - accumulating singularity event counts from wake RingVortices. If None, - counts are discarded. - :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" - ) - ) - - if bound_singularity_counts is None: - bound_singularity_counts = np.zeros(4, dtype=np.int64) - if wake_singularity_counts is None: - wake_singularity_counts = np.zeros(4, dtype=np.int64) - - stackBoundRingVInd_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackBoundRc0s, - singularity_counts=bound_singularity_counts, - ages=None, - nu=self.current_coupled_operating_point.nu, - ) - ) - stackWakeRingVInd_GP1_E = ( - _aerodynamics_functions.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, - r_c0s=self._currentStackWakeRc0s, - singularity_counts=wake_singularity_counts, - ages=self._current_wake_vortex_ages, - nu=self.current_coupled_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 Airplane's Wings. - for wing in self.current_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. - bound_singularity_counts = np.zeros(4, dtype=np.int64) - wake_singularity_counts = np.zeros(4, dtype=np.int64) - stackVelocityRightLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpr_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_right_leg_centers() - ) - stackVelocityFrontLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpf_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_front_leg_centers() - ) - stackVelocityLeftLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpl_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_left_leg_centers() - ) - stackVelocityBackLineVortexCenters_GP1__E = ( - self.calculate_solution_velocity( - stackP_GP1_CgP1=self.stackCblvpb_GP1_CgP1, - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - + self._calculate_current_movement_velocities_at_back_leg_centers() - ) - - unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) - unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) - - # Subtract the expected structural collinearity before logging. For each Wing - # with C chordwise and S spanwise Panels, the four leg center evaluations - # produce (8 * C * S - 2 * C - 2 * S) bound collinearity singularities from - # RingVortex self and adjacent shared edge pairs. When there is a wake (time - # step > 0), each trailing edge Panel's back leg center is also collinear with - # and on filament for the first wake row's front leg, adding S wake collinearity - # singularities per Wing. - expected_bound_collinearity = 0 - expected_wake_collinearity = 0 - for wing in self.current_airplane.wings: - num_chordwise = wing.num_chordwise_panels - num_spanwise = wing.num_spanwise_panels - assert num_spanwise is not None - n = num_chordwise * num_spanwise - expected_bound_collinearity += 8 * n - 2 * num_chordwise - 2 * num_spanwise - if self._current_step > 0: - expected_wake_collinearity += num_spanwise - unexpected_bound_singularity_counts[3] -= expected_bound_collinearity - unexpected_wake_singularity_counts[3] -= expected_wake_collinearity - _functions.log_unexpected_singularity_counts( - _logger, - logging.ERROR, - "_calculate_loads (bound)", - unexpected_bound_singularity_counts, - ) - _functions.log_unexpected_singularity_counts( - _logger, - logging.INFO, - "_calculate_loads (wake)", - unexpected_wake_singularity_counts, - ) - - # 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_coupled_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_coupled_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_coupled_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_coupled_operating_point.rho - * np.expand_dims(effective_back_line_vortex_strengths, axis=1) - * _functions.numba_1d_explicit_cross( - stackVelocityBackLineVortexCenters_GP1__E, - self.stackBbrv_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_coupled_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 - ) - - # 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 - ) - - # 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 _pass_loads_to_mujoco(self) -> None: - """Passes external and aerodynamic loads to MuJoCo for rigid body dynamics - integration. - - **Notes:** - - The loads are specified in wind axes, then transformed to Earth axes for - MuJoCo's world coordinate system. - - The transformation chain is: W_CgP1 > GP1_CgP1 > BP1_CgP1 > E_CgP1. - - :return: None - """ - # Get the forces (in wind axes) on the Airplane. - forces_W = self.current_airplane.forces_W - assert forces_W is not None - - # Get the moments (in wind axes, relative to the first Airplane's CG) on the - # Airplane. - moments_W_CgP1 = self.current_airplane.moments_W_CgP1 - assert moments_W_CgP1 is not None - - # Add the external wind x-axis force, specified in the CoupledOperatingPoint. - forces_W = forces_W + np.array( - [self.current_coupled_operating_point.externalFX_W, 0.0, 0.0], dtype=float - ) - - # If an external forces function is provided, call it and add the additional - # forces and moments (in wind axes). - external_forces_fn = self.coupled_unsteady_problem.external_forces_fn - if external_forces_fn is not None: - extra_forces_W, extra_moments_W_CgP1 = external_forces_fn( - self.current_coupled_operating_point, self.current_airplane - ) - forces_W = forces_W + extra_forces_W - moments_W_CgP1 = moments_W_CgP1 + extra_moments_W_CgP1 - - # Compose the full transformation. - T_pas_W_CgP1_to_E_CgP1 = ( - self.current_coupled_operating_point.T_pas_W_CgP1_to_E_CgP1 - ) - - # Transform the force, which is a free vector, so has_point=False. - forces_E = _transformations.apply_T_to_vectors( - T_pas_W_CgP1_to_E_CgP1, forces_W, has_point=False - ) - - # Find the unit vector for the direction of gravitational acceleration (in - # Earth axes). - thisG_E = self.current_coupled_operating_point.g_E - thisUnitG_E = thisG_E / np.linalg.norm(thisG_E) - - # Add the force from weight (in Earth axes). - forces_E = forces_E + self.current_airplane.weight * thisUnitG_E - - # Transform the moment (about the first Airplane's CG). Both W_CgP1 and - # E_CgP1 are relative to the same point (first airplane's CG), - # so the transformation only rotates axes. However, we use has_point=True to - # match the existing codebase pattern for moments. - moments_E_CgP1 = _transformations.apply_T_to_vectors( - T_pas_W_CgP1_to_E_CgP1, moments_W_CgP1, has_point=True - ) - - # Apply the loads to the MuJoCo model, if this time step is beyond the - # prescribed portion of the simulation. - if ( - self._current_step - >= self.coupled_unsteady_problem.coupled_movement.prescribed_num_steps - ): - self.mujoco_model.apply_loads(forces_E, moments_E_CgP1) - - def _process_new_states_from_mujoco(self) -> None: - """Processes the updated state from MuJoCo and creates a new - CoupledOperatingPoint. - - **Notes:** - - Retrieves the updated position, orientation, velocity, and angular velocity from - MuJoCo after the dynamics integration step. It transforms the velocity from - Earth axes to the first Airplane's geometry axes, computes the new angle of - attack and sideslip angle, and creates a new CoupledOperatingPoint for the next - time step. - - The transformation chain for velocity is: E > BP1 > GP1. - - Stores the state for use by _create_next_coupled_steady_problem(). - - :return: None - """ - # Get the updated state from MuJoCo. - state = self.mujoco_model.get_state() - - # Store the state for use in _create_next_coupled_steady_problem. - self._nextPosition_E_E = cast(np.ndarray, state["position_E_E"]) - self._nextR_pas_E_to_BP1 = cast(np.ndarray, state["R_pas_E_to_BP1"]) - self._nextVelocity_E__E = cast(np.ndarray, state["velocity_E__E"]) - self._nextOmegas_BP1__E = cast(np.ndarray, state["omegas_BP1__E"]) - - # Store the state in history ndarrays for visualization. The state after MuJoCo - # steps belongs to the next time step (current_step + 1), since this is the - # state at the beginning of that time step. We only store if there is a next - # time step. The initial state (for time step 0) is stored at the start of the - # run method. - if self._current_step < self.num_steps - 1: - self.stackPosition_E_E[self._current_step + 1] = ( - self._nextPosition_E_E.copy() - ) - self.stackR_pas_E_to_BP1[self._current_step + 1] = ( - self._nextR_pas_E_to_BP1.copy() - ) - - # REFACTOR: This method of extracting Euler angles from a rotation matrix should - # be converted into a function _transformations.py and then checked with unit - # tests. - # Extract Euler angles from the rotation matrix R_pas_E_to_BP1. - # For intrinsic z-y'-x" sequence (izyx), the passive rotation matrix is: - # R_pas = (Rz(angleZ) @ Ry(angleY) @ Rx(angleX)).T - # - # The matrix structure: - # R_pas[0,2] = -sin(angleY) - # R_pas[1,2] = cos(angleY) * sin(angleX) - # R_pas[2,2] = cos(angleY) * cos(angleX) - # R_pas[0,1] = sin(angleZ) * cos(angleY) - # R_pas[0,0] = cos(angleZ) * cos(angleY) - # - # Extraction formulas: - # angleY = asin(-R[0, 2]) - # angleX = atan2(R[1, 2], R[2, 2]) - # angleZ = atan2(R[0, 1], R[0, 0]) - # - # Gimbal lock occurs when angleY = +/- 90 degrees (cos(angleY) = 0). - R = self._nextR_pas_E_to_BP1 - # Extract pitch (angleY) with clamping to avoid numerical issues with asin. - sin_angleY = -R[0, 2] - sin_angleY = np.clip(sin_angleY, -1.0, 1.0) - angleY = np.rad2deg(np.arcsin(sin_angleY)) - # Check for gimbal lock (pitch near +/- 90 degrees). - if np.abs(sin_angleY) > 0.99999: - # Gimbal lock: set roll to zero and compute yaw from remaining elements. - # At gimbal lock, R[1,0] = -sin(angleZ) and R[1,1] = cos(angleZ). - angleX = 0.0 - angleZ = np.rad2deg(np.arctan2(-R[1, 0], R[1, 1])) - else: - # Normal case: extract roll and yaw. - angleX = np.rad2deg(np.arctan2(R[1, 2], R[2, 2])) - angleZ = np.rad2deg(np.arctan2(R[0, 1], R[0, 0])) - # Assemble the angles into the angles_E_to_BP1_izyx vector. - angles_E_to_BP1_izyx = np.array([angleX, angleY, angleZ], dtype=float) - - logging.debug(f" next angleX = {angleX:.2f} deg") - logging.debug(f" next angleY = {angleY:.2f} deg") - logging.debug(f" next angleZ = {angleZ:.2f} deg") - logging.debug( - f" next vCg_E__E = [{self._nextVelocity_E__E[0]:.2e}, " - f"{self._nextVelocity_E__E[1]:.2e}, {self._nextVelocity_E__E[2]:.2e}] m/s" - ) - - # Compute the speed (magnitude of velocity in Earth frame). - vCg__E = float(np.linalg.norm(self._nextVelocity_E__E)) - - # Compute the freestream velocity (in Earth axes, observed from Earth frame). - # Assuming still air, the apparent wind is opposite to the Airplane's motion. - vInf_E__E = -self._nextVelocity_E__E - - # Transform freestream velocity from Earth axes to the first Airplane's body - # axes. The rotation matrix R_pas_E_to_BP1 maps vectors from Earth to the - # first Airplane's body axes. - vInf_BP1__E = self._nextR_pas_E_to_BP1 @ vInf_E__E - - # REFACTOR: This method of extracting alpha and beta from vInf_BP1__E should be - # converted into a function _transformations.py and then checked with unit - # tests - # Compute alpha and beta from the freestream velocity in body axes. - # The freestream direction in body axes is used to determine the wind axes. - # Standard formulas: - # - alpha (angle of attack): angle between body x-axis and projection of - # freestream onto body xz-plane. - # - beta (sideslip angle): angle between freestream and body xz-plane. - u, v, w = vInf_BP1__E - alpha = np.rad2deg(np.arctan2(-w, -u)) - # Guard against numerical issues when computing beta. - v_normalized = v / (vCg__E + 1e-12) - v_normalized = np.clip(v_normalized, -1.0, 1.0) - beta = np.rad2deg(np.arcsin(v_normalized)) - - # Get the previous operating point's density and viscosity (these don't change). - rho = self.current_coupled_operating_point.rho - nu = self.current_coupled_operating_point.nu - externalFX_W = self.current_coupled_operating_point.externalFX_W - - # Create a new CoupledOperatingPoint with the updated state. - new_coupled_operating_point = operating_point.CoupledOperatingPoint( - rho=rho, - vCg__E=vCg__E, - omegas_BP1__E=self._nextOmegas_BP1__E, - angles_E_to_BP1_izyx=angles_E_to_BP1_izyx, - alpha=alpha, - beta=beta, - externalFX_W=externalFX_W, - nu=nu, - ) - - # Append the new CoupledOperatingPoint to the list. - self.coupled_unsteady_problem.coupled_movement.coupled_operating_points.append( - new_coupled_operating_point - ) - - def _create_next_coupled_steady_problem(self) -> None: - """Creates the next time step's CoupledSteadyProblem with updated geometry. - - **Notes:** - - Makes a new Airplane with the updated position and orientation from MuJoCo, but - preserves the Wing geometry from the prescribed motion (e.g., flapping). It then - creates a new CoupledSteadyProblem with the updated Airplane and the - CoupledOperatingPoint created in _process_new_states_from_mujoco(). - - The position and orientation are obtained from the state stored by - _process_new_states_from_mujoco(). - - :return: None - """ - # Check that we are not at the last time step. - if self._current_step >= self.num_steps - 1: - return - - # Get the pre-generated Airplane for the next time step from the - # CoupledMovement. This Airplane has the correct Wing geometry based on - # prescribed motion (e.g., flapping). - next_step_id = self._current_step + 1 - prescribed_airplane = self.coupled_unsteady_problem.coupled_movement.airplanes[ - next_step_id - ] - - # REFACTOR: Verify that reusing Wings from prescribed motion is correct. The - # relative geometry should be fine, but the wake RingVortices (stored in - # the Wings) have absolute coordinates in GP1_CgP1. Consider whether the - # wakes should be stored in the solver instead of in the Wings for coupled - # simulations, or whether wake positions need to be updated to account for - # the Airplane's new position and orientation. - # Create a deep copy of the prescribed Airplane with the correct CG position. - # This uses deep_copy_with_Cg_GP1_CgP1 to avoid re-processing wing symmetry - # (which would fail because the Wings are already meshed with immutable set - # once attributes). - updated_airplane = prescribed_airplane.deep_copy_with_Cg_GP1_CgP1( - np.array([0.0, 0.0, 0.0], dtype=float) - ) - - # Get the CoupledOperatingPoint that was created in - # _process_new_states_from_mujoco. - next_coupled_operating_point: operating_point.CoupledOperatingPoint = ( - self.coupled_unsteady_problem.coupled_movement.coupled_operating_points[ - next_step_id - ] - ) - - # Create a new CoupledSteadyProblem with the updated Airplane and - # CoupledOperatingPoint. - next_coupled_steady_problem = problems.CoupledSteadyProblem( - airplane=updated_airplane, - coupled_operating_point=next_coupled_operating_point, - ) - - # Append the new CoupledSteadyProblem to the list. - self.coupled_steady_problems.append(next_coupled_steady_problem) - - def _populate_next_airplanes_wake(self) -> None: - """Updates the next time step's Airplane's wake. - - :return: None - """ - # Populate the locations of the next time step's Airplane's wake RingVortex - # points. - self._populate_next_airplanes_wake_vortex_points() - - # Populate the locations of the next time step's Airplane's 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 Airplane's 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: - bound_singularity_counts = np.zeros(4, dtype=np.int64) - wake_singularity_counts = np.zeros(4, dtype=np.int64) - - # Get the next time step's Airplane. - next_problem = self.coupled_steady_problems[self._current_step + 1] - next_airplane = next_problem.airplane - - # 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_airplane - 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), - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - - # 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), - bound_singularity_counts=bound_singularity_counts, - wake_singularity_counts=wake_singularity_counts, - ) - ) - - # 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, - ) - ) - - unexpected_bound_singularity_counts = np.copy(bound_singularity_counts) - unexpected_wake_singularity_counts = np.copy(wake_singularity_counts) - - _functions.log_unexpected_singularity_counts( - _logger, - logging.DEBUG, - "_populate_next_airplanes_wake_vortex_points (bound)", - unexpected_bound_singularity_counts, - ) - _functions.log_unexpected_singularity_counts( - _logger, - logging.DEBUG, - "_populate_next_airplanes_wake_vortex_points (wake)", - unexpected_wake_singularity_counts, - ) - - 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_steady_problems[self._current_step + 1] - next_airplane = next_problem.airplane - - # Iterate through the next Airplane's predecessor's Wings. - for wing_id, this_wing in enumerate(self.current_airplane.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_airplane.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 - old_wake_ring_vortex = cast( - _vortices.ring_vortex.RingVortex, - next_wake_ring_vortices[ - chordwise_point_id, spanwise_point_id - ], - ) - - # Compute the updated age. - if self._current_step == 0: - new_age = self.delta_time - else: - new_age = old_wake_ring_vortex.age + self.delta_time - - # Replace with a new RingVortex at the updated - # position (RingVortex positions are immutable). - new_wake_ring_vortex = _vortices.ring_vortex.RingVortex( - Frrvp_GP1_CgP1=Frwrvp_GP1_CgP1, - Flrvp_GP1_CgP1=Flwrvp_GP1_CgP1, - Blrvp_GP1_CgP1=Blwrvp_GP1_CgP1, - Brrvp_GP1_CgP1=Brwrvp_GP1_CgP1, - strength=old_wake_ring_vortex.strength, - ) - new_wake_ring_vortex.age = new_age - next_wake_ring_vortices[ - chordwise_point_id, spanwise_point_id - ] = new_wake_ring_vortex - - 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, - ] = _vortices.ring_vortex.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, - ) diff --git a/pterasoftware/temporary_comparison_files/free_flight_problems.py b/pterasoftware/temporary_comparison_files/free_flight_problems.py deleted file mode 100644 index 5d76076a9..000000000 --- a/pterasoftware/temporary_comparison_files/free_flight_problems.py +++ /dev/null @@ -1,497 +0,0 @@ -"""Contains classes for aerodynamic problems. - -**Contains the following classes:** - -SteadyProblem: A class used to contain steady aerodynamics problems. - -UnsteadyProblem: A class used to contain unsteady aerodynamics problems. - -CoupledSteadyProblem: A class used to contain steady aerodynamics problems that -characterize each time step of a coupled unsteady simulation. - -CoupledUnsteadyProblem: A class used to contain unsteady aerodynamics problems that will -be used for coupled unsteady simulations. - -**Contains the following functions:** - -None -""" - -from __future__ import annotations - -import math -from collections.abc import Callable, Sequence - -import numpy as np - -from . import ( - _mujoco_model, - _parameter_validation, - _transformations, - geometry, - movements, -) -from . import operating_point as operating_point_mod - - -class SteadyProblem: - """A class used to contain steady aerodynamics problems. - - **Contains the following methods:** - - reynolds_numbers: A tuple of Reynolds numbers, one for each Airplane in the - SteadyProblem. - """ - - def __init__( - self, - airplanes: list[geometry.airplane.Airplane], - operating_point: operating_point_mod.OperatingPoint, - ) -> None: - """The initialization method. - - :param airplanes: The list of the Airplanes for this SteadyProblem. - :param operating_point: The OperatingPoint for this SteadyProblem. - :return: None - """ - # Validate and store immutable attributes. - if not isinstance(airplanes, list): - raise TypeError("airplanes must be a list.") - if len(airplanes) < 1: - raise ValueError("airplanes must have at least one element.") - for airplane in airplanes: - if not isinstance(airplane, geometry.airplane.Airplane): - raise TypeError("Every element in airplanes must be an Airplane.") - # Store as tuple to prevent external mutation via .append(), .pop(), etc. - self._airplanes: tuple[geometry.airplane.Airplane, ...] = tuple(airplanes) - - if not isinstance(operating_point, operating_point_mod.OperatingPoint): - raise TypeError("operating_point must be an OperatingPoint.") - self._operating_point = operating_point - - # Initialize the caches for the properties derived from the immutable - # attributes. - self._reynolds_numbers: tuple[float, ...] | None = None - - # Validate that the first Airplane has Cg_GP1_CgP1 set to zeros. - self._airplanes[0].validate_first_airplane_constraints() - - # Populate GP1_CgP1 coordinates for all Airplanes' Panels. This finds the - # Panels' positions in the first Airplane's geometry axes, relative to the - # first Airplane's CG based on their locally defined positions. - for airplane in self._airplanes: - # Compute the passive transformation matrix from this Airplane's local - # geometry axes, relative to its CG, to the first Airplane's geometry axes, - # relative to the first Airplane's CG. - T_pas_G_Cg_to_GP1_CgP1 = airplane.T_pas_G_Cg_to_GP1_CgP1 - - for wing in airplane.wings: - assert wing.panels is not None - - for panel in np.ravel(wing.panels): - panel.Frpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Frpp_G_Cg, has_point=True - ) - panel.Flpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Flpp_G_Cg, has_point=True - ) - panel.Blpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Blpp_G_Cg, has_point=True - ) - panel.Brpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Brpp_G_Cg, has_point=True - ) - - # --- Immutable: read only properties --- - @property - def airplanes(self) -> tuple[geometry.airplane.Airplane, ...]: - return self._airplanes - - @property - def operating_point(self) -> operating_point_mod.OperatingPoint: - return self._operating_point - - # --- Immutable derived: manual lazy caching --- - @property - def reynolds_numbers(self) -> tuple[float, ...]: - """A tuple of Reynolds numbers, one for each Airplane in the SteadyProblem. - - **Notes:** - - The Reynolds number is calculated as: Re = (V x L) / nu, where V is the - freestream speed, observed from the Earth frame (vCg__E from OperatingPoint, - m/s), L is the characteristic length (c_ref from Airplane, m), and nu is the - kinematic viscosity (nu from OperatingPoint, m^2/s). - - These Reynolds numbers only consider the freestream speed, not any apparent - velocity due to prescribed motion, so be careful interpreting it for cases where - this SteadyProblem corresponds to one time step in an UnsteadyProblem. - - :return: A tuple of Reynolds numbers, one for each Airplane. - """ - if self._reynolds_numbers is None: - v = self._operating_point.vCg__E - nu = self._operating_point.nu - - reynolds_list = [] - for airplane in self._airplanes: - c_ref = airplane.c_ref - assert c_ref is not None, "Airplane c_ref must be set to calculate Re" - re = (v * c_ref) / nu - reynolds_list.append(re) - - # Store as tuple to prevent external mutation. - self._reynolds_numbers = tuple(reynolds_list) - return self._reynolds_numbers - - -class UnsteadyProblem: - """A class used to contain unsteady aerodynamics problems. - - **Contains the following methods:** - - None - """ - - def __init__( - self, - movement: movements.movement.Movement, - only_final_results: bool | np.bool_ = False, - ) -> None: - """The initialization method. - - :param movement: The Movement that contains this UnsteadyProblem's - OperatingPointMovement and AirplaneMovements. - :param only_final_results: Determines whether the Solver will only calculate - loads for the final time step (for static Movements) or (for non static - Movements) for will only calculate loads for the time steps in the final - complete motion cycle (of the Movement's sub Movement with the longest - period), which increases simulation speed. Can be a bool or a numpy bool and - will be converted internally to a bool. The default is False. - :return: None - """ - # Validate and store immutable attributes. - 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: int = self._movement.num_steps - self._delta_time: float = self._movement.delta_time - self._max_wake_rows: int | None = self._movement.max_wake_rows - - # For UnsteadyProblems with a static Movement, we 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) we are typically interested in the forces and moments averaged over the - # last cycle simulated. Use the LCM of all motion periods to ensure we average - # over a complete cycle of all motions. - _movement_lcm_period = self._movement.lcm_period - self._first_averaging_step: int - if _movement_lcm_period == 0: - self._first_averaging_step = self._num_steps - 1 - else: - self._first_averaging_step = max( - 0, - math.floor(self._num_steps - (_movement_lcm_period / self._delta_time)), - ) - - # If we 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. - self._first_results_step: int - 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. These are mutable and populated by the solver. - self.finalForces_W: list[np.ndarray] = [] - self.finalForceCoefficients_W: list[np.ndarray] = [] - self.finalMoments_W_CgP1: list[np.ndarray] = [] - self.finalMomentCoefficients_W_CgP1: list[np.ndarray] = [] - - # 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. These are mutable and populated by the - # solver. - self.finalMeanForces_W: list[np.ndarray] = [] - self.finalMeanForceCoefficients_W: list[np.ndarray] = [] - self.finalMeanMoments_W_CgP1: list[np.ndarray] = [] - self.finalMeanMomentCoefficients_W_CgP1: list[np.ndarray] = [] - - # 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. These are mutable and populated by - # the solver. - self.finalRmsForces_W: list[np.ndarray] = [] - self.finalRmsForceCoefficients_W: list[np.ndarray] = [] - self.finalRmsMoments_W_CgP1: list[np.ndarray] = [] - self.finalRmsMomentCoefficients_W_CgP1: list[np.ndarray] = [] - - # Initialize an empty list to hold the SteadyProblems as they are generated. - steady_problems_temp: list[SteadyProblem] = [] - - # 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 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 --- - @property - def movement(self) -> movements.movement.Movement: - return self._movement - - @property - def only_final_results(self) -> bool: - return self._only_final_results - - @property - def num_steps(self) -> int: - return self._num_steps - - @property - def delta_time(self) -> float: - return self._delta_time - - @property - def first_averaging_step(self) -> int: - return self._first_averaging_step - - @property - def first_results_step(self) -> int: - return self._first_results_step - - @property - def max_wake_rows(self) -> int | None: - return self._max_wake_rows - - @property - def steady_problems(self) -> tuple[SteadyProblem, ...]: - return self._steady_problems - - -class CoupledSteadyProblem: - """A class used to contain steady aerodynamics problems that characterize each time - step of a coupled unsteady simulation. - - **Contains the following methods:** - - None - """ - - def __init__( - self, - airplane: geometry.airplane.Airplane, - coupled_operating_point: operating_point_mod.CoupledOperatingPoint, - ) -> None: - """The initialization method. - - :param airplane: The Airplane for this CoupledSteadyProblem. - :param coupled_operating_point: The CoupledOperatingPoint for this - CoupledSteadyProblem. - :return: None - """ - if not isinstance(airplane, geometry.airplane.Airplane): - raise TypeError("airplane must be an Airplane.") - self._airplane = airplane - - if not isinstance( - coupled_operating_point, operating_point_mod.CoupledOperatingPoint - ): - raise TypeError("coupled_operating_point must be a CoupledOperatingPoint.") - self._coupled_operating_point = coupled_operating_point - - # As CoupledSteadyProblems can only have one Airplane, they must have - # Cg_GP1_CgP1 set to zeros. - self._airplane.validate_first_airplane_constraints() - - # Populate the GP1_CgP1 coordinates for the Airplane's Panels. - T_pas_G_Cg_to_GP1_CgP1 = airplane.T_pas_G_Cg_to_GP1_CgP1 - for wing in airplane.wings: - _panels = wing.panels - assert _panels is not None - - for panel in np.ravel(_panels): - panel.Frpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Frpp_G_Cg, has_point=True - ) - panel.Flpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Flpp_G_Cg, has_point=True - ) - panel.Blpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Blpp_G_Cg, has_point=True - ) - panel.Brpp_GP1_CgP1 = _transformations.apply_T_to_vectors( - T_pas_G_Cg_to_GP1_CgP1, panel.Brpp_G_Cg, has_point=True - ) - - # --- Immutable: read only properties --- - @property - def airplane(self) -> geometry.airplane.Airplane: - return self._airplane - - @property - def coupled_operating_point(self) -> operating_point_mod.CoupledOperatingPoint: - return self._coupled_operating_point - - -class CoupledUnsteadyProblem: - """A class used to contain unsteady aerodynamics problems that will be used for - coupled unsteady simulations. - - **Contains the following methods:** - - None - """ - - def __init__( - self, - coupled_movement: movements.movement.CoupledMovement, - I_BP1_CgP1: np.ndarray | Sequence[Sequence[float | int]], - external_forces_fn: ( - Callable[ - [ - operating_point_mod.CoupledOperatingPoint, - geometry.airplane.Airplane, - ], - tuple[np.ndarray, np.ndarray], - ] - | None - ) = None, - extra_xml: dict[str, str] | None = None, - mujoco_assets: dict[str, bytes] | None = None, - ) -> None: - """The initialization method. - - :param coupled_movement: The CoupledMovement that contains this - CoupledUnsteadyProblem's CoupledOperatingPoints and AirplaneMovements. - :param I_BP1_CgP1: An array-like object of numbers (ints or floats) with shape - (3,3) for the inertia matrix of the airplane represented by - coupled_movement's AirplaneMovement. It is in the first Airplane's body - axes, relative to the first Airplane's CG. It can be a tuple, list, or - ndarray. Values will be converted internally to floats. Its units are in - kilogram square meters. - :param external_forces_fn: A callable that computes additional forces and - moments to apply to the Airplane during the coupled simulation. It takes a - CoupledOperatingPoint and an Airplane and returns a tuple of two (3,) - ndarrays of floats: the additional force (in wind axes, in Newtons) and the - additional moment (in wind axes, relative to the first Airplane's CG, in - Newton meters). Setting this to None applies no additional forces. The - default is None. - :param extra_xml: A dict mapping injection point names to XML fragment strings - to inject into the MuJoCo model's XML. Supported keys are "default", - "asset", "visual", "worldbody", and "body". Setting this to None injects no - extra XML. The default is None. - :param mujoco_assets: A dict mapping virtual filenames to their binary contents - for the MuJoCo model. Setting this to None provides no extra assets. The - default is None. - :return: None - """ - if not isinstance(coupled_movement, movements.movement.CoupledMovement): - raise TypeError("coupled_movement must be a CoupledMovement.") - self._coupled_movement = coupled_movement - - I_BP1_CgP1 = _parameter_validation.m_by_n_number_arrayLike_return_float( - I_BP1_CgP1, "I_BP1_CgP1", 3, 3 - ) - if not np.allclose(I_BP1_CgP1, I_BP1_CgP1.T): - raise ValueError("I_BP1_CgP1 must be symmetric.") - self._I_BP1_CgP1 = I_BP1_CgP1 - self._I_BP1_CgP1.flags.writeable = False - - if external_forces_fn is not None and not callable(external_forces_fn): - raise TypeError("external_forces_fn must be callable or None.") - self._external_forces_fn = external_forces_fn - - self._num_steps: int = self._coupled_movement.num_steps - self._delta_time: float = self._coupled_movement.delta_time - - # Initialize empty lists to hold the loads and load coefficients experienced by - # each time step's Airplane. - self.forces_W: list[np.ndarray] = [] - self.forceCoefficients_W: list[np.ndarray] = [] - self.moments_W_Cg: list[np.ndarray] = [] - self.momentCoefficients_W_Cg: list[np.ndarray] = [] - - # Get the tuple representing the Airplane at each time step. - self._airplanes = self._coupled_movement.airplanes - - # Initialize a list with the first time step's CoupledSteadyProblem. The - # CoupledUnsteadyRingVortexLatticeMethodSolver will append each subsequent time - # step's CoupledSteadyProblem to this list. - self.coupled_steady_problems = [ - CoupledSteadyProblem( - airplane=self._airplanes[0], - coupled_operating_point=self._coupled_movement.coupled_operating_points[ - 0 - ], - ) - ] - - self._mujoco_model = _mujoco_model.MuJoCoModel( - coupled_movement=self._coupled_movement, - I_BP1_CgP1=self._I_BP1_CgP1, - extra_xml=extra_xml, - mujoco_assets=mujoco_assets, - ) - - # --- Immutable: read only properties --- - @property - def coupled_movement(self) -> movements.movement.CoupledMovement: - return self._coupled_movement - - @property - def I_BP1_CgP1(self) -> np.ndarray: - return self._I_BP1_CgP1 - - @property - def external_forces_fn( - self, - ) -> ( - Callable[ - [ - operating_point_mod.CoupledOperatingPoint, - geometry.airplane.Airplane, - ], - tuple[np.ndarray, np.ndarray], - ] - | None - ): - return self._external_forces_fn - - @property - def num_steps(self) -> int: - return self._num_steps - - @property - def delta_time(self) -> float: - return self._delta_time - - @property - def airplanes(self) -> tuple[geometry.airplane.Airplane, ...]: - return self._airplanes - - @property - def mujoco_model(self) -> _mujoco_model.MuJoCoModel: - return self._mujoco_model From 037db55ced8e0b2878bd1ecf1cb97828716d973b Mon Sep 17 00:00:00 2001 From: JonahJ27 Date: Fri, 3 Apr 2026 16:35:36 -0400 Subject: [PATCH 3/3] fix mypy --- pterasoftware/_core.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pterasoftware/_core.py b/pterasoftware/_core.py index f10529625..181d7bede 100644 --- a/pterasoftware/_core.py +++ b/pterasoftware/_core.py @@ -5,9 +5,13 @@ import copy import math from collections.abc import Callable, Sequence +from typing import TYPE_CHECKING import numpy as np +if TYPE_CHECKING: + from . import problems as problems_mod + from . import _oscillation, _parameter_validation, _transformations, geometry from . import operating_point as operating_point_mod @@ -2361,3 +2365,11 @@ def first_results_step(self) -> int: @property def max_wake_rows(self) -> int | None: return self._max_wake_rows + + @property + def movement(self) -> CoreMovement: + raise NotImplementedError + + @property + def steady_problems(self) -> tuple[problems_mod.SteadyProblem, ...]: + raise NotImplementedError