Repository: CORE-GATECH-GROUP/serpent-tools
Pull request #262 (2019-01-21 18:27:01)

[API] Collector for coefficient files
Created a `BranchCollector` class in `serpentTools.xs` with the sole purpose of taking group constants from a coefficient file and storing them in multi-dimensional tables. Included a jupyter notebook but I haven't converted to part of our documentation yet for one main reason.

**This is experimental**

I like the data layout so far, but we (myself and @DanKotlyar) want to play around with this and make it available for others to utilize.  Currently, group constants are stored in arrays with dimensions corresponding to the following parameters: universe, any perturbations found in the file, burnup, and then energy group. There are some things that I would like feedback from, either here on this issue or, pending approval, later down the development pipeline, such as

1. Is this an ideal way to access and iterate over group constants? Maybe we want burnup to be before perturbations.
2. Should we store the values in `BranchCollector.xsTables` as `BranchedDataTable` objects as well? 
3. Is there any benefit to creating the `BranchedDataTable` object? It mainly serves as a wrapper over the underlying `numpy` array, with access to perturbation information.

:rotating_light: Since the data structure may change, I have left out major testing :rotating_light: 

I will add testing of the existing data storage at request with no issue. The testing is left out because, if we do change the data structure, then the testing would have to be revisited as well.

