From cdc496b61cecd1d414d52fe433f15648bad1add0 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Tue, 20 Aug 2024 15:23:42 +0200 Subject: [PATCH 01/23] [TMP] Prototype recursive clone code --- poetry.lock | 26 ++- pyproject.toml | 4 +- src/ansys/acp/core/_recursive_clone.py | 164 ++++++++++++++++++ .../_grpc_helpers/edge_property_list.py | 5 +- .../_grpc_helpers/property_helper.py | 4 +- .../acp/core/_tree_objects/_traversal.py | 101 +++++++++++ .../_tree_objects/linked_selection_rule.py | 22 ++- .../acp/core/_tree_objects/modeling_ply.py | 14 +- src/ansys/acp/core/_tree_objects/stackup.py | 10 +- .../acp/core/_tree_objects/sublaminate.py | 9 +- .../core/_tree_objects/virtual_geometry.py | 14 +- 11 files changed, 348 insertions(+), 25 deletions(-) create mode 100644 src/ansys/acp/core/_recursive_clone.py create mode 100644 src/ansys/acp/core/_tree_objects/_traversal.py diff --git a/poetry.lock b/poetry.lock index d9a07ca013..e85f831867 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2107,13 +2107,13 @@ referencing = ">=0.31.0" [[package]] name = "jupyter-client" -version = "8.6.2" +version = "8.6.3" description = "Jupyter protocol implementation and client libraries" optional = false python-versions = ">=3.8" files = [ - {file = "jupyter_client-8.6.2-py3-none-any.whl", hash = "sha256:50cbc5c66fd1b8f65ecb66bc490ab73217993632809b6e505687de18e9dea39f"}, - {file = "jupyter_client-8.6.2.tar.gz", hash = "sha256:2bda14d55ee5ba58552a8c53ae43d215ad9868853489213f37da060ced54d8df"}, + {file = "jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f"}, + {file = "jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419"}, ] [package.dependencies] @@ -2901,6 +2901,24 @@ files = [ {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, ] +[[package]] +name = "networkx" +version = "3.3" +description = "Python package for creating and manipulating graphs and networks" +optional = false +python-versions = ">=3.10" +files = [ + {file = "networkx-3.3-py3-none-any.whl", hash = "sha256:28575580c6ebdaf4505b22c6256a2b9de86b316dc63ba9e93abde3d78dfdbcf2"}, + {file = "networkx-3.3.tar.gz", hash = "sha256:0c127d8b2f4865f59ae9cb8aafcd60b5c70f3241ebd66f7defad7c4ab90126c9"}, +] + +[package.extras] +default = ["matplotlib (>=3.6)", "numpy (>=1.23)", "pandas (>=1.4)", "scipy (>=1.9,!=1.11.0,!=1.11.1)"] +developer = ["changelist (==0.5)", "mypy (>=1.1)", "pre-commit (>=3.2)", "rtoml"] +doc = ["myst-nb (>=1.0)", "numpydoc (>=1.7)", "pillow (>=9.4)", "pydata-sphinx-theme (>=0.14)", "sphinx (>=7)", "sphinx-gallery (>=0.14)", "texext (>=0.6.7)"] +extra = ["lxml (>=4.6)", "pydot (>=2.0)", "pygraphviz (>=1.12)", "sympy (>=1.10)"] +test = ["pytest (>=7.2)", "pytest-cov (>=4.0)"] + [[package]] name = "nodeenv" version = "1.9.1" @@ -5002,4 +5020,4 @@ examples = ["ansys-dpf-composites", "ansys-mapdl-core", "ansys-mechanical-core", [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "18c93570d47795eefec3cb312ff1cb5d1d89b55d4c1e5d84599dccd6d23d53d2" +content-hash = "b387ab7ac9a40f004afc5eb3571f76df4294625c4b70c3b0e77e32f2d8293c44" diff --git a/pyproject.toml b/pyproject.toml index 28ee3abea8..22a6327e03 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ ansys-tools-path = ">=0" ansys-tools-local-product-launcher = ">=0.1" ansys-tools-filetransfer = ">=0.1" pyvista = ">=0.42.0" +networkx = ">=3.0.0" # Dependencies for the examples. Update also the 'dev' group when # these are updated. @@ -147,8 +148,9 @@ pretty = true [[tool.mypy.overrides]] module = [ "docker.*", - "grpc.*", "grpc_health.*", + "grpc.*", + "networkx", "scipy.optimize", "ansys.mapdl", "ansys.mapdl.core", diff --git a/src/ansys/acp/core/_recursive_clone.py b/src/ansys/acp/core/_recursive_clone.py new file mode 100644 index 0000000000..3c442a5832 --- /dev/null +++ b/src/ansys/acp/core/_recursive_clone.py @@ -0,0 +1,164 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from collections.abc import Iterable +from dataclasses import dataclass + +import networkx as nx + +from ._tree_objects._traversal import ( + all_linked_objects, + child_objects, + directly_linked_objects, + edge_property_lists, + edge_property_targets, + linked_object_lists, +) +from ._tree_objects.base import CreatableTreeObject, TreeObject + + +@dataclass +class _WalkTreeOptions: + include_children: bool + include_linked_objects: bool + + +def _build_dependency_graph( + *, source_objects: Iterable[CreatableTreeObject], options: _WalkTreeOptions +) -> tuple[nx.DiGraph, dict[str, CreatableTreeObject]]: + graph = nx.DiGraph() + visited_objects: dict[str, CreatableTreeObject] = dict() + for tree_object in source_objects: + _build_dependency_graph_impl( + tree_object=tree_object, graph=graph, visited_objects=visited_objects, options=options + ) + return graph, visited_objects + + +def _build_dependency_graph_impl( + *, + tree_object: CreatableTreeObject, + graph: nx.DiGraph, + visited_objects: dict[str, CreatableTreeObject], + options: _WalkTreeOptions, +) -> None: + key = tree_object._resource_path.value + if key in visited_objects: + return + visited_objects[key] = tree_object + graph.add_node(key) + + if options.include_children: + for child_object in child_objects(tree_object): + if not isinstance(child_object, CreatableTreeObject): + print(f"Skipping {child_object} as it is not a CreatableTreeObject.") + continue + graph.add_edge(child_object._resource_path.value, key) + _build_dependency_graph_impl( + tree_object=child_object, + graph=graph, + visited_objects=visited_objects, + options=options, + ) + if options.include_linked_objects: + for linked_object in all_linked_objects(tree_object): + assert isinstance(linked_object, CreatableTreeObject) + graph.add_edge(tree_object._resource_path.value, linked_object._resource_path.value) + _build_dependency_graph_impl( + tree_object=linked_object, + graph=graph, + visited_objects=visited_objects, + options=options, + ) + + +def recursive_copy( + *, + source_objects: Iterable[CreatableTreeObject], + parent_mapping: Iterable[tuple[TreeObject, TreeObject]], +) -> list[CreatableTreeObject]: + """TODO: document this function.""" + options = _WalkTreeOptions(include_children=True, include_linked_objects=True) + graph, visited_objects = _build_dependency_graph(source_objects=source_objects, options=options) + + replacement_mapping = { + parent._resource_path.value: new_parent for parent, new_parent in parent_mapping + } + new_objects: list[CreatableTreeObject] = [] + + for node in reversed(list(nx.topological_sort(graph))): + print(node) + if node in replacement_mapping: + # Skip nodes which are already copied (e.g. coming from the parent_mapping) + continue + tree_object = visited_objects[node] + if hasattr(tree_object, "id"): + print(tree_object.id) + + new_tree_object = tree_object.clone() + for attr_name, linked_object in directly_linked_objects(tree_object): + new_linked_object = replacement_mapping[linked_object._resource_path.value] + setattr(new_tree_object, attr_name, new_linked_object) + for attr_name, linked_object_list in linked_object_lists(tree_object): + new_linked_objects = [ + replacement_mapping[linked_object._resource_path.value] + for linked_object in linked_object_list + ] + setattr(new_tree_object, attr_name, new_linked_objects) + + # clear edge property lists, then re-create them once the new object is stored + for attr_name, _ in edge_property_lists(tree_object): + setattr(new_tree_object, attr_name, []) + + parent_rp = tree_object._resource_path.value.rsplit("/", 2)[0] + new_parent = replacement_mapping[parent_rp] + new_tree_object.store(parent=new_parent) + + for attr_name, edge_property_list in edge_property_lists(tree_object): + new_edge_property_list = [edge.clone() for edge in edge_property_list] + # for edge in edge_property_list: + # new_edge = edge.clone() + # for edge_attr_name in edge._GRPC_PROPERTIES: + # print(edge_attr_name) + # edge_prop = getattr(new_edge, edge_attr_name) + # if isinstance(edge_prop, TreeObject): + # new_edge_prop = replacement_mapping[edge_prop._resource_path.value] + # setattr(new_edge, edge_attr_name, new_edge_prop) + + # # new_edge.store(parent=new_tree_object) + # new_edge_property_list.append(new_edge) + for edge, edge_prop_name, edge_target in edge_property_targets(new_edge_property_list): + new_edge_target = replacement_mapping[edge_target._resource_path.value] + setattr(edge, edge_prop_name, new_edge_target) + setattr(new_tree_object, attr_name, new_edge_property_list) + + # # now replace the edge property list in one go + # setattr(new_tree_object, attr_name, new_edge_property_list) + + + + print(new_tree_object._pb_object) + + replacement_mapping[node] = new_tree_object + new_objects.append(new_tree_object) + + return new_objects diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py index 3f51c5c438..dcd0418f1e 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py @@ -33,6 +33,7 @@ from .._object_cache import ObjectCacheMixin, constructor_with_cache from ..base import CreatableTreeObject from .property_helper import _exposed_grpc_property, _wrap_doc, grpc_data_getter, grpc_data_setter +from .protocols import GrpcObjectBase __all__ = [ "EdgePropertyList", @@ -42,7 +43,7 @@ ] -class GenericEdgePropertyType(Protocol): +class GenericEdgePropertyType(GrpcObjectBase, Protocol): """Protocol for the definition of ACP edge properties such as FabricWithAngle.""" def __init__(self, *kwargs: Any) -> None: ... @@ -170,7 +171,7 @@ def _object_list(self) -> list[ValueT]: # There are two scenarios when the parent object becomes # stored: # - The _object_list already contained some values. In this case, we - # simply keep it, and make a (inexaustive) check that the size + # simply keep it, and make a (inexhaustive) check that the size # matches. # - The parent object was default-constructed and then its _pb_object # was then replaced (e.g. by a call to _from_object_info). In this diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index c6300c812f..b16efd3eb7 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py @@ -90,10 +90,8 @@ def grpc_linked_object_getter(name: str) -> Callable[[Readable], Any]: """Create a getter method which obtains the linked server object.""" def inner(self: Readable) -> CreatableFromResourcePath | None: - # Import here to avoid circular references. Cannot use the registry before - # all the object have been imported. if not self._is_stored: - raise Exception("Cannot get linked object from unstored object") + raise RuntimeError(f"Cannot get linked object '{name}' from unstored object") self._get() object_resource_path = _get_data_attribute(self._pb_object, name) diff --git a/src/ansys/acp/core/_tree_objects/_traversal.py b/src/ansys/acp/core/_tree_objects/_traversal.py new file mode 100644 index 0000000000..8ab0a5149c --- /dev/null +++ b/src/ansys/acp/core/_tree_objects/_traversal.py @@ -0,0 +1,101 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from collections.abc import Iterator +from typing import TypeVar + +from ._grpc_helpers.edge_property_list import EdgePropertyList, GenericEdgePropertyType +from ._grpc_helpers.linked_object_list import LinkedObjectList +from ._grpc_helpers.mapping import Mapping +from .base import CreatableTreeObject, TreeObjectBase + +__all__ = [ + "all_linked_objects", + "child_objects", + "directly_linked_objects", + "linked_object_lists", + "edge_property_lists", + "edge_property_targets", +] + + +def all_linked_objects(tree_object: TreeObjectBase) -> Iterator[TreeObjectBase]: + """Yield all objects linked to the given tree object.""" + for _, linked_object in directly_linked_objects(tree_object): + yield linked_object + for _, linked_object_list in linked_object_lists(tree_object): + yield from linked_object_list + for _, edge_property_list in edge_property_lists(tree_object): + for _, _, linked_object in edge_property_targets(edge_property_list): + yield linked_object + + +def child_objects(tree_object: TreeObjectBase) -> Iterator[TreeObjectBase]: + """Yield all child objects of the given tree object.""" + for _, mapping in _yield_attrs_of_type(tree_object, Mapping): + yield from mapping.values() + + +def directly_linked_objects(tree_object: TreeObjectBase) -> Iterator[tuple[str, TreeObjectBase]]: + """Yield the attribute name and linked object for all directly linked objects.""" + yield from _yield_attrs_of_type(tree_object, TreeObjectBase) + + +def linked_object_lists( + tree_object: TreeObjectBase, +) -> Iterator[tuple[str, LinkedObjectList[CreatableTreeObject]]]: + """Yield the attribute name and linked object list for all linked object lists.""" + yield from _yield_attrs_of_type(tree_object, LinkedObjectList) + + +def edge_property_lists( + tree_object: TreeObjectBase, +) -> Iterator[tuple[str, EdgePropertyList[GenericEdgePropertyType]]]: + """Yield the attribute name and edge property list for all edge property lists.""" + yield from _yield_attrs_of_type(tree_object, EdgePropertyList) + + +def edge_property_targets( + edge_property_list: EdgePropertyList[GenericEdgePropertyType], +) -> Iterator[tuple[GenericEdgePropertyType, str, TreeObjectBase]]: + """Yield the edge property, edge property name, and linked object for all edge properties.""" + for edge in edge_property_list: + for edge_prop_name in edge._GRPC_PROPERTIES: + try: + edge_prop = getattr(edge, edge_prop_name) + except (AttributeError, RuntimeError): + continue + if isinstance(edge_prop, TreeObjectBase): + yield (edge, edge_prop_name, edge_prop) + + +T = TypeVar("T") + + +def _yield_attrs_of_type(tree_object: TreeObjectBase, type_: type[T]) -> Iterator[tuple[str, T]]: + for attr_name in tree_object._GRPC_PROPERTIES: + try: + attr = getattr(tree_object, attr_name) + except (AttributeError, RuntimeError): + continue + if isinstance(attr, type_): + yield (attr_name, attr) diff --git a/src/ansys/acp/core/_tree_objects/linked_selection_rule.py b/src/ansys/acp/core/_tree_objects/linked_selection_rule.py index 34a67a731f..a751c2b1b1 100644 --- a/src/ansys/acp/core/_tree_objects/linked_selection_rule.py +++ b/src/ansys/acp/core/_tree_objects/linked_selection_rule.py @@ -32,6 +32,7 @@ from ._grpc_helpers.edge_property_list import GenericEdgePropertyType from ._grpc_helpers.polymorphic_from_pb import tree_object_from_resource_path +from ._grpc_helpers.property_helper import _exposed_grpc_property, mark_grpc_properties from .base import CreatableTreeObject from .cutoff_selection_rule import CutoffSelectionRule from .cylindrical_selection_rule import CylindricalSelectionRule @@ -63,6 +64,7 @@ ] +@mark_grpc_properties class LinkedSelectionRule(GenericEdgePropertyType): r"""Defines selection rules linked to a Boolean Selection Rule or Modeling Ply. @@ -123,7 +125,7 @@ def __init__( self.parameter_1 = parameter_1 self.parameter_2 = parameter_2 - @property + @_exposed_grpc_property def selection_rule(self) -> _LINKABLE_SELECTION_RULE_TYPES: """Link to an existing selection rule.""" return self._selection_rule @@ -134,7 +136,7 @@ def selection_rule(self, value: _LINKABLE_SELECTION_RULE_TYPES) -> None: if self._callback_apply_changes is not None: self._callback_apply_changes() - @property + @_exposed_grpc_property def operation_type(self) -> BooleanOperationType: """Operation to combine the selection rule with other selection rules.""" return self._operation_type @@ -154,7 +156,7 @@ def operation_type(self, value: BooleanOperationType) -> None: if self._callback_apply_changes is not None: self._callback_apply_changes() - @property + @_exposed_grpc_property def template_rule(self) -> bool: """Whether the selection rule is a template rule.""" return self._template_rule @@ -165,7 +167,7 @@ def template_rule(self, value: bool) -> None: if self._callback_apply_changes is not None: self._callback_apply_changes() - @property + @_exposed_grpc_property def parameter_1(self) -> float: """First template parameter of the selection rule.""" return self._parameter_1 @@ -176,7 +178,7 @@ def parameter_1(self, value: float) -> None: if self._callback_apply_changes is not None: self._callback_apply_changes() - @property + @_exposed_grpc_property def parameter_2(self) -> float: """Second template parameter of the selection rule.""" return self._parameter_2 @@ -266,3 +268,13 @@ def __repr__(self) -> str: f"parameter_1={self.parameter_1}, " f"parameter_2={self.parameter_2})" ) + + def clone(self) -> LinkedSelectionRule: + """Create a clone of the current LinkedSelectionRule object.""" + return LinkedSelectionRule( + selection_rule=self.selection_rule, + operation_type=self.operation_type, + template_rule=self.template_rule, + parameter_1=self.parameter_1, + parameter_2=self.parameter_2, + ) diff --git a/src/ansys/acp/core/_tree_objects/modeling_ply.py b/src/ansys/acp/core/_tree_objects/modeling_ply.py index 6ad2dd5140..aacd692785 100644 --- a/src/ansys/acp/core/_tree_objects/modeling_ply.py +++ b/src/ansys/acp/core/_tree_objects/modeling_ply.py @@ -41,6 +41,7 @@ from ._grpc_helpers.linked_object_list import define_linked_object_list from ._grpc_helpers.mapping import get_read_only_collection_property from ._grpc_helpers.property_helper import ( + _exposed_grpc_property, grpc_data_property, grpc_data_property_read_only, grpc_link_property, @@ -114,6 +115,7 @@ class ModelingPlyNodalData(NodalData): ply_offset: VectorData | None = None +@mark_grpc_properties class TaperEdge(GenericEdgePropertyType): """Defines a taper edge. @@ -135,7 +137,7 @@ def __init__(self, edge_set: EdgeSet, *, angle: float, offset: float): self.angle = angle self.offset = offset - @property + @_exposed_grpc_property def edge_set(self) -> EdgeSet: """Edge along which the ply tapering is applied.""" return self._edge_set @@ -148,7 +150,7 @@ def edge_set(self, edge_set: EdgeSet) -> None: if self._callback_apply_changes is not None: self._callback_apply_changes() - @property + @_exposed_grpc_property def angle(self) -> float: """Angle between the cutting plane and the reference surface.""" return self._angle @@ -159,7 +161,7 @@ def angle(self, angle: float) -> None: if self._callback_apply_changes is not None: self._callback_apply_changes() - @property + @_exposed_grpc_property def offset(self) -> float: """Move the cutting plane along the out-of-plane direction. @@ -221,6 +223,12 @@ def __repr__(self) -> str: f"angle={self.angle!r}, offset={self.offset!r})" ) + def clone(self): + return TaperEdge( + edge_set=self.edge_set, + angle=self.angle, + offset=self.offset, + ) @mark_grpc_properties @register diff --git a/src/ansys/acp/core/_tree_objects/stackup.py b/src/ansys/acp/core/_tree_objects/stackup.py index a9a2936453..2235c248fb 100644 --- a/src/ansys/acp/core/_tree_objects/stackup.py +++ b/src/ansys/acp/core/_tree_objects/stackup.py @@ -34,6 +34,7 @@ define_edge_property_list, ) from ._grpc_helpers.property_helper import ( + _exposed_grpc_property, grpc_data_property, grpc_data_property_read_only, grpc_link_property, @@ -62,6 +63,7 @@ __all__ = ["Stackup", "FabricWithAngle"] +@mark_grpc_properties class FabricWithAngle(GenericEdgePropertyType): """Defines a fabric of a stackup. @@ -79,7 +81,7 @@ def __init__(self, fabric: Fabric, angle: float = 0.0): self.fabric = fabric self.angle = angle - @property + @_exposed_grpc_property def fabric(self) -> Fabric: """Linked fabric.""" return self._fabric @@ -92,7 +94,7 @@ def fabric(self, value: Fabric) -> None: if self._callback_apply_changes: self._callback_apply_changes() - @property + @_exposed_grpc_property def angle(self) -> float: """Orientation angle in degree of the fabric with respect to the reference direction.""" return self._angle @@ -140,6 +142,10 @@ def __repr__(self) -> str: return f"FabricWithAngle(fabric={self.fabric.__repr__()}, angle={self.angle})" + def clone(self) -> FabricWithAngle: + """Create a copy of the object.""" + return FabricWithAngle(fabric=self.fabric, angle=self.angle) + @mark_grpc_properties @register class Stackup(CreatableTreeObject, IdTreeObject): diff --git a/src/ansys/acp/core/_tree_objects/sublaminate.py b/src/ansys/acp/core/_tree_objects/sublaminate.py index d2877f9750..2c3dbeb8ea 100644 --- a/src/ansys/acp/core/_tree_objects/sublaminate.py +++ b/src/ansys/acp/core/_tree_objects/sublaminate.py @@ -36,6 +36,7 @@ ) from ._grpc_helpers.polymorphic_from_pb import tree_object_from_resource_path from ._grpc_helpers.property_helper import ( + _exposed_grpc_property, grpc_data_property, grpc_data_property_read_only, mark_grpc_properties, @@ -51,6 +52,7 @@ _LINKABLE_MATERIAL_TYPES = Union[Fabric, Stackup] +@mark_grpc_properties class Lamina(GenericEdgePropertyType): """ Class to link a material with a sub-laminate. @@ -69,7 +71,7 @@ def __init__(self, material: _LINKABLE_MATERIAL_TYPES, angle: float = 0.0): self.material = material self.angle = angle - @property + @_exposed_grpc_property def material(self) -> _LINKABLE_MATERIAL_TYPES: """Link to an existing fabric or stackup.""" return self._material @@ -85,7 +87,7 @@ def material(self, value: _LINKABLE_MATERIAL_TYPES) -> None: if self._callback_apply_changes: self._callback_apply_changes() - @property + @_exposed_grpc_property def angle(self) -> float: """Orientation angle in degree of the material with respect to the reference direction.""" return self._angle @@ -143,6 +145,9 @@ def __eq__(self, other: Any) -> bool: def __repr__(self) -> str: return f"Lamina(material={self.material.__repr__()}, angle={self.angle})" + def clone(self) -> Lamina: + """Create a clone of the current Lamina object.""" + return Lamina(material=self.material, angle=self.angle) @mark_grpc_properties @register diff --git a/src/ansys/acp/core/_tree_objects/virtual_geometry.py b/src/ansys/acp/core/_tree_objects/virtual_geometry.py index 3ea22b7ee4..23c4d20858 100644 --- a/src/ansys/acp/core/_tree_objects/virtual_geometry.py +++ b/src/ansys/acp/core/_tree_objects/virtual_geometry.py @@ -33,7 +33,11 @@ define_add_method, define_edge_property_list, ) -from ._grpc_helpers.property_helper import grpc_data_property_read_only, mark_grpc_properties +from ._grpc_helpers.property_helper import ( + _exposed_grpc_property, + grpc_data_property_read_only, + mark_grpc_properties, +) from .base import CreatableTreeObject, IdTreeObject from .cad_geometry import CADGeometry from .enums import status_type_from_pb, virtual_geometry_dimension_from_pb @@ -43,6 +47,7 @@ from .cad_component import CADComponent +@mark_grpc_properties class SubShape(GenericEdgePropertyType): """Represents a sub-shape of a virtual geometry.""" @@ -51,7 +56,7 @@ def __init__(self, cad_geometry: CADGeometry, path: str): self.cad_geometry = cad_geometry self.path = path - @property + @_exposed_grpc_property def cad_geometry(self) -> CADGeometry: """Linked CAD geometry.""" return self._cad_geometry @@ -64,7 +69,7 @@ def cad_geometry(self, value: CADGeometry) -> None: if self._callback_apply_changes: self._callback_apply_changes() - @property + @_exposed_grpc_property def path(self) -> str: """Topological path of the sub-shape within the CAD geometry.""" return self._path @@ -115,6 +120,9 @@ def __eq__(self, other: Any) -> bool: def __repr__(self) -> str: return f"SubShape(cad_geometry={self._cad_geometry.__repr__()}, path={self._path})" + def clone(self) -> SubShape: + """Create a clone of the SubShape.""" + return SubShape(cad_geometry=self.cad_geometry, path=self.path) @mark_grpc_properties @register From 7f0d6c68cb3870da30352abec5ef79862a376898 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 22 Aug 2024 16:54:50 +0200 Subject: [PATCH 02/23] Add docstring to recursive_copy --- doc/source/api/index.rst | 1 + doc/source/api/other_utils.rst | 9 ++ src/ansys/acp/core/__init__.py | 2 + ..._recursive_clone.py => _recursive_copy.py} | 86 +++++++++++++------ .../_grpc_helpers/edge_property_list.py | 4 + .../acp/core/_tree_objects/_traversal.py | 4 +- 6 files changed, 80 insertions(+), 26 deletions(-) create mode 100644 doc/source/api/other_utils.rst rename src/ansys/acp/core/{_recursive_clone.py => _recursive_copy.py} (71%) diff --git a/doc/source/api/index.rst b/doc/source/api/index.rst index b02f968c6d..f4cab8f7a6 100644 --- a/doc/source/api/index.rst +++ b/doc/source/api/index.rst @@ -16,6 +16,7 @@ and attributes. material_property_sets enum_types plot_utils + other_utils workflow example_helpers internal diff --git a/doc/source/api/other_utils.rst b/doc/source/api/other_utils.rst new file mode 100644 index 0000000000..5a16ddd96b --- /dev/null +++ b/doc/source/api/other_utils.rst @@ -0,0 +1,9 @@ +Other utilities +--------------- + +.. currentmodule:: ansys.acp.core + +.. autosummary:: + :toctree: _autosummary + + recursive_copy diff --git a/src/ansys/acp/core/__init__.py b/src/ansys/acp/core/__init__.py index 1a7291dbf4..6f47e0a1bf 100644 --- a/src/ansys/acp/core/__init__.py +++ b/src/ansys/acp/core/__init__.py @@ -30,6 +30,7 @@ from . import example_helpers, material_property_sets from ._model_printer import get_model_tree, print_model from ._plotter import get_directions_plotter +from ._recursive_copy import recursive_copy from ._server import ( ACP, ConnectLaunchConfig, @@ -223,6 +224,7 @@ "ProductionPly", "ProductionPlyElementalData", "ProductionPlyNodalData", + "recursive_copy", "Rosette", "RosetteSelectionMethod", "RosetteType", diff --git a/src/ansys/acp/core/_recursive_clone.py b/src/ansys/acp/core/_recursive_copy.py similarity index 71% rename from src/ansys/acp/core/_recursive_clone.py rename to src/ansys/acp/core/_recursive_copy.py index 3c442a5832..b87613c1d9 100644 --- a/src/ansys/acp/core/_recursive_clone.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -35,6 +35,8 @@ ) from ._tree_objects.base import CreatableTreeObject, TreeObject +__all__ = ["recursive_copy"] + @dataclass class _WalkTreeOptions: @@ -70,7 +72,6 @@ def _build_dependency_graph_impl( if options.include_children: for child_object in child_objects(tree_object): if not isinstance(child_object, CreatableTreeObject): - print(f"Skipping {child_object} as it is not a CreatableTreeObject.") continue graph.add_edge(child_object._resource_path.value, key) _build_dependency_graph_impl( @@ -95,9 +96,67 @@ def recursive_copy( *, source_objects: Iterable[CreatableTreeObject], parent_mapping: Iterable[tuple[TreeObject, TreeObject]], + include_children: bool = True, + include_linked_objects: bool = True, ) -> list[CreatableTreeObject]: - """TODO: document this function.""" - options = _WalkTreeOptions(include_children=True, include_linked_objects=True) + """Recursively copy a tree of ACP objects. + + This function copies a tree of ACP objects, starting from the given source objects. + You can specify whether to include children (for example the Modeling Plies in a + Modeling Group) and linked objects (for example the Rosettes linked to an Oriented + Selection Set) in the copy. + + To specify where the new objects should be stored, you must provide a list of tuples + in the ``parent_mapping`` argument. Each tuple contains the original parent object + as the first element and the new parent object as the second element. + Note that this mapping may need to contain parent objects that are not direct parents + of the source objects, if another branch of the tree is included via linked objects. + + The function returns a list of newly created objects. + + .. note:: + + Only attributes supported by PyACP are copied to the new objects. + + Parameters + ---------- + source_objects : + The starting point of the tree to copy. + parent_mapping : + A list of tuples defining where the new objects are stored. Each tuple contains + the original parent object as the first element and the new parent object as the + second element. + include_children : + Whether to include child objects when creating the tree to copy. + include_linked_objects : + Whether to include linked objects when creating the tree to copy. + + Returns + ------- + : + A list of newly created objects. + + Examples + -------- + To copy all Modeling Groups and associated definitions from one model to another, + you can use the following code: + + .. code-block:: python + + import ansys.acp.core as pyacp + + model1 = ... # loaded in some way + model2 = ... # loaded in some way + + pyacp.recursive_copy( + source_objects=model1.modeling_groups.values(), + parent_mapping=[(model1, model2)], + ) + + """ + options = _WalkTreeOptions( + include_children=include_children, include_linked_objects=include_linked_objects + ) graph, visited_objects = _build_dependency_graph(source_objects=source_objects, options=options) replacement_mapping = { @@ -106,13 +165,10 @@ def recursive_copy( new_objects: list[CreatableTreeObject] = [] for node in reversed(list(nx.topological_sort(graph))): - print(node) if node in replacement_mapping: # Skip nodes which are already copied (e.g. coming from the parent_mapping) continue tree_object = visited_objects[node] - if hasattr(tree_object, "id"): - print(tree_object.id) new_tree_object = tree_object.clone() for attr_name, linked_object in directly_linked_objects(tree_object): @@ -135,29 +191,11 @@ def recursive_copy( for attr_name, edge_property_list in edge_property_lists(tree_object): new_edge_property_list = [edge.clone() for edge in edge_property_list] - # for edge in edge_property_list: - # new_edge = edge.clone() - # for edge_attr_name in edge._GRPC_PROPERTIES: - # print(edge_attr_name) - # edge_prop = getattr(new_edge, edge_attr_name) - # if isinstance(edge_prop, TreeObject): - # new_edge_prop = replacement_mapping[edge_prop._resource_path.value] - # setattr(new_edge, edge_attr_name, new_edge_prop) - - # # new_edge.store(parent=new_tree_object) - # new_edge_property_list.append(new_edge) for edge, edge_prop_name, edge_target in edge_property_targets(new_edge_property_list): new_edge_target = replacement_mapping[edge_target._resource_path.value] setattr(edge, edge_prop_name, new_edge_target) setattr(new_tree_object, attr_name, new_edge_property_list) - # # now replace the edge property list in one go - # setattr(new_tree_object, attr_name, new_edge_property_list) - - - - print(new_tree_object._pb_object) - replacement_mapping[node] = new_tree_object new_objects.append(new_tree_object) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py index dcd0418f1e..6e523ea66a 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/edge_property_list.py @@ -62,6 +62,10 @@ def _check(self) -> bool: ... def _set_callback_apply_changes(self, callback_apply_changes: Callable[[], None]) -> None: ... + def clone(self) -> Self: + """Create a new unstored object with the same properties.""" + raise NotImplementedError + ValueT = TypeVar("ValueT", bound=GenericEdgePropertyType) diff --git a/src/ansys/acp/core/_tree_objects/_traversal.py b/src/ansys/acp/core/_tree_objects/_traversal.py index 8ab0a5149c..b1d83450df 100644 --- a/src/ansys/acp/core/_tree_objects/_traversal.py +++ b/src/ansys/acp/core/_tree_objects/_traversal.py @@ -20,7 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from typing import TypeVar from ._grpc_helpers.edge_property_list import EdgePropertyList, GenericEdgePropertyType @@ -75,7 +75,7 @@ def edge_property_lists( def edge_property_targets( - edge_property_list: EdgePropertyList[GenericEdgePropertyType], + edge_property_list: Iterable[GenericEdgePropertyType], ) -> Iterator[tuple[GenericEdgePropertyType, str, TreeObjectBase]]: """Yield the edge property, edge property name, and linked object for all edge properties.""" for edge in edge_property_list: From f5a400da3a4adf4e8c7818b0b68ee3103576ee3c Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 22 Aug 2024 20:05:04 +0200 Subject: [PATCH 03/23] Add more recursive_copy tests --- src/ansys/acp/core/_recursive_copy.py | 69 ++++++--- tests/unittests/test_recursive_copy.py | 199 +++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 20 deletions(-) create mode 100644 tests/unittests/test_recursive_copy.py diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index b87613c1d9..8958ee7a52 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -112,6 +112,10 @@ def recursive_copy( Note that this mapping may need to contain parent objects that are not direct parents of the source objects, if another branch of the tree is included via linked objects. + The ``parent_mapping`` argument can also include objects which are part of the + ``source_objects`` list. In this case, the function will not create a new object for + the parent, but will use the existing object instead. + The function returns a list of newly created objects. .. note:: @@ -153,6 +157,19 @@ def recursive_copy( parent_mapping=[(model1, model2)], ) + To copy all definitions from one model to another, you can use the following code: + + .. code-block:: python + + import ansys.acp.core as pyacp + + model1 = ... # loaded in some way + model2 = ... # loaded in some way + + pyacp.recursive_copy( + source_objects=[model1], + parent_mapping=[(model1, model2)], + ) """ options = _WalkTreeOptions( include_children=include_children, include_linked_objects=include_linked_objects @@ -171,30 +188,42 @@ def recursive_copy( tree_object = visited_objects[node] new_tree_object = tree_object.clone() - for attr_name, linked_object in directly_linked_objects(tree_object): - new_linked_object = replacement_mapping[linked_object._resource_path.value] - setattr(new_tree_object, attr_name, new_linked_object) - for attr_name, linked_object_list in linked_object_lists(tree_object): - new_linked_objects = [ - replacement_mapping[linked_object._resource_path.value] - for linked_object in linked_object_list - ] - setattr(new_tree_object, attr_name, new_linked_objects) - - # clear edge property lists, then re-create them once the new object is stored - for attr_name, _ in edge_property_lists(tree_object): - setattr(new_tree_object, attr_name, []) + + # If the linked objects are also copied, replace them with the new objects. + # Otherwise, we can directly store the new object. + if include_linked_objects: + for attr_name, linked_object in directly_linked_objects(tree_object): + new_linked_object = replacement_mapping[linked_object._resource_path.value] + setattr(new_tree_object, attr_name, new_linked_object) + for attr_name, linked_object_list in linked_object_lists(tree_object): + new_linked_objects = [ + replacement_mapping[linked_object._resource_path.value] + for linked_object in linked_object_list + ] + setattr(new_tree_object, attr_name, new_linked_objects) + + # clear edge property lists, then re-create them once the new object is stored + for attr_name, _ in edge_property_lists(tree_object): + setattr(new_tree_object, attr_name, []) parent_rp = tree_object._resource_path.value.rsplit("/", 2)[0] - new_parent = replacement_mapping[parent_rp] + try: + new_parent = replacement_mapping[parent_rp] + except KeyError as exc: + raise KeyError( + f"Parent object not found in 'parent_mapping' for object '{tree_object!r}'." + ) from exc new_tree_object.store(parent=new_parent) - for attr_name, edge_property_list in edge_property_lists(tree_object): - new_edge_property_list = [edge.clone() for edge in edge_property_list] - for edge, edge_prop_name, edge_target in edge_property_targets(new_edge_property_list): - new_edge_target = replacement_mapping[edge_target._resource_path.value] - setattr(edge, edge_prop_name, new_edge_target) - setattr(new_tree_object, attr_name, new_edge_property_list) + if include_linked_objects: + for attr_name, edge_property_list in edge_property_lists(tree_object): + new_edge_property_list = [edge.clone() for edge in edge_property_list] + for edge, edge_prop_name, edge_target in edge_property_targets( + new_edge_property_list + ): + new_edge_target = replacement_mapping[edge_target._resource_path.value] + setattr(edge, edge_prop_name, new_edge_target) + setattr(new_tree_object, attr_name, new_edge_property_list) replacement_mapping[node] = new_tree_object new_objects.append(new_tree_object) diff --git a/tests/unittests/test_recursive_copy.py b/tests/unittests/test_recursive_copy.py new file mode 100644 index 0000000000..889311f69a --- /dev/null +++ b/tests/unittests/test_recursive_copy.py @@ -0,0 +1,199 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import pytest + +from ansys.acp.core import FabricWithAngle, recursive_copy + + +@pytest.fixture +def minimal_complete_model(load_model_from_tempfile): + with load_model_from_tempfile() as model: + yield model + + +def test_basic_recursive_copy(minimal_complete_model): + """Test copying a Modeling Ply and its linked objects.""" + # GIVEN: A Modeling Ply with linked objects + mg = minimal_complete_model.modeling_groups["ModelingGroup.1"] + mp = mg.modeling_plies["ModelingPly.1"] + + # WHEN: Recursively copying the Modeling Ply onto the same Modeling Group + new_objects = recursive_copy( + source_objects=[mp], + parent_mapping=[(mg, mg), (minimal_complete_model, minimal_complete_model)], + ) + + # THEN: The expected new objects are created + assert len(new_objects) == 6 + assert {obj.id for obj in new_objects} == { # type: ignore + "Global Coordinate System.2", + "All_Elements.2", + "Structural Steel.2", + "Fabric.2", + "OrientedSelectionSet.2", + "ModelingPly.2", + } + + +def test_basic_recursive_copy_no_links(minimal_complete_model): + """Test copying a Modeling Ply without linked objects.""" + # GIVEN: A Modeling Ply with linked objects + mg = minimal_complete_model.modeling_groups["ModelingGroup.1"] + mp = mg.modeling_plies["ModelingPly.1"] + + # WHEN: Recursively copying the Modeling Ply onto the same Modeling Group, without linked objects + new_objects = recursive_copy( + source_objects=[mp], parent_mapping=[(mg, mg)], include_linked_objects=False + ) + + # THEN: The expected new objects are created + assert len(new_objects) == 1 + assert {obj.id for obj in new_objects} == { # type: ignore + "ModelingPly.2", + } + + +def test_copy_all_objects(minimal_complete_model): + """Test copying all objects on a model onto itself.""" + # GIVEN: A simple model + model = minimal_complete_model + + # WHEN: Recursively copying, starting from the root of the model + new_objects = recursive_copy(source_objects=[model], parent_mapping=[(model, model)]) + + # THEN: The expected new objects are created + # NOTE: This list may need to be updated when the model reference file + # is changed. + assert len(new_objects) == 8 + assert {obj.id for obj in new_objects} == { # type: ignore + "Global Coordinate System.2", + "All_Elements.2", + "ns_edge.2", + "Structural Steel.2", + "Fabric.2", + "OrientedSelectionSet.2", + "ModelingGroup.2", + "ModelingPly.2", + } + + +def test_copy_to_different_model(minimal_complete_model, load_model_from_tempfile): + """Test copying all objects on a model onto a different model.""" + # GIVEN: Two models + model1 = minimal_complete_model + with load_model_from_tempfile() as model2: + + # WHEN: Recursively copying all objects from model1 to model2 + new_objects = recursive_copy(source_objects=[model1], parent_mapping=[(model1, model2)]) + + # THEN: The expected new objects are created + # NOTE: This list may need to be updated when the model reference file + # is changed. + assert len(new_objects) == 8 + assert {obj.id for obj in new_objects} == { # type: ignore + "Global Coordinate System.2", + "All_Elements.2", + "ns_edge.2", + "Structural Steel.2", + "Fabric.2", + "OrientedSelectionSet.2", + "ModelingGroup.2", + "ModelingPly.2", + } + + +def test_children_not_included(minimal_complete_model): + """Test copying without including the child objects.""" + # GIVEN: A simple model + model = minimal_complete_model + + # WHEN: Recursively copying, starting from the a Modeling Group, but without including its + # children + new_objects = recursive_copy( + source_objects=[model.modeling_groups["ModelingGroup.1"]], + parent_mapping=[(model, model)], + include_children=False, + ) + + # THEN: Only the Modeling Group is copied + assert len(new_objects) == 1 + assert {obj.id for obj in new_objects} == { # type: ignore + "ModelingGroup.2", + } + + +def test_copy_edge_property_list(minimal_complete_model): + """Test copying an object which has an Edge Property List.""" + # GIVEN: A simple model with a Stackup + model = minimal_complete_model + fabric1 = model.fabrics["Fabric.1"] + fabric2 = model.create_fabric(name="other_fabric", material=model.materials["Structural Steel"]) + stackup = model.create_stackup( + name="Stackup.1", + fabrics=[ + FabricWithAngle(fabric=fabric1, angle=0), + FabricWithAngle(fabric=fabric2, angle=90), + ], + ) + + # WHEN: Recursively copying the Stackup + new_objects = recursive_copy(source_objects=[stackup], parent_mapping=[(model, model)]) + + # THEN: + # - The stackup, fabrics, and materials are copied + # - The copied stackup links the new fabrics with the correct order and angles + assert len(new_objects) == 4 + assert {obj.id for obj in new_objects} == { # type: ignore + "Stackup.2", + "Fabric.2", + "other_fabric.2", + "Structural Steel.2", + } + new_stackup = model.stackups["Stackup.2"] + linked_fabrics = new_stackup.fabrics + assert [fabric_with_angle.fabric.id for fabric_with_angle in linked_fabrics] == [ + "Fabric.2", + "other_fabric.2", + ] + assert [fabric_with_angle.angle for fabric_with_angle in linked_fabrics] == [0, 90] + + +def test_missing_parent_object_raises(minimal_complete_model): + """Test that an exception is raised if the parent_mapping is incomplete""" + + # GIVEN: A simple model + mg = minimal_complete_model.modeling_groups["ModelingGroup.1"] + mp = mg.modeling_plies["ModelingPly.1"] + + # WHEN: Recursively copying a Modeling Ply without providing the Model in + # the parent_mapping (needed due to links to e.g. the Global Coordinate System) + # THEN: An exception is raised + with pytest.raises(KeyError) as exc_info: + recursive_copy( + source_objects=[mp], + parent_mapping=[(mg, mg)], + ) + + msg = str(exc_info.value) + assert "Parent object" in msg + assert "parent_mapping" in msg From 0a0967b6d9c1d187e6fae8f124eb7033f89ead4c Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 22 Aug 2024 23:04:51 +0200 Subject: [PATCH 04/23] Improve docstrings --- .../_grpc_helpers/property_helper.py | 21 ++++++++++++------- .../_tree_objects/linked_selection_rule.py | 2 +- .../acp/core/_tree_objects/modeling_ply.py | 6 ++++-- src/ansys/acp/core/_tree_objects/stackup.py | 8 ++++--- .../acp/core/_tree_objects/sublaminate.py | 9 +++++--- .../core/_tree_objects/virtual_geometry.py | 9 +++++--- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py index b16efd3eb7..50bac3c7e4 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/property_helper.py @@ -29,7 +29,7 @@ from collections.abc import Callable from functools import reduce -from typing import Any, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar from google.protobuf.message import Message @@ -51,14 +51,21 @@ _FROM_PROTOBUF_T = Callable[[_PROTOBUF_T], _GET_T] -class _exposed_grpc_property(property): - """Mark a property as exposed via gRPC. +if TYPE_CHECKING: # pragma: no cover + # This is needed because mypy does not understand custom property + # subclasses. + # See https://github.com/python/mypy/issues/6158 + _exposed_grpc_property = property +else: - Wrapper around 'property', used to signal that the object should - be collected into the '_GRPC_PROPERTIES' class attribute. - """ + class _exposed_grpc_property(property): + """Mark a property as exposed via gRPC. + + Wrapper around 'property', used to signal that the object should + be collected into the '_GRPC_PROPERTIES' class attribute. + """ - pass + pass T = TypeVar("T", bound=type[GrpcObjectBase]) diff --git a/src/ansys/acp/core/_tree_objects/linked_selection_rule.py b/src/ansys/acp/core/_tree_objects/linked_selection_rule.py index a751c2b1b1..6dd88e71a4 100644 --- a/src/ansys/acp/core/_tree_objects/linked_selection_rule.py +++ b/src/ansys/acp/core/_tree_objects/linked_selection_rule.py @@ -270,7 +270,7 @@ def __repr__(self) -> str: ) def clone(self) -> LinkedSelectionRule: - """Create a clone of the current LinkedSelectionRule object.""" + """Create a new unstored LinkedSelectionRule with the same properties.""" return LinkedSelectionRule( selection_rule=self.selection_rule, operation_type=self.operation_type, diff --git a/src/ansys/acp/core/_tree_objects/modeling_ply.py b/src/ansys/acp/core/_tree_objects/modeling_ply.py index aacd692785..5b2b201d69 100644 --- a/src/ansys/acp/core/_tree_objects/modeling_ply.py +++ b/src/ansys/acp/core/_tree_objects/modeling_ply.py @@ -223,13 +223,15 @@ def __repr__(self) -> str: f"angle={self.angle!r}, offset={self.offset!r})" ) - def clone(self): - return TaperEdge( + def clone(self) -> Self: + """Create a new unstored TaperEdge with the same properties.""" + return type(self)( edge_set=self.edge_set, angle=self.angle, offset=self.offset, ) + @mark_grpc_properties @register class ModelingPly(CreatableTreeObject, IdTreeObject): diff --git a/src/ansys/acp/core/_tree_objects/stackup.py b/src/ansys/acp/core/_tree_objects/stackup.py index 2235c248fb..f12ff5d762 100644 --- a/src/ansys/acp/core/_tree_objects/stackup.py +++ b/src/ansys/acp/core/_tree_objects/stackup.py @@ -25,6 +25,8 @@ from collections.abc import Callable, Iterable, Sequence from typing import Any +from typing_extensions import Self + from ansys.api.acp.v0 import stackup_pb2, stackup_pb2_grpc from .._utils.property_protocols import ReadOnlyProperty, ReadWriteProperty @@ -141,10 +143,10 @@ def __eq__(self, other: Any) -> bool: def __repr__(self) -> str: return f"FabricWithAngle(fabric={self.fabric.__repr__()}, angle={self.angle})" + def clone(self) -> Self: + """Create a new unstored FabricWithAngle with the same properties.""" + return type(self)(fabric=self.fabric, angle=self.angle) - def clone(self) -> FabricWithAngle: - """Create a copy of the object.""" - return FabricWithAngle(fabric=self.fabric, angle=self.angle) @mark_grpc_properties @register diff --git a/src/ansys/acp/core/_tree_objects/sublaminate.py b/src/ansys/acp/core/_tree_objects/sublaminate.py index 2c3dbeb8ea..f3e7f03ad1 100644 --- a/src/ansys/acp/core/_tree_objects/sublaminate.py +++ b/src/ansys/acp/core/_tree_objects/sublaminate.py @@ -26,6 +26,8 @@ import typing from typing import Any, Union, get_args +from typing_extensions import Self + from ansys.api.acp.v0 import sublaminate_pb2, sublaminate_pb2_grpc from .._utils.property_protocols import ReadOnlyProperty, ReadWriteProperty @@ -145,9 +147,10 @@ def __eq__(self, other: Any) -> bool: def __repr__(self) -> str: return f"Lamina(material={self.material.__repr__()}, angle={self.angle})" - def clone(self) -> Lamina: - """Create a clone of the current Lamina object.""" - return Lamina(material=self.material, angle=self.angle) + def clone(self) -> Self: + """Create a new unstored Lamina with the same properties.""" + return type(self)(material=self.material, angle=self.angle) + @mark_grpc_properties @register diff --git a/src/ansys/acp/core/_tree_objects/virtual_geometry.py b/src/ansys/acp/core/_tree_objects/virtual_geometry.py index 23c4d20858..0af3696054 100644 --- a/src/ansys/acp/core/_tree_objects/virtual_geometry.py +++ b/src/ansys/acp/core/_tree_objects/virtual_geometry.py @@ -26,6 +26,8 @@ import typing from typing import Any +from typing_extensions import Self + from ansys.api.acp.v0 import base_pb2, virtual_geometry_pb2, virtual_geometry_pb2_grpc from ._grpc_helpers.edge_property_list import ( @@ -120,9 +122,10 @@ def __eq__(self, other: Any) -> bool: def __repr__(self) -> str: return f"SubShape(cad_geometry={self._cad_geometry.__repr__()}, path={self._path})" - def clone(self) -> SubShape: - """Create a clone of the SubShape.""" - return SubShape(cad_geometry=self.cad_geometry, path=self.path) + def clone(self) -> Self: + """Create a new unstored SubShape with the same properties.""" + return type(self)(cad_geometry=self.cad_geometry, path=self.path) + @mark_grpc_properties @register From 692c49e1861c20ee0d8e18deb4dec3eaeda60036 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Fri, 23 Aug 2024 09:30:01 +0200 Subject: [PATCH 05/23] Fix LUT copying in recursive copy --- src/ansys/acp/core/_recursive_copy.py | 15 +++++++++++++ tests/unittests/test_recursive_copy.py | 30 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index 8958ee7a52..8f8bfc4ebc 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -25,6 +25,7 @@ import networkx as nx +from ._tree_objects import LookUpTable1D, LookUpTable1DColumn, LookUpTable3D, LookUpTable3DColumn from ._tree_objects._traversal import ( all_linked_objects, child_objects, @@ -187,6 +188,11 @@ def recursive_copy( continue tree_object = visited_objects[node] + if isinstance(tree_object, (LookUpTable1DColumn, LookUpTable3DColumn)): + # handled explicitly while copying the LookUpTable object + if tree_object.name == "Location": + continue + new_tree_object = tree_object.clone() # If the linked objects are also copied, replace them with the new objects. @@ -213,8 +219,17 @@ def recursive_copy( raise KeyError( f"Parent object not found in 'parent_mapping' for object '{tree_object!r}'." ) from exc + new_tree_object.store(parent=new_parent) + # NOTE: if there are more type-specific fixes needed, we may want + # to implement a more generic way to handle these. + # Explicit fix for LookUpTable, since the Location column needs to + # be set correctly s.t. other columns may be stored. + if isinstance(new_tree_object, (LookUpTable1D, LookUpTable3D)): + assert isinstance(tree_object, (LookUpTable1D, LookUpTable3D)) + new_tree_object.columns["Location"].data = tree_object.columns["Location"].data + if include_linked_objects: for attr_name, edge_property_list in edge_property_lists(tree_object): new_edge_property_list = [edge.clone() for edge in edge_property_list] diff --git a/tests/unittests/test_recursive_copy.py b/tests/unittests/test_recursive_copy.py index 889311f69a..de60bb3915 100644 --- a/tests/unittests/test_recursive_copy.py +++ b/tests/unittests/test_recursive_copy.py @@ -20,6 +20,7 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import numpy as np import pytest from ansys.acp.core import FabricWithAngle, recursive_copy @@ -197,3 +198,32 @@ def test_missing_parent_object_raises(minimal_complete_model): msg = str(exc_info.value) assert "Parent object" in msg assert "parent_mapping" in msg + + +def test_copy_lookup_table_with_columns(minimal_complete_model): + """Test copying lookup tables with columns and their data. + + This case is special because LUT implement a check for the shape + of the data in their columns, which is determined by the "Location" + column. + """ + # GIVEN: a model which has objects with sub-attributes + # (here: a lookup table with columns) + model = minimal_complete_model + lut = model.create_lookup_table_1d() + lut.columns["Location"].data = [1.0, 2.0, 3.0] + lut.create_column(name="column1", data=[4.0, 5.0, 6.0]) + + # WHEN: recursively copying the lookup table + new_objects = recursive_copy(source_objects=[lut], parent_mapping=[(model, model)]) + + # THEN: the expected new objects are created, but the sub-attributes are + # not explicitly copied + assert len(new_objects) == 2 + assert {obj.id for obj in new_objects} == { # type: ignore + "LookUpTable1D.2", + "column1", + } + new_lut = model.lookup_tables_1d["LookUpTable1D.2"] + assert np.allclose(new_lut.columns["Location"].data, [1.0, 2.0, 3.0]) + assert np.allclose(new_lut.columns["column1"].data, [4.0, 5.0, 6.0]) From 435df2addd61b1d98a5b673145e035b229fd10f6 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Fri, 23 Aug 2024 17:26:24 +0200 Subject: [PATCH 06/23] Fix allowed values in reference_direction_field, small bugfix in .store() --- src/ansys/acp/core/_tree_objects/oriented_selection_set.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ansys/acp/core/_tree_objects/oriented_selection_set.py b/src/ansys/acp/core/_tree_objects/oriented_selection_set.py index f3b52fbcf6..613e60c355 100644 --- a/src/ansys/acp/core/_tree_objects/oriented_selection_set.py +++ b/src/ansys/acp/core/_tree_objects/oriented_selection_set.py @@ -60,6 +60,7 @@ rosette_selection_method_to_pb, status_type_from_pb, ) +from .lookup_table_1d_column import LookUpTable1DColumn from .lookup_table_3d_column import LookUpTable3DColumn from .object_registry import register from .parallel_selection_rule import ParallelSelectionRule @@ -179,7 +180,7 @@ def __init__( draping_material_model: DrapingMaterialType = DrapingMaterialType.WOVEN, draping_ud_coefficient: float = 0.0, rotation_angle: float = 0.0, - reference_direction_field: LookUpTable3DColumn | None = None, + reference_direction_field: LookUpTable1DColumn | LookUpTable3DColumn | None = None, ): super().__init__(name=name) self.element_sets = element_sets @@ -270,7 +271,8 @@ def _create_stub(self) -> oriented_selection_set_pb2_grpc.ObjectServiceStub: ) reference_direction_field = grpc_link_property( - "properties.reference_direction_field", allowed_types=LookUpTable3DColumn + "properties.reference_direction_field", + allowed_types=(LookUpTable3DColumn, LookUpTable1DColumn), ) elemental_data = elemental_data_property(OrientedSelectionSetElementalData) From 82f0ed9fe5e5fe6d9cfd9c6ddc6dddd082be0eab Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 10:03:29 +0200 Subject: [PATCH 07/23] Use linked_object_helpers to replace links --- src/ansys/acp/core/_recursive_copy.py | 44 ++++++------------- .../_grpc_helpers/linked_object_helpers.py | 37 +++++++++++----- src/ansys/acp/core/_tree_objects/base.py | 4 +- 3 files changed, 40 insertions(+), 45 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index 8f8bfc4ebc..26dcb0294f 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -26,14 +26,8 @@ import networkx as nx from ._tree_objects import LookUpTable1D, LookUpTable1DColumn, LookUpTable3D, LookUpTable3DColumn -from ._tree_objects._traversal import ( - all_linked_objects, - child_objects, - directly_linked_objects, - edge_property_lists, - edge_property_targets, - linked_object_lists, -) +from ._tree_objects._grpc_helpers.linked_object_helpers import get_linked_paths +from ._tree_objects._traversal import all_linked_objects, child_objects from ._tree_objects.base import CreatableTreeObject, TreeObject __all__ = ["recursive_copy"] @@ -175,6 +169,9 @@ def recursive_copy( options = _WalkTreeOptions( include_children=include_children, include_linked_objects=include_linked_objects ) + # Build up a graph of the objects to clone. Graph edges represent a dependency: + # - from child to parent node + # - from source to target of a link graph, visited_objects = _build_dependency_graph(source_objects=source_objects, options=options) replacement_mapping = { @@ -182,6 +179,8 @@ def recursive_copy( } new_objects: list[CreatableTreeObject] = [] + # The 'topological_sort' of the graph ensures that each node is only handled + # once its parent and linked objects are stored. for node in reversed(list(nx.topological_sort(graph))): if node in replacement_mapping: # Skip nodes which are already copied (e.g. coming from the parent_mapping) @@ -198,19 +197,12 @@ def recursive_copy( # If the linked objects are also copied, replace them with the new objects. # Otherwise, we can directly store the new object. if include_linked_objects: - for attr_name, linked_object in directly_linked_objects(tree_object): - new_linked_object = replacement_mapping[linked_object._resource_path.value] - setattr(new_tree_object, attr_name, new_linked_object) - for attr_name, linked_object_list in linked_object_lists(tree_object): - new_linked_objects = [ - replacement_mapping[linked_object._resource_path.value] - for linked_object in linked_object_list - ] - setattr(new_tree_object, attr_name, new_linked_objects) - - # clear edge property lists, then re-create them once the new object is stored - for attr_name, _ in edge_property_lists(tree_object): - setattr(new_tree_object, attr_name, []) + for linked_resource_path in get_linked_paths(new_tree_object._pb_object.properties): + # TODO: handle case when linked objects are not (yet) supported by PyACP or + # the server, but are included in the API. + linked_resource_path.value = replacement_mapping[ + linked_resource_path.value + ]._resource_path.value parent_rp = tree_object._resource_path.value.rsplit("/", 2)[0] try: @@ -230,16 +222,6 @@ def recursive_copy( assert isinstance(tree_object, (LookUpTable1D, LookUpTable3D)) new_tree_object.columns["Location"].data = tree_object.columns["Location"].data - if include_linked_objects: - for attr_name, edge_property_list in edge_property_lists(tree_object): - new_edge_property_list = [edge.clone() for edge in edge_property_list] - for edge, edge_prop_name, edge_target in edge_property_targets( - new_edge_property_list - ): - new_edge_target = replacement_mapping[edge_target._resource_path.value] - setattr(edge, edge_prop_name, new_edge_target) - setattr(new_tree_object, attr_name, new_edge_property_list) - replacement_mapping[node] = new_tree_object new_objects.append(new_tree_object) diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_helpers.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_helpers.py index fd8b5d86d1..1110ece089 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_helpers.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/linked_object_helpers.py @@ -25,28 +25,41 @@ from google.protobuf.descriptor import FieldDescriptor from google.protobuf.message import Message -from ansys.api.acp.v0.base_pb2 import CollectionPath, ResourcePath +from ansys.api.acp.v0.base_pb2 import ResourcePath -__all__ = ("unlink_objects", "linked_path_fields") +__all__ = ("unlink_objects", "get_linked_paths") def unlink_objects(pb_object: Message) -> None: """Remove all ResourcePaths and CollectionPaths from a protobuf object.""" - for parent_message, field_descriptor, _ in linked_path_fields(pb_object): + for parent_message, field_descriptor, _ in _linked_path_fields(pb_object): parent_message.ClearField(field_descriptor.name) -def linked_path_fields( +def get_linked_paths(pb_object: Message) -> Iterable[ResourcePath]: + """Get all resource paths present in a protobuf object.""" + for _, field_descriptor, field_value in _linked_path_fields(pb_object): + if field_descriptor.label == field_descriptor.LABEL_REPEATED: + yield from field_value # type: ignore + else: + yield field_value # type: ignore + + +def _linked_path_fields( pb_object: Message, -) -> Iterable[tuple[Message, FieldDescriptor, ResourcePath | CollectionPath]]: - """Get all linked paths from a protobuf object. +) -> Iterable[tuple[Message, FieldDescriptor, Message]]: + """Get the field field information for resource paths in the message. - Get tuples (parent_message, field_descriptor, {resource_path or collection_path}) - describing all resource or collection paths present in the protobuf - object. + Get tuples (parent_message, field_descriptor, field_value) describing + all resource paths present in the protobuf object. Note that the fields + can also be repeated (containing multiple resource paths). """ for field_descriptor, field_value in pb_object.ListFields(): - if isinstance(field_value, (ResourcePath, CollectionPath)): + if getattr(field_descriptor.message_type, "name", None) == "ResourcePath": yield (pb_object, field_descriptor, field_value) - elif isinstance(field_value, Message): - yield from linked_path_fields(field_value) + elif field_descriptor.type == field_descriptor.TYPE_MESSAGE: + if field_descriptor.label == field_descriptor.LABEL_REPEATED: + for sub_obj in field_value: + yield from _linked_path_fields(sub_obj) + else: + yield from _linked_path_fields(field_value) diff --git a/src/ansys/acp/core/_tree_objects/base.py b/src/ansys/acp/core/_tree_objects/base.py index 070c598857..31d3bc7296 100644 --- a/src/ansys/acp/core/_tree_objects/base.py +++ b/src/ansys/acp/core/_tree_objects/base.py @@ -42,7 +42,7 @@ from .._utils.resource_paths import join as _rp_join from .._utils.resource_paths import to_parts from ._grpc_helpers.exceptions import wrap_grpc_errors -from ._grpc_helpers.linked_object_helpers import linked_path_fields, unlink_objects +from ._grpc_helpers.linked_object_helpers import get_linked_paths, unlink_objects from ._grpc_helpers.polymorphic_from_pb import CreatableFromResourcePath from ._grpc_helpers.property_helper import ( _get_data_attribute, @@ -307,7 +307,7 @@ def store(self: Self, parent: TreeObject) -> None: # check that all linked objects are located in the same model path_values = [collection_path.value] + [ - path.value for _, _, path in linked_path_fields(self._pb_object.properties) + path.value for path in get_linked_paths(self._pb_object.properties) ] # filter out empty paths path_values = [path for path in path_values if path] From 080d33bc2bc2185b5e5b2be56aabda0613bc6ed7 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 10:10:02 +0200 Subject: [PATCH 08/23] Update src/ansys/acp/core/_recursive_copy.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: René Roos <105842014+roosre@users.noreply.github.com> --- src/ansys/acp/core/_recursive_copy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index 26dcb0294f..e9a0d0ff92 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -78,7 +78,7 @@ def _build_dependency_graph_impl( if options.include_linked_objects: for linked_object in all_linked_objects(tree_object): assert isinstance(linked_object, CreatableTreeObject) - graph.add_edge(tree_object._resource_path.value, linked_object._resource_path.value) + graph.add_edge(key, linked_object._resource_path.value) _build_dependency_graph_impl( tree_object=linked_object, graph=graph, From 22a2b0cddd537c7461b0eab936e68ad3c2c5d8a6 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 11:06:23 +0200 Subject: [PATCH 09/23] Implement KEEP / COPY / DISCARD link handling modes --- src/ansys/acp/core/_recursive_copy.py | 46 +++++++++++++------ src/ansys/acp/core/_server/common.py | 4 +- .../_grpc_helpers/enum_wrapper.py | 12 +++-- src/ansys/acp/core/_typing_helper.py | 36 +++++++++------ tests/unittests/test_recursive_copy.py | 44 +++++++++--------- 5 files changed, 87 insertions(+), 55 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index e9a0d0ff92..5284e4f5a9 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -29,8 +29,9 @@ from ._tree_objects._grpc_helpers.linked_object_helpers import get_linked_paths from ._tree_objects._traversal import all_linked_objects, child_objects from ._tree_objects.base import CreatableTreeObject, TreeObject +from ._typing_helper import StrEnum -__all__ = ["recursive_copy"] +__all__ = ["recursive_copy", "LinkedObjectHandling"] @dataclass @@ -87,19 +88,25 @@ def _build_dependency_graph_impl( ) +class LinkedObjectHandling(StrEnum): + """Defines options for handling linked objects when copying a tree of ACP objects.""" + + COPY = "copy" + KEEP = "keep" + DISCARD = "discard" + + def recursive_copy( *, source_objects: Iterable[CreatableTreeObject], parent_mapping: Iterable[tuple[TreeObject, TreeObject]], - include_children: bool = True, - include_linked_objects: bool = True, + linked_object_handling: LinkedObjectHandling | str = "copy", ) -> list[CreatableTreeObject]: """Recursively copy a tree of ACP objects. This function copies a tree of ACP objects, starting from the given source objects. - You can specify whether to include children (for example the Modeling Plies in a - Modeling Group) and linked objects (for example the Rosettes linked to an Oriented - Selection Set) in the copy. + The copied tree includes all child objects. Linked objects can optionally be included, + controlled by the ``linked_object_handling`` argument. To specify where the new objects should be stored, you must provide a list of tuples in the ``parent_mapping`` argument. Each tuple contains the original parent object @@ -125,10 +132,18 @@ def recursive_copy( A list of tuples defining where the new objects are stored. Each tuple contains the original parent object as the first element and the new parent object as the second element. - include_children : - Whether to include child objects when creating the tree to copy. - include_linked_objects : - Whether to include linked objects when creating the tree to copy. + linked_object_handling : + Defines how linked objects are handled. The following options are available: + + - ``"copy"``: Copy the linked objects, and replace the links. + - ``"keep"``: Keep linking to the original objects, and do not + copy them (unless they are otherwise included in the tree). + - ``"discard"``: Discard object links. + + Note that when copying objects between two models, only the ``"copy"`` and + ``"discard"`` options are valid. If you wish to use links to existing objects, + the ``"copy"`` option can be used, specifying how links should be replaced in + the ``parent_mapping`` argument. Returns ------- @@ -166,8 +181,11 @@ def recursive_copy( parent_mapping=[(model1, model2)], ) """ + linked_object_handling = LinkedObjectHandling(linked_object_handling) + options = _WalkTreeOptions( - include_children=include_children, include_linked_objects=include_linked_objects + include_children=True, + include_linked_objects=linked_object_handling == LinkedObjectHandling.COPY, ) # Build up a graph of the objects to clone. Graph edges represent a dependency: # - from child to parent node @@ -192,11 +210,13 @@ def recursive_copy( if tree_object.name == "Location": continue - new_tree_object = tree_object.clone() + new_tree_object = tree_object.clone( + unlink=linked_object_handling == LinkedObjectHandling.DISCARD + ) # If the linked objects are also copied, replace them with the new objects. # Otherwise, we can directly store the new object. - if include_linked_objects: + if linked_object_handling == LinkedObjectHandling.COPY: for linked_resource_path in get_linked_paths(new_tree_object._pb_object.properties): # TODO: handle case when linked objects are not (yet) supported by PyACP or # the server, but are included in the API. diff --git a/src/ansys/acp/core/_server/common.py b/src/ansys/acp/core/_server/common.py index a8e93615c0..d4e177b6cf 100644 --- a/src/ansys/acp/core/_server/common.py +++ b/src/ansys/acp/core/_server/common.py @@ -31,12 +31,12 @@ __all__ = ["LaunchMode"] -class ServerKey(StrEnum): # type: ignore +class ServerKey(StrEnum): MAIN = "main" FILE_TRANSFER = "file_transfer" -class LaunchMode(StrEnum): # type: ignore +class LaunchMode(StrEnum): """Available launch modes for ACP.""" DIRECT = "direct" diff --git a/src/ansys/acp/core/_tree_objects/_grpc_helpers/enum_wrapper.py b/src/ansys/acp/core/_tree_objects/_grpc_helpers/enum_wrapper.py index eeb9d6ea86..3884d7f80b 100644 --- a/src/ansys/acp/core/_tree_objects/_grpc_helpers/enum_wrapper.py +++ b/src/ansys/acp/core/_tree_objects/_grpc_helpers/enum_wrapper.py @@ -27,9 +27,11 @@ from ansys.acp.core._typing_helper import StrEnum - # mypy doesn't understand this dynamically created Enum, so we have to # fall back to 'Any'. +_StrEnumT = Any + + def wrap_to_string_enum( class_name: str, proto_enum: Any, @@ -39,7 +41,7 @@ def wrap_to_string_enum( value_converter: Callable[[str], str] = lambda val: val.lower(), doc: str, explicit_value_list: tuple[int, ...] | None = None, -) -> tuple[StrEnum, Callable[[StrEnum], int], Callable[[int], StrEnum]]: +) -> tuple[_StrEnumT, Callable[[_StrEnumT], int], Callable[[int], _StrEnumT]]: """Create a string Enum with the same keys as the given protobuf Enum. Values of the enum are the keys, converted to lowercase. @@ -65,13 +67,13 @@ def wrap_to_string_enum( to_pb_conversion_dict[enum_value] = pb_value from_pb_conversion_dict[pb_value] = enum_value - res_enum = StrEnum(class_name, fields, module=module) + res_enum: _StrEnumT = StrEnum(class_name, fields, module=module) # type: ignore res_enum.__doc__ = doc - def to_pb_conversion_func(val: StrEnum) -> int: + def to_pb_conversion_func(val: _StrEnumT) -> int: return to_pb_conversion_dict[val] - def from_pb_conversion_func(val: int) -> StrEnum: + def from_pb_conversion_func(val: int) -> _StrEnumT: return res_enum(from_pb_conversion_dict[val]) return ( diff --git a/src/ansys/acp/core/_typing_helper.py b/src/ansys/acp/core/_typing_helper.py index 10f301aa63..385be37b01 100644 --- a/src/ansys/acp/core/_typing_helper.py +++ b/src/ansys/acp/core/_typing_helper.py @@ -21,24 +21,34 @@ # SOFTWARE. """Helpers for defining type annotations.""" +import enum import os -from typing import Union +from typing import TYPE_CHECKING, Union __all__ = ["PATH", "StrEnum"] PATH = Union[str, os.PathLike[str]] -try: - from enum import StrEnum # type: ignore -except ImportError: - # For Python 3.10 and below, emulate the behavior of StrEnum by - # inheriting from str and enum.Enum. - # Note that this does *not* work on Python 3.11+, since the default - # Enum format method has changed and will not return the value of - # the enum member. - import enum - - class StrEnum(str, enum.Enum): # type: ignore +# For Python 3.10 and below, emulate the behavior of StrEnum by +# inheriting from str and enum.Enum. +# Note that this does *not* work on Python 3.11+, since the default +# Enum format method has changed and will not return the value of +# the enum member. +# When type checking, always use the Python 3.10 workaround, otherwise +# the StrEnum resolves as 'Any'. +if TYPE_CHECKING: + + class StrEnum(str, enum.Enum): """String enum.""" - pass +else: + try: + from enum import StrEnum + except ImportError: + + import enum + + class StrEnum(str, enum.Enum): + """String enum.""" + + pass diff --git a/tests/unittests/test_recursive_copy.py b/tests/unittests/test_recursive_copy.py index de60bb3915..75f56fc185 100644 --- a/tests/unittests/test_recursive_copy.py +++ b/tests/unittests/test_recursive_copy.py @@ -56,6 +56,25 @@ def test_basic_recursive_copy(minimal_complete_model): } +def test_basic_recursive_copy_keep_links(minimal_complete_model): + """Test copying a Modeling Ply without linked objects.""" + # GIVEN: A Modeling Ply with linked objects + mg = minimal_complete_model.modeling_groups["ModelingGroup.1"] + mp = mg.modeling_plies["ModelingPly.1"] + + # WHEN: Recursively copying the Modeling Ply onto the same Modeling Group, without linked objects + new_objects = recursive_copy( + source_objects=[mp], parent_mapping=[(mg, mg)], linked_object_handling="keep" + ) + + # THEN: The expected new objects are created, with the links kept + assert len(new_objects) == 1 + assert {obj.id for obj in new_objects} == { # type: ignore + "ModelingPly.2", + } + assert new_objects[0].ply_material.id == "Fabric.1" # type: ignore + + def test_basic_recursive_copy_no_links(minimal_complete_model): """Test copying a Modeling Ply without linked objects.""" # GIVEN: A Modeling Ply with linked objects @@ -64,14 +83,15 @@ def test_basic_recursive_copy_no_links(minimal_complete_model): # WHEN: Recursively copying the Modeling Ply onto the same Modeling Group, without linked objects new_objects = recursive_copy( - source_objects=[mp], parent_mapping=[(mg, mg)], include_linked_objects=False + source_objects=[mp], parent_mapping=[(mg, mg)], linked_object_handling="discard" ) - # THEN: The expected new objects are created + # THEN: The expected new objects are created, without links assert len(new_objects) == 1 assert {obj.id for obj in new_objects} == { # type: ignore "ModelingPly.2", } + assert new_objects[0].ply_material is None # type: ignore def test_copy_all_objects(minimal_complete_model): @@ -123,26 +143,6 @@ def test_copy_to_different_model(minimal_complete_model, load_model_from_tempfil } -def test_children_not_included(minimal_complete_model): - """Test copying without including the child objects.""" - # GIVEN: A simple model - model = minimal_complete_model - - # WHEN: Recursively copying, starting from the a Modeling Group, but without including its - # children - new_objects = recursive_copy( - source_objects=[model.modeling_groups["ModelingGroup.1"]], - parent_mapping=[(model, model)], - include_children=False, - ) - - # THEN: Only the Modeling Group is copied - assert len(new_objects) == 1 - assert {obj.id for obj in new_objects} == { # type: ignore - "ModelingGroup.2", - } - - def test_copy_edge_property_list(minimal_complete_model): """Test copying an object which has an Edge Property List.""" # GIVEN: A simple model with a Stackup From 7113b81a5e1be6641738f68450b0437d0d16e644 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 11:50:00 +0200 Subject: [PATCH 10/23] Make tree objects hashable, use them in dict --- src/ansys/acp/core/_recursive_copy.py | 53 +++++++++++++---------- src/ansys/acp/core/_tree_objects/base.py | 25 ++++++++++- src/ansys/acp/core/_tree_objects/model.py | 2 + 3 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index 5284e4f5a9..fe89295022 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -42,34 +42,38 @@ class _WalkTreeOptions: def _build_dependency_graph( *, source_objects: Iterable[CreatableTreeObject], options: _WalkTreeOptions -) -> tuple[nx.DiGraph, dict[str, CreatableTreeObject]]: +) -> nx.DiGraph: graph = nx.DiGraph() - visited_objects: dict[str, CreatableTreeObject] = dict() + + # We need to manually keep track of which objects have been visited, + # since the node may also be created when being linked to. + visited_objects: set[CreatableTreeObject] = set() for tree_object in source_objects: _build_dependency_graph_impl( tree_object=tree_object, graph=graph, visited_objects=visited_objects, options=options ) - return graph, visited_objects + return graph def _build_dependency_graph_impl( *, tree_object: CreatableTreeObject, graph: nx.DiGraph, - visited_objects: dict[str, CreatableTreeObject], + visited_objects: set[CreatableTreeObject], options: _WalkTreeOptions, ) -> None: - key = tree_object._resource_path.value - if key in visited_objects: + + if tree_object in visited_objects: return - visited_objects[key] = tree_object - graph.add_node(key) + + visited_objects.add(tree_object) + graph.add_node(tree_object) if options.include_children: for child_object in child_objects(tree_object): if not isinstance(child_object, CreatableTreeObject): continue - graph.add_edge(child_object._resource_path.value, key) + graph.add_edge(child_object, tree_object) _build_dependency_graph_impl( tree_object=child_object, graph=graph, @@ -79,7 +83,7 @@ def _build_dependency_graph_impl( if options.include_linked_objects: for linked_object in all_linked_objects(tree_object): assert isinstance(linked_object, CreatableTreeObject) - graph.add_edge(key, linked_object._resource_path.value) + graph.add_edge(tree_object, linked_object) _build_dependency_graph_impl( tree_object=linked_object, graph=graph, @@ -99,7 +103,7 @@ class LinkedObjectHandling(StrEnum): def recursive_copy( *, source_objects: Iterable[CreatableTreeObject], - parent_mapping: Iterable[tuple[TreeObject, TreeObject]], + parent_mapping: dict[TreeObject, TreeObject], linked_object_handling: LinkedObjectHandling | str = "copy", ) -> list[CreatableTreeObject]: """Recursively copy a tree of ACP objects. @@ -190,20 +194,23 @@ def recursive_copy( # Build up a graph of the objects to clone. Graph edges represent a dependency: # - from child to parent node # - from source to target of a link - graph, visited_objects = _build_dependency_graph(source_objects=source_objects, options=options) + graph = _build_dependency_graph(source_objects=source_objects, options=options) - replacement_mapping = { - parent._resource_path.value: new_parent for parent, new_parent in parent_mapping + replacement_mapping = dict(parent_mapping) + # keep track of the new resource paths for easy replacement of linked objects + resource_path_replacement_mapping = { + obj._resource_path.value: new_obj._resource_path.value + for obj, new_obj in parent_mapping.items() } new_objects: list[CreatableTreeObject] = [] # The 'topological_sort' of the graph ensures that each node is only handled # once its parent and linked objects are stored. - for node in reversed(list(nx.topological_sort(graph))): - if node in replacement_mapping: + for tree_object in reversed(list(nx.topological_sort(graph))): + if tree_object in replacement_mapping: # Skip nodes which are already copied (e.g. coming from the parent_mapping) continue - tree_object = visited_objects[node] + # tree_object = visited_objects[node] if isinstance(tree_object, (LookUpTable1DColumn, LookUpTable3DColumn)): # handled explicitly while copying the LookUpTable object @@ -220,13 +227,12 @@ def recursive_copy( for linked_resource_path in get_linked_paths(new_tree_object._pb_object.properties): # TODO: handle case when linked objects are not (yet) supported by PyACP or # the server, but are included in the API. - linked_resource_path.value = replacement_mapping[ + linked_resource_path.value = resource_path_replacement_mapping[ linked_resource_path.value - ]._resource_path.value + ] - parent_rp = tree_object._resource_path.value.rsplit("/", 2)[0] try: - new_parent = replacement_mapping[parent_rp] + new_parent = replacement_mapping[tree_object.parent] except KeyError as exc: raise KeyError( f"Parent object not found in 'parent_mapping' for object '{tree_object!r}'." @@ -242,7 +248,10 @@ def recursive_copy( assert isinstance(tree_object, (LookUpTable1D, LookUpTable3D)) new_tree_object.columns["Location"].data = tree_object.columns["Location"].data - replacement_mapping[node] = new_tree_object + replacement_mapping[tree_object] = new_tree_object + resource_path_replacement_mapping[tree_object._resource_path.value] = ( + new_tree_object._resource_path.value + ) new_objects.append(new_tree_object) return new_objects diff --git a/src/ansys/acp/core/_tree_objects/base.py b/src/ansys/acp/core/_tree_objects/base.py index 31d3bc7296..758f993719 100644 --- a/src/ansys/acp/core/_tree_objects/base.py +++ b/src/ansys/acp/core/_tree_objects/base.py @@ -43,7 +43,10 @@ from .._utils.resource_paths import to_parts from ._grpc_helpers.exceptions import wrap_grpc_errors from ._grpc_helpers.linked_object_helpers import get_linked_paths, unlink_objects -from ._grpc_helpers.polymorphic_from_pb import CreatableFromResourcePath +from ._grpc_helpers.polymorphic_from_pb import ( + CreatableFromResourcePath, + tree_object_from_resource_path, +) from ._grpc_helpers.property_helper import ( _get_data_attribute, grpc_data_property, @@ -100,6 +103,9 @@ def __eq__(self: Self, other: Any) -> bool: return self is other return self._resource_path.value == other._resource_path.value + def __hash__(self) -> int: + return id(self) + @classmethod @constructor_with_cache( key_getter=lambda object_info, *args, **kwargs: object_info.info.resource_path.value, @@ -145,6 +151,23 @@ def _server_wrapper(self) -> ServerWrapper: def _is_stored(self) -> bool: return self._server_wrapper_store is not None + @property + def parent(self) -> CreatableFromResourcePath: + """The parent of the object.""" + if not self._is_stored: + raise RuntimeError("Cannot get the parent of an unstored object.") + rp_parts = to_parts(self._resource_path.value) + if len(rp_parts) < 3: + raise RuntimeError("The object does not have a parent.") + + parent_path = _rp_join(*rp_parts[:-2]) + parent = tree_object_from_resource_path( + ResourcePath(value=parent_path), server_wrapper=self._server_wrapper + ) + if parent is None: + raise RuntimeError("The parent object could not be found.") + return parent + def __repr__(self) -> str: return f"<{type(self).__name__} with name '{self.name}'>" diff --git a/src/ansys/acp/core/_tree_objects/model.py b/src/ansys/acp/core/_tree_objects/model.py index 421b58cd62..b4fec9c781 100644 --- a/src/ansys/acp/core/_tree_objects/model.py +++ b/src/ansys/acp/core/_tree_objects/model.py @@ -112,6 +112,7 @@ from .material import Material from .modeling_group import ModelingGroup from .modeling_ply import ModelingPly +from .object_registry import register from .oriented_selection_set import OrientedSelectionSet from .parallel_selection_rule import ParallelSelectionRule from .rosette import Rosette @@ -197,6 +198,7 @@ class ModelNodalData(NodalData): @mark_grpc_properties +@register class Model(TreeObject): """Defines an ACP Model. From 74c00ae331f1bfb92705caed81335d566690c98b Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 11:55:36 +0200 Subject: [PATCH 11/23] Switch to returning an old -> new mapping --- src/ansys/acp/core/_recursive_copy.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index fe89295022..dcb6852511 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -22,6 +22,7 @@ from collections.abc import Iterable from dataclasses import dataclass +from typing import cast import networkx as nx @@ -105,7 +106,7 @@ def recursive_copy( source_objects: Iterable[CreatableTreeObject], parent_mapping: dict[TreeObject, TreeObject], linked_object_handling: LinkedObjectHandling | str = "copy", -) -> list[CreatableTreeObject]: +) -> dict[CreatableTreeObject, CreatableTreeObject]: """Recursively copy a tree of ACP objects. This function copies a tree of ACP objects, starting from the given source objects. @@ -152,7 +153,8 @@ def recursive_copy( Returns ------- : - A list of newly created objects. + A mapping of the newly created objects. The keys are the original objects, + and the values are the new objects. Examples -------- @@ -202,7 +204,6 @@ def recursive_copy( obj._resource_path.value: new_obj._resource_path.value for obj, new_obj in parent_mapping.items() } - new_objects: list[CreatableTreeObject] = [] # The 'topological_sort' of the graph ensures that each node is only handled # once its parent and linked objects are stored. @@ -210,7 +211,6 @@ def recursive_copy( if tree_object in replacement_mapping: # Skip nodes which are already copied (e.g. coming from the parent_mapping) continue - # tree_object = visited_objects[node] if isinstance(tree_object, (LookUpTable1DColumn, LookUpTable3DColumn)): # handled explicitly while copying the LookUpTable object @@ -252,6 +252,14 @@ def recursive_copy( resource_path_replacement_mapping[tree_object._resource_path.value] = ( new_tree_object._resource_path.value ) - new_objects.append(new_tree_object) - return new_objects + # Return a mapping of only the newly created objects + # (key: old object, value: new object). + # The type cast is necessary because the 'parent_mapping' could also + # include non-creatable objects, but the filter ensures that only + # creatable objects are returned. + return { + cast(CreatableTreeObject, old_obj): cast(CreatableTreeObject, new_obj) + for old_obj, new_obj in replacement_mapping.items() + if old_obj is not new_obj + } From 20d9f1264b42bb488530de28b707af6930568fdd Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 13:27:28 +0200 Subject: [PATCH 12/23] Fix tests --- src/ansys/acp/core/_recursive_copy.py | 31 ++++++++++------------- tests/unittests/test_recursive_copy.py | 34 +++++++++++++------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index dcb6852511..f18fb7cb54 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -20,9 +20,9 @@ # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +import collections from collections.abc import Iterable from dataclasses import dataclass -from typing import cast import networkx as nx @@ -113,9 +113,9 @@ def recursive_copy( The copied tree includes all child objects. Linked objects can optionally be included, controlled by the ``linked_object_handling`` argument. - To specify where the new objects should be stored, you must provide a list of tuples - in the ``parent_mapping`` argument. Each tuple contains the original parent object - as the first element and the new parent object as the second element. + To specify where the new objects should be stored, you must provide a dictionary + in the ``parent_mapping`` argument. The keys of the dictionary are the original + parent objects, and the values are the new parent objects. Note that this mapping may need to contain parent objects that are not direct parents of the source objects, if another branch of the tree is included via linked objects. @@ -170,7 +170,7 @@ def recursive_copy( pyacp.recursive_copy( source_objects=model1.modeling_groups.values(), - parent_mapping=[(model1, model2)], + parent_mapping={model1: model2}, ) To copy all definitions from one model to another, you can use the following code: @@ -184,7 +184,7 @@ def recursive_copy( pyacp.recursive_copy( source_objects=[model1], - parent_mapping=[(model1, model2)], + parent_mapping={model1: model2}, ) """ linked_object_handling = LinkedObjectHandling(linked_object_handling) @@ -198,7 +198,11 @@ def recursive_copy( # - from source to target of a link graph = _build_dependency_graph(source_objects=source_objects, options=options) - replacement_mapping = dict(parent_mapping) + new_object_mapping: dict[CreatableTreeObject, CreatableTreeObject] = {} + replacement_mapping = collections.ChainMap[TreeObject, TreeObject]( + new_object_mapping, parent_mapping # type: ignore + ) + # keep track of the new resource paths for easy replacement of linked objects resource_path_replacement_mapping = { obj._resource_path.value: new_obj._resource_path.value @@ -248,18 +252,9 @@ def recursive_copy( assert isinstance(tree_object, (LookUpTable1D, LookUpTable3D)) new_tree_object.columns["Location"].data = tree_object.columns["Location"].data - replacement_mapping[tree_object] = new_tree_object + new_object_mapping[tree_object] = new_tree_object resource_path_replacement_mapping[tree_object._resource_path.value] = ( new_tree_object._resource_path.value ) - # Return a mapping of only the newly created objects - # (key: old object, value: new object). - # The type cast is necessary because the 'parent_mapping' could also - # include non-creatable objects, but the filter ensures that only - # creatable objects are returned. - return { - cast(CreatableTreeObject, old_obj): cast(CreatableTreeObject, new_obj) - for old_obj, new_obj in replacement_mapping.items() - if old_obj is not new_obj - } + return new_object_mapping diff --git a/tests/unittests/test_recursive_copy.py b/tests/unittests/test_recursive_copy.py index 75f56fc185..83113f5207 100644 --- a/tests/unittests/test_recursive_copy.py +++ b/tests/unittests/test_recursive_copy.py @@ -41,12 +41,12 @@ def test_basic_recursive_copy(minimal_complete_model): # WHEN: Recursively copying the Modeling Ply onto the same Modeling Group new_objects = recursive_copy( source_objects=[mp], - parent_mapping=[(mg, mg), (minimal_complete_model, minimal_complete_model)], + parent_mapping={mg: mg, minimal_complete_model: minimal_complete_model}, ) # THEN: The expected new objects are created assert len(new_objects) == 6 - assert {obj.id for obj in new_objects} == { # type: ignore + assert {obj.id for obj in new_objects.values()} == { # type: ignore "Global Coordinate System.2", "All_Elements.2", "Structural Steel.2", @@ -64,15 +64,15 @@ def test_basic_recursive_copy_keep_links(minimal_complete_model): # WHEN: Recursively copying the Modeling Ply onto the same Modeling Group, without linked objects new_objects = recursive_copy( - source_objects=[mp], parent_mapping=[(mg, mg)], linked_object_handling="keep" + source_objects=[mp], parent_mapping={mg: mg}, linked_object_handling="keep" ) # THEN: The expected new objects are created, with the links kept assert len(new_objects) == 1 - assert {obj.id for obj in new_objects} == { # type: ignore + assert {obj.id for obj in new_objects.values()} == { # type: ignore "ModelingPly.2", } - assert new_objects[0].ply_material.id == "Fabric.1" # type: ignore + assert list(new_objects.values())[0].ply_material.id == "Fabric.1" # type: ignore def test_basic_recursive_copy_no_links(minimal_complete_model): @@ -83,15 +83,15 @@ def test_basic_recursive_copy_no_links(minimal_complete_model): # WHEN: Recursively copying the Modeling Ply onto the same Modeling Group, without linked objects new_objects = recursive_copy( - source_objects=[mp], parent_mapping=[(mg, mg)], linked_object_handling="discard" + source_objects=[mp], parent_mapping={mg: mg}, linked_object_handling="discard" ) # THEN: The expected new objects are created, without links assert len(new_objects) == 1 - assert {obj.id for obj in new_objects} == { # type: ignore + assert {obj.id for obj in new_objects.values()} == { # type: ignore "ModelingPly.2", } - assert new_objects[0].ply_material is None # type: ignore + assert list(new_objects.values())[0].ply_material is None # type: ignore def test_copy_all_objects(minimal_complete_model): @@ -100,13 +100,13 @@ def test_copy_all_objects(minimal_complete_model): model = minimal_complete_model # WHEN: Recursively copying, starting from the root of the model - new_objects = recursive_copy(source_objects=[model], parent_mapping=[(model, model)]) + new_objects = recursive_copy(source_objects=[model], parent_mapping={model: model}) # THEN: The expected new objects are created # NOTE: This list may need to be updated when the model reference file # is changed. assert len(new_objects) == 8 - assert {obj.id for obj in new_objects} == { # type: ignore + assert {obj.id for obj in new_objects.values()} == { # type: ignore "Global Coordinate System.2", "All_Elements.2", "ns_edge.2", @@ -125,13 +125,13 @@ def test_copy_to_different_model(minimal_complete_model, load_model_from_tempfil with load_model_from_tempfile() as model2: # WHEN: Recursively copying all objects from model1 to model2 - new_objects = recursive_copy(source_objects=[model1], parent_mapping=[(model1, model2)]) + new_objects = recursive_copy(source_objects=[model1], parent_mapping={model1: model2}) # THEN: The expected new objects are created # NOTE: This list may need to be updated when the model reference file # is changed. assert len(new_objects) == 8 - assert {obj.id for obj in new_objects} == { # type: ignore + assert {obj.id for obj in new_objects.values()} == { # type: ignore "Global Coordinate System.2", "All_Elements.2", "ns_edge.2", @@ -158,13 +158,13 @@ def test_copy_edge_property_list(minimal_complete_model): ) # WHEN: Recursively copying the Stackup - new_objects = recursive_copy(source_objects=[stackup], parent_mapping=[(model, model)]) + new_objects = recursive_copy(source_objects=[stackup], parent_mapping={model: model}) # THEN: # - The stackup, fabrics, and materials are copied # - The copied stackup links the new fabrics with the correct order and angles assert len(new_objects) == 4 - assert {obj.id for obj in new_objects} == { # type: ignore + assert {obj.id for obj in new_objects.values()} == { # type: ignore "Stackup.2", "Fabric.2", "other_fabric.2", @@ -192,7 +192,7 @@ def test_missing_parent_object_raises(minimal_complete_model): with pytest.raises(KeyError) as exc_info: recursive_copy( source_objects=[mp], - parent_mapping=[(mg, mg)], + parent_mapping={mg: mg}, ) msg = str(exc_info.value) @@ -215,12 +215,12 @@ def test_copy_lookup_table_with_columns(minimal_complete_model): lut.create_column(name="column1", data=[4.0, 5.0, 6.0]) # WHEN: recursively copying the lookup table - new_objects = recursive_copy(source_objects=[lut], parent_mapping=[(model, model)]) + new_objects = recursive_copy(source_objects=[lut], parent_mapping={model: model}) # THEN: the expected new objects are created, but the sub-attributes are # not explicitly copied assert len(new_objects) == 2 - assert {obj.id for obj in new_objects} == { # type: ignore + assert {obj.id for obj in new_objects.values()} == { # type: ignore "LookUpTable1D.2", "column1", } From c2ba9a1f5d72f9adaf0464460607142229170d78 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 14:33:53 +0200 Subject: [PATCH 13/23] Fix pre-commit hooks and doc build --- doc/source/api/enum_types.rst | 1 + src/ansys/acp/core/__init__.py | 3 ++- tests/benchmarks/conftest.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/source/api/enum_types.rst b/doc/source/api/enum_types.rst index e609b287cc..1ba5f80310 100644 --- a/doc/source/api/enum_types.rst +++ b/doc/source/api/enum_types.rst @@ -21,6 +21,7 @@ Enumeration data types FeFormat GeometricalRuleType IgnorableEntity + LinkedObjectHandling LookUpTable3DInterpolationAlgorithm LookUpTableColumnValueType NodalDataType diff --git a/src/ansys/acp/core/__init__.py b/src/ansys/acp/core/__init__.py index 6f47e0a1bf..c254523b1b 100644 --- a/src/ansys/acp/core/__init__.py +++ b/src/ansys/acp/core/__init__.py @@ -30,7 +30,7 @@ from . import example_helpers, material_property_sets from ._model_printer import get_model_tree, print_model from ._plotter import get_directions_plotter -from ._recursive_copy import recursive_copy +from ._recursive_copy import LinkedObjectHandling, recursive_copy from ._server import ( ACP, ConnectLaunchConfig, @@ -192,6 +192,7 @@ "Lamina", "launch_acp", "LaunchMode", + "LinkedObjectHandling", "LinkedSelectionRule", "LookUpTable1D", "LookUpTable1DColumn", diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py index 8bf8445546..fd7e7da0fb 100644 --- a/tests/benchmarks/conftest.py +++ b/tests/benchmarks/conftest.py @@ -129,7 +129,7 @@ def launch_benchmark_server(network_options): "PYACP_DELAY": f"{network_options.delay_ms}ms", "PYACP_RATE": f"{network_options.rate_kbit}kbit", } - acp = pyacp.launch_acp(config=conf, launch_mode=pyacp.LaunchMode.DOCKER_COMPOSE) # type: ignore + acp = pyacp.launch_acp(config=conf, launch_mode=pyacp.LaunchMode.DOCKER_COMPOSE) acp.wait(SERVER_STARTUP_TIMEOUT) return acp From 5525c3d6704c1ca31ea42bf2f64acd88127ba1c2 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 14:51:47 +0200 Subject: [PATCH 14/23] Remove _traversal helpers, move dependency graph generation to separate file --- src/ansys/acp/core/_dependency_graph.py | 108 ++++++++++++++++++ src/ansys/acp/core/_recursive_copy.py | 63 +--------- .../acp/core/_tree_objects/_traversal.py | 101 ---------------- 3 files changed, 109 insertions(+), 163 deletions(-) create mode 100644 src/ansys/acp/core/_dependency_graph.py delete mode 100644 src/ansys/acp/core/_tree_objects/_traversal.py diff --git a/src/ansys/acp/core/_dependency_graph.py b/src/ansys/acp/core/_dependency_graph.py new file mode 100644 index 0000000000..87f2c259df --- /dev/null +++ b/src/ansys/acp/core/_dependency_graph.py @@ -0,0 +1,108 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from collections.abc import Iterable, Iterator +from dataclasses import dataclass + +import networkx as nx + +from ._tree_objects._grpc_helpers.linked_object_helpers import get_linked_paths +from ._tree_objects._grpc_helpers.mapping import Mapping +from ._tree_objects._grpc_helpers.polymorphic_from_pb import tree_object_from_resource_path +from ._tree_objects.base import CreatableTreeObject, TreeObject + + +@dataclass +class _WalkTreeOptions: + include_children: bool + include_linked_objects: bool + + +def _build_dependency_graph( + *, source_objects: Iterable[CreatableTreeObject], options: _WalkTreeOptions +) -> nx.DiGraph: + """Build a dependency graph of the given objects.""" + graph = nx.DiGraph() + + # We need to manually keep track of which objects have been visited, + # since the node may also be created when being linked to. + visited_objects: set[CreatableTreeObject] = set() + for tree_object in source_objects: + _build_dependency_graph_impl( + tree_object=tree_object, graph=graph, visited_objects=visited_objects, options=options + ) + return graph + + +def _build_dependency_graph_impl( + *, + tree_object: CreatableTreeObject, + graph: nx.DiGraph, + visited_objects: set[CreatableTreeObject], + options: _WalkTreeOptions, +) -> None: + + if tree_object in visited_objects: + return + + visited_objects.add(tree_object) + graph.add_node(tree_object) + + if options.include_children: + for child_object in _yield_child_objects(tree_object): + if not isinstance(child_object, CreatableTreeObject): + continue + graph.add_edge(child_object, tree_object) + _build_dependency_graph_impl( + tree_object=child_object, + graph=graph, + visited_objects=visited_objects, + options=options, + ) + if options.include_linked_objects: + for linked_object in _yield_linked_objects(tree_object): + graph.add_edge(tree_object, linked_object) + _build_dependency_graph_impl( + tree_object=linked_object, + graph=graph, + visited_objects=visited_objects, + options=options, + ) + + +def _yield_child_objects(tree_object: TreeObject) -> Iterator[TreeObject]: + for attr_name in tree_object._GRPC_PROPERTIES: + try: + attr = getattr(tree_object, attr_name) + except (AttributeError, RuntimeError): + continue + if isinstance(attr, Mapping): + yield from attr.values() + + +def _yield_linked_objects(tree_object: TreeObject) -> Iterator[CreatableTreeObject]: + for linked_path in get_linked_paths(tree_object._pb_object.properties): + linked_object = tree_object_from_resource_path( + linked_path, server_wrapper=tree_object._server_wrapper + ) + assert isinstance(linked_object, CreatableTreeObject) + yield linked_object diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index f18fb7cb54..6274ca8025 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -22,77 +22,18 @@ import collections from collections.abc import Iterable -from dataclasses import dataclass import networkx as nx +from ._dependency_graph import _build_dependency_graph, _WalkTreeOptions from ._tree_objects import LookUpTable1D, LookUpTable1DColumn, LookUpTable3D, LookUpTable3DColumn from ._tree_objects._grpc_helpers.linked_object_helpers import get_linked_paths -from ._tree_objects._traversal import all_linked_objects, child_objects from ._tree_objects.base import CreatableTreeObject, TreeObject from ._typing_helper import StrEnum __all__ = ["recursive_copy", "LinkedObjectHandling"] -@dataclass -class _WalkTreeOptions: - include_children: bool - include_linked_objects: bool - - -def _build_dependency_graph( - *, source_objects: Iterable[CreatableTreeObject], options: _WalkTreeOptions -) -> nx.DiGraph: - graph = nx.DiGraph() - - # We need to manually keep track of which objects have been visited, - # since the node may also be created when being linked to. - visited_objects: set[CreatableTreeObject] = set() - for tree_object in source_objects: - _build_dependency_graph_impl( - tree_object=tree_object, graph=graph, visited_objects=visited_objects, options=options - ) - return graph - - -def _build_dependency_graph_impl( - *, - tree_object: CreatableTreeObject, - graph: nx.DiGraph, - visited_objects: set[CreatableTreeObject], - options: _WalkTreeOptions, -) -> None: - - if tree_object in visited_objects: - return - - visited_objects.add(tree_object) - graph.add_node(tree_object) - - if options.include_children: - for child_object in child_objects(tree_object): - if not isinstance(child_object, CreatableTreeObject): - continue - graph.add_edge(child_object, tree_object) - _build_dependency_graph_impl( - tree_object=child_object, - graph=graph, - visited_objects=visited_objects, - options=options, - ) - if options.include_linked_objects: - for linked_object in all_linked_objects(tree_object): - assert isinstance(linked_object, CreatableTreeObject) - graph.add_edge(tree_object, linked_object) - _build_dependency_graph_impl( - tree_object=linked_object, - graph=graph, - visited_objects=visited_objects, - options=options, - ) - - class LinkedObjectHandling(StrEnum): """Defines options for handling linked objects when copying a tree of ACP objects.""" @@ -229,8 +170,6 @@ def recursive_copy( # Otherwise, we can directly store the new object. if linked_object_handling == LinkedObjectHandling.COPY: for linked_resource_path in get_linked_paths(new_tree_object._pb_object.properties): - # TODO: handle case when linked objects are not (yet) supported by PyACP or - # the server, but are included in the API. linked_resource_path.value = resource_path_replacement_mapping[ linked_resource_path.value ] diff --git a/src/ansys/acp/core/_tree_objects/_traversal.py b/src/ansys/acp/core/_tree_objects/_traversal.py deleted file mode 100644 index b1d83450df..0000000000 --- a/src/ansys/acp/core/_tree_objects/_traversal.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. -# SPDX-License-Identifier: MIT -# -# -# Permission is hereby granted, free of charge, to any person obtaining a copy -# of this software and associated documentation files (the "Software"), to deal -# in the Software without restriction, including without limitation the rights -# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -# copies of the Software, and to permit persons to whom the Software is -# furnished to do so, subject to the following conditions: -# -# The above copyright notice and this permission notice shall be included in all -# copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -# SOFTWARE. - -from collections.abc import Iterable, Iterator -from typing import TypeVar - -from ._grpc_helpers.edge_property_list import EdgePropertyList, GenericEdgePropertyType -from ._grpc_helpers.linked_object_list import LinkedObjectList -from ._grpc_helpers.mapping import Mapping -from .base import CreatableTreeObject, TreeObjectBase - -__all__ = [ - "all_linked_objects", - "child_objects", - "directly_linked_objects", - "linked_object_lists", - "edge_property_lists", - "edge_property_targets", -] - - -def all_linked_objects(tree_object: TreeObjectBase) -> Iterator[TreeObjectBase]: - """Yield all objects linked to the given tree object.""" - for _, linked_object in directly_linked_objects(tree_object): - yield linked_object - for _, linked_object_list in linked_object_lists(tree_object): - yield from linked_object_list - for _, edge_property_list in edge_property_lists(tree_object): - for _, _, linked_object in edge_property_targets(edge_property_list): - yield linked_object - - -def child_objects(tree_object: TreeObjectBase) -> Iterator[TreeObjectBase]: - """Yield all child objects of the given tree object.""" - for _, mapping in _yield_attrs_of_type(tree_object, Mapping): - yield from mapping.values() - - -def directly_linked_objects(tree_object: TreeObjectBase) -> Iterator[tuple[str, TreeObjectBase]]: - """Yield the attribute name and linked object for all directly linked objects.""" - yield from _yield_attrs_of_type(tree_object, TreeObjectBase) - - -def linked_object_lists( - tree_object: TreeObjectBase, -) -> Iterator[tuple[str, LinkedObjectList[CreatableTreeObject]]]: - """Yield the attribute name and linked object list for all linked object lists.""" - yield from _yield_attrs_of_type(tree_object, LinkedObjectList) - - -def edge_property_lists( - tree_object: TreeObjectBase, -) -> Iterator[tuple[str, EdgePropertyList[GenericEdgePropertyType]]]: - """Yield the attribute name and edge property list for all edge property lists.""" - yield from _yield_attrs_of_type(tree_object, EdgePropertyList) - - -def edge_property_targets( - edge_property_list: Iterable[GenericEdgePropertyType], -) -> Iterator[tuple[GenericEdgePropertyType, str, TreeObjectBase]]: - """Yield the edge property, edge property name, and linked object for all edge properties.""" - for edge in edge_property_list: - for edge_prop_name in edge._GRPC_PROPERTIES: - try: - edge_prop = getattr(edge, edge_prop_name) - except (AttributeError, RuntimeError): - continue - if isinstance(edge_prop, TreeObjectBase): - yield (edge, edge_prop_name, edge_prop) - - -T = TypeVar("T") - - -def _yield_attrs_of_type(tree_object: TreeObjectBase, type_: type[T]) -> Iterator[tuple[str, T]]: - for attr_name in tree_object._GRPC_PROPERTIES: - try: - attr = getattr(tree_object, attr_name) - except (AttributeError, RuntimeError): - continue - if isinstance(attr, type_): - yield (attr_name, attr) From f6f9fb0915dfad669a8dbee637c7e4cf4c3d432c Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 15:59:14 +0200 Subject: [PATCH 15/23] Implement suggestions from code review --- src/ansys/acp/core/_recursive_copy.py | 41 ++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index 6274ca8025..a1779fc488 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -30,6 +30,7 @@ from ._tree_objects._grpc_helpers.linked_object_helpers import get_linked_paths from ._tree_objects.base import CreatableTreeObject, TreeObject from ._typing_helper import StrEnum +from ._utils.resource_paths import common_path, to_parts __all__ = ["recursive_copy", "LinkedObjectHandling"] @@ -37,8 +38,8 @@ class LinkedObjectHandling(StrEnum): """Defines options for handling linked objects when copying a tree of ACP objects.""" - COPY = "copy" KEEP = "keep" + COPY = "copy" DISCARD = "discard" @@ -46,13 +47,14 @@ def recursive_copy( *, source_objects: Iterable[CreatableTreeObject], parent_mapping: dict[TreeObject, TreeObject], - linked_object_handling: LinkedObjectHandling | str = "copy", + linked_object_handling: LinkedObjectHandling | str = "keep", ) -> dict[CreatableTreeObject, CreatableTreeObject]: """Recursively copy a tree of ACP objects. This function copies a tree of ACP objects, starting from the given source objects. - The copied tree includes all child objects. Linked objects can optionally be included, - controlled by the ``linked_object_handling`` argument. + The copied tree includes all child objects. Linked objects (such as a Fabric linked to + by a Modeling Ply) can optionally be included, controlled by the + ``linked_object_handling`` argument. To specify where the new objects should be stored, you must provide a dictionary in the ``parent_mapping`` argument. The keys of the dictionary are the original @@ -64,7 +66,8 @@ def recursive_copy( ``source_objects`` list. In this case, the function will not create a new object for the parent, but will use the existing object instead. - The function returns a list of newly created objects. + The function returns a ``dict`` mapping the original objects to the newly created + objects. .. note:: @@ -79,11 +82,14 @@ def recursive_copy( the original parent object as the first element and the new parent object as the second element. linked_object_handling : - Defines how linked objects are handled. The following options are available: + Defines how linked objects are handled. An example of a linked object is a Fabric + linked to by a Modeling Ply. + + The following options are available: - - ``"copy"``: Copy the linked objects, and replace the links. - ``"keep"``: Keep linking to the original objects, and do not copy them (unless they are otherwise included in the tree). + - ``"copy"``: Copy the linked objects, and replace the links. - ``"discard"``: Discard object links. Note that when copying objects between two models, only the ``"copy"`` and @@ -128,6 +134,27 @@ def recursive_copy( parent_mapping={model1: model2}, ) """ + # Check that the given source objects and parent mapping keys belong to the same + # model. + common_source_path = common_path( + *[obj._resource_path.value for obj in list(source_objects) + list(parent_mapping.keys())] + ) + if len(to_parts(common_source_path)) < 2: + raise ValueError( + "The 'source_objects' and 'parent_mapping' keys must all belong to the same model." + ) + common_target_path = common_path(*[obj._resource_path.value for obj in parent_mapping.values()]) + if len(to_parts(common_target_path)) < 2: + raise ValueError("The 'parent_mapping' values must all belong to the same model.") + if linked_object_handling == LinkedObjectHandling.KEEP: + if len(to_parts(common_path(common_source_path, common_target_path))) < 2: + raise ValueError( + "When using 'linked_object_handling=\"keep\"', all provided objects in 'source_objects' " + "and 'parent_mapping' (keys and values) must belong to the same model. Use " + "'linked_object_handling=\"copy\"' or 'linked_object_handling=\"discard\"' to copy " + "objects between models." + ) + linked_object_handling = LinkedObjectHandling(linked_object_handling) options = _WalkTreeOptions( From b32c7aa8a2d82744bc34c34a264a6be62a78cb63 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 16:05:25 +0200 Subject: [PATCH 16/23] Fix tests --- src/ansys/acp/core/_recursive_copy.py | 2 ++ tests/unittests/test_recursive_copy.py | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index a1779fc488..d468020f32 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -118,6 +118,7 @@ def recursive_copy( pyacp.recursive_copy( source_objects=model1.modeling_groups.values(), parent_mapping={model1: model2}, + linked_object_handling="copy", ) To copy all definitions from one model to another, you can use the following code: @@ -132,6 +133,7 @@ def recursive_copy( pyacp.recursive_copy( source_objects=[model1], parent_mapping={model1: model2}, + linked_object_handling="copy", ) """ # Check that the given source objects and parent mapping keys belong to the same diff --git a/tests/unittests/test_recursive_copy.py b/tests/unittests/test_recursive_copy.py index 83113f5207..362f3c05d1 100644 --- a/tests/unittests/test_recursive_copy.py +++ b/tests/unittests/test_recursive_copy.py @@ -42,6 +42,7 @@ def test_basic_recursive_copy(minimal_complete_model): new_objects = recursive_copy( source_objects=[mp], parent_mapping={mg: mg, minimal_complete_model: minimal_complete_model}, + linked_object_handling="copy", ) # THEN: The expected new objects are created @@ -125,7 +126,9 @@ def test_copy_to_different_model(minimal_complete_model, load_model_from_tempfil with load_model_from_tempfile() as model2: # WHEN: Recursively copying all objects from model1 to model2 - new_objects = recursive_copy(source_objects=[model1], parent_mapping={model1: model2}) + new_objects = recursive_copy( + source_objects=[model1], parent_mapping={model1: model2}, linked_object_handling="copy" + ) # THEN: The expected new objects are created # NOTE: This list may need to be updated when the model reference file @@ -158,7 +161,9 @@ def test_copy_edge_property_list(minimal_complete_model): ) # WHEN: Recursively copying the Stackup - new_objects = recursive_copy(source_objects=[stackup], parent_mapping={model: model}) + new_objects = recursive_copy( + source_objects=[stackup], parent_mapping={model: model}, linked_object_handling="copy" + ) # THEN: # - The stackup, fabrics, and materials are copied @@ -193,6 +198,7 @@ def test_missing_parent_object_raises(minimal_complete_model): recursive_copy( source_objects=[mp], parent_mapping={mg: mg}, + linked_object_handling="copy", ) msg = str(exc_info.value) From 8eea31db62ac6cb782a2b65cc379aefd0a957fa1 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Wed, 18 Sep 2024 16:54:13 +0200 Subject: [PATCH 17/23] Test exceptions when copying across models --- src/ansys/acp/core/_recursive_copy.py | 8 +-- tests/unittests/test_recursive_copy.py | 73 ++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/ansys/acp/core/_recursive_copy.py b/src/ansys/acp/core/_recursive_copy.py index d468020f32..ce9ce2a11b 100644 --- a/src/ansys/acp/core/_recursive_copy.py +++ b/src/ansys/acp/core/_recursive_copy.py @@ -151,10 +151,10 @@ def recursive_copy( if linked_object_handling == LinkedObjectHandling.KEEP: if len(to_parts(common_path(common_source_path, common_target_path))) < 2: raise ValueError( - "When using 'linked_object_handling=\"keep\"', all provided objects in 'source_objects' " - "and 'parent_mapping' (keys and values) must belong to the same model. Use " - "'linked_object_handling=\"copy\"' or 'linked_object_handling=\"discard\"' to copy " - "objects between models." + "When using 'linked_object_handling=\"keep\"', objects cannot be copied from one model " + "to another. The objects in 'source_objects' and 'parent_mapping' must all belong to the " + 'same model. Use \'linked_object_handling="copy"\' or linked_object_handling="discard"\' ' + "to copy objects between models." ) linked_object_handling = LinkedObjectHandling(linked_object_handling) diff --git a/tests/unittests/test_recursive_copy.py b/tests/unittests/test_recursive_copy.py index 362f3c05d1..14bf0afdb7 100644 --- a/tests/unittests/test_recursive_copy.py +++ b/tests/unittests/test_recursive_copy.py @@ -233,3 +233,76 @@ def test_copy_lookup_table_with_columns(minimal_complete_model): new_lut = model.lookup_tables_1d["LookUpTable1D.2"] assert np.allclose(new_lut.columns["Location"].data, [1.0, 2.0, 3.0]) assert np.allclose(new_lut.columns["column1"].data, [4.0, 5.0, 6.0]) + + +def test_inconsistent_source_model(minimal_complete_model, load_model_from_tempfile): + """Test that an exception is raised if the source objects are not all from the same model.""" + # GIVEN: Two models + model1 = minimal_complete_model + with load_model_from_tempfile() as model2: + # WHEN: Copying objects from different models + # THEN: An exception is raised + with pytest.raises(ValueError) as exc: + recursive_copy( + source_objects=[model1, model2], + parent_mapping={model1: model2}, + linked_object_handling="copy", + ) + assert "source_objects" in str(exc.value) + assert "'parent_mapping' keys" in str(exc.value) + assert "same model" in str(exc.value) + + +def test_inconsistent_source_model_2(minimal_complete_model, load_model_from_tempfile): + """Test that an exception is raised if the source objects are not all from the same model.""" + # GIVEN: Two models + model1 = minimal_complete_model + with load_model_from_tempfile() as model2: + # WHEN: Copying objects from different models + # THEN: An exception is raised + with pytest.raises(ValueError) as exc: + recursive_copy( + source_objects=[model1], + parent_mapping={model2: model2}, # parent_mapping keys are from the wrong model + linked_object_handling="copy", + ) + assert "source_objects" in str(exc.value) + assert "'parent_mapping' keys" in str(exc.value) + assert "same model" in str(exc.value) + + +def test_inconsistent_target_model(minimal_complete_model, load_model_from_tempfile): + """Test that an exception is raised if the target parents are not all from the same model.""" + # GIVEN: Two models + model1 = minimal_complete_model + mat = model1.materials["Structural Steel"] + with load_model_from_tempfile() as model2: + # WHEN: Copying objects from different models + # THEN: An exception is raised + with pytest.raises(ValueError) as exc: + recursive_copy( + source_objects=[model1], + parent_mapping={model1: model2, mat: mat}, + linked_object_handling="copy", + ) + assert "parent_mapping" in str(exc.value) + assert "'parent_mapping' values" in str(exc.value) + assert "same model" in str(exc.value) + + +def test_keep_links_across_models_raises(minimal_complete_model, load_model_from_tempfile): + """Test that an exception is raised when trying to keep links across models.""" + # GIVEN: Two models + model1 = minimal_complete_model + with load_model_from_tempfile() as model2: + # WHEN: Copying objects across models, with linked_object_handling="keep" + # THEN: An exception is raised + with pytest.raises(ValueError) as exc: + recursive_copy( + source_objects=[model1], + parent_mapping={model1: model2}, + linked_object_handling="keep", + ) + assert "linked_object_handling" in str(exc.value) + assert "keep" in str(exc.value) + assert "copy objects between models" in str(exc.value) From 00639716d3d829f980fcaa850c549cbce645d9e0 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 19 Sep 2024 09:30:15 +0200 Subject: [PATCH 18/23] Improve test_properties performance --- tests/unittests/common/tree_object_tester.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unittests/common/tree_object_tester.py b/tests/unittests/common/tree_object_tester.py index 1a0fed10eb..f2858d6436 100644 --- a/tests/unittests/common/tree_object_tester.py +++ b/tests/unittests/common/tree_object_tester.py @@ -183,10 +183,11 @@ def test_properties(tree_object, object_properties: ObjectPropertiesToTest): with pytest.raises(AttributeError): setattr(tree_object, prop, value) + string_representation = str(tree_object) for prop, _ in object_properties.read_only + object_properties.read_write: - assert f"{prop}=" in str( - tree_object - ), f"{prop} not found in object string: {str(tree_object)}" + assert ( + f"{prop}=" in string_representation + ), f"{prop} not found in object string: {string_representation}" @staticmethod def test_collection_delitem(collection_test_data): From 222223506454b91223a56f77c496f17092c34e58 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 19 Sep 2024 10:49:24 +0200 Subject: [PATCH 19/23] Adapt test_workflow_unit_system_dat to server changes --- tests/unittests/test_workflow.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/unittests/test_workflow.py b/tests/unittests/test_workflow.py index e92ade4f61..1654a6d98f 100644 --- a/tests/unittests/test_workflow.py +++ b/tests/unittests/test_workflow.py @@ -24,6 +24,7 @@ import shutil import tempfile +from packaging.version import parse as parse_version import pytest from ansys.acp.core import ACPWorkflow, UnitSystemType @@ -102,10 +103,16 @@ def test_workflow_unit_system_dat(acp_instance, model_data_dir, unit_system): input_file_path = model_data_dir / "flat_plate_input.dat" - if unit_system != UnitSystemType.UNDEFINED: + # Initializing a workflow with a defined unit system is not allowed if the + # input file does contain the unit system. + # Since 25.1, we also allow it if the unit systems match. + if parse_version(acp_instance.server_version) < parse_version("25.1"): + allowed_unit_system_values = [UnitSystemType.UNDEFINED] + else: + allowed_unit_system_values = [UnitSystemType.UNDEFINED, UnitSystemType.MKS] + + if unit_system not in allowed_unit_system_values: with pytest.raises(ValueError) as ex: - # Initializing a workflow with a defined unit system is not allowed - # if the input file does contain the unit system. ACPWorkflow.from_cdb_or_dat_file( acp=acp_instance, cdb_or_dat_file_path=input_file_path, From 500b2b89bdc6f3c9a61f3f1d0039b7dd3c34949c Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 19 Sep 2024 11:23:15 +0200 Subject: [PATCH 20/23] Add tests for EdgePropertyType clone --- tests/unittests/test_edge_property_types.py | 83 +++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/unittests/test_edge_property_types.py diff --git a/tests/unittests/test_edge_property_types.py b/tests/unittests/test_edge_property_types.py new file mode 100644 index 0000000000..bd7052df14 --- /dev/null +++ b/tests/unittests/test_edge_property_types.py @@ -0,0 +1,83 @@ +# Copyright (C) 2022 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from pytest_cases import parametrize_with_cases + +from ansys.acp.core import ( + BooleanOperationType, + FabricWithAngle, + Lamina, + LinkedSelectionRule, + SubShape, + TaperEdge, +) + + +def case_fabric_with_angle(load_model_from_tempfile): + with load_model_from_tempfile() as model: + fabric = model.create_fabric() + yield FabricWithAngle(fabric=fabric, angle=12.3), ("fabric", "angle") + + +def case_linked_selection_rule(load_model_from_tempfile): + with load_model_from_tempfile() as model: + selection_rule = model.create_parallel_selection_rule() + yield LinkedSelectionRule( + selection_rule=selection_rule, + operation_type=BooleanOperationType.ADD, + template_rule=False, + parameter_1=1.0, + parameter_2=2.0, + ), ("selection_rule", "operation_type", "template_rule", "parameter_1", "parameter_2") + + +def case_taper_edge(load_model_from_tempfile): + with load_model_from_tempfile() as model: + edge_set = model.create_edge_set() + yield TaperEdge(edge_set=edge_set, angle=11.2, offset=0.6), ("edge_set", "angle", "offset") + + +def case_subshape(load_model_from_tempfile): + with load_model_from_tempfile() as model: + cad_geometry = model.create_cad_geometry() + yield SubShape(cad_geometry=cad_geometry, path="path/to/subshape"), ("cad_geometry", "path") + + +def case_lamina_fabric(load_model_from_tempfile): + with load_model_from_tempfile() as model: + material = model.create_fabric() + yield Lamina(material=material, angle=7.5), ("material", "angle") + + +def case_lamina_stackup(load_model_from_tempfile): + with load_model_from_tempfile() as model: + material = model.create_stackup() + yield Lamina(material=material, angle=7.5), ("material", "angle") + + +@parametrize_with_cases("edge_property_type_instance,attribute_names", cases=".", glob="*") +def test_clone(edge_property_type_instance, attribute_names): + cloned_instance = edge_property_type_instance.clone() + for attr_name in attribute_names: + assert getattr(cloned_instance, attr_name) == getattr( + edge_property_type_instance, attr_name + ) From 37c5c0c5542fff86e0c7b578a3f1f1b9db6e7dc8 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 19 Sep 2024 11:39:40 +0200 Subject: [PATCH 21/23] Add tests for the .parent property --- tests/unittests/common/tree_object_tester.py | 19 +++++++++++++++++++ tests/unittests/test_analysis_ply.py | 8 +++++++- tests/unittests/test_cad_component.py | 5 +++-- tests/unittests/test_model.py | 6 ++++++ 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/unittests/common/tree_object_tester.py b/tests/unittests/common/tree_object_tester.py index f2858d6436..f3640cd68c 100644 --- a/tests/unittests/common/tree_object_tester.py +++ b/tests/unittests/common/tree_object_tester.py @@ -111,6 +111,14 @@ def test_collection_getitem_inexistent(collection_test_data): object_collection[INEXISTENT_ID] assert object_collection.get(INEXISTENT_ID) is None + @staticmethod + def test_parent_access(collection_test_data, parent_object): + """Test the parent access of the objects in the collection.""" + object_collection, _, object_ids = collection_test_data + ref_id = object_ids[0] + + assert object_collection[ref_id].parent is parent_object + class TreeObjectTester(TreeObjectTesterReadOnly): COLLECTION_NAME: str @@ -200,6 +208,17 @@ def test_collection_delitem(collection_test_data): with pytest.raises(KeyError): object_collection[ref_id] + @staticmethod + def test_unstored_parent_access_raises(collection_test_data): + """Test that unstored objects raise an error when accessing the parent.""" + object_collection, _, object_ids = collection_test_data + ref_id = object_ids[0] + object = object_collection[ref_id].clone() + with pytest.raises(RuntimeError) as exc: + object.parent + assert "unstored" in str(exc.value) + assert "parent" in str(exc.value) + class NoLockedMixin(TreeObjectTester): @pytest.fixture diff --git a/tests/unittests/test_analysis_ply.py b/tests/unittests/test_analysis_ply.py index 005f351370..56cbe636f5 100644 --- a/tests/unittests/test_analysis_ply.py +++ b/tests/unittests/test_analysis_ply.py @@ -78,11 +78,17 @@ def properties_3_layers(self, model): }, } + @staticmethod @pytest.fixture - def collection_test_data(self, model: Model): + def parent_object(model: Model): add_stackup_with_3_layers_to_modeling_ply(model) production_ply = get_first_modeling_ply(model).production_plies["ProductionPly"] + return production_ply + + @pytest.fixture + def collection_test_data(self, parent_object): + production_ply = parent_object object_collection = getattr(production_ply, self.COLLECTION_NAME) object_collection.values() object_names = ["P1L1__ModelingPly.1", "P1L2__ModelingPly.1", "P1L3__ModelingPly.1"] diff --git a/tests/unittests/test_cad_component.py b/tests/unittests/test_cad_component.py index 19ecaf2392..8cce9b4ca2 100644 --- a/tests/unittests/test_cad_component.py +++ b/tests/unittests/test_cad_component.py @@ -32,7 +32,7 @@ def model(load_model_from_tempfile): @pytest.fixture -def cad_geometry(model, load_cad_geometry): +def parent_object(model, load_cad_geometry): with load_cad_geometry(model) as cad_geometry: yield cad_geometry @@ -41,6 +41,7 @@ class TestCADComponent(TreeObjectTesterReadOnly): COLLECTION_NAME = "cad_components" @pytest.fixture - def collection_test_data(self, model, cad_geometry): + def collection_test_data(self, model, parent_object): + cad_geometry = parent_object model.update() return cad_geometry.root_shapes, ["SOLID", "SHELL"], ["SOLID", "SHELL"] diff --git a/tests/unittests/test_model.py b/tests/unittests/test_model.py index d32bf6b368..eb86bff3d2 100644 --- a/tests/unittests/test_model.py +++ b/tests/unittests/test_model.py @@ -268,3 +268,9 @@ def test_modeling_ply_export(acp_instance, minimal_complete_model, xfail_before) minimal_complete_model.export_modeling_ply_geometries(out_file_path) acp_instance.download_file(out_file_path, local_file_path) assert local_file_path.exists() + + +def test_parent_access_raises(minimal_complete_model): + with pytest.raises(RuntimeError) as exc: + minimal_complete_model.parent + assert "parent" in str(exc.value) From 9a3d478073a02b9606f1b277eb7cd39061ce401d Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 19 Sep 2024 12:45:21 +0200 Subject: [PATCH 22/23] Add multiple coverage uploads --- .github/workflows/ci_cd.yml | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index f258db0ca1..66016befd1 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -172,15 +172,13 @@ jobs: LICENSE_SERVER: ${{ secrets.LICENSE_SERVER }} IMAGE_NAME: "ghcr.io/ansys/acp${{ github.event.inputs.docker_image_suffix || ':latest' }}" - - name: "Unit testing (2024R2 server)" - if: matrix.python-version == env.MAIN_PYTHON_VERSION - working-directory: tests/unittests - run: | - docker pull $IMAGE_NAME - poetry run pytest -v --license-server=1055@$LICENSE_SERVER --no-server-log-files --docker-image=$IMAGE_NAME --cov=ansys.acp.core --cov-report=term --cov-report=xml --cov-report=html --cov-append + - name: "Upload coverage to Codecov" + uses: codecov/codecov-action@v4 env: - LICENSE_SERVER: ${{ secrets.LICENSE_SERVER }} - IMAGE_NAME: "ghcr.io/ansys/acp:2024r2" + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: coverage.xml + flags: 'server-latest,python-${{ matrix.python-version }}' - name: "Upload coverage report (HTML)" uses: actions/upload-artifact@v4 @@ -190,13 +188,24 @@ jobs: path: htmlcov retention-days: 7 - - name: "Upload coverage to Codecov" - uses: codecov/codecov-action@v4 + - name: "Unit testing (2024R2 server)" + if: matrix.python-version == env.MAIN_PYTHON_VERSION + working-directory: tests/unittests + run: | + docker pull $IMAGE_NAME + poetry run pytest -v --license-server=1055@$LICENSE_SERVER --no-server-log-files --docker-image=$IMAGE_NAME --cov=ansys.acp.core --cov-report=term --cov-report=xml --cov-report=html + env: + LICENSE_SERVER: ${{ secrets.LICENSE_SERVER }} + IMAGE_NAME: "ghcr.io/ansys/acp:2024r2" + + - name: "Upload coverage to Codecov (2024R2 server)" if: matrix.python-version == env.MAIN_PYTHON_VERSION + uses: codecov/codecov-action@v4 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} with: files: coverage.xml + flags: 'server-242,python-${{ matrix.python-version }}' - name: Benchmarks working-directory: tests/benchmarks From 27174efc9bbbed5cb0a11d8f68870d680d1979a2 Mon Sep 17 00:00:00 2001 From: Dominik Gresch Date: Thu, 19 Sep 2024 13:22:58 +0200 Subject: [PATCH 23/23] Remove broken HTML coverage upload --- .github/workflows/ci_cd.yml | 8 -------- 1 file changed, 8 deletions(-) diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 66016befd1..807758ea23 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -180,14 +180,6 @@ jobs: files: coverage.xml flags: 'server-latest,python-${{ matrix.python-version }}' - - name: "Upload coverage report (HTML)" - uses: actions/upload-artifact@v4 - if: matrix.python-version == env.MAIN_PYTHON_VERSION - with: - name: coverage-report-html - path: htmlcov - retention-days: 7 - - name: "Unit testing (2024R2 server)" if: matrix.python-version == env.MAIN_PYTHON_VERSION working-directory: tests/unittests