import numpy as np
from pymead.core.parametric_curve import ParametricCurve, PCurveData
from pymead.core.point import PointSequence, Point
from pymead.core.param import Param
[docs]
class NACA4(ParametricCurve):
[docs]
def __init__(self, max_camber: Param, max_camber_loc: Param, max_thickness: Param, leading_edge: Point,
trailing_edge: Point, upper: bool,
default_nt: int or None = None, name: str or None = None,
cosine_spacing: bool = True,
sharp_trailing_edge: bool = False,
t_start: float = None, t_end: float = None, **kwargs):
r"""
Computes the profile for either the lower or upper surface of a NACA 4-series airfoil.
Parameters
----------
point_sequence: PointSequence
Sequence of points defining the control points for the Bézier curve
name: str or ``None``
Optional name for the curve. Default: ``None``
t_start: float or ``None``
Optional starting parameter vector value for the Bézier curve. Not specifying this value automatically
gives a value of ``0.0``. Default: ``None``
t_end: float or ``None``
Optional ending parameter vector value for the Bézier curve. Not specifying this value automatically
gives a value of ``1.0``. Default: ``None``
"""
super().__init__(sub_container="naca4", **kwargs)
self.a = [0.29690, -0.12600, -0.35160, 0.28430, -0.1036 if sharp_trailing_edge else -0.10150]
self.sharp_trailing_edge = sharp_trailing_edge
self.t0 = 0.2
self._point_sequence = None
self.max_camber = max_camber
self.max_camber_loc = max_camber_loc
self.max_thickness = max_thickness
self.leading_edge = leading_edge
self.trailing_edge = trailing_edge
self.upper = upper
self.cosine_spacing = cosine_spacing
self.default_nt = default_nt
point_sequence = PointSequence(points=[self.leading_edge, self.trailing_edge])
self.set_point_sequence(point_sequence)
self.t_start = t_start
self.t_end = t_end
name = "NACA4-1" if name is None else name
self.set_name(name)
self.curve_connections = []
self._add_references()
def get_4_digit_designation(self) -> str:
digit_1 = int(round(100 * self.max_camber.value(), 0))
digit_2 = int(round(10 * self.max_camber_loc.value(), 0))
digits_34 = int(round(100 * self.max_thickness.value(), 0))
return str(digit_1) + str(digit_2) + str(digits_34)
def _add_references(self):
for idx, point in enumerate(self.point_sequence().points()):
# If any curves are found at the start point, add their pointers as curve connections
if idx == 0:
for curve in point.curves:
if not curve.reference: # Do not include reference curves
self.curve_connections.append(curve)
# If any other curves are found at the end point, add their pointers as curve connections
elif idx == len(self.point_sequence()) - 1:
for curve in point.curves:
if not curve.reference: # Do not include reference curves
self.curve_connections.append(curve)
# Add the object reference to each point in the curve
if self not in point.curves:
point.curves.append(self)
def point_sequence(self):
return self._point_sequence
def points(self):
return self.point_sequence().points()
def set_point_sequence(self, point_sequence: PointSequence):
self._point_sequence = point_sequence
def point_removal_deletes_curve(self):
return len(self.point_sequence()) <= 2
def remove_point(self, idx: int or None = None, point: Point or None = None):
if isinstance(point, Point):
idx = self.point_sequence().point_idx_from_ref(point)
self.point_sequence().remove_point(idx)
if len(self.point_sequence()) > 2:
delete_curve = False
else:
delete_curve = True
return delete_curve
def remove(self):
if self.canvas_item is not None:
self.canvas_item.sigRemove.emit(self.canvas_item)
[docs]
def compute_camber(self, x: np.ndarray) -> np.ndarray:
"""
Computes the camber distribution of the NACA 4-series airfoil.
Parameters
----------
x: np.ndarray
The pre-computed distribution of :math:`x`-values
Returns
-------
np.ndarray
Array of the same size as ``x`` containing the camber distribution.
"""
m, p = self.max_camber.value(), self.max_camber_loc.value()
yc = np.zeros(x.shape)
if m == 0.0:
return yc
for idx in range(len(x)):
if x[idx] < p:
yc[idx] = m / p**2 * (2 * p * x[idx] - x[idx]**2)
else:
yc[idx] = m / (1 - p)**2 * ((1 - 2*p) + 2*p*x[idx] - x[idx]**2)
return yc
@staticmethod
def _compute_camber_gradient(x: np.ndarray, m: float, p: float, order: int) -> np.ndarray:
"""
Computes the gradient of the camber distribution with respect to :math:`x`.
Parameters
----------
x: np.ndarray
The pre-computed distribution of :math:`x`-values
m: float
Maximum camber
p: float
Maximum camber location
order: int
The order of the derivative. If the order is not 1 or 2, an error is raised.
Returns
-------
np.ndarray
An array of the same size as ``x`` containing the values of the camber gradient at each value of :math:`x`.
"""
if order == 1:
dyc_dt = np.zeros(x.shape)
if m == 0.0:
return dyc_dt
for idx in range(len(x)):
if x[idx] < p:
dyc_dt[idx] = 2 * m / p**2 * (p - x[idx])
else:
dyc_dt[idx] = 2 * m / (1 - p)**2 * (p - x[idx])
return dyc_dt
elif order == 2:
d2yc_dt2 = np.zeros(x.shape)
if m == 0.0:
return d2yc_dt2
for idx in range(len(x)):
if x[idx] < p:
d2yc_dt2[idx] = -2 * m / p ** 2
else:
d2yc_dt2[idx] = -2 * m / (1 - p) ** 2
return d2yc_dt2
raise ValueError(f"Invalid camber gradient order {order}. Must be either 1 or 2.")
@staticmethod
def _compute_camber_angle_gradient(x: np.ndarray, m: float, p: float, order: int) -> np.ndarray:
"""
Computes the gradient of the camber angle distribution with respect to :math:`x`.
Parameters
----------
x: np.ndarray
The pre-computed distribution of :math:`x`-values
m: float
Maximum camber
p: float
Maximum camber location
order: int
The order of the derivative. If the order is not 1 or 2, an error is raised.
Returns
-------
np.ndarray
An array of the same size as ``x`` containing the values of the camber angle gradient at each value of
:math:`x`.
"""
if order == 1:
dtheta_dt = np.zeros(x.shape)
if m == 0.0:
return dtheta_dt
for idx in range(len(x)):
if x[idx] < p:
# dtheta_dt[idx] = 2 * p * (p - x[idx]) / (m * (2 * p * x[idx] - x[idx]**2)**2 + 1)
dtheta_dt[idx] = -2 * m * p**2 / (4 * m**2 * (p - x[idx])**2 + p**4)
else:
# dtheta_dt[idx] = 2 * (1 - p) * (p - x[idx]) / (m * (1 - 2 * p + 2 * p * x[idx] - x[idx]**2)**2 + 1)
dtheta_dt[idx] = -2 * m / (1 - p)**2 / (4 * m**2 * (p - x[idx])**2 / (1 - p)**4 + 1)
return dtheta_dt
elif order == 2:
d2theta_dt2 = np.zeros(x.shape)
if m == 0.0:
return d2theta_dt2
for idx in range(len(x)):
if x[idx] < p:
# k1 = m * x[idx] * (8 * p**3 - 16 * p**2 * x[idx] + 12 * p * x[idx]**2 - 3 * x[idx]**3) + 1
# k2 = m * x[idx]**2 * (x[idx] - 2 * p)**2 + 1
# d2theta_dt2[idx] = 2 * p * k1 / k2**2
d2theta_dt2[idx] = -16 * m**3 * p**2 * (p - x[idx]) / (4 * m**2 * (p - x[idx])**2 + p**4)**2
else:
# k1 = m * (2 * p * x[idx] - 2 * p - x[idx]**2 + 1)**2 + 1
# k2 = 4 * m * (1 - p) * (2 * p - 2 * x[idx])
# k3 = (p - x[idx]) * (2 * p * x[idx] - 2 * p - x[idx]**2 + 1)
# k4 = (m * (2 * p * x[idx] - 2 * p - x[idx]**2 + 1)**2 + 1)**2
# d2theta_dt2[idx] = -2 * (1 - p) / k1 - (k2 * k3) / k4
d2theta_dt2[idx] = -16 * m**3 * (p - x[idx]) / (1 - p)**6 / (4 * m**2 * (p - x[idx])**2 / (1 - p)**4 + 1)**2
return d2theta_dt2
raise ValueError(f"Invalid camber angle gradient order {order}. Must be either 1 or 2.")
[docs]
def derivative(self, theta: np.ndarray, x: np.ndarray, yt: np.ndarray, order: int) -> np.ndarray:
r"""
Calculates an arbitrary-order derivative of the Bézier curve
Parameters
----------
theta: np.ndarray
The pre-computed camber angle distribution
x: np.ndarray
The pre-computed distribution of :math:`x`-values
yt: np.ndarray
The pre-computed thickness distribution
order: int
The order of the derivative. The only valid orders are ``1`` and ``2``.
Returns
-------
np.ndarray
An array of ``shape=(N,2)`` where ``N`` is the number of evaluated points specified by the :math:`x` vector.
The columns represent :math:`C^{(m)}_x(t)` and :math:`C^{(m)}_y(t)`, where :math:`m` is the
derivative order.
"""
# NACA 4-series parameters
m = self.max_camber.value()
p = self.max_camber_loc.value()
t_max = self.max_thickness.value()
t0 = self.t0
a = self.a
error = NotImplementedError(
"Derivatives of order greater than 2 are not implemented for NACA 4-series airfoil curves"
)
# Compute the thickness, camber, and camber angle first-order derivatives
with np.errstate(divide="ignore", invalid="ignore"):
dyt_dt = t_max / t0 * (0.5 * a[0] * np.true_divide(
1, x ** 0.5) + a[1] + 2 * a[2] * x + 3 * a[3] * x ** 2 + 4 * a[4] * x ** 3)
dyc_dt = self._compute_camber_gradient(x, m, p, order=1)
dtheta_dt = self._compute_camber_angle_gradient(x, m, p, order=1)
# Compute the first- and second-order parametric derivatives for either the upper or lower surface
if self.upper:
if order == 1:
with np.errstate(divide="ignore", invalid="ignore"):
dxu_dt = 1 - dyt_dt * np.sin(theta) - yt * dtheta_dt * np.cos(theta)
dyu_dt = dyc_dt + dyt_dt * np.cos(theta) - yt * dtheta_dt * np.sin(theta)
return np.column_stack((dxu_dt, dyu_dt))
elif order == 2:
with np.errstate(divide="ignore", invalid="ignore"):
d2yt_dt2 = t_max / t0 * (-0.25 * a[0] * np.true_divide(
1, x**1.5) + 2 * a[2] + 6 * a[3] * x + 12 * a[4] * x**2)
d2yc_dt2 = self._compute_camber_gradient(x, m, p, order=order)
d2theta_dt2 = self._compute_camber_angle_gradient(x, m, p, order=order)
with np.errstate(divide="ignore", invalid="ignore"):
d2xu_dt2 = (-d2yt_dt2 * np.sin(theta) - 2 * dyt_dt * dtheta_dt * np.cos(theta) -
yt * d2theta_dt2 * np.cos(theta) + yt * dtheta_dt**2 * np.sin(theta))
d2yu_dt2 = (d2yc_dt2 + d2yt_dt2 * np.cos(theta) - 2 * dyt_dt * dtheta_dt * np.sin(theta) -
yt * d2theta_dt2 * np.sin(theta) - yt * dtheta_dt**2 * np.cos(theta))
return np.column_stack((d2xu_dt2, d2yu_dt2))
else:
raise error
else:
if order == 1:
with np.errstate(divide="ignore", invalid="ignore"):
dxl_dt = 1 + dyt_dt * np.sin(theta) + yt * dtheta_dt * np.cos(theta)
dyl_dt = dyc_dt - dyt_dt * np.cos(theta) + yt * dtheta_dt * np.sin(theta)
return np.column_stack((dxl_dt, dyl_dt))
elif order == 2:
with np.errstate(divide="ignore", invalid="ignore"):
d2yt_dt2 = t_max / t0 * (-0.25 * a[0] * np.true_divide(
1, x ** 1.5) + 2 * a[2] + 6 * a[3] * x + 12 * a[4] * x ** 2)
d2yc_dt2 = self._compute_camber_gradient(x, m, p, order=order)
d2theta_dt2 = self._compute_camber_angle_gradient(x, m, p, order=order)
with np.errstate(divide="ignore", invalid="ignore"):
d2xl_dt2 = (d2yt_dt2 * np.sin(theta) + 2 * dyt_dt * dtheta_dt * np.cos(theta) +
yt * d2theta_dt2 * np.cos(theta) - yt * dtheta_dt ** 2 * np.sin(theta))
d2yl_dt2 = (d2yc_dt2 - d2yt_dt2 * np.cos(theta) + 2 * dyt_dt * dtheta_dt * np.sin(theta) +
yt * d2theta_dt2 * np.sin(theta) + yt * dtheta_dt ** 2 * np.cos(theta))
return np.column_stack((d2xl_dt2, d2yl_dt2))
else:
raise error
[docs]
def evaluate(self, t: np.array or None = None, **kwargs):
r"""
Evaluates the curve using an optionally specified parameter vector.
Parameters
----------
t: np.ndarray or ``None``
Optional direct specification of the parameter vector for the curve. Not specifying this value
gives a vector from ``t_start`` or ``t_end`` with the default size and with spacing determined by the value
of ``cosine_spacing`` specified in the class constructor.
Default: ``None``
Returns
-------
PCurveData
Data class specifying the following information about the NACA 4-series airfoil curve:
.. math::
C_x(t), C_y(t), C'_x(t), C'_y(t), C''_x(t), C''_y(t), \kappa(t)
where the :math:`x` and :math:`y` subscripts represent the :math:`x` and :math:`y` components of the
vector-valued functions :math:`\vec{C}(t)`, :math:`\vec{C}'(t)`, and :math:`\vec{C}''(t)`.
"""
# Generate the parameter vector
if self.default_nt is not None:
kwargs["nt"] = self.default_nt
if self.cosine_spacing:
kwargs["spacing"] = "cosine"
t = ParametricCurve.generate_t_vec(**kwargs) if t is None else t
# NACA 4-series parameters
m = self.max_camber.value()
p = self.max_camber_loc.value()
t_max = self.max_thickness.value()
t0 = self.t0
a = self.a
# Compute x-locations, camber, and thickness
x = t
yc = self.compute_camber(x)
yt = t_max / t0 * (a[0] * np.sqrt(x) + a[1]*x + a[2]*x**2 + a[3]*x**3 + a[4]*x**4)
# Compute theta
dyc_dx = self._compute_camber_gradient(x, m, p, order=1)
theta = np.arctan2(dyc_dx, 1)
# if self.upper:
# import matplotlib.pyplot as plt
# plt.plot(x, theta)
# plt.show()
# Evaluate the curve
if self.upper:
xu = x - yt * np.sin(theta)
yu = yc + yt * np.cos(theta)
xy = np.column_stack((xu, yu))
else:
xl = x + yt * np.sin(theta)
yl = yc - yt * np.cos(theta)
xy = np.column_stack((xl, yl))
# Calculate the first derivative
first_deriv = self.derivative(theta, x, yt, order=1)
xp = first_deriv[:, 0]
yp = first_deriv[:, 1]
# Calculate the second derivative
second_deriv = self.derivative(theta, x, yt, order=2)
xpp = second_deriv[:, 0]
ypp = second_deriv[:, 1]
# Combine the derivative x and y data
xpyp = np.column_stack((xp, yp))
xppypp = np.column_stack((xpp, ypp))
# Calculate the curvature
with np.errstate(divide='ignore', invalid='ignore'):
# Calculate the curvature (k = kappa = 1 / R, where R is the radius of curvature)
k = np.abs(np.true_divide((xp * ypp - yp * xpp), (xp ** 2 + yp ** 2) ** (3 / 2)))
if x[0] == 0.0:
k[0] = 1 / (1.1090 * t_max**2)
# Calculate the radius of curvature: R = 1 / kappa
with np.errstate(divide='ignore', invalid='ignore'):
R = np.true_divide(1, k)
# dx/dy at t=0 (x=0):
dxdy_start = np.array([np.cos(theta[0]), np.sin(theta[0])])
return PCurveData(t=t, xy=xy, xpyp=xpyp, xppypp=xppypp, k=k, R=R, dxdy_start=dxdy_start)
[docs]
def get_dict_rep(self):
return {
"max_camber": self.max_camber.name(),
"max_camber_loc": self.max_camber_loc.name(),
"max_thickness": self.max_thickness.name(),
"leading_edge": self.leading_edge.name(),
"trailing_edge": self.trailing_edge.name(),
"upper": self.upper,
"cosine_spacing": self.cosine_spacing,
"default_nt": self.default_nt
}
[docs]
def main():
import matplotlib.pyplot as plt
fig, ax = plt.subplots()
for upper, k_factor in zip([True, False], [-0.005, 0.005]):
naca4 = NACA4(
max_camber=Param(0.05, "NACA4-1.m"),
max_camber_loc=Param(0.5, "NACA4-1.p"),
max_thickness=Param(0.12, "NACA4-1.t"),
leading_edge=Point.generate_from_array(np.array([0.0, 0.0])),
trailing_edge=Point.generate_from_array(np.array([1.0, 0.0])),
upper=upper,
sharp_trailing_edge=False,
default_nt=200,
cosine_spacing=True
)
data = naca4.evaluate()
ax.plot(data.xy[:, 0], data.xy[:, 1], color="steelblue")
comb_tails, comb_heads = data.get_curvature_comb(k_factor, flip_leading_edge_normal=not upper)
for comb_tail, comb_head in zip(comb_tails, comb_heads):
ax.plot([comb_tail[0], comb_head[0]], [comb_tail[1], comb_head[1]], color="indianred")
if upper:
x = np.linspace(0.0, 1.0, 200)
ax.plot(x, naca4.compute_camber(x), color="mediumaquamarine")
ax.set_aspect("equal")
plt.show()
if __name__ == "__main__":
main()