"""
Meshing settings that applies to volumes.
"""
# pylint: disable=too-many-lines
from typing import Literal, Optional, Union
import pydantic as pd
from typing_extensions import deprecated
import flow360.component.simulation.units as u
from flow360.component.simulation.framework.base_model import Flow360BaseModel
from flow360.component.simulation.framework.entity_base import EntityList
from flow360.component.simulation.outputs.output_entities import Slice
from flow360.component.simulation.primitives import (
AxisymmetricBody,
Box,
CustomVolume,
Cylinder,
GenericVolume,
GhostSurface,
MirroredSurface,
SeedpointVolume,
Sphere,
Surface,
WindTunnelGhostSurface,
)
from flow360.component.simulation.unit_system import LengthType
from flow360.component.simulation.validation.validation_context import (
ParamsValidationInfo,
add_validation_warning,
contextual_field_validator,
contextual_model_validator,
get_validation_info,
)
from flow360.component.simulation.validation.validation_utils import (
validate_entity_list_surface_existence,
)
from flow360.exceptions import Flow360ValueError
class classproperty: # pylint: disable=invalid-name,too-few-public-methods
"""Descriptor to create class-level properties that can be accessed from the class itself."""
def __init__(self, func):
self.func = func
def __get__(self, obj, owner):
return self.func(owner)
class StructuredBoxRefinement(Flow360BaseModel):
"""
- The mesh inside the :class:`StructuredBoxRefinement` is semi-structured.
- The :class:`StructuredBoxRefinement` cannot enclose/intersect with other objects.
- The spacings along the three box axes can be adjusted independently.
Example
-------
>>> StructuredBoxRefinement(
... entities=[
... Box.from_principal_axes(
... name="boxRefinement",
... center=(0, 1, 1) * fl.u.cm,
... size=(1, 2, 1) * fl.u.cm,
... axes=((2, 2, 0), (-2, 2, 0)),
... )
... ],
... spacing_axis1=7.5*u.cm,
... spacing_axis2=10*u.cm,
... spacing_normal=15*u.cm,
... )
====
"""
# pylint: disable=no-member
# pylint: disable=too-few-public-methods
name: Optional[str] = pd.Field("StructuredBoxRefinement")
refinement_type: Literal["StructuredBoxRefinement"] = pd.Field(
"StructuredBoxRefinement", frozen=True
)
entities: EntityList[Box] = pd.Field()
spacing_axis1: LengthType.Positive = pd.Field(
description="Spacing along the first axial direction."
)
spacing_axis2: LengthType.Positive = pd.Field(
description="Spacing along the second axial direction."
)
spacing_normal: LengthType.Positive = pd.Field(
description="Spacing along the normal axial direction."
)
@contextual_model_validator(mode="after")
def _validate_only_in_beta_mesher(self, param_info: ParamsValidationInfo):
"""
Ensure that StructuredBoxRefinement objects are only processed with the beta mesher.
"""
if param_info.is_beta_mesher:
return self
raise ValueError("`StructuredBoxRefinement` is only supported with the beta mesher.")
[docs]
class AxisymmetricRefinement(Flow360BaseModel):
"""
- The mesh inside the :class:`AxisymmetricRefinement` is semi-structured.
- The :class:`AxisymmetricRefinement` cannot enclose/intersect with other objects.
- Users could create a donut-shape :class:`AxisymmetricRefinement` and place their hub/centerbody in the middle.
- :class:`AxisymmetricRefinement` can be used for resolving the strong flow gradient
along the axial direction for the actuator or BET disks.
- The spacings along the axial, radial and circumferential directions can be adjusted independently.
Example
-------
>>> fl.AxisymmetricRefinement(
... entities=[cylinder],
... spacing_axial=1e-4,
... spacing_radial=0.3*fl.u.cm,
... spacing_circumferential=5*fl.u.mm
... )
====
"""
name: Optional[str] = pd.Field("Axisymmetric refinement")
refinement_type: Literal["AxisymmetricRefinement"] = pd.Field(
"AxisymmetricRefinement", frozen=True
)
entities: EntityList[Cylinder] = pd.Field()
# pylint: disable=no-member
spacing_axial: LengthType.Positive = pd.Field(description="Spacing along the axial direction.")
spacing_radial: LengthType.Positive = pd.Field(
description="Spacing along the radial direction."
)
spacing_circumferential: LengthType.Positive = pd.Field(
description="Spacing along the circumferential direction."
)
class _RotationVolumeBase(Flow360BaseModel):
"""
Shared base class for rotation volume zones.
- The mesh on :class:`RotationVolume` is guaranteed to be concentric.
- The :class:`RotationVolume` is designed to enclose other objects, but it can't intersect with other objects.
- Users can create a donut-shaped :class:`RotationVolume` and put their stationary centerbody in the middle.
- This type of volume zone can be used to generate volume zones compatible with :class:`~flow360.Rotation` model.
"""
# Note: Please refer to
# Note: https://www.notion.so/flexcompute/Python-model-design-document-
# Note: 78d442233fa944e6af8eed4de9541bb1?pvs=4#c2de0b822b844a12aa2c00349d1f68a3
name: Optional[str] = pd.Field(None, description="Name to display in the GUI.")
enclosed_entities: Optional[
EntityList[Cylinder, Surface, MirroredSurface, AxisymmetricBody, Box, Sphere]
] = pd.Field(
None,
description=(
"Entities enclosed by :class:`RotationVolume`. "
"Can be :class:`~flow360.Surface` and/or other :class:`~flow360.Cylinder`"
"and/or other :class:`~flow360.AxisymmetricBody`"
"and/or other :class:`~flow360.Box`"
"and/or other :class:`~flow360.Sphere`"
),
)
stationary_enclosed_entities: Optional[EntityList[Surface, MirroredSurface]] = pd.Field(
None,
description=(
"Surface entities included in `enclosed_entities` which should remain stationary "
"(excluded from rotation)."
),
)
@pd.model_validator(mode="before")
@classmethod
def _prevent_direct_instantiation(cls, data):
if cls is _RotationVolumeBase:
raise TypeError(
"`_RotationVolumeBase` is an abstract base model and cannot be instantiated directly."
)
return data
@contextual_field_validator("entities", mode="after", check_fields=False)
@classmethod
def _validate_single_instance_in_entity_list(cls, values, param_info: ParamsValidationInfo):
"""
[CAPABILITY-LIMITATION] Only single instance is allowed in entities.
"""
# Note: Should be fine without expansion since we only allow Draft entities here.
# But using expand_entity_list for consistency and future-proofing.
expanded_entities = param_info.expand_entity_list(values)
if len(expanded_entities) > 1:
raise ValueError(
f"Only single instance is allowed in entities for each `{cls.__name__}`."
)
return values
@contextual_field_validator("entities", mode="after", check_fields=False)
@classmethod
def _validate_cylinder_name_length(cls, values, param_info: ParamsValidationInfo):
"""
Check the name length for the cylinder entities due to the 32-character
limitation of all data structure names and labels in CGNS format.
The current prefix is 'rotatingBlock-' with 14 characters.
"""
if param_info.is_beta_mesher:
return values
expanded_entities = param_info.expand_entity_list(values)
cgns_max_zone_name_length = 32
max_cylinder_name_length = cgns_max_zone_name_length - len("rotatingBlock-")
for entity in expanded_entities:
if isinstance(entity, Cylinder) and len(entity.name) > max_cylinder_name_length:
raise ValueError(
f"The name ({entity.name}) of `Cylinder` entity in `{cls.__name__}` "
+ f"exceeds {max_cylinder_name_length} characters limit."
)
return values
@contextual_field_validator("enclosed_entities", mode="after")
@classmethod
def _validate_enclosed_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo):
"""
Ensure that Box and Sphere entities in enclosed_entities are only used with the beta mesher.
"""
if values is None:
return values
if param_info.is_beta_mesher:
return values
expanded = param_info.expand_entity_list(values) # Can Have `Surface`
for entity in expanded:
if isinstance(entity, Box):
raise ValueError(
f"`Box` entity in `{cls.__name__}.enclosed_entities` is only supported with the beta mesher."
)
if isinstance(entity, Sphere):
raise ValueError(
f"`Sphere` entity in `{cls.__name__}.enclosed_entities` is only supported with the beta mesher."
)
return values
@contextual_field_validator("enclosed_entities", mode="after")
@classmethod
def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo):
"""Ensure all boundaries will be present after mesher."""
return validate_entity_list_surface_existence(value, param_info)
@contextual_field_validator("stationary_enclosed_entities", mode="after")
@classmethod
def _validate_stationary_enclosed_entities_only_in_beta_mesher(
cls, values, param_info: ParamsValidationInfo
):
"""
Ensure that stationary_enclosed_entities is only used with the beta mesher.
"""
if values is None:
return values
if not param_info.is_beta_mesher:
raise ValueError(
f"`stationary_enclosed_entities` in `{cls.__name__}` is only supported with the beta mesher."
)
return values
@contextual_model_validator(mode="after")
def _validate_stationary_enclosed_entities_subset(self, param_info: ParamsValidationInfo):
"""
Ensure that stationary_enclosed_entities is a subset of enclosed_entities.
"""
if self.stationary_enclosed_entities is None:
return self
if self.enclosed_entities is None:
raise ValueError(
"`stationary_enclosed_entities` cannot be specified when `enclosed_entities` is None."
)
# Get sets of entity names for comparison
# pylint: disable=no-member
expanded_enclosed_entities = param_info.expand_entity_list(self.enclosed_entities)
enclosed_names = {entity.name for entity in expanded_enclosed_entities}
expanded_stationary_enclosed_entities = param_info.expand_entity_list(
self.stationary_enclosed_entities
)
stationary_names = {entity.name for entity in expanded_stationary_enclosed_entities}
# Check if all stationary entities are in enclosed entities
if not stationary_names.issubset(enclosed_names):
missing_entities = stationary_names - enclosed_names
raise ValueError(
f"All entities in `stationary_enclosed_entities` must be present in `enclosed_entities`. "
f"Missing entities: {', '.join(missing_entities)}"
)
return self
[docs]
class RotationVolume(_RotationVolumeBase):
"""
Creates a rotation volume mesh using cylindrical or axisymmetric body entities.
- The mesh on :class:`RotationVolume` is guaranteed to be concentric.
- The :class:`RotationVolume` is designed to enclose other objects, but it can't intersect with other objects.
- Users can create a donut-shaped :class:`RotationVolume` and put their stationary centerbody in the middle.
- This type of volume zone can be used to generate volume zones compatible with :class:`~flow360.Rotation` model.
- Supports :class:`Cylinder` and :class:`AxisymmetricBody` entities for defining the rotation volume geometry.
.. note::
For spherical sliding interfaces, use :class:`RotationSphere`.
.. note::
The deprecated :class:`RotationCylinder` class is maintained for backward compatibility
but only accepts :class:`Cylinder` entities. New code should use :class:`RotationVolume`.
Example
-------
Using a Cylinder entity:
>>> fl.RotationVolume(
... name="RotationCylinder",
... spacing_axial=0.5*fl.u.m,
... spacing_circumferential=0.3*fl.u.m,
... spacing_radial=1.5*fl.u.m,
... entities=cylinder
... )
Using an AxisymmetricBody entity:
>>> fl.RotationVolume(
... name="RotationConeFrustum",
... spacing_axial=0.5*fl.u.m,
... spacing_circumferential=0.3*fl.u.m,
... spacing_radial=1.5*fl.u.m,
... entities=axisymmetric_body
... )
With enclosed entities:
>>> fl.RotationVolume(
... name="RotationVolume",
... spacing_axial=0.5*fl.u.m,
... spacing_circumferential=0.3*fl.u.m,
... spacing_radial=1.5*fl.u.m,
... entities=outer_cylinder,
... enclosed_entities=[inner_cylinder, surface]
... )
"""
type: Literal["RotationVolume"] = pd.Field("RotationVolume", frozen=True)
name: Optional[str] = pd.Field("Rotation Volume", description="Name to display in the GUI.")
entities: EntityList[Cylinder, AxisymmetricBody] = pd.Field()
# pylint: disable=no-member
spacing_axial: LengthType.Positive = pd.Field(description="Spacing along the axial direction.")
spacing_radial: LengthType.Positive = pd.Field(
description="Spacing along the radial direction."
)
spacing_circumferential: LengthType.Positive = pd.Field(
description="Spacing along the circumferential direction."
)
@contextual_field_validator("entities", mode="after")
@classmethod
def _validate_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo):
"""
Ensure that AxisymmetricBody entities are only used with the beta mesher.
"""
if param_info.is_beta_mesher:
return values
expanded_entities = param_info.expand_entity_list(values)
for entity in expanded_entities:
if isinstance(entity, AxisymmetricBody):
raise ValueError(
"`AxisymmetricBody` entity for `RotationVolume` is only supported with the beta mesher."
)
return values
class RotationSphere(_RotationVolumeBase):
"""
Creates a spherical sliding interface using :class:`Sphere` entities.
- The mesh on :class:`RotationSphere` is guaranteed to be concentric.
- The :class:`RotationSphere` is designed to enclose other objects, but it can't intersect with other objects.
- This type of volume zone can be used to generate volume zones compatible with :class:`~flow360.Rotation` model.
Example
-------
>>> fl.RotationSphere(
... name="RotationSphere",
... spacing_circumferential=0.3*fl.u.m,
... entities=sphere
... )
"""
type: Literal["RotationSphere"] = pd.Field("RotationSphere", frozen=True)
name: Optional[str] = pd.Field("Rotation Sphere", description="Name to display in the GUI.")
entities: EntityList[Sphere] = pd.Field()
# pylint: disable=no-member
spacing_circumferential: LengthType.Positive = pd.Field(
description="Uniform spacing on the spherical interface."
)
@contextual_field_validator("entities", mode="after")
@classmethod
def _validate_entities_beta_mesher_only(cls, values, param_info: ParamsValidationInfo):
"""
Ensure that Sphere entities are only used with the beta mesher.
"""
if param_info.is_beta_mesher:
return values
expanded_entities = param_info.expand_entity_list(values)
if expanded_entities:
raise ValueError(
"`Sphere` entity for `RotationSphere` is only supported with the beta mesher."
)
return values
@deprecated(
"The `RotationCylinder` class is deprecated! Use `RotationVolume` for non-sphere "
"entities and `RotationSphere` for sphere entities instead."
)
class RotationCylinder(RotationVolume):
"""
.. deprecated::
Use :class:`RotationVolume` instead. This class is maintained for backward
compatibility but will be removed in a future version.
RotationCylinder creates a rotation volume mesh using cylindrical entities.
- The mesh on :class:`RotationCylinder` is guaranteed to be concentric.
- The :class:`RotationCylinder` is designed to enclose other objects, but it can't intersect with other objects.
- Users could create a donut-shape :class:`RotationCylinder` and put their stationary centerbody in the middle.
- This type of volume zone can be used to generate volume zone compatible with :class:`~flow360.Rotation` model.
.. note::
:class:`RotationVolume` supports :class:`Cylinder` and :class:`AxisymmetricBody` entities.
:class:`RotationSphere` supports :class:`Sphere` entities.
Please migrate to using :class:`RotationVolume` / :class:`RotationSphere` directly.
Example
-------
>>> fl.RotationCylinder(
... name="RotationCylinder",
... spacing_axial=0.5*fl.u.m,
... spacing_circumferential=0.3*fl.u.m,
... spacing_radial=1.5*fl.u.m,
... entities=cylinder
... )
"""
type: Literal["RotationCylinder"] = pd.Field("RotationCylinder", frozen=True)
entities: EntityList[Cylinder] = pd.Field()
### BEGIN FARFIELDS ###
class _FarfieldBase(Flow360BaseModel):
"""Base class for farfield parameters."""
domain_type: Optional[Literal["half_body_positive_y", "half_body_negative_y", "full_body"]] = (
pd.Field( # In the future, we will support more flexible half model types and full model via Union.
None,
description="""
- half_body_positive_y: Trim to a half-model by slicing with the global Y=0 plane; keep the '+y' side for meshing and simulation.
- half_body_negative_y: Trim to a half-model by slicing with the global Y=0 plane; keep the '-y' side for meshing and simulation.
- full_body: Keep the full body for meshing and simulation without attempting to add symmetry planes.
Warning: When using AutomatedFarfield or UserDefinedFarfield, setting `domain_type` overrides automatic symmetry plane detection.
""",
)
)
@contextual_field_validator("domain_type", mode="after")
@classmethod
def _validate_only_in_beta_mesher(cls, value, param_info: ParamsValidationInfo):
"""
Ensure that domain_type is only used with the beta mesher and GAI.
"""
if not value or (param_info.use_geometry_AI is True and param_info.is_beta_mesher is True):
return value
raise ValueError(
"`domain_type` is only supported when using both GAI surface mesher and beta volume mesher."
)
@pd.field_validator("domain_type", mode="after")
@classmethod
def _validate_domain_type_bbox(cls, value):
"""
Ensure that when domain_type is used, the model actually spans across Y=0.
"""
validation_info = get_validation_info()
if validation_info is None:
return value
if (
value not in ("half_body_positive_y", "half_body_negative_y")
or validation_info.global_bounding_box is None
or validation_info.planar_face_tolerance is None
):
return value
y_min = validation_info.global_bounding_box[0][1]
y_max = validation_info.global_bounding_box[1][1]
largest_dimension = -float("inf")
for dim in range(3):
dimension = (
validation_info.global_bounding_box[1][dim]
- validation_info.global_bounding_box[0][dim]
)
largest_dimension = max(largest_dimension, dimension)
tolerance = largest_dimension * validation_info.planar_face_tolerance
# Check if model crosses Y=0
crossing = y_min < -tolerance and y_max > tolerance
if crossing:
return value
# If not crossing, check if it matches the requested domain
if value == "half_body_positive_y":
# Should be on positive side (y > 0)
if y_min >= -tolerance:
return value
if value == "half_body_negative_y":
# Should be on negative side (y < 0)
if y_max <= tolerance:
return value
message = (
f"The model does not cross the symmetry plane (Y=0) with tolerance {tolerance:.2g}. "
f"Model Y range: [{y_min:.2g}, {y_max:.2g}]. "
"Please check if `domain_type` is set correctly."
)
if getattr(validation_info, "entity_transformation_detected", False):
add_validation_warning(message)
return value
raise ValueError(message)
[docs]
class AutomatedFarfield(_FarfieldBase):
"""
Settings for automatic farfield volume zone generation.
Example
-------
>>> fl.AutomatedFarfield(name="Farfield", method="auto")
====
"""
type: Literal["AutomatedFarfield"] = pd.Field("AutomatedFarfield", frozen=True)
name: Optional[str] = pd.Field("Automated Farfield") # Kept optional for backward compatibility
method: Literal["auto", "quasi-3d", "quasi-3d-periodic"] = pd.Field(
default="auto",
frozen=True,
description="""
- auto: The mesher will Sphere or semi-sphere will be generated based on the bounding box of the geometry.
- Full sphere if min{Y} < 0 and max{Y} > 0.
- +Y semi sphere if min{Y} = 0 and max{Y} > 0.
- -Y semi sphere if min{Y} < 0 and max{Y} = 0.
- quasi-3d: Thin disk will be generated for quasi 3D cases.
Both sides of the farfield disk will be treated as "symmetric plane"
- quasi-3d-periodic: The two sides of the quasi-3d disk will be conformal
Note: For quasi-3d, please do not group patches from both sides of the farfield disk into a single surface.
""",
)
private_attribute_entity: GenericVolume = pd.Field(
GenericVolume(
name="__farfield_zone_name_not_properly_set_yet",
private_attribute_id="farfield_zone_name_not_properly_set_yet",
),
frozen=True,
exclude=True,
)
relative_size: pd.PositiveFloat = pd.Field(
default=50.0,
description="Radius of the far-field (semi)sphere/cylinder relative to "
"the max dimension of the geometry bounding box.",
)
enclosed_surfaces: Optional[EntityList[Surface]] = pd.Field(
None,
description=(
"Geometry surfaces that, together with the farfield surface, form the boundary of the "
"exterior farfield zone. Required when using CustomVolumes alongside an AutomatedFarfield. "
),
)
@property
def farfield(self):
"""Returns the farfield boundary surface."""
# Make sure the naming is the same here and what the geometry/surface mesh pipeline generates.
return GhostSurface(name="farfield", private_attribute_id="farfield")
@property
def symmetry_plane(self) -> GhostSurface:
"""
Returns the symmetry plane boundary surface.
"""
if self.method == "auto":
return GhostSurface(name="symmetric", private_attribute_id="symmetric")
raise Flow360ValueError(
"Unavailable for quasi-3d farfield methods. Please use `symmetry_planes` property instead."
)
@property
def symmetry_planes(self):
"""Returns the symmetry plane boundary surface(s)."""
# Make sure the naming is the same here and what the geometry/surface mesh pipeline generates.
if self.method == "auto":
return GhostSurface(name="symmetric", private_attribute_id="symmetric")
if self.method in ("quasi-3d", "quasi-3d-periodic"):
return [
GhostSurface(name="symmetric-1", private_attribute_id="symmetric-1"),
GhostSurface(name="symmetric-2", private_attribute_id="symmetric-2"),
]
raise Flow360ValueError(f"Unsupported method: {self.method}")
@contextual_field_validator("method", mode="after")
@classmethod
def _validate_quasi_3d_periodic_only_in_legacy_mesher(
cls, values, param_info: ParamsValidationInfo
):
"""
Check mesher and AutomatedFarfield method compatibility
"""
if param_info.is_beta_mesher and values == "quasi-3d-periodic":
raise ValueError("Only legacy mesher can support quasi-3d-periodic")
return values
[docs]
class UserDefinedFarfield(_FarfieldBase):
"""
Setting for user defined farfield zone generation.
This means the "farfield" boundaries are coming from the supplied geometry file
and meshing will take place inside this "geometry".
**Important:** By default, the volume mesher will grow boundary layers on :class:`~flow360.UserDefinedFarfield`.
Use :class:`~flow360.PassiveSpacing` to project or disable boundary layer growth.
Example
-------
>>> fl.UserDefinedFarfield(name="InnerChannel")
====
"""
type: Literal["UserDefinedFarfield"] = pd.Field("UserDefinedFarfield", frozen=True)
name: Optional[str] = pd.Field(None)
@property
def symmetry_plane(self) -> GhostSurface:
"""
Returns the symmetry plane boundary surface.
Warning: This should only be used when using GAI and beta mesher.
"""
if self.domain_type not in (None, "half_body_positive_y", "half_body_negative_y"):
# We allow None here to allow auto detection of domain type from bounding box.
raise Flow360ValueError(
"Symmetry plane of user defined farfield is only supported when domain_type "
"is `half_body_positive_y`, `half_body_negative_y`, or None (auto detection)."
)
return GhostSurface(name="symmetric", private_attribute_id="symmetric")
# pylint: disable=no-member
[docs]
class StaticFloor(Flow360BaseModel):
"""Class for static wind tunnel floor with friction patch."""
type_name: Literal["StaticFloor"] = pd.Field(
"StaticFloor", description="Static floor with friction patch.", frozen=True
)
friction_patch_x_range: LengthType.Range = pd.Field(
default=(-3, 6) * u.m, description="(Minimum, maximum) x of friction patch."
)
friction_patch_width: LengthType.Positive = pd.Field(
default=2 * u.m, description="Width of friction patch."
)
[docs]
class FullyMovingFloor(Flow360BaseModel):
"""Class for fully moving wind tunnel floor with friction patch."""
type_name: Literal["FullyMovingFloor"] = pd.Field(
"FullyMovingFloor", description="Fully moving floor.", frozen=True
)
# pylint: disable=no-member
[docs]
class CentralBelt(Flow360BaseModel):
"""Class for wind tunnel floor with one central belt."""
type_name: Literal["CentralBelt"] = pd.Field(
"CentralBelt", description="Floor with central belt.", frozen=True
)
central_belt_x_range: LengthType.Range = pd.Field(
default=(-2, 2) * u.m, description="(Minimum, maximum) x of central belt."
)
central_belt_width: LengthType.Positive = pd.Field(
default=1.2 * u.m, description="Width of central belt."
)
[docs]
class WheelBelts(CentralBelt):
"""Class for wind tunnel floor with one central belt and four wheel belts."""
type_name: Literal["WheelBelts"] = pd.Field(
"WheelBelts",
description="Floor with central belt and four wheel belts.",
frozen=True,
)
# No defaults for the below; user must specify
front_wheel_belt_x_range: LengthType.Range = pd.Field(
description="(Minimum, maximum) x of front wheel belt."
)
front_wheel_belt_y_range: LengthType.PositiveRange = pd.Field(
description="(Inner, outer) y of front wheel belt."
)
rear_wheel_belt_x_range: LengthType.Range = pd.Field(
description="(Minimum, maximum) x of rear wheel belt."
)
rear_wheel_belt_y_range: LengthType.PositiveRange = pd.Field(
description="(Inner, outer) y of rear wheel belt."
)
@pd.model_validator(mode="after")
def _validate_wheel_belt_ranges(self):
if self.front_wheel_belt_x_range[1] >= self.rear_wheel_belt_x_range[0]:
raise ValueError(
f"Front wheel belt maximum x ({self.front_wheel_belt_x_range[1]}) "
f"must be less than rear wheel belt minimum x ({self.rear_wheel_belt_x_range[0]})."
)
# Central belt is centered at y=0 and extends from -width/2 to +width/2
# It must fit within the inner edges of the wheel belts
front_wheel_inner_edge = self.front_wheel_belt_y_range[0]
rear_wheel_inner_edge = self.rear_wheel_belt_y_range[0]
# Validate central belt width against front wheel belt inner edge
if self.central_belt_width > 2 * front_wheel_inner_edge:
raise ValueError(
f"Central belt width ({self.central_belt_width}) "
f"must be less than or equal to twice the front wheel belt inner edge "
f"(2 × {front_wheel_inner_edge} = {2 * front_wheel_inner_edge})."
)
# Validate central belt width against rear wheel belt inner edge
if self.central_belt_width > 2 * rear_wheel_inner_edge:
raise ValueError(
f"Central belt width ({self.central_belt_width}) "
f"must be less than or equal to twice the rear wheel belt inner edge "
f"(2 × {rear_wheel_inner_edge} = {2 * rear_wheel_inner_edge})."
)
return self
# pylint: disable=no-member
[docs]
class WindTunnelFarfield(_FarfieldBase):
"""
Settings for analytic wind tunnel farfield generation.
The user only needs to provide tunnel dimensions and floor type and dimensions, rather than a geometry.
**Important:** By default, the volume mesher will grow boundary layers on :class:`~flow360.WindTunnelFarfield`.
Use :class:`~flow360.PassiveSpacing` to project or disable boundary layer growth.
Example
-------
>>> fl.WindTunnelFarfield(
width = 10 * fl.u.m,
height = 5 * fl.u.m,
inlet_x_position = -10 * fl.u.m,
outlet_x_position = 20 * fl.u.m,
floor_z_position = 0 * fl.u.m,
floor_type = fl.CentralBelt(
central_belt_x_range = (-1, 4) * fl.u.m,
central_belt_width = 1.2 * fl.u.m
)
)
"""
model_config = pd.ConfigDict(ignored_types=(classproperty,))
type: Literal["WindTunnelFarfield"] = pd.Field("WindTunnelFarfield", frozen=True)
name: str = pd.Field("Wind Tunnel Farfield", description="Name of the wind tunnel farfield.")
# Tunnel parameters
width: LengthType.Positive = pd.Field(default=10 * u.m, description="Width of the wind tunnel.")
height: LengthType.Positive = pd.Field(
default=6 * u.m, description="Height of the wind tunnel."
)
inlet_x_position: LengthType = pd.Field(
default=-20 * u.m, description="X-position of the inlet."
)
outlet_x_position: LengthType = pd.Field(
default=40 * u.m, description="X-position of the outlet."
)
floor_z_position: LengthType = pd.Field(default=0 * u.m, description="Z-position of the floor.")
floor_type: Union[
StaticFloor,
FullyMovingFloor,
CentralBelt,
WheelBelts,
] = pd.Field(
default_factory=StaticFloor,
description="Floor type of the wind tunnel.",
discriminator="type_name",
)
# up direction not yet supported; assume +Z
@property
def symmetry_plane(self) -> GhostSurface:
"""
Returns the symmetry plane boundary surface for half body domains.
"""
if self.domain_type not in ("half_body_positive_y", "half_body_negative_y"):
raise Flow360ValueError(
"Symmetry plane for wind tunnel farfield is only supported when domain_type "
"is `half_body_positive_y` or `half_body_negative_y`."
)
return GhostSurface(name="symmetric", private_attribute_id="symmetric")
# pylint: disable=no-self-argument
@classproperty
def left(cls):
"""Return the ghost surface representing the tunnel's left wall."""
return WindTunnelGhostSurface(name="windTunnelLeft", private_attribute_id="windTunnelLeft")
@classproperty
def right(cls):
"""Return the ghost surface representing the tunnel's right wall."""
return WindTunnelGhostSurface(
name="windTunnelRight", private_attribute_id="windTunnelRight"
)
@classproperty
def inlet(cls):
"""Return the ghost surface corresponding to the wind tunnel inlet."""
return WindTunnelGhostSurface(
name="windTunnelInlet", private_attribute_id="windTunnelInlet"
)
@classproperty
def outlet(cls):
"""Return the ghost surface corresponding to the wind tunnel outlet."""
return WindTunnelGhostSurface(
name="windTunnelOutlet", private_attribute_id="windTunnelOutlet"
)
@classproperty
def ceiling(cls):
"""Return the ghost surface for the tunnel ceiling."""
return WindTunnelGhostSurface(
name="windTunnelCeiling", private_attribute_id="windTunnelCeiling"
)
@classproperty
def floor(cls):
"""Return the ghost surface for the tunnel floor."""
return WindTunnelGhostSurface(
name="windTunnelFloor", private_attribute_id="windTunnelFloor"
)
@classproperty
def friction_patch(cls):
"""Return the ghost surface for the floor friction patch used by static floors."""
return WindTunnelGhostSurface(
name="windTunnelFrictionPatch",
used_by=["StaticFloor"],
private_attribute_id="windTunnelFrictionPatch",
)
@classproperty
def central_belt(cls):
"""Return the ghost surface used by central and wheel belt floor types."""
return WindTunnelGhostSurface(
name="windTunnelCentralBelt",
used_by=["CentralBelt", "WheelBelts"],
private_attribute_id="windTunnelCentralBelt",
)
@classproperty
def front_wheel_belts(cls):
"""Return the ghost surface for the front wheel belt region."""
return WindTunnelGhostSurface(
name="windTunnelFrontWheelBelt",
used_by=["WheelBelts"],
private_attribute_id="windTunnelFrontWheelBelt",
)
@classproperty
def rear_wheel_belts(cls):
"""Return the ghost surface for the rear wheel belt region."""
return WindTunnelGhostSurface(
name="windTunnelRearWheelBelt",
used_by=["WheelBelts"],
private_attribute_id="windTunnelRearWheelBelt",
)
# pylint: enable=no-self-argument
@staticmethod
def _get_valid_ghost_surfaces(
floor_string: Optional[str] = "all", domain_string: Optional[str] = None
) -> list[WindTunnelGhostSurface]:
"""
Returns a list of valid ghost surfaces given a floor type as a string
or ``all``, and the domain type as a string.
"""
common_ghost_surfaces = [
WindTunnelFarfield.inlet,
WindTunnelFarfield.outlet,
WindTunnelFarfield.ceiling,
WindTunnelFarfield.floor,
]
if domain_string != "half_body_negative_y":
common_ghost_surfaces += [WindTunnelFarfield.right]
if domain_string != "half_body_positive_y":
common_ghost_surfaces += [WindTunnelFarfield.left]
for ghost_surface_type in [
WindTunnelFarfield.friction_patch,
WindTunnelFarfield.central_belt,
WindTunnelFarfield.front_wheel_belts,
WindTunnelFarfield.rear_wheel_belts,
]:
if floor_string == "all" or floor_string in ghost_surface_type.used_by:
common_ghost_surfaces += [ghost_surface_type]
return common_ghost_surfaces
@pd.model_validator(mode="after")
def _validate_inlet_is_less_than_outlet(self):
if self.inlet_x_position >= self.outlet_x_position:
raise ValueError(
f"Inlet x position ({self.inlet_x_position}) "
f"must be less than outlet x position ({self.outlet_x_position})."
)
return self
@pd.model_validator(mode="after")
def _validate_central_belt_ranges(self):
# friction patch
if isinstance(self.floor_type, StaticFloor):
if self.floor_type.friction_patch_width >= self.width:
raise ValueError(
f"Friction patch width ({self.floor_type.friction_patch_width}) "
f"must be less than wind tunnel width ({self.width})"
)
if self.floor_type.friction_patch_x_range[0] <= self.inlet_x_position:
raise ValueError(
f"Friction patch minimum x ({self.floor_type.friction_patch_x_range[0]}) "
f"must be greater than inlet x ({self.inlet_x_position})"
)
if self.floor_type.friction_patch_x_range[1] >= self.outlet_x_position:
raise ValueError(
f"Friction patch maximum x ({self.floor_type.friction_patch_x_range[1]}) "
f"must be less than outlet x ({self.outlet_x_position})"
)
# central belt
elif isinstance(self.floor_type, CentralBelt):
if self.floor_type.central_belt_width >= self.width:
raise ValueError(
f"Central belt width ({self.floor_type.central_belt_width}) "
f"must be less than wind tunnel width ({self.width})"
)
if self.floor_type.central_belt_x_range[0] <= self.inlet_x_position:
raise ValueError(
f"Central belt minimum x ({self.floor_type.central_belt_x_range[0]}) "
f"must be greater than inlet x ({self.inlet_x_position})"
)
if self.floor_type.central_belt_x_range[1] >= self.outlet_x_position:
raise ValueError(
f"Central belt maximum x ({self.floor_type.central_belt_x_range[1]}) "
f"must be less than outlet x ({self.outlet_x_position})"
)
return self
@pd.model_validator(mode="after")
def _validate_wheel_belts_ranges(self):
if isinstance(self.floor_type, WheelBelts):
if self.floor_type.front_wheel_belt_y_range[1] >= self.width * 0.5:
raise ValueError(
f"Front wheel outer y ({self.floor_type.front_wheel_belt_y_range[1]}) "
f"must be less than half of wind tunnel width ({self.width * 0.5})"
)
if self.floor_type.rear_wheel_belt_y_range[1] >= self.width * 0.5:
raise ValueError(
f"Rear wheel outer y ({self.floor_type.rear_wheel_belt_y_range[1]}) "
f"must be less than half of wind tunnel width ({self.width * 0.5})"
)
if self.floor_type.front_wheel_belt_x_range[0] <= self.inlet_x_position:
raise ValueError(
f"Front wheel minimum x ({self.floor_type.front_wheel_belt_x_range[0]}) "
f"must be greater than inlet x ({self.inlet_x_position})"
)
if self.floor_type.rear_wheel_belt_x_range[1] >= self.outlet_x_position:
raise ValueError(
f"Rear wheel maximum x ({self.floor_type.rear_wheel_belt_x_range[1]}) "
f"must be less than outlet x ({self.outlet_x_position})"
)
return self
@contextual_model_validator(mode="after")
def _validate_requires_geometry_ai(self, param_info: ParamsValidationInfo):
"""Ensure WindTunnelFarfield is only used when GeometryAI is enabled."""
if not param_info.use_geometry_AI:
raise ValueError("WindTunnelFarfield is only supported when Geometry AI is enabled.")
return self
[docs]
class MeshSliceOutput(Flow360BaseModel):
"""
:class:`MeshSliceOutput` class for mesh slice output settings.
Example
-------
>>> fl.MeshSliceOutput(
... slices=[
... fl.Slice(
... name="Slice_1",
... normal=(0, 1, 0),
... origin=(0, 0.56, 0)*fl.u.m
... ),
... ],
... )
====
"""
name: str = pd.Field("Mesh slice output", description="Name of the `MeshSliceOutput`.")
entities: EntityList[Slice] = pd.Field(
alias="slices",
description="List of output :class:`~flow360.Slice` entities.",
)
include_crinkled_slices: bool = pd.Field(
default=False,
description="Generate crinkled slices in addition to flat slices.",
)
cutoff_radius: Optional[LengthType.Positive] = pd.Field(
default=None,
description="Cutoff radius of the slice output. If not specified, "
"the slice extends to the boundaries of the volume mesh.",
)
output_type: Literal["MeshSliceOutput"] = pd.Field("MeshSliceOutput", frozen=True)
[docs]
class CustomZones(Flow360BaseModel):
"""
:class:`CustomZones` class for creating volume zones from custom volumes or seedpoint volumes.
Names of the generated volume zones will be the names of the custom volumes.
Example
-------
>>> fl.CustomZones(name="Custom zones", entities=[custom_volume1, custom_volume2], )
====
"""
type: Literal["CustomZones"] = pd.Field("CustomZones", frozen=True)
name: str = pd.Field("Custom zones", description="Name of the `CustomZones` meshing setting.")
entities: EntityList[CustomVolume, SeedpointVolume] = pd.Field(
description="The custom volume zones to be generated."
)
element_type: Literal["mixed", "tetrahedra"] = pd.Field(
default="mixed",
description="The element type to be used for the generated volume zones."
+ " - mixed: Mesher will automatically choose the element types used."
+ " - tetrahedra: Only tetrahedra element type will be used for the generated volume zones.",
)