Repository: terrapower/armi

Issue:
Document runLog single=True feature
This should be added to the `runLog` section of the ARMI docs:

https://terrapower.github.io/armi/developer/tooling.html#logging-with-runlog

It goes here in the docs:

https://github.com/terrapower/armi/blob/main/doc/developer/tooling.rst#logging-with-runlog


Discussion:

Patch:
diff --git a/.gitignore b/.gitignore
index 2c8c3d20..e64adfe9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,81 +1,80 @@
-# No non-source python resources.
+# No non-source python resources
 *.pyc
-*.pyo
 *.pyd
+*.pyo
 *.pyx
 
 # No build artifacts
-build
-wheelhouse
-*.lib
 *.dll
-bin/*
+*.lib
+.apidocs/
 /bin
-dist/
+armi/tests/tutorials/case-suite
+bin/*
+build
+coverage.lcov
+coverage.xml
+coverage_results.*
 dist-*/
+dist/
 doc/_build
-doc/user/tutorials
-doc/tutorials/anl-afci-177*
-doc/tutorials/case-suite
-armi/tests/tutorials/case-suite
-.apidocs/
 doc/gallery
 doc/gallery-src/framework/*.yaml
-.coverage
-coverage.xml
-coverage.lcov
-coverage_results.*
+doc/tutorials/anl-afci-177*
+doc/tutorials/case-suite
+doc/user/tutorials
 htmlcov/
 monkeytype.*
+wheelhouse
 
-# No workspace crumbs.
-*~
-.*.swp
-.settings
-.vscode
-.project
-.metadata
-.idea/
-.pydevproject
+# No workspace crumbs
+**/.coverage*
+**/__pycache__
+**/logs/*
+*.ascii
+*.egg-info/
 *.sublime-project
 *.sublime-workspace
-*.egg-info/
-phabricator-lint.txt
-temp-*
-tags
 *.temp
-.externalToolBuilders/
+*~
+.*.swp
 .cache/
+.coverage
+.DS_Store
+.externalToolBuilders/
+.hypothesis/
+.idea/
+.ipynb_checkpoints
+.metadata
+.mypy_cache/
+.project
+.pydevproject
 .pytest_cache/
-pytestdebug.log
-dump-temp-*
-dump-tests*
+.ruff_cache/
+.settings
+.tox
 .vim-bookmarks
+.vscode
 armi-venv/*
+dump-temp-*
+dump-tests*
+phabricator-lint.txt
+pytestdebug.log
+reportsOutputFiles/
+tags
+temp-*
 venv*/
