Repository: CORE-GATECH-GROUP/serpent-tools

Issue:
Explicit attributes for supporting objects
Currently, supporting objects look for their attributes first in their own metadata, then in the metadata of the metadata of the parent. This poses some introspection ugliness, as it is difficult to determine what attributes are on the object.

Each supporting object should have its attributes explicitly stated and stored. This should improve performance and usability.

Maybe use `__slots__` as well?

Discussion:

Patch:
diff --git a/.travis.yml b/.travis.yml
index 19f06e3..98b5729 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,3 +1,5 @@
+env:
+  - ONTRAVIS=True
 language: python
 python:
   - "3.6"
diff --git a/serpentTools/objects/__init__.py b/serpentTools/objects/__init__.py
index 2ef74d4..53949d9 100644
--- a/serpentTools/objects/__init__.py
+++ b/serpentTools/objects/__init__.py
@@ -1,7 +1,7 @@
 """Objects used to support the parsing."""
 
 
-class _SupportingObject(object):
+class SupportingObject(object):
     """
     Base supporting object.
 
@@ -9,27 +9,15 @@ class _SupportingObject(object):
     ----------
     container: Some parser from serpentTools.parsers
         Container that created this object
-    name: str
-        Name of this specific object, e.g. material name, detector name, etc.
 
     """
 
     def __init__(self, container):
-        self._container = container
-        self._filePath = container.filePath
+        self.origin = container.filePath
 
-    def __repr__(self):
-        return '<{} from {}>'.format(self.whatAmI(), self._filePath)
+    def __str__(self):
+        return '<{} from {}>'.format(self.__class__.__name__, self.origin)
 
-    def whatAmI(self):
-        return type(self).__name__
-
-    def __getattr__(self, item):
-        """Search for the item in the containers metadata."""
-        if item in self._container.metadata:
-            return self._container.metadata[item]
-        raise AttributeError('{} has object has no attribute \'{}\''
-                             .format(self, item))
 
     @staticmethod
     def _convertVariableName(variable):
@@ -42,13 +30,13 @@ class _SupportingObject(object):
                                              for item in lowerSplits[1:]])
 
 
-class _NamedObject(_SupportingObject):
+class NamedObject(SupportingObject):
     """Class for named objects like materials and detectors."""
 
     def __init__(self, container, name):
-        _SupportingObject.__init__(self, container)
+        SupportingObject.__init__(self, container)
         self.name = name
 
-    def __repr__(self):
-        return '<{} {} from {}>'.format(self.whatAmI(),
-                                        self.name, self._filePath)
\ No newline at end of file
+    def __str__(self):
+        return '<{} {} from {}>'.format(self.__class__.__name__,
+                                        self.name, self.origin)
diff --git a/serpentTools/objects/materials.py b/serpentTools/objects/materials.py
index 49e2ff4..ac65c6a 100644
--- a/serpentTools/objects/materials.py
+++ b/serpentTools/objects/materials.py
@@ -4,11 +4,12 @@ import numpy
 from matplotlib import pyplot
 
 from serpentTools.settings import messages
-from serpentTools.objects import _NamedObject
+from serpentTools.objects import NamedObject
 
 
-class DepletedMaterial(_NamedObject):
-    """Class for storing material data from ``_dep.m`` files.
+class DepletedMaterial(NamedObject):
+    """
+    Class for storing material data from ``_dep.m`` files.
 
     Parameters
     ----------
@@ -20,39 +21,67 @@ class DepletedMaterial(_NamedObject):
 
     Attributes
     ----------
-    zai: numpy.array
+    zai: numpy.array or None
         Isotope id's
-    names: numpy.array
+    names: numpy.array or None
         Names of isotopes
-    days: numpy.array
+    days: numpy.array or None
         Days overwhich the material was depleted
-    adens: numpy.array
+    adens: numpy.array or None
         Atomic density over time for each nuclide
