"""Geometry information needed for 2D line-element discretizations."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol, runtime_checkable
import numpy as np
from buffalo_core import as_float_array
from buffalo_panel.type_aliases import FloatArray, IntArray
_SMALL_NUMBER = 1e-8
_MIN_NUM_THICK_NODES = 3
_THICK_COLLOCATION_LOC = 0.5
_THICK_ELEMENT_LOC = 0.5
_MIN_NUM_THIN_NODES = 2
_THIN_COLLOCATION_LOC = 0.75
_THIN_ELEMENT_LOC = 0.25
[docs]
@runtime_checkable
class LineKernelGeometry2D(Protocol):
"""Structural protocol for geometries used by 2D line kernels."""
@property
def n_panels(self) -> int:
"""Return the number of panels."""
...
@property
def x_col(self) -> FloatArray:
"""Return the per-panel collocation x-coordinates."""
...
@property
def y_col(self) -> FloatArray:
"""Return the per-panel collocation y-coordinates."""
...
@property
def length(self) -> FloatArray:
"""Return the per-panel lengths."""
...
@property
def s_x(self) -> FloatArray:
"""Return the per-panel tangent x-components."""
...
@property
def s_y(self) -> FloatArray:
"""Return the per-panel tangent y-components."""
...
@property
def n_x(self) -> FloatArray:
"""Return the per-panel normal x-components."""
...
@property
def n_y(self) -> FloatArray:
"""Return the per-panel normal y-components."""
...
[docs]
@runtime_checkable
class PointElementLineGeometry2D(LineKernelGeometry2D, Protocol):
"""
Structural protocol for line-panel geometries with point elements.
Point-element kernels require the shared line-kernel geometry data plus
explicit per-panel point-element coordinates.
Concrete geometries may provide those coordinates through a dedicated
geometry class or through artifact-rebuilt runtime objects.
"""
@property
def x_element(self) -> FloatArray:
"""Return the per-panel point-element x-coordinates."""
...
@property
def y_element(self) -> FloatArray:
"""Return the per-panel point-element y-coordinates."""
...
@property
def source_points(self) -> FloatArray:
"""Return the per-panel point-element coordinates."""
...
[docs]
@dataclass(slots=True)
class LinePanelGeometry2D:
"""
Shared line-panel geometry information for two-dimensional discretizations.
This base class stores the panel-node, panel-segment, collocation-point,
and local-frame data shared by both thick-body and thin-body
discretizations.
Subclasses provide the semantic interpretation of those points.
"""
geometry_id: int
"""Unique-orchestrator identifier for this geometry."""
geometry_name: str
"""Display name for this geometry."""
x: FloatArray
"""Geometry x-coordinates."""
y: FloatArray
"""Geometry y-coordinates."""
connectivity: IntArray
"""Connectivity mapping each panel to its start and end points."""
x_start: FloatArray
"""Starting x-coordinates for every panel."""
y_start: FloatArray
"""Starting y-coordinates for every panel."""
x_end: FloatArray
"""Ending x-coordinates for every panel."""
y_end: FloatArray
"""Ending y-coordinates for every panel."""
x_col: FloatArray
"""Collocation x-coordinates for every panel."""
y_col: FloatArray
"""Collocation y-coordinates for every panel."""
length: FloatArray
"""Length of every panel."""
s_x: FloatArray
"""Unit tangent vector x-component for every panel."""
s_y: FloatArray
"""Unit tangent vector y-component for every panel."""
n_x: FloatArray
"""Unit normal vector x-component for every panel."""
n_y: FloatArray
"""Unit tangent vector y-component for every panel."""
body_panel_indices: IntArray
"""Body panel index for every panel."""
trailing_edge_panel_index: int | None
"""Index of the trailing edge panel, or None if there isn't one."""
@property
def n_panels(self) -> int:
"""Return the number of panels."""
return int(self.length.size)
@property
def n_body_panels(self) -> int:
"""Return the number of body panels."""
return int(self.body_panel_indices.size)
@property
def nodes(self) -> FloatArray:
"""Return the x,y coordinates for the panel nodes."""
return np.column_stack((self.x, self.y))
@property
def collocation_points(self) -> FloatArray:
"""Return the x,y coordinates for the collocation points."""
return np.column_stack((self.x_col, self.y_col))
@property
def tangents(self) -> FloatArray:
"""Return the unit tangent vectors for each panel."""
return np.column_stack((self.s_x, self.s_y))
@property
def normals(self) -> FloatArray:
"""Return the unit normal vectors for each panel."""
return np.column_stack((self.n_x, self.n_y))
@property
def is_closed(self) -> bool:
"""Return whether the geometry is closed."""
return bool(
np.abs(self.x[0] - self.x[-1]) < _SMALL_NUMBER
and np.abs(self.y[0] - self.y[-1]) < _SMALL_NUMBER
)
class BaseBodyLineGeometry2D(LinePanelGeometry2D):
"""Base class for 2d bodies composed of line geometry."""
def __init__(
self,
x: FloatArray,
y: FloatArray,
*,
geometry_id: int,
geometry_name: str | None,
wrap_open_curve: bool,
collocation_fraction: float,
) -> None:
x_arr = as_float_array(x)
y_arr = as_float_array(y)
if x_arr.ndim != 1 or y_arr.ndim != 1 or x_arr.size != y_arr.size:
raise ValueError(
"x and y must be one-dimensional arrays of equal length."
)
if geometry_id < 0:
raise ValueError("geometry_id must be non-negative.")
if not (0.0 < collocation_fraction < 1.0):
raise ValueError(
"collocation_fraction must be strictly between 0 and 1."
)
resolved_geometry_name = (
geometry_name
if geometry_name is not None
else f"Geometry {geometry_id}"
)
input_is_closed = (
np.abs(x_arr[0] - x_arr[-1]) < _SMALL_NUMBER
and np.abs(y_arr[0] - y_arr[-1]) < _SMALL_NUMBER
)
n_nodes = x_arr.size
body_panel_indices = np.arange(n_nodes - 1, dtype=np.int32)
if input_is_closed:
connectivity = np.column_stack((
np.arange(n_nodes - 1, dtype=np.int32),
np.arange(1, n_nodes, dtype=np.int32),
))
trailing_edge_panel_index = None
body_panel_indices = np.arange(
connectivity.shape[0], dtype=np.int32
)
elif wrap_open_curve:
connectivity = np.column_stack((
np.arange(n_nodes, dtype=np.int32),
np.arange(1, n_nodes + 1, dtype=np.int32),
))
connectivity[-1, 1] = 0
trailing_edge_panel_index = n_nodes - 1
body_panel_indices = np.arange(n_nodes - 1, dtype=np.int32)
else:
connectivity = np.column_stack((
np.arange(n_nodes - 1, dtype=np.int32),
np.arange(1, n_nodes, dtype=np.int32),
))
trailing_edge_panel_index = None
body_panel_indices = np.arange(
connectivity.shape[0], dtype=np.int32
)
x_start = x_arr[connectivity[:, 0]]
y_start = y_arr[connectivity[:, 0]]
x_end = x_arr[connectivity[:, 1]]
y_end = y_arr[connectivity[:, 1]]
dx = x_end - x_start
dy = y_end - y_start
length = np.sqrt(dx * dx + dy * dy)
if np.any(length <= 0.0):
raise ValueError("Degenerate panel with zero length detected.")
tx = dx / length
ty = dy / length
nx = -ty
ny = tx
x_col = x_start + collocation_fraction * dx
y_col = y_start + collocation_fraction * dy
super().__init__(
geometry_id=geometry_id,
geometry_name=resolved_geometry_name,
x=x_arr,
y=y_arr,
connectivity=connectivity,
x_start=x_start,
y_start=y_start,
x_end=x_end,
y_end=y_end,
x_col=x_col,
y_col=y_col,
length=length,
s_x=tx,
s_y=ty,
n_x=nx,
n_y=ny,
body_panel_indices=body_panel_indices,
trailing_edge_panel_index=trailing_edge_panel_index,
)
self._collocation_fraction = collocation_fraction
@property
def collocation_fraction(self) -> float:
"""Uniform panel-collocation fraction used by this geometry."""
return self._collocation_fraction
[docs]
class ThickBodyLineGeometry2D(BaseBodyLineGeometry2D):
"""
Line-panel geometry representing a thick-body boundary discretization.
Panels lie on the physical body surface and collocation points represent
boundary-condition enforcement locations on that surface.
"""
def __init__(
self,
x: FloatArray,
y: FloatArray,
*,
geometry_id: int = 0,
geometry_name: str | None = None,
) -> None:
"""
Construct one thick-body line-panel geometry from a set of points.
Parameters
----------
x : FloatArray
X-coordinates of panel nodes.
y : FloatArray
Y-coordinates of panel nodes.
geometry_id : int, default=0
Integer identifier for this geometry.
geometry_name : str | None, default=None
Optional display name for this geometry.
Defaults to ``"Geometry {geometry_id}"``.
collocation_fraction : float, default=0.5
Fractional location of each panel collocation point measured from
the panel start node toward the end node.
Returns
-------
ThickBodyLineGeometry2D
Thick-body panel geometry.
"""
if as_float_array(x).size < _MIN_NUM_THICK_NODES:
raise ValueError("At least three boundary points are required.")
super().__init__(
x,
y,
geometry_id=geometry_id,
geometry_name=geometry_name,
collocation_fraction=_THICK_COLLOCATION_LOC,
wrap_open_curve=True,
)
[docs]
class ThinBodyLineGeometry2D(BaseBodyLineGeometry2D):
"""
Line-panel geometry representing a thin-body camber-line discretization.
Panels lie on the body reference curve.
The intended default convention is quarter-panel element placement with
three-quarter-panel collocation, though the shared stored fields remain the
same as the thick-body geometry.
"""
def __init__(
self,
x: FloatArray,
y: FloatArray,
*,
geometry_id: int = 0,
geometry_name: str | None = None,
point_element: bool = True,
) -> None:
"""
Construct one thin-body line-panel geometry from a set of points.
Thin-body geometry uses the fixed three-quarter-panel collocation
convention intended to pair with quarter-panel element placement.
Parameters
----------
x : FloatArray
X-coordinates of panel nodes.
y : FloatArray
Y-coordinates of panel nodes.
geometry_id : int, default=0
Integer identifier for this geometry.
geometry_name : str | None, default=None
Optional display name for this geometry.
Defaults to ``"Geometry {geometry_id}"``.
point_element : bool, default=True
Whether element on the panel will be a point element.
If it is a point element, then the element location is set to the
quarter panel location and collocation point to the three-quarter
panel location.
Otherwise, both set to the mid-panel location.
Returns
-------
ThinBodyLineGeometry2D
Thin-body panel geometry.
"""
if as_float_array(x).size < _MIN_NUM_THIN_NODES:
raise ValueError("At least three boundary points are required.")
if point_element:
collocation_fraction = _THIN_COLLOCATION_LOC
element_fraction = _THIN_ELEMENT_LOC
else:
collocation_fraction = _THICK_COLLOCATION_LOC
element_fraction = _THICK_ELEMENT_LOC
super().__init__(
x,
y,
geometry_id=geometry_id,
geometry_name=geometry_name,
collocation_fraction=collocation_fraction,
wrap_open_curve=False,
)
dx = self.x_end - self.x_start
dy = self.y_end - self.y_start
self._element_fraction = element_fraction
self._x_element = self.x_start + element_fraction * dx
self._y_element = self.y_start + element_fraction * dy
@property
def x_element(self) -> FloatArray:
"""Panel element x-coordinates for every panel."""
return self._x_element
@property
def y_element(self) -> FloatArray:
"""Panel element y-coordinates for every panel."""
return self._y_element
@property
def source_points(self) -> FloatArray:
"""Element source x,y coordinates."""
return np.column_stack((self.x_element, self.y_element))
@property
def element_fraction(self) -> float:
"""Uniform point-element fraction used by this geometry."""
return self._element_fraction