From 62fdf92a8d0037dc75ef1c2e980c975e18bb160b Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Tue, 7 Oct 2025 14:27:49 -0400 Subject: [PATCH 1/3] [FXC-3529] Introduce `EntitySelector` data model into `EntityList` (#1466) * [FXC-3529] Introduce EntitySelector data model into EntityList * Remove unnecessary changes --- .../simulation/framework/entity_base.py | 30 +++++++++---- .../simulation/framework/entity_selector.py | 45 +++++++++++++++++++ .../simulation/framework/unique_list.py | 6 ++- tests/simulation/converter/ref/ref_c81.json | 1 + tests/simulation/converter/ref/ref_dfdc.json | 1 + .../simulation/converter/ref/ref_monitor.json | 2 +- .../converter/ref/ref_single_bet_disk.json | 10 +++-- tests/simulation/converter/ref/ref_xfoil.json | 1 + .../simulation/converter/ref/ref_xrotor.json | 1 + 9 files changed, 82 insertions(+), 15 deletions(-) create mode 100644 flow360/component/simulation/framework/entity_selector.py diff --git a/flow360/component/simulation/framework/entity_base.py b/flow360/component/simulation/framework/entity_base.py index f13bc1b88..b30817800 100644 --- a/flow360/component/simulation/framework/entity_base.py +++ b/flow360/component/simulation/framework/entity_base.py @@ -12,6 +12,7 @@ import pydantic as pd from flow360.component.simulation.framework.base_model import Flow360BaseModel +from flow360.component.simulation.framework.entity_selector import EntitySelector from flow360.component.simulation.utils import is_exact_instance @@ -211,8 +212,6 @@ class EntityList(Flow360BaseModel, metaclass=_EntityListMeta): instances of `Box`, `Cylinder`, or strings representing naming patterns. Methods: - _format_input_to_list(cls, input: List) -> dict: Class method that formats the input to a - dictionary with the key 'stored_entities'. _check_duplicate_entity_in_list(cls, values): Class method that checks for duplicate entities in the list of stored entities. _get_expanded_entities(self): Method that processes the stored entities to resolve any naming @@ -222,6 +221,9 @@ class EntityList(Flow360BaseModel, metaclass=_EntityListMeta): """ stored_entities: List = pd.Field() + selectors: Optional[List[EntitySelector]] = pd.Field( + None, description="Selectors for rule-based selection." + ) @classmethod def _get_valid_entity_types(cls): @@ -268,18 +270,21 @@ def _valid_individual_input(cls, input_data): @pd.model_validator(mode="before") @classmethod - def _format_input_to_list(cls, input_data: Union[dict, list]): + def deserializer(cls, input_data: Union[dict, list]): """ Flatten List[EntityBase] and put into stored_entities. """ entities_to_store = [] + entity_patterns_to_store = [] valid_types = cls._get_valid_entity_types() - if isinstance(input_data, list): # A list of entities + if isinstance(input_data, list): + # -- User input mode. -- + # List content might be entity Python objects or selector Python objects if input_data == []: raise ValueError("Invalid input type to `entities`, list is empty.") for item in input_data: - if isinstance(item, list): # Nested list comes from assets + if isinstance(item, list): # Nested list comes from assets __getitem__ _ = [cls._valid_individual_input(individual) for individual in item] # pylint: disable=fixme # TODO: Give notice when some of the entities are not selected due to `valid_types`? @@ -299,11 +304,17 @@ def _format_input_to_list(cls, input_data: Union[dict, list]): raise KeyError( f"Invalid input type to `entities`, dict {input_data} is missing the key 'stored_entities'." ) - return {"stored_entities": input_data["stored_entities"]} + return { + "stored_entities": input_data["stored_entities"], + "selectors": None if not entity_patterns_to_store else entity_patterns_to_store, + } # pylint: disable=no-else-return else: # Single entity if input_data is None: - return {"stored_entities": None} + return { + "stored_entities": None, + "selectors": None if not entity_patterns_to_store else entity_patterns_to_store, + } else: cls._valid_individual_input(input_data) if is_exact_instance(input_data, tuple(valid_types)): @@ -315,7 +326,10 @@ def _format_input_to_list(cls, input_data: Union[dict, list]): f" from the input." ) - return {"stored_entities": entities_to_store} + return { + "stored_entities": entities_to_store, + "selectors": None if not entity_patterns_to_store else entity_patterns_to_store, + } def _get_expanded_entities( self, diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py new file mode 100644 index 000000000..78f41facc --- /dev/null +++ b/flow360/component/simulation/framework/entity_selector.py @@ -0,0 +1,45 @@ +""" +Entity selector models + +Defines a minimal, stable schema for selecting entities by rules. +""" + +from typing import List, Literal, Optional, Union + +import pydantic as pd + +from flow360.component.simulation.framework.base_model import Flow360BaseModel + +TargetClass = Literal["Surface", "GhostSurface", "Edge", "Volume"] + + +class Predicate(Flow360BaseModel): + """ + Single predicate in a selector. + """ + + # For now only name matching is supported + attribute: Literal["name"] = pd.Field("name") + operator: Literal[ + "equals", + "notEquals", + "in", + "notIn", + "matches", + "notMatches", + ] = pd.Field() + value: Union[str, List[str]] = pd.Field() + # Applies only to matches/notMatches; default to glob if not specified explicitly. + pattern_syntax: Optional[Literal["glob", "regex"]] = pd.Field("glob") + + +class EntitySelector(Flow360BaseModel): + """Entity selector for an EntityList. + + - target_class chooses the entity pool + - logic combines child predicates (AND = intersection, OR = union) + """ + + target_class: TargetClass = pd.Field() + logic: Literal["AND", "OR"] = pd.Field("AND") + children: List[Predicate] = pd.Field() diff --git a/flow360/component/simulation/framework/unique_list.py b/flow360/component/simulation/framework/unique_list.py index fc13f8290..6e43a3c5e 100644 --- a/flow360/component/simulation/framework/unique_list.py +++ b/flow360/component/simulation/framework/unique_list.py @@ -54,7 +54,8 @@ def check_unique(cls, v): @pd.model_validator(mode="before") @classmethod - def _format_input_to_list(cls, input_data: Union[dict, list, Any]): + def deserializer(cls, input_data: Union[dict, list, Any]): + """Deserializer handling both JSON dict input as well as Python object input""" if isinstance(input_data, list): return {"items": input_data} if isinstance(input_data, dict): @@ -83,7 +84,8 @@ class UniqueStringList(Flow360BaseModel): @pd.model_validator(mode="before") @classmethod - def _format_input_to_list(cls, input_data: Union[dict, list, str]): + def deserializer(cls, input_data: Union[dict, list, str]): + """Deserializer handling both JSON dict input as well as Python object input""" if isinstance(input_data, list): return {"items": input_data} if isinstance(input_data, dict): diff --git a/tests/simulation/converter/ref/ref_c81.json b/tests/simulation/converter/ref/ref_c81.json index 26fa35a91..f9d2394fe 100644 --- a/tests/simulation/converter/ref/ref_c81.json +++ b/tests/simulation/converter/ref/ref_c81.json @@ -2,6 +2,7 @@ "name": "BET disk", "type": "BETDisk", "entities": { + "selectors": null, "stored_entities": [ { "private_attribute_registry_bucket_name": "VolumetricEntityType", diff --git a/tests/simulation/converter/ref/ref_dfdc.json b/tests/simulation/converter/ref/ref_dfdc.json index 8f7febe18..ecbddb3e3 100644 --- a/tests/simulation/converter/ref/ref_dfdc.json +++ b/tests/simulation/converter/ref/ref_dfdc.json @@ -2,6 +2,7 @@ "name": "BET disk", "type": "BETDisk", "entities": { + "selectors": null, "stored_entities": [ { "private_attribute_registry_bucket_name": "VolumetricEntityType", diff --git a/tests/simulation/converter/ref/ref_monitor.json b/tests/simulation/converter/ref/ref_monitor.json index 20738a0aa..ba611ff30 100644 --- a/tests/simulation/converter/ref/ref_monitor.json +++ b/tests/simulation/converter/ref/ref_monitor.json @@ -1 +1 @@ -{"version":"25.7.4b0","unit_system":{"name":"SI"},"meshing":null,"reference_geometry":null,"operating_condition":null,"models":[{"material":{"type":"air","name":"air","dynamic_viscosity":{"reference_viscosity":{"value":0.00001716,"units":"Pa*s"},"reference_temperature":{"value":273.15,"units":"K"},"effective_temperature":{"value":110.4,"units":"K"}}},"initial_condition":{"type_name":"NavierStokesInitialCondition","constants":null,"rho":"rho","u":"u","v":"v","w":"w","p":"p"},"type":"Fluid","navier_stokes_solver":{"absolute_tolerance":1e-10,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":1,"linear_solver":{"max_iterations":30,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":1.0,"kappa_MUSCL":-1.0,"numerical_dissipation_factor":1.0,"limit_velocity":false,"limit_pressure_density":false,"type_name":"Compressible","low_mach_preconditioner":false,"low_mach_preconditioner_threshold":null,"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0},"turbulence_model_solver":{"absolute_tolerance":1e-8,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":4,"linear_solver":{"max_iterations":20,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":2.0,"type_name":"SpalartAllmaras","reconstruction_gradient_limiter":0.5,"quadratic_constitutive_relation":false,"modeling_constants":{"type_name":"SpalartAllmarasConsts","C_DES":0.72,"C_d":8.0,"C_cb1":0.1355,"C_cb2":0.622,"C_sigma":0.6666666666666666,"C_v1":7.1,"C_vonKarman":0.41,"C_w2":0.3,"C_w4":0.21,"C_w5":1.5,"C_t3":1.2,"C_t4":0.5,"C_min_rd":10.0},"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0,"hybrid_model":null,"rotation_correction":false, "controls":null,"low_reynolds_correction":false},"stopping_criterion": null,"transition_model_solver":{"type_name":"None"}}],"time_stepping":{"type_name":"Steady","max_steps":2000,"CFL":{"type":"adaptive","min":0.1,"max":10000.0,"max_relative_change":1.0,"convergence_limiting_factor":0.25}},"user_defined_dynamics":null,"user_defined_fields":[],"outputs":[{"name":"R1","entities":{"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"b9de2bce-36c1-4bbf-af0a-2c6a2ab713a4","name":"Point-0","location":{"value":[2.694298,0.0,1.0195910000000001],"units":"m"}}]},"output_fields":{"items":["primitiveVars"]},"moving_statistic": null,"output_type":"ProbeOutput"},{"name":"V3","entities":{"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"a79cffc0-31d0-499d-906c-f271c2320166","name":"Point-1","location":{"value":[4.007,0.0,-0.31760000000000005],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"8947eb10-fc59-4102-b9c7-168a91ca22b9","name":"Point-2","location":{"value":[4.007,0.0,-0.29760000000000003],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"27ac4e03-592b-4dba-8fa1-8f6678087a96","name":"Point-3","location":{"value":[4.007,0.0,-0.2776],"units":"m"}}]},"output_fields":{"items":["mut"]},"moving_statistic": null,"output_type":"ProbeOutput"}],"private_attribute_asset_cache":{"project_length_unit":null,"project_entity_info":null, "use_inhouse_mesher": false, "variable_context":null, "use_geometry_AI": false}} +{"version":"25.7.4b0","unit_system":{"name":"SI"},"meshing":null,"reference_geometry":null,"operating_condition":null,"models":[{"material":{"type":"air","name":"air","dynamic_viscosity":{"reference_viscosity":{"value":0.00001716,"units":"Pa*s"},"reference_temperature":{"value":273.15,"units":"K"},"effective_temperature":{"value":110.4,"units":"K"}}},"initial_condition":{"type_name":"NavierStokesInitialCondition","constants":null,"rho":"rho","u":"u","v":"v","w":"w","p":"p"},"type":"Fluid","navier_stokes_solver":{"absolute_tolerance":1e-10,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":1,"linear_solver":{"max_iterations":30,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":1.0,"kappa_MUSCL":-1.0,"numerical_dissipation_factor":1.0,"limit_velocity":false,"limit_pressure_density":false,"type_name":"Compressible","low_mach_preconditioner":false,"low_mach_preconditioner_threshold":null,"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0},"turbulence_model_solver":{"absolute_tolerance":1e-8,"relative_tolerance":0.0,"order_of_accuracy":2,"equation_evaluation_frequency":4,"linear_solver":{"max_iterations":20,"absolute_tolerance":null,"relative_tolerance":null},"private_attribute_dict":null,"CFL_multiplier":2.0,"type_name":"SpalartAllmaras","reconstruction_gradient_limiter":0.5,"quadratic_constitutive_relation":false,"modeling_constants":{"type_name":"SpalartAllmarasConsts","C_DES":0.72,"C_d":8.0,"C_cb1":0.1355,"C_cb2":0.622,"C_sigma":0.6666666666666666,"C_v1":7.1,"C_vonKarman":0.41,"C_w2":0.3,"C_w4":0.21,"C_w5":1.5,"C_t3":1.2,"C_t4":0.5,"C_min_rd":10.0},"update_jacobian_frequency":4,"max_force_jac_update_physical_steps":0,"hybrid_model":null,"rotation_correction":false, "controls":null,"low_reynolds_correction":false},"stopping_criterion": null,"transition_model_solver":{"type_name":"None"}}],"time_stepping":{"type_name":"Steady","max_steps":2000,"CFL":{"type":"adaptive","min":0.1,"max":10000.0,"max_relative_change":1.0,"convergence_limiting_factor":0.25}},"user_defined_dynamics":null,"user_defined_fields":[],"outputs":[{"name":"R1","entities":{"selectors": null,"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"b9de2bce-36c1-4bbf-af0a-2c6a2ab713a4","name":"Point-0","location":{"value":[2.694298,0.0,1.0195910000000001],"units":"m"}}]},"output_fields":{"items":["primitiveVars"]},"moving_statistic": null,"output_type":"ProbeOutput"},{"name":"V3","entities":{"selectors": null,"stored_entities":[{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"a79cffc0-31d0-499d-906c-f271c2320166","name":"Point-1","location":{"value":[4.007,0.0,-0.31760000000000005],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"8947eb10-fc59-4102-b9c7-168a91ca22b9","name":"Point-2","location":{"value":[4.007,0.0,-0.29760000000000003],"units":"m"}},{"private_attribute_registry_bucket_name":"PointEntityType","private_attribute_entity_type_name":"Point","private_attribute_id":"27ac4e03-592b-4dba-8fa1-8f6678087a96","name":"Point-3","location":{"value":[4.007,0.0,-0.2776],"units":"m"}}]},"output_fields":{"items":["mut"]},"moving_statistic": null,"output_type":"ProbeOutput"}],"private_attribute_asset_cache":{"project_length_unit":null,"project_entity_info":null, "use_inhouse_mesher": false, "variable_context":null, "use_geometry_AI": false}} diff --git a/tests/simulation/converter/ref/ref_single_bet_disk.json b/tests/simulation/converter/ref/ref_single_bet_disk.json index 31e94cb86..895b96ae2 100644 --- a/tests/simulation/converter/ref/ref_single_bet_disk.json +++ b/tests/simulation/converter/ref/ref_single_bet_disk.json @@ -416,6 +416,7 @@ } ], "entities": { + "selectors": null, "stored_entities": [ { "axis": [ @@ -469,10 +470,11 @@ "angle_unit": null, "blade_line_chord": null, "chord_ref": { - "value": 14.0, - "units": "cm" + "units": "cm", + "value": 14.0 }, "entities": { + "selectors": null, "stored_entities": [ { "axis": [ @@ -517,8 +519,8 @@ "name": "MyBETDisk", "number_of_blades": 3, "omega": { - "value": 156.5352426717779, - "units": "rad/s" + "units": "rad/s", + "value": 156.5352426717779 }, "rotation_direction_rule": "leftHand" }, diff --git a/tests/simulation/converter/ref/ref_xfoil.json b/tests/simulation/converter/ref/ref_xfoil.json index 01956245e..4d76f3963 100644 --- a/tests/simulation/converter/ref/ref_xfoil.json +++ b/tests/simulation/converter/ref/ref_xfoil.json @@ -2,6 +2,7 @@ "name": "BET disk", "type": "BETDisk", "entities": { + "selectors": null, "stored_entities": [ { "private_attribute_registry_bucket_name": "VolumetricEntityType", diff --git a/tests/simulation/converter/ref/ref_xrotor.json b/tests/simulation/converter/ref/ref_xrotor.json index a5e705491..1806a901c 100644 --- a/tests/simulation/converter/ref/ref_xrotor.json +++ b/tests/simulation/converter/ref/ref_xrotor.json @@ -2,6 +2,7 @@ "name": "BET disk", "type": "BETDisk", "entities": { + "selectors": null, "stored_entities": [ { "private_attribute_registry_bucket_name": "VolumetricEntityType", From eb83310bc9fc440266b25c2379aa37bcafe8cf9a Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:26:36 -0400 Subject: [PATCH 2/3] [FXC-3530] Added `EntitySelector` expansion function (#1471) * Added the get_entity_database_for_selectors * WIP * Added support for generic Glob patterns * Ready * Added case sensitivity on Windows --- .gitignore | 1 + flow360/component/simulation/entity_info.py | 123 +++++- .../simulation/framework/entity_base.py | 12 +- .../simulation/framework/entity_selector.py | 348 +++++++++++++++- flow360/component/simulation/services.py | 25 +- .../component/simulation/services_utils.py | 56 +++ poetry.lock | 38 +- pyproject.toml | 1 + .../framework/test_entity_dict_database.py | 176 +++++++++ .../framework/test_entity_expansion_impl.py | 372 ++++++++++++++++++ .../entity_expansion_service_ref_outputs.json | 51 +++ 11 files changed, 1171 insertions(+), 32 deletions(-) create mode 100644 flow360/component/simulation/services_utils.py create mode 100644 tests/simulation/framework/test_entity_dict_database.py create mode 100644 tests/simulation/framework/test_entity_expansion_impl.py create mode 100644 tests/simulation/ref/entity_expansion_service_ref_outputs.json diff --git a/.gitignore b/.gitignore index 713bd825e..79af4d8a9 100644 --- a/.gitignore +++ b/.gitignore @@ -329,6 +329,7 @@ $RECYCLE.BIN/ tmp/ /.vscode +/.cursor # test residual flow360/examples/cylinder2D/flow360mesh.json diff --git a/flow360/component/simulation/entity_info.py b/flow360/component/simulation/entity_info.py index ee60e537d..2cbc1d2e6 100644 --- a/flow360/component/simulation/entity_info.py +++ b/flow360/component/simulation/entity_info.py @@ -8,6 +8,7 @@ from flow360.component.simulation.framework.base_model import Flow360BaseModel from flow360.component.simulation.framework.entity_registry import EntityRegistry +from flow360.component.simulation.framework.entity_selector import EntityDictDatabase from flow360.component.simulation.outputs.output_entities import ( Point, PointArray, @@ -586,12 +587,118 @@ def parse_entity_info_model(data) -> EntityInfoUnion: return pd.TypeAdapter(EntityInfoUnion).validate_python(data) -def get_entity_info_type_from_str(entity_type: str) -> type[EntityInfoModel]: - """Get EntityInfo type from the asset type from the project tree""" - entity_info_type = None - if entity_type == "Geometry": - entity_info_type = GeometryEntityInfo - if entity_type == "VolumeMesh": - entity_info_type = VolumeMeshEntityInfo +def _get_grouped_entities_from_geometry(entity_info: dict, entity_type_name: str) -> list: + """ + Extract entities based on current grouping tag for GeometryEntityInfo. + + Mimics the logic from GeometryEntityInfo._get_list_of_entities. + """ + if entity_type_name == "face": + attribute_names = entity_info.get("face_attribute_names", []) + grouped_list = entity_info.get("grouped_faces", []) + group_tag = entity_info.get("face_group_tag") + elif entity_type_name == "edge": + attribute_names = entity_info.get("edge_attribute_names", []) + grouped_list = entity_info.get("grouped_edges", []) + group_tag = entity_info.get("edge_group_tag") + elif entity_type_name == "body": + attribute_names = entity_info.get("body_attribute_names", []) + grouped_list = entity_info.get("grouped_bodies", []) + group_tag = entity_info.get("body_group_tag") + else: + return [] + + # If no grouping tag is set, use the default (first non-ID tag) + if group_tag is None: + if not attribute_names: + return [] + # Get first non-ID tag (mimics _get_default_grouping_tag logic) + id_tag = f"{entity_type_name}Id" + for tag in attribute_names: + if tag != id_tag: + group_tag = tag + break + if group_tag is None: + group_tag = id_tag + + # Find the index of the grouping tag in attribute_names + if group_tag in attribute_names: + index = attribute_names.index(group_tag) + if index < len(grouped_list): + return grouped_list[index] + + return [] + + +def _extract_geometry_entities(entity_info: dict) -> tuple[list, list, list]: + """Extract entities from GeometryEntityInfo.""" + surfaces = _get_grouped_entities_from_geometry(entity_info, "face") + + edges = [] + if entity_info.get("edge_ids"): + edges = _get_grouped_entities_from_geometry(entity_info, "edge") + + geometry_body_groups = [] + if entity_info.get("body_attribute_names"): + geometry_body_groups = _get_grouped_entities_from_geometry(entity_info, "body") + + return surfaces, edges, geometry_body_groups + + +def _extract_volume_mesh_entities(entity_info: dict) -> tuple[list, list]: + """Extract entities from VolumeMeshEntityInfo.""" + surfaces = entity_info.get("boundaries", []) + generic_volumes = entity_info.get("zones", []) + return surfaces, generic_volumes + + +def _extract_surface_mesh_entities(entity_info: dict) -> list: + """Extract entities from SurfaceMeshEntityInfo.""" + return entity_info.get("boundaries", []) + + +def get_entity_database_for_selectors(params_as_dict: dict) -> EntityDictDatabase: + """ + Go through the simulation json and retrieve the entity database for entity selectors. - return entity_info_type + This function extracts all entities from private_attribute_asset_cache and converts them + to dictionary format for use in entity selection operations. For GeometryEntityInfo, it + respects the current grouping tags (face_group_tag, edge_group_tag, body_group_tag). + + Parameters: + params_as_dict: Simulation parameters as dictionary containing private_attribute_asset_cache + + Returns: + EntityDictDatabase: Database containing all available entities as dictionaries + """ + # Extract and validate asset cache + asset_cache = params_as_dict.get("private_attribute_asset_cache") + if asset_cache is None: + raise ValueError("[Internal] private_attribute_asset_cache not found in params_as_dict.") + + entity_info = asset_cache.get("project_entity_info") + if entity_info is None: + raise ValueError("[Internal] project_entity_info not found in asset cache.") + + # Initialize empty lists + surfaces = [] + edges = [] + generic_volumes = [] + geometry_body_groups = [] + + # Process based on entity info type + entity_info_type = entity_info.get("type_name") + + if entity_info_type == "GeometryEntityInfo": + surfaces, edges, geometry_body_groups = _extract_geometry_entities(entity_info) + elif entity_info_type == "VolumeMeshEntityInfo": + surfaces, generic_volumes = _extract_volume_mesh_entities(entity_info) + elif entity_info_type == "SurfaceMeshEntityInfo": + surfaces = _extract_surface_mesh_entities(entity_info) + + return EntityDictDatabase( + surfaces=surfaces, + edges=edges, + generic_volumes=generic_volumes, + geometry_body_groups=geometry_body_groups, + ) diff --git a/flow360/component/simulation/framework/entity_base.py b/flow360/component/simulation/framework/entity_base.py index b30817800..6e640b698 100644 --- a/flow360/component/simulation/framework/entity_base.py +++ b/flow360/component/simulation/framework/entity_base.py @@ -205,24 +205,16 @@ def _remove_duplicate_entities(expanded_entities: List[EntityBase]): class EntityList(Flow360BaseModel, metaclass=_EntityListMeta): """ - The type accepting a list of entities or (name, registry) pair. + The type accepting a list of entities or selectors. Attributes: stored_entities (List[Union[EntityBase, Tuple[str, registry]]]): List of stored entities, which can be instances of `Box`, `Cylinder`, or strings representing naming patterns. - - Methods: - _check_duplicate_entity_in_list(cls, values): Class method that checks for duplicate entities - in the list of stored entities. - _get_expanded_entities(self): Method that processes the stored entities to resolve any naming - patterns into actual entity references, expanding and filtering based on the defined - entity types. - """ stored_entities: List = pd.Field() selectors: Optional[List[EntitySelector]] = pd.Field( - None, description="Selectors for rule-based selection." + None, description="Selectors on persistent entities for rule-based selection." ) @classmethod diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py index 78f41facc..9122cb69e 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -4,13 +4,18 @@ Defines a minimal, stable schema for selecting entities by rules. """ -from typing import List, Literal, Optional, Union +import re +from collections import deque +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Any, List, Literal, Optional, Union import pydantic as pd from flow360.component.simulation.framework.base_model import Flow360BaseModel -TargetClass = Literal["Surface", "GhostSurface", "Edge", "Volume"] +# These corresponds to the private_attribute_entity_type_name of supported entity types. +TargetClass = Literal["Surface", "Edge", "GenericVolume", "GeometryBodyGroup"] class Predicate(Flow360BaseModel): @@ -19,7 +24,7 @@ class Predicate(Flow360BaseModel): """ # For now only name matching is supported - attribute: Literal["name"] = pd.Field("name") + attribute: Literal["name"] = pd.Field("name", description="The attribute to match/filter on.") operator: Literal[ "equals", "notEquals", @@ -30,7 +35,11 @@ class Predicate(Flow360BaseModel): ] = pd.Field() value: Union[str, List[str]] = pd.Field() # Applies only to matches/notMatches; default to glob if not specified explicitly. - pattern_syntax: Optional[Literal["glob", "regex"]] = pd.Field("glob") + non_glob_syntax: Optional[Literal["regex"]] = pd.Field( + None, + description="If specified, the pattern (`value`) will be treated " + "as a non-glob pattern with the specified syntax.", + ) class EntitySelector(Flow360BaseModel): @@ -43,3 +52,334 @@ class EntitySelector(Flow360BaseModel): target_class: TargetClass = pd.Field() logic: Literal["AND", "OR"] = pd.Field("AND") children: List[Predicate] = pd.Field() + + +@dataclass +class EntityDictDatabase: + """ + [Internal Use Only] + + Entity database for entity selectors. Provides a unified data interface for entity selectors. + + This is intended to strip off differences between root resources and + ensure the expansion has a uniform data interface. + """ + + surfaces: list[dict] = field(default_factory=list) + edges: list[dict] = field(default_factory=list) + generic_volumes: list[dict] = field(default_factory=list) + geometry_body_groups: list[dict] = field(default_factory=list) + + +def _get_entity_pool(entity_database: EntityDictDatabase, target_class: TargetClass) -> list[dict]: + """Return the correct entity list from the database for the target class.""" + if target_class == "Surface": + return entity_database.surfaces + if target_class == "Edge": + return entity_database.edges + if target_class == "GenericVolume": + return entity_database.generic_volumes + if target_class == "GeometryBodyGroup": + return entity_database.geometry_body_groups + raise ValueError(f"Unknown target class: {target_class}") + + +@lru_cache(maxsize=2048) +def _compile_regex_cached(pattern: str) -> re.Pattern: + return re.compile(pattern) + + +@lru_cache(maxsize=2048) +def _compile_glob_cached(pattern: str) -> re.Pattern: + """Compile an extended-glob pattern via wcmatch to a fullmatch-ready regex. + + We enable extended glob features including brace expansion, extglob groups, + and globstar. We intentionally avoid PATHNAME semantics because entity + names are not paths in this context, and we keep case-sensitive matching to + remain predictable across platforms. + """ + # Strong requirement: wcmatch must be present to support full glob features. + try: + # pylint: disable=import-outside-toplevel + from wcmatch import fnmatch as wfnmatch + except Exception as exc: # pragma: no cover - explicit failure path + raise RuntimeError( + "wcmatch is required for extended glob support. Please install 'wcmatch>=10.0'." + ) from exc + + # Enforce case-sensitive matching across platforms (Windows defaults to case-insensitive). + wc_flags = wfnmatch.BRACE | wfnmatch.EXTMATCH | wfnmatch.DOTMATCH | wfnmatch.CASE + translated = wfnmatch.translate(pattern, flags=wc_flags) + # wcmatch.translate may return a tuple: (list_of_regex_strings, list_of_flags) + if isinstance(translated, tuple): + regex_parts, _flags = translated + if isinstance(regex_parts, list) and len(regex_parts) > 1: + + def _strip_anchors(expr: str) -> str: + if expr.startswith("^"): + expr = expr[1:] + if expr.endswith("$"): + expr = expr[:-1] + return expr + + stripped = [_strip_anchors(s) for s in regex_parts] + combined = "^(?:" + ")|(?:".join(stripped) + ")$" + return re.compile(combined) + if isinstance(regex_parts, list) and len(regex_parts) == 1: + return re.compile(regex_parts[0]) + # Otherwise, assume it's a single regex string + return re.compile(translated) + + +def _get_attribute_value(entity: dict, attribute: str) -> Optional[str]: + """Return the scalar string value of an attribute, or None if absent/unsupported. + + Only scalar string attributes are supported by this matcher layer for now. + """ + val = entity.get(attribute) + if isinstance(val, str): + return val + return None + + +def _build_value_matcher(predicate: dict): + """ + Build a fast predicate(value: Optional[str])->bool matcher. + + Precompiles regex/glob and converts membership lists to sets for speed. + """ + operator = predicate.get("operator") + value = predicate.get("value") + non_glob_syntax = predicate.get("non_glob_syntax") + + negate = False + if operator in ("notEquals", "notIn", "notMatches"): + negate = True + base_operator = { + "notEquals": "equals", + "notIn": "in", + "notMatches": "matches", + }.get(operator) + else: + base_operator = operator + + if base_operator == "equals": + target = value + + def base_match(val: Optional[str]) -> bool: + return val == target + + elif base_operator == "in": + values = set(value or []) + + def base_match(val: Optional[str]) -> bool: + return val in values + + elif base_operator == "matches": + if non_glob_syntax == "regex": + pattern = _compile_regex_cached(value) + else: + pattern = _compile_glob_cached(value) + + def base_match(val: Optional[str]) -> bool: + return isinstance(val, str) and (pattern.fullmatch(val) is not None) + + else: + + def base_match(_val: Optional[str]) -> bool: + return False + + if negate: + return lambda val: not base_match(val) + return base_match + + +def _build_index(pool: list[dict], attribute: str) -> dict[str, list[int]]: + """Build an index for equals/in lookups on a given attribute.""" + value_to_indices: dict[str, list[int]] = {} + for idx, item in enumerate(pool): + val = item.get(attribute) + if isinstance(val, str): + value_to_indices.setdefault(val, []).append(idx) + return value_to_indices + + +def _apply_or_selector( + pool: list[dict], + ordered_children: list[dict], +) -> list[dict]: + indices: set[int] = set() + for predicate in ordered_children: + attribute = predicate.get("attribute", "name") + matcher = _build_value_matcher(predicate) + for i, item in enumerate(pool): + if i in indices: + continue + if matcher(_get_attribute_value(item, attribute)): + indices.add(i) + if len(indices) >= len(pool): + break + if len(indices) * 4 < len(pool): + return [pool[i] for i in sorted(indices)] + return [pool[i] for i in range(len(pool)) if i in indices] + + +def _apply_and_selector( + pool: list[dict], + ordered_children: list[dict], + indices_by_attribute: dict[str, dict[str, list[int]]], +) -> list[dict]: + candidate_indices: Optional[set[int]] = None + + def _matched_indices_for_predicate( + predicate: dict, current_candidates: Optional[set[int]] + ) -> set[int]: + operator = predicate.get("operator") + attribute = predicate.get("attribute", "name") + if operator == "equals": + idx_map = indices_by_attribute.get(attribute) + if idx_map is not None: + return set(idx_map.get(predicate.get("value"), [])) + if operator == "in": + idx_map = indices_by_attribute.get(attribute) + if idx_map is not None: + result: set[int] = set() + for v in predicate.get("value") or []: + result.update(idx_map.get(v, [])) + return result + matcher = _build_value_matcher(predicate) + matched: set[int] = set() + if current_candidates is None: + for i, item in enumerate(pool): + if matcher(_get_attribute_value(item, attribute)): + matched.add(i) + return matched + for i in current_candidates: + if matcher(_get_attribute_value(pool[i], attribute)): + matched.add(i) + return matched + + for predicate in ordered_children: + matched = _matched_indices_for_predicate(predicate, candidate_indices) + candidate_indices = ( + matched if candidate_indices is None else candidate_indices.intersection(matched) + ) + if not candidate_indices: + return [] + + assert candidate_indices is not None + if len(candidate_indices) * 4 < len(pool): + return [pool[i] for i in sorted(candidate_indices)] + return [pool[i] for i in range(len(pool)) if i in candidate_indices] + + +def _apply_single_selector(pool: list[dict], selector_dict: dict) -> list[dict]: + """Apply one selector over a pool of entity dicts. + + Implementation notes for future readers: + - We assume selector_dict conforms to the EntitySelector schema (no validation here for speed). + - We respect the default of logic="AND" when absent. + - For performance: + * Reorder predicates under AND so that cheap/selective operations run first. + * Build a name->indices index to accelerate equals/in where beneficial. + * Precompile regex/glob matchers once per predicate. + * Short-circuit when the candidate set becomes empty. + - Result ordering is stable (by original pool index) to keep the operation idempotent. + """ + logic = selector_dict.get("logic", "AND") + children = selector_dict.get("children") or [] + + # Fast path: empty predicates -> return nothing. Empty children is actually misuse. + if not children: + return [] + + # Predicate ordering (AND only): cheap/selective first + def _cost(predicate: dict) -> int: + op = predicate.get("operator") + order = { + "equals": 0, + "in": 1, + "matches": 2, + "notEquals": 3, + "notIn": 4, + "notMatches": 5, + } + return order.get(op, 10) + + ordered_children = children if logic == "OR" else sorted(children, key=_cost) + + # Optional per-attribute indices for equals/in + attributes_needing_index = { + p.get("attribute", "name") + for p in ordered_children + if p.get("operator") in ("equals", "in") + } + indices_by_attribute: dict[str, dict[str, list[int]]] = ( + {attr: _build_index(pool, attr) for attr in attributes_needing_index} + if attributes_needing_index + else {} + ) + + if logic == "OR": + # Favor a full scan for OR to preserve predictable union behavior + # and avoid over-indexing that could complicate ordering. + return _apply_or_selector(pool, ordered_children) + + return _apply_and_selector(pool, ordered_children, indices_by_attribute) + + +def _expand_node_selectors(entity_database: EntityDictDatabase, node: dict) -> None: + selectors_value = node.get("selectors") + if not (isinstance(selectors_value, list) and len(selectors_value) > 0): + return + + additions_by_class: dict[str, list[dict]] = {} + ordered_target_classes: list[str] = [] + + for selector_dict in selectors_value: + if not isinstance(selector_dict, dict): + continue + target_class = selector_dict.get("target_class") + pool = _get_entity_pool(entity_database, target_class) + if not pool: + continue + if target_class not in additions_by_class: + additions_by_class[target_class] = [] + ordered_target_classes.append(target_class) + additions_by_class[target_class].extend(_apply_single_selector(pool, selector_dict)) + + existing = node.get("stored_entities") + base_entities: list[dict] = [] + classes_to_update = set(ordered_target_classes) + if isinstance(existing, list): + for item in existing: + etype = item.get("private_attribute_entity_type_name") + if etype in classes_to_update: + continue + base_entities.append(item) + + for target_class in ordered_target_classes: + base_entities.extend(additions_by_class.get(target_class, [])) + + node["stored_entities"] = base_entities + node["selectors"] = [] + + +def expand_entity_selectors_in_place( + entity_database: EntityDictDatabase, params_as_dict: dict +) -> dict: + """Traverse params_as_dict and expand any EntitySelector in place.""" + queue: deque[Any] = deque([params_as_dict]) + while queue: + node = queue.popleft() + if isinstance(node, dict): + _expand_node_selectors(entity_database, node) + for value in node.values(): + if isinstance(value, (dict, list)): + queue.append(value) + elif isinstance(node, list): + for item in node: + if isinstance(item, (dict, list)): + queue.append(item) + + return params_as_dict diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index fc4e9d9fa..f88128ed1 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -11,7 +11,11 @@ # Required for correct global scope initialization from flow360.component.simulation.blueprint.core.dependency_graph import DependencyGraph +from flow360.component.simulation.entity_info import get_entity_database_for_selectors from flow360.component.simulation.exposed_units import supported_units_by_front_end +from flow360.component.simulation.framework.entity_selector import ( + expand_entity_selectors_in_place as expand_entity_selectors_in_place_impl, +) from flow360.component.simulation.framework.multi_constructor_model_base import ( parse_model_dict, ) @@ -38,6 +42,7 @@ from flow360.component.simulation.outputs.outputs import SurfaceOutput from flow360.component.simulation.primitives import Box # pylint: disable=unused-import from flow360.component.simulation.primitives import Surface # For parse_model_dict +from flow360.component.simulation.services_utils import has_any_entity_selectors from flow360.component.simulation.simulation_params import ( ReferenceGeometry, SimulationParams, @@ -416,6 +421,22 @@ def initialize_variable_space(param_as_dict: dict, use_clear_context: bool = Fal return param_as_dict +def resolve_selectors(params_as_dict: dict): + """ + Expand the entity selectors in the params as dict. + """ + + # Step1: Check in the dictionary via looping and ensure selectors are present, if not just return. + if not has_any_entity_selectors(params_as_dict): + return params_as_dict + + # Step2: Parse the entity info part and retrieve the entity lookup table. + entity_database = get_entity_database_for_selectors(params_as_dict=params_as_dict) + + # Step3: Expand selectors using the entity database + return expand_entity_selectors_in_place_impl(entity_database, params_as_dict) + + def validate_model( # pylint: disable=too-many-locals *, params_as_dict, @@ -779,7 +800,7 @@ def generate_process_json( mesh_unit = _get_mesh_unit(params_as_dict) validation_level = _determine_validation_level(up_to, root_item_type) - # Note: There should not be any validation error for params_as_dict. Here is just a deserilization of the JSON + # Note: There should not be any validation error for params_as_dict. Here is just a deserialization of the JSON params, errors, _ = validate_model( params_as_dict=params_as_dict, validated_by=ValidationCalledBy.SERVICE, # This is called only by web service currently. @@ -1072,7 +1093,7 @@ def get_default_report_config() -> dict: def _parse_root_item_type_from_simulation_json(*, param_as_dict: dict): - """Deduct the root item entity type from simulation.json""" + """[External] Deduct the root item entity type from simulation.json""" try: entity_info_type = param_as_dict["private_attribute_asset_cache"]["project_entity_info"][ "type_name" diff --git a/flow360/component/simulation/services_utils.py b/flow360/component/simulation/services_utils.py new file mode 100644 index 000000000..79bdec278 --- /dev/null +++ b/flow360/component/simulation/services_utils.py @@ -0,0 +1,56 @@ +"""Utility functions for the simulation services.""" + +from collections import deque +from typing import Any + + +def has_any_entity_selectors(params_as_dict: dict) -> bool: + """Return True if there is at least one EntitySelector to expand in params_as_dict. + + This function performs a fast, non-recursive traversal (using a deque) over the + provided dictionary structure and short-circuits on the first occurrence of a + potential `EntityList`-like node with a non-empty `selectors` array. + + A node is treated as having selectors if all of the following hold: + - It is a dict containing the key `selectors` + - The value for `selectors` is a non-empty list + - The first element is a dict with at least keys `target_class` and `children` + + Parameters + ---------- + params_as_dict: dict + The simulation parameters as a plain dictionary. + + Returns + ------- + bool + True if at least one `EntitySelector`-like structure is present; otherwise False. + """ + + if not isinstance(params_as_dict, dict): + return False + + queue: deque[Any] = deque([params_as_dict]) + + while queue: + node = queue.popleft() + + if isinstance(node, dict): + # Quick structural check for selectors + selectors = node.get("selectors") + if isinstance(selectors, list) and len(selectors) > 0: + first = selectors[0] + if isinstance(first, dict) and "target_class" in first and "children" in first: + return True + + # Enqueue children + for value in node.values(): + if isinstance(value, (dict, list, tuple)): + queue.append(value) + + elif isinstance(node, (list, tuple)): + for item in node: + if isinstance(item, (dict, list, tuple)): + queue.append(item) + + return False diff --git a/poetry.lock b/poetry.lock index b34e4d76d..d799aa25a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "accessible-pygments" @@ -518,6 +518,18 @@ urllib3 = [ [package.extras] crt = ["awscrt (==0.23.8)"] +[[package]] +name = "bracex" +version = "2.6" +description = "Bash style brace expander." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952"}, + {file = "bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7"}, +] + [[package]] name = "cachecontrol" version = "0.14.3" @@ -825,7 +837,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {dev = "platform_system == \"Windows\" or sys_platform == \"win32\""} +markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} [[package]] name = "colorful" @@ -5118,7 +5130,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"}, - {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"}, {file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"}, @@ -5127,7 +5138,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"}, - {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"}, {file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"}, @@ -5136,7 +5146,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"}, - {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"}, {file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"}, @@ -5145,7 +5154,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"}, - {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"}, {file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"}, @@ -5154,7 +5162,6 @@ files = [ {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"}, - {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"}, {file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"}, {file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"}, @@ -6294,6 +6301,21 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "wcmatch" +version = "10.1" +description = "Wildcard/glob file name matcher." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a"}, + {file = "wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af"}, +] + +[package.dependencies] +bracex = ">=2.1.1" + [[package]] name = "wcwidth" version = "0.2.13" @@ -6446,4 +6468,4 @@ docs = ["autodoc_pydantic", "cairosvg", "ipython", "jinja2", "jupyter", "myst-pa [metadata] lock-version = "2.1" python-versions = ">=3.9,<3.13" -content-hash = "57c3843c1a957443a78cc80fdc9aa971ee517c6ce16b33502b855bc6831132ab" +content-hash = "d19d416da5f12993a4875cd2582350eb781f3d8d539de2771423d7cd18eead4d" diff --git a/pyproject.toml b/pyproject.toml index 7e8e21082..3b991f5d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ unyt = "^3.0.0" pandas = "^2.2.1" pylint = "^3.1.0" black = {extras = ["jupyter"], version = "^24.10.0"} +wcmatch = "^10.0" # docs autodoc_pydantic = {version="*", optional = true} diff --git a/tests/simulation/framework/test_entity_dict_database.py b/tests/simulation/framework/test_entity_dict_database.py new file mode 100644 index 000000000..12e0ea7fc --- /dev/null +++ b/tests/simulation/framework/test_entity_dict_database.py @@ -0,0 +1,176 @@ +""" +Tests for entity selector and get_entity_database_for_selectors function. +""" + +import json +import os + +import pytest + +from flow360.component.simulation.entity_info import get_entity_database_for_selectors +from flow360.component.simulation.framework.entity_selector import EntityDictDatabase + + +def _load_simulation_json(relative_path: str) -> dict: + """Helper function to load simulation JSON files.""" + test_dir = os.path.dirname(os.path.abspath(__file__)) + json_path = os.path.join(test_dir, "..", relative_path) + with open(json_path, "r", encoding="utf-8") as f: + return json.load(f) + + +def test_get_entity_database_for_geometry_entity_info(): + """ + Test get_entity_database_for_selectors with GeometryEntityInfo. + Uses geometry_grouped_by_file/simulation.json as test data. + """ + params_as_dict = _load_simulation_json("data/geometry_grouped_by_file/simulation.json") + entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] + entity_db = get_entity_database_for_selectors(params_as_dict) + + # Get expected counts from entity_info based on grouping tags + face_group_tag = entity_info.get("face_group_tag") + if face_group_tag: + face_idx = entity_info["face_attribute_names"].index(face_group_tag) + expected_surfaces_count = len(entity_info["grouped_faces"][face_idx]) + else: + expected_surfaces_count = 0 + + edge_group_tag = entity_info.get("edge_group_tag") + if edge_group_tag and entity_info.get("edge_ids"): + edge_idx = entity_info["edge_attribute_names"].index(edge_group_tag) + expected_edges_count = len(entity_info["grouped_edges"][edge_idx]) + else: + expected_edges_count = 0 + + body_group_tag = entity_info.get("body_group_tag") + if body_group_tag and entity_info.get("body_attribute_names"): + body_idx = entity_info["body_attribute_names"].index(body_group_tag) + expected_bodies_count = len(entity_info["grouped_bodies"][body_idx]) + else: + expected_bodies_count = 0 + + assert isinstance(entity_db, EntityDictDatabase) + assert len(entity_db.surfaces) == expected_surfaces_count + assert len(entity_db.edges) == expected_edges_count + assert len(entity_db.geometry_body_groups) == expected_bodies_count + assert len(entity_db.generic_volumes) == 0 + + # Verify entity type names if entities exist + if entity_db.surfaces: + assert entity_db.surfaces[0]["private_attribute_entity_type_name"] == "Surface" + if entity_db.edges: + assert entity_db.edges[0]["private_attribute_entity_type_name"] == "Edge" + if entity_db.geometry_body_groups: + assert ( + entity_db.geometry_body_groups[0]["private_attribute_entity_type_name"] + == "GeometryBodyGroup" + ) + + +def test_get_entity_database_for_volume_mesh_entity_info(): + """ + Test get_entity_database_for_selectors with VolumeMeshEntityInfo. + Uses vm_entity_provider/simulation.json as test data. + """ + params_as_dict = _load_simulation_json("data/vm_entity_provider/simulation.json") + entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] + entity_db = get_entity_database_for_selectors(params_as_dict) + + # Get expected counts from entity_info + expected_boundaries_count = len(entity_info.get("boundaries", [])) + expected_zones_count = len(entity_info.get("zones", [])) + + assert isinstance(entity_db, EntityDictDatabase) + assert len(entity_db.surfaces) == expected_boundaries_count + assert len(entity_db.generic_volumes) == expected_zones_count + assert len(entity_db.edges) == 0 + assert len(entity_db.geometry_body_groups) == 0 + + # Verify entity type names if entities exist + if entity_db.surfaces: + assert entity_db.surfaces[0]["private_attribute_entity_type_name"] == "Surface" + if entity_db.generic_volumes: + assert entity_db.generic_volumes[0]["private_attribute_entity_type_name"] == "GenericVolume" + + +def test_get_entity_database_for_surface_mesh_entity_info(): + """ + Test get_entity_database_for_selectors with SurfaceMeshEntityInfo. + Uses params/data/surface_mesh/simulation.json as test data. + """ + params_as_dict = _load_simulation_json("params/data/surface_mesh/simulation.json") + entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] + entity_db = get_entity_database_for_selectors(params_as_dict) + + # Get expected count from entity_info + expected_boundaries_count = len(entity_info.get("boundaries", [])) + + assert isinstance(entity_db, EntityDictDatabase) + assert len(entity_db.surfaces) == expected_boundaries_count + assert len(entity_db.edges) == 0 + assert len(entity_db.geometry_body_groups) == 0 + assert len(entity_db.generic_volumes) == 0 + + # Verify entity type name if entities exist + if entity_db.surfaces: + assert entity_db.surfaces[0]["private_attribute_entity_type_name"] == "Surface" + + +def test_get_entity_database_missing_asset_cache(): + """ + Test that the function raises ValueError when private_attribute_asset_cache is missing. + """ + params_as_dict = {} + + with pytest.raises(ValueError, match="private_attribute_asset_cache not found"): + get_entity_database_for_selectors(params_as_dict) + + +def test_get_entity_database_missing_entity_info(): + """ + Test that the function raises ValueError when project_entity_info is missing. + """ + params_as_dict = {"private_attribute_asset_cache": {}} + + with pytest.raises(ValueError, match="project_entity_info not found"): + get_entity_database_for_selectors(params_as_dict) + + +def test_geometry_entity_info_respects_grouping_tags(): + """ + Test that GeometryEntityInfo uses the correct grouping tags to extract entities. + Verifies the function extracts entities based on the set grouping tag. + """ + params_as_dict = _load_simulation_json("data/geometry_grouped_by_file/simulation.json") + entity_info = params_as_dict["private_attribute_asset_cache"]["project_entity_info"] + entity_db = get_entity_database_for_selectors(params_as_dict) + + # Verify face grouping + face_group_tag = entity_info.get("face_group_tag") + assert face_group_tag is not None, "Test data should have face_group_tag set" + + face_attribute_names = entity_info.get("face_attribute_names", []) + grouped_faces = entity_info.get("grouped_faces", []) + index = face_attribute_names.index(face_group_tag) + expected_faces = grouped_faces[index] + + assert len(entity_db.surfaces) == len(expected_faces) + + # Verify edge grouping + if entity_info.get("edge_group_tag"): + edge_group_tag = entity_info["edge_group_tag"] + edge_attribute_names = entity_info.get("edge_attribute_names", []) + grouped_edges = entity_info.get("grouped_edges", []) + index = edge_attribute_names.index(edge_group_tag) + expected_edges = grouped_edges[index] + assert len(entity_db.edges) == len(expected_edges) + + # Verify body grouping + if entity_info.get("body_group_tag"): + body_group_tag = entity_info["body_group_tag"] + body_attribute_names = entity_info.get("body_attribute_names", []) + grouped_bodies = entity_info.get("grouped_bodies", []) + index = body_attribute_names.index(body_group_tag) + expected_bodies = grouped_bodies[index] + assert len(entity_db.geometry_body_groups) == len(expected_bodies) diff --git a/tests/simulation/framework/test_entity_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py new file mode 100644 index 000000000..a6e4c7fdc --- /dev/null +++ b/tests/simulation/framework/test_entity_expansion_impl.py @@ -0,0 +1,372 @@ +import copy +import json +import os + +from flow360.component.simulation.framework.entity_selector import ( + EntityDictDatabase, + _compile_glob_cached, + expand_entity_selectors_in_place, +) +from flow360.component.simulation.framework.updater_utils import compare_values +from flow360.component.simulation.services import resolve_selectors + + +def _mk_pool(names, etype): + # Build list of entity dicts with given names and type + return [{"name": n, "private_attribute_entity_type_name": etype} for n in names] + + +def test_in_place_expansion_and_replacement_per_class(): + # Entity database with two classes + db = EntityDictDatabase( + surfaces=_mk_pool(["wing", "tail", "fuselage"], "Surface"), + edges=_mk_pool(["edgeA", "edgeB"], "Edge"), + ) + + # params_as_dict with existing stored_entities including a non-persistent entity + params = { + "outputs": [ + { + "stored_entities": [ + {"name": "custom-box", "private_attribute_entity_type_name": "Box"}, + {"name": "old-wing", "private_attribute_entity_type_name": "Surface"}, + {"name": "old-edgeA", "private_attribute_entity_type_name": "Edge"}, + ], + "selectors": [ + { + "target_class": "Surface", + "logic": "AND", + "children": [ + {"attribute": "name", "operator": "matches", "value": "w*"}, + ], + }, + { + "target_class": "Edge", + "logic": "OR", + "children": [ + {"attribute": "name", "operator": "equals", "value": "edgeB"}, + ], + }, + ], + } + ] + } + + out = expand_entity_selectors_in_place(db, params) + + # In-place: the returned object should be the same reference + assert out is params + + # Non-persistent entity remains; Surface and Edge replaced by new selection + stored = params["outputs"][0]["stored_entities"] + names_by_type = {} + for e in stored: + names_by_type.setdefault(e["private_attribute_entity_type_name"], []).append(e["name"]) + + assert names_by_type["Box"] == ["custom-box"] + assert names_by_type["Surface"] == ["wing"] # matches w* + assert names_by_type["Edge"] == ["edgeB"] # equals edgeB + + # selectors cleared + assert params["outputs"][0]["selectors"] == [] + + +def test_operator_and_syntax_coverage(): + # Prepare database with diverse names (not just quantity) + pool_names = [ + "wing", + "wingtip", + "wing-root", + "wind", + "tail", + "tailplane", + "fuselage", + "body", + "leading-wing", + "my_wing", + "hinge", + ] + db = EntityDictDatabase(surfaces=_mk_pool(pool_names, "Surface")) + + # Build selectors that cover operators和regex/glob(每条注释标注该 selector 的期望匹配,按池顺序) + params = { + "node": { + "selectors": [ + { + # equals("tail") -> ["tail"] + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "equals", "value": "tail"}], + }, + { + # notEquals("wing") -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "notEquals", "value": "wing"}], + }, + { + # in(["wing","fuselage"]) -> ["wing","fuselage"] + "target_class": "Surface", + "children": [ + {"attribute": "name", "operator": "in", "value": ["wing", "fuselage"]} + ], + }, + { + # notIn(["tail","hinge"]) -> ["wing","wingtip","wing-root","wind","tailplane","fuselage","body","leading-wing","my_wing"] + "target_class": "Surface", + "children": [ + {"attribute": "name", "operator": "notIn", "value": ["tail", "hinge"]} + ], + }, + { + # matches("wing*") -> ["wing","wingtip","wing-root"] + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "matches", "value": "wing*"}], + }, + { + # notMatches("^wing$", regex) -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] + "target_class": "Surface", + "children": [ + { + "attribute": "name", + "operator": "notMatches", + "value": "^wing$", + "non_glob_syntax": "regex", + } + ], + }, + ] + } + } + + expand_entity_selectors_in_place(db, params) + stored = params["node"]["stored_entities"] + + # Build expected union by concatenating each selector's expected results (order matters) + expected = [] + expected += ["tail"] + expected += [ + "wingtip", + "wing-root", + "wind", + "tail", + "tailplane", + "fuselage", + "body", + "leading-wing", + "my_wing", + "hinge", + ] + expected += ["wing", "fuselage"] + expected += [ + "wing", + "wingtip", + "wing-root", + "wind", + "tailplane", + "fuselage", + "body", + "leading-wing", + "my_wing", + ] + expected += ["wing", "wingtip", "wing-root"] + expected += [ + "wingtip", + "wing-root", + "wind", + "tail", + "tailplane", + "fuselage", + "body", + "leading-wing", + "my_wing", + "hinge", + ] + + final_names = [ + e["name"] for e in stored if e["private_attribute_entity_type_name"] == "Surface" + ] + assert final_names == expected + + +def test_combined_predicates_and_or(): + db = EntityDictDatabase(surfaces=_mk_pool(["s1", "s2", "wing", "wing-root", "tail"], "Surface")) + + params = { + "node": { + "selectors": [ + { + "target_class": "Surface", + "logic": "AND", + "children": [ + {"attribute": "name", "operator": "matches", "value": "wing*"}, + {"attribute": "name", "operator": "notEquals", "value": "wing"}, + ], + }, + { + "target_class": "Surface", + "logic": "OR", + "children": [ + {"attribute": "name", "operator": "equals", "value": "s1"}, + {"attribute": "name", "operator": "equals", "value": "tail"}, + ], + }, + { + "target_class": "Surface", + "children": [ + {"attribute": "name", "operator": "in", "value": ["wing", "wing-root"]}, + ], + }, + ] + } + } + + expand_entity_selectors_in_place(db, params) + stored = params["node"]["stored_entities"] + + # Union across three selectors (concatenated in selector order, no dedup): + # 1) AND wing* & notEquals wing -> ["wing-root"] + # 2) OR equals s1 or tail -> ["s1", "tail"] + # 3) default AND with in {wing, wing-root} -> ["wing", "wing-root"] + # Final list -> ["wing-root", "s1", "tail", "wing", "wing-root"] + final_names = [ + e["name"] for e in stored if e["private_attribute_entity_type_name"] == "Surface" + ] + assert final_names == ["wing-root", "s1", "tail", "wing", "wing-root"] + + +def test_attribute_tag_scalar_support(): + # Entities include an additional scalar attribute 'tag' + surfaces = [ + {"name": "wing", "tag": "A", "private_attribute_entity_type_name": "Surface"}, + {"name": "tail", "tag": "B", "private_attribute_entity_type_name": "Surface"}, + {"name": "fuselage", "tag": "A", "private_attribute_entity_type_name": "Surface"}, + ] + db = EntityDictDatabase(surfaces=surfaces) + + # Use attribute 'tag' in predicates (engine should not assume 'name') + params = { + "node": { + "selectors": [ + { + "target_class": "Surface", + "logic": "AND", + "children": [ + {"attribute": "tag", "operator": "equals", "value": "A"}, + ], + }, + { + "target_class": "Surface", + "logic": "OR", + "children": [ + {"attribute": "tag", "operator": "in", "value": ["B"]}, + {"attribute": "tag", "operator": "matches", "value": "A"}, + ], + }, + ] + } + } + + expand_entity_selectors_in_place(db, params) + stored = params["node"]["stored_entities"] + + # Expect union of two selectors: + # 1) AND tag == A -> [wing, fuselage] + # 2) OR tag in {B} or matches 'A' -> pool-order union -> [wing, tail, fuselage] + final_names = [ + e["name"] for e in stored if e["private_attribute_entity_type_name"] == "Surface" + ] + assert final_names == ["wing", "fuselage", "wing", "tail", "fuselage"] + + +def test_service_expand_entity_selectors_in_place_end_to_end(): + # Pick a complex simulation.json as input + test_dir = os.path.dirname(os.path.abspath(__file__)) + sim_path = os.path.join(test_dir, "..", "data", "geometry_grouped_by_file", "simulation.json") + with open(sim_path, "r", encoding="utf-8") as fp: + params = json.load(fp) + + # Convert first output's entities to use a wildcard selector and clear stored entities + outputs = params.get("outputs") or [] + if not outputs: + return + entities = outputs[0].get("entities") or {} + entities["selectors"] = [ + { + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "matches", "value": "*"}], + } + ] + entities["stored_entities"] = [] + outputs[0]["entities"] = entities + + # Expand via service function + expanded = json.loads(json.dumps(params)) + resolve_selectors(expanded) + + # Build or load a reference file (only created if missing) + ref_dir = os.path.join(test_dir, "..", "ref") + ref_path = os.path.join(ref_dir, "entity_expansion_service_ref_outputs.json") + + # Load reference and compare with expanded outputs + with open(ref_path, "r", encoding="utf-8") as fp: + ref_outputs = json.load(fp) + assert compare_values(expanded.get("outputs"), ref_outputs) + + +def test_compile_glob_cached_extended_syntax_support(): + # Comments in English for maintainers + # Ensure extended glob features supported by wcmatch translation are honored. + candidates = [ + "a", + "b", + "ab", + "abc", + "file", + "file1", + "file2", + "file10", + "file.txt", + "File.TXT", + "data_01", + "data-xyz", + "[star]", + "literal*star", + "foo.bar", + ".hidden", + "1", + "2", + "3", + ] + + def match(pattern: str) -> list[str]: + regex = _compile_glob_cached(pattern) + return [n for n in candidates if regex.fullmatch(n) is not None] + + # Basic glob + assert match("file*") == ["file", "file1", "file2", "file10", "file.txt"] + assert match("file[0-9]") == ["file1", "file2"] + + # Brace expansion + assert match("{a,b}") == ["a", "b"] + assert match("file{1,2}") == ["file1", "file2"] + assert match("{1..3}") == ["1", "2", "3"] + assert match("file{01..10}") == ["file10"] + + # Extglob + # In extglob, @(file|data) means exactly 'file' or 'data'. To match 'data_*', use data*. + assert match("@(file|data*)") == ["file", "data_01", "data-xyz"] + expected_not_file = [n for n in candidates if n != "file"] + assert match("!(file)") == expected_not_file + assert match("?(file)") == ["file"] + assert match("+(file)") == ["file"] + assert match("*(file)") == ["file"] + + # POSIX character classes + assert match("[[:digit:]]*") == ["1", "2", "3"] + assert match("file[[:digit:]]") == ["file1", "file2"] + assert match("[[:upper:]]*.[[:alpha:]]*") == ["File.TXT"] + + # Escaping and literals + assert match("literal[*]star") == ["literal*star"] + assert match(r"literal\*star") == ["literal*star"] + assert match(r"foo\.bar") == ["foo.bar"] + assert match("foo[.]bar") == ["foo.bar"] diff --git a/tests/simulation/ref/entity_expansion_service_ref_outputs.json b/tests/simulation/ref/entity_expansion_service_ref_outputs.json new file mode 100644 index 000000000..8c6e86adf --- /dev/null +++ b/tests/simulation/ref/entity_expansion_service_ref_outputs.json @@ -0,0 +1,51 @@ +[ + { + "output_fields": { + "items": [ + "Cp", + "yPlus", + "Cf", + "CfVec" + ] + }, + "frequency": -1, + "frequency_offset": 0, + "output_format": "paraview", + "name": "Surface output", + "entities": { + "stored_entities": [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "body00001", + "name": "body00001", + "private_attribute_tag_key": "groupByBodyId", + "private_attribute_sub_components": [ + "body00001_face00001", + "body00001_face00002", + "body00001_face00003", + "body00001_face00004", + "body00001_face00005", + "body00001_face00006", + "body00001_face00007", + "body00001_face00008", + "body00001_face00009", + "body00001_face00010", + "body00001_face00011", + "body00001_face00012", + "body00001_face00013", + "body00001_face00014", + "body00001_face00015", + "body00001_face00016", + "body00001_face00017", + "body00001_face00018" + ], + "private_attribute_potential_issues": [] + } + ], + "selectors": [] + }, + "write_single_file": false, + "output_type": "SurfaceOutput" + } +] \ No newline at end of file From e31ab2501c6bd4a15ca5128202d283e598e18679 Mon Sep 17 00:00:00 2001 From: Ben <106089368+benflexcompute@users.noreply.github.com> Date: Mon, 13 Oct 2025 10:07:07 -0400 Subject: [PATCH 3/3] [FXC-3554] `EntitySelector` generation flunet API (#1492) * Finished implementation * Removed equals and not_equals * Self review * Changed discriminator to be confromal to the user API --- .../simulation/framework/entity_selector.py | 238 +++++++++++++++--- flow360/component/simulation/primitives.py | 9 +- .../framework/test_entity_expansion_impl.py | 44 ++-- .../test_entity_selector_fluent_api.py | 76 ++++++ 4 files changed, 309 insertions(+), 58 deletions(-) create mode 100644 tests/simulation/framework/test_entity_selector_fluent_api.py diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py index 9122cb69e..09ab3f5d8 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -8,9 +8,10 @@ from collections import deque from dataclasses import dataclass, field from functools import lru_cache -from typing import Any, List, Literal, Optional, Union +from typing import Any, List, Literal, Optional, Union, get_args import pydantic as pd +from typing_extensions import Self from flow360.component.simulation.framework.base_model import Flow360BaseModel @@ -26,12 +27,10 @@ class Predicate(Flow360BaseModel): # For now only name matching is supported attribute: Literal["name"] = pd.Field("name", description="The attribute to match/filter on.") operator: Literal[ - "equals", - "notEquals", - "in", - "notIn", + "any_of", + "not_any_of", "matches", - "notMatches", + "not_matches", ] = pd.Field() value: Union[str, List[str]] = pd.Field() # Applies only to matches/notMatches; default to glob if not specified explicitly. @@ -53,6 +52,60 @@ class EntitySelector(Flow360BaseModel): logic: Literal["AND", "OR"] = pd.Field("AND") children: List[Predicate] = pd.Field() + @pd.validate_call + def match( + self, + pattern: str, + *, + attribute: Literal["name"] = "name", + syntax: Literal["glob", "regex"] = "glob", + ) -> Self: + """Append a matches predicate and return self for chaining.""" + # pylint: disable=no-member + self.children.append( + Predicate( + attribute=attribute, + operator="matches", + value=pattern, + non_glob_syntax=("regex" if syntax == "regex" else None), + ) + ) + return self + + @pd.validate_call + def not_match( + self, + pattern: str, + *, + attribute: Literal["name"] = "name", + syntax: Literal["glob", "regex"] = "glob", + ) -> Self: + """Append a notMatches predicate and return self for chaining.""" + # pylint: disable=no-member + self.children.append( + Predicate( + attribute=attribute, + operator="not_matches", + value=pattern, + non_glob_syntax=("regex" if syntax == "regex" else None), + ) + ) + return self + + @pd.validate_call + def any_of(self, values: List[str], *, attribute: Literal["name"] = "name") -> Self: + """Append an in predicate and return self for chaining.""" + # pylint: disable=no-member + self.children.append(Predicate(attribute=attribute, operator="any_of", value=values)) + return self + + @pd.validate_call + def not_any_of(self, values: List[str], *, attribute: Literal["name"] = "name") -> Self: + """Append a notIn predicate and return self for chaining.""" + # pylint: disable=no-member + self.children.append(Predicate(attribute=attribute, operator="not_any_of", value=values)) + return self + @dataclass class EntityDictDatabase: @@ -71,6 +124,140 @@ class EntityDictDatabase: geometry_body_groups: list[dict] = field(default_factory=list) +########## API IMPLEMENTATION ########## + + +class SelectorFactory: + """ + Mixin providing class-level helpers to build EntitySelector instances with + preset predicates. + """ + + @classmethod + @pd.validate_call + def match( + cls, + pattern: str, + /, + *, + attribute: Literal["name"] = "name", + syntax: Literal["glob", "regex"] = "glob", + logic: Literal["AND", "OR"] = "AND", + ) -> EntitySelector: + """ + Create an EntitySelector for this class and seed it with one matches predicate. + + Example + ------- + >>> # Glob match on Surface names (AND logic by default) + >>> fl.Surface.match("wing*") + >>> # Regex full match + >>> fl.Surface.match(r"^wing$", syntax="regex") + >>> # Chain more predicates with AND logic + >>> fl.Surface.match("wing*").not_any_of(["wing"]) + >>> # Use OR logic across predicates (short alias) + >>> fl.Surface.match("s1", logic="OR").any_of(["tail"]) + + ==== + """ + selector = generate_entity_selector_from_class(cls, logic=logic) + selector.match(pattern, attribute=attribute, syntax=syntax) + return selector + + @classmethod + @pd.validate_call + def not_match( + cls, + pattern: str, + /, + *, + attribute: Literal["name"] = "name", + syntax: Literal["glob", "regex"] = "glob", + logic: Literal["AND", "OR"] = "AND", + ) -> EntitySelector: + """Create an EntitySelector and seed a notMatches predicate. + + Example + ------- + >>> # Exclude all surfaces ending with '-root' + >>> fl.Surface.match("*").not_match("*-root") + >>> # Exclude by regex + >>> fl.Surface.match("*").not_match(r".*-(root|tip)$", syntax="regex") + + ==== + """ + selector = generate_entity_selector_from_class(cls, logic=logic) + selector.not_match(pattern, attribute=attribute, syntax=syntax) + return selector + + @classmethod + @pd.validate_call + def any_of( + cls, + values: List[str], + /, + *, + attribute: Literal["name"] = "name", + logic: Literal["AND", "OR"] = "AND", + ) -> EntitySelector: + """Create an EntitySelector and seed an in predicate. + + Example + ------- + >>> fl.Surface.any_of(["a", "b", "c"]) + >>> # Equivalent alias + >>> fl.Surface.in_(["a", "b", "c"]) + >>> # Combine with not_any_of to subtract + >>> fl.Surface.any_of(["a", "b", "c"]).not_any_of(["b"]) + + ==== + """ + selector = generate_entity_selector_from_class(cls, logic=logic) + selector.any_of(values, attribute=attribute) + return selector + + @classmethod + @pd.validate_call + def not_any_of( + cls, + values: List[str], + /, + *, + attribute: Literal["name"] = "name", + logic: Literal["AND", "OR"] = "AND", + ) -> EntitySelector: + """Create an EntitySelector and seed a notIn predicate. + + Example + ------- + >>> # Select all except those in the set + >>> fl.Surface.match("*").not_any_of(["a", "b"]) + + ==== + """ + selector = generate_entity_selector_from_class(cls, logic=logic) + selector.not_any_of(values, attribute=attribute) + return selector + + +def generate_entity_selector_from_class( + entity_class: type, logic: Literal["AND", "OR"] = "AND" +) -> EntitySelector: + """ + Create a new selector for the given entity class. + + entity_class should be one of the supported entity types (Surface, Edge, GenericVolume, GeometryBodyGroup). + """ + class_name = getattr(entity_class, "__name__", str(entity_class)) + allowed_classes = get_args(TargetClass) + assert ( + class_name in allowed_classes + ), f"Unknown entity class: {entity_class} for generating entity selector." + + return EntitySelector(target_class=class_name, logic=logic, children=[]) + + +########## EXPANSION IMPLEMENTATION ########## def _get_entity_pool(entity_database: EntityDictDatabase, target_class: TargetClass) -> list[dict]: """Return the correct entity list from the database for the target class.""" if target_class == "Surface": @@ -153,23 +340,16 @@ def _build_value_matcher(predicate: dict): non_glob_syntax = predicate.get("non_glob_syntax") negate = False - if operator in ("notEquals", "notIn", "notMatches"): + if operator in ("not_any_of", "not_matches"): negate = True base_operator = { - "notEquals": "equals", - "notIn": "in", - "notMatches": "matches", + "not_any_of": "any_of", + "not_matches": "matches", }.get(operator) else: base_operator = operator - if base_operator == "equals": - target = value - - def base_match(val: Optional[str]) -> bool: - return val == target - - elif base_operator == "in": + if base_operator == "any_of": values = set(value or []) def base_match(val: Optional[str]) -> bool: @@ -195,7 +375,7 @@ def base_match(_val: Optional[str]) -> bool: def _build_index(pool: list[dict], attribute: str) -> dict[str, list[int]]: - """Build an index for equals/in lookups on a given attribute.""" + """Build an index for in lookups on a given attribute.""" value_to_indices: dict[str, list[int]] = {} for idx, item in enumerate(pool): val = item.get(attribute) @@ -236,11 +416,7 @@ def _matched_indices_for_predicate( ) -> set[int]: operator = predicate.get("operator") attribute = predicate.get("attribute", "name") - if operator == "equals": - idx_map = indices_by_attribute.get(attribute) - if idx_map is not None: - return set(idx_map.get(predicate.get("value"), [])) - if operator == "in": + if operator == "any_of": idx_map = indices_by_attribute.get(attribute) if idx_map is not None: result: set[int] = set() @@ -297,22 +473,18 @@ def _apply_single_selector(pool: list[dict], selector_dict: dict) -> list[dict]: def _cost(predicate: dict) -> int: op = predicate.get("operator") order = { - "equals": 0, - "in": 1, - "matches": 2, - "notEquals": 3, - "notIn": 4, - "notMatches": 5, + "any_of": 0, + "matches": 1, + "not_any_of": 2, + "not_matches": 3, } return order.get(op, 10) ordered_children = children if logic == "OR" else sorted(children, key=_cost) - # Optional per-attribute indices for equals/in + # Optional per-attribute indices for in attributes_needing_index = { - p.get("attribute", "name") - for p in ordered_children - if p.get("operator") in ("equals", "in") + p.get("attribute", "name") for p in ordered_children if p.get("operator") == "any_of" } indices_by_attribute: dict[str, dict[str, list[int]]] = ( {attr: _build_index(pool, attr) for attr in attributes_needing_index} diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 0718c53ee..3dcf09f98 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -23,6 +23,7 @@ EntityList, generate_uuid, ) +from flow360.component.simulation.framework.entity_selector import SelectorFactory from flow360.component.simulation.framework.multi_constructor_model_base import ( MultiConstructorBaseModel, ) @@ -102,7 +103,7 @@ class ReferenceGeometry(Flow360BaseModel): private_attribute_area_settings: Optional[dict] = pd.Field(None) -class GeometryBodyGroup(EntityBase): +class GeometryBodyGroup(EntityBase, SelectorFactory): """ :class:`GeometryBodyGroup` represents a collection of bodies that are grouped for transformation. """ @@ -205,7 +206,7 @@ class _EdgeEntityBase(EntityBase, metaclass=ABCMeta): @final -class Edge(_EdgeEntityBase): +class Edge(_EdgeEntityBase, SelectorFactory): """ Edge which contains a set of grouped edges from geometry. """ @@ -225,7 +226,7 @@ class Edge(_EdgeEntityBase): @final -class GenericVolume(_VolumeEntityBase): +class GenericVolume(_VolumeEntityBase, SelectorFactory): """ Do not expose. This type of entity will get auto-constructed by assets when loading metadata. @@ -482,7 +483,7 @@ class SurfacePrivateAttributes(Flow360BaseModel): @final -class Surface(_SurfaceEntityBase): +class Surface(_SurfaceEntityBase, SelectorFactory): """ :class:`Surface` represents a boundary surface in three-dimensional space. """ diff --git a/tests/simulation/framework/test_entity_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py index a6e4c7fdc..db85f4621 100644 --- a/tests/simulation/framework/test_entity_expansion_impl.py +++ b/tests/simulation/framework/test_entity_expansion_impl.py @@ -44,7 +44,7 @@ def test_in_place_expansion_and_replacement_per_class(): "target_class": "Edge", "logic": "OR", "children": [ - {"attribute": "name", "operator": "equals", "value": "edgeB"}, + {"attribute": "name", "operator": "any_of", "value": ["edgeB"]}, ], }, ], @@ -65,7 +65,7 @@ def test_in_place_expansion_and_replacement_per_class(): assert names_by_type["Box"] == ["custom-box"] assert names_by_type["Surface"] == ["wing"] # matches w* - assert names_by_type["Edge"] == ["edgeB"] # equals edgeB + assert names_by_type["Edge"] == ["edgeB"] # in ["edgeB"] # selectors cleared assert params["outputs"][0]["selectors"] == [] @@ -93,27 +93,29 @@ def test_operator_and_syntax_coverage(): "node": { "selectors": [ { - # equals("tail") -> ["tail"] + # any_of(["tail"]) -> ["tail"] "target_class": "Surface", - "children": [{"attribute": "name", "operator": "equals", "value": "tail"}], + "children": [{"attribute": "name", "operator": "any_of", "value": ["tail"]}], }, { - # notEquals("wing") -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] + # not_any_of(["wing"]) -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] "target_class": "Surface", - "children": [{"attribute": "name", "operator": "notEquals", "value": "wing"}], + "children": [ + {"attribute": "name", "operator": "not_any_of", "value": ["wing"]} + ], }, { - # in(["wing","fuselage"]) -> ["wing","fuselage"] + # any_of(["wing","fuselage"]) -> ["wing","fuselage"] "target_class": "Surface", "children": [ - {"attribute": "name", "operator": "in", "value": ["wing", "fuselage"]} + {"attribute": "name", "operator": "any_of", "value": ["wing", "fuselage"]} ], }, { - # notIn(["tail","hinge"]) -> ["wing","wingtip","wing-root","wind","tailplane","fuselage","body","leading-wing","my_wing"] + # not_any_of(["tail","hinge"]) -> ["wing","wingtip","wing-root","wind","tailplane","fuselage","body","leading-wing","my_wing"] "target_class": "Surface", "children": [ - {"attribute": "name", "operator": "notIn", "value": ["tail", "hinge"]} + {"attribute": "name", "operator": "not_any_of", "value": ["tail", "hinge"]} ], }, { @@ -122,12 +124,12 @@ def test_operator_and_syntax_coverage(): "children": [{"attribute": "name", "operator": "matches", "value": "wing*"}], }, { - # notMatches("^wing$", regex) -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] + # not_matches("^wing$", regex) -> ["wingtip","wing-root","wind","tail","tailplane","fuselage","body","leading-wing","my_wing","hinge"] "target_class": "Surface", "children": [ { "attribute": "name", - "operator": "notMatches", + "operator": "not_matches", "value": "^wing$", "non_glob_syntax": "regex", } @@ -198,21 +200,21 @@ def test_combined_predicates_and_or(): "logic": "AND", "children": [ {"attribute": "name", "operator": "matches", "value": "wing*"}, - {"attribute": "name", "operator": "notEquals", "value": "wing"}, + {"attribute": "name", "operator": "not_any_of", "value": ["wing"]}, ], }, { "target_class": "Surface", "logic": "OR", "children": [ - {"attribute": "name", "operator": "equals", "value": "s1"}, - {"attribute": "name", "operator": "equals", "value": "tail"}, + {"attribute": "name", "operator": "any_of", "value": ["s1"]}, + {"attribute": "name", "operator": "any_of", "value": ["tail"]}, ], }, { "target_class": "Surface", "children": [ - {"attribute": "name", "operator": "in", "value": ["wing", "wing-root"]}, + {"attribute": "name", "operator": "any_of", "value": ["wing", "wing-root"]}, ], }, ] @@ -223,8 +225,8 @@ def test_combined_predicates_and_or(): stored = params["node"]["stored_entities"] # Union across three selectors (concatenated in selector order, no dedup): - # 1) AND wing* & notEquals wing -> ["wing-root"] - # 2) OR equals s1 or tail -> ["s1", "tail"] + # 1) AND wing* & notIn ["wing"] -> ["wing-root"] + # 2) OR in ["s1"] or in ["tail"] -> ["s1", "tail"] # 3) default AND with in {wing, wing-root} -> ["wing", "wing-root"] # Final list -> ["wing-root", "s1", "tail", "wing", "wing-root"] final_names = [ @@ -250,14 +252,14 @@ def test_attribute_tag_scalar_support(): "target_class": "Surface", "logic": "AND", "children": [ - {"attribute": "tag", "operator": "equals", "value": "A"}, + {"attribute": "tag", "operator": "any_of", "value": ["A"]}, ], }, { "target_class": "Surface", "logic": "OR", "children": [ - {"attribute": "tag", "operator": "in", "value": ["B"]}, + {"attribute": "tag", "operator": "any_of", "value": ["B"]}, {"attribute": "tag", "operator": "matches", "value": "A"}, ], }, @@ -269,7 +271,7 @@ def test_attribute_tag_scalar_support(): stored = params["node"]["stored_entities"] # Expect union of two selectors: - # 1) AND tag == A -> [wing, fuselage] + # 1) AND tag in ["A"] -> [wing, fuselage] # 2) OR tag in {B} or matches 'A' -> pool-order union -> [wing, tail, fuselage] final_names = [ e["name"] for e in stored if e["private_attribute_entity_type_name"] == "Surface" diff --git a/tests/simulation/framework/test_entity_selector_fluent_api.py b/tests/simulation/framework/test_entity_selector_fluent_api.py new file mode 100644 index 000000000..10a1975a9 --- /dev/null +++ b/tests/simulation/framework/test_entity_selector_fluent_api.py @@ -0,0 +1,76 @@ +import json + +import pytest + +from flow360.component.simulation.framework.entity_selector import ( + EntityDictDatabase, + expand_entity_selectors_in_place, +) +from flow360.component.simulation.primitives import Edge, Surface + + +def _mk_pool(names, etype): + # Build list of entity dicts with given names and type + return [{"name": n, "private_attribute_entity_type_name": etype} for n in names] + + +def _expand_and_get_names(db: EntityDictDatabase, selector_model) -> list[str]: + # Convert model to dict for the expansion engine + params = {"node": {"selectors": [selector_model.model_dump()]}} + expand_entity_selectors_in_place(db, params) + stored = params["node"]["stored_entities"] + return [ + e["name"] + for e in stored + if e["private_attribute_entity_type_name"] == selector_model.target_class + ] + + +def test_surface_class_match_and_chain_and(): + # Prepare a pool of Surface entities + db = EntityDictDatabase(surfaces=_mk_pool(["wing", "wing-root", "wingtip", "tail"], "Surface")) + + # AND logic by default; expect intersection of predicates + selector = Surface.match("wing*").not_any_of(["wing"]) + names = _expand_and_get_names(db, selector) + assert names == ["wing-root", "wingtip"] + + +def test_surface_class_match_or_union(): + db = EntityDictDatabase(surfaces=_mk_pool(["s1", "s2", "tail", "wing"], "Surface")) + + # OR logic: union of predicates + selector = Surface.match("s1", logic="OR").any_of(["tail"]) + names = _expand_and_get_names(db, selector) + # Order preserved by pool scan under OR + assert names == ["s1", "tail"] + + +def test_surface_regex_and_not_match(): + db = EntityDictDatabase(surfaces=_mk_pool(["wing", "wing-root", "tail"], "Surface")) + + # Regex fullmatch for exact 'wing', then exclude via not_match (glob) + selector = Surface.match(r"^wing$", syntax="regex").not_match("*-root", syntax="glob") + names = _expand_and_get_names(db, selector) + assert names == ["wing"] + + +def test_in_and_not_any_of_chain(): + db = EntityDictDatabase(surfaces=_mk_pool(["a", "b", "c", "d"], "Surface")) + + # AND semantics: in {a,b,c} and not_in {b} + selector = Surface.match("*").any_of(["a", "b", "c"]).not_any_of(["b"]) + names = _expand_and_get_names(db, selector) + assert names == ["a", "c"] + + +def test_edge_class_basic_match(): + db = EntityDictDatabase(edges=_mk_pool(["edgeA", "edgeB"], "Edge")) + + selector = Edge.match("edgeA") + params = {"node": {"selectors": [selector.model_dump()]}} + expand_entity_selectors_in_place(db, params) + stored = params["node"]["stored_entities"] + assert [e["name"] for e in stored if e["private_attribute_entity_type_name"] == "Edge"] == [ + "edgeA" + ]