+    mdens: numpy.array or None
+        Mass density over time for each nuclide
+    burnup: numpy.array or None
+        Burnup of the material over time
 
     """
 
     def __init__(self, parser, name):
-        _NamedObject.__init__(self, parser, name)
-        self._varData = {}
-
-    def __getattr__(self, item):
-        """
-        Allows the user to get items like ``zai`` and ``adens``
-        with ``self.zai`` and ``self.adens``, respectively.
-        """
-        if item in self._varData:
-            return self._varData[item]
-        return _NamedObject.__getattr__(self, item)
+        NamedObject.__init__(self, parser, name)
+        self.data = {}
+        self.zai = parser.metadata.get('zai', None)
+        self.names = parser.metadata.get('names', None)
+        self.days = parser.metadata.get('days', None)
+        self.__burnup__ = None
+        self.__adens__ = None
+        self.__mdens__ = None
 
     def __getitem__(self, item):
-        if item not in self._varData:
-            if item not in self._container.metadata:
-                raise KeyError('{} has no item {}'.format(self, item))
-            return self._container.metadata[item]
-        return self._varData[item]
+        if item not in self.data:
+            raise KeyError('Key {} not found on material {}'
+                           .format(item, self.name))
+        return self.data[item]
+
+    @property
+    def burnup(self):
+        if 'burnup' not in self.data:
+            raise AttributeError('Burnup for material {} has not been loaded'
+                                 .format(self.name))
+        if self.__burnup__ is None:
+            self.__burnup__ = self.data['burnup']
+        return self.__burnup__
+
+    @property
+    def adens(self):
+        if 'adens' not in self.data:
+            raise AttributeError('Atomic densities for material {} have not '
+                                 'been loaded'.format(self.name))
+        if self.__adens__ is None:
+            self.__adens__ = self.data['adens']
+        return self.__adens__
+
+    @property
+    def mdens(self):
+        if 'mdens' not in self.data:
+            raise AttributeError('Mass densities for material {} has not been '
+                                 'loaded'.format(self.name))
+        if self.__mdens__ is None:
+            self.__mdens__ = self.data['mdens']
+        return self.__mdens__
 
     def addData(self, variable, rawData):
-        """Add data straight from the file onto a variable.
+        """
+        Add data straight from the file onto a variable.
 
         Parameters
         ----------
@@ -70,10 +99,20 @@ class DepletedMaterial(_NamedObject):
             for line in rawData:
                 if line:
                     scratch.append([float(item) for item in line.split()])
-        self._varData[newName] = numpy.array(scratch)
+        self.data[newName] = numpy.array(scratch)
 
+    @messages.depreciated
     def getXY(self, xUnits, yUnits, timePoints=None, names=None):
-        """Return x values for given time, and corresponding isotope values.
+        """Depreciated. Use getValues instead"""
+        if timePoints is None:
+            timePoints = self.days
+            return self.getValues(xUnits, yUnits, timePoints, names), self.days
+        else:
+            return self.getValues(xUnits, yUnits, timePoints, names)
+
+    def getValues(self, xUnits, yUnits, timePoints=None, names=None):
+        """
+        Return x values for given time, and corresponding isotope values.
 
         Parameters
         ----------
@@ -92,8 +131,6 @@ class DepletedMaterial(_NamedObject):
         -------
         numpy.array
             Array of values.
-        numpy.array
-            Vector of time points only if ``timePoints`` is ``None``
 
         Raises
         ------
@@ -104,20 +141,18 @@ class DepletedMaterial(_NamedObject):
             If at least one of the days requested is not present
         """
         if timePoints is not None:
-            returnX = False
             timeCheck = self._checkTimePoints(xUnits, timePoints)
             if any(timeCheck):
                 raise KeyError('The following times were not present in file {}'
-                               '\n{}'.format(self._container.filePath,
+                               '\n{}'.format(self.origin,
                                              ', '.join(timeCheck)))
-        else:
-            returnX = True
-        if names and 'names' not in self._container.metadata:
-            raise AttributeError('Parser {} has not stored the isotope names.'
-                                 .format(self._container))
+        if names and self.names is None:
+            raise AttributeError(
+                'Isotope names not stored on DepletedMaterial {}.'
+                .format(self.name))
         xVals, colIndices = self._getXSlice(xUnits, timePoints)
         rowIndices = self._getIsoID(names)
-        allY = self[yUnits]
+        allY = self.data[yUnits]
         if allY.shape[0] == 1 or len(allY.shape) == 1:  # vector
             yVals = allY[colIndices] if colIndices else allY
         else:
@@ -125,17 +160,15 @@ class DepletedMaterial(_NamedObject):
             for isoID, rowId in enumerate(rowIndices):
                 yVals[isoID, :] = (allY[rowId][colIndices] if colIndices
                                    else allY[rowId][:])
-        if returnX:
-            return yVals, xVals
         return yVals
 
     def _checkTimePoints(self, xUnits, timePoints):
-        valid = self[xUnits]
+        valid = self.days if xUnits == 'days' else self.data[xUnits]
         badPoints = [str(time) for time in timePoints if time not in valid]
         return badPoints
 
     def _getXSlice(self, xUnits, timePoints):
-        allX = self[xUnits]
+        allX = self.days if xUnits == 'days' else self.data[xUnits]
         if timePoints is not None:
             colIndices = [indx for indx, xx in enumerate(allX)
                           if xx in timePoints]
@@ -157,7 +190,8 @@ class DepletedMaterial(_NamedObject):
         return rowIDs
 
     def plot(self, xUnits, yUnits, timePoints=None, names=None, ax=None):
-        """Plot some data as a function of time for some or all isotopes.
+        """
+        Plot some data as a function of time for some or all isotopes.
 
         Parameters
         ----------
@@ -185,9 +219,10 @@ class DepletedMaterial(_NamedObject):
         getXY
 
         """
-        xVals, yVals = self.getXY(xUnits, yUnits, timePoints, names)
-        ax = ax or pyplot.subplots(1, 1)[1]
-        labels = names or [None]
+        xVals = timePoints or self.days
+        yVals = self.getValues(xUnits, yUnits, xVals, names)
+        ax = ax or pyplot.axes()
+        labels = names or ['']
         for row in range(yVals.shape[0]):
             ax.plot(xVals, yVals[row], label=labels[row])
         return ax
diff --git a/serpentTools/objects/readers.py b/serpentTools/objects/readers.py
index b26ef67..b67b9d6 100644
--- a/serpentTools/objects/readers.py
+++ b/serpentTools/objects/readers.py
@@ -17,7 +17,7 @@ class BaseReader(object):
         self.metadata = {}
         self.settings = rc.getReaderSettings(readerSettingsLevel)
 
-    def __repr__(self):
+    def __str__(self):
         return '<{} reading {}>'.format(self.__class__.__name__, self.filePath)
 
     def read(self):
diff --git a/serpentTools/parsers/depletion.py b/serpentTools/parsers/depletion.py
index b6f0dda..87355bc 100644
--- a/serpentTools/parsers/depletion.py
+++ b/serpentTools/parsers/depletion.py
@@ -69,6 +69,9 @@ class DepletionReader(MaterialReader):
                     self._addTotal(chunk)
                 else:
                     self._addMetadata(chunk)
+        if 'days' in self.metadata:
+            for mKey in self.materials:
+                self.materials[mKey].days = self.metadata['days']
         messages.debug('Done reading depletion file')
         messages.debug('  found {} materials'.format(len(self.materials)))
 


Test patch:
diff --git a/serpentTools/tests/test_depletion.py b/serpentTools/tests/test_depletion.py
index 2e00eec..6e8e0d5 100644
--- a/serpentTools/tests/test_depletion.py
+++ b/serpentTools/tests/test_depletion.py
@@ -120,18 +120,18 @@ class DepletedMaterialTester(_DepletionTestHelper):
         self.assertListEqual(self.material.zai,
                              self.reader.metadata['zai'])
         numpy.testing.assert_equal(self.material.adens, expectedAdens)
-        numpy.testing.assert_equal(self.material.ingTox, expectedIngTox)
+        numpy.testing.assert_equal(self.material['ingTox'], expectedIngTox)
 
     def test_getXY_burnup_full(self):
         """
         Verify the material can produce the full burnup vector through getXY.
         """
-        actual, _days = self.material.getXY('days', 'burnup', )
+        actual = self.material.getValues('days', 'burnup', )
         numpy.testing.assert_equal(actual, self.fuelBU)
 
     def test_getXY_burnup_slice(self):
         """Verify depletedMaterial getXY correctly slices a vector."""
-        actual = self.material.getXY('days', 'burnup', self.requestedDays)
+        actual = self.material.getValues('days', 'burnup', self.requestedDays)
         expected = [0.0E0, 1.90317E-2, 3.60163E-2, 1.74880E-1, 3.45353E-01,
                     8.49693E-01, 1.66071E0]
         numpy.testing.assert_equal(actual, expected)
@@ -147,27 +147,28 @@ class DepletedMaterialTester(_DepletionTestHelper):
             [0.00000E+00, 2.90880E-14, 5.57897E-14, 2.75249E-13, 5.46031E-13,
              1.35027E-12, 2.64702E-12],
         ], float)
-        actual = self.material.getXY('days', 'adens', names=names,
-                                     timePoints=self.requestedDays)
+        actual = self.material.getValues('days', 'adens', names=names,
+                                         timePoints=self.requestedDays)
         numpy.testing.assert_allclose(actual, expected, rtol=1E-4)
 
-    def test_getXY_adensAndTime(self):
-        """Verify correct atomic density and time slice are returned."""
-        actualAdens, actualDays = self.material.getXY('days', 'adens',
-                                                      names=['Xe135'])
-        numpy.testing.assert_equal(actualDays, self.reader.metadata['days'])
-
     def test_getXY_raisesError_badTime(self):
         """Verify that a ValueError is raised for non-present requested days."""
         badDays = [-1, 0, 50]
         with self.assertRaises(KeyError):
-            self.material.getXY('days', 'adens', timePoints=badDays)
+            self.material.getValues('days', 'adens', timePoints=badDays)
 
     def test_fetchData(self):
         """Verify that key errors are raised when bad data are requested."""
         with self.assertRaises(KeyError):
             _ = self.material['fake units']
 
+    @unittest.skipIf(os.getenv('ONTRAVIS') is not None,
+                     "Plotting doesn't play well with Travis")
+    def test_plotter(self):
+        """Verify the plotting functionality is operational."""
+        self.material.plot('days', 'adens', timePoints=self.requestedDays,
+                           names=['Xe135', 'U235'])
+
 
 if __name__ == '__main__':
     unittest.main()