"""Spec-backed runtime classes for NACA 5-digit airfoils."""
from __future__ import annotations
from copy import deepcopy
from typing import Literal, Self
from .airfoil import OrthogonalAirfoil
from .airfoil_schema import Naca5AirfoilParamsSpec, Naca5AirfoilSpec
from .camber_base import Camber
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.t <= NACA4_MAX_THICKNESS_LIMIT:
raise ValueError("NACA 5-digit parameter t must satisfy 0 <= t <= 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: float) -> float:
"""
Convert ideal lift coefficient to the camber lift index.
Parameters
----------
ideal_lift_coefficient : float
Design lift coefficient as a dimensionaless fraction.
Returns
-------
float
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: float) -> float:
"""
Convert max camber location to the camber location index.
Parameters
----------
max_camber_location : float
Chordwise location of maximum camber as a fraction of chord.
Returns
-------
float
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.
Parameters
----------
spec : Naca5AirfoilSpec
Serialized specification containing a classic 5-digit designation.
Raises
------
ValueError
If ``spec`` does not contain a valid designation-only definition.
"""
[docs]
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 = float(spec.designation[0])
max_camber_index = 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=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)
[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.
Parameters
----------
spec : Naca5AirfoilSpec
Serialized specification containing explicit 5-digit parameters.
Raises
------
ValueError
If ``spec`` does not contain a valid params-only definition.
"""
[docs]
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.t,
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)
[docs]
@classmethod
def from_params(
cls,
*,
ideal_lift_coefficient: float,
max_camber_location: float,
reflexed: bool,
t: float,
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 : float
Design lift coefficient associated with the camber line.
max_camber_location : float
Chordwise location of maximum camber as a fraction of chord.
reflexed : bool
Whether to build the reflexed 5-digit camber-line family.
t : float
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,
t=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
__all__ = ["Naca5AirfoilClassic", "Naca5AirfoilParams"]