Repository: PMEAL/OpenPNM
Pull request #863 (2018-04-20 04:33:46)

More Little Tweaks
This PR implements a number of fixes for various issues, as well as general changes to the api..


Patch:
diff --git a/openpnm/core/Base.py b/openpnm/core/Base.py
index 607d245de..bcfbdc624 100644
--- a/openpnm/core/Base.py
+++ b/openpnm/core/Base.py
@@ -83,11 +83,9 @@ class Base(dict):
 
         # Write value to dictionary
         if sp.shape(value)[0] == 1:  # If value is scalar
-            logger.debug('Broadcasting scalar value into vector: '+key)
             value = sp.ones((self._count(element), ), dtype=value.dtype)*value
             super(Base, self).__setitem__(key, value)
         elif sp.shape(value)[0] == self._count(element):
-            logger.debug('Updating vector: '+key)
             super(Base, self).__setitem__(key, value)
         else:
             if self._count(element) == 0:
@@ -132,7 +130,7 @@ class Base(dict):
         r"""
         A subclassed version of the standard dict's clear method.  This can be
         used to selectively clear certain aspects of the object, including
-        properties, labels and/or models.  It can also clear everything,
+        properties and/or labels.  It can also clear everything,
         except for the 'pore.all' and 'throat.all' labels which are required
         for object to remain functional.
 
@@ -148,27 +146,64 @@ class Base(dict):
             **'props'** : Removes all numerical property values from the object
             dictionary.
 
-            **'labels'** : Removes all labels from the object dictionary
+            **'labels'** : Removes all labels from the object dictionary,
+            except those relating to the pore and throat locations of
+            associated objects.
 
-            **'all'** : Removes all of the above AND sets the \'pore.all\'
-            and \'throat.all\' labels to zero length.  This also removes any
-            pore and throat locations that were previously set.  This mode
-            should be used carefully since it can break some subtle aspects
-            of the framework; it is meant for advanced users and developers.
+            **'all'** : Removes both 'props' and 'labels'
+
+        Notes
+        -----
+        If you wish to completey reset the object, you can do so manually
+        by looping over the keys and deleting each one, then re-adding
+        'pore.all' and 'throat.all' using the ``update`` command.
+
+        If you wish to selectively remove some properties but not other, use
+        something like ``del object['pore.blah']`` at the python prompt.
+        Obviously this can be done in a for-loop to remove a list of items.
+
+        Examples
+        --------
+        >>> import openpnm as op
+        >>> pn = op.network.Cubic(shape=[5, 5, 5])
+        >>> len(pn.labels())  # There are 10 total labels on the network
+        10
+        >>> pn.clear(mode='labels')
+        >>> len(pn.labels())  # Kept only 'pore.all' and 'throat.all'
+        2
+        >>> geom = op.geometry.GenericGeometry(network=pn, pores=pn.Ps,
+        ...                                    throats=pn.Ts)
+        >>> len(pn.labels())  # 2 new labels were added for geometry locations
+        4
+        >>> pn.clear(mode='labels')
+        >>> 'pore.'+geom.name in pn.keys()  # The geometry labels were kept
+        True
+        >>> len(pn.props())  # The network has two properties
+        2
+        >>> pn.clear(element='pore', mode='props')
+        >>> 'pore.coords' in pn.keys()  # The pore property was removed
+        False
+        >>> pn.clear()  # Remove everything except protected labels
+        >>> len(pn.keys())
+        4
 
         """
         allowed = ['props', 'labels', 'all']
         mode = self._parse_mode(mode=mode, allowed=allowed)
         for item in self.keys(mode=mode, element=element):
             if item not in ['pore.all', 'throat.all']:
-                del self[item]
+                if item.split('.')[1] not in self.project.names:
+                    del self[item]
 
-    def keys(self, element=None, mode='skip'):
+    def keys(self, element=None, mode=None):
         r"""
         This subclass works exactly like ``keys`` when no arguments are passed,
         but optionally accepts an ``element`` and/or a ``mode``, which filters
         the output to only the requested keys.
 
+        The default behavior is exactly equivalent to the normal ``keys``
+        method.
+
         Parameters
         ----------
         element : string
@@ -179,18 +214,41 @@ class Base(dict):
         mode : string (optional, default is 'skip')
             Controls which keys are returned.  Options are:
 
-            **'skip'** : This mode (default) bypasses this subclassed method
+            **``None``** : This mode (default) bypasses this subclassed method
             and just returns the normal KeysView object.
 
-            **'labels'** and/or **'props'** : Limits the returned list of keys
-            to only 'labels' (boolean arrays) and/or 'props' (numerical
-            arrays).
+            **'labels'** : Limits the returned list of keys to only 'labels'
+            (boolean arrays)
+
+            **'props'** : Limits he return list of keys to only 'props'
+            (numerical arrays).
 
             **'all'** : Returns both 'labels' and 'props'.  This is equivalent
             to sending a list of both 'labels' and 'props'.
 
+        See Also
+        --------
+        props
+        labels
+
+        Notes
+        -----
+        This subclass can be used to get dictionary keys of specific kinds of
+        data.  It's use augments ``props`` and ``labels`` by returning a list
+        containing both types, but possibly limited by element type ('pores'
+        or 'throats'.)
+
+        Examples
+        --------
+        >>> import openpnm as op
+        >>> pn = op.network.Cubic([5, 5, 5])
+        >>> pn.keys(mode='props')  # Get all props
+        ['pore.coords', 'throat.conns']
+        >>> pn.keys(mode='props', element='pore')  # Get only pore props
+        ['pore.coords']
+
         """
-        if mode == 'skip':
+        if mode is None:
             return super().keys()
         element = self._parse_element(element=element)
         allowed = ['props', 'labels']
@@ -207,24 +265,24 @@ class Base(dict):
             temp = [i for i in temp if i.split('.')[0] in element]
         return temp
 
-    def get(self, key, value=None):
-        r"""
-        This sub-classed method attempt to retrieve the requested item,
-        and if it is not found among the data, the pore-scale model of the
-        same name is run if present.  If successful, the newly calculated
-        values are retrieved and returned, otherwise the given default value
-        is returned.
-
-        """
-        v = super().get(key, value)
-        if v is None:
-            if hasattr(self, 'models'):
-                if key in self.models.keys():
-                    if self.models[key]['regen_mode'] in ['deferred', 'lazy']:
-                        logger.info(key+' not found, regenerating model')
-                        self.regenerate_models(propnames=key)
-                        v = self.get(key=key, value=value)
-        return v
+#    def get(self, key, value=None):
+#        r"""
+#        This sub-classed method attempts to retrieve the requested item,
+#        and if it is not found among the data, the pore-scale model of the
+#        same name is run if present.  If successful, the newly calculated
+#        values are retrieved and returned, otherwise the given default value
+#        is returned.
+#
+#        """
+#        v = super().get(key, value)
+#        if v is None:
+#            if hasattr(self, 'models'):
+#                if key in self.models.keys():
+#                    if self.models[key]['regen_mode'] in ['deferred']:
+#                        logger.debug(key+' not found, regenerating model')
+#                        self.regenerate_models(propnames=key)
+#                        v = self.get(key=key, value=value)
+#        return v
 
     # -------------------------------------------------------------------------
     """Data Query Methods"""
@@ -245,16 +303,12 @@ class Base(dict):
 
             **'all'** : Returns all properties on the object
 
-            **'data'**: Returns a list of all numberical arrays on the object,
-            regardless of how they were calculated.
-
             **'models'** : Returns only properties that are associated with a
             model
 
             **'constants'** : returns data values that were *not* generated by
             a model, but manaully created.
 
-
         Returns
         -------
         A an alphabetically sorted list containing the string name of all
@@ -264,6 +318,7 @@ class Base(dict):
         See Also
         --------
         labels
+        keys
 
         Examples
         --------