-.mypy_cache/
-**/__pycache__
-**/.coverage*
-**/logs/*
-*.ascii
 
-# Misc. exclusions
-*.html
-*.diff
+# Ignore common data files
 *.avi
-*.ppm
-*.mp4
+*.diff
 *.h5
+*.html
+*.mp4
+*.ppm
 *.txt
 
-# metadata file specific to MacOS
-.DS_Store
-
 # vis files
-*.vtu
 *.vtd
+*.vtu
 *.xdmf
-
-# fixtures
-.ipynb_checkpoints
-.tox
diff --git a/armi/cli/reportsEntryPoint.py b/armi/cli/reportsEntryPoint.py
index 263081a0..efb2802f 100644
--- a/armi/cli/reportsEntryPoint.py
+++ b/armi/cli/reportsEntryPoint.py
@@ -19,15 +19,16 @@ from armi.bookkeeping.report import newReports as reports
 from armi.cli import entryPoint
 from armi.reactor import blueprints
 from armi.reactor import reactors
-from armi.utils import directoryChangers
+from armi.utils.directoryChangers import ForcedCreationDirectoryChanger
 
 
 class ReportsEntryPoint(entryPoint.EntryPoint):
-    """Create report from database files."""
+    """Create a report from a database file."""
 
     name = "report"
     settingsArgument = "optional"
     description = "Convert ARMI databases into a report"
+    report_out_dir = "reportsOutputFiles"
 
     def __init__(self):
         entryPoint.EntryPoint.__init__(self)
@@ -43,8 +44,7 @@ class ReportsEntryPoint(entryPoint.EntryPoint):
         self.parser.add_argument(
             "--output-name",
             "-o",
-            help="Base name for output file(s). File extensions will be added as "
-            "appropriate",
+            help="Base name for output file(s). File extensions will be added as appropriate",
             type=str,
             default=None,
         )
@@ -57,15 +57,15 @@ class ReportsEntryPoint(entryPoint.EntryPoint):
         )
         self.parser.add_argument(
             "--max-node",
-            help="An optional (cycle,timeNode) tuple to specify the latest time step "
-            "that should be included",
+            help="An optional (cycle,timeNode) tuple to specify the latest time step that should "
+            "be included",
             type=str,
             default=None,
         )
         self.parser.add_argument(
             "--min-node",
-            help="An optional (cycle,timeNode) tuple to specify the earliest time step "
-            "that should be included",
+            help="An optional (cycle,timeNode) tuple to specify the earliest time step that should "
+            "be included",
             type=str,
             default=None,
         )
@@ -78,31 +78,22 @@ class ReportsEntryPoint(entryPoint.EntryPoint):
         )
 
     def invoke(self):
-        nodes = self.args.nodes
-
         if self.args.h5db is None:
-            # Just do begining stuff, no database is given...
-            if self.cs is not None:
-                site = createReportFromSettings(self.cs)
-                if self.args.view:
-                    webbrowser.open(site)
-            else:
-                raise RuntimeError(
-                    "No Settings with Blueprint or Database, cannot gerenate a report"
-                )
-
+            # Just do BOL stuff, no database is given.
+            site = createReportFromSettings(self.cs)
+            if self.args.view:
+                webbrowser.open(site)
         else:
+            self._cleanArgs()
+            nodes = self.args.nodes
+            blueprint = self.args.bp
+
             report = reports.ReportContent("Overview")
             pm = getPluginManagerOrFail()
             db = databaseFactory(self.args.h5db, "r")
-            if self.args.bp is not None:
-                blueprint = self.args.bp
 
             with db:
-                with directoryChangers.ForcedCreationDirectoryChanger(
-                    "reportsOutputFiles"
-                ):
-
+                with ForcedCreationDirectoryChanger(self.report_out_dir):
                     dbNodes = list(db.genTimeSteps())
                     cs = db.loadCS()
                     if self.args.bp is None:
@@ -118,6 +109,7 @@ class ReportsEntryPoint(entryPoint.EntryPoint):
                     )
                     stage = reports.ReportStage.Standard
                     for cycle, node in dbNodes:
+                        # check to see if we should skip this time node
                         if nodes is not None and (cycle, node) not in nodes:
                             continue
 
@@ -148,23 +140,68 @@ class ReportsEntryPoint(entryPoint.EntryPoint):
                     if self.args.view:
                         webbrowser.open(site)
 
+    @staticmethod
+    def toTwoTuple(strInput):
+        """Convert a string to a two-tuple of integers.
+
+        Parameters
+        ----------
+        strInput : str
+            Representing a simple two-tuple of integers: '(1,3)'.
+
+        Returns
+        -------
+        tuple
+            A tuple of two integers.
+        """
+        s = strInput.replace("(", "").replace(")", "").split(",")
+        return tuple([int(s[0]), int(s[1])])
+
+    def _cleanArgs(self):
+        """The string arguments passed to this entry point, on the command line, need to be
+        converted to integers.
+        """
+        if self.args.min_node is not None and type(self.args.min_node) is str:
+            self.args.min_node = ReportsEntryPoint.toTwoTuple(self.args.min_node)
+
+        if self.args.max_node is not None and type(self.args.max_node) is str:
+            self.args.max_node = ReportsEntryPoint.toTwoTuple(self.args.max_node)
+
+        if self.args.nodes is not None and type(self.args.nodes) is str:
+            self.args.nodes = [
+                ReportsEntryPoint.toTwoTuple(n) for n in self.args.nodes.split(")")[:-1]
+            ]
+
 
 def createReportFromSettings(cs):
     """
     Create BEGINNING reports, given a settings file.
 
-    This will construct a reactor from the given settings and create BOL reports for
-    that reactor/settings.
+    This will construct a reactor from the given settings and create BOL reports for that
+    reactor/settings.
+
+    Parameters
+    ----------
+    cs : Settings
+        A standard ARMI Settings object, to define a run.
+
+    Returns
+    -------
+    str
+        A string representing the HTML for a web page.
     """
+    if cs is None:
+        raise RuntimeError(
+            "No Settings with Blueprint or Database, cannot gerenate a report"
+        )
+
     blueprint = blueprints.loadFromCs(cs)
     r = reactors.factory(cs, blueprint)
     report = reports.ReportContent("Overview")
     pm = getPluginManagerOrFail()
     report.title = r.name
 
-    with directoryChangers.ForcedCreationDirectoryChanger(
-        "{}-reports".format(cs.caseTitle)
-    ):
+    with ForcedCreationDirectoryChanger("{}-reports".format(cs.caseTitle)):
         _ = pm.hook.getReportContents(
             r=r,
             cs=cs,
diff --git a/armi/materials/material.py b/armi/materials/material.py
index 5123af6f..45800fd4 100644
--- a/armi/materials/material.py
+++ b/armi/materials/material.py
@@ -432,7 +432,7 @@ class Material:
         # 0 at tempertature of targetDensity
         densFunc = lambda temp: self.density(Tc=temp) - targetDensity
         # is a numpy array if fsolve is called
-        tAtTargetDensity = float(fsolve(densFunc, tempGuessInC))
+        tAtTargetDensity = float(fsolve(densFunc, tempGuessInC)[0])
         return tAtTargetDensity
 
     @property
diff --git a/armi/utils/mathematics.py b/armi/utils/mathematics.py
index efa5e29e..3169e92b 100644
--- a/armi/utils/mathematics.py
+++ b/armi/utils/mathematics.py
@@ -26,7 +26,7 @@ SCIPAT_SPECIAL = re.compile(r"([+-]?\d*\.\d+)[eEdD]?([+-]\d+)")
 
 def average1DWithinTolerance(vals, tolerance=0.2):
     """
-    Compute the average of a series of arrays with a tolerance.
+    Compute the average of a series of 1D arrays with a tolerance.
 
     Tuned for averaging assembly meshes or block heights.
 
@@ -34,6 +34,13 @@ def average1DWithinTolerance(vals, tolerance=0.2):
     ----------
     vals : 2D np.array
         could be assembly x axial mesh tops or heights
+    tolerance : float
+        The accuracy to which we need to know the average.
+
+    Returns
+    -------
+    1D np.array
+        The average of all the input 1D NumPy arrays.
     """
     vals = np.array(vals)
 
@@ -57,11 +64,10 @@ def average1DWithinTolerance(vals, tolerance=0.2):
     return avg
 
 
-def convertToSlice(x, increment=False):
+def convertToSlice(x, increment=0):
     """
-    Convert a int, float, list of ints or floats, None, or slice
-    to a slice. Also optionally increments that slice to make it easy to line
-    up lists that don't start with 0.
+    Convert a int, float, list of ints or floats, None, or slice to a slice. Also optionally
+    increments that slice to make it easy to line up lists that don't start with 0.
 
     Use this with np.array (np.ndarray) types to easily get selections of it's elements.
 
@@ -72,15 +78,17 @@ def convertToSlice(x, increment=False):
         list of int: select these index numbers.
         None: select all indices.
         slice: select this slice
+    increment : integer (or boolean), optional
+        Step size, when taking your slices. (`False` is zero.)
 
     Returns
     -------
     slice : slice
-        Returns a slice object that can be used in an array
-        like a[x] to select from its members.
-        Also, the slice has its index numbers decremented by 1.
-        It can also return a numpy array, which can be used
-        to slice other numpy arrays in the same way as a slice.
+        Returns a slice object that can be used in an array like a[x] to select from its members.
+        Also, the slice has its index numbers decremented by 1. It can also return a numpy array,
+        which can be used to slice other numpy arrays in the same way as a slice.
+    increment : int
+        Step size to take, if you want to take less then every datum in the collection.
 
     Examples
     --------
@@ -102,19 +110,12 @@ def convertToSlice(x, increment=False):
     >>> a[utils.convertToSlice([1, 3])]
     array([11, 13])
 
-
     >>> a[utils.convertToSlice([1, 3], increment=-1)]
     array([10, 12])
 
     >>> a[utils.convertToSlice(slice(2, 3, None), increment=-1)]
     array([11])
     """
-    if increment is False:
-        increment = 0
-
-    if not isinstance(increment, int):
-        raise Exception("increment must be False or an integer in utils.convertToSlice")
-
     if x is None:
         x = np.s_[:]
 
@@ -143,15 +144,11 @@ def convertToSlice(x, increment=False):
         jstep = x.step
 
         return np.s_[jstart:jstop:jstep]
-
     elif isinstance(x, np.ndarray):
         return np.array([i + increment for i in x])
-
     else:
         raise Exception(
-            (
-                "It is not known how to handle x type: " "{0} in utils.convertToSlice"
-            ).format(type(x))
+            f"It is not known how to handle x type: {type(x)} in utils.convertToSlice"
         )
 
 
@@ -200,7 +197,7 @@ def expandRepeatedFloats(repeatedList):
 
 
 def findClosest(listToSearch, val, indx=False):
-    r"""
+    """
     Find closest item in a list.
 
     Parameters
@@ -325,7 +322,7 @@ def isMonotonic(inputIter, relation):
 
 
 def linearInterpolation(x0, y0, x1, y1, targetX=None, targetY=None):
-    r"""
+    """
     Does a linear interpolation (or extrapolation) for y=f(x).
 
     Parameters
@@ -373,8 +370,8 @@ def minimizeScalarFunc(
     method=None,
     tol=1.0e-3,
 ):
-    r"""
-    Use scipy minimize with the given function, goal value, and first guess.
+    """
+    Use SciPy minimize with the given function, goal value, and first guess.
 
     Parameters
     ----------
@@ -496,11 +493,15 @@ def parabolaFromPoints(p1, p2, p3):
     ----------
     p1 : tuple
         first point (x,y) coordinates
-    p2,p3: tuple, second and third points.
+    p2 : tuple
+        second (x,y) points
+    p3 : tuple
+        third (x,y) points
 
     Returns
     -------
-    a,b,c coefficients of y=ax^2+bx+c
+    tuple
+        3 floats: a,b,c coefficients of y=ax^2+bx+c
     """
     A = np.array(
         [[p1[0] ** 2, p1[0], 1], [p2[0] ** 2, p2[0], 1], [p3[0] ** 2, p3[0], 1]]
@@ -518,7 +519,7 @@ def parabolaFromPoints(p1, p2, p3):
 
 
 def parabolicInterpolation(ap, bp, cp, targetY):
-    r"""
+    """
     Given parabola coefficients, this interpolates the time
     that would give k=targetK.
 
@@ -532,10 +533,13 @@ def parabolicInterpolation(ap, bp, cp, targetY):
 
     Parameters
     ----------
-    ap, bp,cp : floats
-        coefficients of a parabola y = ap*x^2 + bp*x + cp
-
-    targetK : float
+    ap : float
+        coefficients ap of a parabola y = ap*x^2 + bp*x + cp
+    bp : float
+        coefficients bp of a parabola y = ap*x^2 + bp*x + cp
+    cp : float
+        coefficients cp of a parabola y = ap*x^2 + bp*x + cp
+    targetY : float
         The keff to find the cycle length of
 
     Returns
@@ -587,13 +591,10 @@ def resampleStepwise(xin, yin, xout, avg=True):
     ----------
     xin : list
         interval points / mesh points
-
     yin : list
         interval values / inter-mesh values
-
     xout : list
         new interval points / new mesh points
-
     avg : bool
         By default, this is set to True, forcing the resampling to be done
         by averaging. But if this is False, the resmampling will be done by
@@ -659,19 +660,19 @@ def rotateXY(x, y, degreesCounterclockwise=None, radiansCounterclockwise=None):
 
     Parameters
     ----------
-    x, y : array_like
-        coordinates
-
+    x : float
+        X coordinates, array-like
+    y : float
+        Y coordinates, array-like
     degreesCounterclockwise : float
         Degrees to rotate in the CCW direction
-
     radiansCounterclockwise : float
         Radians to rotate in the CCW direction
 
     Returns
     -------
-    xr, yr : array_like
-        the rotated coordinates.
+    tuple
+        xr, yr: the rotated coordinates
     """
     if radiansCounterclockwise is None:
         radiansCounterclockwise = degreesCounterclockwise * math.pi / 180.0
diff --git a/armi/utils/reportPlotting.py b/armi/utils/reportPlotting.py
index 0a83069c..f3bf59c7 100644
--- a/armi/utils/reportPlotting.py
+++ b/armi/utils/reportPlotting.py
@@ -29,7 +29,7 @@ import itertools
 import math
 import os
 
-from matplotlib import cm
+from matplotlib import colormaps
 from matplotlib import colors as mpltcolors
 import matplotlib.path
 import matplotlib.projections.polar
@@ -305,7 +305,7 @@ def xsHistoryVsTime(name, history, buGroups, extension=None):
     plt.legend()
     plt.title("Block burnups used to generate XS for {0}".format(name))
     plt.xlabel("Time (years)")
-    plt.ylabel("Burnup (% FIMA)")
+    plt.ylabel(r"Burnup (% FIMA)")
 
     plt.ylim(0, maxbu * 1.05)
     figName = name + ".bugroups." + extension
@@ -727,7 +727,7 @@ def plotAxialProfile(zVals, dataVals, fName, metadata, nPlot=1, yLog=False):
         dataVals = numpy.log10(abs(dataVals))
 
     if nPlot > 1:
-        colormap = cm.get_cmap("jet")
+        colormap = colormaps["jet"]
         norm = mpltcolors.Normalize(0, nPlot - 1)
 
         # alternate between line styles to help distinguish neighboring groups (close on the color map)
diff --git a/doc/conf.py b/doc/conf.py
index 4b42a783..d2135cfa 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -333,10 +333,10 @@ release = meta.__version__
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
 """
-'library/xml.rst' – ignores the library/xml.rst file (replaces entry in unused_docs)
-'library/xml' – ignores the library/xml directory (replaces entry in exclude_trees)
-'library/xml*' – ignores all files and directories starting with library/xml
-'**/.svn' – ignores all .svn directories (replaces entry in exclude_dirnames)
+'library/xml.rst' - ignores the library/xml.rst file (replaces entry in unused_docs)
+'library/xml' - ignores the library/xml directory (replaces entry in exclude_trees)
+'library/xml*' - ignores all files and directories starting with library/xml
+'**/.svn' - ignores all .svn directories (replaces entry in exclude_dirnames)
 """
 exclude_patterns = [
     "**/Python27*",
diff --git a/doc/developer/tooling.rst b/doc/developer/tooling.rst
index 00dc0aac..998eef31 100644
--- a/doc/developer/tooling.rst
+++ b/doc/developer/tooling.rst
@@ -251,6 +251,31 @@ For reference, here are the log levels that ARMI supports:
       - Use ONLY to define major sections in the log files.
 
 
+Blocking Duplicate Logs
+-----------------------
+Sometimes you want to add a log message, but based on program logic it might pop up in the final log
+file multiple times, even thousands of times. And probably you do not want that. Happily, the
+``runLog`` tool provides a simple argument that will stop a single log line from being logged more
+than once.
+
+Here is a (silly) example of a heavily duplicate log message:
+
+.. code-block:: python
+
+    for _i in range(1000):
+        runLog.warning("Something wicked this way comes.")
+
+That log message gets printed 1,000 times, but we can ensure it is only printed once:
+
+.. code-block:: python
+
+    for _i in range(1000):
+        runLog.warning("Something wicked this way comes.", single=True)
+
+Obviously, this will not be useful in every scenario. But it is a handy tool to clean up your log
+files.
+
+
 Module-Level Logging
 --------------------
 The ``runLog`` tool also allows for you to log one module differently from the rest of the code
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 8cf811e3..7ede4100 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -18,6 +18,7 @@ API Changes
 
 Bug Fixes
 ---------
+#. Fixed error parsing command line integers in ``ReportsEntryPoint``. (`PR#1824 <https://github.com/terrapower/armi/pull/1824>`_)
 #. TBD
 
 Quality Work


Test patch:
diff --git a/armi/cli/tests/test_runEntryPoint.py b/armi/cli/tests/test_runEntryPoint.py
index 93cc995d..370200e2 100644
--- a/armi/cli/tests/test_runEntryPoint.py
+++ b/armi/cli/tests/test_runEntryPoint.py
@@ -12,6 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 """Test for run cli entry point."""
+from glob import glob
 from shutil import copyfile
 import logging
 import os
@@ -39,6 +40,50 @@ from armi.utils.directoryChangers import TemporaryDirectoryChanger
 from armi.utils.dynamicImporter import getEntireFamilyTree
 
 
+def buildTestDB(fileName, numNodes=1, numCycles=1):
+    """This function builds a (super) simple test DB.
+
+    Notes
+    -----
+    This needs to be run inside a temp directory.
+
+    Parameters
+    ----------
+    fileName : str
+        The file name (not path) we want for the ARMI test DB.
+    numNodes : int, optional
+        The number of nodes we want in the DB, default 1.
+    numCycles : int, optional
+        The number of cycles we want in the DB, default 1.
+
+    Returns
+    -------
+    str
+        Database file name.
+    """
+    o, r = loadTestReactor(
+        TEST_ROOT,
+        inputFileName="smallestTestReactor/armiRunSmallest.yaml",
+    )
+
+    # create the tests DB
+    dbi = DatabaseInterface(r, o.cs)
+    dbi.initDB(fName=f"{fileName}.h5")
+    db = dbi.database
+
+    # populate the db with something
+    r.p.cycle = 0
+    for node in range(abs(numNodes)):
+        for cycle in range(abs(numCycles)):
+            r.p.timeNode = node
+            r.p.cycle = cycle
+            r.p.cycleLength = 100
+            db.writeToDB(r)
+
+    db.close()
+    return f"{fileName}.h5"
+
+
 class TestInitializationEntryPoints(unittest.TestCase):
     def test_entryPointInitialization(self):
         """Tests the initialization of all subclasses of `EntryPoint`.
