"""Runtime adapters that solve panel cases from structured config specs."""
from __future__ import annotations
import buffalo_wings.airfoil as bwa
import numpy as np
from buffalo_panel.assembly import Freestream2D
from buffalo_panel.config.internal.schema import (
AirfoilBody2DSpec,
PanelCaseSpec,
)
from buffalo_panel.formulations import (
HessSmithFormulation,
LumpedVortexFormulation,
)
from buffalo_panel.geometry import (
LinePanelGeometry2D,
ThickBodyLineGeometry2D,
ThinBodyLineGeometry2D,
)
from buffalo_panel.kernels.registry_builds import build_2d_registry
from buffalo_panel.post import (
BodyReference2D,
PanelSolution2D,
SolvedCaseArtifact,
build_hess_smith_artifact,
build_lumped_vortex_artifact,
)
from buffalo_panel.type_aliases import FloatArray
_SUPPORTED_SCHEMA_VERSION = 1
[docs]
def solve_panel_case(spec: PanelCaseSpec) -> tuple[FloatArray, PanelSolution2D]:
"""
Solve a supported structured panel case.
Parameters
----------
spec : PanelCaseSpec
Structured case description using Buffalo Panel solver settings and
Buffalo Wings airfoil schema objects.
Returns
-------
FloatArray
Raw linear-system solution vector.
PanelSolution2D
Recovered surface, integrated, and on-demand field result interface.
Raises
------
NotImplementedError
If the case requests a solver or geometry multiplicity that is part of
the schema but not yet wired into the runtime solve path.
ValueError
If the config contains unsupported unit declarations for this solve
path.
Notes
-----
This runtime adapter intentionally supports one embedded-airfoil body with
either the Hess-Smith thick-body formulation or the lumped-vortex
thin-body formulation.
The schema is broader than this implementation so user-facing case files
can grow without changing the top-level model shape.
"""
_require_supported_solver(spec)
body = _single_supported_body(spec)
geometry = _build_airfoil_geometry(spec, body)
body_reference = _build_body_reference(spec, body)
formulation = _build_formulation(
spec,
geometry,
body_reference,
)
return formulation.solve(_build_freestream(spec))
[docs]
def solve_panel_case_artifact(spec: PanelCaseSpec) -> SolvedCaseArtifact:
"""
Solve one supported structured panel case and persist its solved state.
Parameters
----------
spec : PanelCaseSpec
Structured case description using Buffalo Panel solver settings and
Buffalo Wings airfoil schema objects.
Returns
-------
SolvedCaseArtifact
Versioned solved-state artifact that can later be loaded into the
post-processing layer.
"""
_require_supported_solver(spec)
body = _single_supported_body(spec)
geometry = _build_airfoil_geometry(spec, body)
body_reference = _build_body_reference(spec, body)
formulation = _build_formulation(
spec,
geometry,
body_reference,
)
freestream = _build_freestream(spec)
solution_vector, _results = formulation.solve(freestream)
if isinstance(formulation, HessSmithFormulation):
source_families, vortex_families = formulation.build_solution_families()
return build_hess_smith_artifact(
spec=spec,
geometry=geometry,
freestream=freestream,
body_reference=body_reference,
backend=spec.solver.backend,
solution_vector=solution_vector,
source_families=source_families,
vortex_families=vortex_families,
)
vortex_families = (formulation.build_body_vortex_family(),)
return build_lumped_vortex_artifact(
spec=spec,
geometry=geometry,
freestream=freestream,
body_reference=body_reference,
backend=spec.solver.backend,
solution_vector=solution_vector,
vortex_families=vortex_families,
)
def _require_supported_solver(spec: PanelCaseSpec) -> None:
"""Validate the solver controls supported by the config adapter."""
if spec.schema_version != _SUPPORTED_SCHEMA_VERSION:
raise NotImplementedError(
"Only panel case schema_version 1 is supported."
)
if spec.solver.formulation not in {"hess_smith", "lumped_vortex"}:
raise NotImplementedError(
"Only the hess_smith and lumped_vortex formulations are supported."
)
if spec.units.length not in {"m", "ft"}:
raise ValueError("Length units must be 'm' or 'ft'.")
if spec.units.angle not in {"deg", "rad"}:
raise ValueError("Angle units must be 'deg' or 'rad'.")
if spec.solver.formulation == "hess_smith":
unsupported_bodies = [
body.id
for body in spec.geometry.bodies
if body.discretization != "thick_body"
]
if unsupported_bodies:
body_list = ", ".join(unsupported_bodies)
raise NotImplementedError(
"Only thick_body airfoil discretization is currently "
"supported by the hess_smith solver path. Unsupported "
f"bodies: {body_list}."
)
return
unsupported_bodies = [
body.id
for body in spec.geometry.bodies
if body.discretization != "thin_body"
]
if unsupported_bodies:
body_list = ", ".join(unsupported_bodies)
raise NotImplementedError(
"Only thin_body airfoil discretization is currently supported "
"by the lumped_vortex solver path. Unsupported bodies: "
f"{body_list}."
)
def _single_supported_body(spec: PanelCaseSpec) -> AirfoilBody2DSpec:
"""Return the single supported airfoil body from a case spec."""
bodies = spec.geometry.bodies
if len(bodies) != 1:
raise NotImplementedError("Exactly one airfoil body is supported.")
return bodies[0]
def _build_airfoil_geometry(
spec: PanelCaseSpec, body: AirfoilBody2DSpec
) -> LinePanelGeometry2D:
"""Sample a Buffalo Wings airfoil body and build line-panel geometry."""
airfoil = bwa.AirfoilFactory.from_spec(body.airfoil)
if body.discretization == "thick_body":
return _build_thick_airfoil_geometry(spec, body, airfoil)
return _build_thin_airfoil_geometry(spec, body, airfoil)
def _build_formulation(
spec: PanelCaseSpec,
geometry: LinePanelGeometry2D,
body_reference: BodyReference2D,
) -> HessSmithFormulation | LumpedVortexFormulation:
"""Build the configured runtime formulation object."""
registry = build_2d_registry()
if spec.solver.formulation == "hess_smith":
return HessSmithFormulation(
geometry=geometry,
registry=registry,
backend=spec.solver.backend,
body_reference=body_reference,
)
if not isinstance(geometry, ThinBodyLineGeometry2D):
raise TypeError(
"The lumped_vortex formulation requires thin-body geometry."
)
return LumpedVortexFormulation(
geometry=geometry,
registry=registry,
backend=spec.solver.backend,
body_reference=body_reference,
)
def _build_thick_airfoil_geometry(
spec: PanelCaseSpec,
body: AirfoilBody2DSpec,
airfoil: bwa.Airfoil,
) -> ThickBodyLineGeometry2D:
"""Build thick-body panel geometry from sampled airfoil boundary points."""
boundary = bwa.sample_airfoil_boundary(
airfoil,
num_points_per_surface=body.sampling.num_points_per_surface,
spacing=body.sampling.spacing,
order="lower_to_upper",
warning_policy="ignore",
)
x, y = _place_airfoil_coordinates(
boundary.coordinates[:, 0],
boundary.coordinates[:, 1],
body,
angle_unit=spec.units.angle,
)
return ThickBodyLineGeometry2D(x, y, geometry_name=body.id)
def _build_thin_airfoil_geometry(
spec: PanelCaseSpec,
body: AirfoilBody2DSpec,
airfoil: bwa.Airfoil,
) -> ThinBodyLineGeometry2D:
"""Build thin-body panel geometry from the airfoil camber curve."""
xi = _sampling_parameter_values(
num_points=body.sampling.num_points_per_surface,
spacing=body.sampling.spacing,
)
camber = airfoil.camber_curve(
num_points=body.sampling.num_points_per_surface,
spacing=body.sampling.spacing,
)
x_camber, y_camber = camber.curve.xy_from_u(xi)
x, y = _place_airfoil_coordinates(
x_camber,
y_camber,
body,
angle_unit=spec.units.angle,
)
return ThinBodyLineGeometry2D(x, y, geometry_name=body.id)
def _sampling_parameter_values(
*,
num_points: int,
spacing: str,
) -> FloatArray:
"""Return one-dimensional sample parameters for airfoil body curves."""
if spacing == "uniform":
return np.linspace(0.0, 1.0, num_points, dtype=np.float64)
theta = np.linspace(0.0, np.pi, num_points, dtype=np.float64)
return 0.5 * (1.0 - np.cos(theta))
def _place_airfoil_coordinates(
x: FloatArray,
y: FloatArray,
body: AirfoilBody2DSpec,
*,
angle_unit: str,
) -> tuple[FloatArray, FloatArray]:
"""Apply the configured airfoil scale, rotation, and translation."""
scale = body.placement.scale
if scale <= 0.0:
raise ValueError("Airfoil placement scale must be positive.")
theta = body.placement.rotation
theta_rad = float(np.deg2rad(theta)) if angle_unit == "deg" else theta
cos_theta = float(np.cos(theta_rad))
sin_theta = float(np.sin(theta_rad))
rotation_point_x, rotation_point_y = body.placement.rotation_point
translation_x, translation_y = body.placement.translation
x_scaled = scale * x
y_scaled = scale * y
pivot_x = scale * rotation_point_x
pivot_y = scale * rotation_point_y
x_centered = x_scaled - pivot_x
y_centered = y_scaled - pivot_y
x_rotated = pivot_x + cos_theta * x_centered - sin_theta * y_centered
y_rotated = pivot_y + sin_theta * x_centered + cos_theta * y_centered
x_placed = translation_x + x_rotated
y_placed = translation_y + y_rotated
return x_placed, y_placed
def _build_body_reference(
spec: PanelCaseSpec,
body: AirfoilBody2DSpec,
) -> BodyReference2D:
"""Convert case reference metadata to post-processing reference values."""
moment_reference_x, moment_reference_y = spec.reference.moment_point
reference_length = (
spec.reference.length
if spec.reference.length is not None
else body.placement.scale
)
return BodyReference2D(
reference_length=reference_length,
moment_reference_x=moment_reference_x,
moment_reference_y=moment_reference_y,
)
def _build_freestream(spec: PanelCaseSpec) -> Freestream2D:
"""Convert freestream config values to the runtime freestream model."""
alpha_deg = (
spec.freestream.alpha
if spec.units.angle == "deg"
else float(np.rad2deg(spec.freestream.alpha))
)
return Freestream2D(speed=spec.freestream.speed, alpha_deg=alpha_deg)