@@ -277,21 +332,24 @@ class Base(dict):
         ['pore.coords', 'throat.conns']
         """
         # Parse Inputs
-        allowed_modes = ['all', 'data', 'constants', 'models']
-        mode = self._parse_mode(mode=mode, allowed=allowed_modes)
-        if 'all' in mode:
-            mode = allowed_modes
         element = self._parse_element(element=element)
-        vals = set()
-        if 'data' in mode:
-            vals = set(self.keys(mode=['props']))
-        # Now add model names
-        if hasattr(self, 'models'):
-            if 'models' in mode:
-                vals = vals.union(self.models.keys())
-            if 'constants' in mode:
-                temp = set(self.keys(mode='props')).difference(self.models.keys())
-                vals = vals.union(temp)
+        allowed_modes = ['all', 'constants', 'models']
+        mode = self._parse_mode(mode=mode, allowed=allowed_modes, single=True)
+        if mode == 'all':
+            vals = set(self.keys(mode='props'))
+        if mode == 'constants':
+            if hasattr(self, 'models'):
+                temp = set(self.keys(mode='props'))
+                vals = temp.difference(self.models.keys())
+            else:
+                vals = set(self.keys(mode='props'))
+        if mode == 'models':
+            if hasattr(self, 'models'):
+                temp = set(self.keys(mode='props'))
+                vals = temp.intersection(self.models.keys())
+            else:
+                logger.warning('Object does not have a models attribute')
+                vals = set()
         # Deal with hidden props
         hide = set([i for i in self.keys() if i.split('.')[1].startswith('_')])
         vals = vals.difference(hide)
@@ -309,8 +367,8 @@ class Base(dict):
         """
         # Parse inputs
         locations = self._parse_indices(locations)
-        allowed = ['none', 'union', 'intersection', 'not', 'count', 'mask',
-                   'difference']
+        allowed = ['none', 'union', 'intersection', 'complement', 'not',
+                   'count', 'mask', 'difference']
         mode = self._parse_mode(mode=mode, allowed=allowed, single=True)
         element = self._parse_element(element=element)
         # Collect list of all pore OR throat labels
@@ -332,7 +390,7 @@ class Base(dict):
             temp = labels[sp.sum(arr, axis=0) == sp.shape(locations, )[0]]
             temp.tolist()
             return PrintableList(temp)
-        if mode in ['not', 'difference']:
+        if mode in ['not', 'complement', 'difference']:
             temp = labels[sp.sum(arr, axis=0) != sp.shape(locations, )[0]]
             temp.tolist()
             return PrintableList(temp)
@@ -372,7 +430,7 @@ class Base(dict):
             **'intersection'** : Label applied to ALL of the given pores
             (or throats)
 
-            **'not'** : Labels NOT applied to ALL pores (or throats)
+            **'complement'** : Labels NOT applied to ALL pores (or throats)
 
             **'count'** : The number of labels on each pores (or throats)
 
@@ -386,6 +444,11 @@ class Base(dict):
         A list containing the dictionary keys on the object, limited by the
         specified ``mode``.
 
+        See Also
+        --------
+        props
+        keys
+
         Examples
         --------
         >>> import openpnm as op
@@ -425,7 +488,7 @@ class Base(dict):
         """
         # Parse and validate all input values.
         allowed = ['union', 'intersection', 'not_intersection', 'not',
-                   'difference']
+                   'difference', 'complement']
         mode = self._parse_mode(mode=mode, allowed=allowed, single=True)
         element = self._parse_element(element, single=True)
         labels = self._parse_labels(labels=labels, element=element)
@@ -444,16 +507,16 @@ class Base(dict):
                 intersect = intersect*self[element+'.'+item.split('.')[-1]]
             ind = intersect
         elif mode in ['not_intersection']:
-            not_intersect = sp.zeros_like(self[element+'.all'], dtype=int)
+            none = sp.zeros_like(self[element+'.all'], dtype=int)
             for item in labels:  # Iterate over labels and collect all indices
                 info = self[element+'.'+item.split('.')[-1]]
-                not_intersect = not_intersect + sp.int8(info)
-            ind = (not_intersect == 1)
-        elif mode in ['not', 'difference']:
+                none = none + sp.int8(info)
+            ind = (none == 1)
+        elif mode in ['not', 'difference', 'complement']:
             none = sp.zeros_like(self[element+'.all'], dtype=int)
             for item in labels:  # Iterate over labels and collect all indices
                 info = self[element+'.'+item.split('.')[-1]]
-                none = none - sp.int8(info)
+                none = none + sp.int8(info)
             ind = (none == 0)
         # Extract indices from boolean mask
         ind = sp.where(ind)[0]
@@ -478,20 +541,26 @@ class Base(dict):
             **'union'** : (default) All pores with ANY of the given labels are
             returned.
 
-            **'all'** : Only pore with ALL the given labels are returned. This
-            is equivalent to ``intersection`` which is deprecated.
+            **'intersection'** : Only pore with ALL the given labels are
+            returned. This is equivalent to ``intersection`` which is
+            deprecated.
 
-            **'one'** : Only pores with exactly ONE of the given labels are
-            returned.
+            **'exclusive_or'** : Only pores with exactly ONE of the given
+            labels are returned.
 
-            **'none'** : Only pores with NONE of the given labels are returned.
-            This is equivalent to ``not_intersection`` which is deprecated.
+            **'complement'** : Only pores with NONE of the given labels are
+            returned. This is equivalent to ``not_intersection`` which is
+            deprecated.
 
         Returns
         -------
         A Numpy array containing pore indices where the specified label(s)
         exist.
 
+        See Also
+        --------
+        throats
+
         Examples
         --------
         >>> import openpnm as op
@@ -544,6 +613,10 @@ class Base(dict):
         A Numpy array containing the throat indices where the specified
         label(s) exist.
 
+        See Also
+        --------
+        pores
+
         Examples
         --------
         >>> import openpnm as op
@@ -635,6 +708,10 @@ class Base(dict):
         A boolean mask of length Np or Nt with True in the specified pore or
         throat locations.
 
+        See Also
+        --------
+        toindices
+
         Examples
         --------
         >>> import openpnm as op
@@ -673,6 +750,10 @@ class Base(dict):
         A list of pore or throat indices corresponding the locations where
         the received mask was True.
 
+        See Also
+        --------
+        tomask
+
         Notes
         -----
         This behavior could just as easily be accomplished by using the mask
@@ -738,8 +819,12 @@ class Base(dict):
         #     sources = list(proj.geometries().values())
         # elif self._isa('phase'):
         #     sources = list(proj.phases().values())
+        # elif self._isa('physics')
+        #     sources = list(proj.physics().values())
+        # elif self._isa('physics'):
+        #     sources = list(proj.geometries().values())
         # else:
-        #     return self[prop]
+        #     pass
 
         # Attempt to 'get' the requested array from each object
         # Use 'get' so that missing keys return None, instead of KeyError
@@ -812,6 +897,14 @@ class Base(dict):
         -----
         - This uses an unweighted average, without attempting to account for
         distances or sizes of pores and throats.
+
+        Examples
+        --------
+        >>> import openpnm as op
+        >>> pn = op.network.Cubic(shape=[3, 1, 1])
+        >>> pn['pore.value'] = [1, 2, 3]
+        >>> pn.interpolate_data('pore.value')
+        array([1.5, 2.5])
         """
         mro = self._mro()
         if 'GenericNetwork' in mro:
@@ -843,10 +936,9 @@ class Base(dict):
             values = temp[Ps]
         elif propname.startswith('pore'):
             # Upcast data to full network size
-            temp = sp.ones((net.Np, ))*sp.nan
-            temp[Ps] = self[propname]
-            data = temp
-            Ps12 = net.find_connected_pores(throats=Ts, flatten=False)
+            data = sp.ones((net.Np, ))*sp.nan
+            data[Ps] = self[propname]
+            Ps12 = net['throat.conns'][Ts]
             values = sp.mean(data[Ps12], axis=1)
         return values
 
@@ -873,10 +965,11 @@ class Base(dict):
             **'intersection'** : Only locations with ALL the given labels are
             kept.
 
-            **'not_intersection'** : Only locations with exactly one of the
+            **'exlusive_or'** : Only locations with exactly one of the
             given labels are kept.
 
-            **'not'** : Only locations with none of the given labels are kept.
+            **'complement'** : Only locations with none of the given labels
+            are kept.
 
         See Also
         --------
