diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 34a7445fa..5bf5a0d68 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -374,6 +374,57 @@ def _validate_only_in_beta_mesher(cls, value): "`domain_type` is only supported when using both GAI surface mesher and beta volume mesher." ) + @pd.field_validator("domain_type", mode="after") + @classmethod + def _validate_domain_type_bbox(cls, value): + """ + Ensure that when domain_type is used, the model actually spans across Y=0. + """ + validation_info = get_validation_info() + if validation_info is None: + return value + + if ( + value not in ("half_body_positive_y", "half_body_negative_y") + or validation_info.global_bounding_box is None + ): + return value + + y_min = validation_info.global_bounding_box[0][1] + y_max = validation_info.global_bounding_box[1][1] + + largest_dimension = -float("inf") + for dim in range(3): + dimension = ( + validation_info.global_bounding_box[1][dim] + - validation_info.global_bounding_box[0][dim] + ) + largest_dimension = max(largest_dimension, dimension) + + tolerance = largest_dimension * validation_info.planar_face_tolerance + + # Check if model crosses Y=0 + crossing = y_min < -tolerance and y_max > tolerance + if crossing: + return value + + # If not crossing, check if it matches the requested domain + if value == "half_body_positive_y": + # Should be on positive side (y > 0) + if y_min >= -tolerance: + return value + + if value == "half_body_negative_y": + # Should be on negative side (y < 0) + if y_max <= tolerance: + return value + + raise ValueError( + f"The model does not cross the symmetry plane (Y=0) with tolerance {tolerance:.2g}. " + f"Model Y range: [{y_min:.2g}, {y_max:.2g}]. " + "Please check if `domain_type` is set correctly." + ) + class AutomatedFarfield(_FarfieldBase): """ diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 36b659239..355dcc1ad 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -554,7 +554,7 @@ def _overlaps(self, ghost_surface_center_y: Optional[float], length_tolerance: f return True def _will_be_deleted_by_mesher( - # pylint: disable=too-many-arguments, too-many-return-statements + # pylint: disable=too-many-arguments, too-many-return-statements, too-many-branches self, at_least_one_body_transformed: bool, farfield_method: Optional[Literal["auto", "quasi-3d", "quasi-3d-periodic", "user-defined"]], @@ -562,6 +562,7 @@ def _will_be_deleted_by_mesher( planar_face_tolerance: Optional[float], half_model_symmetry_plane_center_y: Optional[float], quasi_3d_symmetry_planes_center_y: Optional[tuple[float]], + farfield_domain_type: Optional[str] = None, ) -> bool: """ Check against the automated farfield method and @@ -576,12 +577,24 @@ def _will_be_deleted_by_mesher( # VolumeMesh or Geometry/SurfaceMesh with legacy schema. return False + length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance + + if farfield_domain_type in ("half_body_positive_y", "half_body_negative_y"): + if self.private_attributes is not None: + # pylint: disable=no-member + y_min = self.private_attributes.bounding_box.ymin + y_max = self.private_attributes.bounding_box.ymax + + if farfield_domain_type == "half_body_positive_y" and y_max < -length_tolerance: + return True + + if farfield_domain_type == "half_body_negative_y" and y_min > length_tolerance: + return True + if farfield_method == "user-defined": # Not applicable to user defined farfield return False - length_tolerance = global_bounding_box.largest_dimension * planar_face_tolerance - if farfield_method == "auto": if half_model_symmetry_plane_center_y is None: # Legacy schema. 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 e0de7ed71..f10a59257 100644 --- a/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py +++ b/flow360/component/simulation/user_defined_dynamics/user_defined_dynamics.py @@ -136,6 +136,7 @@ def ensure_output_surface_existence(cls, value): planar_face_tolerance=validation_info.planar_face_tolerance, half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y, quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y, + farfield_domain_type=validation_info.farfield_domain_type, ): 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_utils.py b/flow360/component/simulation/validation/validation_utils.py index 917aea12c..bd9794dee 100644 --- a/flow360/component/simulation/validation/validation_utils.py +++ b/flow360/component/simulation/validation/validation_utils.py @@ -84,6 +84,7 @@ def check_deleted_surface_in_entity_list(value): planar_face_tolerance=validation_info.planar_face_tolerance, half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y, quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y, + farfield_domain_type=validation_info.farfield_domain_type, ): raise ValueError( f"Boundary `{surface.name}` will likely be deleted after mesh generation. " @@ -115,6 +116,7 @@ def check_deleted_surface_pair(value): planar_face_tolerance=validation_info.planar_face_tolerance, half_model_symmetry_plane_center_y=validation_info.half_model_symmetry_plane_center_y, quasi_3d_symmetry_planes_center_y=validation_info.quasi_3d_symmetry_planes_center_y, + farfield_domain_type=validation_info.farfield_domain_type, ): raise ValueError( f"Boundary `{surface.name}` will likely be deleted after mesh generation. " @@ -173,6 +175,12 @@ def check_symmetric_boundary_existence(stored_entities): if item.private_attribute_entity_type_name != "GhostCircularPlane": continue + if validation_info.farfield_domain_type in ( + "half_body_positive_y", + "half_body_negative_y", + ): + continue + if not item.exists(validation_info): # pylint: disable=protected-access y_min, y_max, tolerance, largest_dimension = item._get_existence_dependency( diff --git a/tests/simulation/params/test_automated_farfield.py b/tests/simulation/params/test_automated_farfield.py index d1ad659ec..e52e56149 100644 --- a/tests/simulation/params/test_automated_farfield.py +++ b/tests/simulation/params/test_automated_farfield.py @@ -8,6 +8,8 @@ from flow360.component.project_utils import set_up_params_for_uploading from flow360.component.resource_base import local_metadata_builder from flow360.component.simulation import services +from flow360.component.simulation.entity_info import SurfaceMeshEntityInfo +from flow360.component.simulation.framework.param_utils import AssetCache from flow360.component.simulation.meshing_param.face_params import SurfaceRefinement from flow360.component.simulation.meshing_param.params import ( MeshingDefaults, @@ -481,3 +483,81 @@ def _test_and_show_errors(geometry): assert errors_1 is None assert errors_2 is None assert errors_3 is None + + +def test_domain_type_bounding_box_check(): + # Case 1: Model does not cross Y=0 (Positive Half) + # y range [1, 10] + # Request half_body_positive_y -> Should pass (aligned) + + dummy_boundary = Surface(name="dummy") + + asset_cache_positive = AssetCache( + project_length_unit="m", + use_inhouse_mesher=True, + use_geometry_AI=True, + project_entity_info=SurfaceMeshEntityInfo( + global_bounding_box=[[0, 1, 0], [10, 10, 10]], + ghost_entities=[], + boundaries=[dummy_boundary], + ), + ) + + farfield_pos = UserDefinedFarfield(domain_type="half_body_positive_y") + + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + planar_face_tolerance=0.01, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ), + volume_zones=[farfield_pos], + ), + models=[Wall(entities=[dummy_boundary])], # Assign BC to avoid missing BC error + private_attribute_asset_cache=asset_cache_positive, + ) + + params_dict = params.model_dump(mode="json", exclude_none=True) + _, errors, _ = services.validate_model( + params_as_dict=params_dict, + validated_by=services.ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + + domain_errors = [ + e for e in (errors or []) if "The model does not cross the symmetry plane" in e["msg"] + ] + assert len(domain_errors) == 0 + + # Case 2: Misaligned + # Request half_body_negative_y on Positive Model -> Should Fail + farfield_neg = UserDefinedFarfield(domain_type="half_body_negative_y") + + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + planar_face_tolerance=0.01, + geometry_accuracy=1e-5, + boundary_layer_first_layer_thickness=1e-3, + ), + volume_zones=[farfield_neg], + ), + models=[Wall(entities=[dummy_boundary])], + private_attribute_asset_cache=asset_cache_positive, + ) + + params_dict = params.model_dump(mode="json", exclude_none=True) + _, errors, _ = services.validate_model( + params_as_dict=params_dict, + validated_by=services.ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="All", + ) + + assert errors is not None + domain_errors = [e for e in errors if "The model does not cross the symmetry plane" in e["msg"]] + assert len(domain_errors) == 1 diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 71997e03f..8011447b9 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -2279,3 +2279,93 @@ def test_ghost_surface_pair_requires_quasi_3d_periodic_farfield(): # Case 4: Farfield method IS "quasi-3d-periodic" → should pass with SI_unit_system, ValidationContext(CASE, quasi_3d_periodic_farfield_context): Periodic(surface_pairs=(periodic_1, periodic_2), spec=Translational()) + + +def test_deleted_surfaces_domain_type(): + # Mock Asset Cache + surface_pos = Surface( + name="pos_surf", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, 1, 0], [1, 2, 1]]), + ) + surface_neg = Surface( + name="neg_surf", + private_attributes=SurfacePrivateAttributes(bounding_box=[[0, -2, 0], [1, -1, 1]]), + ) + surface_cross = Surface( + name="cross_surf", + private_attributes=SurfacePrivateAttributes( + bounding_box=[[0, -0.000001, 0], [1, 0.000001, 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]], # Crosses Y=0 + boundaries=[surface_pos, surface_neg, surface_cross], + ), + ) + + # Test half_body_positive_y -> keeps positive, deletes negative + 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_pos]), # OK + Wall(entities=[surface_neg]), # Error + Wall(entities=[surface_cross]), # OK (touches 0) + ], + 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"] + + # Test half_body_negative_y -> keeps negative, deletes positive + farfield_neg = UserDefinedFarfield(domain_type="half_body_negative_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_neg], + ), + models=[ + Wall(entities=[surface_pos]), # Error + Wall(entities=[surface_neg]), # OK + Wall(entities=[surface_cross]), # OK + ], + 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 `pos_surf` will likely be deleted" in errors[0]["msg"]