From 511e70c5353733159b25e02f24829d119f5a9f0a Mon Sep 17 00:00:00 2001 From: Eytan Adler <63426601+eytanadler@users.noreply.github.com> Date: Fri, 15 Jul 2022 19:04:02 -0400 Subject: [PATCH] CST airfoil parameterization (#141) * Initial CST push, not close to being ready to run/home/eytan/miniconda3/envs/foil/bin/python * Added tests * Removed plotting from test * Ran black * Parameterized more airfoils in the test * Added more (and fixed) derivative tests * First draft of most of the needed functions in DVGeoCST have been added (except derivative stuff) * Fixed point set so it works with only the points assigned to the current proc * Added separate N1 and N2 values for upper and lower surfaces and associated DVs * Added chord DV * Fixed default DV errors * Ran black * First pass (certainly incorrect so far) at derivatives for CST * Cleaned up a bit and added more (still incorrect) derivatives for use with DVCon...still needs testing and some other TODOs * Supposedly added CST parameter fitting * Initialized CST parameter and chord DVs to the fit values * Added some tests for totalSensitivityProd, still need more for N1 and N2 DVs * Added TODO for better complex handling * Much cleaner handling of compelx numbers for derivatives debugging via isComplex in __init__ * Derivatives tests and some related corrections to DVGeoCST * Fixed bug in trailing edge that messed up derivatives * Ran black * Removed old TODO about derivatives with scaling * Getting there on the fixes, need to figure out how to set the default DVs to the airfoil fit in the pyOpt initialization and fix the bug when there are no DVGeoCST DVs (only alpha) in an opt problem * All good except initializing optimization problem with fitted CST params * Changed the interface yet again to take in a dat file, fixes all existing problems * Added individual xMax, xMin, and thicknessTE to each point set (fixing another bug) * Added dummy comm to total sensitivities * Added allreduce to totalSensitivity function * Added debug mode and plotting * Ran black and removed unused imports * fix prefoil import * Updated prefoil import in DVGeoCST * Updated imports in test for DVGeometryCST * Switched airfoil splitting to use preFoil utilities, added printDesignVariables, added new airfoil for testing * Ran black * Minor version bump to 1.12.0 * Hoping to assuage our flake8 overlords * I failed * Fixed test failures (I hope) * Added prefoil to autodoc_mock_imports * Added ESP and CST to the API docs * Hopefully fixed the math * Added pyOCSM to autodoc_mock_imports * Added DVGeometryVSP to the API docs * Added cstDV * Sorry black and flake8 * Refined docstrings * Added more tests * Fixed some math in docstrings * More tests * Fixed the silver baboon * Credentials no longer required * No more drivatives * Addressed most of Anil's comments * Fixed new flake8 complaint * Moved number of CST coeffs to init from addDV * Added improved trailing edge detection to identify non-vertical trailing edges and sharp and closed cases * Added metric to measure goodness of airfoil CST fit * Undid change to try to fix flake8 * Modified tolTE input so the angle is provided by the user * Moved prints only on root and changed upper/lower bound name in addDV * Perform fit on only one proc and broadcast the result to the rest * Another attempt to fix flake8 * Ran black * Improved debug plot so it gathers data into a single plot on the root * Fixed complex warnings in tests * Ran black Co-authored-by: Alasdair Gray --- README.md | 1 - doc/API.rst | 3 + doc/DVGeometryCST.rst | 8 + doc/DVGeometryESP.rst | 8 + doc/DVGeometryVSP.rst | 8 + doc/conf.py | 2 +- pygeo/__init__.py | 3 +- pygeo/parameterization/DVGeoCST.py | 1229 +++++++++++++++++++++++ pygeo/parameterization/__init__.py | 1 + pygeo/parameterization/designVars.py | 9 + tests/reg_tests/e63.dat | 61 ++ tests/reg_tests/naca0012.dat | 160 +++ tests/reg_tests/naca0012_closed.dat | 161 +++ tests/reg_tests/naca0012_sharp.dat | 153 +++ tests/reg_tests/naca2412.dat | 160 +++ tests/reg_tests/test_DVGeometryCST.py | 746 ++++++++++++++ tests/reg_tests/test_DVGeometryMulti.py | 2 +- 17 files changed, 2711 insertions(+), 4 deletions(-) create mode 100644 doc/DVGeometryCST.rst create mode 100644 doc/DVGeometryESP.rst create mode 100644 doc/DVGeometryVSP.rst create mode 100644 pygeo/parameterization/DVGeoCST.py create mode 100644 tests/reg_tests/e63.dat create mode 100644 tests/reg_tests/naca0012.dat create mode 100644 tests/reg_tests/naca0012_closed.dat create mode 100644 tests/reg_tests/naca0012_sharp.dat create mode 100644 tests/reg_tests/naca2412.dat create mode 100644 tests/reg_tests/test_DVGeometryCST.py diff --git a/README.md b/README.md index 1468e3bc..9a7aa03f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ It provides a free form deformation (FFD) based geometry manipulation object, an ## Documentation Please see the [documentation](https://mdolab-pygeo.readthedocs-hosted.com/en/latest/) for installation details and API documentation. -This link requires credentials currently only available to MDO Lab members. To locally build the documentation, enter the `doc` folder and enter `make html` in terminal. You can then view the built documentation in the `_build` folder. diff --git a/doc/API.rst b/doc/API.rst index ef1b7b2c..0f8983cd 100644 --- a/doc/API.rst +++ b/doc/API.rst @@ -11,6 +11,9 @@ This package consists of the following modules: DVConstraints DVGeometry DVGeometryMulti + DVGeometryESP + DVGeometryVSP + DVGeometryCST pyNetwork pyGeo pyBlock diff --git a/doc/DVGeometryCST.rst b/doc/DVGeometryCST.rst new file mode 100644 index 00000000..7fcf6b58 --- /dev/null +++ b/doc/DVGeometryCST.rst @@ -0,0 +1,8 @@ +.. _DVGeometryCST: + +DVGeometryCST +------------- +.. currentmodule:: pygeo + +.. autoclass:: DVGeometryCST + :members: diff --git a/doc/DVGeometryESP.rst b/doc/DVGeometryESP.rst new file mode 100644 index 00000000..df7304c5 --- /dev/null +++ b/doc/DVGeometryESP.rst @@ -0,0 +1,8 @@ +.. _DVGeometryESP: + +DVGeometryESP +------------- +.. currentmodule:: pygeo + +.. autoclass:: DVGeometryESP + :members: diff --git a/doc/DVGeometryVSP.rst b/doc/DVGeometryVSP.rst new file mode 100644 index 00000000..d2d181d3 --- /dev/null +++ b/doc/DVGeometryVSP.rst @@ -0,0 +1,8 @@ +.. _DVGeometryVSP: + +DVGeometryVSP +------------- +.. currentmodule:: pygeo + +.. autoclass:: DVGeometryVSP + :members: diff --git a/doc/conf.py b/doc/conf.py index 8cb99923..d979487a 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -21,4 +21,4 @@ extensions.extend(["numpydoc"]) # mock import for autodoc -autodoc_mock_imports = ["numpy", "mpi4py", "scipy", "pyspline", "baseclasses", "pysurf"] +autodoc_mock_imports = ["numpy", "mpi4py", "scipy", "pyspline", "baseclasses", "pysurf", "prefoil", "pyOCSM"] diff --git a/pygeo/__init__.py b/pygeo/__init__.py index c2835e22..98260e45 100644 --- a/pygeo/__init__.py +++ b/pygeo/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.11.2" +__version__ = "1.12.0" from .pyNetwork import pyNetwork from .pyGeo import pyGeo @@ -6,6 +6,7 @@ from .constraints import DVConstraints from .parameterization import DVGeometry from .parameterization import DVGeometryAxi +from .parameterization import DVGeometryCST try: from .parameterization import DVGeometryVSP diff --git a/pygeo/parameterization/DVGeoCST.py b/pygeo/parameterization/DVGeoCST.py new file mode 100644 index 00000000..10018496 --- /dev/null +++ b/pygeo/parameterization/DVGeoCST.py @@ -0,0 +1,1229 @@ +""" +============================================================================== +DVGeo: CST Parameterisation +============================================================================== +@Author : Eytan Adler, Alasdair Christison Gray +@Description : A DVGeo implementation based on the Class-Shape Transformation method +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== + +# ============================================================================== +# External Python modules +# ============================================================================== +import numpy as np +from mpi4py import MPI +from scipy.special import factorial +from prefoil.utils import readCoordFile +from prefoil.airfoil import Airfoil + +try: + import matplotlib.pyplot as plt + + pltImport = True +except ModuleNotFoundError: + pltImport = False + + +# ============================================================================== +# Extension modules +# ============================================================================== +from pygeo.parameterization.BaseDVGeo import BaseDVGeometry +from pygeo.parameterization.designVars import cstDV + + +class DVGeometryCST(BaseDVGeometry): + r""" + This class implements a 2D geometry parameterisation based on Brenda Kulfan's CST (Class-Shape Transformation) method. + This class can work with 3D coordinates but will only change the point coordinates in one direction. + + The CST equation is as follows: + + :math:`y(x) = C(x) * S(x) + y_\text{te}x` + + Where C is the class function: + + :math:`C(x) = (x^{N1} + (1 - x)^{N2})` + + And S is the shape function, in this case a summation of Bernstein polynomials: + + :math:`S(x) = \sum_i^n w_i \binom{n}{i}x^i(1-x)^{n-i}` + + Here x is the normalized chordwise coordinate, ranging from 0 to 1 from front to the rear of the shape. + + Assumptions about the point sets being added: + + - Dat file is ordered continuously around the airfoil and the beginning and end of + the list is the trailing edge (no jumping around, but CW vs. CCW does not matter) + - Geometry is exclusively an extruded shape (no spanwise changes allowed) + - Airfoil's leading edge is on the left (min x) and trailing edge is on the right (max x) + - Airfoil is not rotated (trailing edge and leading edge are close to y equals zero) + + Parameters + ---------- + datFile : str + Filename of dat file that represents the initial airfoil. The coordinates in this file will be used to + determine the camber line, which is the dividing line to distinguish upper and lower surface points. + numCST : int or list of two ints + Number of CST parameters to use for the initial fit and the DVs (if DVs with type ``"upper"`` or ``"lower"`` + are added). If ``numCST`` is an int, the value will be used for both upper and lower. If it is a two-item list, + the first value defines the number of upper CST coefficients and the second is the number of lower coefficients, + by default 8. + idxChord : int, optional + Index of the column in the point set to use as the chordwise (x in CST) coordinates, by default 0 + idxVertical : int, optional + Index of the column in the point set to use as the vertical (y in CST) airfoil coordinates, by default 1 + comm : MPI communicator, optional + Communicator for DVGeometryCST instance, by default MPI.COMM_WORLD + isComplex : bool, optional + Initialize variables to complex types where necessary, by default False + debug : bool, optional + Show plots when addPointSet is called to visually verify that it is correctly splitting + the upper and lower surfaces of the airfoil points, by default False + tolTE : float, optional + Tolerance used to detect trailing edge corners on the airfoil. The value represents the angle difference + in degrees between adjacent edges of the airfoil, by default 60 deg. + """ + + def __init__( + self, + datFile, + numCST=8, + idxChord=0, + idxVertical=1, + comm=MPI.COMM_WORLD, + isComplex=False, + debug=False, + tolTE=60.0, + ): + super().__init__(datFile) + self.xIdx = idxChord + self.yIdx = idxVertical + self.comm = comm + self.isComplex = isComplex + if isComplex: + self.dtype = complex + self.dtypeMPI = MPI.DOUBLE_COMPLEX + else: + self.dtype = float + self.dtypeMPI = MPI.DOUBLE + self.debug = debug + if debug and not pltImport: + raise ImportError("matplotlib.pyplot could not be imported and is required for DVGeoCST debug mode") + + # Error check the numCST input + if isinstance(numCST, int): + self.nCSTUpper = numCST + self.nCSTLower = numCST + else: + if isinstance(numCST, list): + if len(numCST) != 2 or not isinstance(numCST[0], int) or not isinstance(numCST[1], int): + raise ValueError(f"numCST input of {numCST} is incorrect; must be int or list of two ints") + else: + self.nCSTUpper = numCST[0] + self.nCSTLower = numCST[1] + else: + raise ValueError(f"numCST input of type {type(numCST)} is incorrect; must be int or list of two ints") + + # Store the DVs and flags to determine if the limited options have already been specified + self.DVs = {} + self.DVExists = { + "upper": False, + "lower": False, + "n1_upper": False, + "n2_upper": False, + "n1_lower": False, + "n2_lower": False, + "chord": False, + } + + # Default DVs to be copied for each point set + self.defaultDV = { + "upper": 0.1 * np.ones(self.nCSTUpper, dtype=self.dtype), + "lower": -0.1 * np.ones(self.nCSTLower, dtype=self.dtype), + "n1_upper": np.array([0.5], dtype=self.dtype), + "n2_upper": np.array([1.0], dtype=self.dtype), + "n1_lower": np.array([0.5], dtype=self.dtype), + "n2_lower": np.array([1.0], dtype=self.dtype), + "n1": np.array([0.5], dtype=self.dtype), + "n2": np.array([1.0], dtype=self.dtype), + "chord": np.array([1.0], dtype=self.dtype), + } + + # ========== Process the input airfoil and set variables accordingly ========== + coords = readCoordFile(datFile) + self.foilCoords = np.zeros_like(coords, dtype=self.dtype) + self.foilCoords[:, self.xIdx] = coords[:, 0] + self.foilCoords[:, self.yIdx] = coords[:, 1] + + # Set the leading and trailing edge x coordinates + self.xMin = np.min(self.foilCoords[:, self.xIdx]) + self.xMax = np.max(self.foilCoords[:, self.xIdx]) + + # Check that the leading edge is at y = 0 + idxLE = np.argmin(self.foilCoords[:, self.xIdx]) + yLE = self.foilCoords[idxLE, self.yIdx] + if abs(yLE) > 1e-2: + raise ValueError(f"Leading edge y (or idxVertical) value must equal zero, not {yLE}") + + # Determine if the dat file is closed (first and last points are the same); remove the duplicate point if so + distance = np.linalg.norm(self.foilCoords[0, :] - self.foilCoords[-1, :]) + distTol = 1e-12 + if distance < distTol: + self.foilCoords = self.foilCoords[:-1, :] + + # Traverse the airfoil surface to find the corner(s) defining the trailing edge (ignore anything in the front + # half, chordwise, of the airfoil) + cosTolTE = np.cos(np.deg2rad(tolTE)) + cornerIdx = [] + for idx in range(self.foilCoords.shape[0]): + pt = self.foilCoords[idx, :] + # Ignore if closer to the leading edge + if pt[self.xIdx] - self.xMin < self.xMax - pt[self.xIdx]: + continue + edgePrev = pt - self.foilCoords[idx - 1, :] + edgePrev /= np.linalg.norm(edgePrev) + edgeNext = self.foilCoords[(idx + 1) % self.foilCoords.shape[0], :] - pt + edgeNext /= np.linalg.norm(edgeNext) + if np.dot(edgePrev, edgeNext) < cosTolTE: + cornerIdx.append(idx) + if len(cornerIdx) > 2: + raise RuntimeError( + "More than two corners in the airfoil identified when looking for the " + + "trailing edge. If the actual airfoil geometry in the dat file has more " + + "than two corners it is not supported. If not, try reducing the tolTE input." + ) + elif len(cornerIdx) == 0: + raise RuntimeError( + "Zero corners in the airfoil identified when looking for the " + + "trailing edge. If the actual airfoil geometry in the dat file has zero corners " + + "(is a circle??) it is not supported. If not, try increasing the tolTE input." + ) + + # Airfoil is sharp if only one corner is detected + self.sharp = len(cornerIdx) == 1 + + # Save the upper and lower trailing edge coordinates if it is not sharp + if self.sharp: + self.thicknessTE = np.array([0.0]) + else: + if self.foilCoords[cornerIdx[0], self.yIdx] > self.foilCoords[cornerIdx[1], self.yIdx]: + self.coordUpperTE = self.foilCoords[cornerIdx[0]] + self.coordLowerTE = self.foilCoords[cornerIdx[1]] + else: + self.coordUpperTE = self.foilCoords[cornerIdx[1]] + self.coordLowerTE = self.foilCoords[cornerIdx[0]] + self.thicknessTE = self.coordUpperTE[self.yIdx] - self.coordLowerTE[self.yIdx] + + # Compute splines for the upper and lower surfaces (used to split the foil in addPointSet). + # preFoil defines the leading edge as the point furthest from the trailing edge + self.foil = Airfoil(coords) + self.upperSpline, self.lowerSpline = self.foil.splitAirfoil() + + # Fit CST parameters to the airfoil's upper and lower surface + self.idxFoil = {} + self.idxFoil["upper"], self.idxFoil["lower"] = self._splitUpperLower(self.foilCoords) + chord = self.xMax - self.xMin + self.defaultDV["chord"][0] = chord + if self.comm.rank == 0: + print(f"######## Fitting CST coefficients to coordinates in {datFile} ########") + for dvType in ["upper", "lower"]: + if self.comm.rank == 0: + self.defaultDV[dvType] = self.computeCSTfromCoords( + self.foilCoords[self.idxFoil[dvType], self.xIdx], + self.foilCoords[self.idxFoil[dvType], self.yIdx], + self.defaultDV[dvType].size, + N1=self.defaultDV[f"n1_{dvType}"], + N2=self.defaultDV[f"n2_{dvType}"], + dtype=self.dtype, + ) + + # Compute the quality of the fit by computing an L2 norm of the fit vs. the actual coordinates + xPts = self.foilCoords[self.idxFoil[dvType], self.xIdx] + yTE = self.thicknessTE / 2 if dvType == "upper" else -self.thicknessTE / 2 + ptsFit = chord * self.computeCSTCoordinates( + xPts / chord, + self.defaultDV["n1_lower"], + self.defaultDV["n2_lower"], + self.defaultDV[dvType], + yTE, + dtype=self.dtype, + ) + L2norm = np.sqrt( + 1 / ptsFit.size * np.sum((self.foilCoords[self.idxFoil[dvType], self.yIdx] - ptsFit) ** 2) + ) + + print(f"{dvType.capitalize()} surface") + print(f" L2 norm of coordinates in dat file versus fit coordinates: {L2norm}") + print(f" Fit CST coefficients: {self.defaultDV[dvType]}") + + # Broadcast the fit DV to the rest of the procs + self.comm.Bcast([self.defaultDV[dvType], self.dtypeMPI]) + + def addPointSet(self, points, ptName, boundTol=1e-10, **kwargs): + """ + Add a set of coordinates to DVGeometry + The is the main way that geometry in the form of a coordinate list is given to DVGeometry + to be manipulated. + + .. note:: + Even if ``isComplex=True``, the imaginary portion of coordinates passed in here + is ignored when determining if a given point is on the upper or lower surface. + + Parameters + ---------- + points : array, size (N,3) + The coordinates to embed. + ptName : str + A user supplied name to associate with the set of coordinates. + This name will need to be provided when updating the coordinates or when + getting the derivatives of the coordinates. + boundTol : float, optional + Small absolute deviation by which the airfoil coordinates can exceed the initial + minimum and maximum x coordinates, by default 1e-10. + kwargs + Any other parameters are ignored. + """ + # Convert points to the type specified at initialization (with isComplex) and store the points + points = points.astype(self.dtype) + + # Check that all points are within the airfoil x bounds + if np.any(points[:, self.xIdx] < self.xMin - boundTol) or np.any(points[:, self.xIdx] > self.xMax + boundTol): + raise ValueError( + f'Points in the point set "{ptName}" have x coordinates outside' + + f"the min and max x values in the initial dat file ({self.xMin} and {self.xMax})" + ) + self.updated[ptName] = False + self.points[ptName] = { + "points": points, + "xMax": self.xMax.copy(), + "xMin": self.xMin.copy(), + "thicknessTE": self.thicknessTE.copy(), + } + + # Determine which points are on the upper and lower surfaces + self.points[ptName]["upper"], self.points[ptName]["lower"] = self._splitUpperLower(points) + + # If debug mode is on, plot the upper and lower surface points + if self.debug: + # Gather all the plotting data on the root proc + dataGlob = {} + for name in ["points", "upper", "lower"]: + # First, determine the sizes and displacements of the arrays from each proc for gather + vecFlatLoc = self.points[ptName][name].flatten() + if name in ["upper", "lower"]: + vecFlatLoc = vecFlatLoc.astype("intc") + numLoc = vecFlatLoc.size + sizes = np.array(self.comm.allgather(numLoc), dtype="intc") + disp = np.array([np.sum(sizes[:i]) for i in range(self.comm.size)], dtype="intc") + if name == "points": + dispPoints = disp.copy() + numGlob = np.sum(sizes) + + # Send coordinates to root proc + dtype = "intc" + dtypeMPI = MPI.INT + if name == "points": + dtype = self.dtype + dtypeMPI = self.dtypeMPI + dataGlob[name] = np.zeros(numGlob, dtype=dtype) # recv buffer + + # Shift the data by the displacement if it is local index data + if name in ["upper", "lower"]: + vecFlatLoc += dispPoints[self.comm.rank] // 3 + + # Finally, collect the data on the root proc + self.comm.Gatherv([vecFlatLoc, numLoc], [dataGlob[name], sizes, disp, dtypeMPI]) + + if self.comm.rank == 0: + # Reshape the flatted coordinates + coords = dataGlob["points"].reshape((dataGlob["points"].size // 3, 3)) + + fig = plt.figure() + plt.scatter( + coords[:, self.xIdx][dataGlob["upper"]], + coords[:, self.yIdx][dataGlob["upper"]], + c="b", + ) + plt.scatter( + coords[:, self.xIdx][dataGlob["lower"]], + coords[:, self.yIdx][dataGlob["lower"]], + c="r", + ) + plt.scatter( + coords[:, self.xIdx], + coords[:, self.yIdx], + s=3, + c="k", + zorder=3, + ) + plt.legend(["Upper", "Lower"]) + plt.show() + plt.close(fig) + + self.comm.Barrier() + + def addDV(self, dvName, dvType, lowerBound=None, upperBound=None, scale=1.0, default=None): + """ + Add design variables to the DVGeometryCST object. For upper and lower CST coefficient DVs, + the number of design variables is defined using the ``numCST`` parameter in DVGeoCST's + init function. + + Parameters + ---------- + dvName : str + A unique name to be given to this design variable group + dvType : str + Define the type of CST design variable being added. Either the upper/lower surface class shape + parameter DV can be defined (e.g., ``"N1_upper"``), or the DV for both the upper and lower surfaces' class shape + parameter can be defined (e.g., ``"N1"``), but not both. The options (not case sensitive) are + - ``"upper"``: upper surface CST coefficients (specify ``dvNum`` to define how many) + - ``"lower"``: lower surface CST coefficients (specify ``dvNum`` to define how many) + - ``"N1"``: first class shape parameter for both upper and lower surfaces (adds a single DV) + - ``"N2"``: second class shape parameter for both upper and lower surfaces (adds a single DV) + - ``"N1_upper"``: first class shape parameters for upper surface (adds a single DV) + - ``"N1_lower"``: first class shape parameters for lower surface (adds a single DV) + - ``"N2_upper"``: second class shape parameters for upper surface (adds a single DV) + - ``"N2_lower"``: second class shape parameters for lower surface (adds a single DV) + - ``"chord"``: chord length in whatever units the point set length is defined and scaled + to keep the leading edge at the same position (adds a single DV) + lowerBound : float or ndarray, optional + The lower bound for the variable(s). This will be applied to + all shape variables + upperBound : float or ndarray, optional + The upper bound for the variable(s). This will be applied to + all shape variables + scale : float, optional + The scaling of the variables. A good approximate scale to + start with is approximately 1.0/(upper-lower). This gives + variables that are of order ~1.0. + default : ndarray, optional + Default value for design variable (must be same length as number of DVs added). + + Returns + ------- + N : int + The number of design variables added. + """ + # Do some error checking + if dvType.lower() not in [ + "upper", + "lower", + "n1", + "n2", + "n1_upper", + "n1_lower", + "n2_upper", + "n2_lower", + "chord", + ]: + raise ValueError( + 'dvType must be one of "upper", "lower", "N1", "N2", "N1_upper", "N1_lower", ' + + f'"N2_upper", "N2_lower", or "chord", not {dvType}' + ) + dvType = dvType.lower() + + if dvType == "upper": + dvNum = self.nCSTUpper + elif dvType == "lower": + dvNum = self.nCSTLower + else: + dvNum = 1 + + # Check that a duplicate DV doesn't already exist + if dvType in ["n1", "n2", "n1_upper", "n1_lower", "n2_upper", "n2_lower"]: + if dvType in ["n1", "n2"]: # if either of these is added, the individual lower and upper params can't be + if self.DVExists[dvType + "_lower"]: + raise ValueError(f'"{dvType}" cannot be added when "{dvType}_lower" already exists') + elif self.DVExists[dvType + "_upper"]: + raise ValueError(f'"{dvType}" cannot be added when "{dvType}_upper" already exists') + else: + self.DVExists[dvType + "_lower"] = True + self.DVExists[dvType + "_upper"] = True + else: # the parameter that controls both the upper and lower surfaces simultaneously can't be added + param = dvType.split("_")[0] # either N1 or N2 + if self.DVExists[dvType]: + raise ValueError(f'"{dvType}" cannot be added when "{param}" or "{dvType}" already exist') + else: + self.DVExists[dvType] = True + else: + if self.DVExists[dvType]: + raise ValueError(f'"{dvType}" design variable already exists') + else: + self.DVExists[dvType] = True + + if dvName in self.DVs.keys(): + raise ValueError(f'A design variable with the name "{dvName}" already exists') + + # Set the default value + if default is None: + default = self.defaultDV[dvType] + else: + if not isinstance(default, np.ndarray): + raise ValueError(f"The default value for the {dvName} DV must be a NumPy array, not a {type(default)}") + default = default.flatten() + if default.size != dvNum: + raise ValueError( + f"The default value for the {dvName} DV must have a length of {dvNum}, not {default.size}" + ) + + # Set new default + self.defaultDV[dvType] = default.astype(self.dtype) + if dvType in ["n1", "n2"]: + self.defaultDV[f"{dvType}_lower"] = default.astype(self.dtype) + self.defaultDV[f"{dvType}_upper"] = default.astype(self.dtype) + + # Add the DV to the internally-stored list + self.DVs[dvName] = cstDV( + name=dvName, + value=default.astype(self.dtype), + nVal=dvNum, + lower=lowerBound, + upper=upperBound, + scale=scale, + dvType=dvType, + ) + + return dvNum + + def setDesignVars(self, dvDict): + """ + Standard routine for setting design variables from a design variable dictionary. + + Parameters + ---------- + dvDict : dict + Dictionary of design variables. The keys of the dictionary must correspond to the design variable names. + Any additional keys in the dictionary are simply ignored. + """ + for dvName, dvVal in dvDict.items(): + if dvName in self.DVs: + if dvVal.shape != self.DVs[dvName].value.shape: + raise ValueError( + f'Input shape of {dvVal.shape} for the DV named "{dvName}" does ' + + f"not match the DV's shape of {self.DVs[dvName].value.shape}" + ) + self.DVs[dvName].value = dvVal.astype(self.dtype) + + # Flag all the pointSets as not being up to date + for pointSet in self.updated: + self.updated[pointSet] = False + + def getValues(self): + """ + Generic routine to return the current set of design variables. + Values are returned in a dictionary format that would be suitable for a subsequent call to setValues() + + Returns + ------- + dvDict : dict + Dictionary of design variables + """ + # Format the dictonary into the desired shape + DVs = {} + for dvName in self.DVs.keys(): + DVs[dvName] = self.DVs[dvName].value + + return DVs + + def getVarNames(self, **kwargs): + """ + Return a list of the design variable names. This is typically used when specifying a wrt= argument for pyOptSparse. + + Examples + -------- + optProb.addCon(.....wrt=DVGeo.getVarNames()) + """ + return list(self.DVs.keys()) + + def totalSensitivity(self, dIdpt, ptSetName, comm=None, **kwargs): + r""" + This function computes sensitivity information. + Specifically, it computes the following: + :math:`\frac{dX_{pt}}{dX_{DV}}^T \frac{dI}{d_{pt}}` + + Parameters + ---------- + dIdpt : array of size (Npt, 3) or (N, Npt, 3) + This is the total derivative of the objective or function of interest with respect to the coordinates in 'ptSetName'. + This can be a single array of size (Npt, 3) **or** a group of N vectors of size (N, Npt, 3). + If you have many to do, it is faster to do many at once. + ptSetName : str + The name of set of points we are dealing with + kwargs + Any other parameters ignored, but this is maintained to allow the same + interface as other DVGeo implementations. + + Returns + ------- + dIdxDict : dict + The dictionary containing the derivatives, suitable for pyOptSparse + """ + # Unpack some useful variables + desVars = self._unpackDVs() + ptsX = self.points[ptSetName]["points"][:, self.xIdx] + xMax = self.points[ptSetName]["xMax"] + xMin = self.points[ptSetName]["xMin"] + scaledX = (ptsX - xMin) / (xMax - xMin) + idxUpper = self.points[ptSetName]["upper"] + idxLower = self.points[ptSetName]["lower"] + funcSens_local = {} + + # If dIdpt is a group of vectors, reorder the axes so it + # is handled properly by the matrix multiplies + dim = dIdpt.shape + if len(dim) == 3: + dIdpt = np.moveaxis(dIdpt, 0, -1) + + for dvName, DV in self.DVs.items(): + dvType = DV.type + + if dvType == "upper": + dydUpperCST = self.computeCSTdydw( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + dydUpperCST *= desVars["chord"] + funcSens_local[dvName] = dydUpperCST @ dIdpt[idxUpper, self.yIdx] + elif dvType == "lower": + dydLowerCST = self.computeCSTdydw( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + dydLowerCST *= desVars["chord"] + funcSens_local[dvName] = dydLowerCST @ dIdpt[idxLower, self.yIdx] + elif dvType == "n1_upper": + funcSens_local[dvName] = ( + desVars["chord"] + * self.computeCSTdydN1( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + @ dIdpt[idxUpper, self.yIdx] + ) + elif dvType == "n2_upper": + funcSens_local[dvName] = ( + desVars["chord"] + * self.computeCSTdydN2( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + @ dIdpt[idxUpper, self.yIdx] + ) + elif dvType == "n1_lower": + funcSens_local[dvName] = ( + desVars["chord"] + * self.computeCSTdydN1( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + @ dIdpt[idxLower, self.yIdx] + ) + elif dvType == "n2_lower": + funcSens_local[dvName] = ( + desVars["chord"] + * self.computeCSTdydN2( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + @ dIdpt[idxLower, self.yIdx] + ) + elif dvType == "n1": + funcSens_local[dvName] = ( + desVars["chord"] + * self.computeCSTdydN1( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + @ dIdpt[idxUpper, self.yIdx] + ) + funcSens_local[dvName] += ( + desVars["chord"] + * self.computeCSTdydN1( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + @ dIdpt[idxLower, self.yIdx] + ) + elif dvType == "n2": + funcSens_local[dvName] = ( + desVars["chord"] + * self.computeCSTdydN2( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + @ dIdpt[idxUpper, self.yIdx] + ) + funcSens_local[dvName] += ( + desVars["chord"] + * self.computeCSTdydN2( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + @ dIdpt[idxLower, self.yIdx] + ) + else: # chord + dydchord = self.points[ptSetName]["points"][:, self.yIdx] / desVars["chord"] + dxdchord = (ptsX - xMin) / desVars["chord"] + funcSens_local[dvName] = dxdchord @ dIdpt[:, self.xIdx] + dydchord @ dIdpt[:, self.yIdx] + + # If the axes were reordered to handle a group of dIdpt vectors, + # switch them back to the expected order for output + if len(dim) == 3: + for dvName in funcSens_local.keys(): + funcSens_local[dvName] = np.moveaxis(np.atleast_2d(funcSens_local[dvName]), 0, -1) + + if comm: + funcSens = {} + for dvName in funcSens_local.keys(): + funcSens[dvName] = comm.allreduce(funcSens_local[dvName], op=MPI.SUM) + else: + funcSens = funcSens_local + + return funcSens + + def totalSensitivityProd(self, vec, ptSetName, **kwargs): + r""" + This function computes sensitivity information. + Specifically, it computes the following: + :math:`\frac{dX_{pt}}{dX_{DV}} \times\mathrm{vec}`. + This is useful for forward AD mode. + + Parameters + ---------- + vec : dictionary whose keys are the design variable names, and whose + values are the derivative seeds of the corresponding design variable. + ptSetName : str + The name of set of points we are dealing with + kwargs + Any other parameters ignored, but this is maintained to allow the same + interface as other DVGeo implementations. + + Returns + ------- + xsdot : array (Nx3) + Array with derivative seeds of the surface nodes. + """ + # Unpack some useful variables + desVars = self._unpackDVs() + ptsX = self.points[ptSetName]["points"][:, self.xIdx] + xMax = self.points[ptSetName]["xMax"] + xMin = self.points[ptSetName]["xMin"] + scaledX = (ptsX - xMin) / (xMax - xMin) + idxUpper = self.points[ptSetName]["upper"] + idxLower = self.points[ptSetName]["lower"] + idxTE = np.full((self.points[ptSetName]["points"].shape[0],), True, dtype=bool) + idxTE[idxUpper] = False + idxTE[idxLower] = False + xsdot = np.zeros_like(self.points[ptSetName]["points"], dtype=self.dtype) + + for dvName, dvSeed in vec.items(): + dvType = self.DVs[dvName].type + + if dvType == "upper": + dydUpperCST = self.computeCSTdydw( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + dydUpperCST *= desVars["chord"] + xsdot[idxUpper, self.yIdx] += dydUpperCST.T @ dvSeed + if dvType == "lower": + dydLowerCST = self.computeCSTdydw( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + dydLowerCST *= desVars["chord"] + xsdot[idxLower, self.yIdx] += dydLowerCST.T @ dvSeed + if dvType == "n1_upper" or dvType == "n1": + xsdot[idxUpper, self.yIdx] += ( + dvSeed + * desVars["chord"] + * self.computeCSTdydN1( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + ) + if dvType == "n2_upper" or dvType == "n2": + xsdot[idxUpper, self.yIdx] += ( + dvSeed + * desVars["chord"] + * self.computeCSTdydN2( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], dtype=self.dtype + ) + ) + if dvType == "n1_lower" or dvType == "n1": + xsdot[idxLower, self.yIdx] += ( + dvSeed + * desVars["chord"] + * self.computeCSTdydN1( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + ) + if dvType == "n2_lower" or dvType == "n2": + xsdot[idxLower, self.yIdx] += ( + dvSeed + * desVars["chord"] + * self.computeCSTdydN2( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], dtype=self.dtype + ) + ) + if dvType == "chord": + dydchord = self.points[ptSetName]["points"][:, self.yIdx] / desVars["chord"] + dxdchord = (ptsX - xMin) / desVars["chord"] + xsdot[:, self.yIdx] += dvSeed * dydchord + xsdot[:, self.xIdx] += dvSeed * dxdchord + + return xsdot + + def addVariablesPyOpt(self, optProb): + """ + Add the current set of variables to the optProb object. + + Parameters + ---------- + optProb : pyOpt_optimization class + Optimization problem definition to which variables are added + """ + # Add design variables to the problem + for DV in self.DVs.values(): + optProb.addVarGroup( + DV.name, + DV.nVal, + "c", + value=DV.value, + lower=DV.lower, + upper=DV.upper, + scale=DV.scale, + ) + + def update(self, ptSetName, **kwargs): + """ + This is the main routine for returning coordinates that have + been updated by design variables. + + Parameters + ---------- + ptSetName : str + Name of point-set to return. This must match ones of the + given in an :func:`addPointSet()` call. + kwargs + Any other parameters ignored, but this is maintained to allow the same + interface as other DVGeo implementations. + + Returns + ------- + points : ndarray (N x 3) + Updated point set coordinates. + """ + desVars = self._unpackDVs() + + # Unpack the points to make variable names more accessible + idxUpper = self.points[ptSetName]["upper"] + idxLower = self.points[ptSetName]["lower"] + idxTE = np.full((self.points[ptSetName]["points"].shape[0],), True, dtype=bool) + idxTE[idxUpper] = False + idxTE[idxLower] = False + points = self.points[ptSetName]["points"] + ptsX = points[:, self.xIdx] + ptsY = points[:, self.yIdx] + xMax = self.points[ptSetName]["xMax"] + xMin = self.points[ptSetName]["xMin"] + thicknessTE = self.points[ptSetName]["thicknessTE"] + + # Scale the airfoil to the range 0 to 1 in x direction + shift = xMin + chord = xMax - xMin + scaledX = (ptsX - shift) / chord + yTE = thicknessTE / chord / 2 # half the scaled trailing edge thickness + + ptsY[idxUpper] = desVars["chord"] * self.computeCSTCoordinates( + scaledX[idxUpper], desVars["n1_upper"], desVars["n2_upper"], desVars["upper"], yTE, dtype=self.dtype + ) + ptsY[idxLower] = desVars["chord"] * self.computeCSTCoordinates( + scaledX[idxLower], desVars["n1_lower"], desVars["n2_lower"], desVars["lower"], -yTE, dtype=self.dtype + ) + ptsY[idxTE] *= desVars["chord"] / chord + + # Scale the chord according to the chord DV + points[:, self.xIdx] = (points[:, self.xIdx] - shift) * desVars["chord"] / chord + shift + + # Scale the point set's properties based on the new chord length + self.points[ptSetName]["xMax"] = (xMax - shift) * desVars["chord"] / chord + shift + self.points[ptSetName]["thicknessTE"] *= desVars["chord"] / chord + + self.updated[ptSetName] = True + + return points.copy() + + def getNDV(self): + """ + Return the total number of design variables this object has. + + Returns + ------- + nDV : int + Total number of design variables + """ + nDV = 0 + for DV in self.DVs.values(): + nDV += DV.nVal + return nDV + + def printDesignVariables(self): + """ + Print a formatted list of design variables to the screen + """ + if self.comm.rank == 0: + print("\nDVGeometryCST design variables") + print("==============================") + for DV in self.DVs.values(): + print(f"{DV.name} ({DV.type} type): {DV.value}") + print("") + + def _unpackDVs(self): + """ + Return the parameters needed for the airfoil shape calculation + based on the DVs and default values. This requires a few extra + checks to handle the multiple ways of parameterizing the class + shape variables. + + Returns + ------- + desVars : dict + Dictionary containing the following airfoil shape parameters: + `"upper"`: upper surface CST coefficients + `"lower"`: lower surface CST coefficients + `"n1_lower"`: first class shape parameter on lower surface + `"n2_lower"`: second class shape parameter on lower surface + `"n1_upper"`: first class shape parameter on upper surface + `"n2_upper"`: second class shape parameter on upper surface + `"chord"`: chord length + """ + desVars = {} + desVars["upper"] = self.defaultDV["upper"].copy() + desVars["lower"] = self.defaultDV["lower"].copy() + desVars["n1_upper"] = self.defaultDV["n1_upper"].copy() + desVars["n2_upper"] = self.defaultDV["n2_upper"].copy() + desVars["n1_lower"] = self.defaultDV["n1_lower"].copy() + desVars["n2_lower"] = self.defaultDV["n2_lower"].copy() + desVars["chord"] = self.defaultDV["chord"].copy() + + for DV in self.DVs.values(): + if DV.type in ["n1", "n2"]: + desVars[f"{DV.type}_upper"] = DV.value + desVars[f"{DV.type}_lower"] = DV.value + else: + desVars[DV.type] = DV.value + + return desVars + + def _splitUpperLower(self, points): + """ + Figure out the indices of points on the upper and lower + surfaces of the airfoil. This requires that the attributes + self.xMax, self.lowerSpline, self.upperSpline, self.xIdx, + and self.yIdx have already been set. + + Parameters + ---------- + points : ndarray (Npts x 3) + Point array to separate upper and lower surfaces + + Returns + ------- + ndarray (1D) + Indices of upper surface points (correspond to rows in points) + ndarray (1D) + Indices of lower surface points (correspond to rows in points) + """ + # Determine which surface (either upper, lower, or trailing edge) each point is + # on based on which spline it is closest to + # Upper (if it's complex, ignore the imaginary part since the spline doesn't handle that) + _, upperDist = self.upperSpline.projectPoint(np.real(points[:, [self.xIdx, self.yIdx]])) + upperDist = np.linalg.norm(upperDist, axis=1) + # Lower + _, lowerDist = self.lowerSpline.projectPoint(np.real(points[:, [self.xIdx, self.yIdx]])) + lowerDist = np.linalg.norm(lowerDist, axis=1) + # Trailing edge + teDist = np.full_like(upperDist, np.inf) + if not self.sharp: + x0 = points[:, self.xIdx] + y0 = points[:, self.yIdx] + x1 = self.coordLowerTE[self.xIdx] + y1 = self.coordLowerTE[self.yIdx] + x2 = self.coordUpperTE[self.xIdx] + y2 = self.coordUpperTE[self.yIdx] + teDist = np.abs((x2 - x1) * (y1 - y0) - (x1 - x0) * (y2 - y1)) / np.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2) + + # Determine if each point is on the upper surface if it's closer to the upper spline than + # either the lower spline or the trailing edge line (and do the same for the lower surface) + upperBool = np.logical_and(upperDist <= lowerDist, upperDist <= teDist) + lowerBool = np.logical_and(lowerDist < upperDist, lowerDist <= teDist) + + return np.where(upperBool)[0], np.where(lowerBool)[0] + + @staticmethod + def computeCSTCoordinates(x, N1, N2, w, yte, dtype=float): + """ + Compute the vertical coordinates of a CST curve. + + This function assumes x has been normalized to the range [0,1]. + + Parameters + ---------- + x : ndarray (# pts,) + x coordinates at which to compute the CST curve height + N1 : float + First class shape parameter + N2 : float + Second class shape parameter + w : ndarray (# coeff,) + CST coefficient array + yte : float + y coordinate of the trailing edge (used to define trailing edge thickness). + Note that the trailing edge will be twice this thick, assuming the same ``yte`` + value is used for both the upper and lower surfaces. + dtype : type, optional + Type for instantiated arrays, by default float + + Returns + ------- + ndarray (# pts,) + y coordinates of the CST curve + """ + C = DVGeometryCST.computeClassShape(x, N1, N2, dtype=dtype) + S = DVGeometryCST.computeShapeFunctions(x, w, dtype=dtype) + return C * S.sum(axis=0) + yte * x + + @staticmethod + def computeClassShape(x, N1, N2, dtype=float): + """ + Compute the class shape of a CST curve + + Parameters + ---------- + x : ndarray (# pts,) + x coordinates at which to compute the CST curve height + N1 : float + First class shape parameter + N2 : float + Second class shape parameter + dtype : type, optional + Type for instantiated arrays, by default float + + Returns + ------- + ndarray (# pts,) + y coordinates of the class shape + """ + C = np.zeros_like(x, dtype=dtype) + + # 0 to the power of a complex number is undefined, so anywhere + # x is 0 or 1, just keep C as zero (doesn't change the result for real) + mask = np.logical_and(x != 0.0, x != 1.0) + C[mask] = x[mask] ** N1 * (1.0 - x[mask]) ** N2 + + return C + + @staticmethod + def computeShapeFunctions(x, w, dtype=float): + """Compute the Bernstein polynomial shape function of a CST curve + + This function assumes x has been normalized to the range [0,1]. + + Parameters + ---------- + x : ndarray (# pts,) + x coordinates at which to compute the CST curve height + w : ndarray (# coeff,) + CST coefficient array + dtype : type, optional + Type for instantiated arrays, by default float + + Returns + ------- + ndarray (# coeff, # pts) + Bernstein polynomials for each CST coefficient + """ + numCoeffs = len(w) + order = numCoeffs - 1 + S = np.zeros((numCoeffs, len(x)), dtype=dtype) + facts = factorial(np.arange(0, order + 1)) + for i in range(numCoeffs): + binom = facts[-1] / (facts[i] * facts[order - i]) + S[i] = w[i] * binom * x ** (i) * (1.0 - x) ** (order - i) + return S + + @staticmethod + def computeCSTdydw(x, N1, N2, w, dtype=float): + r"""Compute the derivatives of the height of a CST curve with respect to the shape function coefficients + + Given :math:`y = C(x) * sum [w_i * p_i(x)]` + + :math:`\frac{dy}{dw_i} = C(x) * p_i(x)` + + This function assumes x has been normalized to the range [0,1]. + + Only the shape of w is used, not the values. + + Parameters + ---------- + x : ndarray (# pts,) + x coordinates at which to compute the CST curve height + N1 : float + First class shape parameter + N2 : float + Second class shape parameter + w : ndarray (# coeff,) + CST coefficient array + dtype : type, optional + Type for instantiated arrays, by default float + + Returns + ------- + ndarray (# coeff, # pts) + Derivatives of the y coordinates with respect to the CST coefficients + """ + C = DVGeometryCST.computeClassShape(x, N1, N2, dtype=dtype) + S = DVGeometryCST.computeShapeFunctions(x, np.ones_like(w), dtype=dtype) + return C * S + + @staticmethod + def computeCSTdydN1(x, N1, N2, w, dtype=float): + r"""Compute the derivatives of the height of a CST curve with respect to N1 + + Given :math:`y = C(x, N1, N2) * S(x)` + + :math:`\frac{dy}{dN1} = S(x) * \frac{dC}{dN1} = S(x) * C(x, N1, N2) * \ln{x}` + + This function assumes x has been normalised to the range [0,1]. + + Parameters + ---------- + x : ndarray (# pts,) + x coordinates at which to compute the CST curve height + N1 : float + First class shape parameter + N2 : float + Second class shape parameter + w : ndarray (# coeff,) + CST coefficient array + dtype : type, optional + Type for instantiated arrays, by default float + + Returns + ------- + ndarray (# pts,) + Derivative of the y coordinates with respect to the first class shape parameter + """ + C = DVGeometryCST.computeClassShape(x[x != 0.0], N1, N2, dtype=dtype) + S = DVGeometryCST.computeShapeFunctions(x[x != 0.0], w, dtype=dtype) + dydN1 = np.zeros_like(x, dtype=dtype) + dydN1[x != 0.0] = np.sum(S, axis=0) * C * np.log(x[x != 0.0]) + return dydN1 + + @staticmethod + def computeCSTdydN2(x, N1, N2, w, dtype=float): + r"""Compute the derivatives of the height of a CST curve with respect to N2 + + Given :math:`y = C(x, N1, N2) * S(x)` + + :math:`\frac{dy}{dN2} = S(x) * \frac{dC}{dN2} = S(x) * C(x, N1, N2) * \ln(1-x)` + + This function assumes x has been normalised to the range [0,1]. + + Parameters + ---------- + x : ndarray (# pts,) + x coordinates at which to compute the CST curve height + N1 : float + First class shape parameter + N2 : float + Second class shape parameter + w : ndarray (# coeff,) + CST coefficient array + dtype : type, optional + Type for instantiated arrays, by default float + + Returns + ------- + ndarray (# pts,) + Derivative of the y coordinates with respect to the second class shape parameter + """ + C = DVGeometryCST.computeClassShape(x[x != 1.0], N1, N2, dtype=dtype) + S = DVGeometryCST.computeShapeFunctions(x[x != 1.0], w, dtype=dtype) + dydN2 = np.zeros_like(x, dtype=dtype) + dydN2[x != 1.0] = np.sum(S, axis=0) * C * np.log(1 - x[x != 1.0]) + return dydN2 + + @staticmethod + def computeCSTfromCoords(xCoord, yCoord, nCST, N1=0.5, N2=1.0, dtype=float): + """ + Compute the CST coefficients that fit a set of airfoil + coordinates (either for the upper or lower surface, not both). + + This function internally normalizes the x and y-coordinates. + + Parameters + ---------- + xCoord : ndarray + Upper or lower surface airfoil x-coordinates (same length + as yCoord vector). + yCoord : ndarray + Upper or lower surface airfoil y-coordinates (same length + as xCoord vector). + nCST : int + Number of CST coefficients to fit. + N1 : float, optional + First class shape parameter to assume in fitting, by default 0.5 + N2 : float, optional + Second class shape parameter to assume in fitting, by default 1.0 + dtype : type, optional + Type for instantiated arrays, by default float + + Returns + ------- + np.ndarray (nCST,) + CST coefficients fit to the airfoil surface. + """ + # Normalize x and y + chord = np.max(xCoord) - np.min(xCoord) + xCoord = (xCoord - np.min(xCoord)) / chord + yCoord /= chord + + # Compute the coefficients via linear least squares + dydw = DVGeometryCST.computeCSTdydw(xCoord, N1, N2, np.ones(nCST), dtype=dtype) + w = np.linalg.lstsq(dydw.T, yCoord, rcond=None)[0] + return w + + @staticmethod + def plotCST(upperCoeff, lowerCoeff, N1=0.5, N2=1.0, nPts=100, ax=None, **kwargs): + """Simple utility to generate a plot from CST coefficients. + + Parameters + ---------- + upperCoeff : ndarray + One dimensional array of CST coefficients for the upper surface. + lowerCoeff : ndarray + One dimensional array of CST coefficients for the lower surface. + N1 : float + First class shape parameter. + N2 : float + Second class shape parameter. + nPts : int, optional + Number of coordinates to compute on each surface. + ax : matplotlib Axes, optional + Axes on which to plot airfoil. + **kwargs + Keyword arguments passed to matplotlib.pyplot.plot + + Returns + ------- + matplotlib Axes + Axes with airfoil plotted + """ + if not pltImport: + raise ImportError("matplotlib could not be imported and is required for plotCST") + + if ax is None: + _ = plt.figure() + ax = plt.gca() + + x = np.linspace(0, 1, nPts) + yUpper = DVGeometryCST.computeCSTCoordinates(x, N1, N2, upperCoeff, 0.0) + yLower = DVGeometryCST.computeCSTCoordinates(x, N1, N2, lowerCoeff, 0.0) + + ax.plot(x, yUpper, **kwargs) + ax.plot(x, yLower, **kwargs) + ax.set_aspect("equal") + + return ax diff --git a/pygeo/parameterization/__init__.py b/pygeo/parameterization/__init__.py index e2cb1de3..5b2f6d45 100644 --- a/pygeo/parameterization/__init__.py +++ b/pygeo/parameterization/__init__.py @@ -1,5 +1,6 @@ from .DVGeo import DVGeometry from .DVGeoAxi import DVGeometryAxi +from .DVGeoCST import DVGeometryCST try: from .DVGeoVSP import DVGeometryVSP diff --git a/pygeo/parameterization/designVars.py b/pygeo/parameterization/designVars.py index 6cd607a7..63d880bd 100644 --- a/pygeo/parameterization/designVars.py +++ b/pygeo/parameterization/designVars.py @@ -325,3 +325,12 @@ def __init__(self, parmID, component, group, parm, value, lower, upper, scale, d self.group = group self.parm = parm self.dh = dh + + +class cstDV(geoDV): + def __init__(self, name, value, nVal, lower, upper, scale, dvType): + """ + Internal class for storing CST design variable information + """ + super().__init__(name=name, value=value, nVal=nVal, lower=lower, upper=upper, scale=scale) + self.type = dvType diff --git a/tests/reg_tests/e63.dat b/tests/reg_tests/e63.dat new file mode 100644 index 00000000..cd3940ed --- /dev/null +++ b/tests/reg_tests/e63.dat @@ -0,0 +1,61 @@ + 1.000000 0.000100 + 0.997190 0.001210 + 0.989380 0.004730 + 0.977510 0.009860 + 0.961730 0.015530 + 0.941640 0.021260 + 0.917170 0.027090 + 0.888610 0.033010 + 0.856240 0.038850 + 0.820390 0.044510 + 0.781410 0.049850 + 0.739680 0.054800 + 0.695620 0.059210 + 0.649670 0.063040 + 0.602290 0.066170 + 0.553940 0.068570 + 0.505090 0.070160 + 0.456240 0.070940 + 0.407860 0.070840 + 0.360430 0.069900 + 0.314410 0.068090 + 0.270260 0.065450 + 0.228400 0.061980 + 0.189200 0.057750 + 0.153040 0.052800 + 0.120230 0.047230 + 0.091030 0.041110 + 0.065680 0.034570 + 0.044350 0.027750 + 0.027140 0.020830 + 0.014160 0.014040 + 0.005360 0.007660 + 0.000760 0.002180 + 0.000550 -0.001410 + 0.005570 -0.003060 + 0.016510 -0.003300 + 0.033160 -0.002270 + 0.055500 -0.000040 + 0.083420 0.003150 + 0.116710 0.007080 + 0.155040 0.011510 + 0.198000 0.016200 + 0.245090 0.020930 + 0.295740 0.025460 + 0.349310 0.029620 + 0.405130 0.033190 + 0.462470 0.036050 + 0.520560 0.038030 + 0.578590 0.039070 + 0.635760 0.039070 + 0.691250 0.038060 + 0.744300 0.036040 + 0.794140 0.033100 + 0.840040 0.029300 + 0.881320 0.024820 + 0.917350 0.019790 + 0.947560 0.014390 + 0.971150 0.008870 + 0.987540 0.004100 + 0.996950 0.001020 + 1.000000 -0.000100 diff --git a/tests/reg_tests/naca0012.dat b/tests/reg_tests/naca0012.dat new file mode 100644 index 00000000..879778ce --- /dev/null +++ b/tests/reg_tests/naca0012.dat @@ -0,0 +1,160 @@ + 1.000000 0.1260000E-02 + 0.9937200 0.2137733E-02 + 0.9827658 0.3652628E-02 + 0.9699775 0.5395763E-02 + 0.9556484 0.7317189E-02 + 0.9402486 0.9345839E-02 + 0.9242123 0.1141940E-01 + 0.9078338 0.1349729E-01 + 0.8912799 0.1555747E-01 + 0.8746367 0.1758921E-01 + 0.8579464 0.1958767E-01 + 0.8412299 0.2155079E-01 + 0.8244974 0.2347775E-01 + 0.8077546 0.2536826E-01 + 0.7910046 0.2722215E-01 + 0.7742495 0.2903929E-01 + 0.7574910 0.3081946E-01 + 0.7407304 0.3256231E-01 + 0.7239692 0.3426741E-01 + 0.7072086 0.3593415E-01 + 0.6904498 0.3756179E-01 + 0.6736942 0.3914944E-01 + 0.6569430 0.4069607E-01 + 0.6401976 0.4220048E-01 + 0.6234593 0.4366132E-01 + 0.6067296 0.4507707E-01 + 0.5900098 0.4644604E-01 + 0.5733015 0.4776639E-01 + 0.5566061 0.4903607E-01 + 0.5399254 0.5025287E-01 + 0.5232609 0.5141440E-01 + 0.5066145 0.5251805E-01 + 0.4899881 0.5356103E-01 + 0.4733836 0.5454034E-01 + 0.4568033 0.5545275E-01 + 0.4402494 0.5629481E-01 + 0.4237245 0.5706282E-01 + 0.4072312 0.5775284E-01 + 0.3907728 0.5836065E-01 + 0.3743523 0.5888176E-01 + 0.3579737 0.5931135E-01 + 0.3416411 0.5964430E-01 + 0.3253592 0.5987512E-01 + 0.3091334 0.5999796E-01 + 0.2929700 0.6000654E-01 + 0.2768762 0.5989415E-01 + 0.2608607 0.5965360E-01 + 0.2449337 0.5927721E-01 + 0.2291078 0.5875675E-01 + 0.2133984 0.5808348E-01 + 0.1978253 0.5724818E-01 + 0.1824140 0.5624130E-01 + 0.1671985 0.5505334E-01 + 0.1522253 0.5367551E-01 + 0.1375585 0.5210105E-01 + 0.1232868 0.5032750E-01 + 0.1095300 0.4836006E-01 + 0.9644040E-01 0.4621587E-01 + 0.8419250E-01 0.4392743E-01 + 0.7295464E-01 0.4154215E-01 + 0.6284856E-01 0.3911578E-01 + 0.5391754E-01 0.3670115E-01 + 0.4612202E-01 0.3433829E-01 + 0.3936201E-01 0.3205037E-01 + 0.3350894E-01 0.2984552E-01 + 0.2843159E-01 0.2772130E-01 + 0.2401083E-01 0.2566907E-01 + 0.2014561E-01 0.2367712E-01 + 0.1675353E-01 0.2173266E-01 + 0.1376927E-01 0.1982291E-01 + 0.1114227E-01 0.1793562E-01 + 0.8834520E-02 0.1605954E-01 + 0.6818652E-02 0.1418471E-01 + 0.5076404E-02 0.1230310E-01 + 0.3597085E-02 0.1040943E-01 + 0.2375947E-02 0.8502398E-02 + 0.1411548E-02 0.6585675E-02 + 0.7030324E-03 0.4670019E-02 + 0.2448913E-03 0.2768967E-02 + 0.2616688E-04 0.9084721E-03 + 0.2616688E-04 -0.9084721E-03 + 0.2448913E-03 -0.2768967E-02 + 0.7030324E-03 -0.4670019E-02 + 0.1411548E-02 -0.6585675E-02 + 0.2375947E-02 -0.8502398E-02 + 0.3597085E-02 -0.1040943E-01 + 0.5076404E-02 -0.1230310E-01 + 0.6818652E-02 -0.1418471E-01 + 0.8834520E-02 -0.1605954E-01 + 0.1114227E-01 -0.1793562E-01 + 0.1376927E-01 -0.1982291E-01 + 0.1675353E-01 -0.2173266E-01 + 0.2014561E-01 -0.2367712E-01 + 0.2401083E-01 -0.2566907E-01 + 0.2843159E-01 -0.2772130E-01 + 0.3350894E-01 -0.2984552E-01 + 0.3936201E-01 -0.3205037E-01 + 0.4612202E-01 -0.3433829E-01 + 0.5391754E-01 -0.3670115E-01 + 0.6284856E-01 -0.3911578E-01 + 0.7295464E-01 -0.4154215E-01 + 0.8419250E-01 -0.4392743E-01 + 0.9644040E-01 -0.4621587E-01 + 0.1095300 -0.4836006E-01 + 0.1232868 -0.5032750E-01 + 0.1375585 -0.5210105E-01 + 0.1522253 -0.5367551E-01 + 0.1671985 -0.5505334E-01 + 0.1824140 -0.5624130E-01 + 0.1978253 -0.5724818E-01 + 0.2133984 -0.5808348E-01 + 0.2291078 -0.5875675E-01 + 0.2449337 -0.5927721E-01 + 0.2608607 -0.5965360E-01 + 0.2768762 -0.5989415E-01 + 0.2929700 -0.6000654E-01 + 0.3091334 -0.5999796E-01 + 0.3253592 -0.5987512E-01 + 0.3416411 -0.5964430E-01 + 0.3579737 -0.5931135E-01 + 0.3743523 -0.5888176E-01 + 0.3907728 -0.5836065E-01 + 0.4072312 -0.5775284E-01 + 0.4237245 -0.5706282E-01 + 0.4402494 -0.5629481E-01 + 0.4568033 -0.5545275E-01 + 0.4733836 -0.5454034E-01 + 0.4899881 -0.5356103E-01 + 0.5066145 -0.5251805E-01 + 0.5232609 -0.5141440E-01 + 0.5399254 -0.5025287E-01 + 0.5566061 -0.4903607E-01 + 0.5733015 -0.4776639E-01 + 0.5900098 -0.4644604E-01 + 0.6067296 -0.4507707E-01 + 0.6234593 -0.4366132E-01 + 0.6401976 -0.4220048E-01 + 0.6569430 -0.4069607E-01 + 0.6736942 -0.3914944E-01 + 0.6904498 -0.3756179E-01 + 0.7072086 -0.3593415E-01 + 0.7239692 -0.3426741E-01 + 0.7407304 -0.3256231E-01 + 0.7574910 -0.3081946E-01 + 0.7742495 -0.2903929E-01 + 0.7910046 -0.2722215E-01 + 0.8077546 -0.2536826E-01 + 0.8244974 -0.2347775E-01 + 0.8412299 -0.2155079E-01 + 0.8579464 -0.1958767E-01 + 0.8746367 -0.1758921E-01 + 0.8912799 -0.1555747E-01 + 0.9078338 -0.1349729E-01 + 0.9242123 -0.1141940E-01 + 0.9402486 -0.9345839E-02 + 0.9556484 -0.7317189E-02 + 0.9699775 -0.5395763E-02 + 0.9827658 -0.3652628E-02 + 0.9937200 -0.2137733E-02 + 1.000000 -0.1260000E-02 diff --git a/tests/reg_tests/naca0012_closed.dat b/tests/reg_tests/naca0012_closed.dat new file mode 100644 index 00000000..bfc3a187 --- /dev/null +++ b/tests/reg_tests/naca0012_closed.dat @@ -0,0 +1,161 @@ + 1.000000 0.1260000E-02 + 0.9937200 0.2137733E-02 + 0.9827658 0.3652628E-02 + 0.9699775 0.5395763E-02 + 0.9556484 0.7317189E-02 + 0.9402486 0.9345839E-02 + 0.9242123 0.1141940E-01 + 0.9078338 0.1349729E-01 + 0.8912799 0.1555747E-01 + 0.8746367 0.1758921E-01 + 0.8579464 0.1958767E-01 + 0.8412299 0.2155079E-01 + 0.8244974 0.2347775E-01 + 0.8077546 0.2536826E-01 + 0.7910046 0.2722215E-01 + 0.7742495 0.2903929E-01 + 0.7574910 0.3081946E-01 + 0.7407304 0.3256231E-01 + 0.7239692 0.3426741E-01 + 0.7072086 0.3593415E-01 + 0.6904498 0.3756179E-01 + 0.6736942 0.3914944E-01 + 0.6569430 0.4069607E-01 + 0.6401976 0.4220048E-01 + 0.6234593 0.4366132E-01 + 0.6067296 0.4507707E-01 + 0.5900098 0.4644604E-01 + 0.5733015 0.4776639E-01 + 0.5566061 0.4903607E-01 + 0.5399254 0.5025287E-01 + 0.5232609 0.5141440E-01 + 0.5066145 0.5251805E-01 + 0.4899881 0.5356103E-01 + 0.4733836 0.5454034E-01 + 0.4568033 0.5545275E-01 + 0.4402494 0.5629481E-01 + 0.4237245 0.5706282E-01 + 0.4072312 0.5775284E-01 + 0.3907728 0.5836065E-01 + 0.3743523 0.5888176E-01 + 0.3579737 0.5931135E-01 + 0.3416411 0.5964430E-01 + 0.3253592 0.5987512E-01 + 0.3091334 0.5999796E-01 + 0.2929700 0.6000654E-01 + 0.2768762 0.5989415E-01 + 0.2608607 0.5965360E-01 + 0.2449337 0.5927721E-01 + 0.2291078 0.5875675E-01 + 0.2133984 0.5808348E-01 + 0.1978253 0.5724818E-01 + 0.1824140 0.5624130E-01 + 0.1671985 0.5505334E-01 + 0.1522253 0.5367551E-01 + 0.1375585 0.5210105E-01 + 0.1232868 0.5032750E-01 + 0.1095300 0.4836006E-01 + 0.9644040E-01 0.4621587E-01 + 0.8419250E-01 0.4392743E-01 + 0.7295464E-01 0.4154215E-01 + 0.6284856E-01 0.3911578E-01 + 0.5391754E-01 0.3670115E-01 + 0.4612202E-01 0.3433829E-01 + 0.3936201E-01 0.3205037E-01 + 0.3350894E-01 0.2984552E-01 + 0.2843159E-01 0.2772130E-01 + 0.2401083E-01 0.2566907E-01 + 0.2014561E-01 0.2367712E-01 + 0.1675353E-01 0.2173266E-01 + 0.1376927E-01 0.1982291E-01 + 0.1114227E-01 0.1793562E-01 + 0.8834520E-02 0.1605954E-01 + 0.6818652E-02 0.1418471E-01 + 0.5076404E-02 0.1230310E-01 + 0.3597085E-02 0.1040943E-01 + 0.2375947E-02 0.8502398E-02 + 0.1411548E-02 0.6585675E-02 + 0.7030324E-03 0.4670019E-02 + 0.2448913E-03 0.2768967E-02 + 0.2616688E-04 0.9084721E-03 + 0.2616688E-04 -0.9084721E-03 + 0.2448913E-03 -0.2768967E-02 + 0.7030324E-03 -0.4670019E-02 + 0.1411548E-02 -0.6585675E-02 + 0.2375947E-02 -0.8502398E-02 + 0.3597085E-02 -0.1040943E-01 + 0.5076404E-02 -0.1230310E-01 + 0.6818652E-02 -0.1418471E-01 + 0.8834520E-02 -0.1605954E-01 + 0.1114227E-01 -0.1793562E-01 + 0.1376927E-01 -0.1982291E-01 + 0.1675353E-01 -0.2173266E-01 + 0.2014561E-01 -0.2367712E-01 + 0.2401083E-01 -0.2566907E-01 + 0.2843159E-01 -0.2772130E-01 + 0.3350894E-01 -0.2984552E-01 + 0.3936201E-01 -0.3205037E-01 + 0.4612202E-01 -0.3433829E-01 + 0.5391754E-01 -0.3670115E-01 + 0.6284856E-01 -0.3911578E-01 + 0.7295464E-01 -0.4154215E-01 + 0.8419250E-01 -0.4392743E-01 + 0.9644040E-01 -0.4621587E-01 + 0.1095300 -0.4836006E-01 + 0.1232868 -0.5032750E-01 + 0.1375585 -0.5210105E-01 + 0.1522253 -0.5367551E-01 + 0.1671985 -0.5505334E-01 + 0.1824140 -0.5624130E-01 + 0.1978253 -0.5724818E-01 + 0.2133984 -0.5808348E-01 + 0.2291078 -0.5875675E-01 + 0.2449337 -0.5927721E-01 + 0.2608607 -0.5965360E-01 + 0.2768762 -0.5989415E-01 + 0.2929700 -0.6000654E-01 + 0.3091334 -0.5999796E-01 + 0.3253592 -0.5987512E-01 + 0.3416411 -0.5964430E-01 + 0.3579737 -0.5931135E-01 + 0.3743523 -0.5888176E-01 + 0.3907728 -0.5836065E-01 + 0.4072312 -0.5775284E-01 + 0.4237245 -0.5706282E-01 + 0.4402494 -0.5629481E-01 + 0.4568033 -0.5545275E-01 + 0.4733836 -0.5454034E-01 + 0.4899881 -0.5356103E-01 + 0.5066145 -0.5251805E-01 + 0.5232609 -0.5141440E-01 + 0.5399254 -0.5025287E-01 + 0.5566061 -0.4903607E-01 + 0.5733015 -0.4776639E-01 + 0.5900098 -0.4644604E-01 + 0.6067296 -0.4507707E-01 + 0.6234593 -0.4366132E-01 + 0.6401976 -0.4220048E-01 + 0.6569430 -0.4069607E-01 + 0.6736942 -0.3914944E-01 + 0.6904498 -0.3756179E-01 + 0.7072086 -0.3593415E-01 + 0.7239692 -0.3426741E-01 + 0.7407304 -0.3256231E-01 + 0.7574910 -0.3081946E-01 + 0.7742495 -0.2903929E-01 + 0.7910046 -0.2722215E-01 + 0.8077546 -0.2536826E-01 + 0.8244974 -0.2347775E-01 + 0.8412299 -0.2155079E-01 + 0.8579464 -0.1958767E-01 + 0.8746367 -0.1758921E-01 + 0.8912799 -0.1555747E-01 + 0.9078338 -0.1349729E-01 + 0.9242123 -0.1141940E-01 + 0.9402486 -0.9345839E-02 + 0.9556484 -0.7317189E-02 + 0.9699775 -0.5395763E-02 + 0.9827658 -0.3652628E-02 + 0.9937200 -0.2137733E-02 + 1.000000 -0.1260000E-02 + 1.000000 0.1260000E-02 \ No newline at end of file diff --git a/tests/reg_tests/naca0012_sharp.dat b/tests/reg_tests/naca0012_sharp.dat new file mode 100644 index 00000000..ad97e920 --- /dev/null +++ b/tests/reg_tests/naca0012_sharp.dat @@ -0,0 +1,153 @@ + 1.000000 0.0000000E-02 + 0.9556484 0.7317189E-02 + 0.9402486 0.9345839E-02 + 0.9242123 0.1141940E-01 + 0.9078338 0.1349729E-01 + 0.8912799 0.1555747E-01 + 0.8746367 0.1758921E-01 + 0.8579464 0.1958767E-01 + 0.8412299 0.2155079E-01 + 0.8244974 0.2347775E-01 + 0.8077546 0.2536826E-01 + 0.7910046 0.2722215E-01 + 0.7742495 0.2903929E-01 + 0.7574910 0.3081946E-01 + 0.7407304 0.3256231E-01 + 0.7239692 0.3426741E-01 + 0.7072086 0.3593415E-01 + 0.6904498 0.3756179E-01 + 0.6736942 0.3914944E-01 + 0.6569430 0.4069607E-01 + 0.6401976 0.4220048E-01 + 0.6234593 0.4366132E-01 + 0.6067296 0.4507707E-01 + 0.5900098 0.4644604E-01 + 0.5733015 0.4776639E-01 + 0.5566061 0.4903607E-01 + 0.5399254 0.5025287E-01 + 0.5232609 0.5141440E-01 + 0.5066145 0.5251805E-01 + 0.4899881 0.5356103E-01 + 0.4733836 0.5454034E-01 + 0.4568033 0.5545275E-01 + 0.4402494 0.5629481E-01 + 0.4237245 0.5706282E-01 + 0.4072312 0.5775284E-01 + 0.3907728 0.5836065E-01 + 0.3743523 0.5888176E-01 + 0.3579737 0.5931135E-01 + 0.3416411 0.5964430E-01 + 0.3253592 0.5987512E-01 + 0.3091334 0.5999796E-01 + 0.2929700 0.6000654E-01 + 0.2768762 0.5989415E-01 + 0.2608607 0.5965360E-01 + 0.2449337 0.5927721E-01 + 0.2291078 0.5875675E-01 + 0.2133984 0.5808348E-01 + 0.1978253 0.5724818E-01 + 0.1824140 0.5624130E-01 + 0.1671985 0.5505334E-01 + 0.1522253 0.5367551E-01 + 0.1375585 0.5210105E-01 + 0.1232868 0.5032750E-01 + 0.1095300 0.4836006E-01 + 0.9644040E-01 0.4621587E-01 + 0.8419250E-01 0.4392743E-01 + 0.7295464E-01 0.4154215E-01 + 0.6284856E-01 0.3911578E-01 + 0.5391754E-01 0.3670115E-01 + 0.4612202E-01 0.3433829E-01 + 0.3936201E-01 0.3205037E-01 + 0.3350894E-01 0.2984552E-01 + 0.2843159E-01 0.2772130E-01 + 0.2401083E-01 0.2566907E-01 + 0.2014561E-01 0.2367712E-01 + 0.1675353E-01 0.2173266E-01 + 0.1376927E-01 0.1982291E-01 + 0.1114227E-01 0.1793562E-01 + 0.8834520E-02 0.1605954E-01 + 0.6818652E-02 0.1418471E-01 + 0.5076404E-02 0.1230310E-01 + 0.3597085E-02 0.1040943E-01 + 0.2375947E-02 0.8502398E-02 + 0.1411548E-02 0.6585675E-02 + 0.7030324E-03 0.4670019E-02 + 0.2448913E-03 0.2768967E-02 + 0.2616688E-04 0.9084721E-03 + 0.2616688E-04 -0.9084721E-03 + 0.2448913E-03 -0.2768967E-02 + 0.7030324E-03 -0.4670019E-02 + 0.1411548E-02 -0.6585675E-02 + 0.2375947E-02 -0.8502398E-02 + 0.3597085E-02 -0.1040943E-01 + 0.5076404E-02 -0.1230310E-01 + 0.6818652E-02 -0.1418471E-01 + 0.8834520E-02 -0.1605954E-01 + 0.1114227E-01 -0.1793562E-01 + 0.1376927E-01 -0.1982291E-01 + 0.1675353E-01 -0.2173266E-01 + 0.2014561E-01 -0.2367712E-01 + 0.2401083E-01 -0.2566907E-01 + 0.2843159E-01 -0.2772130E-01 + 0.3350894E-01 -0.2984552E-01 + 0.3936201E-01 -0.3205037E-01 + 0.4612202E-01 -0.3433829E-01 + 0.5391754E-01 -0.3670115E-01 + 0.6284856E-01 -0.3911578E-01 + 0.7295464E-01 -0.4154215E-01 + 0.8419250E-01 -0.4392743E-01 + 0.9644040E-01 -0.4621587E-01 + 0.1095300 -0.4836006E-01 + 0.1232868 -0.5032750E-01 + 0.1375585 -0.5210105E-01 + 0.1522253 -0.5367551E-01 + 0.1671985 -0.5505334E-01 + 0.1824140 -0.5624130E-01 + 0.1978253 -0.5724818E-01 + 0.2133984 -0.5808348E-01 + 0.2291078 -0.5875675E-01 + 0.2449337 -0.5927721E-01 + 0.2608607 -0.5965360E-01 + 0.2768762 -0.5989415E-01 + 0.2929700 -0.6000654E-01 + 0.3091334 -0.5999796E-01 + 0.3253592 -0.5987512E-01 + 0.3416411 -0.5964430E-01 + 0.3579737 -0.5931135E-01 + 0.3743523 -0.5888176E-01 + 0.3907728 -0.5836065E-01 + 0.4072312 -0.5775284E-01 + 0.4237245 -0.5706282E-01 + 0.4402494 -0.5629481E-01 + 0.4568033 -0.5545275E-01 + 0.4733836 -0.5454034E-01 + 0.4899881 -0.5356103E-01 + 0.5066145 -0.5251805E-01 + 0.5232609 -0.5141440E-01 + 0.5399254 -0.5025287E-01 + 0.5566061 -0.4903607E-01 + 0.5733015 -0.4776639E-01 + 0.5900098 -0.4644604E-01 + 0.6067296 -0.4507707E-01 + 0.6234593 -0.4366132E-01 + 0.6401976 -0.4220048E-01 + 0.6569430 -0.4069607E-01 + 0.6736942 -0.3914944E-01 + 0.6904498 -0.3756179E-01 + 0.7072086 -0.3593415E-01 + 0.7239692 -0.3426741E-01 + 0.7407304 -0.3256231E-01 + 0.7574910 -0.3081946E-01 + 0.7742495 -0.2903929E-01 + 0.7910046 -0.2722215E-01 + 0.8077546 -0.2536826E-01 + 0.8244974 -0.2347775E-01 + 0.8412299 -0.2155079E-01 + 0.8579464 -0.1958767E-01 + 0.8746367 -0.1758921E-01 + 0.8912799 -0.1555747E-01 + 0.9078338 -0.1349729E-01 + 0.9242123 -0.1141940E-01 + 0.9402486 -0.9345839E-02 + 0.9556484 -0.7317189E-02 diff --git a/tests/reg_tests/naca2412.dat b/tests/reg_tests/naca2412.dat new file mode 100644 index 00000000..0f8a2abe --- /dev/null +++ b/tests/reg_tests/naca2412.dat @@ -0,0 +1,160 @@ + 1.000000 0.1260000E-02 + 0.9938106 0.2535617E-02 + 0.9830325 0.4731161E-02 + 0.9704784 0.7247672E-02 + 0.9564397 0.1001055E-01 + 0.9413697 0.1291711E-01 + 0.9256838 0.1587834E-01 + 0.9096628 0.1883632E-01 + 0.8934663 0.2175935E-01 + 0.8771770 0.2463174E-01 + 0.8608359 0.2744614E-01 + 0.8444631 0.3019920E-01 + 0.8280691 0.3288943E-01 + 0.8116591 0.3551612E-01 + 0.7952365 0.3807885E-01 + 0.7788034 0.4057732E-01 + 0.7623615 0.4301118E-01 + 0.7459123 0.4537998E-01 + 0.7294571 0.4768322E-01 + 0.7129973 0.4992024E-01 + 0.6965342 0.5209027E-01 + 0.6800691 0.5419242E-01 + 0.6636034 0.5622568E-01 + 0.6471385 0.5818886E-01 + 0.6306757 0.6008068E-01 + 0.6142166 0.6189970E-01 + 0.5977625 0.6364431E-01 + 0.5813152 0.6531279E-01 + 0.5648762 0.6690323E-01 + 0.5484471 0.6841360E-01 + 0.5320298 0.6984165E-01 + 0.5156264 0.7118501E-01 + 0.4992389 0.7244109E-01 + 0.4828702 0.7360713E-01 + 0.4665236 0.7468013E-01 + 0.4502042 0.7565687E-01 + 0.4339202 0.7653383E-01 + 0.4176864 0.7730707E-01 + 0.4015296 0.7797313E-01 + 0.3854925 0.7851149E-01 + 0.3695440 0.7890151E-01 + 0.3536593 0.7914038E-01 + 0.3378299 0.7922448E-01 + 0.3220547 0.7914962E-01 + 0.3063364 0.7891118E-01 + 0.2906798 0.7850424E-01 + 0.2750917 0.7792361E-01 + 0.2595800 0.7716381E-01 + 0.2441544 0.7621911E-01 + 0.2288264 0.7508356E-01 + 0.2136101 0.7375099E-01 + 0.1985224 0.7221520E-01 + 0.1835852 0.7047009E-01 + 0.1688262 0.6851007E-01 + 0.1542824 0.6633080E-01 + 0.1400036 0.6393038E-01 + 0.1260577 0.6131145E-01 + 0.1125371 0.5848429E-01 + 0.9956312E-01 0.5547103E-01 + 0.8728464E-01 0.5230973E-01 + 0.7586421E-01 0.4905589E-01 + 0.6544830E-01 0.4577788E-01 + 0.5613115E-01 0.4254561E-01 + 0.4793159E-01 0.3941685E-01 + 0.4079595E-01 0.3642842E-01 + 0.3462169E-01 0.3359541E-01 + 0.2928580E-01 0.3091594E-01 + 0.2466652E-01 0.2837791E-01 + 0.2065506E-01 0.2596420E-01 + 0.1715977E-01 0.2365615E-01 + 0.1410618E-01 0.2143542E-01 + 0.1143536E-01 0.1928499E-01 + 0.9101759E-02 0.1718952E-01 + 0.7071123E-02 0.1513561E-01 + 0.5318767E-02 0.1311206E-01 + 0.3828012E-02 0.1111021E-01 + 0.2588773E-02 0.9124697E-02 + 0.1595690E-02 0.7154069E-02 + 0.8461609E-03 0.5202233E-02 + 0.3368445E-03 0.3277473E-02 + 0.6114029E-04 0.1393975E-02 + 0.5824771E-05 -0.4282090E-03 + 0.1663006E-03 -0.2267967E-02 + 0.5689305E-03 -0.4149103E-02 + 0.1231592E-02 -0.6035257E-02 + 0.2163949E-02 -0.7906377E-02 + 0.3366577E-02 -0.9743942E-02 + 0.4837292E-02 -0.1153834E-01 + 0.6575304E-02 -0.1328678E-01 + 0.8585137E-02 -0.1499197E-01 + 0.1087889E-01 -0.1666006E-01 + 0.1347786E-01 -0.1829912E-01 + 0.1641399E-01 -0.1991809E-01 + 0.1973158E-01 -0.2152616E-01 + 0.2348938E-01 -0.2313222E-01 + 0.2776322E-01 -0.2474436E-01 + 0.3264848E-01 -0.2636909E-01 + 0.3826157E-01 -0.2801005E-01 + 0.4473833E-01 -0.2966610E-01 + 0.5222477E-01 -0.3132860E-01 + 0.6085460E-01 -0.3297807E-01 + 0.7071541E-01 -0.3458296E-01 + 0.8181387E-01 -0.3610240E-01 + 0.9406021E-01 -0.3749388E-01 + 0.1072889 -0.3872292E-01 + 0.1213045 -0.3976936E-01 + 0.1359245 -0.4062764E-01 + 0.1510010 -0.4130303E-01 + 0.1664244 -0.4180707E-01 + 0.1821159 -0.4215413E-01 + 0.1980201 -0.4235920E-01 + 0.2140974 -0.4243691E-01 + 0.2303188 -0.4240097E-01 + 0.2466627 -0.4226406E-01 + 0.2631123 -0.4203782E-01 + 0.2796544 -0.4173293E-01 + 0.2962783 -0.4135919E-01 + 0.3129747 -0.4092561E-01 + 0.3297351 -0.4044053E-01 + 0.3465508 -0.3991170E-01 + 0.3634109 -0.3934642E-01 + 0.3802984 -0.3875170E-01 + 0.3971827 -0.3813491E-01 + 0.4140115 -0.3748978E-01 + 0.4308120 -0.3679530E-01 + 0.4476169 -0.3605475E-01 + 0.4644392 -0.3527159E-01 + 0.4812836 -0.3444944E-01 + 0.4981511 -0.3359176E-01 + 0.5150414 -0.3270184E-01 + 0.5319531 -0.3178276E-01 + 0.5488849 -0.3083736E-01 + 0.5658353 -0.2986830E-01 + 0.5828025 -0.2887803E-01 + 0.5997852 -0.2786879E-01 + 0.6167818 -0.2684261E-01 + 0.6337908 -0.2580135E-01 + 0.6508109 -0.2474667E-01 + 0.6678406 -0.2368004E-01 + 0.6848787 -0.2260278E-01 + 0.7019237 -0.2151599E-01 + 0.7189744 -0.2042062E-01 + 0.7360295 -0.1931747E-01 + 0.7530876 -0.1820714E-01 + 0.7701475 -0.1709010E-01 + 0.7872076 -0.1596664E-01 + 0.8042664 -0.1483693E-01 + 0.8213218 -0.1370100E-01 + 0.8383706 -0.1255880E-01 + 0.8554075 -0.1141029E-01 + 0.8724223 -0.1025563E-01 + 0.8893943 -0.9095567E-02 + 0.9062806 -0.7932284E-02 + 0.9229930 -0.6771113E-02 + 0.9393579 -0.5623546E-02 + 0.9550647 -0.4511258E-02 + 0.9696524 -0.3467717E-02 + 0.9826250 -0.2530412E-02 + 0.9936846 -0.1723856E-02 + 1.000000 -0.1260000E-02 diff --git a/tests/reg_tests/test_DVGeometryCST.py b/tests/reg_tests/test_DVGeometryCST.py new file mode 100644 index 00000000..f7353af6 --- /dev/null +++ b/tests/reg_tests/test_DVGeometryCST.py @@ -0,0 +1,746 @@ +""" +============================================================================== +DVGeoCST: Test suite for the DVGeoCST module. +============================================================================== +""" + +# ============================================================================== +# Standard Python modules +# ============================================================================== +import unittest +import os +from parameterized import parameterized_class + +# ============================================================================== +# External Python modules +# ============================================================================== +import numpy as np +from prefoil.utils import readCoordFile +from mpi4py import MPI + +# ============================================================================== +# Extension modules +# ============================================================================== +from pygeo import DVGeometryCST + +# LEUpper is true if the leading edge (minimum x) point is considered to be on the upper surface +airfoils = [ + {"fName": "naca2412.dat", "LEUpper": False}, + {"fName": "naca0012.dat", "LEUpper": True}, + {"fName": "e63.dat", "LEUpper": False}, +] + +# Parameterization of design variables +DVs = [ + {"dvName": "upper", "dvNum": 5}, + {"dvName": "lower", "dvNum": 5}, + {"dvName": "n1", "dvNum": 1}, + {"dvName": "n2", "dvNum": 1}, + {"dvName": "n1_upper", "dvNum": 1}, + {"dvName": "n1_lower", "dvNum": 1}, + {"dvName": "n2_upper", "dvNum": 1}, + {"dvName": "n2_lower", "dvNum": 1}, + {"dvName": "chord", "dvNum": 1}, +] + + +@parameterized_class(airfoils) +class DVGeometryCSTUnitTest(unittest.TestCase): + + N_PROCS = 1 + + def setUp(self): + self.rng = np.random.default_rng(1) + self.sensTol = 1e-10 + self.coordTol = 1e-10 + self.maxNumCoeff = 10 + self.x = np.linspace(0, 1, 100) + self.yte = 1e-3 + self.CS_delta = 1e-200 + self.curDir = os.path.abspath(os.path.dirname(__file__)) + + def test_ClassShape(self): + """Test that for w_i = 1, the class shape has the expected shape""" + N1 = 0.5 + N2 = 1.0 + yExact = np.sqrt(self.x) * (1 - self.x) + y = DVGeometryCST.computeClassShape(self.x, N1, N2) + np.testing.assert_allclose(y, yExact, atol=self.coordTol, rtol=self.coordTol) + + def test_ShapeFunctions(self): + """Test that the shape functions sum to 1 when all weights are 1""" + for n in range(1, self.maxNumCoeff + 1): + w = np.ones(n) + y = DVGeometryCST.computeShapeFunctions(self.x, w) + np.testing.assert_allclose(y.sum(axis=0), 1.0, atol=self.coordTol, rtol=self.coordTol) + + def test_dydN1(self): + """Test the derivatives of the CST curve height w.r.t N1""" + N1 = self.rng.random(1) + N2 = self.rng.random(1) + for n in range(1, self.maxNumCoeff + 1): + w = self.rng.random(n) + dydN1 = DVGeometryCST.computeCSTdydN1(self.x, N1, N2, w) + dydN1_CS = ( + np.imag( + DVGeometryCST.computeCSTCoordinates(self.x, N1 + self.CS_delta * 1j, N2, w, self.yte, dtype=complex) + ) + / self.CS_delta + ) + np.testing.assert_allclose(dydN1, dydN1_CS, atol=self.sensTol, rtol=self.sensTol) + + def test_dydN2(self): + """Test the derivatives of the CST curve height w.r.t N2""" + N1 = self.rng.random(1) + N2 = self.rng.random(1) + for n in range(1, self.maxNumCoeff + 1): + w = self.rng.random(n) + dydN2 = DVGeometryCST.computeCSTdydN2(self.x, N1, N2, w) + dydN2_CS = ( + np.imag( + DVGeometryCST.computeCSTCoordinates(self.x, N1, N2 + self.CS_delta * 1j, w, self.yte, dtype=complex) + ) + / self.CS_delta + ) + np.testing.assert_allclose(dydN2, dydN2_CS, atol=self.sensTol, rtol=self.sensTol) + + def test_dydw(self): + """Test the derivatives of the CST curve height w.r.t N2""" + N1 = self.rng.random(1) + N2 = self.rng.random(1) + for n in range(1, self.maxNumCoeff + 1): + w = self.rng.random(n) + dydw = DVGeometryCST.computeCSTdydw(self.x, N1, N2, w) + dydw_CS = np.zeros((n, self.x.size), dtype=float) + w = w.astype(complex) + for i in range(n): + w[i] += self.CS_delta * 1j + dydw_CS[i, :] = ( + np.imag(DVGeometryCST.computeCSTCoordinates(self.x, N1, N2, w, self.yte, dtype=complex)) + / self.CS_delta + ) + w[i] -= self.CS_delta * 1j + + np.testing.assert_allclose(dydw, dydw_CS, atol=self.sensTol, rtol=self.sensTol) + + def test_fitCST(self): + """Test the CST parameter fitting""" + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(os.path.join(self.curDir, self.fName)) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + idxLE = np.argmin(coords[:, 0]) + idxUpper = np.arange(0, idxLE + self.LEUpper) + idxLower = np.arange(idxLE + self.LEUpper, coords.shape[0]) + yTE = coords[0, 1] + N1 = 0.5 + N2 = 1.0 + + for nCST in range(2, 10): + # Fit the CST parameters and then compute the coordinates + # with those parameters and check that it's close + upperCST = DVGeometryCST.computeCSTfromCoords(coords[idxUpper, 0], coords[idxUpper, 1], nCST, N1=N1, N2=N2) + lowerCST = DVGeometryCST.computeCSTfromCoords(coords[idxLower, 0], coords[idxLower, 1], nCST, N1=N1, N2=N2) + fitCoordsUpper = DVGeometryCST.computeCSTCoordinates(coords[idxUpper, 0], N1, N2, upperCST, yTE) + fitCoordsLower = DVGeometryCST.computeCSTCoordinates(coords[idxLower, 0], N1, N2, lowerCST, -yTE) + + # Loosen the tolerances for the challenging e63 airfoil + if self.fName == "e63.dat": + if nCST < 4: + atol = 1e-1 + rtol = 1.0 + else: + atol = 1e-2 + rtol = 6e-1 + else: + atol = 1e-3 + rtol = 1e-1 + + np.testing.assert_allclose(fitCoordsUpper, coords[idxUpper, 1], atol=atol, rtol=rtol) + np.testing.assert_allclose(fitCoordsLower, coords[idxLower, 1], atol=atol, rtol=rtol) + + +@parameterized_class(airfoils) +class DVGeometryCSTPointSetSerial(unittest.TestCase): + # Test in serial + N_PROCS = 1 + + def setUp(self): + self.curDir = os.path.abspath(os.path.dirname(__file__)) + self.datFile = os.path.join(self.curDir, self.fName) + self.comm = MPI.COMM_WORLD + self.DVGeo = DVGeometryCST(self.datFile, comm=self.comm) + + def test_addPointSet_sorted(self): + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(self.datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + idxLE = np.argmin(coords[:, 0]) + # Don't include the points at the corners of the trailing edge because it's not guaranteed + # that they'll be included in the upper and lower surface (which is ok) + idxUpper = np.arange(1, idxLE + self.LEUpper) + idxLower = np.arange(idxLE + self.LEUpper, coords.shape[0] - 1) + thickTE = coords[0, 1] - coords[-1, 1] + + self.DVGeo.addPointSet(coords, "test") + + # Arrays are short so this is fast enough + for idx in idxUpper: + self.assertIn(idx, self.DVGeo.points["test"]["upper"]) + for idx in idxLower: + self.assertIn(idx, self.DVGeo.points["test"]["lower"]) + np.testing.assert_equal(thickTE, self.DVGeo.points["test"]["thicknessTE"]) + self.assertEqual(min(coords[:, 0]), self.DVGeo.points["test"]["xMin"]) + self.assertEqual(max(coords[:, 0]), self.DVGeo.points["test"]["xMax"]) + + def test_addPointSet_randomized(self): + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(self.datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + idxLE = np.argmin(coords[:, 0]) + idxUpper = np.arange(0, idxLE + self.LEUpper) + idxLower = np.arange(idxLE + self.LEUpper, coords.shape[0]) + thickTE = coords[0, 1] - coords[-1, 1] + + # Randomize the index order (do indices so we can track where they end up) + rng = np.random.default_rng(1) + # Maps from the original index to the new one (e.g., the first value is the index the first coordinate ends up at) + idxShuffle = np.arange(0, coords.shape[0]) + rng.shuffle(idxShuffle) + coordsRand = np.zeros(coords.shape) + coordsRand[idxShuffle, :] = coords + idxUpperRand = np.sort(idxShuffle[idxUpper]) + idxLowerRand = np.sort(idxShuffle[idxLower]) + + self.DVGeo.addPointSet(coordsRand, "test") + + # Don't include the points at the corners of the trailing edge because it's not guaranteed + # that they'll be included in the upper and lower surface (which is ok) + # Arrays are short so this is fast enough + for idx in idxUpperRand: + if idx != idxShuffle[0]: + self.assertIn(idx, self.DVGeo.points["test"]["upper"]) + for idx in idxLowerRand: + if idx != idxShuffle[-1]: + self.assertIn(idx, self.DVGeo.points["test"]["lower"]) + np.testing.assert_equal(thickTE, self.DVGeo.points["test"]["thicknessTE"]) + self.assertEqual(min(coords[:, 0]), self.DVGeo.points["test"]["xMin"]) + self.assertEqual(max(coords[:, 0]), self.DVGeo.points["test"]["xMax"]) + + def test_addPointSet_bluntTE(self): # includes a blunt trailing edge with points along it + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(self.datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + nPointsTE = 6 # total points on the trailing edge + pointsTE = np.ones((nPointsTE - 2, 3), dtype=float) + pointsTE[:, 2] = 0 # z coordinates are zero + pointsTE[:, 1] = np.linspace(coords[-1, 1] + 1e-4, coords[0, 1] - 1e-4, pointsTE.shape[0]) + coords = np.vstack((coords, pointsTE)) + idxLE = np.argmin(coords[:, 0]) + # Don't include the points at the corners of the trailing edge because it's not guaranteed + # that they'll be included in the upper and lower surface (which is ok) + idxUpper = np.arange(1, idxLE + self.LEUpper) + idxLower = np.arange(idxLE + self.LEUpper, coords.shape[0] - nPointsTE + 2 - 1) + thickTE = coords[0, 1] - coords[coords.shape[0] - nPointsTE + 1, 1] + + self.DVGeo.addPointSet(coords, "test") + + # Arrays are short so this is fast enough + for idx in idxUpper: + self.assertIn(idx, self.DVGeo.points["test"]["upper"]) + for idx in idxLower: + self.assertIn(idx, self.DVGeo.points["test"]["lower"]) + np.testing.assert_equal(thickTE, self.DVGeo.points["test"]["thicknessTE"]) + self.assertEqual(min(coords[:, 0]), self.DVGeo.points["test"]["xMin"]) + self.assertEqual(max(coords[:, 0]), self.DVGeo.points["test"]["xMax"]) + + +@parameterized_class(airfoils) +class DVGeometryCSTPointSetParallel(unittest.TestCase): + # Test in parallel + N_PROCS = 4 + + def setUp(self): + self.curDir = os.path.abspath(os.path.dirname(__file__)) + self.datFile = os.path.join(self.curDir, self.fName) + self.comm = MPI.COMM_WORLD + self.DVGeo = DVGeometryCST(self.datFile, comm=self.comm) + + def test_addPointSet_sorted(self): + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(self.datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + idxLE = np.argmin(coords[:, 0]) + + isUpper = np.zeros(coords.shape[0]) + isUpper[: idxLE + self.LEUpper] = 1 + isUpper = isUpper == 1 + isLower = np.logical_not(isUpper) + + thickTE = coords[0, 1] - coords[-1, 1] + + # Divide up the points among the procs (mostly evenly, but not quite to check the harder case) + if self.N_PROCS == 1: + nPerProc = coords.shape[0] + else: + nPerProc = int(coords.shape[0] // (self.N_PROCS - 0.5)) + rank = self.comm.rank + if self.comm.rank < self.comm.size - 1: # all but last proc takes nPerProc elements + self.DVGeo.addPointSet(coords[rank * nPerProc : (rank + 1) * nPerProc, :], "test") + idxUpper = np.where(isUpper[rank * nPerProc : (rank + 1) * nPerProc])[0] + idxLower = np.where(isLower[rank * nPerProc : (rank + 1) * nPerProc])[0] + else: + self.DVGeo.addPointSet(coords[rank * nPerProc :, :], "test") + idxUpper = np.where(isUpper[rank * nPerProc :])[0] + idxLower = np.where(isLower[rank * nPerProc :])[0] + + # Don't include the points at the corners of the trailing edge because it's not guaranteed + # that they'll be included in the upper and lower surface (which is ok) + # Arrays are short so this is fast enough + for idx in idxUpper: + if idx != 0: + self.assertIn(idx, self.DVGeo.points["test"]["upper"]) + for idx in idxLower: + if idx != coords.shape[0] - 1: + self.assertIn(idx, self.DVGeo.points["test"]["lower"]) + np.testing.assert_equal(thickTE, self.DVGeo.points["test"]["thicknessTE"]) + self.assertEqual(min(coords[:, 0]), self.DVGeo.points["test"]["xMin"]) + self.assertEqual(max(coords[:, 0]), self.DVGeo.points["test"]["xMax"]) + + def test_addPointSet_randomized(self): + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(self.datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + idxLE = np.argmin(coords[:, 0]) + + isUpper = np.zeros(coords.shape[0]) + isUpper[: idxLE + self.LEUpper] = 1 + isUpper = isUpper == 1 + isLower = np.logical_not(isUpper) + + thickTE = coords[0, 1] - coords[-1, 1] + + # Randomize the index order (do indices so we can track where they end up) + rng = np.random.default_rng(1) + # Maps from the original index to the new one (e.g., the first value is the index the first coordinate ends up at) + idxShuffle = np.arange(0, coords.shape[0]) + rng.shuffle(idxShuffle) + # idxShuffle = (idxShuffle + 10) % coords.shape[0] + coordsRand = np.zeros(coords.shape) + isUpperRand = np.full(isUpper.shape, False) + isLowerRand = np.full(isLower.shape, False) + coordsRand[idxShuffle, :] = coords + isUpperRand[idxShuffle] = isUpper + isLowerRand[idxShuffle] = isLower + + # Maps from the shuffled index to the original one (e.g., the first value is + # the original index of the first value in the shuffled array) + idxInverseShuffle = np.zeros(idxShuffle.shape[0]) + idxInverseShuffle[idxShuffle] = np.arange(0, coords.shape[0]) + + # Divide up the points among the procs (mostly evenly, but not quite to check the harder case) + nPerProc = int(coordsRand.shape[0] // 3.5) + rank = self.comm.rank + if self.comm.rank < self.comm.size - 1: # all but last proc takes nPerProc elements + self.DVGeo.addPointSet(coordsRand[rank * nPerProc : (rank + 1) * nPerProc, :], "test", rank=self.comm.rank) + idxUpper = np.where(isUpperRand[rank * nPerProc : (rank + 1) * nPerProc])[0] + idxLower = np.where(isLowerRand[rank * nPerProc : (rank + 1) * nPerProc])[0] + + # Figure out the local indices where the first and last coordinates in the dat file ended up + idxStart = np.argwhere(0 == idxInverseShuffle[rank * nPerProc : (rank + 1) * nPerProc]) + idxEnd = np.argwhere(coords.shape[0] == idxInverseShuffle[rank * nPerProc : (rank + 1) * nPerProc]) + else: + self.DVGeo.addPointSet(coordsRand[rank * nPerProc :, :], "test", rank=self.comm.rank) + idxUpper = np.where(isUpperRand[rank * nPerProc :])[0] + idxLower = np.where(isLowerRand[rank * nPerProc :])[0] + + # Figure out the local indices where the first and last coordinates in the dat file ended up + idxStart = np.argwhere(0 == idxInverseShuffle[rank * nPerProc :]) + idxEnd = np.argwhere(coords.shape[0] == idxInverseShuffle[rank * nPerProc :]) + + # Turn the single element array to a number or None if the first + # or last points aren't in this partition + if idxStart: + idxStart = idxStart.item() + else: + idxStart = None + if idxEnd: + idxEnd = idxEnd.item() + else: + idxEnd = None + + # Don't include the points at the corners of the trailing edge because it's not guaranteed + # that they'll be included in the upper and lower surface (which is ok) + # Arrays are short so this is fast enough + for idx in idxUpper: + if idx != idxStart: + self.assertIn(idx, self.DVGeo.points["test"]["upper"]) + for idx in idxLower: + if idx != idxEnd: + self.assertIn(idx, self.DVGeo.points["test"]["lower"]) + np.testing.assert_equal(thickTE, self.DVGeo.points["test"]["thicknessTE"]) + self.assertEqual(min(coords[:, 0]), self.DVGeo.points["test"]["xMin"]) + self.assertEqual(max(coords[:, 0]), self.DVGeo.points["test"]["xMax"]) + + +class DVGeometryCSTSharpOrClosed(unittest.TestCase): + # Test in serial + N_PROCS = 1 + + def test_addPointSet_sharp(self): + curDir = os.path.abspath(os.path.dirname(__file__)) + datFile = os.path.join(curDir, "naca0012_sharp.dat") + comm = MPI.COMM_WORLD + DVGeo = DVGeometryCST(datFile, comm=comm) + + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + idxLE = np.argmin(coords[:, 0]) + 1 + + # Don't include the points at the corners of the trailing edge because it's not guaranteed + # that they'll be included in the upper and lower surface (which is ok) + idxUpper = np.arange(1, idxLE) + idxLower = np.arange(idxLE, coords.shape[0] - 1) + thickTE = 0.0 + + DVGeo.addPointSet(coords, "test") + + # Arrays are short so this is fast enough + for idx in idxUpper: + self.assertIn(idx, DVGeo.points["test"]["upper"]) + for idx in idxLower: + self.assertIn(idx, DVGeo.points["test"]["lower"]) + np.testing.assert_equal(thickTE, DVGeo.points["test"]["thicknessTE"]) + self.assertEqual(min(coords[:, 0]), DVGeo.points["test"]["xMin"]) + self.assertEqual(max(coords[:, 0]), DVGeo.points["test"]["xMax"]) + self.assertTrue(DVGeo.sharp) + + def test_addPointSet_closed(self): + curDir = os.path.abspath(os.path.dirname(__file__)) + datFile = os.path.join(curDir, "naca0012_closed.dat") + comm = MPI.COMM_WORLD + DVGeo = DVGeometryCST(datFile, comm=comm) + + # Read in airfoil coordinates to test with and split up the surfaces + coords = readCoordFile(datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) + idxLE = np.argmin(coords[:, 0]) + 1 + + # Don't include the points at the corners of the trailing edge because it's not guaranteed + # that they'll be included in the upper and lower surface (which is ok) + idxUpper = np.arange(1, idxLE) + idxLower = np.arange(idxLE, coords.shape[0] - 2) + thickTE = coords[0, 1] - coords[-2, 1] + + DVGeo.addPointSet(coords, "test") + + # Arrays are short so this is fast enough + for idx in idxUpper: + self.assertIn(idx, DVGeo.points["test"]["upper"]) + for idx in idxLower: + self.assertIn(idx, DVGeo.points["test"]["lower"]) + np.testing.assert_equal(thickTE, DVGeo.points["test"]["thicknessTE"]) + self.assertEqual(min(coords[:, 0]), DVGeo.points["test"]["xMin"]) + self.assertEqual(max(coords[:, 0]), DVGeo.points["test"]["xMax"]) + self.assertFalse(DVGeo.sharp) + + +@parameterized_class(DVs) +class DVGeometryCSTSensitivity(unittest.TestCase): + # Test in serial + N_PROCS = 1 + + def setUp(self): + self.curDir = os.path.abspath(os.path.dirname(__file__)) + self.datFile = os.path.join(self.curDir, "naca2412.dat") + self.rng = np.random.default_rng(1) + self.comm = MPI.COMM_WORLD + if self.dvName in ["upper", "lower"]: + numCST = self.dvNum + else: + numCST = 4 + self.DVGeo = DVGeometryCST(self.datFile, comm=self.comm, isComplex=True, numCST=numCST) + + # Read in airfoil coordinates (use NACA 2412) + coords = readCoordFile(self.datFile) + coords = np.hstack((coords, np.zeros((coords.shape[0], 1)))) # z-coordinates + self.coords = coords.astype(complex) + idxLE = np.argmin(coords[:, 0]) + self.idxUpper = np.arange(0, idxLE) + self.idxLower = np.arange(idxLE, coords.shape[0]) + self.thickTE = coords[0, 1] - coords[-1, 1] + self.ptName = "pt" + + self.sensTol = 1e-10 + self.coordTol = 1e-10 + self.CS_delta = 1e-200 + + def test_DV_sensitivityProd(self): + """ + Test DVGeo.totalSensitivityProd for all design variables + """ + self.DVGeo.addDV(self.dvName, dvType=self.dvName) + self.DVGeo.addPointSet(self.coords, self.ptName) + + # Set DV to random values + self.DVGeo.setDesignVars({self.dvName: self.rng.random(self.dvNum)}) + self.DVGeo.update(self.ptName) + + DVs = self.DVGeo.getValues() + + # First compute the analytic ones with the built in function + sensProd = [] + for i in range(self.dvNum): + vec = np.zeros(self.dvNum) + vec[i] = 1 + sensProd.append(np.real(self.DVGeo.totalSensitivityProd({self.dvName: vec}, self.ptName))) + + # Then check them against doing it with complex step + valDV = DVs[self.dvName] + for i in range(DVs[self.dvName].size): + valDV[i] += self.CS_delta * 1j + self.DVGeo.setDesignVars({self.dvName: valDV}) + pertCoords = self.DVGeo.update(self.ptName) + valDV[i] -= self.CS_delta * 1j + + dXdDV = np.imag(pertCoords) / self.CS_delta + np.testing.assert_allclose(sensProd[i], dXdDV, atol=self.sensTol, rtol=self.sensTol) + + def test_DV_sensitivity_simple(self): + """ + Test DVGeo.totalSensitivity for all design variables with dIdXpt of all ones + """ + self.DVGeo.addDV(self.dvName, dvType=self.dvName) + self.DVGeo.addPointSet(self.coords, self.ptName) + + # Set DV to random values + self.DVGeo.setDesignVars({self.dvName: self.rng.random(self.dvNum)}) + self.DVGeo.update(self.ptName) + + # dIdXpt of all ones means the total sensitivities will just be the sum of the + # derivatives at each of the coordianates + dIdXpt = np.ones_like(self.coords) + + DVs = self.DVGeo.getValues() + + # First compute the analytic ones with the built in function + sens = np.real(self.DVGeo.totalSensitivity(dIdXpt, self.ptName)[self.dvName]) + + # Then check them against doing it with complex step + valDV = DVs[self.dvName] + sensCS = np.zeros(self.dvNum) + for i in range(DVs[self.dvName].size): + valDV[i] += self.CS_delta * 1j + self.DVGeo.setDesignVars({self.dvName: valDV}) + pertCoords = self.DVGeo.update(self.ptName) + valDV[i] -= self.CS_delta * 1j + + dXdDV = np.imag(pertCoords) / self.CS_delta + + # Sum the derivatives + sensCS[i] = np.sum(dXdDV) + + np.testing.assert_allclose(sens, sensCS, atol=self.sensTol, rtol=self.sensTol) + + def test_DV_sensitivity_parallel(self): + """ + Test DVGeo.totalSensitivity for all design variables with dIdXpt containing + three different Npts x 3 arrays (another possible input) + """ + self.DVGeo.addDV(self.dvName, dvType=self.dvName) + self.DVGeo.addPointSet(self.coords, self.ptName) + + # Set DV to random values + self.DVGeo.setDesignVars({self.dvName: self.rng.random(self.dvNum)}) + self.DVGeo.update(self.ptName) + + # dIdXpt of all ones means the total sensitivities will just be the sum of the + # derivatives at each of the coordianates + dIdXpt = np.ones_like(self.coords) + coeff = np.array([0.1, 0.5, 1.0]) + dIdXptVectorized = np.array([coeff[0] * dIdXpt, coeff[1] * dIdXpt, coeff[2] * dIdXpt]) + + DVs = self.DVGeo.getValues() + + # First compute the analytic ones with the built in function + sens = np.real(self.DVGeo.totalSensitivity(dIdXptVectorized, self.ptName, comm=self.comm)[self.dvName]) + + # Then check them against doing it with complex step + valDV = DVs[self.dvName] + sensCS = np.zeros((dIdXptVectorized.shape[0], self.dvNum)) + for i in range(DVs[self.dvName].size): + valDV[i] += self.CS_delta * 1j + self.DVGeo.setDesignVars({self.dvName: valDV}) + pertCoords = self.DVGeo.update(self.ptName) + valDV[i] -= self.CS_delta * 1j + + dXdDV = np.imag(pertCoords) / self.CS_delta + + # Sum the derivatives + for j in range(sensCS.shape[0]): + sensCS[j, i] = coeff[j] * np.sum(dXdDV) + + np.testing.assert_allclose(sens, sensCS, atol=self.sensTol, rtol=self.sensTol) + + +class TestFunctionality(unittest.TestCase): + """ + This class tests that some simple methods run without errors. + """ + + def test_plotCST(self): + DVGeometryCST.plotCST(np.ones(4), np.ones(3)) + + def test_print(self): + curDir = os.path.abspath(os.path.dirname(__file__)) + nUpper = 5 + nLower = 3 + self.DVGeo = DVGeometryCST(os.path.join(curDir, "naca2412.dat"), numCST=[nUpper, nLower]) + + self.DVGeo.addDV("upper", dvType="upper") + self.DVGeo.addDV("lower", dvType="lower") + self.DVGeo.addDV("n1", dvType="n1") + self.DVGeo.addDV("n2", dvType="n2") + self.DVGeo.addDV("chord", dvType="chord") + self.DVGeo.printDesignVariables() + + def test_getNDV(self): + curDir = os.path.abspath(os.path.dirname(__file__)) + nUpper = 5 + nLower = 3 + nOther = 3 # N1, N2, and chord + self.DVGeo = DVGeometryCST(os.path.join(curDir, "naca2412.dat"), numCST=[nUpper, nLower]) + + self.DVGeo.addDV("upper", dvType="upper") + self.DVGeo.addDV("lower", dvType="lower") + self.DVGeo.addDV("n1", dvType="n1") + self.DVGeo.addDV("n2", dvType="n2") + self.DVGeo.addDV("chord", dvType="chord") + + self.assertEqual(nUpper + nLower + nOther, self.DVGeo.getNDV()) + + def test_getValues(self): + curDir = os.path.abspath(os.path.dirname(__file__)) + nUpper = 5 + nLower = 3 + self.DVGeo = DVGeometryCST(os.path.join(curDir, "naca2412.dat"), numCST=[nUpper, nLower]) + + upper = np.full((nUpper,), 0.3) + lower = 0.1 * np.ones(nLower) + N1 = np.array([0.4]) + N2_lower = np.array([1.2]) + chord = np.array([0.5]) + + self.DVGeo.addDV("upper", dvType="upper", default=upper) + self.DVGeo.addDV("lower", dvType="lower") + self.DVGeo.addDV("n1", dvType="n1") + self.DVGeo.addDV("n2_lower", dvType="n2_lower") + self.DVGeo.addDV("chord", dvType="chord") + + DVs = { + "upper": upper, + "lower": lower, + "n1": N1, + "n2_lower": N2_lower, + "chord": chord, + } + self.DVGeo.setDesignVars(DVs) + + valDVs = self.DVGeo.getValues() + + for dvName in DVs.keys(): + np.testing.assert_array_equal(DVs[dvName], valDVs[dvName]) + + def test_getVarNames(self): + curDir = os.path.abspath(os.path.dirname(__file__)) + self.DVGeo = DVGeometryCST(os.path.join(curDir, "naca2412.dat")) + + dvNames = ["amy", "joesph", "maryann", "tobysue", "sir blue bus"] + + self.DVGeo.addDV(dvNames[0], dvType="upper") + self.DVGeo.addDV(dvNames[1], dvType="lower") + self.DVGeo.addDV(dvNames[2], dvType="n1") + self.DVGeo.addDV(dvNames[3], dvType="n2_lower") + self.DVGeo.addDV(dvNames[4], dvType="chord") + + names = self.DVGeo.getVarNames() + + for name in names: + self.assertTrue(name in dvNames) + + +class TestErrorChecking(unittest.TestCase): + def setUp(self): + curDir = os.path.abspath(os.path.dirname(__file__)) + self.DVGeo = DVGeometryCST(os.path.join(curDir, "naca2412.dat"), numCST=4) + + def test_addPointSet_min_out_of_bounds(self): + points = np.array( + [ + [0.5, 0.1, 4.0], + [-0.5, 0.1, 4.0], + ] + ) + with self.assertRaises(ValueError): + self.DVGeo.addPointSet(points, "bjork") + + def test_addPointSet_max_out_of_bounds(self): + points = np.array( + [ + [0.5, 0.1, 4.0], + [1.5, 0.1, 4.0], + ] + ) + with self.assertRaises(ValueError): + self.DVGeo.addPointSet(points, "jacobo") + + def test_addDV_invalid_type(self): + with self.assertRaises(ValueError): + self.DVGeo.addDV("samantha", dvType="this is an invalid type") + + def test_addDV_duplicate_n1(self): + self.DVGeo.addDV("silver baboon", dvType="n1", default=np.array([0.4])) + with self.assertRaises(ValueError): + self.DVGeo.addDV("panda express", dvType="n1_upper") + + def test_addDV_duplicate_n1_reverse(self): + self.DVGeo.addDV("candace", dvType="n1_lower") + with self.assertRaises(ValueError): + self.DVGeo.addDV("richard", dvType="n1") + + def test_addDV_duplicate_n2(self): + self.DVGeo.addDV("harry", dvType="n2") + with self.assertRaises(ValueError): + self.DVGeo.addDV("bobby", dvType="n2_upper") + + def test_addDV_duplicate_n2_reverse(self): + self.DVGeo.addDV("bob haimes", dvType="n2_lower") + with self.assertRaises(ValueError): + self.DVGeo.addDV("hannah", dvType="n2") + + def test_addDV_duplicate_same_type(self): + self.DVGeo.addDV("ali", dvType="upper") + with self.assertRaises(ValueError): + self.DVGeo.addDV("anil", dvType="upper") + + def test_addDV_duplicate_same_name(self): + self.DVGeo.addDV("josh", dvType="upper") + with self.assertRaises(ValueError): + self.DVGeo.addDV("josh", dvType="lower") + + def test_addDV_duplicate_invalid_default_type(self): + with self.assertRaises(ValueError): + self.DVGeo.addDV("timo", dvType="chord", default=5.0) + + def test_addDV_duplicate_invalid_default_size(self): + with self.assertRaises(ValueError): + self.DVGeo.addDV("brick", dvType="upper", default=np.array([5.0, 1])) + + def test_setDesignVars_invalid_shape(self): + self.DVGeo.addDV("mafa", dvType="upper") + with self.assertRaises(ValueError): + self.DVGeo.setDesignVars({"mafa": np.array([1.0, 3.0])}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/reg_tests/test_DVGeometryMulti.py b/tests/reg_tests/test_DVGeometryMulti.py index d4490d06..22109627 100644 --- a/tests/reg_tests/test_DVGeometryMulti.py +++ b/tests/reg_tests/test_DVGeometryMulti.py @@ -99,7 +99,7 @@ def test_boxes(self, train=False): nTwist = nRefAxPts - 1 # Set up a twist variable - def twist(val, geo): + def twist(val, geo, nRefAxPts=nRefAxPts): for i in range(1, nRefAxPts): geo.rot_z["box"].coef[i] = val[i - 1]