@@ -900,7 +993,7 @@ class Base(dict):
         ...                    mode='intersection')
         array([ 4,  9, 14, 19, 24])
         """
-        allowed = ['union', 'intersection', 'not_intersection', 'not']
+        allowed = ['union', 'intersection', 'exclusive_or', 'complement']
         mode = self._parse_mode(mode=mode, allowed=allowed, single=True)
         # Convert inputs to locations and element
         if (sp.size(throats) > 0) and (sp.size(pores) > 0):
@@ -943,10 +1036,10 @@ class Base(dict):
             **intersection** : Only pores with ALL the given labels are
             counted.
 
-            **'one'** : Only pores with exactly one of the given
+            **'exclusive_or'** : Only pores with exactly one of the given
             labels are counted.
 
-            **'none'** : Only pores with none of the given labels are
+            **'complement'** : Only pores with none of the given labels are
             counted.
 
         Returns
@@ -971,8 +1064,6 @@ class Base(dict):
         45
         >>> pn.num_pores(labels=['top', 'front'], mode='intersection')
         5
-        >>> pn.num_pores(labels=['top', 'front'], mode='not_intersection')
-        40
 
         """
         # Count number of pores of specified type
@@ -1006,10 +1097,10 @@ class Base(dict):
             **'intersection'** : Only throats with ALL the given labels are
             counted.
 
-            **'one'** : Only throats with exactly one of the given
+            **'exclusive_or'** : Only throats with exactly one of the given
             labels are counted.
 
-            **'none'** : Only throats with none of the given labels are
+            **'complement'** : Only throats with none of the given labels are
             counted.
 
         Returns
@@ -1238,10 +1329,6 @@ class Base(dict):
             elif element+'.'+label in self.keys():
                 temp = [element+'.'+label]
             else:
-                # TODO: The following Error should/could be raised but it
-                # breaks the net-geom and phase-phys look-up logic
-                # raise KeyError('\''+element+'.'+label+'\''+' not found')
-                logger.warning('\''+element+'.'+label+'\''+' not found')
                 temp = [element+'.'+label]
             parsed_labels.extend(temp)
             # Remove duplicates if any
diff --git a/openpnm/core/ModelsMixin.py b/openpnm/core/ModelsMixin.py
index 8abca9f4c..6b85521dd 100644
--- a/openpnm/core/ModelsMixin.py
+++ b/openpnm/core/ModelsMixin.py
@@ -58,36 +58,20 @@ class ModelsMixin():
             Controls how/when the model is run (See Notes for more details).
             Options are:
 
-            *'normal'* or *'eager'* : The model is run directly upon being
-            assiged, and also run every time ``object.regenerate_models`` is
-            called.
+            *'normal'* : The model is run directly upon being assiged, and
+            also run every time ``regenerate_models`` is called.
 
             *'constant'* : The model is run directly upon being assigned, but
             is not called again, thus making it's data act like a constant.
+            If, however, the data is deleted from the object it will be
+            regenerated again.
 
-            *'deferred'* or *'lazy'*: Is not run upon being assigned,
-            but is run the first time that it's data is requested, and each
-            time after that as well.
-
-        Notes
-        -----
-        The difference between 'eager' and 'lazy' execution is useful to
-        understand.  In 'eager' mode the model is run as soon as it's assigned
-        to the object, which means that users must be careful to assign
-        dependent models first, or else a KeyError will be raised since needed
-        data is not present.  In 'lazy' mode models are not run until their
-        data is asked for, which creates a cascading call to all necessary
-        models that have not been run yet.  The latter behavior is more
-        convenient, but can be confusing since a lot happens behind the scenes.
-
-        For example: In 'eager' mode, if 'pore.volume' is assigned before
-        'pore.diameter' a KeyError will occur when the model attempts to
-        access the 'pore.diameter' data.  In 'lazy' mode the request for
-        `pore.volume` data will run the 'pore.volume' model, which attempts
-        to access `pore.diameter` data, and upon failing to find it will
-        attempt to run the `pore.diameter` model.
+            *'deferred'* Is not run upon being assigned, but is run the first
+            time that ``regenerate_models`` is called.
 
         """
+        if propname in kwargs.values():  # Prevent infinite loops of look-ups
+            raise Exception(propname+' can\'t be both dependency and propname')
         # Add model and regen_mode to kwargs dictionary
         kwargs.update({'model': model, 'regen_mode': regen_mode})
         # Insepct model to extract arguments and default values
@@ -99,49 +83,15 @@ class ModelsMixin():
                 # Skip if argument was given in kwargs
                 if k not in kwargs:
                     kwargs.update({k: v})
-        # Ensure that a model doesn't call itself if regen mode is lazy.
-        if (propname in kwargs.values()) and \
-           (regen_mode in ['deferred', 'lazy']):
-            raise Exception()
         # Store all keyword argumnents in model
         self.models[propname] = kwargs
         # Regenerate model values if necessary
-        if regen_mode not in ['deferred', 'lazy']:
+        if regen_mode not in ['deferred']:
             self._regen(propname)
 
-    def remove_model(self, propname=None, mode=['model', 'data']):
-        r"""
-        Removes model and data from object.
-
-        Parameters
-        ----------
-        propname : string or list of strings
-            The property or list of properties to remove
-
-        mode : list of strings
-            Controls what is removed.  Options are:
-
-            *'model'* : Removes the model but not any numerical data that may
-            already exist.
-
-            *'data'* : Removes the data but leaves the model.
-
-        The default is both.
-
-        """
-        if type(propname) is str:
-            propname = [propname]
-        for item in propname:
-            if 'model' in mode:
-                if item in self.models.keys():
-                    del self.models[item]
-            if 'data' in mode:
-                if item in self.keys():
-                    del self[item]
-
     def regenerate_models(self, propnames=None, exclude=[]):
         r"""
-        Re-runs the specified models.
+        Re-runs the specified model or models.
 
         Parameters
         ----------
@@ -153,23 +103,22 @@ class ModelsMixin():
         exclude : list of strings
             Since the default behavior is to run ALL models, this can be used
             to exclude specific models.  It may be more convenient to supply
