diff --git a/flow360/component/results/base_results.py b/flow360/component/results/base_results.py index 832a80e7d..0f29a1b47 100644 --- a/flow360/component/results/base_results.py +++ b/flow360/component/results/base_results.py @@ -673,7 +673,6 @@ def full_name_pattern(word: str) -> re.Pattern: return rf"^(?:{re.escape(word)}|[^/]+/{re.escape(word)})$" self.reload_data() # Remove all the imposed filters - print(">> _x_columns =", self._x_columns) raw_values = {} for x_column in self._x_columns: raw_values[x_column] = np.array(self.raw_values[x_column]) diff --git a/flow360/component/simulation/framework/entity_base.py b/flow360/component/simulation/framework/entity_base.py index ad6f0082c..2b794d0a0 100644 --- a/flow360/component/simulation/framework/entity_base.py +++ b/flow360/component/simulation/framework/entity_base.py @@ -2,7 +2,6 @@ from __future__ import annotations -import copy import hashlib import uuid from abc import ABCMeta @@ -283,7 +282,7 @@ def _get_valid_entity_types(cls): @classmethod def _valid_individual_input(cls, input_data): """Validate each individual element in a list or as standalone entity.""" - if isinstance(input_data, (str, EntityBase)): + if isinstance(input_data, EntityBase): return input_data raise ValueError( @@ -291,15 +290,62 @@ def _valid_individual_input(cls, input_data): "Expected entity instance." ) + @classmethod + def _process_selector(cls, selector: EntitySelector, valid_type_names: List[str]) -> dict: + """Process and validate an EntitySelector object.""" + if selector.target_class not in valid_type_names: + raise ValueError( + f"Selector target_class ({selector.target_class}) is incompatible " + f"with EntityList types {valid_type_names}." + ) + return selector.model_dump() + + @classmethod + def _process_entity(cls, entity: EntityBase, valid_types: tuple) -> Optional[EntityBase]: + """Process and validate an entity object. Returns None if entity type is invalid.""" + cls._valid_individual_input(entity) + if is_exact_instance(entity, valid_types): + return entity + return None + + @classmethod + def _build_result( + cls, entities_to_store: List[EntityBase], entity_patterns_to_store: List[dict] + ) -> dict: + """Build the final result dictionary.""" + return { + "stored_entities": entities_to_store, + "selectors": entity_patterns_to_store if entity_patterns_to_store else None, + } + + @classmethod + # pylint: disable=too-many-arguments + def _process_single_item( + cls, + item: Union[EntityBase, EntitySelector], + valid_types: tuple, + valid_type_names: List[str], + entities_to_store: List[EntityBase], + entity_patterns_to_store: List[dict], + ) -> None: + """Process a single item (entity or selector) and add to appropriate storage lists.""" + if isinstance(item, EntitySelector): + entity_patterns_to_store.append(cls._process_selector(item, valid_type_names)) + else: + processed_entity = cls._process_entity(item, valid_types) + if processed_entity is not None: + entities_to_store.append(processed_entity) + @pd.model_validator(mode="before") @classmethod - def deserializer(cls, input_data: Union[dict, list]): + def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector]): """ Flatten List[EntityBase] and put into stored_entities. """ entities_to_store = [] entity_patterns_to_store = [] - valid_types = cls._get_valid_entity_types() + valid_types = tuple(cls._get_valid_entity_types()) + valid_type_names = [t.__name__ for t in valid_types] if isinstance(input_data, list): # -- User input mode. -- @@ -308,107 +354,46 @@ def deserializer(cls, input_data: Union[dict, list]): raise ValueError("Invalid input type to `entities`, list is empty.") for item in input_data: if isinstance(item, list): # Nested list comes from assets __getitem__ - _ = [cls._valid_individual_input(individual) for individual in item] + processed_entities = [ + entity + for entity in ( + cls._process_entity(individual, valid_types) for individual in item + ) + if entity is not None + ] # pylint: disable=fixme # TODO: Give notice when some of the entities are not selected due to `valid_types`? - entities_to_store.extend( - [ - individual - for individual in item - if is_exact_instance(individual, tuple(valid_types)) - ] - ) + entities_to_store.extend(processed_entities) else: - cls._valid_individual_input(item) - if is_exact_instance(item, tuple(valid_types)): - entities_to_store.append(item) + # Single entity or selector + cls._process_single_item( + item, + valid_types, + valid_type_names, + entities_to_store, + entity_patterns_to_store, + ) elif isinstance(input_data, dict): # Deserialization if "stored_entities" not in input_data: raise KeyError( f"Invalid input type to `entities`, dict {input_data} is missing the key '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 + return cls._build_result(input_data["stored_entities"], input_data.get("selectors", [])) + else: # Single entity or selector if input_data is 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)): - entities_to_store.append(input_data) + return cls._build_result(None, []) + cls._process_single_item( + input_data, + valid_types, + valid_type_names, + entities_to_store, + entity_patterns_to_store, + ) - if not entities_to_store: + if not entities_to_store and not entity_patterns_to_store: raise ValueError( f"Can not find any valid entity of type {[valid_type.__name__ for valid_type in valid_types]}" f" from the input." ) - return { - "stored_entities": entities_to_store, - "selectors": None if not entity_patterns_to_store else entity_patterns_to_store, - } - - def _get_expanded_entities( - self, - *, - create_hard_copy: bool, - ) -> List[EntityBase]: - """ - Processes `stored_entities` to remove duplicate entities and raise error if conflicting entities are found. - - Possible future upgrade includes expanding `TokenEntity` (naming pattern, enabling compact data storage - like MatrixType and also templating SimulationParams which is planned when importing JSON as setting template) - - Raises: - TypeError: If an entity does not match the expected type. - Returns: - Expanded entities list. - """ - - entities = getattr(self, "stored_entities", []) - - expanded_entities = [] - # Note: Points need to skip deduplication bc: - # 1. Performance of deduplication is slow when Point count is high. - not_merged_entity_types_name = [ - "Point" - ] # Entity types that need skipping deduplication (hacky) - not_merged_entities = [] - - # pylint: disable=not-an-iterable - for entity in entities: - if entity.private_attribute_entity_type_name in not_merged_entity_types_name: - not_merged_entities.append(entity) - continue - # if entity not in expanded_entities: - expanded_entities.append(entity) - - expanded_entities = _remove_duplicate_entities(expanded_entities) - expanded_entities += not_merged_entities - - if not expanded_entities: - raise ValueError( - f"Failed to find any matching entity with {entities}. Please check the input to entities." - ) - # pylint: disable=fixme - # TODO: As suggested by Runda. We better prompt user what entities are actually used/expanded to - # TODO: avoid user input error. We need a switch to turn it on or off. - if create_hard_copy is True: - return copy.deepcopy(expanded_entities) - return expanded_entities - - # pylint: disable=arguments-differ - def preprocess(self, **kwargs): - """ - Expand and overwrite self.stored_entities in preparation for submission/serialization. - Should only be called as late as possible to incorporate all possible changes. - """ - # WARNING: this is very expensive all for long lists as it is quadratic - self.stored_entities = self._get_expanded_entities(create_hard_copy=False) - return super().preprocess(**kwargs) + return cls._build_result(entities_to_store, entity_patterns_to_store) diff --git a/flow360/component/simulation/framework/entity_materialization_context.py b/flow360/component/simulation/framework/entity_materialization_context.py new file mode 100644 index 000000000..f273e248d --- /dev/null +++ b/flow360/component/simulation/framework/entity_materialization_context.py @@ -0,0 +1,52 @@ +"""Scoped context for entity materialization and reuse. + +This module provides a context-managed cache and an injectable builder +for converting entity dictionaries to model instances, avoiding global +state and enabling high-performance reuse during validation. +""" + +from __future__ import annotations + +import contextvars +from typing import Any, Callable, Optional + +_entity_cache_ctx: contextvars.ContextVar[Optional[dict]] = contextvars.ContextVar( + "entity_cache", default=None +) +_entity_builder_ctx: contextvars.ContextVar[Optional[Callable[[dict], Any]]] = ( + contextvars.ContextVar("entity_builder", default=None) +) + + +class EntityMaterializationContext: + """Context manager providing a per-validation scoped cache and builder. + + Use this to avoid global state when materializing entity dictionaries + into model instances while reusing objects across the validation pass. + """ + + def __init__(self, *, builder: Callable[[dict], Any]): + self._token_cache = None + self._token_builder = None + self._builder = builder + + def __enter__(self): + self._token_cache = _entity_cache_ctx.set({}) + self._token_builder = _entity_builder_ctx.set(self._builder) + return self + + def __exit__(self, exc_type, exc, tb): + _entity_cache_ctx.reset(self._token_cache) + _entity_builder_ctx.reset(self._token_builder) + + +def get_entity_cache() -> Optional[dict]: + """Return the current cache dict for entity reuse, or None if not active.""" + + return _entity_cache_ctx.get() + + +def get_entity_builder() -> Optional[Callable[[dict], Any]]: + """Return the current dict->entity builder, or None if not active.""" + + return _entity_builder_ctx.get() diff --git a/flow360/component/simulation/framework/entity_materializer.py b/flow360/component/simulation/framework/entity_materializer.py new file mode 100644 index 000000000..0b2e43263 --- /dev/null +++ b/flow360/component/simulation/framework/entity_materializer.py @@ -0,0 +1,144 @@ +"""Entity materialization utilities. + +Provides mapping from entity type names to classes, stable keys, and an +in-place materialization routine to convert entity dictionaries to shared +Pydantic model instances and perform per-list deduplication. +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Any + +import pydantic as pd + +from flow360.component.simulation.framework.entity_materialization_context import ( + EntityMaterializationContext, + get_entity_builder, + get_entity_cache, +) +from flow360.component.simulation.outputs.output_entities import ( + Point, + PointArray, + PointArray2D, + Slice, +) +from flow360.component.simulation.primitives import ( + Box, + CustomVolume, + Cylinder, + Edge, + GenericVolume, + GeometryBodyGroup, + GhostCircularPlane, + GhostSphere, + GhostSurface, + ImportedSurface, + Surface, +) + +ENTITY_TYPE_MAP = { + "Surface": Surface, + "Edge": Edge, + "GenericVolume": GenericVolume, + "GeometryBodyGroup": GeometryBodyGroup, + "CustomVolume": CustomVolume, + "Box": Box, + "Cylinder": Cylinder, + "ImportedSurface": ImportedSurface, + "GhostSurface": GhostSurface, + "GhostSphere": GhostSphere, + "GhostCircularPlane": GhostCircularPlane, + "Point": Point, + "PointArray": PointArray, + "PointArray2D": PointArray2D, + "Slice": Slice, +} + + +def _stable_entity_key_from_dict(d: dict) -> tuple: + """Return a stable deduplication key for an entity dict. + + Prefer (type, private_attribute_id); if missing, hash a sanitized + JSON-dumped copy (excluding volatile fields like private_attribute_input_cache). + """ + t = d.get("private_attribute_entity_type_name") + pid = d.get("private_attribute_id") + if pid: + return (t, pid) + data = {k: v for k, v in d.items() if k not in ("private_attribute_input_cache",)} + return (t, hashlib.sha256(json.dumps(data, sort_keys=True).encode("utf-8")).hexdigest()) + + +def _stable_entity_key_from_obj(o: Any) -> tuple: + """Return a stable deduplication key for an entity object instance.""" + t = getattr(o, "private_attribute_entity_type_name", type(o).__name__) + pid = getattr(o, "private_attribute_id", None) + return (t, pid) if pid else (t, id(o)) + + +def _build_entity_instance(entity_dict: dict): + """Construct a concrete entity instance from a dictionary via TypeAdapter.""" + type_name = entity_dict.get("private_attribute_entity_type_name") + cls = ENTITY_TYPE_MAP.get(type_name) + if cls is None: + raise ValueError(f"[Internal] Unknown entity type: {type_name}") + return pd.TypeAdapter(cls).validate_python(entity_dict) + + +def materialize_entities_in_place( + params_as_dict: dict, *, not_merged_types: set[str] = frozenset({"Point"}) +) -> dict: + """Materialize entity dicts to shared instances and dedupe per list in-place. + + - Converts dict entries to instances using a scoped cache for reuse. + - Deduplicates within each stored_entities list; skips types in not_merged_types. + - If called re-entrantly on an already materialized structure, object + instances are passed through and participate in per-list deduplication. + """ + + def visit(node): + if isinstance(node, dict): + stored_entities = node.get("stored_entities", None) + if isinstance(stored_entities, list): + cache = get_entity_cache() + builder = get_entity_builder() + new_list = [] + seen = set() + for item in stored_entities: + if isinstance(item, dict): + key = _stable_entity_key_from_dict(item) + obj = cache.get(key) if (cache and key in cache) else builder(item) + if cache is not None and key not in cache: + cache[key] = obj + else: + # If already materialized (e.g., re-entrant call), passthrough + obj = item + key = _stable_entity_key_from_dict( + { + "private_attribute_entity_type_name": getattr( + obj, "private_attribute_entity_type_name", type(obj).__name__ + ), + "private_attribute_id": getattr(obj, "private_attribute_id", None), + "name": getattr(obj, "name", None), + } + ) + entity_type = getattr(obj, "private_attribute_entity_type_name", None) + if entity_type in not_merged_types: + new_list.append(obj) + continue + if key in seen: + continue + seen.add(key) + new_list.append(obj) + node["stored_entities"] = new_list + for v in node.values(): + visit(v) + elif isinstance(node, list): + for it in node: + visit(it) + + with EntityMaterializationContext(builder=_build_entity_instance): + visit(params_as_dict) + return params_as_dict diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py index 09ab3f5d8..9810c7c50 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -49,6 +49,8 @@ class EntitySelector(Flow360BaseModel): """ target_class: TargetClass = pd.Field() + # Unique name for global reuse + name: str = pd.Field(description="Unique name for this selector.") logic: Literal["AND", "OR"] = pd.Field("AND") children: List[Predicate] = pd.Field() @@ -127,6 +129,64 @@ class EntityDictDatabase: ########## API IMPLEMENTATION ########## +def _validate_selector_factory_common( + method_name: str, + *, + name: str, + attribute: str, + logic: str, + syntax: Optional[str] = None, +) -> None: + """ + Validate common arguments for SelectorFactory methods. + + This performs friendly, actionable validation with clear error messages. + """ + # name: required and meaningful + if not isinstance(name, str) or not name.strip(): + raise ValueError( + f"SelectorFactory.{method_name}: 'name' must be a non-empty string; " + "it is the selector's unique identifier." + ) + + # attribute: currently only 'name' is supported + if attribute != "name": + raise ValueError( + f"SelectorFactory.{method_name}: attribute must be 'name'. Other attributes are not supported." + ) + + # logic + if logic not in ("AND", "OR"): + raise ValueError( + f"SelectorFactory.{method_name}: logic must be one of {{'AND','OR'}}. Got: {logic!r}." + ) + + # syntax (if applicable) + if syntax is not None and syntax not in ("glob", "regex"): + raise ValueError( + f"SelectorFactory.{method_name}: syntax must be one of {{'glob','regex'}}. Got: {syntax!r}." + ) + + +def _validate_selector_pattern(method_name: str, pattern: str) -> None: + """Validate the pattern argument for match/not_match.""" + if not isinstance(pattern, str) or len(pattern) == 0: + raise ValueError(f"SelectorFactory.{method_name}: pattern must be a non-empty string.") + + +def _validate_selector_values(method_name: str, values: list[str]) -> None: + """Validate values argument for any_of/not_any_of.""" + if not isinstance(values, list) or len(values) == 0: + raise ValueError( + f"SelectorFactory.{method_name}: values must be a non-empty list of strings." + ) + for i, v in enumerate(values): + if not isinstance(v, str) or not v: + raise ValueError( + f"SelectorFactory.{method_name}: values[{i}] must be a non-empty string." + ) + + class SelectorFactory: """ Mixin providing class-level helpers to build EntitySelector instances with @@ -134,12 +194,13 @@ class SelectorFactory: """ @classmethod - @pd.validate_call + # pylint: disable=too-many-arguments def match( cls, pattern: str, /, *, + name: str, attribute: Literal["name"] = "name", syntax: Literal["glob", "regex"] = "glob", logic: Literal["AND", "OR"] = "AND", @@ -150,27 +211,35 @@ def match( Example ------- >>> # Glob match on Surface names (AND logic by default) - >>> fl.Surface.match("wing*") + >>> fl.Surface.match("wing*", name="wing_sel") >>> # Regex full match - >>> fl.Surface.match(r"^wing$", syntax="regex") + >>> fl.Surface.match(r"^wing$", syntax="regex", name="wing_sel") >>> # Chain more predicates with AND logic - >>> fl.Surface.match("wing*").not_any_of(["wing"]) + >>> fl.Surface.match("wing*", name="wing_sel").not_any_of(["wing"]) >>> # Use OR logic across predicates (short alias) - >>> fl.Surface.match("s1", logic="OR").any_of(["tail"]) + >>> fl.Surface.match("s1", name="s1_or", logic="OR").any_of(["tail"]) ==== """ - selector = generate_entity_selector_from_class(cls, logic=logic) + _validate_selector_factory_common( + "match", name=name, attribute=attribute, logic=logic, syntax=syntax + ) + _validate_selector_pattern("match", pattern) + + selector = generate_entity_selector_from_class( + selector_name=name, entity_class=cls, logic=logic + ) selector.match(pattern, attribute=attribute, syntax=syntax) return selector @classmethod - @pd.validate_call + # pylint: disable=too-many-arguments def not_match( cls, pattern: str, /, *, + name: str, attribute: Literal["name"] = "name", syntax: Literal["glob", "regex"] = "glob", logic: Literal["AND", "OR"] = "AND", @@ -180,23 +249,30 @@ def not_match( Example ------- >>> # Exclude all surfaces ending with '-root' - >>> fl.Surface.match("*").not_match("*-root") + >>> fl.Surface.match("*", name="exclude_root").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) + _validate_selector_factory_common( + "not_match", name=name, attribute=attribute, logic=logic, syntax=syntax + ) + _validate_selector_pattern("not_match", pattern) + + selector = generate_entity_selector_from_class( + selector_name=name, entity_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], /, *, + name: str, attribute: Literal["name"] = "name", logic: Literal["AND", "OR"] = "AND", ) -> EntitySelector: @@ -212,17 +288,22 @@ def any_of( ==== """ - selector = generate_entity_selector_from_class(cls, logic=logic) + _validate_selector_factory_common("any_of", name=name, attribute=attribute, logic=logic) + _validate_selector_values("any_of", values) + + selector = generate_entity_selector_from_class( + selector_name=name, entity_class=cls, logic=logic + ) selector.any_of(values, attribute=attribute) return selector @classmethod - @pd.validate_call def not_any_of( cls, values: List[str], /, *, + name: str, attribute: Literal["name"] = "name", logic: Literal["AND", "OR"] = "AND", ) -> EntitySelector: @@ -235,13 +316,18 @@ def not_any_of( ==== """ - selector = generate_entity_selector_from_class(cls, logic=logic) + _validate_selector_factory_common("not_any_of", name=name, attribute=attribute, logic=logic) + _validate_selector_values("not_any_of", values) # type: ignore[arg-type] + + selector = generate_entity_selector_from_class( + selector_name=name, entity_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" + selector_name: str, entity_class: type, logic: Literal["AND", "OR"] = "AND" ) -> EntitySelector: """ Create a new selector for the given entity class. @@ -254,7 +340,7 @@ def generate_entity_selector_from_class( class_name in allowed_classes ), f"Unknown entity class: {entity_class} for generating entity selector." - return EntitySelector(target_class=class_name, logic=logic, children=[]) + return EntitySelector(name=selector_name, target_class=class_name, logic=logic, children=[]) ########## EXPANSION IMPLEMENTATION ########## @@ -500,11 +586,46 @@ def _cost(predicate: dict) -> int: 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 +def _get_selector_cache_key(selector_dict: dict) -> tuple: + """ + Return the cache key for a selector: requires unique name. + + We mandate a unique identifier per selector; use ("name", target_class, name) + for stable global reuse. If neither `name` is provided, fall back to a + structural key so different unnamed selectors won't collide. + """ + target_class = selector_dict.get("target_class") + name = selector_dict.get("name") + if name: + return ("name", target_class, name) + + logic = selector_dict.get("logic", "AND") + children = selector_dict.get("children") or [] + def _normalize_value(v): + if isinstance(v, list): + return tuple(v) + return v + + predicates = tuple( + ( + p.get("attribute", "name"), + p.get("operator"), + _normalize_value(p.get("value")), + p.get("non_glob_syntax"), + ) + for p in children + if isinstance(p, dict) + ) + return ("struct", target_class, logic, predicates) + + +def _process_selectors( + entity_database: EntityDictDatabase, + selectors_value: list, + selector_cache: dict, +) -> tuple[dict[str, list[dict]], list[str]]: + """Process selectors and return additions grouped by class.""" additions_by_class: dict[str, list[dict]] = {} ordered_target_classes: list[str] = [] @@ -515,37 +636,111 @@ def _expand_node_selectors(entity_database: EntityDictDatabase, node: dict) -> N pool = _get_entity_pool(entity_database, target_class) if not pool: continue + cache_key = _get_selector_cache_key(selector_dict) + additions = selector_cache.get(cache_key) + if additions is None: + additions = _apply_single_selector(pool, selector_dict) + selector_cache[cache_key] = additions 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)) + additions_by_class[target_class].extend(additions) - existing = node.get("stored_entities") + return additions_by_class, ordered_target_classes + + +def _merge_entities( + existing: list[dict], + additions_by_class: dict[str, list[dict]], + ordered_target_classes: list[str], + merge_mode: Literal["merge", "replace"], +) -> list[dict]: + """Merge existing entities with selector additions based on merge mode.""" base_entities: list[dict] = [] - classes_to_update = set(ordered_target_classes) - if isinstance(existing, list): + + if merge_mode == "merge": # explicit first, then selector additions + base_entities.extend(existing) + for target_class in ordered_target_classes: + base_entities.extend(additions_by_class.get(target_class, [])) + + else: # replace: drop explicit items of targeted classes + classes_to_update = set(ordered_target_classes) for item in existing: - etype = item.get("private_attribute_entity_type_name") - if etype in classes_to_update: - continue - base_entities.append(item) + entity_type = item.get("private_attribute_entity_type_name") + if entity_type not in classes_to_update: + base_entities.append(item) + for target_class in ordered_target_classes: + base_entities.extend(additions_by_class.get(target_class, [])) + + return base_entities + + +def _expand_node_selectors( + entity_database: EntityDictDatabase, + node: dict, + selector_cache: dict, + merge_mode: Literal["merge", "replace"], +) -> None: + """ + Expand selectors on one node and write results into stored_entities. + + - merge_mode="merge": keep explicit stored_entities first, then append selector results. + - merge_mode="replace": replace explicit items of target classes affected by selectors. + """ + selectors_value = node.get("selectors") + if not (isinstance(selectors_value, list) and len(selectors_value) > 0): + return + + additions_by_class, ordered_target_classes = _process_selectors( + entity_database, selectors_value, selector_cache + ) - for target_class in ordered_target_classes: - base_entities.extend(additions_by_class.get(target_class, [])) + existing = node.get("stored_entities", []) + base_entities = _merge_entities( + existing, additions_by_class, ordered_target_classes, merge_mode + ) node["stored_entities"] = base_entities - node["selectors"] = [] def expand_entity_selectors_in_place( - entity_database: EntityDictDatabase, params_as_dict: dict + entity_database: EntityDictDatabase, + params_as_dict: dict, + *, + merge_mode: Literal["merge", "replace"] = "merge", ) -> dict: - """Traverse params_as_dict and expand any EntitySelector in place.""" + """Traverse params_as_dict and expand any EntitySelector in place. + + How caching works + ----------------- + - Each selector must provide a unique name. We build a cross-tree + cache key as ("name", target_class, name). + - For every node that contains a non-empty `selectors` list, we compute the + additions once per unique cache key, store the expanded list of entity + dicts in `selector_cache`, and reuse it for subsequent nodes that reference + the same selector name and target_class. + - This avoids repeated pool scans and matcher compilation across the tree + while preserving stable result ordering. + + Merge policy + ------------ + - merge_mode="merge" (default): keep explicit `stored_entities` first, then + append selector results; duplicates (if any) can be handled later by the + materialization/dedup stage. + - merge_mode="replace": for classes targeted by selectors in the node, + drop explicit items of those classes and use selector results instead. + """ queue: deque[Any] = deque([params_as_dict]) + selector_cache: dict = {} while queue: node = queue.popleft() if isinstance(node, dict): - _expand_node_selectors(entity_database, node) + _expand_node_selectors( + entity_database, + node, + selector_cache=selector_cache, + merge_mode=merge_mode, + ) for value in node.values(): if isinstance(value, (dict, list)): queue.append(value) diff --git a/flow360/component/simulation/framework/param_utils.py b/flow360/component/simulation/framework/param_utils.py index 25b2cdeb9..67646b690 100644 --- a/flow360/component/simulation/framework/param_utils.py +++ b/flow360/component/simulation/framework/param_utils.py @@ -136,8 +136,7 @@ def register_entity_list(model: Flow360BaseModel, registry: EntityRegistry) -> N if isinstance(field, EntityList): # pylint: disable=protected-access - expanded_entities = field._get_expanded_entities(create_hard_copy=False) - for entity in expanded_entities if expanded_entities else []: + for entity in field.stored_entities if field.stored_entities else []: known_frozen_hashes = registry.fast_register(entity, known_frozen_hashes) elif isinstance(field, (list, tuple)): diff --git a/flow360/component/simulation/meshing_param/params.py b/flow360/component/simulation/meshing_param/params.py index 42979d0f8..cf795dc20 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -371,12 +371,16 @@ def _check_no_reused_volume_entities(self) -> Self: usage = EntityUsageMap() + if not get_validation_info(): + # Validation deferred since the entities are not deduplicated yet + return self + for volume_zone in self.volume_zones if self.volume_zones is not None else []: if isinstance(volume_zone, (RotationVolume, RotationCylinder)): # pylint: disable=protected-access _ = [ usage.add_entity_usage(item, volume_zone.type) - for item in volume_zone.entities._get_expanded_entities(create_hard_copy=False) + for item in volume_zone.entities.stored_entities ] for refinement in self.refinements if self.refinements is not None else []: @@ -387,7 +391,7 @@ def _check_no_reused_volume_entities(self) -> Self: # pylint: disable=protected-access _ = [ usage.add_entity_usage(item, refinement.refinement_type) - for item in refinement.entities._get_expanded_entities(create_hard_copy=False) + for item in refinement.entities.stored_entities ] error_msg = "" diff --git a/flow360/component/simulation/meshing_param/volume_params.py b/flow360/component/simulation/meshing_param/volume_params.py index 390ea913e..acd811bb0 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -226,7 +226,11 @@ def _validate_single_instance_in_entity_list(cls, values): `enclosed_entities` is planned to be auto_populated in the future. """ # pylint: disable=protected-access - if len(values._get_expanded_entities(create_hard_copy=False)) > 1: + + if not get_validation_info(): + return values + + if len(values.stored_entities) > 1: raise ValueError( "Only single instance is allowed in entities for each `RotationVolume`." ) diff --git a/flow360/component/simulation/models/volume_models.py b/flow360/component/simulation/models/volume_models.py index 89beb48a6..e2527ad1d 100644 --- a/flow360/component/simulation/models/volume_models.py +++ b/flow360/component/simulation/models/volume_models.py @@ -1267,6 +1267,10 @@ class Rotation(Flow360BaseModel): def _ensure_entities_have_sufficient_attributes(cls, value: EntityList): """Ensure entities have sufficient attributes.""" + if not get_validation_info(): + # stored_entities is not expanded yet. + return value + for entity in value.stored_entities: if entity.axis is None: raise ValueError( @@ -1368,6 +1372,10 @@ class PorousMedium(Flow360BaseModel): def _ensure_entities_have_sufficient_attributes(cls, value: EntityList): """Ensure entities have sufficient attributes.""" + if not get_validation_info(): + # stored_entities is not expanded yet. + return value + for entity in value.stored_entities: if entity.axes is None: raise ValueError( diff --git a/flow360/component/simulation/primitives.py b/flow360/component/simulation/primitives.py index 2a85af67e..d9c4459b5 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -789,6 +789,9 @@ class CustomVolume(_VolumeEntityBase): @classmethod def ensure_unique_boundary_names(cls, v): """Check if the boundaries have different names within a CustomVolume.""" + if not get_validation_info(): + # stored_entities is not expanded yet. + return v if len(v.stored_entities) != len({boundary.name for boundary in v.stored_entities}): raise ValueError("The boundaries of a CustomVolume must have different names.") return v diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index b570e03df..f4b5ffe01 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -13,8 +13,11 @@ 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_materializer import ( + materialize_entities_in_place, +) from flow360.component.simulation.framework.entity_selector import ( - expand_entity_selectors_in_place as expand_entity_selectors_in_place_impl, + expand_entity_selectors_in_place, ) from flow360.component.simulation.framework.multi_constructor_model_base import ( parse_model_dict, @@ -28,20 +31,16 @@ ) from flow360.component.simulation.models.surface_models import Freestream, Wall -# Following unused-import for supporting parse_model_dict -from flow360.component.simulation.models.volume_models import ( # pylint: disable=unused-import - BETDisk, -) - -# pylint: disable=unused-import +# pylint: disable=unused-import # For parse_model_dict +from flow360.component.simulation.models.volume_models import BETDisk from flow360.component.simulation.operating_condition.operating_condition import ( AerospaceCondition, GenericReferenceCondition, ThermalState, ) -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.primitives import Box + +# pylint: enable=unused-import from flow360.component.simulation.services_utils import has_any_entity_selectors from flow360.component.simulation.simulation_params import ( ReferenceGeometry, @@ -77,7 +76,6 @@ ALL, ParamsValidationInfo, ValidationContext, - get_value_with_path, ) from flow360.exceptions import ( Flow360RuntimeError, @@ -172,6 +170,9 @@ def get_default_params( Default parameters for Flow360 simulation stored in a dictionary. """ + # pylint: disable=import-outside-toplevel + from flow360.component.simulation.outputs.outputs import SurfaceOutput + from flow360.component.simulation.primitives import Surface unit_system = init_unit_system(unit_system_name) dummy_value = 0.1 @@ -423,7 +424,11 @@ def initialize_variable_space(param_as_dict: dict, use_clear_context: bool = Fal def resolve_selectors(params_as_dict: dict): """ - Expand the entity selectors in the params as dict. + Expand any EntitySelector into stored_entities in-place (dict level). + + - Performs a fast existence check first. + - Builds an entity database from asset cache. + - Applies expansion with merge semantics (explicit entities kept, selectors appended). """ # Step1: Check in the dictionary via looping and ensure selectors are present, if not just return. @@ -433,8 +438,8 @@ def resolve_selectors(params_as_dict: 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) + # Step3: Expand selectors using the entity database (default merge: explicit first) + return expand_entity_selectors_in_place(entity_database, params_as_dict, merge_mode="merge") def validate_model( # pylint: disable=too-many-locals @@ -502,9 +507,19 @@ def validate_model( # pylint: disable=too-many-locals ) with ValidationContext(levels=validation_levels_to_use, info=additional_info): - # Multi-constructor model support updated_param_as_dict = parse_model_dict(updated_param_as_dict, globals()) - validated_param = SimulationParams(file_content=updated_param_as_dict) + # Expand selectors (if any) with tag/name cache and merge strategy + updated_param_as_dict = resolve_selectors(updated_param_as_dict) + # Materialize entities (dict -> shared instances) and per-list dedupe + updated_param_as_dict = materialize_entities_in_place(updated_param_as_dict) + # Multi-constructor model support + updated_param_as_dict = SimulationParams._sanitize_params_dict(updated_param_as_dict) + updated_param_as_dict, _ = SimulationParams._update_param_dict(updated_param_as_dict) + + unit_system = updated_param_as_dict.get("unit_system") + with UnitSystem.from_dict(**unit_system): # pylint: disable=not-context-manager + validated_param = SimulationParams(**updated_param_as_dict) + except pd.ValidationError as err: validation_errors = err.errors() except Exception as err: # pylint: disable=broad-exception-caught diff --git a/flow360/component/simulation/services_utils.py b/flow360/component/simulation/services_utils.py index 79bdec278..7baf4ada8 100644 --- a/flow360/component/simulation/services_utils.py +++ b/flow360/component/simulation/services_utils.py @@ -3,6 +3,12 @@ from collections import deque from typing import Any +from flow360.component.simulation.entity_info import get_entity_database_for_selectors +from flow360.component.simulation.framework.entity_materializer import ( + _stable_entity_key_from_dict, +) +from flow360.component.simulation.framework.entity_selector import _process_selectors + 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. @@ -54,3 +60,69 @@ def has_any_entity_selectors(params_as_dict: dict) -> bool: queue.append(item) return False + + +def strip_selector_matches_inplace(params_as_dict: dict) -> dict: + """ + Remove entities matched by selectors from each EntityList node's stored_entities, in place. + + Rationale: + - Keep user hand-picked entities distinguishable for the UI by stripping items that are + implied by EntitySelector expansion. + - Do not modify schema; operate at dict level without mutating model structure. + + Behavior: + - For every dict node that has a non-empty `selectors` list, compute the set of additions + implied by those selectors over the current entity database, and remove those additions + from the node's `stored_entities` list. + - Nodes without `selectors` are left untouched. + + Returns the same dict object for chaining. + """ + if not isinstance(params_as_dict, dict): + return params_as_dict + + if not has_any_entity_selectors(params_as_dict): + return params_as_dict + + entity_database = get_entity_database_for_selectors(params_as_dict) + selector_cache: dict = {} + + def _matched_keyset_for_selectors(selectors_value: list) -> set: + additions_by_class, _ = _process_selectors(entity_database, selectors_value, selector_cache) + keys: set = set() + for items in additions_by_class.values(): + for d in items: + if isinstance(d, dict): + keys.add(_stable_entity_key_from_dict(d)) + return keys + + def _visit_dict(node: dict) -> None: + selectors_value = node.get("selectors") + if isinstance(selectors_value, list) and len(selectors_value) > 0: + matched_keys = _matched_keyset_for_selectors(selectors_value) + se = node.get("stored_entities") + if isinstance(se, list) and len(se) > 0: + node["stored_entities"] = [ + item + for item in se + if not ( + isinstance(item, dict) + and _stable_entity_key_from_dict(item) in matched_keys + ) + ] + for v in node.values(): + if isinstance(v, (dict, list)): + _visit(v) + + def _visit(node): + if isinstance(node, dict): + _visit_dict(node) + return + if isinstance(node, list): + for it in node: + if isinstance(it, (dict, list)): + _visit(it) + + _visit(params_as_dict) + return params_as_dict diff --git a/flow360/component/simulation/simulation_params.py b/flow360/component/simulation/simulation_params.py index 5309d75af..43f21b801 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -202,6 +202,8 @@ def _sanitize_params_dict(model_dict): """ recursive_remove_key(model_dict, "_id") + model_dict.pop("hash", None) + return model_dict def _init_no_unit_context(self, filename, file_content, **kwargs): @@ -231,6 +233,7 @@ def _init_no_unit_context(self, filename, file_content, **kwargs): def _init_with_unit_context(self, **kwargs): """ Initializes the simulation parameters with the given unit context. + This is the entry when user construct Param with Python script. """ # When treating dicts the updater is skipped. kwargs = _ParamModelBase._init_check_unit_system(**kwargs) diff --git a/flow360/component/simulation/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index dcc04256a..145a80bea 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -108,11 +108,11 @@ def rotation_volume_translator(obj: RotationVolume, rotor_disk_names: list): if is_exact_instance(entity, Cylinder): if entity.name in rotor_disk_names: # Current sliding interface encloses a rotor disk - # Then we append the interace name which is hardcoded "rotorDisk-"" + # Then we append the interface name which is hardcoded "rotorDisk-"" setting["enclosedObjects"].append("rotorDisk-" + entity.name) else: # Current sliding interface encloses another sliding interface - # Then we append the interace name which is hardcoded "slidingInterface-"" + # Then we append the interface name which is hardcoded "slidingInterface-"" setting["enclosedObjects"].append("slidingInterface-" + entity.name) elif is_exact_instance(entity, AxisymmetricBody): setting["enclosedObjects"].append("slidingInterface-" + entity.name) diff --git a/flow360/component/simulation/validation/validation_output.py b/flow360/component/simulation/validation/validation_output.py index 5bfe171ce..1c95fe8b1 100644 --- a/flow360/component/simulation/validation/validation_output.py +++ b/flow360/component/simulation/validation/validation_output.py @@ -13,6 +13,9 @@ ) from flow360.component.simulation.time_stepping.time_stepping import Steady from flow360.component.simulation.user_code.core.types import Expression +from flow360.component.simulation.validation.validation_context import ( + get_validation_info, +) def _check_output_fields(params): @@ -230,6 +233,10 @@ def _check_unique_surface_volume_probe_entity_names(params): if not params.outputs: return params + if not get_validation_info(): + # stored_entities is not expanded yet. + return params + for output_index, output in enumerate(params.outputs): if isinstance(output, (ProbeOutput, SurfaceProbeOutput)): active_entity_names = set() diff --git a/flow360/component/simulation/validation/validation_simulation_params.py b/flow360/component/simulation/validation/validation_simulation_params.py index 76871be3b..5805a3897 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -72,13 +72,17 @@ def _check_duplicate_entities_in_models(params): if not params.models: return params + if not get_validation_info(): + # Validation deferred since the entities are not deduplicated yet + return params + models = params.models usage = EntityUsageMap() for model in models: if hasattr(model, "entities"): # pylint: disable = protected-access - expanded_entities = model.entities._get_expanded_entities(create_hard_copy=False) + expanded_entities = model.entities.stored_entities for entity in expanded_entities: usage.add_entity_usage(entity, model.type) @@ -404,7 +408,7 @@ def _check_complete_boundary_condition_and_unknown_surface( entities = [] # pylint: disable=protected-access if hasattr(model, "entities"): - entities = model.entities._get_expanded_entities(create_hard_copy=False) + entities = model.entities.stored_entities elif hasattr(model, "entity_pairs"): # Periodic BC entities = [ pair for surface_pair in model.entity_pairs.items for pair in surface_pair.pair diff --git a/flow360/component/simulation/web/draft.py b/flow360/component/simulation/web/draft.py index cde7aef9b..c5e307e66 100644 --- a/flow360/component/simulation/web/draft.py +++ b/flow360/component/simulation/web/draft.py @@ -19,6 +19,7 @@ from flow360.cloud.rest_api import RestApi from flow360.component.interfaces import DraftInterface from flow360.component.resource_base import Flow360Resource, ResourceDraft +from flow360.component.simulation.services_utils import strip_selector_matches_inplace from flow360.component.utils import ( check_existence_of_one_file, check_read_access_of_one_file, @@ -134,10 +135,13 @@ def from_cloud(cls, draft_id: IDStringType) -> Draft: def update_simulation_params(self, params): """update the SimulationParams of the draft""" + # Serialize to dict and strip selector-matched entities so that UI can distinguish handpicked items + params_dict = params.model_dump(mode="json", exclude_none=True) + params_dict = strip_selector_matches_inplace(params_dict) self.post( json={ - "data": params.model_dump_json(exclude_none=True), + "data": json.dumps(params_dict), "type": "simulation", "version": "", }, diff --git a/tests/conftest.py b/tests/conftest.py index bb0a576b8..4f21b3887 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,10 @@ import os +from flow360.component.simulation.validation.validation_context import ( + ParamsValidationInfo, + ValidationContext, +) + os.environ["MPLBACKEND"] = "Agg" import matplotlib @@ -41,3 +46,10 @@ def before_log_test(request): def after_log_test(): yield set_logging_file(pytest.tmp_log_file, level="DEBUG") + + +@pytest.fixture +def mock_validation_context(): + return ValidationContext( + levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) + ) diff --git a/tests/simulation/framework/data/airplane_volume_mesh/simulation.json b/tests/simulation/framework/data/airplane_volume_mesh/simulation.json new file mode 100644 index 000000000..0655e175e --- /dev/null +++ b/tests/simulation/framework/data/airplane_volume_mesh/simulation.json @@ -0,0 +1,346 @@ +{ + "version": "25.7.4b0", + "unit_system": { + "name": "SI" + }, + "reference_geometry": { + "moment_center": { + "value": [ + 0, + 0, + 0 + ], + "units": "m" + }, + "moment_length": { + "value": [ + 1, + 1, + 1 + ], + "units": "m" + }, + "area": { + "type_name": "number", + "value": 1, + "units": "m**2" + } + }, + "operating_condition": { + "type_name": "AerospaceCondition", + "private_attribute_constructor": "default", + "private_attribute_input_cache": { + "alpha": { + "value": 0, + "units": "degree" + }, + "beta": { + "value": 0, + "units": "degree" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "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" + } + } + } + } + }, + "alpha": { + "value": 0, + "units": "degree" + }, + "beta": { + "value": 0, + "units": "degree" + }, + "thermal_state": { + "type_name": "ThermalState", + "private_attribute_constructor": "default", + "private_attribute_input_cache": {}, + "temperature": { + "value": 288.15, + "units": "K" + }, + "density": { + "value": 1.225, + "units": "kg/m**3" + }, + "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" + } + } + } + } + }, + "models": [ + { + "type": "Wall", + "entities": { + "stored_entities": [] + }, + "name": "Wall", + "use_wall_function": false, + "heat_spec": { + "value": { + "value": 0, + "units": "W/m**2" + }, + "type_name": "HeatFlux" + }, + "roughness_height": { + "value": 0, + "units": "m" + } + }, + { + "type": "Freestream", + "entities": { + "stored_entities": [] + }, + "name": "Freestream" + }, + { + "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", + "rho": "rho", + "u": "u", + "v": "v", + "w": "w", + "p": "p" + }, + "type": "Fluid", + "navier_stokes_solver": { + "absolute_tolerance": 1e-10, + "relative_tolerance": 0, + "order_of_accuracy": 2, + "equation_evaluation_frequency": 1, + "linear_solver": { + "max_iterations": 30 + }, + "CFL_multiplier": 1, + "kappa_MUSCL": -1, + "numerical_dissipation_factor": 1, + "limit_velocity": false, + "limit_pressure_density": false, + "type_name": "Compressible", + "low_mach_preconditioner": false, + "update_jacobian_frequency": 4, + "max_force_jac_update_physical_steps": 0 + }, + "turbulence_model_solver": { + "absolute_tolerance": 1e-8, + "relative_tolerance": 0, + "order_of_accuracy": 2, + "equation_evaluation_frequency": 4, + "linear_solver": { + "max_iterations": 20 + }, + "CFL_multiplier": 2, + "type_name": "SpalartAllmaras", + "reconstruction_gradient_limiter": 0.5, + "quadratic_constitutive_relation": false, + "modeling_constants": { + "type_name": "SpalartAllmarasConsts", + "C_DES": 0.72, + "C_d": 8, + "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 + }, + "update_jacobian_frequency": 4, + "max_force_jac_update_physical_steps": 0, + "rotation_correction": false, + "low_reynolds_correction": false + }, + "transition_model_solver": { + "type_name": "None" + } + } + ], + "time_stepping": { + "type_name": "Steady", + "max_steps": 2000, + "CFL": { + "type": "adaptive", + "min": 0.1, + "max": 10000, + "max_relative_change": 1, + "convergence_limiting_factor": 0.25 + } + }, + "user_defined_fields": [], + "outputs": [ + { + "output_fields": { + "items": [ + "Cp", + "yPlus", + "Cf", + "CfVec" + ] + }, + "private_attribute_id": "d453e840-1d6b-408e-a957-882abc8126cf", + "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", + "name": "*", + "private_attribute_sub_components": [] + } + ] + }, + "write_single_file": false, + "output_type": "SurfaceOutput" + } + ], + "private_attribute_asset_cache": { + "project_length_unit": { + "value": 1, + "units": "m" + }, + "use_inhouse_mesher": false, + "use_geometry_AI": false, + "project_entity_info": { + "type_name": "VolumeMeshEntityInfo", + "zones": [ + { + "private_attribute_registry_bucket_name": "VolumetricEntityType", + "private_attribute_entity_type_name": "GenericVolume", + "private_attribute_id": "fluid", + "name": "fluid", + "private_attribute_zone_boundary_names": { + "items": [ + "fluid/farfield", + "fluid/fuselage", + "fluid/leftWing", + "fluid/rightWing" + ] + }, + "private_attribute_full_name": "fluid", + "axes": null, + "axis": null, + "center": null + } + ], + "boundaries": [ + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "fluid/fuselage", + "name": "fluid/fuselage", + "private_attribute_full_name": "fluid/fuselage", + "private_attribute_is_interface": false, + "private_attribute_tag_key": null, + "private_attribute_sub_components": [], + "private_attribute_color": null, + "private_attributes": null + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "fluid/leftWing", + "name": "fluid/leftWing", + "private_attribute_full_name": "fluid/leftWing", + "private_attribute_is_interface": false, + "private_attribute_tag_key": null, + "private_attribute_sub_components": [], + "private_attribute_color": null, + "private_attributes": null + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "fluid/rightWing", + "name": "fluid/rightWing", + "private_attribute_full_name": "fluid/rightWing", + "private_attribute_is_interface": false, + "private_attribute_tag_key": null, + "private_attribute_sub_components": [], + "private_attribute_color": null, + "private_attributes": null + }, + { + "private_attribute_registry_bucket_name": "SurfaceEntityType", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "fluid/farfield", + "name": "fluid/farfield", + "private_attribute_full_name": "fluid/farfield", + "private_attribute_is_interface": false, + "private_attribute_tag_key": null, + "private_attribute_sub_components": [], + "private_attribute_color": null, + "private_attributes": null + } + ] + } + } +} \ No newline at end of file diff --git a/tests/simulation/framework/test_entities_v2.py b/tests/simulation/framework/test_entities_v2.py index bcf1088e1..050126c40 100644 --- a/tests/simulation/framework/test_entities_v2.py +++ b/tests/simulation/framework/test_entities_v2.py @@ -325,23 +325,6 @@ def test_copying_entity(my_cylinder1): ): my_cylinder1.copy(update={"height": 1.0234}) - with pytest.raises( - ValueError, - match=re.escape( - "Copying an entity requires a new name to be specified. Please provide a new name in the update dictionary." - ), - ): - my_cylinder1.copy(update={"height": 1.0234, "name": my_cylinder1.name}) - - assert ( - len( - TempFluidDynamics( - entities=[my_cylinder1, my_cylinder1] - ).entities._get_expanded_entities(create_hard_copy=False) - ) - == 1 - ) - my_cylinder3_2 = my_cylinder1.copy(update={"height": 8119 * u.m, "name": "zone/Cylinder3-2"}) assert my_cylinder3_2.height == 8119 * u.m @@ -349,92 +332,6 @@ def test_copying_entity(my_cylinder1): ##:: ---------------- EntityList/Registry tests ---------------- -def test_EntityList_discrimination(): - class ConfusingEntity1(EntityBase): - some_value: int = pd.Field(1, gt=1) - private_attribute_entity_type_name: Literal["ConfusingEntity1"] = pd.Field( - "ConfusingEntity1", frozen=True - ) - private_attribute_registry_bucket_name: Literal["UnitTestEntityType"] = pd.Field( - "UnitTestEntityType", frozen=True - ) - - class ConfusingEntity2(EntityBase): - some_value: int = pd.Field(1, gt=2) - private_attribute_entity_type_name: Literal["ConfusingEntity2"] = pd.Field( - "ConfusingEntity2", frozen=True - ) - private_attribute_registry_bucket_name: Literal["UnitTestEntityType"] = pd.Field( - "UnitTestEntityType", frozen=True - ) - - class MyModel(Flow360BaseModel): - entities: EntityList[ConfusingEntity1, ConfusingEntity2] = pd.Field() - - # Ensure EntityList is looking for the discriminator - with pytest.raises( - ValueError, - match=re.escape( - "Unable to extract tag using discriminator 'private_attribute_entity_type_name'" - ), - ): - MyModel( - **{ - "entities": { - "stored_entities": [ - { - "name": "private_attribute_entity_type_name is missing", - "some_value": 1, - } - ], - } - } - ) - - # Ensure EntityList is only trying to validate against ConfusingEntity1 - try: - MyModel( - **{ - "entities": { - "stored_entities": [ - { - "name": "I should be deserialize as ConfusingEntity1", - "private_attribute_entity_type_name": "ConfusingEntity1", - "some_value": 1, - } - ], - } - } - ) - except pd.ValidationError as err: - validation_errors = err.errors() - # Without discrimination, above deserialization would have failed both - # ConfusingEntitys' checks and result in 3 errors: - # 1. some_value is less than 1 (from ConfusingEntity1) - # 2. some_value is less than 2 (from ConfusingEntity2) - # 3. private_attribute_entity_type_name is incorrect (from ConfusingEntity2) - # But now we enforce Pydantic to only check against ConfusingEntity1 - assert validation_errors[0]["msg"] == "Input should be greater than 1" - assert validation_errors[0]["loc"] == ( - "entities", - "stored_entities", - 0, - "ConfusingEntity1", - "some_value", - ) - assert len(validation_errors) == 1 - - -def test_entities_expansion(my_cylinder1, my_box_zone1): - """Test that the exact same entities will be removed in expanded entities.""" - expanded_entities = TempFluidDynamics( - entities=[my_cylinder1, my_cylinder1, my_box_zone1] - ).entities._get_expanded_entities(create_hard_copy=False) - assert my_cylinder1 in expanded_entities - assert my_box_zone1 in expanded_entities - assert len(expanded_entities) == 2 - - def test_by_reference_registry(my_cylinder2): """Test that the entity registry contains reference not deepcopy of the entities.""" my_fd = TempFluidDynamics(entities=[my_cylinder2]) @@ -458,16 +355,6 @@ def test_by_reference_registry(my_cylinder2): assert my_fd.entities.stored_entities[0].height == 132 * u.m -def test_by_value_expansion(my_cylinder2): - expanded_entities = TempFluidDynamics(entities=[my_cylinder2]).entities._get_expanded_entities( - create_hard_copy=True - ) - my_cylinder2.height = 1012 * u.cm - for entity in expanded_entities: - if isinstance(entity, Cylinder) and entity.name == "zone/Cylinder2": - assert entity.height == 12 * u.nm # unchanged - - def test_entity_registry_item_retrieval( my_cylinder1, my_cylinder2, @@ -496,45 +383,13 @@ def test_entity_registry_item_retrieval( assert items[0].name == "CC_ground" -def test_entities_input_interface(my_volume_mesh1): +def test_asset_getitem(my_volume_mesh1): + """Test the __getitem__ interface of asset objects.""" # 1. Using reference of single asset entity - expanded_entities = TempFluidDynamics( - entities=my_volume_mesh1["zone*"] - ).entities._get_expanded_entities(create_hard_copy=True) + expanded_entities = my_volume_mesh1["zone*"] assert len(expanded_entities) == 3 - assert expanded_entities == my_volume_mesh1["zone*"] - # 2. test using invalid entity input (UGRID convention example) - with pytest.raises( - ValueError, - match=re.escape( - "Type() of input to `entities` (1) is not valid. Expected entity instance." - ), - ): - expanded_entities = TempFluidDynamics(entities=1).entities._get_expanded_entities( - create_hard_copy=True - ) - # 3. test empty list - with pytest.raises( - ValueError, - match=re.escape("Invalid input type to `entities`, list is empty."), - ): - expanded_entities = TempFluidDynamics(entities=[]).entities._get_expanded_entities( - create_hard_copy=True - ) - - # 4. test None - with pytest.raises( - ValueError, - match=re.escape( - "Input should be a valid list [type=list_type, input_value=None, input_type=NoneType]" - ), - ): - expanded_entities = TempFluidDynamics(entities=None).entities._get_expanded_entities( - create_hard_copy=True - ) - - # 5. test typo/non-existing entities. + # 2. test typo/non-existing entities. with pytest.raises( ValueError, match=re.escape("Failed to find any matching entity with asdf. Please check your input."), @@ -542,43 +397,6 @@ def test_entities_input_interface(my_volume_mesh1): my_volume_mesh1["asdf"] -def test_entire_workflow(my_cylinder1, my_volume_mesh1): - with SI_unit_system: - my_param = TempSimulationParam( - far_field_type="auto", - models=[ - TempFluidDynamics( - entities=[ - my_cylinder1, - my_cylinder1, - my_cylinder1, - my_volume_mesh1["*"], - my_volume_mesh1["*zone*"], - ] - ), - TempWallBC(surfaces=[my_volume_mesh1["*"]]), - ], - ) - - my_param.preprocess() - - fluid_dynamics_entity_names = [ - entity.name for entity in my_param.models[0].entities.stored_entities - ] - - wall_entity_names = [entity.name for entity in my_param.models[1].entities.stored_entities] - assert "zone/Cylinder1" in fluid_dynamics_entity_names - assert "zone_1" in fluid_dynamics_entity_names - assert "zone_2" in fluid_dynamics_entity_names - assert "zone_3" in fluid_dynamics_entity_names - assert len(fluid_dynamics_entity_names) == 4 - - assert "surface_1" in wall_entity_names - assert "surface_2" in wall_entity_names - assert "surface_3" in wall_entity_names - assert len(wall_entity_names) == 3 - - def test_multiple_param_creation_and_asset_registry( my_cylinder1, my_box_zone2, my_box_zone1, my_volume_mesh1, my_volume_mesh2 ): # Make sure that no entities from the first param are present in the second param diff --git a/tests/simulation/framework/test_entity_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py index db85f4621..6df99d58a 100644 --- a/tests/simulation/framework/test_entity_expansion_impl.py +++ b/tests/simulation/framework/test_entity_expansion_impl.py @@ -11,64 +11,9 @@ from flow360.component.simulation.services import resolve_selectors -def _mk_pool(names, etype): +def _mk_pool(names, entity_type): # 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": "any_of", "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"] # in ["edgeB"] - - # selectors cleared - assert params["outputs"][0]["selectors"] == [] + return [{"name": n, "private_attribute_entity_type_name": entity_type} for n in names] def test_operator_and_syntax_coverage(): @@ -269,7 +214,6 @@ def test_attribute_tag_scalar_support(): expand_entity_selectors_in_place(db, params) stored = params["node"]["stored_entities"] - # Expect union of two selectors: # 1) AND tag in ["A"] -> [wing, fuselage] # 2) OR tag in {B} or matches 'A' -> pool-order union -> [wing, tail, fuselage] diff --git a/tests/simulation/framework/test_entity_list.py b/tests/simulation/framework/test_entity_list.py new file mode 100644 index 000000000..b7f5abbd0 --- /dev/null +++ b/tests/simulation/framework/test_entity_list.py @@ -0,0 +1,157 @@ +import re +from typing import ClassVar, Literal + +import pydantic as pd +import pytest + +import flow360 as fl +from flow360.component.simulation.framework.base_model import Flow360BaseModel +from flow360.component.simulation.framework.entity_base import EntityBase, EntityList +from flow360.component.simulation.primitives import GenericVolume, Surface + + +class _SurfaceEntityBase(EntityBase): + """Base class for surface-like entities (CAD or mesh).""" + + entity_bucket: ClassVar[str] = "surfaces" + private_attribute_entity_type_name: Literal["_SurfaceEntityBase"] = pd.Field( + "_SurfaceEntityBase", frozen=True + ) + + +class TempSurface(_SurfaceEntityBase): + private_attribute_entity_type_name: Literal["TempSurface"] = pd.Field( + "TempSurface", frozen=True + ) + + +def test_entity_list_deserializer_handles_mixed_types_and_selectors(): + """ + Test: EntityList deserializer correctly processes a mixed list of entities and selectors. + - Verifies that EntityList can accept a list containing both entity instances and selectors. + - Verifies that entity objects are placed in `stored_entities`. + - Verifies that EntitySelector objects are placed in `selectors`. + - Verifies that the types are validated against the EntityList's generic parameters. + """ + with fl.SI_unit_system: + selector = Surface.match("*", name="all_surfaces") + surface_entity = Surface(name="my_surface") + temp_surface_entity = TempSurface(name="my_temp_surface") + # This entity should be filtered out as it's not a valid type for this list + volume_entity = GenericVolume(name="my_volume") + + # Use model_validate to correctly trigger the "before" mode validator + entity_list = EntityList[Surface, TempSurface].model_validate( + [selector, surface_entity, temp_surface_entity, volume_entity] + ) + + assert len(entity_list.stored_entities) == 2 + assert entity_list.stored_entities[0] == surface_entity + assert entity_list.stored_entities[1] == temp_surface_entity + + assert len(entity_list.selectors) == 1 + assert entity_list.selectors[0] == selector + + +def test_entity_list_discrimination(): + """ + Test: EntityList correctly uses the discriminator field for Pydantic model validation. + """ + + class ConfusingEntity1(EntityBase): + entity_bucket: ClassVar[str] = "confusing" + some_value: int = pd.Field(1, gt=1) + private_attribute_entity_type_name: Literal["ConfusingEntity1"] = pd.Field( + "ConfusingEntity1", frozen=True + ) + + class ConfusingEntity2(EntityBase): + entity_bucket: ClassVar[str] = "confusing" + some_value: int = pd.Field(1, gt=2) + private_attribute_entity_type_name: Literal["ConfusingEntity2"] = pd.Field( + "ConfusingEntity2", frozen=True + ) + + class MyModel(Flow360BaseModel): + entities: EntityList[ConfusingEntity1, ConfusingEntity2] + + # Ensure EntityList requires the discriminator + with pytest.raises( + ValueError, + match=re.escape( + "Unable to extract tag using discriminator 'private_attribute_entity_type_name'" + ), + ): + MyModel( + entities={ + "stored_entities": [ + { + "name": "discriminator_is_missing", + "some_value": 3, + } + ], + } + ) + + # Ensure EntityList validates against the correct model based on the discriminator + with pytest.raises(pd.ValidationError) as err: + MyModel( + entities={ + "stored_entities": [ + { + "name": "should_be_confusing_entity_1", + "private_attribute_entity_type_name": "ConfusingEntity1", + "some_value": 1, # This violates the gt=1 constraint of ConfusingEntity1 + } + ], + } + ) + + validation_errors = err.value.errors() + # Pydantic should only try to validate against ConfusingEntity1, resulting in one error. + # Without discrimination, it would have failed checks for both models. + assert len(validation_errors) == 1 + assert validation_errors[0]["msg"] == "Input should be greater than 1" + assert validation_errors[0]["loc"] == ( + "entities", + "stored_entities", + 0, + "ConfusingEntity1", + "some_value", + ) + + +def test_entity_list_invalid_inputs(): + """ + Test: EntityList deserializer handles various invalid inputs gracefully. + """ + # 1. Test invalid entity type in list (e.g., int) + with pytest.raises( + ValueError, + match=re.escape( + "Type() of input to `entities` (1) is not valid. Expected entity instance." + ), + ): + EntityList[Surface].model_validate([1]) + + # 2. Test empty list + with pytest.raises( + ValueError, + match=re.escape("Invalid input type to `entities`, list is empty."), + ): + EntityList[Surface].model_validate([]) + + # 3. Test None input + with pytest.raises( + pd.ValidationError, + match="Input should be a valid list", + ): + EntityList[Surface].model_validate(None) + + # 4. Test list containing only invalid types + with pytest.raises( + ValueError, + match=re.escape("Can not find any valid entity of type ['Surface'] from the input."), + ): + with fl.SI_unit_system: + EntityList[Surface].model_validate([GenericVolume(name="a_volume")]) diff --git a/tests/simulation/framework/test_entity_materializer.py b/tests/simulation/framework/test_entity_materializer.py new file mode 100644 index 000000000..33f6269a2 --- /dev/null +++ b/tests/simulation/framework/test_entity_materializer.py @@ -0,0 +1,270 @@ +import copy +from typing import Optional + +import pydantic as pd + +from flow360.component.simulation.framework.entity_materializer import ( + materialize_entities_in_place, +) +from flow360.component.simulation.outputs.output_entities import Point +from flow360.component.simulation.primitives import Surface + + +def _mk_entity(name: str, entity_type: str, eid: Optional[str] = None) -> dict: + d = {"name": name, "private_attribute_entity_type_name": entity_type} + if eid is not None: + d["private_attribute_id"] = eid + return d + + +def _mk_surface_dict(name: str, eid: str): + return { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": eid, + "name": name, + } + + +def _mk_point_dict(name: str, eid: str, coords=(0.0, 0.0, 0.0)): + return { + "private_attribute_entity_type_name": "Point", + "private_attribute_id": eid, + "name": name, + "location": {"units": "m", "value": list(coords)}, + } + + +def test_materializes_dicts_and_shares_instances_across_lists(): + """ + Test: Entity materializer converts dicts to Pydantic instances and shares them globally. + + Purpose: + - Verify that materialize_entities_in_place() converts entity dicts to model instances + - Verify that entities with same (type, id) are the same Python object (by identity) + - Verify that instance sharing works across different nodes in the params tree + - Verify that materialization is idempotent with respect to instance identity + + Expected behavior: + - Input: Entity dicts with same IDs in different locations (nodes a and b) + - Process: Materialization uses global cache keyed by (type, id) + - Output: Same instances appear in both locations (a_list[0] is b_list[1]) + + This enables memory efficiency and supports identity-based entity comparison. + """ + params = { + "a": { + "stored_entities": [ + _mk_entity("wing", "Surface", eid="s-1"), + _mk_entity("tail", "Surface", eid="s-2"), + ] + }, + "b": { + "stored_entities": [ + # same ids as in node a + _mk_entity("tail", "Surface", eid="s-2"), + _mk_entity("wing", "Surface", eid="s-1"), + ] + }, + } + + out = materialize_entities_in_place(copy.deepcopy(params)) + a_list = out["a"]["stored_entities"] + b_list = out["b"]["stored_entities"] + + # Objects with same (type, id) across different lists should be the same instance + assert a_list[0] is b_list[1] + assert a_list[1] is b_list[0] + + +def test_per_list_dedup_for_non_point(): + """ + Test: Materializer deduplicates non-Point entities within each list. + + Purpose: + - Verify that materialize_entities_in_place() removes duplicate entities + - Verify that deduplication is based on stable key (type, id) tuple + - Verify that order is preserved (first occurrence kept) + - Verify that this applies to all non-Point entity types + + Expected behavior: + - Input: List with duplicate Surface entities (same id "s-1") + - Process: Deduplication removes second occurrence + - Output: Single "wing" and one "tail" entity remain + + Note: Point entities are exempt from deduplication (tested separately). + """ + params = { + "node": { + "stored_entities": [ + _mk_entity("wing", "Surface", eid="s-1"), + _mk_entity("wing", "Surface", eid="s-1"), # duplicate + _mk_entity("tail", "Surface", eid="s-2"), + ] + } + } + + out = materialize_entities_in_place(copy.deepcopy(params)) + items = out["node"]["stored_entities"] + # Dedup preserves order and removes duplicates for non-Point types + assert [e.name for e in items] == ["wing", "tail"] + + +def test_skip_dedup_for_point(): + """ + Test: Point entities are exempt from deduplication during materialization. + + Purpose: + - Verify that Point entity type is explicitly excluded from deduplication + - Verify that duplicate Point entities with identical data are preserved + - Verify that this exception only applies to Point (not PointArray, etc.) + + Expected behavior: + - Input: Two Point entities with same name and location + - Process: Materialization skips deduplication for Point type + - Output: Both Point entities remain in the list + + Rationale: Point entities may intentionally be duplicated for different + use cases (e.g., multiple probes or streamline seeds at same location). + """ + params = { + "node": { + "stored_entities": [ + { + "name": "p1", + "private_attribute_entity_type_name": "Point", + "location": {"units": "m", "value": [0.0, 0.0, 0.0]}, + }, + { + "name": "p1", + "private_attribute_entity_type_name": "Point", + "location": {"units": "m", "value": [0.0, 0.0, 0.0]}, + }, # duplicate Point remains + { + "name": "p2", + "private_attribute_entity_type_name": "Point", + "location": {"units": "m", "value": [1.0, 0.0, 0.0]}, + }, + ] + } + } + + out = materialize_entities_in_place(copy.deepcopy(params)) + items = out["node"]["stored_entities"] + assert [e.name for e in items] == ["p1", "p1", "p2"] + + +def test_reentrant_safe_and_idempotent(): + """ + Test: Materializer is reentrant-safe and idempotent. + + Purpose: + - Verify that materialize_entities_in_place() can be called multiple times safely + - Verify that subsequent calls on already-materialized data are no-ops + - Verify that object identity is maintained across re-entrant calls + - Verify that deduplication results are stable + + Expected behavior: + - First call: Converts dicts to objects, deduplicates + - Second call: Recognizes already-materialized objects, preserves identity + - Output: Same results, same object identities (items1[0] is items2[0]) + + This property is important for pipeline robustness and allows the + materializer to be called at multiple stages without side effects. + """ + params = { + "node": { + "stored_entities": [ + _mk_entity("wing", "Surface", eid="s-1"), + _mk_entity("wing", "Surface", eid="s-1"), # duplicate + _mk_entity("tail", "Surface", eid="s-2"), + ] + } + } + + out1 = materialize_entities_in_place(copy.deepcopy(params)) + # Re-entrant call on already materialized objects + out2 = materialize_entities_in_place(out1) + items1 = out1["node"]["stored_entities"] + items2 = out2["node"]["stored_entities"] + + assert [e.name for e in items1] == ["wing", "tail"] + assert [e.name for e in items2] == ["wing", "tail"] + # Identity maintained across re-entrant call + assert items1[0] is items2[0] + assert items1[1] is items2[1] + + +def test_materialize_dedup_and_point_passthrough(): + params = { + "models": [ + { + "entities": { + "stored_entities": [ + _mk_surface_dict("wing", "s1"), + _mk_surface_dict("wing", "s1"), # duplicate by id + _mk_point_dict("p1", "p1", (0.0, 0.0, 0.0)), + ] + } + } + ] + } + + out = materialize_entities_in_place(params) + items = out["models"][0]["entities"]["stored_entities"] + + # 1) Surfaces are deduped per list + assert sum(isinstance(x, Surface) for x in items) == 1 + # 2) Points are not deduped by policy (pass-through in not_merged_types) + assert sum(isinstance(x, Point) for x in items) == 1 + + # 3) Idempotency: re-run should keep the same shape and types + out2 = materialize_entities_in_place(out) + items2 = out2["models"][0]["entities"]["stored_entities"] + assert len(items2) == len(items) + assert sum(isinstance(x, Surface) for x in items2) == 1 + assert sum(isinstance(x, Point) for x in items2) == 1 + + +def test_materialize_passthrough_on_reentrant_call(): + # Re-entrant call should pass through object instances and remain stable + explicit = pd.TypeAdapter(Surface).validate_python( + { + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "s1", + "name": "wing", + } + ) + params = { + "models": [ + { + "entities": { + "stored_entities": [ + explicit, + ] + } + } + ] + } + out = materialize_entities_in_place(params) + items = out["models"][0]["entities"]["stored_entities"] + assert len([x for x in items if isinstance(x, Surface)]) == 1 + + +def test_materialize_reuses_cached_instance_across_nodes(): + # Same entity appears in multiple lists -> expect the same Python object reused + sdict = _mk_surface_dict("wing", "s1") + params = { + "models": [ + {"entities": {"stored_entities": [sdict]}}, + {"entities": {"stored_entities": [sdict]}}, + ] + } + + out = materialize_entities_in_place(params) + items1 = out["models"][0]["entities"]["stored_entities"] + items2 = out["models"][1]["entities"]["stored_entities"] + + obj1 = next(x for x in items1 if isinstance(x, Surface)) + obj2 = next(x for x in items2 if isinstance(x, Surface)) + # identity check: cached instance is reused across nodes + assert obj1 is obj2 diff --git a/tests/simulation/framework/test_entity_selector_fluent_api.py b/tests/simulation/framework/test_entity_selector_fluent_api.py index 10a1975a9..838e85a04 100644 --- a/tests/simulation/framework/test_entity_selector_fluent_api.py +++ b/tests/simulation/framework/test_entity_selector_fluent_api.py @@ -9,9 +9,9 @@ from flow360.component.simulation.primitives import Edge, Surface -def _mk_pool(names, etype): +def _mk_pool(names, entity_type): # Build list of entity dicts with given names and type - return [{"name": n, "private_attribute_entity_type_name": etype} for n in names] + return [{"name": n, "private_attribute_entity_type_name": entity_type} for n in names] def _expand_and_get_names(db: EntityDictDatabase, selector_model) -> list[str]: @@ -27,47 +27,119 @@ def _expand_and_get_names(db: EntityDictDatabase, selector_model) -> list[str]: def test_surface_class_match_and_chain_and(): + """ + Test: EntitySelector fluent API with AND logic (default) and predicate chaining. + + Purpose: + - Verify that Surface.match() creates a selector with glob pattern matching + - Verify that chaining .not_any_of() adds an exclusion predicate + - Verify that AND logic correctly computes intersection of predicates + - Verify that the selector expands correctly against an entity database + + Expected behavior: + - match("wing*") selects: ["wing", "wing-root", "wingtip"] + - not_any_of(["wing"]) excludes: ["wing"] + - AND logic result: ["wing-root", "wingtip"] + """ # 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"]) + selector = Surface.match("wing*", name="t_and").not_any_of(["wing"]) names = _expand_and_get_names(db, selector) assert names == ["wing-root", "wingtip"] def test_surface_class_match_or_union(): + """ + Test: EntitySelector with OR logic for union of predicates. + + Purpose: + - Verify that logic="OR" parameter works correctly + - Verify that OR logic computes union of all matching predicates + - Verify that result order is stable (preserved from original pool) + - Verify that any_of() predicate works in OR mode + + Expected behavior: + - match("s1") selects: ["s1"] + - any_of(["tail"]) selects: ["tail"] + - OR logic result: ["s1", "tail"] (in pool order) + """ db = EntityDictDatabase(surfaces=_mk_pool(["s1", "s2", "tail", "wing"], "Surface")) # OR logic: union of predicates - selector = Surface.match("s1", logic="OR").any_of(["tail"]) + selector = Surface.match("s1", name="t_or", 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(): + """ + Test: EntitySelector with mixed syntax (regex and glob) for pattern matching. + + Purpose: + - Verify that syntax="regex" enables regex pattern matching (fullmatch) + - Verify that syntax="glob" enables glob pattern matching (default) + - Verify that match() and not_match() predicates can be chained + - Verify that different syntax modes can be used in the same selector + + Expected behavior: + - match(r"^wing$", syntax="regex") selects: ["wing"] (exact match) + - not_match("*-root", syntax="glob") excludes: ["wing-root"] + - Result: ["wing"] (passed both predicates) + """ 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") + selector = Surface.match(r"^wing$", name="t_regex", 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(): + """ + Test: EntitySelector with any_of() and not_any_of() membership predicates. + + Purpose: + - Verify that any_of() (inclusion) predicate works correctly + - Verify that not_any_of() (exclusion) predicate works correctly + - Verify that membership predicates can be combined with pattern matching + - Verify that AND logic correctly applies set operations in sequence + + Expected behavior: + - match("*") selects all: ["a", "b", "c", "d"] + - any_of(["a", "b", "c"]) filters to: ["a", "b", "c"] + - not_any_of(["b"]) excludes: ["b"] + - Final result: ["a", "c"] + """ 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"]) + selector = Surface.match("*", name="t_in").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(): + """ + Test: EntitySelector with Edge entity type (non-Surface). + + Purpose: + - Verify that entity selector works with different entity types (Edge vs Surface) + - Verify that Edge.match() creates a selector targeting Edge entities + - Verify that the entity database correctly routes to the edges pool + - Verify that simple exact name matching works + + Expected behavior: + - Edge.match("edgeA") selects only edgeA from the edges pool + - Edge entities are correctly filtered by target_class + """ db = EntityDictDatabase(edges=_mk_pool(["edgeA", "edgeB"], "Edge")) - selector = Edge.match("edgeA") + selector = Edge.match("edgeA", name="edge_basic") params = {"node": {"selectors": [selector.model_dump()]}} expand_entity_selectors_in_place(db, params) stored = params["node"]["stored_entities"] diff --git a/tests/simulation/framework/test_selector_merge_vs_replace.py b/tests/simulation/framework/test_selector_merge_vs_replace.py new file mode 100644 index 000000000..be8c8fb07 --- /dev/null +++ b/tests/simulation/framework/test_selector_merge_vs_replace.py @@ -0,0 +1,68 @@ +from flow360.component.simulation.framework.entity_selector import ( + EntityDictDatabase, + expand_entity_selectors_in_place, +) + + +def _mk_pool(names, entity_type): + return [{"name": n, "private_attribute_entity_type_name": entity_type} for n in names] + + +def test_merge_mode_preserves_explicit_then_appends_selector_results(): + db = EntityDictDatabase(surfaces=_mk_pool(["wing", "tail", "body"], "Surface")) + params = { + "node": { + "stored_entities": [{"name": "tail", "private_attribute_entity_type_name": "Surface"}], + "selectors": [ + { + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "any_of", "value": ["wing"]}], + } + ], + } + } + expand_entity_selectors_in_place(db, params, merge_mode="merge") + items = params["node"]["stored_entities"] + assert [e["name"] for e in items if e["private_attribute_entity_type_name"] == "Surface"] == [ + "tail", + "wing", + ] + assert params["node"]["selectors"] == [ + { + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "any_of", "value": ["wing"]}], + } + ] + + +def test_replace_mode_overrides_target_class_only(): + db = EntityDictDatabase( + surfaces=_mk_pool(["wing", "tail"], "Surface"), edges=_mk_pool(["e1"], "Edge") + ) + params = { + "node": { + "stored_entities": [ + {"name": "tail", "private_attribute_entity_type_name": "Surface"}, + {"name": "e1", "private_attribute_entity_type_name": "Edge"}, + ], + "selectors": [ + { + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "any_of", "value": ["wing"]}], + } + ], + } + } + expand_entity_selectors_in_place(db, params, merge_mode="replace") + items = params["node"]["stored_entities"] + # Surface entries replaced by selector result; Edge preserved + assert [e["name"] for e in items if e["private_attribute_entity_type_name"] == "Surface"] == [ + "wing" + ] + assert [e["name"] for e in items if e["private_attribute_entity_type_name"] == "Edge"] == ["e1"] + assert params["node"]["selectors"] == [ + { + "children": [{"attribute": "name", "operator": "any_of", "value": ["wing"]}], + "target_class": "Surface", + } + ] diff --git a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py index 6fb40a95a..573064b09 100644 --- a/tests/simulation/params/meshing_validation/test_meshing_param_validation.py +++ b/tests/simulation/params/meshing_validation/test_meshing_param_validation.py @@ -143,8 +143,8 @@ def test_disable_invalid_axisymmetric_body_construction(): ) -def test_disable_multiple_cylinder_in_one_rotation_volume(): - with pytest.raises( +def test_disable_multiple_cylinder_in_one_rotation_volume(mock_validation_context): + with mock_validation_context, pytest.raises( pd.ValidationError, match="Only single instance is allowed in entities for each `RotationVolume`.", ): @@ -274,8 +274,8 @@ def test_limit_axisymmetric_body_in_rotation_volume(): ) -def test_reuse_of_same_cylinder(): - with pytest.raises( +def test_reuse_of_same_cylinder(mock_validation_context): + with mock_validation_context, pytest.raises( pd.ValidationError, match=r"Using Volume entity `I am reused` in `AxisymmetricRefinement`, `RotationVolume` at the same time is not allowed.", ): @@ -343,7 +343,7 @@ def test_reuse_of_same_cylinder(): ) ) - with pytest.raises( + with mock_validation_context, pytest.raises( pd.ValidationError, match=r"Using Volume entity `I am reused` in `AxisymmetricRefinement`, `UniformRefinement` at the same time is not allowed.", ): @@ -369,7 +369,7 @@ def test_reuse_of_same_cylinder(): ) ) - with pytest.raises( + with mock_validation_context, pytest.raises( pd.ValidationError, match=r" Volume entity `I am reused` is used multiple times in `UniformRefinement`.", ): diff --git a/tests/simulation/params/test_porous_medium.py b/tests/simulation/params/test_porous_medium.py index c55cdbb6c..aead1cf55 100644 --- a/tests/simulation/params/test_porous_medium.py +++ b/tests/simulation/params/test_porous_medium.py @@ -3,10 +3,18 @@ import flow360.component.simulation.units as u from flow360.component.simulation.models.volume_models import PorousMedium from flow360.component.simulation.primitives import GenericVolume +from flow360.component.simulation.validation.validation_context import ( + ParamsValidationInfo, + ValidationContext, +) def test_ensure_entities_have_sufficient_attributes(): - with pytest.raises( + mock_context = ValidationContext( + levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) + ) + + with mock_context, pytest.raises( ValueError, match="Entity 'zone_with_no_axes' must specify `axes` to be used under `PorousMedium`.", ): diff --git a/tests/simulation/params/test_rotation.py b/tests/simulation/params/test_rotation.py index aaf80d43c..28d981b5c 100644 --- a/tests/simulation/params/test_rotation.py +++ b/tests/simulation/params/test_rotation.py @@ -11,9 +11,9 @@ from flow360.component.simulation.unit_system import SI_unit_system -def test_ensure_entities_have_sufficient_attributes(): +def test_ensure_entities_have_sufficient_attributes(mock_validation_context): - with pytest.raises( + with mock_validation_context, pytest.raises( ValueError, match="Entity 'zone_with_no_axis' must specify `axis` to be used under `Rotation`.", ): @@ -23,7 +23,7 @@ def test_ensure_entities_have_sufficient_attributes(): spec=AngleExpression("0.45 * t"), ) - with pytest.raises( + with mock_validation_context, pytest.raises( ValueError, match="Entity 'zone_with_no_axis' must specify `center` to be used under `Rotation`.", ): diff --git a/tests/simulation/params/test_validators_criterion.py b/tests/simulation/params/test_validators_criterion.py index d8bc7cda4..91b44cf57 100644 --- a/tests/simulation/params/test_validators_criterion.py +++ b/tests/simulation/params/test_validators_criterion.py @@ -148,6 +148,7 @@ def test_criterion_multi_entities_probe_validation_fails( scalar_user_variable_density, single_point_probe_output, single_point_surface_probe_output, + mock_validation_context, ): """Test that multi-entity ProbeOutput is rejected.""" message = ( @@ -159,7 +160,7 @@ def test_criterion_multi_entities_probe_validation_fails( multi_point_probe_output.entities.stored_entities.append( Point(name="pt2", location=(1, 1, 1) * u.m) ) - with SI_unit_system, pytest.raises(ValueError, match=message): + with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): StoppingCriterion( monitor_field=scalar_user_variable_density, monitor_output=multi_point_probe_output, @@ -175,7 +176,7 @@ def test_criterion_multi_entities_probe_validation_fails( number_of_points=2, ), ] - with SI_unit_system, pytest.raises(ValueError, match=message): + with SI_unit_system, mock_validation_context, pytest.raises(ValueError, match=message): StoppingCriterion( monitor_field=scalar_user_variable_density, monitor_output=point_array_surface_probe_output, diff --git a/tests/simulation/params/test_validators_output.py b/tests/simulation/params/test_validators_output.py index 2a39bd7eb..8194d675a 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -453,10 +453,10 @@ def test_duplicate_probe_names(): ) -def test_duplicate_probe_entity_names(): +def test_duplicate_probe_entity_names(mock_validation_context): # should have no error - with imperial_unit_system: + with imperial_unit_system, mock_validation_context: SimulationParams( outputs=[ ProbeOutput( @@ -475,7 +475,7 @@ def test_duplicate_probe_entity_names(): ], ) - with pytest.raises( + with mock_validation_context, pytest.raises( ValueError, match=re.escape( "In `outputs`[0] ProbeOutput: Entity name point_1 has already been used in the " @@ -501,7 +501,7 @@ def test_duplicate_probe_entity_names(): ], ) - with pytest.raises( + with mock_validation_context, pytest.raises( ValueError, match=re.escape( "In `outputs`[0] SurfaceProbeOutput: Entity name point_1 has already been used in the " diff --git a/tests/simulation/params/test_validators_params.py b/tests/simulation/params/test_validators_params.py index 71997e03f..1a9fa1508 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -1026,8 +1026,11 @@ def test_duplicate_entities_in_models(): f"Volume entity `{entity_generic_volume.name}` appears multiple times in `{volume_model1.type}` model.\n" ) + mock_context = ValidationContext( + levels=None, info=ParamsValidationInfo(param_as_dict={}, referenced_expressions=[]) + ) # Invalid simulation params - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): + with SI_unit_system, mock_context, pytest.raises(ValueError, match=re.escape(message)): _ = SimulationParams( models=[volume_model1, volume_model2, surface_model1, surface_model2, surface_model3], ) @@ -1035,7 +1038,7 @@ def test_duplicate_entities_in_models(): message = f"Volume entity `{entity_cylinder.name}` appears multiple times in `{rotation_model1.type}` model.\n" # Invalid simulation params (Draft Entity) - with SI_unit_system, pytest.raises(ValueError, match=re.escape(message)): + with SI_unit_system, mock_context, pytest.raises(ValueError, match=re.escape(message)): _ = SimulationParams( models=[rotation_model1, rotation_model2], ) @@ -1790,7 +1793,7 @@ def test_validate_liquid_operating_condition(): assert errors[0]["loc"] == ("models",) -def test_beta_mesher_only_features(): +def test_beta_mesher_only_features(mock_validation_context): with SI_unit_system: params = SimulationParams( meshing=MeshingParams( @@ -1960,7 +1963,7 @@ def test_beta_mesher_only_features(): ) # Unique interface names - with pytest.raises( + with mock_validation_context, pytest.raises( ValueError, match="The boundaries of a CustomVolume must have different names." ): with SI_unit_system: diff --git a/tests/simulation/ref/entity_expansion_service_ref_outputs.json b/tests/simulation/ref/entity_expansion_service_ref_outputs.json index 8c6e86adf..72ecdf123 100644 --- a/tests/simulation/ref/entity_expansion_service_ref_outputs.json +++ b/tests/simulation/ref/entity_expansion_service_ref_outputs.json @@ -43,7 +43,18 @@ "private_attribute_potential_issues": [] } ], - "selectors": [] + "selectors": [ + { + "target_class": "Surface", + "children": [ + { + "attribute": "name", + "operator": "matches", + "value": "*" + } + ] + } + ] }, "write_single_file": false, "output_type": "SurfaceOutput" diff --git a/tests/simulation/services/test_entity_processing_service.py b/tests/simulation/services/test_entity_processing_service.py new file mode 100644 index 000000000..94644d272 --- /dev/null +++ b/tests/simulation/services/test_entity_processing_service.py @@ -0,0 +1,406 @@ +import copy +import json +import os + +import pytest + +import flow360 as fl +from flow360.component.project_utils import set_up_params_for_uploading +from flow360.component.resource_base import local_metadata_builder +from flow360.component.simulation.framework.entity_selector import ( + EntitySelector, + Predicate, +) +from flow360.component.simulation.framework.updater_utils import compare_values +from flow360.component.simulation.models.surface_models import Wall +from flow360.component.simulation.outputs.output_entities import Point +from flow360.component.simulation.primitives import Surface +from flow360.component.simulation.services import ValidationCalledBy, validate_model +from flow360.component.simulation.services_utils import strip_selector_matches_inplace +from flow360.component.volume_mesh import VolumeMeshMetaV2, VolumeMeshV2 + + +@pytest.fixture(autouse=True) +def _change_test_dir(request, monkeypatch): + monkeypatch.chdir(request.fspath.dirname) + + +def _load_local_vm(): + """Fixture to load a local volume mesh for testing.""" + return VolumeMeshV2.from_local_storage( + mesh_id="vm-aa3bb31e-2f85-4504-943c-7788d91c1ab0", + local_storage_path=os.path.join( + os.path.dirname(__file__), "..", "framework", "data", "airplane_volume_mesh" + ), + meta_data=VolumeMeshMetaV2( + **local_metadata_builder( + id="vm-aa3bb31e-2f85-4504-943c-7788d91c1ab0", + name="TEST", + cloud_path_prefix="/", + status="completed", + ) + ), + ) + + +def _load_json(path_from_tests_dir: str) -> dict: + """Helper to load a JSON file from the tests/simulation directory.""" + base = os.path.dirname(os.path.abspath(__file__)) + with open(os.path.join(base, "..", path_from_tests_dir), "r", encoding="utf-8") as file: + return json.load(file) + + +def test_validate_model_expands_selectors_and_preserves_them(): + """ + Test: End-to-end validation of a mixed entity/selector list. + - Verifies that `validate_model` expands selectors into `stored_entities`. + - Verifies that the original `selectors` list is preserved for future edits. + - Verifies that the process is idempotent. + """ + vm = _load_local_vm() + vm.internal_registry = vm._entity_info.get_registry(vm.internal_registry) + + with fl.SI_unit_system: + all_wings_selector = Surface.match("*Wing", name="all_wings") + fuselage_selector = Surface.match("flu*fuselage", name="fuselage") + wall_with_mixed_entities = Wall(entities=[all_wings_selector, vm["fluid/leftWing"]]) + wall_with_only_selectors = Wall(entities=[fuselage_selector]) + freestream = fl.Freestream(entities=[vm["fluid/farfield"]]) + params = fl.SimulationParams( + models=[wall_with_mixed_entities, wall_with_only_selectors, freestream] + ) + + params_with_cache = set_up_params_for_uploading( + vm, 1 * fl.u.m, params, use_beta_mesher=False, use_geometry_AI=False + ) + + validated, errors, _ = validate_model( + params_as_dict=params_with_cache.model_dump(mode="json", exclude_none=True), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", + ) + assert not errors, f"Unexpected validation errors: {errors}" + + # Verify expansion: explicit entity + selector results + expanded_entities1 = validated.models[0].entities.stored_entities + assert len(expanded_entities1) == 2 + assert expanded_entities1[0].name == "fluid/leftWing" + assert expanded_entities1[1].name == "fluid/rightWing" + + # Verify pure selector expansion + expanded_entities2 = validated.models[1].entities.stored_entities + assert len(expanded_entities2) == 1 + assert expanded_entities2[0].name == "fluid/fuselage" + + # Verify selectors are preserved + assert validated.models[0].entities.selectors == [all_wings_selector] + assert validated.models[1].entities.selectors == [fuselage_selector] + + # Verify idempotency + validated_dict = validated.model_dump(mode="json", exclude_none=True) + validated_again, errors, _ = validate_model( + params_as_dict=copy.deepcopy(validated_dict), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", + ) + assert not errors, "Validation failed on the second pass" + assert compare_values( + validated.model_dump(mode="json"), validated_again.model_dump(mode="json") + ) + + +def test_validate_model_materializes_dict_and_preserves_selectors(): + """ + Test: `validate_model` correctly materializes entity dicts into objects + while preserving the original selectors from a raw dictionary input. + """ + params = _load_json("data/geometry_grouped_by_file/simulation.json") + + # Inject a selector into the params dict and assign all entities to a Wall + # to satisfy the boundary condition validation. + selector_dict = { + "target_class": "Surface", + "name": "some_selector_name", + "logic": "AND", + "children": [{"attribute": "name", "operator": "matches", "value": "*"}], + } + outputs = params.get("outputs") or [] + entities = outputs[0].get("entities") or {} + entities["selectors"] = [selector_dict] + entities["stored_entities"] = [] # Start with no materialized entities + + # Assign all boundaries to a default wall to pass validation + all_boundaries_selector = { + "target_class": "Surface", + "name": "all_boundaries", + "children": [{"attribute": "name", "operator": "matches", "value": "*"}], + } + params["models"].append( + { + "type": "Wall", + "name": "DefaultWall", + "entities": {"selectors": [all_boundaries_selector]}, + } + ) + + validated, errors, _ = validate_model( + params_as_dict=params, + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Case", + ) + assert not errors, f"Unexpected validation errors: {errors}" + + # Verify materialization + materialized_entities = validated.outputs[0].entities.stored_entities + assert materialized_entities and all(isinstance(e, Surface) for e in materialized_entities) + assert len(materialized_entities) > 0 + + # Verify selectors are preserved after materialization + preserved_selectors = validated.outputs[0].entities.selectors + assert len(preserved_selectors) == 1 + assert preserved_selectors[0].model_dump(exclude_none=True) == selector_dict + + +def test_validate_model_deduplicates_non_point_entities(): + """ + Test: `validate_model` deduplicates non-Point entities based on (type, id). + """ + params = { + "version": "25.7.6b0", + "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "outputs": [ + { + "output_type": "SurfaceOutput", + "name": "o1", + "output_fields": ["Cp"], + "entities": { + "stored_entities": [ + { + "name": "wing", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "s-1", + }, + { + "name": "wing", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "s-1", + }, + ] + }, + } + ], + "private_attribute_asset_cache": { + "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []} + }, + "unit_system": {"name": "SI"}, + } + + validated, errors, _ = validate_model( + params_as_dict=params, validated_by=ValidationCalledBy.LOCAL, root_item_type="Case" + ) + assert not errors + final_entities = validated.outputs[0].entities.stored_entities + assert len(final_entities) == 1 + assert final_entities[0].name == "wing" + + +def test_validate_model_does_not_deduplicate_point_entities(): + """ + Test: `validate_model` preserves duplicate Point entities. + """ + params = { + "version": "25.7.6b0", + "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "outputs": [ + { + "output_type": "StreamlineOutput", + "name": "o2", + "entities": { + "stored_entities": [ + { + "name": "p1", + "private_attribute_entity_type_name": "Point", + "location": {"value": [0, 0, 0], "units": "m"}, + }, + { + "name": "p1", + "private_attribute_entity_type_name": "Point", + "location": {"value": [0, 0, 0], "units": "m"}, + }, + ] + }, + } + ], + "private_attribute_asset_cache": { + "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []} + }, + "unit_system": {"name": "SI"}, + } + + validated, errors, _ = validate_model( + params_as_dict=params, validated_by=ValidationCalledBy.LOCAL, root_item_type="Case" + ) + assert not errors + final_entities = validated.outputs[0].entities.stored_entities + assert len(final_entities) == 2 + assert all(e.name == "p1" for e in final_entities) + + +def test_validate_model_shares_entity_instances_across_lists(): + """ + Test: `validate_model` uses a global cache to share entity instances, + ensuring that an entity with the same ID is the same Python object everywhere. + """ + entity_dict = { + "name": "s", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "s-1", + } + params = { + "version": "25.7.6b0", + "unit_system": {"name": "SI"}, + "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "models": [ + {"type": "Wall", "name": "Wall", "entities": {"stored_entities": [entity_dict]}} + ], + "outputs": [ + { + "output_type": "SurfaceOutput", + "name": "o3", + "output_fields": ["Cp"], + "entities": {"stored_entities": [entity_dict]}, + } + ], + "private_attribute_asset_cache": { + "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []} + }, + } + + validated, errors, _ = validate_model( + params_as_dict=params, validated_by=ValidationCalledBy.LOCAL, root_item_type="Case" + ) + assert not errors + entity_in_model = validated.models[0].entities.stored_entities[0] + entity_in_output = validated.outputs[0].entities.stored_entities[0] + assert entity_in_model is entity_in_output + + +def test_strip_selector_matches_preserves_semantics_end_to_end(): + """ + simulation.json -> expand -> mock submit (strip) -> read back -> compare stored_entities + Ensures stripping selector-matched entities before upload does not change semantics. + + Derivation notes (for future readers): + - We inject a mixed EntityList (handpicked + selector with overlap) into outputs[0].entities. + - Baseline: validate_model expands selectors and materializes entities. + - Mock submit: strip_selector_matches_inplace removes selector matches from stored_entities. + - Read back: validate_model expands selectors again → final entities must equal baseline. + """ + # Use a large, real geometry with many faces + params = _load_json("../data/geo-fcbe1113-a70b-43b9-a4f3-bbeb122d64fb/simulation.json") + + # Set face grouping tag so selector operates on faceId groups + pei = params["private_attribute_asset_cache"]["project_entity_info"] + pei["face_group_tag"] = "faceId" + # Remove obsolete/unknown meshing defaults to avoid validation noise in Case-level + params.get("meshing", {}).get("defaults", {}).pop("geometry_tolerance", None) + + # Build mixed EntityList with overlap under outputs[0].entities + outputs = params.get("outputs") or [] + assert outputs, "Test fixture lacks outputs" + entities = outputs[0].get("entities") or {} + entities["stored_entities"] = [ + { + "private_attribute_entity_type_name": "Surface", + "name": "body00001_face00001", + "private_attribute_id": "body00001_face00001", + }, + { + "private_attribute_entity_type_name": "Surface", + "name": "body00001_face00014", + "private_attribute_id": "body00001_face00014", + }, + ] + entities["selectors"] = [ + { + "target_class": "Surface", + "name": "some_overlap", + "children": [ + { + "attribute": "name", + "operator": "any_of", + "value": ["body00001_face00001", "body00001_face00002"], + } + ], + } + ] + outputs[0]["entities"] = entities + params["outputs"] = outputs + + # Ensure models contain a DefaultWall that matches all to satisfy BC validation + all_boundaries_selector = { + "target_class": "Surface", + "name": "all_boundaries", + "children": [{"attribute": "name", "operator": "matches", "value": "*"}], + } + params.setdefault("models", []).append( + { + "type": "Wall", + "name": "DefaultWall", + "entities": {"selectors": [all_boundaries_selector]}, + } + ) + + # Baseline expansion + materialization + validated, errors, _ = validate_model( + params_as_dict=params, + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Case", + ) + assert not errors, f"Unexpected validation errors: {errors}" + + baseline_entities = validated.outputs[0].entities.stored_entities # type: ignore[index] + baseline_names = sorted( + [f"{e.private_attribute_entity_type_name}:{e.name}" for e in baseline_entities] + ) + + # Mock submit (strip selector-matched) + upload_dict = strip_selector_matches_inplace( + validated.model_dump(mode="json", exclude_none=True) + ) + + # Assert what remains in the upload_dict after stripping selector matches + upload_entities = ( + upload_dict.get("outputs", [])[0].get("entities", {}).get("stored_entities", []) + ) + upload_names = sorted( + [f"{d.get('private_attribute_entity_type_name')}:{d.get('name')}" for d in upload_entities] + ) + expected_remaining = ["Surface:body00001_face00014"] + assert upload_names == expected_remaining, ( + "Unexpected remaining stored_entities in upload_dict after stripping\n" + + f"Remaining: {upload_names}\n" + + f"Expected : {expected_remaining}\n" + ) + + # Read back and expand again + validated2, errors2, _ = validate_model( + params_as_dict=upload_dict, + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Case", + ) + assert not errors2, f"Unexpected validation errors on read back: {errors2}" + post_entities = validated2.outputs[0].entities.stored_entities # type: ignore[index] + post_names = sorted([f"{e.private_attribute_entity_type_name}:{e.name}" for e in post_entities]) + + # Show both sides for easy visual inspection + assert baseline_names == post_names, ( + "Entity list mismatch at outputs[0].entities\n" + + f"Baseline: {baseline_names}\n" + + f"Post : {post_names}\n" + ) + + # Sanity: intended overlap surfaced in baseline + baseline_only = [s.split(":", 1)[1] for s in baseline_names] + assert "body00001_face00001" in baseline_only and "body00001_face00002" in baseline_only + assert "body00001_face00014" in baseline_only diff --git a/tests/simulation/test_project.py b/tests/simulation/test_project.py index b4c99129b..33b1e778e 100644 --- a/tests/simulation/test_project.py +++ b/tests/simulation/test_project.py @@ -223,9 +223,9 @@ def test_conflicting_entity_grouping_tags(mock_response, capsys): ), ): geo.group_faces_by_tag("groupByBodyId") - params_as_dict["outputs"][0]["entities"]["stored_entities"][0][ - "private_attribute_tag_key" - ] = "groupByBodyId" + params_as_dict["outputs"][0]["entities"]["stored_entities"][ + 0 + ].private_attribute_tag_key = "groupByBodyId" params, _, _ = validate_model( params_as_dict=params_as_dict, validated_by=ValidationCalledBy.LOCAL,