From cab0c8f720fdb0c58b988f5135f18ce92d4c48ec Mon Sep 17 00:00:00 2001 From: sseraj Date: Wed, 23 Mar 2022 13:24:06 -0400 Subject: [PATCH 1/3] added more precise thickness and volume constraints --- pygeo/constraints/DVCon.py | 103 +++++++++++++++++++++++++++++-------- 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/pygeo/constraints/DVCon.py b/pygeo/constraints/DVCon.py index e152712c..498f62bd 100644 --- a/pygeo/constraints/DVCon.py +++ b/pygeo/constraints/DVCon.py @@ -460,13 +460,14 @@ def addThicknessConstraints2D( * The leading and trailing edges are approximated using 2-order splines (line segments) and nSpan points are - interpolated in a linear fashion. Note that the thickness - constraint may not correspond **EXACT** to intermediate - locations in leList and teList. For example, in the example - above, with leList=3 and nSpan=3, the three thickness + interpolated in a linear fashion. For integer nSpan, the thickness + constraint may not correspond **EXACTLY** to intermediate + locations in leList and teList. In the example above, + with len(leList)=3 and nSpan=3, the three thickness constraints on the leading edge of the 2D domain would be at the left and right boundaries, and at the point denoted by - 'o' which is equidistance between the root and tip. + 'o' which is equidistant between the root and tip. + To match intermediate locations exactly, pass a list for nSpan. * If a curved leading or trailing edge domain is desired, simply pass in lists for leList and teList with a sufficient @@ -490,18 +491,21 @@ def addThicknessConstraints2D( leList : list or array A list or array of points (size should be (Nx3) where N is at least 2) defining the 'leading edge' or the start of the - domain + domain. teList : list or array Same as leList but for the trailing edge. - nSpan : int + nSpan : int or list of int The number of thickness constraints to be (linear) - interpolated *along* the leading and trailing edges + interpolated *along* the leading and trailing edges. + A list of length N-1 can be used to specify the number + for each segment defined by leList and teList and + precisely match intermediate locations. nChord : int The number of thickness constraints to be (linearly) - interpolated between the leading and trailing edges + interpolated between the leading and trailing edges. lower : float or array of size (nSpan x nChord) The lower bound for the constraint. A single float will @@ -575,12 +579,13 @@ def addThicknessConstraints2D( """ self._checkDVGeo(DVGeoName) - upper = convertTo2D(upper, nSpan, nChord).flatten() - lower = convertTo2D(lower, nSpan, nChord).flatten() - scale = convertTo2D(scale, nSpan, nChord).flatten() coords = self._generateIntersections(leList, teList, nSpan, nChord, surfaceName) + # nSpan needs to be the total number of sections for the remainder of the function + if isinstance(nSpan, list): + nSpan = sum(nSpan) + # Create the thickness constraint object: coords = coords.reshape((nSpan * nChord * 2, 3)) @@ -588,6 +593,10 @@ def addThicknessConstraints2D( if typeName not in self.constraints: self.constraints[typeName] = OrderedDict() + upper = convertTo2D(upper, nSpan, nChord).flatten() + lower = convertTo2D(lower, nSpan, nChord).flatten() + scale = convertTo2D(scale, nSpan, nChord).flatten() + # Create a name if name is None: conName = "%s_thickness_constraints_%d" % (self.name, len(self.constraints[typeName])) @@ -1610,18 +1619,21 @@ def addVolumeConstraint( leList : list or array A list or array of points (size should be (Nx3) where N is at least 2) defining the 'leading edge' or the start of the - domain + domain. teList : list or array Same as leList but for the trailing edge. - nSpan : int - The number of thickness constraints to be (linear) - interpolated *along* the leading and trailing edges + nSpan : int or list of int + The number of projected points to be (linear) + interpolated *along* the leading and trailing edges. + A list of length N-1 can be used to specify the number + for each segment defined by leList and teList and + precisely match intermediate locations. nChord : int - The number of thickness constraints to be (linearly) - interpolated between the leading and trailing edges + The number of projected points to be (linearly) + interpolated between the leading and trailing edges. lower : float The lower bound for the volume constraint. @@ -1698,6 +1710,11 @@ def addVolumeConstraint( conName = name coords = self._generateIntersections(leList, teList, nSpan, nChord, surfaceName) + + # nSpan needs to be the total number of sections for the remainder of the function + if isinstance(nSpan, list): + nSpan = sum(nSpan) + coords = coords.reshape((nSpan * nChord * 2, 3)) # Finally add the volume constraint object @@ -3204,12 +3221,56 @@ def _generateIntersections(self, leList, teList, nSpan, nChord, surfaceName): root_s = Curve(X=[leList[0], teList[0]], k=2) tip_s = Curve(X=[leList[-1], teList[-1]], k=2) - # Generate parametric distances - span_s = np.linspace(0.0, 1.0, nSpan) + # Generate spanwise parametric distances + if isinstance(nSpan, int): + # Use equal spacing along the curve + le_span_s = te_span_s = np.linspace(0.0, 1.0, nSpan) + elif isinstance(nSpan, list): + # Use equal spacing within each segment defined by leList and teList + + # We use the same nSpan for the leading and trailing edges, so check that the lists are the same size + if len(leList) != len(teList): + raise ValueError("leList and teList must be the same length if nSpan is provided as a list.") + + # Also check that nSpan is the correct length + numSegments = len(leList) - 1 + if len(nSpan) != numSegments: + raise ValueError(f"nSpan must be of length {numSegments}.") + + # Find the parametric distances of the break points that define each segment + le_breakPoints = le_s.projectPoint(leList)[0] + te_breakPoints = te_s.projectPoint(teList)[0] + + # Initialize empty arrays for the full spanwise parameteric distances + le_span_s = np.array([]) + te_span_s = np.array([]) + + for i in range(numSegments): + + # Only include the endpoint if this is the last segment to avoid double counting points + if i == numSegments - 1: + endpoint = True + else: + endpoint = False + + # Interpolate over this segment and append to the parametric distance array + le_span_s = np.append( + le_span_s, np.linspace(le_breakPoints[i], le_breakPoints[i + 1], nSpan[i], endpoint=endpoint) + ) + te_span_s = np.append( + te_span_s, np.linspace(te_breakPoints[i], te_breakPoints[i + 1], nSpan[i], endpoint=endpoint) + ) + + # nSpan needs to be the total number of sections for the remainder of the function + nSpan = sum(nSpan) + else: + raise TypeError("nSpan must be either an int or a list.") + + # Generate chordwise parametric distances chord_s = np.linspace(0.0, 1.0, nChord) # Generate a 2D region of intersections - X = geo_utils.tfi_2d(le_s(span_s), te_s(span_s), root_s(chord_s), tip_s(chord_s)) + X = geo_utils.tfi_2d(le_s(le_span_s), te_s(te_span_s), root_s(chord_s), tip_s(chord_s)) coords = np.zeros((nSpan, nChord, 2, 3)) for i in range(nSpan): for j in range(nChord): From bc05a203b6efbb1229e60695096b112fe5c4f220 Mon Sep 17 00:00:00 2001 From: sseraj Date: Wed, 23 Mar 2022 13:24:22 -0400 Subject: [PATCH 2/3] added tests --- tests/reg_tests/test_DVConstraints.py | 50 +++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/reg_tests/test_DVConstraints.py b/tests/reg_tests/test_DVConstraints.py index 06ba0838..8e7fd8a1 100644 --- a/tests/reg_tests/test_DVConstraints.py +++ b/tests/reg_tests/test_DVConstraints.py @@ -330,6 +330,31 @@ def test_thickness2D(self, train=False, refDeriv=False): funcs, funcsSens = self.wing_test_deformed(DVGeo, DVCon, handler) + def test_thickness2D_nSpanList(self, train=False, refDeriv=False): + refFile = os.path.join(self.base_path, "ref/test_DVConstraints_thickness2D.ref") + with BaseRegTest(refFile, train=train) as handler: + DVGeo, DVCon = self.generate_dvgeo_dvcon("c172") + + leList = [[0.7, 0.0, 0.1], [0.7, 0.0, 1.325], [0.7, 0.0, 5.0]] + teList = [[0.9, 0.0, 0.1], [0.9, 0.0, 1.325], [0.9, 0.0, 5.0]] + + # Use a list for nSpan instead of an integer + DVCon.addThicknessConstraints2D(leList, teList, [1, 4], 5) + + funcs, funcsSens = generic_test_base(DVGeo, DVCon, handler) + # 2D thickness should be all ones at the start + handler.assert_allclose( + funcs["DVCon1_thickness_constraints_0"], np.ones(25), name="thickness_base", rtol=1e-7, atol=1e-7 + ) + + funcs, funcsSens = self.wing_test_twist(DVGeo, DVCon, handler) + # 2D thickness shouldn't change much under only twist + handler.assert_allclose( + funcs["DVCon1_thickness_constraints_0"], np.ones(25), name="thickness_twisted", rtol=1e-2, atol=1e-2 + ) + + funcs, funcsSens = self.wing_test_deformed(DVGeo, DVCon, handler) + def test_thickness2D_box(self, train=False, refDeriv=False): refFile = os.path.join(self.base_path, "ref/test_DVConstraints_thickness2D_box.ref") with BaseRegTest(refFile, train=train) as handler: @@ -384,6 +409,31 @@ def test_volume(self, train=False, refDeriv=False): funcs, funcsSens = self.wing_test_deformed(DVGeo, DVCon, handler) + def test_volume_nSpanList(self, train=False, refDeriv=False): + refFile = os.path.join(self.base_path, "ref/test_DVConstraints_volume.ref") + with BaseRegTest(refFile, train=train) as handler: + DVGeo, DVCon = self.generate_dvgeo_dvcon("c172") + + leList = [[0.7, 0.0, 0.1], [0.7, 0.0, 1.325], [0.7, 0.0, 5.0]] + teList = [[0.9, 0.0, 0.1], [0.9, 0.0, 1.325], [0.9, 0.0, 5.0]] + + # Use a list for nSpan instead of an integer + DVCon.addVolumeConstraint(leList, teList, [1, 4], 5) + + funcs, funcsSens = generic_test_base(DVGeo, DVCon, handler) + # Volume should be normalized to 1 at the start + handler.assert_allclose( + funcs["DVCon1_volume_constraint_0"], np.ones(1), name="volume_base", rtol=1e-7, atol=1e-7 + ) + + funcs, funcsSens = self.wing_test_twist(DVGeo, DVCon, handler) + # Volume shouldn't change much with twist only + handler.assert_allclose( + funcs["DVCon1_volume_constraint_0"], np.ones(1), name="volume_twisted", rtol=1e-2, atol=1e-2 + ) + + funcs, funcsSens = self.wing_test_deformed(DVGeo, DVCon, handler) + def test_volume_box(self, train=False, refDeriv=False): refFile = os.path.join(self.base_path, "ref/test_DVConstraints_volume_box.ref") with BaseRegTest(refFile, train=train) as handler: From 2d6d6715a78dbed3705a2389bcb3d73d54877430 Mon Sep 17 00:00:00 2001 From: sseraj Date: Thu, 24 Mar 2022 17:12:08 -0400 Subject: [PATCH 3/3] clarify nSpan variable --- pygeo/constraints/DVCon.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/pygeo/constraints/DVCon.py b/pygeo/constraints/DVCon.py index 498f62bd..5cd52de3 100644 --- a/pygeo/constraints/DVCon.py +++ b/pygeo/constraints/DVCon.py @@ -582,20 +582,19 @@ def addThicknessConstraints2D( coords = self._generateIntersections(leList, teList, nSpan, nChord, surfaceName) - # nSpan needs to be the total number of sections for the remainder of the function - if isinstance(nSpan, list): - nSpan = sum(nSpan) + # Get the total number of spanwise sections + nSpanTotal = np.sum(nSpan) # Create the thickness constraint object: - coords = coords.reshape((nSpan * nChord * 2, 3)) + coords = coords.reshape((nSpanTotal * nChord * 2, 3)) typeName = "thickCon" if typeName not in self.constraints: self.constraints[typeName] = OrderedDict() - upper = convertTo2D(upper, nSpan, nChord).flatten() - lower = convertTo2D(lower, nSpan, nChord).flatten() - scale = convertTo2D(scale, nSpan, nChord).flatten() + upper = convertTo2D(upper, nSpanTotal, nChord).flatten() + lower = convertTo2D(lower, nSpanTotal, nChord).flatten() + scale = convertTo2D(scale, nSpanTotal, nChord).flatten() # Create a name if name is None: @@ -1711,16 +1710,15 @@ def addVolumeConstraint( coords = self._generateIntersections(leList, teList, nSpan, nChord, surfaceName) - # nSpan needs to be the total number of sections for the remainder of the function - if isinstance(nSpan, list): - nSpan = sum(nSpan) + # Get the total number of spanwise sections + nSpanTotal = np.sum(nSpan) - coords = coords.reshape((nSpan * nChord * 2, 3)) + coords = coords.reshape((nSpanTotal * nChord * 2, 3)) # Finally add the volume constraint object self.constraints[typeName][conName] = VolumeConstraint( conName, - nSpan, + nSpanTotal, nChord, coords, lower, @@ -3260,25 +3258,25 @@ def _generateIntersections(self, leList, teList, nSpan, nChord, surfaceName): te_span_s = np.append( te_span_s, np.linspace(te_breakPoints[i], te_breakPoints[i + 1], nSpan[i], endpoint=endpoint) ) - - # nSpan needs to be the total number of sections for the remainder of the function - nSpan = sum(nSpan) else: raise TypeError("nSpan must be either an int or a list.") # Generate chordwise parametric distances chord_s = np.linspace(0.0, 1.0, nChord) + # Get the total number of spanwise sections + nSpanTotal = np.sum(nSpan) + # Generate a 2D region of intersections X = geo_utils.tfi_2d(le_s(le_span_s), te_s(te_span_s), root_s(chord_s), tip_s(chord_s)) - coords = np.zeros((nSpan, nChord, 2, 3)) - for i in range(nSpan): + coords = np.zeros((nSpanTotal, nChord, 2, 3)) + for i in range(nSpanTotal): for j in range(nChord): # Generate the 'up_vec' from taking the cross product # across a quad if i == 0: uVec = X[i + 1, j] - X[i, j] - elif i == nSpan - 1: + elif i == nSpanTotal - 1: uVec = X[i, j] - X[i - 1, j] else: uVec = X[i + 1, j] - X[i - 1, j]