-            as list of 2 models to exclude than to specify 8 models include.
+            as list of 2 models to exclude than to specify 8 models to include.
+
         """
-        # If no props given, then regenerate them all
-        all_props = self.models.dependency_tree()
-        if propnames is None:
-            propnames = all_props
+        if type(propnames) is str:  # Convert string to list if necessary
+            propnames = [propnames]
+        if propnames is None:  # If no props given, then regenerate them all
+            propnames = self.models.dependency_tree()
             # If some props are to be excluded, remove them from list
             if len(exclude) > 0:
                 propnames = [i for i in propnames if i not in exclude]
         else:
-            # Re-create propnames to ensure it's in correct order
+            # Re-order given propnames according to dependency tree
+            all_props = self.models.dependency_tree()
             propnames = [i for i in all_props if i in propnames]
-        if len(propnames) == 0:
-            logger.info('List of propnames to regenerate is empty')
         # Scan through list of propnames and regenerate each one
         for item in propnames:
-            logger.info('Regenerating model: '+item)
             self._regen(item)
 
     def _regen(self, prop):
@@ -187,15 +136,43 @@ class ModelsMixin():
             if prop not in self.keys():
                 self[prop] = model(target=self, **kwargs)
         else:
-            # Try to run the model, but catch KeyError is missing values
             try:
                 self[prop] = model(target=self, **kwargs)
+                logger.debug('Regnerating model: ' + prop)
             except KeyError:
-                # Set model to deferred, to run later when called
-                logger.warn('Dependencies for ' + prop + ' not available,' +
-                            ' setting regen_mode to deferred')
+                logger.warning(prop+' was not run due to missing dependencies')
                 self.models[prop]['regen_mode'] = 'deferred'
 
+    def remove_model(self, propname=None, mode=['model', 'data']):
+        r"""
+        Removes model and data from object.
+
+        Parameters
+        ----------
+        propname : string or list of strings
+            The property or list of properties to remove
+
+        mode : list of strings
+            Controls what is removed.  Options are:
+
+            *'model'* : Removes the model but not any numerical data that may
+            already exist.
+
+            *'data'* : Removes the data but leaves the model.
+
+        The default is both.
+
+        """
+        if type(propname) is str:
+            propname = [propname]
+        for item in propname:
+            if 'model' in mode:
+                if item in self.models.keys():
+                    del self.models[item]
+            if 'data' in mode:
+                if item in self.keys():
+                    del self[item]
+
     def _get_models(self):
         if not hasattr(self, '_models_dict'):
             self._models_dict = ModelsDict()
@@ -203,11 +180,8 @@ class ModelsMixin():
 
     def _set_models(self, dict_):
         self._models_dict = ModelsDict()
-        self._models_dict.update(dict_)
         # Renerate all models in new dict if regen mode says so
-        for model in dict_:
-            # In case regen mode is not set, do it now
-            dict_[model].setdefault('regen_mode', 'normal')
-        self.regenerate_models()
+        for model in dict_.keys():
+            self.add_model(propname=model, **dict_[model])
 
     models = property(fget=_get_models, fset=_set_models)
diff --git a/openpnm/core/Project.py b/openpnm/core/Project.py
index 366e5b430..f81da3cfd 100644
--- a/openpnm/core/Project.py
+++ b/openpnm/core/Project.py
@@ -16,6 +16,7 @@ class Project(list):
         ws[name] = self
         self._grid = {}
         self.settings = SettingsDict()
+        self.comments = 'Using OpenPNM ' + openpnm.__version__
 
     def extend(self, obj):
         if hasattr(obj, '_mro'):
@@ -146,8 +147,7 @@ class Project(list):
             return phys
 
     def _validate_name(self, name):
-        names = [i.name for i in self]
-        if name in names:
+        if name in self.names:
             raise Exception('An object already exists named '+name)
         for item in self:
             for key in item.keys():
@@ -160,6 +160,11 @@ class Project(list):
         name = prefix + '_' + num.zfill(2)
         return name
 
+    @property
+    def names(self):
+        names = [i.name for i in self]
+        return names
+
     def purge_object(self, obj):
         r"""
         """
@@ -209,6 +214,87 @@ class Project(list):
             obj = openpnm.core.Base(project=self, name=name)
         return obj
 
+    def import_data(self, filename):
+        r"""
+        """
+        raise NotImplementedError('Use the io module to import data')
+
+    def export_data(self, network=None, phases=[], filename=None,
+                    filetype='vtp'):
+        r"""
+        Export the pore and throat data from the given object(s) into the
+        specified file and format.
+
+        Parameters
+        ----------
+        network: OpenPNM Network Object
+            The network containing the data to be stored
+
+        phases : list of OpenPNM Phase Objects
+            The data on each supplied phase will be added to file
+
+        filename : string
+            The file name to use.  If none is supplied then one will be
+            automatically generated based on the name of the project
+            containing the supplied Network, with the date and time appended.
+
+        filetype : string
+            Which file format to store the data.  If a valid extension is
+            included in the ``filename``, this is ignored.  Option are:
+
+            **'vtk'** : (default) The Visualization Toolkit format, used by
+            various softwares such as Paraview.  This actually produces a 'vtp'
+            file.  NOTE: This can be quite slow since all the data is written
+            to a simple text file.  For large data simulations consider
+            'xdmf'.
+
+            **'csv'** : The comma-separated values format, which is easily
+            openned in any spreadsheet program.  The column names represent
+            the property name, including the type and name of the object to
+            which they belonged, all separated by the pipe character.
+
+            **'xmf'** : The extensible data markup format, is a very efficient
+            format for large data sets.  This actually results in the creation
+            of two files, the *xmf* file and an associated *hdf* file.  The
+            *xmf* file contains instructions for looking into the *hdf* file
+            where the data is stored. Paraview opens the *xmf* format natively,
+            and is very fast.
+
+            **'mat'** : Matlab 'mat-file', which can be openned in Matlab.
+
+        Notes
+        -----
+        This is a helper function for the actual functions in the IO module.
+        For more control over the format of the output, and more information
+        about the format refer to ``openpnm.io``.
+
+        """
+        project = network.project
+        if filename is None:
+            filename = project.name + '_' + time.strftime('%Y%b%d_%H%M%p')
+        # Infer filetype from extension on file name...if given
+        if '.' in filename:
+            exts = ['vtk', 'vtp', 'vtu', 'csv', 'xmf', 'xdmf', 'hdf', 'hdf5',
+                    'h5', 'mat']
+            if filename.split('.')[-1] in exts:
+                filename, filetype = filename.rsplit('.', 1)
+        if filetype.lower() in ['vtk', 'vtp', 'vtu']:
+            openpnm.io.VTK.save(network=network, phases=phases,
+                                filename=filename)
+        if filetype.lower() == 'csv':
+            openpnm.io.CSV.save(network=network, phases=phases,
+                                filename=filename)
+        if filetype.lower() in ['xmf', 'xdmf']:
+            openpnm.io.XDMF.save(network=network, phases=phases,
+                                 filename=filename)
+        if filetype.lower() in ['hdf5', 'hdf', 'h5']:
+            f = openpnm.io.HDF5.to_hdf5(network=network, phases=phases,
+                                        filename=filename)
+            f.close()
+        if filetype.lower() == 'mat':
+            openpnm.io.MAT.save(network=network, phases=phases,
+                                filename=filename)
+
     def _dump_data(self, mode=['props']):
         r"""
         Dump data from all objects in project to an HDF5 file
@@ -256,26 +342,44 @@ class Project(list):
 
     @property
     def network(self):
-        net = list(self._get_objects_of_type('network').values())
+        net = list(self._get_objects_by_type('network').values())
         if len(net) > 0:
             net = net[0]
         else:
             net = None
         return net
 
-    def geometries(self):
-        return self._get_objects_of_type('geometry')
+    def geometries(self, name=None):
+        if name:
+            return self._get_object_by_name(name)
+        else:
+            return self._get_objects_by_type('geometry')
 
-    def phases(self):
-        return self._get_objects_of_type('phase')
+    def phases(self, name=None):
+        if name:
+            return self._get_object_by_name(name)
+        else:
+            return self._get_objects_by_type('phase')
 
-    def physics(self):
-        return self._get_objects_of_type('physics')
+    def physics(self, name=None):
+        if name:
+            return self._get_object_by_name(name)
+        else:
+            return self._get_objects_by_type('physics')
 
-    def algorithms(self):
-        return self._get_objects_of_type('algorithm')
+    def algorithms(self, name=None):
+        if name:
+            return self._get_object_by_name(name)
+        else:
+            return self._get_objects_by_type('algorithm')
+
+    def _get_object_by_name(self, name):
+        for item in self:
+            if item.name == name:
+                return item
+        raise Exception('An object named ' + name + ' was not found')
 
-    def _get_objects_of_type(self, objtype):
+    def _get_objects_by_type(self, objtype):
         return {item.name: item for item in self if item._isa(objtype)}
 
     def _set_comments(self, string):
diff --git a/openpnm/core/Workspace.py b/openpnm/core/Workspace.py
index f25c5e1c6..8b708701e 100644
--- a/openpnm/core/Workspace.py
+++ b/openpnm/core/Workspace.py
@@ -25,7 +25,6 @@ class Workspace(dict):
 
     def __init__(self):
         super().__init__()
-        self.comments = 'Using OpenPNM ' + openpnm.__version__
         self.settings = SettingsDict()
 
     def __setitem__(self, name, project):
@@ -58,7 +57,7 @@ class Workspace(dict):
         r"""
         """
         if filename == '':
-            filename = 'bob'
+            filename = 'workspace' + '_' + time.strftime('%Y%b%d_%H%M%p')
         else:
             filename = filename.rsplit('.pnm', 1)[0]
         d = {}
@@ -149,89 +148,13 @@ class Workspace(dict):
 
         Returns
         -------
