"""Meshing related parameters for volume and surface mesher."""
import logging
from typing import Annotated, Literal
import pydantic as pd
from flow360_schema.framework.base_model import Flow360BaseModel
from flow360_schema.models.entities.volume_entities import (
AxisymmetricBody,
Cylinder,
SeedpointVolume,
Sphere,
)
from flow360_schema.models.simulation.framework.updater import (
DEFAULT_PLANAR_FACE_TOLERANCE,
DEFAULT_SLIDING_INTERFACE_TOLERANCE,
)
from flow360_schema.models.simulation.meshing_param import snappy
from flow360_schema.models.simulation.meshing_param.edge_params import SurfaceEdgeRefinement
from flow360_schema.models.simulation.meshing_param.face_params import (
BoundaryLayer,
GeometryRefinement,
PassiveSpacing,
SurfaceRefinement,
)
from flow360_schema.models.simulation.meshing_param.meshing_specs import (
MeshingDefaults,
VolumeMeshingDefaults,
)
from flow360_schema.models.simulation.meshing_param.meshing_validators import (
validate_snappy_uniform_refinement_entities,
)
from flow360_schema.models.simulation.meshing_param.volume_params import (
AutomatedFarfield,
AxisymmetricRefinement,
CustomVolume,
CustomZones,
MeshSliceOutput,
RotationCylinder,
RotationSphere,
RotationVolume,
StructuredBoxRefinement,
UniformRefinement,
UserDefinedFarfield,
WindTunnelFarfield,
_FarfieldAllowingEnclosedEntities,
)
from flow360_schema.models.simulation.validation.validation_context import (
SURFACE_MESH,
VOLUME_MESH,
ContextField,
ParamsValidationInfo,
add_validation_warning,
contextual_field_validator,
contextual_model_validator,
)
from flow360_schema.models.simulation.validation.validation_utils import EntityUsageMap
from typing_extensions import Self
logger = logging.getLogger(__name__)
RefinementTypes = Annotated[
SurfaceEdgeRefinement
| SurfaceRefinement
| GeometryRefinement
| BoundaryLayer
| PassiveSpacing
| UniformRefinement
| StructuredBoxRefinement
| AxisymmetricRefinement,
pd.Field(discriminator="refinement_type"),
]
VolumeZonesTypes = Annotated[
RotationVolume
| RotationCylinder
| RotationSphere
| AutomatedFarfield
| UserDefinedFarfield
| CustomZones
| WindTunnelFarfield,
pd.Field(discriminator="type"),
]
ZoneTypesModular = Annotated[
RotationVolume | RotationSphere | AutomatedFarfield | UserDefinedFarfield | CustomZones,
pd.Field(discriminator="type"),
]
VolumeRefinementTypes = Annotated[
UniformRefinement | AxisymmetricRefinement | BoundaryLayer | PassiveSpacing | StructuredBoxRefinement,
pd.Field(discriminator="refinement_type"),
]
def _collect_rotation_entity_names(zones, param_info, zone_types):
"""Collect entity names associated with RotationVolume/RotationCylinder/RotationSphere zones."""
names: set[str] = set()
for zone in zones:
if isinstance(zone, zone_types):
for entity in param_info.expand_entity_list(zone.entities):
names.add(entity.name)
return names
def _validate_farfield_enclosed_entities(zones, rotation_entity_names, has_custom_volumes, param_info):
"""Validate farfield enclosed_entities: require CustomVolumes and rotation-volume association.
Only applies to farfield types that support enclosed_entities (Automated, WindTunnel).
"""
for zone in zones:
if not isinstance(zone, _FarfieldAllowingEnclosedEntities):
continue
if zone.enclosed_entities is None:
if has_custom_volumes:
raise ValueError(
"`enclosed_entities` for farfield must be specified when "
"`CustomVolume` entities are present in volume zones."
)
continue
if not has_custom_volumes:
raise ValueError(
"`enclosed_entities` for farfield is only allowed when "
"`CustomVolume` entities are present in volume zones."
)
for entity in param_info.expand_entity_list(zone.enclosed_entities):
if isinstance(entity, (Cylinder, AxisymmetricBody, Sphere)) and entity.name not in rotation_entity_names:
raise ValueError(
f"`{type(entity).__name__}` entity `{entity.name}` in "
f"`enclosed_entities` must be associated with a `RotationVolume` or `RotationSphere`."
)
def _collect_all_custom_volumes(zones):
"""Collect all CustomVolume instances from CustomZones."""
custom_volumes: list[CustomVolume] = []
for zone in zones:
if isinstance(zone, CustomZones):
for cv in zone.entities.stored_entities:
if isinstance(cv, CustomVolume):
custom_volumes.append(cv)
return custom_volumes
def _collect_all_seedpoint_volumes(zones):
"""Collect all SeedpointVolume instances from CustomZones."""
seedpoint_volumes: list[SeedpointVolume] = []
for zone in zones:
if isinstance(zone, CustomZones):
for volume in zone.entities.stored_entities:
if isinstance(volume, SeedpointVolume):
seedpoint_volumes.append(volume)
return seedpoint_volumes
def _validate_seedpoint_volume_mesher_compatibility(seedpoint_volumes, param_info: ParamsValidationInfo):
"""Validate SeedpointVolume usage against mesher capabilities."""
if seedpoint_volumes and not (param_info.use_snappy or param_info.is_beta_mesher):
raise ValueError("`SeedpointVolume` is supported only when using snappyHexMeshing or the beta mesher.")
def _validate_seedpoint_volume_snappy_single_point(seedpoint_volumes):
"""Each `SeedpointVolume` reaching the snappy path must carry exactly one seed.
snappy's `locationInMesh` takes a single triplet per zone; multi-seed is supported
only by the GAI / beta mesher paths.
"""
for sv in seedpoint_volumes:
if len(sv.point_in_mesh) != 1:
raise ValueError(
f"`SeedpointVolume` `{sv.name}` has {len(sv.point_in_mesh)} seed points; "
"Snappy requires exactly one point per zone."
)
def _validate_seedpoint_volume_geometry_ai_exclusivity(zones, param_info: ParamsValidationInfo):
"""When geometry AI is enabled, SeedpointVolumes and explicit CustomVolume-based
CustomZones cannot coexist. If any SeedpointVolume is present, there must be exactly
one CustomZone, and it must contain only SeedpointVolume entities."""
if not param_info.use_geometry_AI or param_info.use_snappy:
return
custom_zones = [zone for zone in zones if isinstance(zone, CustomZones)]
seedpoint_zones = [
zone
for zone in custom_zones
if any(isinstance(entity, SeedpointVolume) for entity in zone.entities.stored_entities)
]
if not seedpoint_zones:
return
message = (
"When using `SeedpointVolume` with geometry AI, exactly one `CustomZones` "
"containing only `SeedpointVolume` entities is allowed; mixing with other "
"`CustomZones` or `CustomVolume` entities is not supported."
)
if len(custom_zones) > 1:
raise ValueError(message)
sole_zone = seedpoint_zones[0]
if any(not isinstance(entity, SeedpointVolume) for entity in sole_zone.entities.stored_entities):
raise ValueError(message)
def _validate_custom_volume_rotation_association(custom_volumes, rotation_entity_names, param_info):
"""Validate that Cylinder/AxisymmetricBody/Sphere in CustomVolume.bounding_entities
are associated with a RotationVolume or RotationSphere."""
for cv in custom_volumes:
for entity in param_info.expand_entity_list(cv.bounding_entities):
if isinstance(entity, (Cylinder, AxisymmetricBody, Sphere)) and entity.name not in rotation_entity_names:
raise ValueError(
f"`{type(entity).__name__}` entity `{entity.name}` in "
f"`CustomVolume` `{cv.name}` `bounding_entities` must be "
f"associated with a `RotationVolume` or `RotationSphere`."
)
[docs]
class MeshingParams(Flow360BaseModel):
"""
Meshing parameters for volume and/or surface mesher. This contains all the meshing related settings.
Example
-------
>>> fl.MeshingParams(
... refinement_factor=1.0,
... gap_treatment_strength=0.5,
... defaults=fl.MeshingDefaults(
... surface_max_edge_length=1*fl.u.m,
... boundary_layer_first_layer_thickness=1e-5*fl.u.m
... ),
... volume_zones=[farfield],
... refinements=[
... fl.SurfaceEdgeRefinement(
... edges=[geometry["edge1"], geometry["edge2"]],
... method=fl.AngleBasedRefinement(value=8*fl.u.deg)
... ),
... fl.SurfaceRefinement(
... faces=[geometry["face1"], geometry["face2"]],
... max_edge_length=0.001*fl.u.m
... ),
... fl.UniformRefinement(
... entities=[cylinder, box],
... spacing=1*fl.u.cm
... )
... ]
... )
====
"""
type_name: Literal["MeshingParams"] = pd.Field("MeshingParams", frozen=True)
refinement_factor: pd.PositiveFloat | None = pd.Field(
default=1,
description="All spacings in refinement regions"
+ "and first layer thickness will be adjusted to generate `r`-times"
+ " finer mesh where r is the refinement_factor value.",
)
gap_treatment_strength: float | None = ContextField(
default=None,
ge=0,
le=1,
description="Narrow gap treatment strength used when two surfaces are in close proximity."
" Use a value between 0 and 1, where 0 is no treatment and 1 is the most conservative treatment."
" This parameter has a global impact where the anisotropic transition into the isotropic mesh."
" However the impact on regions without close proximity is negligible."
" The beta mesher uses a conservative default value of 1.0.",
context=VOLUME_MESH,
)
defaults: MeshingDefaults = pd.Field(
MeshingDefaults(),
description="Default settings for meshing."
" In other words the settings specified here will be applied"
" as a default setting for all `Surface` (s) and `Edge` (s).",
)
refinements: list[RefinementTypes] = pd.Field(
default=[],
description="Additional fine-tunning for refinements on top of :py:attr:`defaults`",
)
# Will add more to the Union
volume_zones: list[VolumeZonesTypes] | None = pd.Field(default=None, description="Creation of new volume zones.")
# Meshing outputs (for now, volume mesh slices)
outputs: list[MeshSliceOutput] = pd.Field(
default=[],
description="Mesh output settings.",
)
@pd.field_validator("volume_zones", mode="after")
@classmethod
def _check_volume_zones_has_farfield(cls, v):
if v is None:
# User did not put anything in volume_zones so may not want to use volume meshing
return v
total_farfield = sum(
isinstance(
volume_zone,
(AutomatedFarfield, WindTunnelFarfield, UserDefinedFarfield),
)
for volume_zone in v
)
if total_farfield == 0:
raise ValueError("Farfield zone is required in `volume_zones`.")
if total_farfield > 1:
raise ValueError("Only one farfield zone is allowed in `volume_zones`.")
return v
@contextual_field_validator("volume_zones", mode="after")
@classmethod
def _check_automated_farfield_custom_volumes(cls, v, param_info):
if v is None:
return v
automated_farfield = next((zone for zone in v if isinstance(zone, AutomatedFarfield)), None)
if automated_farfield is not None:
custom_volumes = _collect_all_custom_volumes(v)
if any(cv.name == "farfield" for cv in custom_volumes):
raise ValueError(
"CustomVolume name 'farfield' is reserved when using AutomatedFarfield. "
"The 'farfield' zone will be automatically generated using `AutomatedFarfield.enclosed_entities`. "
"Please choose a different name."
)
enclosed_entities = (
param_info.expand_entity_list(automated_farfield.enclosed_entities)
if automated_farfield.enclosed_entities is not None
else []
)
if custom_volumes and not enclosed_entities:
raise ValueError(
"When using AutomatedFarfield with CustomVolumes, `enclosed_entities` must be "
"specified on the AutomatedFarfield to define the exterior farfield zone boundary."
)
if enclosed_entities and not custom_volumes:
raise ValueError(
"`enclosed_entities` on AutomatedFarfield is only allowed when CustomVolume entities are used. "
"Without custom volumes, the farfield zone will be automatically detected."
)
if any(s.name == "farfield" for s in enclosed_entities):
raise ValueError(
"Surface name 'farfield' in `enclosed_entities` will conflict with the automatically "
"generated farfield boundary. Please choose a different surface."
)
return v
@contextual_field_validator("volume_zones", mode="after")
@classmethod
def _check_enclosed_entities_rotation_volume_association(cls, v, param_info: ParamsValidationInfo):
"""
Ensure that:
- enclosed_entities on any farfield requires at least one CustomZone
- Cylinder, AxisymmetricBody, and Sphere entities in enclosed_entities
are associated with a RotationVolume or RotationSphere
"""
if v is None:
return v
rotation_entity_names = _collect_rotation_entity_names(
v, param_info, (RotationVolume, RotationCylinder, RotationSphere)
)
custom_volumes = _collect_all_custom_volumes(v)
has_custom_volumes = len(custom_volumes) > 0
_validate_farfield_enclosed_entities(v, rotation_entity_names, has_custom_volumes, param_info)
_validate_custom_volume_rotation_association(custom_volumes, rotation_entity_names, param_info)
return v
@contextual_field_validator("volume_zones", mode="after")
@classmethod
def _check_volume_zones_have_unique_names(cls, v):
"""Ensure there won't be duplicated volume zone names."""
if v is None:
return v
to_be_generated_volume_zone_names = set()
for volume_zone in v:
if not isinstance(volume_zone, CustomZones):
continue
# Extract CustomVolume from CustomZones
for custom_volume in volume_zone.entities.stored_entities:
if custom_volume.name in to_be_generated_volume_zone_names:
raise ValueError(
f"Multiple CustomVolume with the same name `{custom_volume.name}` are not allowed."
)
to_be_generated_volume_zone_names.add(custom_volume.name)
return v
@contextual_field_validator("volume_zones", mode="after")
@classmethod
def _check_seedpoint_volume_usage(cls, v, param_info: ParamsValidationInfo):
"""Validate SeedpointVolume usage in legacy meshing schema."""
if v is None:
return v
seedpoint_volumes = _collect_all_seedpoint_volumes(v)
_validate_seedpoint_volume_mesher_compatibility(seedpoint_volumes, param_info)
_validate_seedpoint_volume_geometry_ai_exclusivity(v, param_info)
return v
@contextual_model_validator(mode="after")
def _check_no_reused_volume_entities(self) -> Self:
"""
Meshing entities reuse check.
+------------------------+------------------------+------------------------+------------------------+
| | RotationCylinder | AxisymmetricRefinement | UniformRefinement |
+------------------------+------------------------+------------------------+------------------------+
| RotationCylinder | NO | -- | -- |
+------------------------+------------------------+------------------------+------------------------+
| AxisymmetricRefinement | NO | NO | -- |
+------------------------+------------------------+------------------------+------------------------+
| UniformRefinement | YES | NO | NO |
+------------------------+------------------------+------------------------+------------------------+
+------------------------+------------------------+------------------------+
| |StructuredBoxRefinement | UniformRefinement |
+------------------------+------------------------+------------------------+
|StructuredBoxRefinement | NO | -- |
+------------------------+------------------------+------------------------+
| UniformRefinement | NO | NO |
+------------------------+------------------------+------------------------+
"""
usage = EntityUsageMap()
for volume_zone in self.volume_zones if self.volume_zones is not None else []:
if isinstance(volume_zone, (RotationVolume, RotationCylinder, RotationSphere)):
_ = [usage.add_entity_usage(item, volume_zone.type) for item in volume_zone.entities.stored_entities]
for refinement in self.refinements if self.refinements is not None else []:
if isinstance(
refinement,
(UniformRefinement, AxisymmetricRefinement, StructuredBoxRefinement),
):
_ = [
usage.add_entity_usage(item, refinement.refinement_type)
for item in refinement.entities.stored_entities
]
error_msg = ""
for entity_type, entity_model_map in usage.dict_entity.items():
for entity_info in entity_model_map.values():
if len(entity_info["model_list"]) == 1 or sorted(entity_info["model_list"]) in [
sorted(["RotationCylinder", "UniformRefinement"]),
sorted(["RotationVolume", "UniformRefinement"]),
sorted(["RotationSphere", "UniformRefinement"]),
]:
continue
model_set = set(entity_info["model_list"])
if len(model_set) == 1:
error_msg += (
f"{entity_type} entity `{entity_info['entity_name']}` "
+ f"is used multiple times in `{model_set.pop()}`."
)
else:
model_string = ", ".join(f"`{x}`" for x in sorted(model_set))
error_msg += (
f"Using {entity_type} entity `{entity_info['entity_name']}` "
+ f"in {model_string} at the same time is not allowed."
)
if error_msg:
raise ValueError(error_msg)
return self
@contextual_model_validator(mode="after")
def _check_sizing_against_octree_series(self, param_info: ParamsValidationInfo):
"""Validate that UniformRefinement spacings align with the octree series."""
if not param_info.is_beta_mesher:
return self
if self.defaults.octree_spacing is None:
logger.warning(
"No `octree_spacing` configured in `%s`; "
"octree spacing validation for UniformRefinement will be skipped.",
type(self.defaults).__name__,
)
return self
if self.refinements is not None:
for refinement in self.refinements:
if isinstance(refinement, UniformRefinement):
self.defaults.octree_spacing.check_spacing(refinement.spacing, type(refinement).__name__)
return self
@contextual_model_validator(mode="after")
def _warn_min_passage_size_without_remove_hidden_geometry(self) -> Self:
"""Warn when GeometryRefinement specifies min_passage_size but remove_hidden_geometry is disabled."""
if self.defaults.remove_hidden_geometry:
return self
for refinement in self.refinements or []:
if isinstance(refinement, GeometryRefinement) and refinement.min_passage_size is not None:
add_validation_warning(
f"GeometryRefinement '{refinement.name}' specifies 'min_passage_size' but "
"'remove_hidden_geometry' is not enabled in meshing defaults. "
"The per-face 'min_passage_size' will be ignored."
)
return self
@contextual_model_validator(mode="after")
def _warn_multi_zone_remove_hidden_geometry(self) -> Self:
"""Warn when remove_hidden_geometry is enabled with multiple farfield/custom volume zones."""
if not self.defaults.remove_hidden_geometry:
return self
if self.volume_zones is None:
return self
# AF and WTF each generate their own farfield zone but UDF does not,
# so it doesn't contribute to the zone count
has_non_udf_farfield = any(
isinstance(zone, (AutomatedFarfield, WindTunnelFarfield)) for zone in self.volume_zones
)
count = len(_collect_all_custom_volumes(self.volume_zones)) + (1 if has_non_udf_farfield else 0)
if count > 1:
add_validation_warning(
"Multiple farfield/custom volume zones detected. Removal of hidden geometry "
"for multi-zone cases is not fully supported and may not work as intended."
)
return self
@property
def farfield_method(self):
"""Returns the farfield method used."""
if self.volume_zones:
for zone in self.volume_zones:
if isinstance(zone, AutomatedFarfield):
return zone.method
if isinstance(zone, WindTunnelFarfield):
return "wind-tunnel"
if isinstance(zone, UserDefinedFarfield):
return "user-defined"
return None
[docs]
class VolumeMeshingParams(Flow360BaseModel):
"""
Volume meshing parameters.
"""
type_name: Literal["VolumeMeshingParams"] = pd.Field("VolumeMeshingParams", frozen=True)
defaults: VolumeMeshingDefaults = pd.Field()
refinement_factor: pd.PositiveFloat | None = pd.Field(
default=1,
description="All spacings in refinement regions"
+ "and first layer thickness will be adjusted to generate `r`-times"
+ " finer mesh where r is the refinement_factor value.",
)
refinements: list[VolumeRefinementTypes] = pd.Field(
default=[],
description="Additional fine-tunning for refinements on top of the global settings",
)
planar_face_tolerance: pd.NonNegativeFloat = pd.Field(
DEFAULT_PLANAR_FACE_TOLERANCE,
description="Tolerance used for detecting planar faces in the input surface mesh"
" that need to be remeshed, such as symmetry planes."
" This tolerance is non-dimensional, and represents a distance"
" relative to the largest dimension of the bounding box of the input surface mesh."
" This is only supported by the beta mesher and can not be overridden per face.",
)
gap_treatment_strength: float | None = pd.Field(
default=None,
ge=0,
le=1,
description="Narrow gap treatment strength used when two surfaces are in close proximity."
" Use a value between 0 and 1, where 0 is no treatment and 1 is the most conservative treatment."
" This parameter has a global impact where the anisotropic transition into the isotropic mesh."
" However the impact on regions without close proximity is negligible."
" The beta mesher uses a conservative default value of 1.0.",
)
sliding_interface_tolerance: pd.NonNegativeFloat = pd.Field(
DEFAULT_SLIDING_INTERFACE_TOLERANCE,
strict=True,
description="Tolerance used for detecting / creating curves in the input surface mesh / geometry lying on"
" sliding interfaces. This tolerance is non-dimensional, and represents a distance"
" relative to the smallest radius of all sliding interfaces specified in meshing parameters."
" This cannot be overridden per sliding interface.",
)
@contextual_model_validator(mode="after")
def _check_sizing_against_octree_series(self, param_info: ParamsValidationInfo):
"""Validate that UniformRefinement spacings align with the octree series."""
if not param_info.is_beta_mesher:
return self
if self.defaults.octree_spacing is None:
logger.warning(
"No `octree_spacing` configured in `%s`; "
"octree spacing validation for UniformRefinement will be skipped.",
type(self.defaults).__name__,
)
return self
if self.refinements is not None:
for refinement in self.refinements:
if isinstance(refinement, UniformRefinement):
self.defaults.octree_spacing.check_spacing(refinement.spacing, type(refinement).__name__)
return self
@contextual_model_validator(mode="after")
def _check_snappy_uniform_refinement_entities(self, param_info: ParamsValidationInfo):
"""Validate projected UniformRefinement entities are compatible with snappyHexMesh."""
if not param_info.use_snappy:
return self
for refinement in self.refinements:
if isinstance(refinement, UniformRefinement) and refinement.project_to_surface is not False:
validate_snappy_uniform_refinement_entities(refinement)
return self
SurfaceMeshingParams = Annotated[snappy.SurfaceMeshingParams, pd.Field(discriminator="type_name")]
[docs]
class ModularMeshingWorkflow(Flow360BaseModel):
"""
Structure consolidating surface and volume meshing parameters.
"""
type_name: Literal["ModularMeshingWorkflow"] = pd.Field("ModularMeshingWorkflow", frozen=True)
surface_meshing: SurfaceMeshingParams | None = ContextField(default=None, context=SURFACE_MESH)
volume_meshing: VolumeMeshingParams | None = ContextField(default=None, context=VOLUME_MESH)
zones: list[ZoneTypesModular]
# Meshing outputs (for now, volume mesh slices)
outputs: list[MeshSliceOutput] = pd.Field(
default=[],
description="Mesh output settings.",
)
@pd.field_validator("zones", mode="after")
@classmethod
def _check_volume_zones_has_farfield(cls, v):
total_automated_farfield = sum(isinstance(volume_zone, AutomatedFarfield) for volume_zone in v)
total_user_defined_farfield = sum(isinstance(volume_zone, UserDefinedFarfield) for volume_zone in v)
total_custom_zones = sum(isinstance(volume_zone, CustomZones) for volume_zone in v)
if total_custom_zones and total_user_defined_farfield:
raise ValueError("When using `CustomZones` the `UserDefinedFarfield` will be ignored.")
if total_automated_farfield > 1:
raise ValueError("Only one `AutomatedFarfield` zone is allowed in `zones`.")
if total_user_defined_farfield > 1:
raise ValueError("Only one `UserDefinedFarfield` zone is allowed in `zones`.")
if total_automated_farfield + total_user_defined_farfield > 1:
raise ValueError("Cannot use `AutomatedFarfield` and `UserDefinedFarfield` simultaneously.")
if (total_user_defined_farfield + total_automated_farfield + total_custom_zones) == 0:
raise ValueError("At least one zone defining the farfield is required.")
if total_automated_farfield and total_custom_zones:
raise ValueError("`CustomZones` cannot be used with `AutomatedFarfield`.")
return v
@pd.field_validator("zones", mode="after")
@classmethod
def _check_volume_zones_have_unique_names(cls, v):
"""Ensure there won't be duplicated volume zone names."""
if v is None:
return v
to_be_generated_volume_zone_names = set()
for volume_zone in v:
if isinstance(volume_zone, CustomZones):
for custom_volume in volume_zone.entities.stored_entities:
if custom_volume.name in to_be_generated_volume_zone_names:
raise ValueError(
f"Multiple `CustomVolume` with the same name `{custom_volume.name}` are not allowed."
)
to_be_generated_volume_zone_names.add(custom_volume.name)
return v
@contextual_field_validator("zones", mode="after")
@classmethod
def _check_enclosed_entities_rotation_volume_association(cls, v, param_info: ParamsValidationInfo):
"""
Ensure that:
- enclosed_entities on any farfield requires at least one CustomZone
- Cylinder, AxisymmetricBody, and Sphere entities in enclosed_entities
are associated with a RotationVolume or RotationSphere
"""
if v is None:
return v
has_custom_zones = any(isinstance(zone, CustomZones) for zone in v)
rotation_entity_names = _collect_rotation_entity_names(v, param_info, (RotationVolume, RotationSphere))
_validate_farfield_enclosed_entities(v, rotation_entity_names, has_custom_zones, param_info)
custom_volumes = _collect_all_custom_volumes(v)
_validate_custom_volume_rotation_association(custom_volumes, rotation_entity_names, param_info)
return v
@pd.model_validator(mode="after")
def _check_snappy_zones(self) -> Self:
total_custom_volumes = 0
seedpoint_volumes: list[SeedpointVolume] = []
for zone in self.zones:
if isinstance(zone, CustomZones):
for custom_volume in zone.entities.stored_entities:
if isinstance(custom_volume, CustomVolume):
total_custom_volumes += 1
if isinstance(custom_volume, SeedpointVolume):
seedpoint_volumes.append(custom_volume)
if isinstance(self.surface_meshing, snappy.SurfaceMeshingParams):
if seedpoint_volumes and total_custom_volumes:
raise ValueError(
"Volume zones with snappyHexMeshing are defined using `SeedpointVolume`, not `CustomZones`."
)
if self.farfield_method != "auto" and not seedpoint_volumes:
raise ValueError(
"snappyHexMeshing requires at least one `SeedpointVolume` when not using `AutomatedFarfield`."
)
_validate_seedpoint_volume_snappy_single_point(seedpoint_volumes)
return self
@contextual_field_validator("zones", mode="after")
@classmethod
def _check_seedpoint_volume_usage(cls, v, param_info: ParamsValidationInfo):
"""Validate SeedpointVolume usage in modular meshing schema."""
_validate_seedpoint_volume_mesher_compatibility(_collect_all_seedpoint_volumes(v), param_info)
_validate_seedpoint_volume_geometry_ai_exclusivity(v, param_info)
return v
@contextual_model_validator(mode="after")
def _check_uniform_refinement_names_not_in_body_ids(self, param_info: ParamsValidationInfo) -> Self:
"""Ensure no UniformRefinement entity shares a name with a body id in the geometry."""
if not isinstance(self.surface_meshing, snappy.SurfaceMeshingParams):
return self
entity_info = param_info.get_entity_info()
if entity_info is None or getattr(entity_info, "type_name", None) != "GeometryEntityInfo":
return self
body_ids = set(entity_info.all_body_ids)
if not body_ids:
return self
conflicting: list[str] = []
# Surface meshing: all UniformRefinement entities
if self.surface_meshing is not None and self.surface_meshing.refinements is not None:
for refinement in self.surface_meshing.refinements:
if isinstance(refinement, UniformRefinement):
for entity in refinement.entities.stored_entities:
if entity.name in body_ids:
conflicting.append(entity.name)
# Volume meshing: UniformRefinement entities that project to surface
# (project_to_surface defaults to True for snappy, so None counts as True)
if self.volume_meshing is not None and self.volume_meshing.refinements is not None:
for refinement in self.volume_meshing.refinements:
if isinstance(refinement, UniformRefinement) and (refinement.project_to_surface is not False):
for entity in refinement.entities.stored_entities:
if entity.name in body_ids:
conflicting.append(entity.name)
if conflicting:
names_str = ", ".join(f"`{name}`" for name in dict.fromkeys(conflicting))
raise ValueError(
f"UniformRefinement entity name(s) {names_str} conflict with body id(s)"
" in the geometry. Please use different names for the UniformRefinement entities."
)
return self
@contextual_model_validator(mode="after")
def _check_no_reused_volume_entities(self) -> Self:
"""
Meshing entities reuse check.
+------------------------+------------------------+------------------------+------------------------+
| | RotationCylinder | AxisymmetricRefinement | UniformRefinement |
+------------------------+------------------------+------------------------+------------------------+
| RotationCylinder | NO | -- | -- |
+------------------------+------------------------+------------------------+------------------------+
| AxisymmetricRefinement | NO | NO | -- |
+------------------------+------------------------+------------------------+------------------------+
| UniformRefinement | YES | NO | NO |
+------------------------+------------------------+------------------------+------------------------+
+------------------------+------------------------+------------------------+
| |StructuredBoxRefinement | UniformRefinement |
+------------------------+------------------------+------------------------+
|StructuredBoxRefinement | NO | -- |
+------------------------+------------------------+------------------------+
| UniformRefinement | NO | NO |
+------------------------+------------------------+------------------------+
"""
usage = EntityUsageMap()
for volume_zone in self.zones if self.zones is not None else []:
if isinstance(volume_zone, (RotationVolume, RotationSphere)):
_ = [usage.add_entity_usage(item, volume_zone.type) for item in volume_zone.entities.stored_entities]
for refinement in (
self.volume_meshing.refinements
if (self.volume_meshing is not None and self.volume_meshing.refinements is not None)
else []
):
if isinstance(
refinement,
(UniformRefinement, AxisymmetricRefinement, StructuredBoxRefinement),
):
_ = [
usage.add_entity_usage(item, refinement.refinement_type)
for item in refinement.entities.stored_entities
]
error_msg = ""
for entity_type, entity_model_map in usage.dict_entity.items():
for entity_info in entity_model_map.values():
if len(entity_info["model_list"]) == 1 or sorted(entity_info["model_list"]) in [
sorted(["RotationCylinder", "UniformRefinement"]),
sorted(["RotationVolume", "UniformRefinement"]),
sorted(["RotationSphere", "UniformRefinement"]),
]:
continue
model_set = set(entity_info["model_list"])
if len(model_set) == 1:
error_msg += (
f"{entity_type} entity `{entity_info['entity_name']}` "
+ f"is used multiple times in `{model_set.pop()}`."
)
else:
model_string = ", ".join(f"`{x}`" for x in sorted(model_set))
error_msg += (
f"Using {entity_type} entity `{entity_info['entity_name']}` "
+ f"in {model_string} at the same time is not allowed."
)
if error_msg:
raise ValueError(error_msg)
return self
@property
def farfield_method(self):
"""Returns the farfield method used."""
if self.zones:
for zone in self.zones:
if isinstance(zone, AutomatedFarfield):
return zone.method
return "user-defined"
return None