Source code for buffalo_wings.airfoil.internal.analytic.naca.naca5_airfoil

"""Spec-backed runtime classes for NACA 5-digit airfoils."""

from __future__ import annotations

from copy import deepcopy
from typing import Literal, Self, cast

from buffalo_core.numeric import as_float_scalar
from buffalo_core.typing import FloatScalar

from buffalo_wings.airfoil.internal.analytic.camber_base import Camber
from buffalo_wings.airfoil.internal.analytic.orthogonal_base import (
    OrthogonalAirfoil,
)
from buffalo_wings.airfoil.internal.schema.naca import (
    Naca5AirfoilParamsSpec,
    Naca5AirfoilSpec,
)

from .naca4_airfoil import NACA4_MAX_THICKNESS_LIMIT
from .naca5_camber import (
    Naca5DigitCamberClassic,
    Naca5DigitCamberParams,
    Naca5DigitCamberReflexedClassic,
    Naca5DigitCamberReflexedParams,
)
from .naca45_thickness import (
    Naca45DigitThicknessClassic,
    Naca45DigitThicknessParams,
)

NACA5_DESIGNATION_LENGTH = 5
NACA5_MAX_IDEAL_LIFT_COEFFICIENT = 0.6
NACA5_MIN_IDEAL_LIFT_COEFFICIENT = 0.15
NACA5_MAX_CAMBER_LOCATION = 0.3
NACA5_MIN_CAMBER_LOCATION = 0.05


def _validate_naca5_designation(designation: str) -> None:
    """
    Validate a classic 5-digit NACA designation string.

    Parameters
    ----------
    designation : str
        Five-digit NACA designation string.

    Raises
    ------
    ValueError
        If ``designation`` is not exactly five decimal digits or uses an
        unsupported reflex digit.
    """
    if (
        len(designation) != NACA5_DESIGNATION_LENGTH
        or not designation.isdigit()
    ):
        raise ValueError("NACA 5-digit designations must be a 5-digit string.")
    if designation[2] not in {"0", "1"}:
        raise ValueError(
            "NACA 5-digit designations must use 0 or 1 as the reflex digit."
        )


def _validate_naca5_params(params: Naca5AirfoilParamsSpec) -> None:
    """
    Validate the explicit NACA 5-digit parameter specification.

    Parameters
    ----------
    params : Naca5AirfoilParamsSpec
        Explicit NACA 5-digit parameter specification to validate.

    Raises
    ------
    ValueError
        If any parameter violates the supported NACA 5-digit bounds.
    """
    if not (
        NACA5_MIN_IDEAL_LIFT_COEFFICIENT
        <= params.ideal_lift_coefficient
        < NACA5_MAX_IDEAL_LIFT_COEFFICIENT
    ):
        raise ValueError(
            "NACA 5-digit ideal_lift_coefficient must satisfy "
            "0.15 <= ideal_lift_coefficient < 0.6."
        )
    if not (
        NACA5_MIN_CAMBER_LOCATION
        <= params.max_camber_location
        < NACA5_MAX_CAMBER_LOCATION
    ):
        raise ValueError(
            "NACA 5-digit max_camber_location must satisfy "
            "0.05 <= max_camber_location < 0.3."
        )
    if not 0.0 <= params.max_thickness <= NACA4_MAX_THICKNESS_LIMIT:
        raise ValueError(
            "NACA 5-digit parameter max_thickness must satisfy "
            "0 <= max_thickness <= 0.4."
        )


def _copy_naca5_spec(spec: Naca5AirfoilSpec) -> Naca5AirfoilSpec:
    """
    Return a defensive copy of a NACA 5-digit spec.

    Parameters
    ----------
    spec : Naca5AirfoilSpec
        Source specification to copy.

    Returns
    -------
    Naca5AirfoilSpec
        Deep copy of ``spec``.
    """
    return deepcopy(spec)


def _ideal_lift_coefficient_to_index(
    ideal_lift_coefficient: FloatScalar,
) -> FloatScalar:
    """
    Convert ideal lift coefficient to the camber lift index.

    Parameters
    ----------
    ideal_lift_coefficient : FloatScalar
        Design lift coefficient as a dimensionaless fraction.

    Returns
    -------
    FloatScalar
        Lift index used by the NACA 5-digit camber classes.
    """
    return (20.0 / 3.0) * ideal_lift_coefficient


def _max_camber_location_to_index(
    max_camber_location: FloatScalar,
) -> FloatScalar:
    """
    Convert max camber location to the camber location index.

    Parameters
    ----------
    max_camber_location : FloatScalar
        Chordwise location of maximum camber as a fraction of chord.

    Returns
    -------
    FloatScalar
        Camber-location index used by the NACA 5-digit camber classes.
    """
    return 20.0 * max_camber_location


