diff --git a/flow360/component/simulation/meshing_param/meshing_specs.py b/flow360/component/simulation/meshing_param/meshing_specs.py index 8af927731..0400f26ff 100644 --- a/flow360/component/simulation/meshing_param/meshing_specs.py +++ b/flow360/component/simulation/meshing_param/meshing_specs.py @@ -198,7 +198,8 @@ class MeshingDefaults(Flow360BaseModel): None, description="Target number of surface mesh nodes. When specified, the surface mesher " "will rescale the meshing parameters to achieve approximately this number of nodes. " - "This option is only supported when using geometry AI and can not be overridden per face.", + "This option is only supported by the beta surface mesher or when using geometry AI, " + "and can not be overridden per face.", context=SURFACE_MESH, ) @@ -337,7 +338,6 @@ def invalid_geometry_accuracy(cls, value, param_info: ParamsValidationInfo): @contextual_field_validator( "surface_max_aspect_ratio", "surface_max_adaptation_iterations", - "target_surface_node_count", "resolve_face_boundaries", "preserve_thin_geometry", "sealing_size", @@ -350,6 +350,14 @@ def ensure_geometry_ai_features(cls, value, info, param_info: ParamsValidationIn """Validate that the feature is only used when Geometry AI is enabled.""" return check_geometry_ai_features(cls, value, info, param_info) + @contextual_field_validator("target_surface_node_count", mode="after") + @classmethod + def ensure_target_surface_node_count_mesher(cls, value, param_info: ParamsValidationInfo): + """Validate that target_surface_node_count is only used with geometry AI or beta mesher.""" + if value is not None and not (param_info.use_geometry_AI or param_info.is_beta_mesher): + raise ValueError("target_surface_node_count is not supported by the legacy mesher.") + return value + @contextual_field_validator("octree_spacing", mode="after") @classmethod def _set_default_octree_spacing(cls, octree_spacing, param_info: ParamsValidationInfo): diff --git a/flow360/component/simulation/translator/surface_meshing_translator.py b/flow360/component/simulation/translator/surface_meshing_translator.py index 5de67d54c..f9dc82258 100644 --- a/flow360/component/simulation/translator/surface_meshing_translator.py +++ b/flow360/component/simulation/translator/surface_meshing_translator.py @@ -1,3 +1,4 @@ +# pylint:disable = too-many-lines """Surface meshing parameter translator.""" from copy import deepcopy @@ -6,6 +7,7 @@ from unyt import unyt_array from flow360.component.simulation.entity_info import GeometryEntityInfo +from flow360.component.simulation.framework.updater_utils import recursive_remove_key from flow360.component.simulation.meshing_param import snappy from flow360.component.simulation.meshing_param.edge_params import SurfaceEdgeRefinement from flow360.component.simulation.meshing_param.face_params import SurfaceRefinement @@ -636,6 +638,12 @@ def legacy_mesher_json(input_params: SimulationParams): translated["faces"] = face_config + ##:: >> Step 5.2: Get target_surface_node_count [OPTIONAL] + if input_params.meshing.defaults.target_surface_node_count is not None: + translated["target_surface_node_count"] = ( + input_params.meshing.defaults.target_surface_node_count + ) + ##:: >> Step 6: Tell surface mesher how do we group boundaries. translated["boundaries"] = {} grouped_faces: List[Surface] = ( @@ -965,6 +973,12 @@ def filter_simulation_json(input_params: SimulationParams, mesh_units): # Filter the JSON to only include the GAI surface meshing parameters filtered_json = _traverse_and_filter(json_data, whitelist) + # Remove selectors from the filtered JSON. After selector expansion (done by + # @preprocess_input), selectors are redundant — only stored_entities matter + # for meshing. Selectors contain random UUIDs (selector_id) that would cause + # hash inconsistency between identical runs. + recursive_remove_key(filtered_json, "selectors") + return filtered_json diff --git a/flow360/examples/DTU_WindTurbine.py b/flow360/examples/DTU_WindTurbine.py new file mode 100644 index 000000000..f0ca9fb72 --- /dev/null +++ b/flow360/examples/DTU_WindTurbine.py @@ -0,0 +1,12 @@ +""" +DTU Wind Turbine example +""" + +from .base_test_case import BaseTestCase + + +class DTU_WindTurbine(BaseTestCase): + name = "dtu_wind_turbine" + + class url: + geometry = "https://simcloud-public-1.s3.amazonaws.com/dtu10MWrwt/geometry.egads" diff --git a/flow360/examples/__init__.py b/flow360/examples/__init__.py index cb72eb94e..0312a4b11 100644 --- a/flow360/examples/__init__.py +++ b/flow360/examples/__init__.py @@ -9,6 +9,7 @@ from .cylinder3D import Cylinder3D from .DARPA_SUBOFF import DARPA_SUBOFF from .drivaer import DrivAer +from .DTU_WindTurbine import DTU_WindTurbine from .evtol import EVTOL from .f1_2025 import F1_2025 from .isolated_propeller import IsolatedPropeller @@ -43,6 +44,7 @@ "Cylinder2D", "Cylinder3D", "DARPA_SUBOFF", + "DTU_WindTurbine", "DrivAer", "EVTOL", "F1_2025", diff --git a/tests/simulation/translator/test_surface_meshing_translator.py b/tests/simulation/translator/test_surface_meshing_translator.py index 4671b29ad..5ff822124 100644 --- a/tests/simulation/translator/test_surface_meshing_translator.py +++ b/tests/simulation/translator/test_surface_meshing_translator.py @@ -1301,6 +1301,83 @@ def test_gai_translator_hashing_ignores_id(): ), f"Hashes should be identical despite different UUIDs:\n Hash 1: {hashes[0]}\n Hash 2: {hashes[1]}" +def test_gai_translator_selectors_do_not_affect_hash(): + """Test that selectors (with random selector_id) are removed from GAI translation output, + ensuring identical inputs produce identical hashes across runs.""" + + from flow360.component.simulation.framework.entity_selector import SurfaceSelector + + hashes = [] + + for _ in range(2): + geometry = Geometry.from_local_storage( + geometry_id="geo-e5c01a98-2180-449e-b255-d60162854a83", + local_storage_path=os.path.join( + os.path.dirname(__file__), "data", "gai_geometry_entity_info" + ), + meta_data=GeometryMeta( + **local_metadata_builder( + id="geo-e5c01a98-2180-449e-b255-d60162854a83", + name="aaa", + cloud_path_prefix="aaa", + status="processed", + ) + ), + ) + geometry.group_faces_by_tag("faceId") + geometry.group_edges_by_tag("edgeId") + geometry.group_bodies_by_tag("groupByFile") + + with create_draft(new_run_from=geometry) as draft: + with SI_unit_system: + selector = SurfaceSelector(name="all_faces").match("*") + + params = SimulationParams( + meshing=MeshingParams( + defaults=MeshingDefaults( + geometry_accuracy=0.05 * u.m, + surface_max_edge_length=0.2, + ), + refinements=[ + SurfaceRefinement( + name="selector_based_refinement", + max_edge_length=0.1, + faces=[selector], + ), + ], + ), + operating_condition=AerospaceCondition( + velocity_magnitude=10 * u.m / u.s, + ), + ) + + params = set_up_params_for_uploading( + root_asset=geometry, + length_unit=1 * u.m, + params=params, + use_beta_mesher=True, + use_geometry_AI=True, + ) + + params, err, _ = validate_params_with_context(params, "Geometry", "SurfaceMesh") + assert err is None, f"Validation error: {err}" + + translated = get_surface_meshing_json(params, mesh_unit=1 * u.m) + + # Verify no selectors in translated output + translated_str = json.dumps(translated) + assert ( + "selectors" not in translated_str + ), "Translated GAI output should not contain selectors after expansion" + + hash_value = SimulationParams._calculate_hash(translated) + hashes.append(hash_value) + + assert ( + hashes[0] == hashes[1] + ), f"Hashes should be identical across runs:\n Hash 1: {hashes[0]}\n Hash 2: {hashes[1]}" + + def test_gai_analytic_wind_tunnel_farfield(): with SI_unit_system: wind_tunnel = WindTunnelFarfield( @@ -1853,8 +1930,34 @@ def test_gai_target_surface_node_count_absent(): assert "target_surface_node_count" not in translated["meshing"]["defaults"] +def test_beta_mesher_target_surface_node_count_set(get_om6wing_geometry): + """target_surface_node_count is accepted and translated when using the beta surface mesher.""" + my_geometry = TempGeometry("om6wing.csm") + with SI_unit_system: + params = SimulationParams( + private_attribute_asset_cache=AssetCache( + project_entity_info=my_geometry._get_entity_info(), + use_inhouse_mesher=True, + ), + meshing=MeshingParams( + defaults=MeshingDefaults( + surface_edge_growth_rate=1.2, + curvature_resolution_angle=12 * u.deg, + surface_max_edge_length=1 * u.m, + target_surface_node_count=50000, + edge_split_layers=0, + ), + ), + ) + + params, err, warnings = validate_params_with_context(params, "Geometry", "SurfaceMesh") + assert err is None, f"Unexpected validation error: {err}" + translated = get_surface_meshing_json(params, mesh_unit=get_om6wing_geometry.mesh_unit) + assert translated["target_surface_node_count"] == 50000 + + def test_legacy_target_surface_node_count_rejected(get_om6wing_geometry): - """target_surface_node_count is rejected for legacy (non-GAI) flows.""" + """target_surface_node_count is rejected for the legacy mesher.""" my_geometry = TempGeometry("om6wing.csm") with SI_unit_system: params = SimulationParams(