Source code for buffalo_wings.wing.internal.wing_canonical

"""Canonical wing-geometry evaluation from the documented schema."""

from __future__ import annotations

from dataclasses import dataclass

import numpy as np

from buffalo_wings.airfoil import Airfoil, AirfoilFactory, DatAirfoilSpec
from buffalo_wings.internal.numeric import as_float_array, as_float_scalar
from buffalo_wings.type_aliases import FloatArray, FloatScalar

from .wing_schema import (
    DistributionSpec,
    PanelSpec,
    PiecewiseLinearDistribution,
    ReferenceAxis,
    SingleAirfoilRef,
    WingSpec,
)

_MIN_STATION_COUNT = 2


[docs] @dataclass(slots=True) class EvaluatedStation: """ Evaluated panel properties at a span station. Attributes ---------- panel_id : str Identifier of the panel evaluated at this station. eta : FloatScalar Normalized spanwise coordinate used for evaluation. y : FloatScalar Spanwise position in the wing reference frame. x_ref : FloatScalar Reference-axis x-position at the span station. z_ref : FloatScalar Reference-axis z-position at the span station. chord : FloatScalar Local chord length at the span station. twist_rad : FloatScalar Local twist angle in radians. """ panel_id: str eta: FloatScalar y: FloatScalar x_ref: FloatScalar z_ref: FloatScalar chord: FloatScalar twist_rad: FloatScalar
[docs] @dataclass(slots=True) class SectionCurves: """ Sampled upper and lower 3D section curves at one span station. Attributes ---------- station : EvaluatedStation Evaluated panel properties used to place the section in 3D space. chordwise_samples : FloatArray One-dimensional normalized chordwise sample locations in ``[0, 1]``. upper_curve : FloatArray Array of shape ``(n_samples, 3)`` containing upper-surface points. lower_curve : FloatArray Array of shape ``(n_samples, 3)`` containing lower-surface points. """ station: EvaluatedStation chordwise_samples: FloatArray upper_curve: FloatArray lower_curve: FloatArray
[docs] class WingCanonical: """ Canonical evaluator for wing-section placement and sampling. Parameters ---------- spec : WingSpec Parsed wing schema used for panel evaluation and section sampling. Raises ------ ValueError If the configured reference-axis and twist-axis combination is not supported by the current implementation. """
[docs] def __init__(self, spec: WingSpec) -> None: """ Store the parsed wing specification and validate axis choices. Parameters ---------- spec : WingSpec Parsed wing schema used for panel evaluation and section sampling. Raises ------ ValueError If the configured reference-axis and twist-axis combination is not supported by the current implementation. """ self._spec = spec self._panel_map = {panel.id: panel for panel in spec.wing.panels} self._validate_axes()
[docs] @classmethod def from_spec(cls, spec: WingSpec) -> WingCanonical: """ Build a canonical evaluator from a parsed ``WingSpec``. Parameters ---------- spec : WingSpec Parsed wing schema used for panel evaluation and section sampling. Returns ------- WingCanonical Canonical evaluator backed by ``spec``. Raises ------ ValueError If the configured reference-axis and twist-axis combination is not supported by the current implementation. """ return cls(spec)
@property def spec(self) -> WingSpec: """ Return the underlying wing specification. Returns ------- WingSpec Wing schema used by the evaluator. """ return self._spec
[docs] def evaluate_distribution( self, distribution: DistributionSpec, eta: FloatScalar, ) -> FloatScalar: """ Evaluate a scalar distribution at ``eta``. Parameters ---------- distribution : DistributionSpec Distribution definition to evaluate. eta : FloatScalar Spanwise parameter at which to evaluate the distribution. Returns ------- FloatScalar Interpolated scalar value at ``eta``. Raises ------ ValueError If ``eta`` lies outside the distribution support or the distribution data are invalid. """ return self._evaluate_piecewise_linear(distribution, eta)
[docs] def sample_span_stations( self, panel_id: str, count: int, *, spacing: str = "uniform", ) -> FloatArray: """ Generate span stations over a panel ``eta_range``. Parameters ---------- panel_id : str Identifier of the panel to sample. count : int Number of stations to generate. spacing : str, default="uniform" Spacing rule used between the panel endpoints. Supported values are ``"uniform"`` and ``"cosine"``. Returns ------- FloatArray One-dimensional array of sampled ``eta`` values. Raises ------ KeyError If ``panel_id`` does not match a known panel. ValueError If ``count < 2`` or ``spacing`` is unsupported. """ if count < _MIN_STATION_COUNT: msg = "Span station count must be at least 2." raise ValueError(msg) panel = self._get_panel(panel_id) eta0, eta1 = panel.eta_range if spacing == "uniform": return np.linspace(eta0, eta1, count, dtype=np.float64) if spacing == "cosine": beta = np.linspace(0.0, np.pi, count, dtype=np.float64) blend = 0.5 * (1.0 - np.cos(beta)) return eta0 + (eta1 - eta0) * blend msg = f"Unsupported spacing method: {spacing}" raise ValueError(msg)
[docs] def evaluate_panel( self, panel_id: str, eta: FloatScalar ) -> EvaluatedStation: """ Evaluate the spanwise panel properties at ``eta``. Parameters ---------- panel_id : str Identifier of the panel to evaluate. eta : FloatScalar Spanwise parameter within the panel ``eta_range``. Returns ------- EvaluatedStation Evaluated reference-line position, chord, and twist at ``eta``. Raises ------ KeyError If ``panel_id`` does not match a known panel. ValueError If ``eta`` lies outside the panel ``eta_range``. """ panel = self._get_panel(panel_id) self._validate_eta(panel, eta) return EvaluatedStation( panel_id=panel_id, eta=eta, y=self._spec.wing.half_span * eta, x_ref=self.evaluate_distribution(panel.ref_line.x_ref, eta), z_ref=self.evaluate_distribution(panel.ref_line.z_ref, eta), chord=self.evaluate_distribution(panel.chord, eta), twist_rad=self._angle_to_radians( self.evaluate_distribution(panel.twist, eta) ), )
[docs] def section_curves( self, panel_id: str, *, eta: FloatScalar, chordwise_samples: FloatArray, ) -> SectionCurves: """ Generate sampled upper and lower 3D curves for one section. Parameters ---------- panel_id : str Identifier of the panel to sample. eta : float Spanwise parameter within the panel ``eta_range``. chordwise_samples : FloatArray One-dimensional normalized chordwise coordinates in ``[0, 1]``. Returns ------- SectionCurves Sampled upper and lower section curves in the canonical wing frame. Raises ------ KeyError If ``panel_id`` or the referenced airfoil name is unknown. ValueError If ``eta`` lies outside the panel range or ``chordwise_samples`` is not a one-dimensional array in ``[0, 1]``. NotImplementedError If the panel uses an unsupported airfoil reference or airfoil type. """ station = self.evaluate_panel(panel_id, eta) samples = as_float_array(chordwise_samples) if samples.ndim != 1: msg = "Chordwise samples must be a 1D array." raise ValueError(msg) if np.any(samples < 0.0) or np.any(samples > 1.0): msg = "Chordwise samples must lie in [0, 1]." raise ValueError(msg) airfoil = self._get_panel_airfoil(self._get_panel(panel_id)) upper_curve = self._section_surface_curve( airfoil, station, samples, upper=True, ) lower_curve = self._section_surface_curve( airfoil, station, samples, upper=False, ) return SectionCurves( station=station, chordwise_samples=samples, upper_curve=upper_curve, lower_curve=lower_curve, )
def _section_surface_curve( self, airfoil: Airfoil, station: EvaluatedStation, chordwise_samples: FloatArray, *, upper: bool, ) -> FloatArray: """ Build one 3D section curve in the canonical wing frame. Parameters ---------- airfoil : Airfoil Runtime airfoil object used to evaluate the section shape. station : EvaluatedStation Evaluated panel properties at the sampled span station. chordwise_samples : FloatArray One-dimensional normalized chordwise coordinates in ``[0, 1]``. upper : bool If ``True``, build the upper-surface curve. Returns ------- FloatArray Array of shape ``(n_samples, 3)`` containing section points. """ t_surface = airfoil.t_from_x(chordwise_samples, upper=upper) x_airfoil, z_airfoil = airfoil.xy(t_surface) axis_fraction = self._reference_axis_fraction( self._spec.wing.reference_axis ) local_x = station.chord * (x_airfoil - axis_fraction) local_z = station.chord * z_airfoil cos_theta = as_float_scalar(np.cos(station.twist_rad)) sin_theta = as_float_scalar(np.sin(station.twist_rad)) x_rotated = cos_theta * local_x + sin_theta * local_z z_rotated = -sin_theta * local_x + cos_theta * local_z curve = np.empty((chordwise_samples.size, 3), dtype=np.float64) curve[:, 0] = station.x_ref + x_rotated curve[:, 1] = station.y curve[:, 2] = station.z_ref + z_rotated return curve def _get_panel_airfoil(self, panel: PanelSpec) -> Airfoil: """ Resolve the airfoil definition associated with ``panel``. Parameters ---------- panel : PanelSpec Wing panel whose referenced airfoil should be resolved. Returns ------- Airfoil Runtime airfoil object for the panel. Raises ------ KeyError If the panel references an unknown named airfoil. NotImplementedError If the panel uses an unsupported airfoil reference or airfoil type. """ if not isinstance(panel.airfoil, SingleAirfoilRef): msg = ( "Only single-airfoil panel definitions are supported by " "WingCanonical." ) raise NotImplementedError(msg) try: airfoil_spec = self._spec.airfoils[panel.airfoil.name] except KeyError as exc: msg = f"Unknown airfoil reference: {panel.airfoil.name}" raise KeyError(msg) from exc if isinstance(airfoil_spec, DatAirfoilSpec): msg = "DAT airfoils are not yet supported by WingCanonical." raise NotImplementedError(msg) try: return AirfoilFactory.from_spec(airfoil_spec) except NotImplementedError as exc: msg = ( "Only analytic NACA airfoils supported by AirfoilFactory are " "currently supported by WingCanonical." ) raise NotImplementedError(msg) from exc def _validate_axes(self) -> None: """ Reject unsupported reference-axis and twist-axis combinations. Returns ------- None This method validates the stored wing specification in place. Raises ------ ValueError If the configured axes are unsupported by the canonical evaluator. """ reference_axis = self._spec.wing.reference_axis twist_axis = self._spec.wing.twist_axis if reference_axis == "elastic_axis" or twist_axis == "elastic_axis": msg = ( "elastic_axis is not yet supported because the schema does " "not define its chordwise location." ) raise ValueError(msg) if reference_axis != twist_axis: msg = ( "WingCanonical currently requires reference_axis and " "twist_axis to match." ) raise ValueError(msg) def _get_panel(self, panel_id: str) -> PanelSpec: """ Return the panel definition for ``panel_id``. Parameters ---------- panel_id : str Identifier of the panel to retrieve. Returns ------- PanelSpec Panel definition matching ``panel_id``. Raises ------ KeyError If ``panel_id`` does not match a known panel. """ try: return self._panel_map[panel_id] except KeyError as exc: msg = f"Unknown panel id: {panel_id}" raise KeyError(msg) from exc @staticmethod def _validate_eta(panel: PanelSpec, eta: FloatScalar) -> None: """ Ensure ``eta`` lies within the panel spanwise interval. Parameters ---------- panel : PanelSpec Panel whose spanwise interval should contain ``eta``. eta : FloatScalar Spanwise parameter to validate. Returns ------- None This method validates the inputs in place. Raises ------ ValueError If ``eta`` lies outside ``panel.eta_range``. """ eta0, eta1 = panel.eta_range if eta < eta0 or eta > eta1: msg = ( f"eta={eta} lies outside panel {panel.id!r} range " f"[{eta0}, {eta1}]." ) raise ValueError(msg) @staticmethod def _evaluate_piecewise_linear( distribution: PiecewiseLinearDistribution, eta: FloatScalar, ) -> FloatScalar: """ Interpolate a piecewise-linear scalar distribution at ``eta``. Parameters ---------- distribution : PiecewiseLinearDistribution Piecewise-linear distribution definition to evaluate. eta : float Spanwise parameter at which to evaluate the distribution. Returns ------- float Interpolated distribution value at ``eta``. Raises ------ ValueError If the distribution data are empty, non-monotonic, or do not bracket ``eta``. """ if not distribution.data: msg = ( "Piecewise-linear distributions must contain at least one " "point." ) raise ValueError(msg) eta_values = np.array( [point[0] for point in distribution.data], dtype=np.float64, ) data_values = np.array( [point[1] for point in distribution.data], dtype=np.float64, ) deltas = np.diff(eta_values) if np.any(deltas <= 0.0): msg = "Piecewise-linear distribution eta values must be increasing." raise ValueError(msg) eta_min = as_float_scalar(eta_values[0]) eta_max = as_float_scalar(eta_values[-1]) if eta < eta_min or eta > eta_max: msg = ( f"eta={eta} lies outside distribution range " f"[{eta_min}, {eta_max}]." ) raise ValueError(msg) return as_float_scalar(np.interp(eta, eta_values, data_values)) def _angle_to_radians(self, angle_value: FloatScalar) -> FloatScalar: """ Convert an input angle to radians using the schema units. Parameters ---------- angle_value : FloatScalar Angle value expressed in the schema-declared units. Returns ------- FloatScalar Angle converted to radians. """ if self._spec.units.angle == "rad": return angle_value return as_float_scalar(np.deg2rad(angle_value)) @staticmethod def _reference_axis_fraction(axis: ReferenceAxis) -> float: """ Return the chordwise fraction associated with a reference axis. Parameters ---------- axis : ReferenceAxis Reference-axis label from the wing schema. Returns ------- float Chordwise fraction associated with ``axis``. Raises ------ ValueError If ``axis`` is unsupported by the canonical evaluator. """ if axis == "leading_edge": return 0.0 if axis == "quarter_chord": return 0.25 msg = f"Unsupported reference axis: {axis}" raise ValueError(msg)
__all__ = [ "EvaluatedStation", "SectionCurves", "WingCanonical", ]