Source code for buffalo_wings.airfoil.internal.analytic.simple.ellipse_airfoil

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