Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Precise thickness and volume constraints for non-trapezoidal wings #127

Merged
merged 5 commits into from
Mar 24, 2022
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 82 additions & 21 deletions pygeo/constraints/DVCon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -575,19 +579,24 @@ 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))

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()

# Create a name
if name is None:
conName = "%s_thickness_constraints_%d" % (self.name, len(self.constraints[typeName]))
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.")
marcomangano marked this conversation as resolved.
Show resolved Hide resolved

# 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if I like this change to what nSpan is mid-way through the routine. Initially its the list of spanwise section counts, but then it is switched to the total count. I guess it would be a bit better if we used a different variable for the total (or the span list)? I am not sure how useful my comment is but thought this type of using the same variable name for two things might be confusing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that the current approach is not very clean. I can propose two alternatives that would keep nSpan as an integer:

  1. Add a separate kwarg that is a list defining the fraction of nSpan that gets allocated to each segment, e.g. [0.5, 0.5].
  2. Don't add any additional arguments. For a given uniform distribution in parametric space, 'snap' the closest point to the intermediate location and resample the curves. This would change the default behavior.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Turns out Anil was referring to the variable names in the function, not the API. I will update the variable names to be clearer.

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):
Expand Down
50 changes: 50 additions & 0 deletions tests/reg_tests/test_DVConstraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down