Repository: networkx/networkx
Pull request: Fix for issue #7645: Do not preserve 'cw' and 'ccw' attributes in PlanarEmbedding.to_undirected()
I propose this fix for [issue #7645](https://github.com/networkx/networkx/issues/7645). The changes ensure that the 'cw' and 'ccw' attributes are not preserved for both graphs and multi-graphs in the result of PlanarEmbedding.to_undirected().

I have included tests to verify that the issue is resolved.

I would be grateful for any feedback or suggestions to improve this PR. I am more than happy to incorporate any necessary changes based on the review. Thank you for your time and guidance!

Fixes #7645 

Problem statement:
`PlanarEmbedding.to_undirected()` result in a `Graph` whose edges keep the 'cw' and 'ccw' attributes of one of the half-edges.
### Current Behavior

`PlanarEmbedding.to_undirected()` result in a `Graph` whose edges keep the 'cw' and 'ccw' attributes of one of the half-edges.

### Expected Behavior

It is documented that 'overlapping' directed edges result in and unpredictable edge attribute dict being picked for the undirected edge, no surprise there.

Since the 'cw' and 'ccw' edge attributes do not make any sense in the undirected graph, I think `PlanarEmbedding.to_undirected()` should strip these attributes from all edges.

Maybe `DiGraph.to_undirected()` could handle 'overlapping' edges' attributes in a defined way, such as always dropping them (unless they are equal?).

### Steps to Reproduce

```python
import networkx as nx
G = nx.Graph(((0, 1), (1, 2), (2, 3), (3, 0), (0, 2)))
is_planar, P = nx.check_planarity(G)
is_planar
```
    True

```python
P[0][2], P[2][0]
```
    ({'cw': 1, 'ccw': 3}, {'ccw': 1, 'cw': 3})

```python
U = P.to_undirected()
U[0][2]
```
    {'cw': 3, 'ccw': 1}

### Environment

Python version: 3.10
NetworkX version: 3.3

Hints from issue discussion:

Commit message: Review findings: Improve documentation. Add exceptions for 'as_view' and 'reciprocal' and corresponding tests.

Patch to review:
diff --git a/networkx/algorithms/planarity.py b/networkx/algorithms/planarity.py
index 17d0bec5a16..ce8720c2a32 100644
--- a/networkx/algorithms/planarity.py
+++ b/networkx/algorithms/planarity.py
@@ -1,4 +1,5 @@
 from collections import defaultdict
+from copy import deepcopy
 
 import networkx as nx
 
@@ -1400,3 +1401,65 @@ def copy(self, as_view=False):
             for v, datadict in nbrs.items()
         )
         return G
