Repository: CORE-GATECH-GROUP/serpent-tools
Pull request #221 (2018-08-06 14:22:04)

[DEV] Utils module
Cleaned up our `utils` module by creating a new directory with dedicated files
```
serpentTools/utils
  - plot.py: - functions dedicated to assisting the creation and formatting of plots
  - docstrings.py - functions dedicated to tweaking docstrings, e.g. magicPlotDocDecorator
  - __init__.py - includes the primary developer-focused functions from the above two files, and those that did not fit into the mold, e.g. str2Vec, convertVariableName, etc.
```

This structure ensures compatibility between previous commits, so that `from serpentTools.utils import X` should work for almost all use cases. Passing unittests indicate all should work, but our coverage indicates we could miss some.

## But why

I want to add a `serpentTools/utils/compare.py` file on the comparison branch with all of the functions created to perform our comparison, but also a helpful high-level comparison method for comparing dictionaries of data. This cleanup will be helpful for reducing the clutter and providing dedicated spaces for these compare methods. In the future, we  can also add some math routines, like normalizing detector data per unit lethargy (we do this, but inside the `spectrumPlot` method) into something like `serpentTools/utils/math.py`. Name might use some work
- [x] PR fits the [project scope](http://serpent-tools.readthedocs.io/en/latest/develop/contributing.html#project-scope)
- [x] Code to be committed is [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) and matches the [code style](http://serpent-tools.readthedocs.io/en/latest/develop/codestyle.html#code-style) of the project
- [x] New and/or changed features are [well documented](http://serpent-tools.readthedocs.io/en/latest/develop/documentation.html#documentation) and have adequate testing

## TODO
- [ ] Add this to PR


Patch:
diff --git a/docs/changelog.rst b/docs/changelog.rst
index 33a6601..77817a2 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,6 +6,13 @@
 Changelog
 =========
 
+.. _vNext:
+
+Next
+====
+
+* :pull:`221` - Expanded ``utils`` module to better assist developers
+
 .. _v0.5.2:
 
 :release-tag:`0.5.2`
diff --git a/docs/develop/plot-routines.rst b/docs/develop/plot-routines.rst
index 70db77f..9fa8e3d 100644
--- a/docs/develop/plot-routines.rst
+++ b/docs/develop/plot-routines.rst
@@ -27,21 +27,21 @@ Plot Formatters
 The following functions can be used to tidy up a plot given our wide-range
 of supported arguments.
 
-.. autofunction:: serpentTools.plot.formatPlot
+.. autofunction:: serpentTools.utils.plot.formatPlot
 
-.. autoattribute:: serpentTools.plot.PLOT_FORMAT_DEFAULTS
+.. autoattribute:: serpentTools.utils.plot.PLOT_FORMAT_DEFAULTS
 
-.. autoattribute:: serpentTools.plot.LEGEND_KWARGS
+.. autoattribute:: serpentTools.utils.plot.LEGEND_KWARGS
 
-.. autofunction:: serpentTools.plot.placeLegend
+.. autofunction:: serpentTools.utils.plot.placeLegend
 
-.. autofunction:: serpentTools.plot.setAx_xlims
+.. autofunction:: serpentTools.utils.plot.setAx_xlims
 
-.. autofunction:: serpentTools.plot.setAx_ylims
+.. autofunction:: serpentTools.utils.plot.setAx_ylims
 
-.. autofunction:: serpentTools.plot.addColorbar
+.. autofunction:: serpentTools.utils.plot.addColorbar
 
-.. autofunction:: serpentTools.plot.normalizerFactory
+.. autofunction:: serpentTools.utils.plot.normalizerFactory
 
 .. _dev-plot-docformatters:
 
@@ -57,9 +57,9 @@ extra code.
 See :ref:`dev-plot-magicStrings` for a listing of what is replaced
 by what when calling :func:`serpentTools.plot.magicPlotDocDecorator`.
 
-.. autofunction:: serpentTools.plot.magicPlotDocDecorator
+.. autofunction:: serpentTools.utils.docstrings.magicPlotDocDecorator
 
-.. autoattribute:: serpentTools.plot.PLOT_MAGIC_STRINGS
+.. autoattribute:: serpentTools.utils.docstrings.PLOT_MAGIC_STRINGS
     :annotation: Magic strings that, if found as {x}, will be replaced
                  by PLOT_MAGIC_STRINGS[x]
 
diff --git a/serpentTools/objects/base.py b/serpentTools/objects/base.py
index 93bcd38..8c961c4 100644
--- a/serpentTools/objects/base.py
+++ b/serpentTools/objects/base.py
@@ -9,9 +9,9 @@ from numpy import arange, hstack, log, divide
 from matplotlib.pyplot import axes
 
 from serpentTools.messages import debug, warning, SerpentToolsException
-from serpentTools.plot import (
-    plot, magicPlotDocDecorator, formatPlot, cartMeshPlot,
-    DETECTOR_PLOT_LABELS,
+from serpentTools.plot import plot, cartMeshPlot
+from serpentTools.utils import (
+    magicPlotDocDecorator, formatPlot, DETECTOR_PLOT_LABELS,
 )
 
 
diff --git a/serpentTools/objects/containers.py b/serpentTools/objects/containers.py
index 84b55fe..5b57bcd 100644
--- a/serpentTools/objects/containers.py
+++ b/serpentTools/objects/containers.py
@@ -15,7 +15,7 @@ from matplotlib import pyplot
 from numpy import array, arange, hstack, ndarray, zeros_like
 
 from serpentTools.settings import rc
-from serpentTools.plot import magicPlotDocDecorator, formatPlot
+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
diff --git a/serpentTools/objects/detectors.py b/serpentTools/objects/detectors.py
index 8bcb759..32a6706 100644
--- a/serpentTools/objects/detectors.py
+++ b/serpentTools/objects/detectors.py
@@ -31,12 +31,11 @@ from matplotlib.patches import RegularPolygon
 from matplotlib.collections import PatchCollection
 from matplotlib.pyplot import axes
 
-from serpentTools.utils import linkToWiki
 from serpentTools.messages import warning, debug, SerpentToolsException
 from serpentTools.objects.base import DetectorBase
-from serpentTools.plot import (
+from serpentTools.utils import (
     magicPlotDocDecorator, formatPlot, setAx_xlims, setAx_ylims,
-    addColorbar, normalizerFactory,
+    addColorbar, normalizerFactory, linkToWiki,
 )
 
 __all__ = ['Detector', 'CartesianDetector', 'HexagonalDetector',
diff --git a/serpentTools/objects/materials.py b/serpentTools/objects/materials.py
index 0f12b60..294da7e 100644
--- a/serpentTools/objects/materials.py
+++ b/serpentTools/objects/materials.py
@@ -4,11 +4,11 @@ import numpy
 from matplotlib import pyplot
 
 from serpentTools.messages import warning, debug
-from serpentTools.plot import (
+from serpentTools.utils import (
     magicPlotDocDecorator, formatPlot, DEPLETION_PLOT_LABELS,
+    convertVariableName,
 )
 from serpentTools.objects.base import NamedObject
-from serpentTools.utils import convertVariableName
 
 
 class DepletedMaterialBase(NamedObject):
diff --git a/serpentTools/objects/xsdata.py b/serpentTools/objects/xsdata.py
index d957fd3..a5f3cec 100644
--- a/serpentTools/objects/xsdata.py
+++ b/serpentTools/objects/xsdata.py
@@ -4,7 +4,7 @@ from matplotlib import pyplot
 
 from serpentTools.messages import error
 from serpentTools.objects.base import NamedObject
-from serpentTools.plot import magicPlotDocDecorator, formatPlot
+from serpentTools.utils.plot import magicPlotDocDecorator, formatPlot
 
 
 class XSData(NamedObject):
diff --git a/serpentTools/parsers/depletion.py b/serpentTools/parsers/depletion.py
index 6f6e5d9..76e94e0 100644
--- a/serpentTools/parsers/depletion.py
+++ b/serpentTools/parsers/depletion.py
@@ -4,10 +4,11 @@ import re
 from matplotlib import pyplot
 from six import iteritems
 
-from serpentTools.plot import (
+from serpentTools.utils import (
     magicPlotDocDecorator, formatPlot, DEPLETION_PLOT_LABELS,
+    convertVariableName, str2vec,
 )
-from serpentTools.utils import convertVariableName, str2vec
+
 from serpentTools.engines import KeywordParser
 from serpentTools.parsers.base import MaterialReader
 from serpentTools.objects.materials import DepletedMaterial
diff --git a/serpentTools/parsers/sensitivity.py b/serpentTools/parsers/sensitivity.py
index ecfeebd..b14f628 100644
--- a/serpentTools/parsers/sensitivity.py
+++ b/serpentTools/parsers/sensitivity.py
@@ -8,7 +8,7 @@ from six import iteritems
 from numpy import transpose, hstack
 from matplotlib.pyplot import axes
 
-from serpentTools.plot import magicPlotDocDecorator, formatPlot
+from serpentTools.utils.plot import magicPlotDocDecorator, formatPlot
 from serpentTools.engines import KeywordParser
 from serpentTools.messages import warning, SerpentToolsException, critical
 from serpentTools.utils import convertVariableName, str2vec
diff --git a/serpentTools/plot.py b/serpentTools/plot.py
index 9352807..16cf648 100644
--- a/serpentTools/plot.py
+++ b/serpentTools/plot.py
@@ -1,281 +1,19 @@
 """Plot routines"""
-from functools import wraps
-from textwrap import dedent
 
 from numpy import meshgrid
 from matplotlib import pyplot
-from matplotlib.axes import Axes
-from matplotlib.colors import LogNorm, Normalize
 
 from serpentTools.messages import warning
+from serpentTools.utils import (
+    magicPlotDocDecorator,
+    addColorbar,
+    normalizerFactory,
+)
 
 __all__ = [
     'cartMeshPlot',
 ]
 
-#
-# DOCSTRING EXTRAS
-#
-_MPL_AX = ':py:class:`matplotlib.axes.Axes`'
-_LOG_BASE = """log{}: bool\n    Apply a log scale to {}."""
-LOG_LOG = _LOG_BASE.format('log', 'both axes')
-LOGX = _LOG_BASE.format('x', 'x axis')
-LOGY = _LOG_BASE.format('y', 'y axis')
-TITLE = """title: str\n    Title to apply to the figure."""
-LABELS = """labels: None or iterable
-    Labels to apply to each line drawn. This can be used to identify
-    which bin is plotted as what line."""
-
-XLABEL = """xlabel: str or None\n    Label for x-axis."""
-YLABEL = """yabel: str or None\n    Label for y-axis."""
-SIGMA = """sigma: int
-    Confidence interval to apply to errors. If not given or ``0``,
-    no errors will be drawn."""
-AX = """ax: {} or None
-    Ax on which to plot the data.""".format(_MPL_AX)
-RETURNS_AX = """{}
-    Ax on which the data was plotted.""".format(_MPL_AX)
-CMAP = """cmap: str or None
-    Valid Matplotlib colormap to apply to the plot."""
-KWARGS = """kwargs:\n    Addition keyword arguments to pass to"""
-MAT_FMT_DOC = """labelFmt: str or None
-    Formattable string for labeling the individual plots. If not
-    given, just label as isotope name, e.g. ``'U235'``.
-    Will make the following substitutions on the ``labelFmt`` string,
-    if given:
-
-    +---------------+-------------------------+
-    |Keyword        | Replacement             |
-    +===============+=========================+
-    |``'mat'``      | name of this material   |
-    +---------------+-------------------------+
-    |``'iso'``      | specific isotope name   |
-    +---------------+-------------------------+
-    |``'zai'``      | specific isotope ZZAAAI |
-    +---------------+-------------------------+
-"""
-
-UNIV_FMT_DOC = """labelFmt: str or None
-    formattable string for labeling the individual plots.
-
-    +---------+----------------------------+
-    | String  | Replaced value             |
-    +=========+============================+
-    | ``{k}`` | Name of variable plotted   |
-    +---------+----------------------------+
-    | ``{u}`` | Name of this universe      |
-    +---------+----------------------------+
-    | ``{b}`` | Value of burnup in MWd/kgU |
-    +---------+----------------------------+
-    | ``{d}`` | Value of burnup in days    |
-    +---------+----------------------------+
-    | ``{i}`` | Burnup index               |
-    +---------+----------------------------+
-"""
-
-LEGEND_KWARGS = {
-    'above': {'bbox_to_anchor': (0., 1.02, 1., 1.02),
-              'loc': 3, 'mode': 'expand'},
-    'right': {'bbox_to_anchor': (1.02, 1),
-              'loc': 2}
-}
-"""
-Settings for modifying the position of the legend
-source: `<https://matplotlib.org/users/legend_guide.html>`_
-"""
-
-
-LEGEND = """legend: bool or str
-    Automatically label the plot. Pass one of the following values
-    to place the legend outside the plot: {}""".format(
-    ', '.join(LEGEND_KWARGS.keys()))
-
-NCOL = """ncol: int\n    Integer number of columns to apply to the legend."""
-
-PLOT_MAGIC_STRINGS = {
-    'loglog': LOG_LOG, 'logy': LOGY, 'logx': LOGX,
-    'xlabel': XLABEL, 'ylabel': YLABEL, 'sigma': SIGMA,
-    'ax': AX, 'rax': RETURNS_AX, 'labels': LABELS, 'xlabel': XLABEL,
-    'ylabel': YLABEL, 'kwargs': KWARGS, 'cmap': CMAP, 'title': TITLE,
-    'matLabelFmt': MAT_FMT_DOC, 'legend': LEGEND, 'ncol': NCOL,
-    'univLabelFmt': UNIV_FMT_DOC,
-}
-"""Magic strings that, if found as {x}, will be replaced by the key of x"""
-
-DEPLETION_PLOT_LABELS = {
-    'adens': r'Atom density $[\#/b-cm]$',
-    'mdens': r'Mass density $[g/cm^3]$',
-    'a': r'Activity $[Bq]$',
-    'h': r'Decay heat $[W]$',
-    'sf': r'Spontaneous fission rate $[\#/s]$',
-    'gsrc': r'Photon emission rate $[\#/s]$',
-    'ingTox': 'Ingestion toxicity $[Sv]$',
-    'inhTox': 'Inhalation toxicity $[Sv]$',
-    'days': 'Burnup $[d]$',
-    'burnup': 'Burnup $[MWd/kgU]$',
-}
-
-DETECTOR_PLOT_LABELS = {
-    'energy': 'Energy [MeV]',
-    'xmesh': 'X Position [cm]',
-    'ymesh': 'Y Position [cm]',
-    'zmesh': 'Z Position [cm]',
-}
-
-_DET_LABEL_INDEXES = {
-    'reaction', 'material', 'cell', 'universe', 'lattice',
-}
-# bins that increment by integer indices and reflect discrete quantities,
-# unlike energy inidices
-
-for key in _DET_LABEL_INDEXES:
-    DETECTOR_PLOT_LABELS[key] = key.capitalize() + " Index"
-
-del _DET_LABEL_INDEXES
-
-
-def magicPlotDocDecorator(f):
-    """
-    Decorator that replaces a lot magic strings used in plot functions.
-
-    Allows docstrings to contain keyword that will be replaced with
-    a valid and proper explanation of the keyword.
-    Keywords must be wrapped in single brackets, i.e. ``{x}``
-    """
-
-    @wraps(f)
-    def decorated(*args, **kwargs):
-        return f(*args, **kwargs)
-    doc = dedent(f.__doc__)
-    for magic, replace in PLOT_MAGIC_STRINGS.items():
-        lookF = '{' + magic + '}'
-        if lookF in doc:
-            doc = doc.replace(lookF, replace)
-    decorated.__doc__ = doc
-    return decorated
-
-
-PLOT_FORMAT_DEFAULTS = {
-    'xlabel': None, 'ylabel': None, 'legend': True,
-    'loglog': None, 'logy': None, 'logx': None,
-    'ncol': 1, 'title': None}
-
-
-@magicPlotDocDecorator
-def formatPlot(ax, **kwargs):
-    """
-    Apply a range of formatting options to the plot.
-
-    Parameters
-    ----------
-    {ax}
-    {xlabel}
-    {ylabel}
-    {loglog}
-    {logx}
-    {logy}
-    {legend}
-    {ncol}
-    {title}
-
-    Returns
-    -------
-    {rax}
-
-    Raises
-    ------
-    TypeError
-        If the ``ax`` argument is not an instance of
-        :py:class:`matplotlib.pyplot.axes.Axes`
-
-    """
-
-    if not isinstance(ax, Axes):
-        raise TypeError("Expected {} got {}".format(type(Axes), type(ax)))
-    loglog = kwargs.get('loglog', PLOT_FORMAT_DEFAULTS['loglog'])
-    logx = kwargs.get('logx', PLOT_FORMAT_DEFAULTS['logx'])
-    logy = kwargs.get('logy', PLOT_FORMAT_DEFAULTS['logy'])
-    xlabel = kwargs.get('xlabel', PLOT_FORMAT_DEFAULTS['xlabel'])
-    ylabel = kwargs.get('ylabel', PLOT_FORMAT_DEFAULTS['ylabel'])
-    legend = kwargs.get('legend', PLOT_FORMAT_DEFAULTS['legend'])
-    title = kwargs.get('title', PLOT_FORMAT_DEFAULTS['title'])
-    ncol = kwargs.get('ncol', PLOT_FORMAT_DEFAULTS['ncol'])
-
-    if logx is None:
-        logx = inferAxScale(ax, 'x')
-    if logy is None:
-        logy = inferAxScale(ax, 'y')
-    if loglog or logx:
-        ax.set_xscale('log')
-    if loglog or logy:
-        ax.set_yscale('log')
-    if xlabel:
-        ax.set_xlabel(xlabel)
-    if ylabel:
-        ax.set_ylabel(ylabel)
-    if legend:
-        ax = placeLegend(ax, legend, ncol)
-    if title:
-        ax.set_title(title)
-
-    return ax
-
-
-def inferAxScale(ax, dim):
-    lims = getattr(ax, 'get_{}lim'.format(dim))()
-    mn = min(lims)
-    mx = max(lims)
-    if not mn:
-        return mx > 100
-    if mn < 0:
-        return mx > 10
-    return abs(mx / mn) > 100
-
-
-def normalizerFactory(data, norm, logScale, xticks, yticks):
-    """
-    Construct and return a :class:`~matplotlib.colors.Normalize` for this data
-
-    Parameters
-    ----------
-    data: :class:`numpy.ndarray`
-        Data to be plotted and normalized
-    norm: None or callable or :class:`matplotlib.colors.Normalize`
-        If a ``Normalize`` object, then use this as the normalizer.
-        If callable, set the normalizer with
-        ``norm(data, xticks, yticks)``. If not None, set the
-        normalizer to be based on the min and max of the data
-    logScale: bool
-        If this evaluates to true, construct a
-        :class:`matplotlib.colors.LogNorm` with the minimum
-        set to be the minimum of the positive values.
-    xticks: :class:`numpy.ndarray`
-    yticks: :class:`numpy.ndarray`
-        Arrays ideally corresponding to the data. Used with callable
-        `norm` function.
-
-    Returns
-    --------
-    :class:`matplotlib.colors.Normalize`
-    or :class:`matplotlib.colors.LogNorm`
-    or object:
-        Object used to normalize colormaps against these data
-    """
-    if norm is not None:
-        if isinstance(norm, Normalize):
-            return norm
-        if issubclass(norm.__class__, Normalize):
-            return norm
-        if callable(norm):
-            return norm(data, xticks, yticks)
-    if logScale:
-        if (data < 0).any():
-            warning("Negative values will be excluded from logarithmic "
-                    "colormap.")
-        posData = data[data > 0]
-        return LogNorm(posData.min(), posData.max())
-    return Normalize(data.min(), data.max())
-
 
 @magicPlotDocDecorator
 def cartMeshPlot(data, xticks=None, yticks=None, ax=None, cmap=None,
@@ -386,31 +124,6 @@ def cartMeshPlot(data, xticks=None, yticks=None, ax=None, cmap=None,
     return ax
 
 
-def addColorbar(ax, mappable, norm, cbarLabel=None):
-    """
-    Quick utility to add a colorbar to an axes object
-
-    Parameters
-    ----------
-    mappable: iterable
-        Collection of meshes, patches, or values that are used to construct
-        the colorbar.
-    norm: None or :class:`matplotlib.colors.Normalize` subclass
-        Normalizer for this plot
-    cbarLabel: None or str
-        If given, place this as the y-label for the colorbar
-
-    Returns
-    -------
-    :class:`matplotlib.colorbar.Colorbar`
-        The colorbar that was added
-    """
-    cbar = ax.figure.colorbar(mappable, norm=norm)
-    if cbarLabel is not None:
-        cbar.ax.set_ylabel(cbarLabel)
-    return cbar
-
-
 @magicPlotDocDecorator
 def plot(xdata, plotData, ax=None, labels=None, yerr=None, **kwargs):
     """
@@ -476,74 +189,3 @@ def plot(xdata, plotData, ax=None, labels=None, yerr=None, **kwargs):
         ax.legend()
 
     return ax
-
-
-@magicPlotDocDecorator
-def placeLegend(ax, legend, ncol=1):
-    """
-    Add a legend to the figure outside the plot.
-
-    Parameters
-    ----------
-    {ax}
-    {legend}
-    {ncol}
-
-    Returns
-    -------
-    {rax}
-
-    Raises
-    ------
-    KeyError
-        If ``key`` is not in :py:attr:`~serpentTools.plot.LEGEND_KWARGS`
-    """
-    handles, labels = ax.get_legend_handles_labels()
-    if not (handles and labels):
-        return ax
-    ncol = max(1, int(ncol)) if ncol else 1
-    if not isinstance(legend, str):
-        ax.legend(ncol=ncol)
-        return ax
-    kwargs = LEGEND_KWARGS[legend]
-    ax.legend(borderaxespad=0., ncol=ncol, **kwargs)
-
-    return ax
-
-
-def setAx_xlims(ax, xmin, xmax, pad=10):
-    return _set_ax_lims(ax, xmin, xmax, True, pad)
-
-
-def setAx_ylims(ax, ymin, ymax, pad=10):
-    return _set_ax_lims(ax, ymin, ymax, False, pad)
-
-
-_set_lim_doc = """
-Set the {v} limits on an Axes object
-
-Parameters
-----------
-ax: :class:`matplotlib.axes.Axes`
-    Ax to be updated
-{v}min: float
-    Current minimum extent of {v} axis
-{v}max: float
-    Current maximum extent of {v} axis
-pad: float
-    Padding, in percent, to apply to each side of
-    the current min and max
-"""
-
-
-setAx_xlims.__doc__ = _set_lim_doc.format(v='x')
-setAx_ylims.__doc__ = _set_lim_doc.format(v='y')
-del _set_lim_doc
-
-
-def _set_ax_lims(ax, vmin, vmax, xory, pad):
-    assert pad >= 0
-    func = getattr(ax, 'set_{}lim'.format('x' if xory else 'y'))
-    diff = vmax - vmin
-    offset = pad * diff / 100
-    return func(vmin - offset, vmax + offset)
diff --git a/serpentTools/samplers/depletion.py b/serpentTools/samplers/depletion.py
index d4bb0af..96d7207 100644
--- a/serpentTools/samplers/depletion.py
+++ b/serpentTools/samplers/depletion.py
@@ -10,7 +10,7 @@ from numpy import zeros, zeros_like
 from matplotlib import pyplot
 
 from serpentTools.messages import SamplerError, warning
-from serpentTools.plot import (
+from serpentTools.utils import (
     magicPlotDocDecorator, formatPlot, DEPLETION_PLOT_LABELS,
 )
 from serpentTools.objects.materials import DepletedMaterialBase
diff --git a/serpentTools/samplers/detector.py b/serpentTools/samplers/detector.py
index 4a36293..772fd81 100644
--- a/serpentTools/samplers/detector.py
+++ b/serpentTools/samplers/detector.py
@@ -9,7 +9,7 @@ from matplotlib import pyplot
 
 from serpentTools.messages import (MismatchedContainersError, warning,
                                    SamplerError, SerpentToolsException)
-from serpentTools.plot import magicPlotDocDecorator
+from serpentTools.utils import magicPlotDocDecorator
 from serpentTools.parsers.detector import DetectorReader
 from serpentTools.objects.base import DetectorBase
 from serpentTools.samplers.base import (Sampler, SampledContainer,
diff --git a/serpentTools/utils.py b/serpentTools/utils/__init__.py
similarity index 97%
rename from serpentTools/utils.py
rename to serpentTools/utils/__init__.py
index fce98f5..e17bbd1 100644
--- a/serpentTools/utils.py
+++ b/serpentTools/utils/__init__.py
@@ -5,6 +5,9 @@ from re import compile
 
 from numpy import array, ndarray
 
+from serpentTools.utils.docstrings import *  # noqa
+from serpentTools.utils.plot import *  # noqa
+
 # Regular expressions
 
 STR_REGEX = compile(r'\'.+\'')  # string
diff --git a/serpentTools/utils/docstrings.py b/serpentTools/utils/docstrings.py
new file mode 100644
index 0000000..cb32b74
--- /dev/null
+++ b/serpentTools/utils/docstrings.py
@@ -0,0 +1,104 @@
+"""
+Utilities for modifying docstrings
+"""
+from functools import wraps
+from textwrap import dedent
+
+__all__ = [
+    'magicPlotDocDecorator',
+]
+
+_MPL_AX = ':py:class:`matplotlib.axes.Axes`'
+_LOG_BASE = """log{}: bool\n    Apply a log scale to {}."""
+LOG_LOG = _LOG_BASE.format('log', 'both axes')
+LOGX = _LOG_BASE.format('x', 'x axis')
+LOGY = _LOG_BASE.format('y', 'y axis')
+TITLE = """title: str\n    Title to apply to the figure."""
+LABELS = """labels: None or iterable
+    Labels to apply to each line drawn. This can be used to identify
+    which bin is plotted as what line."""
+
+XLABEL = """xlabel: str or None\n    Label for x-axis."""
+YLABEL = """yabel: str or None\n    Label for y-axis."""
+SIGMA = """sigma: int
+    Confidence interval to apply to errors. If not given or ``0``,
+    no errors will be drawn."""
+AX = """ax: {} or None
+    Ax on which to plot the data.""".format(_MPL_AX)
+RETURNS_AX = """{}
+    Ax on which the data was plotted.""".format(_MPL_AX)
+CMAP = """cmap: str or None
+    Valid Matplotlib colormap to apply to the plot."""
+KWARGS = """kwargs:\n    Addition keyword arguments to pass to"""
+MAT_FMT_DOC = """labelFmt: str or None
+    Formattable string for labeling the individual plots. If not
+    given, just label as isotope name, e.g. ``'U235'``.
+    Will make the following substitutions on the ``labelFmt`` string,
+    if given:
+
+    +---------------+-------------------------+
+    |Keyword        | Replacement             |
+    +===============+=========================+
+    |``'mat'``      | name of this material   |
+    +---------------+-------------------------+
+    |``'iso'``      | specific isotope name   |
+    +---------------+-------------------------+
+    |``'zai'``      | specific isotope ZZAAAI |
+    +---------------+-------------------------+
+"""
+
+UNIV_FMT_DOC = """labelFmt: str or None
+    formattable string for labeling the individual plots.
+
+    +---------+----------------------------+
+    | String  | Replaced value             |
+    +=========+============================+
+    | ``{k}`` | Name of variable plotted   |
+    +---------+----------------------------+
+    | ``{u}`` | Name of this universe      |
+    +---------+----------------------------+
+    | ``{b}`` | Value of burnup in MWd/kgU |
+    +---------+----------------------------+
+    | ``{d}`` | Value of burnup in days    |
+    +---------+----------------------------+
+    | ``{i}`` | Burnup index               |
+    +---------+----------------------------+
+"""
+
+LEGEND = """legend: bool or str
+    Automatically label the plot. Pass one of the following values
+    to place the legend outside the plot: {}""".format(
+    "above, right")
+
+NCOL = """ncol: int\n    Integer number of columns to apply to the legend."""
+
+PLOT_MAGIC_STRINGS = {
+    'loglog': LOG_LOG, 'logy': LOGY, 'logx': LOGX,
+    'xlabel': XLABEL, 'ylabel': YLABEL, 'sigma': SIGMA,
+    'ax': AX, 'rax': RETURNS_AX, 'labels': LABELS, 'xlabel': XLABEL,
+    'ylabel': YLABEL, 'kwargs': KWARGS, 'cmap': CMAP, 'title': TITLE,
+    'matLabelFmt': MAT_FMT_DOC, 'legend': LEGEND, 'ncol': NCOL,
+    'univLabelFmt': UNIV_FMT_DOC,
+}
+"""Magic strings that, if found as {x}, will be replaced by the key of x"""
+
+
+def magicPlotDocDecorator(f):
+    """
+    Decorator that replaces a lot magic strings used in plot functions.
+
+    Allows docstrings to contain keyword that will be replaced with
+    a valid and proper explanation of the keyword.
+    Keywords must be wrapped in single brackets, i.e. ``{x}``
+    """
+
+    @wraps(f)
+    def decorated(*args, **kwargs):
+        return f(*args, **kwargs)
+    doc = dedent(f.__doc__)
+    for magic, replace in PLOT_MAGIC_STRINGS.items():
+        lookF = '{' + magic + '}'
+        if lookF in doc:
+            doc = doc.replace(lookF, replace)
+    decorated.__doc__ = doc
+    return decorated
diff --git a/serpentTools/utils/plot.py b/serpentTools/utils/plot.py
new file mode 100644
index 0000000..e4cfdd6
--- /dev/null
+++ b/serpentTools/utils/plot.py
@@ -0,0 +1,280 @@
+"""
+Utilties for assisting with plots
+"""
+from matplotlib.axes import Axes
+from matplotlib.colors import Normalize, LogNorm
+
+from serpentTools.messages import warning
+from serpentTools.utils.docstrings import magicPlotDocDecorator
+
+
+__all__ = [
+    'DETECTOR_PLOT_LABELS',
+    'DEPLETION_PLOT_LABELS',
+    'formatPlot',
+    'addColorbar',
+    'setAx_xlims',
+    'setAx_ylims',
+    'normalizerFactory',
+]
+
+LEGEND_KWARGS = {
+    'above': {'bbox_to_anchor': (0., 1.02, 1., 1.02),
+              'loc': 3, 'mode': 'expand'},
+    'right': {'bbox_to_anchor': (1.02, 1),
+              'loc': 2}
+}
+"""
+Settings for modifying the position of the legend
+source: `<https://matplotlib.org/users/legend_guide.html>`_
+"""
+
+
+DEPLETION_PLOT_LABELS = {
+    'adens': r'Atom density $[\#/b-cm]$',
+    'mdens': r'Mass density $[g/cm^3]$',
+    'a': r'Activity $[Bq]$',
+    'h': r'Decay heat $[W]$',
+    'sf': r'Spontaneous fission rate $[\#/s]$',
+    'gsrc': r'Photon emission rate $[\#/s]$',
+    'ingTox': 'Ingestion toxicity $[Sv]$',
+    'inhTox': 'Inhalation toxicity $[Sv]$',
+    'days': 'Burnup $[d]$',
+    'burnup': 'Burnup $[MWd/kgU]$',
+}
+
+DETECTOR_PLOT_LABELS = {
+    'energy': 'Energy [MeV]',
+    'xmesh': 'X Position [cm]',
+    'ymesh': 'Y Position [cm]',
+    'zmesh': 'Z Position [cm]',
+}
+
+_DET_LABEL_INDEXES = {
+    'reaction', 'material', 'cell', 'universe', 'lattice',
+}
+# bins that increment by integer indices and reflect discrete quantities,
+# unlike energy inidices
+
+for key in _DET_LABEL_INDEXES:
+    DETECTOR_PLOT_LABELS[key] = key.capitalize() + " Index"
+
+del _DET_LABEL_INDEXES
+
+
+PLOT_FORMAT_DEFAULTS = {
+    'xlabel': None, 'ylabel': None, 'legend': True,
+    'loglog': None, 'logy': None, 'logx': None,
+    'ncol': 1, 'title': None}
+
+
+@magicPlotDocDecorator
+def formatPlot(ax, **kwargs):
+    """
+    Apply a range of formatting options to the plot.
+
+    Parameters
+    ----------
+    {ax}
+    {xlabel}
+    {ylabel}
+    {loglog}
+    {logx}
+    {logy}
+    {legend}
+    {ncol}
+    {title}
+
+    Returns
+    -------
+    {rax}
+
+    Raises
+    ------
+    TypeError
+        If the ``ax`` argument is not an instance of
+        :py:class:`matplotlib.pyplot.axes.Axes`
+
+    """
+
+    if not isinstance(ax, Axes):
+        raise TypeError("Expected {} got {}".format(type(Axes), type(ax)))
+    loglog = kwargs.get('loglog', PLOT_FORMAT_DEFAULTS['loglog'])
+    logx = kwargs.get('logx', PLOT_FORMAT_DEFAULTS['logx'])
+    logy = kwargs.get('logy', PLOT_FORMAT_DEFAULTS['logy'])
+    xlabel = kwargs.get('xlabel', PLOT_FORMAT_DEFAULTS['xlabel'])
+    ylabel = kwargs.get('ylabel', PLOT_FORMAT_DEFAULTS['ylabel'])
+    legend = kwargs.get('legend', PLOT_FORMAT_DEFAULTS['legend'])
+    title = kwargs.get('title', PLOT_FORMAT_DEFAULTS['title'])
+    ncol = kwargs.get('ncol', PLOT_FORMAT_DEFAULTS['ncol'])
+
+    if logx is None:
+        logx = inferAxScale(ax, 'x')
+    if logy is None:
+        logy = inferAxScale(ax, 'y')
+    if loglog or logx:
+        ax.set_xscale('log')
+    if loglog or logy:
+        ax.set_yscale('log')
+    if xlabel:
+        ax.set_xlabel(xlabel)
+    if ylabel:
+        ax.set_ylabel(ylabel)
+    if legend:
+        ax = placeLegend(ax, legend, ncol)
+    if title:
+        ax.set_title(title)
+
+    return ax
+
+
+def inferAxScale(ax, dim):
+    lims = getattr(ax, 'get_{}lim'.format(dim))()
+    mn = min(lims)
+    mx = max(lims)
+    if not mn:
+        return mx > 100
+    if mn < 0:
+        return mx > 10
+    return abs(mx / mn) > 100
+
+
+def normalizerFactory(data, norm, logScale, xticks, yticks):
+    """
+    Construct and return a :class:`~matplotlib.colors.Normalize` for this data
+
+    Parameters
+    ----------
+    data: :class:`numpy.ndarray`
+        Data to be plotted and normalized
+    norm: None or callable or :class:`matplotlib.colors.Normalize`
+        If a ``Normalize`` object, then use this as the normalizer.
+        If callable, set the normalizer with
+        ``norm(data, xticks, yticks)``. If not None, set the
+        normalizer to be based on the min and max of the data
+    logScale: bool
+        If this evaluates to true, construct a
+        :class:`matplotlib.colors.LogNorm` with the minimum
+        set to be the minimum of the positive values.
+    xticks: :class:`numpy.ndarray`
+    yticks: :class:`numpy.ndarray`
+        Arrays ideally corresponding to the data. Used with callable
+        `norm` function.
+
+    Returns
+    --------
+    :class:`matplotlib.colors.Normalize`
+    or :class:`matplotlib.colors.LogNorm`
+    or object:
+        Object used to normalize colormaps against these data
+    """
+    if norm is not None:
+        if isinstance(norm, Normalize):
+            return norm
+        if issubclass(norm.__class__, Normalize):
+            return norm
+        if callable(norm):
+            return norm(data, xticks, yticks)
+    if logScale:
+        if (data < 0).any():
+            warning("Negative values will be excluded from logarithmic "
+                    "colormap.")
+        posData = data[data > 0]
+        return LogNorm(posData.min(), posData.max())
+    return Normalize(data.min(), data.max())
+
+
+@magicPlotDocDecorator
+def placeLegend(ax, legend, ncol=1):
+    """
+    Add a legend to the figure outside the plot.
+
+    Parameters
+    ----------
+    {ax}
+    {legend}
+    {ncol}
+
+    Returns
+    -------
+    {rax}
+
+    Raises
+    ------
+    KeyError
+        If ``key`` is not in :py:attr:`~serpentTools.plot.LEGEND_KWARGS`
+    """
+    handles, labels = ax.get_legend_handles_labels()
+    if not (handles and labels):
+        return ax
+    ncol = max(1, int(ncol)) if ncol else 1
+    if not isinstance(legend, str):
+        ax.legend(ncol=ncol)
+        return ax
+    kwargs = LEGEND_KWARGS[legend]
+    ax.legend(borderaxespad=0., ncol=ncol, **kwargs)
+
+    return ax
+
+
+def setAx_xlims(ax, xmin, xmax, pad=10):
+    return _set_ax_lims(ax, xmin, xmax, True, pad)
+
+
+def setAx_ylims(ax, ymin, ymax, pad=10):
+    return _set_ax_lims(ax, ymin, ymax, False, pad)
+
+
+_set_lim_doc = """
+Set the {v} limits on an Axes object
+
+Parameters
+----------
+ax: :class:`matplotlib.axes.Axes`
+    Ax to be updated
+{v}min: float
+    Current minimum extent of {v} axis
+{v}max: float
+    Current maximum extent of {v} axis
+pad: float
+    Padding, in percent, to apply to each side of
+    the current min and max
+"""
+
+
+setAx_xlims.__doc__ = _set_lim_doc.format(v='x')
+setAx_ylims.__doc__ = _set_lim_doc.format(v='y')
+del _set_lim_doc
+
+
+def _set_ax_lims(ax, vmin, vmax, xory, pad):
+    assert pad >= 0
+    func = getattr(ax, 'set_{}lim'.format('x' if xory else 'y'))
+    diff = vmax - vmin
+    offset = pad * diff / 100
+    return func(vmin - offset, vmax + offset)
+
+
+def addColorbar(ax, mappable, norm, cbarLabel=None):
+    """
+    Quick utility to add a colorbar to an axes object
+
+    Parameters
+    ----------
+    mappable: iterable
+        Collection of meshes, patches, or values that are used to construct
+        the colorbar.
+    norm: None or :class:`matplotlib.colors.Normalize` subclass
+        Normalizer for this plot
+    cbarLabel: None or str
+        If given, place this as the y-label for the colorbar
+
+    Returns
+    -------
+    :class:`matplotlib.colorbar.Colorbar`
+        The colorbar that was added
+    """
+    cbar = ax.figure.colorbar(mappable, norm=norm)
+    if cbarLabel is not None:
+        cbar.ax.set_ylabel(cbarLabel)
+    return cbar
diff --git a/setup.py b/setup.py
index 6115337..8c483d7 100644
--- a/setup.py
+++ b/setup.py
@@ -56,7 +56,7 @@ pythonRequires = '>=2.7,!=3.0,!=3.1,!=3.2,!=3.3,!=3.4'
 
 setupArgs = {
     'name': 'serpentTools',
-    'packages': ['serpentTools', 'serpentTools.parsers',
+    'packages': ['serpentTools', 'serpentTools.parsers', 'serpentTools.utils',
                  'serpentTools.data', 'serpentTools.tests',
                  'serpentTools.objects', 'serpentTools.samplers'],
     'url': 'https://github.com/CORE-GATECH-GROUP/serpent-tools',


Test patch:
diff --git a/serpentTools/tests/test_plot.py b/serpentTools/tests/test_plot.py
index 3f48f6e..5afd21c 100644
--- a/serpentTools/tests/test_plot.py
+++ b/serpentTools/tests/test_plot.py
@@ -8,7 +8,7 @@ from numpy import arange
 from matplotlib.colors import Normalize, LogNorm
 from matplotlib.pyplot import subplots
 
-from serpentTools.plot import (
+from serpentTools.utils import (
     formatPlot, normalizerFactory, setAx_xlims, setAx_ylims,
 )