Repository: martinfleis/momepy
Pull request #240 (2020-12-22 21:26:46)

ENH: more options in converting to networkx graphs
Allow specification of graph type (`Graph`, `DiGraph`, `MultiGraph`, MutliDiGraph`) and whether to compute angles in dual graphs.

Both functions are also refactored so it should also provide significant performance gain.

codecov[bot]: # [Codecov](https://codecov.io/gh/martinfleis/momepy/pull/240?src=pr&el=h1) Report
> Merging [#240](https://codecov.io/gh/martinfleis/momepy/pull/240?src=pr&el=desc) (f73b833) into [master](https://codecov.io/gh/martinfleis/momepy/commit/3287e9763fd1de2db9d8a113e1cfa2b1595540a9?el=desc) (3287e97) will **decrease** coverage by `0.25%`.
> The diff coverage is `92.85%`.

[![Impacted file tree graph](https://codecov.io/gh/martinfleis/momepy/pull/240/graphs/tree.svg?width=650&height=150&src=pr&token=i04999UxkK)](https://codecov.io/gh/martinfleis/momepy/pull/240?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master     #240      +/-   ##
==========================================
- Coverage   95.97%   95.72%   -0.26%     
==========================================
  Files          12       11       -1     
  Lines        2611     2617       +6     
==========================================
- Hits         2506     2505       -1     
- Misses        105      112       +7     
```


| [Impacted Files](https://codecov.io/gh/martinfleis/momepy/pull/240?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [momepy/utils.py](https://codecov.io/gh/martinfleis/momepy/pull/240/diff?src=pr&el=tree#diff-bW9tZXB5L3V0aWxzLnB5) | `97.60% <92.85%> (-1.71%)` | :arrow_down: |
| [momepy/graph.py](https://codecov.io/gh/martinfleis/momepy/pull/240/diff?src=pr&el=tree#diff-bW9tZXB5L2dyYXBoLnB5) | `96.56% <0.00%> (-1.53%)` | :arrow_down: |
| [momepy/\_\_init\_\_.py](https://codecov.io/gh/martinfleis/momepy/pull/240/diff?src=pr&el=tree#diff-bW9tZXB5L19faW5pdF9fLnB5) | | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/martinfleis/momepy/pull/240?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/martinfleis/momepy/pull/240?src=pr&el=footer). Last update [3287e97...f73b833](https://codecov.io/gh/martinfleis/momepy/pull/240?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/momepy/utils.py b/momepy/utils.py
index c4a4528..723061a 100644
--- a/momepy/utils.py
+++ b/momepy/utils.py
@@ -54,92 +54,200 @@ def _angle(a, b, c):
     return angle
 
 
-def _generate_primal(G, gdf_network, fields):
+def _generate_primal(G, gdf_network, fields, multigraph):
     """
     Generate primal graph.
     Helper for gdf_to_nx.
     """
     G.graph["approach"] = "primal"
     key = 0
-    for index, row in gdf_network.iterrows():
+    for row in gdf_network.itertuples():
         first = row.geometry.coords[0]
         last = row.geometry.coords[-1]
 
-        data = [row[f] for f in fields]
+        data = [r for r in row][1:]
         attributes = dict(zip(fields, data))
-        G.add_edge(first, last, key=key, **attributes)
-        key += 1
+        if multigraph:
+            G.add_edge(first, last, key=key, **attributes)
+            key += 1
+        else:
+            G.add_edge(first, last, **attributes)
 
 
-def _generate_dual(G, gdf_network, fields):
+def _generate_dual(G, gdf_network, fields, angles, multigraph, angle):
     """
     Generate dual graph
     Helper for gdf_to_nx.
     """
     G.graph["approach"] = "dual"
-    sw = libpysal.weights.Queen.from_dataframe(gdf_network)
-    gdf_network["mm_cent"] = gdf_network.geometry.centroid
+    key = 0
 
-    for i, (index, row) in enumerate(gdf_network.iterrows()):
-        centroid = (row.mm_cent.x, row.mm_cent.y)
-        data = [row[f] for f in fields]
+    sw = libpysal.weights.Queen.from_dataframe(gdf_network, silence_warnings=True)
+    cent = gdf_network.geometry.centroid
+    gdf_network["temp_x_coords"] = cent.x
+    gdf_network["temp_y_coords"] = cent.y
+
+    for i, row in enumerate(gdf_network.itertuples()):
+        centroid = (row.temp_x_coords, row.temp_y_coords)
+        data = [f for f in row][1:-2]
         attributes = dict(zip(fields, data))
         G.add_node(centroid, **attributes)
 
         if sw.cardinalities[i] > 0:
             for n in sw.neighbors[i]:
                 start = centroid
-                end = list(gdf_network.iloc[n]["mm_cent"].coords)[0]
+                end = (
+                    gdf_network["temp_x_coords"].iloc[n],
+                    gdf_network["temp_y_coords"].iloc[n],
+                )
                 p0 = row.geometry.coords[0]
                 p1 = row.geometry.coords[-1]
-                p2 = gdf_network.iloc[n]["geometry"].coords[0]
-                p3 = gdf_network.iloc[n]["geometry"].coords[-1]
+                geom = gdf_network.geometry.iloc[n]
+                p2 = geom.coords[0]
+                p3 = geom.coords[-1]
                 points = [p0, p1, p2, p3]
                 shared = [x for x in points if points.count(x) > 1]
                 if shared:  # fix for non-planar graph
                     remaining = [e for e in points if e not in [shared[0]]]
                     if len(remaining) == 2:
-                        angle = _angle(remaining[0], shared[0], remaining[1])
-                        G.add_edge(start, end, key=0, angle=angle)
+                        if angles:
+                            angle_value = _angle(remaining[0], shared[0], remaining[1])
+                            if multigraph:
+                                G.add_edge(start, end, key=0, **{angle: angle_value})
+                                key += 1
+                            else:
+                                G.add_edge(start, end, **{angle: angle_value})
+                        else:
+                            if multigraph:
+                                G.add_edge(start, end, key=0)
+                                key += 1
+                            else:
+                                G.add_edge(start, end)
+
+
+def gdf_to_nx(
+    gdf_network,
+    approach="primal",
+    length="mm_len",
+    multigraph=True,
+    directed=False,
+    angles=True,
+    angle="angle",
+):
+    """
+    Convert LineString GeoDataFrame to networkx.MultiGraph or other Graph as per
+    specification.
 
+    Preserves columns as edge or node attributes (depending on the ``approach``).
+    Index is not preserved.
 
-def gdf_to_nx(gdf_network, approach="primal", length="mm_len"):
-    """
-    Convert LineString GeoDataFrame to networkx.MultiGraph
+    See the User Guide page :doc:`../../user_guide/graph/convert` for details.
 
     Parameters
     ----------
     gdf_network : GeoDataFrame
         GeoDataFrame containing objects to convert
     approach : str, default 'primal'
-        Decide wheter genereate ``'primal'`` or ``'dual'`` graph.
-    length : str, default mm_len
+        Allowed options are ``'primal'`` or ``'dual'``. Primal graph represents
+        endpoints as nodes and LineStrings as edges, dual graph represents
+        LineStrings as nodes and their topological relation as edges. In such a
+        case, it can encode an angle between LineStrings as an edge attribute.
+    length : str, default 'mm_len'
         name of attribute of segment length (geographical) which will be saved to graph
+    multigraph : bool, default True
+        create ``MultiGraph`` of ``Graph`` (potentially directed). ``MutliGraph`` allows multiple
+        edges between any pair of nodes, which is a common case in street networks.
+    directed : bool, default False
+        create directed graph (``DiGraph`` or ``MultiDiGraph``). Directionality follows
+        the order of LineString coordinates.
+    angles : bool, default True
+        capture angles between LineStrings as an attribute of a dual graph. Ignored if
+        ``approach="primal"``.
+    length : str, default 'angle'
+        name of attribute of angle between LineStrings which will be saved to graph. Ignored if
+        ``approach="primal"``.
 
     Returns
     -------
-    networkx.Graph
-        Graph
+    networkx.Graph,
+    networkx.MultiGraph,
+    networkx.DiGraph,
+    networkx.MultiDiGraph
+        Graph as per specification
+
+    See also
+    --------
+    nx_to_gdf
+
+    Examples
+    --------
+    >>> import geopandas as gpd
+    >>> df = gpd.read_file(momepy.datasets.get_path('bubenec'), layer='streets')
+    >>> df.head(5)
+                                                geometry
+    0  LINESTRING (1603585.640 6464428.774, 1603413.2...
+    1  LINESTRING (1603268.502 6464060.781, 1603296.8...
+    2  LINESTRING (1603607.303 6464181.853, 1603592.8...
+    3  LINESTRING (1603678.970 6464477.215, 1603675.6...
+    4  LINESTRING (1603537.194 6464558.112, 1603557.6...
+    
+    Primal graph:
+
+    >>> G = momepy.gdf_to_nx(df)
+    >>> G
+    <networkx.classes.multigraph.MultiGraph object at 0x7f8cf90fad50>
+    
+    >>> G_directed = momepy.gdf_to_nx(df, directed=True)
+    >>> G_directed
+    <networkx.classes.multidigraph.MultiDiGraph object at 0x7f8cf90f56d0>
+    
+    >>> G_digraph = momepy.gdf_to_nx(df, multigraph=False, directed=True)
+    >>> G_digraph
+    <networkx.classes.digraph.DiGraph object at 0x7f8cf9150c10>
+    
+    >>> G_graph = momepy.gdf_to_nx(df, multigraph=False, directed=False)
+    >>> G_graph
+    <networkx.classes.graph.Graph object at 0x7f8cf90facd0>
+
+    Dual graph:
+
+    >>> G_dual = momepy.gdf_to_nx(df, approach="dual")
+    >>> G_dual
+    <networkx.classes.multigraph.MultiGraph object at 0x7f8cf9150fd0>
+
 
     """
     gdf_network = gdf_network.copy()
     if "key" in gdf_network.columns:
         gdf_network.rename(columns={"key": "__key"}, inplace=True)
-    # generate graph from GeoDataFrame of LineStrings
-    net = nx.MultiGraph()
+
+    if multigraph and directed:
+        net = nx.MultiDiGraph()
+    elif multigraph and not directed:
+        net = nx.MultiGraph()
+    elif not multigraph and directed:
+        net = nx.DiGraph()
+    else:
+        net = nx.Graph()
+
     net.graph["crs"] = gdf_network.crs
     gdf_network[length] = gdf_network.geometry.length
     fields = list(gdf_network.columns)
 
     if approach == "primal":
-        _generate_primal(net, gdf_network, fields)
+        _generate_primal(net, gdf_network, fields, multigraph)
 
     elif approach == "dual":
-        _generate_dual(net, gdf_network, fields)
+        if directed:
+            raise ValueError("Directed graphs are not supported in dual approach.")
+
+        _generate_dual(
+            net, gdf_network, fields, angles=angles, multigraph=multigraph, angle=angle
+        )
 
     else:
         raise ValueError(
-            "Approach {} is not supported. Use 'primal' or 'dual'.".format(approach)
+            f"Approach {approach} is not supported. Use 'primal' or 'dual'."
         )
 
     return net
@@ -222,7 +330,12 @@ def _dual_to_gdf(net):
 
 def nx_to_gdf(net, points=True, lines=True, spatial_weights=False, nodeID="nodeID"):
     """
-    Convert ``networkx.Graph`` to LineString GeoDataFrame and Point GeoDataFrame
+    Convert ``networkx.Graph`` to LineString GeoDataFrame and Point GeoDataFrame.
+
+    Automatically detects an ``approach`` of the graph and assignes edges and nodes to
+    relevant geometry type.
+
+    See the User Guide page :doc:`../../user_guide/graph/convert` for details.
 
     Parameters
     ----------
@@ -233,15 +346,55 @@ def nx_to_gdf(net, points=True, lines=True, spatial_weights=False, nodeID="nodeI
     lines : bool
         export line-based gdf representing streets
     spatial_weights : bool
-        export libpysal spatial weights for nodes
+        export libpysal spatial weights for nodes (only for primal graphs)
     nodeID : str
         name of node ID column to be generated
 
     Returns
     -------
     GeoDataFrame
-        Selected gdf or tuple of both gdf or tuple of gdfs and weights
-
+        Selected gdf or tuple of both gdfs or tuple of gdfs and weights
+
+    See also
+    --------
+    gdf_to_nx
+
+    Examples
+    --------
+    >>> import geopandas as gpd
+    >>> df = gpd.read_file(momepy.datasets.get_path('bubenec'), layer='streets')
+    >>> df.head(2)
+                                                geometry
+    0  LINESTRING (1603585.640 6464428.774, 1603413.2...
+    1  LINESTRING (1603268.502 6464060.781, 1603296.8...
+    >>> G = momepy.gdf_to_nx(df)
+
+    Converting primal Graph to points as intersections and lines as street segments:
+
+    >>> points, lines = momepy.nx_to_gdf(G)
+    >>> points.head(2)
+       nodeID                         geometry
+    0       1  POINT (1603585.640 6464428.774)
+    1       2  POINT (1603413.206 6464228.730)
+    >>> lines.head(2)
+                                                geometry      mm_len  node_start  node_end
+    0  LINESTRING (1603585.640 6464428.774, 1603413.2...  264.103950           1         2
+    1  LINESTRING (1603561.740 6464494.467, 1603564.6...   70.020202           1         9
+
+    Storing relationship between points/nodes as libpysal W object:
+
+    >>> points, lines, W = momepy.nx_to_gdf(G, spatial_weights=True)
+    >>> W
+    <libpysal.weights.weights.W object at 0x7f8d01837210>
+
+    Converting dual Graph to lines. Dual Graph does not export edges to GDF:
+
+    >>> G = momepy.gdf_to_nx(df, approach="dual")
+    >>> lines = momepy.nx_to_gdf(G)
+    >>> lines.head(2)
+                                                geometry      mm_len
+    0  LINESTRING (1603585.640 6464428.774, 1603413.2...  264.103950
+    1  LINESTRING (1603607.303 6464181.853, 1603592.8...  199.746503
     """
     # generate nodes and edges geodataframes from graph
     primal = None
@@ -252,9 +405,7 @@ def nx_to_gdf(net, points=True, lines=True, spatial_weights=False, nodeID="nodeI
             return _dual_to_gdf(net)
         else:
             raise ValueError(
-                "Approach {} is not supported. Use 'primal' or 'dual'.".format(
-                    net.graph["approach"]
-                )
+                f"Approach {net.graph['approach']} is not supported. Use 'primal' or 'dual'."
             )
 
     if not primal:


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 84c9fee..93b755b 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -36,6 +36,60 @@ class TestUtils:
         with pytest.raises(ValueError):
             mm.gdf_to_nx(self.df_streets, approach="nonexistent")
 
+        nx = mm.gdf_to_nx(self.df_streets, multigraph=False)
+        assert isinstance(nx, networkx.Graph)
+        assert nx.number_of_nodes() == 29
+        assert nx.number_of_edges() == 35
+
+        nx = mm.gdf_to_nx(self.df_streets, multigraph=False, directed=True)
+        assert isinstance(nx, networkx.DiGraph)
+        assert nx.number_of_nodes() == 29
+        assert nx.number_of_edges() == 35
+
+        nx = mm.gdf_to_nx(self.df_streets, directed=True)
+        assert isinstance(nx, networkx.MultiDiGraph)
+        assert nx.number_of_nodes() == 29
+        assert nx.number_of_edges() == 35
+
+        dual = mm.gdf_to_nx(self.df_streets, approach="dual", angles=False)
+        assert (
+            dual.edges[
+                (1603499.42326969, 6464328.7520580515),
+                (1603510.1061735682, 6464204.555117119),
+                0,
+            ]
+            == {}
+        )
+
+        dual = mm.gdf_to_nx(self.df_streets, approach="dual", angle="ang")
+        assert dual.edges[
+            (1603499.42326969, 6464328.7520580515),
+            (1603510.1061735682, 6464204.555117119),
+            0,
+        ] == {"ang": 1.0963654487814474}
+
+        dual = mm.gdf_to_nx(
+            self.df_streets, approach="dual", angles=False, multigraph=False
+        )
+        assert isinstance(nx, networkx.Graph)
+        assert (
+            dual.edges[
+                (1603499.42326969, 6464328.7520580515),
+                (1603510.1061735682, 6464204.555117119),
+            ]
+            == {}
+        )
+
+        dual = mm.gdf_to_nx(self.df_streets, approach="dual", multigraph=False)
+        assert isinstance(nx, networkx.Graph)
+        assert dual.edges[
+            (1603499.42326969, 6464328.7520580515),
+            (1603510.1061735682, 6464204.555117119),
+        ] == {"angle": 1.0963654487814474}
+
+        with pytest.raises(ValueError):
+            mm.gdf_to_nx(self.df_streets, approach="dual", directed=True)
+
     def test_nx_to_gdf(self):
         nx = mm.gdf_to_nx(self.df_streets)
         nodes, edges, W = mm.nx_to_gdf(nx, spatial_weights=True)