diff --git a/flow360/component/simulation/meshing_param/face_params.py b/flow360/component/simulation/meshing_param/face_params.py index 6f7c77720..3bac23e4b 100644 --- a/flow360/component/simulation/meshing_param/face_params.py +++ b/flow360/component/simulation/meshing_param/face_params.py @@ -23,6 +23,7 @@ check_deleted_surface_in_entity_list, check_geometry_ai_features, check_ghost_surface_usage_policy_for_face_refinements, + remap_symmetric_ghost_entity, ) @@ -65,6 +66,12 @@ class SurfaceRefinement(Flow360BaseModel): + "accurately during the surface meshing process using anisotropic mesh refinement.", ) + @contextual_field_validator("entities", mode="after") + @classmethod + def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo): + """Remap 'symmetric' ghost entity to user's symmetry surface name for UDF backward compat.""" + return remap_symmetric_ghost_entity(value, param_info) + @contextual_field_validator("entities", mode="after") @classmethod def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): @@ -193,6 +200,12 @@ class PassiveSpacing(Flow360BaseModel): Surface, MirroredSurface, WindTunnelGhostSurface, GhostSurface, GhostCircularPlane ] = pd.Field(alias="faces") + @contextual_field_validator("entities", mode="after") + @classmethod + def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo): + """Remap 'symmetric' ghost entity to user's symmetry surface name for UDF backward compat.""" + return remap_symmetric_ghost_entity(value, param_info) + @contextual_field_validator("entities", mode="after") @classmethod def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 2abf82009..396a9dd2d 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -796,6 +796,9 @@ def symmetry_plane(self) -> GhostSurface: Returns the symmetry plane boundary surface. Warning: This should only be used when using GAI and beta mesher. + + Note: If your geometry has an explicit symmetry plane, you can reference it + directly as geometry["your_face_name"] instead of using this property. """ if self.domain_type not in ( None, diff --git a/flow360/component/simulation/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index 7e1dc17c2..095319cf2 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -1,7 +1,9 @@ """ -Contains basically only boundary conditons for now. In future we can add new models like 2D equations. +Contains basically only boundary conditions for now. In future we can add new models like 2D equations. """ +# pylint: disable=too-many-lines + from abc import ABCMeta from typing import Annotated, Dict, Literal, Optional, Union @@ -48,6 +50,7 @@ ) from flow360.component.simulation.validation.validation_utils import ( check_deleted_surface_pair, + remap_symmetric_ghost_entity, validate_entity_list_surface_existence, ) @@ -67,6 +70,12 @@ class BoundaryBase(Flow360BaseModel, metaclass=ABCMeta): ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) + @contextual_field_validator("entities", mode="after") + @classmethod + def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo): + """Remap 'symmetric' ghost entity to user's symmetry surface name for UDF backward compat.""" + return remap_symmetric_ghost_entity(value, param_info) + @contextual_field_validator("entities", mode="after") @classmethod def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index ab5dbacf0..0d0732ea9 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -740,7 +740,8 @@ class Surface(_SurfaceEntityBase): # Note: private_attribute_id should not be `Optional` anymore. # B.C. Updater and geometry pipeline will populate it. - def _overlaps(self, ghost_surface_center_y: Optional[float], length_tolerance: float) -> bool: + def _lies_on(self, ghost_surface_center_y: Optional[float], length_tolerance: float) -> bool: + # Check if the surface lies entirely within tolerance of the center y if self.private_attributes is None: # Legacy cloud asset. return False @@ -764,7 +765,6 @@ def _will_be_deleted_by_mesher( half_model_symmetry_plane_center_y: Optional[float], quasi_3d_symmetry_planes_center_y: Optional[tuple[float]], farfield_domain_type: Optional[str] = None, - gai_and_beta_mesher: Optional[bool] = False, ) -> bool: """ Check against the automated farfield method and @@ -782,6 +782,7 @@ def _will_be_deleted_by_mesher( length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance if farfield_domain_type in ("half_body_positive_y", "half_body_negative_y"): + # Wrong half if self.private_attributes is not None: # pylint: disable=no-member y_min = self.private_attributes.bounding_box.ymin @@ -793,35 +794,30 @@ def _will_be_deleted_by_mesher( if farfield_domain_type == "half_body_negative_y" and y_min > length_tolerance: return True - if farfield_method == "wind-tunnel": - # Not applicable to wind tunnel farfield + if farfield_method in ("user-defined", "wind-tunnel"): + # User-defined: user surfaces are not deleted + # Wind-tunnel: not applicable return False - if farfield_method in ("auto", "user-defined"): + if farfield_method == "auto": if half_model_symmetry_plane_center_y is None: # Legacy schema. return False - if farfield_method == "user-defined" and not gai_and_beta_mesher: - return False - if ( - farfield_method == "auto" - and farfield_domain_type not in ("half_body_positive_y", "half_body_negative_y") - and ( - not _auto_symmetric_plane_exists_from_bbox( - global_bounding_box=global_bounding_box, - planar_face_tolerance=planar_face_tolerance, - ) + if farfield_domain_type not in ("half_body_positive_y", "half_body_negative_y") and ( + not _auto_symmetric_plane_exists_from_bbox( + global_bounding_box=global_bounding_box, + planar_face_tolerance=planar_face_tolerance, ) ): return False - return self._overlaps(half_model_symmetry_plane_center_y, length_tolerance) + return self._lies_on(half_model_symmetry_plane_center_y, length_tolerance) if farfield_method in ("quasi-3d", "quasi-3d-periodic"): if quasi_3d_symmetry_planes_center_y is None: # Legacy schema. return False for plane_center_y in quasi_3d_symmetry_planes_center_y: - if self._overlaps(plane_center_y, length_tolerance): + if self._lies_on(plane_center_y, length_tolerance): return True return False @@ -936,7 +932,7 @@ def exists(self, validation_info) -> bool: """For automated farfield, check mesher logic for symmetric plane existence.""" if self.name != "symmetric": - # Quasi-3D mode, no need to check existence. + # Quasi-3D mode or user-named symmetry patch (exists by definition) return True if validation_info is None: diff --git a/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py b/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py index e36da69e9..ff93f8e09 100644 --- a/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py +++ b/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py @@ -134,7 +134,6 @@ def ensure_output_surface_existence(cls, value, param_info: ParamsValidationInfo half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, ): raise ValueError( f"Boundary `{value.name}` will likely be deleted after mesh generation. Therefore it cannot be used." diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 138afdd6c..da741fcc6 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -58,7 +58,10 @@ add_validation_warning, get_validation_levels, ) -from flow360.component.simulation.validation.validation_utils import EntityUsageMap +from flow360.component.simulation.validation.validation_utils import ( + EntityUsageMap, + find_user_symmetry_surfaces, +) def _populate_validated_field_to_validation_context(v, param_info, attribute_name): @@ -423,7 +426,6 @@ def _collect_asset_boundary_entities(params, param_info: ParamsValidationInfo) - half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, ) is False ] @@ -447,12 +449,19 @@ def _collect_asset_boundary_entities(params, param_info: ParamsValidationInfo) - ] elif farfield_method == "user-defined": if param_info.use_geometry_AI and param_info.is_beta_mesher: - asset_boundary_entities += [ - item - for item in ghost_entities - if item.name == "symmetric" - and (param_info.entity_transformation_detected or item.exists(param_info)) - ] + # Skip adding "symmetric" ghost if user geometry has y=0 surfaces + user_sym_surfaces = find_user_symmetry_surfaces( + asset_boundary_entities, + param_info.global_bounding_box, + param_info.planar_face_tolerance, + ) + if len(user_sym_surfaces) == 0: + asset_boundary_entities += [ + item + for item in ghost_entities + if item.name == "symmetric" + and (param_info.entity_transformation_detected or item.exists(param_info)) + ] elif farfield_method == "wind-tunnel": if param_info.will_generate_forced_symmetry_plane(): asset_boundary_entities += [item for item in ghost_entities if item.name == "symmetric"] @@ -601,6 +610,28 @@ def _check_complete_boundary_condition_and_unknown_surface( ) used_boundaries = _collect_used_boundary_names(params, param_info) + # Warn if multiple y=0 surfaces have different BC types + if param_info.farfield_method == "user-defined": + sym_surfaces = find_user_symmetry_surfaces( + asset_boundary_entities, + param_info.global_bounding_box, + param_info.planar_face_tolerance, + ) + if len(sym_surfaces) > 1: + sym_names = {s.name for s in sym_surfaces} + bc_types = { + type(m).__name__ + for m in params.models + if isinstance(m, get_args(SurfaceModelTypes)) + and hasattr(m, "entities") + and any(e.name in sym_names for e in param_info.expand_entity_list(m.entities)) + } + if len(bc_types) > 1: + add_validation_warning( + f"Multiple symmetry plane surfaces have different boundary conditions " + f"({', '.join(sorted(bc_types))}). Please check if this is intended." + ) + # Step 4: Validate set differences with policy _validate_boundary_completeness( asset_boundaries=asset_boundaries, diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index 0caf69f3b..dcd57e73a 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -11,12 +11,16 @@ from flow360.component.simulation.entity_info import DraftEntityTypes from flow360.component.simulation.outputs.output_fields import CommonFieldNames from flow360.component.simulation.primitives import ( + GhostCircularPlane, + GhostSurface, ImportedSurface, Surface, _SurfaceEntityBase, _VolumeEntityBase, ) from flow360.component.simulation.user_code.core.types import Expression, UserVariable +from flow360.component.simulation.utils import model_attribute_unlock +from flow360.log import log def _validator_append_instance_name(func): @@ -133,7 +137,6 @@ def check_deleted_surface_in_entity_list(expanded_entities: list, param_info) -> half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, ): deleted_boundaries.append(surface.name) @@ -195,7 +198,6 @@ def check_deleted_surface_pair(value, param_info): half_model_symmetry_plane_center_y=param_info.half_model_symmetry_plane_center_y, quasi_3d_symmetry_planes_center_y=param_info.quasi_3d_symmetry_planes_center_y, farfield_domain_type=param_info.farfield_domain_type, - gai_and_beta_mesher=param_info.use_geometry_AI and param_info.is_beta_mesher, ): deleted_boundaries.append(surface.name) @@ -274,6 +276,93 @@ def check_symmetric_boundary_existence(stored_entities, param_info): return stored_entities +def find_user_symmetry_surfaces(boundaries, global_bounding_box, planar_face_tolerance): + """Return Surface entities that lie on the y=0 symmetry plane (bounding box within tolerance).""" + if not global_bounding_box: + return [] + tol = global_bounding_box.largest_dimension * (planar_face_tolerance or 1e-6) + return [ + b + for b in boundaries + if isinstance(b, Surface) and b._lies_on(0, tol) # pylint: disable=protected-access + ] + + +def remap_symmetric_ghost_entity(value, param_info): # pylint: disable=too-many-return-statements + """For UDF with GAI, replace any 'symmetric' ghost entity with the actual user Surface. + Call in validation on any model that can hold farfield.symmetry_plane (BCs and refinements). + + Downstream validators see the correct entity, allowing users to directly reference + their symmetry surface, but we discourage using the legacy 'farfield.symmetry_plane'. + """ + + if value is None or param_info.farfield_method != "user-defined": + return value + if not param_info.use_geometry_AI or not param_info.is_beta_mesher: + return value + if not hasattr(value, "stored_entities") or not value.stored_entities: + return value + + ghost_idx = next( + ( + i + for i, e in enumerate(value.stored_entities) + if isinstance(e, (GhostSurface, GhostCircularPlane)) and e.name == "symmetric" + ), + None, + ) + if ghost_idx is None: + return value + + entity_info = param_info.get_entity_info() + if entity_info is None: + return value + + try: # if entity info is incomplete, skip remap + boundaries = entity_info.get_boundaries() + except (ValueError, KeyError, AttributeError): + return value + + sym_surfaces = find_user_symmetry_surfaces( + boundaries, + param_info.global_bounding_box, + param_info.planar_face_tolerance, + ) + + if len(sym_surfaces) == 1: + user_surface = sym_surfaces[0] + # Replace the ghost entity with the real Surface in the BC/refinement entity list + value.stored_entities[ghost_idx] = user_surface + # Also rename the ghost entity in entity_info so asset boundary collection matches + asset_ghost = next( + ( + g + for g in entity_info.ghost_entities + if isinstance(g, (GhostSurface, GhostCircularPlane)) and g.name == "symmetric" + ), + None, + ) + if asset_ghost is not None: + with model_attribute_unlock(asset_ghost, "name"): + asset_ghost.name = user_surface.name + with model_attribute_unlock(asset_ghost, "private_attribute_id"): + asset_ghost.private_attribute_id = user_surface.private_attribute_id + log.warning( + "Your geometry has a symmetry surface '%s'. " + "Remapping farfield.symmetry_plane to use this name. " + "Consider using geometry['%s'] directly.", + user_surface.name, + user_surface.name, + ) + elif len(sym_surfaces) > 1: + raise ValueError( + "farfield.symmetry_plane cannot be used with multiple symmetry surfaces. " + "Use geometry['name'] to reference individual symmetry surfaces directly." + ) + + return value + + def _ghost_surface_names(stored_entities) -> list[str]: """Collect names of ghost-type boundaries in the list.""" names = [] diff --git a/tests/simulation/params/data/surface_mesh/simulation.json b/tests/simulation/params/data/surface_mesh/simulation.json index 3f8111a62..63d373d36 100644 --- a/tests/simulation/params/data/surface_mesh/simulation.json +++ b/tests/simulation/params/data/surface_mesh/simulation.json @@ -634,12 +634,12 @@ "bounding_box": [ [ 0, - 1159.931377585337, + 0, 0 ], [ 1, - 1159.931377585337, + 0, 1 ] ], @@ -665,7 +665,7 @@ { "center": [ 1359.480337868488, - 1159.931377585337, + 0, 372.74731940854895 ], "max_radius": 2533.9434744230307, diff --git a/tests/simulation/params/test_automated_farfield.py b/tests/simulation/params/test_farfield.py similarity index 99% rename from tests/simulation/params/test_automated_farfield.py rename to tests/simulation/params/test_farfield.py index 665c2b827..97483c35d 100644 --- a/tests/simulation/params/test_automated_farfield.py +++ b/tests/simulation/params/test_farfield.py @@ -336,10 +336,9 @@ def test_user_defined_farfield_auto_symmetry_plane(surface_mesh): volume_zones=[farfield], ), models=[ + # unlike test_user_defined_farfield_symmetry_plane, reference geometry directly Wall(surfaces=[s for s in surface_mesh["*"] if s.name != "preexistingSymmetry"]), - SymmetryPlane( - surfaces=farfield.symmetry_plane, - ), + SymmetryPlane(surfaces=surface_mesh["preexistingSymmetry"]), ], ) errors, warnings = _run_validation( diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 690447a22..425c0269d 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -29,6 +29,7 @@ from flow360.component.simulation.meshing_param.face_params import ( BoundaryLayer, GeometryRefinement, + PassiveSpacing, ) from flow360.component.simulation.meshing_param.meshing_specs import ( MeshingDefaults, @@ -68,6 +69,7 @@ Pressure, SlaterPorousBleed, SlipWall, + SymmetryPlane, TotalPressure, Translational, Wall, @@ -3379,6 +3381,407 @@ def test_deleted_surfaces_domain_type(): assert "Boundary `pos_surf` will likely be deleted" in errors[0]["msg"] +def test_udf_symmetry_face_not_deleted(): + """UDF (with GAI): surface at y=0 is not deleted.""" + surface_sym = Surface( + name="mySymmetry", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 0, 0], [1, 0, 1]]), + ) + surface_body = Surface( + name="body", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 0.1, 0], [1, 1, 1]]), + ) + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[0, 0, 0], [1, 1, 1]], + boundaries=[surface_sym, surface_body], + ), + ) + farfield = UserDefinedFarfield(domain_type="half_body_positive_y") + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + planar_face_tolerance=1e-4, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ), + volume_zones=[farfield], + ), + models=[ + Wall(entities=[surface_body]), + SymmetryPlane(entities=[surface_sym]), + ], + private_attribute_asset_cache=asset_cache, + ) + _, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert errors is None, f"Expected no errors but got: {errors}" + + +def test_udf_wrong_half_deleted(): + """UDF: surface on wrong half is still deleted.""" + surface_neg = Surface( + name="neg_surf", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, -2, 0], [1, -1, 1]]), + ) + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[0, -2, 0], [1, 2, 1]], + boundaries=[surface_neg], + ), + ) + farfield = UserDefinedFarfield(domain_type="half_body_positive_y") + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + planar_face_tolerance=1e-4, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ), + volume_zones=[farfield], + ), + models=[Wall(entities=[surface_neg])], + private_attribute_asset_cache=asset_cache, + ) + _, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert len(errors) == 1 + assert "Boundary `neg_surf` will likely be deleted" in errors[0]["msg"] + + +def test_udf_symmetry_plane_remap(): + """UDF + farfield.symmetry_plane + explicit face: remaps in both BCs and refinements.""" + surface_sym = Surface( + name="mySymmetry", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 0, 0], [1, 0, 1]]), + ) + surface_body = Surface( + name="body", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 0.1, 0], [1, 1, 1]]), + ) + # Ghost entity in entity_info (created by preprocessing, GhostCircularPlane type) + ghost_in_entity_info = GhostCircularPlane( + name="symmetric", + center=[0.5, 0, 0.5], + max_radius=50, + normal_axis=[0, 1, 0], + private_attribute_id="symmetric", + ) + # Ghost entity as returned by farfield.symmetry_plane (GhostSurface type) + ghost_from_farfield = GhostSurface(name="symmetric", private_attribute_id="symmetric") + + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[0, 0, 0], [1, 1, 1]], + boundaries=[surface_sym, surface_body], + ghost_entities=[ghost_in_entity_info], + ), + ) + farfield = UserDefinedFarfield(domain_type="half_body_positive_y") + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + planar_face_tolerance=1e-4, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ), + volume_zones=[farfield], + refinements=[ + PassiveSpacing(entities=ghost_from_farfield, type="projected"), + ], + ), + models=[ + Wall(entities=[surface_body]), + SymmetryPlane(entities=ghost_from_farfield), + ], + private_attribute_asset_cache=asset_cache, + ) + validated, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert errors is None, f"Expected no errors but got: {errors}" + # BC entity replaced with real Surface (not GhostSurface) + sym_bc = next(m for m in validated.models if isinstance(m, SymmetryPlane)) + sym_entity = next(e for e in sym_bc.entities.stored_entities if e.name == "mySymmetry") + assert isinstance(sym_entity, Surface) + # PassiveSpacing entity also replaced + ps = validated.meshing.refinements[0] + ps_entity = next(e for e in ps.entities.stored_entities if e.name == "mySymmetry") + assert isinstance(ps_entity, Surface) + # Ghost entity in entity_info remapped + ghost_names = [ + g.name for g in validated.private_attribute_asset_cache.project_entity_info.ghost_entities + ] + assert "symmetric" not in ghost_names + assert "mySymmetry" in ghost_names + + +def test_udf_symmetry_plane_no_remap_without_explicit_face(): + """UDF + farfield.symmetry_plane + no y=0 face: stays 'symmetric'.""" + ghost_sym = GhostCircularPlane( + name="symmetric", + center=[0.5, 0, 0.5], + max_radius=50, + normal_axis=[0, 1, 0], + private_attribute_id="symmetric", + ) + # All surfaces are in positive y + surface_body = Surface( + name="body", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 0.1, 0], [1, 1, 1]]), + ) + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[0, 0, 0], [1, 1, 1]], + boundaries=[surface_body], + ghost_entities=[ghost_sym], + ), + ) + farfield = UserDefinedFarfield(domain_type="half_body_positive_y") + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + planar_face_tolerance=1e-4, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ), + volume_zones=[farfield], + ), + models=[ + Wall(entities=[surface_body]), + SymmetryPlane( + entities=GhostSurface(name="symmetric", private_attribute_id="symmetric") + ), + ], + private_attribute_asset_cache=asset_cache, + ) + validated, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert errors is None, f"Expected no errors but got: {errors}" + sym_bc = next(m for m in validated.models if isinstance(m, SymmetryPlane)) + assert any(e.name == "symmetric" for e in sym_bc.entities.stored_entities) + + +def test_auto_farfield_no_remap(): + """Auto farfield: no remap even with explicit y=0 face.""" + surface_sym = Surface( + name="mySymmetry", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 0, 0], [1, 0, 1]]), + ) + ghost_farfield = GhostSphere( + name="farfield", + center=[0, 0, 0], + max_radius=50, + private_attribute_id="farfield", + ) + ghost_sym = GhostCircularPlane( + name="symmetric", + center=[0.5, 0, 0.5], + max_radius=50, + normal_axis=[0, 1, 0], + private_attribute_id="symmetric", + ) + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[0, 0, 0], [1, 1, 1]], + boundaries=[surface_sym], + ghost_entities=[ghost_farfield, ghost_sym], + ), + ) + farfield = AutomatedFarfield() + with SI_unit_system: + params = SimulationParams( + operating_condition=AerospaceCondition(velocity_magnitude=1), + meshing=MeshingParams( + defaults=MeshingDefaults( + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ), + volume_zones=[farfield], + ), + models=[ + Freestream(entities=[farfield.farfield]), + SymmetryPlane(entities=[farfield.symmetry_plane]), + ], + private_attribute_asset_cache=asset_cache, + ) + validated, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert errors is None, f"Expected no errors but got: {errors}" + sym_bc = next(m for m in validated.models if isinstance(m, SymmetryPlane)) + assert any(e.name == "symmetric" for e in sym_bc.entities.stored_entities) + + +def test_udf_multi_patch_symmetry(): + """UDF + multiple y=0 faces: direct refs pass, farfield.symmetry_plane errors.""" + surface_sym_a = Surface( + name="sym_a", + private_attributes=SurfacePrivateAttributes(bounding_box=[[-1, 0, 0], [0, 0, 1]]), + ) + surface_sym_b = Surface( + name="sym_b", + private_attributes=SurfacePrivateAttributes(bounding_box=[[1, 0, 0], [2, 0, 1]]), + ) + ghost_sym = GhostCircularPlane( + name="symmetric", + center=[0.5, 0, 0.5], + max_radius=50, + normal_axis=[0, 1, 0], + private_attribute_id="symmetric", + ) + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[-1, 0, 0], [2, 1, 1]], + boundaries=[surface_sym_a, surface_sym_b], + ghost_entities=[ghost_sym], + ), + ) + farfield = UserDefinedFarfield(domain_type="half_body_positive_y") + + # Direct geometry refs should pass + with SI_unit_system: + defaults = MeshingDefaults( + planar_face_tolerance=1e-4, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ) + params = SimulationParams( + meshing=MeshingParams(defaults=defaults, volume_zones=[farfield]), + models=[SymmetryPlane(entities=[surface_sym_a, surface_sym_b])], + private_attribute_asset_cache=asset_cache, + ) + _, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert errors is None, f"Expected no errors for direct refs but got: {errors}" + + # farfield.symmetry_plane (ghost) should error + with SI_unit_system: + defaults = MeshingDefaults( + planar_face_tolerance=1e-4, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ) + params = SimulationParams( + meshing=MeshingParams(defaults=defaults, volume_zones=[farfield]), + models=[ + Wall(entities=[surface_sym_a, surface_sym_b]), + SymmetryPlane( + entities=GhostSurface(name="symmetric", private_attribute_id="symmetric") + ), + ], + private_attribute_asset_cache=asset_cache, + ) + _, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert errors is not None, "Expected error for farfield.symmetry_plane with multiple y=0 faces" + assert "farfield.symmetry_plane cannot be used with multiple symmetry surfaces" in str(errors) + + +def test_udf_multi_patch_warn_conflicting_bc(): + """UDF + multiple y=0 faces with different BC types: should warn.""" + surface_sym_a = Surface( + name="sym_a", + private_attributes=SurfacePrivateAttributes(bounding_box=[[-1, 0, 0], [0, 0, 1]]), + ) + surface_sym_b = Surface( + name="sym_b", + private_attributes=SurfacePrivateAttributes(bounding_box=[[1, 0, 0], [2, 0, 1]]), + ) + ghost_sym = GhostCircularPlane( + name="symmetric", + center=[0.5, 0, 0.5], + max_radius=50, + normal_axis=[0, 1, 0], + private_attribute_id="symmetric", + ) + asset_cache = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[-1, 0, 0], [2, 1, 1]], + boundaries=[surface_sym_a, surface_sym_b], + ghost_entities=[ghost_sym], + ), + ) + farfield = UserDefinedFarfield(domain_type="half_body_positive_y") + + with SI_unit_system: + defaults = MeshingDefaults( + planar_face_tolerance=1e-4, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ) + params = SimulationParams( + meshing=MeshingParams(defaults=defaults, volume_zones=[farfield]), + models=[ + SymmetryPlane(entities=[surface_sym_a]), + SlipWall(entities=[surface_sym_b]), + ], + private_attribute_asset_cache=asset_cache, + ) + _, errors, val_warnings = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + assert errors is None + assert any( + "Multiple symmetry plane surfaces have different boundary conditions" in w.get("msg", "") + for w in val_warnings + ) + + def test_unique_selector_names(): """Test that duplicate selector names are detected and raise an error.""" from flow360.component.simulation.framework.entity_selector import (