"""Shared validation utilities and result types for airfoil schema checks."""
from __future__ import annotations
import math
import re
from copy import deepcopy
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, cast
from buffalo_core.diagnostics import (
Diagnostic,
DiagnosticLocation,
DiagnosticReport,
DiagnosticSeverity,
)
from buffalo_wings.airfoil.internal.schema import AirfoilDefinitionSpec
from buffalo_wings.airfoil.internal.schema.common import Point2DSpec
if TYPE_CHECKING:
from buffalo_wings.airfoil.internal.schema.document import (
AirfoilDocumentSpec,
)
NACA5_REFLEX_DIGIT_INDEX = 2
NACA16_DESIGNATION_PATTERN = re.compile(r"^16-(\d)(\d{2})$")
NACA6_DESIGNATION_PATTERN = re.compile(
r"^(6[3-7])(?:\(([1-9])\))?-(\d)(\d{2})$"
)
NACA6A_DESIGNATION_PATTERN = re.compile(r"^(6[3-5]A)-(\d)(\d{2})$")
SUPPORTED_FILE_FORMATS = {
"auto",
"lednicer",
"selig",
"surface_curve",
"upper_lower",
}
SUPPORTED_POINTS_FORMATS = {"surface_curve", "upper_lower"}
SUPPORTED_NACA6_SERIES = {"63", "64", "65", "66", "67"}
SUPPORTED_NACA6A_SERIES = {"63A", "64A", "65A"}
UPPER_LOWER_MIN_POINTS = 2
SURFACE_CURVE_MIN_POINTS = 3
POLYGON_MIN_VERTICES = 3
POINT2D_LENGTH = 2
NACA4_MODIFIED_DIGIT_LENGTH = 6
NACA5_MODIFIED_DIGIT_LENGTH = 7
NACA16_MAX_IDEAL_LIFT_COEFFICIENT = 0.9
NACA_SERIES_MAX_THICKNESS = 0.4
CAMBER_COMPONENT_MIN = -0.5
CAMBER_COMPONENT_MAX = 0.5
SUPPORTED_TRAILING_EDGE_OPTIONS = {"standard", "sharp"}
SUPPORTED_LEADING_EDGE_RADIUS_OPTIONS = {"standard", "exact"}
SUPPORTED_STRUCTURED_FILE_SUFFIXES = {".json", ".yaml", ".yml"}
SUPPORTED_STRUCTURED_PAYLOAD_FORMATS = {"surface_curve", "upper_lower"}
TITLE_AND_COUNT_LINE_COUNT = 2
StructuredPayloadFormat = Literal["surface_curve", "upper_lower"]
DetectedFileFormat = Literal[
"selig",
"lednicer",
"surface_curve",
"upper_lower",
]
[docs]
@dataclass(frozen=True, slots=True)
class AirfoilValidationResult:
"""Structured validation result for one airfoil schema definition."""
is_valid: bool
"""
Whether validation completed without any error diagnostics.
This flag is ``True`` only when the associated diagnostic report contains
no errors.
"""
diagnostics: DiagnosticReport
"""
Structured diagnostics emitted by the validation workflow.
The report may include warnings or informational entries even when
``is_valid`` is ``True``.
"""
validated_spec: AirfoilDefinitionSpec | None = None
"""
Defensive copy of the validated schema when validation succeeds.
This field remains ``None`` whenever validation reports any error.
"""
[docs]
@dataclass(frozen=True, slots=True)
class AirfoilDocumentValidationResult:
"""Structured validation result for one airfoil document schema."""
is_valid: bool
"""Whether validation completed without any error diagnostics."""
diagnostics: DiagnosticReport
"""Structured diagnostics emitted by the document validation workflow."""
validated_spec: AirfoilDocumentSpec | None = None
"""
Defensive copy of the validated document when validation succeeds.
This field remains ``None`` whenever validation reports any error.
"""
def _is_real_number(value: object) -> bool:
"""
Return whether ``value`` is a finite real scalar.
Parameters
----------
value : object
Candidate scalar-like object.
Returns
-------
bool
``True`` when ``value`` is a finite ``int`` or ``float`` but not a
boolean.
"""
if isinstance(value, bool):
return False
if not isinstance(value, int | float):
return False
return math.isfinite(float(value))
def _is_point2d(value: object) -> bool:
"""
Return whether ``value`` is a numeric two-component point.
Parameters
----------
value : object
Candidate point-like object.
Returns
-------
bool
``True`` when ``value`` is a list or tuple of two finite real
scalars.
"""
if not isinstance(value, tuple | list):
return False
point = cast(tuple[object, ...] | list[object], value)
if len(point) != POINT2D_LENGTH:
return False
return _is_real_number(point[0]) and _is_real_number(point[1])
def _structured_payload_format(
value: object,
) -> StructuredPayloadFormat | None:
"""
Return one validated structured point-payload format literal.
Parameters
----------
value : object
Candidate structured payload format token.
Returns
-------
StructuredPayloadFormat | None
Normalized format literal when supported, otherwise ``None``.
"""
if value == "surface_curve":
return "surface_curve"
if value == "upper_lower":
return "upper_lower"
return None
def _distance(point_a: Point2DSpec, point_b: Point2DSpec) -> float:
"""
Return the Euclidean distance between two planar points.
Returns
-------
float
Straight-line distance between ``point_a`` and ``point_b``.
"""
delta_x = float(point_a[0]) - float(point_b[0])
delta_y = float(point_a[1]) - float(point_b[1])
return math.hypot(delta_x, delta_y)
def _trailing_edge_midpoint(
point_a: Point2DSpec,
point_b: Point2DSpec,
) -> Point2DSpec:
"""
Return the midpoint of two trailing-edge endpoints.
Returns
-------
Point2DSpec
Midpoint between the two provided endpoints.
"""
return (
0.5 * (float(point_a[0]) + float(point_b[0])),
0.5 * (float(point_a[1]) + float(point_b[1])),
)
def _error(
*,
code: str,
message: str,
field_path: str,
object_path: str = "airfoil",
) -> Diagnostic:
"""
Build one schema-validation error diagnostic.
Returns
-------
Diagnostic
Error diagnostic localized to the requested object and field path.
"""
return Diagnostic(
severity=DiagnosticSeverity.ERROR,
code=code,
message=message,
location=DiagnosticLocation(
object_path=object_path,
field_path=field_path,
),
)
def _file_error(
*,
code: str,
message: str,
field_path: str,
) -> Diagnostic:
"""
Build one file-source validation error diagnostic.
Returns
-------
Diagnostic
Error diagnostic rooted at the file-source object path.
"""
return _error(
code=code,
message=message,
field_path=field_path,
object_path="airfoil.file_source",
)
def _file_info(
*,
code: str,
message: str,
field_path: str,
) -> Diagnostic:
"""
Build one file-source informational diagnostic.
Returns
-------
Diagnostic
Informational diagnostic rooted at the file-source object path.
"""
return Diagnostic(
severity=DiagnosticSeverity.INFO,
code=code,
message=message,
location=DiagnosticLocation(
object_path="airfoil.file_source",
field_path=field_path,
),
)
def _support_error(spec: AirfoilDefinitionSpec) -> Diagnostic:
"""
Build the shared unsupported-runtime diagnostic.
Returns
-------
Diagnostic
Error diagnostic stating that the current runtime cannot yet
construct ``spec``.
"""
return _error(
code="airfoil.support.unsupported_type",
message=f"Airfoil runtime does not support {type(spec).__name__} yet.",
field_path="type",
)
def _result_from_diagnostics(
spec: AirfoilDefinitionSpec,
diagnostics: tuple[Diagnostic, ...],
) -> AirfoilValidationResult:
"""
Build the public validation result from a diagnostic tuple.
Returns
-------
AirfoilValidationResult
Structured validation result with a defensive spec copy when no
errors are present.
"""
report = DiagnosticReport(entries=diagnostics)
if report.has_errors:
return AirfoilValidationResult(
is_valid=False,
diagnostics=report,
validated_spec=None,
)
return AirfoilValidationResult(
is_valid=True,
diagnostics=report,
validated_spec=deepcopy(spec),
)
def _document_result_from_diagnostics(
document: AirfoilDocumentSpec,
diagnostics: tuple[Diagnostic, ...],
) -> AirfoilDocumentValidationResult:
"""
Build the public document-validation result from a diagnostic tuple.
Returns
-------
AirfoilDocumentValidationResult
Structured validation result with a defensive document copy when no
errors are present.
"""
report = DiagnosticReport(entries=diagnostics)
if report.has_errors:
return AirfoilDocumentValidationResult(
is_valid=False,
diagnostics=report,
validated_spec=None,
)
return AirfoilDocumentValidationResult(
is_valid=True,
diagnostics=report,
validated_spec=deepcopy(document),
)
def _validated_or_unreachable[T](
value: T | None,
*,
context: str,
) -> T:
"""
Return one guarded validated value or fail as an internal invariant.
Raises
------
AssertionError
If ``value`` is unexpectedly ``None`` after prior validation guards.
"""
if value is None:
msg = (
f"{context} validation invariant violated after prior guard checks."
)
raise AssertionError(msg)
return value
is_real_number = _is_real_number
is_point2d = _is_point2d
coerce_structured_payload_format = _structured_payload_format
point_distance = _distance
trailing_edge_midpoint = _trailing_edge_midpoint
build_validation_error = _error
build_file_validation_error = _file_error
build_file_validation_info = _file_info
build_support_error = _support_error
document_result_from_diagnostics = _document_result_from_diagnostics
result_from_diagnostics = _result_from_diagnostics
validated_or_unreachable = _validated_or_unreachable