-        An empty project object, suitable for passing into a Network
+        An empty project object, sui+table for passing into a Network
         generator
 
         """
         sim = openpnm.core.Project(name=name)
         return sim
 
-    def import_data(self, filename):
-        r"""
-        """
-        raise NotImplementedError()
-
-    def export_data(self, network=None, phases=[], filename=None,
-                    filetype='vtp'):
-        r"""
-        Export the pore and throat data from the given object(s) into the
-        specified file and format.
-
-        Parameters
-        ----------
-        network: OpenPNM Network Object
-            The network containing the data to be stored
-
-        phases : list of OpenPNM Phase Objects
-            The data on each supplied phase will be added to file
-
-        filename : string
-            The file name to use.  If none is supplied then one will be
-            automatically generated based on the name of the project
-            containing the supplied Network, with the date and time appended.
-
-        filetype : string
-            Which file format to store the data.  Option are:
-
-            **'vtk'** : (default) The Visualization Toolkit format, used by
-            various softwares such as Paraview.  This actually produces a 'vtp'
-            file.  NOTE: This can be quite slow since all the data is written
-            to a simple text file.  For large data simulations consider
-            'xdmf'.
-
-            **'csv'** : The comma-separated values format, which is easily
-            openned in any spreadsheet program.  The column names represent
-            the property name, including the type and name of the object to
-            which they belonged, all separated by the pipe character.
-
-            **'xmf'** : The extensible data markup format, is a very efficient
-            format for large data sets.  This actually results in the creation
-            of two files, the *xmf* file and an associated *hdf* file.  The
-            *xmf* file contains instructions for looking into the *hdf* file.
-            Paraview opens the *xmf* format natively, and is very fast.  NOTE:
-            This is using *xmf* version 2.
-
-            **'mat'** : Matlab 'mat-file', which can be openned in Matlab as
-            a structured variable, with field names corresponding to the
-            property names.
-
-        Notes
-        -----
-        This is a helper function for the actual functions in the IO module.
-        For more control over the format of the output, and more information
-        about the format refer to ``openpnm.io``.
-
-        """
-        project = network.project
-        if filename is None:
-            filename = project.name + '_' + time.strftime('%Y%b%d_%H%M%p')
-        if filetype.lower() in ['vtp', 'vtp', 'vtu']:
-            openpnm.io.VTK.save(network=network, phases=phases,
-                                filename=filename)
-        if filetype.lower() == 'csv':
-            openpnm.io.CSV.save(network=network, phases=phases,
-                                filename=filename)
-        if filetype.lower() in ['xmf', 'xdmf']:
-            openpnm.io.XDMF.save(network=network, phases=phases,
-                                 filename=filename)
-        if filetype.lower() in ['hdf5', 'hdf', 'h5']:
-            f = openpnm.io.HDF5.to_hdf5(network=network, phases=phases,
-                                        filename=filename)
-            f.close()
-        if filetype.lower() == 'mat':
-            openpnm.io.MAT.save(network=network, phases=phases,
-                                filename=filename)
-
     def _gen_name(self):
         r"""
         Generates a valid name for projects
@@ -243,23 +166,12 @@ class Workspace(dict):
         name = 'sim_'+str(max(n)+1).zfill(2)
         return name
 
-    def _set_comments(self, string):
-        if hasattr(self, '_comments') is False:
-            self._comments = {}
-        self._comments[time.strftime('%c')] = string
-
-    def _get_comments(self):
-        if hasattr(self, '_comments') is False:
-            self._comments = {}
-        for key in list(self._comments.keys()):
-            print(key, ': ', self._comments[key])
-
-    comments = property(fget=_get_comments, fset=_set_comments)
-
     def __str__(self):
         s = []
         hr = '―'*78
         s.append(hr)
+        s.append('OpenPNM Version ' + openpnm.__version__ + ' Workspace')
+        s.append(hr)
         for item in self.values():
             s.append(' ' + item.name)
             s.append(item.__str__())
diff --git a/openpnm/geometry/StickAndBall.py b/openpnm/geometry/StickAndBall.py
index 52ad1dacf..bbd753ddb 100644
--- a/openpnm/geometry/StickAndBall.py
+++ b/openpnm/geometry/StickAndBall.py
@@ -34,6 +34,11 @@ class StickAndBall(GenericGeometry):
                        model=mods.geometry.pore_size.largest_sphere,
                        iters=10)
 
+        self.add_model(propname='pore.diameter',
+                       model=mods.misc.product,
+                       prop1='pore.max_size',
+                       prop2='pore.seed')
+
         self.add_model(propname='pore.area',
                        model=mods.geometry.pore_area.sphere,
                        pore_diameter='pore.diameter')
@@ -42,10 +47,6 @@ class StickAndBall(GenericGeometry):
                        model=mods.geometry.pore_volume.sphere,
                        pore_diameter='pore.diameter')
 
