Repository: CORE-GATECH-GROUP/serpent-tools

Issue:
[ENH] Ability to compare readers and objects
Implement a simple public method for readers and containers `.compare(other)` that would return `True` or `False` depending on the degree of difference between the two objects. This would be beneficial for comparing outputs across SERPENT versions, or sensitivity studies.

## Additional options
1. Allow tolerances (relative and/or absolute) to control the level of allowable difference
1. Take potential random behavior into account with an option to control confidence interval
1. Utilize the [`messages`](https://github.com/CORE-GATECH-GROUP/serpent-tools/blob/develop/serpentTools/messages.py) module for displaying more information on what passes/fails comparisons

## Tasks
This issue can be closed when the following comparisons are implemented
- [x] ResultsReader
- [x] HomogUniv
- [ ] BranchingReader
- [ ] DepletionReader
- [ ] DepletedMaterial
- [x] DetectorReader
- [x] Detector

Open to including other readers and containers on this list, but these are the *big* ones in my opinion

## Path forward
1. Start a new feature branch for these comparisons off of develop - [`enh-compare`](https://github.com/CORE-GATECH-GROUP/serpent-tools/tree/enh-compare)
1. Treat this branch as a develop-style branch, with review and CI protected pull requests
1. Add compare methods for readers and containers with branches off of this sub-branch to keep process clean and segmented
1. Once a sufficient number of comparisons have been added to this main feature branch, merge, **not squash merge**  into develop

Discussion:

Patch:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 8b52e58..0c9561c 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -1,4 +1,8 @@
 .. |homogUniv| replace:: :py:class:`~serpentTools.objects.containers.HomogUniv`
+.. |resultReader| replace:: :class:`~serpentTools.parsers.results.ResultsReader` 
+.. |detector| replace:: :class:`~serpentTools.objects.detectors.Detector`
+.. |detectorReader| replace:: :class:`~serpentTools.parsers.detector.DetectorReader`
+.. |depletionReader| replace:: :class:`~serpentTools.parsers.depletion.DepletionReader`
 
 .. _changelog:
 
@@ -6,9 +10,24 @@
 Changelog
 =========
 
+.. _v0.6.0:
+
+0.6.0
+=====
+
+* :pull:`174` - Added parent object ``BaseObject`` with basic comparison
+  method from which all objects inherit. Comparison method contains
+  upper and lower bounds for values w/o uncertainties, :pull:`191`
+* :pull:`196` - Add comparison methods for |resultReader| and 
+  |homogUniv| objects
+* :pull:`228` - Add comparison methods for |detectorReader| and
+  |detector| objects
+* :pull:`236` - Add comparison methods for |depletionReader| and
+  :class:`~serpentTools.objects.materials.DepletedMaterial` objects
+
 .. _v0.5.4:
 
-:release-tag:`0.5.3`
+:release-tag:`0.5.4`
 ====================
 
 * :pull:`239` - Update python dependencies to continue use of python 2
@@ -24,7 +43,7 @@ Changelog
   files with unique random seeds - :mod:`serpentTools.seed`
 * :pull:`229` - :meth:`serpentTools.parsers.sensitivity.SensitivityReader.plot`
   now respects the option to not set x nor y labels.
-* :pull:`231` - :class:`~serpentTools.parsers.results.ResultsReader` objects
+* :pull:`231` - |resultReader| objects
   can now read files that do not contain group constant data. The setting
   :ref:`results-expectGcu` should be used to inform the reader that no
   group constant data is anticipated
@@ -41,10 +60,10 @@ Changelog
   :func:`serpentTools.plot.cartMeshPlot`
 * :pull:`201` - Support for plotting hexagonal meshes with
   :meth:`serpentTools.objects.detectors.HexagonalDetector.hexPlot`
-* :pull:`204` - Access :class:`serpentTools.objects.detectors.Detector`
-  objects directly from :class:`serpentTools.parsers.detector.DetectorReader`
+* :pull:`204` - Access |detector|
+  objects directly from |detectorReader|
   with ``reader[detName]``
-* :pull:`205` - Access materials from :class:`serpentTools.readers.depletion.DepletionReader`
+* :pull:`205` - Access materials from |depletionReader|
   and :class:`serpentTools.samplers.depletion.DepletionSampler` using key-like
   indexing, e.g. ``reader[matName] == reader.material[matName]``
 * :pull:`213` - Better default x-axis labels for simple detector plots
diff --git a/docs/develop/comparisons.rst b/docs/develop/comparisons.rst
new file mode 100644
index 0000000..8ac3e90
--- /dev/null
+++ b/docs/develop/comparisons.rst
@@ -0,0 +1,132 @@
+.. |baseObj| replace:: ``BaseObject``
+
+.. |error| replace:: :func:`~serpentTools.messages.error`
+
+.. |warn| replace:: :func:`~serpentTools.messages.warning`
+
+.. |info| replace:: :func:`~serpentTools.messages.info`
+
+.. |debug| replace:: :func:`~serpentTools.messages.debug`
+
+.. _dev-comparisons:
+
+==================
+Comparison Methods
+==================
+
+We are currently developing methods for our readers and containers to be able
+for comparing between like objects. This could be used to compare the effect 
+of changing fuel enrichment on pin powers or criticality, or used to compare
+the effect of different ``SERPENT`` settings. The ``BaseObject`` that 
+every object **should** inherit from contains the bulk of the input checking,
+so each reader and object needs to implement a private ``_compare`` method with
+the following structure::
+
+    def _compare(self, other, lower, upper, sigma):
+        return <boolean output of comparison>
+
+.. note::
+
+    While these methods will iterate over many quantities, and some quantities
+    may fail early on in the test, the comparison method should continue
+    until all quantities have been tested.
+
+The value ``sigma`` should be used to compare quantities with uncertainties
+by constructing intervals bounded by :math:`x\pm S\sigma`, where
+``sigma``:math:`=S`. Quantities that do not have overlapping confidence
+windows will be considered too different and should result in a ``False`` 
+value being returned from the method.
+
+The ``lower`` and ``upper`` arguments should be used to compare values
+that do not have uncertainties. Both will be ``float`` values, with 
+``lower`` less than or equal to ``upper``.  This functionality is
+implemented with the :func:`serpentTools.utils.directCompare` function,
+while the result is reported with :func:`serpentTools.utils.logDirectCompare`.
+
+.. _dev-comp-message:
+
+Use of messaging module
+=======================
+
+Below is a non-definitive nor comprehensive list of possible comparison cases
+and the corresponding message that should be printed. Using a range of message
+types allows the user to be able to easily focus on things that are really bad by
+using our :ref:`verbosity` setting.
+
+* Two objects contain different data sets, e.g. different dictionary values
+  - |warn| displaying the missing items, and then apply test to items in both objects
+* Two items are identically zero, or arrays of zeros - |debug|
+* Two items are outside of the ``sigma`` confidence intervals - |error|
+* Two items without uncertainties have relative difference
+
+    * less than ``lower`` - |debug|
+    * greater than or equal to ``upper`` - |error|
+    * otherwise - |warn|
+
+* Two items are identical - |debug|
+* Two arrays are not of similar size - |error|
+
+
+.. _dev-comp-utils:
+
+High-level Logging and Comparison Utilities
+===========================================
+
+The :mod:`~serpentTools.utils` module contains a collection of functions
+that can be used to compare quantities and automatically log results.
+When possible, these routines should be favored over hand-writing 
+comparison routines. If the situation calls for custom comparison 
+functions, utilize or extend logging routines from :ref:`dev-comp-log`
+appropriately.
+
+.. autofunction:: serpentTools.utils.compare.compareDictOfArrays
+
+.. autofunction:: serpentTools.utils.compare.getCommonKeys
+
+.. autofunction:: serpentTools.utils.compare.directCompare
+
+.. autofunction:: serpentTools.utils.compare.logDirectCompare
+
+.. autofunction:: serpentTools.utils.compare.splitdictByKeys
+
+.. autofunction:: serpentTools.utils.compare.getKeyMatchingShapes
+
+.. autofunction:: serpentTools.utils.compare.getOverlaps
+
+.. autofunction:: serpentTools.utils.compare.getLogOverlaps
+
+.. autofunction:: serpentTools.utils.docstrings.compareDocDecorator
+
+.. _dev-comp-log:
+
+Low-level Logging Utilities
+===========================
+
+The :mod:`~serpentTools.messages` module contains a collection of functions
+that can be used to notify the user about the results of a comparison
+routine. 
+
+.. autofunction:: serpentTools.messages.logIdentical
+
+.. autofunction:: serpentTools.messages.logNotIdentical
+
+.. autofunction:: serpentTools.messages.logAcceptableLow
+
+.. autofunction:: serpentTools.messages.logAcceptableHigh
+
+.. autofunction:: serpentTools.messages.logOutsideTols
+
+.. autofunction:: serpentTools.messages.logIdenticalWithUncs
+
+.. autofunction:: serpentTools.messages.logInsideConfInt
+
+.. autofunction:: serpentTools.messages.logOutsideConfInt
+
+.. autofunction:: serpentTools.messages.logDifferentTypes
+
+.. autofunction:: serpentTools.messages.logMissingKeys
+
+.. autofunction:: serpentTools.messages.logBadTypes
+
+.. autofunction:: serpentTools.messages.logBadShapes
+
diff --git a/docs/develop/index.rst b/docs/develop/index.rst
index 8845e23..e616b27 100644
--- a/docs/develop/index.rst
+++ b/docs/develop/index.rst
@@ -24,3 +24,4 @@ without any loss of comprehension.
     checklist.rst
     git.rst
     serpentVersions.rst
+    comparisons.rst
diff --git a/docs/develop/utils.rst b/docs/develop/utils.rst
index 6d251ab..726aa42 100644
--- a/docs/develop/utils.rst
+++ b/docs/develop/utils.rst
@@ -1,4 +1,4 @@
-.. _dev-utils:
+.. _api-utils:
 
 =========
 Utilities
@@ -6,7 +6,6 @@ Utilities
 
 
 .. automodule:: serpentTools.utils
-    :members: convertVariableName, linkToWiki, str2vec, splitValsUnc
 
 
 .. _dev-testUtils:
diff --git a/serpentTools/messages.py b/serpentTools/messages.py
index 8ef4f26..119c7ce 100644
--- a/serpentTools/messages.py
+++ b/serpentTools/messages.py
@@ -12,6 +12,9 @@ import warnings
 import logging
 from logging import Handler
 from logging.config import dictConfig
+from collections import Callable
+
+from numpy import ndarray
 
 
 class SerpentToolsException(Exception):
@@ -164,6 +167,203 @@ def _updateFilterAlert(msg, category):
     warnings.warn(msg, category=category, stacklevel=3)
     warnings.simplefilter('default', category)
 
+# =========================================================
+# Functions for notifying the user about comparison results
+# =========================================================
+
+
+def _prefaceNotice(obj, leader):
+    msg = '\n\t{} '.format(leader) + ''.join(str(obj).split('\n'))
+    return msg
+
+
+def _notify(func, quantity, header, obj0, obj1):
+    msg = header.format(quantity)
+    msg += _prefaceNotice(obj0, '>')
+    if obj1 is not None:
+        msg += _prefaceNotice(obj1, '<')
+    func(msg)
+
+
+def logIdentical(obj0, obj1, quantity):
+    """Two objects are identical."""
+    _notify(debug, quantity, 'Values for {} are identical', obj0, None)
+
+
+def logNotIdentical(obj0, obj1, quantity):
+    """Values should be identical but aren't."""
+    _notify(error, quantity, "Values for {} are not identical",
+            obj0, obj1)
+
+
+def logAcceptableLow(obj0, obj1, quantity):
+    """Two values differ, but inside nominal and acceptable ranges."""
+    _notify(info, quantity, "Values for {} are not identical, but close",
+            obj0, obj1)
+
+
+def logAcceptableHigh(obj0, obj1, quantity):
+    """Two values differ, enough to merit a warning but not an error."""
+    _notify(warning, quantity,
+            "Values for {} are different, but within tolerances", obj0, obj1)
+
+
+def logOutsideTols(obj0, obj1, quantity):
+    """Two values differ outside acceptable tolerances."""
+    _notify(error, quantity,
+            "Values for {} are outside acceptable tolerances.", obj0, obj1)
+
+
+def _notifyWithUncs(func, quantity, msg, value0, unc0, value1, unc1):
+    logMsg = msg.format(quantity)
+    logMsg += _prefaceNotice(value0, '>V')
+    logMsg += _prefaceNotice(unc0, '>U')
+    if value1 is not None:
+        logMsg += _prefaceNotice(value1, '<V')
+    logMsg += _prefaceNotice(unc0, '<U')
+    func(logMsg)
+
+
+def logIdenticalWithUncs(value, unc0, unc1, quantity):
+    """Notify that two values have identical expected values."""
+    _notifyWithUncs(debug, quantity,
+                    'Expected values for {} are identical',
+                    value, unc0, None, unc1)
+
+
+def logInsideConfInt(value0, unc0, value1, unc1, quantity):
+    """Two values are within acceptable statistical limits."""
+    _notifyWithUncs(debug, quantity, 'Confidence intervals for {} overlap',
+                    value0, unc0, value1, unc1)
+
+
+def logOutsideConfInt(value0, unc0, value1, unc1, quantity):
+    """Two values are outside acceptable statistical limits."""
+    _notifyWithUncs(error, quantity,
+                    "Values for {} are outside acceptable statistical limits",
+                    value0, unc0, value1, unc1)
+
+
+def logDifferentTypes(type0, type1, quantity):
+    """Two values are of different types."""
+    _notify(error, quantity, "Types for {} are different.",
+            type0, type1)
+
+
+def logBadShapes(obj0, obj1, quantity):
+    """
+    Log an error message that two arrays are of different shapes.
+
+    Parameters
+    ----------
+    obj0: :class:`numpy.ndarray`
+    obj1: :class:`numpy.ndarray`
+        Arrays that have been compared and found to have different shapes
+    quantity: str
+        Descriptor of the quantity being compared, e.g. what these objects
+        represent
+    """
+    shapes = [obj.shape if isinstance(obj, ndarray)
+              else len(obj) for obj in (obj0, obj1)]
+    _notify(error, quantity, "Shapes for {} are different.",
+            shapes[0], shapes[1])
+
+
+MISSING_MSG_HEADER = "{} from {} and {} contain different items"
+MISSING_MSG_SUBJ = "\n\tItems present in {} but not in {}:\n\t\t{}"
+
+
+def _checkHerald(herald):
+    if not isinstance(herald, Callable):
+        critical("Heralding object {} is not callable. Falling back to error."
+                 .format(herald))
+        return error
+    return herald
+
+
+def logMissingKeys(quantity, desc0, desc1, in0, in1, herald=error):
+    """
+    Log a warning message that two objects contain different items
+
+    Parameters
+    ----------
+    quantity: str
+        Indicator as to what is being compared, e.g. ``'metadata'``
+    desc0: str
+    desc1: str
+        Descriptions of the two originators
+    in0: set or iterable
+    in1: set or iterable
+        Items that are unique to originators ``0`` and ``1``, respectively
+    herald: callable
+        Callable function that accepts a single string. This will be called
+        with the error message. If not given, defaults to :func:`error`
+    """
+    if not any(in0) and not any(in1):
+        return
+    herald = _checkHerald(herald)
+    msg = MISSING_MSG_HEADER.format(quantity, desc0, desc1)
+    if any(in0):
+        msg += MISSING_MSG_SUBJ.format(desc0, desc1,
+                                       ', '.join([str(xx) for xx in in0]))
+    if any(in1):
+        msg += MISSING_MSG_SUBJ.format(desc1, desc0,
+                                       ', '.join([str(xx) for xx in in1]))
+    herald(msg)
+
+
+BAD_TYPES_HEADER = "Items from {d0} and {d1} {q} have different types"
+BAD_SHAPES_HEADER = "Items from {d0} and {d1} {q} have different shapes"
+BAD_OBJ_SUBJ = "\n\t{key}: {t0} - {t1}"
+
+
+def logBadTypes(quantity, desc0, desc1, types):
+    """
+    Log an error message for containers with mismatched types
+
+    Parameters
+    ----------
+    quantity: str
+        Indicator as to what is being compared, e.g. ``'metadata'``
+    desc0: str
+    desc1: str
+        Descriptions of the two originators
+    types: dict
+        Dictionary where the keys represent the locations of
+        items with mismatched types. Corresponding keys should
+        be a list or tuple of the types for objects from
+        ``desc0`` and ``desc1`` stored under ``key``
+    """
+    msg = BAD_TYPES_HEADER.format(q=quantity, d0=desc0, d1=desc1)
+    for key in sorted(list(types.keys())):
+        t0, t1 = types[key]
+        msg += BAD_OBJ_SUBJ.format(key=key, t0=t0, t1=t1)
+    error(msg)
+
+
+def logMapOfBadShapes(quantity, desc0, desc1, shapes):
+    """
+    Log an error message for containers with mismatched shapes
+
+    Parameters
+    ----------
+    quantity: str
+        Indicator as to what is being compared, e.g. ``'metadata'``
+    desc0: str
+    desc1: str
+        Descriptions of the two originators
+    shapes: dict
+        Dictionary where the keys represent the locations of
+        items with mismatched shapes. Corresponding keys should
+        be a list or tuple of the shapes for objects from
+        ``desc0`` and ``desc1`` stored under ``key``
+    """
+    msg = BAD_SHAPES_HEADER.format(q=quantity, d0=desc0, d1=desc1)
+    for key in sorted(list(shapes.keys())):
+        t0, t1 = shapes[key]
+        msg += BAD_OBJ_SUBJ.format(key=key, t0=t0, t1=t1)
+    error(msg)
+
 
 class DictHandler(Handler):
     """
diff --git a/serpentTools/objects/base.py b/serpentTools/objects/base.py
index 8c961c4..eadb0fe 100644
--- a/serpentTools/objects/base.py
+++ b/serpentTools/objects/base.py
@@ -8,14 +8,138 @@ from six import add_metaclass
 from numpy import arange, hstack, log, divide
 from matplotlib.pyplot import axes
 
-from serpentTools.messages import debug, warning, SerpentToolsException
+from serpentTools.messages import (
+    debug, warning, SerpentToolsException, info,
+    error,
+    BAD_OBJ_SUBJ,
+
+)
 from serpentTools.plot import plot, cartMeshPlot
 from serpentTools.utils import (
     magicPlotDocDecorator, formatPlot, DETECTOR_PLOT_LABELS,
+    compareDocDecorator, DEF_COMP_LOWER, DEF_COMP_SIGMA,
+    DEF_COMP_UPPER, compareDictOfArrays,
+)
+from serpentTools.utils.compare import (
+    getLogOverlaps, finalCompareMsg,
 )
+from serpentTools.settings import rc
+
+
+class BaseObject(object):
+    """Most basic class shared by all other classes."""
+
+    @compareDocDecorator
+    def compare(self, other, lower=DEF_COMP_LOWER, upper=DEF_COMP_UPPER,
+                sigma=DEF_COMP_SIGMA, verbosity=None):
+        """
+        Compare the results of this reader to another.
+
+        For values without uncertainties, the upper and lower
+        arguments control what passes and what messages get
+        raised. If a quantity in ``other`` is less than
+        ``lower`` percent different that the same quantity
+        on this object, consider this allowable and make
+        no messages.
+        Quantities that are greater than ``upper`` percent
+        different will have a error messages printed and
+        the comparison will return ``False``, but continue.
+        Quantities with difference between these ranges will
+        have warning messages printed.
 
+        Parameters
+        ----------
+        other:
+            Other reader instance against which to compare.
+            Must be a similar class as this one.
+        {compLimits}
+        {sigma}
+        verbosity: None or str
+            If given, update the verbosity just for this comparison.
 
-class NamedObject(object):
+        Returns
+        -------
+        bool:
+            ``True`` if the objects are in agreement with
+            each other according to the parameters specified
+
+        Raises
+        ------
+        {compTypeErr}
+        ValueError
+            If upper > lower,
+            If sigma, lower, or upper are negative
+        """
+        upper = float(upper)
+        lower = float(lower)
+        sigma = int(sigma)
+        if upper < lower:
+            raise ValueError("Upper limit must be greater than lower. "
+                             "{} is not greater than {}"
+                             .format(upper, lower))
+        for item, key in zip((upper, lower, sigma),
+                             ('upper', 'lower', 'sigma')):
+            if item < 0:
+                raise ValueError("{} must be non-negative, is {}"
+                                 .format(key, item))
+
+        self._checkCompareObj(other)
+
+        previousVerb = None
+        if verbosity is not None:
+            previousVerb = rc['verbosity']
+            rc['verbosity'] = verbosity
+
+        self._compareLogPreMsg(other, lower, upper, sigma)
+
+        areSimilar = self._compare(other, lower, upper, sigma)
+
+        if areSimilar:
+            herald = info
+        else:
+            herald = warning
+        herald(finalCompareMsg(self, other, areSimilar))
+        if previousVerb is not None:
+            rc['verbosity'] = previousVerb
+
+        return areSimilar
+
+    def _compare(self, other, lower, upper, sigma):
+        """Actual comparison method for similar classes."""
+        raise NotImplementedError
+
+    def _checkCompareObj(self, other):
+        """Verify that the two objects are same class or subclasses."""
+        if not (isinstance(other, self.__class__) or
+                issubclass(other.__class__, self.__class__)):
+            oName = other.__class__.__name__
+            name = self.__class__.__name__
+            raise TypeError(
+                "Cannot compare against {} - not instance nor subclass "
+                "of {}".format(oName, name))
+
+    def _compareLogPreMsg(self, other, lower=None, upper=None, sigma=None,
+                          quantity=None):
+        """Log an INFO message about this specific comparison."""
+        leader = "Comparing {}> against < with the following tolerances:"
+        tols = [leader.format((quantity + ' from ') if quantity else ''), ]
+        for leader, obj in zip(('>', '<'), (self, other)):
+            tols.append("{} {}".format(leader, obj))
+        for title, val in zip(('Lower', 'Upper'), (lower, upper)):
+            if val is None:
+                continue
+            tols.append("{} tolerance: {:5.3F} [%]".format(title, val))
+        if sigma is not None:
+            sigmaStr = ("Confidence interval for statistical values: {:d} "
+                        "sigma or {} %")
+            sigmaDict = {1: 68, 2: 95}
+            tols.append(
+                sigmaStr.format(sigma, sigmaDict.get(sigma, '>= 99.7')
+                                if sigma else 0))
+        info('\n\t'.join(tols))
+
+
+class NamedObject(BaseObject):
     """Class for named objects like materials and detectors."""
 
     def __init__(self, name):
@@ -413,3 +537,34 @@ class DetectorBase(NamedObject):
         if qty in self.indexes:
             return self.indexes[qty], xlabel
         return fallbackX, xlabel
+
+    def _compare(self, other, lower, upper, sigma):
+        myShape = self.tallies.shape
+        otherShape = other.tallies.shape
+        if myShape != otherShape:
+            error("Detector tallies do not have identical shapes"
+                  + BAD_OBJ_SUBJ.format('tallies', myShape, otherShape))
+            return False
+        similar = compareDictOfArrays(self.grids, other.grids, 'grids',
+                                      lower=lower, upper=upper)
+
+        similar &= getLogOverlaps('tallies', self.tallies, other.tallies,
+                                  self.errors, other.errors, sigma,
+                                  relative=True)
+        hasScores = [obj.scores is not None for obj in (self, other)]
+
+        similar &= hasScores[0] == hasScores[1]
+
+        if not any(hasScores):
+            return similar
+        if all(hasScores):
+            similar &= getLogOverlaps('scores', self.scores, other.scores,
+                                      self.errors, other.errors, sigma,
+                                      relative=True)
+            return similar
+        firstK, secondK = "first", "second"
+        if hasScores[1]:
+            firstK, secondK = secondK, firstK
+        error("{} detector has scores while {} does not"
+              .format(firstK.capitalize(), secondK))
+        return similar
diff --git a/serpentTools/objects/containers.py b/serpentTools/objects/containers.py
index 5b57bcd..e1c3fd2 100644
--- a/serpentTools/objects/containers.py
+++ b/serpentTools/objects/containers.py
@@ -15,10 +15,28 @@ from matplotlib import pyplot
 from numpy import array, arange, hstack, ndarray, zeros_like
 
 from serpentTools.settings import rc
-from serpentTools.utils.plot import magicPlotDocDecorator, formatPlot
-from serpentTools.objects.base import NamedObject
-from serpentTools.utils import convertVariableName
-from serpentTools.messages import warning, SerpentToolsException, debug, info
+from serpentTools.objects.base import NamedObject, BaseObject
+from serpentTools.utils import (
+    convertVariableName,
+    getKeyMatchingShapes,
+    logDirectCompare,
+    getLogOverlaps,
+    compareDocReplacer,
+    compareDocDecorator,
+    magicPlotDocDecorator,
+    formatPlot,
+)
+
+from serpentTools.objects.base import (DEF_COMP_LOWER,
+                                       DEF_COMP_UPPER, DEF_COMP_SIGMA)
+from serpentTools.messages import (
+    warning,
+    SerpentToolsException,
+    debug,
+    info,
+    critical,
+    error,
+)
 
 SCATTER_MATS = set()
 SCATTER_ORDERS = 8
@@ -439,8 +457,132 @@ class HomogUniv(NamedObject):
 
     hasData = __bool__
 
+    def _compare(self, other, lower, upper, sigma):
+        similar = self.compareAttributes(other, lower, upper, sigma)
+        similar &= self.compareInfData(other, sigma)
+        similar &= self.compareB1Data(other, sigma)
+        similar &= self.compareGCData(other, sigma)
 
-class BranchContainer(object):
+        return similar
+
+    @compareDocDecorator
+    def compareAttributes(self, other, lower=DEF_COMP_LOWER,
+                          upper=DEF_COMP_UPPER, sigma=DEF_COMP_SIGMA):
+        """
+        Compare attributes like group structure and burnup. Return the result
+
+        Parameters
+        ----------
+        other: :class:`HomogUniv`
+            Universe against which to compare
+        {compLimits}
+        {sigma}
+
+        Returns
+        -------
+        bools:
+            ``True`` if the attributes agree within specifications
+
+        Raises
+        ------
+        {compTypeErr}
+        """
+
+        self._checkCompareObj(other)
+
+        myMeta = {}
+        otherMeta = {}
+
+        for key in {'bu', 'step', 'groups', 'microGroups', 'reshaped'}:
+            for meta, obj in zip((myMeta, otherMeta), (self, other)):
+                try:
+                    meta[key] = getattr(obj, key)
+                except AttributeError:
+                    meta[key] = None
+
+        matchingKeys = getKeyMatchingShapes(myMeta, otherMeta, 'metadata')
+        similar = len(matchingKeys) == len(myMeta)
+
+        for key in sorted(matchingKeys):
+            similar &= logDirectCompare(myMeta[key], otherMeta[key],
+                                        lower, upper, key)
+
+        return similar
+
+    __docCompare = compareDocReplacer("""
+    Return ``True`` if contents of ``{qty}Exp`` and ``{qty}Unc`` agree
+
+    Parameters
+    ----------
+    other: :class:`HomogUniv`
+        Object from which to grab group constant dictionaries
+    {sigma}
+
+    Returns
+    bool
+        If the dictionaries contain identical values with uncertainties,
+        and if those values have overlapping confidence intervals
+    Raises
+    ------
+    {compTypeErr}
+    """)
+
+    def _helpCompareGCDict(self, other, attrBase, sigma):
+        """
+        Method that actually compare group constant dictionaries.
+
+        ``attrBase`` is used to find dictionaries by appending
+        ``'Exp'`` and ``'Unc'`` to ``attrBase``
+        """
+        self._checkCompareObj(other)
+
+        valName = (attrBase + 'Exp') if attrBase != 'gc' else 'gc'
+        uncName = attrBase + 'Unc'
+        try:
+            myVals = getattr(self, valName)
+            myUncs = getattr(self, uncName)
+            otherVals = getattr(other, valName)
+            otherUncs = getattr(other, uncName)
+        except Exception as ee:
+            critical("The following error was raised extracting {} and "
+                     "{} from universes {} and {}:\n\t{}"
+                     .format(valName, uncName, self, other, ee))
+            return False
+
+        keys = getKeyMatchingShapes(myVals, otherVals, valName)
+        similar = len(keys) == len(myVals) == len(otherVals)
+
+        for key in keys:
+            if key not in myUncs or key not in otherUncs:
+                loc = self if key in otherUncs else other
+                error("Uncertainty data for {} missing from {}"
+                      .format(key, loc))
+                similar = False
+                continue
+            myVal = myVals[key]
+            myUnc = myUncs[key]
+            otherVal = otherVals[key]
+            otherUnc = otherUncs[key]
+
+            similar &= getLogOverlaps(key, myVal, otherVal, myUnc, otherUnc,
+                                      sigma, relative=True)
+        return similar
+
+    def compareInfData(self, other, sigma):
+        return self._helpCompareGCDict(other, 'inf', sigma)
+
+    def compareB1Data(self, other, sigma):
+        return self._helpCompareGCDict(other, 'b1', sigma)
+
+    def compareGCData(self, other, sigma):
+        return self._helpCompareGCDict(other, 'gc', sigma)
+
+    compareInfData.__doc__ = __docCompare.format(qty='inf')
+    compareB1Data.__doc__ = __docCompare.format(qty='b1')
+    compareGCData.__doc__ = __docCompare.format(qty='gc')
+
+
+class BranchContainer(BaseObject):
     """
     Class that stores data for a single branch.
 
@@ -524,18 +666,6 @@ class BranchContainer(object):
         univID: int or str
             Identifier for this universe
         burnup: float or int
-            Value of burnup [MWd/kgU]. A negative value here indicates
-            the value is really in units of days.
-        burnIndex: int
-            Point in the depletion schedule
-        burnDays: int or float
-            Point in time
-
-        Returns
-        -------
-        serpentTools.objects.containers.HomogUniv
-            Empty new universe
-
         """
         if self.__hasDays is None and burnup:
             self.__hasDays = burnup < 0
diff --git a/serpentTools/objects/materials.py b/serpentTools/objects/materials.py
index 294da7e..07d18a9 100644
--- a/serpentTools/objects/materials.py
+++ b/serpentTools/objects/materials.py
@@ -8,6 +8,10 @@ from serpentTools.utils import (
     magicPlotDocDecorator, formatPlot, DEPLETION_PLOT_LABELS,
     convertVariableName,
 )
+from serpentTools.utils.compare import (
+    logDirectCompare,
+    compareDictOfArrays,
+)
 from serpentTools.objects.base import NamedObject
 
 
@@ -235,6 +239,23 @@ class DepletedMaterialBase(NamedObject):
 
         return labels
 
+    def _compare(self, other, lower, upper, sigma):
+        # look for identical isotope names and
+        similar = logDirectCompare(self.names, other.names, 0, 0,
+                                   'isotope names')
+        similar &= logDirectCompare(self.zai, other.zai, 0, 0, 'isotope ZAI')
+
+        # test data dictionary
+        # if uncertianties exist, use those
+        myUncs = self.uncertainties if hasattr(self, 'uncertainties') else {}
+        otherUncs = (other.uncertainties if hasattr(other, 'uncertainties')
+                     else {})
+        similar &= compareDictOfArrays(
+            self.data, other.data, 'data', lower=lower, upper=upper,
+            sigma=sigma, u0=myUncs, u1=otherUncs, relative=False)
+
+        return similar
+
 
 class DepletedMaterial(DepletedMaterialBase):
     __doc__ = DepletedMaterialBase.__doc__
diff --git a/serpentTools/parsers/_collections.py b/serpentTools/parsers/_collections.py
new file mode 100644
index 0000000..0b93156
--- /dev/null
+++ b/serpentTools/parsers/_collections.py
@@ -0,0 +1,97 @@
+"""
+Collections of objects that are helpful for the parsers
+"""
+
+RES_DATA_NO_UNCS = {
+    "burnMaterials",
+    "burnMode",
+    "burnStep",
+    "iniBurnFmass",
+    "totBurnFmass",
+    "resMemsize",
+    "totNuclides",
+    "fissionProductInhTox",
+    "ingestionToxicity",
+    "totSfRate",
+    "electronDecaySource",
+    "uresDiluCut",
+    "implNxn",
+    "neutronErgTol",
+    "useDbrc",
+    "actinideActivity",
+    "actinideInhTox",
+    "photonDecaySource",
+    "te132Activity",
+    "implCapt",
+    "alphaDecaySource",
+    "totActivity",
+    "fissionProductActivity",
+    "simulationCompleted",
+    "sourcePopulation",
+    "useUres",
+    "lostParticles",
+    "iniFmass",
+    "totPhotonNuclides",
+    "totTransmuRea",
+    "uresEmax",
+    "uresUsed",
+    "i132Activity",
+    "cpuUsage",
+    "xsMemsize",
+    "memsize",
+    "totDecayNuclides",
+    "tmsMode",
+    "actinideIngTox",
+    "totDosimetryNuclides",
+    "cs134Activity",
+    "uresEmin",
+    "totCells",
+    "neutronErgNe",
+    "fissionProductIngTox",
+    "sampleCapt",
+    "actinideDecayHeat",
+    "runningTime",
+    "uresAvail",
+    "cycleIdx",
+    "neutronEmin",
+    "neutronDecaySource",
+    "totDecayHeat",
+    "dopplerPreprocessor",
+    "matMemsize",
+    "inhalationToxicity",
+    "sampleFiss",
+    "totFmass",
+    "useDelnu",
+    "cs137Activity",
+    "availMem",
+    "neutronEmax",
+    "miscMemsize",
+    "sampleScatt",
+    "unusedMemsize",
+    "unionCells",
+    "sr90Activity",
+    "totCpuTime",
+    "implFiss",
+    "allocMemsize",
+    "unknownMemsize",
+    "ompParallelFrac",
+    "fissionProductDecayHeat",
+    "totReaChannels",
+    "totTransportNuclides",
+    "ifcMemsize",
+    "i131Activity",
+    "balaSrcNeutronTot",
+    "balaSrcNeutronFiss",
+    "balaSrcNeutronNxn",
+    "balaLossNeutronFiss",
+    "balaLossNeutronTot",
+    "balaLossNeutronCapt",
+    "balaLossNeutronLeak",
+    "transportCycleTime",
+    "processTime",
+    "initTime",
+}
+"""
+Set containing keys for objects stored in :attr:`ResultsReader.resdata`
+that do not contain uncertainties.
+"""
diff --git a/serpentTools/parsers/base.py b/serpentTools/parsers/base.py
index 17519b9..457d706 100644
--- a/serpentTools/parsers/base.py
+++ b/serpentTools/parsers/base.py
@@ -11,10 +11,11 @@ from six import add_metaclass
 
 from serpentTools.messages import info
 from serpentTools.settings import rc
+from serpentTools.objects.base import BaseObject
 
 
 @add_metaclass(ABCMeta)
-class BaseReader(object):
+class BaseReader(BaseObject):
     """Parent class from which all parsers will inherit.
 
     Parameters
diff --git a/serpentTools/parsers/depletion.py b/serpentTools/parsers/depletion.py
index 76e94e0..528929d 100644
--- a/serpentTools/parsers/depletion.py
+++ b/serpentTools/parsers/depletion.py
@@ -13,9 +13,17 @@ from serpentTools.engines import KeywordParser
 from serpentTools.parsers.base import MaterialReader
 from serpentTools.objects.materials import DepletedMaterial
 
-from serpentTools.messages import (warning, debug, error,
-                                   SerpentToolsException)
-
+from serpentTools.messages import (
+    warning, debug, error, SerpentToolsException,
+)
+from serpentTools.utils import (
+    getKeyMatchingShapes,
+    logDirectCompare,
+    compareDocDecorator,
+    DEF_COMP_LOWER,
+    DEF_COMP_UPPER,
+    DEF_COMP_SIGMA,
+)
 
 METADATA_KEYS = {'ZAI', 'NAMES', 'BU', 'DAYS'}
 
@@ -263,3 +271,109 @@ class DepletionReader(DepPlotMixin, MaterialReader):
 
         if 'bu' in self.metadata:
             self.metadata['burnup'] = self.metadata.pop('bu')
+
+    def _compare(self, other, lower, upper, _sigma):
+
+        similar = self._compareMetadata(other, lower, upper, _sigma)
+        if not self._comparePrecheckMetadata(other):
+            return False
+        similar &= self._compareMaterials(other, lower, upper, _sigma)
+        return similar
+
+    def _comparePrecheckMetadata(self, other):
+        for key, myVec in iteritems(self.metadata):
+            otherVec = other.metadata[key]
+            if len(myVec) != len(otherVec):
+                error("Stopping comparison early due to mismatched {} vectors"
+                      "\n\t>{}\n\t<{}".format(key, myVec, otherVec))
+                return False
+        return True
+
+    @compareDocDecorator
+    def compareMaterials(self, other, lower=DEF_COMP_LOWER,
+                         upper=DEF_COMP_UPPER, sigma=DEF_COMP_SIGMA):
+        """
+        Return the result of comparing all materials on two readers
+
+        Parameters
+        ----------
+        other: :class:`DepletionReader`
+            Reader to compare against
+        {compLimits}
+        {sigma}
+
+        Returns
+        -------
+        bool:
+            ``True`` if all materials agree to the given tolerances
+
+        Raises
+        ------
+        {compTypeErr}
+        """
+        self._checkCompareObj(other)
+        self._compareLogPreMsg(other, lower, upper, quantity='materials')
+
+        if not self._comparePrecheckMetadata(other):
+            return False
+
+        return self._compareMaterials(other, lower, upper, sigma)
+
+    def _compareMaterials(self, other, lower, upper, sigma):
+        """Private method for going directly into the comparison."""
+        commonMats = getKeyMatchingShapes(
+            self.materials, other.materials, 'materials')
+        similar = (
+            len(self.materials) == len(other.materials) == len(commonMats))
+
+        for matName in sorted(commonMats):
+            myMat = self[matName]
+            otherMat = other[matName]
+            similar &= myMat.compare(otherMat, lower, upper, sigma)
+        return similar
+
+    @compareDocDecorator
+    def compareMetadata(self, other, lower=DEF_COMP_LOWER,
+                        upper=DEF_COMP_UPPER, sigma=DEF_COMP_SIGMA):
+        """
+        Return the result of comparing metadata on two readers
+
+        Parameters
+        ----------
+        other: :class:`DepletionReader`
+            Object to compare against
+        {compLimits}
+        {header}
+
+        Returns
+        -------
+        bool
+            True if the metadata agree within the given tolerances
+
+        Raises
+        ------
+        {compTypeErr}
+        """
+
+        self._checkCompareObj(other)
+
+        self._compareLogPreMsg(other, lower, upper, quantity='metadata')
+
+        return self._compareMetadata(other, lower, upper, sigma)
+
+    def _compareMetadata(self, other, lower, upper, _sigma):
+        """Private method for comparing metadata"""
+
+        similar = logDirectCompare(
+            self.metadata['names'], other.metadata['names'],
+            0, 0, 'names')
+        similar &= logDirectCompare(
+            self.metadata['zai'], other.metadata['zai'],
+            0, 0, 'zai')
+        similar &= logDirectCompare(
+            self.metadata['days'], other.metadata['days'],
+            lower, upper, 'days')
+        similar &= logDirectCompare(
+            self.metadata['burnup'], other.metadata['burnup'],
+            lower, upper, 'burnup')
+        return similar
diff --git a/serpentTools/parsers/detector.py b/serpentTools/parsers/detector.py
index 9053962..e63bfaa 100644
--- a/serpentTools/parsers/detector.py
+++ b/serpentTools/parsers/detector.py
@@ -4,6 +4,7 @@ from six import iteritems
 from numpy import empty
 
 from serpentTools.utils import str2vec
+from serpentTools.utils.compare import getKeyMatchingShapes
 from serpentTools.engines import KeywordParser
 from serpentTools.objects.detectors import detectorFactory
 from serpentTools.parsers.base import BaseReader
@@ -95,6 +96,20 @@ class DetectorReader(BaseReader):
         if not self.detectors:
             warning("No detectors stored from file {}".format(self.filePath))
 
+    def _compare(self, other, lower, upper, sigma):
+        """Compare two detector readers."""
+        similar = len(self.detectors) == len(other.detectors)
+
+        commonKeys = getKeyMatchingShapes(self.detectors, other.detectors,
+                                          'detectors')
+        similar &= len(commonKeys) == len(self.detectors)
+
+        for detName in sorted(commonKeys):
+            myDetector = self[detName]
+            otherDetector = other[detName]
+            similar &= myDetector.compare(otherDetector, lower, upper, sigma)
+        return similar
+
 
 def cleanDetChunk(chunk):
     """
diff --git a/serpentTools/parsers/results.py b/serpentTools/parsers/results.py
index ccf30cf..d46a7fd 100644
--- a/serpentTools/parsers/results.py
+++ b/serpentTools/parsers/results.py
@@ -7,11 +7,26 @@ from serpentTools.settings import rc
 from serpentTools.utils import convertVariableName
 from serpentTools.objects.containers import HomogUniv
 from serpentTools.parsers.base import XSReader
+from serpentTools.parsers._collections import RES_DATA_NO_UNCS
+from serpentTools.objects.base import (DEF_COMP_LOWER,
+                                       DEF_COMP_SIGMA, DEF_COMP_UPPER)
 from serpentTools.utils import (
-    str2vec, splitValsUncs,
-    STR_REGEX, VEC_REGEX, SCALAR_REGEX, FIRST_WORD_REGEX,
+    str2vec,
+    splitValsUncs,
+    getCommonKeys,
+    logDirectCompare,
+    compareDocDecorator,
+    getKeyMatchingShapes,
+    getLogOverlaps,
+    STR_REGEX,
+    VEC_REGEX,
+    SCALAR_REGEX,
+    FIRST_WORD_REGEX,
+)
+from serpentTools.messages import (
+    warning, debug, SerpentToolsException,
+    info,
 )
-from serpentTools.messages import (warning, debug, SerpentToolsException)
 
 
 MapStrVersions = {
@@ -65,6 +80,9 @@ Convert items in metadata dictionary from arrays to these data types
 """
 
 
+__all__ = ['ResultsReader', ]
+
+
 class ResultsReader(XSReader):
     """
     Parser responsible for reading and working with result files.
@@ -102,6 +120,16 @@ class ResultsReader(XSReader):
     IOError: file is unexpectedly closes while reading
     """
 
+    __METADATA_COMP_SKIPS = {
+        'title',
+        'inputFileName',
+        'workingDirectory',
+        'startDate',
+        'completeDate',
+        'seed',
+    }
+    """Metadata keys that will not be compared."""
+
     def __init__(self, filePath):
         XSReader.__init__(self, filePath, 'results')
         self.__serpentVersion = rc['serpentVersion']
@@ -345,6 +373,135 @@ class ResultsReader(XSReader):
         self._inspectData()
         self._cleanMetadata()
 
+    def _compare(self, other, lower, upper, sigma):
+        similar = self.compareMetadata(other)
+        similar &= self.compareResults(other, lower, upper, sigma)
+        similar &= self.compareUniverses(other, lower, upper, sigma)
+        return similar
+
+    @compareDocDecorator
+    def compareMetadata(self, other, header=False):
+        """
+        Return True if the metadata (settings) are identical.
+
+        Parameters
+        ----------
+        other: :class:`ResultsReader`
+            Class against which to compare
+        {header}
+
+        Returns
+        -------
+        bool:
+            If the metadata are identical
+
+        Raises
+        ------
+        {compTypeErr}
+        """
+
+        self._checkCompareObj(other)
+        if header:
+            self._compareLogPreMsg(other, quantity='metadata')
+        myKeys = set(self.metadata.keys())
+        otherKeys = set(other.metadata.keys())
+        similar = not any(myKeys.symmetric_difference(otherKeys))
+        commonKeys = getCommonKeys(myKeys, otherKeys, 'metadata')
+        skips = commonKeys.intersection(self.__METADATA_COMP_SKIPS)
+        if any(skips):
+            info("The following items will be skipped in the comparison\n\t{}"
+                 .format(', '.join(sorted(skips))))
+        for key in sorted(commonKeys):
+            if key in self.__METADATA_COMP_SKIPS:
+                continue
+            selfV = self.metadata[key]
+            otherV = other.metadata[key]
+            similar &= logDirectCompare(selfV, otherV, 0., 0., key)
+
+        return similar
+
+    @compareDocDecorator
+    def compareResults(self, other, lower=DEF_COMP_LOWER,
+                       upper=DEF_COMP_UPPER, sigma=DEF_COMP_SIGMA,
+                       header=False):
+        """
+        Compare the contents of the results dictionary
+
+        Parameters
+        ----------
+        other: :class:`ResultsReader`
+            Class against which to compare
+        {compLimits}
+        {sigma}
+        {header}
+
+        Returns
+        -------
+        bool:
+            If the results data agree to given tolerances
+
+        Raises
+        ------
+        {compTypeErr}
+        """
+        self._checkCompareObj(other)
+        if header:
+            self._compareLogPreMsg(other, lower, upper, sigma, 'results')
+        myRes = self.resdata
+        otherR = other.resdata
+
+        commonTypeKeys = getKeyMatchingShapes(myRes, otherR, 'results')
+
+        similar = len(commonTypeKeys) == len(myRes) == len(otherR)
+
+        for key in sorted(commonTypeKeys):
+            mine = myRes[key]
+            theirs = otherR[key]
+            if key in RES_DATA_NO_UNCS:
+                similar &= logDirectCompare(mine, theirs, lower, upper, key)
+                continue
+            myVals, myUncs = splitValsUncs(mine)
+            theirVals, theirUncs = splitValsUncs(theirs)
+            similar &= getLogOverlaps(key, myVals, theirVals, myUncs,
+                                      theirUncs, sigma, relative=True)
+        return similar
+
+    @compareDocDecorator
+    def compareUniverses(self, other, lower=DEF_COMP_LOWER,
+                         upper=DEF_COMP_UPPER, sigma=DEF_COMP_SIGMA):
+        """
+        Compare the contents of the ``universes`` dictionary
+
+        Parameters
+        ----------
+        other: :class:`ResultsReader`
+            Reader by which to compare
+        {compLimits}
+        {sigma}
+
+        Returns
+        -------
+        bool:
+            If the contents of the universes agree to given tolerances
+
+        Raises
+        ------
+        {compTypeErr}
+        """
+        self._checkCompareObj(other)
+        myUniverses = self.universes
+        otherUniverses = other.universes
+        keyGoodTypes = getKeyMatchingShapes(myUniverses, otherUniverses,
+                                            'universes')
+
+        similar = len(keyGoodTypes) == len(myUniverses) == len(otherUniverses)
+
+        for univKey in keyGoodTypes:
+            myUniv = myUniverses[univKey]
+            otherUniv = otherUniverses[univKey]
+            similar &= myUniv.compare(otherUniv, lower, upper, sigma)
+        return similar
+
     def _cleanMetadata(self):
         """Replace some items in metadata dictionary with easier data types."""
         mdata = self.metadata
diff --git a/serpentTools/samplers/depletion.py b/serpentTools/samplers/depletion.py
index 96d7207..de226c3 100644
--- a/serpentTools/samplers/depletion.py
+++ b/serpentTools/samplers/depletion.py
@@ -163,7 +163,7 @@ class SampledDepletedMaterial(SampledContainer, DepletedMaterialBase):
     ----------
     {depAttrs:s}
     uncertainties: dict
-        Uncertainties for all variables stored in ``data``
+        Absolute uncertainties for all variables stored in ``data``
     allData: dict
         Dictionary where key, value pairs correspond to names of
         variables stored on this object and arrays of data from all files.
diff --git a/serpentTools/utils/__init__.py b/serpentTools/utils/__init__.py
index e17bbd1..b9dec17 100644
--- a/serpentTools/utils/__init__.py
+++ b/serpentTools/utils/__init__.py
@@ -1,190 +1,7 @@
 """
 Commonly used functions and utilities
 """
-from re import compile
-
-from numpy import array, ndarray
-
+from serpentTools.utils.core import *  # noqa
 from serpentTools.utils.docstrings import *  # noqa
+from serpentTools.utils.compare import *  # noqa
 from serpentTools.utils.plot import *  # noqa
-
-# Regular expressions
-
-STR_REGEX = compile(r'\'.+\'')  # string
-VEC_REGEX = compile(r'(?<==.)\[.+?\]')  # vector
-SCALAR_REGEX = compile(r'=.+;')  # scalar
-FIRST_WORD_REGEX = compile(r'^\w+')  # first word in the line
-
-
-def str2vec(iterable, of=float, out=array):
-    """
-    Convert a string or other iterable to vector.
-
-    Parameters
-    ----------
-    iterable: str or iterable
-        If string, will be split with ``split(splitAt)``
-        to create a list. Every item in this list, or original
-        iterable, will be iterated over and converted accoring
-        to the other arguments.
-    of: type
-        Convert each value in ``iterable`` to this data type.
-    out: type
-        Return data type. Will be passed the iterable of
-        converted items of data dtype ``of``.
-
-    Returns
-    -------
-    vector
-        Iterable of all values of ``iterable``, or split variant,
-        converted to type ``of``.
-
-    Examples
-    --------
-    ::
-
-        >>> v = "1 2 3 4"
-        >>> str2vec(v)
-        array([1., 2., 3., 4.,])
-
-        >>> str2vec(v, int, list)
-        [1, 2, 3, 4]
-
-        >>> x = [1, 2, 3, 4]
-        >>> str2vec(x)
-        array([1., 2., 3., 4.,])
-
-    """
-    vec = (iterable.split() if isinstance(iterable, str)
-           else iterable)
-    return out([of(xx) for xx in vec])
-
-
-def splitValsUncs(iterable, copy=False):
-    """
-    Return even and odd indexed values from iterable
-
-    Designed to extract expected values and uncertainties from
-    SERPENT vectors/matrices of the form
-    ``[x1, u1, x2, u2, ...]``
-
-    Slices along the last axis present on ``iterable``, e.g.
-    columns in 2D matrix.
-
-    Parameters
-    ----------
-    iterable: :class:`numpy.ndarray`or iterable
-        Initial arguments to be processed. If not
-        :class:`numpy.ndarray`, then strings will be converted
-        by calling :func:`str2vec`. Lists and tuples
-        will be sent directly to arrays with
-        :func:`numpy.array`.
-    copy: bool
-        If true, return a unique instance of the values
-        and uncertainties. Otherwise, returns a view
-        per numpy slicing methods
-
-    Returns
-    -------
-    :class:`numpy.ndarray`
-        Even indexed values from ``iterable``
-    :class:`numpy.ndarray`
-        Odd indexed values from ``iterable``
-
-    Examples
-    --------
-    ::
-
-        >>> v = [1, 2, 3, 4]
-        >>> splitValsUncs(v)
-        array([1, 3]), array([2, 4])
-
-        >>> line = "1 2 3 4"
-        >>> splitValsUnc(line)
-        array([1, 3]), array([2, 4])
-
-        >>> v = [[1, 2], [3, 4]]
-        >>> splitValsUncs(v)
-        array([[1], [3]]), array([[2], [4]])
-
-    """
-
-    if not isinstance(iterable, ndarray):
-        iterable = (str2vec(iterable) if isinstance(iterable, str)
-                    else array(iterable))
-    vals = iterable[..., 0::2]
-    uncs = iterable[..., 1::2]
-    if copy:
-        return vals.copy(), uncs.copy()
-    return vals, uncs
-
-
-def convertVariableName(variable):
-    """
-    Return the mixedCase version of a SERPENT variable.
-
-    Parameters
-    ----------
-    variable: str
-        ``SERPENT_STYLE`` variable name to be converted
-
-    Returns
-    -------
-    str:
-        Variable name that has been split at underscores and
-        converted to ``mixedCase``
-
-    Examples
-    --------
-    ::
-
-        >>> v = "INF_KINF"
-        >>> convertVariableName(v)
-        infKinf
-
-        >>> v = "VERSION"
-        >>> convertVariableName(v)
-        version
-
-    """
-    lowerSplits = [item.lower() for item in variable.split('_')]
-    if len(lowerSplits) == 1:
-        return lowerSplits[0]
-    return lowerSplits[0] + ''.join([item.capitalize()
-                                     for item in lowerSplits[1:]])
-
-
-LEADER_TO_WIKI = "http://serpent.vtt.fi/mediawiki/index.php/"
-
-
-def linkToWiki(subLink, text=None):
-    """
-    Return a string that will render as a hyperlink to the SERPENT wiki.
-
-    Parameters
-    ----------
-    subLink: str
-        Desired path inside the SERPENT wiki - following the
-        ``index.php``
-    text: None or str
-        If given, use this as the shown text for the full link.
-
-    Returns
-    -------
-    str:
-        String that can be used as an rst hyperlink to the
-        SERPENT wiki
-
-    Examples
-    --------
-    >>> linkToWiki('Input_syntax_manual')
-    http://serpent.vtt.fi/mediawiki/index.php/Input_syntax_manual
-    >>> linkToWiki('Description_of_output_files#Burnup_calculation_output',
-    ...            "Depletion Output")
-    `Depletion Output <http://serpent.vtt.fi/mediawiki/index.php/
-    Description_of_output_files#Burnup_calculation_output>`_
-    """
-    fullLink = LEADER_TO_WIKI + subLink
-    if not text:
-        return fullLink
-    return "`{} <{}>`_".format(text, fullLink)
diff --git a/serpentTools/utils/compare.py b/serpentTools/utils/compare.py
new file mode 100644
index 0000000..fee2a3a
--- /dev/null
+++ b/serpentTools/utils/compare.py
@@ -0,0 +1,634 @@
+"""
+Comparison utilities
+"""
+
+from collections import Iterable
+
+from numpy.core.defchararray import equal as charEqual
+from numpy import (
+    fabs, zeros_like, ndarray, array, greater, multiply, subtract,
+    equal,
+)
+
+from serpentTools.messages import (
+    error,
+    logIdentical,
+    logNotIdentical,
+    logAcceptableLow,
+    logAcceptableHigh,
+    logOutsideTols,
+    logDifferentTypes,
+    logMissingKeys,
+    logBadTypes,
+    logBadShapes,
+    logMapOfBadShapes,
+    logIdenticalWithUncs,
+    logInsideConfInt,
+    logOutsideConfInt,
+)
+
+from serpentTools.utils.docstrings import compareDocDecorator
+
+LOWER_LIM_DIVISION = 1E-8
+"""Lower limit for denominator for division"""
+
+#
+# Defaults for comparison
+#
+DEF_COMP_LOWER = 0
+DEF_COMP_UPPER = 10
+DEF_COMP_SIGMA = 2
+
+
+@compareDocDecorator
+def getCommonKeys(d0, d1, quantity, desc0='first', desc1='second',
+                  herald=error):
+    """
+    Return a set of common keys from two dictionaries
+
+    Also supports printing warning messages for keys not
+    found on one collection.
+
+    If ``d0`` and ``d1`` are :class:`dict`, then the
+    keys will be obtained with ``d1.keys()``. Otherwise,
+    assume we have an iterable of keys and convert to
+    :class:`set`.
+
+    Parameters
+    ----------
+    d0: dict or iterable
+    d1: dict or iterable
+        Dictionary of keys or iterable of keys to be compared
+    quantity: str
+        Indicator as to what is being compared, e.g. ``'metadata'``
+    {desc}
+    {herald}
+    Returns
+    -------
+    set:
+        Keys found in both ``d{{0, 1}}``
+    """
+    k0 = d0.keys() if isinstance(d0, dict) else d0
+    k1 = d1.keys() if isinstance(d1, dict) else d1
+    s0 = set(k0)
+    s1 = set(k1)
+
+    common = s0.intersection(s1)
+    missing = s0.symmetric_difference(s1)
+    if missing:
+        in0 = s0.difference(s1)
+        in1 = s1.difference(s0)
+        logMissingKeys(quantity, desc0, desc1, in0, in1, herald)
+    return common
+
+
+TPL_FLOAT_INT = float, int
+
+# Error codes for direct compare
+DC_STAT_GOOD = 0
+"""Values are identical to FP precision, or by ``==`` operator."""
+DC_STAT_LE_LOWER = 1
+"""Values are not identical, but max diff <= lower tolerance."""
+DC_STAT_MID = 10
+"""Values differ with max difference between lower and upper tolerance."""
+DC_STAT_GE_UPPER = 100
+"""Values differ with max difference greater than or equal to upper tolerance"""  # noqa
+DC_STAT_NOT_IDENTICAL = 200
+"""Values should be identical but are not, e.g. strings or bools."""
+DC_STAT_DIFF_TYPES = 255
+"""Values are of different types"""
+DC_STAT_NOT_IMPLEMENTED = -1
+"""Direct compare is not implemented for these types"""
+DC_STAT_DIFF_SHAPES = 250
+"""Values are of different shapes."""
+
+COMPARE_STATUS_CODES = {
+    DC_STAT_GOOD: (logIdentical, True),
+    DC_STAT_LE_LOWER: (logAcceptableLow, True),
+    DC_STAT_MID: (logAcceptableHigh, True),
+    DC_STAT_NOT_IDENTICAL: (logNotIdentical, False),
+    DC_STAT_GE_UPPER: (logOutsideTols, False),
+    DC_STAT_DIFF_TYPES: (logDifferentTypes, False),
+    DC_STAT_DIFF_SHAPES: (logBadShapes, False),
+}
+"""Keys of status codes with ``(caller, return)`` values."""
+
+
+@compareDocDecorator
+def directCompare(obj0, obj1, lower, upper):
+    """
+    Return True if values are close enough to each other.
+
+    Wrapper around various comparision tests for strings, numeric, and
+    arrays.
+
+    Parameters
+    ----------
+    obj0: str or float or int or :class:`numpy.ndarray`
+    obj1: str or float or int or :class:`numpy.ndarray`
+        Objects to compare
+    {compLimits}
+    quantity: str
+        Description of the value being compared. Will be
+        used to notify the user about any differences
+
+    Returns
+    -------
+    int:
+        Status code of the comparison.
+
+        * {good} - Values are identical to floating point precision or,
+          for strings/booleans, are identical with the ``==`` operator
+        * {leLower} - Values are not identical, but the max difference
+          is less than ``lower``.
+        * {mid} - Values differ, with the max difference greater
+          than ``lower`` but less than ``upper``
+        * {geUpper} - Values differ by greater than or equal to ``upper``
+        * {notIdentical} - Values should be identical (strings, booleans),
+          but are not
+        * {diffShapes} - Numeric data has different shapes
+        * {diffTypes} - Values are of different types
+        * {notImplemented} - Type comparison is not supported. This means that
+          developers should either implement a test for this
+          data type, or use a different function
+
+    See Also
+    --------
+    * :func:`logDirectCompare` - Function that utilizes this and logs
+      the results using the :mod:`serpentTools.messages` module
+    """
+    type0 = type(obj0)
+    type1 = type(obj1)
+
+    if type0 != type1:
+        # can still compare floats and ints easily
+        if type0 not in TPL_FLOAT_INT or type1 not in TPL_FLOAT_INT:
+            return DC_STAT_DIFF_TYPES
+    if type0 in (str, bool):
+        if obj0 != obj1:
+            return DC_STAT_NOT_IDENTICAL
+        return DC_STAT_GOOD
+
+    # Convert all to numpy arrays
+    if not isinstance(obj0, Iterable):
+        obj0 = array([obj0])
+        obj1 = array([obj1])
+    else:
+        # convert to array, but return if data-type is object
+        # need some indexable structure so dicts and sets won't work
+        obj0 = array(obj0)
+        obj1 = array(obj1)
+        if obj0.dtype.name == 'object':
+            return DC_STAT_NOT_IMPLEMENTED
+    if obj0.shape != obj1.shape:
+        return DC_STAT_DIFF_SHAPES
+
+    if not upper:
+        return _directCompareIdentical(obj0, obj1)
+    return _directCompareWithTols(obj0, obj1, lower, upper)
+
+
+def _directCompareIdentical(obj0, obj1):
+    """Compare arrays that should be identical"""
+    # special case for strings
+    if obj0.dtype.name[:3] == 'str':
+        compArray = charEqual(obj0, obj1)
+    else:
+        compArray = equal(obj0, obj1)
+    if compArray.all():
+        return DC_STAT_GOOD
+    return DC_STAT_NOT_IDENTICAL
+
+
+def _directCompareWithTols(obj0, obj1, lower, upper):
+    """Compare arrays that have some allowable tolerances"""
+    diff = multiply(
+        fabs(subtract(obj0, obj1)), 100
+    )
+    nonZI = greater(fabs(obj0), LOWER_LIM_DIVISION)
+    diff[nonZI] /= obj0[nonZI]
+    maxDiff = diff.max()
+    if maxDiff < LOWER_LIM_DIVISION:
+        return DC_STAT_GOOD
+    if maxDiff <= lower:
+        return DC_STAT_LE_LOWER
+    if maxDiff >= upper:
+        return DC_STAT_GE_UPPER
+    return DC_STAT_MID
+
+
+directCompare.__doc__ = directCompare.__doc__.format(
+    good=DC_STAT_GOOD,
+    leLower=DC_STAT_LE_LOWER,
+    mid=DC_STAT_MID,
+    geUpper=DC_STAT_GE_UPPER,
+    notIdentical=DC_STAT_NOT_IDENTICAL,
+    diffTypes=DC_STAT_DIFF_TYPES,
+    notImplemented=DC_STAT_NOT_IMPLEMENTED,
+    diffShapes=DC_STAT_DIFF_SHAPES,
+)
+
+
+@compareDocDecorator
+def logDirectCompare(obj0, obj1, lower, upper, quantity):
+    """
+    Compare objects using :func:`directCompare` and log the result
+
+    Parameters
+    ----------
+    obj0: str or float or int or :class:`numpy.ndarray`
+    obj1: str or float or int or :class:`numpy.ndarray`
+        Objects to compare
+    {compLimits}
+    quantity: str
+        Description of the value being compared. Will be
+        used to notify the user about any differences
+
+    Returns
+    -------
+    bool:
+        ``True`` if the objects agree according to tolerances, or numerics
+        differ less than ``upper``. ``False`` otherwise
+
+    Raises
+    ------
+    TypeError:
+        If the objects being compared are not supported by
+        :func:`directCompare`.  Developers should either extend the
+        function or utilize a different comparison function
+
+    See Also
+    --------
+    * :func:`directCompare` - function that does the comparison
+    * :func:`getOverlaps` - function for evaluating values with uncertainties
+    * :func:`getLogOverlaps` - function that logs the result of stastistical
+      comparisions
+    """
+    result = directCompare(obj0, obj1, lower, upper)
+    if result < 0:  # failures
+        if result == DC_STAT_NOT_IMPLEMENTED:
+            raise TypeError(
+                "directCompare is not configured to make tests on objects "
+                "of type {tp}\n\tQuantity: {k}\n\tUsers: Create a issue on "
+                "GitHub to alert developers.\n\tDevelopers: Update this "
+                "function or create a compare function "
+                "for {tp} objects.".format(k=quantity, tp=type(obj0)))
+    noticeTuple = [obj0, obj1, quantity]
+    if result in COMPARE_STATUS_CODES:
+        func, returnV = COMPARE_STATUS_CODES[result]
+        func(*noticeTuple)
+        return returnV
+    raise ValueError("Received value of {} from directCompare. Not sure "
+                     "what this means.")
+
+
+def splitDictByKeys(map0, map1, keySet=None):
+    """
+    Return various sub-sets and dictionaries from two maps.
+
+    Used to test the internal workings on :func:`getKeyMatchingShapes`
+
+    Parameters
+    ----------
+    map0: dict
+    map1: dict
+        Dictionaries to compare
+    keySet: set or None
+        Iterable collection of keys found in ``map0`` and ``map1``.
+        Missing keys will be returned from this function under
+        the ``missing0`` and ``missing1`` sets. If ``None``, take
+        to be the set of keys that exist in both maps
+
+    Returns
+    -------
+    missing0: set
+        Keys that exist in ``keySet`` but not in ``map0``
+    missing1: set
+        Keys that exist in ``keySet`` but not in ``map1``
+    differentTypes: dict
+        Dictionary with tuples ``{key: (t0, t1)}`` indicating the values
+        ``map0[key]`` and ``map1[key]`` are of different types
+    badShapes: dict
+        Dictionary with tuples ``{key: (t0, t1)}`` indicating the values
+        ``map0[key]`` and ``map1[key]`` are arrays of different shapes
+    goodKeys: set
+        Keys found in both ``map0`` and ``map1`` that are of the same type
+        or point to arrays of the same shape
+    """
+    if keySet is None:
+        keySet = set(map1.keys())
+        keySet.update(set(map0.keys()))
+    missing = {0: set(), 1: set()}
+    differentTypes = {}
+    badShapes = {}
+    goodKeys = set()
+    for key in keySet:
+        if key not in map0 or key not in map1:
+            for mapD, misK in zip((map0, map1), (0, 1)):
+                if key not in mapD:
+                    missing[misK].add(key)
+            continue
+        v0 = map0[key]
+        v1 = map1[key]
+        t0 = type(v0)
+        t1 = type(v1)
+        if t0 != t1:
+            differentTypes[key] = (t0, t1)
+            continue
+        if t0 is ndarray:
+            if v0.shape != v1.shape:
+                badShapes[key] = (v0.shape, v1.shape)
+                continue
+        goodKeys.add(key)
+
+    return missing[0], missing[1], differentTypes, badShapes, goodKeys
+
+
+def getKeyMatchingShapes(map0, map1, quantity, keySet=None, desc0='first',
+                         desc1='second'):
+    """
+    Return a set of keys in map0/1 that point to arrays with identical shapes.
+
+    Parameters
+    ----------
+    keySet: set or list or tuple or iterable or None
+        Iterable container with keys that exist in map0 and map1. The contents
+        of ``map0/1`` under these keys will be compared. If ``None``,
+        will be determined by :func:`splitDictByKeys`
+    map0: dict
+    map1: dict
+        Two dictionaries containing at least all the keys in ``keySet``.
+        Objects under keys in ``keySet`` will have their sizes compared if
+        they are :class:`numpy.ndarray`. Non-arrays will be included only
+        if their types are identical
+    quantity: str
+        Indicator as to what is being compared, e.g. ``'metadata'``
+    desc0: str
+    decs1: str
+        Descriptions of the two dictionaries being compared. Used to alert the
+        user to the shortcomings of the two dictionaries
+
+    Returns
+    -------
+    set:
+        Set of all keys that exist in both dictionaries and are either
+        identical types, or are arrays of identical shapes
+
+    See Also
+    --------
+    * :func:`splitDictByKeys`
+    """
+    missing0, missing1, differentTypes, badShapes, goodKeys = (
+        splitDictByKeys(map0, map1, keySet))
+
+    # raise some messages
+    if any(missing0) or any(missing1):
+        logMissingKeys(quantity, desc0, desc1, missing0, missing1)
+    if differentTypes:
+        logBadTypes(quantity, desc0, desc1, differentTypes)
+    if badShapes:
+        logMapOfBadShapes(quantity, desc0, desc1, badShapes)
+    return goodKeys
+
+
+@compareDocDecorator
+def getOverlaps(arr0, arr1, unc0, unc1, sigma, relative=True):
+    r"""
+    Return the indicies of overlapping confidence intervals
+
+    Parameters
+    ----------
+    arr0: :class:`numpy.ndarray`
+    arr1: :class:`numpy.ndarray`
+        Arrays containing the expected values to be compared
+    unc0: :class:`numpy.ndarray`
+    unc1: :class:`numpy.ndarray`
+        Associated absolute uncertainties, :math:`1\sigma`,
+        corresponding to the values in ``arr0`` and ``arr1``
+    {sigma}
+    relative: bool
+        True if uncertainties are relative and should be multiplied
+        by their respective values. Otherwise, assume values are
+        absolute
+
+    Returns
+    -------
+    :class:`numpy.ndarray`
+        Boolean array of equal shape to incoming arrays.
+        Every index with ``True`` as the value indicates that
+        the confidence intervals for the arrays overlap
+        at those indices.
+
+    Examples
+    --------
+    Using absolute uncertainties::
+
+        >>> from numpy import ones, zeros, array
+        >>> a0 = ones(4)
+        >>> a1 = ones(4) * 0.5
+        >>> u0 = array([0, 0.2, 0.1, 0.2])
+        >>> u1 = array([1, 0.55, 0.25, 0.4])
+
+    Here, the first point in the confidence interval for
+    ``a0`` is completely contained within that of ``a1``.
+    The upper limit of ``a1[1]`` is contained within the confidence
+    interval for ``a0``.
+    The confidence intervals for the third point do not overlap,
+    while the lower bound of ``a0[3]`` is within the confidence interval
+    of ``a1[3]``.
+    ::
+
+        >>> getOverlaps(a0, a1, u0, u1, 1, relative=False)
+        array([True, True, False, True])
+
+    This function also works for multi-dimensional arrays as well.
+    ::
+
+        >>> a2 = a0.reshape(2, 2)
+        >>> a3 = a1.reshape(2, 2)
+        >>> u2 = u0.reshape(2, 2)
+        >>> u3 = u1.reshape(2, 2)
+        >>> getOverlaps(a2, a3, u2, u3 1, relative=False)
+        array([[ True,  True],
+               [False, False])
+
+    Raises
+    ------
+    IndexError
+        If the shapes of incoming arrays do not agree
+
+    See Also
+    --------
+    * :func:`getLogOverlaps` - High-level function that
+      uses this to report if two values have overlapping
+      confidence intervals
+    """
+    shapes = {arg.shape for arg in (arr0, arr1, unc1, unc0)}
+    if len(shapes) != 1:
+        shapes = [str(a.shape) for a in [arr0, arr1, unc1, unc0]]
+        raise IndexError("Array shapes do not agree:\n{}"
+                         .format(', '.join(shapes)))
+    err0 = fabs(unc0 * sigma)
+    err1 = fabs(unc1 * sigma)
+
+    if relative:
+        err0 *= arr0
+        err1 *= arr1
+
+    min0 = arr0 - err0
+    max0 = arr0 + err0
+    min1 = arr1 - err1
+    max1 = arr1 + err1
+
+    overlap = zeros_like(arr0, dtype=bool)
+
+    # Where values are identical to numerical precision
+    overlap[arr0 == arr1] = True
+
+    min0le1 = min0 <= min1
+    max0ge1 = max0 >= max1
+    min1le0 = min1 <= min0
+    max1ge0 = max1 >= max0
+
+    # locations where condidence intervals are completely contained
+    # in the other set
+    cont0In1 = min0le1 * (min0le1 == max0ge1)
+    overlap[cont0In1] = True
+    cont1In0 = min1le0 * (min1le0 == max1ge0)
+    overlap[cont1In0] = True
+
+    # locations where min of 0 is less than 1, but max 0 > min 1
+    # and the opposite
+    overlap[min0le1 * (max0 >= min1)] = True
+    overlap[min1le0 * (max1 >= min0)] = True
+
+    # locations where max 0 > max 1, but min 0 < max 1
+    # and the opposite
+    overlap[max0ge1 * (min0 <= max1)] = True
+    overlap[max1ge0 * (min1 <= max0)] = True
+
+    return overlap
+
+
+@compareDocDecorator
+def getLogOverlaps(quantity, arr0, arr1, unc0, unc1, sigma, relative=True):
+    """
+    Wrapper around :func:`getOverlaps` that logs the result
+
+    Parameters
+    ----------
+    quantity: str
+        Name of the value being compared
+    arr0: :class:`numpy.ndarray`
+    arr1: :class:`numpy.ndarray`
+    unc0: :class:`numpy.ndarray`
+    unc1: :class:`numpy.ndarray`
+        Arrays and their uncertainties to evaluate
+    {sigma}
+    relative: bool
+        If uncertainties are relative. Otherwise, assume absolute
+        uncertainties.
+
+    Returns
+    -------
+    bool:
+        ``True`` if all locations ``arr0`` and ``arr1`` are either
+        identical or within allowable statistical variations.
+
+    See Also
+    --------
+    * :func:`getOverlaps` - This function performs all the comparisons
+      while this function simply reports the output using
+      :mod:`serpentTools.messages`
+    """
+
+    if (arr0 == arr1).all():
+        logIdenticalWithUncs(arr0, unc0, unc1, quantity)
+        return True
+    overlaps = getOverlaps(arr0, arr1, unc0, unc1, sigma, relative)
+    if overlaps.all():
+        logInsideConfInt(arr0, unc0, arr1, unc1, quantity)
+        return True
+    logOutsideConfInt(arr0, unc0, arr1, unc1, quantity)
+    return False
+
+
+@compareDocDecorator
+def compareDictOfArrays(d0, d1, desc, lower=DEF_COMP_LOWER,
+                        upper=DEF_COMP_UPPER, sigma=DEF_COMP_SIGMA,
+                        u0={}, u1={}, relative=True):
+    """
+    High-level routine for evaluating the similarities of two dictionaries
+
+    The following tests are performed
+
+        1. Find a set of keys that both exist in ``d0`` and ``d1``
+          and point to arrays with identical shapes using
+          :meth:`getKeyMatchingShapes`
+        2. For each key in this common set, compare the values
+          with :meth:`logDirectCompare` or :meth:`getLogOverlaps`.
+          The latter is used if the key exists in  ``u0`` and
+          ``u1``, provided uncertainty arrays are of identical shapes.
+
+    Parameters
+    ----------
+    d0: dict
+    d1: dict
+        Dictionaries to be compared
+    desc: str
+        Descption of the two dictionaries. What data do they represent?
+    {compLimits}
+    {sigma}
+    u0: dict
+    u1: dict
+        If uncKeys is not ``None``, then find the uncertainties for data in
+        ``d0`` and ``d1`` under the same keys.
+    relative: bool
+        If this evaluates to ``true``, then uncertainties in ``u0`` and ``u1``
+        are relative.
+
+    Returns
+    -------
+    bool
+        ``True`` If all comparisons pass
+    """
+    similar = len(d0) == len(d1)
+    keysMatchingTypes = getKeyMatchingShapes(d0, d1, desc)
+    similar &= len(d0) == len(keysMatchingTypes)
+
+    for key in sorted(keysMatchingTypes):
+        val0 = d0[key]
+        val1 = d1[key]
+        if key in u0 and key in u1:
+            unc0 = u0[key]
+            unc1 = u1[key]
+            similar &= getLogOverlaps(key, val0, val1, unc0, unc1,
+                                      sigma, relative)
+            continue
+        similar &= logDirectCompare(val0, val1, lower, upper, key)
+    return similar
+
+
+FINAL_COMPARE_MSG = "Objects {} and {}{} agree within given tolerances"
+
+
+def finalCompareMsg(obj0, obj1, similar):
+    """
+    Return the string used to signify the conclusion of a comparison
+
+    Mainly exposed to developers for testing purposes.
+
+    Parameters
+    ----------
+    obj0:
+    obj1: Subclass of :class:`~serpentTools.objects.base.BaseObject`
+        Objects that have been compared
+    similar: bool
+        Result of comparison
+
+    Returns
+    -------
+    str:
+        Concluding remark about the comparison.
+    """
+    return FINAL_COMPARE_MSG.format(obj0, obj1, "" if similar else "do not")
diff --git a/serpentTools/utils/core.py b/serpentTools/utils/core.py
new file mode 100644
index 0000000..3a19ea7
--- /dev/null
+++ b/serpentTools/utils/core.py
@@ -0,0 +1,189 @@
+"""
+Core utilities
+"""
+
+from re import compile
+
+from numpy import array, ndarray
+
+
+# Regular expressions
+
+STR_REGEX = compile(r'\'.+\'')  # string
+VEC_REGEX = compile(r'(?<==.)\[.+?\]')  # vector
+SCALAR_REGEX = compile(r'=.+;')  # scalar
+FIRST_WORD_REGEX = compile(r'^\w+')  # first word in the line
+
+
+def str2vec(iterable, of=float, out=array):
+    """
+    Convert a string or other iterable to vector.
+
+    Parameters
+    ----------
+    iterable: str or iterable
+        If string, will be split with ``split(splitAt)``
+        to create a list. Every item in this list, or original
+        iterable, will be iterated over and converted accoring
+        to the other arguments.
+    of: type
+        Convert each value in ``iterable`` to this data type.
+    out: type
+        Return data type. Will be passed the iterable of
+        converted items of data dtype ``of``.
+
+    Returns
+    -------
+    vector
+        Iterable of all values of ``iterable``, or split variant,
+        converted to type ``of``.
+
+    Examples
+    --------
+    ::
+
+        >>> v = "1 2 3 4"
+        >>> str2vec(v)
+        array([1., 2., 3., 4.,])
+
+        >>> str2vec(v, int, list)
+        [1, 2, 3, 4]
+
+        >>> x = [1, 2, 3, 4]
+        >>> str2vec(x)
+        array([1., 2., 3., 4.,])
+
+    """
+    vec = (iterable.split() if isinstance(iterable, str)
+           else iterable)
+    return out([of(xx) for xx in vec])
+
+
+def splitValsUncs(iterable, copy=False):
+    """
+    Return even and odd indexed values from iterable
+
+    Designed to extract expected values and uncertainties from
+    SERPENT vectors/matrices of the form
+    ``[x1, u1, x2, u2, ...]``
+
+    Slices along the last axis present on ``iterable``, e.g.
+    columns in 2D matrix.
+
+    Parameters
+    ----------
+    iterable: :class:`numpy.ndarray`or iterable
+        Initial arguments to be processed. If not
+        :class:`numpy.ndarray`, then strings will be converted
+        by calling :func:`str2vec`. Lists and tuples
+        will be sent directly to arrays with
+        :func:`numpy.array`.
+    copy: bool
+        If true, return a unique instance of the values
+        and uncertainties. Otherwise, returns a view
+        per numpy slicing methods
+
+    Returns
+    -------
+    :class:`numpy.ndarray`
+        Even indexed values from ``iterable``
+    :class:`numpy.ndarray`
+        Odd indexed values from ``iterable``
+
+    Examples
+    --------
+    ::
+
+        >>> v = [1, 2, 3, 4]
+        >>> splitValsUncs(v)
+        array([1, 3]), array([2, 4])
+
+        >>> line = "1 2 3 4"
+        >>> splitValsUnc(line)
+        array([1, 3]), array([2, 4])
+
+        >>> v = [[1, 2], [3, 4]]
+        >>> splitValsUncs(v)
+        array([[1], [3]]), array([[2], [4]])
+
+    """
+
+    if not isinstance(iterable, ndarray):
+        iterable = (str2vec(iterable) if isinstance(iterable, str)
+                    else array(iterable))
+    vals = iterable[..., 0::2]
+    uncs = iterable[..., 1::2]
+    if copy:
+        return vals.copy(), uncs.copy()
+    return vals, uncs
+
+
+def convertVariableName(variable):
+    """
+    Return the mixedCase version of a SERPENT variable.
+
+    Parameters
+    ----------
+    variable: str
+        ``SERPENT_STYLE`` variable name to be converted
+
+    Returns
+    -------
+    str:
+        Variable name that has been split at underscores and
+        converted to ``mixedCase``
+
+    Examples
+    --------
+    ::
+
+        >>> v = "INF_KINF"
+        >>> convertVariableName(v)
+        infKinf
+
+        >>> v = "VERSION"
+        >>> convertVariableName(v)
+        version
+
+    """
+    lowerSplits = [item.lower() for item in variable.split('_')]
+    if len(lowerSplits) == 1:
+        return lowerSplits[0]
+    return lowerSplits[0] + ''.join([item.capitalize()
+                                     for item in lowerSplits[1:]])
+
+
+LEADER_TO_WIKI = "http://serpent.vtt.fi/mediawiki/index.php/"
+
+
+def linkToWiki(subLink, text=None):
+    """
+    Return a string that will render as a hyperlink to the SERPENT wiki.
+
+    Parameters
+    ----------
+    subLink: str
+        Desired path inside the SERPENT wiki - following the
+        ``index.php``
+    text: None or str
+        If given, use this as the shown text for the full link.
+
+    Returns
+    -------
+    str:
+        String that can be used as an rst hyperlink to the
+        SERPENT wiki
+
+    Examples
+    --------
+    >>> linkToWiki('Input_syntax_manual')
+    http://serpent.vtt.fi/mediawiki/index.php/Input_syntax_manual
+    >>> linkToWiki('Description_of_output_files#Burnup_calculation_output',
+    ...            "Depletion Output")
+    `Depletion Output <http://serpent.vtt.fi/mediawiki/index.php/
+    Description_of_output_files#Burnup_calculation_output>`_
+    """
+    fullLink = LEADER_TO_WIKI + subLink
+    if not text:
+        return fullLink
+    return "`{} <{}>`_".format(text, fullLink)
diff --git a/serpentTools/utils/docstrings.py b/serpentTools/utils/docstrings.py
index cb32b74..499eb16 100644
--- a/serpentTools/utils/docstrings.py
+++ b/serpentTools/utils/docstrings.py
@@ -4,8 +4,12 @@ Utilities for modifying docstrings
 from functools import wraps
 from textwrap import dedent
 
+from six import iteritems
+
 __all__ = [
     'magicPlotDocDecorator',
+    'compareDocDecorator',
+    'compareDocReplacer',
 ]
 
 _MPL_AX = ':py:class:`matplotlib.axes.Axes`'
@@ -102,3 +106,73 @@ def magicPlotDocDecorator(f):
             doc = doc.replace(lookF, replace)
     decorated.__doc__ = doc
     return decorated
+
+
+COMPARE_DOC_DESC = """
+    desc0: dict or None
+    desc1: dict or None
+        Description of the origin of each value set. Only needed
+        if ``quiet`` evalues to ``True``."""
+COMPARE_DOC_HERALD = """herald: callable
+        Function that accepts a single string argument used to
+        notify that differences were found. If
+        the function is not a callable object, a
+        :func:`serpentTools.messages.critical` message
+        will be printed and :func:`serpentTools.messages.error`
+        will be used."""
+COMPARE_DOC_LIMITS = """
+    lower: float or int
+        Lower limit for relative tolerances in percent
+        Differences below this will be considered allowable
+    upper: float or int
+        Upper limit for relative tolerances in percent. Differences
+        above this will be considered failure and errors
+        messages will be raised"""
+COMPARE_DOC_SIGMA = """sigma: int
+        Size of confidence interval to apply to
+        quantities with uncertainties. Quantities that do not
+        have overlapping confidence intervals will fail"""
+COMPARE_DOC_TYPE_ERR = """TypeError
+        If ``other`` is not of the same class as this class
+        nor a subclass of this class"""
+COMPARE_DOC_HEADER = """header: bool
+        Print/log an ``info`` message about this comparison."""
+COMPARE_DOC_MAPPING = {
+    'herald': COMPARE_DOC_HERALD,
+    'desc': COMPARE_DOC_DESC,
+    'compLimits': COMPARE_DOC_LIMITS,
+    'sigma': COMPARE_DOC_SIGMA,
+    'compTypeErr': COMPARE_DOC_TYPE_ERR,
+    'header': COMPARE_DOC_HEADER,
+}
+
+COMPARE_FAIL_MSG = "Values {desc0} and {desc1} are not identical:\n\t"
+COMPARE_WARN_MSG = ("Values {desc0} and {desc1} are not identical, but within "
+                    "tolerances:\n\t")
+COMPARE_PASS_MSG = "Values {desc0} and {desc0} are identical:\n\t"
+
+
+def compareDocReplacer(doc):
+    """Make replacements for comparison docstrings."""
+    if not doc:
+        return ""
+    doc = dedent(doc)
+    for magic, replace in iteritems(COMPARE_DOC_MAPPING):
+        lookF = '{' + magic + '}'
+        if lookF in doc:
+            doc = doc.replace(lookF, dedent(replace))
+    return doc
+
+
+def compareDocDecorator(f):
+    """Decorator that updates doc strings for comparison methods.
+
+    Similar to :func:`serpentTools.plot.magicPlotDocDecorator`
+    but for comparison functions
+    """
+
+    @wraps(f)
+    def decorated(*args, **kwargs):
+        return f(*args, **kwargs)
+    decorated.__doc__ = compareDocReplacer(f.__doc__)
+    return decorated


Test patch:
diff --git a/serpentTools/tests/compare/__init__.py b/serpentTools/tests/compare/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/serpentTools/tests/compare/test_depletion.py b/serpentTools/tests/compare/test_depletion.py
new file mode 100644
index 0000000..195b9ac
--- /dev/null
+++ b/serpentTools/tests/compare/test_depletion.py
@@ -0,0 +1,110 @@
+"""
+Test the comparisons of the depleted materials
+"""
+
+import serpentTools
+from serpentTools.tests.utils import TestCaseWithLogCapture
+
+DATA_FILE = 'ref_dep.m'
+REF_MATERIAL = 'fuel'
+
+
+class DepletionCompareHelper(TestCaseWithLogCapture):
+    """Read the reference files for creating the readers"""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.refReader = serpentTools.readDataFile(DATA_FILE)
+        cls.refMaterial = cls.refReader[REF_MATERIAL]
+
+    def setUp(self):
+        self.otherReader = serpentTools.readDataFile(DATA_FILE)
+        self.otherMaterial = self.otherReader[REF_MATERIAL]
+        TestCaseWithLogCapture.setUp(self)
+
+    def compare(self, lower=0, upper=0, sigma=0, verbosity='info'):
+        raise NotImplementedError
+
+    def test_compareIdentical(self):
+        """Compare the objects against themselves for passage"""
+        self.assertTrue(self.compare(0, 0, 0))
+
+    def test_mishapenMetadata(self):
+        """Verify that changes in the metadata shape fail the comparison"""
+        numNames = len(self.refMaterial.names)
+        self.otherMaterial.names = self.refMaterial.names[:numNames - 1]
+        self.assertFalse(self.compare(0, 0, 0))
+
+    def test_missingData(self):
+        """Verify that the test fails if one object is missing data."""
+        keys = list(self.refMaterial.data.keys())
+        for key in keys:
+            data = self.otherMaterial.data.pop(key)
+            self.assertFalse(self.compare(0, 0, 0))
+            self.assertMsgInLogs('ERROR', key, partial=True)
+
+            # put things back in place
+            self.handler.logMessages = {}
+            self.otherMaterial.data[key] = data
+
+    def test_minorTweakData(self):
+        """Verify that the test passes after minor tweaks to data"""
+        key = 'adens'
+        diffInPercent = 1.
+        self.otherMaterial.data[key] *= (1 + diffInPercent / 100)
+        # Test by setting the lower tolerance to barely above perturbation
+        self.assertTrue(self.compare(diffInPercent + 1E-6, diffInPercent * 2,
+                        verbosity='info'))
+        self.assertMsgInLogs("INFO", key, partial=True)
+        for level in ["WARNING", "ERROR", "CRITICAL"]:
+            self.assertTrue(level not in self.handler.logMessages)
+        # Test again, with tighter lower tolerance and look for warning
+        self.assertTrue(self.compare(diffInPercent, diffInPercent * 2,
+                        verbosity='info'))
+        self.assertMsgInLogs("WARNING", key, partial=True)
+
+
+class DepletedMaterialComparisonTester(DepletionCompareHelper):
+    """TestCase that only compares depleted materials"""
+
+    def compare(self, lower=0, upper=0, sigma=0, verbosity='info'):
+        return self.refMaterial.compare(self.otherMaterial, lower, upper,
+                                        sigma, verbosity=verbosity)
+
+
+class DepletionReaderComparisonTester(DepletionCompareHelper):
+    """Class for comparing the reader compare method"""
+
+    def compare(self, lower=0, upper=0, sigma=0, verbosity='info'):
+        return self.refReader.compare(self.otherReader, lower, upper, sigma,
+                                      verbosity=verbosity)
+
+    def test_badMetadataShapes(self):
+        """
+        Verify the comparison fails early for dissimilar metadata shapes.
+        """
+        newVecKey = 'badMetadataKey'
+        self.refReader.metadata[newVecKey] = [0, 1, 2, 3]
+        self.otherReader.metadata[newVecKey] = [0, 1, 2]
+        self.assertFalse(self.compare(100, 100, 100))
+        self.assertMsgInLogs('ERROR', newVecKey, partial=True)
+        self.refReader.metadata.pop(newVecKey)
+
+    def test_diffMaterialDicts(self):
+        """Verify the test fails if different materials are stored."""
+        for key in self.otherReader.materials:
+            self.otherReader.materials.pop(key)
+            break
+        newMaterialKey = "newMateria"
+        self.otherReader.materials[newMaterialKey] = None
+        self.assertFalse(self.compare())
+        self.assertMsgInLogs("ERROR", key, partial=True)
+        self.assertMsgInLogs("ERROR", newMaterialKey, partial=True)
+
+
+del DepletionCompareHelper
+
+
+if __name__ == '__main__':
+    from unittest import main
+    main()
diff --git a/serpentTools/tests/compare/test_detector.py b/serpentTools/tests/compare/test_detector.py
new file mode 100644
index 0000000..61dd824
--- /dev/null
+++ b/serpentTools/tests/compare/test_detector.py
@@ -0,0 +1,202 @@
+"""
+Test the comparison utilities for detectors
+"""
+
+from serpentTools.tests.utils import TestCaseWithLogCapture
+from serpentTools.utils.compare import finalCompareMsg
+from serpentTools import readDataFile
+
+TEST_FILE = 'bwr_det0.m'
+DET_TO_MODIFY = 'xymesh'
+UNMODIFIED_DETS = [
+    'spectrum',
+]
+# All other detectors that shall be unmodified
+
+
+def compareObjs(ref, other):
+    """Fixture to call identical comparisons across tests."""
+    return ref.compare(other, verbosity='debug')
+
+
+class DetCompareHelper(TestCaseWithLogCapture):
+    """
+    Helper case for testing the detector compare methods
+
+    setUpClass:
+        *. Read the ``bwr_det0.m`` file
+        *. Store this reader on the class
+
+    setUp:
+        *. Read the same file, but store this reader as the
+           one to mess up.
+        *. Call setUp from TestCaseWithLogCapture to capture
+           log messages
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.refReader = readDataFile(TEST_FILE)
+        cls.refDetector = cls.refReader.detectors[DET_TO_MODIFY]
+
+    def setUp(self):
+        TestCaseWithLogCapture.setUp(self)
+        self.otherReader = readDataFile(TEST_FILE)
+        self.otherDetector = self.otherReader.detectors[DET_TO_MODIFY]
+
+
+class DetGridsCompareTester(DetCompareHelper):
+    """
+    Tweak the grid structure on a detector and ensure that logs
+    capture this.
+    """
+
+    GRID_FMT = "Values for {} are {}"
+
+    def setUp(self):
+        DetCompareHelper.setUp(self)
+        self.gridKeys = sorted(list(self.refDetector.grids.keys()))
+
+    def test_identicalGrids(self):
+        """Verify that the inner grid compare works."""
+        similar = compareObjs(self.refDetector, self.otherDetector)
+        self.assertTrue(similar, msg='output from comparison')
+        for gridKey in self.gridKeys:
+            self.assertMsgInLogs(
+                "DEBUG", self.GRID_FMT.format(gridKey, 'identical'),
+                partial=True)
+
+    def test_modifiedGrids(self):
+        """Pick up on some errors due to modified grids."""
+        # Changes
+        # X grid unchanged
+        # E grid slightly modified, but inside tolerances
+        # Y grid modified to be outside tolerances
+        # Z grid set to be of different shape
+        # Added a bad grid that should not exist in the reference
+        grids = self.otherDetector.grids
+        missingKey = 'NOT PRESENT'
+        grids['E'] *= 1.05
+        grids['Z'] = grids['X']
+        grids['Y'] *= 2
+        grids[missingKey] = list(range(5))
+        similar = compareObjs(self.refDetector, self.otherDetector)
+        self.assertFalse(similar,
+                         msg="Mismatched grids did not induce failure.")
+        self.assertMsgInLogs(
+            "WARNING",
+            self.GRID_FMT.format('E', 'different, but within tolerances'),
+            partial=True)
+        self.assertMsgInLogs(
+            "ERROR",
+            self.GRID_FMT.format('Y', 'outside acceptable tolerances'),
+            partial=True)
+        self.assertMsgInLogs(
+            "ERROR",
+            "Z: {} - {}".format(
+                self.refDetector.grids['Z'].shape,
+                grids['Z'].shape),
+            partial=True)
+        self.assertMsgInLogs(
+            'DEBUG',
+            self.GRID_FMT.format('X', 'identical'),
+            partial=True)
+
+
+class TallyModifier(DetCompareHelper):
+    """Base class that modifies detectors and checks the comparisons."""
+
+    IDENTICAL_TALLY_MSG = "Expected values for tallies are identical"
+    INSIDE_CONF_MSG = "Confidence intervals for tallies overlap"
+    OUTISDE_CONF_MSG = ("Values for tallies are outside acceptable "
+                        "statistical limits")
+
+    def compare(self):
+        """Compare the two test objects and return the result."""
+        return compareObjs(self.refObj, self.otherObj)
+
+    @property
+    def refObj(self):
+        raise AttributeError
+
+    @property
+    def otherObj(self):
+        raise AttributeError
+
+    def checkUnmodifiedDetectors(self):
+        """Check all other detectors in the comparison."""
+        pass
+
+    def checkFinalStatus(self, obj0, obj1, status):
+        """Assert that the correct final status is logged."""
+        expected = finalCompareMsg(obj0, obj1, status)
+        level = "INFO" if status else "WARNING"
+        self.assertMsgInLogs(level, expected)
+
+    def test_unmodifiedCompare(self):
+        """Verify that w/o modifications the test passes"""
+        similar = self.compare()
+        self.assertTrue(similar)
+        self.assertMsgInLogs(
+            "DEBUG", self.IDENTICAL_TALLY_MSG,
+            partial=True)
+        self.checkFinalStatus(self.refObj, self.otherObj, True)
+        self.checkUnmodifiedDetectors()
+
+    def test_withinConfIntervals(self):
+        """Verify that slight differences in tallies are logged."""
+        self.otherDetector.tallies *= 1.01
+        similar = self.compare()
+        self.assertTrue(similar)
+        self.assertMsgInLogs(
+            "DEBUG", self.INSIDE_CONF_MSG, partial=True)
+        self.checkFinalStatus(self.refObj, self.otherObj, True)
+        self.checkUnmodifiedDetectors()
+
+    def test_outsideConfIntervals(self):
+        """Verify that large differences in tallies are logged."""
+        self.otherDetector.tallies *= 2.0
+        similar = self.compare()
+        self.assertFalse(similar)
+        self.assertMsgInLogs(
+            "ERROR", self.OUTISDE_CONF_MSG, partial=True)
+        self.checkFinalStatus(self.refObj, self.otherObj, False)
+        self.checkUnmodifiedDetectors()
+
+
+class DetectorCompareTester(TallyModifier):
+    """Class that tests a compare across detectors"""
+
+    @property
+    def refObj(self):
+        return self.refDetector
+
+    @property
+    def otherObj(self):
+        return self.otherDetector
+
+
+class DetectorReaderCompareTester(TallyModifier):
+    """Class that also tests the reader-level compare for completeness."""
+
+    @property
+    def refObj(self):
+        return self.refReader
+
+    @property
+    def otherObj(self):
+        return self.otherReader
+
+    def checkUnmodifiedDetectors(self):
+        for detName in UNMODIFIED_DETS:
+            myDet = self.refReader.detectors[detName]
+            otherDet = self.otherReader.detectors[detName]
+            finalMsg = finalCompareMsg(myDet, otherDet, True)
+            self.assertMsgInLogs("INFO", finalMsg)
+
+
+del TallyModifier
+
+if __name__ == '__main__':
+    from unittest import main
+    main()
diff --git a/serpentTools/tests/compare/test_results.py b/serpentTools/tests/compare/test_results.py
new file mode 100644
index 0000000..32a1b6f
--- /dev/null
+++ b/serpentTools/tests/compare/test_results.py
@@ -0,0 +1,80 @@
+"""
+Test object-level compare methods
+"""
+
+from serpentTools.tests.utils import TestCaseWithLogCapture
+from serpentTools.data import readDataFile
+
+
+RES_DATA_FILE = 'pwr_res.m'
+# strings that should appear in specific logging messages after formatted
+# with a single value key
+IDENTICAL_KEY_FMT = "for {} are identical"
+OVERLAPPING_KEY_FMT = "{} overlap"
+WITHIN_TOLS_KEY_FMT = "{} are different, but within tolerance"
+OUTSIDE_TOLS_KEY_FMT = "{} are outside acceptable tolerances"
+
+
+class ResultsCompareTester(TestCaseWithLogCapture):
+    """
+    Test the ResultsReader comparison methods
+    """
+
+    @classmethod
+    def setUpClass(cls):
+        cls.r0 = readDataFile(RES_DATA_FILE)
+        cls.resultsKeys = set(cls.r0.resdata.keys())
+        cls.univKeys = set()
+        for univ in cls.r0.universes.values():
+            cls.univKeys.update(set(univ.infExp.keys()))
+            cls.univKeys.update(set(univ.b1Exp.keys()))
+            cls.univKeys.update(set(univ.gc.keys()))
+            break
+
+    def setUp(self):
+        self.r1 = readDataFile(RES_DATA_FILE)
+        TestCaseWithLogCapture.setUp(self)
+
+    def _runCompare(self, verbosity):
+        return self.r0.compare(self.r1, verbosity=verbosity)
+
+    def test_fullCompare(self):
+        """Test the primary comparison method with no modifications."""
+        out = self._runCompare('debug')
+        self.assertTrue(out, msg="Result from comparison")
+        self.assertMsgInLogs('DEBUG', 'Updated setting verbosity to debug')
+        for resKey in self.resultsKeys:
+            self.assertMsgInLogs('DEBUG', IDENTICAL_KEY_FMT.format(resKey),
+                                 partial=True)
+
+    def test_moddedResults(self):
+        """
+        Verify that the results comparison logs errors in modified data.
+        """
+        resd = self.r1.resdata
+        # drastically increase one value with uncertainties
+        # to ensure disagreement
+        resd['anaKeff'][:, ::2] *= 2
+        # slightly modify one value with uncertainties to force overlapping
+        # confidence intervals, but not identical quantities
+        resd['colKeff'][:, ::2] *= 1.01
+        resd['colKeff'][:, 1::2] *= 1.05
+        # modify a value w/o uncertainties slightly
+        resd['allocMemsize'] *= 1.01
+        # drastically modify a value w/o uncertainties
+        resd['uresAvail'] *= -2
+        out = self._runCompare('debug')
+        self.assertFalse(out)
+        self.assertMsgInLogs('ERROR', 'anaKeff', partial=True)
+        self.assertMsgInLogs(
+            'DEBUG', OVERLAPPING_KEY_FMT.format('colKeff'), partial=True)
+        self.assertMsgInLogs(
+            'WARNING', WITHIN_TOLS_KEY_FMT.format('allocMemsize'),
+            partial=True)
+        self.assertMsgInLogs(
+            'ERROR', OUTSIDE_TOLS_KEY_FMT.format('uresAvail'), partial=True)
+
+
+if __name__ == '__main__':
+    from unittest import main
+    main()
diff --git a/serpentTools/tests/test_base_compare.py b/serpentTools/tests/test_base_compare.py
new file mode 100644
index 0000000..82c5c19
--- /dev/null
+++ b/serpentTools/tests/test_base_compare.py
@@ -0,0 +1,56 @@
+"""Test the basic aspect of the comparison system."""
+
+from unittest import TestCase
+
+from serpentTools.objects.base import BaseObject
+from serpentTools.parsers.results import ResultsReader
+
+
+class SafeCompare(BaseObject):
+    """Object that can use the comparison method."""
+
+    def _compare(self, *args, **kwargs):
+        return True
+
+
+class SafeSubclass(SafeCompare):
+    pass
+
+
+class BaseCompareTester(TestCase):
+    """Class responsible for testing the basic compare system."""
+
+    def setUp(self):
+        self.obj = SafeCompare()
+
+    def test_badCompType(self):
+        """Verify that comparisons against bad-types raise errors."""
+        with self.assertRaises(TypeError):
+            self.obj.compare(ResultsReader(None))
+        with self.assertRaises(TypeError):
+            self.obj.compare(list())
+
+    def test_safeCompare(self):
+        """
+        Verify that the compare method doesn't fail when
+        comparing against similar objects or subclasses
+        """
+        self.obj.compare(SafeCompare())
+        self.obj.compare(SafeSubclass())
+
+    def test_badArgs(self):
+        """Verify that the compare method fails for bad arguments."""
+        other = SafeCompare()
+        with self.assertRaises(ValueError):
+            self.obj.compare(other, lower=-1)
+        with self.assertRaises(ValueError):
+            self.obj.compare(other, upper=-1)
+        with self.assertRaises(ValueError):
+            self.obj.compare(other, sigma=-1)
+        with self.assertRaises(ValueError):
+            self.obj.compare(other, lower=100, upper=1)
+
+
+if __name__ == '__main__':
+    from unittest import main
+    main()
diff --git a/serpentTools/tests/test_utils.py b/serpentTools/tests/test_utils.py
index 89301b2..ce80756 100644
--- a/serpentTools/tests/test_utils.py
+++ b/serpentTools/tests/test_utils.py
@@ -2,16 +2,32 @@
 Test the various utilities in serpentTools/utils.py
 """
 
-import unittest
+from unittest import TestCase
 
-from numpy import arange, ndarray, array
+from numpy import arange, ndarray, array, ones, ones_like, zeros_like
 from numpy.testing import assert_array_equal
 from six import iteritems
 
-from serpentTools.utils import convertVariableName, splitValsUncs, str2vec
+from serpentTools.utils import (
+    convertVariableName,
+    splitValsUncs,
+    str2vec,
+    getCommonKeys,
+    directCompare,
+    getOverlaps,
+    splitDictByKeys,
+    DC_STAT_GOOD,
+    DC_STAT_LE_LOWER,
+    DC_STAT_MID,
+    DC_STAT_GE_UPPER,
+    DC_STAT_NOT_IDENTICAL,
+    DC_STAT_NOT_IMPLEMENTED,
+    DC_STAT_DIFF_TYPES,
+    DC_STAT_DIFF_SHAPES,
+)
 
 
-class VariableConverterTester(unittest.TestCase):
+class VariableConverterTester(TestCase):
     """Class for testing our variable name conversion function."""
 
     def test_variableConversion(self):
@@ -26,7 +42,7 @@ class VariableConverterTester(unittest.TestCase):
             self.assertEqual(expected, actual, msg=serpentStyle)
 
 
-class VectorConverterTester(unittest.TestCase):
+class VectorConverterTester(TestCase):
     """Class for testing the str2vec function"""
 
     def setUp(self):
@@ -60,7 +76,7 @@ class VectorConverterTester(unittest.TestCase):
             self.assertEqual(expected, actual, msg=case)
 
 
-class SplitValsTester(unittest.TestCase):
+class SplitValsTester(TestCase):
     """Class that tests splitValsUncs."""
 
     def setUp(self):
@@ -93,5 +109,274 @@ class SplitValsTester(unittest.TestCase):
         assert_array_equal(expectedU, actualU, err_msg="Uncertainties")
 
 
+class CommonKeysTester(TestCase):
+    """Class that tests getCommonKeys"""
+
+    def test_goodKeys_dict(self):
+        """Verify a complete set of keys is returned from getCommonKeys"""
+        d0 = {1: None, '2': None, (1, 2, 3): "tuple"}
+        expected = set(d0.keys())
+        actual = getCommonKeys(d0, d0, 'identical dictionary')
+        self.assertSetEqual(expected, actual,
+                            msg="Passed two dictionaries")
+        actual = getCommonKeys(d0, expected, 'dictionary and set')
+        self.assertSetEqual(expected, actual,
+                            msg="Passed dictionary and set")
+
+    def test_getKeys_missing(self):
+        """Verify that missing keys are properly notified."""
+        log = []
+        d0 = {1, 2, 3}
+        emptyS = set()
+        desc0 = "xObj0x"
+        desc1 = "xObj1x"
+        common = getCommonKeys(d0, emptyS, 'missing keys', desc0, desc1,
+                               herald=log.append)
+        self.assertSetEqual(emptyS, common)
+        self.assertEqual(len(log), 1, msg="Failed to append warning message")
+        warnMsg = log[0]
+        self.assertIsInstance(warnMsg, str, msg="Log messages is not string")
+        for desc in (desc0, desc1):
+            errMsg = "Description {} missing from warning message\n{}"
+            self.assertIn(desc, warnMsg, msg=errMsg.format(desc, warnMsg))
+
+
+class DirectCompareTester(TestCase):
+    """Class for testing utils.directCompare."""
+
+    NUMERIC_ITEMS = (
+        [0, 0.001],
+        [1E-8, 0],
+        [array([1, 1, ]), array([1, 1.0001])],
+    )
+
+    def checkStatus(self, expected, obj0, obj1, lower, upper, msg=None):
+        """Wrapper around directCompare with ``args``. Pass ``kwargs`` to
+        assertEqual."""
+        actual = directCompare(obj0, obj1, lower, upper)
+        self.assertEqual(expected, actual, msg=msg)
+
+    def test_badTypes(self):
+        """Verify that two objects of different types return -1."""
+        status = DC_STAT_DIFF_TYPES
+        value = 1
+        for otherType in (bool, str):
+            self.checkStatus(status, value, otherType(value), 0, 1,
+                             msg=str(otherType))
+        asIterable = [value, ]
+        for otherType in (list, set, tuple, array):
+            self.checkStatus(status, value, otherType(asIterable), 0, 1,
+                             msg=str(otherType))
+
+    def test_identicalString(self):
+        """Verify that identical strings return 0."""
+        msg = obj = 'identicalStrings'
+        status = DC_STAT_GOOD
+        self.checkStatus(status, obj, obj, 0, 1, msg=msg)
+
+    def test_dissimilarString(self):
+        """Verify returns the proper code for dissimilar strings."""
+        msg = "dissimilarStrings"
+        status = DC_STAT_NOT_IDENTICAL
+        self.checkStatus(status, 'item0', 'item1', 0, 1, msg=msg)
+
+    def _testNumericsForItems(self, status, lower, upper):
+        msg = "lower: {lower}, upper: {upper}\n{}\n{}"
+        for (obj0, obj1) in self.NUMERIC_ITEMS:
+            self.checkStatus(status, obj0, obj1, lower, upper,
+                             msg=msg.format(obj0, obj1, lower=lower,
+                                            upper=upper))
+
+    def test_acceptableLow(self):
+        """Verify returns the proper code for close numerics."""
+        lower = 5
+        upper = 1E4
+        self._testNumericsForItems(DC_STAT_LE_LOWER, lower, upper)
+
+    def test_acceptableHigh(self):
+        """Verify returns the proper code for close but not quite values."""
+        lower = 0
+        upper = 1E4
+        self._testNumericsForItems(DC_STAT_MID, lower, upper)
+
+    def test_outsideTols(self):
+        """Verify returns the proper code for values outside tolerances."""
+        lower = 1E-8
+        upper = 1E-8
+        self._testNumericsForItems(DC_STAT_GE_UPPER, lower, upper)
+
+    def test_notImplemented(self):
+        """Check the not-implemented cases."""
+        self.checkStatus(DC_STAT_NOT_IMPLEMENTED, {'hello': 'world'},
+                         {'hello': 'world'}, 0, 0,
+                         msg="testing on dictionaries")
+
+    def test_stringArrays(self):
+        """Verify the function handles string arrays properly."""
+        stringList = ['hello', 'world', '1', '-1']
+        vec0 = array(stringList)
+        self.checkStatus(DC_STAT_GOOD, vec0, vec0, 0, 0,
+                         msg='Identical string arrays')
+        vec1 = array(stringList)
+        vec1[-1] = 'foobar'
+        self.checkStatus(DC_STAT_NOT_IDENTICAL, vec0, vec1, 0, 0,
+                         msg='Dissimilar string arrays')
+
+    def test_diffShapes(self):
+        """
+        Verify that that directCompare fails for arrays of different shape.
+        """
+        vec0 = [0, 1, 2, 3, 4]
+        vec1 = [0, 1]
+        mat0 = [[0, 1], [1, 2]]
+        mat1 = [[0, 1, 2], [3, 4, 5], [6, 7, 8]]
+        self.checkStatus(DC_STAT_DIFF_SHAPES, vec0, vec1, 0, 0,
+                         msg="Compare two vectors.")
+        self.checkStatus(DC_STAT_DIFF_SHAPES, vec0, mat0, 0, 0,
+                         msg="Compare vector and array.")
+        self.checkStatus(DC_STAT_DIFF_SHAPES, mat0, mat1, 0, 0,
+                         msg="Compare vector and array.")
+
+
+class OverlapTester(TestCase):
+    """Class for testing the Overlapping uncertainties function."""
+
+    a0 = ones(4)
+    a1 = ones(4) * 0.5
+    u0 = array([0, 0.2, 0.1, 0.2])
+    u1 = array([1, 0.55, 0.25, 0.4])
+    expected = array([True, True, False, True])
+    sigma = 1
+    relative = False
+
+    _errMsg = "Sigma:{}\na0:\n{}\nu0:\n{}\na1:\n{}\nu1:\n{}"
+
+    def _test(self, expected, a0, a1, u0, u1, sigma, relative):
+        """Symmetric test on the data by switching the order of arguments."""
+        assert_array_equal(expected, getOverlaps(a0, a1, u0, u1, sigma,
+                                                 relative),
+                           err_msg=self._errMsg.format(a0, u0, a1, u1, sigma))
+        assert_array_equal(expected, getOverlaps(a1, a0, u1, u0, sigma,
+                                                 relative),
+                           err_msg=self._errMsg.format(sigma, a1, u1, a0, u0))
+
+    def _testWithReshapes(self, expected, a0, a1, u0, u1, sigma, shape,
+                          relative):
+        """Call symmetric test twice, using reshaped arrays the second time."""
+        self._test(expected, a0, a1, u0, u1, sigma, relative)
+        ra0, ra1, ru0, ru1 = [arg.reshape(*shape) for arg in [a0, a1, u0, u1]]
+        self._test(expected.reshape(*shape), ra0, ra1, ru0, ru1, sigma,
+                   relative)
+
+    def test_overlap_absolute(self):
+        """Verify the getOverlaps works using absolute uncertainties."""
+        self._testWithReshapes(self.expected, self.a0, self.a1, self.u0,
+                               self.u1, self.sigma, (2, 2), False)
+
+    def test_overlap_relative(self):
+        """Verify the getOverlaps works using relative uncertainties."""
+        u0 = self.u0 / self.a0
+        u1 = self.u1 / self.a1
+        self._testWithReshapes(self.expected, self.a0, self.a1, u0,
+                               u1, self.sigma, (2, 2), True)
+
+    @staticmethod
+    def _setupIdentical(nItems, shape=None):
+        arr = arange(nItems)
+        if shape is not None:
+            arr = arr.reshape(*shape)
+        unc = zeros_like(arr)
+        expected = ones_like(arr, dtype=bool)
+        return arr, unc, expected
+
+    def test_overlap_identical_1D(self):
+        """
+        Verify that all positions are found to overlap for identical arrays.
+        """
+        vec, unc, expected = self._setupIdentical(8)
+        self._test(expected, vec, vec, unc, unc, 1, False)
+
+    def test_overlap_identical_2D(self):
+        """
+        Verify that all positions are found to overlap for identical arrays.
+        """
+        vec, unc, expected = self._setupIdentical(8, (2, 4))
+        self._test(expected, vec, vec, unc, unc, 1, False)
+
+    def test_overlap_identical_3D(self):
+        """
+        Verify that all positions are found to overlap for identical arrays.
+        """
+        vec, unc, expected = self._setupIdentical(8, (2, 2, 2))
+        self._test(expected, vec, vec, unc, unc, 1, False)
+
+    def test_overlap_badshapes(self):
+        """Verify IndexError is raised for bad shapes."""
+        vec = arange(4)
+        mat = vec.reshape(2, 2)
+        with self.assertRaises(IndexError):
+            getOverlaps(vec, mat, vec, mat, 1)
+
+
+class SplitDictionaryTester(TestCase):
+    """Class for testing utils.splitDictByKeys."""
+
+    def setUp(self):
+        self.map0 = {
+            'hello': 'world',
+            'missingFrom1': True,
+            'infKeff': arange(2),
+            'float': 0.24,
+            'absKeff': arange(2),
+            'anaKeff': arange(6),
+            'notBool': 1,
+        }
+        self.map1 = {
+            'hello': 'world',
+            'infKeff': arange(2),
+            'float': 0.24,
+            'missingFrom0': True,
+            'notBool': False,
+            'anaKeff': arange(2),
+            'absKeff': arange(2),
+        }
+        self.badTypes = {'notBool': (int, bool)}
+        self.badShapes = {'anaKeff': ((6, ), (2, )), }
+        self.goodKeys = {'hello', 'absKeff', 'float', 'infKeff', }
+
+    def callTest(self, keySet=None):
+        return splitDictByKeys(self.map0, self.map1, keySet)
+
+    def test_noKeys(self):
+        """Verify that splitDictByKeys works when keySet is None."""
+        m0, m1, badTypes, badShapes, goodKeys = self.callTest(None)
+        self.assertSetEqual({'missingFrom0', }, m0)
+        self.assertSetEqual({'missingFrom1', }, m1)
+        self.assertDictEqual(self.badTypes, badTypes)
+        self.assertDictEqual(self.badShapes, badShapes)
+        self.assertSetEqual(self.goodKeys, goodKeys)
+
+    def test_keySet_all(self):
+        """Verify that splitDictByKeys works when keySet is all keys."""
+        keys = set(self.map0.keys())
+        keys.update(set(self.map1.keys()))
+        keys.add('NOT IN ANY MAP')
+        missing0 = set()
+        missing1 = set()
+        for key in keys:
+            if key not in self.map0:
+                missing0.add(key)
+            if key not in self.map1:
+                missing1.add(key)
+
+        m0, m1, badTypes, badShapes, goodKeys = self.callTest(keys)
+        self.assertSetEqual(missing0, m0)
+        self.assertSetEqual(missing1, m1)
+        self.assertDictEqual(self.badTypes, badTypes)
+        self.assertDictEqual(self.badShapes, badShapes)
+        self.assertSetEqual(self.goodKeys, goodKeys)
+
+
 if __name__ == '__main__':
-    unittest.main()
+    from unittest import main
+    main()