diff --git a/.github/azure-pipelines.yaml b/.github/azure-pipelines.yaml index 91512bf3..23d0e6f4 100644 --- a/.github/azure-pipelines.yaml +++ b/.github/azure-pipelines.yaml @@ -1,8 +1,8 @@ trigger: - - master + - main pr: - - master + - main resources: repositories: diff --git a/README.md b/README.md index a1601c6f..1468e3bc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # pyGeo -[![Build Status](https://dev.azure.com/mdolab/Public/_apis/build/status/mdolab.pygeo?branchName=master)](https://dev.azure.com/mdolab/Public/_build/latest?definitionId=17&branchName=master) +[![Build Status](https://dev.azure.com/mdolab/Public/_apis/build/status/mdolab.pygeo?branchName=main)](https://dev.azure.com/mdolab/Public/_build/latest?definitionId=17&branchName=main) [![Documentation Status](https://readthedocs.com/projects/mdolab-pygeo/badge/?version=latest)](https://mdolab-pygeo.readthedocs-hosted.com/en/latest/?badge=latest) -[![codecov](https://codecov.io/gh/mdolab/pygeo/branch/master/graph/badge.svg?token=N2L58WGCDI)](https://codecov.io/gh/mdolab/pygeo) +[![codecov](https://codecov.io/gh/mdolab/pygeo/branch/main/graph/badge.svg?token=N2L58WGCDI)](https://codecov.io/gh/mdolab/pygeo) pyGeo is an object oriented geometry manipulation framework for multidisciplinary design optimization. It provides a free form deformation (FFD) based geometry manipulation object, an interface to NASA's Vehicle Sketch Pad geometry engine, a simple geometric constraint formulation object, and some utility functions for geometry manipulation. diff --git a/pygeo/__init__.py b/pygeo/__init__.py index b4714a3a..e4c00942 100644 --- a/pygeo/__init__.py +++ b/pygeo/__init__.py @@ -1,4 +1,4 @@ -__version__ = "1.9.0" +__version__ = "1.10.0" from .pyNetwork import pyNetwork from .pyGeo import pyGeo diff --git a/pygeo/constraints/areaConstraint.py b/pygeo/constraints/areaConstraint.py index 4c2f4c6e..89abf080 100644 --- a/pygeo/constraints/areaConstraint.py +++ b/pygeo/constraints/areaConstraint.py @@ -86,11 +86,11 @@ def getVarNames(self): variables, but some constraints may extend this to include other variables. """ if self.DVGeo1 is not None: - varnamelist = self.DVGeo1.getVarNames() + varnamelist = self.DVGeo1.getVarNames(pyOptSparse=True) if self.DVGeo2 is not None: - varnamelist.extend(self.DVGeo2.getVarNames()) + varnamelist.extend(self.DVGeo2.getVarNames(pyOptSparse=True)) else: - varnamelist = self.DVGeo2.getVarNames() + varnamelist = self.DVGeo2.getVarNames(pyOptSparse=True) return varnamelist diff --git a/pygeo/constraints/baseConstraint.py b/pygeo/constraints/baseConstraint.py index dc68790e..072bfcb7 100644 --- a/pygeo/constraints/baseConstraint.py +++ b/pygeo/constraints/baseConstraint.py @@ -2,6 +2,7 @@ # Imports # ====================================================================== from abc import ABC, abstractmethod +from collections import OrderedDict import numpy as np from baseclasses.utils import Error @@ -56,7 +57,7 @@ def getVarNames(self): return the var names relevant to this constraint. By default, this is the DVGeo variables, but some constraints may extend this to include other variables. """ - return self.DVGeo.getVarNames() + return self.DVGeo.getVarNames(pyOptSparse=True) def addConstraintsPyOpt(self, optProb, exclude_wrt=None): """ @@ -256,6 +257,32 @@ def _finalize(self): # with-respect-to are just the keys of the jacobian self.wrt = list(self.jac.keys()) + # now map the jac to composite domain: + # we assume jac is always only wrt "local" DVs + if self.DVGeo.useComposite: + nDV = self.DVGeo.getNDV() + # for the jac, we need to "pad" the rest of the matrix with zero, then perform mat-mat product + newJac = np.zeros((self.ncon, nDV)) + for i in range(self.ncon): + temp_dict = {} + # all_DVs just contains all the DVs so we can loop over them easily + all_DVs = OrderedDict({}) + all_DVs.update(self.DVGeo.DV_listGlobal) + all_DVs.update(self.DVGeo.DV_listLocal) + all_DVs.update(self.DVGeo.DV_listSectionLocal) + all_DVs.update(self.DVGeo.DV_listSpanwiseLocal) + + for dv in all_DVs.keys(): + if dv in self.wrt: + temp_dict[dv] = self.jac[dv][i, :].flatten() + else: + temp_dict[dv] = np.zeros(all_DVs[dv].nVal) + newJac[i, :] = self.DVGeo.convertDictToSensitivity(temp_dict) + # now multiply by the mapping + newJac = newJac @ self.DVGeo.DVComposite.u + self.jac = {self.DVGeo.DVComposite.name: newJac} + self.wrt = [self.DVGeo.DVComposite.name] + def writeTecplot(self, handle): """ Write the visualization of this set of lete constraints diff --git a/pygeo/constraints/thicknessConstraint.py b/pygeo/constraints/thicknessConstraint.py index 62c50461..aca21f64 100644 --- a/pygeo/constraints/thicknessConstraint.py +++ b/pygeo/constraints/thicknessConstraint.py @@ -27,7 +27,7 @@ def __init__(self, name, coords, lower, upper, scaled, scale, DVGeo, addToPyOpt, # Now get the reference lengths self.D0 = np.zeros(self.nCon) for i in range(self.nCon): - self.D0[i] = np.linalg.norm(self.coords[2 * i] - self.coords[2 * i + 1]) + self.D0[i] = geo_utils.norm.euclideanNorm(self.coords[2 * i] - self.coords[2 * i + 1]) def evalFunctions(self, funcs, config): """ @@ -42,7 +42,7 @@ def evalFunctions(self, funcs, config): self.coords = self.DVGeo.update(self.name, config=config) D = np.zeros(self.nCon) for i in range(self.nCon): - D[i] = np.linalg.norm(self.coords[2 * i] - self.coords[2 * i + 1]) + D[i] = geo_utils.norm.euclideanNorm(self.coords[2 * i] - self.coords[2 * i + 1]) if self.scaled: D[i] /= self.D0[i] funcs[self.name] = D diff --git a/pygeo/geo_utils/misc.py b/pygeo/geo_utils/misc.py index e987452e..6fcb8979 100644 --- a/pygeo/geo_utils/misc.py +++ b/pygeo/geo_utils/misc.py @@ -65,7 +65,9 @@ def convertTo1D(value, dim1): if temp.shape[0] == dim1: return value else: - raise ValueError("The size of the 1D array was the incorrect shape") + raise ValueError( + "The size of the 1D array was the incorrect shape! " + f"Expected {dim1} but got {temp.size}" + ) def convertTo2D(value, dim1, dim2): diff --git a/pygeo/geo_utils/norm.py b/pygeo/geo_utils/norm.py index c3b35dee..442d1337 100644 --- a/pygeo/geo_utils/norm.py +++ b/pygeo/geo_utils/norm.py @@ -12,11 +12,7 @@ def euclideanNorm(inVec): CS derivatives. """ inVec = np.array(inVec) - temp = 0.0 - for i in range(inVec.shape[0]): - temp += inVec[i] ** 2 - - return np.sqrt(temp) + return np.sqrt(inVec.dot(inVec)) def cross_b(a, b, crossb): diff --git a/pygeo/parameterization/DVGeo.py b/pygeo/parameterization/DVGeo.py index 89f0fff0..fd6c3dd5 100644 --- a/pygeo/parameterization/DVGeo.py +++ b/pygeo/parameterization/DVGeo.py @@ -13,7 +13,7 @@ import os import warnings from baseclasses.utils import Error -from .designVars import geoDVGlobal, geoDVLocal, geoDVSpanwiseLocal, geoDVSectionLocal +from .designVars import geoDVGlobal, geoDVLocal, geoDVSpanwiseLocal, geoDVSectionLocal, geoDVComposite class DVGeometry: @@ -99,6 +99,7 @@ def __init__(self, fileName, *args, isComplex=False, child=False, faceFreeze=Non self.DV_listLocal = OrderedDict() # Local Design Variable List self.DV_listSectionLocal = OrderedDict() # Local Normal Design Variable List self.DV_listSpanwiseLocal = OrderedDict() # Local Spanwise Design Variable List + self.DVComposite = None # Composite Design Variable # FIXME: for backwards compatibility we still allow the argument complex=True/False # which we now check in kwargs and overwrite @@ -190,6 +191,7 @@ def __init__(self, fileName, *args, isComplex=False, child=False, faceFreeze=Non self.nDVL_count = 0 # number of local (L) variables self.nDVSL_count = 0 # number of section (SL) local variables self.nDVSW_count = 0 # number of spanwise (SW) local variables + self.useComposite = False # The set of user supplied axis. self.axis = OrderedDict() @@ -1237,6 +1239,45 @@ class in geo_utils. Using pointSelect discards everything in volList. return self.DV_listSectionLocal[dvName].nVal + def addCompositeDV(self, dvName, ptSetName=None, u=None, scale=None): + """ + Add composite DVs. Note that this is essentially a preprocessing call which only works in serial + at the moment. + + Parameters + ---------- + dvName : str + The name of the composite DVs + ptSetName : str, optional + If the matrices need to be computed, then a point set must be specified, by default None + u : ndarray, optional + The u matrix used for the composite DV, by default None + scale : float or ndarray, optional + The scaling applied to this DV, by default None + """ + NDV = self.getNDV() + self.useComposite = True + if self.name is not None: + dvName = f"{self.name}_{dvName}" + if u is not None: + # we are after a square matrix + if u.shape != (NDV, NDV): + raise ValueError(f"The shapes don't match! Got shape = {u.shape} but NDV = {NDV}") + if scale is None: + raise ValueError("If u is provided, then scale must also be provided.") + s = None + else: + if ptSetName is None: + raise ValueError("If u and s need to be computed, you must specify the ptSetName") + self.computeTotalJacobian(ptSetName) + J_full = self.JT[ptSetName].todense() # this is in CSR format but we convert it to a dense matrix + u, s, _ = np.linalg.svd(J_full) + scale = np.sqrt(s) + # normalize the scaling + scale = scale * (NDV / np.sum(scale)) + + self.DVComposite = geoDVComposite(dvName, NDV, u, scale=scale, s=s) + def addGeoDVSectionLocal(self, *args, **kwargs): warnings.warn("addGeoDVSectionLocal will be deprecated, use addLocalSectionDV instead") return self.addLocalSectionDV(*args, **kwargs) @@ -1351,6 +1392,9 @@ def setDesignVars(self, dvDict): self._finalize() self._complexifyCoef() + if self.useComposite: + dvDict = self.mapXDictToDVGeo(dvDict) + for key in dvDict: if key in self.DV_listGlobal: vals_to_set = np.atleast_1d(dvDict[key]).astype("D") @@ -1448,6 +1492,9 @@ def getValues(self): childdvDict = child.getValues() dvDict.update(childdvDict) + if self.useComposite: + dvDict = self.mapXDictToComp(dvDict) + return dvDict def extractCoef(self, axisID): @@ -1793,7 +1840,7 @@ def pointSetUpToDate(self, ptSetName): else: return True - def convertSensitivityToDict(self, dIdx, out1D=False): + def convertSensitivityToDict(self, dIdx, out1D=False, useCompositeNames=False): """ This function takes the result of totalSensitivity and converts it to a dict for use in pyOptSparse @@ -1808,6 +1855,11 @@ def convertSensitivityToDict(self, dIdx, out1D=False): If true, creates a 1D array in the dictionary instead of 2D. This function is used in the matrix-vector product calculation. + useCompositeNames : boolean + Whether the sensitivity dIdx is with respect to the composite DVs or the original DVGeo DVs. + If False, the returned dictionary will have keys corresponding to the original set of geometric DVs. + If True, the returned dictionary will have replace those with a single key corresponding to the composite DV name. + Returns ------- dIdxDict : dictionary @@ -1858,7 +1910,9 @@ def convertSensitivityToDict(self, dIdx, out1D=False): # Add in child portion for iChild in range(len(self.children)): - childdIdx = self.children[iChild].convertSensitivityToDict(dIdx, out1D=out1D) + childdIdx = self.children[iChild].convertSensitivityToDict( + dIdx, out1D=out1D, useCompositeNames=useCompositeNames + ) # update the total sensitivities with the derivatives from the child for key in childdIdx: if key in dIdxDict.keys(): @@ -1866,6 +1920,14 @@ def convertSensitivityToDict(self, dIdx, out1D=False): else: dIdxDict[key] = childdIdx[key] + # replace other names with user + if useCompositeNames and self.useComposite: + array = [] + for _key, val in dIdxDict.items(): + array.append(val) + array = np.hstack(array) + dIdxDict = {self.DVComposite.name: array} + return dIdxDict def convertDictToSensitivity(self, dIdxDict): @@ -1919,19 +1981,28 @@ def convertDictToSensitivity(self, dIdxDict): dIdx += childdIdx return dIdx - def getVarNames(self): + def getVarNames(self, pyOptSparse=False): """ Return a list of the design variable names. This is typically used when specifying a wrt= argument for pyOptSparse. + Parameters + ---------- + pyOptSparse : bool + Flag to specify whether the DVs returned should be those in the optProb or those internal to DVGeo. + Only relevant if using composite DVs. + Examples -------- optProb.addCon(.....wrt=DVGeo.getVarNames()) """ - names = list(self.DV_listGlobal.keys()) - names.extend(list(self.DV_listLocal.keys())) - names.extend(list(self.DV_listSectionLocal.keys())) - names.extend(list(self.DV_listSpanwiseLocal.keys())) + if not pyOptSparse or not self.useComposite: + names = list(self.DV_listGlobal.keys()) + names.extend(list(self.DV_listLocal.keys())) + names.extend(list(self.DV_listSectionLocal.keys())) + names.extend(list(self.DV_listSpanwiseLocal.keys())) + else: + names = [self.DVComposite.name] # Call the children recursively for iChild in range(len(self.children)): @@ -2002,8 +2073,11 @@ def totalSensitivity(self, dIdpt, ptSetName, comm=None, config=None): else: dIdx = dIdx_local + if self.useComposite: + dIdx = self.mapSensToComp(dIdx) + # Now convert to dict: - dIdx = self.convertSensitivityToDict(dIdx) + dIdx = self.convertSensitivityToDict(dIdx, useCompositeNames=True) return dIdx @@ -2222,7 +2296,7 @@ def computeTotalJacobian(self, ptSetName, config=None): self._finalize() self.curPtSet = ptSetName - if not (self.JT[ptSetName] is None): + if self.JT[ptSetName] is not None: return # compute the derivatives of the coeficients of this level wrt all of the design @@ -2285,7 +2359,7 @@ def computeTotalJacobianCS(self, ptSetName, config=None): self._finalize() self.curPtSet = ptSetName - if not (self.JT[ptSetName] is None): + if self.JT[ptSetName] is not None: return if self.isChild: @@ -2446,6 +2520,40 @@ def addVariablesPyOpt( ("spanwiselocalVars", self.DV_listSpanwiseLocal), ] ) + + # we add the composite DVs, and construct linear constraints that replace the existing bounds + # then we simply return without adding any of the other DVs + if self.useComposite: + dv = self.DVComposite + optProb.addVarGroup(dv.name, dv.nVal, "c", value=dv.value, lower=dv.lower, upper=dv.upper, scale=dv.scale) + + # add the linear DV constraints that replace the existing bounds! + # Note that we assume all DVs are added here, i.e. no ignoreVars or any of the vars = False + if len(ignoreVars) != 0: + warnings.warn("Use of ignoreVars is incompatible with composite DVs") + lb = {} + ub = {} + for lst in varLists: + for key in varLists[lst]: + dv = varLists[lst][key] + lb[key] = dv.lower + ub[key] = dv.upper + + lb = self.convertDictToSensitivity(lb) + ub = self.convertDictToSensitivity(ub) + + optProb.addConGroup( + f"{self.DVComposite.name}_con", + self.getNDV(), + lower=lb, + upper=ub, + scale=1.0, + linear=True, + wrt=self.DVComposite.name, + jac={self.DVComposite.name: self.DVComposite.u}, + ) + return + for lst in varLists: if ( lst == "globalVars" @@ -3363,6 +3471,105 @@ def _unComplexifyCoef(self): self.coef = self.coef.real.astype("d") + def mapXDictToDVGeo(self, inDict): + """ + Map a dictionary of DVs to the 'DVGeo' design, while keeping non-DVGeo DVs in place + without modifying them + + Parameters + ---------- + inDict : dict + The dictionary of DVs to be mapped + + Returns + ------- + dict + The mapped DVs in the same dictionary format + """ + # first make a copy so we don't modify in place + inDict = copy.deepcopy(inDict) + userVec = inDict.pop(self.DVComposite.name) + outVec = self.mapVecToDVGeo(userVec) + outDict = self.convertSensitivityToDict(outVec.reshape(1, -1), out1D=True, useCompositeNames=False) + # now merge inDict and outDict + for key in inDict: + outDict[key] = inDict[key] + return outDict + + def mapXDictToComp(self, inDict): + """ + The inverse of :func:`mapXDictToDVGeo`, where we map the DVs to the composite space + + Parameters + ---------- + inDict : dict + The DVs to be mapped + + Returns + ------- + dict + The mapped DVs + """ + # first make a copy so we don't modify in place + inDict = copy.deepcopy(inDict) + userVec = self.convertDictToSensitivity(inDict) + outVec = self.mapVecToComp(userVec) + outDict = self.convertSensitivityToDict(outVec.reshape(1, -1), out1D=True, useCompositeNames=True) + return outDict + + def mapVecToDVGeo(self, inVec): + """ + This is the vector version of :func:`mapDictToDVGeo`, where the actual mapping is done + + Parameters + ---------- + inVec : ndarray + The DVs in a single 1D array + + Returns + ------- + ndarray + The mapped DVs in a single 1D array + """ + inVec = inVec.reshape(self.getNDV(), -1) + outVec = self.DVComposite.u @ inVec + return outVec.flatten() + + def mapVecToComp(self, inVec): + """ + This is the vector version of :func:`mapDictToComp`, where the actual mapping is done + + Parameters + ---------- + inVec : ndarray + The DVs in a single 1D array + + Returns + ------- + ndarray + The mapped DVs in a single 1D array + """ + inVec = inVec.reshape(self.getNDV(), -1) + outVec = self.DVComposite.u.T @ inVec + return outVec.flatten() + + def mapSensToComp(self, inVec): + """ + Maps the sensitivity matrix to the composite design space + + Parameters + ---------- + inVec : ndarray + The sensitivities to be mapped + + Returns + ------- + ndarray + The mapped sensitivity matrix + """ + outVec = inVec @ self.DVComposite.u # this is the same as (self.DVComposite.u.T @ inVec.T).T + return outVec + def computeTotalJacobianFD(self, ptSetName, config=None): """This function takes the total derivative of an objective, I, with respect the points controlled on this processor using FD. @@ -3374,7 +3581,7 @@ def computeTotalJacobianFD(self, ptSetName, config=None): self._finalize() self.curPtSet = ptSetName - if not (self.JT[ptSetName] is None): + if self.JT[ptSetName] is not None: return if self.isChild: diff --git a/pygeo/parameterization/DVGeoESP.py b/pygeo/parameterization/DVGeoESP.py index 1daf5958..addc1d92 100644 --- a/pygeo/parameterization/DVGeoESP.py +++ b/pygeo/parameterization/DVGeoESP.py @@ -739,7 +739,7 @@ def getNDV(self): Return the number of DVs""" return len(self.globalDVList) - def getVarNames(self): + def getVarNames(self, pyOptSparse=False): """ Return a list of the design variable names. This is typically used when specifying a wrt= argument for pyOptSparse. diff --git a/pygeo/parameterization/DVGeoMulti.py b/pygeo/parameterization/DVGeoMulti.py index 0a5d9c37..3859fa1e 100644 --- a/pygeo/parameterization/DVGeoMulti.py +++ b/pygeo/parameterization/DVGeoMulti.py @@ -531,7 +531,7 @@ def getNDV(self): nDV += self.comps[comp].DVGeo.getNDV() return nDV - def getVarNames(self): + def getVarNames(self, pyOptSparse=False): """ Return a list of the design variable names. This is typically used when specifying a ``wrt=`` argument for pyOptSparse. diff --git a/pygeo/parameterization/DVGeoVSP.py b/pygeo/parameterization/DVGeoVSP.py index ae414772..35fd6409 100644 --- a/pygeo/parameterization/DVGeoVSP.py +++ b/pygeo/parameterization/DVGeoVSP.py @@ -429,7 +429,7 @@ def getNDV(self): """ return len(self.DVs) - def getVarNames(self): + def getVarNames(self, pyOptSparse=False): """ Return a list of the design variable names. This is typically used when specifying a wrt= argument for pyOptSparse. diff --git a/pygeo/parameterization/designVars.py b/pygeo/parameterization/designVars.py index 18706cb8..55c21cd3 100644 --- a/pygeo/parameterization/designVars.py +++ b/pygeo/parameterization/designVars.py @@ -299,3 +299,18 @@ def mapIndexSets(self, indSetA, indSetB): cons.append([up, down]) return cons + + +class geoDVComposite(object): + def __init__(self, dvName, nVal, u, scale=1.0, s=None): + """ + Create a set of design variables which are linear combinations of existing design variables. + """ + self.name = dvName + self.nVal = nVal + self.value = np.zeros(self.nVal, "D") + self.lower = None + self.upper = None + self.scale = convertTo1D(scale, self.nVal) + self.u = u + self.s = s