"""Spec-backed runtime classes for modified 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 (
Naca5ModifiedAirfoilParamsSpec,
Naca5ModifiedAirfoilSpec,
)
from .naca4_modified_airfoil import NACA4_MODIFIED_MIN_THICKNESS_LOCATION
from .naca5_airfoil import (
NACA5_MAX_CAMBER_LOCATION,
NACA5_MAX_IDEAL_LIFT_COEFFICIENT,
NACA5_MIN_CAMBER_LOCATION,
NACA5_MIN_IDEAL_LIFT_COEFFICIENT,
)
from .naca5_camber import (
Naca5DigitCamberClassic,
Naca5DigitCamberParams,
Naca5DigitCamberReflexedClassic,
Naca5DigitCamberReflexedParams,
)
from .naca45_modified_thickness import (
LEADING_EDGE_INDEX_MAX,
LEADING_EDGE_INDEX_MIN,
MAX_THICKNESS_LOCATION_MAX,
MAX_THICKNESS_LOCATION_MIN,
Naca45DigitModifiedThicknessClassic,
Naca45DigitModifiedThicknessParams,
)
NACA5_MODIFIED_DESIGNATION_LENGTH = 8
NACA5_MODIFIED_DESIGNATION_DASH_INDEX = 5
NACA5_MAX_THICKNESS_LIMIT = 0.4
def _validate_naca5_modified_designation(designation: str) -> None:
"""
Validate a modified NACA 5-digit designation string.
Parameters
----------
designation : str
Modified NACA 5-digit designation string in ``#####-##`` form.
Raises
------
ValueError
If ``designation`` does not match the supported modified-series
format.
"""
if len(designation) != NACA5_MODIFIED_DESIGNATION_LENGTH:
raise ValueError(
"Modified NACA 5-digit designations must be 8 characters."
)
if designation[NACA5_MODIFIED_DESIGNATION_DASH_INDEX] != "-":
raise ValueError(
"Modified NACA 5-digit designations must use the form #####-##."
)
digits = designation.replace("-", "")
if not digits.isdigit():
raise ValueError(
"Modified NACA 5-digit designations must contain only digits."
)
if designation[2] not in {"0", "1"}:
raise ValueError(
"Modified NACA 5-digit designations must use 0 or 1 as the "
"reflex digit."
)
def _validate_naca5_modified_params(
params: Naca5ModifiedAirfoilParamsSpec,
) -> None:
"""
Validate the explicit modified NACA 5-digit parameters.
Parameters
----------
params : Naca5ModifiedAirfoilParamsSpec
Explicit modified NACA 5-digit parameter specification to validate.
Raises
------
ValueError
If any parameter violates the supported modified-series bounds.
"""
if not (
NACA5_MIN_IDEAL_LIFT_COEFFICIENT
<= params.ideal_lift_coefficient
< NACA5_MAX_IDEAL_LIFT_COEFFICIENT
):
raise ValueError(
"Modified 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(
"Modified NACA 5-digit max_camber_location must satisfy "
"0.05 <= max_camber_location < 0.3."
)
if not 0.0 <= params.max_thickness <= NACA5_MAX_THICKNESS_LIMIT:
raise ValueError(
"Modified NACA 5-digit parameter max_thickness must satisfy "
"0 <= max_thickness <= 0.4."
)
if (
not LEADING_EDGE_INDEX_MIN
<= params.leading_edge_index
< LEADING_EDGE_INDEX_MAX
):
raise ValueError(
"Modified NACA 5-digit leading_edge_index must satisfy "
"1 <= leading_edge_index < 10."
)
if not (
NACA4_MODIFIED_MIN_THICKNESS_LOCATION
<= params.max_thickness_location
< 1.0
):
raise ValueError(
"Modified NACA 5-digit max_thickness_location must satisfy "
"0.1 <= max_thickness_location < 1.0."
)
def _copy_naca5_modified_spec(
spec: Naca5ModifiedAirfoilSpec,
) -> Naca5ModifiedAirfoilSpec:
"""
Return a defensive copy of a modified NACA 5-digit spec.
Parameters
----------
spec : Naca5ModifiedAirfoilSpec
Source specification to copy.
Returns
-------
Naca5ModifiedAirfoilSpec
Deep copy of ``spec``.
"""
return deepcopy(spec)
def _designation_to_lmti(designation: str) -> FloatScalar:
"""
Return the max-thickness-location index from a designation.
Parameters
----------
designation : str
Modified NACA 5-digit designation string.
Returns
-------
FloatScalar
Max-thickness-location index extracted from ``designation``.
Raises
------
ValueError
If the designation encodes an unsupported thickness-location index.
"""
lmti = as_float_scalar(float(designation[7]))
if not MAX_THICKNESS_LOCATION_MIN <= lmti < MAX_THICKNESS_LOCATION_MAX:
raise ValueError(
"Modified NACA 5-digit max thickness location must be 1-9."
)
return lmti
[docs]
class Naca5ModifiedAirfoilClassic(OrthogonalAirfoil):
"""Classic modified NACA 5-digit airfoil built from a designation."""
def __init__(self, spec: Naca5ModifiedAirfoilSpec) -> None:
"""
Build a classic modified NACA 5-digit airfoil from its spec.
Parameters
----------
spec : Naca5ModifiedAirfoilSpec
Serialized specification containing a modified-series
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 modified NACA 5-digit airfoils require "
"designation-only specs."
)
_validate_naca5_modified_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 = Naca45DigitModifiedThicknessClassic(
mti=as_float_scalar(float(spec.designation[3:5])),
lei=as_float_scalar(float(spec.designation[6])),
lmti=_designation_to_lmti(spec.designation),
)
super().__init__(camber=camber, thickness=thickness)
self._spec = _copy_naca5_modified_spec(spec)
[docs]
@classmethod
def from_designation(cls, designation: str) -> Self:
"""
Build a classic modified NACA 5-digit airfoil from a designation.
Parameters
----------
designation : str
Modified NACA 5-digit designation string.
Returns
-------
Self
Runtime airfoil built from ``designation``.
Raises
------
ValueError
If ``designation`` is not a valid modified NACA 5-digit code.
"""
return cls(Naca5ModifiedAirfoilSpec(designation=designation))
@property
def spec(self) -> Naca5ModifiedAirfoilSpec:
"""
Return the source spec for this airfoil.
Returns
-------
Naca5ModifiedAirfoilSpec
Defensive copy of the serialized source spec.
"""
return _copy_naca5_modified_spec(self._spec)
@property
def camber(
self,
) -> Naca5DigitCamberClassic | Naca5DigitCamberReflexedClassic:
"""
Return the concrete classic modified-series camber model.
This property exposes the classic 5-digit camber-line object
constructed from the stored modified-series designation, including
the reflexed variant when requested by the source spec.
"""
return cast(
Naca5DigitCamberClassic | Naca5DigitCamberReflexedClassic,
self._camber,
)
@property
def thickness(self) -> Naca45DigitModifiedThicknessClassic:
"""
Return the concrete classic modified-series thickness model.
This property exposes the modified 4/5-digit thickness relation
constructed from the stored classic modified-series definition.
"""
return cast(Naca45DigitModifiedThicknessClassic, self._thickness)
[docs]
def to_spec(self) -> Naca5ModifiedAirfoilSpec:
"""
Return the schema definition needed to recreate this airfoil.
Returns
-------
Naca5ModifiedAirfoilSpec
Serialized source spec for this airfoil.
"""
return self.spec
[docs]
class Naca5ModifiedAirfoilParams(OrthogonalAirfoil):
"""Parametric modified NACA 5-digit airfoil built from explicit params."""
def __init__(self, spec: Naca5ModifiedAirfoilSpec) -> None:
"""
Build a parametric modified NACA 5-digit airfoil from its spec.
Parameters
----------
spec : Naca5ModifiedAirfoilSpec
Serialized specification containing explicit modified-series
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 modified NACA 5-digit airfoils require "
"params-only specs."
)
_validate_naca5_modified_params(spec.params)
lift_index = (20.0 / 3.0) * spec.params.ideal_lift_coefficient
max_camber_index = 20.0 * 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 = Naca45DigitModifiedThicknessParams(
mti=100.0 * spec.params.max_thickness,
lei=spec.params.leading_edge_index,
lmti=10.0 * spec.params.max_thickness_location,
closed_te=spec.params.trailing_edge == "sharp",
)
super().__init__(camber=camber, thickness=thickness)
self._spec = _copy_naca5_modified_spec(spec)
@property
def camber(
self,
) -> Naca5DigitCamberParams | Naca5DigitCamberReflexedParams:
"""
Return the concrete parametric modified-series camber model.
This property exposes the continuous-index 5-digit camber-line object
constructed from the stored modified-series parameter definition,
including the reflexed variant when requested by the source spec.
"""
return cast(
Naca5DigitCamberParams | Naca5DigitCamberReflexedParams,
self._camber,
)
@property
def thickness(self) -> Naca45DigitModifiedThicknessParams:
"""
Return the concrete parametric modified-series thickness model.
This property exposes the modified 4/5-digit thickness relation
constructed from the stored parametric modified-series definition.
"""
return cast(Naca45DigitModifiedThicknessParams, self._thickness)
[docs]
@classmethod
def from_params(
cls,
*,
ideal_lift_coefficient: FloatScalar,
max_camber_location: FloatScalar,
reflexed: bool,
t: FloatScalar,
leading_edge_index: FloatScalar,
max_thickness_location: FloatScalar,
trailing_edge: Literal["standard", "sharp"] = "standard",
) -> Self:
"""
Build a parametric modified NACA 5-digit airfoil from 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.
leading_edge_index : FloatScalar
Modified-series leading-edge shape index.
max_thickness_location : FloatScalar
Chordwise location of maximum thickness as a fraction of chord.
trailing_edge : {"standard", "sharp"}, default="standard"
Trailing-edge closure model for the thickness distribution.
Returns
-------
Self
Runtime airfoil built from the explicit parameters.
Raises
------
ValueError
If the parameter set falls outside the supported modified-series
range.
"""
params = Naca5ModifiedAirfoilParamsSpec(
ideal_lift_coefficient=ideal_lift_coefficient,
max_camber_location=max_camber_location,
reflexed=reflexed,
max_thickness=t,
leading_edge_index=leading_edge_index,
max_thickness_location=max_thickness_location,
trailing_edge=trailing_edge,
)
return cls(Naca5ModifiedAirfoilSpec(params=params))
@property
def spec(self) -> Naca5ModifiedAirfoilSpec:
"""
Return the source spec for this airfoil.
Returns
-------
Naca5ModifiedAirfoilSpec
Defensive copy of the serialized source spec.
"""
return _copy_naca5_modified_spec(self._spec)
[docs]
def to_spec(self) -> Naca5ModifiedAirfoilSpec:
"""
Return the schema definition needed to recreate this airfoil.
Returns
-------
Naca5ModifiedAirfoilSpec
Serialized source spec for this airfoil.
"""
return self.spec