"""Contains the Wing class.
**Contains the following classes:**
Wing: A class used to contain wings of an Airplane.
**Contains the following functions:**
None
"""
from __future__ import annotations
import copy
from collections.abc import Sequence
import numpy as np
import pyvista as pv
from .. import _panel, _parameter_validation, _transformations
from . import _meshing
from . import wing_cross_section as wing_cross_section_mod
[docs]
class Wing:
"""A class used to contain the wings of an Airplane.
**Contains the following methods:**
__deepcopy__: Creates a deep copy of this Wing, preserving mesh geometry but
resetting wake state.
T_pas_G_Cg_to_Wn_Ler: The passive transformation matrix which maps in homogeneous
coordinates from geometry axes relative to the CG to wing axes relative to the
leading edge root point. It is None if the Wing's symmetry type hasn't been defined
yet.
T_pas_Wn_Ler_to_G_Cg: The passive transformation matrix which maps in homogeneous
coordinates from wing axes relative to the leading edge root point to geometry axes
relative to the CG point. It is None if the Wing's symmetry type hasn't been defined
yet.
WnX_G: The wing axes' first basis vector (in geometry axes).
WnY_G: The wing axes' second basis vector (in geometry axes).
WnZ_G: The wing axes' third basis vector (in geometry axes).
children_T_pas_Wn_Ler_to_Wcs_Lp: A list of passive transformation matrices which map
in homogeneous coordinates from wing axes, relative to the leading edge root point,
to each of this Wing's WingCrossSection's axes, relative to their respective leading
points.
children_T_pas_Wcs_Lp_to_Wn_Ler: A list of passive transformation matrices which map
in homogeneous coordinates from each of this Wing's WingCrossSection's axes,
relative to their respective leading points, to wing axes, relative to the leading
edge root point.
children_T_pas_G_Cg_to_Wcs_Lp: A list of passive transformation matrices which map
in homogeneous coordinates from geometry axes, relative to the CG, to each of this
Wing's WingCrossSection's axes, relative to their respective leading points.
children_T_pas_Wcs_Lp_to_G_Cg: A list of passive transformation matrices which map
in homogeneous coordinates from each of this Wing's WingCrossSection's axes,
relative to their respective leading points, to geometry axes, relative to the CG.
symmetry_type: The symmetry type of this Wing.
num_spanwise_panels: The number of spanwise Panels on this Wing.
num_panels: The total number of Panels on this Wing.
panels: The 2D array of Panels on this Wing.
projected_area: The area of the Wing projected onto the plane defined by the wing
axes' xy plane.
wetted_area: The Wing's wetted area.
average_panel_aspect_ratio: The average aspect ratio of the Wing's Panels.
span: The Wing's span.
standard_mean_chord: The Wing's standard mean chord.
mean_aerodynamic_chord: The Wing's mean aerodynamic chord.
generate_mesh: Generates this Wing's mesh, which finishes the process of preparing
the Wing to be used in a simulation. It is called by the Wing's parent Airplane,
after it's determined its symmetry type.
get_plottable_data: Returns plottable data for this Wing's Airfoils' outlines and
mean camber lines.
**Notes:**
Immutable attributes (wing_cross_sections, name, Ler_Gs_Cgs, angles_Gs_to_Wn_ixyz,
num_chordwise_panels, and chordwise_spacing) are set during initialization and
cannot be modified afterward. The numpy arrays Ler_Gs_Cgs and angles_Gs_to_Wn_ixyz
are made read only to prevent in place mutation. The wing_cross_sections attribute
is stored as a tuple to prevent external mutation.
Derived transformation matrices and basis vectors (T_pas_G_Cg_to_Wn_Ler,
T_pas_Wn_Ler_to_G_Cg, WnX_G, WnY_G, WnZ_G, and the children_T_pas_* properties) are
lazily evaluated and cached. Derived geometric properties (projected_area,
wetted_area, average_panel_aspect_ratio, span, standard_mean_chord, and
mean_aerodynamic_chord) are also lazily evaluated and cached.
The symmetry_type, num_spanwise_panels, num_panels, and panels attributes are set
once by generate_mesh and cannot be modified after being set.
The symmetric, mirror_only, symmetryNormal_G, and symmetryPoint_G_Cg attributes
remain mutable as they may be modified by Airplane.process_wing_symmetry() for type
5 symmetry handling. The wake_ring_vortices and gridWrvp_GP1_CgP1 attributes are
mutable as they are modified during simulation.
Every Wing has its own axis system, known as wing axes. The user sets the
relationship between these axes and geometry axes with the Ler_Gs_Cgs and
angles_Gs_to_Wn_ixyz parameters. However, the steps for transforming a vector from
geometry axes to wing axes, and the interpretation of the wing axes orientation and
position relative to an Airplane's geometry axes, also depend on the parameters
symmetric, mirror_only, symmetryNormal_G, and symmetryPoint_G_Cg. In all cases, the
order of transformations from geometry axes to wing axes is reflection (if
applicable), translation, and then rotation.
There are five symmetry types. Type 1: symmetric=False, mirror_only=False, and the
symmetry plane must be undefined. Type 2: symmetric=False, mirror_only=True, and the
symmetry plane is coincident with the wing axes' xz plane. Type 3: symmetric=False,
mirror_only=True, and the symmetry plane is not coincident with the wing axes' xz
plane. Type 4: symmetric=True, mirror_only=False, and the symmetry plane is
coincident with the wing axes' xz plane. Type 5: symmetric=True, mirror_only=False,
and the symmetry plane is not coincident with the wing axes' xz plane.
**Citation:**
Adapted from: geometry.Wing in AeroSandbox
Author: Peter Sharpe
Date of retrieval: 04/24/2020
"""
__slots__ = (
# Immutable
"_wing_cross_sections",
"_name",
"_Ler_Gs_Cgs",
"_angles_Gs_to_Wn_ixyz",
"_num_chordwise_panels",
"_chordwise_spacing",
# Mutable (type 5 symmetry)
"symmetric",
"mirror_only",
"symmetryNormal_G",
"symmetryPoint_G_Cg",
# Set once
"_symmetry_type",
"_num_spanwise_panels",
"_num_panels",
"_panels",
# Mutable (wake)
"gridWrvp_GP1_CgP1",
"wake_ring_vortices",
# Caches from immutable
"_T_pas_G_Cg_to_Wn_Ler",
"_T_pas_Wn_Ler_to_G_Cg",
"_WnX_G",
"_WnY_G",
"_WnZ_G",
"_children_T_pas_Wn_Ler_to_Wcs_Lp",
"_children_T_pas_Wcs_Lp_to_Wn_Ler",
"_children_T_pas_G_Cg_to_Wcs_Lp",
"_children_T_pas_Wcs_Lp_to_G_Cg",
# Caches from set once
"_projected_area",
"_wetted_area",
"_average_panel_aspect_ratio",
"_span",
"_standard_mean_chord",
"_mean_aerodynamic_chord",
)
def __init__(
self,
wing_cross_sections: list[wing_cross_section_mod.WingCrossSection],
name: str = "Untitled Wing",
Ler_Gs_Cgs: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
angles_Gs_to_Wn_ixyz: np.ndarray | Sequence[float | int] = (0.0, 0.0, 0.0),
symmetric: bool | np.bool_ = False,
mirror_only: bool | np.bool_ = False,
symmetryNormal_G: None | np.ndarray | Sequence[float | int] = None,
symmetryPoint_G_Cg: None | np.ndarray | Sequence[float | int] = None,
num_chordwise_panels: int = 8,
chordwise_spacing: str = "cosine",
) -> None:
"""The initialization method.
:param wing_cross_sections: A list of WingCrossSections representing the wing's
cross sections in order from root to tip. It must contain at least two
WingCrossSections.
:param name: A sensible name for the Wing. The default is "Untitled Wing".
:param Ler_Gs_Cgs: An array-like object of 3 numbers (int or float) representing
the position of the origin of this Wing's axes (in geometry axes after
accounting for symmetry, relative to the CG after accounting for symmetry).
Can be a tuple, list, or ndarray. Values are converted to floats internally.
The units are meters. The default is (0.0, 0.0, 0.0).
:param angles_Gs_to_Wn_ixyz: An array-like object of 3 numbers (int or float)
representing the angle vector that defines the orientation of this Wing's
axes relative to the geometry axes (after accounting for symmetry). Can be a
tuple, list, or ndarray. Values are converted to floats internally. All
angles must be in the range [-90, 90] degrees. Rotations are intrinsic, and
proceed in the xy'z" order. The units are degrees. The default is (0.0, 0.0,
0.0).
:param symmetric: Set this to True if the Wing's geometry should be mirrored
across the symmetry plane while retaining the non mirrored side. If
mirror_only is True, symmetric must be False. If symmetric is True, then
neither symmetryNormal_G nor symmetryPoint_G_Cg can be None. If the symmetry
plane is coincident with this Wing's axes' xz plane, the mirrored and non
mirrored geometry will be meshed as a single wing. If not, this Wing's
Airplane will automatically create another Wing with the mirrored geometry,
modify both Wings' parameters, and add the reflected Wing to its list of
Wings immediately following this one. For more details on that process, and
how this parameter interacts with symmetryNormal_G, symmetryPoint_G_Cg, and
mirror_only, see the class docstring. Can be a bool or a numpy bool and will
be converted internally to a bool. The default is False.
:param mirror_only: Set this to True if the Wing's geometry should be reflected
about the symmetry plane without retaining the non reflected geometry. If
symmetric is True, mirror_only must be False. If mirror_only is True, then
neither symmetryNormal_G nor symmetryPoint_G_Cg can be None. For more
details on how this parameter interacts with symmetryNormal_G,
symmetryPoint_G_Cg, and symmetric, see the class docstring. Can be a bool or
a numpy bool and will be converted internally to a bool. The default is
False.
:param symmetryNormal_G: None, or an array-like of 3 numbers (int or float)
representing the unit normal vector (in geometry axes) that, together with
symmetryPoint_G_Cg, defines the plane used for symmetry or mirroring. Can be
None, or a tuple, list, or ndarray. If not None, values are converted to
floats and normalized internally. Note that reversing the normal direction
(using the antiparallel vector) defines the same plane and produces the same
result. This value must be None if both symmetric and mirror_only are False,
and cannot be None if either are True. For more details on how this
parameter interacts with symmetryPoint_G_Cg, symmetric, and mirror_only, see
the class docstring. The default is None.
:param symmetryPoint_G_Cg: None or an array-like object of 3 numbers (int or
float) representing a point (in geometry axes, relative to the CG) that,
along with symmetryNormal_G, defines the location of the plane about which
symmetry or mirroring is applied. Can be None, or a list, tuple, or ndarray.
If not None, values are converted to floats internally. This value must be
None if both symmetric and mirror_only are False, and cannot be None if
either are True. For more details on how this parameter interacts with
symmetryNormal_G, symmetric, and mirror_only, see the class docstring. The
units are meters. The default is None.
:param num_chordwise_panels: The number of chordwise panels to be used on this
Wing, which must be set to a positive integer. The default is 8.
:param chordwise_spacing: The type of spacing between the Wing's chordwise
panels. Can be "cosine" or "uniform". Using cosine spacing is highly
recommended for steady simulations and uniform spacing is highly recommended
for unsteady simulations. The default is "cosine".
:return: None
"""
# Validate wing_cross_sections.
wing_cross_sections = _parameter_validation.non_empty_list_return_list(
wing_cross_sections, "wing_cross_sections"
)
num_wing_cross_sections = len(wing_cross_sections)
if num_wing_cross_sections < 2:
raise ValueError("wing_cross_sections must contain at least two elements.")
for wing_cross_section_id, wing_cross_section in enumerate(wing_cross_sections):
if not isinstance(
wing_cross_section, wing_cross_section_mod.WingCrossSection
):
raise TypeError(
"Every element in wing_cross_sections must be a WingCrossSection."
)
if wing_cross_section_id == 0:
# Validate root WingCrossSection constraints.
wing_cross_section.validate_root_constraints()
elif wing_cross_section_id == num_wing_cross_sections - 1:
# Validate tip WingCrossSection constraints.
wing_cross_section.validate_tip_constraints()
else:
wing_cross_section.validate_mid_constraints()
# Set the validated flag for this WingCrossSection.
wing_cross_section.validated = True
# Store as tuple to prevent external mutation.
self._wing_cross_sections: tuple[
wing_cross_section_mod.WingCrossSection, ...
] = tuple(wing_cross_sections)
# Validate name and store as immutable.
self._name = _parameter_validation.str_return_str(name, "name")
# Validate Ler_Gs_Cgs, store as immutable, and make read-only.
self._Ler_Gs_Cgs = _parameter_validation.threeD_number_vectorLike_return_float(
Ler_Gs_Cgs, "Ler_Gs_Cgs"
)
self._Ler_Gs_Cgs.flags.writeable = False
# Validate angles_Gs_to_Wn_ixyz, store as immutable, and make read-only.
self._angles_Gs_to_Wn_ixyz = (
_parameter_validation.threeD_number_vectorLike_return_float(
angles_Gs_to_Wn_ixyz, "angles_Gs_to_Wn_ixyz"
)
)
if not np.all(
(-90.0 <= self._angles_Gs_to_Wn_ixyz) & (self._angles_Gs_to_Wn_ixyz <= 90.0)
):
raise ValueError(
"All elements of angles_Gs_to_Wn_ixyz must lie in the range [-90, "
"90] degrees."
)
self._angles_Gs_to_Wn_ixyz.flags.writeable = False
# Validate symmetric and mirror_only. These are mutable because
# Airplane.process_wing_symmetry modifies them for type 5 symmetry.
symmetric = _parameter_validation.boolLike_return_bool(symmetric, "symmetric")
mirror_only = _parameter_validation.boolLike_return_bool(
mirror_only, "mirror_only"
)
if symmetric and mirror_only:
raise ValueError("symmetric and mirror_only cannot both be True.")
self.symmetric = symmetric
self.mirror_only = mirror_only
# Validate symmetryNormal_G and symmetryPoint_G_Cg. These are mutable because
# Airplane.process_wing_symmetry modifies them for type 5 symmetry.
if self.symmetric or self.mirror_only:
if symmetryNormal_G is None:
raise ValueError(
"symmetryNormal_G cannot be None when symmetric or mirror_only is "
"True."
)
symmetryNormal_G = (
_parameter_validation.threeD_number_vectorLike_return_float_unit_vector(
symmetryNormal_G, "symmetryNormal_G"
)
)
if symmetryPoint_G_Cg is None:
raise ValueError(
"symmetryPoint_G_Cg cannot be None when symmetric or mirror_only "
"is True."
)
symmetryPoint_G_Cg = (
_parameter_validation.threeD_number_vectorLike_return_float(
symmetryPoint_G_Cg, "symmetryPoint_G_Cg"
)
)
else:
if symmetryNormal_G is not None:
raise ValueError(
"symmetryNormal_G must be None when both symmetric and "
"mirror_only are False."
)
if symmetryPoint_G_Cg is not None:
raise ValueError(
"symmetryPoint_G_Cg must be None when both symmetric and "
"mirror_only are False."
)
self.symmetryNormal_G = symmetryNormal_G
self.symmetryPoint_G_Cg = symmetryPoint_G_Cg
# Validate num_chordwise_panels and chordwise_spacing. Store as immutable.
self._num_chordwise_panels = _parameter_validation.int_in_range_return_int(
num_chordwise_panels,
"num_chordwise_panels",
min_val=1,
min_inclusive=True,
)
if chordwise_spacing not in ["cosine", "uniform"]:
raise ValueError('chordwise_spacing must be "cosine" or "uniform".')
self._chordwise_spacing = chordwise_spacing
# Set once attributes: will be initialized or populated once this Wing's parent
# Airplane calls generate_mesh.
self._symmetry_type: int | None = None
self._num_spanwise_panels: int | None = None
self._num_panels: int | None = None
self._panels: np.ndarray | None = None
# Mutable wake state.
self.gridWrvp_GP1_CgP1: np.ndarray | None = None
self.wake_ring_vortices: np.ndarray | None = None
# Caches for properties derived from immutable attributes. These are populated
# on first access and preserved in deepcopy.
self._T_pas_G_Cg_to_Wn_Ler: np.ndarray | None = None
self._T_pas_Wn_Ler_to_G_Cg: np.ndarray | None = None
self._WnX_G: np.ndarray | None = None
self._WnY_G: np.ndarray | None = None
self._WnZ_G: np.ndarray | None = None
self._children_T_pas_Wn_Ler_to_Wcs_Lp: list[np.ndarray] | None = None
self._children_T_pas_Wcs_Lp_to_Wn_Ler: list[np.ndarray] | None = None
self._children_T_pas_G_Cg_to_Wcs_Lp: list[np.ndarray] | None = None
self._children_T_pas_Wcs_Lp_to_G_Cg: list[np.ndarray] | None = None
# Caches for properties derived from set once attributes. These are populated
# on first access and reset to None in deepcopy.
self._projected_area: float | None = None
self._wetted_area: float | None = None
self._average_panel_aspect_ratio: float | None = None
self._span: float | None = None
self._standard_mean_chord: float | None = None
self._mean_aerodynamic_chord: float | None = None
# --- Deep copy method ---
def __deepcopy__(self, memo: dict) -> Wing:
"""Creates a deep copy of this Wing, preserving mesh geometry but resetting wake
state.
The copy preserves: (1) wing parameters (name, position, angles, symmetry
settings, panel counts), (2) WingCrossSections (deep copied), (3) mesh metadata
(symmetry_type, num_spanwise_panels, num_panels), (4) Panels array (each Panel
is deep copied), and (5) caches for properties derived from immutable attributes
(transformation matrices, basis vectors, children transformation matrices).
The copy resets: (1) wake state (wake_ring_vortices and gridWrvp_GP1_CgP1 are
reset to empty arrays with correct shape if meshed, or None if not meshed), and
(2) caches for properties derived from set once attributes (projected_area,
wetted_area, average_panel_aspect_ratio, span, standard_mean_chord,
mean_aerodynamic_chord).
:param memo: A dict used by the copy module to track already copied objects and
avoid infinite recursion.
:return: A new Wing with preserved mesh geometry and reset wake state.
"""
# Create a new Wing instance without calling __init__ to avoid redundant
# validation and meshing.
new_wing = object.__new__(Wing)
# Store this Wing in memo to handle potential circular references.
memo[id(self)] = new_wing
# Deep copy the WingCrossSections into a new tuple.
new_wing._wing_cross_sections = tuple(
copy.deepcopy(wing_cross_section, memo)
for wing_cross_section in self._wing_cross_sections
)
# Copy immutable Wing parameters (primitive types).
new_wing._name = self._name
new_wing._num_chordwise_panels = self._num_chordwise_panels
new_wing._chordwise_spacing = self._chordwise_spacing
# Copy mutable symmetry attributes (these may be modified by
# process_wing_symmetry for type 5 symmetry).
new_wing.symmetric = self.symmetric
new_wing.mirror_only = self.mirror_only
# Copy immutable numpy arrays and make them read-only.
new_wing._Ler_Gs_Cgs = np.copy(self._Ler_Gs_Cgs)
new_wing._Ler_Gs_Cgs.flags.writeable = False
new_wing._angles_Gs_to_Wn_ixyz = np.copy(self._angles_Gs_to_Wn_ixyz)
new_wing._angles_Gs_to_Wn_ixyz.flags.writeable = False
# Copy mutable symmetry arrays (may be None, may be modified by
# process_wing_symmetry).
new_wing.symmetryNormal_G = (
self.symmetryNormal_G.copy() if self.symmetryNormal_G is not None else None
)
new_wing.symmetryPoint_G_Cg = (
self.symmetryPoint_G_Cg.copy()
if self.symmetryPoint_G_Cg is not None
else None
)
# Copy set once mesh metadata directly to private attributes (bypassing
# setters) since we're copying, not setting for the first time.
new_wing._symmetry_type = self._symmetry_type
new_wing._num_spanwise_panels = self._num_spanwise_panels
new_wing._num_panels = self._num_panels
# Deep copy the Panels array if it exists (directly to private attribute).
if self._panels is not None:
new_wing._panels = np.empty_like(self._panels, dtype=object)
for i in range(self._panels.shape[0]):
for j in range(self._panels.shape[1]):
new_wing._panels[i, j] = copy.deepcopy(self._panels[i, j], memo)
else:
new_wing._panels = None
# Reset wake state to empty arrays with correct shape (if meshed).
if self._num_spanwise_panels is not None:
new_wing.wake_ring_vortices = np.zeros(
(0, self._num_spanwise_panels), dtype=object
)
new_wing.gridWrvp_GP1_CgP1 = np.empty(
(0, self._num_spanwise_panels + 1, 3), dtype=float
)
else:
new_wing.wake_ring_vortices = None
new_wing.gridWrvp_GP1_CgP1 = None
# Preserve caches for properties derived from immutable attributes.
# Copy numpy arrays and make them read-only.
if self._T_pas_G_Cg_to_Wn_Ler is not None:
new_wing._T_pas_G_Cg_to_Wn_Ler = self._T_pas_G_Cg_to_Wn_Ler.copy()
new_wing._T_pas_G_Cg_to_Wn_Ler.flags.writeable = False
else:
new_wing._T_pas_G_Cg_to_Wn_Ler = None
if self._T_pas_Wn_Ler_to_G_Cg is not None:
new_wing._T_pas_Wn_Ler_to_G_Cg = self._T_pas_Wn_Ler_to_G_Cg.copy()
new_wing._T_pas_Wn_Ler_to_G_Cg.flags.writeable = False
else:
new_wing._T_pas_Wn_Ler_to_G_Cg = None
if self._WnX_G is not None:
new_wing._WnX_G = self._WnX_G.copy()
new_wing._WnX_G.flags.writeable = False
else:
new_wing._WnX_G = None
if self._WnY_G is not None:
new_wing._WnY_G = self._WnY_G.copy()
new_wing._WnY_G.flags.writeable = False
else:
new_wing._WnY_G = None
if self._WnZ_G is not None:
new_wing._WnZ_G = self._WnZ_G.copy()
new_wing._WnZ_G.flags.writeable = False
else:
new_wing._WnZ_G = None
# Copy list caches (lists of numpy arrays).
if self._children_T_pas_Wn_Ler_to_Wcs_Lp is not None:
new_wing._children_T_pas_Wn_Ler_to_Wcs_Lp = []
for T in self._children_T_pas_Wn_Ler_to_Wcs_Lp:
T_copy = T.copy()
T_copy.flags.writeable = False
new_wing._children_T_pas_Wn_Ler_to_Wcs_Lp.append(T_copy)
else:
new_wing._children_T_pas_Wn_Ler_to_Wcs_Lp = None
if self._children_T_pas_Wcs_Lp_to_Wn_Ler is not None:
new_wing._children_T_pas_Wcs_Lp_to_Wn_Ler = []
for T in self._children_T_pas_Wcs_Lp_to_Wn_Ler:
T_copy = T.copy()
T_copy.flags.writeable = False
new_wing._children_T_pas_Wcs_Lp_to_Wn_Ler.append(T_copy)
else:
new_wing._children_T_pas_Wcs_Lp_to_Wn_Ler = None
if self._children_T_pas_G_Cg_to_Wcs_Lp is not None:
new_wing._children_T_pas_G_Cg_to_Wcs_Lp = []
for T in self._children_T_pas_G_Cg_to_Wcs_Lp:
T_copy = T.copy()
T_copy.flags.writeable = False
new_wing._children_T_pas_G_Cg_to_Wcs_Lp.append(T_copy)
else:
new_wing._children_T_pas_G_Cg_to_Wcs_Lp = None
if self._children_T_pas_Wcs_Lp_to_G_Cg is not None:
new_wing._children_T_pas_Wcs_Lp_to_G_Cg = []
for T in self._children_T_pas_Wcs_Lp_to_G_Cg:
T_copy = T.copy()
T_copy.flags.writeable = False
new_wing._children_T_pas_Wcs_Lp_to_G_Cg.append(T_copy)
else:
new_wing._children_T_pas_Wcs_Lp_to_G_Cg = None
# Reset caches for properties derived from set once attributes.
new_wing._projected_area = None
new_wing._wetted_area = None
new_wing._average_panel_aspect_ratio = None
new_wing._span = None
new_wing._standard_mean_chord = None
new_wing._mean_aerodynamic_chord = None
return new_wing
# --- Immutable: read only properties ---
@property
def wing_cross_sections(
self,
) -> tuple[wing_cross_section_mod.WingCrossSection, ...]:
return self._wing_cross_sections
@property
def name(self) -> str:
return self._name
@property
def Ler_Gs_Cgs(self) -> np.ndarray:
return self._Ler_Gs_Cgs
@property
def angles_Gs_to_Wn_ixyz(self) -> np.ndarray:
return self._angles_Gs_to_Wn_ixyz
@property
def num_chordwise_panels(self) -> int:
return self._num_chordwise_panels
@property
def chordwise_spacing(self) -> str:
return self._chordwise_spacing
# --- Immutable derived: manual lazy caching ---
@property
def T_pas_G_Cg_to_Wn_Ler(self) -> None | np.ndarray:
"""The passive transformation matrix which maps in homogeneous coordinates from
geometry axes relative to the CG to wing axes relative to the leading edge root
point. Is None if the Wing's symmetry type hasn't been defined yet.
:return: A (4,4) ndarray of floats representing the transformation matrix or
None if the Wing's symmetry type hasn't been defined yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self.symmetry_type is None:
return None
# Return cached value if available.
if self._T_pas_G_Cg_to_Wn_Ler is not None:
return self._T_pas_G_Cg_to_Wn_Ler
# Step 1: Create T_reflect_pas_G_Cg_to_Gs_Cgs, which maps from which maps in
# homogeneous coordinates from geometry axes relative to the CG to reflected
# geometry axes (after accounting for symmetry) relative to the CG (after
# accounting for symmetry). This is the reflection step. Only apply reflection
# for mirror-only Wings (types 2 and 3), not for symmetric Wings (type 4).
if self.symmetry_type in (2, 3):
assert self.symmetryPoint_G_Cg is not None
assert self.symmetryNormal_G is not None
T_reflect_pas_G_Cg_to_Gs_Cgs = _transformations.generate_reflect_T(
plane_point_A_a=self.symmetryPoint_G_Cg,
plane_normal_A=self.symmetryNormal_G,
passive=True,
)
else:
T_reflect_pas_G_Cg_to_Gs_Cgs = np.eye(4, dtype=float)
# Step 2: Create T_trans_pas_Gs_Cgs_to_Gs_Ler, which maps in homogeneous
# coordinates from geometry axes (after accounting for symmetry) relative to
# the CG (after accounting for symmetry) to geometry axes (after accounting
# for symmetry) relative to the leading edge root point. This is the
# translation step.
T_trans_pas_Gs_Cgs_to_Gs_Ler = _transformations.generate_trans_T(
self.Ler_Gs_Cgs, passive=True
)
# Step 3: Create T_rot_pas_Gs_to_Wn, which maps in homogeneous coordinates
# from geometry axes (after accounting for symmetry) to wing axes. This is
# the rotation step.
T_rot_pas_Gs_to_Wn = _transformations.generate_rot_T(
self.angles_Gs_to_Wn_ixyz, passive=True, intrinsic=True, order="xyz"
)
# Cache and return the result.
self._T_pas_G_Cg_to_Wn_Ler = _transformations.compose_T_pas(
T_reflect_pas_G_Cg_to_Gs_Cgs,
T_trans_pas_Gs_Cgs_to_Gs_Ler,
T_rot_pas_Gs_to_Wn,
)
self._T_pas_G_Cg_to_Wn_Ler.flags.writeable = False
return self._T_pas_G_Cg_to_Wn_Ler
@property
def T_pas_Wn_Ler_to_G_Cg(self) -> None | np.ndarray:
"""The passive transformation matrix which maps in homogeneous coordinates from
wing axes relative to the leading edge root point to geometry axes relative to
the CG. Is None if the Wing's symmetry type hasn't been defined yet.
:return: A (4,4) ndarray of floats representing the transformation matrix or
None if the Wing's symmetry type hasn't been defined yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self.symmetry_type is None:
return None
# Return cached value if available.
if self._T_pas_Wn_Ler_to_G_Cg is not None:
return self._T_pas_Wn_Ler_to_G_Cg
_T_pas_G_Cg_to_Wn_Ler = self.T_pas_G_Cg_to_Wn_Ler
assert _T_pas_G_Cg_to_Wn_Ler is not None
# Cache and return the result.
self._T_pas_Wn_Ler_to_G_Cg = _transformations.invert_T_pas(
_T_pas_G_Cg_to_Wn_Ler
)
self._T_pas_Wn_Ler_to_G_Cg.flags.writeable = False
return self._T_pas_Wn_Ler_to_G_Cg
@property
def WnX_G(self) -> None | np.ndarray:
"""The wing axes' first basis vector (in geometry axes).
:return: A (3,) ndarray of floats representing the wing axes' first basis vector
(in geometry axes) or None if the Wing's symmetry type hasn't been defined
yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self.symmetry_type is None:
return None
# Return cached value if available.
if self._WnX_G is not None:
return self._WnX_G
WnX_Wn = np.array([1.0, 0.0, 0.0])
_T_pas_Wn_Ler_to_G_Cg = self.T_pas_Wn_Ler_to_G_Cg
assert _T_pas_Wn_Ler_to_G_Cg is not None
# Cache and return the result.
self._WnX_G = _transformations.apply_T_to_vectors(
_T_pas_Wn_Ler_to_G_Cg, WnX_Wn, has_point=False
)
self._WnX_G.flags.writeable = False
return self._WnX_G
@property
def WnY_G(self) -> None | np.ndarray:
"""The wing axes' second basis vector (in geometry axes).
:return: A (3,) ndarray of floats representing the wing axes' second basis
vector (in geometry axes) or None if the Wing's symmetry type hasn't been
defined yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self.symmetry_type is None:
return None
# Return cached value if available.
if self._WnY_G is not None:
return self._WnY_G
WnY_Wn = np.array([0.0, 1.0, 0.0])
_T_pas_Wn_Ler_to_G_Cg = self.T_pas_Wn_Ler_to_G_Cg
assert _T_pas_Wn_Ler_to_G_Cg is not None
# Cache and return the result.
self._WnY_G = _transformations.apply_T_to_vectors(
_T_pas_Wn_Ler_to_G_Cg, WnY_Wn, has_point=False
)
self._WnY_G.flags.writeable = False
return self._WnY_G
@property
def WnZ_G(self) -> None | np.ndarray:
"""The wing axes' third basis vector (in geometry axes).
:return: A (3,) ndarray of floats representing the wing axes' third basis vector
(in geometry axes) or None if the Wing's symmetry type hasn't been defined
yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self.symmetry_type is None:
return None
# Return cached value if available.
if self._WnZ_G is not None:
return self._WnZ_G
WnZ_Wn = np.array([0.0, 0.0, 1.0])
_T_pas_Wn_Ler_to_G_Cg = self.T_pas_Wn_Ler_to_G_Cg
assert _T_pas_Wn_Ler_to_G_Cg is not None
# Cache and return the result.
self._WnZ_G = _transformations.apply_T_to_vectors(
_T_pas_Wn_Ler_to_G_Cg, WnZ_Wn, has_point=False
)
self._WnZ_G.flags.writeable = False
return self._WnZ_G
@property
def children_T_pas_Wn_Ler_to_Wcs_Lp(self) -> list[np.ndarray]:
"""A list of passive transformation matrices which map in homogeneous
coordinates from wing axes, relative to the leading edge root point, to each of
this Wing's WingCrossSection's axes, relative to their respective leading
points.
:return: A list of (4,4) ndarrays of floats representing the homogeneous
transformation matrices.
"""
# Return cached value if available.
if self._children_T_pas_Wn_Ler_to_Wcs_Lp is not None:
return self._children_T_pas_Wn_Ler_to_Wcs_Lp
# Compute, cache, and return the result.
result = []
for i in range(len(self.wing_cross_sections)):
T = _transformations.compose_T_pas(
*(
_assert_T_not_none(wing_cross_section.T_pas_Wcsp_Lpp_to_Wcs_Lp)
for wing_cross_section in self.wing_cross_sections[: i + 1]
)
)
T.flags.writeable = False
result.append(T)
self._children_T_pas_Wn_Ler_to_Wcs_Lp = result
return self._children_T_pas_Wn_Ler_to_Wcs_Lp
@property
def children_T_pas_Wcs_Lp_to_Wn_Ler(self) -> list[np.ndarray]:
"""A list of passive transformation matrices which map in homogeneous
coordinates from each of this Wing's WingCrossSection's axes, relative to their
respective leading points, to wing axes, relative to the leading edge root
point.
:return: A list of (4,4) ndarrays of floats representing the homogeneous
transformation matrices.
"""
# Return cached value if available.
if self._children_T_pas_Wcs_Lp_to_Wn_Ler is not None:
return self._children_T_pas_Wcs_Lp_to_Wn_Ler
# Compute, cache, and return the result.
result = []
for i in range(len(self.wing_cross_sections)):
T = _transformations.invert_T_pas(self.children_T_pas_Wn_Ler_to_Wcs_Lp[i])
T.flags.writeable = False
result.append(T)
self._children_T_pas_Wcs_Lp_to_Wn_Ler = result
return self._children_T_pas_Wcs_Lp_to_Wn_Ler
@property
def children_T_pas_G_Cg_to_Wcs_Lp(self) -> list[np.ndarray]:
"""A list of passive transformation matrices which map in homogeneous
coordinates from geometry axes, relative to the CG, to each of this Wing's
WingCrossSection's axes, relative to their respective leading points.
:return: A list of (4,4) ndarrays of floats representing the homogeneous
transformation matrices.
"""
# Return cached value if available.
if self._children_T_pas_G_Cg_to_Wcs_Lp is not None:
return self._children_T_pas_G_Cg_to_Wcs_Lp
_T_pas_G_Cg_to_Wn_Ler = self.T_pas_G_Cg_to_Wn_Ler
assert _T_pas_G_Cg_to_Wn_Ler is not None
# Compute, cache, and return the result.
result = []
for i in range(len(self.wing_cross_sections)):
T = _transformations.compose_T_pas(
_T_pas_G_Cg_to_Wn_Ler, self.children_T_pas_Wn_Ler_to_Wcs_Lp[i]
)
T.flags.writeable = False
result.append(T)
self._children_T_pas_G_Cg_to_Wcs_Lp = result
return self._children_T_pas_G_Cg_to_Wcs_Lp
@property
def children_T_pas_Wcs_Lp_to_G_Cg(self) -> list[np.ndarray]:
"""A list of passive transformation matrices which map in homogeneous
coordinates from each of this Wing's WingCrossSection's axes, relative to their
respective leading points, to geometry axes, relative to the CG.
:return: A list of (4,4) ndarrays of floats representing the homogeneous
transformation matrices.
"""
# Return cached value if available.
if self._children_T_pas_Wcs_Lp_to_G_Cg is not None:
return self._children_T_pas_Wcs_Lp_to_G_Cg
# Compute, cache, and return the result.
result = []
for i in range(len(self.wing_cross_sections)):
T = _transformations.invert_T_pas(self.children_T_pas_G_Cg_to_Wcs_Lp[i])
T.flags.writeable = False
result.append(T)
self._children_T_pas_Wcs_Lp_to_G_Cg = result
return self._children_T_pas_Wcs_Lp_to_G_Cg
# --- Set once: properties with single assignment enforcement ---
@property
def symmetry_type(self) -> int | None:
"""The symmetry type of this Wing.
:return: An integer from 1-4 representing the symmetry type, or None if the
Wing's symmetry type hasn't been determined yet.
"""
return self._symmetry_type
@symmetry_type.setter
def symmetry_type(self, value: int) -> None:
if self._symmetry_type is not None:
raise AttributeError("symmetry_type can only be set once")
self._symmetry_type = value
@property
def num_spanwise_panels(self) -> int | None:
"""The total number of spanwise Panels on this Wing.
:return: A positive integer representing the number of spanwise Panels, or None
if the Wing hasn't been meshed yet.
"""
return self._num_spanwise_panels
@num_spanwise_panels.setter
def num_spanwise_panels(self, value: int) -> None:
if self._num_spanwise_panels is not None:
raise AttributeError("num_spanwise_panels can only be set once")
self._num_spanwise_panels = value
@property
def num_panels(self) -> int | None:
"""The total number of Panels on this Wing.
:return: A positive integer representing the total number of Panels, or None if
the Wing hasn't been meshed yet.
"""
return self._num_panels
@num_panels.setter
def num_panels(self, value: int) -> None:
if self._num_panels is not None:
raise AttributeError("num_panels can only be set once")
self._num_panels = value
@property
def panels(self) -> np.ndarray | None:
"""The 2D array of Panels on this Wing.
:return: A (num_chordwise_panels, num_spanwise_panels) ndarray of Panel objects,
or None if the Wing hasn't been meshed yet.
"""
return self._panels
@panels.setter
def panels(self, value: np.ndarray) -> None:
if self._panels is not None:
raise AttributeError("panels can only be set once")
self._panels = value
# --- Set once derived: manual lazy caching ---
@property
def projected_area(self) -> None | float:
"""The area of the Wing projected onto the plane defined by the wing axes' xy
plane.
**Notes:**
If the Wing is symmetric and continuous, the area of the mirrored half is
included.
:return: The projected area of the Wing. It has units of square meters. If the
Wing hasn't been meshed yet, None is returned instead.
"""
# Return None if the Wing hasn't been meshed yet.
if self._panels is None:
return None
# Return cached value if available.
if self._projected_area is not None:
return self._projected_area
projected_area = 0.0
# Get the wing Z-axis once before iterating.
WnZ_G = self.WnZ_G
assert WnZ_G is not None
# Iterate through the chordwise and spanwise indices of the Panels and add
# their area to the total projected area.
assert self._num_spanwise_panels is not None
for chordwise_location in range(self._num_chordwise_panels):
for spanwise_location in range(self._num_spanwise_panels):
this_panel: _panel.Panel = self._panels[
chordwise_location, spanwise_location
]
projected_area += this_panel.calculate_projected_area(WnZ_G)
# Cache the computed value.
self._projected_area = projected_area
return projected_area
@property
def wetted_area(self) -> None | float:
"""The Wing's wetted area.
**Notes:**
If the Wing is symmetric and continuous, the area of the mirrored half is
included.
:return: The wetted area of the Wing. It has units of square meters. If the Wing
hasn't been meshed yet, None is returned instead.
"""
# Return None if the Wing hasn't been meshed yet.
if self._panels is None:
return None
# Return cached value if available.
if self._wetted_area is not None:
return self._wetted_area
wetted_area = 0.0
# Iterate through the chordwise and spanwise indices of the panels and add
# their area to the total wetted area.
assert self._num_spanwise_panels is not None
for chordwise_location in range(self._num_chordwise_panels):
for spanwise_location in range(self._num_spanwise_panels):
this_panel: _panel.Panel = self._panels[
chordwise_location, spanwise_location
]
wetted_area += this_panel.area
# Cache the computed value.
self._wetted_area = wetted_area
return wetted_area
@property
def average_panel_aspect_ratio(self) -> None | float:
"""The average aspect ratio of the Wing's Panels.
:return: The average aspect ratio of the Wing's Panels. If the Wing hasn't been
meshed yet, None is returned instead.
"""
# Return None if the Wing hasn't been meshed yet.
if self._panels is None:
return None
# Return cached value if available.
if self._average_panel_aspect_ratio is not None:
return self._average_panel_aspect_ratio
aspect_ratio_sum = 0.0
# Iterate through the chordwise and spanwise indices of the Panels and sum
# all the Panels' aspect ratios.
assert self._num_spanwise_panels is not None
for chordwise_location in range(self._num_chordwise_panels):
for spanwise_location in range(self._num_spanwise_panels):
this_panel: _panel.Panel = self._panels[
chordwise_location, spanwise_location
]
aspect_ratio_sum += this_panel.aspect_ratio
assert self._num_panels is not None
# Cache and return the result.
self._average_panel_aspect_ratio = aspect_ratio_sum / self._num_panels
return self._average_panel_aspect_ratio
@property
def span(self) -> None | float:
"""The Wing's span.
**Notes:**
The span is derived by first finding the vector connecting the leading edges of
the root and tip WingCrossSections. Then, this vector is projected onto the wing
axes' second basis vector. The span is defined as the magnitude of this
projection.
If the Wing is symmetric and continuous, this method includes the span of the
mirrored half.
:return: The Wing's span. It has units of meters. None is returned if the Wing's
symmetry type hasn't been defined yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self._symmetry_type is None:
return None
# Return cached value if available.
if self._span is not None:
return self._span
tipLp_Wcsp_Lpp = self._wing_cross_sections[-1].Lp_Wcsp_Lpp
tip_T_pas_Wcsp_Lpp_to_Wn_Ler = self.children_T_pas_Wcs_Lp_to_Wn_Ler[-2]
tipLp_Wn_Ler = _transformations.apply_T_to_vectors(
tip_T_pas_Wcsp_Lpp_to_Wn_Ler, tipLp_Wcsp_Lpp, has_point=True
)
# Project the tip position onto the wing axes' y direction (spanwise direction)
projectedTipLp_Wn_Ler = np.dot(
tipLp_Wn_Ler, np.array([0.0, 1.0, 0.0])
) * np.array([0.0, 1.0, 0.0])
span = float(np.linalg.norm(projectedTipLp_Wn_Ler))
# If the wing is symmetric and continuous, multiply the span by two.
if self._symmetry_type == 4:
span *= 2
# Cache the computed value.
self._span = span
return span
@property
def standard_mean_chord(self) -> None | float:
"""The Wing's standard mean chord.
**Notes:**
The standard mean chord is defined as the projected area divided by the span.
See their respective methods for the definitions of span and projected area.
:return: The standard mean chord of the Wing. It has units of meters. None is
returned if the Wing's symmetry type hasn't been defined yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self.symmetry_type is None:
return None
# Return cached value if available.
if self._standard_mean_chord is not None:
return self._standard_mean_chord
_projected_area = self.projected_area
assert _projected_area is not None
_span = self.span
assert _span is not None
# Cache and return the result.
self._standard_mean_chord = _projected_area / _span
return self._standard_mean_chord
@property
def mean_aerodynamic_chord(self) -> None | float:
"""The Wing's mean aerodynamic chord.
:return: The mean aerodynamic chord of the Wing. It has units of meters. None is
returned if the Wing's symmetry type hasn't been defined yet.
"""
# If the Wing's symmetry type hasn't been set yet, return None to avoid
# incorrect symmetry handling.
if self.symmetry_type is None:
return None
# Return cached value if available.
if self._mean_aerodynamic_chord is not None:
return self._mean_aerodynamic_chord
# This method is based on the equation for the mean aerodynamic chord of a
# wing, which can be found here: https://en.wikipedia.org/wiki/Chord_(
# aeronautics)#Mean_aerodynamic_chord. This equation integrates the squared
# chord from the Wing's center to the Wing's tip. We will perform this
# integral piecewise for each section of the Wing.
integral = 0.0
# Iterate through the WingCrossSections to add the contribution of their
# corresponding Wing section to the piecewise integral.
for wing_cross_section_id, wing_cross_section in enumerate(
self.wing_cross_sections[:-1]
):
next_wing_cross_section = self.wing_cross_sections[
wing_cross_section_id + 1
]
chord = wing_cross_section.chord
next_chord = next_wing_cross_section.chord
# Find this section's span by calculating the positions of both
# WingCrossSections in wing axes, then finding the distance between them.
# Calculate current WingCrossSection's position in wing axes
Lp_Wcs_Lp = np.array([0.0, 0.0, 0.0])
T_pas_Wcs_Lp_to_Wn_Ler = self.children_T_pas_Wcs_Lp_to_Wn_Ler[
wing_cross_section_id
]
Lp_Wn_Ler = _transformations.apply_T_to_vectors(
T_pas_Wcs_Lp_to_Wn_Ler, Lp_Wcs_Lp, has_point=True
)
# Calculate next WingCrossSection's position in wing axes
nextLp_nextWcs_nextLp = np.array([0.0, 0.0, 0.0])
T_pas_nextWcs_nextLp_to_Wn_Ler = self.children_T_pas_Wcs_Lp_to_Wn_Ler[
wing_cross_section_id + 1
]
nextLp_Wn_Ler = _transformations.apply_T_to_vectors(
T_pas_nextWcs_nextLp_to_Wn_Ler, nextLp_nextWcs_nextLp, has_point=True
)
# Find the section vector and project it onto spanwise direction (wing axes y direction)
nextLp_Wn_Lp = nextLp_Wn_Ler - Lp_Wn_Ler
nextLpProj_Wn_Lp = np.dot(
nextLp_Wn_Lp, np.array([0.0, 1.0, 0.0])
) * np.array([0.0, 1.0, 0.0])
section_span = float(np.linalg.norm(nextLpProj_Wn_Lp))
# Each Wing section is, by definition, trapezoidal (at least when
# projected on to the wing axes' xy plane). For a trapezoid,
# the integral from the cited equation can be shown to evaluate to the
# following.
integral += (
section_span * (chord**2 + chord * next_chord + next_chord**2) / 3
)
_projected_area = self.projected_area
assert _projected_area is not None
# Multiply the integral's value by the coefficients from the cited equation.
# Double if the wing is symmetric and continuous.
if self.symmetry_type == 4:
self._mean_aerodynamic_chord = 2 * integral / _projected_area
else:
self._mean_aerodynamic_chord = integral / _projected_area
return self._mean_aerodynamic_chord
# --- Other methods ---
[docs]
def generate_mesh(self, symmetry_type: int) -> None:
"""Generates this Wing's mesh, which finishes the process of preparing the Wing
to be used in a simulation. It is called by the Wing's parent Airplane, after
it's determined its symmetry type.
:param symmetry_type: The symmetry type of this Wing as an int from 1-4. See the
class docstring for details on how to interpret the symmetry types.
:return: None
"""
# Validate and apply symmetry_type. 5 isn't a valid symmetry type, because
# the parent Airplane should have modified a Wing that initially had type 5
# symmetry to have type 1 symmetry, and then made a new reflected Wing with
# type 3 symmetry.
validated_symmetry_type = _parameter_validation.int_in_range_return_int(
symmetry_type,
"symmetry_type",
min_val=1,
min_inclusive=True,
max_val=4,
max_inclusive=True,
)
self.symmetry_type = validated_symmetry_type
# Set this Wing's children WingCrossSections' symmetry type parameters.
for wing_cross_section in self.wing_cross_sections:
wing_cross_section.symmetry_type = validated_symmetry_type
# Find the number of spanwise panels on the wing by adding each cross
# section's number of spanwise panels. Exclude the last cross section's
# number of spanwise panels as this is irrelevant. If the wing has type 4
# symmetry multiply the summation by two.
computed_num_spanwise_panels = 0
for wing_cross_section in self.wing_cross_sections[:-1]:
assert wing_cross_section.num_spanwise_panels is not None
computed_num_spanwise_panels += wing_cross_section.num_spanwise_panels
if validated_symmetry_type == 4:
computed_num_spanwise_panels *= 2
self.num_spanwise_panels = computed_num_spanwise_panels
# Calculate the number of panels on this wing.
self.num_panels = computed_num_spanwise_panels * self.num_chordwise_panels
# Initialize empty arrays to hold this wing's wake RingVortices and its wake
# RingVortex points.
self.wake_ring_vortices = np.zeros(
(0, computed_num_spanwise_panels), dtype=object
)
self.gridWrvp_GP1_CgP1 = np.empty(
(0, computed_num_spanwise_panels + 1, 3), dtype=float
)
# Generate the wing's mesh, which populates the Panels attribute.
_meshing.mesh_wing(self)
[docs]
def get_plottable_data(
self, show: bool | np.bool_ = False
) -> list[list[np.ndarray]] | None:
"""Returns plottable data for this Wing's Airfoils' outlines and mean camber
lines.
:param show: Determines whether to display the plot. If True, the method
displays the plot and returns None. If False, the method returns the data
without displaying. Can be a bool or a numpy bool and will be converted
internally to a bool. The default is False.
:return: None if the Wing's symmetry type hasn't been set yet, or if show is
True. Otherwise, returns a list of two lists, each containing one ndarray
for every one of this Wing's Airfoils. These ndarrays represent points on
each Airfoil's outline and mean camber lines, respectively. The points are
in wing axes, relative to the leading edge root point. The units are in
meters.
"""
# Validate the input flag.
show = _parameter_validation.boolLike_return_bool(show, "show")
# If this Wing hasn't had its symmetry type set, return None.
if self.symmetry_type is None:
return None
airfoilOutlines_Wn_ler = []
airfoilMcls_Wn_ler = []
for wing_cross_section_id, wing_cross_section in enumerate(
self.wing_cross_sections
):
plottable_data = wing_cross_section.get_plottable_data(show=False)
assert plottable_data is not None
[airfoilOutline_Wcs_lp, airfoilMcl_Wcs_lp] = plottable_data
T_pas_Wcs_Lp_to_Wn_Ler = self.children_T_pas_Wcs_Lp_to_Wn_Ler[
wing_cross_section_id
]
airfoilOutline_Wn_ler = _transformations.apply_T_to_vectors(
T_pas_Wcs_Lp_to_Wn_Ler, airfoilOutline_Wcs_lp, has_point=True
)
airfoilMcl_Wn_ler = _transformations.apply_T_to_vectors(
T_pas_Wcs_Lp_to_Wn_Ler, airfoilMcl_Wcs_lp, has_point=True
)
airfoilOutlines_Wn_ler.append(airfoilOutline_Wn_ler)
airfoilMcls_Wn_ler.append(airfoilMcl_Wn_ler)
if not show:
return [airfoilOutlines_Wn_ler, airfoilMcls_Wn_ler]
plotter = pv.Plotter()
AxesGCg = pv.AxesAssembly(
x_label="GX@Cg",
y_label="GY@Cg",
z_label="GZ@Cg",
# labels=None,
label_color="black",
show_labels=True,
# label_position=(1, 1, 1),
label_size=15,
x_color="red",
y_color="green",
z_color="blue",
# position=(0.0, 0.0, 0.0),
# orientation=(0.0, 0.0, 0.0),
# origin=(0.0, 0.0, 0.0),
scale=(0.25, 0.25, 0.25),
user_matrix=np.eye(4, dtype=float),
name="G",
shaft_type="cylinder",
shaft_radius=0.025,
shaft_length=(0.8, 0.8, 0.8),
tip_type="cone",
tip_radius=0.1,
tip_length=(0.2, 0.2, 0.2),
symmetric_bounds=False,
)
plotter.add_actor(AxesGCg) # type: ignore[arg-type]
_T_pas_G_Cg_to_Wn_Ler = self.T_pas_G_Cg_to_Wn_Ler
assert _T_pas_G_Cg_to_Wn_Ler is not None
AxesWLerWcs1Lp1_G_Cg = pv.AxesAssembly(
x_label="WX@Ler/Wcs1XLp1",
y_label="WY@Ler/Wcs1YLp1",
z_label="WZ@Ler/Wcs1ZLp1",
# labels=None,
label_color="black",
show_labels=True,
# label_position=(1, 1, 1),
label_size=15,
x_color="red",
y_color="green",
z_color="blue",
# position=(0.0, 0.0, 0.0),
# orientation=(0.0, 0.0, 0.0),
# origin=(0.0, 0.0, 0.0),
scale=(0.25, 0.25, 0.25),
user_matrix=np.linalg.inv(_T_pas_G_Cg_to_Wn_Ler),
# user_matrix=wingAxes_T_act,
name="W/Wcs1",
shaft_type="cylinder",
shaft_radius=0.025,
shaft_length=(0.8, 0.8, 0.8),
tip_type="cone",
tip_radius=0.1,
tip_length=(0.2, 0.2, 0.2),
symmetric_bounds=False,
)
plotter.add_actor(AxesWLerWcs1Lp1_G_Cg) # type: ignore[arg-type]
_T_pas_Wn_Ler_to_G_Cg = self.T_pas_Wn_Ler_to_G_Cg
assert _T_pas_Wn_Ler_to_G_Cg is not None
for wing_cross_section_id, wing_cross_section in enumerate(
self.wing_cross_sections
):
airfoilOutline_Wn_ler = airfoilOutlines_Wn_ler[wing_cross_section_id]
airfoilMcl_Wn_ler = airfoilMcls_Wn_ler[wing_cross_section_id]
airfoilOutline_G_Cg = _transformations.apply_T_to_vectors(
_T_pas_Wn_Ler_to_G_Cg, airfoilOutline_Wn_ler, has_point=True
)
airfoilMcl_G_Cg = _transformations.apply_T_to_vectors(
_T_pas_Wn_Ler_to_G_Cg, airfoilMcl_Wn_ler, has_point=True
)
airfoilOutline_faces = np.hstack(
[
airfoilOutline_G_Cg.shape[0],
np.arange(airfoilOutline_G_Cg.shape[0]),
]
)
airfoilOutline_mesh = pv.PolyData(
airfoilOutline_G_Cg, faces=airfoilOutline_faces
)
plotter.add_mesh(airfoilOutline_mesh)
plotter.add_lines(airfoilMcl_G_Cg)
if wing_cross_section_id != 0:
wing_cross_section_num = wing_cross_section_id + 1
AxesWcsLp_G_Cg = pv.AxesAssembly(
x_label=f"Wcs{wing_cross_section_num}X@Lp{wing_cross_section_num}",
y_label=f"Wcs{wing_cross_section_num}Y@Lp{wing_cross_section_num}",
z_label=f"Wcs{wing_cross_section_num}Z@Lp{wing_cross_section_num}",
# labels=None,
label_color="black",
show_labels=True,
# label_position=(1, 1, 1),
label_size=15,
x_color="red",
y_color="green",
z_color="blue",
# position=(0.0, 0.0, 0.0),
# orientation=(0.0, 0.0, 0.0),
# origin=(0.0, 0.0, 0.0),
scale=(0.25, 0.25, 0.25),
user_matrix=np.linalg.inv(
self.children_T_pas_G_Cg_to_Wcs_Lp[wing_cross_section_id]
),
name=f"Wcs{wing_cross_section_id}",
shaft_type="cylinder",
shaft_radius=0.025,
shaft_length=(0.8, 0.8, 0.8),
tip_type="cone",
tip_radius=0.1,
tip_length=(0.2, 0.2, 0.2),
symmetric_bounds=False,
)
plotter.add_actor(AxesWcsLp_G_Cg) # type: ignore[arg-type]
if self.panels is not None:
# Initialize empty arrays to hold the Panels' vertices and faces.
panel_vertices = np.empty((0, 3), dtype=float)
panel_faces = np.empty(0, dtype=int)
# Initialize a variable to keep track of how many Panels' data has been
# added to the arrays.
panel_num = 0
# Unravel the Wing's Panel matrix and iterate through it.
panels = np.ravel(self.panels)
for panel in panels:
# Stack this Panel's vertices and faces.
panel_vertices_to_add = np.vstack(
(
panel.Flpp_G_Cg,
panel.Frpp_G_Cg,
panel.Brpp_G_Cg,
panel.Blpp_G_Cg,
)
)
panel_face_to_add = np.array(
[
4,
(panel_num * 4),
(panel_num * 4) + 1,
(panel_num * 4) + 2,
(panel_num * 4) + 3,
]
)
# Stack this Panel's vertices and faces with the array of all
# vertices and faces.
panel_vertices = np.vstack((panel_vertices, panel_vertices_to_add))
panel_faces = np.hstack((panel_faces, panel_face_to_add))
# Update the number of previous Panels.
panel_num += 1
# Convert the Panel vertices and faces to PolyData.
panel_surfaces = pv.PolyData(panel_vertices, panel_faces)
# Add the Panels to the plotter.
plotter.add_mesh(
panel_surfaces,
show_edges=True,
color="chartreuse",
smooth_shading=False,
)
plotter.enable_parallel_projection() # type: ignore[call-arg]
plotter.show(
cpos=(-1, -1, 1),
full_screen=False,
auto_close=False,
)
return None
def _assert_T_not_none(T: np.ndarray | None) -> np.ndarray:
"""Assert that a transformation matrix is not None and return it.
:param T: None, or a (4,4) ndarray of floats representing the transformation matrix.
:return: A (4,4) ndarray of floats representing the transformation matrix
(guaranteed not to be None).
"""
assert T is not None
return T