Source code for flow360_schema.models.simulation.meshing_param.meshing_specs

"""Default settings for meshing using different meshing algorithms"""

import logging
from math import log2

import numpy as np
import pydantic as pd
import unyt as u

from flow360_schema.framework.base_model import Flow360BaseModel
from flow360_schema.framework.physical_dimensions import Angle, Length
from flow360_schema.models.simulation.framework.updater import (
    DEFAULT_PLANAR_FACE_TOLERANCE,
    DEFAULT_SLIDING_INTERFACE_TOLERANCE,
)
from flow360_schema.models.simulation.validation.validation_context import (
    SURFACE_MESH,
    VOLUME_MESH,
    ConditionalField,
    ContextField,
    ParamsValidationInfo,
    add_validation_warning,
    contextual_field_validator,
)
from flow360_schema.models.simulation.validation.validation_utils import (
    check_geometry_ai_features,
)

logger = logging.getLogger(__name__)


[docs] class OctreeSpacing(Flow360BaseModel): """ Helper class for octree-based meshers. Holds the base for the octree spacing and lows calculation of levels. """ base_spacing: Length.PositiveFloat64 @pd.model_validator(mode="before") @classmethod def _reject_plain_value(cls, input_data): if isinstance(input_data, u.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))
[docs] @pd.validate_call def to_level(self, spacing: Length.PositiveFloat64): """ 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
[docs] @pd.validate_call def check_spacing(self, spacing: Length.PositiveFloat64, 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})." ) logger.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 project_length = 1 * 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 ... ) ==== """ geometry_accuracy: Length.PositiveFloat64 | None = 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, ) boundary_layer_first_layer_thickness: Length.PositiveFloat64 | None = 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: pd.NonNegativeInt | None = 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.", ) 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: Length.PositiveFloat64 | None = 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: pd.PositiveInt | None = 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: Angle.PositiveFloat64 = 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: Length.NonNegativeFloat64 = 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: Length.PositiveFloat64 | None = 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.", ) remove_baffle_faces: bool = pd.Field( True, description="Flag to remove baffle faces (faces with both sides facing exterior) detected " + "during hidden geometry removal. When False, baffles are thickened into a closed shell. " + "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: OctreeSpacing | None = 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", "remove_baffle_faces", 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, ) boundary_layer_first_layer_thickness: Length.PositiveFloat64 = pd.Field( description="Default first layer thickness for volumetric anisotropic layers." " This can be overridden with :class:`~flow360.BoundaryLayer`.", ) number_of_boundary_layers: pd.NonNegativeInt | None = 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: OctreeSpacing | None = 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)