From 43d143d76e6adef73755a2f087ed949eeffbe53b Mon Sep 17 00:00:00 2001 From: Anna Ivagnes Date: Wed, 13 Sep 2023 18:50:06 +0200 Subject: [PATCH] restore profiles and adjust tests --- bladex/__init__.py | 4 +- bladex/profile/__init__.py | 4 +- bladex/profile/customprofile.py | 290 +++------ bladex/profile/nacaprofile.py | 12 +- .../{profilebase.py => profileinterface.py} | 261 +++++---- tests/test_package.py | 4 - tests/test_profilebase.py | 549 ------------------ tests/test_profileinterface.py | 10 + tests/test_profiles.py | 6 +- 9 files changed, 248 insertions(+), 892 deletions(-) rename bladex/profile/{profilebase.py => profileinterface.py} (86%) delete mode 100644 tests/test_profilebase.py create mode 100644 tests/test_profileinterface.py diff --git a/bladex/__init__.py b/bladex/__init__.py index ecc5d93..2605d9a 100644 --- a/bladex/__init__.py +++ b/bladex/__init__.py @@ -1,12 +1,12 @@ """ BladeX init """ -__all__ = ['ProfileBase', 'NacaProfile', 'CustomProfile', 'ReversePropeller', +__all__ = ['ProfileInterface', 'NacaProfile', 'CustomProfile', 'ReversePropeller', 'Blade', 'Shaft', 'Propeller', 'Deformation', 'ParamFile', 'RBF', 'reconstruct_f', 'scipy_bspline'] from .meta import * -from .profile import ProfileBase +from .profile import ProfileInterface from .profile import NacaProfile from .profile import CustomProfile from .blade import Blade diff --git a/bladex/profile/__init__.py b/bladex/profile/__init__.py index 88e90c8..3b44d32 100644 --- a/bladex/profile/__init__.py +++ b/bladex/profile/__init__.py @@ -1,8 +1,8 @@ """ Profile init """ -__all__ = ['ProfileBase', 'NacaProfile', 'CustomProfile'] +__all__ = ['ProfileInterface', 'NacaProfile', 'CustomProfile'] -from .profilebase import ProfileBase +from .profileinterface import ProfileInterface from .nacaprofile import NacaProfile from .customprofile import CustomProfile diff --git a/bladex/profile/customprofile.py b/bladex/profile/customprofile.py index 68ef247..36227b3 100644 --- a/bladex/profile/customprofile.py +++ b/bladex/profile/customprofile.py @@ -8,11 +8,11 @@ """ import numpy as np -from .profilebase import ProfileBase +from .profileinterface import ProfileInterface from scipy.optimize import newton -class CustomProfile(ProfileBase): +class CustomProfile(ProfileInterface): """ Provide custom profile, given the airfoil coordinates or the airfoil parameters, i.e. , chord percentages and length, nondimensional and maximum camber, @@ -42,29 +42,31 @@ class CustomProfile(ProfileBase): :param float camber_max: the maximum camber at a certain airfoil section :param float thickness_max: the maximum thickness at a certain airfoil section """ - def __init__(self, convention='british', **kwargs): + def __init__(self, **kwargs): super(CustomProfile, self).__init__() - self.convention = convention if set(kwargs.keys()) == set( ['xup', 'yup', 'xdown', 'ydown']): - self.xup_coordinates = kwargs['xup'] - self.yup_coordinates = kwargs['yup'] - self.xdown_coordinates = kwargs['xdown'] - self.ydown_coordinates = kwargs['ydown'] + self._xup_coordinates = kwargs['xup'] + self._yup_coordinates = kwargs['yup'] + self._xdown_coordinates = kwargs['xdown'] + self._ydown_coordinates = kwargs['ydown'] self._check_coordinates() + self.generate_parameters(convention='british') elif set(kwargs.keys()) == set([ 'chord_perc', 'camber_perc', 'thickness_perc', - 'camber_max', 'thickness_max' + 'camber_max', 'thickness_max' , 'chord_len' ]): - self.chord_percentage = kwargs['chord_perc'] - self.camber_percentage = kwargs['camber_perc'] - self.thickness_percentage = kwargs['thickness_perc'] - self.camber_max = kwargs['camber_max'] - self.thickness_max = kwargs['thickness_max'] + self._chord_percentage = kwargs['chord_perc'] + self._camber_percentage = kwargs['camber_perc'] + self._thickness_percentage = kwargs['thickness_perc'] + self._camber_max = kwargs['camber_max'] + self._thickness_max = kwargs['thickness_max'] + self._chord_length = kwargs['chord_len'] self._check_parameters() + self.generate_coordinates(convention='british') else: raise RuntimeError( @@ -72,54 +74,27 @@ def __init__(self, convention='british', **kwargs): (xup, yup, xdown, ydown) or the section parameters (camber_perc, thickness_perc, camber_max, thickness_max, chord_perc).""") + def generate_parameters(self, convention='british'): + return super().generate_parameters(convention) + + def generate_coordinates(self, convention='british'): + if convention == 'british': + self._compute_coordinates_british_convention() + elif convention == 'american': + self._compute_coordinates_american_convention() - def _check_parameters(self): + def _compute_coordinates_british_convention(self): """ - Private method that checks whether the airfoil parameters defined - are provided correctly. - In particular, the chord, camber and thickness percentages are - consistent and have the same length. + Compute the coordinates of points on upper and lower profile according + to the British convention. """ - - if self.chord_percentage is None: - raise ValueError('object "chord_perc" refers to an empty array.') - if self.camber_percentage is None: - raise ValueError('object "camber_perc" refers to an empty array.') - if self.thickness_percentage is None: - raise ValueError( - 'object "thickness_perc" refers to an empty array.') - if self.camber_max is None: - raise ValueError('object "camber_max" refers to an empty array.') - if self.thickness_max is None: - raise ValueError('object "thickness_max" refers to an empty array.') - - if not isinstance(self.chord_percentage, np.ndarray): - self.chord_percentage = np.asarray(self.chord_percentage, - dtype=float) - if not isinstance(self.camber_percentage, np.ndarray): - self.camber_percentage = np.asarray(self.camber_percentage, - dtype=float) - if not isinstance(self.thickness_percentage, np.ndarray): - self.thickness_percentage = np.asarray(self.thickness_percentage, - dtype=float) - if not isinstance(self.camber_max, np.ndarray): - self.camber_max = np.asarray(self.camber_max, dtype=float) - if not isinstance(self.thickness_max, np.ndarray): - self.thickness_max = np.asarray(self.thickness_max, dtype=float) - if self.camber_max < 0: - raise ValueError('camber_max must be positive.') - if self.thickness_max < 0: - raise ValueError('thickness_max must be positive.') - - # Therefore the arrays camber_percentage and thickness_percentage - # should have the same length of chord_percentage, equal to n_pos, - # which is the number of cuts along the chord line - if self.camber_percentage.shape != self.chord_percentage.shape: - raise ValueError('camber_perc and chord_perc must have same shape.') - if self.thickness_percentage.shape != self.chord_percentage.shape: - raise ValueError( - 'thickness_perc and chord_perc must have same shape.') - + self._xup_coordinates = self.chord_percentage*self.chord_length + self._xdown_coordinates = self._xup_coordinates.copy() + self._yup_coordinates = (self.camber_percentage*self.camber_max + + self.thickness_max/2*self.thickness_percentage) + self._ydown_coordinates = (self.camber_percentage*self.camber_max - + self.thickness_max/2*self.thickness_percentage) + def _compute_orth_camber_coordinates(self): """ Compute the coordinates of points on upper and lower profile on the @@ -134,13 +109,13 @@ def _compute_orth_camber_coordinates(self): for i in range(1, n_pos, 1): m[i] = (self.camber_percentage[i]- self.camber_percentage[i-1])/(self.chord_percentage[i]- - self.chord_percentage[i-1])*self.camber_max/self.chord_len + self.chord_percentage[i-1])*self.camber_max/self.chord_length m_angle = np.arctan(m) - xup_tmp = (self.chord_percentage*self.chord_len - + xup_tmp = (self.chord_percentage*self.chord_length - self.thickness_percentage*np.sin(m_angle)*self.thickness_max/2) - xdown_tmp = (self.chord_percentage*self.chord_len + + xdown_tmp = (self.chord_percentage*self.chord_length + self.thickness_percentage*np.sin(m_angle)*self.thickness_max/2) yup_tmp = (self.camber_percentage*self.camber_max + self.thickness_max/2*self.thickness_percentage*np.cos(m_angle)) @@ -152,150 +127,67 @@ def _compute_orth_camber_coordinates(self): yup_tmp[1], ydown_tmp[1] = yup_tmp[2]-1e-16, ydown_tmp[2]-1e-16 return [xup_tmp, xdown_tmp, yup_tmp, ydown_tmp] - - def _compute_coordinates_british_convention(self): - """ - Compute the coordinates of points on upper and lower profile according - to the British convention. - """ - self.xup_coordinates = self.chord_percentage - self.xdown_coordinates = self.xup_coordinates.copy() - self.yup_coordinates = (self.camber_percentage*self.camber_max + - self.thickness_max/2*self.thickness_percentage) - self.ydown_coordinates = (self.camber_percentage*self.camber_max - - self.thickness_max/2*self.thickness_percentage) - self._check_coordinates() - + def _compute_coordinates_american_convention(self): """ Compute the coordinates of points on upper and lower profile according to the American convention. """ - [self.xup_coordinates, self.xdown_coordinates, self.yup_coordinates, - self.ydown_coordinates] = self._compute_orth_camber_coordinates() + [self._xup_coordinates, self._xdown_coordinates, self._yup_coordinates, + self._ydown_coordinates] = self._compute_orth_camber_coordinates() - self.ydown_coordinates = self.ydown_curve( - (self.chord_percentage).reshape(-1,1)).reshape( + self._ydown_coordinates = self.ydown_curve( + (self.chord_percentage*self.chord_length).reshape(-1,1)).reshape( self.chord_percentage.shape) - self.xup_coordinates = self.chord_percentage - self.xdown_coordinates = self.xup_coordinates.copy() - self.yup_coordinates = (2*self.camber_max*self.camber_percentage - + self._yup_coordinates = (2*self.camber_max*self.camber_percentage - self.ydown_coordinates) - def generate_coordinates(self, convention='british'): - """ - Method that generates the coordinates of a general airfoil - profile, starting from the chord percentages and the related - nondimensional camber and thickness, the maximum values of thickness - and camber. The convention fot the coordinates generation - can be either British or American. - """ - if convention == 'british': - self._compute_coordinates_british_convention() - elif convention == 'american': - self._compute_coordinates_american_convention() - else: - raise ValueError('Convention not recognized.') - -# def adimensionalize(self): -# """ -# Rescale coordinates of upper and lower profiles of section such that -# coordinates on x axis are between 0 and 1. -# """ -# factor = abs(self.xup_coordinates[-1]-self.xup_coordinates[0]) -# self.yup_coordinates *= 1/factor -# self.xdown_coordinates *= 1/factor -# self.ydown_coordinates *= 1/factor -# self.xup_coordinates *= 1/factor - - def _compute_thickness_british_convention(self): - """ - Generate parameters in British convention. - """ - thickness = abs(self.yup_coordinates - self.ydown_coordinates) - self.thickness_max = np.max(thickness) - self.thickness_percentage = thickness/self.thickness_max - - def _compute_thickness_american_convention(self): - """ - Generate parameters in American convention. - """ - n_pos = self.xup_coordinates.shape[0] - m = np.zeros(n_pos) - for i in range(1, n_pos, 1): - m[i] = (self.camber_percentage[i]- - self.camber_percentage[i-1])/(self.chord_percentage[i]- - self.chord_percentage[i-1])*self.camber_max#/self.chord_len - m_angle = np.arctan(m) - - # generating temporary profile coordinates orthogonal to the camber - # line - camber = self.camber_max*self.camber_percentage - ind_horizontal_camber = (np.sin(m_angle)==0) - def eq_to_solve(x): - spline_curve = self.ydown_curve(x.reshape(-1,1)).reshape( - x.shape[0],) - line_orth_camber = (camber[~ind_horizontal_camber] + - np.cos(m_angle[~ind_horizontal_camber])/ - np.sin(m_angle[~ind_horizontal_camber])*( - self.chord_percentage[~ind_horizontal_camber]-x)) - return spline_curve - line_orth_camber - - xdown_tmp = self.xdown_coordinates.copy() - xdown_tmp[~ind_horizontal_camber] = newton(eq_to_solve, - xdown_tmp[~ind_horizontal_camber]) - xup_tmp = (2*self.chord_percentage - xdown_tmp) - ydown_tmp = self.ydown_curve(xdown_tmp.reshape(-1,1)).reshape( - xdown_tmp.shape[0],) - yup_tmp = 2*self.camber_max*self.camber_percentage - ydown_tmp - if xup_tmp[1]= ydown_coordinates must be satisfied # element-wise to the whole elements in the mentioned arrays. if not all( @@ -378,4 +258,4 @@ def _check_coordinates(self): if not np.isclose(self.ydown_coordinates[-1], self.yup_coordinates[-1], atol=1e-6): - raise ValueError('(ydown[0]=yup[0]) not satisfied.') + raise ValueError('(ydown[0]=yup[0]) not satisfied.') \ No newline at end of file diff --git a/bladex/profile/nacaprofile.py b/bladex/profile/nacaprofile.py index 7e39c23..b7d0bb1 100644 --- a/bladex/profile/nacaprofile.py +++ b/bladex/profile/nacaprofile.py @@ -4,9 +4,9 @@ """ from scipy.interpolate import splev, splrep import numpy as np -from .profilebase import ProfileBase +from .profileinterface import ProfileInterface -class NacaProfile(ProfileBase): +class NacaProfile(ProfileInterface): """ Generate 4- and 5-digit NACA profiles. @@ -68,7 +68,8 @@ def __init__(self, digits, n_points=240, cosine_spacing=True): self.n_points = n_points self.cosine_spacing = cosine_spacing self._check_args() - self._generate_coordinates() + self.generate_coordinates() + self.generate_parameters(convention='british') def _check_args(self): """ @@ -84,7 +85,10 @@ def _check_args(self): if self.n_points < 0: raise ValueError('n_points must be positive.') - def _generate_coordinates(self): + def generate_parameters(self, convention='british'): + return super().generate_parameters(convention) + + def generate_coordinates(self): """ Private method that generates the coordinates of the NACA 4 or 5 digits airfoil profile. The method assumes a zero-thickness trailing edge, and diff --git a/bladex/profile/profilebase.py b/bladex/profile/profileinterface.py similarity index 86% rename from bladex/profile/profilebase.py rename to bladex/profile/profileinterface.py index e283f53..0fbf337 100644 --- a/bladex/profile/profilebase.py +++ b/bladex/profile/profileinterface.py @@ -9,7 +9,7 @@ from abc import ABC, abstractmethod from scipy.optimize import newton -class ProfileBase(ABC, object): +class ProfileInterface(ABC): """ Base sectional profile of the propeller blade. @@ -38,17 +38,134 @@ class ProfileBase(ABC, object): :param numpy.ndarray trailing_edge: 2D coordinates of the airfoil's trailing edge. Default values are zeros """ + @abstractmethod + def generate_parameters(self, convention='british'): + """ + Abstract method that generates the airfoil parameters based on the + given coordinates. - def __init__(self, convention='british'): - self.convention = convention - self.xup_coordinates = None - self.xdown_coordinates = None - self.yup_coordinates = None - self.ydown_coordinates = None - self.chord_line = None - self.camber_line = None - self.leading_edge = np.zeros(2) - self.trailing_edge = np.zeros(2) + The method generates the airfoil's chord length, chord percentages, + maximum camber, camber percentages, maximum thickness, thickness percentages. + + :param str convention: convention of the airfoil coordinates. Default + value is 'british' + """ + self._update_edges() + # compute chord parameters + self._chord_length = np.linalg.norm(self.leading_edge - self.trailing_edge) + self._chord_percentage = (self.xup_coordinates - np.min(self.xup_coordinates))/self._chord_length + # compute camber parameters + _camber = (self.yup_coordinates + self.ydown_coordinates)/2 + self._camber_max = abs(np.max(_camber)) + if self._camber_max == 0: + self._camber_percentage = np.zeros(self.xup_coordinates.shape[0]) + elif self.camber_max != 0: + self._camber_percentage = _camber/self._camber_max + # compute thickness parameters + if convention == 'british' or self._camber_max==0: + _thickness = abs(self.yup_coordinates - self.ydown_coordinates) + elif convention == 'american': + _thickness = self._compute_thickness_american() + self._thickness_max = np.max(_thickness) + if self._thickness_max == 0: + self._thickness_percentage = np.zeros(self.xup_coordinates.shape[0]) + elif self._thickness_max != 0: + self._thickness_percentage = _thickness/self._thickness_max + + @abstractmethod + def generate_coordinates(self): + """ + Abstract method that generates the airfoil coordinates based on the + given parameters. + + The method generates the airfoil's upper and lower surfaces + coordinates. The method is called automatically when the airfoil + parameters are inserted by the user. + + :param str convention: convention of the airfoil coordinates. Default + value is 'british' + """ + pass + + @property + def xup_coordinates(self): + return self._xup_coordinates + + @xup_coordinates.setter + def xup_coordinates(self, xup_coordinates): + self._xup_coordinates = xup_coordinates + + @property + def xdown_coordinates(self): + return self._xdown_coordinates + + @xdown_coordinates.setter + def xdown_coordinates(self, xdown_coordinates): + self._xdown_coordinates = xdown_coordinates + + @property + def yup_coordinates(self): + return self._yup_coordinates + + @yup_coordinates.setter + def yup_coordinates(self, yup_coordinates): + self._yup_coordinates = yup_coordinates + + @property + def ydown_coordinates(self): + return self._ydown_coordinates + + @ydown_coordinates.setter + def ydown_coordinates(self, ydown_coordinates): + self._ydown_coordinates = ydown_coordinates + + @property + def chord_length(self): + return self._chord_length + + @chord_length.setter + def chord_length(self, chord_length): + self._chord_length = chord_length + + @property + def chord_percentage(self): + return self._chord_percentage + + @chord_percentage.setter + def chord_percentage(self, chord_percentage): + self._chord_percentage = chord_percentage + + @property + def camber_max(self): + return self._camber_max + + @camber_max.setter + def camber_max(self, camber_max): + self._camber_max = camber_max + + @property + def camber_percentage(self): + return self._camber_percentage + + @camber_percentage.setter + def camber_percentage(self, camber_percentage): + self._camber_percentage = camber_percentage + + @property + def thickness_max(self): + return self._thickness_max + + @thickness_max.setter + def thickness_max(self, thickness_max): + self._thickness_max = thickness_max + + @property + def thickness_percentage(self): + return self._thickness_percentage + + @thickness_percentage.setter + def thickness_percentage(self, thickness_percentage): + self._thickness_percentage = thickness_percentage def _update_edges(self): """ @@ -61,6 +178,8 @@ def _update_edges(self): trailing edge, hence both the leading and the trailing edges are always unique. """ + self.leading_edge = np.zeros(2) + self.trailing_edge = np.zeros(2) if np.fabs(self.xup_coordinates[0] - self.xdown_coordinates[0]) > 1e-4: raise ValueError('Airfoils must have xup_coordinates[0] '\ 'almost equal to xdown_coordinates[0]') @@ -324,56 +443,6 @@ def reference_point(self): ] return np.asarray(reference_point) - @property - def chord_length(self): - """ - Measure the l2-norm (Euclidean distance) between the leading edge - and the trailing edge. - - :return: chord length - :rtype: float - """ - self._update_edges() - return np.linalg.norm(self.leading_edge - self.trailing_edge) - - @property - def chord_percentage(self): - """ - Return the percentage of the chord coordinates with respect to the - chord length of the airfoil. - - :return: chord length percentage - :rtype: float - """ - return (self.xup_coordinates - np.min(self.xup_coordinates))/self.chord_length - - def _camber(self): - """ - Compute the camber line of the airfoil. - """ - return (self.yup_coordinates + self.ydown_coordinates)/2 - - @property - def camber_max(self): - """ - Return the maximum camber of the airfoil. - - :return: maximum camber - :rtype: float - """ - return abs(np.max(self._camber())) - - @property - def camber_percentage(self): - """ - Return the percentage of the camber coordinates with respect to the - maximum camber of the airfoil. - """ - if self.camber_max == 0: - return np.zeros(self.xup_coordinates.shape[0]) - else: - return abs(self._camber())/self.camber_max - def _compute_thickness_american(self): """ Compute the thickness of the airfoil using the American standard @@ -382,14 +451,14 @@ def _compute_thickness_american(self): n_pos = self.xup_coordinates.shape[0] m = np.zeros(n_pos) for i in range(1, n_pos, 1): - m[i] = (self.camber_percentage[i]- - self.camber_percentage[i-1])/(self.chord_percentage[i]- - self.chord_percentage[i-1])*self.camber_max/self.chord_length + m[i] = (self._camber_percentage[i]- + self._camber_percentage[i-1])/(self._chord_percentage[i]- + self._chord_percentage[i-1])*self._camber_max/self._chord_length m_angle = np.arctan(m) # generating temporary profile coordinates orthogonal to the camber # line - camber = self.camber_max*self.camber_percentage + camber = self._camber_max*self._camber_percentage ind_horizontal_camber = (np.sin(m_angle)==0) def eq_to_solve(x): spline_curve = self.ydown_curve(x.reshape(-1,1)).reshape( @@ -397,74 +466,21 @@ def eq_to_solve(x): line_orth_camber = (camber[~ind_horizontal_camber] + np.cos(m_angle[~ind_horizontal_camber])/ np.sin(m_angle[~ind_horizontal_camber])*( - self.chord_percentage[~ind_horizontal_camber]-x)) + self._chord_percentage[~ind_horizontal_camber]*self._chord_length-x)) return spline_curve - line_orth_camber xdown_tmp = self.xdown_coordinates.copy() xdown_tmp[~ind_horizontal_camber] = newton(eq_to_solve, xdown_tmp[~ind_horizontal_camber]) - xup_tmp = (2*self.chord_percentage - xdown_tmp) + xup_tmp = (2*self._chord_percentage*self._chord_length - xdown_tmp) ydown_tmp = self.ydown_curve(xdown_tmp.reshape(-1,1)).reshape( xdown_tmp.shape[0],) - yup_tmp = 2*self.camber_max*self.camber_percentage - ydown_tmp + yup_tmp = 2*self._camber_max*self._camber_percentage - ydown_tmp if xup_tmp[1]