+
+    def to_undirected(self, reciprocal=False, as_view=False):
+        """
+         Returns a non-embedding undirected representation of the graph.
+
+         This method strips the planar embedding information and provides
+         a simple undirected graph representation. While creating the undirected graph,
+         all edge attributes are retained except the `"cw"` and `"ccw"` attributes
+         which are removed from the edge data. Those attributes are specific to
+         the requirements of planar embeddings.
+
+         Parameters
+         ----------
+         reciprocal : bool (optional)
+             Not supported for PlanarEmbedding. This parameter raises an exception
+             if used. All valid embeddings include half-edges by definition, making this
+             parameter unnecessary."
+         as_view : bool (optional, default=False)
+             Not supported for PlanarEmbedding. This parameter raises an exception
+             if used.
+
+         Returns
+         -------
+         G : Graph
+             An undirected graph with the same name and nodes as the PlanarEmbedding.
+             Edges are included with their data, except for the `"cw"` and `"ccw"` attributes,
+             which are omitted.
+
+
+         Notes
+         -----
+        - If edges exist in both directions (u, v) and (v, u) in the PlanarEmbedding,
+             attributes for the resulting undirected edge will be combined, excluding `"cw"`
+             and `"ccw"`.
+         - A "deepcopy" is made of the other edge attributes as well as the
+             node and graph attributes, ensuring independence of the resulting graph.
+         - Subclass-specific data structures used in the original graph may not transfer
+             to the undirected graph. The resulting graph will be of type ``nx.Graph``.
+        """
+
+        if reciprocal:
+            raise ValueError(
+                "'reciprocal=True' is not supported for PlanarEmbedding. "
+                "All valid embeddings include half-edges by definition, making this "
+                "parameter unnecessary."
+            )
+
+        if as_view:
+            raise ValueError("'as_view=True' is not supported for PlanarEmbedding.")
+
+        graph_class = self.to_undirected_class()
+        if as_view is True:
+            return nx.graphviews.generic_graph_view(self, graph_class)
+        G = graph_class()
+        G.graph.update(deepcopy(self.graph))
+        G.add_nodes_from((n, deepcopy(d)) for n, d in self._node.items())
+        G.add_edges_from(
+            (u, v, {k: deepcopy(v) for k, v in d.items() if k not in {"cw", "ccw"}})
+            for u, nbrs in self._adj.items()
+            for v, d in nbrs.items()
+        )
+        return G
diff --git a/networkx/algorithms/tests/test_planarity.py b/networkx/algorithms/tests/test_planarity.py
index 99bcff4184a..6e245055f0c 100644
--- a/networkx/algorithms/tests/test_planarity.py
+++ b/networkx/algorithms/tests/test_planarity.py
@@ -291,6 +291,27 @@ def test_edge_removal_from_planar_embedding(self):
         P.remove_edges_from(((0, 3), (1, 3)))
         self.check_graph(P, is_planar=True)
 
