Source code for buffalo_wings.airfoil.internal.sampling

"""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)