Repository: PMEAL/OpenPNM
Pull request #737 (2017-10-20 15:29:22)

Improved networkx compatibility
This Fork and PR stuff if very confusing....I already did this once, and we had a long conversation that is now gone.  Oh well, this PR seems to have both @ma-sadeghi and Joost's code


Patch:
diff --git a/OpenPNM/Utilities/IO.py b/OpenPNM/Utilities/IO.py
index 924c935c4..b4aeec8ec 100644
--- a/OpenPNM/Utilities/IO.py
+++ b/OpenPNM/Utilities/IO.py
@@ -5,6 +5,7 @@ import scipy as _sp
 import numpy as _np
 import pandas as _pd
 import yaml as _yaml
+import networkx as _nx
 import OpenPNM
 from OpenPNM.Utilities import misc as _misc
 from OpenPNM.Base import logging
@@ -825,43 +826,44 @@ class CSV(GenericIO):
 class NetworkX(GenericIO):
     r"""
     This class is meant specifcally for exchanging data with NetworkX, which
-    is a common tool for dealing with network structures.  A network object
-    in NetworkX has a ``to_yaml`` method which produces the correct file format
-    for use here.
+    is a common tool for dealing with network structures.
 
     Notes
     -----
-    1. Each node in a NetworkX object (i.e. ``net``) can be assigned properties
-    using syntax like ``net.node[n]['diameter'] = 0.5`` where ``n`` is the
+    1. Each node in a NetworkX object (i.e. ``G``) can be assigned properties
+    using syntax like ``G.node[n]['diameter'] = 0.5`` where ``n`` is the
     node number.  There is no need to precede the property name with any
     indication that it is pore data such as \'pore\_\'.  OpenPNM will prepend
     \'pore.\' to each property name.
 
     2. Since \'pore.coords\' is so central to OpenPNM it should be specified
     in the NetworkX object as \'coords\', and the [X, Y, Z] coordinates of
-    each node should be a 3x1 list.
+    each node should be a 1x3 list.
 
     3. Edges in a NetworkX object are accessed using the index numbers of the
-    two nodes it connects, such as ``net.edge[2][3]['length'] = 0.1``
+    two nodes it connects, such as ``G.adj[2][3]['length'] = 0.1``
     indicating the edge that connects nodes 2 and 3.  There is no need to
     precede the property name with any indication that it is throat data such
     as \'throat\_\'.  OpenPNM will prepend \'throat.\' to each property name.
 
     4. The \'throat.conns\' property is essential to OpenPNM, but this does NOT
     need to be specified explicitly as a property in NetworkX.  The
-    connectivity is embedded into the network representation in the 'yaml' file
-    and is extracted by OpenPNM.
+    connectivity is embedded into the network representation and is extracted
+    by OpenPNM.
     """
 
     @classmethod
-    def load(cls, filename, network=None, return_geometry=False):
+    def load(cls, G, network=None, return_geometry=False):
         r"""
-        Add data to an OpenPNM Network from a NetworkX generated YAML file.
+        Add data to an OpenPNM Network from a undirected NetworkX graph object.
 
         Parameters
         ----------
-        filename : string
-            The yaml file containing the NetworkX data
+        G : networkx.classes.graph.Graph Object
+            The NetworkX graph. G should be undirected. The numbering of nodes
+            should be numeric (int's), zero-based and should not contain any
+            gaps, i.e. ``G.nodes() = [0,1,3,4,5]`` is not allowed and should be
+            mapped to ``G.nodes() = [0,1,2,3,4]``.
 
         network : OpenPNM Network Object
             The OpenPNM Network onto which the data should be loaded.  If no
@@ -884,32 +886,49 @@ class NetworkX(GenericIO):
         If return_geometry is True, then a tuple is returned containing both
         the network and a geometry object.
 
+        Notes
+        -----
+        This function used to accept a path to a YAML file that was created by
+        NetworkX.  The functionality has been changed, so that now you pass
+        an actual NetworkX graph object.  If you have a network saved as a
+        YAML file, you can open it in NetworkX first then pass the resulting
+        object to this function.  This change was made since NetworkX moved to
+        version 2 and the YAML file format changed.
+
         """
         net = {}
 