[docs] class Naca5AirfoilClassic(OrthogonalAirfoil): """Classic NACA 5-digit airfoil built from a designation.""" def __init__(self, spec: Naca5AirfoilSpec) -> None: """ Build a classic NACA 5-digit airfoil from its source spec. Parameters ---------- spec : Naca5AirfoilSpec Serialized specification containing a classic 5-digit designation. Raises ------ ValueError If ``spec`` does not contain a valid designation-only definition. """ if spec.designation is None or spec.params is not None: raise ValueError( "Classic NACA 5-digit airfoils require designation-only specs." ) _validate_naca5_designation(spec.designation) lift_index = as_float_scalar(float(spec.designation[0])) max_camber_index = as_float_scalar(float(spec.designation[1])) reflexed = spec.designation[2] == "1" camber: Camber if reflexed: camber = Naca5DigitCamberReflexedClassic( lci=lift_index, mci=max_camber_index, ) else: camber = Naca5DigitCamberClassic( lci=lift_index, mci=max_camber_index, ) thickness = Naca45DigitThicknessClassic( mti=as_float_scalar(float(spec.designation[3:5])) ) super().__init__(camber=camber, thickness=thickness) self._spec = _copy_naca5_spec(spec)
[docs] @classmethod def from_designation(cls, designation: str) -> Self: """ Build a classic NACA 5-digit airfoil from a designation. Parameters ---------- designation : str Five-digit NACA designation such as ``"23012"``. Returns ------- Self Runtime airfoil built from ``designation``. Raises ------ ValueError If ``designation`` is not a valid 5-digit NACA code. """ return cls(Naca5AirfoilSpec(designation=designation))
@property def spec(self) -> Naca5AirfoilSpec: """ Return the source spec for this airfoil. Returns ------- Naca5AirfoilSpec Defensive copy of the serialized source spec. """ return _copy_naca5_spec(self._spec) @property def camber( self, ) -> Naca5DigitCamberClassic | Naca5DigitCamberReflexedClassic: """ Return the concrete classic NACA 5-digit camber model. This property exposes the classic 5-digit camber-line object constructed from the stored designation, including the reflexed variant when requested by the source spec. """ return cast( Naca5DigitCamberClassic | Naca5DigitCamberReflexedClassic, self._camber, ) @property def thickness(self) -> Naca45DigitThicknessClassic: """ Return the concrete classic NACA thickness model. This property exposes the classic thickness relation constructed from the stored 5-digit definition. """ return cast(Naca45DigitThicknessClassic, self._thickness)
[docs] def to_spec(self) -> Naca5AirfoilSpec: """ Return the schema definition needed to recreate this airfoil. Returns ------- Naca5AirfoilSpec Serialized source spec for this airfoil. """ return self.spec
[docs] class Naca5AirfoilParams(OrthogonalAirfoil): """Parametric NACA 5-digit airfoil built from explicit parameters.""" def __init__(self, spec: Naca5AirfoilSpec) -> None: """ Build a parametric NACA 5-digit airfoil from its source spec. Parameters ---------- spec : Naca5AirfoilSpec Serialized specification containing explicit 5-digit parameters. Raises ------ ValueError If ``spec`` does not contain a valid params-only definition. """ if spec.designation is not None or spec.params is None: raise ValueError( "Parametric NACA 5-digit airfoils require params-only specs." ) _validate_naca5_params(spec.params) lift_index = _ideal_lift_coefficient_to_index( spec.params.ideal_lift_coefficient ) max_camber_index = _max_camber_location_to_index( spec.params.max_camber_location ) camber: Camber if spec.params.reflexed: camber = Naca5DigitCamberReflexedParams( lci=lift_index, mci=max_camber_index, ) else: camber = Naca5DigitCamberParams( lci=lift_index, mci=max_camber_index, ) thickness = Naca45DigitThicknessParams( mti=100.0 * spec.params.max_thickness, closed_te=spec.params.trailing_edge == "sharp", use_radius=spec.params.leading_edge_radius == "exact", ) super().__init__(camber=camber, thickness=thickness) self._spec = _copy_naca5_spec(spec) @property def camber( self, ) -> Naca5DigitCamberParams | Naca5DigitCamberReflexedParams: """ Return the concrete parametric NACA 5-digit camber model. This property exposes the continuous-index 5-digit camber-line object constructed from the stored parameter definition, including the reflexed variant when requested by the source spec. """ return cast( Naca5DigitCamberParams | Naca5DigitCamberReflexedParams, self._camber, ) @property def thickness(self) -> Naca45DigitThicknessParams: """ Return the concrete parametric NACA thickness model. This property exposes the parametric thickness relation constructed from the stored 5-digit definition. """ return cast(Naca45DigitThicknessParams, self._thickness)
[docs] @classmethod def from_params( cls, *, ideal_lift_coefficient: FloatScalar, max_camber_location: FloatScalar, reflexed: bool, t: FloatScalar, trailing_edge: Literal["standard", "sharp"] = "standard", leading_edge_radius: Literal["standard", "exact"] = "standard", ) -> Self: """ Build a parametric NACA 5-digit airfoil from explicit params. Parameters ---------- ideal_lift_coefficient : FloatScalar Design lift coefficient associated with the camber line. max_camber_location : FloatScalar Chordwise location of maximum camber as a fraction of chord. reflexed : bool Whether to build the reflexed 5-digit camber-line family. t : FloatScalar Maximum thickness as a fraction of chord. trailing_edge : {"standard", "sharp"}, default="standard" Trailing-edge closure model for the thickness distribution. leading_edge_radius : {"standard", "exact"}, default="standard" Leading-edge radius treatment for the thickness distribution. Returns ------- Self Runtime airfoil built from the explicit parameters. Raises ------ ValueError If the parameter set falls outside the supported NACA 5-digit range. """ params = Naca5AirfoilParamsSpec( ideal_lift_coefficient=ideal_lift_coefficient, max_camber_location=max_camber_location, reflexed=reflexed, max_thickness=t, trailing_edge=trailing_edge, leading_edge_radius=leading_edge_radius, ) return cls(Naca5AirfoilSpec(params=params))
@property def spec(self) -> Naca5AirfoilSpec: """ Return the source spec for this airfoil. Returns ------- Naca5AirfoilSpec Defensive copy of the serialized source spec. """ return _copy_naca5_spec(self._spec)
[docs] def to_spec(self) -> Naca5AirfoilSpec: """ Return the schema definition needed to recreate this airfoil. Returns ------- Naca5AirfoilSpec Serialized source spec for this airfoil. """ return self.spec