From dbb00638535b560b09edca902ca90b487984832b Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Sun, 26 Oct 2025 21:40:13 -0400 Subject: [PATCH 1/9] Update the service to ensure expanding before the validation --- flow360/component/case.py | 5 +- .../entity_materialization_context.py | 52 +++ .../framework/entity_materializer.py | 144 +++++++++ .../simulation/framework/entity_selector.py | 258 +++++++++++++-- flow360/component/simulation/services.py | 34 +- .../component/simulation/simulation_params.py | 3 + tests/framework/test_entity_materializer.py | 100 ++++++ .../framework/test_entity_expansion_impl.py | 61 +--- .../framework/test_entity_materializer.py | 174 ++++++++++ .../test_entity_selector_fluent_api.py | 86 ++++- .../test_selector_merge_vs_replace.py | 89 ++++++ ...date_model_selector_and_materialization.py | 299 ++++++++++++++++++ tests/simulation/test_project.py | 6 +- 13 files changed, 1204 insertions(+), 107 deletions(-) create mode 100644 flow360/component/simulation/framework/entity_materialization_context.py create mode 100644 flow360/component/simulation/framework/entity_materializer.py create mode 100644 tests/framework/test_entity_materializer.py create mode 100644 tests/simulation/framework/test_entity_materializer.py create mode 100644 tests/simulation/framework/test_selector_merge_vs_replace.py create mode 100644 tests/simulation/services/test_validate_model_selector_and_materialization.py diff --git a/flow360/component/case.py b/flow360/component/case.py index 571c9f4e0..6da06d88d 100644 --- a/flow360/component/case.py +++ b/flow360/component/case.py @@ -8,7 +8,7 @@ import json import os import tempfile -from typing import Any, Iterator, List, Optional, Union +from typing import TYPE_CHECKING, Any, Iterator, List, Optional, Union import pydantic as pd import pydantic.v1 as pd_v1 @@ -76,6 +76,9 @@ from .v1.flow360_params import Flow360Params, UnvalidatedFlow360Params from .validator import Validator +if TYPE_CHECKING: + from flow360.component.volume_mesh import VolumeMeshV2 + class CaseBase: """ 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..28e48e2ef 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 (aka tag) + 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,63 @@ 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 +193,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 +210,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 +248,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 +287,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) # type: ignore[arg-type] + + 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 +315,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 +339,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 +585,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/tag. + + We mandate a unique identifier per selector; use ("name", target_class, name) + for stable global reuse. If neither `name` nor `tag` is provided, fall back to a + structural key so different unnamed selectors won't collide. + """ + tclass = selector_dict.get("target_class") + name = selector_dict.get("name") or selector_dict.get("tag") + if name: + return ("name", tclass, 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 + + preds = 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", tclass, logic, preds) + + +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 +635,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 - for target_class in ordered_target_classes: - base_entities.extend(additions_by_class.get(target_class, [])) + +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 + ) + + 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 (or tag). 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/services.py b/flow360/component/simulation/services.py index 19449be68..b247eb110 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, @@ -42,6 +45,11 @@ 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 ( + Edge, + GenericVolume, + GeometryBodyGroup, +) from flow360.component.simulation.services_utils import has_any_entity_selectors from flow360.component.simulation.simulation_params import ( ReferenceGeometry, @@ -423,7 +431,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 +445,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 +514,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/simulation_params.py b/flow360/component/simulation/simulation_params.py index 87a3ef6cd..08a5c29e6 100644 --- a/flow360/component/simulation/simulation_params.py +++ b/flow360/component/simulation/simulation_params.py @@ -198,6 +198,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): @@ -227,6 +229,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/tests/framework/test_entity_materializer.py b/tests/framework/test_entity_materializer.py new file mode 100644 index 000000000..455c2697c --- /dev/null +++ b/tests/framework/test_entity_materializer.py @@ -0,0 +1,100 @@ +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_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_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_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py index db85f4621..8a04d668d 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,7 @@ def test_attribute_tag_scalar_support(): expand_entity_selectors_in_place(db, params) stored = params["node"]["stored_entities"] - + print(">>> ", params) # 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_materializer.py b/tests/simulation/framework/test_entity_materializer.py new file mode 100644 index 000000000..3765d4c92 --- /dev/null +++ b/tests/simulation/framework/test_entity_materializer.py @@ -0,0 +1,174 @@ +import copy + +import pytest + +from flow360.component.simulation.framework.entity_materializer import ( + materialize_entities_in_place, +) + + +def _mk_entity(name: str, entity_type: str, eid: str | None = 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 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] 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..7f4c37c73 --- /dev/null +++ b/tests/simulation/framework/test_selector_merge_vs_replace.py @@ -0,0 +1,89 @@ +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"] == [] + + +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"] == [] + + +def test_named_selector_cache_key_tag_alias(): + db = EntityDictDatabase(surfaces=_mk_pool(["s1", "s2"], "Surface")) + params = { + "a": { + "selectors": [ + { + "target_class": "Surface", + "tag": "selA", # alias for name + "children": [{"attribute": "name", "operator": "any_of", "value": ["s1"]}], + } + ] + }, + "b": { + "selectors": [ + { + "target_class": "Surface", + "name": "selA", # same cache key across nodes + "children": [{"attribute": "name", "operator": "any_of", "value": ["s1"]}], + } + ] + }, + } + expand_entity_selectors_in_place(db, params) + a_items = params["a"]["stored_entities"] + b_items = params["b"]["stored_entities"] + assert [e["name"] for e in a_items] == ["s1"] + assert [e["name"] for e in b_items] == ["s1"] + assert params["a"]["selectors"] == [] + assert params["b"]["selectors"] == [] diff --git a/tests/simulation/services/test_validate_model_selector_and_materialization.py b/tests/simulation/services/test_validate_model_selector_and_materialization.py new file mode 100644 index 000000000..467c19467 --- /dev/null +++ b/tests/simulation/services/test_validate_model_selector_and_materialization.py @@ -0,0 +1,299 @@ +import copy +import json +import os + +import unyt as u + +from flow360.component.simulation.framework.entity_materializer import ( + materialize_entities_in_place, +) +from flow360.component.simulation.services import ValidationCalledBy, validate_model + + +def _load_json(path_from_tests_dir: str) -> dict: + 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_resolves_selectors_and_materializes_end_to_end(): + """ + Test: End-to-end integration of selector expansion and entity materialization in validate_model. + + Purpose: + - Verify that validate_model() correctly processes EntitySelector objects + - Verify that selectors are expanded against the entity database from asset cache + - Verify that expanded entity dicts are materialized into Pydantic model instances + - Verify that selectors are cleared after expansion + + Expected behavior: + - Input: params with selectors and empty stored_entities + - Process: Selectors expand to find matching entities from geometry entity info + - Output: validated model with materialized Surface objects in stored_entities + - Selectors list should be empty after processing + """ + params = _load_json("data/geometry_grouped_by_file/simulation.json") + + # Convert first output to selector-only and clear stored_entities + outputs = params.get("outputs") or [] + if not outputs: + return + entities = outputs[0].get("entities") or {} + entities["selectors"] = [ + { + "target_class": "Surface", + "children": [{"attribute": "name", "operator": "matches", "value": "*"}], + } + ] + entities["stored_entities"] = [] + outputs[0]["entities"] = entities + + validated, errors, _ = validate_model( + params_as_dict=params, + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Geometry", + validation_level=None, + ) + assert not errors, f"Unexpected validation errors: {errors}" + + # selectors should be cleared and stored_entities should be objects + sd = validated.outputs[0].entities.stored_entities + assert sd and all( + getattr(e, "private_attribute_entity_type_name", None) == "Surface" for e in sd + ) + + +def test_validate_model_per_list_dedup_for_non_point(): + """ + Test: Entity deduplication for non-Point entities during materialization. + + Purpose: + - Verify that materialize_entities_in_place() deduplicates non-Point entities + - Verify that deduplication is based on (type, id) tuple + - Verify that deduplication preserves order (first occurrence kept) + - Verify that validate_model() applies this deduplication + + Expected behavior: + - Input: Two Surface entities with same name and ID + - Process: Materialization deduplicates based on (Surface, s-1) key + - Output: Single Surface entity in validated model + + Note: Point entities are NOT deduplicated (tested separately) + """ + # Minimal dict with duplicate Surface items in one list + params = { + "version": "25.7.6b0", + "unit_system": {"name": "SI"}, + "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "models": [], + "outputs": [ + { + "output_type": "SurfaceOutput", + "name": "o1", + "output_fields": {"items": ["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_length_unit": {"value": 1, "units": "m"}, + "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, + }, + } + + validated, errors, _ = validate_model( + params_as_dict=copy.deepcopy(params), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Case", + validation_level=None, + ) + assert not errors, f"Unexpected validation errors: {errors}" + names = [e.name for e in validated.outputs[0].entities.stored_entities] + assert names == ["wing"] + + +def test_validate_model_skip_dedup_for_point(): + """ + Test: Point entities are NOT deduplicated during materialization. + + Purpose: + - Verify that Point entities are exempted from deduplication + - Verify that duplicate Point entities with same location are preserved + - Verify that this exception only applies to Point entity type + + Expected behavior: + - Input: Two Point entities with same name and location + - Process: Materialization skips deduplication for Point type + - Output: Both Point entities remain in validated model + + Rationale: Point entities may intentionally have duplicates for different + purposes (e.g., multiple streamline origins at same location) + """ + params = { + "version": "25.7.6b0", + "unit_system": {"name": "SI"}, + "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "models": [], + "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_length_unit": {"value": 1, "units": "m"}, + "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, + }, + } + + validated, errors, _ = validate_model( + params_as_dict=copy.deepcopy(params), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Case", + validation_level=None, + ) + assert not errors, f"Unexpected validation errors: {errors}" + names = [e.name for e in validated.outputs[0].entities.stored_entities] + assert names == ["p1", "p1"] + + +def test_validate_model_shares_instances_across_lists(): + """ + Test: Entity instances are shared across different lists (models and outputs). + + Purpose: + - Verify that materialize_entities_in_place() uses global instance caching + - Verify that entities with same (type, id) are the same Python object (identity) + - Verify that this sharing works across different parts of the params tree + - Verify that validate_model() maintains this instance sharing + + Expected behavior: + - Input: Same Surface entity (by id) in both models[0] and outputs[0] + - Process: Materialization creates single instance, reused in both locations + - Output: validated.models[0].entities[0] is validated.outputs[0].entities[0] + + Benefits: Memory efficiency and enables identity-based comparison + """ + # Same Surface appears in models and outputs lists with same id + 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": [ + { + "name": "s", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "s-1", + } + ] + }, + } + ], + "outputs": [ + { + "output_type": "SurfaceOutput", + "name": "o3", + "output_fields": {"items": ["Cp"]}, + "entities": { + "stored_entities": [ + { + "name": "s", + "private_attribute_entity_type_name": "Surface", + "private_attribute_id": "s-1", + } + ] + }, + } + ], + "private_attribute_asset_cache": { + "project_length_unit": {"value": 1, "units": "m"}, + "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, + }, + } + + validated, errors, _ = validate_model( + params_as_dict=copy.deepcopy(params), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Case", + validation_level=None, + ) + assert not errors, f"Unexpected validation errors: {errors}" + a = validated.models[0].entities.stored_entities[0] + b = validated.outputs[0].entities.stored_entities[0] + assert a is b + + +def test_resolve_selectors_noop_when_absent(): + """ + Test: Selector expansion and materialization are no-ops when no selectors present. + + Purpose: + - Verify that expand_entity_selectors_in_place() handles missing selectors gracefully + - Verify that materialize_entities_in_place() handles empty entity lists + - Verify that validate_model() succeeds with minimal valid params (no selectors) + - Verify that these operations don't crash or produce errors on empty inputs + + Expected behavior: + - Input: Valid params with empty models and outputs, no selectors + - Process: Both expansion and materialization are no-ops + - Output: Validation succeeds with empty result + + This tests robustness and ensures the pipeline handles edge cases gracefully. + """ + # No selectors anywhere; materializer also should be a no-op for empty lists + params = { + "version": "25.7.6b0", + "unit_system": {"name": "SI"}, + "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, + "models": [], + "outputs": [], + "private_attribute_asset_cache": { + "project_length_unit": { + "value": 1, + "units": "m", + }, + "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, + }, + } + + # Ensure materializer does not crash on empty structure + _ = materialize_entities_in_place(copy.deepcopy(params)) + + validated, errors, _ = validate_model( + params_as_dict=copy.deepcopy(params), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="Case", + validation_level=None, + ) + assert not errors, f"Unexpected validation errors: {errors}" 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, From c11afd21d083cc791f1795cc113766212d2d357e Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 27 Oct 2025 12:10:09 -0400 Subject: [PATCH 2/9] Fixed unit test --- tests/framework/test_entity_materializer.py | 100 ------------------ .../framework/test_entity_materializer.py | 100 +++++++++++++++++- 2 files changed, 98 insertions(+), 102 deletions(-) delete mode 100644 tests/framework/test_entity_materializer.py diff --git a/tests/framework/test_entity_materializer.py b/tests/framework/test_entity_materializer.py deleted file mode 100644 index 455c2697c..000000000 --- a/tests/framework/test_entity_materializer.py +++ /dev/null @@ -1,100 +0,0 @@ -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_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_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_materializer.py b/tests/simulation/framework/test_entity_materializer.py index 3765d4c92..33f6269a2 100644 --- a/tests/simulation/framework/test_entity_materializer.py +++ b/tests/simulation/framework/test_entity_materializer.py @@ -1,19 +1,39 @@ import copy +from typing import Optional -import pytest +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: str | None = None) -> dict: +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. @@ -172,3 +192,79 @@ def test_reentrant_safe_and_idempotent(): # 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 From 39fccf20c8b51bacd36e0f4f3edf1dd2a6b18c78 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 27 Oct 2025 21:15:30 -0400 Subject: [PATCH 3/9] Added support for assigning EntitySelector to EntityList, also updated validations to defer stored_entities related checks to validate_model() stage --- .../simulation/framework/entity_base.py | 149 ++++---- .../simulation/framework/entity_selector.py | 1 + .../simulation/framework/param_utils.py | 3 +- .../simulation/meshing_param/params.py | 8 +- .../simulation/meshing_param/volume_params.py | 6 +- .../simulation/models/volume_models.py | 11 + flow360/component/simulation/primitives.py | 3 + .../translator/volume_meshing_translator.py | 4 +- .../validation/validation_output.py | 7 + .../validation_simulation_params.py | 8 +- tests/conftest.py | 12 + .../data/airplane_volume_mesh/simulation.json | 346 ++++++++++++++++++ .../simulation/framework/test_entities_v2.py | 88 +---- ..._entity_selector_entitylist_integration.py | 96 +++++ .../test_meshing_param_validation.py | 12 +- tests/simulation/params/test_porous_medium.py | 10 +- tests/simulation/params/test_rotation.py | 6 +- .../params/test_validators_criterion.py | 5 +- .../params/test_validators_output.py | 8 +- .../params/test_validators_params.py | 12 +- 20 files changed, 599 insertions(+), 196 deletions(-) create mode 100644 tests/simulation/framework/data/airplane_volume_mesh/simulation.json create mode 100644 tests/simulation/framework/test_entity_selector_entitylist_integration.py diff --git a/flow360/component/simulation/framework/entity_base.py b/flow360/component/simulation/framework/entity_base.py index ad6f0082c..1f1cc7837 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 @@ -291,15 +290,61 @@ 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 + 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_type_names = [t.__name__ for t in valid_types] if isinstance(input_data, list): # -- User input mode. -- @@ -319,96 +364,34 @@ def deserializer(cls, input_data: Union[dict, list]): ] ) else: - cls._valid_individual_input(item) - if is_exact_instance(item, tuple(valid_types)): - entities_to_store.append(item) + cls._process_single_item( + item, + tuple(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"], []) + 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, + tuple(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_selector.py b/flow360/component/simulation/framework/entity_selector.py index 28e48e2ef..00217eb1d 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -661,6 +661,7 @@ def _merge_entities( 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: 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 cac79cfee..74dadf6e9 100644 --- a/flow360/component/simulation/meshing_param/params.py +++ b/flow360/component/simulation/meshing_param/params.py @@ -355,12 +355,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 []: @@ -371,7 +375,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 42894875f..324270e58 100644 --- a/flow360/component/simulation/meshing_param/volume_params.py +++ b/flow360/component/simulation/meshing_param/volume_params.py @@ -224,7 +224,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 2a4b1e21b..e71a2d942 100644 --- a/flow360/component/simulation/models/volume_models.py +++ b/flow360/component/simulation/models/volume_models.py @@ -205,6 +205,9 @@ def _preprocess_monitor_output_with_id(cls, v): def _check_single_point_in_probe_output(cls, v): if not isinstance(v, (ProbeOutput, SurfaceProbeOutput)): return v + if not get_validation_info(): + # stored_entities is not expanded yet. + return v if len(v.entities.stored_entities) == 1 and isinstance( v.entities.stored_entities[0], Point ): @@ -1441,6 +1444,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( @@ -1541,6 +1548,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 b37bce1ec..56063da22 100644 --- a/flow360/component/simulation/primitives.py +++ b/flow360/component/simulation/primitives.py @@ -755,6 +755,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/translator/volume_meshing_translator.py b/flow360/component/simulation/translator/volume_meshing_translator.py index b14dfd0a7..842aaca19 100644 --- a/flow360/component/simulation/translator/volume_meshing_translator.py +++ b/flow360/component/simulation/translator/volume_meshing_translator.py @@ -106,11 +106,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 8f623b8ae..15cf8288b 100644 --- a/flow360/component/simulation/validation/validation_output.py +++ b/flow360/component/simulation/validation/validation_output.py @@ -12,6 +12,9 @@ SurfaceProbeOutput, ) from flow360.component.simulation.time_stepping.time_stepping import Steady +from flow360.component.simulation.validation.validation_context import ( + get_validation_info, +) def _check_output_fields(params): @@ -175,6 +178,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 61f2ce362..eeff0b01f 100644 --- a/flow360/component/simulation/validation/validation_simulation_params.py +++ b/flow360/component/simulation/validation/validation_simulation_params.py @@ -67,13 +67,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) @@ -396,7 +400,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/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 c940a774d..6531b98ea 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 @@ -425,16 +408,6 @@ class MyModel(Flow360BaseModel): 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 +431,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, @@ -500,7 +463,7 @@ def test_entities_input_interface(my_volume_mesh1): # 1. Using reference of single asset entity expanded_entities = TempFluidDynamics( entities=my_volume_mesh1["zone*"] - ).entities._get_expanded_entities(create_hard_copy=True) + ).entities.stored_entities assert len(expanded_entities) == 3 assert expanded_entities == my_volume_mesh1["zone*"] @@ -511,17 +474,13 @@ def test_entities_input_interface(my_volume_mesh1): "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 - ) + expanded_entities = TempFluidDynamics(entities=1).entities.stored_entities # 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 - ) + expanded_entities = TempFluidDynamics(entities=[]).entities.stored_entities # 4. test None with pytest.raises( @@ -530,9 +489,7 @@ def test_entities_input_interface(my_volume_mesh1): "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 - ) + expanded_entities = TempFluidDynamics(entities=None).entities.stored_entities # 5. test typo/non-existing entities. with pytest.raises( @@ -542,43 +499,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_selector_entitylist_integration.py b/tests/simulation/framework/test_entity_selector_entitylist_integration.py new file mode 100644 index 000000000..4374d7f58 --- /dev/null +++ b/tests/simulation/framework/test_entity_selector_entitylist_integration.py @@ -0,0 +1,96 @@ +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.models.surface_models import Wall +from flow360.component.simulation.primitives import Surface +from flow360.component.simulation.services import ValidationCalledBy, validate_model +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(): + return VolumeMeshV2.from_local_storage( + mesh_id="vm-aa3bb31e-2f85-4504-943c-7788d91c1ab0", + local_storage_path=os.path.join(os.path.dirname(__file__), "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 test_direct_assignment_selector_and_entity_registry_index(): + vm = _load_local_vm() + # Ensure registry available for __getitem__ + vm.internal_registry = vm._entity_info.get_registry(vm.internal_registry) + + with fl.SI_unit_system: + all_wings = Surface.match("*Wing", name="all_wings") + wall = Wall(entities=[all_wings, vm["fluid/leftWing"]]) + # Object-level assertions (before any serialization/validation) + assert wall.entities.selectors and len(wall.entities.selectors) == 1 + assert wall.entities.selectors[0].target_class == "Surface" + assert wall.entities.selectors[0].name == "all_wings" + assert wall.entities.stored_entities and len(wall.entities.stored_entities) == 1 + assert all( + e.private_attribute_entity_type_name == "Surface" for e in wall.entities.stored_entities + ) + + freestream = fl.Freestream( + entities=[vm["fluid/farfield"]] + ) # Legacy entity assignment syntax + + fuselage = Surface.match("flu*fuselage", name="fuselage") + + nothing_surface = Surface.match("nothing", name="nothing") + + # wall_fuselage = Wall( + # entities=[fuselage, nothing_surface], use_wall_function=True # List of EntitySelectors + + # ) + + params = fl.SimulationParams(models=[wall, freestream]) + + # Fill in project_entity_info to provide selector database + params = set_up_params_for_uploading( + vm, 1 * fl.u.m, params, use_beta_mesher=False, use_geometry_AI=False + ) + + # Full validate path (includes resolve_selectors + materialize) + validated, errors, _ = validate_model( + params_as_dict=params.model_dump(mode="json", exclude_none=True), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", + validation_level=None, + ) + + assert not errors, f"Unexpected validation errors: {errors}" + + # Ensure at least one entity exists; selector append after explicit + entities = validated.models[0].entities.stored_entities + assert len(entities) == 2 # Selector resolved to leftWing and rightWing + assert entities[0].name == "fluid/leftWing" + assert entities[1].name == "fluid/rightWing" + print(">>> entities: ", [entity.name for entity in entities]) + + # Legacy + entities = validated.models[1].entities.stored_entities + assert len(entities) == 1 + assert entities[0].name == "fluid/farfield" + + # Pure selectors + # entities = validated.models[2].entities.stored_entities + # assert len(entities) == 1 + # assert entities[0].name == "fluid/fuselage" 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 64a4719b0..ba220127a 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 b7643a99a..5f868f845 100644 --- a/tests/simulation/params/test_validators_criterion.py +++ b/tests/simulation/params/test_validators_criterion.py @@ -145,6 +145,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 = ( @@ -156,7 +157,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): StopCriterion( monitor_field=scalar_user_variable_density, monitor_output=multi_point_probe_output, @@ -172,7 +173,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): StopCriterion( 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 03b40bd37..bf3a8ae2a 100644 --- a/tests/simulation/params/test_validators_output.py +++ b/tests/simulation/params/test_validators_output.py @@ -374,10 +374,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( @@ -396,7 +396,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 " @@ -422,7 +422,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 60d842438..519fa47ba 100644 --- a/tests/simulation/params/test_validators_params.py +++ b/tests/simulation/params/test_validators_params.py @@ -106,6 +106,7 @@ from flow360.component.simulation.validation.validation_context import ( CASE, VOLUME_MESH, + ParamsValidationInfo, ValidationContext, ) @@ -1018,8 +1019,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], ) @@ -1027,7 +1031,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], ) @@ -1783,7 +1787,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( @@ -1936,7 +1940,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: From f07e9ca2ac1a854464fe0169dc73afa14b524751 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Tue, 28 Oct 2025 09:52:44 -0400 Subject: [PATCH 4/9] Enabled pure selector input --- flow360/component/results/base_results.py | 1 - .../simulation/framework/entity_base.py | 2 +- .../simulation/framework/entity_selector.py | 2 +- flow360/component/simulation/services.py | 2 +- .../framework/test_entity_expansion_impl.py | 1 - ..._entity_selector_entitylist_integration.py | 48 +++++++++++++++---- 6 files changed, 41 insertions(+), 15 deletions(-) diff --git a/flow360/component/results/base_results.py b/flow360/component/results/base_results.py index 1a09154b7..2775021ae 100644 --- a/flow360/component/results/base_results.py +++ b/flow360/component/results/base_results.py @@ -674,7 +674,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 1f1cc7837..7a8e96df6 100644 --- a/flow360/component/simulation/framework/entity_base.py +++ b/flow360/component/simulation/framework/entity_base.py @@ -376,7 +376,7 @@ def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector]) raise KeyError( f"Invalid input type to `entities`, dict {input_data} is missing the key 'stored_entities'." ) - return cls._build_result(input_data["stored_entities"], []) + return cls._build_result(input_data["stored_entities"], input_data.get("selectors", [])) else: # Single entity or selector if input_data is None: return cls._build_result(None, []) diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py index 00217eb1d..eb1b80bed 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -700,7 +700,7 @@ def _expand_node_selectors( ) node["stored_entities"] = base_entities - node["selectors"] = [] + # node["selectors"] = selectors_value def expand_entity_selectors_in_place( diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index b247eb110..796972811 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -522,8 +522,8 @@ def validate_model( # pylint: disable=too-many-locals # 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") + 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) diff --git a/tests/simulation/framework/test_entity_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py index 8a04d668d..6df99d58a 100644 --- a/tests/simulation/framework/test_entity_expansion_impl.py +++ b/tests/simulation/framework/test_entity_expansion_impl.py @@ -214,7 +214,6 @@ def test_attribute_tag_scalar_support(): expand_entity_selectors_in_place(db, params) stored = params["node"]["stored_entities"] - print(">>> ", params) # 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_selector_entitylist_integration.py b/tests/simulation/framework/test_entity_selector_entitylist_integration.py index 4374d7f58..2695cba5b 100644 --- a/tests/simulation/framework/test_entity_selector_entitylist_integration.py +++ b/tests/simulation/framework/test_entity_selector_entitylist_integration.py @@ -1,3 +1,4 @@ +import copy import os import pytest @@ -5,6 +6,11 @@ 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.primitives import Surface from flow360.component.simulation.services import ValidationCalledBy, validate_model @@ -56,18 +62,16 @@ def test_direct_assignment_selector_and_entity_registry_index(): nothing_surface = Surface.match("nothing", name="nothing") - # wall_fuselage = Wall( - # entities=[fuselage, nothing_surface], use_wall_function=True # List of EntitySelectors - - # ) + wall_fuselage = Wall( + entities=[fuselage, nothing_surface], use_wall_function=True # List of EntitySelectors + ) - params = fl.SimulationParams(models=[wall, freestream]) + params = fl.SimulationParams(models=[wall, freestream, wall_fuselage]) # Fill in project_entity_info to provide selector database params = set_up_params_for_uploading( vm, 1 * fl.u.m, params, use_beta_mesher=False, use_geometry_AI=False ) - # Full validate path (includes resolve_selectors + materialize) validated, errors, _ = validate_model( params_as_dict=params.model_dump(mode="json", exclude_none=True), @@ -83,7 +87,6 @@ def test_direct_assignment_selector_and_entity_registry_index(): assert len(entities) == 2 # Selector resolved to leftWing and rightWing assert entities[0].name == "fluid/leftWing" assert entities[1].name == "fluid/rightWing" - print(">>> entities: ", [entity.name for entity in entities]) # Legacy entities = validated.models[1].entities.stored_entities @@ -91,6 +94,31 @@ def test_direct_assignment_selector_and_entity_registry_index(): assert entities[0].name == "fluid/farfield" # Pure selectors - # entities = validated.models[2].entities.stored_entities - # assert len(entities) == 1 - # assert entities[0].name == "fluid/fuselage" + entities = validated.models[2].entities.stored_entities + assert len(entities) == 1 + assert entities[0].name == "fluid/fuselage" + + # Ensure idempotency + validated_dict = validated.model_dump(mode="json", exclude_none=True) + + validated, errors, _ = validate_model( + params_as_dict=copy.deepcopy(validated_dict), + validated_by=ValidationCalledBy.LOCAL, + root_item_type="VolumeMesh", + validation_level=None, + ) + + validated_dict_2 = validated.model_dump(mode="json", exclude_none=True) + assert compare_values(validated_dict, validated_dict_2) + + # Ensure the selectors are not cleared + assert validated.models[0].entities.selectors == [ + EntitySelector( + target_class="Surface", + name="all_wings", + logic="AND", + children=[ + Predicate(attribute="name", operator="matches", value="*Wing", non_glob_syntax=None) + ], + ) + ] From 6c0e15786bf60a1250a4777297425d3316141444 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Tue, 28 Oct 2025 10:12:16 -0400 Subject: [PATCH 5/9] fixed lint --- flow360/component/simulation/framework/entity_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/flow360/component/simulation/framework/entity_base.py b/flow360/component/simulation/framework/entity_base.py index 7a8e96df6..4431a12fb 100644 --- a/flow360/component/simulation/framework/entity_base.py +++ b/flow360/component/simulation/framework/entity_base.py @@ -319,6 +319,7 @@ def _build_result( } @classmethod + # pylint: disable=too-many-arguments def _process_single_item( cls, item: Union[EntityBase, EntitySelector], From 91931f07af8a97d079e31ccce0487eff4cb25670 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Tue, 28 Oct 2025 13:01:55 -0400 Subject: [PATCH 6/9] Refactor on entity test structure --- .../simulation/framework/test_entities_v2.py | 110 +------ .../simulation/framework/test_entity_list.py | 157 +++++++++ ..._entity_selector_entitylist_integration.py | 124 -------- .../test_entity_processing_service.py | 284 +++++++++++++++++ ...date_model_selector_and_materialization.py | 300 ------------------ 5 files changed, 445 insertions(+), 530 deletions(-) create mode 100644 tests/simulation/framework/test_entity_list.py delete mode 100644 tests/simulation/framework/test_entity_selector_entitylist_integration.py create mode 100644 tests/simulation/services/test_entity_processing_service.py delete mode 100644 tests/simulation/services/test_validate_model_selector_and_materialization.py diff --git a/tests/simulation/framework/test_entities_v2.py b/tests/simulation/framework/test_entities_v2.py index 6531b98ea..5c44e7970 100644 --- a/tests/simulation/framework/test_entities_v2.py +++ b/tests/simulation/framework/test_entities_v2.py @@ -332,82 +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_by_reference_registry(my_cylinder2): """Test that the entity registry contains reference not deepcopy of the entities.""" my_fd = TempFluidDynamics(entities=[my_cylinder2]) @@ -459,39 +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.stored_entities + 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.stored_entities - # 3. test empty list - with pytest.raises( - ValueError, - match=re.escape("Invalid input type to `entities`, list is empty."), - ): - expanded_entities = TempFluidDynamics(entities=[]).entities.stored_entities - - # 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.stored_entities - # 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."), 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_selector_entitylist_integration.py b/tests/simulation/framework/test_entity_selector_entitylist_integration.py deleted file mode 100644 index 2695cba5b..000000000 --- a/tests/simulation/framework/test_entity_selector_entitylist_integration.py +++ /dev/null @@ -1,124 +0,0 @@ -import copy -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.primitives import Surface -from flow360.component.simulation.services import ValidationCalledBy, validate_model -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(): - return VolumeMeshV2.from_local_storage( - mesh_id="vm-aa3bb31e-2f85-4504-943c-7788d91c1ab0", - local_storage_path=os.path.join(os.path.dirname(__file__), "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 test_direct_assignment_selector_and_entity_registry_index(): - vm = _load_local_vm() - # Ensure registry available for __getitem__ - vm.internal_registry = vm._entity_info.get_registry(vm.internal_registry) - - with fl.SI_unit_system: - all_wings = Surface.match("*Wing", name="all_wings") - wall = Wall(entities=[all_wings, vm["fluid/leftWing"]]) - # Object-level assertions (before any serialization/validation) - assert wall.entities.selectors and len(wall.entities.selectors) == 1 - assert wall.entities.selectors[0].target_class == "Surface" - assert wall.entities.selectors[0].name == "all_wings" - assert wall.entities.stored_entities and len(wall.entities.stored_entities) == 1 - assert all( - e.private_attribute_entity_type_name == "Surface" for e in wall.entities.stored_entities - ) - - freestream = fl.Freestream( - entities=[vm["fluid/farfield"]] - ) # Legacy entity assignment syntax - - fuselage = Surface.match("flu*fuselage", name="fuselage") - - nothing_surface = Surface.match("nothing", name="nothing") - - wall_fuselage = Wall( - entities=[fuselage, nothing_surface], use_wall_function=True # List of EntitySelectors - ) - - params = fl.SimulationParams(models=[wall, freestream, wall_fuselage]) - - # Fill in project_entity_info to provide selector database - params = set_up_params_for_uploading( - vm, 1 * fl.u.m, params, use_beta_mesher=False, use_geometry_AI=False - ) - # Full validate path (includes resolve_selectors + materialize) - validated, errors, _ = validate_model( - params_as_dict=params.model_dump(mode="json", exclude_none=True), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level=None, - ) - - assert not errors, f"Unexpected validation errors: {errors}" - - # Ensure at least one entity exists; selector append after explicit - entities = validated.models[0].entities.stored_entities - assert len(entities) == 2 # Selector resolved to leftWing and rightWing - assert entities[0].name == "fluid/leftWing" - assert entities[1].name == "fluid/rightWing" - - # Legacy - entities = validated.models[1].entities.stored_entities - assert len(entities) == 1 - assert entities[0].name == "fluid/farfield" - - # Pure selectors - entities = validated.models[2].entities.stored_entities - assert len(entities) == 1 - assert entities[0].name == "fluid/fuselage" - - # Ensure idempotency - validated_dict = validated.model_dump(mode="json", exclude_none=True) - - validated, errors, _ = validate_model( - params_as_dict=copy.deepcopy(validated_dict), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="VolumeMesh", - validation_level=None, - ) - - validated_dict_2 = validated.model_dump(mode="json", exclude_none=True) - assert compare_values(validated_dict, validated_dict_2) - - # Ensure the selectors are not cleared - assert validated.models[0].entities.selectors == [ - EntitySelector( - target_class="Surface", - name="all_wings", - logic="AND", - children=[ - Predicate(attribute="name", operator="matches", value="*Wing", non_glob_syntax=None) - ], - ) - ] 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..190552840 --- /dev/null +++ b/tests/simulation/services/test_entity_processing_service.py @@ -0,0 +1,284 @@ +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.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 diff --git a/tests/simulation/services/test_validate_model_selector_and_materialization.py b/tests/simulation/services/test_validate_model_selector_and_materialization.py deleted file mode 100644 index 5ab5d5cc3..000000000 --- a/tests/simulation/services/test_validate_model_selector_and_materialization.py +++ /dev/null @@ -1,300 +0,0 @@ -import copy -import json -import os - -import unyt as u - -from flow360.component.simulation.framework.entity_materializer import ( - materialize_entities_in_place, -) -from flow360.component.simulation.services import ValidationCalledBy, validate_model - - -def _load_json(path_from_tests_dir: str) -> dict: - 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_resolves_selectors_and_materializes_end_to_end(): - """ - Test: End-to-end integration of selector expansion and entity materialization in validate_model. - - Purpose: - - Verify that validate_model() correctly processes EntitySelector objects - - Verify that selectors are expanded against the entity database from asset cache - - Verify that expanded entity dicts are materialized into Pydantic model instances - - Verify that selectors are cleared after expansion - - Expected behavior: - - Input: params with selectors and empty stored_entities - - Process: Selectors expand to find matching entities from geometry entity info - - Output: validated model with materialized Surface objects in stored_entities - - Selectors list should be empty after processing - """ - params = _load_json("data/geometry_grouped_by_file/simulation.json") - - # Convert first output to selector-only and clear stored_entities - outputs = params.get("outputs") or [] - if not outputs: - return - entities = outputs[0].get("entities") or {} - entities["selectors"] = [ - { - "target_class": "Surface", - "name": "some_selector_name", - "children": [{"attribute": "name", "operator": "matches", "value": "*"}], - } - ] - entities["stored_entities"] = [] - outputs[0]["entities"] = entities - - validated, errors, _ = validate_model( - params_as_dict=params, - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Geometry", - validation_level=None, - ) - assert not errors, f"Unexpected validation errors: {errors}" - - # selectors should be cleared and stored_entities should be objects - sd = validated.outputs[0].entities.stored_entities - assert sd and all( - getattr(e, "private_attribute_entity_type_name", None) == "Surface" for e in sd - ) - - -def test_validate_model_per_list_dedup_for_non_point(): - """ - Test: Entity deduplication for non-Point entities during materialization. - - Purpose: - - Verify that materialize_entities_in_place() deduplicates non-Point entities - - Verify that deduplication is based on (type, id) tuple - - Verify that deduplication preserves order (first occurrence kept) - - Verify that validate_model() applies this deduplication - - Expected behavior: - - Input: Two Surface entities with same name and ID - - Process: Materialization deduplicates based on (Surface, s-1) key - - Output: Single Surface entity in validated model - - Note: Point entities are NOT deduplicated (tested separately) - """ - # Minimal dict with duplicate Surface items in one list - params = { - "version": "25.7.6b0", - "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, - "models": [], - "outputs": [ - { - "output_type": "SurfaceOutput", - "name": "o1", - "output_fields": {"items": ["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_length_unit": {"value": 1, "units": "m"}, - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, - }, - } - - validated, errors, _ = validate_model( - params_as_dict=copy.deepcopy(params), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Case", - validation_level=None, - ) - assert not errors, f"Unexpected validation errors: {errors}" - names = [e.name for e in validated.outputs[0].entities.stored_entities] - assert names == ["wing"] - - -def test_validate_model_skip_dedup_for_point(): - """ - Test: Point entities are NOT deduplicated during materialization. - - Purpose: - - Verify that Point entities are exempted from deduplication - - Verify that duplicate Point entities with same location are preserved - - Verify that this exception only applies to Point entity type - - Expected behavior: - - Input: Two Point entities with same name and location - - Process: Materialization skips deduplication for Point type - - Output: Both Point entities remain in validated model - - Rationale: Point entities may intentionally have duplicates for different - purposes (e.g., multiple streamline origins at same location) - """ - params = { - "version": "25.7.6b0", - "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, - "models": [], - "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_length_unit": {"value": 1, "units": "m"}, - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, - }, - } - - validated, errors, _ = validate_model( - params_as_dict=copy.deepcopy(params), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Case", - validation_level=None, - ) - assert not errors, f"Unexpected validation errors: {errors}" - names = [e.name for e in validated.outputs[0].entities.stored_entities] - assert names == ["p1", "p1"] - - -def test_validate_model_shares_instances_across_lists(): - """ - Test: Entity instances are shared across different lists (models and outputs). - - Purpose: - - Verify that materialize_entities_in_place() uses global instance caching - - Verify that entities with same (type, id) are the same Python object (identity) - - Verify that this sharing works across different parts of the params tree - - Verify that validate_model() maintains this instance sharing - - Expected behavior: - - Input: Same Surface entity (by id) in both models[0] and outputs[0] - - Process: Materialization creates single instance, reused in both locations - - Output: validated.models[0].entities[0] is validated.outputs[0].entities[0] - - Benefits: Memory efficiency and enables identity-based comparison - """ - # Same Surface appears in models and outputs lists with same id - 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": [ - { - "name": "s", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "s-1", - } - ] - }, - } - ], - "outputs": [ - { - "output_type": "SurfaceOutput", - "name": "o3", - "output_fields": {"items": ["Cp"]}, - "entities": { - "stored_entities": [ - { - "name": "s", - "private_attribute_entity_type_name": "Surface", - "private_attribute_id": "s-1", - } - ] - }, - } - ], - "private_attribute_asset_cache": { - "project_length_unit": {"value": 1, "units": "m"}, - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, - }, - } - - validated, errors, _ = validate_model( - params_as_dict=copy.deepcopy(params), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Case", - validation_level=None, - ) - assert not errors, f"Unexpected validation errors: {errors}" - a = validated.models[0].entities.stored_entities[0] - b = validated.outputs[0].entities.stored_entities[0] - assert a is b - - -def test_resolve_selectors_noop_when_absent(): - """ - Test: Selector expansion and materialization are no-ops when no selectors present. - - Purpose: - - Verify that expand_entity_selectors_in_place() handles missing selectors gracefully - - Verify that materialize_entities_in_place() handles empty entity lists - - Verify that validate_model() succeeds with minimal valid params (no selectors) - - Verify that these operations don't crash or produce errors on empty inputs - - Expected behavior: - - Input: Valid params with empty models and outputs, no selectors - - Process: Both expansion and materialization are no-ops - - Output: Validation succeeds with empty result - - This tests robustness and ensures the pipeline handles edge cases gracefully. - """ - # No selectors anywhere; materializer also should be a no-op for empty lists - params = { - "version": "25.7.6b0", - "unit_system": {"name": "SI"}, - "operating_condition": {"type_name": "AerospaceCondition", "velocity_magnitude": 10}, - "models": [], - "outputs": [], - "private_attribute_asset_cache": { - "project_length_unit": { - "value": 1, - "units": "m", - }, - "project_entity_info": {"type_name": "SurfaceMeshEntityInfo", "boundaries": []}, - }, - } - - # Ensure materializer does not crash on empty structure - _ = materialize_entities_in_place(copy.deepcopy(params)) - - validated, errors, _ = validate_model( - params_as_dict=copy.deepcopy(params), - validated_by=ValidationCalledBy.LOCAL, - root_item_type="Case", - validation_level=None, - ) - assert not errors, f"Unexpected validation errors: {errors}" From 714c48e5fd185b2d761ac1de330e59ca5f721433 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Thu, 30 Oct 2025 11:36:09 -0400 Subject: [PATCH 7/9] Added removal of matched entities before submission --- .../component/simulation/services_utils.py | 72 +++++++++++ flow360/component/simulation/web/draft.py | 6 +- .../test_entity_processing_service.py | 122 ++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) 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/web/draft.py b/flow360/component/simulation/web/draft.py index c0ddc4bd1..505120bc1 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/simulation/services/test_entity_processing_service.py b/tests/simulation/services/test_entity_processing_service.py index 190552840..94644d272 100644 --- a/tests/simulation/services/test_entity_processing_service.py +++ b/tests/simulation/services/test_entity_processing_service.py @@ -16,6 +16,7 @@ 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 @@ -282,3 +283,124 @@ def test_validate_model_shares_entity_instances_across_lists(): 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 From 41f0ce96868ab08b8d592552f9164f44d9e025c9 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 17 Nov 2025 19:22:44 -0500 Subject: [PATCH 8/9] Reviewed --- .../simulation/framework/entity_base.py | 25 ++++++++--------- .../simulation/framework/entity_selector.py | 27 ++++++++++--------- flow360/component/simulation/services.py | 23 ++++++---------- .../framework/test_entity_expansion_impl.py | 2 -- 4 files changed, 35 insertions(+), 42 deletions(-) diff --git a/flow360/component/simulation/framework/entity_base.py b/flow360/component/simulation/framework/entity_base.py index 4431a12fb..2b794d0a0 100644 --- a/flow360/component/simulation/framework/entity_base.py +++ b/flow360/component/simulation/framework/entity_base.py @@ -282,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( @@ -344,7 +344,7 @@ def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector]) """ 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): @@ -354,20 +354,21 @@ def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector]) 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: + # Single entity or selector cls._process_single_item( item, - tuple(valid_types), + valid_types, valid_type_names, entities_to_store, entity_patterns_to_store, @@ -383,7 +384,7 @@ def deserializer(cls, input_data: Union[dict, list, EntityBase, EntitySelector]) return cls._build_result(None, []) cls._process_single_item( input_data, - tuple(valid_types), + valid_types, valid_type_names, entities_to_store, entity_patterns_to_store, diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py index a3c7c62a7..dc5751a1d 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -49,7 +49,7 @@ class EntitySelector(Flow360BaseModel): """ target_class: TargetClass = pd.Field() - # Unique name for global reuse (aka tag) + # 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() @@ -137,7 +137,8 @@ def _validate_selector_factory_common( logic: str, syntax: Optional[str] = None, ) -> None: - """Validate common arguments for SelectorFactory methods. + """ + Validate common arguments for SelectorFactory methods. This performs friendly, actionable validation with clear error messages. """ @@ -288,7 +289,7 @@ def any_of( ==== """ _validate_selector_factory_common("any_of", name=name, attribute=attribute, logic=logic) - _validate_selector_values("any_of", values) # type: ignore[arg-type] + _validate_selector_values("any_of", values) selector = generate_entity_selector_from_class( selector_name=name, entity_class=cls, logic=logic @@ -343,7 +344,9 @@ def generate_entity_selector_from_class( ########## EXPANSION IMPLEMENTATION ########## -def _get_entity_pool(entity_database: EntityDictDatabase, target_class: TargetClass) -> list[dict]: +def _get_entity_pool( + entity_database: EntityDictDatabase, target_class: TargetClass +) -> list[dict]: """Return the correct entity list from the database for the target class.""" if target_class == "Surface": return entity_database.surfaces @@ -587,16 +590,16 @@ def _cost(predicate: dict) -> int: def _get_selector_cache_key(selector_dict: dict) -> tuple: """ - Return the cache key for a selector: requires unique name/tag. + 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` nor `tag` is provided, fall back to a + for stable global reuse. If neither `name` is provided, fall back to a structural key so different unnamed selectors won't collide. """ - tclass = selector_dict.get("target_class") + target_class = selector_dict.get("target_class") name = selector_dict.get("name") if name: - return ("name", tclass, name) + return ("name", target_class, name) logic = selector_dict.get("logic", "AND") children = selector_dict.get("children") or [] @@ -606,7 +609,7 @@ def _normalize_value(v): return tuple(v) return v - preds = tuple( + predicates = tuple( ( p.get("attribute", "name"), p.get("operator"), @@ -616,7 +619,7 @@ def _normalize_value(v): for p in children if isinstance(p, dict) ) - return ("struct", tclass, logic, preds) + return ("struct", target_class, logic, predicates) def _process_selectors( @@ -700,8 +703,6 @@ def _expand_node_selectors( ) node["stored_entities"] = base_entities - # node["selectors"] = selectors_value - print(">>> selectors: ", node["selectors"]) def expand_entity_selectors_in_place( @@ -714,7 +715,7 @@ def expand_entity_selectors_in_place( How caching works ----------------- - - Each selector must provide a unique name (or tag). We build a cross-tree + - 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 diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index 578bee0bc..d29aef8e2 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -31,25 +31,19 @@ ) 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, + BETDisk, # For parse_model_dict ) - -# pylint: disable=unused-import -from flow360.component.simulation.operating_condition.operating_condition import ( - AerospaceCondition, - GenericReferenceCondition, - ThermalState, +from flow360.component.simulation.operating_condition.operating_condition import ( # pylint: disable=unused-import + AerospaceCondition, # For parse_model_dict + GenericReferenceCondition, # For parse_model_dict + ThermalState, # For parse_model_dict ) 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 ( - Edge, - GenericVolume, - GeometryBodyGroup, +from flow360.component.simulation.primitives import ( # pylint: disable=unused-import + Box, # For parse_model_dict ) +from flow360.component.simulation.primitives import Surface from flow360.component.simulation.services_utils import has_any_entity_selectors from flow360.component.simulation.simulation_params import ( ReferenceGeometry, @@ -85,7 +79,6 @@ ALL, ParamsValidationInfo, ValidationContext, - get_value_with_path, ) from flow360.exceptions import ( Flow360RuntimeError, diff --git a/tests/simulation/framework/test_entity_expansion_impl.py b/tests/simulation/framework/test_entity_expansion_impl.py index a0b8465d9..6df99d58a 100644 --- a/tests/simulation/framework/test_entity_expansion_impl.py +++ b/tests/simulation/framework/test_entity_expansion_impl.py @@ -246,9 +246,7 @@ def test_service_expand_entity_selectors_in_place_end_to_end(): # Expand via service function expanded = json.loads(json.dumps(params)) - print("\n [0] >>> expanded: ", expanded["outputs"]) resolve_selectors(expanded) - print("\n [1] >>> expanded: ", expanded["outputs"]) # Build or load a reference file (only created if missing) ref_dir = os.path.join(test_dir, "..", "ref") From 9d1422dca2b5a671a1ba8e8593f0e53a7d19efa1 Mon Sep 17 00:00:00 2001 From: benflexcompute Date: Mon, 17 Nov 2025 20:01:39 -0500 Subject: [PATCH 9/9] Fixed pylint --- .../simulation/framework/entity_selector.py | 4 +--- flow360/component/simulation/services.py | 24 +++++++++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/flow360/component/simulation/framework/entity_selector.py b/flow360/component/simulation/framework/entity_selector.py index dc5751a1d..9810c7c50 100644 --- a/flow360/component/simulation/framework/entity_selector.py +++ b/flow360/component/simulation/framework/entity_selector.py @@ -344,9 +344,7 @@ def generate_entity_selector_from_class( ########## EXPANSION IMPLEMENTATION ########## -def _get_entity_pool( - entity_database: EntityDictDatabase, target_class: TargetClass -) -> list[dict]: +def _get_entity_pool(entity_database: EntityDictDatabase, target_class: TargetClass) -> list[dict]: """Return the correct entity list from the database for the target class.""" if target_class == "Surface": return entity_database.surfaces diff --git a/flow360/component/simulation/services.py b/flow360/component/simulation/services.py index d29aef8e2..f4b5ffe01 100644 --- a/flow360/component/simulation/services.py +++ b/flow360/component/simulation/services.py @@ -31,19 +31,16 @@ ) from flow360.component.simulation.models.surface_models import Freestream, Wall -from flow360.component.simulation.models.volume_models import ( # pylint: disable=unused-import - BETDisk, # For parse_model_dict +# 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.operating_condition.operating_condition import ( # pylint: disable=unused-import - AerospaceCondition, # For parse_model_dict - GenericReferenceCondition, # For parse_model_dict - ThermalState, # For parse_model_dict -) -from flow360.component.simulation.outputs.outputs import SurfaceOutput -from flow360.component.simulation.primitives import ( # pylint: disable=unused-import - Box, # For parse_model_dict -) -from flow360.component.simulation.primitives import Surface +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, @@ -173,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