-        # Open file and read first line, to prevent NetworkX instantiation
-        with cls._read_file(filename=filename, ext='yaml') as f:
-            line = f.readline()
-            if line.startswith('!!python/object:networkx.classes.graph.Graph'):
-                a = _yaml.safe_load(f)
-            else:
-                raise ('Provided file does not appear to be a NetworkX file')
+        # Ensure G is an undirected networkX graph with numerically numbered
+        # nodes for which numbering starts at 0 and does not contain any gaps
+        if not isinstance(G, _nx.Graph):
+            raise ('Provided object is not a NetworkX graph.')
+        if _nx.is_directed(G):
+            raise ('Provided graph is directed. Convert to undirected graph.')
+        if not all(isinstance(n, int) for n in G.nodes()):
+            raise ('Node numbering is not numeric. Convert to int.')
+        if min(G.nodes()) != 0:
+            raise ('Node numbering does not start at zero.')
+        if max(G.nodes()) + 1 != len(G.nodes()):
+            raise ('Node numbering contains gaps. Map nodes to remove gaps.')
 
         # Parsing node data
-        Np = len(a['node'])
+        Np = len(G)
         net.update({'pore.all': _sp.ones((Np,), dtype=bool)})
-        for n in a['node'].keys():
-            props = a['node'][n]
+        for n, props in G.nodes(data=True):
             for item in props.keys():
-                val = a['node'][n][item]
+                val = props[item]
                 dtype = type(val)
                 # Remove prepended pore. and pore_ if present
                 for b in ['pore.', 'pore_']:
                     item = item.replace(b, '')
                 # Create arrays for subsequent indexing, if not present already
                 if 'pore.'+item not in net.keys():
-                    if dtype is list:
+                    if dtype == str:  # handle strings of arbitrary length
+                        net['pore.'+item] = _sp.ndarray((Np,), dtype='object')
+                    elif dtype is list:
                         dtype = type(val[0])
+                        if dtype == str:
+                            dtype = 'object'
                         cols = len(val)
                         net['pore.'+item] = _sp.ndarray((Np, cols), dtype=dtype)
                     else:
@@ -918,14 +937,12 @@ class NetworkX(GenericIO):
 
         # Parsing edge data
         # Deal with conns explicitly
-
-        conns = []
-        for n in a['edge'].keys():
-            neighbors = a['edge'][n].keys()
-            conns.extend([sorted([i, n]) for i in neighbors])
-        # Remove duplicate pairs from conns and sort
+        try:
+            conns = list(G.edges)  # NetworkX V2
+        except:
+            conns = G.edges()  # NetworkX V1
         conns.sort()
-        conns = list(conns for conns, _ in _itertools.groupby(conns))
+
         # Add conns to Network
         Nt = len(conns)
         net.update({'throat.all': _sp.ones(Nt, dtype=bool)})
@@ -934,7 +951,7 @@ class NetworkX(GenericIO):
         # Scan through each edge and extract all its properties
         i = 0
         for t in conns:
-            props = a['edge'][t[0]][t[1]]
+            props = G[t[0]][t[1]]
             for item in props:
                 val = props[item]
                 dtype = type(val)
@@ -943,8 +960,12 @@ class NetworkX(GenericIO):
                     item = item.replace(b, '')
                 # Create arrays for subsequent indexing, if not present already
                 if 'throat.'+item not in net.keys():
+                    if dtype == str:
+                        net['throat.'+item] = _sp.ndarray((Nt,), dtype='object')
                     if dtype is list:
                         dtype = type(val[0])
+                        if dtype == str:
+                            dtype = 'object'
                         cols = len(val)
                         net['throat.'+item] = _sp.ndarray((Nt, cols), dtype=dtype)
                     else:
@@ -958,6 +979,67 @@ class NetworkX(GenericIO):
                                       return_geometry=return_geometry)
         return network
 
