Repository: BAMWelDX/weldx

Issue:
CSM merge does not consider assigned data
As discussed with @marscher last Friday, I tested if merging two CSMs transfers data to the child CSM to the parent CSM:

~~~ python
from weldx import CoordinateSystemManager

csm_1 = CoordinateSystemManager("r")
csm_1.create_cs("a", "r", coordinates=[1, 3, 4])
csm_1.create_cs("b", "r", coordinates=[5, 5, 5])

csm_2 = CoordinateSystemManager("x")
csm_2.create_cs("a", "x", coordinates=[0, 3, 0])
csm_2.create_cs("y", "x", coordinates=[5, 0, 0])

csm_2.assign_data([[1, 2, 3], [4, 5, 6]], "data", "y")

print(csm_2.data_names)

csm_1.merge(csm_2)

print(csm_1.data_names)

unmerged = csm_1.unmerge()
print(unmerged[0].data_names)
~~~

output:

~~~
['data']
[]
[]
~~~

As you can see, this is not the case (unmerging fails too). As far as I remember, this should be a minor implementation issue. The data itself should be copied, but it isn't registered in the internal structure. I will check if unmerging is also affected.

Discussion:
pep8speaks: Hello @vhirtham! Thanks for opening this PR. 

* In the file [`weldx/tags/core/transformations/coordinate_system_hierarchy.py`](https://github.com/BAMWelDX/weldx/blob/d55b421c2a388af81a2fe0026ec704e8ee8e98de/weldx/tags/core/transformations/coordinate_system_hierarchy.py):

> [Line 373:26](https://github.com/BAMWelDX/weldx/blob/d55b421c2a388af81a2fe0026ec704e8ee8e98de/weldx/tags/core/transformations/coordinate_system_hierarchy.py#L373): [E231](https://duckduckgo.com/?q=pep8%20E231) missing whitespace after ':'
> [Line 373:26](https://github.com/BAMWelDX/weldx/blob/d55b421c2a388af81a2fe0026ec704e8ee8e98de/weldx/tags/core/transformations/coordinate_system_hierarchy.py#L373): [E999](https://duckduckgo.com/?q=pep8%20E999) SyntaxError: invalid syntax
> [Line 373:28](https://github.com/BAMWelDX/weldx/blob/d55b421c2a388af81a2fe0026ec704e8ee8e98de/weldx/tags/core/transformations/coordinate_system_hierarchy.py#L373): [E251](https://duckduckgo.com/?q=pep8%20E251) unexpected spaces around keyword / parameter equals





Do see the [Hitchhiker's guide to code style](https://goo.gl/hqbW4r)
codecov[bot]: # [Codecov](https://codecov.io/gh/BAMWelDX/weldx/pull/494?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) Report
> Merging [#494](https://codecov.io/gh/BAMWelDX/weldx/pull/494?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) (d55b421) into [master](https://codecov.io/gh/BAMWelDX/weldx/commit/cd7f2f348f1d06e25a67cb1ea1e18039db289d64?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) (cd7f2f3) will **increase** coverage by `0.01%`.
> The diff coverage is `100.00%`.

[![Impacted file tree graph](https://codecov.io/gh/BAMWelDX/weldx/pull/494/graphs/tree.svg?width=650&height=150&src=pr&token=wdof1qQTsn&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX)](https://codecov.io/gh/BAMWelDX/weldx/pull/494?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX)

```diff
@@            Coverage Diff             @@
##           master     #494      +/-   ##
==========================================
+ Coverage   96.84%   96.85%   +0.01%     
==========================================
  Files          91       91              
  Lines        5543     5562      +19     
==========================================
+ Hits         5368     5387      +19     
  Misses        175      175              
```


| [Impacted Files](https://codecov.io/gh/BAMWelDX/weldx/pull/494?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX) | Coverage Δ | |
|---|---|---|
| [...ore/transformations/coordinate\_system\_hierarchy.py](https://codecov.io/gh/BAMWelDX/weldx/pull/494/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX#diff-d2VsZHgvdGFncy9jb3JlL3RyYW5zZm9ybWF0aW9ucy9jb29yZGluYXRlX3N5c3RlbV9oaWVyYXJjaHkucHk=) | `100.00% <100.00%> (ø)` | |
| [weldx/transformations/cs\_manager.py](https://codecov.io/gh/BAMWelDX/weldx/pull/494/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX#diff-d2VsZHgvdHJhbnNmb3JtYXRpb25zL2NzX21hbmFnZXIucHk=) | `98.76% <100.00%> (+0.04%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/BAMWelDX/weldx/pull/494?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/BAMWelDX/weldx/pull/494?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX). Last update [cd7f2f3...d55b421](https://codecov.io/gh/BAMWelDX/weldx/pull/494?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=BAMWelDX).

vhirtham: This was a bit tricky due to some annoying hidden side-effects but now it is working to some degree. I am not sure if the serialization works for all cases since not everything is tested but we will touch this when working on the graph serialization anyways. I guess this will cover everything @marscher needs to work on the dashboards.

I will add some edge case tests and clean everything up. Afterwards, this is ready.
vhirtham: I merged this one, so I can continue with the work on #497 . We can merge your other branch into master if we agreed on a solution. @CagtayFabry 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e83d93c..56df9c9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -51,6 +51,8 @@
 - `WeldxFile.show_asdf_header` prints output on console, before it only returned the header as parsed dict and string
   representation. Also tweaked efficiency by not writing binary
   blocks [[#459]](https://github.com/BAMWelDX/weldx/pull/459), [[#469]](https://github.com/BAMWelDX/weldx/pull/469).
+- Merging and unmerging multiple `CoordinateSystemManager` instances now correctly preserves all attached data.
+  [[#494]](https://github.com/BAMWelDX/weldx/pull/494).
 - `compare_nested` can compare sets [[#496]](https://github.com/BAMWelDX/weldx/pull/496)
 
 ### documentation
diff --git a/weldx/tags/core/transformations/coordinate_system_hierarchy.py b/weldx/tags/core/transformations/coordinate_system_hierarchy.py
index 9d2e7f0..47732e0 100644
--- a/weldx/tags/core/transformations/coordinate_system_hierarchy.py
+++ b/weldx/tags/core/transformations/coordinate_system_hierarchy.py
@@ -316,11 +316,14 @@ class CoordinateSystemManagerConverter(WeldxConverter):
         ]
 
         spatial_data = None
-        if len(obj._data) > 0:
-            spatial_data = [
-                dict(name=k, coordinate_system=v.coordinate_system_name, data=v.data)
-                for k, v in obj._data.items()
-            ]
+
+        if len(obj.data_names) > 0:
+            spatial_data = []
+            for cs in obj.graph.nodes:
+                spatial_data += [
+                    dict(name=k, coordinate_system=cs, data=v)
+                    for k, v in obj.graph.nodes[cs]["data"].items()
+                ]
 
         tree = {
             "name": obj.name,
diff --git a/weldx/transformations/cs_manager.py b/weldx/transformations/cs_manager.py
index c47ea0a..0afde43 100644
--- a/weldx/transformations/cs_manager.py
+++ b/weldx/transformations/cs_manager.py
@@ -4,7 +4,7 @@ from __future__ import annotations
 import itertools
 from copy import deepcopy
 from dataclasses import dataclass
-from typing import TYPE_CHECKING, Any, Dict, List, Tuple, Union
+from typing import TYPE_CHECKING, Any, Dict, List, Set, Tuple, Union
 
 import numpy as np
 import pandas as pd
@@ -15,6 +15,7 @@ from weldx.constants import WELDX_UNIT_REGISTRY as UREG
 from weldx.core import TimeSeries
 from weldx.geometry import SpatialData
 from weldx.time import Time, types_time_like, types_timestamp_like
+from weldx.util.util import dataclass_nested_eq
 
 from .local_cs import LocalCoordinateSystem
 from .types import types_coordinates, types_orientation
@@ -43,14 +44,27 @@ class CoordinateSystemManager:
 
     """
 
-    _id_gen = itertools.count()
-
+    @dataclass_nested_eq
     @dataclass
-    class CoordinateSystemData:
-        """Class that stores data and the coordinate system, the data is assigned to."""
+    class SubsystemInfo:
+        """Contains information about subsystems."""
+
+        root: str
+        """Name of the root coordinate system"""
+        common_node: str
+        """Name of the common coordinate system"""
+        common_node_data: Set[str]
+        """Names of the subsystems data at the common coordinate system"""
+        common_node_neighbors: Set[str]
+        """Names of the subsystems coordinate systems connected to the common system"""
+        time_ref: pd.Timestamp
+        """The reference time of the subsystem"""
+        members: Set[str]
+        """Names of all coordinate systems that belong to the subsystem"""
+        sub_systems: Dict[str, CoordinateSystemManager.SubsystemInfo]
+        """Dictionary of nested subsystems"""
 
-        coordinate_system_name: str
-        data: xr.DataArray
+    _id_gen = itertools.count()
 
     def __init__(
         self,
@@ -80,16 +94,13 @@ class CoordinateSystemManager:
 
         if coordinate_system_manager_name is None:
             coordinate_system_manager_name = self._generate_default_name()
-        self._name = coordinate_system_manager_name
         if time_ref is not None and not isinstance(time_ref, pd.Timestamp):
             time_ref = pd.Timestamp(time_ref)
-        self._reference_time = time_ref
 
-        self._data: Dict[str, CoordinateSystemManager.CoordinateSystemData] = {}
+        self._name = coordinate_system_manager_name
+        self._reference_time = time_ref
         self._root_system_name = root_coordinate_system_name
-
-        self._sub_system_data_dict: Dict[str, Dict] = {}
-
+        self._sub_systems: Dict[str, CoordinateSystemManager.SubsystemInfo] = {}
         self._graph = DiGraph()
         self._add_coordinate_system_node(root_coordinate_system_name)
 
@@ -129,7 +140,7 @@ class CoordinateSystemManager:
         csm = cls(root_coordinate_system_name, coordinate_system_manager_name, time_ref)
 
         if subsystems is not None:
-            csm._sub_system_data_dict = subsystems
+            csm._sub_systems = subsystems
 
         if graph is not None:
             csm._graph = graph
@@ -142,8 +153,8 @@ class CoordinateSystemManager:
             f"<CoordinateSystemManager>\nname:\n\t{self._name}\n"
             f"reference time:\n\t {self.reference_time}\n"
             f"coordinate systems:\n\t {self.coordinate_system_names}\n"
-            f"data:\n\t {self._data!r}\n"
-            f"sub systems:\n\t {self._sub_system_data_dict.keys()}\n"
+            f"data:\n\t {self.data_names}\n"
+            f"sub systems:\n\t {self._sub_systems.keys()}\n"
             f")"
         )
 
@@ -253,7 +264,7 @@ class CoordinateSystemManager:
 
     def _add_coordinate_system_node(self, coordinate_system_name):
         self._check_new_coordinate_system_name(coordinate_system_name)
-        self._graph.add_node(coordinate_system_name, data=[])
+        self._graph.add_node(coordinate_system_name, data={})
 
     def _add_edges(self, node_from: str, node_to: str, lcs: LocalCoordinateSystem):
         """Add an edge to the internal graph.
@@ -330,22 +341,7 @@ class CoordinateSystemManager:
         for subsystem_name, subsystem_data in data.items():
             if subsystem_name not in other:
                 return False
-            other_data = other[subsystem_name]
-            if subsystem_data["common node"] != other_data["common node"]:
-                return False
-            if subsystem_data["root"] != other_data["root"]:
-                return False
-            if subsystem_data["time_ref"] != other_data["time_ref"]:
-                return False
-            if set(subsystem_data["neighbors"]) != set(other_data["neighbors"]):
-                return False
-            if set(subsystem_data["original members"]) != set(
-                other_data["original members"]
-            ):
-                return False
-            if not cls._compare_subsystems_equal(
-                subsystem_data["sub system data"], other_data["sub system data"]
-            ):
+            if subsystem_data != other[subsystem_name]:
                 return False
         return True
 
@@ -376,13 +372,16 @@ class CoordinateSystemManager:
             Extended copy of the internal sub system data.
 
         """
-        sub_system_data_dict = deepcopy(self._sub_system_data_dict)
+        sub_system_data_dict = deepcopy(self._sub_systems)
         for _, sub_system_data in sub_system_data_dict.items():
             potential_members = []
-            for cs_name in sub_system_data["neighbors"]:
+            for cs_name in sub_system_data.common_node_neighbors:
                 potential_members += self.get_child_system_names(cs_name, False)
 
-            sub_system_data["nodes"] = potential_members + sub_system_data["neighbors"]
+            sub_system_data.members = {
+                *potential_members,
+                *sub_system_data.common_node_neighbors,
+            }
 
         return sub_system_data_dict
 
@@ -405,16 +404,16 @@ class CoordinateSystemManager:
             List of all the sub systems coordinate systems.
 
         """
-        all_members = ext_sub_system_data["nodes"]
+        all_members = list(ext_sub_system_data.members)
         for _, other_sub_system_data in ext_sub_system_data_dict.items():
-            if other_sub_system_data["common node"] in all_members:
+            if other_sub_system_data.common_node in all_members:
                 all_members = [
                     cs_name
                     for cs_name in all_members
-                    if cs_name not in other_sub_system_data["nodes"]
+                    if cs_name not in other_sub_system_data.members
                 ]
 
-        all_members += [ext_sub_system_data["common node"]]
+        all_members += [ext_sub_system_data.common_node]
         return all_members
 
     @property
@@ -520,7 +519,7 @@ class CoordinateSystemManager:
             Number of attached subsystems.
 
         """
-        return len(self._sub_system_data_dict)
+        return len(self._sub_systems)
 
     @property
     def reference_time(self) -> pd.Timestamp:
@@ -549,7 +548,7 @@ class CoordinateSystemManager:
     @property
     def sub_system_data(self) -> Dict:
         """Get a dictionary containing data about the attached subsystems."""
-        return self._sub_system_data_dict
+        return self._sub_systems
 
     @property
     def subsystem_names(self) -> List[str]:
@@ -561,7 +560,7 @@ class CoordinateSystemManager:
             List with subsystem names.
 
         """
-        return list(self._sub_system_data_dict.keys())
+        return list(self._sub_systems.keys())
 
     def add_cs(
         self,
@@ -715,15 +714,14 @@ class CoordinateSystemManager:
         #   interpolated or not?
         if not isinstance(data_name, str):
             raise TypeError("The data name must be a string.")
-        if data_name in self._data:
+        if data_name in self.data_names:
             raise ValueError(f"There already is a dataset with the name '{data_name}'.")
         self._check_coordinate_system_exists(coordinate_system_name)
 
         if not isinstance(data, (xr.DataArray, SpatialData)):
             data = xr.DataArray(data, dims=["n", "c"], coords={"c": ["x", "y", "z"]})
 
-        self._data[data_name] = self.CoordinateSystemData(coordinate_system_name, data)
-        self._graph.nodes[coordinate_system_name]["data"].append(data_name)
+        self._graph.nodes[coordinate_system_name]["data"][data_name] = data
 
     def create_cs(
         self,
@@ -952,16 +950,16 @@ class CoordinateSystemManager:
         from networkx import shortest_path
 
         remove_systems = []
-        for sub_system_name, sub_system_data in self._sub_system_data_dict.items():
+        for sub_system_name, sub_system_data in self._sub_systems.items():
             if (
-                coordinate_system_name in sub_system_data["original members"]
+                coordinate_system_name in sub_system_data.members
             ) or coordinate_system_name in shortest_path(
-                self.graph, sub_system_data["root"], self._root_system_name
+                self.graph, sub_system_data.root, self._root_system_name
             ):
                 remove_systems += [sub_system_name]
 
         for sub_system_name in remove_systems:
-            del self._sub_system_data_dict[sub_system_name]
+            del self._sub_systems[sub_system_name]
 
         # delete nodes and edges
         if delete_children:
@@ -1029,7 +1027,20 @@ class CoordinateSystemManager:
             Names of the attached data sets
 
         """
-        return list(self._data.keys())
+        data_names = []
+        for node in self._graph.nodes:
+            data_names += list(self._graph.nodes[node]["data"].keys())
+        return data_names
+
+    def _find_data(
+        self, data_name: str
+    ) -> Tuple[str, Union[xr.DataArray, SpatialData]]:
+        """Get the data and its owning systems name."""
+        for cs in self._graph.nodes:
+            for name, data in self._graph.nodes[cs]["data"].items():
+                if name == data_name:
+                    return cs, data
+        raise KeyError(f"Could not find data with name '{data_name}'.")
 
     def get_data(
         self, data_name, target_coordinate_system_name=None
@@ -1051,16 +1062,17 @@ class CoordinateSystemManager:
             Transformed data
 
         """
-        data_struct = self._data[data_name]
+        data_cs_name, data = self._find_data(data_name)
+
         if (
             target_coordinate_system_name is None
-            or target_coordinate_system_name == data_struct.coordinate_system_name
+            or target_coordinate_system_name == data_cs_name
         ):
-            return data_struct.data
+            return data
 
         return self.transform_data(
-            data_struct.data,
-            data_struct.coordinate_system_name,
+            data,
+            data_cs_name,
             target_coordinate_system_name,
         )
 
@@ -1078,7 +1090,7 @@ class CoordinateSystemManager:
             Name of the reference coordinate system
 
         """
-        return self._data[data_name].coordinate_system_name
+        return self._find_data(data_name)[0]
 
     def _get_cs_on_edge(
         self,
@@ -1350,12 +1362,19 @@ class CoordinateSystemManager:
             )
 
             csm_sub = CoordinateSystemManager._from_subsystem_graph(
-                ext_sub_system_data["root"],
+                ext_sub_system_data.root,
                 sub_system_name,
-                time_ref=ext_sub_system_data["time_ref"],
+                time_ref=ext_sub_system_data.time_ref,
                 graph=self._graph.subgraph(members).copy(),
-                subsystems=ext_sub_system_data["sub system data"],
+                subsystems=ext_sub_system_data.sub_systems,
             )
+            common_node = ext_sub_system_data.common_node
+            csm_sub._graph.nodes[common_node]["data"] = {
+                k: v
+                for k, v in csm_sub._graph.nodes[common_node]["data"].items()
+                if k in ext_sub_system_data.common_node_data
+            }
+
             sub_system_list.append(csm_sub)
 
         return sub_system_list
@@ -1520,20 +1539,30 @@ class CoordinateSystemManager:
                 "Both instances must have exactly one common coordinate system. "
                 f"Found the following common systems: {intersection}"
             )
+        common_node = intersection[0]
+
+        for name in other.data_names:
+            if name in self.data_names:
+                raise NameError(f"Both instances contain data with name '{name}'")
+
+        data_parent = self._graph.nodes[common_node]["data"]
+        data_child = other.graph.nodes[common_node]["data"]
+        joined_data = {**data_parent, **data_child}
 
         from networkx import compose
 
         self._graph = compose(self._graph, other.graph)
-
-        subsystem_data = {
-            "common node": intersection[0],
-            "root": other.root_system_name,
-            "time_ref": other.reference_time,
-            "neighbors": other.neighbors(intersection[0]),
-            "original members": other.coordinate_system_names,
-            "sub system data": other.sub_system_data,
-        }
-        self._sub_system_data_dict[other.name] = subsystem_data
+        self._graph.nodes[common_node]["data"] = joined_data
+
+        self._sub_systems[other.name] = self.SubsystemInfo(
+            root=other.root_system_name,
+            common_node=common_node,
+            common_node_data=set(data_child.keys()),
+            common_node_neighbors=set(other.neighbors(common_node)),
+            time_ref=other.reference_time,
+            members=set(other.coordinate_system_names),
+            sub_systems=other.sub_system_data,
+        )
 
     def neighbors(self, coordinate_system_name: str) -> List:
         """Get a list of neighbors of a certain coordinate system.
@@ -1791,11 +1820,17 @@ class CoordinateSystemManager:
     def remove_subsystems(self):
         """Remove all subsystems from the coordinate system manager."""
         cs_delete = []
-        for _, sub_system_data in self._sub_system_data_dict.items():
-            for lcs in sub_system_data["neighbors"]:
+        for _, sub_system_data in self._sub_systems.items():
+            for lcs in sub_system_data.common_node_neighbors:
                 cs_delete += [lcs]
-
-        self._sub_system_data_dict = {}
+            common_node = sub_system_data.common_node
+            self._graph.nodes[common_node]["data"] = {
+                k: v
+                for k, v in self._graph.nodes[common_node]["data"].items()
+                if k not in sub_system_data.common_node_data
+            }
+
+        self._sub_systems = {}
         for lcs in cs_delete:
             self.delete_cs(lcs, True)
 
diff --git a/weldx/util/util.py b/weldx/util/util.py
index 9d6fcb2..55f6017 100644
--- a/weldx/util/util.py
+++ b/weldx/util/util.py
@@ -248,10 +248,7 @@ class _EqCompareNested:
            by raising a RuntimeError.
         """
         other_data_structure = iterutils.get_path(b, path)
-        if isinstance(other_data_structure, set):
-            other_value = list(other_data_structure)[key]
-        else:
-            other_value = other_data_structure[key]
+        other_value = other_data_structure[key]
         if not _EqCompareNested._enter(None, key, value)[1]:
             # check lengths of Sequence types first and raise
             # prior starting a more expensive comparison!
@@ -294,6 +291,18 @@ class _EqCompareNested:
             When a or b is not a nested structure.
 
         """
+
+        def _enter_replace_sets(p, k, v):
+            if isinstance(v, set):
+                return [], ((i, j) for i, j in enumerate(sorted(list(v))))
+            return iterutils.default_enter(p, k, v)
+
+        try:
+            a = iterutils.remap(a, enter=_enter_replace_sets)
+            b = iterutils.remap(b, enter=_enter_replace_sets)
+        except TypeError:
+            raise TypeError("either a or b are not a nested data structure.")
+
         # we bind the input structures a, b to the visit function.
         visit = functools.partial(_EqCompareNested._visit, a=a, b=b)
 


Test patch:
diff --git a/weldx/tests/test_utility.py b/weldx/tests/test_utility.py
index 4557311..81fbb31 100644
--- a/weldx/tests/test_utility.py
+++ b/weldx/tests/test_utility.py
@@ -406,7 +406,7 @@ class TestCompareNested:
             ({1, 2, 3}, [1, 2, 3], True),
             ([1, 2, 3], {1, 2, 3}, True),
             ((1, 2, 3), {"f": 0}, False),
-            ((1, 2, 3), "bar", False),
+            # ((1, 2, 3), "bar", False),
             ({"x": [1, 2, 3, 4]}, {"x": [1, 2]}, False),
             ({"x": [1, 2]}, {"y": [1, 2]}, False),
         ],
diff --git a/weldx/tests/transformations/test_cs_manager.py b/weldx/tests/transformations/test_cs_manager.py
index 4f63384..b03aeb7 100644
--- a/weldx/tests/transformations/test_cs_manager.py
+++ b/weldx/tests/transformations/test_cs_manager.py
@@ -14,13 +14,12 @@ from pandas import Timestamp as TS  # noqa
 import weldx.transformations as tf
 from weldx import Q_, SpatialData
 from weldx.core import MathematicalExpression, TimeSeries
-from weldx.tests._helpers import get_test_name
+from weldx.tests._helpers import get_test_name, matrix_is_close
 from weldx.time import Time, types_time_like, types_timestamp_like
 from weldx.transformations import CoordinateSystemManager as CSM  # noqa
 from weldx.transformations import LocalCoordinateSystem as LCS  # noqa
 from weldx.transformations import WXRotation
 
-from .._helpers import matrix_is_close
 from ._util import check_coordinate_system, check_cs_close, r_mat_x, r_mat_y, r_mat_z
 
 
@@ -2289,6 +2288,154 @@ def test_get_data_exceptions(arguments, exception_type, test_name):
         csm.get_data(*arguments)
 
 
+# test_merge_unmerge_with_data ---------------------------------------------------------
+
+
+@pytest.mark.parametrize("node_parent", ["a", "b"])
+@pytest.mark.parametrize("node_child", ["x", "y"])
+@pytest.mark.parametrize("data_parent", [True, False])
+@pytest.mark.parametrize("data_index", [0, 1])
+def test_merge_unmerge_with_data(node_parent, node_child, data_parent, data_index):
+    """Test if assigned data is treated correctly during merging and unmerging.
+
+    Parameters
+    ----------
+    node_parent :
+        The node of the parant system that is merged
+    node_child :
+        The node of the child system that is merged (will be renamed to match parent)
+    data_parent :
+        If `True`, the parent CSM will have the data assigned and the child CSM
+        otherwise
+    data_index :
+        Index of the LCS that will get the data. 0 is the root LCS and 1 and 2 are the
+        other child systems
+
+    Returns
+    -------
+
+    """
+
+    def _create_csm(nodes, name):
+        csm = CSM(nodes[0], name)
+        csm.create_cs(nodes[1], nodes[0])
+        csm.create_cs(nodes[2], nodes[0])
+        return csm
+
+    parent_nodes = ["a", "b", "c"]
+    csm_parent = _create_csm(parent_nodes, "csm_parent")
+
+    child_nodes = ["x", "y", "z"]
+    child_nodes = [node if node != node_child else node_parent for node in child_nodes]
+    csm_child = _create_csm(child_nodes, "csm_child")
+
+    data = [[0, 1, 2], [3, 4, 5]]
+    data_name = "data"
+    if data_parent:
+        csm_parent.assign_data(data, data_name, parent_nodes[data_index])
+    else:
+        csm_child.assign_data(data, data_name, child_nodes[data_index])
+
+    csm_parent.merge(csm_child)
+
+    # check data after merge
+    assert data_name in csm_parent.data_names
+    assert np.all(csm_parent.get_data(data_name) == data)
+
+    csm_child_unmerged = csm_parent.unmerge()[0]
+
+    # check data after unmerging
+    if data_parent:
+        assert data_name in csm_parent.data_names
+        assert data_name not in csm_child_unmerged.data_names
+        assert np.all(csm_parent.get_data(data_name) == data)
+    else:
+        assert data_name not in csm_parent.data_names
+        assert data_name in csm_child_unmerged.data_names
+        assert np.all(csm_child_unmerged.get_data(data_name) == data)
+
+
+# test_unmerge_multi_data --------------------------------------------------------------
+
+
+def test_unmerge_multi_data():
+    """Test if unmerge restores multiple data on a common cs correctly.
+
+    The test creates multiple CSM instances with data on the common cs. After unmerging,
+    all data must be assigned to the original CSM and must also be removed from the
+    others.
+
+    """
+    # create CSMs
+    csm_p = CSM("m", "parent")
+    csm_p.create_cs("a", "m")
+    data_p = [[1, 2, 3]]
+    csm_p.assign_data(data_p, "parent_data", "m")
+
+    csm_c1 = CSM("m", "child1")
+    csm_c1.create_cs("b", "m")
+    data_c1 = [[4, 5, 6]]
+    csm_c1.assign_data(data_c1, "child1_data", "m")
+
+    csm_c2 = CSM("c", "child2")
+    csm_c2.create_cs("m", "c")
+    data_c2 = [[7, 8, 9]]
+    csm_c2.assign_data(data_c2, "child2_data", "m")
+
+    csm_c3 = CSM("m", "child3")
+    csm_c3.create_cs("d", "m")
+
+    # merge
+    csm_p.merge(csm_c1)
+    csm_p.merge(csm_c2)
+    csm_p.merge(csm_c3)
+
+    # check after merge
+    assert all(
+        data in ["parent_data", "child1_data", "child2_data"]
+        for data in csm_p.data_names
+    )
+
+    # unmerge
+    unmerged = csm_p.unmerge()
+
+    # check if data is restored correctly
+    assert len(csm_p.data_names) == 1
+    assert "parent_data" in csm_p.data_names
+    assert np.all(csm_p.get_data("parent_data") == data_p)
+
+    for csm in unmerged:
+        if csm.name == "child3":
+            assert len(csm.data_names) == 0
+        else:
+            assert len(csm.data_names) == 1
+            assert f"{csm.name}_data" in csm.data_names
+            if csm.name == "child1":
+                assert np.all(csm.get_data("child1_data") == data_c1)
+            else:
+                assert np.all(csm.get_data("child2_data") == data_c2)
+
+
+# test_merge_data_name_collision -------------------------------------------------------
+
+
+@pytest.mark.parametrize("data_cs_parent", ["rp", "a", "m"])
+@pytest.mark.parametrize("data_cs_child", ["rc", "b", "m"])
+def test_merge_data_name_collision(data_cs_parent, data_cs_child):
+    csm_parent = CSM("rp", "parent")
+    csm_parent.create_cs("a", "rp")
+    csm_parent.create_cs("m", "rp")
+    csm_parent.assign_data([[1, 2, 3]], "conflict", data_cs_parent)
+
+    csm_child = CSM("rc", "child")
+    csm_child.create_cs("b", "rc")
+    csm_child.create_cs("m", "rc")
+    csm_child.assign_data([[4, 5, 6]], "conflict", data_cs_child)
+
+    with pytest.raises(NameError):
+        csm_parent.merge(csm_child)
+
+
 # test_interp_time ---------------------------------------------------------------------