"""Typed outputs, validation, and diagnostics for airfoil sampling."""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from typing import Literal
import numpy as np
from buffalo_core.diagnostics import (
Diagnostic,
DiagnosticLocation,
DiagnosticReport,
DiagnosticSeverity,
OperationResult,
)
from buffalo_core.numeric import as_float_array, as_float_scalar, as_int_array
from buffalo_core.typing import FloatArray, FloatInput, FloatScalar, IntArray
from numpy.typing import NDArray
from .base import Airfoil
from .parameter_validation import (
validate_surface_arc_length as validate_surface_arc_length_domain,
)
from .parameter_validation import validate_xi as validate_xi_domain
from .runtime_common import ROOT_ABS_TOLERANCE
SURFACE_SLOPE_SINGULARITY_MESSAGE = (
"Surface samples report slope as dy/dx. "
"Airfoil surfaces can contain singular slope locations where the "
"reported slope will appear as inf, -inf, or nan. "
"This commonly occurs near the leading edge. "
"For cambered airfoils, the upper-surface singular slope can occur "
"at an x-location less than 0."
)
_MIN_POINTS_PER_SURFACE = 2
_POINT_DIMENSION = 2
SamplingWarningPolicy = Literal["warn", "ignore", "error", "diagnostics_only"]
AirfoilBoundaryOrder = Literal["lower_to_upper"]
AirfoilBoundaryQuantity = Literal[
"arc_length",
"tangent",
"normal",
"curvature",
]
def validate_xi(xi: FloatArray) -> None:
"""
Validate ordered surface-local samples.
Raises
------
ValueError
If the sequence is not monotone in leading-edge to trailing-edge
order.
"""
validate_monotonic_surface_samples(
xi,
quantity_name="Xi values",
)
def validate_surface_arc_length(
s: FloatArray,
*,
upper: bool,
) -> None:
"""
Validate ordered one-surface arc-length samples.
Raises
------
ValueError
If the sequence is not monotone in leading-edge to trailing-edge
order.
"""
surface_name = "upper" if upper else "lower"
validate_monotonic_surface_samples(
s,
quantity_name=f"{surface_name.capitalize()} surface arc lengths",
)
def validate_monotonic_surface_samples(
values: FloatArray,
*,
quantity_name: str,
) -> None:
"""
Validate that explicit surface samples preserve LE-to-TE order.
Raises
------
ValueError
If ``values`` decreases beyond the allowed tolerance.
"""
if values.size > 1 and (np.diff(values) < -ROOT_ABS_TOLERANCE).any():
msg = f"{quantity_name} must be nondecreasing."
raise ValueError(msg)
def warn_surface_slope_singularity() -> None:
"""
Warn that sampled ``dy/dx`` values can be singular on the surface.
Returns
-------
None
This helper emits a ``RuntimeWarning`` in place.
"""
warnings.warn(
SURFACE_SLOPE_SINGULARITY_MESSAGE,
RuntimeWarning,
stacklevel=3,
)
def surface_sampling_diagnostics() -> DiagnosticReport:
"""
Return the standard diagnostic report for airfoil sampling workflows.
Returns
-------
DiagnosticReport
Diagnostic report containing the slope-singularity warning that
accompanies downstream surface sampling payloads.
"""
return DiagnosticReport(
entries=(
Diagnostic(
severity=DiagnosticSeverity.WARNING,
code="airfoil.surface_sampling.slope_singularity",
message=SURFACE_SLOPE_SINGULARITY_MESSAGE,
location=DiagnosticLocation(
object_path="airfoil.sample",
field_path="slope",
geometry_region="leading_edge",
),
),
)
)
[docs]
def sample_airfoil(
airfoil: Airfoil,
*,
num_points_per_surface: int,
spacing: Literal["uniform", "cosine"] = "cosine",
) -> AirfoilSurfaceSamples:
"""
Sample both airfoil surfaces using a standard spacing rule.
Parameters
----------
airfoil : Airfoil
Airfoil to sample.
num_points_per_surface : int
Number of sample points to generate on each surface.
spacing : {"uniform", "cosine"}, default="cosine"
Spacing rule used to distribute surface-local sample parameters.
Returns
-------
AirfoilSurfaceSamples
Structured downstream sampling payload for both airfoil surfaces.
Raises
------
ValueError
If fewer than two points per surface are requested or if
``spacing`` is not one of the supported rules.
"""
return sample_airfoil_result(
airfoil,
num_points_per_surface=num_points_per_surface,
spacing=spacing,
warning_policy="warn",
).value
[docs]
def sample_airfoil_result(
airfoil: Airfoil,
*,
num_points_per_surface: int,
spacing: Literal["uniform", "cosine"] = "cosine",
warning_policy: SamplingWarningPolicy = "warn",
) -> OperationResult[AirfoilSurfaceSamples]:
"""
Sample both airfoil surfaces with structured diagnostic reporting.
Parameters
----------
airfoil : Airfoil
Airfoil to sample.
num_points_per_surface : int
Number of sample points to generate on each surface.
spacing : {"uniform", "cosine"}, default="cosine"
Spacing rule used to distribute surface-local sample parameters.
warning_policy : {"warn", "ignore", "error", "diagnostics_only"},
default="warn"
How to handle the standard slope-singularity advisory.
Returns
-------
OperationResult[AirfoilSurfaceSamples]
Typed sampling payload together with the structured diagnostics
emitted while producing it.
Raises
------
ValueError
If fewer than two points per surface are requested or if
``spacing`` is not one of the supported rules.
RuntimeError
If ``warning_policy`` is ``"error"`` and the standard sampling
advisory is promoted to an exception.
"""
if num_points_per_surface < _MIN_POINTS_PER_SURFACE:
msg = "num_points_per_surface must be at least 2."
raise ValueError(msg)
if spacing == "uniform":
xi = np.linspace(
0.0,
1.0,
num_points_per_surface,
dtype=np.float64,
)
elif spacing == "cosine":
theta = np.linspace(
0.0,
np.pi,
num_points_per_surface,
dtype=np.float64,
)
xi = 0.5 * (1.0 - np.cos(theta))
else:
msg = "spacing must be either 'uniform' or 'cosine'."
raise ValueError(msg)
return sample_airfoil_at_xi_result(
airfoil,
lower=xi,
upper=xi,
warning_policy=warning_policy,
)
[docs]
def sample_airfoil_boundary(
airfoil: Airfoil,
*,
num_points_per_surface: int,
spacing: Literal["uniform", "cosine"] = "cosine",
order: AirfoilBoundaryOrder = "lower_to_upper",
quantities: tuple[AirfoilBoundaryQuantity, ...] = (),
warning_policy: SamplingWarningPolicy = "warn",
) -> AirfoilBoundarySamples:
"""
Sample an airfoil as one ordered boundary point distribution.
Parameters
----------
airfoil : Airfoil
Airfoil to sample.
num_points_per_surface : int
Number of sample points to generate on each surface.
spacing : {"uniform", "cosine"}, default="cosine"
Spacing rule used to distribute surface-local sample parameters.
order : {"lower_to_upper"}, default="lower_to_upper"
Boundary ordering convention.
The current convention starts at the lower trailing edge, proceeds to
the leading edge, and then proceeds to the upper trailing edge.
quantities : tuple[AirfoilBoundaryQuantity, ...], default=()
Optional boundary quantities to populate.
Coordinates, topology metadata, and trailing-edge closure metadata are
always included.
warning_policy : {"warn", "ignore", "error", "diagnostics_only"},
default="warn"
How to handle the standard slope-singularity advisory emitted by the
underlying surface sampler.
Returns
-------
AirfoilBoundarySamples
Ordered boundary samples and requested optional geometry quantities.
Raises
------
ValueError
If the sampling request is invalid or an unknown boundary quantity is
requested.
NotImplementedError
If an unsupported boundary ordering is requested.
Notes
-----
This helper preserves the trailing-edge geometry implied by the airfoil.
It reports whether the sampled trailing-edge points coincide but does not
force closure or insert a closure segment.
Downstream consumers decide how to handle open trailing edges.
"""
samples = sample_airfoil_result(
airfoil,
num_points_per_surface=num_points_per_surface,
spacing=spacing,
warning_policy=warning_policy,
).value
return airfoil_surface_samples_to_boundary(
samples,
order=order,
quantities=quantities,
)
[docs]
def airfoil_surface_samples_to_boundary(
samples: AirfoilSurfaceSamples,
*,
order: AirfoilBoundaryOrder = "lower_to_upper",
quantities: tuple[AirfoilBoundaryQuantity, ...] = (),
) -> AirfoilBoundarySamples:
"""
Convert two-surface airfoil samples into one boundary distribution.
Parameters
----------
samples : AirfoilSurfaceSamples
Existing two-surface sampling payload.
order : {"lower_to_upper"}, default="lower_to_upper"
Boundary ordering convention.
quantities : tuple[AirfoilBoundaryQuantity, ...], default=()
Optional quantities to populate on the boundary result.
Returns
-------
AirfoilBoundarySamples
Ordered boundary samples derived from ``samples``.
Raises
------
ValueError
If an unknown boundary quantity is requested.
NotImplementedError
If an unsupported boundary ordering is requested.
"""
if order != "lower_to_upper":
raise NotImplementedError(
"Only lower_to_upper boundary order is supported."
)
_validate_boundary_quantities(quantities)
lower = samples.lower
upper = samples.upper
coordinates = np.vstack((lower.coordinates[:0:-1], upper.coordinates))
lower_count = lower.coordinates.shape[0] - 1
upper_count = upper.coordinates.shape[0]
sample_count = lower_count + upper_count
lower_indices = np.arange(lower_count, dtype=np.int32)
upper_indices = np.arange(
lower_count,
sample_count,
dtype=np.int32,
)
surface = np.empty(sample_count, dtype=np.int32)
surface[lower_indices] = 0
surface[upper_indices] = 1
xi = np.concatenate((lower.xi[:0:-1], upper.xi))
curve_u = np.concatenate((lower.curve_u[:0:-1], upper.curve_u))
leading_edge_index = lower_count
trailing_edge_is_closed = bool(
np.allclose(
coordinates[0],
coordinates[-1],
atol=ROOT_ABS_TOLERANCE,
rtol=0.0,
)
)
requested = set(quantities)
return AirfoilBoundarySamples(
coordinates=coordinates,
xi=xi,
curve_u=curve_u,
surface=as_int_array(surface),
lower_indices=as_int_array(lower_indices),
upper_indices=as_int_array(upper_indices),
leading_edge_index=leading_edge_index,
trailing_edge_is_closed=trailing_edge_is_closed,
arc_length=(
_boundary_arc_length(lower, upper)
if "arc_length" in requested
else None
),
tangent=(
_boundary_tangent(lower, upper) if "tangent" in requested else None
),
normal=(
_rotate_tangent_to_left_normal(_boundary_tangent(lower, upper))
if "normal" in requested
else None
),
curvature=(
_boundary_curvature(lower, upper)
if "curvature" in requested
else None
),
)
def _validate_boundary_quantities(
quantities: tuple[AirfoilBoundaryQuantity, ...],
) -> None:
"""
Validate optional airfoil boundary quantity names.
Raises
------
ValueError
If an unknown quantity name is requested.
"""
supported: set[str] = {"arc_length", "tangent", "normal", "curvature"}
unexpected = [
quantity for quantity in quantities if quantity not in supported
]
if unexpected:
msg = f"Unsupported boundary quantities: {unexpected!r}."
raise ValueError(msg)
def _boundary_arc_length(
lower: SurfaceSamples,
upper: SurfaceSamples,
) -> FloatArray:
"""Return cumulative boundary arc length in lower-to-upper order."""
return np.concatenate((
lower.surface_length - lower.arc_length[:0:-1],
lower.surface_length + upper.arc_length,
))
def _boundary_tangent(
lower: SurfaceSamples,
upper: SurfaceSamples,
) -> FloatArray:
"""Return unit tangents in lower-to-upper boundary order."""
lower_tangent = -_surface_tangent_from_slope(lower.slope[:0:-1])
upper_tangent = _surface_tangent_from_slope(upper.slope)
return np.vstack((lower_tangent, upper_tangent))
def _surface_tangent_from_slope(slope: FloatArray) -> FloatArray:
"""Return LE-to-TE unit tangents from one-surface ``dy/dx`` values."""
tangent = np.empty((slope.shape[0], 2), dtype=np.float64)
finite = np.isfinite(slope)
tangent[finite, 0] = 1.0
tangent[finite, 1] = slope[finite]
tangent[~finite, 0] = 0.0
tangent[~finite, 1] = np.sign(slope[~finite])
tangent[np.isnan(tangent[:, 1]), 1] = 1.0
norm = np.linalg.norm(tangent, axis=1)
return tangent / norm[:, np.newaxis]
def _rotate_tangent_to_left_normal(tangent: FloatArray) -> FloatArray:
"""Rotate unit tangents into left-hand unit normals."""
return np.column_stack((-tangent[:, 1], tangent[:, 0]))
def _boundary_curvature(
lower: SurfaceSamples,
upper: SurfaceSamples,
) -> FloatArray:
"""Return signed curvature values in lower-to-upper boundary order."""
return np.concatenate((-lower.curvature[:0:-1], upper.curvature))
[docs]
def sample_airfoil_at_xi(
airfoil: Airfoil,
*,
lower: FloatInput,
upper: FloatInput,
) -> AirfoilSurfaceSamples:
"""
Sample both surfaces at explicit ``xi`` coordinates.
Parameters
----------
airfoil : Airfoil
Airfoil to sample.
lower : FloatInput
Lower-surface ``xi`` values in ``[0, 1]`` ordered from leading edge
to trailing edge.
upper : FloatInput
Upper-surface ``xi`` values in ``[0, 1]`` ordered from leading edge
to trailing edge.
Returns
-------
AirfoilSurfaceSamples
Structured downstream sampling payload for both airfoil surfaces.
"""
return sample_airfoil_at_xi_result(
airfoil,
lower=lower,
upper=upper,
warning_policy="warn",
).value
[docs]
def sample_airfoil_at_xi_result(
airfoil: Airfoil,
*,
lower: FloatInput,
upper: FloatInput,
warning_policy: SamplingWarningPolicy = "warn",
) -> OperationResult[AirfoilSurfaceSamples]:
"""
Sample both surfaces at explicit ``xi`` with diagnostics.
Parameters
----------
airfoil : Airfoil
Airfoil to sample.
lower : FloatInput
Lower-surface ``xi`` values in ``[0, 1]`` ordered from leading edge
to trailing edge.
upper : FloatInput
Upper-surface ``xi`` values in ``[0, 1]`` ordered from leading edge
to trailing edge.
warning_policy : {"warn", "ignore", "error", "diagnostics_only"},
default="warn"
How to handle the standard slope-singularity advisory.
Returns
-------
OperationResult[AirfoilSurfaceSamples]
Typed sampling payload together with the structured diagnostics
emitted while producing it.
"""
lower_xi = validate_xi_domain(lower)
upper_xi = validate_xi_domain(upper)
validate_xi(lower_xi)
validate_xi(upper_xi)
diagnostics = surface_sampling_diagnostics()
_handle_sampling_warning_policy(
warning_policy=warning_policy,
diagnostics=diagnostics,
)
samples = _build_surface_samples(
airfoil,
lower_xi=lower_xi,
upper_xi=upper_xi,
lower_u=airfoil.u_from_xi(lower_xi, surface="lower"),
upper_u=airfoil.u_from_xi(upper_xi, surface="upper"),
diagnostics=diagnostics,
)
return OperationResult(value=samples, diagnostics=diagnostics)
[docs]
def sample_airfoil_at_arc_length(
airfoil: Airfoil,
*,
lower: FloatInput,
upper: FloatInput,
) -> AirfoilSurfaceSamples:
"""
Sample both surfaces at explicit surface-local arc lengths.
Parameters
----------
airfoil : Airfoil
Airfoil to sample.
lower : FloatInput
Lower-surface arc lengths measured from the leading edge.
upper : FloatInput
Upper-surface arc lengths measured from the leading edge.
Returns
-------
AirfoilSurfaceSamples
Structured downstream sampling payload for both airfoil surfaces.
"""
return sample_airfoil_at_arc_length_result(
airfoil,
lower=lower,
upper=upper,
warning_policy="warn",
).value
[docs]
def sample_airfoil_at_arc_length_result(
airfoil: Airfoil,
*,
lower: FloatInput,
upper: FloatInput,
warning_policy: SamplingWarningPolicy = "warn",
) -> OperationResult[AirfoilSurfaceSamples]:
"""
Sample both surfaces at explicit arc length with diagnostics.
Parameters
----------
airfoil : Airfoil
Airfoil to sample.
lower : FloatInput
Lower-surface arc lengths measured from the leading edge.
upper : FloatInput
Upper-surface arc lengths measured from the leading edge.
warning_policy : {"warn", "ignore", "error", "diagnostics_only"},
default="warn"
How to handle the standard slope-singularity advisory.
Returns
-------
OperationResult[AirfoilSurfaceSamples]
Typed sampling payload together with the structured diagnostics
emitted while producing it.
"""
lower_arc_length = validate_surface_arc_length_domain(
lower,
surface="lower",
surface_length=_lower_surface_length(airfoil),
)
upper_arc_length = validate_surface_arc_length_domain(
upper,
surface="upper",
surface_length=_upper_surface_length(airfoil),
)
lower_length = _lower_surface_length(airfoil)
upper_length = _upper_surface_length(airfoil)
validate_surface_arc_length(
lower_arc_length,
upper=False,
)
validate_surface_arc_length(
upper_arc_length,
upper=True,
)
diagnostics = surface_sampling_diagnostics()
_handle_sampling_warning_policy(
warning_policy=warning_policy,
diagnostics=diagnostics,
)
lower_full_arc_length = lower_length - lower_arc_length
upper_full_arc_length = lower_length + upper_arc_length
samples = _build_surface_samples(
airfoil,
lower_xi=lower_arc_length / lower_length,
upper_xi=upper_arc_length / upper_length,
lower_u=airfoil.u_from_s(lower_full_arc_length),
upper_u=airfoil.u_from_s(upper_full_arc_length),
diagnostics=diagnostics,
)
return OperationResult(value=samples, diagnostics=diagnostics)
def _handle_sampling_warning_policy(
*,
warning_policy: SamplingWarningPolicy,
diagnostics: DiagnosticReport,
) -> None:
"""
Apply the configured advisory policy for surface sampling.
Parameters
----------
warning_policy : {"warn", "ignore", "error", "diagnostics_only"}
How to handle the standard slope-singularity advisory.
diagnostics : DiagnosticReport
Structured diagnostics associated with the sampling workflow.
Raises
------
RuntimeError
If ``warning_policy`` is ``"error"`` and the advisory is promoted
to an exception.
"""
if warning_policy == "warn":
warn_surface_slope_singularity()
return
if warning_policy in {"ignore", "diagnostics_only"}:
return
if warning_policy == "error":
first_message = diagnostics.entries[0].message
raise RuntimeError(first_message)
msg = (
"warning_policy must be one of 'warn', 'ignore', 'error', "
"or 'diagnostics_only'."
)
raise ValueError(msg)
def _build_surface_samples(
airfoil: Airfoil,
*,
lower_xi: FloatArray,
upper_xi: FloatArray,
lower_u: FloatArray,
upper_u: FloatArray,
diagnostics: DiagnosticReport,
) -> AirfoilSurfaceSamples:
"""
Build the combined lower and upper surface sampling payload.
Parameters
----------
airfoil : Airfoil
Airfoil being sampled.
lower_xi : FloatArray
Lower-surface ``xi`` locations ordered from leading edge to trailing
edge.
upper_xi : FloatArray
Upper-surface ``xi`` locations ordered from leading edge to trailing
edge.
lower_u : FloatArray
Lower-surface native parameters matching ``lower_xi``.
upper_u : FloatArray
Upper-surface native parameters matching ``upper_xi``.
diagnostics : DiagnosticReport
Structured diagnostics to attach to the returned payload.
Returns
-------
AirfoilSurfaceSamples
Complete downstream sampling payload including both surfaces,
reference-edge points, and standard diagnostics.
"""
leading_edge_point = np.array(airfoil.leading_edge(), dtype=np.float64)
lower_x, lower_y = airfoil.xy_from_u(-1.0)
upper_x, upper_y = airfoil.xy_from_u(1.0)
trailing_edge_lower = np.array(
[as_float_scalar(lower_x), as_float_scalar(lower_y)],
dtype=np.float64,
)
trailing_edge_upper = np.array(
[as_float_scalar(upper_x), as_float_scalar(upper_y)],
dtype=np.float64,
)
return AirfoilSurfaceSamples(
lower=_build_one_surface_samples(
airfoil,
xi=lower_xi,
u=lower_u,
upper=False,
),
upper=_build_one_surface_samples(
airfoil,
xi=upper_xi,
u=upper_u,
upper=True,
),
leading_edge=leading_edge_point,
trailing_edge_upper=trailing_edge_upper,
trailing_edge_lower=trailing_edge_lower,
trailing_edge_midpoint=0.5
* (trailing_edge_upper + trailing_edge_lower),
chord_length=airfoil.chord(),
diagnostics=diagnostics,
)
def _build_one_surface_samples(
airfoil: Airfoil,
*,
xi: FloatArray,
u: FloatArray,
upper: bool,
) -> SurfaceSamples:
"""
Build the sampling payload for one surface.
Parameters
----------
airfoil : Airfoil
Airfoil being sampled.
xi : FloatArray
Surface-local ``xi`` values ordered from leading edge to trailing
edge.
u : FloatArray
Native airfoil parameters matching ``xi``.
upper : bool
Whether the payload corresponds to the upper surface.
Returns
-------
SurfaceSamples
Stable per-surface sampling payload with coordinates, slope,
curvature, and native parameter metadata.
"""
x, y = airfoil.xy_from_u(u)
slope = airfoil.dydx(u)
curvature = airfoil.k(u)
if not upper:
curvature = -curvature
return SurfaceSamples(
xi=xi,
arc_length=np.abs(airfoil.arc_length(0.0, u)),
curve_u=u,
coordinates=np.column_stack((x, y)),
slope=slope,
slope_is_finite=np.isfinite(slope),
curvature=curvature,
surface_length=(
_upper_surface_length(airfoil)
if upper
else _lower_surface_length(airfoil)
),
)
def _lower_surface_length(airfoil: Airfoil) -> FloatScalar:
"""
Return the cached lower-surface length for sampling workflows.
Parameters
----------
airfoil : Airfoil
Airfoil providing the cached lower-surface arc length.
Returns
-------
FloatScalar
Lower-surface length measured from the lower trailing edge to the
leading edge.
"""
return airfoil._lower_surface_length() # pyright: ignore[reportPrivateUsage]
def _upper_surface_length(airfoil: Airfoil) -> FloatScalar:
"""
Return the cached upper-surface length for sampling workflows.
Parameters
----------
airfoil : Airfoil
Airfoil providing the cached upper-surface arc length.
Returns
-------
FloatScalar
Upper-surface length measured from the leading edge to the upper
trailing edge.
"""
return airfoil._upper_surface_length() # pyright: ignore[reportPrivateUsage]
def _require_vector(
value: FloatArray,
*,
name: str,
length: int | None = None,
) -> FloatArray:
"""
Validate that a field is a one-dimensional float64 vector.
Raises
------
ValueError
If the array is not one-dimensional or does not match ``length``
when one is requested.
"""
array = as_float_array(value)
if array.ndim != 1:
msg = f"{name} must be a one-dimensional float64 array."
raise ValueError(msg)
if length is not None and array.shape[0] != length:
msg = f"{name} must have length {length}."
raise ValueError(msg)
return array
def _require_matrix(
value: FloatArray,
*,
name: str,
rows: int,
cols: int,
) -> FloatArray:
"""
Validate that a field is a two-dimensional float64 matrix.
Raises
------
ValueError
If the array does not have the requested matrix shape.
"""
array = as_float_array(value)
if array.shape != (rows, cols):
msg = f"{name} must have shape ({rows}, {cols})."
raise ValueError(msg)
return array
def _require_optional_vector(
value: FloatArray | None,
*,
name: str,
length: int,
) -> FloatArray | None:
"""
Validate an optional one-dimensional float64 array field.
Raises
------
ValueError
If the array is present and does not match ``length``.
"""
if value is None:
return None
return _require_vector(value, name=name, length=length)
def _require_optional_matrix(
value: FloatArray | None,
*,
name: str,
rows: int,
cols: int,
) -> FloatArray | None:
"""
Validate an optional two-dimensional float64 matrix field.
Raises
------
ValueError
If the array is present and does not have the requested shape.
"""
if value is None:
return None
return _require_matrix(value, name=name, rows=rows, cols=cols)
[docs]
@dataclass(slots=True)
class AirfoilBoundarySamples:
"""
Sampled airfoil boundary in one ordered point distribution.
The boundary preserves the trailing-edge geometry implied by the source
airfoil.
For ``lower_to_upper`` order, samples start at the lower trailing edge,
proceed to the leading edge, and then proceed to the upper trailing edge.
Attributes
----------
coordinates : FloatArray
Boundary coordinates with shape ``(N, 2)``.
xi : FloatArray
Surface-local coordinates associated with each boundary point.
curve_u : FloatArray
Native airfoil parameters associated with each boundary point.
surface : IntArray
Surface identifier for each boundary point, with ``0`` for lower and
``1`` for upper.
lower_indices : IntArray
Boundary indices associated with lower-surface samples.
upper_indices : IntArray
Boundary indices associated with upper-surface samples.
leading_edge_index : int
Boundary index of the unique leading-edge sample.
trailing_edge_is_closed : bool
Whether the first and last boundary points coincide within the runtime
root tolerance.
arc_length : FloatArray | None
Optional cumulative boundary arc length measured from the first point.
tangent : FloatArray | None
Optional unit tangent vectors with shape ``(N, 2)`` in boundary order.
normal : FloatArray | None
Optional left-hand unit normals with shape ``(N, 2)`` in boundary
order.
curvature : FloatArray | None
Optional signed curvature values in boundary orientation.
"""
coordinates: FloatArray
xi: FloatArray
curve_u: FloatArray
surface: IntArray
lower_indices: IntArray
upper_indices: IntArray
leading_edge_index: int
trailing_edge_is_closed: bool
arc_length: FloatArray | None = None
tangent: FloatArray | None = None
normal: FloatArray | None = None
curvature: FloatArray | None = None
coordinate_ordering: AirfoilBoundaryOrder = "lower_to_upper"
normalization: Literal["unit_chord_local_frame"] = "unit_chord_local_frame"
def __post_init__(self) -> None:
"""
Validate array shapes for the stable boundary-sampling contract.
Raises
------
ValueError
If any boundary field violates the required shape contract.
"""
self.coordinates = as_float_array(self.coordinates)
if (
self.coordinates.ndim != _POINT_DIMENSION
or self.coordinates.shape[1] != _POINT_DIMENSION
):
msg = "coordinates must have shape (N, 2)."
raise ValueError(msg)
sample_count = self.coordinates.shape[0]
self.xi = _require_vector(self.xi, name="xi", length=sample_count)
self.curve_u = _require_vector(
self.curve_u,
name="curve_u",
length=sample_count,
)
self.surface = as_int_array(self.surface)
if self.surface.shape != (sample_count,):
msg = "surface must have length N."
raise ValueError(msg)
self.lower_indices = as_int_array(self.lower_indices)
self.upper_indices = as_int_array(self.upper_indices)
self.arc_length = _require_optional_vector(
self.arc_length,
name="arc_length",
length=sample_count,
)
self.tangent = _require_optional_matrix(
self.tangent,
name="tangent",
rows=sample_count,
cols=2,
)
self.normal = _require_optional_matrix(
self.normal,
name="normal",
rows=sample_count,
cols=2,
)
self.curvature = _require_optional_vector(
self.curvature,
name="curvature",
length=sample_count,
)
if not 0 <= self.leading_edge_index < sample_count:
msg = "leading_edge_index must select a boundary point."
raise ValueError(msg)
[docs]
@dataclass(slots=True)
class SurfaceSamples:
"""
Sampled geometry for one airfoil surface in leading-edge order.
Attributes
----------
xi : FloatArray
Surface-local chord coordinates in leading-edge to trailing-edge
order.
arc_length : FloatArray
Surface-local arc lengths measured from the leading edge.
curve_u : FloatArray
Native airfoil parameters matching the sampled points.
coordinates : FloatArray
``(x, y)`` coordinates with shape ``(N, 2)``.
slope : FloatArray
Surface slope reported as ``dy/dx``.
slope_is_finite : NDArray[np.bool_]
Boolean mask identifying finite slope entries.
curvature : FloatArray
Signed surface curvature in surface-local orientation.
surface_length : FloatScalar
Total arc length of the sampled surface.
"""
xi: FloatArray
arc_length: FloatArray
curve_u: FloatArray
coordinates: FloatArray
slope: FloatArray
slope_is_finite: NDArray[np.bool_]
curvature: FloatArray
surface_length: FloatScalar
def __post_init__(self) -> None:
"""
Validate array shapes for the stable surface-sampling contract.
Raises
------
ValueError
If any array field violates the required shape contract.
"""
self.xi = _require_vector(
self.xi,
name="xi",
)
sample_count = self.xi.shape[0]
self.arc_length = _require_vector(
self.arc_length,
name="arc_length",
length=sample_count,
)
self.curve_u = _require_vector(
self.curve_u,
name="curve_u",
length=sample_count,
)
self.coordinates = _require_matrix(
self.coordinates,
name="coordinates",
rows=sample_count,
cols=2,
)
self.slope = _require_vector(
self.slope,
name="slope",
length=sample_count,
)
finite_mask = np.asarray(self.slope_is_finite, dtype=np.bool_)
if finite_mask.ndim != 1:
msg = "slope_is_finite must be a one-dimensional boolean array."
raise ValueError(msg)
if finite_mask.shape[0] != sample_count:
msg = "slope_is_finite must match xi length."
raise ValueError(msg)
self.slope_is_finite = finite_mask
self.curvature = _require_vector(
self.curvature,
name="curvature",
length=sample_count,
)
self.surface_length = as_float_scalar(self.surface_length)
[docs]
@dataclass(slots=True)
class AirfoilSurfaceSamples:
"""
Complete downstream sampling payload for both airfoil surfaces.
Attributes
----------
lower : SurfaceSamples
Lower-surface sampling payload.
upper : SurfaceSamples
Upper-surface sampling payload.
leading_edge : FloatArray
Leading-edge point with shape ``(2,)``.
trailing_edge_upper : FloatArray
Upper trailing-edge point with shape ``(2,)``.
trailing_edge_lower : FloatArray
Lower trailing-edge point with shape ``(2,)``.
trailing_edge_midpoint : FloatArray
Midpoint between the trailing-edge surface points.
chord_length : FloatScalar
Chord length measured between the leading-edge and trailing-edge
midpoint references.
diagnostics : DiagnosticReport
Standard sampling diagnostics associated with the payload.
"""
lower: SurfaceSamples
upper: SurfaceSamples
leading_edge: FloatArray
trailing_edge_upper: FloatArray
trailing_edge_lower: FloatArray
trailing_edge_midpoint: FloatArray
chord_length: FloatScalar
diagnostics: DiagnosticReport = field(default_factory=DiagnosticReport)
coordinate_ordering: Literal["surface_leading_edge_to_trailing_edge"] = (
"surface_leading_edge_to_trailing_edge"
)
parameter_ordering: Literal["xi_0_to_1"] = "xi_0_to_1"
arc_length_ordering: Literal["surface_arc_length_0_to_length"] = (
"surface_arc_length_0_to_length"
)
normalization: Literal["unit_chord_local_frame"] = "unit_chord_local_frame"
trailing_edge_handling: Literal["preserve_surface_points"] = (
"preserve_surface_points"
)
def __post_init__(self) -> None:
"""
Validate vector metadata and normalize scalar payload fields.
Raises
------
ValueError
If any edge-reference vector does not have shape ``(2,)``.
"""
self.leading_edge = _require_vector(
self.leading_edge,
name="leading_edge",
length=2,
)
self.trailing_edge_upper = _require_vector(
self.trailing_edge_upper,
name="trailing_edge_upper",
length=2,
)
self.trailing_edge_lower = _require_vector(
self.trailing_edge_lower,
name="trailing_edge_lower",
length=2,
)
self.trailing_edge_midpoint = _require_vector(
self.trailing_edge_midpoint,
name="trailing_edge_midpoint",
length=2,
)
self.chord_length = as_float_scalar(self.chord_length)