From 4b0a624eafbb53a28aa14ed5354033c6a306ddfd Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 31 Mar 2026 17:33:18 +0000 Subject: [PATCH 01/13] update _will_be_deleted_by_mesher to not block udf symmetry plane --- .../simulation/meshing_param/volume_params.py | 3 ++ flow360/component/simulation/primitives.py | 32 ++++++++----------- .../user_defined_dynamics.py | 1 - .../validation_simulation_params.py | 1 - .../simulation/validation/validation_utils.py | 2 -- 5 files changed, 17 insertions(+), 22 deletions(-) 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/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..4d75aa94d 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -423,7 +423,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 ] diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index 0caf69f3b..e50dd943f 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -133,7 +133,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 +194,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) From 600ac98283798cf9c91a63f719988e58e9bb5b35 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 1 Apr 2026 21:51:39 +0000 Subject: [PATCH 02/13] fix test json with symmetry y != 0 --- tests/simulation/params/data/surface_mesh/simulation.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/simulation/params/data/surface_mesh/simulation.json b/tests/simulation/params/data/surface_mesh/simulation.json index 3f8111a62..2e027e6df 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, @@ -740,4 +740,4 @@ }, "user_defined_fields": [], "version": "25.6.6" -} +} \ No newline at end of file From 96708396a75597ef1f49ec768b1fe10f81026ab3 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 15:38:16 +0000 Subject: [PATCH 03/13] validation time remap_symmetric_to_user_name, adjust _collect_asset_boundary_entities; +6 unit tests --- .../simulation/models/surface_models.py | 70 ++++ .../validation_simulation_params.py | 24 +- .../simulation/validation/validation_utils.py | 10 + .../params/data/surface_mesh/simulation.json | 2 +- ...automated_farfield.py => test_farfield.py} | 5 +- .../params/test_validators_params.py | 318 ++++++++++++++++++ 6 files changed, 418 insertions(+), 11 deletions(-) rename tests/simulation/params/{test_automated_farfield.py => test_farfield.py} (99%) diff --git a/flow360/component/simulation/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index 7e1dc17c2..7447153d1 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -42,12 +42,14 @@ MassFlowRateType, PressureType, ) +from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, contextual_field_validator, ) from flow360.component.simulation.validation.validation_utils import ( check_deleted_surface_pair, + find_user_symmetry_surfaces, validate_entity_list_surface_existence, ) @@ -772,6 +774,74 @@ class SymmetryPlane(BoundaryBase): description="List of boundaries with the `SymmetryPlane` boundary condition imposed.", ) + @contextual_field_validator("entities", mode="after") + @classmethod + def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo): + """For UDF with GAI, remap 'symmetric' ghost entity to ensure solver JSON compatibility. + When user geometry has an explicit symmetry face, it can be referenced directly. + We discourage but maintain backwards compatibility for 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 + # Symmetry plane entity on the BC model (flows to solver JSON) + bc_entity = next( + ( + e + for e in value.stored_entities + if isinstance(e, (GhostSurface, GhostCircularPlane)) and e.name == "symmetric" + ), + None, + ) + if bc_entity is None: + return value + + entity_info = param_info.get_entity_info() + if entity_info is None: + return value + # Symmetry plane entity in entity_info (used by asset boundary collection) + ghost_asset = next( + ( + g + for g in entity_info.ghost_entities + if isinstance(g, (GhostSurface, GhostCircularPlane)) and g.name == "symmetric" + ), + None, + ) + + sym_surfaces = find_user_symmetry_surfaces( + entity_info.get_boundaries(), + param_info.global_bounding_box, + param_info.planar_face_tolerance, + ) + + if len(sym_surfaces) == 1: + user_name = sym_surfaces[0].name + # Remap both so solver JSON and asset boundary collection agree on the name + for entity in (bc_entity, ghost_asset): + if entity is None: + continue + with model_attribute_unlock(entity, "name"): + entity.name = user_name + with model_attribute_unlock(entity, "private_attribute_id"): + entity.private_attribute_id = user_name + log.warning( + f"Your geometry has a symmetry surface '{user_name}'. " + f"Remapping farfield.symmetry_plane to use this name. " + f"Consider using geometry['{user_name}'] directly." + ) + elif len(sym_surfaces) > 1: + log.warning( + "Multiple symmetry surfaces with different names detected. " + "Symmetry surface name preservation is not yet supported for this case." + ) + + return value + class Periodic(Flow360BaseModel): """ diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 4d75aa94d..b5e4d5c7a 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): @@ -446,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 exactly one symmetry plane surface + 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) != 1: + 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"] diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index e50dd943f..e1315a86f 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -272,6 +272,16 @@ 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 _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 2e027e6df..63d373d36 100644 --- a/tests/simulation/params/data/surface_mesh/simulation.json +++ b/tests/simulation/params/data/surface_mesh/simulation.json @@ -740,4 +740,4 @@ }, "user_defined_fields": [], "version": "25.6.6" -} \ No newline at end of file +} 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..67f41e2c8 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -68,6 +68,7 @@ Pressure, SlaterPorousBleed, SlipWall, + SymmetryPlane, TotalPressure, Translational, Wall, @@ -3379,6 +3380,323 @@ 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 to user's name.""" + 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_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, 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}" + # Check that the entity was remapped on the BC model + sym_bc = next(m for m in validated.models if isinstance(m, SymmetryPlane)) + assert any(e.name == "mySymmetry" for e in sym_bc.entities.stored_entities) + # Check that the ghost entity in entity_info was also 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_symmetry_plane_no_remap_multiple_faces(): + """UDF + multiple y=0 faces: no remap, stays 'symmetric'.""" + 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: + 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_sym_a, surface_sym_b]), + 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_unique_selector_names(): """Test that duplicate selector names are detected and raise an error.""" from flow360.component.simulation.framework.entity_selector import ( From e5a951533b06b50e76148ee7e9188bab76b6638a Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 15:43:40 +0000 Subject: [PATCH 04/13] format --- flow360/component/simulation/models/surface_models.py | 3 ++- flow360/component/simulation/validation/validation_utils.py | 6 ++++-- tests/simulation/params/test_validators_params.py | 3 +-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/flow360/component/simulation/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index 7447153d1..514f8bf81 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -1,6 +1,7 @@ """ -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 diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index e1315a86f..f1e1d185f 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -278,8 +278,10 @@ def find_user_symmetry_surfaces(boundaries, global_bounding_box, planar_face_tol 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 + b + for b in boundaries + if isinstance(b, Surface) and b._lies_on(0, tol) # pylint: disable=protected-access + ] def _ghost_surface_names(stored_entities) -> list[str]: diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 67f41e2c8..f368ce938 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -3522,8 +3522,7 @@ def test_udf_symmetry_plane_remap(): assert any(e.name == "mySymmetry" for e in sym_bc.entities.stored_entities) # Check that the ghost entity in entity_info was also remapped ghost_names = [ - g.name - for g in validated.private_attribute_asset_cache.project_entity_info.ghost_entities + 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 From c899a354261883a849278edb78984d31c8b7c759 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 15:45:36 +0000 Subject: [PATCH 05/13] format --- flow360/component/simulation/models/surface_models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flow360/component/simulation/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index 514f8bf81..cd64b24f4 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -1,6 +1,7 @@ """ 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 7929bbd6adf26c02d3dc9a10857a0f70e3292798 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 20:07:58 +0000 Subject: [PATCH 06/13] fix: remap on boundarybase, not only symmetryplane --- .../simulation/models/surface_models.py | 137 +++++++++--------- 1 file changed, 69 insertions(+), 68 deletions(-) diff --git a/flow360/component/simulation/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index cd64b24f4..c60aa7b5d 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -79,6 +79,75 @@ def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): # TODO: This should have been moved to EntityListAllowingGhost? return validate_entity_list_surface_existence(value, param_info) + @contextual_field_validator("entities", mode="after") + @classmethod + def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo): + """For UDF with GAI, remap 'symmetric' ghost entity to ensure solver JSON compatibility. + When user geometry has an explicit symmetry face, it can be referenced directly. + We discourage but maintain backwards compatibility for 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 "symmetric" entity on the BC model (flows to solver JSON) + bc_entity = next( + ( + e + for e in value.stored_entities + if isinstance(e, (GhostSurface, GhostCircularPlane)) and e.name == "symmetric" + ), + None, + ) + if bc_entity is None: + return value + + entity_info = param_info.get_entity_info() + if entity_info is None: + return value + + # Ghost "symmetric" entity in entity_info (used by asset boundary collection, separate instance) + asset_ghost = next( + ( + g + for g in entity_info.ghost_entities + if isinstance(g, (GhostSurface, GhostCircularPlane)) and g.name == "symmetric" + ), + None, + ) + + sym_surfaces = find_user_symmetry_surfaces( + entity_info.get_boundaries(), + param_info.global_bounding_box, + param_info.planar_face_tolerance, + ) + + if len(sym_surfaces) == 1: + user_name = sym_surfaces[0].name + # Remap both so solver JSON and asset boundary collection agree on the name + for entity in (bc_entity, asset_ghost): + if entity is None: + continue + with model_attribute_unlock(entity, "name"): + entity.name = user_name + with model_attribute_unlock(entity, "private_attribute_id"): + entity.private_attribute_id = user_name + log.warning( + f"Your geometry has a symmetry surface '{user_name}'. " + f"Remapping farfield.symmetry_plane to use this name. " + f"Consider using geometry['{user_name}'] directly." + ) + elif len(sym_surfaces) > 1: + log.warning( + "Multiple symmetry surfaces with different names detected. " + "Symmetry surface name preservation is not yet supported for this case." + ) + + return value + class BoundaryBaseWithTurbulenceQuantities(BoundaryBase, metaclass=ABCMeta): """Boundary base with turbulence quantities""" @@ -776,74 +845,6 @@ class SymmetryPlane(BoundaryBase): description="List of boundaries with the `SymmetryPlane` boundary condition imposed.", ) - @contextual_field_validator("entities", mode="after") - @classmethod - def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo): - """For UDF with GAI, remap 'symmetric' ghost entity to ensure solver JSON compatibility. - When user geometry has an explicit symmetry face, it can be referenced directly. - We discourage but maintain backwards compatibility for 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 - # Symmetry plane entity on the BC model (flows to solver JSON) - bc_entity = next( - ( - e - for e in value.stored_entities - if isinstance(e, (GhostSurface, GhostCircularPlane)) and e.name == "symmetric" - ), - None, - ) - if bc_entity is None: - return value - - entity_info = param_info.get_entity_info() - if entity_info is None: - return value - # Symmetry plane entity in entity_info (used by asset boundary collection) - ghost_asset = next( - ( - g - for g in entity_info.ghost_entities - if isinstance(g, (GhostSurface, GhostCircularPlane)) and g.name == "symmetric" - ), - None, - ) - - sym_surfaces = find_user_symmetry_surfaces( - entity_info.get_boundaries(), - param_info.global_bounding_box, - param_info.planar_face_tolerance, - ) - - if len(sym_surfaces) == 1: - user_name = sym_surfaces[0].name - # Remap both so solver JSON and asset boundary collection agree on the name - for entity in (bc_entity, ghost_asset): - if entity is None: - continue - with model_attribute_unlock(entity, "name"): - entity.name = user_name - with model_attribute_unlock(entity, "private_attribute_id"): - entity.private_attribute_id = user_name - log.warning( - f"Your geometry has a symmetry surface '{user_name}'. " - f"Remapping farfield.symmetry_plane to use this name. " - f"Consider using geometry['{user_name}'] directly." - ) - elif len(sym_surfaces) > 1: - log.warning( - "Multiple symmetry surfaces with different names detected. " - "Symmetry surface name preservation is not yet supported for this case." - ) - - return value - class Periodic(Flow360BaseModel): """ From 5b61969a9b10bedc5cbdbe53f860d48151a98e62 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 21:16:16 +0000 Subject: [PATCH 07/13] replace symmetric with real surface; factor out remap to call on BC and refinements --- .../simulation/meshing_param/face_params.py | 7 ++ .../simulation/models/surface_models.py | 78 ++----------------- .../simulation/validation/validation_utils.py | 74 ++++++++++++++++++ .../params/test_validators_params.py | 29 ++++--- 4 files changed, 108 insertions(+), 80 deletions(-) diff --git a/flow360/component/simulation/meshing_param/face_params.py b/flow360/component/simulation/meshing_param/face_params.py index 6f7c77720..c0072bb04 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, ) @@ -193,6 +194,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/models/surface_models.py b/flow360/component/simulation/models/surface_models.py index c60aa7b5d..095319cf2 100644 --- a/flow360/component/simulation/models/surface_models.py +++ b/flow360/component/simulation/models/surface_models.py @@ -44,14 +44,13 @@ MassFlowRateType, PressureType, ) -from flow360.component.simulation.utils import model_attribute_unlock from flow360.component.simulation.validation.validation_context import ( ParamsValidationInfo, contextual_field_validator, ) from flow360.component.simulation.validation.validation_utils import ( check_deleted_surface_pair, - find_user_symmetry_surfaces, + remap_symmetric_ghost_entity, validate_entity_list_surface_existence, ) @@ -71,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): @@ -79,75 +84,6 @@ def ensure_surface_existence(cls, value, param_info: ParamsValidationInfo): # TODO: This should have been moved to EntityListAllowingGhost? return validate_entity_list_surface_existence(value, param_info) - @contextual_field_validator("entities", mode="after") - @classmethod - def remap_symmetric_to_user_name(cls, value, param_info: ParamsValidationInfo): - """For UDF with GAI, remap 'symmetric' ghost entity to ensure solver JSON compatibility. - When user geometry has an explicit symmetry face, it can be referenced directly. - We discourage but maintain backwards compatibility for 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 "symmetric" entity on the BC model (flows to solver JSON) - bc_entity = next( - ( - e - for e in value.stored_entities - if isinstance(e, (GhostSurface, GhostCircularPlane)) and e.name == "symmetric" - ), - None, - ) - if bc_entity is None: - return value - - entity_info = param_info.get_entity_info() - if entity_info is None: - return value - - # Ghost "symmetric" entity in entity_info (used by asset boundary collection, separate instance) - asset_ghost = next( - ( - g - for g in entity_info.ghost_entities - if isinstance(g, (GhostSurface, GhostCircularPlane)) and g.name == "symmetric" - ), - None, - ) - - sym_surfaces = find_user_symmetry_surfaces( - entity_info.get_boundaries(), - param_info.global_bounding_box, - param_info.planar_face_tolerance, - ) - - if len(sym_surfaces) == 1: - user_name = sym_surfaces[0].name - # Remap both so solver JSON and asset boundary collection agree on the name - for entity in (bc_entity, asset_ghost): - if entity is None: - continue - with model_attribute_unlock(entity, "name"): - entity.name = user_name - with model_attribute_unlock(entity, "private_attribute_id"): - entity.private_attribute_id = user_name - log.warning( - f"Your geometry has a symmetry surface '{user_name}'. " - f"Remapping farfield.symmetry_plane to use this name. " - f"Consider using geometry['{user_name}'] directly." - ) - elif len(sym_surfaces) > 1: - log.warning( - "Multiple symmetry surfaces with different names detected. " - "Symmetry surface name preservation is not yet supported for this case." - ) - - return value - class BoundaryBaseWithTurbulenceQuantities(BoundaryBase, metaclass=ABCMeta): """Boundary base with turbulence quantities""" diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index f1e1d185f..fdb7b762b 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): @@ -284,6 +288,76 @@ def find_user_symmetry_surfaces(boundaries, global_bounding_box, planar_face_tol ] +def remap_symmetric_ghost_entity(value, param_info): + """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 + + sym_surfaces = find_user_symmetry_surfaces( + entity_info.get_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.name + 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: + log.warning( + "Multiple symmetry surfaces with different names detected. " + "Symmetry surface name preservation is not yet supported for this case." + ) + + 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/test_validators_params.py b/tests/simulation/params/test_validators_params.py index f368ce938..73b82ad7e 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, @@ -3465,7 +3466,7 @@ def test_udf_wrong_half_deleted(): def test_udf_symmetry_plane_remap(): - """UDF + farfield.symmetry_plane + explicit face: remaps to user's name.""" + """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]]), @@ -3474,13 +3475,17 @@ def test_udf_symmetry_plane_remap(): name="body", private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 0.1, 0], [1, 1, 1]]), ) - ghost_sym = GhostCircularPlane( + # 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, @@ -3488,7 +3493,7 @@ def test_udf_symmetry_plane_remap(): project_entity_info=SurfaceMeshEntityInfo( global_bounding_box=[[0, 0, 0], [1, 1, 1]], boundaries=[surface_sym, surface_body], - ghost_entities=[ghost_sym], + ghost_entities=[ghost_in_entity_info], ), ) farfield = UserDefinedFarfield(domain_type="half_body_positive_y") @@ -3501,12 +3506,13 @@ def test_udf_symmetry_plane_remap(): 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=GhostSurface(name="symmetric", private_attribute_id="symmetric") - ), + SymmetryPlane(entities=ghost_from_farfield), ], private_attribute_asset_cache=asset_cache, ) @@ -3517,10 +3523,15 @@ def test_udf_symmetry_plane_remap(): validation_level="All", ) assert errors is None, f"Expected no errors but got: {errors}" - # Check that the entity was remapped on the BC model + # BC entity replaced with real Surface (not GhostSurface) sym_bc = next(m for m in validated.models if isinstance(m, SymmetryPlane)) - assert any(e.name == "mySymmetry" for e in sym_bc.entities.stored_entities) - # Check that the ghost entity in entity_info was also remapped + 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 ] From eb14b56f0ed6d554307ee1e8f9ccb7dea3cb06be Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 21:27:06 +0000 Subject: [PATCH 08/13] guard against incomplete entity info --- .../component/simulation/validation/validation_utils.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index fdb7b762b..4d5ae2318 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -318,8 +318,13 @@ def remap_symmetric_ghost_entity(value, param_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( - entity_info.get_boundaries(), + boundaries, param_info.global_bounding_box, param_info.planar_face_tolerance, ) From 19227ca4660a3820dfa44ffecf4d90a746369285 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 21:33:26 +0000 Subject: [PATCH 09/13] add remap for surface refinement --- .../simulation/meshing_param/face_params.py | 6 ++++++ .../simulation/validation/validation_utils.py | 13 ++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/flow360/component/simulation/meshing_param/face_params.py b/flow360/component/simulation/meshing_param/face_params.py index c0072bb04..3bac23e4b 100644 --- a/flow360/component/simulation/meshing_param/face_params.py +++ b/flow360/component/simulation/meshing_param/face_params.py @@ -66,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): diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index 4d5ae2318..bb3122a4f 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -296,11 +296,14 @@ def remap_symmetric_ghost_entity(value, param_info): 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: + if ( + value is None + or param_info.farfield_method != "user-defined" + or not param_info.use_geometry_AI + or not param_info.is_beta_mesher + or not hasattr(value, "stored_entities") + or not value.stored_entities + ): return value ghost_idx = next( From 723e1b261b32b06d2b7fcc2001e4116599cfbc20 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 21:36:56 +0000 Subject: [PATCH 10/13] l i n t --- .../simulation/validation/validation_utils.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index bb3122a4f..70cdab904 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -288,7 +288,7 @@ def find_user_symmetry_surfaces(boundaries, global_bounding_box, planar_face_tol ] -def remap_symmetric_ghost_entity(value, param_info): +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). @@ -296,14 +296,11 @@ def remap_symmetric_ghost_entity(value, param_info): their symmetry surface, but we discourage using the legacy 'farfield.symmetry_plane'. """ - if ( - value is None - or param_info.farfield_method != "user-defined" - or not param_info.use_geometry_AI - or not param_info.is_beta_mesher - or not hasattr(value, "stored_entities") - or not value.stored_entities - ): + 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( From 08b8a86b49311a49f066de6a11e7decc9a2f3579 Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 2 Apr 2026 21:49:54 +0000 Subject: [PATCH 11/13] cursor comment --- flow360/component/simulation/validation/validation_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index 70cdab904..f789d39cf 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -346,7 +346,7 @@ def remap_symmetric_ghost_entity(value, param_info): # pylint: disable=too-many 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.name + 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. " From cde574ec900eee77196527bff7d4c54193390d8d Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 9 Apr 2026 18:26:04 +0000 Subject: [PATCH 12/13] block farfield.symmetry_plane for multi-patch --- .../validation_simulation_params.py | 4 +- .../simulation/validation/validation_utils.py | 6 +-- .../params/test_validators_params.py | 47 +++++++++++++------ 3 files changed, 38 insertions(+), 19 deletions(-) diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index b5e4d5c7a..70d50cbec 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -449,13 +449,13 @@ 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: - # Skip adding "symmetric" ghost if user geometry has exactly one symmetry plane surface + # 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) != 1: + if len(user_sym_surfaces) == 0: asset_boundary_entities += [ item for item in ghost_entities diff --git a/flow360/component/simulation/validation/validation_utils.py b/flow360/component/simulation/validation/validation_utils.py index f789d39cf..dcd57e73a 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -355,9 +355,9 @@ def remap_symmetric_ghost_entity(value, param_info): # pylint: disable=too-many user_surface.name, ) elif len(sym_surfaces) > 1: - log.warning( - "Multiple symmetry surfaces with different names detected. " - "Symmetry surface name preservation is not yet supported for this case." + raise ValueError( + "farfield.symmetry_plane cannot be used with multiple symmetry surfaces. " + "Use geometry['name'] to reference individual symmetry surfaces directly." ) return value diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 73b82ad7e..aa39b0724 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -3650,8 +3650,8 @@ def test_auto_farfield_no_remap(): assert any(e.name == "symmetric" for e in sym_bc.entities.stored_entities) -def test_udf_symmetry_plane_no_remap_multiple_faces(): - """UDF + multiple y=0 faces: no remap, stays 'symmetric'.""" +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]]), @@ -3678,16 +3678,36 @@ def test_udf_symmetry_plane_no_remap_multiple_faces(): ), ) 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=MeshingDefaults( - planar_face_tolerance=1e-4, - geometry_accuracy=1e-5, - boundary_layer_first_layer_thickness=1e-3, - ), - volume_zones=[farfield], - ), + 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( @@ -3696,15 +3716,14 @@ def test_udf_symmetry_plane_no_remap_multiple_faces(): ], private_attribute_asset_cache=asset_cache, ) - validated, errors, _ = validate_model( + _, 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) + 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_unique_selector_names(): From 6e0316c5c428d320925a773d97cd698c36337e0e Mon Sep 17 00:00:00 2001 From: Alex Date: Thu, 9 Apr 2026 19:02:33 +0000 Subject: [PATCH 13/13] warn for conflicting bcs on multi symmetry patch --- .../validation_simulation_params.py | 22 ++++++++ .../params/test_validators_params.py | 56 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 70d50cbec..da741fcc6 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -610,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/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index aa39b0724..425c0269d 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -3726,6 +3726,62 @@ def test_udf_multi_patch_symmetry(): 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 (