This is something that could be very useful for people, hence why this experimental version is linked to our next minor release, [0.6.2](https://github.com/CORE-GATECH-GROUP/serpent-tools/milestone/9). I want to have the data structure, proper testing, and documentation completed by the next major release, [0.7.0](https://github.com/CORE-GATECH-GROUP/serpent-tools/milestone/10)

## Todo
- [x] Add something to changelog
- [x] Convert jupyter notebook to documentation
- [x] `BranchCollector.xsTable` :arrow_right: `BranchCollector.xsTables`

If we keep the `BranchedDataTable` object:
- [ ] Testing of getting and setting attributes
- [ ] Add `__getitem__` method to view/slice into data easier


Patch:
diff --git a/docs/api/index.rst b/docs/api/index.rst
index 18a756f..c9edce1 100644
--- a/docs/api/index.rst
+++ b/docs/api/index.rst
@@ -27,4 +27,5 @@ API
    DepletionMatrix.rst
    exceptions.rst
    seed.rst
+   xs.rst
    io.rst
diff --git a/docs/api/xs.rst b/docs/api/xs.rst
new file mode 100644
index 0000000..5230839
--- /dev/null
+++ b/docs/api/xs.rst
@@ -0,0 +1,11 @@
+.. _api-xs:
+
+=================================
+Branching Cross Section Collector
+=================================
+
+.. autoclass:: serpentTools.xs.BranchCollector
+
+.. autoclass:: serpentTools.xs.BranchedUniv
+
+.. autoclass:: serpentTools.xs.BranchedDataTable
diff --git a/docs/changelog.rst b/docs/changelog.rst
index de8b25c..970e877 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -6,7 +6,7 @@
 .. |depmtxReader| replace:: :class:`~serpentTools.parsers.depmatrix.DepmtxReader`
 .. |toMatlab-short| replace:: :func:`~serpentTools.io.toMatlab`
 .. |toMatlab-full| replace:: :func:`serpentTools.io.toMatlab`
-
+.. |branchCollector| replace:: :class:`~serpentTools.xs.BranchCollector` 
 .. _changelog:
 
 =========
@@ -25,6 +25,15 @@ Next
   MATLAB files using |toMatlab-full|
 * |resultReader| can plot data using
   :meth:`~serpentTools.parsers.results.ResultsReader.plot`
+* Experimental |branchCollector| for
+  collecting group constants from coefficient files. Collects
+  group constants in in multi-dimensional matrices according
+  to perturbations, universes, and burnup.
+
+.. warning::
+
+   The API for the |branchCollector| may be subject to change
+   through revisions until ``0.7.0``
 
 Pending Deprecations
 --------------------
diff --git a/docs/examples/BranchToNodalDiffusion.rst b/docs/examples/BranchToNodalDiffusion.rst
new file mode 100644
index 0000000..ebe9e54
--- /dev/null
+++ b/docs/examples/BranchToNodalDiffusion.rst
@@ -0,0 +1,389 @@
+.. |BranchReader| replace:: :class:`~serpentTools.parsers.branching.BranchingReader`
+
+.. |BranchCollector| replace:: :class:`~serpentTools.xs.BranchCollector`
+
+.. |BranchCol-tables| replace:: :attr:`~serpentTools.xs.BranchCollector.xsTables`
+
+.. |BranchCol-states| replace:: :attr:`~serpentTools.xs.BranchCollector.states`
+
+.. |BranchCol-perturbations| replace:: :attr:`~serpentTools.xs.BranchCollector.perturbations`
+
+.. |BranchCol-burnups| replace:: :attr:`~serpentTools.xs.BranchCollector.burnups`
+
+.. |BranchCol-axis| replace:: :attr:`~serpentTools.xs.BranchCollector.axis`
+
+.. |BranchCol-universes| replace:: :attr:`~serpentTools.xs.BranchCollector.universes`
+
+.. |BranchCol-univIndex| replace:: :attr:`~serpentTools.xs.BranchCollector.univIndex`
+
+.. |BranchedUniv| replace:: :class:`~serpentTools.xs.BranchedUniv`
+
+.. |BranchedUniv-tables| replace:: :class:`~serpentTools.xs.BranchedUniv.xsTables`
+
+.. |BranchedUniv-states| replace:: :attr:`~serpentTools.xs.BranchedUniv.states`
+
+.. |BranchedUniv-perturbations| replace:: :attr:`~serpentTools.xs.BranchedUniv.perturbations`
+
+.. _branch-col-example:
+
+Coefficient file to nodal diffusion cross sections
+==================================================
+
+A recent feature of SERPENT is the ability to performing branching
+calculations using the `automated burnup
+sequence <http://serpent.vtt.fi/mediawiki/index.php/Automated_burnup_sequence>`__.
+``serpentTools`` can read these coefficient files using the |BranchReader|
+This automated burnup sequence is ideal for generating group constant
+data for nodal diffusion codes, that often include some multi-physics
+features, criticality searches, or other control mechanisms. A
+criticality search could be performed by tweaking the boron
+concentration in the coolant or adjusting control rod insertions.
+Similarly, some codes may include coupled TH analysis to convert power
+profiles to temperature profiles and adjust cross sections accordingly.
+Each code has a unique flavor for utilizing a set of group constants
+across these perturbations, and this notebook will demonstrate using the
+|BranchCollector| to gather and write a simple set of cross sections.
+
+.. code:: 
+    
+    >>> import numpy
+    >>> import serpentTools
+    >>> from serpentTools.xs import BranchCollector
+
+.. code:: 
+    
+    >>> # use serpentTools.read for everything except following along with examples
+    >>> coe = serpentTools.readDataFile('demo.coe')
+
+This specific input file contained two perturbations: boron
+concentration and fuel temperature. Boron concentration had three
+branches: ``nom`` with no boron, then ``B1000`` and ``B750``, with 1000
+and 750 ppm boron in coolant. Fuel temperature had a nominal branch at
+900 K, with 1200 and 600 K perturbations as well. These can be confirmed
+by observing the
+:attr:`~serpentTools.parsers.branching.BrancingReader.branches`
+dictionary on the |BranchReader|.
+
+.. code:: 
+    
+    >>> list(coe.branches.keys())
+    [('nom', 'nom'),
+     ('B750', 'nom'),
+     ('B1000', 'nom'),
+     ('nom', 'FT1200'),
+    ('B750', 'FT1200'),
+     ('B1000', 'FT1200'),
+     ('nom', 'FT600'),
+     ('B750',
+    'FT600'),
+     ('B1000', 'FT600')]
+
+Cross sections are spread out through this |BranchReader| across
+branches, burnup, and universes. The job of the |BranchCollector| is
+to place that data into mutli-dimensional matrices that represent the
+perturbations chosen by the user. A single group constant, say total
+cross section, has unique values for each universe, at each burnup
+point, for each perturbed state, and each energy group. Such a matrix
+would then contain five dimensions for this case.
+
+First, we create the |BranchCollector| from the |BranchReader|
+and instruct the reader what perturbations are present in the file. The
+ordering is not important at this point, as it can be changed later.
+
+.. code:: 
+    
+    >>> collector = BranchCollector(coe)
+    >>> collector.collect(('BOR', 'TFU'))
+
+Now we can inspect the perturbation states, |BranchCol-states| found by the
+collector.
+
+.. code:: 
+    
+    >>> collector.states
+    (('B1000', 'B750', 'nom'), ('FT1200', 'FT600', 'nom'))
+
+The group constants are stored in the |BranchCol-tables| dictionary. Here we
+select the total cross section, ``infTot`` for further exploration.
+
+.. code:: 
+    
+    >>> list(collector.xsTables.keys())
+    ['infTot', 'infFiss', 'infS0', 'infS1',
+     'infDiffcoef', 'b1Tot', 'b1Fiss', 'b1S0',
+     'b1S1', 'b1Diffcoef']
+    >>> infT = collector.xsTables['infTot']
+    >>> infT.shape
+    (5, 3, 3, 3, 2)
+
+Five dimensions as mentioned above. But how are they ordered? Inspecting
+the |BranchCol-axis| attribute tells us that the dimensions are universe, boron
+concentration, fuel temperature, burnup, and energy group.
+
+.. code:: 
+    
+    >>> collector.axis
+    ('Universe', 'BOR', 'TFU', 'Burnup', 'Group')
+
+The ordering of each of these dimensions is found by examining the
+|BranchCol-univIndex|, |BranchCol-states| and |BranchCol-burnups| attributes.
+
+.. code:: 
+    
+    >>> collector.univIndex
+    (0, 10, 20, 30, 40)
+    >>> collector.states
+    (('B1000', 'B750', 'nom'), ('FT1200', 'FT600', 'nom'))
+    >>> collector.burnups
+    array([ 0.,  1., 10.])
+
+For example, if we wanted the total cross section for universe 10, at
+1000 ppm boron, nominal fuel temperature, and 10 MWd/kgU burnup, we
+would request
+
+.. code:: 
+    
+    >>> infT[1, 0, 2, 2]
+    array([0.324746, 0.864346])
+
+For this example, the scattering matrices were not reshaped from vectors
+to matrices and we would observe slightly different behavior in the
+``'Group'`` dimension.
+
+.. code:: 
+    
+    >>> collector.xsTables['infS1'].shape
+    (5, 3, 3, 3, 4)
+
+Four items in the last axis as the vectorized matrix represents fast to
+fast, fast to thermal, thermal to fast, and thermal to thermal
+scattering.
+
+.. code:: 
+    
+    >>> collector.xsTables['infS1'][1, 0, 2, 2]
+    array([0.087809  , 0.00023068, 0.00073939, 0.123981  ])
+
+Many nodal diffusion codes request group constants on a per universe
+basis, or per assembly type. As we saw above, the first dimension of the
+|BranchCol-tables| matrices corresponds to universe. One can view group
+constants for specific universes with the |BranchCol-universes| dictionary.
+
+.. code:: 
+    
+    >>> collector.universes
+    {0: <serpentTools.xs.BranchedUniv at 0x7fb62f749a98>,
+     10:
+    <serpentTools.xs.BranchedUniv at 0x7fb62f731b88>,
+     20:
+    <serpentTools.xs.BranchedUniv at 0x7fb62f749e08>,
+     30:
+    <serpentTools.xs.BranchedUniv at 0x7fb62f749e58>,
+     40:
+    <serpentTools.xs.BranchedUniv at 0x7fb62f749ea8>}
+    >>> u0 = collector.universes[0]
+
+These |BranchedUniv| objects store views into the underlying
+collectors |BranchedUniv-tables| data corresponding to a single universe. The
+structuring is identical to that of the collector, with the first axis
+removed.
+
+.. code:: 
+    
+    >>> u0.perturbations
+    ('BOR', 'TFU')
+    >>> u0.axis
+    ('BOR', 'TFU', 'Burnup', 'Group')
+    >>> u0.states
+    (('B1000', 'B750', 'nom'), ('FT1200', 'FT600', 'nom'))
+
+The contents of the |BranchedUniv-tables| dictionary are
+:class:`numpy.array` views into the data stored on the 
+collector.
+
+.. code:: 
+    
+    >>> list(u0.xsTables.keys())
+    ['infTot', 'infFiss', 'infS0', 'infS1',
+     'infDiffcoef', 'b1Tot', 'b1Fiss', 'b1S0',
+     'b1S1', 'b1Diffcoef']
+    >>> u0Tot = u0.xsTables['infTot']
+    >>> u0Tot.shape
+    (3, 3, 3, 2)
+    >>> u0Tot
+    array([[[[0.313696, 0.544846],
+             [0.311024, 0.617734],
+             [0.313348, 0.614651]],
+    
+            [[0.313338, 0.54515 ],
+             [0.310842, 0.618286],
+             [0.31299 , 0.614391]],
+    
+             ...
+    
+            [[0.210873, 0.223528],
+             [0.208646, 0.      ],
+             [0.206532, 0.      ]]]])
+
+.. _branch-col-change:
+
+Changing perturbation values
+----------------------------
+
+The values of |BranchCol-states| and |BranchCol-perturbations| can be easily modified,
+so long as the structures are preserved. For example, as the current
+|BranchCol-states| are string values, and of equal perturbations (three boron
+concentrations, three fuel temperatures), we can set the |BranchCol-states| to
+be a single 2x3 array
+
+.. code:: 
+    
+    >>> collector.states = numpy.array([
+    ...     [1000, 750, 0], 
+    ...     [1200, 600, 900]], 
+    ...     dtype=float)
+    >>> collector.states
+    array([[1000.,  750.,    0.],
+           [1200.,  600.,  900.]])
+
+Some error checking is performed to make sure the passed perturbations
+match the structure of the underlying data. Here, we attempt to pass the
+wrong number of fuel temperature perturbations.
+
+.. code:: 
+    
+    >>> try:
+    ...     collector.states = numpy.array([
+    ...         [1000, 750, 0],
+    ...         [1200, 600],  # wrong
+    ...     ])
+    >>> except ValueError as ve:
+    ...     print(str(ve))
+
+    Current number of perturbations for state TFU is 3, not 2
+
+
+If the specific perturbations were not known when creating the
+collector, the value of |BranchCol-perturbations| can also be changed, with
+similar error checking.
+
+.. code:: 
+    
+    >>> collector.perturbations = ['boron conc', 'fuel temperature']
+    >>> collector.perturbations
+    ['boron conc', 'fuel temperature']
+    >>> try:
+    ...     collector.perturbations = ['boron', 'fuel', 'ctrl']  # wrong
+    >>> except ValueError as ve:
+    ...     print(str(ve))
+    Current number of perturbations is 2, not 3
+
+
+Example nodal diffusion writer
+==============================
+
+As each nodal diffusion code has it’s own required data structure,
+creating a general writer is a difficult task. The intent with the
+|BranchCollector| is to provide a framework where the data is readily
+available, and such a writer can be created with ease. Here, an example
+writer is demonstrated, one that writes each cross section. The writer
+first writes a table of the perturbations at the top of the input file,
+showing the ordering and values of the perturbations. Options are also
+provided for controlling formatting.
+
+The full file is available for download:
+`nodal_writer.py <https://github.com/CORE-GATECH-GROUP/serpent-tools/blob/develop/examples/nodal_writer.py>`_
+
+.. code:: 
+    
+    >>> from nodal_writer import Writer
+    >>> print(Writer.__doc__.strip())
+    Class for writing an example cross section file.
+    
+    Parameters
+    ----------
+    collector: xs.Collector
+            Object that read the branching file and stored
+    the cross sections
+            along the perturbation vector
+        xsPerLine: int
+    Number of cross sections / group constants to write per line
+        floatFmt: str
+    Formattable string used when writing floating point values
+        strFmt: str
+    Formattable string used when writing the names of the perturbations
+    xsRemap: None or dict
+            Dictionary used to find a replacement name for
+    cross sections when
+            writing.  Between each cross section block, the
+    name of cross
+            section and group will be written as ``# {name} group
+    {g}``.
+            When ``xsRemap`` is ``None``, the names are ``mixedCase`` as
+    they appear in ``HomogUniv`` objects, e.g.  ``'infTot'``,
+    ``'diffCoeff'``, etc. If ``xsRemap`` is a dictionary, it can
+            be used to
+    write a different name. Passing ``{'infTot': 'Total
+            cross section'}``
+    would write ``'Total cross seciton'``
+            instead of ``'infTot'``, but all
+    other names would be unchanged.
+
+
+.. code:: 
+    
+    >>> writer = Writer(collector)
+    >>> print(writer.write.__doc__.strip())
+    Write the contents of a single universe
+    
+    Parameters
+    ----------
+    universe: int or key
+                Key of universe that exists in
+    ``self.collector``. Typically
+                integer values of homogenized
+    universes from coefficient file
+            stream: None or str or writeable
+    If ``None``, return a string containing what would have been
+    written to file. If a string, then write to this file. Otherwise,
+    ensure that the object has a ``write`` method and write to this
+    object
+            mode: {'a', 'w'}
+                Write or append to file. Only
+    needed if stream is a string
+
+.. code:: 
+    
+    >>> # write to a file "in memory"
+    >>> out = writer.write(0)
+    >>> print(out[:1000])
+    # Cross sections for universe 0
+    boron conc           1.00000000E+03
+    7.50000000E+02 0.00000000E+00
+    fuel temperature     1.20000000E+03
+    6.00000000E+02 9.00000000E+02
+    Burnup [MWd/kgU]     0.00000000E+00
+    1.00000000E+00 1.00000000E+01
+    # infTot group 1
+     3.13696000E-01 3.11024000E-01
+    3.13348000E-01 3.13338000E-01
+     3.10842000E-01 3.12990000E-01 3.16730000E-01
+    3.13987000E-01
+     3.16273000E-01 3.13772000E-01 3.11335000E-01 3.13311000E-01
+    3.13437000E-01 3.10967000E-01 3.13160000E-01 3.16688000E-01
+     3.14245000E-01
+    3.16392000E-01 2.08020000E-01 2.05774000E-01
+     2.03646000E-01 2.07432000E-01
+    2.05326000E-01 2.03533000E-01
+     2.10873000E-01 2.08646000E-01 2.06532000E-01
+    #
+    infTot group 2
+     5.44846000E-01 6.17734000E-01 6.14651000E-01 5.45150000E-01
+    6.18286000E-01 6.14391000E-01 5.48305000E-01 6.21804000E-01
+     6.18120000E-01
+    5.41505000E-01 6.09197000E-01 6.08837000E-01
+     5.42373000E-01 6.09192000E-01
+    6.08756000E-01 5.45294000E-01
+     6.12767000E-01 6.12985000E-01 2.28908000E-01
+    1.07070000E-01
+     0.00000000E+00 3.1
diff --git a/docs/examples/index.rst b/docs/examples/index.rst
index 4b289a9..22bfc23 100644
--- a/docs/examples/index.rst
+++ b/docs/examples/index.rst
@@ -17,3 +17,4 @@ Examples
    ResultsReader.rst
    MicroXSReader.rst
    DepletionMatrix.rst
+   BranchToNodalDiffusion.rst
diff --git a/docs/examples/nodal_writer.py b/docs/examples/nodal_writer.py
new file mode 120000
index 0000000..b9bf8ca
--- /dev/null
+++ b/docs/examples/nodal_writer.py
@@ -0,0 +1,1 @@
+../examples/nodal_writer.py
\ No newline at end of file
diff --git a/examples/BranchToNodalDiffusion.ipynb b/examples/BranchToNodalDiffusion.ipynb
new file mode 100644
index 0000000..adb329e
--- /dev/null
+++ b/examples/BranchToNodalDiffusion.ipynb
@@ -0,0 +1,887 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Copyright (c) 2017-2019 [Serpent-Tools developer team](https://github.com/CORE-GATECH-GROUP/serpent-tools/graphs/contributors), GTRC\n",
+    "\n",
+    "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Coefficient file to nodal diffusion cross sections\n",
+    "A recent feature of SERPENT is the ability to performing branching calculations using the [automated burnup sequence](http://serpent.vtt.fi/mediawiki/index.php/Automated_burnup_sequence). `serpentTools` can read these coefficient files using the [`BranchingReader`](https://serpent-tools.readthedocs.io/en/master/examples/Branching.html).\n",
+    "This automated burnup sequence is ideal for generating group constant data for nodal diffusion codes, that often include some multi-physics features, criticality searches, or other control mechanisms. A criticality search could be performed by tweaking the boron concentration in the coolant or adjusting control rod insertions. Similarly, some codes may include coupled TH analysis to convert power profiles to temperature profiles and adjust cross sections accordingly. Each code has a unique flavor for utilizing a set of group constants across these perturbations, and this notebook will demonstrate using the `BranchCollector` to gather and write a simple set of cross sections."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import numpy\n",
+    "import serpentTools\n",
+    "from serpentTools.xs import BranchCollector"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 2,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# use serpentTools.read for everything except following along with examples\n",
+    "coe = serpentTools.readDataFile('demo.coe')"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "This specific input file contained two perturbations: boron concentration and fuel temperature. Boron concentration had three branches: `nom` with no boron, then `B1000` and `B750`, with 1000 and 750 ppm boron in coolant. Fuel temperature had a nominal branch at 900 K, with 1200 and 600 K perturbations as well. These can be confirmed by observing the `branches` dictionary on the `BranchingReader`"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "[('nom', 'nom'),\n",
+       " ('B750', 'nom'),\n",
+       " ('B1000', 'nom'),\n",
+       " ('nom', 'FT1200'),\n",
+       " ('B750', 'FT1200'),\n",
+       " ('B1000', 'FT1200'),\n",
+       " ('nom', 'FT600'),\n",
+       " ('B750', 'FT600'),\n",
+       " ('B1000', 'FT600')]"
+      ]
+     },
+     "execution_count": 3,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "list(coe.branches.keys())"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Cross sections are spread out through this `BranchingReader` across branches, burnup, and universes. The job of the `BranchCollector` is to place that data into mutli-dimensional matrices that represent the perturbations chosen by the user. A single group constant, say total cross section, has unique values for each universe, at each burnup point, for each perturbed state, and each energy group. Such a matrix would then contain five dimensions for this case.\n",
+    "\n",
+    "First, we create the `BranchCollector` from the `BranchingReader` and instruct the reader what perturbations are present in the file. The ordering is not important at this point, as it can be changed later."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stderr",
+     "output_type": "stream",
+     "text": [
+      "/home/ajohnson400/github/my-serpent-tools/.venv/develop/lib/python3.7/site-packages/serpentTools-0.6.1+36.g1facac0.dirty-py3.7.egg/serpentTools/xs.py:227: UserWarning: This is an experimental feature, subject to change.\n",
+      "  UserWarning)\n"
+     ]
+    }
+   ],
+   "source": [
+    "collector = BranchCollector(coe)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "collector.collect(('BOR', 'TFU'))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now we can inspect the perturbation states, `states`, found by the collector."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(('B1000', 'B750', 'nom'), ('FT1200', 'FT600', 'nom'))"
+      ]
+     },
+     "execution_count": 6,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.states"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The group constants are stored in the `xsTables` dictionary. Here we select the total cross section, `infTot` for further exploration."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "['infTot',\n",
+       " 'infFiss',\n",
+       " 'infS0',\n",
+       " 'infS1',\n",
+       " 'infDiffcoef',\n",
+       " 'b1Tot',\n",
+       " 'b1Fiss',\n",
+       " 'b1S0',\n",
+       " 'b1S1',\n",
+       " 'b1Diffcoef']"
+      ]
+     },
+     "execution_count": 7,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "list(collector.xsTables.keys())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "infT = collector.xsTables['infTot']"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 9,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(5, 3, 3, 3, 2)"
+      ]
+     },
+     "execution_count": 9,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "infT.shape"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Five dimensions as mentioned above. But how are they ordered? Inspecting the `axis` attribute tells us that the dimensions are universe, boron concentration, fuel temperature, burnup, and energy group."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 10,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "('Universe', 'BOR', 'TFU', 'Burnup', 'Group')"
+      ]
+     },
+     "execution_count": 10,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.axis"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The ordering of each of these dimensions is found by examining the `univIndex`, `states`, and `burnups` attributes."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 11,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(0, 10, 20, 30, 40)"
+      ]
+     },
+     "execution_count": 11,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.univIndex"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 12,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(('B1000', 'B750', 'nom'), ('FT1200', 'FT600', 'nom'))"
+      ]
+     },
+     "execution_count": 12,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.states"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 13,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([ 0.,  1., 10.])"
+      ]
+     },
+     "execution_count": 13,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.burnups"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For example, if we wanted the total cross section for universe 10, at 1000 ppm boron, nominal fuel temperature, and 10 MWd/kgU burnup, we would request"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 14,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([0.324746, 0.864346])"
+      ]
+     },
+     "execution_count": 14,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "infT[1, 0, 2, 2]"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "For this example, the scattering matrices were not reshaped from vectors to matrices and we would observe slightly different behavior in the `'Group'` dimension."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 15,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(5, 3, 3, 3, 4)"
+      ]
+     },
+     "execution_count": 15,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.xsTables['infS1'].shape"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Four items in the last axis as the vectorized matrix represents fast to fast, fast to thermal, thermal to fast, and thermal to thermal scattering."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 16,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([0.087809  , 0.00023068, 0.00073939, 0.123981  ])"
+      ]
+     },
+     "execution_count": 16,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.xsTables['infS1'][1, 0, 2, 2]"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Many nodal diffusion codes request group constants on a per universe basis, or per assembly type. As we saw above, the first dimension of the `xsTables` matrices corresponds to universe. One can view group constants for specific universes with the `universes` dictionary. "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 17,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "{0: <serpentTools.xs.BranchedUniv at 0x7fb62f749a98>,\n",
+       " 10: <serpentTools.xs.BranchedUniv at 0x7fb62f731b88>,\n",
+       " 20: <serpentTools.xs.BranchedUniv at 0x7fb62f749e08>,\n",
+       " 30: <serpentTools.xs.BranchedUniv at 0x7fb62f749e58>,\n",
+       " 40: <serpentTools.xs.BranchedUniv at 0x7fb62f749ea8>}"
+      ]
+     },
+     "execution_count": 17,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.universes"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 18,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "u0 = collector.universes[0]"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "These `BranchedUniv` objects store views into the underlying collectors `xsTables` data corresponding to a single universe. The structuring is identical to that of the collector, with the first axis removed."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 19,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "('BOR', 'TFU')"
+      ]
+     },
+     "execution_count": 19,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "u0.perturbations"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 20,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "('BOR', 'TFU', 'Burnup', 'Group')"
+      ]
+     },
+     "execution_count": 20,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "u0.axis"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 21,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(('B1000', 'B750', 'nom'), ('FT1200', 'FT600', 'nom'))"
+      ]
+     },
+     "execution_count": 21,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "u0.states"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "The contents of the `xsTables` dictionary are `numpy.array`s, views into the data stored on the ``BranchCollector``."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 37,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "['infTot',\n",
+       " 'infFiss',\n",
+       " 'infS0',\n",
+       " 'infS1',\n",
+       " 'infDiffcoef',\n",
+       " 'b1Tot',\n",
+       " 'b1Fiss',\n",
+       " 'b1S0',\n",
+       " 'b1S1',\n",
+       " 'b1Diffcoef']"
+      ]
+     },
+     "execution_count": 37,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "list(u0.xsTables.keys())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 23,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "u0Tot = u0.xsTables['infTot']"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 24,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "(3, 3, 3, 2)"
+      ]
+     },
+     "execution_count": 24,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "u0Tot.shape"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 25,
+   "metadata": {
+    "scrolled": true
+   },
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([[[[0.313696, 0.544846],\n",
+       "         [0.311024, 0.617734],\n",
+       "         [0.313348, 0.614651]],\n",
+       "\n",
+       "        [[0.313338, 0.54515 ],\n",
+       "         [0.310842, 0.618286],\n",
+       "         [0.31299 , 0.614391]],\n",
+       "\n",
+       "        [[0.31673 , 0.548305],\n",
+       "         [0.313987, 0.621804],\n",
+       "         [0.316273, 0.61812 ]]],\n",
+       "\n",
+       "\n",
+       "       [[[0.313772, 0.541505],\n",
+       "         [0.311335, 0.609197],\n",
+       "         [0.313311, 0.608837]],\n",
+       "\n",
+       "        [[0.313437, 0.542373],\n",
+       "         [0.310967, 0.609192],\n",
+       "         [0.31316 , 0.608756]],\n",
+       "\n",
+       "        [[0.316688, 0.545294],\n",
+       "         [0.314245, 0.612767],\n",
+       "         [0.316392, 0.612985]]],\n",
+       "\n",
+       "\n",
+       "       [[[0.20802 , 0.228908],\n",
+       "         [0.205774, 0.10707 ],\n",
+       "         [0.203646, 0.      ]],\n",
+       "\n",
+       "        [[0.207432, 0.315208],\n",
+       "         [0.205326, 0.      ],\n",
+       "         [0.203533, 0.      ]],\n",
+       "\n",
+       "        [[0.210873, 0.223528],\n",
+       "         [0.208646, 0.      ],\n",
+       "         [0.206532, 0.      ]]]])"
+      ]
+     },
+     "execution_count": 25,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "u0Tot"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Changing perturbation values\n",
+    "The values of `states` and `perturbations` can be easily modified, so long as the structures are preserved. For example, as the current `states` are string values, and of equal perturbations (three boron concentrations, three fuel temperatures), we can set the `states` to be a single\n",
+    "2x3 array"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 26,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "array([[1000.,  750.,    0.],\n",
+       "       [1200.,  600.,  900.]])"
+      ]
+     },
+     "execution_count": 26,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.states = numpy.array([\n",
+    "    [1000, 750, 0], \n",
+    "    [1200, 600, 900]], \n",
+    "    dtype=float)\n",
+    "collector.states"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Some error checking is performed to make sure the passed perturbations match the structure of the underlying data. Here, we attempt to pass the wrong number of fuel temperature perturbations."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 27,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Current number of perturbations for state TFU is 3, not 2\n"
+     ]
+    }
+   ],
+   "source": [
+    "try:\n",
+    "    collector.states = numpy.array([\n",
+    "        [1000, 750, 0],\n",
+    "        [1200, 600],  # wrong\n",
+    "    ])\n",
+    "except ValueError as ve:\n",
+    "    print(str(ve))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "If the specific perturbations were not known when creating the collector, the value of `perturbations` can also be changed, with similar error checking."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 28,
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "['boron conc', 'fuel temperature']"
+      ]
+     },
+     "execution_count": 28,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "collector.perturbations = ['boron conc', 'fuel temperature']\n",
+    "collector.perturbations"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 29,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Current number of perturbations is 2, not 3\n"
+     ]
+    }
+   ],
+   "source": [
+    "try:\n",
+    "    collector.perturbations = ['boron', 'fuel', 'ctrl']  # wrong\n",
+    "except ValueError as ve:\n",
+    "    print(str(ve))"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Example nodal diffusion writer\n",
+    "\n",
+    "As each nodal diffusion code has it's own required data structure, creating a general writer is a difficult task. The intent with the `BranchCollector` is to provide a framework where the data is readily available, and such a writer can be created with ease. Here, an example writer is demonstrated, one that writes each cross section. The writer first writes a table of the perturbations at the top of the input file, showing the ordering and values of the perturbations. Options are also provided for controlling formatting.\n",
+    "\n",
+    "The full file is available for download: [`nodal_writer.py`](https://github.com/CORE-GATECH-GROUP/serpent-tools/blob/develop/examples/nodal_writer.py)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 30,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from nodal_writer import Writer"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 31,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Class for writing an example cross section file.\n",
+      "\n",
+      "    Parameters\n",
+      "    ----------\n",
+      "    collector: xs.Collector\n",
+      "        Object that read the branching file and stored the cross sections\n",
+      "        along the perturbation vector\n",
+      "    xsPerLine: int\n",
+      "        Number of cross sections / group constants to write per line\n",
+      "    floatFmt: str\n",
+      "        Formattable string used when writing floating point values\n",
+      "    strFmt: str\n",
+      "        Formattable string used when writing the names of the perturbations\n",
+      "    xsRemap: None or dict\n",
+      "        Dictionary used to find a replacement name for cross sections when\n",
+      "        writing.  Between each cross section block, the name of cross\n",
+      "        section and group will be written as ``# {name} group {g}``.\n",
+      "        When ``xsRemap`` is ``None``, the names are ``mixedCase`` as\n",
+      "        they appear in ``HomogUniv`` objects, e.g.  ``'infTot'``,\n",
+      "        ``'diffCoeff'``, etc. If ``xsRemap`` is a dictionary, it can\n",
+      "        be used to write a different name. Passing ``{'infTot': 'Total\n",
+      "        cross section'}`` would write ``'Total cross seciton'``\n",
+      "        instead of ``'infTot'``, but all other names would be unchanged.\n"
+     ]
+    }
+   ],
+   "source": [
+    "print(Writer.__doc__.strip())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 32,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "writer = Writer(collector)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 33,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "Write the contents of a single universe\n",
+      "\n",
+      "        Parameters\n",
+      "        ----------\n",
+      "        universe: int or key\n",
+      "            Key of universe that exists in ``self.collector``. Typically\n",
+      "            integer values of homogenized universes from coefficient file\n",
+      "        stream: None or str or writeable\n",
+      "            If ``None``, return a string containing what would have been\n",
+      "            written to file. If a string, then write to this file. Otherwise,\n",
+      "            ensure that the object has a ``write`` method and write to this\n",
+      "            object\n",
+      "        mode: {'a', 'w'}\n",
+      "            Write or append to file. Only needed if stream is a string\n"
+     ]
+    }
+   ],
+   "source": [
+    "print(writer.write.__doc__.strip())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 34,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "# write to a file \"in memory\"\n",
+    "out = writer.write(0)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 35,
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "# Cross sections for universe 0\n",
+      "boron conc           1.00000000E+03 7.50000000E+02 0.00000000E+00\n",
+      "fuel temperature     1.20000000E+03 6.00000000E+02 9.00000000E+02\n",
+      "Burnup [MWd/kgU]     0.00000000E+00 1.00000000E+00 1.00000000E+01\n",
+      "# infTot group 1\n",
+      " 3.13696000E-01 3.11024000E-01 3.13348000E-01 3.13338000E-01\n",
+      " 3.10842000E-01 3.12990000E-01 3.16730000E-01 3.13987000E-01\n",
+      " 3.16273000E-01 3.13772000E-01 3.11335000E-01 3.13311000E-01\n",
+      " 3.13437000E-01 3.10967000E-01 3.13160000E-01 3.16688000E-01\n",
+      " 3.14245000E-01 3.16392000E-01 2.08020000E-01 2.05774000E-01\n",
+      " 2.03646000E-01 2.07432000E-01 2.05326000E-01 2.03533000E-01\n",
+      " 2.10873000E-01 2.08646000E-01 2.06532000E-01\n",
+      "# infTot group 2\n",
+      " 5.44846000E-01 6.17734000E-01 6.14651000E-01 5.45150000E-01\n",
+      " 6.18286000E-01 6.14391000E-01 5.48305000E-01 6.21804000E-01\n",
+      " 6.18120000E-01 5.41505000E-01 6.09197000E-01 6.08837000E-01\n",
+      " 5.42373000E-01 6.09192000E-01 6.08756000E-01 5.45294000E-01\n",
+      " 6.12767000E-01 6.12985000E-01 2.28908000E-01 1.07070000E-01\n",
+      " 0.00000000E+00 3.1\n"
+     ]
+    }
+   ],
+   "source": [
+    "print(out[:1000])"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "serpent-tools",
+   "language": "python",
+   "name": "serpent-tools"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.7.2"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/nodal_writer.py b/examples/nodal_writer.py
new file mode 100644
index 0000000..e56285a
--- /dev/null
+++ b/examples/nodal_writer.py
@@ -0,0 +1,180 @@
+# Copyright (c) 2017-2019 Serpent-Tools developer team, GTRC
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+# OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
+# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+# AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+# IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+"""
+Script and templates for writing branched xs from
+serpentTools data file demo.coe
+
+Each cross section is represented as a series of points along
+a perturbation matrix, given at the top of the file. These
+perturbations can be fuel temperature, control rod
+insertion, soluble boron concentration, or other metrics that
+may be of interest to reactor physicists. Burnup is also considered
+a perturbation state for the sake of this script.
+
+The perturbations are listed at the top of the file, according to
+the following form::
+
+    P0 : P0[0], P0[1], ...,
+    P1 : P1[0], P1[1], ...,
+    ...
+    BU : BU[0], BU[1], ...,
+
+where ``P0`` is the name of a perturbation and the vector
+``P0[0], P0[1], ...`` represents the values of that perturbation.
+It is not required that all perturbations have the same number of
+values.
+
+The remaining section of the file contains the group constants
+for that singular universe. The values are presented per group, with
+scattering being treated a little differently. More on that later.
+Each group constant is presented along each perturbation according to
+their order in the perturbation table above. This specific file
+includes two perturbations and burnup (boron concentration and fuel
+temperature), with three values for all perturbations, burnup included.
+Therefore, each group-wise cross section block would have 27 entries for
+a full perturbation table. The values are ordered as follows:
+
+    (B0, F0, BU0), (B0, F0, BU1), (B0, F0, BU2),
+    (B0, F1, BU:), (B0, F2, BU:), (B1, F:, BU:),
+    (B2, F:, BU:)
+
+Here, the Python-esque ``:`` operator is used to represent a slice of
+all elements in the perturbation vector. The above block indicates that
+first burnup is incremented for a single boron concentration and fuel
+temperature. After all burnups have been given, then the fuel temperature is
+incremented and all burnups are again written in order. After cycling through
+all fuel temperatures, the boron index is incrememented. The pattern continues
+until all boron states have been written, and the full perturbation table is
+written.
+
+The simulation file used is intended for two group calculation, and all values,
+except scattering matrices, will be given group 1 first, then group 2.
+The scattering matrices are currently given for four "groups", as a 2x2
+scattering matrix is represented as a four-element vector. The four
+"groups" as explained in the file are
+
+    1. Group 1 -> Group 1
+    2. Group 1 -> Group 2
+    3. Group 2 -> Group 1
+    4. Group 2 -> Group 2
+
+"""
+
+from six import StringIO
+
+
+class Writer(object):
+    """
+    Class for writing an example cross section file.
+
+    Parameters
+    ----------
+    collector: xs.Collector
+        Object that read the branching file and stored the cross sections
+        along the perturbation vector
+    xsPerLine: int
+        Number of cross sections / group constants to write per line
+    floatFmt: str
+        Formattable string used when writing floating point values
+    strFmt: str
+        Formattable string used when writing the names of the perturbations
+    xsRemap: None or dict
+        Dictionary used to find a replacement name for cross sections when
+        writing.  Between each cross section block, the name of cross
+        section and group will be written as ``# {name} group {g}``.
+        When ``xsRemap`` is ``None``, the names are ``mixedCase`` as
+        they appear in ``HomogUniv`` objects, e.g.  ``'infTot'``,
+        ``'diffCoeff'``, etc. If ``xsRemap`` is a dictionary, it can
+        be used to write a different name. Passing ``{'infTot': 'Total
+        cross section'}`` would write ``'Total cross seciton'``
+        instead of ``'infTot'``, but all other names would be unchanged.
+    """
+
+    def __init__(self, collector, xsPerLine=4, floatFmt="{:15.8E}",
+                 strFmt="{:20s}", xsRemap=None):
+        self.collector = collector
+        self.perLine = 4
+        self.floatFmt = floatFmt
+        self.strFmt = strFmt
+        xsRemap = {} if xsRemap is None else xsRemap
+        assert isinstance(xsRemap, dict)
+        self.xsRemap = xsRemap
+
+    def write(self, universe, stream=None, mode='w'):
+        """
+        Write the contents of a single universe
+
+        Parameters
+        ----------
+        universe: int or key
+            Key of universe that exists in ``self.collector``. Typically
+            integer values of homogenized universes from coefficient file
+        stream: None or str or writeable
+            If ``None``, return a string containing what would have been
+            written to file. If a string, then write to this file. Otherwise,
+            ensure that the object has a ``write`` method and write to this
+            object
+        mode: {'a', 'w'}
+            Write or append to file. Only needed if stream is a string
+        """
+
+        universe = self.collector.universes[universe]
+        if stream is None:
+            return self._write(StringIO(), universe).getvalue()
+        if isinstance(stream, str):
+            assert mode in {'a', 'w'}, "Must be writable"
+            with open(stream, mode) as out:
+                return self._write(out, universe)
+        assert hasattr(stream, 'write')
+        return self._write(stream, universe)
+
+    def _write(self, stream, universe):
+        self._fullXsLine = "".join([self.floatFmt, ] * self.perLine) + "\n"
+        stream.write("# Cross sections for universe {}\n"
+                     .format(universe.univID))
+        self._writePerts(stream)
+
+        # Get index of group to write perturbed cross sections for each group
+        groupIndex = len(universe.axis) - 1
+        transpAx = (groupIndex, ) + tuple(range(groupIndex))
+
+        for xsName, xsTable in universe.xsTables.items():
+            name = self.xsRemap.get(xsName, xsName)
+            xsData = xsTable.transpose(transpAx)
+
+            self._writeUnivXs(stream, xsData, name)
+        return stream
+
+    def _writePerts(self, stream):
+        for pertIndex, pert in enumerate(self.collector.perturbations):
+            self._writeSinglePert(stream, pert,
+                                  self.collector.states[pertIndex])
+        self._writeSinglePert(
+            stream, "Burnup [MWd/kgU]", self.collector.burnups)
+
+    def _writeSinglePert(self, stream, name, values):
+        stream.write(self.strFmt.format(name))
+        for val in values:
+            stream.write(self.floatFmt.format(val))
+        stream.write('\n')
+
+    def _writeUnivXs(self, stream, xsData, name):
+        for groupIndex, groupMat in enumerate(xsData, start=1):
+            stream.write("# {} group {}\n".format(name, groupIndex))
+            groupVec = groupMat.ravel()
+            start = 0
+            nFull, nExtra = divmod(groupVec.size, self.perLine)
+            for _step in range(nFull):
+                stream.write(self._fullXsLine.format(
+                    *groupVec[start:start + self.perLine]))
+                start += self.perLine
+            for step in range(nExtra):
+                stream.write(self.floatFmt.format(groupVec[start + step]))
+            stream.write("\n")
diff --git a/serpentTools/xs.py b/serpentTools/xs.py
new file mode 100644
index 0000000..ad053e1
--- /dev/null
+++ b/serpentTools/xs.py
@@ -0,0 +1,548 @@
+"""
+Utilities for collecting and exporting group constants from a
+branching file
+"""
+
+from collections import Iterable
+from itertools import product
+from warnings import warn
+from six import iteritems
+from six.moves import range
+from numpy import empty, nan, array, ndarray
+
+__all__ = [
+    'BranchCollector',
+]
+
+
+class BranchedUniv(object):
+    """
+    Class for storing cross sections for a single universe across branches
+
+    .. versionadded:: 0.6.2
+
+    .. warning::
+
+        This is experimental, and the structure of
+        the underlying data may change. This will
+        be stable in version 0.7.0
+
+
+    Parameters
+    ----------
+    univID: str or int
+        Unique ID for this universe
+    collector: :class:`BranchCollector`
+        Class that parses and places branched coefficient data
+    ndims: int or iterable
+        Number of perturbation dimensions
+
+    Attributes
+    ----------
+    filePath: str
+        Location of the file that stores the data on this object
+    univID: str or int
+        Unique ID for this universe
+    collector: :class:`BranchCollector`
+        Class that parses and places branched coefficient data
+    xsTables: dict
+        Dictionary with keys representing specific values, e.g.
+        ``'infTot'`` and ``'b1Diffcoeff'``. Corresponding values
+        are :class:`BranchedDataTable` objects that store
+        cross section and group constant data across perturbation states
+
+    """
+
+    # Acts largely as a dictionary storing group constants
+    # under their cross section names from SERPENT
+    # Subclass of dictionary?
+    __slots__ = (
+        'filePath', 'univID', 'collector',
+        '_ndims', 'xsTables',
+    )
+
+    def __init__(self, univID, collector, ndims=None):
+        assert (
+            isinstance(collector, BranchCollector)
+            or issubclass(collector, BranchCollector))
+        self.filePath = collector.filePath
+        self.univID = univID
+        self.collector = collector
+        if ndims is None:
+            self._ndims = len(collector.axis[1:])
+        else:
+            if hasattr(ndims, '__iter__'):
+                ndims = len(ndims)
+            elif isinstance(ndims, (int, float)):
+                ndims = int(ndims)
+            assert ndims > 0
+            self._ndims = ndims
+        self.xsTables = {}
+
+    def __getitem__(self, key):
+        """Access the xsTables dictionary"""
+        return self.xsTables[key]
+
+    def __setitem__(self, key, data):
+        shape = data.shape
+        assert len(shape) == self._ndims
+        for pertIndex, pertState in enumerate(self.states):
+            assert len(pertState) == shape[pertIndex], (
+                "{} {}".format(shape[pertIndex], pertState))
+        self.xsTables[key] = data
+
+    def items(self):
+        """Iterate over names of cross sections and associated objects"""
+        for name, obj in iteritems(self.xsTables):
+            yield name, obj
+
+    @property
+    def states(self):
+        """
+        Iterable describing the names or values of each perturbation
+        branch. Length is equal to that of :attr:`perturbations`, and
+        the ``i``-th index of ``states`` indicates the values
+        perturbation ``perturbations[i]`` experiences.
+
+        See Also
+        --------
+        :attr:`BranchCollector.states`
+        """
+        return self.collector.states
+
+    @states.setter
+    def states(self, value):
+        self.collector.states = value
+
+    @property
+    def axis(self):
+        """
+        Tuple describing axis of underlying data
+
+        .. note::
+
+            When setting, the universe index of the
+            axis should not be changed. The changes
+            are passed on to :attr:`BranchCollector.axis`
+            with an indicator for universe placed in the
+            correct spot
+
+        Examples
+        --------
+        >>> col.axis
+        ('Universe', 'BOR', 'TFU', 'Burnup', 'Group')
+        >>> u0 = col.universes[0]
+        >>> u0.axis == col.axis[1:]
+        True
+        >>> u0.axis = ['boron conc', 'fuel temp', 'burnup', 'group']
+        >>> u0.axis
+        ('boron conc', 'fuel temp', 'burnup', 'group')
+        >>> col.axis
+        ('Universe', 'boron conc', 'fuel temp', 'burnup', 'group')
+
+        See Also
+        --------
+        :class:`BranchCollector.axis`
+        """
+        return self.collector.axis[1:]
+
+    @axis.setter
+    def axis(self, value):
+        if not isinstance(value, tuple):
+            value = tuple(value)
+        self.collector.axis = ("Universe", ) + value
+
+    @property
+    def burnups(self):
+        """
+        Vector of burnups from coefficient file
+
+        See Also
+        --------
+        :attr:`BranchCollector.burnups`"""
+        return self.collector.burnups
+
+    @burnups.setter
+    def burnups(self, value):
+        self.collector.burnups = value
+
+    @property
+    def perturbations(self):
+        """
+        Iterable indicating the specific perturbation types
+
+        See Also
+        --------
+        :attr:`BranchCollector.perturbations`
+        """
+        return self.collector.perturbations
+
+    @perturbations.setter
+    def perturbations(self, value):
+        self.collector.perturbations = value
+
+
+class BranchCollector(object):
+    """
+    Main class that collects and arranges branched data
+
+    .. versionadded:: 0.6.2
+
+    .. warning::
+
+        This is experimental, and the structure of
+        the underlying data may change. This will
+        be stable in version 0.7.0
+
+    Parameters
+    ----------
+    reader: :class:`serpentTools.parsers.branching.BranchingReader`
+        Read data from the passed reader
+
+    Attributes
+    ----------
+    filePath: str
+        Location of the read file
+    univIndex: tuple
+        Ordered tuple of universe as they appear in the first dimension
+        of all arrays in :attr:`xsTables`
+    universes: dict
+        Dictionary of universe-specific cross sections. Each entry
+        is a :class:`BranchedUniv` object that stores cross sections
+        for a single universe.
+    xsTables: dict
+        Dictionary of ``{k: x}`` pairs where ``k`` corresponds to
+        all cross sections processed and ``x`` are large multidimensional
+        cross sections. The structure is described with :attr:`axis`
+
+    """
+
+    __slots__ = (
+        'filePath', '_branches', 'xsTables', 'universes', '_axis',
+        '_perturbations', 'univIndex', '_burnups', '_states', '_shapes',
+    )
+
+    def __init__(self, reader):
+        warn("This is an experimental feature, subject to change.",
+             UserWarning)
+        self.filePath = reader.filePath
+        self._branches = reader.branches
+        self.xsTables = {}
+        self.universes = {}
+        self._perturbations = None
+        self._states = None
+        self._axis = None
+        self._burnups = None
+
+    @property
+    def perturbations(self):
+        """
+        Iterable indicating the specific perturbation types
+
+        Can be set to any iterable, so long as the number of
+        perturbations is preserved. Ordering is important,
+        as changing this does not change the structure
+        of any group constants stored
+
+        Example
+        -------
+        >>> print(col.perturbations)
+        ('BOR', 'TFU')
+        >>> col.perturbations = ['B', 'T']  # allowed
+        >>> col.perturbations = [
+        >>>    'boron conc', 'fuel temp', 'ctrl pos',  # not allowed
+        >>> ]
+        ValueError("Current number of perturbations is 2, not 3")
+        """
+        return self._perturbations
+
+    @perturbations.setter
+    def perturbations(self, value):
+        if self._perturbations is None:
+            raise AttributeError(
+                "Collect first to ensure correct data structure.")
+        if isinstance(value, str) or not isinstance(value, Iterable):
+            value = value,
+        if len(value) != len(self._perturbations):
+            raise ValueError(
+                "Current number of perturbations is {}, not {}"
+                .format(len(self._perturbations), len(value)))
+        self._perturbations = value
+
+    @property
+    def states(self):
+        """
+        Iterable describing each perturbation branch.
+
+        Length is equal to that of :attr:`perturbations`, and
+        the ``i``-th index of ``states`` indicates the values
+        perturbation ``perturbations[i]`` experiences.
+
+        Can be set to any iterable such that the total number
+        of perturbations is preserved
+
+        Examples
+        --------
+        >>> col.states
+        (('B1000', 'B750', 'nom'), ('FT1200', 'FT600', 'nom'))
+        # set as numpy array
+        >>> states = numpy.array([
+            [1000., 750., 0.],
+            [1200., 600., 900.]
+        ])
+        >>> col.states = states
+        >>> col.states
+        array([[1000.,  750.,    0],
+               [1200.,  600.,  900]])
+        # set as individual numpy vectors
+        >>> col.states = (states[0], states[1])
+        >>> col.states
+        (array([1000., 750., 0.,]), array([1200., 600., 900.,]))
+        # pass incorrect shape
+        >>> col.states = (
+        >>>    (1000, 750, 0), (1200, 600, 900), (0, 1)
+        >>> )
+        ValueError("Current number of perturbations is 2, not 3")
+        # pass incorrect states for one perturbations
+        >>> cols.states = (
+        >>>     (1000, 750, 500, 0), (1200, 600, 900)
+        >>> )
+        ValueError("Current number of perturbations for state BOR "
+                   "is 3, not 4")
+        """
+        return self._states
+
+    @states.setter
+    def states(self, value):
+        if self._states is None:
+            raise AttributeError(
+                "Collect first to ensure correct data structure.")
+        if len(value) != len(self._states):
+            raise ValueError(
+                "Current number of perturbations is {}, not {}"
+                .format(len(self._states), len(value)))
+
+        # check to make sure all perturbation vectors in the
+        # requested value are of the same length
+        for index, pertVec in enumerate(self._states):
+            if len(value[index]) != len(pertVec):
+                raise ValueError(
+                    "Current number of perturbations for state {} is {}, "
+                    "not {}"
+                    .format(self._perturbations[index], len(pertVec),
+                            len(value[index])))
+        self._states = value
+
+    @property
+    def axis(self):
+        """
+        Tuple describing axis of underlying data
+
+        Each index contains a description of the changes in
+        group constant data along that axis.
+        Can be set to any iterable, but is converted to a
+        tuple to prevent in-place changes, such as appending
+        to a list or removing one item.
+        Passing an ordered object, :class:`list`,
+        :class:`tuple`, or :class:`numpy.array` is preferred,
+        as the conversion to :class:`tuple` can sort values
+        in un-ordered objects like :class:`set` or :class:`dict`
+        strangely.
+
+        Examples
+        --------
+        >>> col.axis
+        ("Universe", "BOR", "TFU", "Burnup", "Group")
+        >>> infTot = col.xsTables['infTot']
+        >>> infTot.shape
+        (5, 3, 3, 3, 2)
+        # five universes, three BOR perturbations
+        # three TFU perturbations, three burnups,
+        # two energy groups
+        >>> col.axis = ['u', 'b', 't', 'bu', 'g']
+        >>> col.axis
+        ('u', 'b', 't', 'bu', 'g')
+        # pass an unordered set
+        >>> col.axis = {'u', 'b', 't', 'bu', 'g'}
+        >>> col.axis
+        ('bu', 'u', 't', 'g', 'b')
+        # incorrectly set axis
+        >>> col.axis = [1, 2, 3, 4]
+        ValueError("Current axis has 5 dimensions, not 4")
+        """
+        if self._axis is None:
+            raise AttributeError("Axis not set. Collect first.")
+        return self._axis
+
+    @axis.setter
+    def axis(self, value):
+        if self._axis is None:
+            raise AttributeError(
+                "Collect first to ensure correct data structure.")
+
+        # coerce into tuple to enforce some immutability
+        if not isinstance(value, tuple):
+            value = tuple(value)
+
+        if len(value) != len(self._axis):
+            raise ValueError(
+                "Current axis has {} dimensions, not {}"
+                .format(len(self._axis), len(value)))
+
+        self._axis = value
+
+    @property
+    def burnups(self):
+        """
+        Vector of burnups from coefficient file
+
+        Can be set to any iterable that has same number
+        of entries as existing burnup. Automatically
+        converts to :class:`numpy.array`
+
+        Examples
+        --------
+
+        >>> col.burnups
+        array([0., 1., 10.])
+        >>> col.burnups = array([0., 5.6, 56.])
+        >>> col.burnups
+        array([0., 5.6, 56.])
+        >>> col.burnups = [0, 1, 10]
+        # converted to array of integers
+        >>> col.burnups
+        array([0, 1, 10])
+        >>> col.burnups = [0, 1, 2, 3]  # not allowed
+        ValueError("Current burnup vector has 3 items, not 3")
+        """
+        if self._burnups is None:
+            raise AttributeError("Burnups not set. Collect first.")
+        return self._burnups
+
+    @burnups.setter
+    def burnups(self, value):
+        if self._burnups is None:
+            raise AttributeError(
+                "Collect first to ensure correct data structure.")
+        if not isinstance(value, ndarray):
+            value = array(value)
+        if value.size != self._burnups.size:
+            raise ValueError(
+                "Current burnup vector has {} items, not {}"
+                .format(self._burnups.size, value.size))
+        self._burnups = value
+
+    def collect(self, perturbations):
+        """
+        Parse the contents of the file and collect cross sections
+
+        Parameters
+        ----------
+        perturbations: tuple
+            Tuple where each entry is a state that is perturbed across
+            the analysis, e.g. ``("Tfuel", "RhoCool", "CR")``. These
+            must appear in the same order as they are ordered in the
+            coefficient file.
+        """
+        if (isinstance(perturbations, str)
+           or not isinstance(perturbations, Iterable)):
+            self._perturbations = perturbations,
+        else:
+            self._perturbations = tuple(perturbations)
+        sampleBranchKey = self._getBranchStates()
+        sampleUniv = self._getUnivsBurnups(sampleBranchKey)
+        xsSizes = self._getXsSizes(sampleUniv)
+
+        # Create empty arrays for each xs type
+        # Will send off views of this to each universe container
+        numUniv = len(self.univIndex)
+        numBurnup = len(self._burnups)
+        for key, size in iteritems(xsSizes):
+            shape = self._shapes + [numUniv, numBurnup, size]
+            self.xsTables[key] = empty(shape)
+
+        missing = self._populateXsTable()
+        if missing:
+            items = ["{}: {}".format(str(k), str(v))
+                     for k, v in iteritems(missing)]
+            msg = ("The following branch states and indexes are "
+                   "unaccounted for:\n{}".format("\n".join(items)))
+            warn(msg, RuntimeWarning)
+
+        self._burnups = array(self._burnups)
+        self._populateUniverses()
+        del self._branches, self._shapes
+
+    def _getBranchStates(self):
+        branchSets = tuple([set() for _tpl in self.perturbations])
+        for branchKey in self._branches:
+            for stateIndex, state in enumerate(branchKey):
+                branchSets[stateIndex].add(state)
+        self._states = tuple([tuple(sorted(s)) for s in branchSets])
+        self._shapes = [len(s) for s in self._states]
+        return branchKey
+
+    def _getUnivsBurnups(self, branchKey):
+        branch = self._branches[branchKey]
+        univs = set()
+        _burnups = set()
+        for unID, bu, ix in branch.universes:
+            univs.add(unID)
+            _burnups.add(bu)
+        self._burnups = tuple(sorted(_burnups))
+        self.univIndex = tuple(sorted(univs))
+        return branch.universes[unID, bu, ix]
+
+    @staticmethod
+    def _getXsSizes(sampleUniv):
+        sizes = {}
+        for gcAttr in ('infExp', 'b1Exp', 'gc'):
+            attr = getattr(sampleUniv, gcAttr)
+            for key, value in iteritems(attr):
+                sizes[key] = value.size
+        return sizes
+
+    def _populateXsTable(self):
+        missing = {}
+        # Create a map of enumerated tuples
+        branchMap = map(enumerate, self._states)
+        branchIndexer = empty(
+            (len(self._states), 2), order='F', dtype=object)
+
+        xsTables = self.xsTables
+        keys = set(xsTables.keys())
+        for branchMapItem in product(*branchMap):
+            branchIndexer[:, :] = branchMapItem
+            stateIndex = tuple(branchIndexer[:, 0].astype(int).tolist())
+            branchKey = tuple(branchIndexer[:, 1].tolist())
+            branch = self._branches.get(branchKey)
+            if branch is None:
+                missing[branchKey] = stateIndex
+                for submat in self.xsTables.values():
+                    submat[stateIndex].fill(nan)
+                continue
+            univIterator = map(enumerate, (self.univIndex, self._burnups))
+            for (uIndex, univID), (bIndex, burnup) in product(*univIterator):
+                universe = branch.getUniv(univID, burnup)
+                thisSlice = stateIndex + (uIndex, bIndex)
+                for xsKey in keys:
+                    xsTables[xsKey][thisSlice] = universe.get(xsKey)
+        return missing
+
+    def _populateUniverses(self):
+        self._axis = ("Universe", ) + self._perturbations + ("Burnup", "Group")
+        nPerts = len(self._perturbations)
+        newAxis = (
+            (nPerts,) + tuple(range(nPerts))
+            + (nPerts + 1, nPerts + 2))
+        origKeys = self.xsTables.keys()
+        for key in origKeys:
+            self.xsTables[key] = self.xsTables[key].transpose(*newAxis)
+
+        univAxis = self._axis[1:]
+        # Create all the univIndex
+        for univIndex, univID in enumerate(self.univIndex):
+            self.universes[univID] = BranchedUniv(univID, self, univAxis)
+        for xsKey, xsMat in iteritems(self.xsTables):
+            for univIndex, univID in enumerate(self.univIndex):
+                self.universes[univID][xsKey] = xsMat[univIndex]


Test patch:
diff --git a/serpentTools/tests/test_xs.py b/serpentTools/tests/test_xs.py
new file mode 100644
index 0000000..aa27f11
--- /dev/null
+++ b/serpentTools/tests/test_xs.py
@@ -0,0 +1,221 @@
+"""
+Test the branching collector
+"""
+
+from unittest import TestCase
+from six import iteritems
+from numpy import ones, array, arange, nan
+from numpy.testing import assert_array_equal
+from serpentTools.xs import BranchCollector
+from serpentTools.data import readDataFile
+
+
+class _BranchTestHelper(TestCase):
+    """Helper class for testing branched collectors"""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.reader = readDataFile('demo.coe')
+
+
+class CollectedHelper(_BranchTestHelper):
+    """
+    Class that helps test the ability to set attributes on branched objects
+    """
+
+    XS_MAP = {
+        ('nom', 'nom'): {  # branch
+            'infTot': {  # group constant
+                0: {  # burnup
+                    0: array([2.10873E-01, 2.23528E-01]),  # univ: value
+                    20: array([2.10868E-01, 1.22701E-01]),
+                },
+                1: {
+                    0: array([2.08646E-01, 0.00000E+00]),
+                    20: array([2.08083E-01, 0.00000E+00]),
+                },
+            },
+            'b1Diffcoef': {
+                0: {
+                    0: array([1.80449E+00, 1.97809E+00]),
+                    20: array([1.80519E+00, 0.00000E+00]),
+                },
+            },
+        },
+        ('B750', 'FT1200'): {
+            'infTot': {
+                0: {
+                    0: array([3.13772E-01, 5.41505E-01]),
+                    20: array([3.13711E-01, 5.41453E-01]),
+                },
+                1: {
+                    0: array([3.11335E-01, 6.09197E-01]),
+                    20: array([3.11729E-01, 8.37580E-01]),
+                },
+            },
+            'b1Diffcoef': {
+                0: {
+                    0: array([1.75127E+00, 8.63097E-01]),
+                    20: array([1.75206E+00, 8.62754E-01]),
+                },
+            },
+        },
+    }
+
+    XS_ERR_MSG = """
+Pert: {p}
+Group constant: {g}
+Universe: {u}
+Burnup: {b}""".strip()
+
+    def collect(self):
+        """Return the collector after collecting group constants"""
+        collector = BranchCollector(self.reader)
+        collector.collect(("BOR", "TFU"))
+        return collector
+
+    def getGroupConstant(self, pertVals, univID, gcKey, burnup, msg):
+        """Return the stored group constant"""
+        try:
+            val = self._getGroupConstant(pertVals, univID, gcKey, burnup)
+        except KeyError:
+            raise KeyError(msg)
+        except IndexError:
+            raise IndexError(msg)
+        return val
+
+    def _getGroupConstant(self, pertVals, univID, gcKEy, burnup):
+        raise NotImplementedError
+
+    def _getBurnupIndex(self, burnup):
+        bmask = self.testObj.burnups == burnup
+        if not bmask.any():
+            raise IndexError(burnup)
+        return bmask.argsort()[-1]
+
+    def _getPertIndex(self, perts):
+        generator = (self.testObj.states[i].index(v)
+                     for i, v in enumerate(perts))
+        return tuple(generator)
+
+    def compareGroupConstant(self, expGC, pertVals, univID, gcKey, burnup):
+        msg = self.XS_ERR_MSG.format(p=pertVals, g=gcKey, u=univID, b=burnup)
+        actual = self.getGroupConstant(pertVals, univID, gcKey, burnup, msg)
+        if actual is nan:
+            return  # avoid comparing different universes
+        assert_array_equal(actual, expGC, err_msg=msg)
+
+    def test_setPert(self):
+        """Test the ability to change perturbations attribute"""
+        lenCur = len(self.testObj.perturbations)
+        goodVals = [True, ] * lenCur
+        self.testObj.perturbations = goodVals
+        badVals0 = [True, ] * (lenCur - 1)
+        badVals1 = [True, ] * (lenCur + 1)
+        with self.assertRaises(ValueError):
+            self.testObj.perturbations = badVals0
+        with self.assertRaises(ValueError):
+            self.testObj.perturbations = badVals1
+
+    def test_setPertStates(self):
+        """Test the ability to change states attribute"""
+        asNpArray = array(self.testObj.states)
+        self.testObj.states = asNpArray
+        badShapes = [ones(dim + 1) for dim in asNpArray.shape]
+        with self.assertRaises(ValueError):
+            self.testObj.states = badShapes
+
+    def test_setAxis(self):
+        """Test the ability to set the axis attribute"""
+        goodAx = (None, ) * len(self.testObj.axis)
+        self.testObj.axis = goodAx
+        self.assertTrue(goodAx == self.testObj.axis)
+        # convert to list, but axis converts back to tuple
+        self.testObj.axis = list(goodAx)
+        self.assertTrue(goodAx == self.testObj.axis)
+        # misshapen axis object
+        badAx = (None, ) * (len(goodAx) - 1)
+        with self.assertRaises(ValueError):
+            self.testObj.axis = badAx
+
+    def test_setBurnup(self):
+        """Test the ability to set the burnup attribute"""
+        goodBurnup = arange(self.testObj.burnups.size)
+        self.testObj.burnups = goodBurnup
+        self.assertTrue(goodBurnup is self.testObj.burnups)
+        # convert to list, but setter automatically converts to array
+        self.testObj.burnups = list(goodBurnup)
+        assert_array_equal(goodBurnup, self.testObj.burnups)
+        badBurnup = arange(goodBurnup.size + 1)
+        with self.assertRaises(ValueError):
+            self.testObj.burnups = badBurnup
+
+    def test_xsTables(self):
+        """Verify that group constants are placed correctly"""
+        # dig through the XS_MAP
+        for pertKey, pertMap in iteritems(self.XS_MAP):
+            for gcKey, gcMap in iteritems(pertMap):
+                for burnup, burnMap in iteritems(gcMap):
+                    for univID, expGC in iteritems(burnMap):
+                        self.compareGroupConstant(
+                            expGC, pertKey, univID, gcKey, burnup)
+
+
+class BranchCollectorTester(CollectedHelper):
+    """Class that tests the branching collector"""
+
+    def setUp(self):
+        self.collector = self.collect()
+        self.testObj = self.collector
+
+    def _getGroupConstant(self, pertVals, univID, gcKey, burnup):
+        xsTable = self.collector.xsTables[gcKey]
+        univIndex = self.collector.univIndex.index(univID)
+        burnupIndex = self._getBurnupIndex(burnup)
+        pertIndexes = self._getPertIndex(pertVals)
+        # current xsTable structure:
+        # univ, <perts>, burnup
+        index = (univIndex, ) + pertIndexes + (burnupIndex, )
+        return xsTable[index]
+
+
+class BranchUnivTester(CollectedHelper):
+    """Class that tests the branch universe object"""
+
+    def setUp(self):
+        col = self.collect()
+        self.universe = col.universes[col.univIndex[0]]
+        self.testObj = self.universe
+
+    def _getGroupConstant(self, pertVals, univID, gcKey, burnup):
+        if univID != self.universe.univID:
+            return nan
+        xsTable = self.universe.xsTables[gcKey]
+        burnupIndex = self._getBurnupIndex(burnup)
+        pertIndex = self._getPertIndex(pertVals)
+        # data is stored as
+        # <perturbations>, burnup
+        return xsTable[pertIndex + (burnupIndex, )]
+
+
+del CollectedHelper
+
+
+class BareBranchContainerTester(_BranchTestHelper):
+    """Class for testing the pre-collected classes"""
+
+    def test_bareBranchedContainer(self):
+        """Verify that certain attributes cannot be set before collection"""
+        collector = BranchCollector(self.reader)
+        self._testUnsettableAttributes(
+            collector, ('burnups', 'axis', 'states', 'perturbations'))
+
+    def _testUnsettableAttributes(self, obj, attrs):
+        for attr in attrs:
+            with self.assertRaises(AttributeError, msg='set ' + attr):
+                setattr(obj, attr, None)
+
+
+if __name__ == '__main__':
+    from unittest import main
+    main()