@@ -335,17 +380,76 @@ class TestModifyCaseSettingsCommand(unittest.TestCase):
 
 
 class TestReportsEntryPoint(unittest.TestCase):
-    def test_reportsEntryPointBasics(self):
+    def test_toTwoTuple(self):
+        result = ReportsEntryPoint.toTwoTuple("(1,2)")
+        self.assertEqual(result, (1, 2))
+
+        result = ReportsEntryPoint.toTwoTuple("(-931,223)")
+        self.assertEqual(result, (-931, 223))
+
+        result = ReportsEntryPoint.toTwoTuple("(-7,7")
+        self.assertEqual(result, (-7, 7))
+
+        # here is a funny edge case
+        result = ReportsEntryPoint.toTwoTuple("(1,2,3)")
+        self.assertEqual(result, (1, 2))
+
+        # test some cases that SHOULD fail
+        with self.assertRaises(ValueError):
+            ReportsEntryPoint.toTwoTuple("(1,)")
+
+        with self.assertRaises(ValueError):
+            ReportsEntryPoint.toTwoTuple("()")
+
+        with self.assertRaises(ValueError):
+            ReportsEntryPoint.toTwoTuple("[1,5]")
+
+    def test_cleanArgs(self):
         rep = ReportsEntryPoint()
         rep.addOptions()
