"""Dataclasses for the structured Buffalo Panel case schema."""
from __future__ import annotations
from dataclasses import dataclass, field, fields, is_dataclass
from types import NoneType, UnionType
from typing import (
Any,
Literal,
Union,
cast,
get_args,
get_origin,
get_type_hints,
)
from buffalo_core.schema import (
SchemaChoice,
SchemaCondition,
SchemaField,
)
from buffalo_core.schema import (
schema_field_metadata as _core_schema_field_metadata,
)
from buffalo_wings.airfoil import AirfoilDefinitionSpec, Point2DSpec
type LengthUnit = Literal["m", "ft"]
type TimeUnit = Literal["s", "min"]
type AngleUnit = Literal["deg", "rad"]
type SolverFormulation = Literal["hess_smith", "lumped_vortex"]
type AirfoilSpacing = Literal["uniform", "cosine"]
type BodyDiscretization = Literal["thick_body", "thin_body"]
type SurfaceQuantity = Literal["velocity", "cp"]
type IntegratedQuantity = Literal[
"cl_pressure",
"cl_circulation",
"cd_pressure",
"cm_pressure",
]
type FieldQuantity = Literal["velocity", "potential", "stream_function"]
type SchemaFieldMetadata = dict[str, object]
type SchemaConditionMetadata = dict[str, object]
_PAIR_ARG_COUNT = 2
[docs]
@dataclass(frozen=True, slots=True)
class SchemaFieldTree:
"""Tree node describing one schema field."""
name: str
"""Field name on the parent dataclass."""
type_name: str
"""Display-friendly type name for the field."""
optional: bool
"""Whether the field accepts ``None``."""
docstring: str | None
"""Short field documentation text, when available."""
metadata: SchemaFieldMetadata
"""Structured machine-readable field metadata."""
object_schema: SchemaClassTree | None = None
"""Nested dataclass schema when the field value is a dataclass object."""
item_type_name: str | None = None
"""Display-friendly item type name for list, tuple, or mapping values."""
item_schema: SchemaClassTree | None = None
"""Nested dataclass schema for list, tuple, or mapping items when known."""
[docs]
@dataclass(frozen=True, slots=True)
class SchemaClassTree:
"""Tree node describing one schema dataclass."""
class_name: str
"""Dataclass name."""
docstring: str | None
"""Top-level class documentation string."""
fields: tuple[SchemaFieldTree, ...]
"""Ordered field descriptions."""
schema_field_metadata = _core_schema_field_metadata
def _unwrap_optional(target_type: Any) -> tuple[object, bool]:
"""Return the non-optional type plus whether ``None`` is allowed."""
origin = get_origin(target_type)
if origin is UnionType or origin is Union:
args = tuple(
arg for arg in get_args(target_type) if arg is not NoneType
)
if len(args) + 1 == len(get_args(target_type)):
if len(args) == 1:
return args[0], True
return target_type, True
return target_type, False
def _type_name(target_type: Any) -> str:
"""Return a display-friendly type name."""
origin = get_origin(target_type)
if origin is None:
if isinstance(target_type, type):
return target_type.__name__
return str(target_type)
args = get_args(target_type)
origin_name = getattr(origin, "__name__", str(origin))
if args:
return f"{origin_name}[{', '.join(_type_name(arg) for arg in args)}]"
return origin_name
def _field_docstring(metadata: SchemaFieldMetadata) -> str | None:
"""Return the best available short doc text for a field."""
short_help = metadata.get("short_help")
notes = metadata.get("notes")
if isinstance(short_help, str) and isinstance(notes, str):
return f"{short_help} {notes}"
if isinstance(short_help, str):
return short_help
if isinstance(notes, str):
return notes
return None
def _item_schema_for_type(
target_type: Any,
) -> tuple[str | None, SchemaClassTree | None]:
"""Return item type display name and nested schema when available."""
origin = get_origin(target_type)
args = get_args(target_type)
if origin is list and args:
item_type = args[0]
return _type_name(item_type), (
schema_tree(cast(type[object], item_type))
if isinstance(item_type, type) and is_dataclass(item_type)
else None
)
if origin is tuple and args:
item_type = (
args[0]
if len(args) == _PAIR_ARG_COUNT and args[1] is Ellipsis
else None
)
if item_type is not None:
return _type_name(item_type), (
schema_tree(cast(type[object], item_type))
if isinstance(item_type, type) and is_dataclass(item_type)
else None
)
return "mixed", None
if origin is dict and len(args) == _PAIR_ARG_COUNT:
item_type = args[1]
return _type_name(item_type), (
schema_tree(cast(type[object], item_type))
if isinstance(item_type, type) and is_dataclass(item_type)
else None
)
return None, None
[docs]
def schema_tree(cls: type[object]) -> SchemaClassTree:
"""Return a nested tree description for one schema dataclass.
The returned object gives GUI and editor code one machine-readable
structure containing:
- ordered field descriptions
- nested dataclass children
- optionality
- display-friendly type names
- field-level documentation text
- the structured field metadata used for validation and presentation
"""
if not is_dataclass(cls):
raise TypeError("cls must be a dataclass type.")
type_hints = get_type_hints(cls)
field_trees: list[SchemaFieldTree] = []
for field_info in fields(cls):
annotated_type = type_hints[field_info.name]
value_type, optional = _unwrap_optional(annotated_type)
resolved_value_type = value_type
metadata = dict(field_info.metadata)
object_schema = (
schema_tree(cast(type[object], resolved_value_type))
if isinstance(resolved_value_type, type)
and is_dataclass(resolved_value_type)
else None
)
item_type_name, item_schema = _item_schema_for_type(resolved_value_type)
field_trees.append(
SchemaFieldTree(
name=field_info.name,
type_name=_type_name(resolved_value_type),
optional=optional,
docstring=_field_docstring(metadata),
metadata=metadata,
object_schema=object_schema,
item_type_name=item_type_name,
item_schema=item_schema,
)
)
return SchemaClassTree(
class_name=cls.__name__,
docstring=cls.__doc__.strip() if cls.__doc__ is not None else None,
fields=tuple(field_trees),
)
[docs]
@dataclass(slots=True)
class UnitsSpec:
"""Unit declarations used by serialized panel case files."""
length: LengthUnit = field(
default="m",
metadata=SchemaField(
value_kind="enum",
required=False,
label="Length Unit",
order=10,
short_help="Unit used by geometric and reference-length inputs.",
default="m",
choices=(
SchemaChoice(value="m", label="m"),
SchemaChoice(value="ft", label="ft"),
),
).to_metadata(),
)
"""Length unit used by geometric input and reference metadata."""
time: TimeUnit = field(
default="s",
metadata=SchemaField(
value_kind="enum",
required=False,
label="Time Unit",
order=20,
short_help="Unit used with the legnth unit for velocity inputs.",
default="s",
choices=(
SchemaChoice(value="s", label="s"),
SchemaChoice(value="min", label="min"),
),
).to_metadata(),
)
"""Time unit used with the length unit for velocity metadata."""
angle: AngleUnit = field(
default="deg",
metadata=SchemaField(
value_kind="enum",
required=False,
label="Angle Unit",
order=30,
short_help="Unit used by freestream and placement angles.",
default="deg",
choices=(
SchemaChoice(value="deg", label="deg"),
SchemaChoice(value="rad", label="rad"),
),
).to_metadata(),
)
"""Angle unit used by freestream and placement metadata."""
[docs]
@dataclass(slots=True)
class SolverSpec:
"""Solver selection and backend controls."""
formulation: SolverFormulation = field(
default="hess_smith",
metadata=SchemaField(
value_kind="enum",
required=False,
label="Formulation",
order=10,
short_help="Potential-flow formulation used by the solve path.",
default="hess_smith",
choices=(
SchemaChoice(value="hess_smith", label="Hess-Smith"),
SchemaChoice(
value="lumped_vortex",
label="Lumped Vortex",
),
),
).to_metadata(),
)
"""Potential-flow formulation requested by the case."""
backend: str = field(
default="python",
metadata=SchemaField(
value_kind="enum",
required=False,
label="Backend",
order=20,
short_help="Registered kernel backend label.",
default="python",
format_hint="registered backend label",
choices=(SchemaChoice(value="python", label="Python"),),
advanced=True,
shown_when=(
SchemaCondition(
path="$.solver.formulation",
operator="in",
value=("hess_smith", "lumped_vortex"),
message=(
"Backend selection is currently defined per solver "
"formulation."
),
),
),
).to_metadata(),
)
"""Kernel backend selected from the kernel registry."""
[docs]
@dataclass(slots=True)
class FreestreamSpec:
"""Freestream state for the case."""
speed: float = field(
metadata=SchemaField(
value_kind="float",
required=True,
label="Speed",
order=10,
short_help="Freestream speed magnitude.",
minimum=0.0,
).to_metadata()
)
"""Freestream speed magnitude."""
alpha: float = field(
metadata=SchemaField(
value_kind="float",
required=True,
label="Angle of Attack",
order=20,
short_help=(
"Freestream angle of attack in the configured angle unit."
),
notes="Angle unit follows UnitsSpec.angle.",
).to_metadata()
)
"""Freestream angle of attack in ``units.angle``."""
mach: float | None = field(
default=None,
metadata=SchemaField(
value_kind="float",
required=False,
label="Mach Number",
order=30,
short_help="Freestream Mach number for future compressibility use.",
minimum=0.0,
advanced=True,
).to_metadata(),
)
"""Freestream Mach number."""
reynolds: float | None = field(
default=None,
metadata=SchemaField(
value_kind="float",
required=False,
label="Reynolds Number",
order=40,
short_help="Freestream Reynolds number for future viscous models.",
exclusive_minimum=0.0,
advanced=True,
).to_metadata(),
)
"""Freestream Reynolds number."""
[docs]
@dataclass(slots=True)
class AirfoilSamplingSpec:
"""Buffalo Wings airfoil sampling controls."""
num_points_per_surface: int = field(
metadata=SchemaField(
value_kind="int",
required=True,
label="Points Per Surface",
order=10,
short_help=(
"Number of sample points requested on each airfoil surface."
),
minimum=2,
).to_metadata()
)
"""Number of samples requested on each airfoil surface."""
spacing: AirfoilSpacing = field(
default="cosine",
metadata=SchemaField(
value_kind="enum",
required=False,
label="Spacing",
order=20,
short_help="Spacing rule used when sampling the airfoil surfaces.",
default="cosine",
choices=("uniform", "cosine"),
).to_metadata(),
)
"""Surface sampling spacing rule."""
[docs]
@dataclass(slots=True)
class AirfoilPlacement2DSpec:
"""Rigid placement and scale applied to a Buffalo Wings airfoil boundary."""
scale: float = field(
default=1.0,
metadata=SchemaField(
value_kind="float",
required=False,
label="Scale",
order=10,
short_help=(
"Uniform scale applied to the unit-chord airfoil coordinates."
),
default=1.0,
exclusive_minimum=0.0,
).to_metadata(),
)
"""Uniform scale applied to the unit-chord Buffalo Wings coordinates."""
rotation: float = field(
default=0.0,
metadata=SchemaField(
value_kind="float",
required=False,
label="Rotation",
order=20,
short_help="Rigid-body rotation applied after scaling.",
default=0.0,
notes="Angle unit follows UnitsSpec.angle.",
advanced=True,
).to_metadata(),
)
"""Rotation angle applied after scaling."""
rotation_point: Point2DSpec = field(
default=(0.0, 0.0),
metadata=SchemaField(
value_kind="tuple",
required=False,
label="Rotation Point",
order=30,
short_help="Body-frame point about which rigid rotation occurs.",
default=(0.0, 0.0),
tuple_length=2,
item_kind="float",
advanced=True,
).to_metadata(),
)
"""Body-frame point about which rotation is applied."""
translation: Point2DSpec = field(
default=(0.0, 0.0),
metadata=SchemaField(
value_kind="tuple",
required=False,
label="Translation",
order=40,
short_help="Global x and y translation applied after rotation.",
default=(0.0, 0.0),
tuple_length=2,
item_kind="float",
).to_metadata(),
)
"""Global translation applied after scaling and rotation."""
[docs]
@dataclass(slots=True)
class ReferenceSpec:
"""Reference quantities used to nondimensionalize integrated results."""
speed: float | None = field(
default=None,
metadata=SchemaField(
value_kind="float",
required=False,
label="Reference Speed",
order=10,
short_help="Reference speed used for integrated coefficients.",
exclusive_minimum=0.0,
notes="If omitted, the runtime currently uses freestream.speed.",
advanced=True,
shown_when=(
SchemaCondition(
path="$.post.integrated.quantities",
operator="non_empty",
message=(
"Reference speed matters when integrated "
"coefficients are requested."
),
),
),
).to_metadata(),
)
"""Reference speed for integrated coefficient normalization."""
length: float | None = field(
default=None,
metadata=SchemaField(
value_kind="float",
required=False,
label="Reference Length",
order=20,
short_help="Reference length used for integrated coefficients.",
exclusive_minimum=0.0,
notes=(
"If omitted, the runtime currently uses body placement.scale."
),
advanced=True,
shown_when=(
SchemaCondition(
path="$.post.integrated.quantities",
operator="non_empty",
message=(
"Reference length matters when integrated "
"coefficients are requested."
),
),
),
).to_metadata(),
)
"""Reference length for force and moment coefficients."""
moment_point: Point2DSpec = field(
default=(0.25, 0.0),
metadata=SchemaField(
value_kind="tuple",
required=False,
label="Moment Point",
order=30,
short_help=(
"Global x and y point used for pitching-moment reference."
),
default=(0.25, 0.0),
tuple_length=2,
item_kind="float",
shown_when=(
SchemaCondition(
path="$.post.integrated.quantities",
operator="non_empty",
message=(
"Moment reference is only needed when integrated "
"coefficients are requested."
),
),
),
).to_metadata(),
)
"""Moment reference point in global coordinates."""
[docs]
@dataclass(slots=True)
class AirfoilBody2DSpec:
"""Two-dimensional body generated from an embedded Buffalo Wings airfoil."""
id: str = field(
metadata=SchemaField(
value_kind="string",
required=True,
label="Body ID",
order=10,
short_help="Unique identifier for this body within the case.",
format_hint="unique body identifier",
).to_metadata()
)
"""Unique body identifier within the panel case."""
airfoil: AirfoilDefinitionSpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Airfoil",
order=20,
short_help=(
"Embedded Buffalo Wings airfoil definition for this body."
),
).to_metadata()
)
"""Embedded Buffalo Wings airfoil definition for this body."""
sampling: AirfoilSamplingSpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Sampling",
order=30,
short_help="Buffalo Wings airfoil sampling settings.",
).to_metadata()
)
"""Buffalo Wings airfoil sampling controls."""
discretization: BodyDiscretization = field(
default="thick_body",
metadata=SchemaField(
value_kind="enum",
required=False,
label="Discretization",
order=35,
short_help=(
"Choose whether this body is panelized as a thick boundary "
"or a thin camber line."
),
default="thick_body",
choices=(
SchemaChoice(
value="thick_body",
label="Thick Body",
),
SchemaChoice(
value="thin_body",
label="Thin Body",
),
),
).to_metadata(),
)
"""Discretization model used to convert the sampled airfoil into panels."""
placement: AirfoilPlacement2DSpec = field(
default_factory=AirfoilPlacement2DSpec,
metadata=SchemaField(
value_kind="object",
required=False,
label="Placement",
order=40,
short_help=(
"Scale, rotation, and translation applied to the sampled "
"airfoil."
),
).to_metadata(),
)
"""Placement applied to sampled airfoil coordinates."""
[docs]
@dataclass(slots=True)
class GeometrySpec:
"""Geometry block for all bodies in one panel case."""
bodies: list[AirfoilBody2DSpec] = field(
default_factory=list,
metadata=SchemaField(
value_kind="list",
required=False,
label="Bodies",
order=10,
short_help="Ordered list of bodies included in the case geometry.",
min_items=1,
item_kind="AirfoilBody2DSpec",
collection_add_label="Add Body",
collection_item_label_field="id",
collection_item_creation_hint=(
"Create a new airfoil body with an embedded airfoil definition."
),
).to_metadata(),
)
"""Ordered body definitions included in the case."""
[docs]
@dataclass(slots=True)
class SurfacePostSpec:
"""Surface post-processing requests."""
quantities: list[SurfaceQuantity] = field(
default_factory=list,
metadata=SchemaField(
value_kind="list",
required=False,
label="Surface Quantities",
order=10,
short_help="Surface quantities requested from post-processing.",
min_items=0,
item_kind="enum",
choices=(
SchemaChoice(
value="velocity",
label="Surface Velocity",
),
SchemaChoice(value="cp", label="Cp"),
SchemaChoice(
value="panel_lift_coefficient",
label="Panel Lift Coefficient",
),
),
).to_metadata(),
)
"""Requested surface quantities."""
[docs]
@dataclass(slots=True)
class IntegratedPostSpec:
"""Integrated post-processing requests."""
quantities: list[IntegratedQuantity] = field(
default_factory=list,
metadata=SchemaField(
value_kind="list",
required=False,
label="Integrated Quantities",
order=10,
short_help=(
"Integrated aerodynamic coefficients requested from "
"post-processing."
),
min_items=0,
item_kind="enum",
choices=(
SchemaChoice(value="cl_pressure", label="CL Pressure"),
SchemaChoice(
value="cl_circulation",
label="CL Circulation",
),
SchemaChoice(value="cd_pressure", label="CD Pressure"),
SchemaChoice(value="cm_pressure", label="CM Pressure"),
),
).to_metadata(),
)
"""Requested integrated quantities."""
[docs]
@dataclass(slots=True)
class FieldGridSpec:
"""Structured Cartesian field grid request."""
x: tuple[float, float, int] = field(
metadata=SchemaField(
value_kind="tuple",
required=True,
label="X Grid",
order=10,
short_help=(
"Cartesian x-grid definition as minimum, maximum, and count."
),
tuple_length=3,
format_hint="(minimum, maximum, count)",
notes="count should be a positive integer.",
shown_when=(
SchemaCondition(
path="$.post.field.quantities",
operator="non_empty",
message=(
"Field grid settings matter when field outputs are "
"requested."
),
),
),
).to_metadata()
)
"""X-grid definition as ``(minimum, maximum, count)``."""
y: tuple[float, float, int] = field(
metadata=SchemaField(
value_kind="tuple",
required=True,
label="Y Grid",
order=20,
short_help=(
"Cartesian y-grid definition as minimum, maximum, and count."
),
tuple_length=3,
format_hint="(minimum, maximum, count)",
notes="count should be a positive integer.",
shown_when=(
SchemaCondition(
path="$.post.field.quantities",
operator="non_empty",
message=(
"Field grid settings matter when field outputs are "
"requested."
),
),
),
).to_metadata()
)
"""Y-grid definition as ``(minimum, maximum, count)``."""
mask_inside_bodies: bool = field(
default=True,
metadata=SchemaField(
value_kind="bool",
required=False,
label="Mask Inside Bodies",
order=30,
short_help=(
"Whether field points inside closed bodies should be masked."
),
default=True,
advanced=True,
shown_when=(
SchemaCondition(
path="$.post.field.quantities",
operator="non_empty",
message=(
"Body masking only affects requested field-grid "
"outputs."
),
),
),
).to_metadata(),
)
"""Whether field points inside closed bodies should be masked."""
[docs]
@dataclass(slots=True)
class FieldPostSpec:
"""Field post-processing requests."""
grid: FieldGridSpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Grid",
order=10,
short_help=(
"Structured Cartesian grid definition for field evaluation."
),
shown_when=(
SchemaCondition(
path="$.post.field.quantities",
operator="non_empty",
message=(
"Grid definition is only relevant when field "
"outputs are requested."
),
),
),
).to_metadata()
)
"""Cartesian grid on which field quantities should be evaluated."""
quantities: list[FieldQuantity] = field(
default_factory=list,
metadata=SchemaField(
value_kind="list",
required=False,
label="Field Quantities",
order=20,
short_help="Field quantities requested on the configured grid.",
min_items=0,
item_kind="enum",
choices=(
"velocity",
"potential",
SchemaChoice(
value="stream_function",
label="Stream Function",
),
),
).to_metadata(),
)
"""Requested field quantities."""
[docs]
@dataclass(slots=True)
class PostProcessingSpec:
"""Optional post-processing requests for a case."""
surface: SurfacePostSpec | None = field(
default=None,
metadata=SchemaField(
value_kind="object",
required=False,
label="Surface Post",
order=10,
short_help="Requested surface post-processing outputs.",
).to_metadata(),
)
"""Surface quantity requests."""
integrated: IntegratedPostSpec | None = field(
default=None,
metadata=SchemaField(
value_kind="object",
required=False,
label="Integrated Post",
order=20,
short_help="Requested integrated post-processing outputs.",
).to_metadata(),
)
"""Integrated quantity requests."""
field: FieldPostSpec | None = field(
default=None,
metadata=SchemaField(
value_kind="object",
required=False,
label="Field Post",
order=30,
short_help="Requested field-grid post-processing outputs.",
advanced=True,
).to_metadata(),
)
"""Field quantity requests."""
[docs]
@dataclass(slots=True)
class PanelCaseSpec:
"""Top-level Buffalo Panel case schema."""
schema_version: int = field(
metadata=SchemaField(
value_kind="int",
required=True,
label="Schema Version",
order=10,
short_help="Version of the serialized panel-case schema.",
choices=(1,),
minimum=1,
notes="Only schema version 1 is currently supported.",
).to_metadata()
)
"""Schema version number for the serialized panel case."""
units: UnitsSpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Units",
order=20,
short_help="Top-level unit declarations used throughout the case.",
).to_metadata()
)
"""Unit declarations used by the serialized case."""
case: CaseMetadataSpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Case Metadata",
order=30,
short_help="Human-readable name and description for the case.",
).to_metadata()
)
"""Human-readable case metadata."""
solver: SolverSpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Solver",
order=40,
short_help="Solver formulation and backend settings.",
).to_metadata()
)
"""Solver and backend selection."""
freestream: FreestreamSpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Freestream",
order=50,
short_help="Freestream speed and angle-of-attack settings.",
).to_metadata()
)
"""Freestream state."""
geometry: GeometrySpec = field(
metadata=SchemaField(
value_kind="object",
required=True,
label="Geometry",
order=60,
short_help="Embedded body geometry, airfoil, and placement data.",
).to_metadata()
)
"""Geometry definitions for the case."""
reference: ReferenceSpec = field(
default_factory=ReferenceSpec,
metadata=SchemaField(
value_kind="object",
required=False,
label="Reference",
order=70,
short_help="Reference quantities used for integrated coefficients.",
advanced=True,
).to_metadata(),
)
"""Reference quantities for integrated coefficient normalization."""
post: PostProcessingSpec | None = field(
default=None,
metadata=SchemaField(
value_kind="object",
required=False,
label="Post-Processing",
order=80,
short_help=(
"Optional surface, integrated, and field output requests."
),
advanced=True,
).to_metadata(),
)
"""Optional post-processing requests."""