diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 2d3f92ba5..244b753a5 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -190,6 +190,52 @@ def _check_volume_zones_has_farfield(cls, v): return v + @contextual_field_validator("volume_zones", mode="after") + @classmethod + def _check_automated_farfield_custom_volumes(cls, v, param_info): + if v is None: + return v + + automated_farfield = next((zone for zone in v if isinstance(zone, AutomatedFarfield)), None) + if automated_farfield is not None: + custom_volumes = [ + entity + for zone in v + if isinstance(zone, CustomZones) + for entity in param_info.expand_entity_list(zone.entities) + if isinstance(entity, CustomVolume) + ] + if any(cv.name == "farfield" for cv in custom_volumes): + raise ValueError( + "CustomVolume name 'farfield' is reserved when using AutomatedFarfield. " + "The 'farfield' zone will be automatically generated using `AutomatedFarfield.enclosed_surfaces`. " + "Please choose a different name." + ) + + enclosed_surfaces = ( + param_info.expand_entity_list(automated_farfield.enclosed_surfaces) + if automated_farfield.enclosed_surfaces is not None + else [] + ) + + if custom_volumes and not enclosed_surfaces: + raise ValueError( + "When using AutomatedFarfield with CustomVolumes, `enclosed_surfaces` must be " + "specified on the AutomatedFarfield to define the exterior farfield zone boundary." + ) + if enclosed_surfaces and not custom_volumes: + raise ValueError( + "`enclosed_surfaces` on AutomatedFarfield is only allowed when CustomVolume entities are used. " + "Without custom volumes, the farfield zone will be automatically detected." + ) + if any(s.name == "farfield" for s in enclosed_surfaces): + raise ValueError( + "Surface name 'farfield' in `enclosed_surfaces` will conflict with the automatically " + "generated farfield boundary. Please choose a different surface." + ) + + return v + @contextual_field_validator("volume_zones", mode="after") @classmethod def _check_volume_zones_have_unique_names(cls, v): diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 50bbe9ca8..208966a34 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -656,6 +656,13 @@ class AutomatedFarfield(_FarfieldBase): description="Radius of the far-field (semi)sphere/cylinder relative to " "the max dimension of the geometry bounding box.", ) + enclosed_surfaces: Optional[EntityList[Surface]] = pd.Field( + None, + description=( + "Geometry surfaces that, together with the farfield surface, form the boundary of the " + "exterior farfield zone. Required when using CustomVolumes alongside an AutomatedFarfield. " + ), + ) @property def farfield(self): diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 38838d8c8..d9d1a7b01 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -1084,15 +1084,16 @@ def ensure_unique_boundary_names(cls, v, param_info: ParamsValidationInfo): @contextual_model_validator(mode="after") def ensure_beta_mesher_and_compatible_farfield(self, param_info: ParamsValidationInfo): - """Check if the beta mesher is enabled and that the user is using user-defined or wind tunnel farfield.""" + """Check if the beta mesher is enabled and that the user is using a compatible farfield.""" if param_info.is_beta_mesher and param_info.farfield_method in ( "user-defined", "wind-tunnel", + "auto", ): return self raise ValueError( "CustomVolume is supported only when the beta mesher is enabled " - "and either a user-defined farfield or a wind tunnel farfield is enabled." + "and an automated, user-defined, or wind tunnel farfield is enabled." ) def _apply_transformation(self, matrix: np.ndarray) -> "CustomVolume": diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index a39622827..34e606467 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -296,6 +296,18 @@ def _get_custom_volumes(volume_zones: list): } ) + # Create "farfield" zone from enclosed_surfaces on AutomatedFarfield + for zone in volume_zones: + if isinstance(zone, AutomatedFarfield) and zone.enclosed_surfaces is not None: + patch_names = [surface.name for surface in zone.enclosed_surfaces.stored_entities] + custom_volumes.append( + { + "name": "farfield", + "patches": sorted(patch_names), + } + ) + break + if custom_volumes: # Sort custom volumes by name custom_volumes.sort(key=lambda x: x["name"]) @@ -418,6 +430,7 @@ def get_volume_meshing_json(input_params: SimulationParams, mesh_units): ##:: Step 2: Get farfield for zone in volume_zones: + # CustomZone implies user-defined farfield if no farfield is specified anywhere if isinstance(zone, (UserDefinedFarfield, CustomZones)): translated["farfield"] = {"type": "user-defined"} if hasattr(zone, "domain_type") and zone.domain_type is not None: diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 05a77fc12..5b952bcb2 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -2210,7 +2210,7 @@ def test_beta_mesher_only_features(mock_validation_context): ) assert errors is None - # Using CustomZones with all three farfield types + # Using CustomZones with WindTunnelFarfield with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -2279,6 +2279,8 @@ def test_beta_mesher_only_features(mock_validation_context): ) with SI_unit_system: + surface1 = Surface(name="face1") + surface2 = Surface(name="face2") params = SimulationParams( meshing=MeshingParams( defaults=MeshingDefaults( @@ -2291,11 +2293,14 @@ def test_beta_mesher_only_features(mock_validation_context): entities=[ CustomVolume( name="zone1", - boundaries=[Surface(name="face1"), Surface(name="face2")], - ) + boundaries=[surface1, surface2], + ), ], ), - AutomatedFarfield(), + AutomatedFarfield( + method="auto", + enclosed_surfaces=[surface1, surface2], + ), ], ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), @@ -2306,12 +2311,7 @@ def test_beta_mesher_only_features(mock_validation_context): root_item_type="SurfaceMesh", validation_level="VolumeMesh", ) - assert len(errors) == 1 - assert ( - errors[0]["msg"] - == "Value error, CustomVolume is supported only when the beta mesher is enabled " - + "and either a user-defined farfield or a wind tunnel farfield is enabled." - ) + assert errors is None with SI_unit_system: params = SimulationParams( @@ -2345,7 +2345,7 @@ def test_beta_mesher_only_features(mock_validation_context): assert ( errors[0]["msg"] == "Value error, CustomVolume is supported only when the beta mesher is enabled " - + "and either a user-defined farfield or a wind tunnel farfield is enabled." + + "and an automated, user-defined, or wind tunnel farfield is enabled." ) # Unique volume zone names @@ -3478,3 +3478,145 @@ def test_incomplete_BC_without_geometry_AI(): "Value error, The following boundaries do not have a boundary condition: no_bc. " "Please add them to a boundary condition model in the `models` section." ) + + +def test_automated_farfield_with_custom_zones(): + """AutomatedFarfield + CustomZones: enclosed_surfaces is required when CustomVolumes exist.""" + + # Positive: enclosed_surfaces provided with CustomVolumes -> should pass + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + ), + volume_zones=[ + CustomZones( + name="interior", + entities=[ + CustomVolume( + name="zone1", + boundaries=[ + Surface(name="face1"), + Surface(name="face2"), + ], + ) + ], + ), + AutomatedFarfield( + enclosed_surfaces=[ + Surface(name="face1"), + Surface(name="face2"), + ], + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + params, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="VolumeMesh", + ) + assert errors is None + + # Negative: CustomVolumes exist but enclosed_surfaces not provided -> should fail + with SI_unit_system: + params_no_enclosed = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + ), + volume_zones=[ + CustomZones( + name="inner", + entities=[ + CustomVolume( + name="zone1", + boundaries=[Surface(name="face1"), Surface(name="face2")], + ) + ], + ), + AutomatedFarfield(), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + _, errors, _ = validate_model( + params_as_dict=params_no_enclosed.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="VolumeMesh", + ) + assert ( + errors[0]["msg"] + == "Value error, When using AutomatedFarfield with CustomVolumes, `enclosed_surfaces` must be " + "specified on the AutomatedFarfield to define the exterior farfield zone boundary." + ) + + # Negative: enclosed_surfaces provided but no CustomVolumes -> should fail + with SI_unit_system: + params_no_cv = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + ), + volume_zones=[ + AutomatedFarfield( + enclosed_surfaces=[Surface(name="face1")], + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + _, errors, _ = validate_model( + params_as_dict=params_no_cv.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="VolumeMesh", + ) + assert ( + errors[0]["msg"] + == "Value error, `enclosed_surfaces` on AutomatedFarfield is only allowed when CustomVolume " + "entities are used. Without custom volumes, the farfield zone will be automatically detected." + ) + + +def test_custom_volume_named_farfield_with_automated_farfield(): + """CustomVolume named 'farfield' is reserved when using AutomatedFarfield.""" + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + ), + volume_zones=[ + CustomZones( + name="zones", + entities=[ + CustomVolume( + name="farfield", + boundaries=[Surface(name="face1"), Surface(name="face2")], + ) + ], + ), + AutomatedFarfield( + enclosed_surfaces=[Surface(name="face1"), Surface(name="face2")], + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + _, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json"), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="SurfaceMesh", + validation_level="VolumeMesh", + ) + assert ( + errors[0]["msg"] + == "Value error, CustomVolume name 'farfield' is reserved when using AutomatedFarfield. " + "The 'farfield' zone will be automatically generated using `AutomatedFarfield.enclosed_surfaces`. " + "Please choose a different name." + ) diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index a66b25a6b..9a412758f 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -26,7 +26,6 @@ AxisymmetricRefinement, CustomZones, MeshSliceOutput, - RotationCylinder, RotationVolume, StructuredBoxRefinement, UniformRefinement, @@ -1315,3 +1314,39 @@ def test_sphere_rotation_volume_translator(get_surface_mesh): assert outer_interface["maxEdgeLength"] == 0.5 assert "slidingInterface-sphereInterface" in outer_interface["enclosedObjects"] assert "otherBody" in outer_interface["enclosedObjects"] + + +def test_automated_farfield_enclosed_surfaces(get_surface_mesh): + """AutomatedFarfield.enclosed_surfaces should create a 'farfield' zone in translated output.""" + left1 = Surface(name="left1") + right1 = Surface(name="right1") + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + ), + volume_zones=[ + CustomZones( + name="interior_zone", + entities=[ + CustomVolume( + name="inner", + boundaries=[left1, right1], + ), + ], + ), + AutomatedFarfield( + enclosed_surfaces=[left1, right1], + ), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + + translated = get_volume_meshing_json(params, get_surface_mesh.mesh_unit) + assert "zones" in translated + zones_by_name = {z["name"]: z for z in translated["zones"]} + # enclosed_surfaces should produce a "farfield" zone + assert "farfield" in zones_by_name + assert sorted(zones_by_name["farfield"]["patches"]) == ["left1", "right1"]