Source code for flight_mech.wing

"""
Module to analyse wing aerodynamic properties.
"""

###########
# Imports #
###########

# Python imports #

import os
from typing import Literal
from copy import deepcopy

# Dependencies #

import numpy as np
from scipy.interpolate import make_interp_spline

try:
    import pyvista as pv
except Exception as exc:
    raise Warning(
        "Pyvista is not detected, please install it before using the 3D visualization functions.") from exc

# Local imports #

from flight_mech._common import plot_graph
from flight_mech.aerodynamics import compute_linear_drag
from flight_mech.airfoil import Airfoil

#############
# Constants #
#############

# Define a default plane database location
default_wing_database = os.path.join(
    os.path.dirname(__file__), "wing_database")

#############
# Functions #
#############

[docs] def check_pyvista_import(): """ Verify that pyvista is successfully imported. Raises ------ ImportError Raise error if pyvista cannot be accessed. """ try: pv.__version__ except Exception as exc: raise ImportError( "You need to install pyvista before using 3D visualization functions. You can do it with: 'pip install pyvista'") from exc
[docs] def convert_y_to_theta(y_array: np.ndarray, wing_span: float): """ Convert a y array to theta. Parameters ---------- y_array : np.ndarray Values of y coordinates. wing_span : float Wing span. Returns ------- np.ndarray Theta array. """ theta_array = np.acos(2 * y_array / wing_span) return theta_array
[docs] def convert_theta_to_y(theta_array: np.ndarray, wing_span: float): """ Convert a theta array to y. Parameters ---------- theta_array : np.ndarray Values of theta coordinates. wing_span : float Wing span. Returns ------- np.ndarray Y coordinates array. """ y_array = (wing_span / 2) * np.cos(theta_array) return y_array
[docs] def compute_chord_min_and_max_for_trapezoidal_wing(reference_surface: float, aspect_ratio: float, taper_ratio: float): """ Compute the chord min and max values for a trapezoidal wing. Parameters ---------- reference_surface : float Reference surface of the wing. aspect_ratio : float Aspect ratio of the wing. taper_ratio : float Taper ratio of the wing. Returns ------- tuple[float,float] Tuple containing the chord min and max values. """ wing_span = np.sqrt(aspect_ratio * reference_surface) max_chord = 2 * reference_surface / (wing_span * (1 + taper_ratio)) min_chord = max_chord * taper_ratio return min_chord, max_chord
########### # Classes # ###########
[docs] class Wing: """ Class to define a wing and compute its characteristics. """ name: str | None = None _y_array: np.ndarray | None = None chord_length_array: np.ndarray | None = None twisting_angle_array: np.ndarray | None = None x_center_offset_array: np.ndarray | None = None base_airfoil: Airfoil | None = None @property def y_array(self) -> np.ndarray: """ Array of y coordinates used for the wing definition. """ return self._y_array @property def leading_edge_x_array(self) -> np.ndarray: """ Array of x coordinates of the leading edge. """ leading_edge_x_array = self.x_center_offset_array + ( self.chord_length_array[0] - self.chord_length_array) / 2 return leading_edge_x_array @property def trailing_edge_x_array(self) -> np.ndarray: """ Array of x coordinates of the trailing edge. """ trailing_edge_x_array = self.chord_length_array + self.x_center_offset_array + ( self.chord_length_array[0] - self.chord_length_array) / 2 return trailing_edge_x_array @property def center_x_array(self) -> np.ndarray: """ Array of the x coordinates of the mean between leading and trailing edges coordinates. """ center_x_array = (self.leading_edge_x_array + self.leading_edge_x_array) / 2 return center_x_array @y_array.setter def y_array(self, value: np.ndarray): if self.twisting_angle_array is not None and self.chord_length_array is not None and self.x_center_offset_array is not None: if self._y_array.size == self.chord_length_array.size: self.re_interpolate(value, update_y_array=False) self._y_array = value else: self._y_array = value self._chord_length = np.max(value) @property def single_side_surface(self): """ Surface of a single wing. """ surface = np.trapezoid(self.chord_length_array, self.y_array) return surface @property def reference_surface(self): """ Reference surface of the wings of the plane. Equal to 2 times the surface of a single wing. """ reference_surface = self.single_side_surface * 2 return reference_surface @property def wing_span(self): """ Wing span. """ wing_span = np.max(self.y_array) * 2 return wing_span @property def aspect_ratio(self): """ Aspect ratio. It corresponds to the squared wing span over the reference surface. """ aspect_ratio = pow(self.wing_span, 2) / self.reference_surface return aspect_ratio @property def sweep_angle_at_leading_edge(self): """ Sweep angle at the leading edge of the wing. """ sweep_angle = np.atan( (self.leading_edge_x_array[1] - self.leading_edge_x_array[0]) / (self.y_array[1] - self.y_array[0])) return sweep_angle @property def sweep_angle_at_trailing_edge(self): """ Sweep angle at the trailing edge of the wing. """ sweep_angle = np.atan( (self.trailing_edge_x_array[0] - self.trailing_edge_x_array[1]) / (self.y_array[1] - self.y_array[0])) return sweep_angle @property def taper_ratio(self): """ Taper ratio. It corresponds to the ratio of the min chord and the max chord. """ taper_ratio = np.min(self.chord_length_array) / \ np.max(self.chord_length_array) return taper_ratio
[docs] def initialize(self): """ Initialize the wing by setting the undefined array to default values. Raises ------ ValueError Raise error if the y array is not defined. """ if self.y_array is None: raise ValueError( "Unable to initialize the wing, the y array is not defined.") if self.twisting_angle_array is None: self.twisting_angle_array = np.zeros(self.y_array.shape) if self.x_center_offset_array is None: self.x_center_offset_array = np.zeros(self.y_array.shape) if self.base_airfoil is None: self.base_airfoil = Airfoil("naca4412")
[docs] def check_initialization(self): """ Check that the wing is correctly initialized. """ if self.y_array is None: raise ValueError( "The y array is not defined, unable to proceed. Please define it first.") if self.chord_length_array is None: raise ValueError( "The chord length array is not defined, unable to proceed. Please define it first.") if self.twisting_angle_array is None: raise ValueError( "The twisting angle array is not defined, unable to proceed. Please initialize the wing with wing.initialize() or define it first.") if self.x_center_offset_array is None: raise ValueError( "The x center offset array is not defined, unable to proceed. Please initialize the wing with wing.initialize() or define it first.") if self.base_airfoil is None: raise ValueError( "The base airfoil is not defined, unable to proceed. Please initialize the wing with wing.initialize() or define it first.")
[docs] def re_interpolate(self, new_y_array: np.ndarray, update_y_array: bool = True): """ Re-interpolate the wing arrays. Parameters ---------- new_y_array : np.ndarray New y array on which to interpolate. update_y_array : bool, optional Indicate wether to update the y array, by default True """ # Create interpolation functions chord_length_function = make_interp_spline( self.y_array, self.chord_length_array) twisting_angle_function = make_interp_spline( self.y_array, self.twisting_angle_array) x_center_offset_function = make_interp_spline( self.y_array, self.x_center_offset_array) # Interpolate on new points self.chord_length_array = chord_length_function(new_y_array) self.twisting_angle_array = twisting_angle_function(new_y_array) self.x_center_offset_array = x_center_offset_function(new_y_array) # Update y array if needed if update_y_array: self.y_array = new_y_array
[docs] def plot_2D(self, save_path: str | None = None, hold_plot: bool = False, clear_before_plot: bool = False): """ Plot the shape of the wing in 2D. Parameters ---------- save_path : str | None, optional Path to save the figure, by default None hold_plot : bool, optional Indicate wether to keep or plot the figure, by default False clear_before_plot : bool, optional Indicate wether to clear or not the plot before, by default False """ # Group to create a contour x_contour_array = np.concatenate( (self.leading_edge_x_array, self.trailing_edge_x_array[::-1], [self.leading_edge_x_array[0]]), axis=0) y_contour_array = np.concatenate( (self.y_array, self.y_array[::-1], [self.y_array[0]]), axis=0) # Plot the graph plot_graph( y_contour_array, x_contour_array, data_label=self.name, title="Wing visualization", use_grid=True, save_path=save_path, hold_plot=hold_plot, clear_before_plot=clear_before_plot, x_label="y", y_label="x", axis_type="equal" )
[docs] def create_3D_animation(self, output_path: str, nb_points_airfoil: int = 50, nb_frames: int = 60, time_step: float = 0.05, **kwargs): """ Create a rotating 3D animation of the wing. Parameters ---------- output_path : str Path of the output gif. nb_points_airfoil : int, optional Number of points to use for the airfoil, by default 50 nb_frames : int, optional Number of frames in the animation, by default 60 time_step : float, optional Time step between each frame, by default 0.05 kwargs : dict Parameters to pass to the plot 3D function. """ # Create the wing surface # wing_surface = self.create_wing_3D_surface(nb_points_airfoil) # pl = pv.Plotter(off_screen=True) # pl.add_mesh(wing_surface) # Prepare the pyvista scene pl, wing_surface = self.plot_3D( nb_points_airfoil=nb_points_airfoil, for_animation=True, **kwargs) # Animate pl.show(auto_close=False) path = pl.generate_orbital_path( n_points=nb_frames, shift=wing_surface.length, factor=3.0) if not output_path.endswith(".gif"): output_path = output_path + ".gif" pl.open_gif(output_path) pl.orbit_on_path(path, write_frames=True, step=time_step, progress_bar=True) pl.close()
[docs] def create_wing_3D_surface(self, nb_points_airfoil: int = 50): """ Create a wing surface PyVista object for 3D plotting. Parameters ---------- nb_points_airfoil : int, optional Number of points to use for the airfoil, by default 50 Returns ------- pv.PointSet Pyvista 3D surface. """ # Check if pyvista is imported check_pyvista_import() # Create a display airfoil with normalized chord and less points display_airfoil = deepcopy(self.base_airfoil) display_airfoil.chord_length = 1 display_airfoil.re_interpolate_with_cosine_distribution( nb_points_airfoil // 2) # Create an array containing all the points points_array = np.zeros((nb_points_airfoil * self.y_array.size, 3)) for i in range(self.y_array.size): ratio = self.chord_length_array[i] / display_airfoil.chord_length airfoil_x_array, airfoil_z_array = display_airfoil.get_rotated_selig_arrays( self.twisting_angle_array[i]) points_index = i * nb_points_airfoil points_array[points_index:points_index + nb_points_airfoil, 0] =\ airfoil_x_array[:-1] * ratio + \ self.x_center_offset_array[i] + ( self.chord_length_array[0] - self.chord_length_array[i]) / 2 points_array[points_index:points_index + nb_points_airfoil, 1] = self.y_array[i] points_array[points_index:points_index + nb_points_airfoil, 2] = airfoil_z_array[:-1] * ratio # Create a mesh from the points point_cloud = pv.PolyData(points_array) wing_surface = point_cloud.delaunay_3d() return wing_surface
[docs] def plot_3D(self, nb_points_airfoil: int = 50, show_symmetric_wing: bool = False, show_drag: None | Literal["blasius", "polhausen", "simulation"] = None, velocity_method: Literal["constant", "panels"] = "constant", velocity: float = None, rho: float = None, nu: float = None, for_animation: bool = False, title: str | None = None): """ Plot the shape of the wing in 3D. """ # Prepare settings according to mode if for_animation: off_screen = True else: off_screen = False # Create the 3D surface wing_surface = self.create_wing_3D_surface( nb_points_airfoil) # Create the plotter object p = pv.Plotter(off_screen=off_screen) # Create a symmetry if needed if show_symmetric_wing: wing_reflected = wing_surface.reflect((0, 1, 0)) p.add_mesh(wing_reflected) if show_drag is not None: title = "Drag" pbr = False metallic = 0. # Raise error if not enough parameters if velocity is None or rho is None or nu is None: raise ValueError( f"The values of velocity or rho or nu are not properly defined. Please provide them all to show the drag on the wing.") # Allocate an array for the drag drag_array = np.zeros(nb_points_airfoil * self.y_array.size) for i in range(self.y_array.size): points_index = i * nb_points_airfoil drag_array[points_index:points_index + nb_points_airfoil // 2] = self.compute_zero_lift_drag_on_wing_slice( i, velocity, rho, nu, show_drag, velocity_method, nb_points_airfoil // 2, return_array=True, face="upper")[1] drag_array[points_index + nb_points_airfoil // 2:points_index + nb_points_airfoil] = self.compute_zero_lift_drag_on_wing_slice( i, velocity, rho, nu, show_drag, velocity_method, nb_points_airfoil // 2, return_array=True, face="lower")[1][::-1] scalars = drag_array max_drag = np.max(drag_array[drag_array != np.inf]) scalars = np.nan_to_num(scalars, nan=max_drag, posinf=max_drag) scalars[scalars > 1e10] = max_drag else: scalars = None metallic = 1. pbr = True # Plot p.add_mesh( wing_surface, scalars=scalars, log_scale=True, scalar_bar_args={"title": "Wall shear stress [Pa]"}, pbr=pbr, metallic=metallic ) # Set title if title is not None: p.add_title(title) # Show if needed if not for_animation: p.show() else: return p, wing_surface
[docs] def save_3D_shape(self, output_path: str, nb_points_airfoil: int = 50): """ Save the wing shape as a 3D object. The format can be '.ply', '.vtp', '.stl', '.vtk', '.geo', '.obj' or '.iv'. Parameters ---------- output_path : str Path of the output file. nb_points_airfoil : int, optional Number of points to use for the airfoil, by default 50 """ # Create the 3D surface wing_surface = self.create_wing_3D_surface(nb_points_airfoil) # Save the output file wing_surface.extract_geometry().save(output_path)
[docs] def compute_fourrier_coefficients(self, alpha: float, nb_points_fourrier: int = 10): """ Compute the fourrier coefficients used to determine the lift and induced drag. Parameters ---------- alpha : float Angle of incidence of the wing. nb_points_fourrier : int, optional Number of points for the fourrier decomposition, by default 10 Returns ------- tuple[np.ndarray,np.ndarray] Tuple containing the fourrier coefficients and their ids. """ # Change variable from y to theta theta_array = convert_y_to_theta(self.y_array[::-1], self.wing_span) # Create functions to be able to interpolate the arrays on new positions chord_length_on_theta_func = make_interp_spline( theta_array, self.chord_length_array[::-1]) twisting_angle_on_theta_func = make_interp_spline( theta_array, self.twisting_angle_array[::-1]) # Create theta positions to interpolate theta_interpolation_pos = np.linspace( np.pi / 2 / nb_points_fourrier, np.pi / 2, nb_points_fourrier) chord_length_on_theta = chord_length_on_theta_func( theta_interpolation_pos) twisting_angle_on_theta = twisting_angle_on_theta_func( theta_interpolation_pos) # Compute the alpha at zero lift for the airfoil alpha_zero_lift = self.base_airfoil.compute_alpha_zero_lift() # Allocate variables to define the system to solve mat_A = np.zeros((nb_points_fourrier, nb_points_fourrier)) vec_B = np.zeros(nb_points_fourrier) # Iterate to fill the system for i in range(nb_points_fourrier): mu_loc = ( 2 * np.pi * chord_length_on_theta[i]) / (4 * self.wing_span) for j in range(nb_points_fourrier): n = 2 * j + 1 mat_A[i, j] = (np.sin(theta_interpolation_pos[i]) + n * mu_loc) * np.sin(n * theta_interpolation_pos[i]) vec_B[i] = np.sin(theta_interpolation_pos[i]) * \ (-twisting_angle_on_theta[i] + alpha - alpha_zero_lift) * mu_loc # Solve the system An_vec_odd = np.linalg.solve(mat_A, vec_B) n_vec_odd = np.linspace( 1, nb_points_fourrier * 2 - 1, nb_points_fourrier) return An_vec_odd, n_vec_odd
[docs] def compute_lift_and_induced_drag_coefficients(self, alpha: float, nb_points_fourrier: int = 10): """ Compute the coefficients of lift and induced drag. Parameters ---------- alpha : float Angle of incidence of the wing. nb_points_fourrier : int, optional Number of points for the fourrier decomposition, by default 10 Returns ------- tuple[float,float] Tuple containing the lift and induced drag coefficients. """ # Compute the fourrier coefficients An_vec_odd, n_vec_odd = self.compute_fourrier_coefficients( alpha, nb_points_fourrier) # Compute the lift and induced drag coefficients CL = np.pi * An_vec_odd[0] * self.aspect_ratio CD = np.pi * self.aspect_ratio * \ np.sum(n_vec_odd * np.power(An_vec_odd, 2)) return CL, CD
[docs] def compute_zero_lift_drag_on_wing_slice(self, y_index: int, velocity: float, rho: float, nu: float, drag_method: Literal["blasius", "polhausen", "simulation"] = "polhausen", velocity_method: Literal["constant", "panels"] = "constant", nb_points: int = 1000, return_array: bool = False, face: Literal["upper", "lower"] = "upper"): """ Compute the drag of the wing at zero lift. Parameters ---------- y_index : int Index of the slice. velocity : float Velocity of the external flow. rho : float Density of the fluid. nu : float Kinematic viscosity of the fluid. drag_method : Literal["blasius", "polhausen", "simulation"], optional Method to use for the drag computation, by default "polhausen" velocity_method : Literal["constant", "panels"], optional Method to use for the velocity computation, by default "constant" nb_points : int, optional Number of points to use for the computation, by default 1000 face : Literal["upper", "lower"] Indicate on which face to compute the drag. Returns ------- float Drag on the wing slice. Raises ------ NotImplementedError Raise error if the given method is not defined. """ # Create x array x_array = np.linspace( 0, self.chord_length_array[y_index], nb_points) # Create velocity array if velocity_method == "constant": velocity_array = velocity * np.ones(nb_points) elif velocity_method == "panels": raise NotImplementedError if face == "upper": pass elif face == "lower": pass else: raise ValueError velocity_array = ... else: raise NotImplementedError( f"The velocity method {velocity_method} is not implemented. Please use 'constant' or 'panels'.") # Compute the drag on the wing slice result = compute_linear_drag( x_array, velocity_array, rho, nu, drag_method, return_array=return_array) return result
[docs] def compute_zero_lift_drag(self, velocity: float, rho: float, nu: float, drag_method: Literal["blasius", "polhausen", "simulation"] = "polhausen", velocity_method: Literal["constant", "panels"] = "constant", nb_points: int = 1000, face: Literal["both", "upper", "lower"] = "both"): """ Compute the drag of the half-wing at zero lift. Parameters ---------- velocity : float Velocity of the external flow. rho : float Density of the fluid. nu : float Kinematic viscosity of the fluid. drag_method : Literal["blasius", "polhausen", "simulation"], optional Method to use for the drag computation, by default "polhausen" velocity_method : Literal["constant", "panels"], optional Method to use for the velocity computation, by default "constant" nb_points : int, optional Number of points to use for the computation, by default 1000 face : Literal["both", "upper", "lower"] Indicate on which face to compute the drag. Returns ------- float Drag at zero lift """ if face == "both": zero_lift_drag = self.compute_zero_lift_drag(velocity, rho, nu, drag_method, velocity_method, nb_points, face="upper") + \ self.compute_zero_lift_drag( velocity, rho, nu, drag_method, velocity_method, nb_points, face="lower") else: # Compute linear drag on the wing linear_drag_on_wing = np.zeros(self.y_array.shape) for i in range(self.y_array.size): linear_drag_on_wing[i] = self.compute_zero_lift_drag_on_wing_slice( i, velocity, rho, nu, drag_method, velocity_method, nb_points, face=face) # Integrate the drag over y zero_lift_drag = np.trapezoid(linear_drag_on_wing, self.y_array) return zero_lift_drag