+    @pytest.mark.parametrize("graph_type", (nx.Graph, nx.MultiGraph))
+    def test_graph_planar_embedding_to_undirected(self, graph_type):
+        G = graph_type([(0, 1), (0, 1), (1, 2), (2, 3), (3, 0), (0, 2)])
+        is_planar, P = nx.check_planarity(G)
+        assert is_planar
+        U = P.to_undirected()
+        assert isinstance(U, nx.Graph)
+        assert all((d == {} for _, _, d in U.edges(data=True)))
+
+    @pytest.mark.parametrize(
+        "reciprocal, as_view", [(True, True), (True, False), (False, True)]
+    )
+    def test_planar_embedding_to_undirected_invalid_parameters(
+        self, reciprocal, as_view
+    ):
+        G = nx.Graph([(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)])
+        is_planar, P = nx.check_planarity(G)
+        assert is_planar
+        with pytest.raises(ValueError, match="is not supported for PlanarEmbedding."):
+            P.to_undirected(reciprocal=reciprocal, as_view=as_view)
+
 
 def check_embedding(G, embedding):
     """Raises an exception if the combinatorial embedding is not correct


Code review comments:
diff_hunk: @@ -1400,3 +1401,65 @@ def copy(self, as_view=False):
             for v, datadict in nbrs.items()
         )
         return G
+
+    def to_undirected(self, reciprocal=False, as_view=False):
+        """
+         Returns a non-embedding undirected representation of the graph.
+
+         This method strips the planar embedding information and provides
+         a simple undirected graph representation. While creating the undirected graph,
+         all edge attributes are retained except the `"cw"` and `"ccw"` attributes
+         which are removed from the edge data. Those attributes are specific to
+         the requirements of planar embeddings.
+
+         Parameters
+         ----------
+         reciprocal : bool (optional)
+             Not supported for PlanarEmbedding. This parameter raises an exception
+             if used. All valid embeddings include half-edges by definition, making this
+             parameter unnecessary."
+         as_view : bool (optional, default=False)
+             Not supported for PlanarEmbedding. This parameter raises an exception
+             if used.
+
+         Returns
+         -------
+         G : Graph
+             An undirected graph with the same name and nodes as the PlanarEmbedding.
+             Edges are included with their data, except for the `"cw"` and `"ccw"` attributes,
+             which are omitted.
+
+
+         Notes
+         -----
+        - If edges exist in both directions (u, v) and (v, u) in the PlanarEmbedding,
+             attributes for the resulting undirected edge will be combined, excluding `"cw"`
+             and `"ccw"`.
+         - A "deepcopy" is made of the other edge attributes as well as the
+             node and graph attributes, ensuring independence of the resulting graph.
+         - Subclass-specific data structures used in the original graph may not transfer
+             to the undirected graph. The resulting graph will be of type ``nx.Graph``.
+        """
+
+        if reciprocal:
+            raise ValueError(
+                "'reciprocal=True' is not supported for PlanarEmbedding. "
+                "All valid embeddings include half-edges by definition, making this "
+                "parameter unnecessary."
path: networkx/algorithms/planarity.py
text: @user1:
```suggestion
                "All valid embeddings include reciprocal half-edges by definition, "
                "making this parameter unnecessary."
```

diff_hunk: @@ -1400,3 +1401,65 @@ def copy(self, as_view=False):
             for v, datadict in nbrs.items()
         )
         return G
+
+    def to_undirected(self, reciprocal=False, as_view=False):
+        """
+         Returns a non-embedding undirected representation of the graph.
+
+         This method strips the planar embedding information and provides
+         a simple undirected graph representation. While creating the undirected graph,
+         all edge attributes are retained except the `"cw"` and `"ccw"` attributes
+         which are removed from the edge data. Those attributes are specific to
+         the requirements of planar embeddings.
+
+         Parameters
+         ----------
+         reciprocal : bool (optional)
+             Not supported for PlanarEmbedding. This parameter raises an exception
+             if used. All valid embeddings include half-edges by definition, making this
+             parameter unnecessary."
+         as_view : bool (optional, default=False)
+             Not supported for PlanarEmbedding. This parameter raises an exception
+             if used.
+
+         Returns
+         -------
+         G : Graph
+             An undirected graph with the same name and nodes as the PlanarEmbedding.
+             Edges are included with their data, except for the `"cw"` and `"ccw"` attributes,
+             which are omitted.
+
+
+         Notes
+         -----
+        - If edges exist in both directions (u, v) and (v, u) in the PlanarEmbedding,
+             attributes for the resulting undirected edge will be combined, excluding `"cw"`
+             and `"ccw"`.
+         - A "deepcopy" is made of the other edge attributes as well as the
+             node and graph attributes, ensuring independence of the resulting graph.
+         - Subclass-specific data structures used in the original graph may not transfer
+             to the undirected graph. The resulting graph will be of type ``nx.Graph``.
+        """
+
+        if reciprocal:
+            raise ValueError(
+                "'reciprocal=True' is not supported for PlanarEmbedding. "
+                "All valid embeddings include half-edges by definition, making this "
+                "parameter unnecessary."
+            )
+
+        if as_view:
+            raise ValueError("'as_view=True' is not supported for PlanarEmbedding.")
+
+        graph_class = self.to_undirected_class()
+        if as_view is True:
+            return nx.graphviews.generic_graph_view(self, graph_class)
path: networkx/algorithms/planarity.py
text: @user1:
```suggestion
```

diff_hunk: @@ -1400,3 +1401,65 @@ def copy(self, as_view=False):
             for v, datadict in nbrs.items()
         )
         return G
+
+    def to_undirected(self, reciprocal=False, as_view=False):
+        """
+         Returns a non-embedding undirected representation of the graph.
+
+         This method strips the planar embedding information and provides
+         a simple undirected graph representation. While creating the undirected graph,
+         all edge attributes are retained except the `"cw"` and `"ccw"` attributes
+         which are removed from the edge data. Those attributes are specific to
+         the requirements of planar embeddings.
+
+         Parameters
+         ----------
+         reciprocal : bool (optional)
+             Not supported for PlanarEmbedding. This parameter raises an exception
+             if used. All valid embeddings include half-edges by definition, making this
+             parameter unnecessary."
path: networkx/algorithms/planarity.py
text: @user1:
```suggestion
             if used. All valid embeddings include reciprocal half-edges by definition,
             making this parameter unnecessary.
```