"""Default settings for meshing using different meshing algorithms"""
from math import log2
from typing import Optional
import numpy as np
import pydantic as pd
import flow360.component.simulation.units as u
from flow360.component.simulation.framework.base_model import Flow360BaseModel
from flow360.component.simulation.framework.updater import (
DEFAULT_PLANAR_FACE_TOLERANCE,
DEFAULT_SLIDING_INTERFACE_TOLERANCE,
)
from flow360.component.simulation.unit_system import AngleType, LengthType
from flow360.component.simulation.validation.validation_context import (
SURFACE_MESH,
VOLUME_MESH,
ConditionalField,
ContextField,
ParamsValidationInfo,
add_validation_warning,
contextual_field_validator,
)
from flow360.component.simulation.validation.validation_utils import (
check_geometry_ai_features,
)
from flow360.log import log
[docs]
class OctreeSpacing(Flow360BaseModel):
"""
Helper class for octree-based meshers. Holds the base for the octree spacing and lows calculation of levels.
"""
# pylint: disable=no-member
base_spacing: LengthType.Positive
@pd.model_validator(mode="before")
@classmethod
def _reject_plain_value(cls, input_data):
if isinstance(input_data, u.unyt.unyt_quantity):
raise ValueError(
"Passing a plain dimensional value to OctreeSpacing is not supported. "
"Use OctreeSpacing(base_spacing=<value>) instead."
)
return input_data
@pd.validate_call
def __getitem__(self, idx: int):
return self.base_spacing * (2 ** (-idx))
# pylint: disable=no-member
[docs]
@pd.validate_call
def to_level(self, spacing: LengthType.Positive):
"""
Can be used to check in what refinement level would the given spacing result
and if it is a direct match in the spacing series.
"""
level = -log2(spacing / self.base_spacing)
direct_spacing = np.isclose(level, np.round(level), atol=1e-8)
returned_level = np.round(level) if direct_spacing else np.ceil(level)
return returned_level, direct_spacing
# pylint: disable=no-member
[docs]
@pd.validate_call
def check_spacing(self, spacing: LengthType.Positive, location: str):
"""Warn if the given spacing does not align with the octree series."""
lvl, close = self.to_level(spacing)
if not close:
spacing_unit = spacing.units
closest_spacing = self[lvl]
msg = (
f"The spacing of {spacing:.4g} specified in {location} will be cast "
f"to the first lower refinement in the octree series "
f"({closest_spacing.to(spacing_unit):.4g})."
)
log.warning(msg)
def set_default_octree_spacing(octree_spacing, param_info: ParamsValidationInfo):
"""Shared logic for defaulting octree_spacing to 1 * project_length_unit."""
if octree_spacing is not None:
return octree_spacing
if param_info.project_length_unit is None:
add_validation_warning(
"No project length unit found; `octree_spacing` will not be set automatically. "
"Octree spacing validation will be skipped."
)
return octree_spacing
# pylint: disable=no-member
project_length = 1 * LengthType.validate(param_info.project_length_unit)
return OctreeSpacing(base_spacing=project_length)
[docs]
class MeshingDefaults(Flow360BaseModel):
"""
Default/global settings for meshing parameters.
Example
-------
>>> fl.MeshingDefaults(
... surface_max_edge_length=1*fl.u.m,
... surface_edge_growth_rate=1.2,
... curvature_resolution_angle=12*fl.u.deg,
... boundary_layer_growth_rate=1.1,
... boundary_layer_first_layer_thickness=1e-5*fl.u.m
... )
====
"""
# pylint: disable=no-member
geometry_accuracy: Optional[LengthType.Positive] = pd.Field(
None,
description="The smallest length scale that will be resolved accurately by the surface meshing process. "
"This parameter is only valid when using geometry AI."
"It can be overridden with class: ~flow360.GeometryRefinement.",
)
##:: Default surface edge settings
surface_edge_growth_rate: float = ContextField(
1.2,
ge=1,
description="Growth rate of the anisotropic layers grown from the edges."
"This can not be overridden per edge.",
context=SURFACE_MESH,
)
##:: Default boundary layer settings
boundary_layer_growth_rate: float = ContextField(
1.2,
description="Default growth rate for volume prism layers.",
ge=1,
context=VOLUME_MESH,
)
# pylint: disable=no-member
boundary_layer_first_layer_thickness: Optional[LengthType.Positive] = ConditionalField(
None,
description="Default first layer thickness for volumetric anisotropic layers."
" This can be overridden with :class:`~flow360.BoundaryLayer`.",
context=VOLUME_MESH,
) # Truly optional if all BL faces already have first_layer_thickness
number_of_boundary_layers: Optional[pd.NonNegativeInt] = pd.Field(
None,
description="Default number of volumetric anisotropic layers."
" The volume mesher will automatically calculate the required"
" no. of layers to grow the boundary layer elements to isotropic size if not specified."
" This is only supported by the beta mesher and can not be overridden per face.",
)
planar_face_tolerance: pd.NonNegativeFloat = pd.Field(
DEFAULT_PLANAR_FACE_TOLERANCE,
strict=True,
description="Tolerance used for detecting planar faces in the input surface mesh / geometry"
" 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 / geometry."
" This can not be overridden per face.",
)
# pylint: disable=duplicate-code
sliding_interface_tolerance: pd.NonNegativeFloat = ConditionalField(
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.",
context=VOLUME_MESH,
)
##:: Default surface layer settings
surface_max_edge_length: Optional[LengthType.Positive] = ConditionalField(
None,
description="Default maximum edge length for surface cells."
" This can be overridden with :class:`~flow360.SurfaceRefinement`.",
context=SURFACE_MESH,
)
surface_max_aspect_ratio: pd.PositiveFloat = ConditionalField(
10.0,
description="Maximum aspect ratio for surface cells for the GAI surface mesher."
" This cannot be overridden per face",
context=SURFACE_MESH,
)
surface_max_adaptation_iterations: pd.NonNegativeInt = ConditionalField(
50,
description="Maximum adaptation iterations for the GAI surface mesher.",
context=SURFACE_MESH,
)
target_surface_node_count: Optional[pd.PositiveInt] = ContextField(
None,
description="Target number of surface mesh nodes. When specified, the surface mesher "
"will rescale the meshing parameters to achieve approximately this number of nodes. "
"This option is only supported by the beta surface mesher or when using geometry AI, "
"and can not be overridden per face.",
context=SURFACE_MESH,
)
curvature_resolution_angle: AngleType.Positive = ContextField(
12 * u.deg,
description=(
"Default maximum angular deviation in degrees. This value will restrict:"
" 1. The angle between a cell's normal and its underlying surface normal."
" 2. The angle between a line segment's normal and its underlying curve normal."
" This can be overridden per face only when using geometry AI."
),
context=SURFACE_MESH,
)
resolve_face_boundaries: bool = pd.Field(
False,
description="Flag to specify whether boundaries between adjacent faces should be resolved "
+ "accurately during the surface meshing process using anisotropic mesh refinement. "
+ "This option is only supported when using geometry AI, and can be overridden "
+ "per face with :class:`~flow360.SurfaceRefinement`.",
)
preserve_thin_geometry: bool = pd.Field(
False,
description="Flag to specify whether thin geometry features with thickness roughly equal "
+ "to geometry_accuracy should be resolved accurately during the surface meshing process. "
+ "This option is only supported when using geometry AI, and can be overridden "
+ "per face with :class:`~flow360.GeometryRefinement`.",
)
sealing_size: LengthType.NonNegative = pd.Field(
0.0 * u.m,
description="Threshold size below which all geometry gaps are automatically closed. "
+ "This option is only supported when using geometry AI, and can be overridden "
+ "per face with :class:`~flow360.GeometryRefinement`.",
)
remove_hidden_geometry: bool = pd.Field(
False,
description="Flag to remove hidden geometry that is not visible to flow. "
+ "This option is only supported when using geometry AI.",
)
min_passage_size: Optional[LengthType.Positive] = pd.Field(
None,
description="Minimum passage size that hidden geometry removal can resolve. "
+ "Internal regions connected by thin passages smaller than this size may not be detected. "
+ "If not specified, the value is derived from geometry_accuracy and sealing_size. "
+ "This option is only supported when using geometry AI.",
)
edge_split_layers: int = pd.Field(
1,
ge=0,
# Skip default-value validation so warnings are emitted only when users explicitly set this field.
validate_default=False,
description="The number of layers that are considered for edge splitting in the boundary layer mesh."
+ "This only affects beta mesher.",
)
octree_spacing: Optional[OctreeSpacing] = pd.Field(
None,
description="Octree spacing configuration for volume meshing. "
"If specified, this will be used to control the base spacing for octree-based meshers.",
)
@pd.model_validator(mode="before")
@classmethod
def remove_deprecated_arguments(cls, value):
"""
Detect when invoking the constructor of the MeshingDefaults()
(Warning: contrary to deserializing data, which is supposed to be handled by the updater.py)
If the user added the remove_non_manifold_faces in the argument, pop the argument and give warning
that this is no longer supported.
"""
if not isinstance(value, dict):
return value
if "remove_non_manifold_faces" in value:
value.pop("remove_non_manifold_faces", None)
message = (
"`meshing.defaults.remove_non_manifold_faces` is no longer supported and has been "
+ "ignored. Set `meshing.defaults.remove_hidden_geometry` instead."
)
add_validation_warning(message)
return value
@contextual_field_validator("number_of_boundary_layers", mode="after")
@classmethod
def invalid_number_of_boundary_layers(cls, value, param_info: ParamsValidationInfo):
"""Ensure number of boundary layers is not specified"""
if value is not None and not param_info.is_beta_mesher:
raise ValueError("Number of boundary layers is only supported by the beta mesher.")
return value
@contextual_field_validator("edge_split_layers", mode="after")
@classmethod
def invalid_edge_split_layers(cls, value, param_info: ParamsValidationInfo):
"""Ensure edge split layers is only configured for beta mesher."""
if value > 0 and not param_info.is_beta_mesher:
add_validation_warning(
"`edge_split_layers` is only supported by the beta mesher; "
"this setting will be ignored."
)
return value
@contextual_field_validator("geometry_accuracy", mode="after")
@classmethod
def invalid_geometry_accuracy(cls, value, param_info: ParamsValidationInfo):
"""Ensure geometry accuracy is not specified when GAI is not used"""
if value is not None and not param_info.use_geometry_AI:
raise ValueError("Geometry accuracy is only supported when geometry AI is used.")
if value is None and param_info.use_geometry_AI:
raise ValueError("Geometry accuracy is required when geometry AI is used.")
if (
value is not None
and param_info.global_bounding_box is not None
and param_info.project_length_unit is not None
):
relative_bounding_box_limit = 1e-6
bbox_diag = param_info.global_bounding_box.diagonal * param_info.project_length_unit
ga_value = value
lower_limit = relative_bounding_box_limit * bbox_diag
if ga_value < lower_limit:
add_validation_warning(
f"geometry_accuracy ({ga_value}) is below the recommended value "
f"of {relative_bounding_box_limit} * bounding box diagonal ({lower_limit:.2e}). "
f"Please increase geometry_accuracy."
)
return value
@contextual_field_validator(
"surface_max_aspect_ratio",
"surface_max_adaptation_iterations",
"resolve_face_boundaries",
"preserve_thin_geometry",
"sealing_size",
"remove_hidden_geometry",
"min_passage_size",
mode="after",
)
@classmethod
def ensure_geometry_ai_features(cls, value, info, param_info: ParamsValidationInfo):
"""Validate that the feature is only used when Geometry AI is enabled."""
return check_geometry_ai_features(cls, value, info, param_info)
@contextual_field_validator("target_surface_node_count", mode="after")
@classmethod
def ensure_target_surface_node_count_mesher(cls, value, param_info: ParamsValidationInfo):
"""Validate that target_surface_node_count is only used with geometry AI or beta mesher."""
if value is not None and not (param_info.use_geometry_AI or param_info.is_beta_mesher):
raise ValueError("target_surface_node_count is not supported by the legacy mesher.")
return value
@contextual_field_validator("octree_spacing", mode="after")
@classmethod
def _set_default_octree_spacing(cls, octree_spacing, param_info: ParamsValidationInfo):
"""Set default octree_spacing to 1 * project_length_unit when not specified."""
return set_default_octree_spacing(octree_spacing, param_info)
@pd.model_validator(mode="after")
def validate_min_passage_size_requires_remove_hidden_geometry(self):
"""Ensure min_passage_size is only specified when remove_hidden_geometry is True."""
if self.min_passage_size is not None and not self.remove_hidden_geometry:
raise ValueError(
"'min_passage_size' can only be specified when 'remove_hidden_geometry' is True."
)
return self
[docs]
class VolumeMeshingDefaults(Flow360BaseModel):
"""
Default/global settings for volume meshing parameters. To be used with class:`ModularMeshingWorkflow`.
"""
##:: Default boundary layer settings
boundary_layer_growth_rate: float = pd.Field(
1.2,
description="Default growth rate for volume prism layers.",
ge=1,
)
# pylint: disable=no-member
boundary_layer_first_layer_thickness: LengthType.Positive = pd.Field(
description="Default first layer thickness for volumetric anisotropic layers."
" This can be overridden with :class:`~flow360.BoundaryLayer`.",
)
number_of_boundary_layers: Optional[pd.NonNegativeInt] = pd.Field(
None,
description="Default number of volumetric anisotropic layers."
" The volume mesher will automatically calculate the required"
" no. of layers to grow the boundary layer elements to isotropic size if not specified."
" This is only supported by the beta mesher and can not be overridden per face.",
)
octree_spacing: Optional[OctreeSpacing] = pd.Field(
None,
description="Octree spacing configuration for volume meshing. "
"If specified, this will be used to control the base spacing for octree-based meshers.",
)
@contextual_field_validator("octree_spacing", mode="after")
@classmethod
def _set_default_octree_spacing(cls, octree_spacing, param_info: ParamsValidationInfo):
"""Set default octree_spacing to 1 * project_length_unit when not specified."""
return set_default_octree_spacing(octree_spacing, param_info)