diff --git a/pygeo/constraints/DVCon.py b/pygeo/constraints/DVCon.py index e152712c..5cd52de3 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,19 +579,23 @@ 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) + # 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, nSpanTotal, nChord).flatten() + lower = convertTo2D(lower, nSpanTotal, nChord).flatten() + scale = convertTo2D(scale, nSpanTotal, nChord).flatten() + # Create a name if name is None: conName = "%s_thickness_constraints_%d" % (self.name, len(self.constraints[typeName])) @@ -1610,18 +1618,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,12 +1709,16 @@ def addVolumeConstraint( conName = name coords = self._generateIntersections(leList, teList, nSpan, nChord, surfaceName) - coords = coords.reshape((nSpan * nChord * 2, 3)) + + # Get the total number of spanwise sections + nSpanTotal = np.sum(nSpan) + + coords = coords.reshape((nSpanTotal * nChord * 2, 3)) # Finally add the volume constraint object self.constraints[typeName][conName] = VolumeConstraint( conName, - nSpan, + nSpanTotal, nChord, coords, lower, @@ -3204,20 +3219,64 @@ 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) + ) + 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(span_s), te_s(span_s), root_s(chord_s), tip_s(chord_s)) - coords = np.zeros((nSpan, nChord, 2, 3)) - for i in range(nSpan): + 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((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] 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: