From 6847ae3f0b30f959c2a3237ac29c84711b735a8c Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Wed, 11 Feb 2026 21:27:14 +0000 Subject: [PATCH 01/12] allow auto farfield with custom volume --- .../simulation/meshing_param/params.py | 38 +++- flow360/component/simulation/primitives.py | 11 +- .../translator/volume_meshing_translator.py | 20 +- .../params/test_validators_params.py | 188 +++++++++++++++++- .../test_volume_meshing_translator.py | 55 ++++- 5 files changed, 290 insertions(+), 22 deletions(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 2d3f92ba5..b1cdd9e60 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -35,7 +35,12 @@ UserDefinedFarfield, WindTunnelFarfield, ) -from flow360.component.simulation.primitives import SeedpointVolume +from flow360.component.simulation.primitives import ( + GhostCircularPlane, + GhostSphere, + GhostSurface, + SeedpointVolume, +) from flow360.component.simulation.validation.validation_context import ( SURFACE_MESH, VOLUME_MESH, @@ -188,6 +193,37 @@ def _check_volume_zones_has_farfield(cls, v): if total_farfield > 1: raise ValueError("Only one farfield zone is allowed in `volume_zones`.") + # If AutomatedFarfield is used with CustomVolume(s), exactly one of them must reference + # AutomatedFarfield.farfield, so the translator can identify which zone is the exterior farfield zone. + has_automated_farfield = any(isinstance(z, AutomatedFarfield) for z in v) + has_custom_zones = any(isinstance(z, CustomZones) for z in v) + if has_automated_farfield and has_custom_zones: + farfield_custom_volumes = [] + for zone in v: + if not isinstance(zone, CustomZones): + continue + for entity in zone.entities.stored_entities: + if not isinstance(entity, CustomVolume): + continue + has_farfield_ghost = any( + isinstance(s, (GhostSurface, GhostSphere, GhostCircularPlane)) + and s.name == "farfield" + for s in entity.boundaries.stored_entities + ) + if has_farfield_ghost: + farfield_custom_volumes.append(entity.name) + if len(farfield_custom_volumes) == 0: + raise ValueError( + "When using AutomatedFarfield with CustomZones, exactly one CustomVolume must include " + "AutomatedFarfield.farfield in its boundaries to define the exterior farfield zone." + ) + if len(farfield_custom_volumes) > 1: + raise ValueError( + "Multiple CustomVolumes reference AutomatedFarfield.farfield: " + f"{farfield_custom_volumes}. Only one CustomVolume may define the " + "exterior farfield zone." + ) + return v @contextual_field_validator("volume_zones", mode="after") diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 585dc4b5d..4b8ff52b9 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -1050,7 +1050,9 @@ class CustomVolume(_VolumeEntityBase): private_attribute_entity_type_name: Literal["CustomVolume"] = pd.Field( "CustomVolume", frozen=True ) - boundaries: EntityList[Surface, WindTunnelGhostSurface] = pd.Field( + boundaries: EntityList[ + Surface, WindTunnelGhostSurface, GhostSurface, GhostSphere, GhostCircularPlane + ] = pd.Field( description="The surfaces that define the boundaries of the custom volume." ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) @@ -1071,15 +1073,14 @@ 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", + "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 3b7954f8c..96cf0d769 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -29,6 +29,9 @@ Box, CustomVolume, Cylinder, + GhostCircularPlane, + GhostSphere, + GhostSurface, SeedpointVolume, Surface, ) @@ -233,11 +236,20 @@ def _get_custom_volumes(volume_zones: list): enforce_tetrahedral = getattr(zone, "element_type") == "tetrahedra" for custom_volume in zone.entities.stored_entities: if isinstance(custom_volume, CustomVolume): + zone_name = custom_volume.name + patch_names = [] + for surface in custom_volume.boundaries.stored_entities: + if isinstance(surface, (GhostSurface, GhostSphere, GhostCircularPlane)): + # AutomatedFarfield ghost entities will be auto-generated by the mesher + # and should not appear in the patches list. + # After expansion, GhostSurface becomes GhostSphere/GhostCircularPlane. + if surface.name == "farfield": + zone_name = "farfield" + continue + patch_names.append(surface.name) volume_dict = { - "name": custom_volume.name, - "patches": sorted( - [surface.name for surface in custom_volume.boundaries.stored_entities] - ), + "name": zone_name, + "patches": sorted(patch_names), } if enforce_tetrahedral: volume_dict["enforceTetrahedralElements"] = True diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index ce395388d..b267859d0 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -2169,7 +2169,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( @@ -2237,7 +2237,38 @@ def test_beta_mesher_only_features(mock_validation_context): == "Value error, WindTunnelFarfield is only supported when Geometry AI is enabled." ) + # CustomVolume + AutomatedFarfield requires exactly one CustomVolume to reference + # the farfield GhostSurface. Otherwise, it fails at construction + with pytest.raises( + pd.ValidationError, match="exactly one CustomVolume must include AutomatedFarfield.farfield" + ): + with SI_unit_system: + SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + planar_face_tolerance=1e-4, + ), + volume_zones=[ + CustomZones( + name="custom_zones", + entities=[ + CustomVolume( + name="zone1", + boundaries=[Surface(name="face1"), Surface(name="face2")], + ) + ], + ), + AutomatedFarfield(), + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) + with SI_unit_system: + auto_farfield = AutomatedFarfield(method="auto") + surface1 = Surface(name="face1") + surface2 = Surface(name="face2") params = SimulationParams( meshing=MeshingParams( defaults=MeshingDefaults( @@ -2250,11 +2281,15 @@ def test_beta_mesher_only_features(mock_validation_context): entities=[ CustomVolume( name="zone1", - boundaries=[Surface(name="face1"), Surface(name="face2")], - ) + boundaries=[surface1, surface2], + ), + CustomVolume( + name="exterior", + boundaries=[auto_farfield.farfield, surface1, surface2], + ), ], ), - AutomatedFarfield(), + auto_farfield, ], ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), @@ -2265,12 +2300,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( @@ -2304,7 +2334,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 @@ -3399,3 +3429,139 @@ 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_farfield_ghost(): + """AutomatedFarfield + CustomZones: exactly one CustomVolume must reference farfield ghost.""" + auto_ff = AutomatedFarfield() + + # Positive: one CustomVolume references AutomatedFarfield.farfield -> should pass + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + planar_face_tolerance=1e-4, + ), + volume_zones=[ + CustomZones( + name="exterior", + entities=[ + CustomVolume( + name="farfield_zone", + boundaries=[ + Surface(name="face1"), + auto_ff.farfield, + ], + ) + ], + ), + auto_ff, + ], + ), + 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 + + # Positive: GhostSphere (post-expansion form) should also pass + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + planar_face_tolerance=1e-4, + ), + volume_zones=[ + CustomZones( + name="exterior", + entities=[ + CustomVolume( + name="farfield_zone", + boundaries=[ + Surface(name="face1"), + GhostSphere(name="farfield"), + ], + ) + ], + ), + AutomatedFarfield(), + ], + ), + 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: no CustomVolume references farfield ghost -> should fail + with pytest.raises(pd.ValidationError, match="AutomatedFarfield.farfield"): + with SI_unit_system: + SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + planar_face_tolerance=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), + ) + + # Negative: multiple CustomVolumes reference farfield ghost -> should fail + with pytest.raises(pd.ValidationError, match="Multiple CustomVolumes"): + auto_ff2 = AutomatedFarfield() + with SI_unit_system: + SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + planar_face_tolerance=1e-4, + ), + volume_zones=[ + CustomZones( + name="zone_a", + entities=[ + CustomVolume( + name="ff1", + boundaries=[Surface(name="face1"), auto_ff2.farfield], + ) + ], + ), + CustomZones( + name="zone_b", + entities=[ + CustomVolume( + name="ff2", + boundaries=[ + Surface(name="face2"), + GhostSphere(name="farfield"), + ], + ) + ], + ), + auto_ff2, + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), + ) diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index dba7a60b5..8d12e36f9 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, @@ -1208,3 +1207,57 @@ def test_windtunnel_ghost_surface_supported_in_volume_face_refinements(get_surfa assert "faces" in translated assert translated["faces"]["windTunnelFloor"]["type"] == "aniso" assert translated["faces"]["windTunnelInlet"]["type"] == "projectAnisoSpacing" + + +def test_custom_volume_with_ghost_surface_farfield(get_surface_mesh): + """GhostSurface(name='farfield') should be skipped from patches and force zone_name='farfield'.""" + auto_farfield = AutomatedFarfield() + 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, + boundary_layer_growth_rate=1.2, + ), + volume_zones=[ + CustomZones( + name="interior_zone", + entities=[ + CustomVolume( + name="inner", + boundaries=[left1, right1], + ), + ], + ), + CustomZones( + name="exterior_zone", + entities=[ + CustomVolume( + name="outer", + boundaries=[ + left1, + right1, + auto_farfield.farfield, + ], + ), + ], + ), + auto_farfield, + ], + ), + 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"]} + # Exterior CustomVolume should be renamed to "farfield" for the mesher + assert "farfield" in zones_by_name + # GhostSurface should not appear in patches + assert "farfield" not in zones_by_name["farfield"]["patches"] + assert sorted(zones_by_name["farfield"]["patches"]) == ["left1", "right1"] + # Inner zone keeps its original name + assert "inner" in zones_by_name + assert sorted(zones_by_name["inner"]["patches"]) == ["left1", "right1"] From acb9a32b706603fafd489bf1a323376057968e4f Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Wed, 11 Feb 2026 21:45:20 +0000 Subject: [PATCH 02/12] black + lint --- flow360/component/simulation/primitives.py | 8 ++++---- .../simulation/translator/volume_meshing_translator.py | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 3fa383780..0bbffb2b8 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -1065,9 +1065,7 @@ class CustomVolume(_VolumeEntityBase): ) boundaries: EntityList[ Surface, WindTunnelGhostSurface, GhostSurface, GhostSphere, GhostCircularPlane - ] = pd.Field( - description="The surfaces that define the boundaries of the custom volume." - ) + ] = pd.Field(description="The surfaces that define the boundaries of the custom volume.") private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) axes: Optional[OrthogonalAxes] = pd.Field(None, description="") # Porous media support @@ -1088,7 +1086,9 @@ def ensure_unique_boundary_names(cls, v, param_info: ParamsValidationInfo): def ensure_beta_mesher_and_compatible_farfield(self, param_info: ParamsValidationInfo): """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", + "user-defined", + "wind-tunnel", + "auto", ): return self raise ValueError( diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index d4062de9d..cf539b133 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -269,6 +269,7 @@ def rotation_volume_entity_injector( return {} +# pylint: disable=too-many-nested-blocks def _get_custom_volumes(volume_zones: list): """Get translated custom volumes from volume zones.""" From 6dc44921b84e105d8971fda8263994416baa1ad3 Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Thu, 12 Feb 2026 19:44:19 +0000 Subject: [PATCH 03/12] address cursor-found bugs + unit tests for each --- .../simulation/meshing_param/params.py | 13 +++- .../translator/volume_meshing_translator.py | 8 +- .../params/test_validators_params.py | 78 +++++++++---------- .../test_volume_meshing_translator.py | 38 ++++++++- 4 files changed, 85 insertions(+), 52 deletions(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index b1cdd9e60..a90b1086e 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -195,9 +195,14 @@ def _check_volume_zones_has_farfield(cls, v): # If AutomatedFarfield is used with CustomVolume(s), exactly one of them must reference # AutomatedFarfield.farfield, so the translator can identify which zone is the exterior farfield zone. - has_automated_farfield = any(isinstance(z, AutomatedFarfield) for z in v) - has_custom_zones = any(isinstance(z, CustomZones) for z in v) - if has_automated_farfield and has_custom_zones: + has_automated_farfield = any(isinstance(zone, AutomatedFarfield) for zone in v) + has_custom_volumes = any( + isinstance(entity, CustomVolume) + for zone in v + if isinstance(zone, CustomZones) + for entity in zone.entities.stored_entities + ) + if has_automated_farfield and has_custom_volumes: farfield_custom_volumes = [] for zone in v: if not isinstance(zone, CustomZones): @@ -214,7 +219,7 @@ def _check_volume_zones_has_farfield(cls, v): farfield_custom_volumes.append(entity.name) if len(farfield_custom_volumes) == 0: raise ValueError( - "When using AutomatedFarfield with CustomZones, exactly one CustomVolume must include " + "When using AutomatedFarfield with CustomVolumes, exactly one CustomVolume must include " "AutomatedFarfield.farfield in its boundaries to define the exterior farfield zone." ) if len(farfield_custom_volumes) > 1: diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index cf539b133..c8c8d1176 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -283,10 +283,10 @@ def _get_custom_volumes(volume_zones: list): zone_name = custom_volume.name patch_names = [] for surface in custom_volume.boundaries.stored_entities: - if isinstance(surface, (GhostSurface, GhostSphere, GhostCircularPlane)): - # AutomatedFarfield ghost entities will be auto-generated by the mesher - # and should not appear in the patches list. - # After expansion, GhostSurface becomes GhostSphere/GhostCircularPlane. + if type(surface) in (GhostSurface, GhostSphere, GhostCircularPlane): + # AutomatedFarfield ghost entities (GhostSurface => GhostSphere/GhostCircularPlane + # after expansion) will be auto-generated by the mesher and should not appear + # in the patches list. Do not filter out WindTunnelGhostSurfaces. if surface.name == "farfield": zone_name = "farfield" continue diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 63c3900ec..0514071c3 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -3494,7 +3494,7 @@ def test_incomplete_BC_without_geometry_AI(): ) -def test_automated_farfield_with_custom_zones_farfield_ghost(): +def test_automated_farfield_with_custom_zones(): """AutomatedFarfield + CustomZones: exactly one CustomVolume must reference farfield ghost.""" auto_ff = AutomatedFarfield() @@ -3532,48 +3532,15 @@ def test_automated_farfield_with_custom_zones_farfield_ghost(): ) assert errors is None - # Positive: GhostSphere (post-expansion form) should also pass - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-4, - planar_face_tolerance=1e-4, - ), - volume_zones=[ - CustomZones( - name="exterior", - entities=[ - CustomVolume( - name="farfield_zone", - boundaries=[ - Surface(name="face1"), - GhostSphere(name="farfield"), - ], - ) - ], - ), - AutomatedFarfield(), - ], - ), - 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: no CustomVolume references farfield ghost -> should fail - with pytest.raises(pd.ValidationError, match="AutomatedFarfield.farfield"): + with pytest.raises( + pd.ValidationError, match="exactly one CustomVolume must include AutomatedFarfield.farfield" + ): with SI_unit_system: SimulationParams( meshing=MeshingParams( defaults=MeshingDefaults( boundary_layer_first_layer_thickness=1e-4, - planar_face_tolerance=1e-4, ), volume_zones=[ CustomZones( @@ -3592,14 +3559,13 @@ def test_automated_farfield_with_custom_zones_farfield_ghost(): ) # Negative: multiple CustomVolumes reference farfield ghost -> should fail - with pytest.raises(pd.ValidationError, match="Multiple CustomVolumes"): + with pytest.raises(pd.ValidationError, match="Multiple CustomVolumes reference"): auto_ff2 = AutomatedFarfield() with SI_unit_system: SimulationParams( meshing=MeshingParams( defaults=MeshingDefaults( boundary_layer_first_layer_thickness=1e-4, - planar_face_tolerance=1e-4, ), volume_zones=[ CustomZones( @@ -3616,10 +3582,7 @@ def test_automated_farfield_with_custom_zones_farfield_ghost(): entities=[ CustomVolume( name="ff2", - boundaries=[ - Surface(name="face2"), - GhostSphere(name="farfield"), - ], + boundaries=[Surface(name="face2"), auto_ff2.farfield], ) ], ), @@ -3628,3 +3591,32 @@ def test_automated_farfield_with_custom_zones_farfield_ghost(): ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), ) + + +def test_automated_farfield_with_seedpoint_only_custom_zones(): + """AutomatedFarfield + CustomZones with only SeedpointVolumes should have no zones referencing the farfield.""" + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + ), + volume_zones=[ + CustomZones( + name="regions", + entities=[ + SeedpointVolume(name="seedpoint", point_in_mesh=(0, 0, 0)), + ], + ), + AutomatedFarfield(), + ], + ), + 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 diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index fa5c07ba3..876244eea 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -1317,7 +1317,7 @@ def test_sphere_rotation_volume_translator(get_surface_mesh): def test_custom_volume_with_ghost_surface_farfield(get_surface_mesh): - """GhostSurface(name='farfield') should be skipped from patches and force zone_name='farfield'.""" + """AutomatedFarfield GhostSurface should be skipped from patches and force zone_name='farfield'.""" auto_farfield = AutomatedFarfield() left1 = Surface(name="left1") right1 = Surface(name="right1") @@ -1368,3 +1368,39 @@ def test_custom_volume_with_ghost_surface_farfield(get_surface_mesh): # Inner zone keeps its original name assert "inner" in zones_by_name assert sorted(zones_by_name["inner"]["patches"]) == ["left1", "right1"] + + +def test_custom_volume_with_wind_tunnel_ghost_surface(get_surface_mesh): + """WindTunnelGhostSurface should NOT be skipped from patches.""" + wind_tunnel = WindTunnelFarfield() + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, + ), + volume_zones=[ + CustomZones( + name="custom", + entities=[ + CustomVolume( + name="zone1", + boundaries=[ + Surface(name="face1"), + Surface(name="face2"), + wind_tunnel.left, + ], + ), + ], + ), + wind_tunnel, + ], + ), + private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True, use_geometry_AI=True), + ) + + translated = get_volume_meshing_json(params, get_surface_mesh.mesh_unit) + assert "zones" in translated + assert len(translated["zones"]) == 1 + assert translated["zones"][0]["name"] == "zone1" + assert "windTunnelLeft" in translated["zones"][0]["patches"] From 047b49d7412d3a89576c1fe5c55f4df5a1dd3ad3 Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Thu, 12 Feb 2026 20:02:38 +0000 Subject: [PATCH 04/12] refactor custom_volumes list --- .../simulation/meshing_param/params.py | 33 ++++++++----------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index a90b1086e..141c2c9fb 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -196,27 +196,23 @@ def _check_volume_zones_has_farfield(cls, v): # If AutomatedFarfield is used with CustomVolume(s), exactly one of them must reference # AutomatedFarfield.farfield, so the translator can identify which zone is the exterior farfield zone. has_automated_farfield = any(isinstance(zone, AutomatedFarfield) for zone in v) - has_custom_volumes = any( - isinstance(entity, CustomVolume) + custom_volumes = [ + entity for zone in v if isinstance(zone, CustomZones) for entity in zone.entities.stored_entities - ) - if has_automated_farfield and has_custom_volumes: + if isinstance(entity, CustomVolume) + ] + if has_automated_farfield and custom_volumes: farfield_custom_volumes = [] - for zone in v: - if not isinstance(zone, CustomZones): - continue - for entity in zone.entities.stored_entities: - if not isinstance(entity, CustomVolume): - continue - has_farfield_ghost = any( - isinstance(s, (GhostSurface, GhostSphere, GhostCircularPlane)) - and s.name == "farfield" - for s in entity.boundaries.stored_entities - ) - if has_farfield_ghost: - farfield_custom_volumes.append(entity.name) + for cv in custom_volumes: + has_farfield_ghost = any( + isinstance(s, (GhostSurface, GhostSphere, GhostCircularPlane)) + and s.name == "farfield" + for s in cv.boundaries.stored_entities + ) + if has_farfield_ghost: + farfield_custom_volumes.append(cv.name) if len(farfield_custom_volumes) == 0: raise ValueError( "When using AutomatedFarfield with CustomVolumes, exactly one CustomVolume must include " @@ -225,8 +221,7 @@ def _check_volume_zones_has_farfield(cls, v): if len(farfield_custom_volumes) > 1: raise ValueError( "Multiple CustomVolumes reference AutomatedFarfield.farfield: " - f"{farfield_custom_volumes}. Only one CustomVolume may define the " - "exterior farfield zone." + f"{farfield_custom_volumes}. Only one CustomVolume may define the exterior farfield zone." ) return v From 0d4880ad0a7694abaf6421b8bee2d05bd816fe44 Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Fri, 13 Feb 2026 21:11:30 +0000 Subject: [PATCH 05/12] new interface: custom volumes requires specifying enclosed entities with auto ff --- .../simulation/meshing_param/params.py | 45 ++++----- .../simulation/meshing_param/volume_params.py | 12 ++- flow360/component/simulation/primitives.py | 6 +- .../translator/volume_meshing_translator.py | 33 ++++--- .../params/test_validators_params.py | 95 +++++-------------- .../test_volume_meshing_translator.py | 63 +----------- 6 files changed, 73 insertions(+), 181 deletions(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 141c2c9fb..e13ba1541 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -36,9 +36,6 @@ WindTunnelFarfield, ) from flow360.component.simulation.primitives import ( - GhostCircularPlane, - GhostSphere, - GhostSurface, SeedpointVolume, ) from flow360.component.simulation.validation.validation_context import ( @@ -193,35 +190,25 @@ def _check_volume_zones_has_farfield(cls, v): if total_farfield > 1: raise ValueError("Only one farfield zone is allowed in `volume_zones`.") - # If AutomatedFarfield is used with CustomVolume(s), exactly one of them must reference - # AutomatedFarfield.farfield, so the translator can identify which zone is the exterior farfield zone. - has_automated_farfield = any(isinstance(zone, AutomatedFarfield) for zone in v) - custom_volumes = [ - entity - for zone in v - if isinstance(zone, CustomZones) - for entity in zone.entities.stored_entities - if isinstance(entity, CustomVolume) - ] - if has_automated_farfield and custom_volumes: - farfield_custom_volumes = [] - for cv in custom_volumes: - has_farfield_ghost = any( - isinstance(s, (GhostSurface, GhostSphere, GhostCircularPlane)) - and s.name == "farfield" - for s in cv.boundaries.stored_entities - ) - if has_farfield_ghost: - farfield_custom_volumes.append(cv.name) - if len(farfield_custom_volumes) == 0: + automated_farfield = next((zone for zone in v if isinstance(zone, AutomatedFarfield)), None) + if automated_farfield is not None: + has_custom_volumes = any( + isinstance(entity, CustomVolume) + for zone in v + if isinstance(zone, CustomZones) + for entity in zone.entities.stored_entities + ) + has_enclosed_surfaces = automated_farfield.enclosed_surfaces is not None + + if has_custom_volumes and not has_enclosed_surfaces: raise ValueError( - "When using AutomatedFarfield with CustomVolumes, exactly one CustomVolume must include " - "AutomatedFarfield.farfield in its boundaries to define the exterior farfield zone." + "When using AutomatedFarfield with CustomVolumes, `enclosed_surfaces` must be " + "specified on the AutomatedFarfield to define the exterior farfield zone boundary." ) - if len(farfield_custom_volumes) > 1: + if has_enclosed_surfaces and not has_custom_volumes: raise ValueError( - "Multiple CustomVolumes reference AutomatedFarfield.farfield: " - f"{farfield_custom_volumes}. Only one CustomVolume may define the exterior farfield zone." + "`enclosed_surfaces` on AutomatedFarfield is only allowed when CustomVolume entities are used." + "Without custom volumes, the farfield zone will be automatically detected." ) return v diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 50bbe9ca8..7e41baccf 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -544,6 +544,14 @@ class _FarfieldBase(Flow360BaseModel): """, ) ) + 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. " + "The farfield surface and analytically defined surfaces will be implicitly included." + ), + ) @contextual_field_validator("domain_type", mode="after") @classmethod @@ -645,8 +653,8 @@ class AutomatedFarfield(_FarfieldBase): ) private_attribute_entity: GenericVolume = pd.Field( GenericVolume( - name="__farfield_zone_name_not_properly_set_yet", - private_attribute_id="farfield_zone_name_not_properly_set_yet", + name="farfield", + private_attribute_id="farfield_zone", ), frozen=True, exclude=True, diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 0bbffb2b8..d9d1a7b01 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -1063,9 +1063,9 @@ class CustomVolume(_VolumeEntityBase): private_attribute_entity_type_name: Literal["CustomVolume"] = pd.Field( "CustomVolume", frozen=True ) - boundaries: EntityList[ - Surface, WindTunnelGhostSurface, GhostSurface, GhostSphere, GhostCircularPlane - ] = pd.Field(description="The surfaces that define the boundaries of the custom volume.") + boundaries: EntityList[Surface, WindTunnelGhostSurface] = pd.Field( + description="The surfaces that define the boundaries of the custom volume." + ) private_attribute_id: str = pd.Field(default_factory=generate_uuid, frozen=True) axes: Optional[OrthogonalAxes] = pd.Field(None, description="") # Porous media support diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index c8c8d1176..0d12384fd 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -28,9 +28,6 @@ Box, CustomVolume, Cylinder, - GhostCircularPlane, - GhostSphere, - GhostSurface, SeedpointVolume, Sphere, Surface, @@ -269,7 +266,6 @@ def rotation_volume_entity_injector( return {} -# pylint: disable=too-many-nested-blocks def _get_custom_volumes(volume_zones: list): """Get translated custom volumes from volume zones.""" @@ -280,20 +276,11 @@ def _get_custom_volumes(volume_zones: list): enforce_tetrahedral = getattr(zone, "element_type") == "tetrahedra" for custom_volume in zone.entities.stored_entities: if isinstance(custom_volume, CustomVolume): - zone_name = custom_volume.name - patch_names = [] - for surface in custom_volume.boundaries.stored_entities: - if type(surface) in (GhostSurface, GhostSphere, GhostCircularPlane): - # AutomatedFarfield ghost entities (GhostSurface => GhostSphere/GhostCircularPlane - # after expansion) will be auto-generated by the mesher and should not appear - # in the patches list. Do not filter out WindTunnelGhostSurfaces. - if surface.name == "farfield": - zone_name = "farfield" - continue - patch_names.append(surface.name) volume_dict = { - "name": zone_name, - "patches": sorted(patch_names), + "name": custom_volume.name, + "patches": sorted( + [surface.name for surface in custom_volume.boundaries.stored_entities] + ), } if enforce_tetrahedral: volume_dict["enforceTetrahedralElements"] = True @@ -309,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"]) diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 6ec416695..ad72b29a8 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -2278,11 +2278,8 @@ def test_beta_mesher_only_features(mock_validation_context): == "Value error, WindTunnelFarfield is only supported when Geometry AI is enabled." ) - # CustomVolume + AutomatedFarfield requires exactly one CustomVolume to reference - # the farfield GhostSurface. Otherwise, it fails at construction - with pytest.raises( - pd.ValidationError, match="exactly one CustomVolume must include AutomatedFarfield.farfield" - ): + # CustomVolume + AutomatedFarfield requires enclosed_surfaces + with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*must be.*specified"): with SI_unit_system: SimulationParams( meshing=MeshingParams( @@ -2307,7 +2304,6 @@ def test_beta_mesher_only_features(mock_validation_context): ) with SI_unit_system: - auto_farfield = AutomatedFarfield(method="auto") surface1 = Surface(name="face1") surface2 = Surface(name="face2") params = SimulationParams( @@ -2324,13 +2320,12 @@ def test_beta_mesher_only_features(mock_validation_context): name="zone1", boundaries=[surface1, surface2], ), - CustomVolume( - name="exterior", - boundaries=[auto_farfield.farfield, surface1, surface2], - ), ], ), - auto_farfield, + AutomatedFarfield( + method="auto", + enclosed_surfaces=[surface1, surface2], + ), ], ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), @@ -3511,10 +3506,9 @@ def test_incomplete_BC_without_geometry_AI(): def test_automated_farfield_with_custom_zones(): - """AutomatedFarfield + CustomZones: exactly one CustomVolume must reference farfield ghost.""" - auto_ff = AutomatedFarfield() + """AutomatedFarfield + CustomZones: enclosed_surfaces is required when CustomVolumes exist.""" - # Positive: one CustomVolume references AutomatedFarfield.farfield -> should pass + # Positive: enclosed_surfaces provided with CustomVolumes -> should pass with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -3524,18 +3518,23 @@ def test_automated_farfield_with_custom_zones(): ), volume_zones=[ CustomZones( - name="exterior", + name="interior", entities=[ CustomVolume( - name="farfield_zone", + name="zone1", boundaries=[ Surface(name="face1"), - auto_ff.farfield, + Surface(name="face2"), ], ) ], ), - auto_ff, + AutomatedFarfield( + enclosed_surfaces=[ + Surface(name="face1"), + Surface(name="face2"), + ], + ), ], ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), @@ -3548,10 +3547,8 @@ def test_automated_farfield_with_custom_zones(): ) assert errors is None - # Negative: no CustomVolume references farfield ghost -> should fail - with pytest.raises( - pd.ValidationError, match="exactly one CustomVolume must include AutomatedFarfield.farfield" - ): + # Negative: CustomVolumes exist but enclosed_surfaces not provided -> should fail + with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*must be.*specified"): with SI_unit_system: SimulationParams( meshing=MeshingParams( @@ -3574,9 +3571,8 @@ def test_automated_farfield_with_custom_zones(): private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), ) - # Negative: multiple CustomVolumes reference farfield ghost -> should fail - with pytest.raises(pd.ValidationError, match="Multiple CustomVolumes reference"): - auto_ff2 = AutomatedFarfield() + # Negative: enclosed_surfaces provided but no CustomVolumes -> should fail + with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*is only needed"): with SI_unit_system: SimulationParams( meshing=MeshingParams( @@ -3584,55 +3580,10 @@ def test_automated_farfield_with_custom_zones(): boundary_layer_first_layer_thickness=1e-4, ), volume_zones=[ - CustomZones( - name="zone_a", - entities=[ - CustomVolume( - name="ff1", - boundaries=[Surface(name="face1"), auto_ff2.farfield], - ) - ], + AutomatedFarfield( + enclosed_surfaces=[Surface(name="face1")], ), - CustomZones( - name="zone_b", - entities=[ - CustomVolume( - name="ff2", - boundaries=[Surface(name="face2"), auto_ff2.farfield], - ) - ], - ), - auto_ff2, ], ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), ) - - -def test_automated_farfield_with_seedpoint_only_custom_zones(): - """AutomatedFarfield + CustomZones with only SeedpointVolumes should have no zones referencing the farfield.""" - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-4, - ), - volume_zones=[ - CustomZones( - name="regions", - entities=[ - SeedpointVolume(name="seedpoint", point_in_mesh=(0, 0, 0)), - ], - ), - AutomatedFarfield(), - ], - ), - 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 diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index 876244eea..435d48825 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -1316,9 +1316,8 @@ def test_sphere_rotation_volume_translator(get_surface_mesh): assert "otherBody" in outer_interface["enclosedObjects"] -def test_custom_volume_with_ghost_surface_farfield(get_surface_mesh): - """AutomatedFarfield GhostSurface should be skipped from patches and force zone_name='farfield'.""" - auto_farfield = AutomatedFarfield() +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: @@ -1338,20 +1337,9 @@ def test_custom_volume_with_ghost_surface_farfield(get_surface_mesh): ), ], ), - CustomZones( - name="exterior_zone", - entities=[ - CustomVolume( - name="outer", - boundaries=[ - left1, - right1, - auto_farfield.farfield, - ], - ), - ], + AutomatedFarfield( + enclosed_surfaces=[left1, right1], ), - auto_farfield, ], ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), @@ -1360,47 +1348,6 @@ def test_custom_volume_with_ghost_surface_farfield(get_surface_mesh): 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"]} - # Exterior CustomVolume should be renamed to "farfield" for the mesher + # enclosed_surfaces should produce a "farfield" zone assert "farfield" in zones_by_name - # GhostSurface should not appear in patches - assert "farfield" not in zones_by_name["farfield"]["patches"] assert sorted(zones_by_name["farfield"]["patches"]) == ["left1", "right1"] - # Inner zone keeps its original name - assert "inner" in zones_by_name - assert sorted(zones_by_name["inner"]["patches"]) == ["left1", "right1"] - - -def test_custom_volume_with_wind_tunnel_ghost_surface(get_surface_mesh): - """WindTunnelGhostSurface should NOT be skipped from patches.""" - wind_tunnel = WindTunnelFarfield() - with SI_unit_system: - params = SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-4, - ), - volume_zones=[ - CustomZones( - name="custom", - entities=[ - CustomVolume( - name="zone1", - boundaries=[ - Surface(name="face1"), - Surface(name="face2"), - wind_tunnel.left, - ], - ), - ], - ), - wind_tunnel, - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True, use_geometry_AI=True), - ) - - translated = get_volume_meshing_json(params, get_surface_mesh.mesh_unit) - assert "zones" in translated - assert len(translated["zones"]) == 1 - assert translated["zones"][0]["name"] == "zone1" - assert "windTunnelLeft" in translated["zones"][0]["patches"] From 377663fe4e68d4bae7b17cdd63d904d9e1f81a9d Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Tue, 17 Feb 2026 19:06:25 +0000 Subject: [PATCH 06/12] address comments --- .../simulation/meshing_param/params.py | 22 ++++++++----- .../translator/volume_meshing_translator.py | 1 + .../params/test_validators_params.py | 31 +++++++++++++++++-- .../test_volume_meshing_translator.py | 1 - 4 files changed, 44 insertions(+), 11 deletions(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index e13ba1541..a8820f39a 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -35,9 +35,7 @@ UserDefinedFarfield, WindTunnelFarfield, ) -from flow360.component.simulation.primitives import ( - SeedpointVolume, -) +from flow360.component.simulation.primitives import SeedpointVolume from flow360.component.simulation.validation.validation_context import ( SURFACE_MESH, VOLUME_MESH, @@ -192,20 +190,28 @@ def _check_volume_zones_has_farfield(cls, v): automated_farfield = next((zone for zone in v if isinstance(zone, AutomatedFarfield)), None) if automated_farfield is not None: - has_custom_volumes = any( - isinstance(entity, CustomVolume) + custom_volumes = [ + entity for zone in v if isinstance(zone, CustomZones) for entity in zone.entities.stored_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." + ) + has_enclosed_surfaces = automated_farfield.enclosed_surfaces is not None - if has_custom_volumes and not has_enclosed_surfaces: + if custom_volumes and not has_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 has_enclosed_surfaces and not has_custom_volumes: + if has_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." diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index 0d12384fd..5ee560d41 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -430,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 later 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 ad72b29a8..ac77429dd 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -3514,7 +3514,6 @@ def test_automated_farfield_with_custom_zones(): meshing=MeshingParams( defaults=MeshingDefaults( boundary_layer_first_layer_thickness=1e-4, - planar_face_tolerance=1e-4, ), volume_zones=[ CustomZones( @@ -3572,7 +3571,7 @@ def test_automated_farfield_with_custom_zones(): ) # Negative: enclosed_surfaces provided but no CustomVolumes -> should fail - with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*is only needed"): + with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*is only allowed"): with SI_unit_system: SimulationParams( meshing=MeshingParams( @@ -3587,3 +3586,31 @@ def test_automated_farfield_with_custom_zones(): ), private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), ) + + +def test_custom_volume_named_farfield_with_automated_farfield(): + """CustomVolume named 'farfield' is reserved when using AutomatedFarfield.""" + with pytest.raises(pd.ValidationError, match="name 'farfield' is reserved"): + with SI_unit_system: + 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), + ) diff --git a/tests/simulation/translator/test_volume_meshing_translator.py b/tests/simulation/translator/test_volume_meshing_translator.py index 435d48825..9a412758f 100644 --- a/tests/simulation/translator/test_volume_meshing_translator.py +++ b/tests/simulation/translator/test_volume_meshing_translator.py @@ -1325,7 +1325,6 @@ def test_automated_farfield_enclosed_surfaces(get_surface_mesh): meshing=MeshingParams( defaults=MeshingDefaults( boundary_layer_first_layer_thickness=1e-4, - boundary_layer_growth_rate=1.2, ), volume_zones=[ CustomZones( From 32a307bfd33830c8c1be12ada09abb6fa7d97749 Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Tue, 17 Feb 2026 19:27:04 +0000 Subject: [PATCH 07/12] restrict enclosed_surfaces to auto farfield for now --- .../simulation/meshing_param/params.py | 2 +- .../simulation/meshing_param/volume_params.py | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index a8820f39a..c796e1af2 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -213,7 +213,7 @@ def _check_volume_zones_has_farfield(cls, v): ) if has_enclosed_surfaces and not custom_volumes: raise ValueError( - "`enclosed_surfaces` on AutomatedFarfield is only allowed when CustomVolume entities are used." + "`enclosed_surfaces` on AutomatedFarfield is only allowed when CustomVolume entities are used. " "Without custom volumes, the farfield zone will be automatically detected." ) diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 7e41baccf..41accc484 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -544,14 +544,6 @@ class _FarfieldBase(Flow360BaseModel): """, ) ) - 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. " - "The farfield surface and analytically defined surfaces will be implicitly included." - ), - ) @contextual_field_validator("domain_type", mode="after") @classmethod @@ -653,8 +645,8 @@ class AutomatedFarfield(_FarfieldBase): ) private_attribute_entity: GenericVolume = pd.Field( GenericVolume( - name="farfield", - private_attribute_id="farfield_zone", + name="__farfield_zone_name_not_properly_set_yet", + private_attribute_id="farfield_zone_name_not_properly_set_yet", ), frozen=True, exclude=True, @@ -664,6 +656,14 @@ 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. " + "The farfield surface and analytically defined surfaces will be implicitly included." + ), + ) @property def farfield(self): From ea4c52cedc87bdfc861fd67d2be569e757f8ed0a Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Tue, 17 Feb 2026 19:41:43 +0000 Subject: [PATCH 08/12] remove redundant unit test! --- .../params/test_validators_params.py | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index ac77429dd..d555e5d11 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -2278,31 +2278,6 @@ def test_beta_mesher_only_features(mock_validation_context): == "Value error, WindTunnelFarfield is only supported when Geometry AI is enabled." ) - # CustomVolume + AutomatedFarfield requires enclosed_surfaces - with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*must be.*specified"): - with SI_unit_system: - SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-4, - planar_face_tolerance=1e-4, - ), - volume_zones=[ - CustomZones( - name="custom_zones", - entities=[ - CustomVolume( - name="zone1", - boundaries=[Surface(name="face1"), Surface(name="face2")], - ) - ], - ), - AutomatedFarfield(), - ], - ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) - with SI_unit_system: surface1 = Surface(name="face1") surface2 = Surface(name="face2") From 66d4d11506b45b1600965669761e92c14e36cbdf Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Tue, 17 Feb 2026 22:08:22 +0000 Subject: [PATCH 09/12] address comments --- .../simulation/meshing_param/params.py | 20 ++- .../simulation/meshing_param/volume_params.py | 1 - .../params/test_validators_params.py | 134 ++++++++++-------- 3 files changed, 92 insertions(+), 63 deletions(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index c796e1af2..839fd7a21 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -188,13 +188,21 @@ def _check_volume_zones_has_farfield(cls, v): if total_farfield > 1: raise ValueError("Only one farfield zone is allowed in `volume_zones`.") + 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 zone.entities.stored_entities + for entity in param_info.expand_entity_list(zone.entities) if isinstance(entity, CustomVolume) ] if any(cv.name == "farfield" for cv in custom_volumes): @@ -204,14 +212,18 @@ def _check_volume_zones_has_farfield(cls, v): "Please choose a different name." ) - has_enclosed_surfaces = automated_farfield.enclosed_surfaces is not None + 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 has_enclosed_surfaces: + 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 has_enclosed_surfaces and not custom_volumes: + 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." diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 41accc484..208966a34 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -661,7 +661,6 @@ class AutomatedFarfield(_FarfieldBase): description=( "Geometry surfaces that, together with the farfield surface, form the boundary of the " "exterior farfield zone. Required when using CustomVolumes alongside an AutomatedFarfield. " - "The farfield surface and analytically defined surfaces will be implicitly included." ), ) diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index d555e5d11..11c820f9a 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -3522,70 +3522,88 @@ def test_automated_farfield_with_custom_zones(): assert errors is None # Negative: CustomVolumes exist but enclosed_surfaces not provided -> should fail - with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*must be.*specified"): - with SI_unit_system: - 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(), - ], + with SI_unit_system: + params_no_enclosed = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) + 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 any("enclosed_surfaces" in e["msg"] and "must be" in e["msg"] for e in errors) # Negative: enclosed_surfaces provided but no CustomVolumes -> should fail - with pytest.raises(pd.ValidationError, match="enclosed_surfaces.*is only allowed"): - with SI_unit_system: - SimulationParams( - meshing=MeshingParams( - defaults=MeshingDefaults( - boundary_layer_first_layer_thickness=1e-4, - ), - volume_zones=[ - AutomatedFarfield( - enclosed_surfaces=[Surface(name="face1")], - ), - ], + with SI_unit_system: + params_no_cv = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) + 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 any("enclosed_surfaces" in e["msg"] and "is only allowed" in e["msg"] for e in errors) def test_custom_volume_named_farfield_with_automated_farfield(): """CustomVolume named 'farfield' is reserved when using AutomatedFarfield.""" - with pytest.raises(pd.ValidationError, match="name 'farfield' is reserved"): - with SI_unit_system: - 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")], - ), - ], + with SI_unit_system: + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + boundary_layer_first_layer_thickness=1e-4, ), - private_attribute_asset_cache=AssetCache(use_inhouse_mesher=True), - ) + 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 any("name 'farfield' is reserved" in e["msg"] for e in errors) From fb487cfa377ace81f3e44ac1c83bcfa3ab30c8d5 Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Tue, 17 Feb 2026 22:18:44 +0000 Subject: [PATCH 10/12] update tests to match exact message --- .../params/test_validators_params.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 11c820f9a..5b952bcb2 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -3549,7 +3549,11 @@ def test_automated_farfield_with_custom_zones(): root_item_type="SurfaceMesh", validation_level="VolumeMesh", ) - assert any("enclosed_surfaces" in e["msg"] and "must be" in e["msg"] for e in errors) + 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: @@ -3572,7 +3576,11 @@ def test_automated_farfield_with_custom_zones(): root_item_type="SurfaceMesh", validation_level="VolumeMesh", ) - assert any("enclosed_surfaces" in e["msg"] and "is only allowed" in e["msg"] for e in errors) + 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(): @@ -3606,4 +3614,9 @@ def test_custom_volume_named_farfield_with_automated_farfield(): root_item_type="SurfaceMesh", validation_level="VolumeMesh", ) - assert any("name 'farfield' is reserved" in e["msg"] for e in errors) + 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." + ) From 1a5fe4188044929bae412df351f2f27f76510fa6 Mon Sep 17 00:00:00 2001 From: Alex Xu Date: Wed, 18 Feb 2026 01:20:23 +0000 Subject: [PATCH 11/12] address review --- flow360/component/simulation/meshing_param/params.py | 5 +++++ .../simulation/translator/volume_meshing_translator.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 839fd7a21..4cf56de0d 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -228,6 +228,11 @@ def _check_automated_farfield_custom_volumes(cls, v, param_info): "`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 name." + ) return v diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index 5ee560d41..34e606467 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -430,7 +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 later + # 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: From 98c527f7ba8a34147f6d54ccfea1536412ca752f Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Wed, 18 Feb 2026 09:38:17 -0500 Subject: [PATCH 12/12] Changed the error message a bit --- flow360/component/simulation/meshing_param/params.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 4cf56de0d..244b753a5 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -231,7 +231,7 @@ def _check_automated_farfield_custom_volumes(cls, v, param_info): 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 name." + "generated farfield boundary. Please choose a different surface." ) return v