-        rep.parse_args(["-h5db", "/path/to/fake.h5"])
 
-        self.assertEqual(rep.name, "report")
-        self.assertEqual(rep.settingsArgument, "optional")
+        node0 = "(0,0)"
+        node3 = "(3,3)"
+        nodesStr = "(0,2)(1,3)(2,9)"
 
-        with self.assertRaises(ValueError):
-            # The "fake.h5" doesn't exist, so this should fail.
+        rep.parse_args(["--nodes", nodesStr])
+        self.assertEqual(rep.args.nodes, nodesStr)
+        rep._cleanArgs()
+        self.assertEqual(rep.args.nodes[0], (0, 2))
+        self.assertEqual(rep.args.nodes[1], (1, 3))
+        self.assertEqual(rep.args.nodes[2], (2, 9))
+
+        rep.parse_args(["--min-node", node0])
+        self.assertEqual(rep.args.min_node, node0)
+        rep._cleanArgs()
+        self.assertEqual(rep.args.min_node, (0, 0))
+
+        rep.parse_args(["--max-node", node3])
+        self.assertEqual(rep.args.max_node, node3)
+        rep._cleanArgs()
+        self.assertEqual(rep.args.max_node, (3, 3))
+
+    def test_reportsEntryPointBasics(self):
+        with TemporaryDirectoryChanger() as newDir:
+            # set up output names
+            fileNameDB = buildTestDB(self._testMethodName, 1, 1)
+            outputFile = f"{self._testMethodName}.txt"
+            outDir = os.path.join(newDir.destination, "reportsOutputFiles")
+
+            # define report
+            rep = ReportsEntryPoint()
+            rep.addOptions()
+            rep.parse_args(["-h5db", fileNameDB, "-o", outputFile])
+
+            # validate report options
+            self.assertEqual(rep.name, "report")
+            self.assertEqual(rep.settingsArgument, "optional")
+
+            # Run report, and make sure there are output files
             rep.invoke()
