From 19db4f46ed6aa78de3d46a30c06a6bfada9ce8da Mon Sep 17 00:00:00 2001 From: camUrban Date: Mon, 10 Jul 2023 14:28:16 -0400 Subject: [PATCH 01/24] I updated the Wing and Panel classes to facilitate horizontal flapping. I expect this to fail for now. --- pterasoftware/geometry.py | 165 +++++++++++++++++++++++++++----------- pterasoftware/panel.py | 17 ++++ 2 files changed, 136 insertions(+), 46 deletions(-) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index ff9662d8..d3132473 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -152,9 +152,9 @@ def set_reference_dimensions_from_main_wing(self): class Wing: """This is a class used to contain the wings of an Airplane object. - If the wing is symmetric across the XZ plane, just define the right half and - supply "symmetric=True" in the constructor. If the wing is not symmetric across - the XZ plane, just define the wing. + If the wing is symmetric about some plane, just define the right half, + supply "symmetric=True" in the constructor, and include the symmetry plane's + normal vector in the "symmetry_plane_normal" attribute. Citation: Adapted from: geometry.Wing in AeroSandbox @@ -184,14 +184,19 @@ class Wing: This class is not meant to be subclassed. """ + # ToDo: Maybe add an attribute for which plane we should consider for the + # projected area, and which direction we should consider to determine the span + # and the chord. def __init__( self, name="Untitled Wing", x_le=0.0, y_le=0.0, z_le=0.0, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), wing_cross_sections=None, symmetric=False, + chordwise_unit_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=8, chordwise_spacing="cosine", ): @@ -201,44 +206,80 @@ def __init__( This is a sensible name for the wing. The default is "Untitled Wing". :param x_le: float, optional This is the x coordinate of the leading edge of the wing, relative to the - current_airplane's reference - point. The default is 0.0. + airplane's reference point. The default is 0.0. :param y_le: float, optional This is the y coordinate of the leading edge of the wing, relative to the - current_airplane's reference - point. The default is 0.0. + airplane's reference point. The default is 0.0. :param z_le: float, optional This is the z coordinate of the leading edge of the wing, relative to the - current_airplane's reference - point. The default is 0.0. + airplane's reference point. The default is 0.0. + :param symmetry_unit_normal_vector: ndarray, optional + + This is an (3,) ndarray of floats that represents the unit normal vector + of the wing's symmetry plane. It is also the direction vector that the + wing's span will be assessed relative to. Additionally, this vector + crossed with the "chordwise_unit_vector" defines the normal vector of the + plane that the wing's projected area will reference. It must be + equivalent to this wing's root wing cross section's "unit_normal_vector" + attribute. The default is np.array([0.0, 1.0, 0.0]), which is the XZ + plane's unit normal vector. + :param wing_cross_sections: list of WingCrossSection objects, optional This is a list of WingCrossSection objects, that represent the wing's cross sections. The default is None. :param symmetric: bool, optional Set this to true if the wing is across the xz plane. Set it to false if not. The default is false. + :param chordwise_unit_vector: ndarray, optional + + This is an (3,) ndarray of floats that represents the unit vector that + defines the wing's chordwise direction. This vector crossed with the + "symmetry_unit_normal_vector" defines the normal vector of the plane that + the wing's projected area will reference. This vector must be parallel to + the intersection of the wing's symmetry plane with each of its wing cross + section's planes. The default is np.array([1.0, 0.0, 0.0]), which is the + X unit vector. + :param num_chordwise_panels: int, optional This is the number of chordwise panels to be used on this wing. The default is 8. :param chordwise_spacing: str, optional This is the type of spacing between the wing's chordwise panels. It can - be set to "cosine" or "uniform". - Cosine is highly recommended. The default is cosine. + be set to "cosine" or "uniform". Using a cosine spacing is highly + recommended for steady simulations and a uniform spacing is highly + recommended for unsteady simulations. The default is "cosine". """ # Initialize the name and the position of the wing's leading edge. self.name = name self.x_le = x_le self.y_le = y_le self.z_le = z_le + self.symmetry_unit_normal_vector = symmetry_unit_normal_vector self.leading_edge = np.array([self.x_le, self.y_le, self.z_le]) - # If wing_cross_sections is set to None, set it to an empty list. + # If wing_cross_sections is set to None, set it to an empty list. If not, + # check that the wing's symmetry plane is equal to its root wing cross + # section's plane. Also, check that the root wing cross section's leading + # edge isn't offset from the wing's leading edge. if wing_cross_sections is None: wing_cross_sections = [] + elif not np.is_equal( + symmetry_unit_normal_vector, wing_cross_sections[0].unit_normal_vector + ): + raise Exception( + "The wing's symmetry plane must be the same as its root wing cross" + "section's plane." + ) + elif np.any(wing_cross_sections[0].leading_edge): + raise Exception( + "The root wing cross section's leading edge must not be offset from" + "the wing's leading edge." + ) # Initialize the other attributes. self.wing_cross_sections = wing_cross_sections self.symmetric = symmetric + self.chordwise_unit_vector = chordwise_unit_vector self.num_chordwise_panels = num_chordwise_panels self.chordwise_spacing = chordwise_spacing @@ -251,13 +292,25 @@ def __init__( # number of spanwise panels as this is irrelevant. If the wing is symmetric, # multiply the summation by two. self.num_spanwise_panels = 0 - for cross_section in self.wing_cross_sections[:-1]: - self.num_spanwise_panels += cross_section.num_spanwise_panels + for wing_cross_section in self.wing_cross_sections[:-1]: + self.num_spanwise_panels += wing_cross_section.num_spanwise_panels if self.symmetric: self.num_spanwise_panels *= 2 - if self.symmetric and self.wing_cross_sections[0].y_le != 0: - raise Exception("Symmetric wing with root wing cross section off XZ plane!") + # ToDo: Document this section. + for wing_cross_section in self.wing_cross_sections: + # Find the vector parallel to the intersection of this wing cross + # section's plane and the wing's symmetry plane. + orthogonal_vector = np.cross( + self.symmetry_unit_normal_vector, wing_cross_section + ) + + if np.any(np.cross(orthogonal_vector, self.chordwise_unit_vector)): + raise Exception( + "Every wing cross section's plane must intersect with the wing's" + "symmetry plane along a line that is parallel with the wing's" + "chordwise direction." + ) # Calculate the number of panels on this wing. self.num_panels = self.num_spanwise_panels * self.num_chordwise_panels @@ -272,12 +325,19 @@ def __init__( self.wake_ring_vortex_vertices = np.empty((0, self.num_spanwise_panels + 1, 3)) self.wake_ring_vortices = np.zeros((0, self.num_spanwise_panels), dtype=object) + # Define an attribute that is the normal vector of the plane that the + # projected area will reference. + self.projected_unit_normal_vector = np.cross( + chordwise_unit_vector, symmetry_unit_normal_vector + ) + + # ToDo: Update this method's documentation. @property def projected_area(self): """This method calculates the projected area of the wing and assigns it to the projected_area attribute. - If the wing is symmetrical, the area of the mirrored half is included. + If the wing is symmetric, the area of the mirrored half is included. :return projected_area: float This attribute is the projected area of the wing. It has units of square @@ -285,29 +345,17 @@ def projected_area(self): """ projected_area = 0 - # Iterate through the wing cross sections and add the area of their - # corresponding wing sections to the total projected area. - 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 - ] - - span = abs(next_wing_cross_section.y_le - wing_cross_section.y_le) - - chord = wing_cross_section.chord - next_chord = next_wing_cross_section.chord - mean_chord = (chord + next_chord) / 2 - - projected_area += mean_chord * span - - # If the wing is symmetric, double the projected area. - if self.symmetric: - projected_area *= 2 + # Iterate through the chordwise and spanwise indices of the panels and add + # their area to the total projected area. + for chordwise_location in range(self.num_chordwise_panels): + for spanwise_location in range(self.num_spanwise_panels): + projected_area += self.panels[ + chordwise_location, spanwise_location + ].calculate_projected_area(self.projected_unit_normal_vector) return projected_area + # ToDo: Update this method's documentation. @property def wetted_area(self): """This method calculates the wetted area of the wing based on the areas of @@ -325,24 +373,35 @@ def wetted_area(self): # their area to the total wetted area. for chordwise_location in range(self.num_chordwise_panels): for spanwise_location in range(self.num_spanwise_panels): - # Add each panel's area to the total wetted area of the wing. wetted_area += self.panels[chordwise_location, spanwise_location].area return wetted_area + # ToDo: Update this method's documentation. @property def span(self): """This method calculates the span of the wing and assigns it to the span - attribute. + attribute. The span is found first finding vector connecting the leading + edges of the root and tip wing cross sections. Then, this vector is projected + onto the symmetry plane's unit normal vector. The span is defined as the + magnitude of this projection. If the wing is symmetrical, this method includes the span of the mirrored half. :return span: float This attribute is the wingspan. It has units of meters. """ - # Calculate the span (y-distance between the root and the tip) of the entire - # wing. - span = self.wing_cross_sections[-1].y_le - self.wing_cross_sections[0].y_le + root_to_tip_leading_edge = ( + self.wing_cross_sections[-1].leading_edge + - self.wing_cross_sections[0].leading_edge + ) + + projected_leading_edge = ( + np.dot(root_to_tip_leading_edge, self.symmetry_unit_normal_vector) + * self.symmetry_unit_normal_vector + ) + + span = np.linalg.norm(projected_leading_edge) # If the wing is symmetric, multiply the span by two. if self.symmetric: @@ -353,7 +412,9 @@ def span(self): @property def standard_mean_chord(self): """This method calculates the standard mean chord of the wing and assigns it - to the standard_mean_chord attribute. + to the standard_mean_chord attribute. 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: float This is the standard mean chord of the wing. It has units of meters. @@ -386,14 +447,26 @@ def mean_aerodynamic_chord(self): root_chord = wing_cross_section.chord tip_chord = next_wing_cross_section.chord - section_length = next_wing_cross_section.y_le - wing_cross_section.y_le + + # Find this section's span by following the same procedure as for the + # overall wing span. + section_leading_edge = ( + next_wing_cross_section.leading_edge - wing_cross_section.leading_edge + ) + + projected_section_leading_edge = ( + np.dot(section_leading_edge, self.symmetry_unit_normal_vector) + * self.symmetry_unit_normal_vector + ) + + section_span = np.linalg.norm(projected_section_leading_edge) # Each wing section is, by definition, trapezoidal (at least when - # projected on to the body-frame's XY plane). For a trapezoid, + # projected on to the wing's projection plane). For a trapezoid, # the integral from the cited equation can be shown to evaluate to the # following. integral += ( - section_length + section_span * (root_chord**2 + root_chord * tip_chord + tip_chord**2) / 3 ) diff --git a/pterasoftware/panel.py b/pterasoftware/panel.py index 4cefa2c1..989c5951 100644 --- a/pterasoftware/panel.py +++ b/pterasoftware/panel.py @@ -255,6 +255,23 @@ def calculate_induced_velocity(self, point): return induced_velocity + # ToDo: Add this method to the documentation. + def calculate_projected_area(self, n_hat): + """ + + :param n_hat: + :return: + """ + proj_n_hat_first_diag = np.dot(self._first_diagonal, n_hat) * n_hat + proj_n_hat_second_diag = np.dot(self._second_diagonal, n_hat) * n_hat + + proj_plane_first_diag = self._first_diagonal - proj_n_hat_first_diag + proj_plane_second_diag = self._second_diagonal - proj_n_hat_second_diag + + proj_cross = np.cross(proj_plane_first_diag, proj_plane_second_diag) + + return np.linalg.norm(proj_cross) / 2 + def update_coefficients(self, dynamic_pressure): """This method updates the panel's force coefficients. From c878091f464f6f4e036098133606b53f75c0f59d Mon Sep 17 00:00:00 2001 From: camUrban Date: Mon, 10 Jul 2023 17:02:43 -0400 Subject: [PATCH 02/24] I added unit normal vectors to the Wing Cross Section class and fixed a numpy bug with the new Wing class attributes. --- pterasoftware/geometry.py | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index d3132473..d79ba34a 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -196,7 +196,7 @@ def __init__( symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), wing_cross_sections=None, symmetric=False, - chordwise_unit_vector=np.array([1.0, 0.0, 0.0]), + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=8, chordwise_spacing="cosine", ): @@ -214,24 +214,21 @@ def __init__( This is the z coordinate of the leading edge of the wing, relative to the airplane's reference point. The default is 0.0. :param symmetry_unit_normal_vector: ndarray, optional - This is an (3,) ndarray of floats that represents the unit normal vector of the wing's symmetry plane. It is also the direction vector that the wing's span will be assessed relative to. Additionally, this vector - crossed with the "chordwise_unit_vector" defines the normal vector of the + crossed with the "unit_chordwise_vector" defines the normal vector of the plane that the wing's projected area will reference. It must be equivalent to this wing's root wing cross section's "unit_normal_vector" attribute. The default is np.array([0.0, 1.0, 0.0]), which is the XZ plane's unit normal vector. - :param wing_cross_sections: list of WingCrossSection objects, optional This is a list of WingCrossSection objects, that represent the wing's cross sections. The default is None. :param symmetric: bool, optional Set this to true if the wing is across the xz plane. Set it to false if not. The default is false. - :param chordwise_unit_vector: ndarray, optional - + :param unit_chordwise_vector: ndarray, optional This is an (3,) ndarray of floats that represents the unit vector that defines the wing's chordwise direction. This vector crossed with the "symmetry_unit_normal_vector" defines the normal vector of the plane that @@ -239,7 +236,6 @@ def __init__( the intersection of the wing's symmetry plane with each of its wing cross section's planes. The default is np.array([1.0, 0.0, 0.0]), which is the X unit vector. - :param num_chordwise_panels: int, optional This is the number of chordwise panels to be used on this wing. The default is 8. @@ -263,7 +259,7 @@ def __init__( # edge isn't offset from the wing's leading edge. if wing_cross_sections is None: wing_cross_sections = [] - elif not np.is_equal( + elif not np.array_equal( symmetry_unit_normal_vector, wing_cross_sections[0].unit_normal_vector ): raise Exception( @@ -279,7 +275,7 @@ def __init__( # Initialize the other attributes. self.wing_cross_sections = wing_cross_sections self.symmetric = symmetric - self.chordwise_unit_vector = chordwise_unit_vector + self.unit_chordwise_vector = unit_chordwise_vector self.num_chordwise_panels = num_chordwise_panels self.chordwise_spacing = chordwise_spacing @@ -302,10 +298,10 @@ def __init__( # Find the vector parallel to the intersection of this wing cross # section's plane and the wing's symmetry plane. orthogonal_vector = np.cross( - self.symmetry_unit_normal_vector, wing_cross_section + self.symmetry_unit_normal_vector, wing_cross_section.unit_normal_vector ) - if np.any(np.cross(orthogonal_vector, self.chordwise_unit_vector)): + if np.any(np.cross(orthogonal_vector, self.unit_chordwise_vector)): raise Exception( "Every wing cross section's plane must intersect with the wing's" "symmetry plane along a line that is parallel with the wing's" @@ -328,7 +324,7 @@ def __init__( # Define an attribute that is the normal vector of the plane that the # projected area will reference. self.projected_unit_normal_vector = np.cross( - chordwise_unit_vector, symmetry_unit_normal_vector + unit_chordwise_vector, symmetry_unit_normal_vector ) # ToDo: Update this method's documentation. @@ -502,6 +498,7 @@ def __init__( y_le=0.0, z_le=0.0, chord=1.0, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), twist=0.0, airfoil=None, control_surface_type="symmetric", @@ -527,6 +524,14 @@ def __init__( :param chord: float, optional This is the chord of the wing at this cross section. The default value is 1.0. + :param unit_normal_vector: ndarray, optional + This is an (3,) ndarray of floats that represents the unit normal vector + of the plane this wing cross section lies on. If this wing cross section + is a wing's root, this vector must be equal to the wing's + "symmetry_unit_normal_vector". Also, every wing cross section must have a + plane that intersects its parent wing's symmetry plane at a line parallel + to the parent wing's "unit_chordwise_vector". The default is np.array([ + 0.0, 1.0, 0.0]), which is the XZ plane's unit normal vector. :param twist: float, optional This is the twist of the cross section about the leading edge in degrees. The default value is 0.0. @@ -560,6 +565,7 @@ def __init__( self.y_le = y_le self.z_le = z_le self.chord = chord + self.unit_normal_vector = unit_normal_vector self.twist = twist self.airfoil = airfoil self.control_surface_type = control_surface_type @@ -581,6 +587,8 @@ def __init__( if self.spanwise_spacing not in ["cosine", "uniform"]: raise Exception("Invalid value of spanwise_spacing!") + # ToDo: This is wrong because it doesn't account for the wing's potentially + # custom chordwise direction vector. @property def trailing_edge(self): """This method calculates the coordinates of the trailing edge of this wing @@ -592,13 +600,15 @@ def trailing_edge(self): """ # Find the rotation matrix given the cross section's twist. - rotation_matrix = functions.angle_axis_rotation_matrix( + twist_rotation_matrix = functions.angle_axis_rotation_matrix( self.twist * np.pi / 180, np.array([0, 1, 0]) ) # Use the rotation matrix and the leading edge coordinates to calculate the # trailing edge coordinates. - return self.leading_edge + rotation_matrix @ np.array([self.chord, 0.0, 0.0]) + return self.leading_edge + twist_rotation_matrix @ np.array( + [self.chord, 0.0, 0.0] + ) class Airfoil: From b40b30cf762d45cda7fcd07d4239685b43a64b49 Mon Sep 17 00:00:00 2001 From: camUrban Date: Mon, 10 Jul 2023 17:23:56 -0400 Subject: [PATCH 03/24] I made the Airplane, Wing, and WingCrossSection classes require a list of child objects and I fixed the trailing_edge property. --- pterasoftware/geometry.py | 122 +++++++++++++++++--------------------- 1 file changed, 55 insertions(+), 67 deletions(-) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index d79ba34a..87ec2a1f 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -26,6 +26,7 @@ from . import meshing +# ToDo: Add a check that wings has at least one elements. class Airplane: """This is a class used to contain airplanes. @@ -36,7 +37,7 @@ class Airplane: This class contains the following public methods: set_reference_dimensions_from_wing: This method sets the reference dimensions - of the current_airplane from measurements obtained from the main wing. + of the airplane from measurements obtained from the main wing. This class contains the following class attributes: None @@ -47,20 +48,22 @@ class Airplane: def __init__( self, - name="Untitled", + wings, + name="Untitled Airplane", x_ref=0.0, y_ref=0.0, z_ref=0.0, weight=0.0, - wings=None, s_ref=None, c_ref=None, b_ref=None, ): """This is the initialization method. + :param wings: list of Wing objects + This is a list of the airplane's wings defined as Wing objects. :param name: str, optional - A sensible name for your current_airplane. The default is "Untitled". + A sensible name for your airplane. The default is "Untitled Airplane". :param x_ref: float, optional This is the x coordinate of the moment reference point. It should be the x coordinate of the center of gravity. The default is 0.0. @@ -73,9 +76,6 @@ def __init__( :param weight: float, optional This parameter holds the weight of the aircraft in Newtons. This is used by the trim functions. The default value is 0.0. - :param wings: list of Wing objects, optional - This is a list of the current_airplane's wings defined as Wing objects. - The default is None, which this method converts to an empty list. :param s_ref: float, optional if more than one wing is in the wings list. This is the reference wetted area. If not set, it populates from first wing object. @@ -97,15 +97,10 @@ def __init__( # Initialize the weight. self.weight = weight - # If wings was passed as None, set wings to an empty list. - if wings is None: - wings = [] self.wings = wings - # If the wing list is not empty, set the wing reference dimensions to be the - # main wing's reference dimensions. - if len(self.wings) > 0: - self.set_reference_dimensions_from_main_wing() + # Set the wing reference dimensions to be the main wing's reference dimensions. + self.set_reference_dimensions_from_main_wing() # If any of the passed reference dimensions are not None, set that reference # dimension to be what was passed. @@ -129,7 +124,7 @@ def __init__( self.total_near_field_moment_coefficients_wind_axes = None def set_reference_dimensions_from_main_wing(self): - """This method sets the reference dimensions of the current_airplane from + """This method sets the reference dimensions of the airplane from measurements obtained from the main wing. This method assumes the main wing to be the first wing in the wings list @@ -184,17 +179,15 @@ class Wing: This class is not meant to be subclassed. """ - # ToDo: Maybe add an attribute for which plane we should consider for the - # projected area, and which direction we should consider to determine the span - # and the chord. + # ToDo: Add a check that wing_cross_sections has at least two elements. def __init__( self, + wing_cross_sections, name="Untitled Wing", x_le=0.0, y_le=0.0, z_le=0.0, symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), - wing_cross_sections=None, symmetric=False, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=8, @@ -202,6 +195,9 @@ def __init__( ): """This is the initialization method. + :param wing_cross_sections: list of WingCrossSection objects + This is a list of WingCrossSection objects, that represent the wing's + cross sections. :param name: str, optional This is a sensible name for the wing. The default is "Untitled Wing". :param x_le: float, optional @@ -222,9 +218,6 @@ def __init__( equivalent to this wing's root wing cross section's "unit_normal_vector" attribute. The default is np.array([0.0, 1.0, 0.0]), which is the XZ plane's unit normal vector. - :param wing_cross_sections: list of WingCrossSection objects, optional - This is a list of WingCrossSection objects, that represent the wing's - cross sections. The default is None. :param symmetric: bool, optional Set this to true if the wing is across the xz plane. Set it to false if not. The default is false. @@ -253,20 +246,18 @@ def __init__( self.symmetry_unit_normal_vector = symmetry_unit_normal_vector self.leading_edge = np.array([self.x_le, self.y_le, self.z_le]) - # If wing_cross_sections is set to None, set it to an empty list. If not, - # check that the wing's symmetry plane is equal to its root wing cross - # section's plane. Also, check that the root wing cross section's leading - # edge isn't offset from the wing's leading edge. - if wing_cross_sections is None: - wing_cross_sections = [] - elif not np.array_equal( + # Check that the wing's symmetry plane is equal to its root wing cross + # section's plane. + if not np.array_equal( symmetry_unit_normal_vector, wing_cross_sections[0].unit_normal_vector ): raise Exception( "The wing's symmetry plane must be the same as its root wing cross" "section's plane." ) - elif np.any(wing_cross_sections[0].leading_edge): + # Check that the root wing cross section's leading edge isn't offset from the + # wing's leading edge. + if np.any(wing_cross_sections[0].leading_edge): raise Exception( "The root wing cross section's leading edge must not be offset from" "the wing's leading edge." @@ -311,6 +302,9 @@ def __init__( # Calculate the number of panels on this wing. self.num_panels = self.num_spanwise_panels * self.num_chordwise_panels + for wing_cross_section in wing_cross_sections: + wing_cross_section._unit_chordwise_vector = self.unit_chordwise_vector + # Initialize the panels attribute. Then mesh the wing, which will # populate this attribute. self.panels = None @@ -494,13 +488,13 @@ class WingCrossSection: def __init__( self, + airfoil, x_le=0.0, y_le=0.0, z_le=0.0, chord=1.0, unit_normal_vector=np.array([0.0, 1.0, 0.0]), twist=0.0, - airfoil=None, control_surface_type="symmetric", control_surface_hinge_point=0.75, control_surface_deflection=0.0, @@ -509,55 +503,48 @@ def __init__( ): """This is the initialization method. + :param airfoil: Airfoil + This is the airfoil to be used at this wing cross section. :param x_le: float, optional - This is the x coordinate of the leading edge of the cross section - relative to the wing's datum. The default - value is 0.0. + This is the x coordinate of the leading edge of the wing cross section + relative to the wing's datum. The default value is 0.0. :param y_le: float, optional - This is the y coordinate of the leading edge of the cross section - relative to the wing's datum. The default - value is 0.0. + This is the y coordinate of the leading edge of the wing cross section + relative to the wing's leading edge. The default value is 0.0. :param z_le: float, optional - This is the z coordinate of the leading edge of the cross section - relative to the wing's datum. The default - value is 0.0. + This is the z coordinate of the leading edge of the wing cross section + relative to the wing's datum. The default value is 0.0. :param chord: float, optional - This is the chord of the wing at this cross section. The default value is - 1.0. + This is the chord of the wing at this wing cross section. The default + value is 1.0. :param unit_normal_vector: ndarray, optional This is an (3,) ndarray of floats that represents the unit normal vector of the plane this wing cross section lies on. If this wing cross section is a wing's root, this vector must be equal to the wing's - "symmetry_unit_normal_vector". Also, every wing cross section must have a - plane that intersects its parent wing's symmetry plane at a line parallel - to the parent wing's "unit_chordwise_vector". The default is np.array([ - 0.0, 1.0, 0.0]), which is the XZ plane's unit normal vector. + symmetry_unit_normal_vector attribute. Also, every wing cross section + must have a plane that intersects its parent wing's symmetry plane at a + line parallel to the parent wing's "unit_chordwise_vector". The default + is np.array([ 0.0, 1.0, 0.0]), which is the XZ plane's unit normal vector. :param twist: float, optional This is the twist of the cross section about the leading edge in degrees. The default value is 0.0. - :param airfoil: Airfoil, optional - This is the airfoil to be used at this cross section. The default value - is None. :param control_surface_type: str, optional - This is type of control surfaces for this cross section. It can be - "symmetric" or "asymmetric". An example - of symmetric control surfaces are flaps. An example of asymmetric control - surfaces are ailerons. The default - value is "symmetric". + This is type of control surfaces for this wing cross section. It can be + "symmetric" or "asymmetric". An example of symmetric control surfaces are + flaps. An example of asymmetric control surfaces are ailerons. The + default value is "symmetric". :param control_surface_hinge_point: float, optional This is the location of the control surface hinge from the leading edge as a fraction of chord. The default value is 0.75. :param control_surface_deflection: float, optional - This is the Control deflection in degrees. Deflection downwards is - positive. The default value is 0.0 - degrees. + This is the control deflection in degrees. Deflection downwards is + positive. The default value is 0.0 degrees. :param num_spanwise_panels: int, optional - This is the number of spanwise panels to be used between this cross - section and the next one. The default - value is 8. + This is the number of spanwise panels to be used between this wing cross + section and the next one. The default value is 8. :param spanwise_spacing: str, optional - This can be 'cosine' or 'uniform'. Using cosine spacing is highly - recommended. The default value is 'cosine'. + This can be "cosine" or "uniform". Using cosine spacing is highly + recommended. The default value is "cosine". """ # Initialize all the class attributes. @@ -575,6 +562,9 @@ def __init__( self.spanwise_spacing = spanwise_spacing self.leading_edge = np.array([x_le, y_le, z_le]) + # ToDo: Document this + self._unit_chordwise_vector = None + # Catch bad values of the chord length. if self.chord <= 0: raise Exception("Invalid value of chord") @@ -587,8 +577,6 @@ def __init__( if self.spanwise_spacing not in ["cosine", "uniform"]: raise Exception("Invalid value of spanwise_spacing!") - # ToDo: This is wrong because it doesn't account for the wing's potentially - # custom chordwise direction vector. @property def trailing_edge(self): """This method calculates the coordinates of the trailing edge of this wing @@ -604,11 +592,11 @@ def trailing_edge(self): self.twist * np.pi / 180, np.array([0, 1, 0]) ) + chordwise_vector = self.chord * self._unit_chordwise_vector + # Use the rotation matrix and the leading edge coordinates to calculate the # trailing edge coordinates. - return self.leading_edge + twist_rotation_matrix @ np.array( - [self.chord, 0.0, 0.0] - ) + return self.leading_edge + twist_rotation_matrix @ chordwise_vector class Airfoil: From 5637f01546594eeedabd97b7892fbce6fcf69aa5 Mon Sep 17 00:00:00 2001 From: camUrban Date: Tue, 11 Jul 2023 11:14:38 -0400 Subject: [PATCH 04/24] I added a check that the chordwise direction is orthogonal to the symmetry plane. --- pterasoftware/geometry.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index 87ec2a1f..1030ce1a 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -294,8 +294,17 @@ def __init__( if np.any(np.cross(orthogonal_vector, self.unit_chordwise_vector)): raise Exception( - "Every wing cross section's plane must intersect with the wing's" - "symmetry plane along a line that is parallel with the wing's" + "Every wing cross section's plane must intersect with the wing's " + "symmetry plane along a line that is parallel with the wing's " + "chordwise direction." + ) + if ( + np.dot(self.unit_chordwise_vector, self.symmetry_unit_normal_vector) + != 0 + ): + raise Exception( + "Every wing cross section's plane must intersect with the wing's " + "symmetry plane along a line that is parallel with the wing's " "chordwise direction." ) From d8498dd679e2f963e5b9db2b0b8a937f0984b56e Mon Sep 17 00:00:00 2001 From: camUrban Date: Tue, 11 Jul 2023 11:39:06 -0400 Subject: [PATCH 05/24] I annotated the changes needed in meshing.py to allow for the custom cross section planes. --- pterasoftware/geometry.py | 3 +++ pterasoftware/meshing.py | 26 ++++++++++++++++---------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index 1030ce1a..3980b5b9 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -607,6 +607,9 @@ def trailing_edge(self): # trailing edge coordinates. return self.leading_edge + twist_rotation_matrix @ chordwise_vector + # ToDo: Add properties for the wing cross section based on what's needed in the + # meshing.py functions. + class Airfoil: """This class is used to contain the airfoil of a WingCrossSection object. diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index 443a50da..ef523d97 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -11,17 +11,20 @@ quadrilateral mesh of its geometry, and then populates the object's panels with the mesh data. + ToDo: Update this method's documentation. get_wing_cross_section_scaling_factors: Get the scaling factors for each wing cross section. These factors allow the cross sections to intersect correctly at dihedral breaks. get_panel_vertices: This function calculates the vertices of the panels on a wing. + ToDo: Update this method's documentation. get_normalized_projected_quarter_chords: This method returns the quarter chords of a collection of wing cross sections based on the coordinates of their leading and trailing edges. These quarter chords are also projected on to the YZ plane and normalized by their magnitudes. + ToDo: Update this method's documentation. get_transpose_mcl_vectors: This function takes in the inner and outer airfoils of a wing cross section and its chordwise coordinates. It returns a list of four vectors column vectors. They are, in order, the inner airfoil's local up @@ -69,8 +72,6 @@ def mesh_wing(wing): # Iterate through the meshed wing cross sections and vertically stack the global # location of each wing cross sections leading and trailing edges. - # wing_cross_section.trailing_edge is a method that returns the wing cross section's - # trailing edge's coordinates. for wing_cross_section in wing.wing_cross_sections: wing_cross_sections_leading_edges = np.vstack( ( @@ -89,7 +90,7 @@ def mesh_wing(wing): wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges ) - # Get the number of wing cross sections. + # Get the number of wing cross sections and wing sections. num_wing_cross_sections = len(wing.wing_cross_sections) num_wing_sections = num_wing_cross_sections - 1 @@ -131,8 +132,7 @@ def mesh_wing(wing): ) ) else: - # Vertically stack the first normalized wing section quarter chord, and the - # last normalized wing section quarter chord. + # Vertically stack the first and last normalized wing section quarter chords. wing_sections_local_unit_normals = np.vstack( ( normalized_projected_quarter_chords[0, :], @@ -140,11 +140,14 @@ def mesh_wing(wing): ) ) + # FixMe: The back direction is just the chord vector, which combines the + # chord length and the unit chordwise vectors. # Then, construct the back directions for each wing cross section. wing_cross_sections_local_back_vectors = ( wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges ) + # FixMe: This should just be a list of the chords lengths. # Create a list of the wing cross section chord lengths. wing_cross_sections_chord_lengths = np.linalg.norm( wing_cross_sections_local_back_vectors, axis=1 @@ -155,6 +158,7 @@ def mesh_wing(wing): wing_cross_sections_chord_lengths, axis=1 ) + # FixMe: This should just be a list of the unit chordwise vectors. # Normalize the wing cross section back vectors by their magnitudes. wing_cross_sections_local_back_unit_vectors = ( wing_cross_sections_local_back_vectors @@ -168,6 +172,7 @@ def mesh_wing(wing): axis=1, ) + # FixMe: Modify this to account for the new plane implementation. # If the wing is symmetric, set the local up position of the root cross section # to be the in local Z direction. if wing.symmetric: @@ -203,7 +208,6 @@ def mesh_wing(wing): hinge_point=inner_wing_cross_section.control_surface_hinge_point, ) outer_airfoil = outer_wing_cross_section.airfoil.add_control_surface( - # The inner wing cross section dictates control surface deflections. deflection=inner_wing_cross_section.control_surface_deflection, hinge_point=inner_wing_cross_section.control_surface_hinge_point, ) @@ -291,7 +295,6 @@ def mesh_wing(wing): ) outer_airfoil = outer_wing_cross_section.airfoil.add_control_surface( deflection=inner_wing_cross_section.control_surface_deflection, - # The inner wing cross section dictates control surface deflections. hinge_point=inner_wing_cross_section.control_surface_hinge_point, ) else: @@ -304,7 +307,6 @@ def mesh_wing(wing): ) outer_airfoil = outer_wing_cross_section.airfoil.add_control_surface( deflection=-inner_wing_cross_section.control_surface_deflection, - # The inner wing cross section dictates control surface deflections. hinge_point=inner_wing_cross_section.control_surface_hinge_point, ) @@ -355,14 +357,15 @@ def mesh_wing(wing): ) ) + # ToDo: Update this with the new plane formulation. # Reflect the vertices across the XZ plane. front_inner_vertices_reflected = front_inner_vertices * np.array([1, -1, 1]) front_outer_vertices_reflected = front_outer_vertices * np.array([1, -1, 1]) back_inner_vertices_reflected = back_inner_vertices * np.array([1, -1, 1]) back_outer_vertices_reflected = back_outer_vertices * np.array([1, -1, 1]) - # Shift the reflected vertices to account for the wing's leading edge - # position. + # ToDo: Update this with the new plane formulation. + # Shift the reflected vertices based on the wing's leading edge position. front_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le front_outer_vertices_reflected[:, :, 1] += 2 * wing.y_le back_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le @@ -402,6 +405,7 @@ def mesh_wing(wing): wing.panels = wing_panels +# ToDo: Update this method with the new plane formulation. def get_wing_cross_section_scaling_factors( symmetric, wing_section_quarter_chords_proj_yz_norm ): @@ -640,6 +644,7 @@ def get_panel_vertices( ] +# ToDo: Update this method with the new plane formulation. def get_normalized_projected_quarter_chords( wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges ): @@ -709,6 +714,7 @@ def get_normalized_projected_quarter_chords( return normalized_projected_quarter_chords +# ToDo: Update this method with the new plane formulation. def get_transpose_mcl_vectors(inner_airfoil, outer_airfoil, chordwise_coordinates): """This function takes in the inner and outer airfoils of a wing cross section and its chordwise coordinates. It returns a list of four vectors column vectors. From 79a54bb75b5209d2c44ca43e5cc94bf05f6ff195 Mon Sep 17 00:00:00 2001 From: camUrban Date: Tue, 11 Jul 2023 13:38:19 -0400 Subject: [PATCH 06/24] I modified the mesh_wing function for the new custom planes. --- .idea/dictionaries/camer.xml | 1 + pterasoftware/functions.py | 23 ++++ pterasoftware/geometry.py | 14 ++- pterasoftware/meshing.py | 214 +++++++++++++++++++++-------------- 4 files changed, 160 insertions(+), 92 deletions(-) diff --git a/.idea/dictionaries/camer.xml b/.idea/dictionaries/camer.xml index e797e4ef..7f209c62 100644 --- a/.idea/dictionaries/camer.xml +++ b/.idea/dictionaries/camer.xml @@ -159,6 +159,7 @@ viridis vortices vstab + vstack weisstein wetted whitcomb diff --git a/pterasoftware/functions.py b/pterasoftware/functions.py index 5c222b6c..c96480d1 100644 --- a/pterasoftware/functions.py +++ b/pterasoftware/functions.py @@ -653,3 +653,26 @@ def numba_1d_explicit_cross(vectors_1, vectors_2): vectors_1[i, 0] * vectors_2[i, 1] - vectors_1[i, 1] * vectors_2[i, 0] ) return crosses + + +# ToDo: Document this method. +def reflect_point_across_plane(point, plane_unit_normal, plane_point): + """ + + :param point: + :param plane_unit_normal: + :param plane_point: + :return: + """ + point = np.expand_dims(point, -1) + plane_unit_normal = np.expand_dims(plane_unit_normal, -1) + plane_point = np.expand_dims(plane_point, -1) + + plane_unit_normal_transpose = np.transpose(plane_unit_normal) + identity = np.eye(point.size) + + householder = identity - 2 * plane_unit_normal @ plane_unit_normal_transpose + + reflected_point = plane_point + householder @ point + + return np.squeeze(reflected_point) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index 3980b5b9..f639a524 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -572,7 +572,7 @@ def __init__( self.leading_edge = np.array([x_le, y_le, z_le]) # ToDo: Document this - self._unit_chordwise_vector = None + self.unit_chordwise_vector = None # Catch bad values of the chord length. if self.chord <= 0: @@ -601,14 +601,20 @@ def trailing_edge(self): self.twist * np.pi / 180, np.array([0, 1, 0]) ) - chordwise_vector = self.chord * self._unit_chordwise_vector + chordwise_vector = self.chord * self.unit_chordwise_vector # Use the rotation matrix and the leading edge coordinates to calculate the # trailing edge coordinates. return self.leading_edge + twist_rotation_matrix @ chordwise_vector - # ToDo: Add properties for the wing cross section based on what's needed in the - # meshing.py functions. + # ToDo: Document this + @property + def unit_up_vector(self): + """ + + :return: + """ + return np.cross(self.unit_chordwise_vector, self.unit_normal_vector) class Airfoil: diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index ef523d97..5cf8a93b 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -94,89 +94,101 @@ def mesh_wing(wing): num_wing_cross_sections = len(wing.wing_cross_sections) num_wing_sections = num_wing_cross_sections - 1 - # Then, construct the normal directions for each wing cross section. Make the - # normals for the inner wing cross sections, where we need to merge directions. - if num_wing_cross_sections > 2: - # Add together the adjacent normalized wing section quarter chords projected - # onto the YZ plane. - wing_sections_local_normals = ( - normalized_projected_quarter_chords[:-1, :] - + normalized_projected_quarter_chords[1:, :] - ) - - # Create a list of the magnitudes of the summed adjacent normalized wing - # section quarter chords projected onto the YZ plane. - wing_sections_local_normals_len = np.linalg.norm( - wing_sections_local_normals, axis=1 - ) - - # Convert the list to a column vector. - transpose_wing_sections_local_normals_len = np.expand_dims( - wing_sections_local_normals_len, axis=1 - ) - - # Normalize the summed adjacent normalized wing section quarter chords - # projected onto the YZ plane by their magnitudes. - wing_sections_local_unit_normals = ( - wing_sections_local_normals / transpose_wing_sections_local_normals_len - ) - - # Vertically stack the first normalized wing section quarter chord, the inner - # normalized wing section quarter chords, and the last normalized wing - # section quarter chord. - wing_sections_local_unit_normals = np.vstack( - ( - normalized_projected_quarter_chords[0, :], - wing_sections_local_unit_normals, - normalized_projected_quarter_chords[-1, :], - ) - ) - else: - # Vertically stack the first and last normalized wing section quarter chords. - wing_sections_local_unit_normals = np.vstack( - ( - normalized_projected_quarter_chords[0, :], - normalized_projected_quarter_chords[-1, :], - ) - ) - - # FixMe: The back direction is just the chord vector, which combines the - # chord length and the unit chordwise vectors. + # ToDo: Delete this commented section after testing. + # # Then, construct the normal directions for each wing cross section. Make the + # # normals for the inner wing cross sections, where we need to merge directions. + # if num_wing_cross_sections > 2: + # # Add together the adjacent normalized wing section quarter chords projected + # # onto the YZ plane. + # wing_sections_local_normals = ( + # normalized_projected_quarter_chords[:-1, :] + # + normalized_projected_quarter_chords[1:, :] + # ) + # + # # Create a list of the magnitudes of the summed adjacent normalized wing + # # section quarter chords projected onto the YZ plane. + # wing_sections_local_normals_len = np.linalg.norm( + # wing_sections_local_normals, axis=1 + # ) + # + # # Convert the list to a column vector. + # transpose_wing_sections_local_normals_len = np.expand_dims( + # wing_sections_local_normals_len, axis=1 + # ) + # + # # Normalize the summed adjacent normalized wing section quarter chords + # # projected onto the YZ plane by their magnitudes. + # wing_sections_local_unit_normals = ( + # wing_sections_local_normals / transpose_wing_sections_local_normals_len + # ) + # + # # Vertically stack the first normalized wing section quarter chord, the inner + # # normalized wing section quarter chords, and the last normalized wing + # # section quarter chord. + # wing_sections_local_unit_normals = np.vstack( + # ( + # normalized_projected_quarter_chords[0, :], + # wing_sections_local_unit_normals, + # normalized_projected_quarter_chords[-1, :], + # ) + # ) + # else: + # # Vertically stack the first and last normalized wing section quarter chords. + # wing_sections_local_unit_normals = np.vstack( + # ( + # normalized_projected_quarter_chords[0, :], + # normalized_projected_quarter_chords[-1, :], + # ) + # ) + + # ToDo: Delete this commented section after testing. # Then, construct the back directions for each wing cross section. - wing_cross_sections_local_back_vectors = ( - wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges - ) + # wing_cross_sections_local_back_vectors = ( + # wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges + # ) - # FixMe: This should just be a list of the chords lengths. + # ToDo: Delete this commented section after testing. # Create a list of the wing cross section chord lengths. - wing_cross_sections_chord_lengths = np.linalg.norm( - wing_cross_sections_local_back_vectors, axis=1 - ) + # wing_cross_sections_chord_lengths = np.linalg.norm( + # wing_cross_sections_local_back_vectors, axis=1 + # ) + wing_cross_sections_chord_lengths = [ + wing_cross_section.chord for wing_cross_section in wing.wing_cross_sections + ] # Convert the list to a column vector. - transpose_wing_cross_sections_chord_lengths = np.expand_dims( - wing_cross_sections_chord_lengths, axis=1 - ) + # transpose_wing_cross_sections_chord_lengths = np.expand_dims( + # wing_cross_sections_chord_lengths, axis=1 + # ) - # FixMe: This should just be a list of the unit chordwise vectors. + # ToDo: Delete this commented section after testing. # Normalize the wing cross section back vectors by their magnitudes. - wing_cross_sections_local_back_unit_vectors = ( - wing_cross_sections_local_back_vectors - / transpose_wing_cross_sections_chord_lengths - ) + # wing_cross_sections_local_back_unit_vectors = ( + # wing_cross_sections_local_back_vectors + # / transpose_wing_cross_sections_chord_lengths + # ) + wing_cross_sections_unit_back_vectors = [ + wing_cross_section.unit_back_vector + for wing_cross_section in wing.wing_cross_sections + ] + # ToDo: Delete this commented section after testing. # Then, construct the up direction for each wing cross section. - wing_cross_sections_local_up_unit_vectors = np.cross( - wing_cross_sections_local_back_unit_vectors, - wing_sections_local_unit_normals, - axis=1, - ) + # wing_cross_sections_local_up_unit_vectors = np.cross( + # wing_cross_sections_local_back_unit_vectors, + # wing_sections_local_unit_normals, + # axis=1, + # ) + wing_cross_sections_unit_up_vectors = [ + wing_cross_section.unit_up_vector + for wing_cross_section in wing.wing_cross_sections + ] - # FixMe: Modify this to account for the new plane implementation. + # ToDo: Delete this commented section after testing. # If the wing is symmetric, set the local up position of the root cross section # to be the in local Z direction. - if wing.symmetric: - wing_cross_sections_local_up_unit_vectors[0] = np.array([0, 0, 1]) + # if wing.symmetric: + # wing_cross_sections_unit_up_vectors[0] = np.array([0, 0, 1]) # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to # compensate). @@ -237,8 +249,8 @@ def mesh_wing(wing): back_outer_vertices, ] = get_panel_vertices( inner_wing_cross_section_num, - wing_cross_sections_local_back_unit_vectors, - wing_cross_sections_local_up_unit_vectors, + wing_cross_sections_unit_back_vectors, + wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, @@ -324,8 +336,8 @@ def mesh_wing(wing): back_outer_vertices, ] = get_panel_vertices( inner_wing_cross_section_num, - wing_cross_sections_local_back_unit_vectors, - wing_cross_sections_local_up_unit_vectors, + wing_cross_sections_unit_back_vectors, + wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, @@ -357,19 +369,45 @@ def mesh_wing(wing): ) ) - # ToDo: Update this with the new plane formulation. - # Reflect the vertices across the XZ plane. - front_inner_vertices_reflected = front_inner_vertices * np.array([1, -1, 1]) - front_outer_vertices_reflected = front_outer_vertices * np.array([1, -1, 1]) - back_inner_vertices_reflected = back_inner_vertices * np.array([1, -1, 1]) - back_outer_vertices_reflected = back_outer_vertices * np.array([1, -1, 1]) - - # ToDo: Update this with the new plane formulation. - # Shift the reflected vertices based on the wing's leading edge position. - front_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le - front_outer_vertices_reflected[:, :, 1] += 2 * wing.y_le - back_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le - back_outer_vertices_reflected[:, :, 1] += 2 * wing.y_le + # ToDo: Delete the commented section after testing. + front_inner_vertices_reflected = np.apply_along_axis( + functions.reflect_point_across_plane, + -1, + front_inner_vertices, + wing.symmetry_unit_normal_vector, + wing.leading_edge, + ) + front_outer_vertices_reflected = np.apply_along_axis( + functions.reflect_point_across_plane, + -1, + front_outer_vertices, + wing.symmetry_unit_normal_vector, + wing.leading_edge, + ) + back_inner_vertices_reflected = np.apply_along_axis( + functions.reflect_point_across_plane, + -1, + back_inner_vertices, + wing.symmetry_unit_normal_vector, + wing.leading_edge, + ) + back_outer_vertices_reflected = np.apply_along_axis( + functions.reflect_point_across_plane, + -1, + back_outer_vertices, + wing.symmetry_unit_normal_vector, + wing.leading_edge, + ) + # # Reflect the vertices across the XZ plane. + # front_inner_vertices_reflected = front_inner_vertices * np.array([1, -1, 1]) + # front_outer_vertices_reflected = front_outer_vertices * np.array([1, -1, 1]) + # back_inner_vertices_reflected = back_inner_vertices * np.array([1, -1, 1]) + # back_outer_vertices_reflected = back_outer_vertices * np.array([1, -1, 1]) + # # Shift the reflected vertices based on the wing's leading edge position. + # front_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le + # front_outer_vertices_reflected[:, :, 1] += 2 * wing.y_le + # back_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le + # back_outer_vertices_reflected[:, :, 1] += 2 * wing.y_le # Get the reflected wing section's panels. wing_section_panels = get_wing_section_panels( From 660a9b0ac07bfa0b2d9f3b6beb3fb073d0c3bf05 Mon Sep 17 00:00:00 2001 From: camUrban Date: Tue, 11 Jul 2023 21:20:20 -0400 Subject: [PATCH 07/24] I added a simple example to continue debugging. --- examples/simple_flapper_uvlm.py | 170 +++++++++++++++ pterasoftware/geometry.py | 2 +- pterasoftware/meshing.py | 370 +++++++++++++++++--------------- 3 files changed, 362 insertions(+), 180 deletions(-) create mode 100644 examples/simple_flapper_uvlm.py diff --git a/examples/simple_flapper_uvlm.py b/examples/simple_flapper_uvlm.py new file mode 100644 index 00000000..99a4cb45 --- /dev/null +++ b/examples/simple_flapper_uvlm.py @@ -0,0 +1,170 @@ +"""This is script is an example of how to run Ptera Software's unsteady ring vortex +lattice method solver on a custom airplane with variable geometry. """ + +import pterasoftware as ps +import numpy as np + +example_airplane = ps.geometry.Airplane( + wings=[ + ps.geometry.Wing( + name="Caudal Fin", + symmetric=True, + num_chordwise_panels=3, + chordwise_spacing="uniform", + symmetry_unit_normal_vector=np.array([0, 1, 0]), + unit_chordwise_vector=np.array([1, 0, 0]), + wing_cross_sections=[ + ps.geometry.WingCrossSection( + x_le=0.0, + y_le=0.0, + z_le=0.0, + twist=0.0, + unit_normal_vector=np.array([0, 1, 0]), + num_spanwise_panels=3, + spanwise_spacing="cosine", + chord=1.0, + airfoil=ps.geometry.Airfoil( + name="naca0012", + ), + ), + ps.geometry.WingCrossSection( + x_le=0.0, + y_le=5.0, + z_le=0.0, + chord=1.0, + twist=0.0, + unit_normal_vector=np.array([0, 1, 0]), + airfoil=ps.geometry.Airfoil( + name="naca0012", + ), + ), + ], + ), + ], +) + +main_wing_root_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[0], + sweeping_amplitude=0.0, + sweeping_period=0.0, + sweeping_spacing="sine", + pitching_amplitude=0.0, + pitching_period=0.0, + pitching_spacing="sine", + heaving_amplitude=0.0, + heaving_period=0.0, + heaving_spacing="sine", +) + +main_wing_tip_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[1], + sweeping_amplitude=0.0, + sweeping_period=0.0, + sweeping_spacing="sine", + pitching_amplitude=0.0, + pitching_period=0.0, + pitching_spacing="sine", + heaving_amplitude=0.0, + heaving_period=0.0, + heaving_spacing="sine", +) + +main_wing_movement = ps.movement.WingMovement( + base_wing=example_airplane.wings[0], + wing_cross_sections_movements=[ + main_wing_root_wing_cross_section_movement, + main_wing_tip_wing_cross_section_movement, + ], + x_le_amplitude=0.0, + x_le_period=0.0, + x_le_spacing="sine", + y_le_amplitude=0.0, + y_le_period=0.0, + y_le_spacing="sine", + z_le_amplitude=0.0, + z_le_period=0.0, + z_le_spacing="sine", +) + +del main_wing_root_wing_cross_section_movement +del main_wing_tip_wing_cross_section_movement + +airplane_movement = ps.movement.AirplaneMovement( + base_airplane=example_airplane, + wing_movements=[main_wing_movement], + x_ref_amplitude=0.0, + x_ref_period=0.0, + x_ref_spacing="sine", + y_ref_amplitude=0.0, + y_ref_period=0.0, + y_ref_spacing="sine", + z_ref_amplitude=0.0, + z_ref_period=0.0, + z_ref_spacing="sine", +) + +del main_wing_movement + +example_operating_point = ps.operating_point.OperatingPoint( + density=1.225, + beta=0.0, + velocity=10.0, + alpha=0.0, + nu=15.06e-6, +) + +operating_point_movement = ps.movement.OperatingPointMovement( + base_operating_point=example_operating_point, + velocity_amplitude=0.0, + velocity_period=0.0, + velocity_spacing="sine", +) + +movement = ps.movement.Movement( + airplane_movements=[airplane_movement], + operating_point_movement=operating_point_movement, + num_steps=None, + delta_time=None, +) + +del airplane_movement +del operating_point_movement + +example_problem = ps.problems.UnsteadyProblem( + movement=movement, +) + +example_solver = ( + ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( + unsteady_problem=example_problem, + ) +) + +del example_problem + +example_solver.run( + logging_level="Warning", + prescribed_wake=True, +) + +# ps.output.animate( +# unsteady_solver=example_solver, +# scalar_type="lift", +# show_wake_vortices=True, +# save=False, +# ) + +ps.output.print_unsteady_results(unsteady_solver=example_solver) + +ps.output.draw( + solver=example_solver, + scalar_type="lift", + show_wake_vortices=True, + save=False, +) + +# ps.output.plot_results_versus_time( +# unsteady_solver=example_solver, +# show=True, +# save=False, +# ) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index f639a524..e8edd1bb 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -312,7 +312,7 @@ def __init__( self.num_panels = self.num_spanwise_panels * self.num_chordwise_panels for wing_cross_section in wing_cross_sections: - wing_cross_section._unit_chordwise_vector = self.unit_chordwise_vector + wing_cross_section.unit_chordwise_vector = self.unit_chordwise_vector # Initialize the panels attribute. Then mesh the wing, which will # populate this attribute. diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index 5cf8a93b..c975c045 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -24,12 +24,11 @@ and trailing edges. These quarter chords are also projected on to the YZ plane and normalized by their magnitudes. - ToDo: Update this method's documentation. get_transpose_mcl_vectors: This function takes in the inner and outer airfoils of a wing cross section and its chordwise coordinates. It returns a list of four - vectors column vectors. They are, in order, the inner airfoil's local up - direction, the inner airfoil's local back direction, the outer airfoil's local up - direction, and the outer airfoil's local back direction. + column vectors. They are, in order, the inner airfoil's local up direction, + the inner airfoil's local back direction, the outer airfoil's local up direction, + and the outer airfoil's local back direction. get_wing_section_panels: This function takes in arrays panel attributes and returns a 2D array of panel objects. @@ -86,9 +85,11 @@ def mesh_wing(wing): ) ) - normalized_projected_quarter_chords = get_normalized_projected_quarter_chords( - wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges - ) + # ToDo: Delete this commented command after testing. + # wing_cross_sections_scaling_factors, + # normalized_projected_quarter_chords = get_normalized_projected_quarter_chords( + # wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges + # ) # Get the number of wing cross sections and wing sections. num_wing_cross_sections = len(wing.wing_cross_sections) @@ -152,9 +153,9 @@ def mesh_wing(wing): # wing_cross_sections_chord_lengths = np.linalg.norm( # wing_cross_sections_local_back_vectors, axis=1 # ) - wing_cross_sections_chord_lengths = [ - wing_cross_section.chord for wing_cross_section in wing.wing_cross_sections - ] + wing_cross_sections_chord_lengths = np.array( + [wing_cross_section.chord for wing_cross_section in wing.wing_cross_sections] + ) # Convert the list to a column vector. # transpose_wing_cross_sections_chord_lengths = np.expand_dims( @@ -163,26 +164,30 @@ def mesh_wing(wing): # ToDo: Delete this commented section after testing. # Normalize the wing cross section back vectors by their magnitudes. - # wing_cross_sections_local_back_unit_vectors = ( + # wing_cross_sections_local_unit_chordwise_vectors = ( # wing_cross_sections_local_back_vectors # / transpose_wing_cross_sections_chord_lengths # ) - wing_cross_sections_unit_back_vectors = [ - wing_cross_section.unit_back_vector - for wing_cross_section in wing.wing_cross_sections - ] + wing_cross_sections_unit_chordwise_vectors = np.vstack( + [ + wing_cross_section.unit_chordwise_vector + for wing_cross_section in wing.wing_cross_sections + ] + ) # ToDo: Delete this commented section after testing. # Then, construct the up direction for each wing cross section. - # wing_cross_sections_local_up_unit_vectors = np.cross( - # wing_cross_sections_local_back_unit_vectors, + # wing_cross_sections_local_unit_up_vectors = np.cross( + # wing_cross_sections_local_unit_chordwise_vectors, # wing_sections_local_unit_normals, # axis=1, # ) - wing_cross_sections_unit_up_vectors = [ - wing_cross_section.unit_up_vector - for wing_cross_section in wing.wing_cross_sections - ] + wing_cross_sections_unit_up_vectors = np.vstack( + [ + wing_cross_section.unit_up_vector + for wing_cross_section in wing.wing_cross_sections + ] + ) # ToDo: Delete this commented section after testing. # If the wing is symmetric, set the local up position of the root cross section @@ -190,11 +195,12 @@ def mesh_wing(wing): # if wing.symmetric: # wing_cross_sections_unit_up_vectors[0] = np.array([0, 0, 1]) + # ToDo: Delete this commented line after testing. # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to # compensate). - wing_cross_sections_scaling_factors = get_wing_cross_section_scaling_factors( - wing.symmetric, normalized_projected_quarter_chords - ) + # wing_cross_sections_scaling_factors = get_wing_cross_section_scaling_factors( + # wing.symmetric, normalized_projected_quarter_chords + # ) # Initialize an empty array that will hold the panels of this wing. It currently # has 0 columns and M rows, where M is the number of the wing's chordwise panels. @@ -249,10 +255,11 @@ def mesh_wing(wing): back_outer_vertices, ] = get_panel_vertices( inner_wing_cross_section_num, - wing_cross_sections_unit_back_vectors, + wing_cross_sections_unit_chordwise_vectors, wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - wing_cross_sections_scaling_factors, + # ToDo: Delete this commented line after testing. + # wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -336,10 +343,11 @@ def mesh_wing(wing): back_outer_vertices, ] = get_panel_vertices( inner_wing_cross_section_num, - wing_cross_sections_unit_back_vectors, + wing_cross_sections_unit_chordwise_vectors, wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - wing_cross_sections_scaling_factors, + # ToDo: Delete this commented line after testing. + # wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -423,8 +431,7 @@ def mesh_wing(wing): # of the wing's panel matrix. wing_panels = np.hstack((np.flip(wing_section_panels, axis=1), wing_panels)) - # Iterate through the panels and populate their left and right edge flags. Also - # populate their local position attributes. + # Iterate through the panels and populate their local position attributes. for chordwise_position in range(wing.num_chordwise_panels): for spanwise_position in range(wing.num_spanwise_panels): this_panel = wing_panels[chordwise_position, spanwise_position] @@ -443,78 +450,80 @@ def mesh_wing(wing): wing.panels = wing_panels -# ToDo: Update this method with the new plane formulation. -def get_wing_cross_section_scaling_factors( - symmetric, wing_section_quarter_chords_proj_yz_norm -): - """Get the scaling factors for each wing cross section. These factors allow the - cross sections to intersect correctly at dihedral breaks. - - :param symmetric: bool - This parameter is True if the wing is symmetric and False otherwise. - :param wing_section_quarter_chords_proj_yz_norm: array - This parameter is a (N x 3) array of floats, where N is the number of wing - sections (1 less than the number of wing cross sections). For each wing - section, this parameter contains the 3 components of the normalized quarter - chord projected onto the YZ plane. - :return wing_cross_section_scaling_factors: array - This function returns a 1D array of floats of length (N + 1), where N is the - number of wing sections. These values are the corresponding scaling factor - for each of the wing's wing cross sections. These scaling factors stretch - their profiles to account for changes in dihedral at a give wing cross section. - """ - num_wing_cross_sections = len(wing_section_quarter_chords_proj_yz_norm) + 1 - - # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to - # compensate). - wing_cross_section_scaling_factors = np.ones(num_wing_cross_sections) - - for i in range(num_wing_cross_sections): - if i == 0: - if symmetric: - first_chord_norm = wing_section_quarter_chords_proj_yz_norm[0] - mirrored_first_chord_norm = first_chord_norm * np.array([1, 1, -1]) - - product = first_chord_norm * mirrored_first_chord_norm - collapsed_product = np.sum(product) - this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) - else: - this_scaling_factor = 1 - elif i == num_wing_cross_sections - 1: - this_scaling_factor = 1 - else: - this_chord_norm = wing_section_quarter_chords_proj_yz_norm[i - 1, :] - next_chord_norm = wing_section_quarter_chords_proj_yz_norm[i, :] - - product = this_chord_norm * next_chord_norm - collapsed_product = np.sum(product) - this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) - - wing_cross_section_scaling_factors[i] = this_scaling_factor - - return wing_cross_section_scaling_factors +# # ToDo: Delete this commented function after testing. +# def get_wing_cross_section_scaling_factors( +# symmetric, wing_section_quarter_chords_proj_yz_norm +# ): +# """Get the scaling factors for each wing cross section. These factors allow the +# cross sections to intersect correctly at dihedral breaks. +# +# :param symmetric: bool +# This parameter is True if the wing is symmetric and False otherwise. +# :param wing_section_quarter_chords_proj_yz_norm: array +# This parameter is a (N x 3) array of floats, where N is the number of wing +# sections (1 less than the number of wing cross sections). For each wing +# section, this parameter contains the 3 components of the normalized quarter +# chord projected onto the YZ plane. +# :return wing_cross_section_scaling_factors: array +# This function returns a 1D array of floats of length (N + 1), where N is the +# number of wing sections. These values are the corresponding scaling factor +# for each of the wing's wing cross sections. These scaling factors stretch +# their profiles to account for changes in dihedral at a give wing cross section. +# """ +# num_wing_cross_sections = len(wing_section_quarter_chords_proj_yz_norm) + 1 +# +# # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to +# # compensate). +# wing_cross_section_scaling_factors = np.ones(num_wing_cross_sections) +# +# for i in range(num_wing_cross_sections): +# if i == 0: +# if symmetric: +# first_chord_norm = wing_section_quarter_chords_proj_yz_norm[0] +# mirrored_first_chord_norm = first_chord_norm * np.array([1, 1, -1]) +# +# product = first_chord_norm * mirrored_first_chord_norm +# collapsed_product = np.sum(product) +# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) +# else: +# this_scaling_factor = 1 +# elif i == num_wing_cross_sections - 1: +# this_scaling_factor = 1 +# else: +# this_chord_norm = wing_section_quarter_chords_proj_yz_norm[i - 1, :] +# next_chord_norm = wing_section_quarter_chords_proj_yz_norm[i, :] +# +# product = this_chord_norm * next_chord_norm +# collapsed_product = np.sum(product) +# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) +# +# wing_cross_section_scaling_factors[i] = this_scaling_factor +# +# return wing_cross_section_scaling_factors def get_panel_vertices( inner_wing_cross_section_num, - wing_cross_sections_local_back_unit_vectors, - wing_cross_sections_local_up_unit_vectors, + wing_cross_sections_unit_chordwise_vectors, + wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - wing_cross_sections_scaling_factors, + # ToDo: Delete this argument after testing. + # wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, ): + # ToDo: Update this documentation. """This function calculates the vertices of the panels on a wing. :param inner_wing_cross_section_num: int This parameter is the integer index of this wing's section's inner wing cross section. - :param wing_cross_sections_local_back_unit_vectors: array + :param wing_cross_sections_unit_chordwise_vectors: array This parameter is an array of floats with size (X, 3), where X is this wing's number of wing cross sections. It holds two unit vectors that correspond to the wing cross sections' local-back directions, written in the body frame. - :param wing_cross_sections_local_up_unit_vectors: array + :param wing_cross_sections_unit_up_vectors: array This parameter is an array of floats with size (X, 3), where X is this wing's number of wing cross sections. It holds two unit vectors that correspond to the wing cross sections' local-up directions, written in the body frame. @@ -522,11 +531,12 @@ def get_panel_vertices( This parameter is a 1D array of floats with length X, where X is this wing's number of wing cross sections. It holds the chord lengths of this wing's wing cross section in meters. - :param wing_cross_sections_scaling_factors: array - This parameter is a 1D array of floats with length X, where X is this wing's - number of wing cross sections. It holds this wing's wing cross sections' - scaling factors. These factors stretch the shape of the wing cross sections - to account for changes in dihedral at a give wing cross section. + ToDo: Delete this commented parameter after testing + # :param wing_cross_sections_scaling_factors: array + # This parameter is a 1D array of floats with length X, where X is this wing's + # number of wing cross sections. It holds this wing's wing cross sections' + # scaling factors. These factors stretch the shape of the wing cross sections + # to account for changes in dihedral at a give wing cross section. :param wing_cross_sections_leading_edges: array This parameter is an array of floats with size (Xx3), where X is this wing's number of wing cross sections. It holds the coordinates of the leading edge @@ -559,19 +569,20 @@ def get_panel_vertices( # Convert the inner wing cross section's non dimensional local back airfoil frame # coordinates to meshed wing coordinates. - inner_wing_cross_section_mcl_local_back = ( - wing_cross_sections_local_back_unit_vectors[inner_wing_cross_section_num, :] + inner_wing_cross_section_mcl_back = ( + wing_cross_sections_unit_chordwise_vectors[inner_wing_cross_section_num, :] * transpose_inner_mcl_back_vector * wing_cross_sections_chord_lengths[inner_wing_cross_section_num] ) # Convert the inner wing cross section's non dimensional local up airfoil frame # coordinates to meshed wing coordinates. - inner_wing_cross_section_mcl_local_up = ( - wing_cross_sections_local_up_unit_vectors[inner_wing_cross_section_num, :] + inner_wing_cross_section_mcl_up = ( + wing_cross_sections_unit_up_vectors[inner_wing_cross_section_num, :] * transpose_inner_mcl_up_vector - * wing_cross_sections_chord_lengths[inner_wing_cross_section_num] - * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] + * wing_cross_sections_chord_lengths[inner_wing_cross_section_num], + # ToDo: Delete this line after testing. + # * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] ) # Define the index of this wing section's outer wing cross section. @@ -579,35 +590,36 @@ def get_panel_vertices( # Convert the outer wing cross section's non dimensional local back airfoil frame # coordinates to meshed wing coordinates. - outer_wing_cross_section_mcl_local_back = ( - wing_cross_sections_local_back_unit_vectors[outer_wing_cross_section_num, :] + outer_wing_cross_section_mcl_back = ( + wing_cross_sections_unit_chordwise_vectors[outer_wing_cross_section_num, :] * transpose_outer_mcl_back_vector * wing_cross_sections_chord_lengths[outer_wing_cross_section_num] ) # Convert the outer wing cross section's non dimensional local up airfoil frame # coordinates to meshed wing coordinates. - outer_wing_cross_section_mcl_local_up = ( - wing_cross_sections_local_up_unit_vectors[outer_wing_cross_section_num, :] + outer_wing_cross_section_mcl_up = ( + wing_cross_sections_unit_up_vectors[outer_wing_cross_section_num, :] * transpose_outer_mcl_up_vector * wing_cross_sections_chord_lengths[outer_wing_cross_section_num] - * wing_cross_sections_scaling_factors[outer_wing_cross_section_num] + # ToDo: Delete this line after testing. + # * wing_cross_sections_scaling_factors[outer_wing_cross_section_num] ) # Convert the inner wing cross section's meshed wing coordinates to absolute # coordinates. This is size M x 3, where M is the number of chordwise points. inner_wing_cross_section_mcl = ( wing_cross_sections_leading_edges[inner_wing_cross_section_num, :] - + inner_wing_cross_section_mcl_local_back - + inner_wing_cross_section_mcl_local_up + + inner_wing_cross_section_mcl_back + + inner_wing_cross_section_mcl_up ) # Convert the outer wing cross section's meshed wing coordinates to absolute # coordinates. This is size M x 3, where M is the number of chordwise points. outer_wing_cross_section_mcl = ( wing_cross_sections_leading_edges[outer_wing_cross_section_num, :] - + outer_wing_cross_section_mcl_local_back - + outer_wing_cross_section_mcl_local_up + + outer_wing_cross_section_mcl_back + + outer_wing_cross_section_mcl_up ) # Make section_mcl_coordinates: M x N x 3 array of mean camberline coordinates. @@ -682,83 +694,83 @@ def get_panel_vertices( ] -# ToDo: Update this method with the new plane formulation. -def get_normalized_projected_quarter_chords( - wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges -): - """This method returns the quarter chords of a collection of wing cross sections - based on the coordinates of their leading and trailing edges. These quarter - chords are also projected on to the YZ plane and normalized by their magnitudes. - - :param wing_cross_sections_leading_edges: array - This parameter is an array of floats with size (X, 3), where X is this wing's - number of wing cross sections. For each cross section, this array holds the - body-frame coordinates of its leading edge point in meters. - :param wing_cross_sections_trailing_edges: array - This parameter is an array of floats with size (X, 3), where X is this wing's - number of wing cross sections. For each cross section, this array holds the - body-frame coordinates of its trailing edge point in meters. - :return normalized_projected_quarter_chords: array - This functions returns an array of floats with size (X - 1, 3), where X is - this wing's number of wing cross sections. This array holds each wing - section's quarter chords projected on to the YZ plane and normalized by their - magnitudes. - """ - # Get the location of each wing cross section's quarter chord point. - wing_cross_sections_quarter_chord_points = ( - wing_cross_sections_leading_edges - + 0.25 - * (wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges) - ) - - # Get a (L - 1) x 3 array of vectors connecting the wing cross section quarter - # chord points, where L is the number of wing cross sections. - quarter_chords = ( - wing_cross_sections_quarter_chord_points[1:, :] - - wing_cross_sections_quarter_chord_points[:-1, :] - ) - - # Get directions for transforming 2D airfoil data to 3D by the following steps. - # - # Project quarter chords onto YZ plane and normalize. - # - # Create an L x 2 array with just the y and z components of this wing section's - # quarter chord vectors. - projected_quarter_chords = quarter_chords[:, 1:] - - # Create a list of the lengths of each row of the projected_quarter_chords array. - projected_quarter_chords_len = np.linalg.norm(projected_quarter_chords, axis=1) - - # Convert projected_quarter_chords_len into a column vector. - transpose_projected_quarter_chords_len = np.expand_dims( - projected_quarter_chords_len, axis=1 - ) - # Normalize the coordinates by the magnitudes - normalized_projected_quarter_chords = ( - projected_quarter_chords / transpose_projected_quarter_chords_len - ) - - # Create a column vector of all zeros with height equal to the number of quarter - # chord vectors - column_of_zeros = np.zeros((len(quarter_chords), 1)) - - # Horizontally stack the zero column vector with the - # normalized_projected_quarter_chords to give each normalized projected quarter - # chord an X coordinate. - normalized_projected_quarter_chords = np.hstack( - (column_of_zeros, normalized_projected_quarter_chords) - ) - - return normalized_projected_quarter_chords +# ToDo: Delete this commented function after testing. +# def get_normalized_projected_quarter_chords( +# wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges +# ): +# # ToDo: Update this docstring to swap mentions of YZ plane for the custom plane. +# """This method returns the quarter chords of a collection of wing cross sections +# based on the coordinates of their leading and trailing edges. These quarter +# chords are also projected on to the YZ plane and normalized by their magnitudes. +# +# :param wing_cross_sections_leading_edges: array +# This parameter is an array of floats with size (X, 3), where X is this wing's +# number of wing cross sections. For each cross section, this array holds the +# body-frame coordinates of its leading edge point in meters. +# :param wing_cross_sections_trailing_edges: array +# This parameter is an array of floats with size (X, 3), where X is this wing's +# number of wing cross sections. For each cross section, this array holds the +# body-frame coordinates of its trailing edge point in meters. +# :return normalized_projected_quarter_chords: array +# This functions returns an array of floats with size (X - 1, 3), where X is +# this wing's number of wing cross sections. This array holds each wing +# section's quarter chords projected on to the YZ plane and normalized by their +# magnitudes. +# """ +# # Get the location of each wing cross section's quarter chord point. +# wing_cross_sections_quarter_chord_points = ( +# wing_cross_sections_leading_edges +# + 0.25 +# * (wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges) +# ) +# +# # Get a (L - 1) x 3 array of vectors connecting the wing cross section quarter +# # chord points, where L is the number of wing cross sections. +# quarter_chords = ( +# wing_cross_sections_quarter_chord_points[1:, :] +# - wing_cross_sections_quarter_chord_points[:-1, :] +# ) +# +# # Get directions for transforming 2D airfoil data to 3D by the following steps. +# # +# # Project quarter chords onto YZ plane and normalize. +# # +# # Create an L x 2 array with just the y and z components of this wing section's +# # quarter chord vectors. +# projected_quarter_chords = quarter_chords[:, 1:] +# +# # Create a list of the lengths of each row of the projected_quarter_chords array. +# projected_quarter_chords_len = np.linalg.norm(projected_quarter_chords, axis=1) +# +# # Convert projected_quarter_chords_len into a column vector. +# transpose_projected_quarter_chords_len = np.expand_dims( +# projected_quarter_chords_len, axis=1 +# ) +# # Normalize the coordinates by the magnitudes +# normalized_projected_quarter_chords = ( +# projected_quarter_chords / transpose_projected_quarter_chords_len +# ) +# +# # Create a column vector of all zeros with height equal to the number of quarter +# # chord vectors +# column_of_zeros = np.zeros((len(quarter_chords), 1)) +# +# # Horizontally stack the zero column vector with the +# # normalized_projected_quarter_chords to give each normalized projected quarter +# # chord an X coordinate. +# normalized_projected_quarter_chords = np.hstack( +# (column_of_zeros, normalized_projected_quarter_chords) +# ) +# +# return normalized_projected_quarter_chords -# ToDo: Update this method with the new plane formulation. def get_transpose_mcl_vectors(inner_airfoil, outer_airfoil, chordwise_coordinates): """This function takes in the inner and outer airfoils of a wing cross section - and its chordwise coordinates. It returns a list of four vectors column vectors. - They are, in order, the inner airfoil's local up direction, the inner airfoil's - local back direction, the outer airfoil's local up direction, and the outer - airfoil's local back direction. + and its chordwise coordinates. It returns a list of four column vectors. They + are, in order, the inner airfoil's local up direction, the inner airfoil's local + back direction, the outer airfoil's local up direction, and the outer airfoil's + local back direction. :param inner_airfoil: Airfoil This is the wing cross section's inner airfoil object. @@ -768,7 +780,7 @@ def get_transpose_mcl_vectors(inner_airfoil, outer_airfoil, chordwise_coordinate This is a 1D array of the normalized chordwise coordinates where we'd like to sample each airfoil's mean camber line. :return: list of 4 (2x1) arrays - This is a list of four vectors column vectors. They are, in order, the inner + This is a list of four column vectors. They are, in order, the inner airfoil's local up direction, the inner airfoil's local back direction, the outer airfoil's local up direction, and the outer airfoil's local back direction. From 931e960ce5313d81a97ed4e9282609970b7ee833 Mon Sep 17 00:00:00 2001 From: camUrban Date: Tue, 11 Jul 2023 22:45:11 -0400 Subject: [PATCH 08/24] I fixed several bugs relating to symmetric wing reflection. --- examples/simple_flapper_uvlm.py | 59 ++++++++++++++++++++++++--------- pterasoftware/functions.py | 47 ++++++++++++++++++++++---- pterasoftware/geometry.py | 4 +-- pterasoftware/meshing.py | 6 ++-- 4 files changed, 88 insertions(+), 28 deletions(-) diff --git a/examples/simple_flapper_uvlm.py b/examples/simple_flapper_uvlm.py index 99a4cb45..50e6eda8 100644 --- a/examples/simple_flapper_uvlm.py +++ b/examples/simple_flapper_uvlm.py @@ -8,10 +8,12 @@ wings=[ ps.geometry.Wing( name="Caudal Fin", + x_le=4, + y_le=3, symmetric=True, num_chordwise_panels=3, chordwise_spacing="uniform", - symmetry_unit_normal_vector=np.array([0, 1, 0]), + symmetry_unit_normal_vector=np.array([0, np.sqrt(2), np.sqrt(2)]), unit_chordwise_vector=np.array([1, 0, 0]), wing_cross_sections=[ ps.geometry.WingCrossSection( @@ -19,12 +21,12 @@ y_le=0.0, z_le=0.0, twist=0.0, - unit_normal_vector=np.array([0, 1, 0]), + unit_normal_vector=np.array([0, np.sqrt(2), np.sqrt(2)]), num_spanwise_panels=3, spanwise_spacing="cosine", chord=1.0, airfoil=ps.geometry.Airfoil( - name="naca0012", + name="naca2412", ), ), ps.geometry.WingCrossSection( @@ -35,7 +37,18 @@ twist=0.0, unit_normal_vector=np.array([0, 1, 0]), airfoil=ps.geometry.Airfoil( - name="naca0012", + name="naca4012", + ), + ), + ps.geometry.WingCrossSection( + x_le=1.0, + y_le=7.0, + z_le=1.0, + chord=1.0, + twist=0.0, + unit_normal_vector=np.array([0, 1, 0]), + airfoil=ps.geometry.Airfoil( + name="naca4012", ), ), ], @@ -56,7 +69,7 @@ heaving_spacing="sine", ) -main_wing_tip_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( +main_wing_mid_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[1], sweeping_amplitude=0.0, sweeping_period=0.0, @@ -69,10 +82,24 @@ heaving_spacing="sine", ) +main_wing_tip_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( + base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[2], + sweeping_amplitude=0.0, + sweeping_period=0.0, + sweeping_spacing="sine", + pitching_amplitude=0.0, + pitching_period=0.0, + pitching_spacing="sine", + heaving_amplitude=0.0, + heaving_period=0.0, + heaving_spacing="sine", +) + main_wing_movement = ps.movement.WingMovement( base_wing=example_airplane.wings[0], wing_cross_sections_movements=[ main_wing_root_wing_cross_section_movement, + main_wing_mid_wing_cross_section_movement, main_wing_tip_wing_cross_section_movement, ], x_le_amplitude=0.0, @@ -147,22 +174,22 @@ prescribed_wake=True, ) -# ps.output.animate( -# unsteady_solver=example_solver, -# scalar_type="lift", -# show_wake_vortices=True, -# save=False, -# ) - -ps.output.print_unsteady_results(unsteady_solver=example_solver) - -ps.output.draw( - solver=example_solver, +ps.output.animate( + unsteady_solver=example_solver, scalar_type="lift", show_wake_vortices=True, save=False, ) +# ps.output.print_unsteady_results(unsteady_solver=example_solver) + +# ps.output.draw( +# solver=example_solver, +# scalar_type="lift", +# show_wake_vortices=True, +# save=False, +# ) + # ps.output.plot_results_versus_time( # unsteady_solver=example_solver, # show=True, diff --git a/pterasoftware/functions.py b/pterasoftware/functions.py index c96480d1..7e40cb88 100644 --- a/pterasoftware/functions.py +++ b/pterasoftware/functions.py @@ -664,15 +664,48 @@ def reflect_point_across_plane(point, plane_unit_normal, plane_point): :param plane_point: :return: """ - point = np.expand_dims(point, -1) - plane_unit_normal = np.expand_dims(plane_unit_normal, -1) - plane_point = np.expand_dims(plane_point, -1) + [x, y, z] = point + [a, b, c] = plane_unit_normal + d = np.dot(-plane_point, plane_unit_normal) + + ab = a * b + ac = a * c + ad = a * d + bc = b * c + bd = b * d + cd = c * d + + a2 = a**2 + b2 = b**2 + c2 = c**2 + + transformation_matrix = np.array( + [ + [1 - 2 * a2, -2 * ab, -2 * ac, -2 * ad], + [-2 * ab, 1 - 2 * b2, -2 * bc, -2 * bd], + [-2 * ac, -2 * bc, 1 - 2 * c2, -2 * cd], + [0, 0, 0, 1], + ] + ) + + expanded_coordinates = np.array([[x], [y], [z], [1]]) + + transformed_expanded_coordinates = transformation_matrix @ expanded_coordinates - plane_unit_normal_transpose = np.transpose(plane_unit_normal) - identity = np.eye(point.size) + [x_prime, y_prime, z_prime] = transformed_expanded_coordinates[:-1, 0] - householder = identity - 2 * plane_unit_normal @ plane_unit_normal_transpose + return np.array([x_prime, y_prime, z_prime]) + # point = np.expand_dims(point, -1) + # plane_unit_normal = np.expand_dims(plane_unit_normal, -1) + # plane_point = np.expand_dims(plane_point, -1) + # + # plane_unit_normal_transpose = np.transpose(plane_unit_normal) + # identity = np.eye(point.size) + # + # householder = identity - 2 * plane_unit_normal @ plane_unit_normal_transpose + # + # reflected_point = plane_point + householder @ point - reflected_point = plane_point + householder @ point + # reflected_point = householder @ point return np.squeeze(reflected_point) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index e8edd1bb..381d6aba 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -252,14 +252,14 @@ def __init__( symmetry_unit_normal_vector, wing_cross_sections[0].unit_normal_vector ): raise Exception( - "The wing's symmetry plane must be the same as its root wing cross" + "The wing's symmetry plane must be the same as its root wing cross " "section's plane." ) # Check that the root wing cross section's leading edge isn't offset from the # wing's leading edge. if np.any(wing_cross_sections[0].leading_edge): raise Exception( - "The root wing cross section's leading edge must not be offset from" + "The root wing cross section's leading edge must not be offset from " "the wing's leading edge." ) diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index c975c045..3a9b7e58 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -580,10 +580,10 @@ def get_panel_vertices( inner_wing_cross_section_mcl_up = ( wing_cross_sections_unit_up_vectors[inner_wing_cross_section_num, :] * transpose_inner_mcl_up_vector - * wing_cross_sections_chord_lengths[inner_wing_cross_section_num], - # ToDo: Delete this line after testing. - # * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] + * wing_cross_sections_chord_lengths[inner_wing_cross_section_num] ) + # ToDo: Delete this line (was in the above equation) after testing. + # * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] # Define the index of this wing section's outer wing cross section. outer_wing_cross_section_num = inner_wing_cross_section_num + 1 From 29784cc1c80c303621d7bb9783a3a88e720513ec Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 12 Jul 2023 12:31:21 -0400 Subject: [PATCH 09/24] I added a simple airplane geometry file to aid with debugging. --- examples/simple_airplane.py | 186 ++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 examples/simple_airplane.py diff --git a/examples/simple_airplane.py b/examples/simple_airplane.py new file mode 100644 index 00000000..b454e47b --- /dev/null +++ b/examples/simple_airplane.py @@ -0,0 +1,186 @@ +"""This is script is an example of how to run Ptera Software's unsteady ring vortex +lattice method solver on a simple airplane object.""" + +import pterasoftware as ps + +# import numpy as np + +size = 4 + +simple_airplane = ps.geometry.Airplane( + wings=[ + ps.geometry.Wing( + wing_cross_sections=[ + ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca0012", + coordinates=None, + repanel=True, + n_points_per_side=400, + ), + x_le=0.0, + y_le=0.0, + z_le=0.0, + chord=2.0, + # unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=int(size / 2), + spanwise_spacing="cosine", + ), + ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca0012", + coordinates=None, + repanel=True, + n_points_per_side=400, + ), + x_le=0.0, + y_le=1.0, + z_le=0.0, + chord=2.0, + # unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=int(size / 2), + spanwise_spacing="cosine", + ), + ], + name="Main Wing", + x_le=-1.0, + y_le=0.0, + z_le=0.0, + # symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + symmetric=True, + # unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=size, + chordwise_spacing="cosine", + ), + ], + name="Simple Airplane", + x_ref=0.0, + y_ref=0.0, + z_ref=0.0, + weight=0.0, + s_ref=None, + c_ref=None, + b_ref=None, +) + +wing = simple_airplane.wings[0] +panels = wing.panels +show_z = False + +[num_rows, num_cols] = panels.shape + +p_text = "" +row_num = 1 +for row_of_panels in panels: + col_num = 1 + for panel in row_of_panels: + p_text += "│" + col_num += 1 + + [this_fl_x, this_fl_y, this_fl_z] = panel.front_left_vertex + [this_fr_x, this_fr_y, this_fr_z] = panel.front_right_vertex + + f_text = "({: .5e}, {: .5e}" + if show_z: + f_text += ", {: .5e}" + f_text += ") " + f_text += "({: .5e}, {: .5e}" + if show_z: + f_text += ", {: .5e}" + f_text += ")" + + if show_z: + this_f_text = f_text.format( + this_fl_x, + this_fl_y, + this_fl_z, + this_fr_x, + this_fr_y, + this_fr_z, + ) + else: + this_f_text = f_text.format( + this_fl_x, + this_fl_y, + this_fr_x, + this_fr_y, + ) + + p_text += this_f_text + + p_text += "│\n" + for panel in row_of_panels: + p_text += "│" + + [this_bl_x, this_bl_y, this_bl_z] = panel.back_left_vertex + [this_br_x, this_br_y, this_br_z] = panel.back_right_vertex + + b_text = "({: .5e}, {: .5e}" + if show_z: + b_text += ", {: .5e}" + b_text += ") " + b_text += "({: .5e}, {: .5e}" + if show_z: + b_text += ", {: .5e}" + b_text += ")" + + if show_z: + this_b_text = b_text.format( + this_bl_x, + this_bl_y, + this_bl_z, + this_br_x, + this_br_y, + this_br_z, + ) + else: + this_b_text = b_text.format( + this_bl_x, + this_bl_y, + this_br_x, + this_br_y, + ) + + p_text += this_b_text + p_text += "│" + last_line = p_text.splitlines()[-1] + + col_len = int((len(last_line) - 1) / num_cols) + + if row_num != num_rows: + col_text = "─" * (col_len - 1) + "┼" + else: + col_text = "─" * (col_len - 1) + "┴" + + all_col_text = col_text * num_cols + all_col_text = all_col_text[:-1] + + if row_num != num_rows: + all_col_text = "├" + all_col_text + "┤" + else: + all_col_text = "└" + all_col_text + "┘" + + p_text = p_text + "\n" + all_col_text + "\n" + + row_num += 1 + +p_text = p_text[:-1] + +last_line = p_text.splitlines()[-1] +col_len = int((len(last_line) - 1) / num_cols) +col_text = "─" * (col_len - 1) + "┬" +all_col_text = col_text * num_cols +all_col_text = all_col_text[:-1] +all_col_text = "┌" + all_col_text + "┐\n" + +p_text = all_col_text + p_text + +print(p_text) From 2cf5c00d1f1e1233b43e9b64ec51e0bb32846c52 Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 12 Jul 2023 19:20:03 -0400 Subject: [PATCH 10/24] I re-implemented a few features in meshing.py that broke things when removed. --- examples/simple_steady.py | 282 +++++++++++++++++++++++++++++ pterasoftware/geometry.py | 18 +- pterasoftware/meshing.py | 360 ++++++++++++++++++++++---------------- 3 files changed, 504 insertions(+), 156 deletions(-) create mode 100644 examples/simple_steady.py diff --git a/examples/simple_steady.py b/examples/simple_steady.py new file mode 100644 index 00000000..1b82f9e1 --- /dev/null +++ b/examples/simple_steady.py @@ -0,0 +1,282 @@ +"""This is script is an example of how to run Ptera Software's unsteady ring vortex +lattice method solver on a simple airplane object.""" + +import pterasoftware as ps +import numpy as np + +b = 10.0 +theta = np.pi / 6 +y = b * np.cos(theta) +z = b * np.sin(theta) + +simple_airplane = ps.geometry.Airplane( + wings=[ + ps.geometry.Wing( + wing_cross_sections=[ + ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca2412", + coordinates=None, + repanel=True, + n_points_per_side=100, + ), + x_le=0.0, + y_le=0.0, + z_le=0.0, + chord=2.0, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=100, + spanwise_spacing="cosine", + ), + ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca2412", + coordinates=None, + repanel=True, + n_points_per_side=100, + ), + x_le=0.0, + y_le=y, + z_le=z, + chord=2.0, + # unit_normal_vector=np.array([0.0, np.cos(theta), np.sin(theta)]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=100, + spanwise_spacing="cosine", + ), + ], + name="Main Wing", + x_le=0.0, + y_le=0.0, + z_le=0.0, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + symmetric=True, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=20, + chordwise_spacing="cosine", + ), + ], + name="Simple Airplane", + x_ref=0.0, + y_ref=0.0, + z_ref=0.0, + weight=0.0, + s_ref=None, + c_ref=None, + b_ref=None, +) + +simple_operating_point = ps.operating_point.OperatingPoint( + density=1.225, velocity=10.0, alpha=5.0, beta=0.0, external_thrust=0.0, nu=1.5e-5 +) + +simple_static_problem = ps.problems.SteadyProblem( + airplanes=[simple_airplane], operating_point=simple_operating_point +) + +# simple_steady_solver = ( +# ps.steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver( +# steady_problem=simple_static_problem +# ) +# ) +simple_steady_solver = ( + ps.steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver( + steady_problem=simple_static_problem + ) +) + + +del simple_airplane +del simple_operating_point +del simple_static_problem + +simple_steady_solver.run() + +ps.output.print_steady_results(steady_solver=simple_steady_solver) + +# ps.output.draw( +# solver=simple_steady_solver, +# scalar_type="lift", +# show_streamlines=True, +# show_wake_vortices=False, +# ) + +wing = simple_steady_solver.airplanes[0].wings[0] +panels = wing.panels +show_z = True + +[num_rows, num_cols] = panels.shape + +p_text = "" +row_num = 1 +for row_of_panels in panels: + col_num = 1 + for panel in row_of_panels: + p_text += "│" + col_num += 1 + + [this_fl_x, this_fl_y, this_fl_z] = panel.front_left_vertex + [this_fr_x, this_fr_y, this_fr_z] = panel.front_right_vertex + + f_text = "({: .5e}, {: .5e}" + if show_z: + f_text += ", {: .5e}" + f_text += ") " + f_text += "({: .5e}, {: .5e}" + if show_z: + f_text += ", {: .5e}" + f_text += ")" + + if show_z: + this_f_text = f_text.format( + this_fl_x, + this_fl_y, + this_fl_z, + this_fr_x, + this_fr_y, + this_fr_z, + ) + else: + this_f_text = f_text.format( + this_fl_x, + this_fl_y, + this_fr_x, + this_fr_y, + ) + + p_text += this_f_text + + p_text += "│\n" + for panel in row_of_panels: + p_text += "│" + + [this_bl_x, this_bl_y, this_bl_z] = panel.back_left_vertex + [this_br_x, this_br_y, this_br_z] = panel.back_right_vertex + + b_text = "({: .5e}, {: .5e}" + if show_z: + b_text += ", {: .5e}" + b_text += ") " + b_text += "({: .5e}, {: .5e}" + if show_z: + b_text += ", {: .5e}" + b_text += ")" + + if show_z: + this_b_text = b_text.format( + this_bl_x, + this_bl_y, + this_bl_z, + this_br_x, + this_br_y, + this_br_z, + ) + else: + this_b_text = b_text.format( + this_bl_x, + this_bl_y, + this_br_x, + this_br_y, + ) + + p_text += this_b_text + p_text += "│" + last_line = p_text.splitlines()[-1] + + col_len = int((len(last_line) - 1) / num_cols) + + if row_num != num_rows: + col_text = "─" * (col_len - 1) + "┼" + else: + col_text = "─" * (col_len - 1) + "┴" + + all_col_text = col_text * num_cols + all_col_text = all_col_text[:-1] + + if row_num != num_rows: + all_col_text = "├" + all_col_text + "┤" + else: + all_col_text = "└" + all_col_text + "┘" + + p_text = p_text + "\n" + all_col_text + "\n" + + row_num += 1 + +p_text = p_text[:-1] + +last_line = p_text.splitlines()[-1] +col_len = int((len(last_line) - 1) / num_cols) +col_text = "─" * (col_len - 1) + "┬" +all_col_text = col_text * num_cols +all_col_text = all_col_text[:-1] +all_col_text = "┌" + all_col_text + "┐\n" + +p_text = all_col_text + p_text + +# print(p_text) + + +p_text = "" +row_num = 1 +for row_of_panels in panels: + col_num = 1 + for panel in row_of_panels: + p_text += "│" + col_num += 1 + + # this_cd = panel.induced_drag_coefficient + # this_cl = panel.lift_coefficient + # this_cy = panel.side_force_coefficient + # text = "({: .5e}, {: .5e}, {: .5e})" + # this_text = text.format( + # this_cd, + # this_cl, + # this_cy, + # ) + + info = panel.area + text = "{:.5e}" + this_text = text.format(info) + + p_text += this_text + p_text += "│" + last_line = p_text.splitlines()[-1] + + col_len = int((len(last_line) - 1) / num_cols) + + if row_num != num_rows: + col_text = "─" * (col_len - 1) + "┼" + else: + col_text = "─" * (col_len - 1) + "┴" + + all_col_text = col_text * num_cols + all_col_text = all_col_text[:-1] + + if row_num != num_rows: + all_col_text = "├" + all_col_text + "┤" + else: + all_col_text = "└" + all_col_text + "┘" + + p_text = p_text + "\n" + all_col_text + "\n" + + row_num += 1 + +p_text = p_text[:-1] + +last_line = p_text.splitlines()[-1] +col_len = int((len(last_line) - 1) / num_cols) +col_text = "─" * (col_len - 1) + "┬" +all_col_text = col_text * num_cols +all_col_text = all_col_text[:-1] +all_col_text = "┌" + all_col_text + "┐\n" + +p_text = all_col_text + p_text + +# print(p_text) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index 381d6aba..257a3482 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -314,6 +314,18 @@ def __init__( for wing_cross_section in wing_cross_sections: wing_cross_section.unit_chordwise_vector = self.unit_chordwise_vector + # ToDo: Delete these after debugging. + del wing_cross_section + del orthogonal_vector + del unit_chordwise_vector + del symmetry_unit_normal_vector + + # Define an attribute that is the normal vector of the plane that the + # projected area will reference. + self.projected_unit_normal_vector = np.cross( + self.unit_chordwise_vector, self.symmetry_unit_normal_vector + ) + # Initialize the panels attribute. Then mesh the wing, which will # populate this attribute. self.panels = None @@ -324,12 +336,6 @@ def __init__( self.wake_ring_vortex_vertices = np.empty((0, self.num_spanwise_panels + 1, 3)) self.wake_ring_vortices = np.zeros((0, self.num_spanwise_panels), dtype=object) - # Define an attribute that is the normal vector of the plane that the - # projected area will reference. - self.projected_unit_normal_vector = np.cross( - unit_chordwise_vector, symmetry_unit_normal_vector - ) - # ToDo: Update this method's documentation. @property def projected_area(self): diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index 3a9b7e58..4760ded7 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -85,62 +85,63 @@ def mesh_wing(wing): ) ) - # ToDo: Delete this commented command after testing. - # wing_cross_sections_scaling_factors, - # normalized_projected_quarter_chords = get_normalized_projected_quarter_chords( - # wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges - # ) + normalized_projected_quarter_chords = get_normalized_projected_quarter_chords( + wing_cross_sections_leading_edges, + wing_cross_sections_trailing_edges, + wing.unit_chordwise_vector, + ) # Get the number of wing cross sections and wing sections. num_wing_cross_sections = len(wing.wing_cross_sections) num_wing_sections = num_wing_cross_sections - 1 - # ToDo: Delete this commented section after testing. - # # Then, construct the normal directions for each wing cross section. Make the - # # normals for the inner wing cross sections, where we need to merge directions. - # if num_wing_cross_sections > 2: - # # Add together the adjacent normalized wing section quarter chords projected - # # onto the YZ plane. - # wing_sections_local_normals = ( - # normalized_projected_quarter_chords[:-1, :] - # + normalized_projected_quarter_chords[1:, :] - # ) - # - # # Create a list of the magnitudes of the summed adjacent normalized wing - # # section quarter chords projected onto the YZ plane. - # wing_sections_local_normals_len = np.linalg.norm( - # wing_sections_local_normals, axis=1 - # ) - # - # # Convert the list to a column vector. - # transpose_wing_sections_local_normals_len = np.expand_dims( - # wing_sections_local_normals_len, axis=1 - # ) - # - # # Normalize the summed adjacent normalized wing section quarter chords - # # projected onto the YZ plane by their magnitudes. - # wing_sections_local_unit_normals = ( - # wing_sections_local_normals / transpose_wing_sections_local_normals_len - # ) - # - # # Vertically stack the first normalized wing section quarter chord, the inner - # # normalized wing section quarter chords, and the last normalized wing - # # section quarter chord. - # wing_sections_local_unit_normals = np.vstack( - # ( - # normalized_projected_quarter_chords[0, :], - # wing_sections_local_unit_normals, - # normalized_projected_quarter_chords[-1, :], - # ) - # ) - # else: - # # Vertically stack the first and last normalized wing section quarter chords. - # wing_sections_local_unit_normals = np.vstack( - # ( - # normalized_projected_quarter_chords[0, :], - # normalized_projected_quarter_chords[-1, :], - # ) - # ) + # ToDo: Change this part's language to remove YZ plane mentions. + # Then, construct the adjusted normal directions for each wing cross section. + # Make the normals for the inner wing cross sections, where we need to merge + # directions. + if num_wing_cross_sections > 2: + # Add together the adjacent normalized wing section quarter chords projected + # onto the YZ plane. + wing_sections_local_normals = ( + normalized_projected_quarter_chords[:-1, :] + + normalized_projected_quarter_chords[1:, :] + ) + + # Create a list of the magnitudes of the summed adjacent normalized wing + # section quarter chords projected onto the YZ plane. + wing_sections_local_normals_len = np.linalg.norm( + wing_sections_local_normals, axis=1 + ) + + # Convert the list to a column vector. + transpose_wing_sections_local_normals_len = np.expand_dims( + wing_sections_local_normals_len, axis=1 + ) + + # Normalize the summed adjacent normalized wing section quarter chords + # projected onto the YZ plane by their magnitudes. + wing_sections_local_unit_normals = ( + wing_sections_local_normals / transpose_wing_sections_local_normals_len + ) + + # Vertically stack the first normalized wing section quarter chord, the inner + # normalized wing section quarter chords, and the last normalized wing + # section quarter chord. + wing_sections_local_unit_normals = np.vstack( + ( + normalized_projected_quarter_chords[0, :], + wing_sections_local_unit_normals, + normalized_projected_quarter_chords[-1, :], + ) + ) + else: + # Vertically stack the first and last normalized wing section quarter chords. + wing_sections_local_unit_normals = np.vstack( + ( + normalized_projected_quarter_chords[0, :], + normalized_projected_quarter_chords[-1, :], + ) + ) # ToDo: Delete this commented section after testing. # Then, construct the back directions for each wing cross section. @@ -182,25 +183,25 @@ def mesh_wing(wing): # wing_sections_local_unit_normals, # axis=1, # ) - wing_cross_sections_unit_up_vectors = np.vstack( - [ - wing_cross_section.unit_up_vector - for wing_cross_section in wing.wing_cross_sections - ] + adj_wing_cross_sections_unit_up_vectors = np.cross( + wing_cross_sections_unit_chordwise_vectors, + wing_sections_local_unit_normals, + axis=1, ) - # ToDo: Delete this commented section after testing. # If the wing is symmetric, set the local up position of the root cross section # to be the in local Z direction. - # if wing.symmetric: - # wing_cross_sections_unit_up_vectors[0] = np.array([0, 0, 1]) + if wing.symmetric: + adj_wing_cross_sections_unit_up_vectors[0] = np.array([0, 0, 1]) - # ToDo: Delete this commented line after testing. # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to # compensate). - # wing_cross_sections_scaling_factors = get_wing_cross_section_scaling_factors( - # wing.symmetric, normalized_projected_quarter_chords - # ) + wing_cross_sections_scaling_factors = get_wing_cross_section_scaling_factors( + wing.symmetric, + normalized_projected_quarter_chords, + wing.projected_unit_normal_vector, + wing.leading_edge, + ) # Initialize an empty array that will hold the panels of this wing. It currently # has 0 columns and M rows, where M is the number of the wing's chordwise panels. @@ -256,10 +257,9 @@ def mesh_wing(wing): ] = get_panel_vertices( inner_wing_cross_section_num, wing_cross_sections_unit_chordwise_vectors, - wing_cross_sections_unit_up_vectors, + adj_wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - # ToDo: Delete this commented line after testing. - # wing_cross_sections_scaling_factors, + wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -344,10 +344,9 @@ def mesh_wing(wing): ] = get_panel_vertices( inner_wing_cross_section_num, wing_cross_sections_unit_chordwise_vectors, - wing_cross_sections_unit_up_vectors, + adj_wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - # ToDo: Delete this commented line after testing. - # wing_cross_sections_scaling_factors, + wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -502,13 +501,72 @@ def mesh_wing(wing): # return wing_cross_section_scaling_factors +# ToDo: Update this method's documentation +def get_wing_cross_section_scaling_factors( + symmetric, + wing_section_quarter_chords_proj_yz_norm, + wing_projected_unit_normal_vector, + wing_leading_edge, +): + """Get the scaling factors for each wing cross section. These factors allow the + cross sections to intersect correctly at dihedral breaks. + + :param symmetric: bool + This parameter is True if the wing is symmetric and False otherwise. + :param wing_section_quarter_chords_proj_yz_norm: array + This parameter is a (N x 3) array of floats, where N is the number of wing + sections (1 less than the number of wing cross sections). For each wing + section, this parameter contains the 3 components of the normalized quarter + chord projected onto the YZ plane. + :return wing_cross_section_scaling_factors: array + This function returns a 1D array of floats of length (N + 1), where N is the + number of wing sections. These values are the corresponding scaling factor + for each of the wing's wing cross sections. These scaling factors stretch + their profiles to account for changes in dihedral at a give wing cross section. + """ + num_wing_cross_sections = len(wing_section_quarter_chords_proj_yz_norm) + 1 + + # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to + # compensate). + wing_cross_section_scaling_factors = np.ones(num_wing_cross_sections) + + for i in range(num_wing_cross_sections): + if i == 0: + if symmetric: + first_chord_norm = wing_section_quarter_chords_proj_yz_norm[0] + mirrored_first_chord_norm = functions.reflect_point_across_plane( + first_chord_norm, + wing_projected_unit_normal_vector, + wing_leading_edge, + ) + # mirrored_first_chord_norm = first_chord_norm * np.array([1, 1, -1]) + + product = first_chord_norm * mirrored_first_chord_norm + collapsed_product = np.sum(product) + this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) + else: + this_scaling_factor = 1 + elif i == num_wing_cross_sections - 1: + this_scaling_factor = 1 + else: + this_chord_norm = wing_section_quarter_chords_proj_yz_norm[i - 1, :] + next_chord_norm = wing_section_quarter_chords_proj_yz_norm[i, :] + + product = this_chord_norm * next_chord_norm + collapsed_product = np.sum(product) + this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) + + wing_cross_section_scaling_factors[i] = this_scaling_factor + + return wing_cross_section_scaling_factors + + def get_panel_vertices( inner_wing_cross_section_num, wing_cross_sections_unit_chordwise_vectors, wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - # ToDo: Delete this argument after testing. - # wing_cross_sections_scaling_factors, + wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -531,12 +589,11 @@ def get_panel_vertices( This parameter is a 1D array of floats with length X, where X is this wing's number of wing cross sections. It holds the chord lengths of this wing's wing cross section in meters. - ToDo: Delete this commented parameter after testing - # :param wing_cross_sections_scaling_factors: array - # This parameter is a 1D array of floats with length X, where X is this wing's - # number of wing cross sections. It holds this wing's wing cross sections' - # scaling factors. These factors stretch the shape of the wing cross sections - # to account for changes in dihedral at a give wing cross section. + :param wing_cross_sections_scaling_factors: array + This parameter is a 1D array of floats with length X, where X is this wing's + number of wing cross sections. It holds this wing's wing cross sections' + scaling factors. These factors stretch the shape of the wing cross sections + to account for changes in dihedral at a give wing cross section. :param wing_cross_sections_leading_edges: array This parameter is an array of floats with size (Xx3), where X is this wing's number of wing cross sections. It holds the coordinates of the leading edge @@ -581,9 +638,8 @@ def get_panel_vertices( wing_cross_sections_unit_up_vectors[inner_wing_cross_section_num, :] * transpose_inner_mcl_up_vector * wing_cross_sections_chord_lengths[inner_wing_cross_section_num] + * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] ) - # ToDo: Delete this line (was in the above equation) after testing. - # * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] # Define the index of this wing section's outer wing cross section. outer_wing_cross_section_num = inner_wing_cross_section_num + 1 @@ -602,8 +658,7 @@ def get_panel_vertices( wing_cross_sections_unit_up_vectors[outer_wing_cross_section_num, :] * transpose_outer_mcl_up_vector * wing_cross_sections_chord_lengths[outer_wing_cross_section_num] - # ToDo: Delete this line after testing. - # * wing_cross_sections_scaling_factors[outer_wing_cross_section_num] + * wing_cross_sections_scaling_factors[outer_wing_cross_section_num] ) # Convert the inner wing cross section's meshed wing coordinates to absolute @@ -694,75 +749,80 @@ def get_panel_vertices( ] -# ToDo: Delete this commented function after testing. -# def get_normalized_projected_quarter_chords( -# wing_cross_sections_leading_edges, wing_cross_sections_trailing_edges -# ): -# # ToDo: Update this docstring to swap mentions of YZ plane for the custom plane. -# """This method returns the quarter chords of a collection of wing cross sections -# based on the coordinates of their leading and trailing edges. These quarter -# chords are also projected on to the YZ plane and normalized by their magnitudes. -# -# :param wing_cross_sections_leading_edges: array -# This parameter is an array of floats with size (X, 3), where X is this wing's -# number of wing cross sections. For each cross section, this array holds the -# body-frame coordinates of its leading edge point in meters. -# :param wing_cross_sections_trailing_edges: array -# This parameter is an array of floats with size (X, 3), where X is this wing's -# number of wing cross sections. For each cross section, this array holds the -# body-frame coordinates of its trailing edge point in meters. -# :return normalized_projected_quarter_chords: array -# This functions returns an array of floats with size (X - 1, 3), where X is -# this wing's number of wing cross sections. This array holds each wing -# section's quarter chords projected on to the YZ plane and normalized by their -# magnitudes. -# """ -# # Get the location of each wing cross section's quarter chord point. -# wing_cross_sections_quarter_chord_points = ( -# wing_cross_sections_leading_edges -# + 0.25 -# * (wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges) -# ) -# -# # Get a (L - 1) x 3 array of vectors connecting the wing cross section quarter -# # chord points, where L is the number of wing cross sections. -# quarter_chords = ( -# wing_cross_sections_quarter_chord_points[1:, :] -# - wing_cross_sections_quarter_chord_points[:-1, :] -# ) -# -# # Get directions for transforming 2D airfoil data to 3D by the following steps. -# # -# # Project quarter chords onto YZ plane and normalize. -# # -# # Create an L x 2 array with just the y and z components of this wing section's -# # quarter chord vectors. -# projected_quarter_chords = quarter_chords[:, 1:] -# -# # Create a list of the lengths of each row of the projected_quarter_chords array. -# projected_quarter_chords_len = np.linalg.norm(projected_quarter_chords, axis=1) -# -# # Convert projected_quarter_chords_len into a column vector. -# transpose_projected_quarter_chords_len = np.expand_dims( -# projected_quarter_chords_len, axis=1 -# ) -# # Normalize the coordinates by the magnitudes -# normalized_projected_quarter_chords = ( -# projected_quarter_chords / transpose_projected_quarter_chords_len -# ) -# -# # Create a column vector of all zeros with height equal to the number of quarter -# # chord vectors -# column_of_zeros = np.zeros((len(quarter_chords), 1)) -# -# # Horizontally stack the zero column vector with the -# # normalized_projected_quarter_chords to give each normalized projected quarter -# # chord an X coordinate. -# normalized_projected_quarter_chords = np.hstack( -# (column_of_zeros, normalized_projected_quarter_chords) -# ) -# -# return normalized_projected_quarter_chords +def get_normalized_projected_quarter_chords( + wing_cross_sections_leading_edges, + wing_cross_sections_trailing_edges, + wing_unit_chordwise_vector, +): + # ToDo: Update this docstring to swap mentions of YZ plane for the custom plane. + """This method returns the quarter chords of a collection of wing cross sections + based on the coordinates of their leading and trailing edges. These quarter + chords are also projected on to the YZ plane and normalized by their magnitudes. + + :param wing_cross_sections_leading_edges: array + This parameter is an array of floats with size (X, 3), where X is this wing's + number of wing cross sections. For each cross section, this array holds the + body-frame coordinates of its leading edge point in meters. + :param wing_cross_sections_trailing_edges: array + This parameter is an array of floats with size (X, 3), where X is this wing's + number of wing cross sections. For each cross section, this array holds the + body-frame coordinates of its trailing edge point in meters. + :return normalized_projected_quarter_chords: array + This functions returns an array of floats with size (X - 1, 3), where X is + this wing's number of wing cross sections. This array holds each wing + section's quarter chords projected on to the YZ plane and normalized by their + magnitudes. + """ + # Get the location of each wing cross section's quarter chord point. + wing_cross_sections_quarter_chord_points = ( + wing_cross_sections_leading_edges + + 0.25 + * (wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges) + ) + + # Get a (L - 1) x 3 array of vectors connecting the wing cross section quarter + # chord points, where L is the number of wing cross sections. + quarter_chords = ( + wing_cross_sections_quarter_chord_points[1:, :] + - wing_cross_sections_quarter_chord_points[:-1, :] + ) + + # Get directions for transforming 2D airfoil data to 3D by the following steps. + # + # Project quarter chords onto YZ plane and normalize. + # + # Create an L x 2 array with just the y and z components of this wing section's + # quarter chord vectors. + # projected_quarter_chords = quarter_chords[:, 1:] + projected_quarter_chords = quarter_chords - ( + (np.dot(quarter_chords, wing_unit_chordwise_vector)) + * wing_unit_chordwise_vector + ) + + # Create a list of the lengths of each row of the projected_quarter_chords array. + projected_quarter_chords_len = np.linalg.norm(projected_quarter_chords, axis=1) + + # Convert projected_quarter_chords_len into a column vector. + transpose_projected_quarter_chords_len = np.expand_dims( + projected_quarter_chords_len, axis=1 + ) + # Normalize the coordinates by the magnitudes + normalized_projected_quarter_chords = ( + projected_quarter_chords / transpose_projected_quarter_chords_len + ) + + # # Create a column vector of all zeros with height equal to the number of quarter + # # chord vectors + # column_of_zeros = np.zeros((len(quarter_chords), 1)) + # + # # Horizontally stack the zero column vector with the + # # normalized_projected_quarter_chords to give each normalized projected quarter + # # chord an X coordinate. + # normalized_projected_quarter_chords = np.hstack( + # (column_of_zeros, normalized_projected_quarter_chords) + # ) + + return normalized_projected_quarter_chords def get_transpose_mcl_vectors(inner_airfoil, outer_airfoil, chordwise_coordinates): From 6a42b753e453a94717fdb2751d492fd31a113513 Mon Sep 17 00:00:00 2001 From: camUrban Date: Thu, 13 Jul 2023 15:44:04 -0400 Subject: [PATCH 11/24] I fixed a bug in meshing wings with twist and I updated testing values. --- pterasoftware/geometry.py | 27 +++++++------ .../integration/fixtures/airplane_fixtures.py | 23 ++++++++++- .../fixtures/test_for_convergence.py | 26 +++++++++++++ tests/integration/test_steady_convergence.py | 2 + ..._steady_horseshoe_vortex_lattice_method.py | 39 ++++++++++--------- .../test_steady_ring_vortex_lattice_method.py | 22 +++++------ tests/integration/test_steady_trim.py | 1 + .../integration/test_unsteady_convergence.py | 1 + ...ce_method_multiple_wing_static_geometry.py | 12 +++--- ..._method_multiple_wing_variable_geometry.py | 7 ++-- ...g_vortex_lattice_method_static_geometry.py | 8 ++-- ...vortex_lattice_method_variable_geometry.py | 7 ++-- 12 files changed, 118 insertions(+), 57 deletions(-) create mode 100644 tests/integration/fixtures/test_for_convergence.py diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index 257a3482..a10b5e83 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -312,7 +312,7 @@ def __init__( self.num_panels = self.num_spanwise_panels * self.num_chordwise_panels for wing_cross_section in wing_cross_sections: - wing_cross_section.unit_chordwise_vector = self.unit_chordwise_vector + wing_cross_section.wing_unit_chordwise_vector = self.unit_chordwise_vector # ToDo: Delete these after debugging. del wing_cross_section @@ -578,7 +578,7 @@ def __init__( self.leading_edge = np.array([x_le, y_le, z_le]) # ToDo: Document this - self.unit_chordwise_vector = None + self.wing_unit_chordwise_vector = None # Catch bad values of the chord length. if self.chord <= 0: @@ -592,6 +592,19 @@ def __init__( if self.spanwise_spacing not in ["cosine", "uniform"]: raise Exception("Invalid value of spanwise_spacing!") + # ToDo: Update this method's documentation. + @property + def unit_chordwise_vector(self): + # Find the rotation matrix given the cross section's twist. + twist_rotation_matrix = functions.angle_axis_rotation_matrix( + self.twist * np.pi / 180, self.unit_normal_vector + ) + + # Use the rotation matrix and the leading edge coordinates to calculate the + # unit chordwise vector. + return twist_rotation_matrix @ self.wing_unit_chordwise_vector + + # ToDo: Update this method's documentation. @property def trailing_edge(self): """This method calculates the coordinates of the trailing edge of this wing @@ -601,17 +614,9 @@ def trailing_edge(self): This is a 1D array that contains the coordinates of this wing cross section's trailing edge. """ - - # Find the rotation matrix given the cross section's twist. - twist_rotation_matrix = functions.angle_axis_rotation_matrix( - self.twist * np.pi / 180, np.array([0, 1, 0]) - ) - chordwise_vector = self.chord * self.unit_chordwise_vector - # Use the rotation matrix and the leading edge coordinates to calculate the - # trailing edge coordinates. - return self.leading_edge + twist_rotation_matrix @ chordwise_vector + return self.leading_edge + chordwise_vector # ToDo: Document this @property diff --git a/tests/integration/fixtures/airplane_fixtures.py b/tests/integration/fixtures/airplane_fixtures.py index 8dabb311..0a4c4f13 100644 --- a/tests/integration/fixtures/airplane_fixtures.py +++ b/tests/integration/fixtures/airplane_fixtures.py @@ -40,22 +40,35 @@ def make_steady_validation_airplane(): symmetric=True, wing_cross_sections=[ ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil(name="naca2412"), + airfoil=ps.geometry.Airfoil( + name="naca2412", + repanel=True, + n_points_per_side=50, + ), + num_spanwise_panels=20, ), ps.geometry.WingCrossSection( x_le=1.0, y_le=5.0, twist=5.0, chord=0.75, - airfoil=ps.geometry.Airfoil(name="naca2412"), + airfoil=ps.geometry.Airfoil( + name="naca2412", + repanel=True, + n_points_per_side=50, + ), + num_spanwise_panels=20, ), ], + num_chordwise_panels=14, ) ], ) return steady_validation_airplane +# ToDo: Update this airplane to be more representative of the XFLR5 simulation. +# ToDo: Check that this test case has converged characteristics. def make_multiple_wing_steady_validation_airplane(): """This function creates a multi-wing airplane object to be used as a fixture for testing steady solvers. @@ -170,6 +183,8 @@ def make_multiple_wing_steady_validation_airplane(): return multiple_wing_steady_validation_airplane +# ToDo: Update this airplane to be more representative of the XFLR5 simulation. +# ToDo: Check that this test case has converged characteristics. def make_asymmetric_unsteady_validation_airplane(): """This function creates an asymmetric airplane object to be used as a fixture for testing unsteady solvers. @@ -205,6 +220,8 @@ def make_asymmetric_unsteady_validation_airplane(): return asymmetric_unsteady_validation_airplane +# ToDo: Update this airplane to be more representative of the XFLR5 simulation. +# ToDo: Check that this test case has converged characteristics. def make_symmetric_unsteady_validation_airplane(): """This function creates a symmetric airplane object to be used as a fixture for testing unsteady solvers. @@ -237,6 +254,8 @@ def make_symmetric_unsteady_validation_airplane(): return symmetric_unsteady_validation_airplane +# ToDo: Update this airplane to be more representative of the XFLR5 simulation. +# ToDo: Check that this test case has converged characteristics. def make_symmetric_multiple_wing_unsteady_validation_airplane(): """This function creates a multi-wing, symmetric airplane object to be used as a fixture for testing unsteady solvers. diff --git a/tests/integration/fixtures/test_for_convergence.py b/tests/integration/fixtures/test_for_convergence.py new file mode 100644 index 00000000..f2156976 --- /dev/null +++ b/tests/integration/fixtures/test_for_convergence.py @@ -0,0 +1,26 @@ +import math + +import pterasoftware as ps +from tests.integration.fixtures import problem_fixtures + +steady_validation_problem = problem_fixtures.make_steady_validation_problem() + +converged_parameters = ps.convergence.analyze_steady_convergence( + ref_problem=steady_validation_problem, + solver_type="steady horseshoe vortex lattice method", + panel_aspect_ratio_bounds=(4, 1), + num_chordwise_panels_bounds=(3, 20), + convergence_criteria=0.1, +) + +[panel_aspect_ratio, num_chordwise_panels] = converged_parameters +section_length = 5 +section_standard_mean_chord = 0.875 + +num_spanwise_panels = round( + (section_length * num_chordwise_panels) + / (section_standard_mean_chord * panel_aspect_ratio) +) + +num_spanwise_panels = math.ceil(num_spanwise_panels) +print(num_spanwise_panels) diff --git a/tests/integration/test_steady_convergence.py b/tests/integration/test_steady_convergence.py index 7c5b675e..7b93b4f6 100644 --- a/tests/integration/test_steady_convergence.py +++ b/tests/integration/test_steady_convergence.py @@ -73,6 +73,7 @@ def test_steady_horseshoe_convergence(self): converged_panel_ar = converged_parameters[0] converged_num_chordwise = converged_parameters[1] + # ToDo: Update these expected results. panel_ar_ans = 4 num_chordwise_ans = 4 @@ -97,6 +98,7 @@ def test_steady_ring_convergence(self): converged_panel_ar = converged_parameters[0] converged_num_chordwise = converged_parameters[1] + # ToDo: Update these expected results. panel_ar_ans = 4 num_chordwise_ans = 5 diff --git a/tests/integration/test_steady_horseshoe_vortex_lattice_method.py b/tests/integration/test_steady_horseshoe_vortex_lattice_method.py index 1b97dadc..4245b40e 100644 --- a/tests/integration/test_steady_horseshoe_vortex_lattice_method.py +++ b/tests/integration/test_steady_horseshoe_vortex_lattice_method.py @@ -2,10 +2,11 @@ Based on an identical XFLR5 testing case, the expected output for the single-wing case is: - CL: 0.790 - CDi: 0.019 - Cm: -0.690 + CL: 0.788 + CDi: 0.020 + Cm: -0.685 +ToDo: Update these results with a new XFLR5 study. Based on an identical XFLR5 testing case, the expected output for the multi-wing case is: CL: 0.524 @@ -83,29 +84,29 @@ def test_method(self): self.steady_horseshoe_vortex_lattice_method_validation_solver.run() # Calculate the percent errors of the output. - c_di_expected = 0.019 + c_di_expected = 0.020 c_di_calculated = ( self.steady_horseshoe_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_force_coefficients_wind_axes[0] ) - c_di_error = abs(c_di_calculated - c_di_expected) / c_di_expected + c_di_error = abs((c_di_calculated - c_di_expected) / c_di_expected) - c_l_expected = 0.790 + c_l_expected = 0.788 c_l_calculated = ( self.steady_horseshoe_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_force_coefficients_wind_axes[2] ) - c_l_error = abs(c_l_calculated - c_l_expected) / c_l_expected + c_l_error = abs((c_l_calculated - c_l_expected) / c_l_expected) - c_m_expected = -0.690 + c_m_expected = -0.685 c_m_calculated = ( self.steady_horseshoe_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_moment_coefficients_wind_axes[1] ) - c_m_error = abs(c_m_calculated - c_m_expected) / c_m_expected + c_m_error = abs((c_m_calculated - c_m_expected) / c_m_expected) # Set the allowable percent error. allowable_error = 0.10 @@ -114,13 +115,13 @@ def test_method(self): solver=self.steady_horseshoe_vortex_lattice_method_validation_solver, show_wake_vortices=False, show_streamlines=True, - scalar_type="side force", + scalar_type="lift", ) # Assert that the percent errors are less than the allowable error. - self.assertTrue(abs(c_di_error) < allowable_error) - self.assertTrue(abs(c_l_error) < allowable_error) - self.assertTrue(abs(c_m_error) < allowable_error) + self.assertTrue(c_di_error < allowable_error) + self.assertTrue(c_l_error < allowable_error) + self.assertTrue(c_m_error < allowable_error) def test_method_multiple_wings(self): """This method tests the solver's output with multi-wing geometry. @@ -138,7 +139,7 @@ def test_method_multiple_wings(self): ].total_near_field_force_coefficients_wind_axes[ 0 ] - c_di_error = abs(c_di_calculated - c_di_expected) / c_di_expected + c_di_error = abs((c_di_calculated - c_di_expected) / c_di_expected) c_l_expected = 0.524 c_l_calculated = self.steady_multiple_wing_horseshoe_vortex_lattice_method_validation_solver.airplanes[ @@ -146,7 +147,7 @@ def test_method_multiple_wings(self): ].total_near_field_force_coefficients_wind_axes[ 2 ] - c_l_error = abs(c_l_calculated - c_l_expected) / c_l_expected + c_l_error = abs((c_l_calculated - c_l_expected) / c_l_expected) c_m_expected = -0.350 c_m_calculated = self.steady_multiple_wing_horseshoe_vortex_lattice_method_validation_solver.airplanes[ @@ -154,7 +155,7 @@ def test_method_multiple_wings(self): ].total_near_field_moment_coefficients_wind_axes[ 1 ] - c_m_error = abs(c_m_calculated - c_m_expected) / c_m_expected + c_m_error = abs((c_m_calculated - c_m_expected) / c_m_expected) # Set the allowable percent error. allowable_error = 0.10 @@ -167,6 +168,6 @@ def test_method_multiple_wings(self): ) # Assert that the percent errors are less than the allowable error. - self.assertTrue(abs(c_di_error) < allowable_error) - self.assertTrue(abs(c_l_error) < allowable_error) - self.assertTrue(abs(c_m_error) < allowable_error) + self.assertTrue(c_di_error < allowable_error) + self.assertTrue(c_l_error < allowable_error) + self.assertTrue(c_m_error < allowable_error) diff --git a/tests/integration/test_steady_ring_vortex_lattice_method.py b/tests/integration/test_steady_ring_vortex_lattice_method.py index 1a6fd333..31a2c308 100644 --- a/tests/integration/test_steady_ring_vortex_lattice_method.py +++ b/tests/integration/test_steady_ring_vortex_lattice_method.py @@ -1,9 +1,9 @@ """This module is a testing case for the steady ring vortex lattice method solver. -Based on an identical XFLR5 testing case, the expected output for this case is: - CL: 0.788 +Based on an identical XFLR5 VLM2 testing case, the expected output for this case is: + CL: 0.783 CDi: 0.019 - Cm: -0.687 + Cm: -0.678 Note: The expected output was created using XFLR5's inviscid VLM2 analysis type, which is a ring vortex lattice method solver. @@ -76,23 +76,23 @@ def test_method(self): 0 ].total_near_field_force_coefficients_wind_axes[0] ) - c_di_error = abs(c_di_calculated - c_di_expected) / c_di_expected + c_di_error = abs((c_di_calculated - c_di_expected) / c_di_expected) - c_l_expected = 0.788 + c_l_expected = 0.783 c_l_calculated = ( self.steady_ring_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_force_coefficients_wind_axes[2] ) - c_l_error = abs(c_l_calculated - c_l_expected) / c_l_expected + c_l_error = abs((c_l_calculated - c_l_expected) / c_l_expected) - c_m_expected = -0.687 + c_m_expected = -0.678 c_m_calculated = ( self.steady_ring_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_moment_coefficients_wind_axes[1] ) - c_m_error = abs(c_m_calculated - c_m_expected) / c_m_expected + c_m_error = abs((c_m_calculated - c_m_expected) / c_m_expected) # Set the allowable percent error. allowable_error = 0.10 @@ -105,6 +105,6 @@ def test_method(self): ) # Assert that the percent errors are less than the allowable error. - self.assertTrue(abs(c_di_error) < allowable_error) - self.assertTrue(abs(c_l_error) < allowable_error) - self.assertTrue(abs(c_m_error) < allowable_error) + self.assertTrue(c_di_error < allowable_error) + self.assertTrue(c_l_error < allowable_error) + self.assertTrue(c_m_error < allowable_error) diff --git a/tests/integration/test_steady_trim.py b/tests/integration/test_steady_trim.py index c73f7a2a..1cc0ed6d 100644 --- a/tests/integration/test_steady_trim.py +++ b/tests/integration/test_steady_trim.py @@ -41,6 +41,7 @@ def setUp(self): :return: None """ + # ToDo: Update these expected results. self.v_x_ans = 2.848 self.alpha_ans = 1.943 self.beta_ans = 0.000 diff --git a/tests/integration/test_unsteady_convergence.py b/tests/integration/test_unsteady_convergence.py index be556e91..075aac23 100644 --- a/tests/integration/test_unsteady_convergence.py +++ b/tests/integration/test_unsteady_convergence.py @@ -76,6 +76,7 @@ def test_unsteady_convergence(self): converged_panel_ar = converged_parameters[2] converged_num_chordwise = converged_parameters[3] + # ToDo: Update these expected results. wake_state_ans = True num_chords_ans = 4 panel_ar_ans = 4 diff --git a/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_static_geometry.py b/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_static_geometry.py index e139187f..908fef32 100644 --- a/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_static_geometry.py +++ b/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_static_geometry.py @@ -1,11 +1,13 @@ -"""This is a testing case for the unsteady ring vortex lattice method solver with static, multi-wing geometry. +"""This is a testing case for the unsteady ring vortex lattice method solver with +static, multi-wing geometry. -Note: This case does not currently test the solver's output against an expected output. Instead, it just tests that -the solver doesn't throw an error. +Note: This case does not currently test the solver's output against an expected +output. Instead, it just tests that the solver doesn't throw an error. This module contains the following classes: - TestUnsteadyRingVortexLatticeMethodMultipleWingStaticGeometry: This is a class for testing the unsteady ring - vortex lattice method solver on static, multi-wing geometry. + TestUnsteadyRingVortexLatticeMethodMultipleWingStaticGeometry: This is a class + for testing the unsteady ring vortex lattice method solver on static, multi-wing + geometry. This module contains the following exceptions: None diff --git a/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_variable_geometry.py b/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_variable_geometry.py index 5ab0cb34..77065176 100644 --- a/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_variable_geometry.py +++ b/tests/integration/test_unsteady_ring_vortex_lattice_method_multiple_wing_variable_geometry.py @@ -1,7 +1,8 @@ -"""This is a testing case for the unsteady ring vortex lattice method solver with variable, multi-wing geometry. +"""This is a testing case for the unsteady ring vortex lattice method solver with +variable, multi-wing geometry. -Note: This case does not currently test the solver's output against an expected output. Instead, it just tests that -the solver doesn't throw an error. +Note: This case does not currently test the solver's output against an expected +output. Instead, it just tests that the solver doesn't throw an error. This module contains the following classes: TestUnsteadyRingVortexLatticeMethodMultipleWingVariableGeometry: This is a class diff --git a/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py b/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py index 7650105e..bad2c6a5 100644 --- a/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py +++ b/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py @@ -1,14 +1,16 @@ """This is a testing case for the unsteady ring vortex lattice method solver with static geometry. +ToDo: Update these results with a new XFLR5 study. Based on an equivalent XFLR5 testing case, the expected output for this case is: CL: 0.588 CDi: 0.011 Cm: -0.197 -Note: The expected output was created using XFLR5's inviscid VLM2 analysis type, which is a ring vortex lattice -method solver. The geometry in this case is static. Therefore, the results of this unsteady solver should converge to -be close to XFLR5's static result. +Note: The expected output was created using XFLR5's inviscid VLM2 analysis type, +which is a ring vortex lattice method solver. The geometry in this case is static. +Therefore, the results of this unsteady solver should converge to be close to XFLR5's +static result. This module contains the following classes: TestUnsteadyRingVortexLatticeMethodStaticGeometry: This is a class for testing diff --git a/tests/integration/test_unsteady_ring_vortex_lattice_method_variable_geometry.py b/tests/integration/test_unsteady_ring_vortex_lattice_method_variable_geometry.py index 1240946e..5ce4c5f5 100644 --- a/tests/integration/test_unsteady_ring_vortex_lattice_method_variable_geometry.py +++ b/tests/integration/test_unsteady_ring_vortex_lattice_method_variable_geometry.py @@ -1,7 +1,8 @@ -"""This is a testing case for the unsteady ring vortex lattice method solver with variable geometry. +"""This is a testing case for the unsteady ring vortex lattice method solver with +variable geometry. -Note: This case does not currently test the solver's output against an expected output. Instead, it just tests that -the solver doesn't throw an error. +Note: This case does not currently test the solver's output against an expected +output. Instead, it just tests that the solver doesn't throw an error. This module contains the following classes: TestUnsteadyRingVortexLatticeMethodVariableGeometry: This is a class for testing From eac6d1b1248e10dfb8ca135c8549a429ec0bd684 Mon Sep 17 00:00:00 2001 From: camUrban Date: Mon, 17 Jul 2023 15:18:53 -0400 Subject: [PATCH 12/24] I modified meshing to use a more readable method interpolating the points on a wing surface. --- .gitignore | 1 - pterasoftware/functions.py | 48 ++++++--- pterasoftware/meshing.py | 213 +++++++++++++++++-------------------- pterasoftware/output.py | 4 +- 4 files changed, 136 insertions(+), 130 deletions(-) diff --git a/.gitignore b/.gitignore index 29f482e5..554c483c 100644 --- a/.gitignore +++ b/.gitignore @@ -135,7 +135,6 @@ cython_debug/ /tests/unit/fixtures/_trial_temp/ # These are folders and files created by releasing. - /build /dist /Output diff --git a/pterasoftware/functions.py b/pterasoftware/functions.py index 7e40cb88..b942a919 100644 --- a/pterasoftware/functions.py +++ b/pterasoftware/functions.py @@ -695,17 +695,37 @@ def reflect_point_across_plane(point, plane_unit_normal, plane_point): [x_prime, y_prime, z_prime] = transformed_expanded_coordinates[:-1, 0] return np.array([x_prime, y_prime, z_prime]) - # point = np.expand_dims(point, -1) - # plane_unit_normal = np.expand_dims(plane_unit_normal, -1) - # plane_point = np.expand_dims(plane_point, -1) - # - # plane_unit_normal_transpose = np.transpose(plane_unit_normal) - # identity = np.eye(point.size) - # - # householder = identity - 2 * plane_unit_normal @ plane_unit_normal_transpose - # - # reflected_point = plane_point + householder @ point - - # reflected_point = householder @ point - - return np.squeeze(reflected_point) + + +# ToDo: Document this method. +@njit(cache=True, fastmath=False) +def interp_between_points(start_points, end_points, norm_spacings): + """ + + :param start_points: + :param end_points: + :param norm_spacings: + :return: + """ + m = start_points.shape[0] + n = norm_spacings.size + + points = np.zeros((m, n, 3)) + + for i in range(m): + start_point = start_points[i, :] + end_point = end_points[i, :] + + vector = end_point - start_point + # vector_length = np.linalg.norm(vector) + # + # unit_vector = vector / vector_length + + for j in range(n): + norm_spacing = norm_spacings[j] + + spacing = norm_spacing * vector + + points[i, j, :] = start_point + spacing + + return points diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index 4760ded7..6340dbc1 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -176,6 +176,13 @@ def mesh_wing(wing): ] ) + wing_cross_sections_unit_up_vectors = np.vstack( + [ + wing_cross_section.unit_up_vector + for wing_cross_section in wing.wing_cross_sections + ] + ) + # ToDo: Delete this commented section after testing. # Then, construct the up direction for each wing cross section. # wing_cross_sections_local_unit_up_vectors = np.cross( @@ -194,14 +201,15 @@ def mesh_wing(wing): if wing.symmetric: adj_wing_cross_sections_unit_up_vectors[0] = np.array([0, 0, 1]) + # ToDo: Delete this commented section after testing. # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to # compensate). - wing_cross_sections_scaling_factors = get_wing_cross_section_scaling_factors( - wing.symmetric, - normalized_projected_quarter_chords, - wing.projected_unit_normal_vector, - wing.leading_edge, - ) + # wing_cross_sections_scaling_factors = get_wing_cross_section_scaling_factors( + # wing.symmetric, + # normalized_projected_quarter_chords, + # wing.projected_unit_normal_vector, + # wing.leading_edge, + # ) # Initialize an empty array that will hold the panels of this wing. It currently # has 0 columns and M rows, where M is the number of the wing's chordwise panels. @@ -257,9 +265,8 @@ def mesh_wing(wing): ] = get_panel_vertices( inner_wing_cross_section_num, wing_cross_sections_unit_chordwise_vectors, - adj_wing_cross_sections_unit_up_vectors, + wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -346,7 +353,6 @@ def mesh_wing(wing): wing_cross_sections_unit_chordwise_vectors, adj_wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -501,64 +507,64 @@ def mesh_wing(wing): # return wing_cross_section_scaling_factors -# ToDo: Update this method's documentation -def get_wing_cross_section_scaling_factors( - symmetric, - wing_section_quarter_chords_proj_yz_norm, - wing_projected_unit_normal_vector, - wing_leading_edge, -): - """Get the scaling factors for each wing cross section. These factors allow the - cross sections to intersect correctly at dihedral breaks. - - :param symmetric: bool - This parameter is True if the wing is symmetric and False otherwise. - :param wing_section_quarter_chords_proj_yz_norm: array - This parameter is a (N x 3) array of floats, where N is the number of wing - sections (1 less than the number of wing cross sections). For each wing - section, this parameter contains the 3 components of the normalized quarter - chord projected onto the YZ plane. - :return wing_cross_section_scaling_factors: array - This function returns a 1D array of floats of length (N + 1), where N is the - number of wing sections. These values are the corresponding scaling factor - for each of the wing's wing cross sections. These scaling factors stretch - their profiles to account for changes in dihedral at a give wing cross section. - """ - num_wing_cross_sections = len(wing_section_quarter_chords_proj_yz_norm) + 1 - - # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to - # compensate). - wing_cross_section_scaling_factors = np.ones(num_wing_cross_sections) - - for i in range(num_wing_cross_sections): - if i == 0: - if symmetric: - first_chord_norm = wing_section_quarter_chords_proj_yz_norm[0] - mirrored_first_chord_norm = functions.reflect_point_across_plane( - first_chord_norm, - wing_projected_unit_normal_vector, - wing_leading_edge, - ) - # mirrored_first_chord_norm = first_chord_norm * np.array([1, 1, -1]) - - product = first_chord_norm * mirrored_first_chord_norm - collapsed_product = np.sum(product) - this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) - else: - this_scaling_factor = 1 - elif i == num_wing_cross_sections - 1: - this_scaling_factor = 1 - else: - this_chord_norm = wing_section_quarter_chords_proj_yz_norm[i - 1, :] - next_chord_norm = wing_section_quarter_chords_proj_yz_norm[i, :] - - product = this_chord_norm * next_chord_norm - collapsed_product = np.sum(product) - this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) - - wing_cross_section_scaling_factors[i] = this_scaling_factor - - return wing_cross_section_scaling_factors +# ToDo: Delete this commented section after testing. +# def get_wing_cross_section_scaling_factors( +# symmetric, +# wing_section_quarter_chords_proj_yz_norm, +# wing_projected_unit_normal_vector, +# wing_leading_edge, +# ): +# """Get the scaling factors for each wing cross section. These factors allow the +# cross sections to intersect correctly at dihedral breaks. +# +# :param symmetric: bool +# This parameter is True if the wing is symmetric and False otherwise. +# :param wing_section_quarter_chords_proj_yz_norm: array +# This parameter is a (N x 3) array of floats, where N is the number of wing +# sections (1 less than the number of wing cross sections). For each wing +# section, this parameter contains the 3 components of the normalized quarter +# chord projected onto the YZ plane. +# :return wing_cross_section_scaling_factors: array +# This function returns a 1D array of floats of length (N + 1), where N is the +# number of wing sections. These values are the corresponding scaling factor +# for each of the wing's wing cross sections. These scaling factors stretch +# their profiles to account for changes in dihedral at a give wing cross section. +# """ +# num_wing_cross_sections = len(wing_section_quarter_chords_proj_yz_norm) + 1 +# +# # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to +# # compensate). +# wing_cross_section_scaling_factors = np.ones(num_wing_cross_sections) +# +# for i in range(num_wing_cross_sections): +# if i == 0: +# if symmetric: +# first_chord_norm = wing_section_quarter_chords_proj_yz_norm[0] +# mirrored_first_chord_norm = functions.reflect_point_across_plane( +# first_chord_norm, +# wing_projected_unit_normal_vector, +# wing_leading_edge, +# ) +# # mirrored_first_chord_norm = first_chord_norm * np.array([1, 1, -1]) +# +# product = first_chord_norm * mirrored_first_chord_norm +# collapsed_product = np.sum(product) +# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) +# else: +# this_scaling_factor = 1 +# elif i == num_wing_cross_sections - 1: +# this_scaling_factor = 1 +# else: +# this_chord_norm = wing_section_quarter_chords_proj_yz_norm[i - 1, :] +# next_chord_norm = wing_section_quarter_chords_proj_yz_norm[i, :] +# +# product = this_chord_norm * next_chord_norm +# collapsed_product = np.sum(product) +# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) +# +# wing_cross_section_scaling_factors[i] = this_scaling_factor +# +# return wing_cross_section_scaling_factors def get_panel_vertices( @@ -566,7 +572,6 @@ def get_panel_vertices( wing_cross_sections_unit_chordwise_vectors, wing_cross_sections_unit_up_vectors, wing_cross_sections_chord_lengths, - wing_cross_sections_scaling_factors, wing_cross_sections_leading_edges, transpose_mcl_vectors, spanwise_coordinates, @@ -589,11 +594,6 @@ def get_panel_vertices( This parameter is a 1D array of floats with length X, where X is this wing's number of wing cross sections. It holds the chord lengths of this wing's wing cross section in meters. - :param wing_cross_sections_scaling_factors: array - This parameter is a 1D array of floats with length X, where X is this wing's - number of wing cross sections. It holds this wing's wing cross sections' - scaling factors. These factors stretch the shape of the wing cross sections - to account for changes in dihedral at a give wing cross section. :param wing_cross_sections_leading_edges: array This parameter is an array of floats with size (Xx3), where X is this wing's number of wing cross sections. It holds the coordinates of the leading edge @@ -638,7 +638,7 @@ def get_panel_vertices( wing_cross_sections_unit_up_vectors[inner_wing_cross_section_num, :] * transpose_inner_mcl_up_vector * wing_cross_sections_chord_lengths[inner_wing_cross_section_num] - * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] + # * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] ) # Define the index of this wing section's outer wing cross section. @@ -658,7 +658,7 @@ def get_panel_vertices( wing_cross_sections_unit_up_vectors[outer_wing_cross_section_num, :] * transpose_outer_mcl_up_vector * wing_cross_sections_chord_lengths[outer_wing_cross_section_num] - * wing_cross_sections_scaling_factors[outer_wing_cross_section_num] + # * wing_cross_sections_scaling_factors[outer_wing_cross_section_num] ) # Convert the inner wing cross section's meshed wing coordinates to absolute @@ -677,21 +677,23 @@ def get_panel_vertices( + outer_wing_cross_section_mcl_up ) + # ToDo: Delete this commented section after testing. # Make section_mcl_coordinates: M x N x 3 array of mean camberline coordinates. # The first index is chordwise point number, second index is spanwise point # number, third is the x, y, and z coordinates. M is the number of chordwise # points. N is the number of spanwise points. Put a reversed version (from 1 to # 0) of the non dimensional spanwise coordinates in a row vector. This is size 1 # x N, where N is the number of spanwise points. - reversed_nondim_spanwise_coordinates_row_vector = np.expand_dims( - (1 - spanwise_coordinates), 0 - ) + # reversed_nondim_spanwise_coordinates_row_vector = np.expand_dims( + # (1 - spanwise_coordinates), 0 + # ) + # ToDo: Delete this commented section after testing. # Convert the reversed non dimensional spanwise coordinate row vector (from 1 to # 0) to a matrix. This is size 1 x N x 1, where N is the number of spanwise points. - reversed_nondim_spanwise_coordinates_matrix = np.expand_dims( - reversed_nondim_spanwise_coordinates_row_vector, 2 - ) + # reversed_nondim_spanwise_coordinates_matrix = np.expand_dims( + # reversed_nondim_spanwise_coordinates_row_vector, 2 + # ) # Convert the inner and outer wing cross section's mean camberline coordinates # column vectors to matrices. These are size M x 1 x 3, where M is the number of @@ -703,36 +705,10 @@ def get_panel_vertices( outer_wing_cross_section_mcl, 1 ) - # Put the non dimensional spanwise coordinates (from 0 to 1) in a row vector. - # This is size 1 x N, where N is the number of spanwise points. - nondim_spanwise_coordinates_row_vector = np.expand_dims(spanwise_coordinates, 0) - - # Convert the non dimensional spanwise coordinate row vector (from to 0 to 1) to - # a matrix. This is size 1 x N x 1, where N is the number of spanwise points. - nondim_spanwise_coordinates_matrix = np.expand_dims( - nondim_spanwise_coordinates_row_vector, 2 - ) - - # Linearly interpolate between inner and outer wing cross sections. This uses the - # following equation: - # - # f(a, b, i) = i * a + (1 - i) * b - # - # "a" is an N x 3 array of the coordinates points along the outer wing cross - # section's mean camber line. - # - # "b" is an N x 3 array of the coordinates of points along the inner wing cross - # section's mean camber line. - # - # "i" is a 1D array (or vector) of length M that holds the nondimensionalized - # spanwise panel spacing from 0 to 1. - # - # This produces an M x N x 3 array where each slot holds the coordinates of a - # point on the surface between the inner and outer wing cross sections. - wing_section_mcl_vertices = ( - reversed_nondim_spanwise_coordinates_matrix - * inner_wing_cross_section_mcl_matrix - + nondim_spanwise_coordinates_matrix * outer_wing_cross_section_mcl_matrix + wing_section_mcl_vertices = functions.interp_between_points( + inner_wing_cross_section_mcl_matrix, + outer_wing_cross_section_mcl_matrix, + spanwise_coordinates, ) # Compute the corners of each panel. @@ -767,6 +743,7 @@ def get_normalized_projected_quarter_chords( This parameter is an array of floats with size (X, 3), where X is this wing's number of wing cross sections. For each cross section, this array holds the body-frame coordinates of its trailing edge point in meters. + :param wing_unit_chordwise_vector :return normalized_projected_quarter_chords: array This functions returns an array of floats with size (X - 1, 3), where X is this wing's number of wing cross sections. This array holds each wing @@ -794,10 +771,18 @@ def get_normalized_projected_quarter_chords( # Create an L x 2 array with just the y and z components of this wing section's # quarter chord vectors. # projected_quarter_chords = quarter_chords[:, 1:] - projected_quarter_chords = quarter_chords - ( - (np.dot(quarter_chords, wing_unit_chordwise_vector)) - * wing_unit_chordwise_vector + + # ToDo: Document this + quarter_chords_dot_wing_unit_chordwise_vector = np.einsum( + "ij,j->i", quarter_chords, wing_unit_chordwise_vector + ) + c = np.expand_dims(wing_unit_chordwise_vector, axis=0) + b = np.einsum( + "j,ji->ji", + quarter_chords_dot_wing_unit_chordwise_vector, + c, ) + projected_quarter_chords = quarter_chords - b # Create a list of the lengths of each row of the projected_quarter_chords array. projected_quarter_chords_len = np.linalg.norm(projected_quarter_chords, axis=1) diff --git a/pterasoftware/output.py b/pterasoftware/output.py index aa2c911c..320ec774 100644 --- a/pterasoftware/output.py +++ b/pterasoftware/output.py @@ -137,8 +137,10 @@ def draw( :return: None """ - # Initialize the plotter. + # Initialize the plotter and set it to use parallel projection (instead of + # perspective). plotter = pv.Plotter(window_size=window_size, lighting=None) + plotter.enable_parallel_projection() # Get the solver's geometry. if isinstance( From 26a95eed7d5bf80d48cf001c0aa374777afa3fca Mon Sep 17 00:00:00 2001 From: camUrban Date: Mon, 17 Jul 2023 15:44:33 -0400 Subject: [PATCH 13/24] I updated the documentation in meshing.py and removed two unnecessary array resizings. --- pterasoftware/meshing.py | 228 +++------------------------------------ 1 file changed, 12 insertions(+), 216 deletions(-) diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index 6340dbc1..8b530321 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -11,11 +11,6 @@ quadrilateral mesh of its geometry, and then populates the object's panels with the mesh data. - ToDo: Update this method's documentation. - get_wing_cross_section_scaling_factors: Get the scaling factors for each wing - cross section. These factors allow the cross sections to intersect correctly at - dihedral breaks. - get_panel_vertices: This function calculates the vertices of the panels on a wing. ToDo: Update this method's documentation. @@ -143,32 +138,12 @@ def mesh_wing(wing): ) ) - # ToDo: Delete this commented section after testing. - # Then, construct the back directions for each wing cross section. - # wing_cross_sections_local_back_vectors = ( - # wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges - # ) - - # ToDo: Delete this commented section after testing. # Create a list of the wing cross section chord lengths. - # wing_cross_sections_chord_lengths = np.linalg.norm( - # wing_cross_sections_local_back_vectors, axis=1 - # ) wing_cross_sections_chord_lengths = np.array( [wing_cross_section.chord for wing_cross_section in wing.wing_cross_sections] ) - # Convert the list to a column vector. - # transpose_wing_cross_sections_chord_lengths = np.expand_dims( - # wing_cross_sections_chord_lengths, axis=1 - # ) - - # ToDo: Delete this commented section after testing. # Normalize the wing cross section back vectors by their magnitudes. - # wing_cross_sections_local_unit_chordwise_vectors = ( - # wing_cross_sections_local_back_vectors - # / transpose_wing_cross_sections_chord_lengths - # ) wing_cross_sections_unit_chordwise_vectors = np.vstack( [ wing_cross_section.unit_chordwise_vector @@ -176,41 +151,26 @@ def mesh_wing(wing): ] ) + # ToDo: Clarify these calculations' documentation. + # Then, construct the up direction for each wing cross section. wing_cross_sections_unit_up_vectors = np.vstack( [ wing_cross_section.unit_up_vector for wing_cross_section in wing.wing_cross_sections ] ) - - # ToDo: Delete this commented section after testing. - # Then, construct the up direction for each wing cross section. - # wing_cross_sections_local_unit_up_vectors = np.cross( - # wing_cross_sections_local_unit_chordwise_vectors, - # wing_sections_local_unit_normals, - # axis=1, - # ) adj_wing_cross_sections_unit_up_vectors = np.cross( wing_cross_sections_unit_chordwise_vectors, wing_sections_local_unit_normals, axis=1, ) + # ToDo: This isn't correct for all situations now. Fix it. # If the wing is symmetric, set the local up position of the root cross section # to be the in local Z direction. if wing.symmetric: adj_wing_cross_sections_unit_up_vectors[0] = np.array([0, 0, 1]) - # ToDo: Delete this commented section after testing. - # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to - # compensate). - # wing_cross_sections_scaling_factors = get_wing_cross_section_scaling_factors( - # wing.symmetric, - # normalized_projected_quarter_chords, - # wing.projected_unit_normal_vector, - # wing.leading_edge, - # ) - # Initialize an empty array that will hold the panels of this wing. It currently # has 0 columns and M rows, where M is the number of the wing's chordwise panels. wing_panels = np.empty((num_chordwise_panels, 0), dtype=object) @@ -382,7 +342,7 @@ def mesh_wing(wing): ) ) - # ToDo: Delete the commented section after testing. + # Reflect the vertices across the symmetry plane. front_inner_vertices_reflected = np.apply_along_axis( functions.reflect_point_across_plane, -1, @@ -411,16 +371,6 @@ def mesh_wing(wing): wing.symmetry_unit_normal_vector, wing.leading_edge, ) - # # Reflect the vertices across the XZ plane. - # front_inner_vertices_reflected = front_inner_vertices * np.array([1, -1, 1]) - # front_outer_vertices_reflected = front_outer_vertices * np.array([1, -1, 1]) - # back_inner_vertices_reflected = back_inner_vertices * np.array([1, -1, 1]) - # back_outer_vertices_reflected = back_outer_vertices * np.array([1, -1, 1]) - # # Shift the reflected vertices based on the wing's leading edge position. - # front_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le - # front_outer_vertices_reflected[:, :, 1] += 2 * wing.y_le - # back_inner_vertices_reflected[:, :, 1] += 2 * wing.y_le - # back_outer_vertices_reflected[:, :, 1] += 2 * wing.y_le # Get the reflected wing section's panels. wing_section_panels = get_wing_section_panels( @@ -455,118 +405,6 @@ def mesh_wing(wing): wing.panels = wing_panels -# # ToDo: Delete this commented function after testing. -# def get_wing_cross_section_scaling_factors( -# symmetric, wing_section_quarter_chords_proj_yz_norm -# ): -# """Get the scaling factors for each wing cross section. These factors allow the -# cross sections to intersect correctly at dihedral breaks. -# -# :param symmetric: bool -# This parameter is True if the wing is symmetric and False otherwise. -# :param wing_section_quarter_chords_proj_yz_norm: array -# This parameter is a (N x 3) array of floats, where N is the number of wing -# sections (1 less than the number of wing cross sections). For each wing -# section, this parameter contains the 3 components of the normalized quarter -# chord projected onto the YZ plane. -# :return wing_cross_section_scaling_factors: array -# This function returns a 1D array of floats of length (N + 1), where N is the -# number of wing sections. These values are the corresponding scaling factor -# for each of the wing's wing cross sections. These scaling factors stretch -# their profiles to account for changes in dihedral at a give wing cross section. -# """ -# num_wing_cross_sections = len(wing_section_quarter_chords_proj_yz_norm) + 1 -# -# # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to -# # compensate). -# wing_cross_section_scaling_factors = np.ones(num_wing_cross_sections) -# -# for i in range(num_wing_cross_sections): -# if i == 0: -# if symmetric: -# first_chord_norm = wing_section_quarter_chords_proj_yz_norm[0] -# mirrored_first_chord_norm = first_chord_norm * np.array([1, 1, -1]) -# -# product = first_chord_norm * mirrored_first_chord_norm -# collapsed_product = np.sum(product) -# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) -# else: -# this_scaling_factor = 1 -# elif i == num_wing_cross_sections - 1: -# this_scaling_factor = 1 -# else: -# this_chord_norm = wing_section_quarter_chords_proj_yz_norm[i - 1, :] -# next_chord_norm = wing_section_quarter_chords_proj_yz_norm[i, :] -# -# product = this_chord_norm * next_chord_norm -# collapsed_product = np.sum(product) -# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) -# -# wing_cross_section_scaling_factors[i] = this_scaling_factor -# -# return wing_cross_section_scaling_factors - - -# ToDo: Delete this commented section after testing. -# def get_wing_cross_section_scaling_factors( -# symmetric, -# wing_section_quarter_chords_proj_yz_norm, -# wing_projected_unit_normal_vector, -# wing_leading_edge, -# ): -# """Get the scaling factors for each wing cross section. These factors allow the -# cross sections to intersect correctly at dihedral breaks. -# -# :param symmetric: bool -# This parameter is True if the wing is symmetric and False otherwise. -# :param wing_section_quarter_chords_proj_yz_norm: array -# This parameter is a (N x 3) array of floats, where N is the number of wing -# sections (1 less than the number of wing cross sections). For each wing -# section, this parameter contains the 3 components of the normalized quarter -# chord projected onto the YZ plane. -# :return wing_cross_section_scaling_factors: array -# This function returns a 1D array of floats of length (N + 1), where N is the -# number of wing sections. These values are the corresponding scaling factor -# for each of the wing's wing cross sections. These scaling factors stretch -# their profiles to account for changes in dihedral at a give wing cross section. -# """ -# num_wing_cross_sections = len(wing_section_quarter_chords_proj_yz_norm) + 1 -# -# # Get the scaling factor (airfoils at dihedral breaks need to be "taller" to -# # compensate). -# wing_cross_section_scaling_factors = np.ones(num_wing_cross_sections) -# -# for i in range(num_wing_cross_sections): -# if i == 0: -# if symmetric: -# first_chord_norm = wing_section_quarter_chords_proj_yz_norm[0] -# mirrored_first_chord_norm = functions.reflect_point_across_plane( -# first_chord_norm, -# wing_projected_unit_normal_vector, -# wing_leading_edge, -# ) -# # mirrored_first_chord_norm = first_chord_norm * np.array([1, 1, -1]) -# -# product = first_chord_norm * mirrored_first_chord_norm -# collapsed_product = np.sum(product) -# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) -# else: -# this_scaling_factor = 1 -# elif i == num_wing_cross_sections - 1: -# this_scaling_factor = 1 -# else: -# this_chord_norm = wing_section_quarter_chords_proj_yz_norm[i - 1, :] -# next_chord_norm = wing_section_quarter_chords_proj_yz_norm[i, :] -# -# product = this_chord_norm * next_chord_norm -# collapsed_product = np.sum(product) -# this_scaling_factor = 1 / np.sqrt((1 + collapsed_product) / 2) -# -# wing_cross_section_scaling_factors[i] = this_scaling_factor -# -# return wing_cross_section_scaling_factors - - def get_panel_vertices( inner_wing_cross_section_num, wing_cross_sections_unit_chordwise_vectors, @@ -658,7 +496,6 @@ def get_panel_vertices( wing_cross_sections_unit_up_vectors[outer_wing_cross_section_num, :] * transpose_outer_mcl_up_vector * wing_cross_sections_chord_lengths[outer_wing_cross_section_num] - # * wing_cross_sections_scaling_factors[outer_wing_cross_section_num] ) # Convert the inner wing cross section's meshed wing coordinates to absolute @@ -677,37 +514,12 @@ def get_panel_vertices( + outer_wing_cross_section_mcl_up ) - # ToDo: Delete this commented section after testing. - # Make section_mcl_coordinates: M x N x 3 array of mean camberline coordinates. - # The first index is chordwise point number, second index is spanwise point - # number, third is the x, y, and z coordinates. M is the number of chordwise - # points. N is the number of spanwise points. Put a reversed version (from 1 to - # 0) of the non dimensional spanwise coordinates in a row vector. This is size 1 - # x N, where N is the number of spanwise points. - # reversed_nondim_spanwise_coordinates_row_vector = np.expand_dims( - # (1 - spanwise_coordinates), 0 - # ) - - # ToDo: Delete this commented section after testing. - # Convert the reversed non dimensional spanwise coordinate row vector (from 1 to - # 0) to a matrix. This is size 1 x N x 1, where N is the number of spanwise points. - # reversed_nondim_spanwise_coordinates_matrix = np.expand_dims( - # reversed_nondim_spanwise_coordinates_row_vector, 2 - # ) - - # Convert the inner and outer wing cross section's mean camberline coordinates - # column vectors to matrices. These are size M x 1 x 3, where M is the number of - # chordwise points. - inner_wing_cross_section_mcl_matrix = np.expand_dims( - inner_wing_cross_section_mcl, 1 - ) - outer_wing_cross_section_mcl_matrix = np.expand_dims( - outer_wing_cross_section_mcl, 1 - ) - + # Find the vertices of the points on this wing section with interpolation. This + # returns an M x N x 3 matrix, where M and N are the number of chordwise points + # and spanwise points. wing_section_mcl_vertices = functions.interp_between_points( - inner_wing_cross_section_mcl_matrix, - outer_wing_cross_section_mcl_matrix, + inner_wing_cross_section_mcl, + outer_wing_cross_section_mcl, spanwise_coordinates, ) @@ -764,15 +576,10 @@ def get_normalized_projected_quarter_chords( - wing_cross_sections_quarter_chord_points[:-1, :] ) + # ToDo: Update this documentation. # Get directions for transforming 2D airfoil data to 3D by the following steps. - # - # Project quarter chords onto YZ plane and normalize. - # - # Create an L x 2 array with just the y and z components of this wing section's - # quarter chord vectors. - # projected_quarter_chords = quarter_chords[:, 1:] - - # ToDo: Document this + # Project quarter chords onto YZ plane and normalize. Create an L x 2 array with + # just the y and z components of this wing section's quarter chord vectors. quarter_chords_dot_wing_unit_chordwise_vector = np.einsum( "ij,j->i", quarter_chords, wing_unit_chordwise_vector ) @@ -796,17 +603,6 @@ def get_normalized_projected_quarter_chords( projected_quarter_chords / transpose_projected_quarter_chords_len ) - # # Create a column vector of all zeros with height equal to the number of quarter - # # chord vectors - # column_of_zeros = np.zeros((len(quarter_chords), 1)) - # - # # Horizontally stack the zero column vector with the - # # normalized_projected_quarter_chords to give each normalized projected quarter - # # chord an X coordinate. - # normalized_projected_quarter_chords = np.hstack( - # (column_of_zeros, normalized_projected_quarter_chords) - # ) - return normalized_projected_quarter_chords From 0e6fa8b09263174427cdff0fc03b219818638af8 Mon Sep 17 00:00:00 2001 From: camUrban Date: Mon, 17 Jul 2023 18:48:21 -0400 Subject: [PATCH 14/24] I added the new properties to the documentation in panel.py --- .idea/statistic.xml | 4 +- pterasoftware/functions.py | 3 - pterasoftware/models/__init__.py | 2 + pterasoftware/panel.py | 121 ++++++++++++++++++++++++++----- 4 files changed, 109 insertions(+), 21 deletions(-) diff --git a/.idea/statistic.xml b/.idea/statistic.xml index dc5cfd72..12066828 100644 --- a/.idea/statistic.xml +++ b/.idea/statistic.xml @@ -5,7 +5,9 @@ diff --git a/pterasoftware/functions.py b/pterasoftware/functions.py index b942a919..6d4dad84 100644 --- a/pterasoftware/functions.py +++ b/pterasoftware/functions.py @@ -717,9 +717,6 @@ def interp_between_points(start_points, end_points, norm_spacings): end_point = end_points[i, :] vector = end_point - start_point - # vector_length = np.linalg.norm(vector) - # - # unit_vector = vector / vector_length for j in range(n): norm_spacing = norm_spacings[j] diff --git a/pterasoftware/models/__init__.py b/pterasoftware/models/__init__.py index e69de29b..7a233c17 100644 --- a/pterasoftware/models/__init__.py +++ b/pterasoftware/models/__init__.py @@ -0,0 +1,2 @@ +"""This module doesn't import anything. It is only here to make the models directory +a package.""" diff --git a/pterasoftware/panel.py b/pterasoftware/panel.py index 989c5951..ab3aa5af 100644 --- a/pterasoftware/panel.py +++ b/pterasoftware/panel.py @@ -17,11 +17,31 @@ class Panel: """This class is used to contain the panels of a wing. This class contains the following public methods: - calculate_collocation_point_location: This method calculates the location of - the collocation point. + right_leg: This method defines a property for the panel's right leg vector as + a (3,) array. - calculate_area_and_normal: This method calculates the panel's area and the - panel's normal unit vector. + front_leg: This method defines a property for the panel's front leg vector as + a (3,) array. + + left_leg: This method defines a property for the panel's left leg vector as a + (3,) array. + + back_leg: This method defines a property for the panel's back leg vector as a + (3,) array. + + front_right_vortex_vertex: This method defines a property for the coordinates + of the front-right vertex of the ring vortex as a (3,) array. + + front_left_vortex_vertex: This method defines a property for the coordinates + of the front-left vertex of the ring vortex as a (3,) array. + + collocation_point: This method defines a property for the coordinates of the + panel's collocation point as a (3,) array. + + area: This method defines a property which is an estimate of the panel's area. + + unit_normal: This method defines a property for an estimate of the panel's + unit normal vector as a (3,) array. calculate_normalized_induced_velocity: This method calculates the velocity induced at a point by this panel's vortices, assuming a unit vortex strength. @@ -49,16 +69,16 @@ def __init__( ): """This is the initialization method. - :param front_right_vertex: 1D array with three elements + :param front_right_vertex: (3,) array This is an array containing the x, y, and z coordinates of the panel's front right vertex. - :param front_left_vertex: 1D array with three elements + :param front_left_vertex: (3,) array This is an array containing the x, y, and z coordinates of the panel's front left vertex. - :param back_left_vertex: 1D array with three elements + :param back_left_vertex: (3,) array This is an array containing the x, y, and z coordinates of the panel's back left vertex. - :param back_right_vertex: 1D array with three elements + :param back_right_vertex: (3,) array This is an array containing the x, y, and z coordinates of the panel's back right vertex. :param is_leading_edge: bool @@ -99,39 +119,81 @@ def __init__( self.side_force_coefficient = None self.lift_coefficient = None - # ToDo: Update this method's documentation. @property def right_leg(self): + """This method defines a property for the panel's right leg vector as a (3, + ) array. + + :return: (3,) array of floats + This is the panel's right leg vector, which is defined from back to + front. The units are in meters. + """ return self.front_right_vertex - self.back_right_vertex - # ToDo: Update this method's documentation. @property def front_leg(self): + """This method defines a property for the panel's front leg vector as a (3, + ) array. + + :return: (3,) array of floats + This is the panel's front leg vector, which is defined from right to + left. The units are in meters. + """ return self.front_left_vertex - self.front_right_vertex - # ToDo: Update this method's documentation. @property def left_leg(self): + """This method defines a property for the panel's left leg vector as a (3, + ) array. + + :return: (3,) array of floats + This is the panel's left leg vector, which is defined from front to + back. The units are in meters. + """ return self.back_left_vertex - self.front_left_vertex - # ToDo: Update this method's documentation. @property def back_leg(self): + """This method defines a property for the panel's back leg vector as a (3, + ) array. + + :return: (3,) array of floats + This is the panel's back leg vector, which is defined from left to + right. The units are in meters. + """ return self.back_right_vertex - self.back_left_vertex - # ToDo: Update this method's documentation. @property def front_right_vortex_vertex(self): + """This method defines a property for the coordinates of the front-right + vertex of the ring vortex as a (3,) array. + + :return: (3,) array of floats + This is the coordinates of the ring vortex's front-right vertex. The + units are in meters. + """ return self.back_right_vertex + 0.75 * self.right_leg - # ToDo: Update this method's documentation. @property def front_left_vortex_vertex(self): + """This method defines a property for the coordinates of the front-left + vertex of the ring vortex as a (3,) array. + + :return: (3,) array of floats + This is the coordinates of the ring vortex's front-left vertex. The units + are in meters. + """ return self.front_left_vertex + 0.25 * self.left_leg - # ToDo: Update this method's documentation. @property def collocation_point(self): + """This method defines a property for the coordinates of the panel's + collocation point as a (3,) array. + + :return: (3,) array of floats + This is the coordinates of the panel's collocation point. The units are + in meters. + """ # Find the location of points three quarters of the way down the left and # right legs of the panel. right_three_quarter_chord_mark = self.back_right_vertex + 0.25 * self.right_leg @@ -148,14 +210,33 @@ def collocation_point(self): # populate the class attribute. return right_three_quarter_chord_mark + 0.5 * three_quarter_chord_vector - # ToDo: Update this method's documentation. @property def area(self): + """This method defines a property which is an estimate of the panel's area. + + This is only an estimate because the surface defined by four line segments in + 3-space is a hyperboloid, and there doesn't seem to be a close-form equation + for the surface area of a hyperboloid between four points. Instead, + we estimate the area using the cross product of panel's diagonal vectors, + which should be relatively accurate if the panel can be approximated as a + planar, convex quadrilateral. + + :return: float + This is an estimate of the panel's area. The units are square meters. + """ return np.linalg.norm(self._cross) / 2 - # ToDo: Update this method's documentation. @property def unit_normal(self): + """This method defines a property for an estimate of the panel's unit + normal vector as a (3,) array. + + :return: (3,) array of floats + This is an estimate of the panel's unit normal vector as a (3,) array. + The sign is determined via the right-hand rule given the orientation of + panel's leg vectors (front-right to front-left to back-left to + back-right). The units are in meters. + """ return self._cross / np.linalg.norm(self._cross) # ToDo: Update this method's documentation. @@ -163,7 +244,9 @@ def unit_normal(self): def unit_spanwise(self): front_spanwise = -self.front_leg back_spanwise = self.back_leg + spanwise = (front_spanwise + back_spanwise) / 2 + return spanwise / np.linalg.norm(spanwise) # ToDo: Update this method's documentation. @@ -171,6 +254,7 @@ def unit_spanwise(self): def average_span(self): front_leg_length = np.linalg.norm(self.front_leg) back_leg_length = np.linalg.norm(self.back_leg) + return (front_leg_length + back_leg_length) / 2 # ToDo: Update this method's documentation. @@ -178,7 +262,9 @@ def average_span(self): def unit_chordwise(self): right_chordwise = -self.right_leg left_chordwise = self.left_leg + chordwise = (right_chordwise + left_chordwise) / 2 + return chordwise / np.linalg.norm(chordwise) # ToDo: Update this method's documentation. @@ -186,6 +272,7 @@ def unit_chordwise(self): def average_chord(self): right_leg_length = np.linalg.norm(self.right_leg) left_leg_length = np.linalg.norm(self.left_leg) + return (right_leg_length + left_leg_length) / 2 # ToDo: Update this method's documentation. From 1db4fffc111c5e6609cd2adbe0619ce1850d261b Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 19 Jul 2023 12:01:56 -0400 Subject: [PATCH 15/24] I continued updating the documentation. --- pterasoftware/functions.py | 67 ++++++++++++++++++++++++++++---------- pterasoftware/panel.py | 45 ++++++++++++++++++------- 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/pterasoftware/functions.py b/pterasoftware/functions.py index 6d4dad84..1a536368 100644 --- a/pterasoftware/functions.py +++ b/pterasoftware/functions.py @@ -35,12 +35,18 @@ update_ring_vortex_solvers_panel_attributes: This function populates a ring vortex solver's attributes with the attributes of a given panel. - calculate_steady_freestream_wing_influences: This method finds the vector of + calculate_steady_freestream_wing_influences: This function finds the vector of freestream-wing influence coefficients associated with this problem. numba_1d_explicit_cross: This function takes in two arrays, each of which contain N vectors of 3 components. The function then calculates and returns the cross product of the two vectors at each position. + + reflect_point_across_plane: This function finds the coordinates of the reflection + of a point across a plane in 3D space. + + interp_between_points: This function finds the MxN points between M pairs of + points in 3D space given an array of N normalized spacings. """ import logging @@ -605,7 +611,7 @@ def update_ring_vortex_solvers_panel_attributes( def calculate_steady_freestream_wing_influences(steady_solver): - """This method finds the vector of freestream-wing influence coefficients + """This function finds the vector of freestream-wing influence coefficients associated with this problem. :return: None @@ -655,30 +661,48 @@ def numba_1d_explicit_cross(vectors_1, vectors_2): return crosses -# ToDo: Document this method. +@njit(cache=True, fastmath=False) def reflect_point_across_plane(point, plane_unit_normal, plane_point): - """ - - :param point: - :param plane_unit_normal: - :param plane_point: - :return: + """This function finds the coordinates of the reflection of a point across a + plane in 3D space. + + As the plane doesn't necessarily include the origin, this is an affine + transformation. The transformation matrix and details are discussed in the + following source: https://en.wikipedia.org/wiki/Transformation_matrix#Reflection_2 + + :param point: (3,) array of floats + This is a (3,) array of the coordinates of the point to reflect. The units + are meters. + :param plane_unit_normal: (3,) array of floats + This is a (3,) array of the components of the plane's unit normal vector. It + must have unit magnitude. The units are meters. + :param plane_point: (3,) array of floats + This is a (3,) array of the coordinates of a point on the plane. The units + are meters. + :return: (3,) array of floats + This is a (3,) array of the components of the reflected point. The units are + meters. """ [x, y, z] = point [a, b, c] = plane_unit_normal + + # Find the dot product between the point on the plane and unit normal. d = np.dot(-plane_point, plane_unit_normal) + # To make the transformation matrix readable, define new variables for the + # products of the vector components and the dot product. ab = a * b ac = a * c ad = a * d bc = b * c bd = b * d cd = c * d - a2 = a**2 b2 = b**2 c2 = c**2 + # Define the affine transformation matrix. See the citation in the docstring for + # details. transformation_matrix = np.array( [ [1 - 2 * a2, -2 * ab, -2 * ac, -2 * ad], @@ -692,20 +716,29 @@ def reflect_point_across_plane(point, plane_unit_normal, plane_point): transformed_expanded_coordinates = transformation_matrix @ expanded_coordinates + # Extract the reflected coordinates in 3D space. [x_prime, y_prime, z_prime] = transformed_expanded_coordinates[:-1, 0] return np.array([x_prime, y_prime, z_prime]) -# ToDo: Document this method. @njit(cache=True, fastmath=False) def interp_between_points(start_points, end_points, norm_spacings): - """ - - :param start_points: - :param end_points: - :param norm_spacings: - :return: + """This function finds the MxN points between M pairs of points in 3D space given + an array of N normalized spacings. + + :param start_points: (M, 3) array of floats + This is the (M, 3) array containing the coordinates of the M starting points. + The units are meters. + :param end_points: (M, 3) array of floats + This is the (M, 3) array containing the coordinates of the M ending points. + The units are meters. + :param norm_spacings: (N,) array of floats + This is the (N,) array of the N spacings between the starting points and + ending points. The values are unitless and must be normalized from 0 to 1. + :return points: (M, N, 3) array of floats + This is the (M, N, 3) array of the coordinates of the MxN interpolated + points. The units are meters. """ m = start_points.shape[0] n = norm_spacings.size diff --git a/pterasoftware/panel.py b/pterasoftware/panel.py index ab3aa5af..43388fc8 100644 --- a/pterasoftware/panel.py +++ b/pterasoftware/panel.py @@ -12,7 +12,6 @@ import numpy as np -# ToDo: Update the list of methods for this class. class Panel: """This class is used to contain the panels of a wing. @@ -43,12 +42,29 @@ class Panel: unit_normal: This method defines a property for an estimate of the panel's unit normal vector as a (3,) array. + unit_spanwise: This method defines a property for the panel's unit spanwise + vector as a ( 3,) array. + + unit_chordwise: + + average_span: + + average_chord: + + _first_diagonal: + + _second_diagonal: + + _cross: + calculate_normalized_induced_velocity: This method calculates the velocity induced at a point by this panel's vortices, assuming a unit vortex strength. calculate_induced_velocity: This method calculates the velocity induced at a point by this panel's vortices with their given vortex strengths. + calculate_projected_area: + update_coefficients: This method updates the panel's force coefficients. This class contains the following class attributes: @@ -239,9 +255,16 @@ def unit_normal(self): """ return self._cross / np.linalg.norm(self._cross) - # ToDo: Update this method's documentation. @property def unit_spanwise(self): + """This method defines a property for the panel's unit spanwise vector as a ( + 3,) array. + + :return: (3,) array of floats + This is the panel's unit spanwise vector as a (3,) array. The positive + direction is defined as left to right, which is opposite the direction of + the front leg. The units are in meters. + """ front_spanwise = -self.front_leg back_spanwise = self.back_leg @@ -249,14 +272,6 @@ def unit_spanwise(self): return spanwise / np.linalg.norm(spanwise) - # ToDo: Update this method's documentation. - @property - def average_span(self): - front_leg_length = np.linalg.norm(self.front_leg) - back_leg_length = np.linalg.norm(self.back_leg) - - return (front_leg_length + back_leg_length) / 2 - # ToDo: Update this method's documentation. @property def unit_chordwise(self): @@ -267,6 +282,14 @@ def unit_chordwise(self): return chordwise / np.linalg.norm(chordwise) + # ToDo: Update this method's documentation. + @property + def average_span(self): + front_leg_length = np.linalg.norm(self.front_leg) + back_leg_length = np.linalg.norm(self.back_leg) + + return (front_leg_length + back_leg_length) / 2 + # ToDo: Update this method's documentation. @property def average_chord(self): @@ -342,7 +365,7 @@ def calculate_induced_velocity(self, point): return induced_velocity - # ToDo: Add this method to the documentation. + # ToDo: Update this method's documentation. def calculate_projected_area(self, n_hat): """ From 27a29a9fa88c9fd369b74af8c487f3945e10fed3 Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 19 Jul 2023 12:55:44 -0400 Subject: [PATCH 16/24] I finished documenting the changes to panel.py. --- pterasoftware/panel.py | 84 ++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 24 deletions(-) diff --git a/pterasoftware/panel.py b/pterasoftware/panel.py index 43388fc8..3d938eac 100644 --- a/pterasoftware/panel.py +++ b/pterasoftware/panel.py @@ -45,17 +45,13 @@ class Panel: unit_spanwise: This method defines a property for the panel's unit spanwise vector as a ( 3,) array. - unit_chordwise: + unit_chordwise: This method defines a property for the panel's unit chordwise + vector as a (3,) array. - average_span: + average_span: This method defines a property for the average span of the panel. - average_chord: - - _first_diagonal: - - _second_diagonal: - - _cross: + average_chord: This method defines a property for the average chord of the + panel. calculate_normalized_induced_velocity: This method calculates the velocity induced at a point by this panel's vortices, assuming a unit vortex strength. @@ -63,7 +59,8 @@ class Panel: calculate_induced_velocity: This method calculates the velocity induced at a point by this panel's vortices with their given vortex strengths. - calculate_projected_area: + calculate_projected_area: This method calculates the area of the panel + projected on some plane defined by its unit normal vector. update_coefficients: This method updates the panel's force coefficients. @@ -272,9 +269,15 @@ def unit_spanwise(self): return spanwise / np.linalg.norm(spanwise) - # ToDo: Update this method's documentation. @property def unit_chordwise(self): + """This method defines a property for the panel's unit chordwise vector as a + (3,) array. + + :return: (3,) array of floats + This is the panel's unit chordwise vector as a (3,) array. The positive + direction is defined as front to back. The units are in meters. + """ right_chordwise = -self.right_leg left_chordwise = self.left_leg @@ -282,35 +285,61 @@ def unit_chordwise(self): return chordwise / np.linalg.norm(chordwise) - # ToDo: Update this method's documentation. @property def average_span(self): + """This method defines a property for the average span of the panel. + + :return: float + This is the average span, which is defined as the average of the front + and back leg lengths. The units are meters. + """ front_leg_length = np.linalg.norm(self.front_leg) back_leg_length = np.linalg.norm(self.back_leg) return (front_leg_length + back_leg_length) / 2 - # ToDo: Update this method's documentation. @property def average_chord(self): + """This method defines a property for the average chord of the panel. + + :return: float + This is the average chord, which is defined as the average of the right + and left leg lengths. The units are meters. + """ right_leg_length = np.linalg.norm(self.right_leg) left_leg_length = np.linalg.norm(self.left_leg) return (right_leg_length + left_leg_length) / 2 - # ToDo: Update this method's documentation. @property def _first_diagonal(self): + """This method defines a property for the panel's first diagonal vector. + + :return: (3,) array of floats + This is the first diagonal vector, which is defined as the vector from + the back-left vertex to the front-right vertex. The units are meters. + """ return self.front_right_vertex - self.back_left_vertex - # ToDo: Update this method's documentation. @property def _second_diagonal(self): + """This method defines a property for the panel's second diagonal vector. + + :return: (3,) array of floats + This is the second diagonal vector, which is defined as the vector from + the back-right vertex to the front-left vertex. The units are meters. + """ return self.front_left_vertex - self.back_right_vertex - # ToDo: Update this method's documentation. @property def _cross(self): + """This method defines a property for cross product of the panel's first and + second diagonal vectors. + + :return: (3,) array of floats + This is the cross product of the panel's first and second diagonal + vectors. The units are meters. + """ return np.cross(self._first_diagonal, self._second_diagonal) def calculate_normalized_induced_velocity(self, point): @@ -326,7 +355,6 @@ def calculate_normalized_induced_velocity(self, point): This is a vector containing the x, y, and z components of the induced velocity. """ - normalized_induced_velocity = np.zeros(3) if self.ring_vortex is not None: @@ -353,7 +381,6 @@ def calculate_induced_velocity(self, point): This is a vector containing the x, y, and z components of the induced velocity. """ - induced_velocity = np.zeros(3) if self.ring_vortex is not None: @@ -365,21 +392,31 @@ def calculate_induced_velocity(self, point): return induced_velocity - # ToDo: Update this method's documentation. def calculate_projected_area(self, n_hat): - """ + """This method calculates the area of the panel projected on some plane + defined by its unit normal vector. - :param n_hat: - :return: + :param n_hat: (3,) array of floats + This is a (3,) array of the components of the projection plane's unit + normal vector. The vector must have a magnitude of one. The units are + meters. + :return: float + This is the area of the panel projected onto the plane defined by the + normal vector. The units are square meters. """ + # Find the projections of the first and second diagonal vectors onto the + # plane's unit normal vector. proj_n_hat_first_diag = np.dot(self._first_diagonal, n_hat) * n_hat proj_n_hat_second_diag = np.dot(self._second_diagonal, n_hat) * n_hat + # Find the projection of the first and second diagonal onto the plane. proj_plane_first_diag = self._first_diagonal - proj_n_hat_first_diag proj_plane_second_diag = self._second_diagonal - proj_n_hat_second_diag + # The projected area is found by dividing the magnitude of cross product of + # the diagonal vectors by two. Read the area method for a more detailed + # explanation. proj_cross = np.cross(proj_plane_first_diag, proj_plane_second_diag) - return np.linalg.norm(proj_cross) / 2 def update_coefficients(self, dynamic_pressure): @@ -387,7 +424,6 @@ def update_coefficients(self, dynamic_pressure): :return: None """ - induced_drag = -self.near_field_force_wind_axes[0] side_force = self.near_field_force_wind_axes[1] lift = -self.near_field_force_wind_axes[2] From 92971f5a4f7e8fbe2b92c3b72443bd8c2bd482cf Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 19 Jul 2023 13:00:36 -0400 Subject: [PATCH 17/24] I fixed a bug by making one of the functions no longer use numba. --- pterasoftware/functions.py | 1 - pterasoftware/geometry.py | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/pterasoftware/functions.py b/pterasoftware/functions.py index 1a536368..13b4304e 100644 --- a/pterasoftware/functions.py +++ b/pterasoftware/functions.py @@ -661,7 +661,6 @@ def numba_1d_explicit_cross(vectors_1, vectors_2): return crosses -@njit(cache=True, fastmath=False) def reflect_point_across_plane(point, plane_unit_normal, plane_point): """This function finds the coordinates of the reflection of a point across a plane in 3D space. diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index a10b5e83..da412927 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -5,8 +5,7 @@ Wing: This is a class used to contain the wings of an Airplane object. - WingCrossSection: This class is used to contain the cross sections of a Wing - object. + WingCrossSection: This class is used to contain the cross sections of a Wing object. Airfoil: This class is used to contain the airfoil of a WingCrossSection object. From f25b642639e513e866df3e951aede73541fb67da Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 19 Jul 2023 16:56:48 -0400 Subject: [PATCH 18/24] I finished updating the documentation in geometry.py. --- pterasoftware/geometry.py | 220 ++++++++++++++++++++++---------------- 1 file changed, 126 insertions(+), 94 deletions(-) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index da412927..1cc61d6d 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -25,7 +25,6 @@ from . import meshing -# ToDo: Add a check that wings has at least one elements. class Airplane: """This is a class used to contain airplanes. @@ -60,7 +59,8 @@ def __init__( """This is the initialization method. :param wings: list of Wing objects - This is a list of the airplane's wings defined as Wing objects. + This is a list of the airplane's wings defined as Wing objects. It must + contain at least one Wing object. :param name: str, optional A sensible name for your airplane. The default is "Untitled Airplane". :param x_ref: float, optional @@ -85,18 +85,21 @@ def __init__( This is the reference calculate_span. If not set, it populates from first wing object. """ + # Initialize the list of wings or raise an exception if it is empty. + if len(wings) > 0: + self.wings = wings + else: + raise Exception("An airplane's list of wings must have at least one entry.") - # Initialize the name and the moment reference point. + # Initialize the name, moment reference point coordinates, and weight. self.name = name self.x_ref = x_ref self.y_ref = y_ref self.z_ref = z_ref - self.xyz_ref = np.array([self.x_ref, self.y_ref, self.z_ref]) - - # Initialize the weight. self.weight = weight - self.wings = wings + # Create a (3,) array to hold the moment reference point coordinates. + self.xyz_ref = np.array([self.x_ref, self.y_ref, self.z_ref]) # Set the wing reference dimensions to be the main wing's reference dimensions. self.set_reference_dimensions_from_main_wing() @@ -156,14 +159,12 @@ class Wing: Date of Retrieval: 04/24/2020 This class contains the following public methods: - projected_area: This method calculates the projected area of the wing and - assigns it to the projected_area attribute. + projected_area: This method defines a property for the area of the wing + projected onto the plane defined by the projected unit normal vector. - wetted_area: This method calculates the wetted area of the wing based on the - areas of its panels and assigns it to the wetted_area attribute. + wetted_area: This method defines a property for the wing's wetted area. - span: This method calculates the span of the wing and assigns it to the span - attribute. + span: This method defines a property for the wing's span. standard_mean_chord: This method calculates the standard mean chord of the wing and assigns it to the standard_mean_chord attribute. @@ -178,7 +179,6 @@ class Wing: This class is not meant to be subclassed. """ - # ToDo: Add a check that wing_cross_sections has at least two elements. def __init__( self, wing_cross_sections, @@ -237,41 +237,62 @@ def __init__( recommended for steady simulations and a uniform spacing is highly recommended for unsteady simulations. The default is "cosine". """ + # Initialize the list of wing cross sections or raise an exception if it + # contains less than two entries. + if len(wing_cross_sections) >= 2: + self.wing_cross_sections = wing_cross_sections + else: + raise Exception( + "An wing's list of wing cross sections must have at least " + "two entries." + ) + # Initialize the name and the position of the wing's leading edge. self.name = name self.x_le = x_le self.y_le = y_le self.z_le = z_le self.symmetry_unit_normal_vector = symmetry_unit_normal_vector + self.symmetric = symmetric + self.unit_chordwise_vector = unit_chordwise_vector + self.num_chordwise_panels = num_chordwise_panels + + # If the value for the chordwise spacing valid, initial it. Otherwise, + # raise an exception. + if chordwise_spacing in ["cosine", "uniform"]: + self.chordwise_spacing = chordwise_spacing + else: + raise Exception('The chordwise spacing must be "cosine" or "uniform".') + + # Create a (3,) array to hold the leading edge's coordinates. self.leading_edge = np.array([self.x_le, self.y_le, self.z_le]) # Check that the wing's symmetry plane is equal to its root wing cross # section's plane. if not np.array_equal( - symmetry_unit_normal_vector, wing_cross_sections[0].unit_normal_vector + self.symmetry_unit_normal_vector, + self.wing_cross_sections[0].unit_normal_vector, ): raise Exception( "The wing's symmetry plane must be the same as its root wing cross " "section's plane." ) + # Check that the root wing cross section's leading edge isn't offset from the # wing's leading edge. - if np.any(wing_cross_sections[0].leading_edge): + if np.any(self.wing_cross_sections[0].leading_edge): raise Exception( "The root wing cross section's leading edge must not be offset from " "the wing's leading edge." ) - # Initialize the other attributes. - self.wing_cross_sections = wing_cross_sections - self.symmetric = symmetric - self.unit_chordwise_vector = unit_chordwise_vector - self.num_chordwise_panels = num_chordwise_panels - self.chordwise_spacing = chordwise_spacing - - # Catch invalid values of chordwise_spacing. - if self.chordwise_spacing not in ["cosine", "uniform"]: - raise Exception("Invalid value of chordwise_spacing!") + # Check that the wing's chordwise and normal directions are perpendicular. + if np.dot(self.unit_chordwise_vector, self.symmetry_unit_normal_vector) != 0: + raise Exception( + "Every wing cross section's plane must intersect with the wing's " + "symmetry plane along a line that is parallel with the wing's " + "chordwise direction." + ) # 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 @@ -283,24 +304,18 @@ def __init__( if self.symmetric: self.num_spanwise_panels *= 2 - # ToDo: Document this section. + # Check that all the wing cross sections have valid unit normal vectors. for wing_cross_section in self.wing_cross_sections: + # Find the vector parallel to the intersection of this wing cross # section's plane and the wing's symmetry plane. - orthogonal_vector = np.cross( + plane_intersection_vector = np.cross( self.symmetry_unit_normal_vector, wing_cross_section.unit_normal_vector ) - if np.any(np.cross(orthogonal_vector, self.unit_chordwise_vector)): - raise Exception( - "Every wing cross section's plane must intersect with the wing's " - "symmetry plane along a line that is parallel with the wing's " - "chordwise direction." - ) - if ( - np.dot(self.unit_chordwise_vector, self.symmetry_unit_normal_vector) - != 0 - ): + # If this vector is not parallel to the wing's chordwise vector, raise an + # exception. + if np.any(np.cross(plane_intersection_vector, self.unit_chordwise_vector)): raise Exception( "Every wing cross section's plane must intersect with the wing's " "symmetry plane along a line that is parallel with the wing's " @@ -310,23 +325,17 @@ def __init__( # Calculate the number of panels on this wing. self.num_panels = self.num_spanwise_panels * self.num_chordwise_panels - for wing_cross_section in wing_cross_sections: + for wing_cross_section in self.wing_cross_sections: wing_cross_section.wing_unit_chordwise_vector = self.unit_chordwise_vector - # ToDo: Delete these after debugging. - del wing_cross_section - del orthogonal_vector - del unit_chordwise_vector - del symmetry_unit_normal_vector - # Define an attribute that is the normal vector of the plane that the # projected area will reference. self.projected_unit_normal_vector = np.cross( self.unit_chordwise_vector, self.symmetry_unit_normal_vector ) - # Initialize the panels attribute. Then mesh the wing, which will - # populate this attribute. + # Initialize the panels attribute. Then mesh the wing, which will populate + # this attribute. self.panels = None meshing.mesh_wing(self) @@ -335,11 +344,10 @@ def __init__( self.wake_ring_vortex_vertices = np.empty((0, self.num_spanwise_panels + 1, 3)) self.wake_ring_vortices = np.zeros((0, self.num_spanwise_panels), dtype=object) - # ToDo: Update this method's documentation. @property def projected_area(self): - """This method calculates the projected area of the wing and assigns it to - the projected_area attribute. + """This method defines a property for the area of the wing projected onto the + plane defined by the projected unit normal vector. If the wing is symmetric, the area of the mirrored half is included. @@ -359,11 +367,9 @@ def projected_area(self): return projected_area - # ToDo: Update this method's documentation. @property def wetted_area(self): - """This method calculates the wetted area of the wing based on the areas of - its panels and assigns it to the wetted_area attribute. + """This method defines a property for the wing's wetted area. If the wing is symmetrical, the area of the mirrored half is included. @@ -381,19 +387,18 @@ def wetted_area(self): return wetted_area - # ToDo: Update this method's documentation. @property def span(self): - """This method calculates the span of the wing and assigns it to the span - attribute. The span is found first finding vector connecting the leading - edges of the root and tip wing cross sections. Then, this vector is projected - onto the symmetry plane's unit normal vector. The span is defined as the - magnitude of this projection. + """This method defines a property for the wing's span. - If the wing is symmetrical, this method includes the span of the mirrored half. + The span is found by first finding vector connecting the leading edges of the + root and tip wing cross sections. Then, this vector is projected onto the + symmetry plane's unit normal vector. The span is defined as the magnitude of + this projection. If the wing is symmetrical, this method includes the span of + the mirrored half. :return span: float - This attribute is the wingspan. It has units of meters. + This is the wing's span. It has units of meters. """ root_to_tip_leading_edge = ( self.wing_cross_sections[-1].leading_edge @@ -490,8 +495,14 @@ class WingCrossSection: Date of Retrieval: 04/26/2020 This class contains the following public methods: - trailing_edge: This method calculates the coordinates of the trailing edge of - this wing cross section and assigns them to the trailing_edge attribute. + unit_chordwise_vector: This method defines a property for the wing cross + section's unit chordwise vector. + + unit_up_vector: This method defines a property for the wing cross section's + unit up vector. + + trailing_edge: This method defines a property for the coordinates of this + wing cross section's trailing edge. This class contains the following class attributes: None @@ -561,7 +572,7 @@ def __init__( recommended. The default value is "cosine". """ - # Initialize all the class attributes. + # Initialize all the user-provided attributes. self.x_le = x_le self.y_le = y_le self.z_le = z_le @@ -574,26 +585,45 @@ def __init__( self.control_surface_deflection = control_surface_deflection self.num_spanwise_panels = num_spanwise_panels self.spanwise_spacing = spanwise_spacing + + # Create a (3,) array to hold the leading edge's coordinates. self.leading_edge = np.array([x_le, y_le, z_le]) - # ToDo: Document this + # Define an attribute for the parent wing's unit chordwise vector, which will + # be set by this wing cross section's parent wing's initialization method. self.wing_unit_chordwise_vector = None # Catch bad values of the chord length. if self.chord <= 0: - raise Exception("Invalid value of chord") + raise Exception("A wing cross section's chord length must be positive.") - # Catch invalid values of control_surface_type. + # Catch invalid values of the control surface type. if self.control_surface_type not in ["symmetric", "asymmetric"]: - raise Exception("Invalid value of control_surface_type") + raise Exception( + 'A wing cross section\'s control surface type must be "symmetric" or ' + '"asymmetric".' + ) - # Catch invalid values of spanwise_spacing. + # Catch invalid values of the spanwise spacing. if self.spanwise_spacing not in ["cosine", "uniform"]: - raise Exception("Invalid value of spanwise_spacing!") + raise Exception( + 'A wing cross section\'s spanwise spacing must be "cosine" or ' + '"uniform".' + ) - # ToDo: Update this method's documentation. @property def unit_chordwise_vector(self): + """This method defines a property for the wing cross section's unit chordwise + vector. + + The unit chordwise vector is defined as the parent wing's unit chordwise + vector, rotated by the wing cross section's twist about the wing cross + section's normal vector. + + :return: (3,) array of floats + This is the unit vector for the wing cross section's chordwise direction. + The units are meters. + """ # Find the rotation matrix given the cross section's twist. twist_rotation_matrix = functions.angle_axis_rotation_matrix( self.twist * np.pi / 180, self.unit_normal_vector @@ -603,28 +633,28 @@ def unit_chordwise_vector(self): # unit chordwise vector. return twist_rotation_matrix @ self.wing_unit_chordwise_vector - # ToDo: Update this method's documentation. @property - def trailing_edge(self): - """This method calculates the coordinates of the trailing edge of this wing - cross section and assigns them to the trailing_edge attribute. + def unit_up_vector(self): + """This method defines a property for the wing cross section's unit up vector. - :return: array - This is a 1D array that contains the coordinates of this wing cross - section's trailing edge. + :return: (3,) array of floats + This is the unit vector for the wing cross section's chordwise direction. + The units are meters. """ - chordwise_vector = self.chord * self.unit_chordwise_vector - - return self.leading_edge + chordwise_vector + return np.cross(self.unit_chordwise_vector, self.unit_normal_vector) - # ToDo: Document this @property - def unit_up_vector(self): - """ + def trailing_edge(self): + """This method defines a property for the coordinates of this wing cross + section's trailing edge. - :return: + :return: (3,) array of floats + This is an array of the coordinates of this wing cross section's trailing + edge. """ - return np.cross(self.unit_chordwise_vector, self.unit_normal_vector) + chordwise_vector = self.chord * self.unit_chordwise_vector + + return self.leading_edge + chordwise_vector class Airfoil: @@ -711,22 +741,24 @@ def __init__( self.coordinates = coordinates else: # If not, populate the coordinates from the directory. - self.populate_coordinates() # populates self.coordinates - - # Check that the coordinates have been set. - assert hasattr(self, "coordinates") + self.populate_coordinates() - # Initialize other attributes. + # Initialize other user-supplied attributes. self.repanel = repanel - self.mcl_coordinates = None - self.upper_minus_mcl = None - self.thickness = None self.n_points_per_side = n_points_per_side + # Check that the coordinates have been set. + assert hasattr(self, "coordinates") + # If repanel is True, repanel the airfoil. if self.repanel: self.repanel_current_airfoil(n_points_per_side=self.n_points_per_side) + # Initialize other attributes that will be set by populate_mcl_coordinates. + self.mcl_coordinates = None + self.upper_minus_mcl = None + self.thickness = None + # Populate the mean camber line attributes. self.populate_mcl_coordinates() From 1baa6203575d57877b0abb9f01cc0c05304328df Mon Sep 17 00:00:00 2001 From: camUrban Date: Wed, 19 Jul 2023 19:47:55 -0400 Subject: [PATCH 19/24] I significantly simplified meshing.py and documented it. --- pterasoftware/meshing.py | 322 +++++++-------------------------------- 1 file changed, 58 insertions(+), 264 deletions(-) diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index 8b530321..71141bd0 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -11,13 +11,8 @@ quadrilateral mesh of its geometry, and then populates the object's panels with the mesh data. - get_panel_vertices: This function calculates the vertices of the panels on a wing. - - ToDo: Update this method's documentation. - get_normalized_projected_quarter_chords: This method returns the quarter chords - of a collection of wing cross sections based on the coordinates of their leading - and trailing edges. These quarter chords are also projected on to the YZ plane - and normalized by their magnitudes. + get_panel_vertices: This function calculates the vertices of the panels on a wing + section. get_transpose_mcl_vectors: This function takes in the inner and outer airfoils of a wing cross section and its chordwise coordinates. It returns a list of four @@ -47,7 +42,6 @@ def mesh_wing(wing): This is the wing to be meshed. :return: None """ - # Define the number of chordwise panels and points. num_chordwise_panels = wing.num_chordwise_panels num_chordwise_coordinates = num_chordwise_panels + 1 @@ -58,119 +52,10 @@ def mesh_wing(wing): else: chordwise_coordinates = functions.cosspace(0, 1, num_chordwise_coordinates) - # Initialize two empty 0 x 3 arrays to hold the corners of each wing cross - # section. They will eventually be L x 3 arrays, where L is number of wing cross - # sections. - wing_cross_sections_leading_edges = np.empty((0, 3)) - wing_cross_sections_trailing_edges = np.empty((0, 3)) - - # Iterate through the meshed wing cross sections and vertically stack the global - # location of each wing cross sections leading and trailing edges. - for wing_cross_section in wing.wing_cross_sections: - wing_cross_sections_leading_edges = np.vstack( - ( - wing_cross_sections_leading_edges, - wing_cross_section.leading_edge + wing.leading_edge, - ) - ) - wing_cross_sections_trailing_edges = np.vstack( - ( - wing_cross_sections_trailing_edges, - wing_cross_section.trailing_edge + wing.leading_edge, - ) - ) - - normalized_projected_quarter_chords = get_normalized_projected_quarter_chords( - wing_cross_sections_leading_edges, - wing_cross_sections_trailing_edges, - wing.unit_chordwise_vector, - ) - # Get the number of wing cross sections and wing sections. num_wing_cross_sections = len(wing.wing_cross_sections) num_wing_sections = num_wing_cross_sections - 1 - # ToDo: Change this part's language to remove YZ plane mentions. - # Then, construct the adjusted normal directions for each wing cross section. - # Make the normals for the inner wing cross sections, where we need to merge - # directions. - if num_wing_cross_sections > 2: - # Add together the adjacent normalized wing section quarter chords projected - # onto the YZ plane. - wing_sections_local_normals = ( - normalized_projected_quarter_chords[:-1, :] - + normalized_projected_quarter_chords[1:, :] - ) - - # Create a list of the magnitudes of the summed adjacent normalized wing - # section quarter chords projected onto the YZ plane. - wing_sections_local_normals_len = np.linalg.norm( - wing_sections_local_normals, axis=1 - ) - - # Convert the list to a column vector. - transpose_wing_sections_local_normals_len = np.expand_dims( - wing_sections_local_normals_len, axis=1 - ) - - # Normalize the summed adjacent normalized wing section quarter chords - # projected onto the YZ plane by their magnitudes. - wing_sections_local_unit_normals = ( - wing_sections_local_normals / transpose_wing_sections_local_normals_len - ) - - # Vertically stack the first normalized wing section quarter chord, the inner - # normalized wing section quarter chords, and the last normalized wing - # section quarter chord. - wing_sections_local_unit_normals = np.vstack( - ( - normalized_projected_quarter_chords[0, :], - wing_sections_local_unit_normals, - normalized_projected_quarter_chords[-1, :], - ) - ) - else: - # Vertically stack the first and last normalized wing section quarter chords. - wing_sections_local_unit_normals = np.vstack( - ( - normalized_projected_quarter_chords[0, :], - normalized_projected_quarter_chords[-1, :], - ) - ) - - # Create a list of the wing cross section chord lengths. - wing_cross_sections_chord_lengths = np.array( - [wing_cross_section.chord for wing_cross_section in wing.wing_cross_sections] - ) - - # Normalize the wing cross section back vectors by their magnitudes. - wing_cross_sections_unit_chordwise_vectors = np.vstack( - [ - wing_cross_section.unit_chordwise_vector - for wing_cross_section in wing.wing_cross_sections - ] - ) - - # ToDo: Clarify these calculations' documentation. - # Then, construct the up direction for each wing cross section. - wing_cross_sections_unit_up_vectors = np.vstack( - [ - wing_cross_section.unit_up_vector - for wing_cross_section in wing.wing_cross_sections - ] - ) - adj_wing_cross_sections_unit_up_vectors = np.cross( - wing_cross_sections_unit_chordwise_vectors, - wing_sections_local_unit_normals, - axis=1, - ) - - # ToDo: This isn't correct for all situations now. Fix it. - # If the wing is symmetric, set the local up position of the root cross section - # to be the in local Z direction. - if wing.symmetric: - adj_wing_cross_sections_unit_up_vectors[0] = np.array([0, 0, 1]) - # Initialize an empty array that will hold the panels of this wing. It currently # has 0 columns and M rows, where M is the number of the wing's chordwise panels. wing_panels = np.empty((num_chordwise_panels, 0), dtype=object) @@ -222,17 +107,15 @@ def mesh_wing(wing): front_outer_vertices, back_inner_vertices, back_outer_vertices, - ] = get_panel_vertices( - inner_wing_cross_section_num, - wing_cross_sections_unit_chordwise_vectors, - wing_cross_sections_unit_up_vectors, - wing_cross_sections_chord_lengths, - wing_cross_sections_leading_edges, + ] = get_wing_section_panel_vertices( + wing.leading_edge, + inner_wing_cross_section, + outer_wing_cross_section, transpose_mcl_vectors, spanwise_coordinates, ) - # Compute a matrix that is M x N, where M and N are the number of chordwise + # Compute a matrix that is (M, N), where M and N are the number of chordwise # and spanwise panels. The values are either 1 if the panel at that location # is a trailing edge, or 0 if not. wing_section_is_trailing_edge = np.vstack( @@ -242,7 +125,7 @@ def mesh_wing(wing): ) ) - # Compute a matrix that is M x N, where M and N are the number of chordwise + # Compute a matrix that is (M, N), where M and N are the number of chordwise # and spanwise panels. The values are either 1 if the panel at that location # is a leading edge, or 0 if not. wing_section_is_leading_edge = np.vstack( @@ -308,17 +191,15 @@ def mesh_wing(wing): front_outer_vertices, back_inner_vertices, back_outer_vertices, - ] = get_panel_vertices( - inner_wing_cross_section_num, - wing_cross_sections_unit_chordwise_vectors, - adj_wing_cross_sections_unit_up_vectors, - wing_cross_sections_chord_lengths, - wing_cross_sections_leading_edges, + ] = get_wing_section_panel_vertices( + wing.leading_edge, + inner_wing_cross_section, + outer_wing_cross_section, transpose_mcl_vectors, spanwise_coordinates, ) - # Compute a matrix that is M x N, where M and N are the number of + # Compute a matrix that is (M, N), where M and N are the number of # chordwise and spanwise panels. The values are either 1 if the panel at # that location is a trailing edge, or 0 if not. wing_section_is_trailing_edge = np.vstack( @@ -330,7 +211,7 @@ def mesh_wing(wing): ) ) - # Compute a matrix that is M x N, where M and N are the number of + # Compute a matrix that is (M, N), where M and N are the number of # chordwise and spanwise panels. The values are either 1 if the panel at # that location is a leading edge, or 0 if not. wing_section_is_leading_edge = np.vstack( @@ -405,55 +286,39 @@ def mesh_wing(wing): wing.panels = wing_panels -def get_panel_vertices( - inner_wing_cross_section_num, - wing_cross_sections_unit_chordwise_vectors, - wing_cross_sections_unit_up_vectors, - wing_cross_sections_chord_lengths, - wing_cross_sections_leading_edges, +def get_wing_section_panel_vertices( + wing_leading_edge, + inner_wing_cross_section, + outer_wing_cross_section, transpose_mcl_vectors, spanwise_coordinates, ): - # ToDo: Update this documentation. - """This function calculates the vertices of the panels on a wing. - - :param inner_wing_cross_section_num: int - This parameter is the integer index of this wing's section's inner wing cross - section. - :param wing_cross_sections_unit_chordwise_vectors: array - This parameter is an array of floats with size (X, 3), where X is this wing's - number of wing cross sections. It holds two unit vectors that correspond to - the wing cross sections' local-back directions, written in the body frame. - :param wing_cross_sections_unit_up_vectors: array - This parameter is an array of floats with size (X, 3), where X is this wing's - number of wing cross sections. It holds two unit vectors that correspond to - the wing cross sections' local-up directions, written in the body frame. - :param wing_cross_sections_chord_lengths: array - This parameter is a 1D array of floats with length X, where X is this wing's - number of wing cross sections. It holds the chord lengths of this wing's wing - cross section in meters. - :param wing_cross_sections_leading_edges: array - This parameter is an array of floats with size (Xx3), where X is this wing's - number of wing cross sections. It holds the coordinates of the leading edge - points of this wing's wing cross sections. The units are in meters. - :param transpose_mcl_vectors: list - This parameter is a list of 4 (M x 1) arrays of floats, where M is the number - of chordwise points. The first array contains the local-up component of the - mean-camber-line slope at each of the chordwise points along the inner wing + """This function calculates the vertices of the panels on a wing section. + + :param wing_leading_edge: (3,) array of floats + This is an array of the wing's leading edge coordinates. The units are meters. + :param inner_wing_cross_section: WingCrossSection + This is this wing section's inner Wing Cross Section object. + :param outer_wing_cross_section: WingCrossSection + This is this wing section's outer Wing Cross Section object. + :param transpose_mcl_vectors: list of 4 (M, 1) arrays of floats + This parameter is a list of 4 (M, 1) arrays where M is the number of + chordwise points. The first array contains the local-up component of the + mean-camber-line's slope at each of the chordwise points along the inner wing cross section. The second array contains the local-back component of the - mean-camber-line slope at each of the chordwise points along the inner wing + mean-camber-line's slope at each of the chordwise points along the inner wing cross section. The third and fourth arrays are the same but for the outer - wing cross section. - :param spanwise_coordinates: array - This parameter is a 1D array of floats with length N, where N is the number - of spanwise points. It holds the distances of each spanwise point along the - wing cross section and is normalized from 0 to 1. - :return: list - This function returns a list with four arrays. Each array is size (MxNx3), - where M is the number of chordwise points and N is the number of spanwise - points. The arrays are the body frame coordinates of this wing's panels' - front-inner, front-outer, back-inner, and back-outer vertices. The units are - in meters. + wing cross section instead of the inner wing cross section. The units are + meters. + :param spanwise_coordinates: (N, 1) array of floats + This parameter is a (N, 1) array of floats, where N is the number of spanwise + points. It holds the distances of each spanwise point along the wing section + and is normalized from 0 to 1. These values are unitless. + :return: list of 4 (M, N, 3) arrays of floats + This function returns a list with four (M, N, 3) arrays, where M is the + number of chordwise points and N is the number of spanwise points. The arrays + are the coordinates of this wing's panels' front-inner, front-outer, + back-inner, and back-outer vertices. The units are in meters. """ [ transpose_inner_mcl_up_vector, @@ -465,57 +330,55 @@ def get_panel_vertices( # Convert the inner wing cross section's non dimensional local back airfoil frame # coordinates to meshed wing coordinates. inner_wing_cross_section_mcl_back = ( - wing_cross_sections_unit_chordwise_vectors[inner_wing_cross_section_num, :] + inner_wing_cross_section.unit_chordwise_vector + * inner_wing_cross_section.chord * transpose_inner_mcl_back_vector - * wing_cross_sections_chord_lengths[inner_wing_cross_section_num] ) # Convert the inner wing cross section's non dimensional local up airfoil frame # coordinates to meshed wing coordinates. inner_wing_cross_section_mcl_up = ( - wing_cross_sections_unit_up_vectors[inner_wing_cross_section_num, :] + inner_wing_cross_section.unit_up_vector + * inner_wing_cross_section.chord * transpose_inner_mcl_up_vector - * wing_cross_sections_chord_lengths[inner_wing_cross_section_num] - # * wing_cross_sections_scaling_factors[inner_wing_cross_section_num] ) - # Define the index of this wing section's outer wing cross section. - outer_wing_cross_section_num = inner_wing_cross_section_num + 1 - # Convert the outer wing cross section's non dimensional local back airfoil frame # coordinates to meshed wing coordinates. outer_wing_cross_section_mcl_back = ( - wing_cross_sections_unit_chordwise_vectors[outer_wing_cross_section_num, :] + outer_wing_cross_section.unit_chordwise_vector + * outer_wing_cross_section.chord * transpose_outer_mcl_back_vector - * wing_cross_sections_chord_lengths[outer_wing_cross_section_num] ) # Convert the outer wing cross section's non dimensional local up airfoil frame # coordinates to meshed wing coordinates. outer_wing_cross_section_mcl_up = ( - wing_cross_sections_unit_up_vectors[outer_wing_cross_section_num, :] + outer_wing_cross_section.unit_up_vector + * outer_wing_cross_section.chord * transpose_outer_mcl_up_vector - * wing_cross_sections_chord_lengths[outer_wing_cross_section_num] ) # Convert the inner wing cross section's meshed wing coordinates to absolute - # coordinates. This is size M x 3, where M is the number of chordwise points. + # coordinates. This is size (M, 3) where M is the number of chordwise points. inner_wing_cross_section_mcl = ( - wing_cross_sections_leading_edges[inner_wing_cross_section_num, :] + wing_leading_edge + + inner_wing_cross_section.leading_edge + inner_wing_cross_section_mcl_back + inner_wing_cross_section_mcl_up ) # Convert the outer wing cross section's meshed wing coordinates to absolute - # coordinates. This is size M x 3, where M is the number of chordwise points. + # coordinates. This is size (M, 3) where M is the number of chordwise points. outer_wing_cross_section_mcl = ( - wing_cross_sections_leading_edges[outer_wing_cross_section_num, :] + wing_leading_edge + + outer_wing_cross_section.leading_edge + outer_wing_cross_section_mcl_back + outer_wing_cross_section_mcl_up ) # Find the vertices of the points on this wing section with interpolation. This - # returns an M x N x 3 matrix, where M and N are the number of chordwise points + # returns an (M, N, 3) array, where M and N are the number of chordwise points # and spanwise points. wing_section_mcl_vertices = functions.interp_between_points( inner_wing_cross_section_mcl, @@ -523,7 +386,7 @@ def get_panel_vertices( spanwise_coordinates, ) - # Compute the corners of each panel. + # Extract the coordinates for corners of each panel. front_inner_vertices = wing_section_mcl_vertices[:-1, :-1, :] front_outer_vertices = wing_section_mcl_vertices[:-1, 1:, :] back_inner_vertices = wing_section_mcl_vertices[1:, :-1, :] @@ -537,75 +400,6 @@ def get_panel_vertices( ] -def get_normalized_projected_quarter_chords( - wing_cross_sections_leading_edges, - wing_cross_sections_trailing_edges, - wing_unit_chordwise_vector, -): - # ToDo: Update this docstring to swap mentions of YZ plane for the custom plane. - """This method returns the quarter chords of a collection of wing cross sections - based on the coordinates of their leading and trailing edges. These quarter - chords are also projected on to the YZ plane and normalized by their magnitudes. - - :param wing_cross_sections_leading_edges: array - This parameter is an array of floats with size (X, 3), where X is this wing's - number of wing cross sections. For each cross section, this array holds the - body-frame coordinates of its leading edge point in meters. - :param wing_cross_sections_trailing_edges: array - This parameter is an array of floats with size (X, 3), where X is this wing's - number of wing cross sections. For each cross section, this array holds the - body-frame coordinates of its trailing edge point in meters. - :param wing_unit_chordwise_vector - :return normalized_projected_quarter_chords: array - This functions returns an array of floats with size (X - 1, 3), where X is - this wing's number of wing cross sections. This array holds each wing - section's quarter chords projected on to the YZ plane and normalized by their - magnitudes. - """ - # Get the location of each wing cross section's quarter chord point. - wing_cross_sections_quarter_chord_points = ( - wing_cross_sections_leading_edges - + 0.25 - * (wing_cross_sections_trailing_edges - wing_cross_sections_leading_edges) - ) - - # Get a (L - 1) x 3 array of vectors connecting the wing cross section quarter - # chord points, where L is the number of wing cross sections. - quarter_chords = ( - wing_cross_sections_quarter_chord_points[1:, :] - - wing_cross_sections_quarter_chord_points[:-1, :] - ) - - # ToDo: Update this documentation. - # Get directions for transforming 2D airfoil data to 3D by the following steps. - # Project quarter chords onto YZ plane and normalize. Create an L x 2 array with - # just the y and z components of this wing section's quarter chord vectors. - quarter_chords_dot_wing_unit_chordwise_vector = np.einsum( - "ij,j->i", quarter_chords, wing_unit_chordwise_vector - ) - c = np.expand_dims(wing_unit_chordwise_vector, axis=0) - b = np.einsum( - "j,ji->ji", - quarter_chords_dot_wing_unit_chordwise_vector, - c, - ) - projected_quarter_chords = quarter_chords - b - - # Create a list of the lengths of each row of the projected_quarter_chords array. - projected_quarter_chords_len = np.linalg.norm(projected_quarter_chords, axis=1) - - # Convert projected_quarter_chords_len into a column vector. - transpose_projected_quarter_chords_len = np.expand_dims( - projected_quarter_chords_len, axis=1 - ) - # Normalize the coordinates by the magnitudes - normalized_projected_quarter_chords = ( - projected_quarter_chords / transpose_projected_quarter_chords_len - ) - - return normalized_projected_quarter_chords - - def get_transpose_mcl_vectors(inner_airfoil, outer_airfoil, chordwise_coordinates): """This function takes in the inner and outer airfoils of a wing cross section and its chordwise coordinates. It returns a list of four column vectors. They From 5544472e290f6f122679d4ea1d04a854a9f12b38 Mon Sep 17 00:00:00 2001 From: camUrban Date: Thu, 20 Jul 2023 10:00:00 -0400 Subject: [PATCH 20/24] I finished documenting and cleaning up the changes to allow custom cross section planes. --- examples/simple_airplane.py | 186 -------- examples/simple_flapper_uvlm.py | 197 --------- examples/simple_steady.py | 282 ------------ pterasoftware/convergence.py | 6 + .../integration/fixtures/airplane_fixtures.py | 414 +++++++++++++----- .../integration/fixtures/movement_fixtures.py | 5 +- .../fixtures/operating_point_fixtures.py | 9 +- .../fixtures/test_for_convergence.py | 26 -- tests/integration/test_steady_convergence.py | 2 - ..._steady_horseshoe_vortex_lattice_method.py | 17 +- .../test_steady_ring_vortex_lattice_method.py | 4 +- tests/integration/test_steady_trim.py | 7 +- .../integration/test_unsteady_convergence.py | 3 +- ...g_vortex_lattice_method_static_geometry.py | 13 +- tests/references/testing references.xfl | Bin 0 -> 309445 bytes 15 files changed, 331 insertions(+), 840 deletions(-) delete mode 100644 examples/simple_airplane.py delete mode 100644 examples/simple_flapper_uvlm.py delete mode 100644 examples/simple_steady.py delete mode 100644 tests/integration/fixtures/test_for_convergence.py create mode 100644 tests/references/testing references.xfl diff --git a/examples/simple_airplane.py b/examples/simple_airplane.py deleted file mode 100644 index b454e47b..00000000 --- a/examples/simple_airplane.py +++ /dev/null @@ -1,186 +0,0 @@ -"""This is script is an example of how to run Ptera Software's unsteady ring vortex -lattice method solver on a simple airplane object.""" - -import pterasoftware as ps - -# import numpy as np - -size = 4 - -simple_airplane = ps.geometry.Airplane( - wings=[ - ps.geometry.Wing( - wing_cross_sections=[ - ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil( - name="naca0012", - coordinates=None, - repanel=True, - n_points_per_side=400, - ), - x_le=0.0, - y_le=0.0, - z_le=0.0, - chord=2.0, - # unit_normal_vector=np.array([0.0, 1.0, 0.0]), - twist=0.0, - control_surface_type="symmetric", - control_surface_hinge_point=0.75, - control_surface_deflection=0.0, - num_spanwise_panels=int(size / 2), - spanwise_spacing="cosine", - ), - ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil( - name="naca0012", - coordinates=None, - repanel=True, - n_points_per_side=400, - ), - x_le=0.0, - y_le=1.0, - z_le=0.0, - chord=2.0, - # unit_normal_vector=np.array([0.0, 1.0, 0.0]), - twist=0.0, - control_surface_type="symmetric", - control_surface_hinge_point=0.75, - control_surface_deflection=0.0, - num_spanwise_panels=int(size / 2), - spanwise_spacing="cosine", - ), - ], - name="Main Wing", - x_le=-1.0, - y_le=0.0, - z_le=0.0, - # symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), - symmetric=True, - # unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), - num_chordwise_panels=size, - chordwise_spacing="cosine", - ), - ], - name="Simple Airplane", - x_ref=0.0, - y_ref=0.0, - z_ref=0.0, - weight=0.0, - s_ref=None, - c_ref=None, - b_ref=None, -) - -wing = simple_airplane.wings[0] -panels = wing.panels -show_z = False - -[num_rows, num_cols] = panels.shape - -p_text = "" -row_num = 1 -for row_of_panels in panels: - col_num = 1 - for panel in row_of_panels: - p_text += "│" - col_num += 1 - - [this_fl_x, this_fl_y, this_fl_z] = panel.front_left_vertex - [this_fr_x, this_fr_y, this_fr_z] = panel.front_right_vertex - - f_text = "({: .5e}, {: .5e}" - if show_z: - f_text += ", {: .5e}" - f_text += ") " - f_text += "({: .5e}, {: .5e}" - if show_z: - f_text += ", {: .5e}" - f_text += ")" - - if show_z: - this_f_text = f_text.format( - this_fl_x, - this_fl_y, - this_fl_z, - this_fr_x, - this_fr_y, - this_fr_z, - ) - else: - this_f_text = f_text.format( - this_fl_x, - this_fl_y, - this_fr_x, - this_fr_y, - ) - - p_text += this_f_text - - p_text += "│\n" - for panel in row_of_panels: - p_text += "│" - - [this_bl_x, this_bl_y, this_bl_z] = panel.back_left_vertex - [this_br_x, this_br_y, this_br_z] = panel.back_right_vertex - - b_text = "({: .5e}, {: .5e}" - if show_z: - b_text += ", {: .5e}" - b_text += ") " - b_text += "({: .5e}, {: .5e}" - if show_z: - b_text += ", {: .5e}" - b_text += ")" - - if show_z: - this_b_text = b_text.format( - this_bl_x, - this_bl_y, - this_bl_z, - this_br_x, - this_br_y, - this_br_z, - ) - else: - this_b_text = b_text.format( - this_bl_x, - this_bl_y, - this_br_x, - this_br_y, - ) - - p_text += this_b_text - p_text += "│" - last_line = p_text.splitlines()[-1] - - col_len = int((len(last_line) - 1) / num_cols) - - if row_num != num_rows: - col_text = "─" * (col_len - 1) + "┼" - else: - col_text = "─" * (col_len - 1) + "┴" - - all_col_text = col_text * num_cols - all_col_text = all_col_text[:-1] - - if row_num != num_rows: - all_col_text = "├" + all_col_text + "┤" - else: - all_col_text = "└" + all_col_text + "┘" - - p_text = p_text + "\n" + all_col_text + "\n" - - row_num += 1 - -p_text = p_text[:-1] - -last_line = p_text.splitlines()[-1] -col_len = int((len(last_line) - 1) / num_cols) -col_text = "─" * (col_len - 1) + "┬" -all_col_text = col_text * num_cols -all_col_text = all_col_text[:-1] -all_col_text = "┌" + all_col_text + "┐\n" - -p_text = all_col_text + p_text - -print(p_text) diff --git a/examples/simple_flapper_uvlm.py b/examples/simple_flapper_uvlm.py deleted file mode 100644 index 50e6eda8..00000000 --- a/examples/simple_flapper_uvlm.py +++ /dev/null @@ -1,197 +0,0 @@ -"""This is script is an example of how to run Ptera Software's unsteady ring vortex -lattice method solver on a custom airplane with variable geometry. """ - -import pterasoftware as ps -import numpy as np - -example_airplane = ps.geometry.Airplane( - wings=[ - ps.geometry.Wing( - name="Caudal Fin", - x_le=4, - y_le=3, - symmetric=True, - num_chordwise_panels=3, - chordwise_spacing="uniform", - symmetry_unit_normal_vector=np.array([0, np.sqrt(2), np.sqrt(2)]), - unit_chordwise_vector=np.array([1, 0, 0]), - wing_cross_sections=[ - ps.geometry.WingCrossSection( - x_le=0.0, - y_le=0.0, - z_le=0.0, - twist=0.0, - unit_normal_vector=np.array([0, np.sqrt(2), np.sqrt(2)]), - num_spanwise_panels=3, - spanwise_spacing="cosine", - chord=1.0, - airfoil=ps.geometry.Airfoil( - name="naca2412", - ), - ), - ps.geometry.WingCrossSection( - x_le=0.0, - y_le=5.0, - z_le=0.0, - chord=1.0, - twist=0.0, - unit_normal_vector=np.array([0, 1, 0]), - airfoil=ps.geometry.Airfoil( - name="naca4012", - ), - ), - ps.geometry.WingCrossSection( - x_le=1.0, - y_le=7.0, - z_le=1.0, - chord=1.0, - twist=0.0, - unit_normal_vector=np.array([0, 1, 0]), - airfoil=ps.geometry.Airfoil( - name="naca4012", - ), - ), - ], - ), - ], -) - -main_wing_root_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( - base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[0], - sweeping_amplitude=0.0, - sweeping_period=0.0, - sweeping_spacing="sine", - pitching_amplitude=0.0, - pitching_period=0.0, - pitching_spacing="sine", - heaving_amplitude=0.0, - heaving_period=0.0, - heaving_spacing="sine", -) - -main_wing_mid_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( - base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[1], - sweeping_amplitude=0.0, - sweeping_period=0.0, - sweeping_spacing="sine", - pitching_amplitude=0.0, - pitching_period=0.0, - pitching_spacing="sine", - heaving_amplitude=0.0, - heaving_period=0.0, - heaving_spacing="sine", -) - -main_wing_tip_wing_cross_section_movement = ps.movement.WingCrossSectionMovement( - base_wing_cross_section=example_airplane.wings[0].wing_cross_sections[2], - sweeping_amplitude=0.0, - sweeping_period=0.0, - sweeping_spacing="sine", - pitching_amplitude=0.0, - pitching_period=0.0, - pitching_spacing="sine", - heaving_amplitude=0.0, - heaving_period=0.0, - heaving_spacing="sine", -) - -main_wing_movement = ps.movement.WingMovement( - base_wing=example_airplane.wings[0], - wing_cross_sections_movements=[ - main_wing_root_wing_cross_section_movement, - main_wing_mid_wing_cross_section_movement, - main_wing_tip_wing_cross_section_movement, - ], - x_le_amplitude=0.0, - x_le_period=0.0, - x_le_spacing="sine", - y_le_amplitude=0.0, - y_le_period=0.0, - y_le_spacing="sine", - z_le_amplitude=0.0, - z_le_period=0.0, - z_le_spacing="sine", -) - -del main_wing_root_wing_cross_section_movement -del main_wing_tip_wing_cross_section_movement - -airplane_movement = ps.movement.AirplaneMovement( - base_airplane=example_airplane, - wing_movements=[main_wing_movement], - x_ref_amplitude=0.0, - x_ref_period=0.0, - x_ref_spacing="sine", - y_ref_amplitude=0.0, - y_ref_period=0.0, - y_ref_spacing="sine", - z_ref_amplitude=0.0, - z_ref_period=0.0, - z_ref_spacing="sine", -) - -del main_wing_movement - -example_operating_point = ps.operating_point.OperatingPoint( - density=1.225, - beta=0.0, - velocity=10.0, - alpha=0.0, - nu=15.06e-6, -) - -operating_point_movement = ps.movement.OperatingPointMovement( - base_operating_point=example_operating_point, - velocity_amplitude=0.0, - velocity_period=0.0, - velocity_spacing="sine", -) - -movement = ps.movement.Movement( - airplane_movements=[airplane_movement], - operating_point_movement=operating_point_movement, - num_steps=None, - delta_time=None, -) - -del airplane_movement -del operating_point_movement - -example_problem = ps.problems.UnsteadyProblem( - movement=movement, -) - -example_solver = ( - ps.unsteady_ring_vortex_lattice_method.UnsteadyRingVortexLatticeMethodSolver( - unsteady_problem=example_problem, - ) -) - -del example_problem - -example_solver.run( - logging_level="Warning", - prescribed_wake=True, -) - -ps.output.animate( - unsteady_solver=example_solver, - scalar_type="lift", - show_wake_vortices=True, - save=False, -) - -# ps.output.print_unsteady_results(unsteady_solver=example_solver) - -# ps.output.draw( -# solver=example_solver, -# scalar_type="lift", -# show_wake_vortices=True, -# save=False, -# ) - -# ps.output.plot_results_versus_time( -# unsteady_solver=example_solver, -# show=True, -# save=False, -# ) diff --git a/examples/simple_steady.py b/examples/simple_steady.py deleted file mode 100644 index 1b82f9e1..00000000 --- a/examples/simple_steady.py +++ /dev/null @@ -1,282 +0,0 @@ -"""This is script is an example of how to run Ptera Software's unsteady ring vortex -lattice method solver on a simple airplane object.""" - -import pterasoftware as ps -import numpy as np - -b = 10.0 -theta = np.pi / 6 -y = b * np.cos(theta) -z = b * np.sin(theta) - -simple_airplane = ps.geometry.Airplane( - wings=[ - ps.geometry.Wing( - wing_cross_sections=[ - ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil( - name="naca2412", - coordinates=None, - repanel=True, - n_points_per_side=100, - ), - x_le=0.0, - y_le=0.0, - z_le=0.0, - chord=2.0, - unit_normal_vector=np.array([0.0, 1.0, 0.0]), - twist=0.0, - control_surface_type="symmetric", - control_surface_hinge_point=0.75, - control_surface_deflection=0.0, - num_spanwise_panels=100, - spanwise_spacing="cosine", - ), - ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil( - name="naca2412", - coordinates=None, - repanel=True, - n_points_per_side=100, - ), - x_le=0.0, - y_le=y, - z_le=z, - chord=2.0, - # unit_normal_vector=np.array([0.0, np.cos(theta), np.sin(theta)]), - twist=0.0, - control_surface_type="symmetric", - control_surface_hinge_point=0.75, - control_surface_deflection=0.0, - num_spanwise_panels=100, - spanwise_spacing="cosine", - ), - ], - name="Main Wing", - x_le=0.0, - y_le=0.0, - z_le=0.0, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), - symmetric=True, - unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), - num_chordwise_panels=20, - chordwise_spacing="cosine", - ), - ], - name="Simple Airplane", - x_ref=0.0, - y_ref=0.0, - z_ref=0.0, - weight=0.0, - s_ref=None, - c_ref=None, - b_ref=None, -) - -simple_operating_point = ps.operating_point.OperatingPoint( - density=1.225, velocity=10.0, alpha=5.0, beta=0.0, external_thrust=0.0, nu=1.5e-5 -) - -simple_static_problem = ps.problems.SteadyProblem( - airplanes=[simple_airplane], operating_point=simple_operating_point -) - -# simple_steady_solver = ( -# ps.steady_ring_vortex_lattice_method.SteadyRingVortexLatticeMethodSolver( -# steady_problem=simple_static_problem -# ) -# ) -simple_steady_solver = ( - ps.steady_horseshoe_vortex_lattice_method.SteadyHorseshoeVortexLatticeMethodSolver( - steady_problem=simple_static_problem - ) -) - - -del simple_airplane -del simple_operating_point -del simple_static_problem - -simple_steady_solver.run() - -ps.output.print_steady_results(steady_solver=simple_steady_solver) - -# ps.output.draw( -# solver=simple_steady_solver, -# scalar_type="lift", -# show_streamlines=True, -# show_wake_vortices=False, -# ) - -wing = simple_steady_solver.airplanes[0].wings[0] -panels = wing.panels -show_z = True - -[num_rows, num_cols] = panels.shape - -p_text = "" -row_num = 1 -for row_of_panels in panels: - col_num = 1 - for panel in row_of_panels: - p_text += "│" - col_num += 1 - - [this_fl_x, this_fl_y, this_fl_z] = panel.front_left_vertex - [this_fr_x, this_fr_y, this_fr_z] = panel.front_right_vertex - - f_text = "({: .5e}, {: .5e}" - if show_z: - f_text += ", {: .5e}" - f_text += ") " - f_text += "({: .5e}, {: .5e}" - if show_z: - f_text += ", {: .5e}" - f_text += ")" - - if show_z: - this_f_text = f_text.format( - this_fl_x, - this_fl_y, - this_fl_z, - this_fr_x, - this_fr_y, - this_fr_z, - ) - else: - this_f_text = f_text.format( - this_fl_x, - this_fl_y, - this_fr_x, - this_fr_y, - ) - - p_text += this_f_text - - p_text += "│\n" - for panel in row_of_panels: - p_text += "│" - - [this_bl_x, this_bl_y, this_bl_z] = panel.back_left_vertex - [this_br_x, this_br_y, this_br_z] = panel.back_right_vertex - - b_text = "({: .5e}, {: .5e}" - if show_z: - b_text += ", {: .5e}" - b_text += ") " - b_text += "({: .5e}, {: .5e}" - if show_z: - b_text += ", {: .5e}" - b_text += ")" - - if show_z: - this_b_text = b_text.format( - this_bl_x, - this_bl_y, - this_bl_z, - this_br_x, - this_br_y, - this_br_z, - ) - else: - this_b_text = b_text.format( - this_bl_x, - this_bl_y, - this_br_x, - this_br_y, - ) - - p_text += this_b_text - p_text += "│" - last_line = p_text.splitlines()[-1] - - col_len = int((len(last_line) - 1) / num_cols) - - if row_num != num_rows: - col_text = "─" * (col_len - 1) + "┼" - else: - col_text = "─" * (col_len - 1) + "┴" - - all_col_text = col_text * num_cols - all_col_text = all_col_text[:-1] - - if row_num != num_rows: - all_col_text = "├" + all_col_text + "┤" - else: - all_col_text = "└" + all_col_text + "┘" - - p_text = p_text + "\n" + all_col_text + "\n" - - row_num += 1 - -p_text = p_text[:-1] - -last_line = p_text.splitlines()[-1] -col_len = int((len(last_line) - 1) / num_cols) -col_text = "─" * (col_len - 1) + "┬" -all_col_text = col_text * num_cols -all_col_text = all_col_text[:-1] -all_col_text = "┌" + all_col_text + "┐\n" - -p_text = all_col_text + p_text - -# print(p_text) - - -p_text = "" -row_num = 1 -for row_of_panels in panels: - col_num = 1 - for panel in row_of_panels: - p_text += "│" - col_num += 1 - - # this_cd = panel.induced_drag_coefficient - # this_cl = panel.lift_coefficient - # this_cy = panel.side_force_coefficient - # text = "({: .5e}, {: .5e}, {: .5e})" - # this_text = text.format( - # this_cd, - # this_cl, - # this_cy, - # ) - - info = panel.area - text = "{:.5e}" - this_text = text.format(info) - - p_text += this_text - p_text += "│" - last_line = p_text.splitlines()[-1] - - col_len = int((len(last_line) - 1) / num_cols) - - if row_num != num_rows: - col_text = "─" * (col_len - 1) + "┼" - else: - col_text = "─" * (col_len - 1) + "┴" - - all_col_text = col_text * num_cols - all_col_text = all_col_text[:-1] - - if row_num != num_rows: - all_col_text = "├" + all_col_text + "┤" - else: - all_col_text = "└" + all_col_text + "┘" - - p_text = p_text + "\n" + all_col_text + "\n" - - row_num += 1 - -p_text = p_text[:-1] - -last_line = p_text.splitlines()[-1] -col_len = int((len(last_line) - 1) / num_cols) -col_text = "─" * (col_len - 1) + "┬" -all_col_text = col_text * num_cols -all_col_text = all_col_text[:-1] -all_col_text = "┌" + all_col_text + "┐\n" - -p_text = all_col_text + p_text - -# print(p_text) diff --git a/pterasoftware/convergence.py b/pterasoftware/convergence.py index a8c85108..7ac5932d 100644 --- a/pterasoftware/convergence.py +++ b/pterasoftware/convergence.py @@ -181,6 +181,9 @@ def analyze_steady_convergence( next_ref_wing_cross_section = ref_wing_cross_sections[ ref_wing_cross_section_id + 1 ] + # ToDo: Modify this to allow for new geometry with custom + # planes for the wing cross sections. As of now, + # it fails for vertical wings. section_length = ( next_ref_wing_cross_section.y_le - ref_wing_cross_section.y_le @@ -749,6 +752,9 @@ def analyze_unsteady_convergence( ref_wing_cross_section_movement_id + 1 ].base_wing_cross_section ) + # ToDo: Modify this to allow for new geometry + # with custom planes for the wing cross + # sections. As of now, it fails for vertical wings. section_length = ( next_ref_base_wing_cross_section.y_le - ref_base_wing_cross_section.y_le diff --git a/tests/integration/fixtures/airplane_fixtures.py b/tests/integration/fixtures/airplane_fixtures.py index 0a4c4f13..359ae0b1 100644 --- a/tests/integration/fixtures/airplane_fixtures.py +++ b/tests/integration/fixtures/airplane_fixtures.py @@ -13,9 +13,6 @@ make_multiple_wing_steady_validation_airplane: This function creates a multi-wing airplane object to be used as a fixture for testing steady solvers. - make_asymmetric_unsteady_validation_airplane: This function creates an asymmetric - airplane object to be used as a fixture for testing unsteady solvers. - make_symmetric_unsteady_validation_airplane: This function creates a symmetric airplane object to be used as a fixture for testing unsteady solvers. @@ -23,6 +20,8 @@ a multi-wing, symmetric airplane object to be used as a fixture for testing unsteady solvers. """ +import numpy as np + import pterasoftware as ps @@ -30,6 +29,16 @@ def make_steady_validation_airplane(): """This function creates an airplane object to be used as a fixture for testing steady solvers. + The parameters of this airplane were found to be converged based on the following + call to analyze_steady_convergence: + converged_parameters = ps.convergence.analyze_steady_convergence( + ref_problem=steady_validation_problem, + solver_type="steady horseshoe vortex lattice method", + panel_aspect_ratio_bounds=(4, 1), + num_chordwise_panels_bounds=(3, 20), + convergence_criteria=0.1, + ). + :return steady_validation_airplane: Airplane This is the airplane fixture. """ @@ -37,145 +46,197 @@ def make_steady_validation_airplane(): steady_validation_airplane = ps.geometry.Airplane( wings=[ ps.geometry.Wing( - symmetric=True, wing_cross_sections=[ ps.geometry.WingCrossSection( airfoil=ps.geometry.Airfoil( name="naca2412", + coordinates=None, repanel=True, n_points_per_side=50, ), + x_le=0.0, + y_le=0.0, + z_le=0.0, + chord=1.0, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, num_spanwise_panels=20, + spanwise_spacing="cosine", ), ps.geometry.WingCrossSection( - x_le=1.0, - y_le=5.0, - twist=5.0, - chord=0.75, airfoil=ps.geometry.Airfoil( name="naca2412", + coordinates=None, repanel=True, n_points_per_side=50, ), - num_spanwise_panels=20, + x_le=1.0, + y_le=5.0, + z_le=0.0, + chord=0.75, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=5.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=8, + spanwise_spacing="cosine", ), ], + name="Main Wing", + x_le=0.0, + y_le=0.0, + z_le=0.0, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + symmetric=True, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=14, + chordwise_spacing="cosine", ) ], + name="Steady Validation Airplane", + x_ref=0.0, + y_ref=0.0, + z_ref=0.0, + weight=0.0, + s_ref=None, + c_ref=None, + b_ref=None, ) return steady_validation_airplane -# ToDo: Update this airplane to be more representative of the XFLR5 simulation. -# ToDo: Check that this test case has converged characteristics. def make_multiple_wing_steady_validation_airplane(): """This function creates a multi-wing airplane object to be used as a fixture for testing steady solvers. + The parameters of this airplane were found to be converged based on the following + call to analyze_steady_convergence: + converged_parameters = ps.convergence.analyze_steady_convergence( + ref_problem=steady_validation_problem, + solver_type="steady horseshoe vortex lattice method", + panel_aspect_ratio_bounds=(4, 1), + num_chordwise_panels_bounds=(3, 20), + convergence_criteria=0.1, + ). + :return multiple_wing_steady_validation_airplane: Airplane This is the airplane fixture. """ # Create and return the airplane object. multiple_wing_steady_validation_airplane = ps.geometry.Airplane( - x_ref=0.0, - y_ref=0.0, - z_ref=0.0, - weight=1 * 9.81, wings=[ ps.geometry.Wing( - x_le=0.0, - y_le=0.0, - z_le=0.0, wing_cross_sections=[ ps.geometry.WingCrossSection( - x_le=0.0, - y_le=0.0, - z_le=0.0, - chord=1.0, - twist=0.0, airfoil=ps.geometry.Airfoil( name="naca23012", coordinates=None, repanel=True, - n_points_per_side=400, + n_points_per_side=50, ), + x_le=0.0, + y_le=0.0, + z_le=0.0, + chord=1.0, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, control_surface_type="symmetric", control_surface_hinge_point=0.75, control_surface_deflection=0.0, - num_spanwise_panels=8, + num_spanwise_panels=69, spanwise_spacing="uniform", ), ps.geometry.WingCrossSection( - x_le=1.0, - y_le=5.0, - z_le=0.0, - chord=0.75, - twist=0.0, airfoil=ps.geometry.Airfoil( name="naca23012", coordinates=None, repanel=True, - n_points_per_side=400, + n_points_per_side=50, ), + x_le=1.0, + y_le=5.0, + z_le=0.0, + chord=0.75, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, control_surface_type="symmetric", control_surface_hinge_point=0.75, control_surface_deflection=0.0, - num_spanwise_panels=8, - spanwise_spacing="uniform", + num_spanwise_panels=69, + spanwise_spacing="cosine", ), ], + name="Main Wing", + x_le=0.0, + y_le=0.0, + z_le=0.0, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, - num_chordwise_panels=8, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=12, chordwise_spacing="uniform", ), ps.geometry.Wing( - x_le=5.0, - y_le=0.0, - z_le=0.0, wing_cross_sections=[ ps.geometry.WingCrossSection( - x_le=0.0, - y_le=0.0, - z_le=0.0, - chord=1.00, - twist=-5.0, airfoil=ps.geometry.Airfoil( name="naca0010", coordinates=None, repanel=True, - n_points_per_side=400, + n_points_per_side=50, ), + x_le=0.0, + y_le=0.0, + z_le=0.0, + chord=1.00, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=-5.0, control_surface_type="symmetric", control_surface_hinge_point=0.75, control_surface_deflection=0.0, - num_spanwise_panels=8, + num_spanwise_panels=16, spanwise_spacing="uniform", ), ps.geometry.WingCrossSection( - x_le=1.0, - y_le=1.0, - z_le=0.0, - chord=0.75, - twist=-5.0, airfoil=ps.geometry.Airfoil( name="naca0010", coordinates=None, repanel=True, - n_points_per_side=400, + n_points_per_side=50, ), + x_le=1.0, + y_le=1.0, + z_le=0.0, + chord=0.75, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=-5.0, control_surface_type="symmetric", control_surface_hinge_point=0.75, control_surface_deflection=0.0, - num_spanwise_panels=8, - spanwise_spacing="uniform", + num_spanwise_panels=16, + spanwise_spacing="cosine", ), ], + name="Horizontal Stabilizer", + x_le=5.0, + y_le=0.0, + z_le=0.0, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, - num_chordwise_panels=8, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=12, chordwise_spacing="uniform", ), ], + name="Multiple Wing Steady Validation Airplane", + x_ref=0.0, + y_ref=0.0, + z_ref=0.0, + weight=1 * 9.81, s_ref=None, c_ref=None, b_ref=None, @@ -183,49 +244,23 @@ def make_multiple_wing_steady_validation_airplane(): return multiple_wing_steady_validation_airplane -# ToDo: Update this airplane to be more representative of the XFLR5 simulation. -# ToDo: Check that this test case has converged characteristics. -def make_asymmetric_unsteady_validation_airplane(): - """This function creates an asymmetric airplane object to be used as a fixture - for testing unsteady solvers. - - :return asymmetric_unsteady_validation_airplane: Airplane - This is the airplane fixture. - """ - # Create and return the airplane object. - asymmetric_unsteady_validation_airplane = ps.geometry.Airplane( - y_ref=5.0, - wings=[ - ps.geometry.Wing( - num_chordwise_panels=8, - chordwise_spacing="uniform", - wing_cross_sections=[ - ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil(name="naca2412"), - num_spanwise_panels=16, - spanwise_spacing="cosine", - chord=1.0, - ), - ps.geometry.WingCrossSection( - y_le=10.0, - chord=1.0, - airfoil=ps.geometry.Airfoil(name="naca2412"), - num_spanwise_panels=16, - spanwise_spacing="cosine", - ), - ], - ) - ], - ) - return asymmetric_unsteady_validation_airplane - - -# ToDo: Update this airplane to be more representative of the XFLR5 simulation. -# ToDo: Check that this test case has converged characteristics. def make_symmetric_unsteady_validation_airplane(): """This function creates a symmetric airplane object to be used as a fixture for testing unsteady solvers. + The parameters of this airplane were found to be converged based on the following + call to analyze_unsteady_convergence: + converged_parameters = ps.convergence.analyze_unsteady_convergence( + ref_problem=unsteady_validation_problem, + prescribed_wake=True, + free_wake=True, + num_chords_bounds=(3, 9), + panel_aspect_ratio_bounds=(4, 1), + num_chordwise_panels_bounds=(4, 11), + coefficient_mask=[True, False, True, False, True, False], + convergence_criteria=1.0, + ). + :return symmetric_unsteady_validation_airplane: Airplane This is the airplane fixture. """ @@ -233,28 +268,69 @@ def make_symmetric_unsteady_validation_airplane(): symmetric_unsteady_validation_airplane = ps.geometry.Airplane( wings=[ ps.geometry.Wing( - symmetric=True, - chordwise_spacing="uniform", wing_cross_sections=[ ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil(name="naca2412"), + airfoil=ps.geometry.Airfoil( + name="naca2412", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), + x_le=0.0, + y_le=0.0, + z_le=0.0, chord=2.0, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=18, spanwise_spacing="cosine", ), ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca2412", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), + x_le=0.0, y_le=5.0, + z_le=0.0, chord=2.0, - airfoil=ps.geometry.Airfoil(name="naca2412"), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=18, spanwise_spacing="cosine", ), ], + name="Main Wing", + x_le=0.0, + y_le=0.0, + z_le=0.0, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + symmetric=True, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=7, + chordwise_spacing="uniform", ), ], + name="Symmetric Unsteady Validation Airplane", + x_ref=0.0, + y_ref=0.0, + z_ref=0.0, + weight=0.0, + s_ref=None, + c_ref=None, + b_ref=None, ) return symmetric_unsteady_validation_airplane -# ToDo: Update this airplane to be more representative of the XFLR5 simulation. # ToDo: Check that this test case has converged characteristics. def make_symmetric_multiple_wing_unsteady_validation_airplane(): """This function creates a multi-wing, symmetric airplane object to be used as a @@ -267,66 +343,166 @@ def make_symmetric_multiple_wing_unsteady_validation_airplane(): symmetric_multiple_wing_steady_validation_airplane = ps.geometry.Airplane( wings=[ ps.geometry.Wing( - symmetric=True, - chordwise_spacing="uniform", wing_cross_sections=[ ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil(name="naca2412"), + airfoil=ps.geometry.Airfoil( + name="naca2412", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), + x_le=0.0, + y_le=0.0, + z_le=0.0, chord=1.5, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=8, spanwise_spacing="cosine", ), ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca2412", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), x_le=0.5, y_le=5.0, z_le=0.0, chord=1.0, - airfoil=ps.geometry.Airfoil(name="naca2412"), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=8, spanwise_spacing="cosine", ), ], - ), - ps.geometry.Wing( + name="Main Wing", + x_le=0.0, + y_le=0.0, + z_le=0.0, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, - z_le=1.75, - x_le=6.25, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=8, chordwise_spacing="uniform", + ), + ps.geometry.Wing( wing_cross_sections=[ ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil(name="naca0010"), - spanwise_spacing="cosine", + airfoil=ps.geometry.Airfoil( + name="naca0010", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), + x_le=0.0, + y_le=0.0, + z_le=0.0, + chord=1.0, + unit_normal_vector=np.array([0.0, 1.0, 0.0]), twist=-5.0, - chord=1.00, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=8, + spanwise_spacing="cosine", ), ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca0010", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), + x_le=0.25, y_le=1.5, - twist=-5.0, + z_le=0.0, chord=0.75, - x_le=0.25, - airfoil=ps.geometry.Airfoil(name="naca0010"), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), + twist=-5.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=8, spanwise_spacing="cosine", ), ], - ), - ps.geometry.Wing( - symmetric=False, - z_le=0.125, + name="Horizontal Stabilizer", x_le=6.25, + y_le=0.0, + z_le=1.75, + symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + symmetric=True, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=8, chordwise_spacing="uniform", + ), + ps.geometry.Wing( wing_cross_sections=[ ps.geometry.WingCrossSection( - airfoil=ps.geometry.Airfoil(name="naca0010"), - spanwise_spacing="cosine", + airfoil=ps.geometry.Airfoil( + name="naca0010", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), + x_le=0.0, + y_le=0.0, + z_le=0.0, chord=1.0, + unit_normal_vector=np.array([0.0, 0.0, 1.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=8, + spanwise_spacing="cosine", ), ps.geometry.WingCrossSection( + airfoil=ps.geometry.Airfoil( + name="naca0010", + coordinates=None, + repanel=True, + n_points_per_side=50, + ), + x_le=0.25, + y_le=0.0, z_le=1.5, chord=0.75, - x_le=0.25, - airfoil=ps.geometry.Airfoil(name="naca0010"), + unit_normal_vector=np.array([0.0, 0.0, 1.0]), + twist=0.0, + control_surface_type="symmetric", + control_surface_hinge_point=0.75, + control_surface_deflection=0.0, + num_spanwise_panels=8, spanwise_spacing="cosine", ), ], + name="Vertical Stabilizer", + x_le=6.25, + y_le=0.0, + z_le=0.125, + symmetry_unit_normal_vector=np.array([0.0, 0.0, 1.0]), + symmetric=False, + unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), + num_chordwise_panels=8, + chordwise_spacing="uniform", ), ], + name="Symmetric Multiple Wing Unsteady Validation Airplane", + x_ref=0.0, + y_ref=0.0, + z_ref=0.0, + weight=0.0, + s_ref=None, + c_ref=None, + b_ref=None, ) return symmetric_multiple_wing_steady_validation_airplane diff --git a/tests/integration/fixtures/movement_fixtures.py b/tests/integration/fixtures/movement_fixtures.py index d97253a7..b3d2633d 100644 --- a/tests/integration/fixtures/movement_fixtures.py +++ b/tests/integration/fixtures/movement_fixtures.py @@ -33,7 +33,7 @@ def make_static_validation_movement(): # Construct an airplane object and an operating point object. unsteady_validation_airplane = ( - airplane_fixtures.make_asymmetric_unsteady_validation_airplane() + airplane_fixtures.make_symmetric_unsteady_validation_airplane() ) unsteady_validation_operating_point = ( operating_point_fixtures.make_validation_operating_point() @@ -94,8 +94,7 @@ def make_static_validation_movement(): unsteady_validation_movement = ps.movement.Movement( airplane_movements=[unsteady_validation_airplane_movement], operating_point_movement=unsteady_validation_operating_point_movement, - num_steps=None, - delta_time=None, + num_chords=6, ) # Delete the now extraneous constructing fixtures. diff --git a/tests/integration/fixtures/operating_point_fixtures.py b/tests/integration/fixtures/operating_point_fixtures.py index 2d1e00ae..5fab9d0b 100644 --- a/tests/integration/fixtures/operating_point_fixtures.py +++ b/tests/integration/fixtures/operating_point_fixtures.py @@ -22,5 +22,12 @@ def make_validation_operating_point(): """ # Create and return an operating point fixture. - operating_point_fixture = ps.operating_point.OperatingPoint() + operating_point_fixture = ps.operating_point.OperatingPoint( + density=1.225, + velocity=10.0, + alpha=5.0, + beta=0.0, + external_thrust=0.0, + nu=15.06e-6, + ) return operating_point_fixture diff --git a/tests/integration/fixtures/test_for_convergence.py b/tests/integration/fixtures/test_for_convergence.py deleted file mode 100644 index f2156976..00000000 --- a/tests/integration/fixtures/test_for_convergence.py +++ /dev/null @@ -1,26 +0,0 @@ -import math - -import pterasoftware as ps -from tests.integration.fixtures import problem_fixtures - -steady_validation_problem = problem_fixtures.make_steady_validation_problem() - -converged_parameters = ps.convergence.analyze_steady_convergence( - ref_problem=steady_validation_problem, - solver_type="steady horseshoe vortex lattice method", - panel_aspect_ratio_bounds=(4, 1), - num_chordwise_panels_bounds=(3, 20), - convergence_criteria=0.1, -) - -[panel_aspect_ratio, num_chordwise_panels] = converged_parameters -section_length = 5 -section_standard_mean_chord = 0.875 - -num_spanwise_panels = round( - (section_length * num_chordwise_panels) - / (section_standard_mean_chord * panel_aspect_ratio) -) - -num_spanwise_panels = math.ceil(num_spanwise_panels) -print(num_spanwise_panels) diff --git a/tests/integration/test_steady_convergence.py b/tests/integration/test_steady_convergence.py index 7b93b4f6..7c5b675e 100644 --- a/tests/integration/test_steady_convergence.py +++ b/tests/integration/test_steady_convergence.py @@ -73,7 +73,6 @@ def test_steady_horseshoe_convergence(self): converged_panel_ar = converged_parameters[0] converged_num_chordwise = converged_parameters[1] - # ToDo: Update these expected results. panel_ar_ans = 4 num_chordwise_ans = 4 @@ -98,7 +97,6 @@ def test_steady_ring_convergence(self): converged_panel_ar = converged_parameters[0] converged_num_chordwise = converged_parameters[1] - # ToDo: Update these expected results. panel_ar_ans = 4 num_chordwise_ans = 5 diff --git a/tests/integration/test_steady_horseshoe_vortex_lattice_method.py b/tests/integration/test_steady_horseshoe_vortex_lattice_method.py index 4245b40e..59f88588 100644 --- a/tests/integration/test_steady_horseshoe_vortex_lattice_method.py +++ b/tests/integration/test_steady_horseshoe_vortex_lattice_method.py @@ -2,16 +2,15 @@ Based on an identical XFLR5 testing case, the expected output for the single-wing case is: - CL: 0.788 + CL: 0.789 CDi: 0.020 Cm: -0.685 -ToDo: Update these results with a new XFLR5 study. Based on an identical XFLR5 testing case, the expected output for the multi-wing case is: - CL: 0.524 - CDi: 0.007 - Cm: -0.350 + CL: 0.513 + CDi: 0.008 + Cm: -0.336 Note: The expected output was created using XFLR5's inviscid VLM1 analysis type, which is a horseshoe vortex lattice method solver. @@ -92,7 +91,7 @@ def test_method(self): ) c_di_error = abs((c_di_calculated - c_di_expected) / c_di_expected) - c_l_expected = 0.788 + c_l_expected = 0.789 c_l_calculated = ( self.steady_horseshoe_vortex_lattice_method_validation_solver.airplanes[ 0 @@ -133,7 +132,7 @@ def test_method_multiple_wings(self): self.steady_multiple_wing_horseshoe_vortex_lattice_method_validation_solver.run() # Calculate the percent errors of the output. - c_di_expected = 0.007 + c_di_expected = 0.008 c_di_calculated = self.steady_multiple_wing_horseshoe_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_force_coefficients_wind_axes[ @@ -141,7 +140,7 @@ def test_method_multiple_wings(self): ] c_di_error = abs((c_di_calculated - c_di_expected) / c_di_expected) - c_l_expected = 0.524 + c_l_expected = 0.513 c_l_calculated = self.steady_multiple_wing_horseshoe_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_force_coefficients_wind_axes[ @@ -149,7 +148,7 @@ def test_method_multiple_wings(self): ] c_l_error = abs((c_l_calculated - c_l_expected) / c_l_expected) - c_m_expected = -0.350 + c_m_expected = -0.336 c_m_calculated = self.steady_multiple_wing_horseshoe_vortex_lattice_method_validation_solver.airplanes[ 0 ].total_near_field_moment_coefficients_wind_axes[ diff --git a/tests/integration/test_steady_ring_vortex_lattice_method.py b/tests/integration/test_steady_ring_vortex_lattice_method.py index 31a2c308..cd75f4b9 100644 --- a/tests/integration/test_steady_ring_vortex_lattice_method.py +++ b/tests/integration/test_steady_ring_vortex_lattice_method.py @@ -1,7 +1,7 @@ """This module is a testing case for the steady ring vortex lattice method solver. Based on an identical XFLR5 VLM2 testing case, the expected output for this case is: - CL: 0.783 + CL: 0.784 CDi: 0.019 Cm: -0.678 @@ -78,7 +78,7 @@ def test_method(self): ) c_di_error = abs((c_di_calculated - c_di_expected) / c_di_expected) - c_l_expected = 0.783 + c_l_expected = 0.784 c_l_calculated = ( self.steady_ring_vortex_lattice_method_validation_solver.airplanes[ 0 diff --git a/tests/integration/test_steady_trim.py b/tests/integration/test_steady_trim.py index 1cc0ed6d..bdbbf66a 100644 --- a/tests/integration/test_steady_trim.py +++ b/tests/integration/test_steady_trim.py @@ -41,11 +41,10 @@ def setUp(self): :return: None """ - # ToDo: Update these expected results. - self.v_x_ans = 2.848 - self.alpha_ans = 1.943 + self.v_x_ans = 2.9222951743478016 + self.alpha_ans = 1.933469345202583 self.beta_ans = 0.000 - self.thrust_ans = 0.084 + self.thrust_ans = 0.0884579818006783 self.ans_corruption = 0.05 diff --git a/tests/integration/test_unsteady_convergence.py b/tests/integration/test_unsteady_convergence.py index 075aac23..9b17a625 100644 --- a/tests/integration/test_unsteady_convergence.py +++ b/tests/integration/test_unsteady_convergence.py @@ -76,9 +76,8 @@ def test_unsteady_convergence(self): converged_panel_ar = converged_parameters[2] converged_num_chordwise = converged_parameters[3] - # ToDo: Update these expected results. wake_state_ans = True - num_chords_ans = 4 + num_chords_ans = 3 panel_ar_ans = 4 num_chordwise_ans = 4 diff --git a/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py b/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py index bad2c6a5..34a0c7f1 100644 --- a/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py +++ b/tests/integration/test_unsteady_ring_vortex_lattice_method_static_geometry.py @@ -1,11 +1,10 @@ """This is a testing case for the unsteady ring vortex lattice method solver with static geometry. -ToDo: Update these results with a new XFLR5 study. Based on an equivalent XFLR5 testing case, the expected output for this case is: - CL: 0.588 - CDi: 0.011 - Cm: -0.197 + CL: 0.485 + CDi: 0.015 + Cm: -0.166 Note: The expected output was created using XFLR5's inviscid VLM2 analysis type, which is a ring vortex lattice method solver. The geometry in this case is static. @@ -80,15 +79,15 @@ def test_method(self): this_airplane = this_solver.current_airplanes[0] # Calculate the percent errors of the output. - c_di_expected = 0.011 + c_di_expected = 0.015 c_di_calculated = this_airplane.total_near_field_force_coefficients_wind_axes[0] c_di_error = abs(c_di_calculated - c_di_expected) / c_di_expected - c_l_expected = 0.588 + c_l_expected = 0.485 c_l_calculated = this_airplane.total_near_field_force_coefficients_wind_axes[2] c_l_error = abs(c_l_calculated - c_l_expected) / c_l_expected - c_m_expected = -0.197 + c_m_expected = -0.166 c_m_calculated = this_airplane.total_near_field_moment_coefficients_wind_axes[1] c_m_error = abs(c_m_calculated - c_m_expected) / c_m_expected diff --git a/tests/references/testing references.xfl b/tests/references/testing references.xfl new file mode 100644 index 0000000000000000000000000000000000000000..989e51ebefd2db14bf2f0889849df48e9d842064 GIT binary patch literal 309445 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�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 literal 0 HcmV?d00001 From 84ccd411fb3950401205efc408179408d51d7588 Mon Sep 17 00:00:00 2001 From: camUrban Date: Thu, 20 Jul 2023 11:25:01 -0400 Subject: [PATCH 21/24] I fixed a bug where the new wing and wing cross sections variables weren't being incorporated into movement. --- .gitignore | 3 ++- pterasoftware/movement.py | 12 +++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 554c483c..95e0c9e6 100644 --- a/.gitignore +++ b/.gitignore @@ -124,8 +124,9 @@ dmypy.json # This is the cython debug symbols to ignore. cython_debug/ -# This is a folder full of random tests to ignore. +# These are folders for scripts in development to ignore. /testing_in_development/ +/experimental/ # These are folders created by testing to ignore. /tests/_trial_temp/ diff --git a/pterasoftware/movement.py b/pterasoftware/movement.py index c995538c..f83c325f 100644 --- a/pterasoftware/movement.py +++ b/pterasoftware/movement.py @@ -780,7 +780,9 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Generate the non-changing wing attributes. name = self.base_wing.name + symmetry_unit_normal_vector = self.base_wing.symmetry_unit_normal_vector symmetric = self.base_wing.symmetric + unit_chordwise_vector = self.base_wing.unit_chordwise_vector num_chordwise_panels = self.base_wing.num_chordwise_panels chordwise_spacing = self.base_wing.chordwise_spacing @@ -795,12 +797,14 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Make a new wing object for this time step. this_wing = geometry.Wing( + wing_cross_sections=cross_sections, name=name, x_le=x_le, y_le=y_le, z_le=z_le, - wing_cross_sections=cross_sections, + symmetry_unit_normal_vector=symmetry_unit_normal_vector, symmetric=symmetric, + unit_chordwise_vector=unit_chordwise_vector, num_chordwise_panels=num_chordwise_panels, chordwise_spacing=chordwise_spacing, ) @@ -1183,8 +1187,9 @@ def generate_wing_cross_sections( wing_cross_sections = [] # Generate the non-changing wing cross section attributes. - chord = self.base_wing_cross_section.chord airfoil = self.base_wing_cross_section.airfoil + chord = self.base_wing_cross_section.chord + unit_normal_vector = self.base_wing_cross_section.unit_normal_vector control_surface_deflection = self.control_surface_deflection_base control_surface_type = self.base_wing_cross_section.control_surface_type control_surface_hinge_point = ( @@ -1203,12 +1208,13 @@ def generate_wing_cross_sections( # Make a new wing cross section object for this time step. this_wing_cross_section = geometry.WingCrossSection( + airfoil=airfoil, x_le=x_le, y_le=y_le, z_le=z_le, chord=chord, + unit_normal_vector=unit_normal_vector, twist=twist, - airfoil=airfoil, control_surface_type=control_surface_type, control_surface_hinge_point=control_surface_hinge_point, control_surface_deflection=control_surface_deflection, From 2e54ad16000814472108dcf73ae0095d07705a36 Mon Sep 17 00:00:00 2001 From: camUrban Date: Thu, 20 Jul 2023 14:17:02 -0400 Subject: [PATCH 22/24] I modified the wing movement object to allow vertical wings to pitch. --- pterasoftware/movement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pterasoftware/movement.py b/pterasoftware/movement.py index f83c325f..a69af5d2 100644 --- a/pterasoftware/movement.py +++ b/pterasoftware/movement.py @@ -1164,7 +1164,7 @@ def generate_wing_cross_sections( x_le_list = np.ones(num_steps) * self.x_le_base y_le_list = np.ones(num_steps) * self.y_le_base z_le_list = np.ones(num_steps) * self.z_le_base - twist_list = np.ones(num_steps) * self.twist_base + twist_list = pitching_list else: # Find the list of new leading edge points. This uses a spherical From 6ffe4defe4f4e48831a95651362d6a5f74bccc8e Mon Sep 17 00:00:00 2001 From: camUrban Date: Thu, 20 Jul 2023 15:19:22 -0400 Subject: [PATCH 23/24] I changed the names of a few of the geometry orientation vectors. --- pterasoftware/geometry.py | 57 +++++++++++-------- pterasoftware/meshing.py | 8 +-- pterasoftware/movement.py | 26 ++------- .../integration/fixtures/airplane_fixtures.py | 14 ++--- 4 files changed, 50 insertions(+), 55 deletions(-) diff --git a/pterasoftware/geometry.py b/pterasoftware/geometry.py index 1cc61d6d..773fac13 100644 --- a/pterasoftware/geometry.py +++ b/pterasoftware/geometry.py @@ -159,6 +159,10 @@ class Wing: Date of Retrieval: 04/24/2020 This class contains the following public methods: + unit_up_vector: This method sets a property for the wing's up orientation + vector, which is defined as the cross product of its unit chordwise and unit + normal vectors. + projected_area: This method defines a property for the area of the wing projected onto the plane defined by the projected unit normal vector. @@ -186,7 +190,7 @@ def __init__( x_le=0.0, y_le=0.0, z_le=0.0, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=False, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=8, @@ -208,8 +212,8 @@ def __init__( :param z_le: float, optional This is the z coordinate of the leading edge of the wing, relative to the airplane's reference point. The default is 0.0. - :param symmetry_unit_normal_vector: ndarray, optional - This is an (3,) ndarray of floats that represents the unit normal vector + :param unit_normal_vector: array, optional + This is an (3,) array of floats that represents the unit normal vector of the wing's symmetry plane. It is also the direction vector that the wing's span will be assessed relative to. Additionally, this vector crossed with the "unit_chordwise_vector" defines the normal vector of the @@ -220,10 +224,10 @@ def __init__( :param symmetric: bool, optional Set this to true if the wing is across the xz plane. Set it to false if not. The default is false. - :param unit_chordwise_vector: ndarray, optional - This is an (3,) ndarray of floats that represents the unit vector that + :param unit_chordwise_vector: array, optional + This is an (3,) array of floats that represents the unit vector that defines the wing's chordwise direction. This vector crossed with the - "symmetry_unit_normal_vector" defines the normal vector of the plane that + "unit_normal_vector" defines the normal vector of the plane that the wing's projected area will reference. This vector must be parallel to the intersection of the wing's symmetry plane with each of its wing cross section's planes. The default is np.array([1.0, 0.0, 0.0]), which is the @@ -252,7 +256,7 @@ def __init__( self.x_le = x_le self.y_le = y_le self.z_le = z_le - self.symmetry_unit_normal_vector = symmetry_unit_normal_vector + self.unit_normal_vector = unit_normal_vector self.symmetric = symmetric self.unit_chordwise_vector = unit_chordwise_vector self.num_chordwise_panels = num_chordwise_panels @@ -270,7 +274,7 @@ def __init__( # Check that the wing's symmetry plane is equal to its root wing cross # section's plane. if not np.array_equal( - self.symmetry_unit_normal_vector, + self.unit_normal_vector, self.wing_cross_sections[0].unit_normal_vector, ): raise Exception( @@ -287,7 +291,7 @@ def __init__( ) # Check that the wing's chordwise and normal directions are perpendicular. - if np.dot(self.unit_chordwise_vector, self.symmetry_unit_normal_vector) != 0: + if np.dot(self.unit_chordwise_vector, self.unit_normal_vector) != 0: raise Exception( "Every wing cross section's plane must intersect with the wing's " "symmetry plane along a line that is parallel with the wing's " @@ -310,7 +314,7 @@ def __init__( # Find the vector parallel to the intersection of this wing cross # section's plane and the wing's symmetry plane. plane_intersection_vector = np.cross( - self.symmetry_unit_normal_vector, wing_cross_section.unit_normal_vector + self.unit_normal_vector, wing_cross_section.unit_normal_vector ) # If this vector is not parallel to the wing's chordwise vector, raise an @@ -328,12 +332,6 @@ def __init__( for wing_cross_section in self.wing_cross_sections: wing_cross_section.wing_unit_chordwise_vector = self.unit_chordwise_vector - # Define an attribute that is the normal vector of the plane that the - # projected area will reference. - self.projected_unit_normal_vector = np.cross( - self.unit_chordwise_vector, self.symmetry_unit_normal_vector - ) - # Initialize the panels attribute. Then mesh the wing, which will populate # this attribute. self.panels = None @@ -344,6 +342,17 @@ def __init__( self.wake_ring_vortex_vertices = np.empty((0, self.num_spanwise_panels + 1, 3)) self.wake_ring_vortices = np.zeros((0, self.num_spanwise_panels), dtype=object) + @property + def unit_up_vector(self): + """This method sets a property for the wing's up orientation + vector, which is defined as the cross product of its unit chordwise and unit + normal vectors. + + :return: (3,) array of floats + This is the wing's unit up vector. The units are meters. + """ + return np.cross(self.unit_chordwise_vector, self.unit_normal_vector) + @property def projected_area(self): """This method defines a property for the area of the wing projected onto the @@ -363,7 +372,7 @@ def projected_area(self): for spanwise_location in range(self.num_spanwise_panels): projected_area += self.panels[ chordwise_location, spanwise_location - ].calculate_projected_area(self.projected_unit_normal_vector) + ].calculate_projected_area(self.unit_up_vector) return projected_area @@ -406,8 +415,8 @@ def span(self): ) projected_leading_edge = ( - np.dot(root_to_tip_leading_edge, self.symmetry_unit_normal_vector) - * self.symmetry_unit_normal_vector + np.dot(root_to_tip_leading_edge, self.unit_normal_vector) + * self.unit_normal_vector ) span = np.linalg.norm(projected_leading_edge) @@ -464,8 +473,8 @@ def mean_aerodynamic_chord(self): ) projected_section_leading_edge = ( - np.dot(section_leading_edge, self.symmetry_unit_normal_vector) - * self.symmetry_unit_normal_vector + np.dot(section_leading_edge, self.unit_normal_vector) + * self.unit_normal_vector ) section_span = np.linalg.norm(projected_section_leading_edge) @@ -542,11 +551,11 @@ def __init__( :param chord: float, optional This is the chord of the wing at this wing cross section. The default value is 1.0. - :param unit_normal_vector: ndarray, optional - This is an (3,) ndarray of floats that represents the unit normal vector + :param unit_normal_vector: array, optional + This is an (3,) array of floats that represents the unit normal vector of the plane this wing cross section lies on. If this wing cross section is a wing's root, this vector must be equal to the wing's - symmetry_unit_normal_vector attribute. Also, every wing cross section + unit_normal_vector attribute. Also, every wing cross section must have a plane that intersects its parent wing's symmetry plane at a line parallel to the parent wing's "unit_chordwise_vector". The default is np.array([ 0.0, 1.0, 0.0]), which is the XZ plane's unit normal vector. diff --git a/pterasoftware/meshing.py b/pterasoftware/meshing.py index 71141bd0..a3efb8ed 100644 --- a/pterasoftware/meshing.py +++ b/pterasoftware/meshing.py @@ -228,28 +228,28 @@ def mesh_wing(wing): functions.reflect_point_across_plane, -1, front_inner_vertices, - wing.symmetry_unit_normal_vector, + wing.unit_normal_vector, wing.leading_edge, ) front_outer_vertices_reflected = np.apply_along_axis( functions.reflect_point_across_plane, -1, front_outer_vertices, - wing.symmetry_unit_normal_vector, + wing.unit_normal_vector, wing.leading_edge, ) back_inner_vertices_reflected = np.apply_along_axis( functions.reflect_point_across_plane, -1, back_inner_vertices, - wing.symmetry_unit_normal_vector, + wing.unit_normal_vector, wing.leading_edge, ) back_outer_vertices_reflected = np.apply_along_axis( functions.reflect_point_across_plane, -1, back_outer_vertices, - wing.symmetry_unit_normal_vector, + wing.unit_normal_vector, wing.leading_edge, ) diff --git a/pterasoftware/movement.py b/pterasoftware/movement.py index a69af5d2..51bbfc17 100644 --- a/pterasoftware/movement.py +++ b/pterasoftware/movement.py @@ -663,25 +663,11 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Check if this is this wing's root cross section. if wing_cross_section_movement_location == 0: - # Get the root cross section's sweeping and heaving attributes. - first_wing_cross_section_movement_sweeping_amplitude = ( - wing_cross_section_movement.sweeping_amplitude - ) - first_wing_cross_section_movement_sweeping_period = ( - wing_cross_section_movement.sweeping_period - ) - first_wing_cross_section_movement_heaving_amplitude = ( - wing_cross_section_movement.heaving_amplitude - ) - first_wing_cross_section_movement_heaving_period = ( - wing_cross_section_movement.heaving_period - ) - # Check that the root cross section is not sweeping or heaving. - assert first_wing_cross_section_movement_sweeping_amplitude == 0 - assert first_wing_cross_section_movement_sweeping_period == 0 - assert first_wing_cross_section_movement_heaving_amplitude == 0 - assert first_wing_cross_section_movement_heaving_period == 0 + assert wing_cross_section_movement.sweeping_amplitude == 0 + assert wing_cross_section_movement.sweeping_period == 0 + assert wing_cross_section_movement.heaving_amplitude == 0 + assert wing_cross_section_movement.heaving_period == 0 # Set the variables relating this wing cross section to the inner # wing cross section to zero because this is the innermost wing cross @@ -780,7 +766,7 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Generate the non-changing wing attributes. name = self.base_wing.name - symmetry_unit_normal_vector = self.base_wing.symmetry_unit_normal_vector + unit_normal_vector = self.base_wing.unit_normal_vector symmetric = self.base_wing.symmetric unit_chordwise_vector = self.base_wing.unit_chordwise_vector num_chordwise_panels = self.base_wing.num_chordwise_panels @@ -802,7 +788,7 @@ def generate_wings(self, num_steps=10, delta_time=0.1): x_le=x_le, y_le=y_le, z_le=z_le, - symmetry_unit_normal_vector=symmetry_unit_normal_vector, + unit_normal_vector=unit_normal_vector, symmetric=symmetric, unit_chordwise_vector=unit_chordwise_vector, num_chordwise_panels=num_chordwise_panels, diff --git a/tests/integration/fixtures/airplane_fixtures.py b/tests/integration/fixtures/airplane_fixtures.py index 359ae0b1..dbc1c1ea 100644 --- a/tests/integration/fixtures/airplane_fixtures.py +++ b/tests/integration/fixtures/airplane_fixtures.py @@ -90,7 +90,7 @@ def make_steady_validation_airplane(): x_le=0.0, y_le=0.0, z_le=0.0, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=14, @@ -174,7 +174,7 @@ def make_multiple_wing_steady_validation_airplane(): x_le=0.0, y_le=0.0, z_le=0.0, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=12, @@ -225,7 +225,7 @@ def make_multiple_wing_steady_validation_airplane(): x_le=5.0, y_le=0.0, z_le=0.0, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=12, @@ -312,7 +312,7 @@ def make_symmetric_unsteady_validation_airplane(): x_le=0.0, y_le=0.0, z_le=0.0, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=7, @@ -387,7 +387,7 @@ def make_symmetric_multiple_wing_unsteady_validation_airplane(): x_le=0.0, y_le=0.0, z_le=0.0, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=8, @@ -438,7 +438,7 @@ def make_symmetric_multiple_wing_unsteady_validation_airplane(): x_le=6.25, y_le=0.0, z_le=1.75, - symmetry_unit_normal_vector=np.array([0.0, 1.0, 0.0]), + unit_normal_vector=np.array([0.0, 1.0, 0.0]), symmetric=True, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=8, @@ -489,7 +489,7 @@ def make_symmetric_multiple_wing_unsteady_validation_airplane(): x_le=6.25, y_le=0.0, z_le=0.125, - symmetry_unit_normal_vector=np.array([0.0, 0.0, 1.0]), + unit_normal_vector=np.array([0.0, 0.0, 1.0]), symmetric=False, unit_chordwise_vector=np.array([1.0, 0.0, 0.0]), num_chordwise_panels=8, From 80df7ac9c9d13b3d5aac98326fdcbc52dc269241 Mon Sep 17 00:00:00 2001 From: camUrban Date: Fri, 21 Jul 2023 15:51:42 -0400 Subject: [PATCH 24/24] I clarified some of the variables in movement.py --- pterasoftware/movement.py | 203 +++++++++++++++++++------------------- 1 file changed, 104 insertions(+), 99 deletions(-) diff --git a/pterasoftware/movement.py b/pterasoftware/movement.py index 51bbfc17..8d147977 100644 --- a/pterasoftware/movement.py +++ b/pterasoftware/movement.py @@ -113,9 +113,7 @@ def __init__( "Only specify the number of cycles if you haven't specified the " "number of steps and the movement isn't static!" ) - self.num_cycles = num_cycles - else: - self.num_cycles = None + self.num_cycles = num_cycles # If the number of chords were specified, make sure that the number of steps # isn't also specified and that the movement is static. @@ -125,9 +123,7 @@ def __init__( "Only specify the number of chords if you haven't specified the " "number of steps and the movement is static!" ) - self.num_chords = num_chords - else: - self.num_chords = None + self.num_chords = num_chords # Calculate default num_steps and delta_time values if the user hasn't passed # one in. @@ -208,8 +204,8 @@ def __init__( ) def get_max_period(self): - """This method returns the longest period of this movement object's sub- - movement objects, sub-sub-movement objects, etc. + """This method returns the longest period of this movement object's + sub-movement objects, sub-sub-movement objects, etc. :return: float The longest period in seconds. @@ -425,20 +421,32 @@ def generate_airplanes(self, num_steps=10, delta_time=0.1): # Create an empty list of airplanes. airplanes = [] - # Generate the airplane name. + # Get the airplane's unchanging attributes. name = self.base_airplane.name + weight = self.base_airplane.weight + s_ref = self.base_airplane.s_ref + c_ref = self.base_airplane.c_ref + b_ref = self.base_airplane.b_ref # Iterate through the time steps. for step in range(num_steps): - # Get the reference position at this time step. - x_ref = x_ref_list[step] - y_ref = y_ref_list[step] - z_ref = z_ref_list[step] + # Get the changing attributes at this time step. these_wings = wings[:, step] + this_x_ref = x_ref_list[step] + this_y_ref = y_ref_list[step] + this_z_ref = z_ref_list[step] # Make a new airplane object for this time step. this_airplane = geometry.Airplane( - name=name, x_ref=x_ref, y_ref=y_ref, z_ref=z_ref, wings=these_wings + wings=these_wings, + name=name, + x_ref=this_x_ref, + y_ref=this_y_ref, + z_ref=this_z_ref, + weight=weight, + s_ref=s_ref, + c_ref=c_ref, + b_ref=b_ref, ) # Add this new object to the list of airplanes. @@ -448,8 +456,8 @@ def generate_airplanes(self, num_steps=10, delta_time=0.1): return airplanes def get_max_period(self): - """This method returns the longest period of this movement object's sub- - movement objects, sub-sub-movement objects, etc. + """This method returns the longest period of this movement object's + sub-movement objects, sub-sub-movement objects, etc. :return max_period: float The longest period in seconds. @@ -651,14 +659,14 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Initialize a variable to hold the inner wing cross section's list of wing # cross sections for each time step. - last_wing_cross_section_time_histories = None + inner_wing_cross_sections_time_history = None # Iterate through the wing cross section movement locations. for ( wing_cross_section_movement_location, wing_cross_section_movement, ) in enumerate(self.wing_cross_section_movements): - wing_is_vertical = False + wing_section_is_vertical = False # Check if this is this wing's root cross section. if wing_cross_section_movement_location == 0: @@ -672,49 +680,49 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Set the variables relating this wing cross section to the inner # wing cross section to zero because this is the innermost wing cross # section - wing_cross_section_span = 0.0 - base_wing_cross_section_sweep = 0.0 - base_wing_cross_section_heave = 0.0 - last_x_les = np.zeros(num_steps) * 0.0 - last_y_les = np.zeros(num_steps) * 0.0 - last_z_les = np.zeros(num_steps) * 0.0 + base_wing_section_span = 0.0 + base_wing_section_sweep = 0.0 + base_wing_section_heave = 0.0 + inner_x_les = np.zeros(num_steps) + inner_y_les = np.zeros(num_steps) + inner_z_les = np.zeros(num_steps) else: - this_base_wing_cross_section = ( + base_wing_cross_section = ( wing_cross_section_movement.base_wing_cross_section ) - this_x_le = this_base_wing_cross_section.x_le - this_y_le = this_base_wing_cross_section.y_le - this_z_le = this_base_wing_cross_section.z_le + base_x_le = base_wing_cross_section.x_le + base_y_le = base_wing_cross_section.y_le + base_z_le = base_wing_cross_section.z_le # Initialize variables to hold the inner wing cross section's time # histories of its leading edge coordinates. - last_x_les = [] - last_y_les = [] - last_z_les = [] + inner_x_les = [] + inner_y_les = [] + inner_z_les = [] # Iterate through the inner wing cross section's time history and # populate the leading edge coordinate variables. - for last_wing_cross_section in last_wing_cross_section_time_histories: - last_x_les.append(last_wing_cross_section.x_le) - last_y_les.append(last_wing_cross_section.y_le) - last_z_les.append(last_wing_cross_section.z_le) + for inner_wing_cross_section in inner_wing_cross_sections_time_history: + inner_x_les.append(inner_wing_cross_section.x_le) + inner_y_les.append(inner_wing_cross_section.y_le) + inner_z_les.append(inner_wing_cross_section.z_le) # Find the span between this wing cross section and the inner wing - # cross section. - wing_cross_section_span = np.sqrt( - (this_x_le - last_x_les[0]) ** 2 - + (this_y_le - last_y_les[0]) ** 2 - + (this_z_le - last_z_les[0]) ** 2 + # cross section at the first time step. + base_wing_section_span = np.sqrt( + (base_x_le - inner_x_les[0]) ** 2 + + (base_y_le - inner_y_les[0]) ** 2 + + (base_z_le - inner_z_les[0]) ** 2 ) - if this_y_le != last_y_les[0]: + if base_y_le != inner_y_les[0]: # Find the base sweep angle of this wing cross section compared # to the inner wing cross section at the first time step. - base_wing_cross_section_sweep = ( + base_wing_section_sweep = ( np.arctan( - (this_z_le - last_z_les[0]) / (this_y_le - last_y_les[0]) + (base_z_le - inner_z_les[0]) / (base_y_le - inner_y_les[0]) ) * 180 / np.pi @@ -722,49 +730,47 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Find the base heave angle of this wing cross section compared # to the inner wing cross section at the first time step. - base_wing_cross_section_heave = ( + base_wing_section_heave = ( np.arctan( - (this_x_le - last_x_les[0]) / (this_y_le - last_y_les[0]) + (base_x_le - inner_x_les[0]) / (base_y_le - inner_y_les[0]) ) * 180 / np.pi ) else: - base_wing_cross_section_sweep = 0.0 - base_wing_cross_section_heave = 0.0 - wing_is_vertical = True + base_wing_section_sweep = 0.0 + base_wing_section_heave = 0.0 + wing_section_is_vertical = True # Generate this wing cross section's vector of wing cross sections at # each time step based on its movement. - this_wing_cross_sections_list_of_wing_cross_sections = np.array( + wing_cross_sections_time_history = np.array( wing_cross_section_movement.generate_wing_cross_sections( num_steps=num_steps, delta_time=delta_time, - cross_section_span=wing_cross_section_span, - cross_section_sweep=base_wing_cross_section_sweep, - cross_section_heave=base_wing_cross_section_heave, - last_x_les=last_x_les, - last_y_les=last_y_les, - last_z_les=last_z_les, - wing_is_vertical=wing_is_vertical, + base_wing_section_span=base_wing_section_span, + base_wing_section_sweep=base_wing_section_sweep, + base_wing_section_heave=base_wing_section_heave, + inner_x_les=inner_x_les, + inner_y_les=inner_y_les, + inner_z_les=inner_z_les, + wing_section_is_vertical=wing_section_is_vertical, ) ) # Add this vector the wing's array of wing cross section objects. wing_cross_sections[ wing_cross_section_movement_location, : - ] = this_wing_cross_sections_list_of_wing_cross_sections + ] = wing_cross_sections_time_history # Update the inner wing cross section's list of wing cross sections for # each time step. - last_wing_cross_section_time_histories = ( - this_wing_cross_sections_list_of_wing_cross_sections - ) + inner_wing_cross_sections_time_history = wing_cross_sections_time_history # Create an empty list of wings. wings = [] - # Generate the non-changing wing attributes. + # Get the non-changing wing attributes. name = self.base_wing.name unit_normal_vector = self.base_wing.unit_normal_vector symmetric = self.base_wing.symmetric @@ -774,20 +780,19 @@ def generate_wings(self, num_steps=10, delta_time=0.1): # Iterate through the time steps. for step in range(num_steps): - - # Get the reference position at this time step. - x_le = x_le_list[step] - y_le = y_le_list[step] - z_le = z_le_list[step] - cross_sections = wing_cross_sections[:, step] + # Get the changing wing attributes at this time step. + this_x_le = x_le_list[step] + this_y_le = y_le_list[step] + this_z_le = z_le_list[step] + these_cross_sections = wing_cross_sections[:, step] # Make a new wing object for this time step. this_wing = geometry.Wing( - wing_cross_sections=cross_sections, + wing_cross_sections=these_cross_sections, name=name, - x_le=x_le, - y_le=y_le, - z_le=z_le, + x_le=this_x_le, + y_le=this_y_le, + z_le=this_z_le, unit_normal_vector=unit_normal_vector, symmetric=symmetric, unit_chordwise_vector=unit_chordwise_vector, @@ -963,13 +968,13 @@ def generate_wing_cross_sections( self, num_steps=10, delta_time=0.1, - last_x_les=None, - last_y_les=None, - last_z_les=None, - wing_is_vertical=False, - cross_section_span=0.0, - cross_section_sweep=0.0, - cross_section_heave=0.0, + inner_x_les=None, + inner_y_les=None, + inner_z_les=None, + wing_section_is_vertical=False, + base_wing_section_span=0.0, + base_wing_section_sweep=0.0, + base_wing_section_heave=0.0, ): """This method creates the wing cross section objects at each time current_step, and groups them into a list. @@ -979,33 +984,33 @@ def generate_wing_cross_sections( :param delta_time: float, optional This is the time, in seconds, between each time step. The default value is 0.1 seconds. - :param last_x_les: float, optional + :param inner_x_les: float, optional This is an array of the x coordinates of the reference location of the previous cross section at each time step. Its units are in meters, and its default value is 0.0 meters. - :param last_y_les: float, optional + :param inner_y_les: float, optional This is an array of the y coordinates of the reference location of the previous cross section at each time step. Its units are in meters, and its default value is 0.0 meters. - :param last_z_les: float, optional + :param inner_z_les: float, optional This is an array of the z coordinates of the reference location of the previous cross section at each time step. Its units are in meters, and its default value is 0.0 meters. - :param wing_is_vertical: bool, optional - This flag is set to true if the wing containing this wing cross section - is vertical. If true, the cross section's movement will automatically be - eliminated. This is a temporary patch until vertical wing cross section - movement is supported. The default value is false. - :param cross_section_span: float, optional + :param wing_section_is_vertical: bool, optional + This flag is set to true if the wing section containing this wing cross + section is vertical. If true, the cross section's movement will + automatically be eliminated. This is a temporary patch until vertical + wing cross section movement is supported. The default value is false. + :param base_wing_section_span: float, optional This is the length, in meters, of the leading edge stretching between this wing cross section at the previous wing cross section. If this is the first cross section, it should be 0.0 meters. The default value is 0.0 meters. - :param cross_section_sweep: float, optional + :param base_wing_section_sweep: float, optional This is the sweep, in degrees, of this wing cross section relative to the previous wing cross section. If this is the first cross section, it should be 0.0 degrees. The default value is 0.0 degrees. - :param cross_section_heave: float, optional + :param base_wing_section_heave: float, optional This is the heave, in degrees, of this wing cross section relative to the previous wing cross section. If this is the first cross section, it should be 0.0 degrees. The default value is 0.0 degrees. @@ -1021,7 +1026,7 @@ def generate_wing_cross_sections( sweeping_list = oscillating_sinspace( amplitude=self.sweeping_amplitude, period=self.sweeping_period, - base_value=cross_section_sweep, + base_value=base_wing_section_sweep, num_steps=num_steps, delta_time=delta_time, ) @@ -1031,7 +1036,7 @@ def generate_wing_cross_sections( sweeping_list = oscillating_linspace( amplitude=self.sweeping_amplitude, period=self.sweeping_period, - base_value=cross_section_sweep, + base_value=base_wing_section_sweep, num_steps=num_steps, delta_time=delta_time, ) @@ -1048,7 +1053,7 @@ def generate_wing_cross_sections( sweeping_list = oscillating_customspace( amplitude=self.sweeping_amplitude, period=self.sweeping_period, - base_value=cross_section_sweep, + base_value=base_wing_section_sweep, num_steps=num_steps, delta_time=delta_time, custom_function=self.custom_sweep_function, @@ -1109,7 +1114,7 @@ def generate_wing_cross_sections( heaving_list = oscillating_sinspace( amplitude=self.heaving_amplitude, period=self.heaving_period, - base_value=cross_section_heave, + base_value=base_wing_section_heave, num_steps=num_steps, delta_time=delta_time, ) @@ -1119,7 +1124,7 @@ def generate_wing_cross_sections( heaving_list = oscillating_linspace( amplitude=self.heaving_amplitude, period=self.heaving_period, - base_value=cross_section_heave, + base_value=base_wing_section_heave, num_steps=num_steps, delta_time=delta_time, ) @@ -1136,7 +1141,7 @@ def generate_wing_cross_sections( heaving_list = oscillating_customspace( amplitude=self.heaving_amplitude, period=self.heaving_period, - base_value=cross_section_heave, + base_value=base_wing_section_heave, num_steps=num_steps, delta_time=delta_time, custom_function=self.custom_heave_function, @@ -1146,7 +1151,7 @@ def generate_wing_cross_sections( # Throw an exception if the spacing value is not "sine" or "uniform". raise Exception("Bad value of heaving_spacing!") - if wing_is_vertical: + if wing_section_is_vertical: x_le_list = np.ones(num_steps) * self.x_le_base y_le_list = np.ones(num_steps) * self.y_le_base z_le_list = np.ones(num_steps) * self.z_le_base @@ -1158,13 +1163,13 @@ def generate_wing_cross_sections( # section's leading edge point (at each time step) as the origin. Also # convert the lists of sweep, pitch, and heave values to radians before # passing them into numpy's trigonometry functions. - x_le_list = last_x_les + cross_section_span * np.cos( + x_le_list = inner_x_les + base_wing_section_span * np.cos( sweeping_list * np.pi / 180 ) * np.sin(heaving_list * np.pi / 180) - y_le_list = last_y_les + cross_section_span * np.cos( + y_le_list = inner_y_les + base_wing_section_span * np.cos( sweeping_list * np.pi / 180 ) * np.cos(heaving_list * np.pi / 180) - z_le_list = last_z_les + cross_section_span * np.sin( + z_le_list = inner_z_les + base_wing_section_span * np.sin( sweeping_list * np.pi / 180 ) twist_list = pitching_list