-        self.add_model(propname='pore.diameter',
-                       model=mods.misc.product,
-                       props=['pore.max_size', 'pore.seed'])
-
         self.add_model(propname='throat.max_size',
                        model=mods.misc.from_neighbor_pores,
                        mode='min',
@@ -58,7 +59,7 @@ class StickAndBall(GenericGeometry):
 
         self.add_model(propname='throat.length',
                        model=mods.geometry.throat_length.straight,
-                       L_negative=1e-09,
+                       L_negative=1e-12,
                        pore_diameter='pore.diameter')
 
         self.add_model(propname='throat.surface_area',
diff --git a/openpnm/models/misc/misc.py b/openpnm/models/misc/misc.py
index 6a999bef0..c87d169d2 100644
--- a/openpnm/models/misc/misc.py
+++ b/openpnm/models/misc/misc.py
@@ -24,7 +24,7 @@ def constant(target, value):
     return value
 
 
-def product(target, props):
+def product(target, prop1, prop2, **kwargs):
     r"""
     Calculates the product of multiple property values
 
@@ -35,12 +35,20 @@ def product(target, props):
         length of the calculated array, and also provides access to other
         necessary properties.
 
-    props : list of strings
-        The names of each property to be multiplied
+    prop1 : string
+        The name of the first argument
 
+    prop2 : string
+        The name of the second argument
+
+    Notes
+    -----
+    Additional properties can be specified beyond just ``prop1`` and ``prop2``
+    by including additional arguments in the function call (i.e. ``prop3 =
+    'pore.foo'``).
     """
-    value = 1.0
-    for item in props:
+    value = target[prop1]*target[prop2]
+    for item in kwargs.values():
         value *= target[item]
     return value
 
@@ -137,9 +145,9 @@ def polynomial(target, a, prop, **kwargs):
         list corresponds to a0 and so on.  Note that no entries can be skipped
         so 0 coefficients must be sent as 0.
 
-    poreprop : string
+    prop : string
         The dictionary key containing the independent variable or phase
-        property to be used in the correlation.
+        property to be used in the polynomial.
 
     """
     x = target[prop]
diff --git a/openpnm/network/Gabriel.py b/openpnm/network/Gabriel.py
index 0238734ed..fde4a4d6d 100644
--- a/openpnm/network/Gabriel.py
+++ b/openpnm/network/Gabriel.py
@@ -18,5 +18,5 @@ class Gabriel(Delaunay):
 
         super().__init__(num_points=num_points, shape=shape, points=points,
                          **kwargs)
-        topotools.trim(self, throats=self.throats('gabriel', mode='not'))
+        topotools.trim(self, throats=self.throats('gabriel', mode='complement'))
         del self['throat.gabriel']
diff --git a/openpnm/topotools/topotools.py b/openpnm/topotools/topotools.py
index f9254014f..1a499f21d 100644
--- a/openpnm/topotools/topotools.py
+++ b/openpnm/topotools/topotools.py
@@ -414,7 +414,7 @@ def reduce_coordination(network, z):
 
     # Trim throats not on the spanning tree to acheive desired coordination
     z = 3.5
-    Ts = sp.random.permutation(network.throats('mst', mode='not'))
+    Ts = sp.random.permutation(network.throats('mst', mode='complement'))
     Ts = Ts[:int(network.Nt - network.Np*(z/2))]
     network.trim(throats=Ts)
 
@@ -1113,7 +1113,7 @@ def plot_connections(network, throats=None, fig=None, **kwargs):
     >>> import openpnm as op
     >>> pn = op.network.Cubic(shape=[10, 10, 3])
     >>> pn.add_boundary_pores()
-    >>> Ts = pn.throats('*boundary', mode='not')
+    >>> Ts = pn.throats('*boundary', mode='complement')
     >>> # Create figure showing boundary throats
     >>> fig = op.topotools.plot_connections(network=pn, throats=Ts)
     >>> Ts = pn.throats('*boundary')


Test patch:
diff --git a/tests/unit/core/BaseTest.py b/tests/unit/core/BaseTest.py
index 8f5c1a2c0..86e006e41 100644
--- a/tests/unit/core/BaseTest.py
+++ b/tests/unit/core/BaseTest.py
@@ -67,9 +67,9 @@ class BaseTest:
         a = self.net.pores(labels=['top', 'front'], mode='intersection')
         assert sp.all(a == [2, 5, 8])
 
-    def test_pores_two_labels_not_intersection(self):
-        a = self.net.pores(labels=['top', 'front'], mode='not_intersection')
-        assert sp.all(a == [0, 1, 3, 4, 6, 7, 11, 14, 17, 20, 23, 26])
+#    def test_pores_two_labels_not_intersection(self):
+#        a = self.net.pores(labels=['top', 'front'], mode='not_intersection')
+#        assert sp.all(a == [0, 1, 3, 4, 6, 7, 11, 14, 17, 20, 23, 26])
 
     def test_pores_two_labels_difference(self):
         a = self.net.pores(labels=['top', 'front'], mode='difference')
@@ -91,10 +91,10 @@ class BaseTest:
         a = self.net.throats(labels=['label1', 'label2'], mode='intersection')
         assert sp.all(a == [3, 4, 5])
 
-    def test_throats_two_labels_not_intersection(self):
-        a = self.net.throats(labels=['label1', 'label2'],
-                             mode='not_intersection')
-        assert sp.all(a == [0, 1, 2, 6, 7, 8])
+#    def test_throats_two_labels_not_intersection(self):
+#        a = self.net.throats(labels=['label1', 'label2'],
+#                             mode='not_intersection')
+#        assert sp.all(a == [0, 1, 2, 6, 7, 8])
 
     def test_filter_by_label_pores_no_label(self):
         Ps = self.net.pores(['top', 'bottom', 'front'])
@@ -137,19 +137,19 @@ class BaseTest:
         b = []
         assert sp.all(a == b)
 
-    def test_filter_by_label_pores_two_labels_not_intersection(self):
-        Ps = self.net.pores(['top', 'bottom', 'front'])
-        a = self.net.filter_by_label(pores=Ps,
-                                     labels=['top', 'front'],
-                                     mode='not_intersection')
-        b = [0, 1, 3, 4, 6, 7, 11, 14, 17, 20, 23, 26]
-        assert sp.all(a == b)
+#    def test_filter_by_label_pores_two_labels_not_intersection(self):
+#        Ps = self.net.pores(['top', 'bottom', 'front'])
+#        a = self.net.filter_by_label(pores=Ps,
+#                                     labels=['top', 'front'],
+#                                     mode='not_intersection')
+#        b = [0, 1, 3, 4, 6, 7, 11, 14, 17, 20, 23, 26]
+#        assert sp.all(a == b)
 
-    def test_filter_by_label_pores_two_labels_not(self):
+    def test_filter_by_label_pores_two_labels_complement(self):
         Ps = self.net.pores(['top', 'bottom', 'front'])
         a = self.net.filter_by_label(pores=Ps,
                                      labels=['top', 'front'],
-                                     mode='not')
+                                     mode='complement')
         b = [9, 12, 15, 18, 21, 24]
         assert sp.all(a == b)
 
@@ -211,10 +211,10 @@ class BaseTest:
         a = self.net.num_pores(labels=['top', 'front'], mode='intersection')
         assert a == 3
 
-    def test_num_pores_two_labels_notintersection(self):
-        a = self.net.num_pores(labels=['top', 'front'],
-                               mode='not_intersection')
-        assert a == 12
+#    def test_num_pores_two_labels_not_intersection(self):
+#        a = self.net.num_pores(labels=['top', 'front'],
+#                               mode='not_intersection')
+#        assert a == 12
 
     def test_num_pores_two_labels_difference(self):
         a = self.net.num_pores(labels=['top', 'front'], mode='difference')
@@ -237,10 +237,10 @@ class BaseTest:
                                  mode='intersection')
         assert a == 3
 
-    def test_num_throats_two_labels_notintersection(self):
-        a = self.net.num_throats(labels=['label1', 'label2'],
-                                 mode='not_intersection')
-        assert a == 6
+#    def test_num_throats_two_labels_notintersection(self):
+#        a = self.net.num_throats(labels=['label1', 'label2'],
+#                                 mode='not_intersection')
+#        assert a == 6
 
     def test_num_throats_two_labels_difference(self):
         a = self.net.num_throats(labels=['label1', 'label2'],
@@ -676,23 +676,29 @@ class BaseTest:
         geom['pore.blah'] = True
         assert sp.sum(net['pore.blah']) == geom.Np
 
-    def test_get_regenerate_on_demand(self):
-        self.geo.regenerate_models()
-        models = list(self.geo.models.keys())
-        assert len(set(self.geo.keys()).intersection(models)) == 2
-        for item in models:
-            del self.geo[item]
-        assert len(set(self.geo.keys()).intersection(models)) == 0
-        for item in models:
-            self.geo.models[item]['regen_mode'] = 'lazy'
-            arr = self.geo[item]
-            self.geo.models[item]['regen_mode'] = 'normal'
-        assert len(set(self.geo.keys()).intersection(models)) == 2
-
-    def test_get_no_matches(self):
-        self.geo.pop('pore.blah', None)
-        with pytest.raises(KeyError):
-            self.geo['pore.blah']
+    def test_interpolate_data(self):
+        a = self.geo.interpolate_data(propname='throat.diameter')
+        assert a.size == self.geo.Np
+        a = self.geo.interpolate_data(propname='pore.diameter')
+        assert a.size == self.geo.Nt
+
+#    def test_get_regenerate_on_demand(self):
+#        self.geo.regenerate_models()
+#        models = list(self.geo.models.keys())
+#        assert len(set(self.geo.keys()).intersection(models)) == 2
+#        for item in models:
+#            del self.geo[item]
+#        assert len(set(self.geo.keys()).intersection(models)) == 0
+#        for item in models:
+#            self.geo.models[item]['regen_mode'] = 'deferred'
+#            arr = self.geo[item]
+#            self.geo.models[item]['regen_mode'] = 'normal'
+#        assert len(set(self.geo.keys()).intersection(models)) == 2
+#
+#    def test_get_no_matches(self):
+#        self.geo.pop('pore.blah', None)
+#        with pytest.raises(KeyError):
+#            self.geo['pore.blah']
 
 
 if __name__ == '__main__':
diff --git a/tests/unit/core/HealthCheckTest.py b/tests/unit/core/HealthCheckTest.py
index aa0318e6e..0e479b96b 100644
--- a/tests/unit/core/HealthCheckTest.py
+++ b/tests/unit/core/HealthCheckTest.py
@@ -93,7 +93,7 @@ class HealthCheckTest:
 
     def test_check_physics_health_undefined_pores(self):
         proj = self.ws.copy_project(self.proj)
-        phys = self.phys11
+        phys = proj['phys_01']
         phase = proj.find_phase(phys)
         temp = np.copy(phase['pore.'+phys.name])
         phase['pore.'+phys.name] = False
@@ -103,7 +103,7 @@ class HealthCheckTest:
 
     def test_check_physics_health_undefined_throats(self):
         proj = self.ws.copy_project(self.proj)
-        phys = self.phys11
+        phys = proj['phys_01']
         phase = proj.find_phase(phys)
         temp = np.copy(phase['throat.'+phys.name])
         phase['throat.'+phys.name] = False
@@ -113,7 +113,7 @@ class HealthCheckTest:
 
     def test_check_physics_health_overlapping_pores(self):
         proj = self.ws.copy_project(self.proj)
-        phys = self.phys11
+        phys = proj['phys_01']
         phase = proj.find_phase(phys)
         temp = np.copy(phase['pore.'+phys.name])
         phase['pore.'+phys.name] = True
@@ -123,7 +123,7 @@ class HealthCheckTest:
 
     def test_check_physics_health_overlapping_throats(self):
         proj = self.ws.copy_project(self.proj)
-        phys = self.phys11
+        phys = proj['phys_01']
         phase = proj.find_phase(phys)
         temp = np.copy(phase['throat.'+phys.name])
         phase['throat.'+phys.name] = True
@@ -131,6 +131,27 @@ class HealthCheckTest:
         assert np.all(h['overlapping_throats'] == np.where(temp == 0)[0])
         del self.ws[proj.name]
 
+    def test_check_data_health_has_nans(self):
+        proj = self.ws.copy_project(self.proj)
+        phys11 = proj['phys_01']
+        phys11['pore.blah'] = 1.0
+        a = phys11.check_data_health()
+        assert a.health
+        phys11['pore.blah'][0] = np.nan
+        a = phys11.check_data_health()
+        assert not a.health
+        assert a['pore.blah'] == 'Has NaNs'
+
+    def test_check_data_health_wrong_length(self):
+        proj = self.ws.copy_project(self.proj)
+        network = proj.network
+        a = network.check_data_health()
+        assert a.health
+        network.update({'pore.blah': np.array([1, 2, 3])})
+        a = network.check_data_health()
+        assert not a.health
+        assert a['pore.blah'] == 'Wrong Length'
+
 
 if __name__ == '__main__':
 
diff --git a/tests/unit/core/ModelsTest.py b/tests/unit/core/ModelsTest.py
new file mode 100644
index 000000000..72d726f6e
--- /dev/null
+++ b/tests/unit/core/ModelsTest.py
@@ -0,0 +1,42 @@
+import openpnm as op
+import scipy as sp
+import pytest
+
+
+class ModelsTest:
+
+    def setup_class(self):
+        self.net = op.network.Cubic(shape=[3, 3, 3])
+        self.geo = op.geometry.StickAndBall(network=self.net,
+                                            pores=self.net.Ps,
+                                            throats=self.net.Ts)
+
+    def teardown_class(self):
+        ws = op.core.Workspace()
+        ws.clear()
+
+    def test_models_dict_print(self):
+        s = self.geo.models.__str__().split('\n')
+        assert len(s) == 56
+        assert s.count('―'*78) == 13
+
+    def test_regenerate_models(self):
+        a = len(self.geo.props())
+        assert a == 11
+        self.geo.clear(mode='props')
+        a = len(self.geo.props())
+        assert a == 0
+        self.geo.regenerate_models()
+        a = len(self.geo.props())
+        assert a == 11
+
+
+if __name__ == '__main__':
+
+    t = ModelsTest()
+    self = t
+    t.setup_class()
+    for item in t.__dir__():
+        if item.startswith('test'):
+            print('running test: '+item)
+            t.__getattribute__(item)()
diff --git a/tests/unit/core/ProjectTest.py b/tests/unit/core/ProjectTest.py
index 66d3c8e16..f320f064a 100644
--- a/tests/unit/core/ProjectTest.py
+++ b/tests/unit/core/ProjectTest.py
@@ -272,6 +272,28 @@ class ProjectTest:
         assert sp.any([len(item.props()) for item in proj])
         os.remove(proj.name+'.hdf5')
 
+    def test_export_data(self):
+        fname = 'export_data_tests'
+        self.proj.export_data(network=self.net, phases=self.phase1,
+                              filename=fname, filetype='vtp')
+        os.remove(fname+'.vtp')
+        self.proj.export_data(network=self.net, phases=self.phase1,
+                              filename=fname+'.vtp')
+        os.remove(fname+'.vtp')
+        self.proj.export_data(network=self.net, phases=self.phase1,
+                              filename=fname, filetype='csv')
+        os.remove(fname+'.csv')
+#        self.proj.export_data(network=self.net, phases=self.phase1,
+#                              filename=fname, filetype='xmf')
+#        os.remove(fname+'.xmf')
+#        os.remove(fname+'.hdf')
+        self.proj.export_data(network=self.net, phases=self.phase1,
+                              filename=fname, filetype='hdf')
+        os.remove(fname+'.hdf')
+        self.proj.export_data(network=self.net, phases=self.phase1,
+                              filename=fname, filetype='mat')
+        os.remove(fname+'.mat')
+
 
 if __name__ == '__main__':
 
diff --git a/tests/unit/core/WorkspaceTest.py b/tests/unit/core/WorkspaceTest.py
index 6c350bca7..62bcc7af2 100644
--- a/tests/unit/core/WorkspaceTest.py
+++ b/tests/unit/core/WorkspaceTest.py
@@ -1,6 +1,7 @@
 import openpnm as op
 import scipy as sp
 import pytest
+import os
 
 
 class WorkspaceTest:
@@ -8,20 +9,25 @@ class WorkspaceTest:
     def setup_class(self):
         self.ws = op.core.Workspace()
         self.ws.clear()
+        self.net = op.network.Cubic(shape=[5, 5, 5])
+        self.phase = op.phases.Air(network=self.net)
 
     def test_new_project_no_name(self):
         proj = self.ws.new_project()
         assert proj.name in self.ws.keys()
+        self.ws.clear()
 
     def test_new_project_w_name(self):
         proj = self.ws.new_project(name='bob')
         assert proj.name in self.ws.keys()
+        self.ws.clear()
 
     def test_new_project_duplicate_name(self):
         proj = self.ws.new_project('foo')
         with pytest.raises(Exception):
             proj = self.ws.new_project('foo')
         assert proj.name in self.ws.keys()
+        self.ws.clear()
 
     def test_assign_project(self):
         proj = self.ws.new_project()
@@ -33,6 +39,43 @@ class WorkspaceTest:
         assert proj.name == new_name
         assert proj.name in self.ws.keys()
         assert old_name not in self.ws.keys()
+        self.ws.clear()
+
+    def test_str(self):
+        proj = self.ws.new_project()
+        op.network.Cubic(shape=[3, 3, 3], project=proj)
+        s = self.ws.__str__().split('\n')
+        assert 'OpenPNM Version' in s[1]
+        self.ws.clear()
+
+    def test_save_and_load_project(self):
+        proj = self.ws.new_project('test_proj')
+        net = op.network.Cubic(shape=[3, 3, 3], project=proj)
+        op.phases.Air(network=net)
+        self.ws.save_project(proj)
+        assert proj.name in self.ws.keys()
+        self.ws.close_project(proj)
+        assert 'test_proj' not in self.ws.keys()
+        assert proj.workspace == {}
+        self.ws.load_project(filename='test_proj.pnm')
+        assert 'test_proj' in self.ws.keys()
+        self.ws.clear()
+        os.remove('test_proj.pnm')
+
+    def test_save_and_load_workspace(self):
+        proj1 = self.ws.new_project('test_proj_1')
+        proj2 = self.ws.new_project('test_proj_2')
+        op.network.Cubic(shape=[3, 3, 3], project=proj1, name='net1')
+        op.network.Cubic(shape=[3, 3, 3], project=proj2, name='net2')
+        self.ws.save_workspace(filename='workspace_test')
+        self.ws.clear()
+        assert 'test_proj_1' not in self.ws.keys()
+        assert 'test_proj_2' not in self.ws.keys()
+        self.ws.load_workspace('workspace_test')
+        assert 'test_proj_1' in self.ws.keys()
+        assert 'test_proj_2' in self.ws.keys()
+        self.ws.clear()
+        os.remove('workspace_test.pnm')
 
 
 if __name__ == '__main__':
diff --git a/tests/unit/models/geometry/PoreSizeTest.py b/tests/unit/models/geometry/PoreSizeTest.py
index 74b1e9b7d..0ad3660fb 100644
--- a/tests/unit/models/geometry/PoreSizeTest.py
+++ b/tests/unit/models/geometry/PoreSizeTest.py
@@ -46,8 +46,7 @@ class PoreSizeTest:
                                           throats=net.Ts)
         geo.add_model(propname='pore.diameter',
                       model=mods.largest_sphere,
-                      iters=1,
-                      regen_mode='eager')
+                      iters=1)
         dmin = sp.amin(geo['pore.diameter'])
         assert dmin <= 0.1
         geo.models['pore.diameter']['iters'] = 5
@@ -60,12 +59,12 @@ class PoreSizeTest:
         geom2 = op.geometry.GenericGeometry(network=net,
                                             pores=net.pores('top'))
         geom2['pore.diameter'] = 1.0
-        Ps = net.pores('top', mode='not')
+        Ps = net.pores('top', mode='complement')
         geom1 = op.geometry.GenericGeometry(network=net, pores=Ps,
                                             throats=net.Ts)
         geom1.add_model(propname='pore.diameter',
                         model=mods.largest_sphere,
-                        iters=15, regen_mode='eager')
+                        iters=15)
         assert sp.all(geom2['pore.diameter'] == 1.0)
         assert sp.all(sp.ceil(sp.unique(geom1['pore.diameter'])) == [3.0, 5.0])
         geom2['pore.diameter'] = 6
diff --git a/tests/unit/models/geometry/ThroatSIzeTest.py b/tests/unit/models/geometry/ThroatSIzeTest.py
index f3cee89be..e5aa43ba9 100644
--- a/tests/unit/models/geometry/ThroatSIzeTest.py
+++ b/tests/unit/models/geometry/ThroatSIzeTest.py
@@ -9,8 +9,8 @@ class ThroatSizeTest:
     def setup_class(self):
         self.net = op.network.Cubic(shape=[2, 2, 2])
         self.net.add_boundary_pores()
-        Ps = self.net.pores('*boundary', mode='not')
-        Ts = self.net.throats('*boundary', mode='not')
+        Ps = self.net.pores('*boundary', mode='complement')
+        Ts = self.net.throats('*boundary', mode='complement')
         self.geo = op.geometry.GenericGeometry(network=self.net,
                                                pores=Ps,
                                                throats=Ts)
diff --git a/tests/unit/models/geometry/ThroatVolumeTest.py b/tests/unit/models/geometry/ThroatVolumeTest.py
index 281b9ad5c..72cad4da0 100644
--- a/tests/unit/models/geometry/ThroatVolumeTest.py
+++ b/tests/unit/models/geometry/ThroatVolumeTest.py
@@ -28,8 +28,7 @@ class ThroatVolumeTest:
 
     def test_cube(self):
         self.geo.add_model(propname='throat.volume',
-                           model=mods.cuboid,
-                           regen_mode='eager')
+                           model=mods.cuboid)
         a = np.array([0.01])
         b = np.unique(self.geo['throat.volume'])
         assert_approx_equal(a, b)
@@ -37,8 +36,7 @@ class ThroatVolumeTest:
     def test_extrusion(self):
         self.geo.add_model(propname='throat.volume',
                            throat_area='throat.area',
-                           model=mods.extrusion,
-                           regen_mode='eager')
+                           model=mods.extrusion)
         a = np.array([0.03])
         b = np.unique(self.geo['throat.volume'])
         assert np.allclose(a, b)
diff --git a/tests/unit/models/misc/MiscTest.py b/tests/unit/models/misc/MiscTest.py
index 17757d7b3..d72d0de15 100644
--- a/tests/unit/models/misc/MiscTest.py
+++ b/tests/unit/models/misc/MiscTest.py
@@ -24,11 +24,21 @@ class MiscTest:
                            value=2)
         self.geo.add_model(model=mods.constant,
                            propname='pore.value2',
-                           value=4)
+                           value=2)
         self.geo.add_model(model=mods.product,
+                           propname='pore.result1',
+                           prop1='pore.value1',
+                           prop2='pore.value2')
+        assert sp.all(sp.unique(self.geo['pore.result1']) == 4)
+        self.geo.add_model(model=mods.constant,
                            propname='pore.value3',
-                           props=['pore.value1', 'pore.value2'])
-        assert sp.all(sp.unique(self.geo['pore.value3']) == 8)
+                           value=2)
+        self.geo.add_model(model=mods.product,
+                           propname='pore.result2',
+                           prop1='pore.value1',
+                           prop2='pore.value2',
+                           prop3='pore.value3')
+        assert sp.all(sp.unique(self.geo['pore.result2']) == 8)
 
     def test_scaled(self):
         self.geo['pore.value4'] = 4
diff --git a/tests/unit/models/physics/MultiPhaseTest.py b/tests/unit/models/physics/MultiPhaseTest.py
index c2a6d0550..a2c2e34be 100644
--- a/tests/unit/models/physics/MultiPhaseTest.py
+++ b/tests/unit/models/physics/MultiPhaseTest.py
@@ -27,8 +27,7 @@ class MultiPhaseTest:
         self.phys.add_model(propname='throat.conduit_conductance',
                             throat_conductance='throat.diffusive_conductance',
                             model=pm.multiphase.conduit_conductance,
-                            mode='strict', factor=0,
-                            regen_mode='eager')
+                            mode='strict', factor=0)
         Tinv = [1, 2, 3, 4, 5, 12, 13, 16, 17, 21, 22, 31, 34, 42, 43, 46, 52]
         a = sp.where(self.phase['throat.conduit_conductance'] == 0)[0]
         assert sp.all(a == Tinv)
@@ -37,8 +36,7 @@ class MultiPhaseTest:
         self.phys.add_model(propname='throat.conduit_conductance',
                             throat_conductance='throat.diffusive_conductance',
                             model=pm.multiphase.conduit_conductance,
-                            mode='medium', factor=0,
-                            regen_mode='eager')
+                            mode='medium', factor=0)
         Tinv = [1, 2, 3, 4]
         a = sp.where(self.phase['throat.conduit_conductance'] == 0)[0]
         assert sp.all(a == Tinv)
@@ -47,8 +45,7 @@ class MultiPhaseTest:
         self.phys.add_model(propname='throat.conduit_conductance',
                             throat_conductance='throat.diffusive_conductance',
                             model=pm.multiphase.conduit_conductance,
-                            mode='loose', factor=0,
-                            regen_mode='eager')
+                            mode='loose', factor=0)
         Tinv = [1, 2, 3]
         a = sp.where(self.phase['throat.conduit_conductance'] == 0)[0]
         assert sp.all(a == Tinv)
@@ -56,14 +53,14 @@ class MultiPhaseTest:
     def test_late_throat_filling(self):
         self.phys.add_model(propname='throat.nwp_saturation',
                             model=pm.multiphase.late_throat_filling,
-                            Pc=5500, regen_mode='eager')
+                            Pc=5500)
         assert sp.all(self.phase['throat.nwp_saturation'] < 1.0)
         assert sp.all(self.phase['throat.nwp_saturation'] > 0.0)
 
     def test_late_pore_filling(self):
         self.phys.add_model(propname='pore.nwp_saturation',
                             model=pm.multiphase.late_pore_filling,
-                            Pc=5500, regen_mode='eager')
+                            Pc=5500)
         assert sp.all(self.phase['pore.nwp_saturation'] < 1.0)
         assert sp.all(self.phase['pore.nwp_saturation'] > 0.0)
 
diff --git a/tests/unit/network/NetworkHealthCheckTest.py b/tests/unit/network/NetworkHealthCheckTest.py
index 7373e8e6f..c139c2b48 100644
--- a/tests/unit/network/NetworkHealthCheckTest.py
+++ b/tests/unit/network/NetworkHealthCheckTest.py
@@ -50,6 +50,26 @@ class CheckNetworkHealthTest:
         assert len(a['disconnected_clusters']) == 3
         assert len(a['trim_pores']) == 20
 
+    def test_check_network_health_isolated_pores_and_clusters(self):
+        net = op.network.Cubic(shape=[5, 5, 5])
+        # Create first isolated cluster
+        Ps = net['pore.coords'][:, 2] == 2.5
+        Ps = Ps*(net['pore.coords'][:, 1] > 2.5)
+        Ts = net.find_neighbor_throats(pores=Ps, mode='exclusive_or')
+        trim(network=net, throats=Ts)
+        # Create an isolated pore
+        Ts = net.find_neighbor_throats(pores=0)
+        trim(network=net, throats=Ts)
+        a = net.check_network_health()
+        # Ensure pore 0 counts as a cluster
+        assert len(a['disconnected_clusters']) == 3
+        # Ensure trim_pores has right length
+        assert len(a['trim_pores']) == 11
+        # Ensure 0 is listed in trim pores
+        assert 0 in a['trim_pores']
+        # Ensure 0 is also listed as an isolated pore
+        assert a['isolated_pores'] == 0
+
     def test_check_network_health_isolated_pores(self):
         net = op.network.Cubic(shape=[5, 5, 5])
         Ts = net.find_neighbor_throats(pores=0)