"""Normalized ellipse-based analytic airfoil implementation."""
from __future__ import annotations
from typing import override
import numpy as np
from buffalo_core.numeric import as_float_array, as_float_scalar
from buffalo_core.typing import FloatArray, FloatInput, FloatScalar
from buffalo_wings.airfoil.internal.base import Airfoil
from buffalo_wings.airfoil.internal.runtime_types import (
AirfoilSurface,
SurfaceMappedValues,
)
from buffalo_wings.airfoil.internal.schema.analytic import (
EllipseAirfoilParamsSpec,
EllipseAirfoilSpec,
)
_BREAKPOINT_ZERO_TOLERANCE = 1e-15
[docs]
class EllipseAirfoil(Airfoil):
"""
Normalized ellipse-based analytic airfoil.
Notes
-----
The airfoil is defined in the standard normalized section frame with the
leading edge at ``(0, 0)`` and the nominal trailing-edge midpoint at
``(1, 0)``.
The only free geometric parameter is ``max_thickness``.
"""
def __init__(self, *, max_thickness: FloatScalar) -> None:
"""
Initialize the normalized ellipse thickness.
Parameters
----------
max_thickness : FloatScalar
Maximum thickness as a fraction of chord.
"""
super().__init__()
self._max_thickness = max_thickness
@property
def max_thickness(self) -> FloatScalar:
"""
Return the normalized maximum thickness.
This property stores the maximum thickness as a fraction of chord.
"""
return self._max_thickness
@max_thickness.setter
def max_thickness(self, value: FloatScalar) -> None:
"""
Set the normalized maximum thickness.
Parameters
----------
value : FloatScalar
New maximum thickness as a fraction of chord.
"""
self._max_thickness = value
self._airfoil_changed()
@property
def spec(self) -> EllipseAirfoilSpec:
"""
Return the schema definition used to create this airfoil.
The returned schema stores the serialized normalized ellipse
definition.
"""
return EllipseAirfoilSpec(
params=EllipseAirfoilParamsSpec(
max_thickness=as_float_scalar(self.max_thickness)
)
)
[docs]
@override
def xy_from_u(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""
Calculate the coordinates of the normalized ellipse airfoil.
Parameters
----------
u : FloatInput
Signed airfoil parameter values in ``[-1, 1]``.
Returns
-------
tuple[FloatArray, FloatArray]
Tuple ``(x, y)`` of ``float64`` arrays matching the normalized
shape of ``u``.
"""
theta = self._convert_theta(u)
x = as_float_array(0.5 * (1.0 + np.cos(theta)))
y = as_float_array(0.5 * self.max_thickness * np.sin(theta))
return x, y
[docs]
@override
def xy_u(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""
Calculate first derivatives with respect to the airfoil parameter.
Parameters
----------
u : FloatInput
Signed airfoil parameter values in ``[-1, 1]``.
Returns
-------
tuple[FloatArray, FloatArray]
Tuple ``(dx/du, dy/du)`` of ``float64`` arrays.
"""
theta = self._convert_theta(u)
x_u = as_float_array(0.5 * np.pi * np.sin(theta))
y_u = as_float_array(-0.5 * np.pi * self.max_thickness * np.cos(theta))
return x_u, y_u
[docs]
@override
def xy_uu(self, u: FloatInput) -> tuple[FloatArray, FloatArray]:
"""
Return second derivatives of the ellipse coordinates.
Parameters
----------
u : FloatInput
Signed airfoil parameter values in ``[-1, 1]``.
Returns
-------
tuple[FloatArray, FloatArray]
Tuple ``(d2x/du2, d2y/du2)`` of ``float64`` arrays.
"""
theta = self._convert_theta(u)
x_uu = as_float_array(-0.5 * (np.pi**2) * np.cos(theta))
y_uu = as_float_array(
-0.5 * (np.pi**2) * self.max_thickness * np.sin(theta)
)
return x_uu, y_uu
[docs]
@override
def xy_u_breakpoint(
self,
*,
index: int,
) -> tuple[
tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]
]:
"""
Return exact one-sided first derivatives at one breakpoint.
Parameters
----------
index : int
Index into :meth:`breakpoints`.
Returns
-------
tuple[tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]]
``((x_u_minus, y_u_minus), (x_u_plus, y_u_plus))``.
Notes
-----
The ellipse airfoil only reports endpoint breakpoints, so both sides
return the same exact boundary value.
"""
u_breakpoint = self.breakpoints()[index]
theta = as_float_scalar(self._convert_theta(u_breakpoint))
boundary_values = (
as_float_scalar(0.5 * np.pi * np.sin(theta)),
as_float_scalar(-0.5 * np.pi * self.max_thickness * np.cos(theta)),
)
return boundary_values, boundary_values
[docs]
@override
def xy_uu_breakpoint(
self,
*,
index: int,
) -> tuple[
tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]
]:
"""
Return exact one-sided second derivatives at one breakpoint.
Parameters
----------
index : int
Index into :meth:`breakpoints`.
Returns
-------
tuple[tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]]
``((x_uu_minus, y_uu_minus), (x_uu_plus, y_uu_plus))``.
Notes
-----
The ellipse airfoil only reports endpoint breakpoints, so both sides
return the same exact boundary value.
"""
u_breakpoint = self.breakpoints()[index]
theta = as_float_scalar(self._convert_theta(u_breakpoint))
boundary_values = (
as_float_scalar(-0.5 * (np.pi**2) * np.cos(theta)),
as_float_scalar(
-0.5 * (np.pi**2) * self.max_thickness * np.sin(theta)
),
)
return boundary_values, boundary_values
[docs]
@override
def xy_s_breakpoint(
self,
*,
index: int,
) -> tuple[
tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]
]:
"""
Return exact one-sided arc-length derivatives at one breakpoint.
Parameters
----------
index : int
Index into :meth:`breakpoints`.
Returns
-------
tuple[tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]]
``((x_s_minus, y_s_minus), (x_s_plus, y_s_plus))``.
Notes
-----
The ellipse airfoil only reports endpoint breakpoints, so both sides
return the same exact boundary value.
"""
native_u, _ = self.xy_u_breakpoint(index=index)
boundary_values = self._xy_s_from_native_breakpoint(native_u)
return boundary_values, boundary_values
[docs]
@override
def xy_ss_breakpoint(
self,
*,
index: int,
) -> tuple[
tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]
]:
"""
Return exact one-sided arc-length second derivatives at one breakpoint.
Parameters
----------
index : int
Index into :meth:`breakpoints`.
Returns
-------
tuple[tuple[FloatScalar, FloatScalar], tuple[FloatScalar, FloatScalar]]
``((x_ss_minus, y_ss_minus), (x_ss_plus, y_ss_plus))``.
Notes
-----
The ellipse airfoil only reports endpoint breakpoints, so both sides
return the same exact boundary value.
"""
native_u, _ = self.xy_u_breakpoint(index=index)
native_uu, _ = self.xy_uu_breakpoint(index=index)
boundary_values = self._xy_ss_from_native_breakpoint(
native_u,
native_uu,
)
return boundary_values, boundary_values
@staticmethod
def _xy_s_from_native_breakpoint(
native_u: tuple[FloatScalar, FloatScalar],
) -> tuple[FloatScalar, FloatScalar]:
"""
Return one exact arc-length tangent from native derivatives.
Parameters
----------
native_u : tuple[FloatScalar, FloatScalar]
Exact one-sided native derivative tuple ``(x_u, y_u)``.
Returns
-------
tuple[FloatScalar, FloatScalar]
Exact arc-length tangent ``(x_s, y_s)`` on the same breakpoint
side.
"""
x_u, y_u = native_u
speed = as_float_scalar(np.hypot(x_u, y_u))
return (
as_float_scalar(x_u / speed),
as_float_scalar(y_u / speed),
)
@staticmethod
def _xy_ss_from_native_breakpoint(
native_u: tuple[FloatScalar, FloatScalar],
native_uu: tuple[FloatScalar, FloatScalar],
) -> tuple[FloatScalar, FloatScalar]:
"""
Return one exact arc-length second derivative from native data.
Parameters
----------
native_u : tuple[FloatScalar, FloatScalar]
Exact one-sided native derivative tuple ``(x_u, y_u)``.
native_uu : tuple[FloatScalar, FloatScalar]
Exact one-sided native second-derivative tuple
``(x_uu, y_uu)``.
Returns
-------
tuple[FloatScalar, FloatScalar]
Exact arc-length second derivative ``(x_ss, y_ss)`` on the same
breakpoint side.
"""
x_u, y_u = native_u
x_uu, y_uu = native_uu
speed_sq = as_float_scalar(x_u**2 + y_u**2)
speed_pow4 = as_float_scalar(speed_sq**2)
projection = as_float_scalar(x_u * x_uu + y_u * y_uu)
x_ss = as_float_scalar(x_uu / speed_sq - x_u * projection / speed_pow4)
y_ss = as_float_scalar(y_uu / speed_sq - y_u * projection / speed_pow4)
if np.isclose(x_ss, 0.0, atol=_BREAKPOINT_ZERO_TOLERANCE, rtol=0.0):
x_ss = as_float_scalar(0.0)
if np.isclose(y_ss, 0.0, atol=_BREAKPOINT_ZERO_TOLERANCE, rtol=0.0):
y_ss = as_float_scalar(0.0)
return x_ss, y_ss
[docs]
@override
def u_from_xi(
self,
xi: FloatInput,
*,
surface: AirfoilSurface,
) -> FloatArray:
"""
Convert surface-local ``xi`` coordinates to native parameters.
Parameters
----------
xi : FloatInput
Surface-local coordinates in ``[0, 1]`` measured from the
leading edge to the trailing edge.
surface : {"lower", "upper"}
Surface to evaluate.
Returns
-------
FloatArray
Signed native ellipse parameters matching ``xi`` on the selected
surface.
Notes
-----
The ellipse runtime uses the linear mapping ``u = +/- xi``, with the
sign determined by ``surface``.
"""
return self._u_from_xi_signed_linear(xi, surface=surface)
[docs]
@override
def xi_from_u(self, u: FloatInput) -> SurfaceMappedValues:
"""
Convert native parameters to surface-local ``xi`` coordinates.
Parameters
----------
u : FloatInput
Signed native ellipse parameters in ``[-1, 1]``.
Returns
-------
SurfaceMappedValues
Surface-local ``xi`` values and upper-surface membership flags.
Notes
-----
The ellipse runtime uses the linear mapping ``xi = |u|``.
"""
return self._xi_from_u_signed_linear(u)
[docs]
def breakpoints(self) -> list[FloatScalar]: # noqa: PLR6301
"""
Return the trailing-edge parameter locations.
Returns
-------
list[FloatScalar]
Ordered parameter values for the lower and upper trailing-edge
endpoints.
"""
return [as_float_scalar(-1.0), as_float_scalar(1.0)]
@staticmethod
def _convert_theta(u: FloatInput) -> FloatArray:
"""
Convert the airfoil parameter to the ellipse polar angle.
Parameters
----------
u : FloatInput
Signed airfoil parameter values in ``[-1, 1]``.
Returns
-------
FloatArray
Polar angle values in radians.
"""
u_array = as_float_array(u)
return np.pi * (1.0 - u_array)