+            self.assertTrue(os.path.exists(os.path.join(outDir, "index.html")))
+            outFiles = glob(os.path.join(outDir, f"*{self._testMethodName}*"))
+            self.assertGreater(len(outFiles), 2)
 
 
 class TestCompareIsotxsLibsEntryPoint(unittest.TestCase):
diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py
index a83eedef..b84903d0 100644
--- a/armi/reactor/tests/test_reactors.py
+++ b/armi/reactor/tests/test_reactors.py
@@ -834,8 +834,6 @@ class HexReactorTests(ReactorTests):
         self.assertEqual(dominantFuel.getName(), "UZr")
         self.assertEqual(dominantCool.getName(), "Sodium")
 
-        self.assertEqual(list(dominantCool.getNuclides()), ["NA23"])
-
     def test_getSymmetryFactor(self):
         """
         Test getSymmetryFactor().
diff --git a/armi/utils/tests/test_mathematics.py b/armi/utils/tests/test_mathematics.py
index 8b580f90..b7554b07 100644
--- a/armi/utils/tests/test_mathematics.py
+++ b/armi/utils/tests/test_mathematics.py
@@ -113,10 +113,12 @@ class TestMath(unittest.TestCase):
         self.assertEqual(-2.4594981981654e-101, fixed)
 
     def test_getFloat(self):
-        self.assertEqual(getFloat(1.0), 1.0)
-        self.assertEqual(getFloat("1.0"), 1.0)
         self.assertIsNone(getFloat("word"))
 
+        for flt in [-9.123 + f * 0.734 for f in range(25)]:
+            self.assertEqual(getFloat(flt), flt)
+            self.assertEqual(getFloat(str(flt)), flt)
+
     def test_getStepsFromValues(self):
         steps = getStepsFromValues([1.0, 3.0, 6.0, 10.0], prevValue=0.0)
         self.assertListEqual(steps, [1.0, 2.0, 3.0, 4.0])