+    @classmethod
+    def save(cls, network, phases=[]):
+        r"""
+        Write Network to a NetworkX object.
+        Write OpenPNM Network to a NetworkX object.
+        Parameters
+        ----------
+        network : OpenPNM Network Object
+            The OpenPNM Network to be exported to a NetworkX object
+
+        phases : list of phase objects ([])
+            Phases that have properties we want to write to NetworkX object
+
+        Returns
+        -------
+        A NetworkX object with all pore/throat properties attached to it
+
+        """
+        # Ensure phases is a list
+        if type(phases) is not list:
+            phases = [phases]
+        # Ensure network is an OpenPNM Network object.
+        if not isinstance(network,
+                          OpenPNM.Network.__GenericNetwork__.GenericNetwork):
+            raise('Provided network is not an OpenPNM Network.')
+
+        G = _nx.Graph()
+
+        # Extracting node list and connectivity matrix from Network
+        nodes = map(int, network.Ps)
+        conns = network['throat.conns']
+
+        # Explicitly add nodes and connectivity matrix
+        G.add_nodes_from(nodes)
+        G.add_edges_from(conns)
+
+        # Attach Network properties to G
+        for prop in network.props(mode=['all', 'deep']) + network.labels():
+            if 'pore.' in prop:
+                if len(network[prop].shape) > 1:
+                    val = {i: list(network[prop][i]) for i in network.Ps}
+                else:
+                    val = {i: network[prop][i] for i in network.Ps}
+                _nx.set_node_attributes(G, name=prop[5:], values=val)
+            if 'throat.' in prop:
+                val = {tuple(conn): network[prop][i] for i, conn in enumerate(conns)}
+                _nx.set_edge_attributes(G, name=prop[7:], values=val)
+
+        # Attach Phase properties to G
+        for phase in phases:
+            props = phase.props(mode=['all', 'deep']) + phase.labels()
+            for prop in props:
+                if 'pore.' in prop:
+                    val = {i: phase[prop][i] for i in network.Ps}
+                    _nx.set_node_attributes(G, name=prop[5:], values=val)
+                if 'throat.' in prop:
+                    val = {tuple(conn): phase[prop][i] for i, conn in
+                           enumerate(conns)}
+                    _nx.set_edge_attributes(G, name=prop[7:], values=val)
+        return G
+
 
 class iMorph(GenericIO):
     r"""


Test patch:
diff --git a/test/fixtures/test_load_yaml.yaml b/test/fixtures/test_load_yaml.yaml
deleted file mode 100644
index 669df082a..000000000
--- a/test/fixtures/test_load_yaml.yaml
+++ /dev/null
@@ -1,83 +0,0 @@
-!!python/object:networkx.classes.graph.Graph
-adj: &id013
-  0:
-    2: &id001 {label: false, length: 0.21752458390920681, perimeter: 3.0638366387691693}
-    6: &id005 {label: false, length: 1.5637023340600673, perimeter: 3.2198678173967132}
-    8: &id009 {label: false, length: 0.6039271769563883, perimeter: 1.3052515755309098}
-  1:
-    5: &id004 {label: true, length: 1.386634239942217, perimeter: 3.7258329368376337}
-    6: &id006 {label: false, length: 0.34437587129704994, perimeter: 3.132397798526016}
-    8: &id010 {label: true, length: 0.4976669850244455, perimeter: 0.26344576977486645}
-  2:
-    0: *id001
-    4: &id002 {label: true, length: 1.1822407620834836, perimeter: 1.3349794808519069}
-  3:
-    4: &id003 {label: true, length: 0.9055917266142521, perimeter: 3.7054006852183194}
-    7: &id007 {label: false, length: 1.5458455589187055, perimeter: 2.869933891361035}
-  4:
-    2: *id002
-    3: *id003
-    8: &id011 {label: false, length: 1.3938269141290733, perimeter: 0.5525891846217665}
-  5:
-    1: *id004
-    7: &id008 {label: true, length: 0.37370976216193297, perimeter: 3.4733827144068057}
-  6:
-    0: *id005
-    1: *id006
-  7:
-    3: *id007
-    5: *id008
-    8: &id012 {label: false, length: 1.009011283152443, perimeter: 0.7297291253184279}
-  8:
-    0: *id009
-    1: *id010
-    4: *id011
-    7: *id012
-edge: *id013
-graph: {name: (grid_2d_graph)_with_int_labels}
-node:
-  0:
-    area: 0.778751390961991
-    coords: [0.8416597426977945, 7.706111308769752, 0.9522482793682019]
-    diameter: 0.06625774613226765
-    label: false
-  1:
-    area: 1.87516038094891
-    coords: [8.302239694264959, 5.0815328284220715, 6.419236167370665]
-    diameter: 0.35091044754599576
-    label: false
-  2:
-    area: 3.6697611399317513
-    coords: [0.3001022054180502, 1.4785829674436135, 8.38653685332646]
-    diameter: 0.4757306177297379
-    label: false
-  3:
-    area: 1.2163101181480007
-    coords: [9.11333588771552, 0.6356565570608497, 6.4685837735829175]
-    diameter: 0.4441682433224806
-    label: false
-  4:
-    area: 2.8891140807885223
-    coords: [3.74760145716413, 8.48673797255793, 8.96986714410077]
-    diameter: 0.24968912706953605
-    label: true
-  5:
-    area: 3.020438240799418
-    coords: [8.974314057061962, 4.54000509308512, 0.6183010318014825]
-    diameter: 0.15611980058752006
-    label: true
-  6:
-    area: 0.09437376410713183
-    coords: [1.878267639329374, 3.819316257284763, 6.162126185402039]
-    diameter: 0.4798652900535806
-    label: true
-  7:
-    area: 0.8238351511402833
-    coords: [3.9946746725349813, 1.0748226633793367, 9.549495916562055]
-    diameter: 0.20785495948827426
-    label: true
-  8:
-    area: 3.62293122933349
-    coords: [3.0392468339103615, 6.155317787866132, 0.002387668861540959]
-    diameter: 0.9682994433598233
-    label: false
diff --git a/test/unit/Utilities/IOTest.py b/test/unit/Utilities/IOTest.py
index f615b4e35..5300c691a 100644
--- a/test/unit/Utilities/IOTest.py
+++ b/test/unit/Utilities/IOTest.py
@@ -1,5 +1,6 @@
 import OpenPNM as op
 import scipy as sp
+import networkx as nx
 import os
 import OpenPNM.Utilities.IO as io
 import pytest
@@ -144,15 +145,42 @@ class IOTest:
         assert [True for item in net.keys() if 'diffusive_conductance' in item]
 
     def test_load_networkx(self):
-        fname = os.path.join(FIXTURE_DIR, 'test_load_yaml.yaml')
-        net = io.NetworkX.load(filename=fname)
-        assert net.Np == 9
-        assert net.Nt == 12
-        assert sp.shape(net['pore.coords']) == (9, 3)
-        assert sp.shape(net['throat.conns']) == (12, 2)
+        G = nx.complete_graph(10)
+        pos = nx.random_layout(G, dim=3)
+        val = {n: list(pos[n]) for n in pos}
+        nx.set_node_attributes(G, name='coords', values=val)
+        nx.set_node_attributes(G, name='area', values=1.123)
+        nx.set_node_attributes(G, name='diameter', values=1.123)
+        nx.set_edge_attributes(G, name='length', values=1.123)
+        nx.set_edge_attributes(G, name='perimeter', values=1.123)
+        net = io.NetworkX.load(G=G)
+        num_nodes = len(G.nodes())
+        num_edges = len(G.edges())
+        assert net.Np == num_nodes
+        assert net.Nt == num_edges
+        assert sp.shape(net['pore.coords']) == (num_nodes, 3)
+        assert sp.shape(net['throat.conns']) == (num_edges, 2)
         a = {'pore.area', 'pore.diameter', 'throat.length', 'throat.perimeter'}
         assert a.issubset(net.props())
 
+    def test_save_and_load_networkx_no_phases(self):
+        G = io.NetworkX.save(network=self.net)
+        net = io.NetworkX.load(G)
+        assert net.Np == 27
+        assert net.Nt == 54
+        assert sp.shape(net['pore.coords']) == (27, 3)
+        assert sp.shape(net['throat.conns']) == (54, 2)
+
+    def test_save_and_load_networkx_w_phases(self):
+        G = io.NetworkX.save(network=self.net, phases=self.phase)
+        net = io.NetworkX.load(G)
+        assert net.Np == 27
+        assert net.Nt == 54
+        assert sp.shape(net['pore.coords']) == (27, 3)
+        assert sp.shape(net['throat.conns']) == (54, 2)
+        assert [True for item in net.keys() if 'temperature' in item]
+        assert [True for item in net.keys() if 'diffusive_conductance' in item]
+
     def test_load_imorph(self):
         path = os.path.join(FIXTURE_DIR, 'iMorph-Sandstone')
         net = io.iMorph.load(path)