"""Spec-backed runtime classes for NACA 4-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.orthogonal_base import (
OrthogonalAirfoil,
)
from buffalo_wings.airfoil.internal.schema.naca import (
Naca4AirfoilParamsSpec,
Naca4AirfoilSpec,
)
from .naca4_camber import Naca4DigitCamber
from .naca45_thickness import (
Naca45DigitThicknessClassic,
Naca45DigitThicknessParams,
)
NACA4_CODE_LENGTH = 4
NACA4_MAX_CAMBER_LIMIT = 0.1
NACA4_MAX_CAMBER_LOCATION_LIMIT = 0.9
NACA4_MAX_THICKNESS_LIMIT = 0.4
def _validate_naca4_designation(designation: str) -> None:
"""
Validate a classic 4-digit NACA designation string.
Parameters
----------
designation : str
Four-digit NACA designation string.
Raises
------
ValueError
If ``designation`` is not exactly four decimal digits.
"""
if len(designation) != NACA4_CODE_LENGTH or not designation.isdigit():
raise ValueError("NACA 4-digit designations must be a 4-digit string.")
def _validate_naca4_params(params: Naca4AirfoilParamsSpec) -> None:
"""
Validate the explicit NACA 4-digit parameter specification.
Parameters
----------
params : Naca4AirfoilParamsSpec
Explicit NACA 4-digit parameter specification to validate.
Raises
------
ValueError
If any parameter violates the supported NACA 4-digit bounds or
consistency rules.
"""
if not 0.0 <= params.m < NACA4_MAX_CAMBER_LIMIT:
raise ValueError("NACA 4-digit parameter m must satisfy 0 <= m < 0.1.")
if not 0.0 <= params.p <= NACA4_MAX_CAMBER_LOCATION_LIMIT:
raise ValueError("NACA 4-digit parameter p must satisfy 0 <= p <= 0.9.")
if not 0.0 <= params.max_thickness <= NACA4_MAX_THICKNESS_LIMIT:
raise ValueError(
"NACA 4-digit parameter max_thickness must satisfy "
"0 <= max_thickness <= 0.4."
)
if params.m == 0.0 and params.p != 0.0: # noqa: RUF069
raise ValueError("NACA 4-digit parameter p must be 0 when m is 0.")
if params.m > 0.0 and params.p <= 0.0:
raise ValueError(
"NACA 4-digit parameter p must be positive when m > 0."
)
def _copy_naca4_spec(spec: Naca4AirfoilSpec) -> Naca4AirfoilSpec:
"""
Return a defensive copy of a NACA 4-digit spec.
Parameters
----------
spec : Naca4AirfoilSpec
Source specification to copy.
Returns
-------
Naca4AirfoilSpec
Deep copy of ``spec``.
"""
return deepcopy(spec)
[docs]
class Naca4AirfoilClassic(OrthogonalAirfoil):
"""Classic NACA 4-digit airfoil built from a designation."""
def __init__(self, spec: Naca4AirfoilSpec) -> None:
"""
Build a classic NACA 4-digit airfoil from its source spec.
Parameters
----------
spec : Naca4AirfoilSpec
Serialized specification containing a classic 4-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 4-digit airfoils require designation-only specs."
)
_validate_naca4_designation(spec.designation)
camber = Naca4DigitCamber(
mci=as_float_scalar(float(spec.designation[0])),
lci=as_float_scalar(float(spec.designation[1])),
)
thickness = Naca45DigitThicknessClassic(
mti=as_float_scalar(float(spec.designation[2:4]))
)
super().__init__(camber=camber, thickness=thickness)
self._spec = _copy_naca4_spec(spec)
[docs]
@classmethod
def from_designation(cls, designation: str) -> Self:
"""
Build a classic NACA 4-digit airfoil from a designation.
Parameters
----------
designation : str
Four-digit NACA designation such as ``"2412"``.
Returns
-------
Self
Runtime airfoil built from ``designation``.
Raises
------
ValueError
If ``designation`` is not a valid 4-digit NACA code.
"""
return cls(Naca4AirfoilSpec(designation=designation))
@property
def spec(self) -> Naca4AirfoilSpec:
"""
Return the source spec for this airfoil.
This property returns a defensive copy of the serialized source
spec.
"""
return _copy_naca4_spec(self._spec)
@property
def camber(self) -> Naca4DigitCamber:
"""
Return the concrete NACA 4-digit camber model.
This property exposes the camber-line object constructed from the
stored classic 4-digit definition.
"""
return cast(Naca4DigitCamber, 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 4-digit definition.
"""
return cast(Naca45DigitThicknessClassic, self._thickness)
[docs]
def to_spec(self) -> Naca4AirfoilSpec:
"""
Return the schema definition needed to recreate this airfoil.
Returns
-------
Naca4AirfoilSpec
Serialized source spec for this airfoil.
"""
return self.spec
[docs]
class Naca4AirfoilParams(OrthogonalAirfoil):
"""Parametric NACA 4-digit airfoil built from explicit parameters."""
def __init__(self, spec: Naca4AirfoilSpec) -> None:
"""
Build a parametric NACA 4-digit airfoil from its source spec.
Parameters
----------
spec : Naca4AirfoilSpec
Serialized specification containing explicit 4-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 4-digit airfoils require params-only specs."
)
_validate_naca4_params(spec.params)
camber = Naca4DigitCamber(
mci=100.0 * spec.params.m,
lci=10.0 * spec.params.p,
)
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_naca4_spec(spec)
@property
def camber(self) -> Naca4DigitCamber:
"""
Return the concrete NACA 4-digit camber model.
This property exposes the camber-line object constructed from the
explicit 4-digit parameters.
"""
return cast(Naca4DigitCamber, self._camber)
@property
def thickness(self) -> Naca45DigitThicknessParams:
"""
Return the concrete parametric NACA thickness model.
This property exposes the parametric thickness relation
constructed from the explicit 4-digit parameters.
"""
return cast(Naca45DigitThicknessParams, self._thickness)
[docs]
@classmethod
def from_params(
cls,
*,
m: FloatScalar,
p: FloatScalar,
t: FloatScalar,
trailing_edge: Literal["standard", "sharp"] = "standard",
leading_edge_radius: Literal["standard", "exact"] = "standard",
) -> Self:
"""
Build a parametric NACA 4-digit airfoil from explicit params.
Parameters
----------
m : FloatScalar
Maximum camber as a fraction of chord.
p : FloatScalar
Chordwise location of maximum camber as a fraction of chord.
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 4-digit
range.
"""
params = Naca4AirfoilParamsSpec(
m=m,
p=p,
max_thickness=t,
trailing_edge=trailing_edge,
leading_edge_radius=leading_edge_radius,
)
return cls(Naca4AirfoilSpec(params=params))
@property
def spec(self) -> Naca4AirfoilSpec:
"""
Return the source spec for this airfoil.
This property returns a defensive copy of the serialized source
spec.
"""
return _copy_naca4_spec(self._spec)
[docs]
def to_spec(self) -> Naca4AirfoilSpec:
"""
Return the schema definition needed to recreate this airfoil.
Returns
-------
Naca4AirfoilSpec
Serialized source spec for this airfoil.
"""
return self.spec