Repository: terrapower/armi
Pull request #1280 (2023-05-30 14:38:54)

Unifying the Reactor sorting
## Description

In #1018, it was noted that if you iterate through the parts of a Reactor using different helper methods, you get the reactor parts in different order. 

This is obviously not idea. But it was a side effect of an early attempt at sorting the Reactor pieces by over-riding the `__iter__()` method in the `Core` object:

https://github.com/terrapower/armi/blob/23bb1c3cd2b1322dafc4f025d7f9c5dbf11b3665/armi/reactor/reactors.py#L243-L251

I _like_ the idea of sorting the Reactor Composites (parts), but I don't like the inconsistency of iteration order that arrises.

My first idea was to force the Reactor to always be fully sorted. This seems ideal. But that leads to some REAL headaches every time someone tries to `Reactor.add()` or `Assembly.add()`.  Aside from slowing down the code, it means if you do a `Block.add()`, you need to reach up to the entire Reactor to re-sort it. And that kind of backwards code design leads to impenetrable nightmares of code. 

The solution given here means:

1. In 99% of cases, the Reactor will be fully sorted.
2. At any time, the user / developer can fully sort the Reactor (or any sub-part) with `x.sort()`.

---

## Checklist

- [X] This PR has only one purpose or idea.
- [X] Tests have been added/updated to verify that the new/changed code works.

<!-- Check the code quality -->

- [X] The code style follows [good practices](https://terrapower.github.io/armi/developer/standards_and_practices.html).
- [X] The commit message(s) follow [good practices](https://terrapower.github.io/armi/developer/tooling.html).

<!-- Check the project-level cruft -->

- [X] The [release notes](https://terrapower.github.io/armi/release/index.html) (location `doc/release/0.X.rst`) are up-to-date with any bug fixes or new features.
- [X] The documentation is still up-to-date in the `doc` folder.
- [X] The dependencies are still up-to-date in `setup.py`.


john-science: The unit tests pass on my laptop.  I'm working on it.
john-science: It turns out this PR didn't break anything: the `main` branch broke over the weekend.

(No commits were made over the weekend, so there was a change in one of our YAML dependencies. I'm on it.)
john-science: @opotowsky My only "concern" here is that it is _possible_ that any downstream repo could technically be explicitly testing some part of the reactor based on its position in iteration.  So, if someone wrote such a test, it might break (depending on which of the old iteration methods they use).

There's a lot of "ifs" there, but it's possible.
albeanth: >  it is possible that any downstream repo could technically be explicitly testing some part of the reactor based on its position in iteration.

Have you run the "big" downstream tests to double check? I don't _think_ this should be a problem for any of our internal testing, but you never know what you might uncover.... 
opotowsky: > @opotowsky My only "concern" here is that it is _possible_ that any downstream repo could technically be explicitly testing some part of the reactor based on its position in iteration. So, if someone wrote such a test, it might break (depending on which of the old iteration methods they use).
> 
> There's a lot of "ifs" there, but it's possible.

I will look into this!
onufer: our 1D slab cases do depend on order of components being consistent with blueprints. this as an undocumented feature and is not preserved after a DB load. The components like need an addition optional param for preserving order. For this reason i recommend making a setting `sortReactor` that defaults to true so that these cases can still function while we develop a mitigation plan.

the `sortReactor` setting should give a deprecation warning to indicate its just to allow for the transition.
albeanth: Converting to draft while we figure out downstream merge order dependencies for a downstream Application.
john-science: @keckler @albeanth The PR has doubled in size, but it is ready.  We just had to add a temporary setting so people could turn the setting off.

It is REAL easy to add totally unused settings into ARMI, or add settings that _become_ unused over time.  (Same with Parameters.)  And all this unused / untested code becomes a real weight to the project (in that they take a huge portion of my time to support).  So I will be removing this Setting by 2024-01-01 at the latest.
albeanth: Converting to draft while we figure out merge order dependencies for a downstream Application. Thanks for understanding @john-science. I think we should be able to get this in on Monday!

Patch:
diff --git a/armi/bookkeeping/db/database3.py b/armi/bookkeeping/db/database3.py
index 3b09418a..f3592eaf 100644
--- a/armi/bookkeeping/db/database3.py
+++ b/armi/bookkeeping/db/database3.py
@@ -67,22 +67,23 @@ from armi.bookkeeping.db.layout import (
     replaceNonesWithNonsense,
     replaceNonsenseWithNones,
 )
-from armi.reactor import parameters
-from armi.reactor.parameters import parameterCollections
-from armi.reactor.flags import Flags
-from armi.reactor.reactors import Core
+from armi.bookkeeping.db.typedefs import History, Histories
+from armi.nucDirectory import nuclideBases
+from armi.physics.neutronics.settings import CONF_LOADING_FILE
 from armi.reactor import assemblies
+from armi.reactor import grids
+from armi.reactor import parameters
+from armi.reactor import systemLayoutInput
 from armi.reactor.assemblies import Assembly
 from armi.reactor.blocks import Block
 from armi.reactor.components import Component
 from armi.reactor.composites import ArmiObject
-from armi.reactor import grids
-from armi.bookkeeping.db.typedefs import History, Histories
-from armi.reactor import systemLayoutInput
+from armi.reactor.flags import Flags
+from armi.reactor.parameters import parameterCollections
+from armi.reactor.reactors import Core
+from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
 from armi.utils import getNodesPerCycle
 from armi.utils.textProcessors import resolveMarkupInclusions
-from armi.nucDirectory import nuclideBases
-from armi.physics.neutronics.settings import CONF_LOADING_FILE
 
 # CONSTANTS
 _SERIALIZER_NAME = "serializerName"
@@ -712,8 +713,8 @@ class Database3:
 
         Returns
         -------
-        root : ArmiObject
-            The top-level object stored in the database; usually a Reactor.
+        root : Reactor
+            The top-level object stored in the database; a Reactor.
         """
         runLog.info("Loading reactor state for time node ({}, {})".format(cycle, node))
 
@@ -756,7 +757,15 @@ class Database3:
         if updateGlobalAssemNum:
             updateGlobalAssemblyNum(root)
 
-        # usually a reactor object
+        # return a Reactor object
+        if cs[CONF_SORT_REACTOR]:
+            root.sort()
+        else:
+            runLog.warning(
+                "DeprecationWarning: This Reactor is not being sorted on DB load. "
+                f"Due to the setting {CONF_SORT_REACTOR}, this Reactor is unsorted. "
+                "But this feature is temporary and will be removed by 2024."
+            )
         return root
 
     @staticmethod
diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py
index 993cfafa..e45a39f9 100644
--- a/armi/physics/fuelCycle/fuelHandlers.py
+++ b/armi/physics/fuelCycle/fuelHandlers.py
@@ -161,7 +161,6 @@ class FuelHandler:
             numMoved = 0
 
         self.o.r.core.p.numMoves = numMoved
-
         self.o.r.core.setBlockMassParams()
 
         runLog.important(
@@ -201,7 +200,8 @@ class FuelHandler:
         return defaultFactorList, factorSearchFlags
 
     def prepCore(self):
-        """Aux. function to run before XS generation (do moderation, etc. here)."""
+        """Aux function to run before XS generation (do moderation, etc)."""
+        pass
 
     def prepSearch(self, *args, **kwargs):
         """
@@ -916,7 +916,7 @@ class FuelHandler:
     @staticmethod
     def readMoves(fname):
         r"""
-        reads a shuffle output file and sets up the moves dictionary.
+        Reads a shuffle output file and sets up the moves dictionary.
 
         Parameters
         ----------
@@ -1014,7 +1014,7 @@ class FuelHandler:
     @staticmethod
     def trackChain(moveList, startingAt, alreadyDone=None):
         r"""
-        builds a chain of locations based on starting location.
+        Builds a chain of locations based on starting location.
 
         Notes
         -----
diff --git a/armi/physics/neutronics/parameters.py b/armi/physics/neutronics/parameters.py
index b4961c48..0bfc8b94 100644
--- a/armi/physics/neutronics/parameters.py
+++ b/armi/physics/neutronics/parameters.py
@@ -20,11 +20,12 @@ which will enable modular construction of apps.
 """
 import numpy
 
+from armi.physics.neutronics.settings import CONF_DPA_PER_FLUENCE
 from armi.reactor import parameters
-from armi.reactor.parameters import ParamLocation
 from armi.reactor.blocks import Block
+from armi.reactor.parameters import ParamLocation
+from armi.reactor.parameters.parameterDefinitions import isNumpyArray
 from armi.reactor.reactors import Core
-from armi.physics.neutronics.settings import CONF_DPA_PER_FLUENCE
 
 
 def getNeutronicsParameterDefinitions():
@@ -44,16 +45,9 @@ def _getNeutronicsBlockParams():
             categories=[parameters.Category.retainOnReplacement],
         )
 
-        def mgFlux(self, value):
-            self._p_mgFlux = (
-                value
-                if value is None or isinstance(value, numpy.ndarray)
-                else numpy.array(value)
-            )
-
         pb.defParam(
             "mgFlux",
-            setter=mgFlux,
+            setter=isNumpyArray("mgFlux"),
             units="n-cm/s",
             description="multigroup volume-integrated flux",
             location=ParamLocation.VOLUME_INTEGRATED,
@@ -270,15 +264,9 @@ def _getNeutronicsBlockParams():
             ],
         )
 
-        def linPowByPin(self, value):
-            if value is None or isinstance(value, numpy.ndarray):
-                self._p_linPowByPin = value
-            else:
-                self._p_linPowByPin = numpy.array(value)
-
         pb.defParam(
             "linPowByPin",
-            setter=linPowByPin,
+            setter=isNumpyArray("linPowByPin"),
             units="W/cm",
             description=(
                 "Pin linear linear heat rate, which is calculated through flux reconstruction and "
@@ -292,16 +280,10 @@ def _getNeutronicsBlockParams():
             default=None,
         )
 
-        def linPowByPinNeutron(self, value):
-            if value is None or isinstance(value, numpy.ndarray):
-                self._p_linPowByPinNeutron = value
-            else:
-                self._p_linPowByPinNeutron = numpy.array(value)
-
         # gamma category because linPowByPin is only split by neutron/gamma when gamma is activated
         pb.defParam(
             "linPowByPinNeutron",
-            setter=linPowByPinNeutron,
+            setter=isNumpyArray("linPowByPinNeutron"),
             units="W/cm",
             description="Pin linear neutron heat rate. This is the neutron heating component of `linPowByPin`",
             location=ParamLocation.CHILDREN,
@@ -309,15 +291,9 @@ def _getNeutronicsBlockParams():
             default=None,
         )
 
-        def linPowByPinGamma(self, value):
-            if value is None or isinstance(value, numpy.ndarray):
-                self._p_linPowByPinGamma = value
-            else:
-                self._p_linPowByPinGamma = numpy.array(value)
-
         pb.defParam(
             "linPowByPinGamma",
-            setter=linPowByPinGamma,
+            setter=isNumpyArray("linPowByPinGamma"),
             units="W/cm",
             description="Pin linear gamma heat rate. This is the gamma heating component of `linPowByPin`",
             location=ParamLocation.CHILDREN,
@@ -349,6 +325,7 @@ def _getNeutronicsBlockParams():
 
         pb.defParam(
             "pointsEdgeDpa",
+            setter=isNumpyArray("pointsEdgeDpa"),
             units="dpa",
             description="displacements per atom at edges of the block",
             categories=["cumulative", "detailedAxialExpansion", "depletion"],
@@ -356,6 +333,7 @@ def _getNeutronicsBlockParams():
 
         pb.defParam(
             "pointsEdgeDpaRate",
+            setter=isNumpyArray("pointsEdgeDpaRate"),
             units="dpa/s",
             description="Current time derivative of the displacement per atoms at edges of the block",
         )
@@ -383,6 +361,7 @@ def _getNeutronicsBlockParams():
 
         pb.defParam(
             "pointsCornerDpa",
+            setter=isNumpyArray("pointsCornerDpa"),
             units="dpa",
             description="displacements per atom at corners of the block",
             location=ParamLocation.CORNERS,
@@ -391,6 +370,7 @@ def _getNeutronicsBlockParams():
 
         pb.defParam(
             "pointsCornerDpaRate",
+            setter=isNumpyArray("pointsCornerDpaRate"),
             units="dpa/s",
             description="Current time derivative of the displacement per atoms at corners of the block",
         )
diff --git a/armi/reactor/assemblyParameters.py b/armi/reactor/assemblyParameters.py
index e54335ca..f4ac129c 100644
--- a/armi/reactor/assemblyParameters.py
+++ b/armi/reactor/assemblyParameters.py
@@ -17,9 +17,10 @@ import numpy
 
 from armi import runLog
 from armi.reactor import parameters
+from armi.reactor.flags import Flags
 from armi.reactor.parameters import ParamLocation
+from armi.reactor.parameters.parameterDefinitions import isNumpyArray
 from armi.utils import units
-from armi.reactor.flags import Flags  # non-standard import to avoid name conflict below
 
 
 def getAssemblyParameterDefinitions():
@@ -27,15 +28,9 @@ def getAssemblyParameterDefinitions():
 
     with pDefs.createBuilder() as pb:
 
-        def powerDecay(self, value):
-            if value is None or isinstance(value, numpy.ndarray):
-                self._p_powerDecay = value
-            else:
-                self._p_powerDecay = numpy.array(value)
-
         pb.defParam(
             "powerDecay",
-            setter=powerDecay,
+            setter=isNumpyArray("powerDecay"),
             units="W",
             description="List of decay heats at each time step specified in "
             "decayHeatCalcTimesInSeconds setting.",
@@ -133,16 +128,9 @@ def getAssemblyParameterDefinitions():
 
     with pDefs.createBuilder(location=ParamLocation.AVERAGE) as pb:
 
-        def detailedNDens(self, value):
-            """Ensures that data is stored in an numpy array to save memory/space."""
-            if value is None or isinstance(value, numpy.ndarray):
-                self._p_detailedNDens = value
-            else:
-                self._p_detailedNDens = numpy.array(value)
-
         pb.defParam(
             "detailedNDens",
-            setter=detailedNDens,
+            setter=isNumpyArray("detailedNDens"),
             units="atoms/bn-cm",
             description=(
                 "High-fidelity number density vector with up to thousands of nuclides. "
diff --git a/armi/reactor/blockParameters.py b/armi/reactor/blockParameters.py
index 7362e4f6..4196fa2a 100644
--- a/armi/reactor/blockParameters.py
+++ b/armi/reactor/blockParameters.py
@@ -18,11 +18,11 @@ import six
 
 from armi import runLog
 from armi.physics.neutronics import crossSectionGroupManager
-from armi.utils import units
-from armi.utils.units import ASCII_LETTER_A
-
 from armi.reactor import parameters
 from armi.reactor.parameters import ParamLocation, Parameter, NoDefault
+from armi.reactor.parameters.parameterDefinitions import isNumpyArray
+from armi.utils import units
+from armi.utils.units import ASCII_LETTER_A
 
 
 def getBlockParameterDefinitions():
@@ -49,16 +49,9 @@ def getBlockParameterDefinitions():
             location=ParamLocation.CHILDREN,
         )
 
-        def detailedNDens(self, value):
-            """Ensures that data is stored in an numpy array to save memory/space."""
-            if value is None or isinstance(value, numpy.ndarray):
-                self._p_detailedNDens = value
-            else:
-                self._p_detailedNDens = numpy.array(value)
-
         pb.defParam(
             "detailedNDens",
-            setter=detailedNDens,
+            setter=isNumpyArray("detailedNDens"),
             units="atoms/bn-cm",
             description=(
                 "High-fidelity number density vector with up to thousands of nuclides. "
diff --git a/armi/reactor/composites.py b/armi/reactor/composites.py
index 964eaf38..e21aeeaf 100644
--- a/armi/reactor/composites.py
+++ b/armi/reactor/composites.py
@@ -348,7 +348,6 @@ class ArmiObject(metaclass=CompositeModelType):
         sense to sort things across containers or scopes. If this ends up being too
         restrictive, it can probably be relaxed or overridden on specific classes.
         """
-
         if self.spatialLocator is None or other.spatialLocator is None:
             runLog.error("could not compare {} and {}".format(self, other))
             raise ValueError(
@@ -387,7 +386,6 @@ class ArmiObject(metaclass=CompositeModelType):
         Special treatment of ``parent`` is not enough, since the spatialGrid also
         contains a reference back to the armiObject. Consequently, the ``spatialGrid``
         needs to be reassigned in ``__setstate__``.
-
         """
         state = self.__dict__.copy()
         state["parent"] = None
@@ -2648,7 +2646,6 @@ class Composite(ArmiObject):
     mixed with siblings in a grid. This allows mixing grid-representation with
     explicit representation, often useful in advanced assemblies and thermal
     reactors.
-
     """
 
     def __init__(self, name):
@@ -2675,10 +2672,19 @@ class Composite(ArmiObject):
         This does not use quality checks for membership checking because equality
         operations can be fairly heavy. Rather, this only checks direct identity
         matches.
-
         """
         return id(item) in set(id(c) for c in self._children)
 
+    def sort(self):
+        """Sort the children of this object."""
+        # sort the top-level children of this Composite
+        self._children.sort()
+
+        # recursively sort the children below it.
+        for c in self._children:
+            if issubclass(Composite, c.__class__):
+                c.sort()
+
     def index(self, obj):
         """Obtain the list index of a particular child."""
         return self._children.index(obj)
diff --git a/armi/reactor/parameters/parameterDefinitions.py b/armi/reactor/parameters/parameterDefinitions.py
index 019254b5..80c65724 100644
--- a/armi/reactor/parameters/parameterDefinitions.py
+++ b/armi/reactor/parameters/parameterDefinitions.py
@@ -26,16 +26,15 @@ See Also
 --------
 armi.reactor.parameters
 """
+from typing import Any, Dict, Optional, Sequence, Tuple, Type
 import enum
-import re
 import functools
-from typing import Any, Dict, Optional, Sequence, Tuple, Type
+import re
 
 import numpy
 
 from armi.reactor.flags import Flags
-
-from .exceptions import ParameterError, ParameterDefinitionError
+from armi.reactor.parameters.exceptions import ParameterError, ParameterDefinitionError
 
 # bitwise masks for high-speed operations on the `assigned` attribute
 # (see http://www.vipan.com/htdocs/bitwisehelp.html)
@@ -184,6 +183,29 @@ class Serializer:
         raise NotImplementedError()
 
 
+def isNumpyArray(paramStr):
+    """Helper meta-function to create a method that sets a Parameter value to a NumPy array.
+
+    Parameters
+    ----------
+    paramStr : str
+        Name of the Parameter we want to set.
+
+    Returns
+    -------
+    function
+        A setter method on the Parameter class to force the value to be a NumPy array.
+    """
+
+    def setParameter(selfObj, value):
+        if value is None or isinstance(value, numpy.ndarray):
+            setattr(selfObj, "_p_" + paramStr, value)
+        else:
+            setattr(selfObj, "_p_" + paramStr, numpy.array(value))
+
+    return setParameter
+
+
 @functools.total_ordering
 class Parameter:
     r"""Metadata about a specific parameter."""
diff --git a/armi/reactor/reactorParameters.py b/armi/reactor/reactorParameters.py
index 2a5c0135..48b8c314 100644
--- a/armi/reactor/reactorParameters.py
+++ b/armi/reactor/reactorParameters.py
@@ -17,9 +17,10 @@ Reactor parameter definitions.
 """
 import numpy
 
-from armi.utils import units
 from armi.reactor import parameters
 from armi.reactor.parameters import ParamLocation
+from armi.reactor.parameters.parameterDefinitions import isNumpyArray
+from armi.utils import units
 
 
 def defineReactorParameters():
@@ -111,15 +112,9 @@ def defineCoreParameters():
 
     with pDefs.createBuilder() as pb:
 
-        def detailedNucKeys(self, value):
-            if value is None or isinstance(value, numpy.ndarray):
-                self._p_detailedNucKeys = value
-            else:
-                self._p_detailedNucKeys = numpy.array(value)
-
         pb.defParam(
             "detailedNucKeys",
-            setter=detailedNucKeys,
+            setter=isNumpyArray("detailedNucKeys"),
             units="ZZZAAA (ZZZ atomic number, AAA mass number, + 100 * m for metastable states",
             description="Nuclide vector keys, used to map densities in b.p.detailedNDens and a.p.detailedNDens",
             saveToDB=True,
diff --git a/armi/reactor/reactors.py b/armi/reactor/reactors.py
index 20addc62..7ea40cc4 100644
--- a/armi/reactor/reactors.py
+++ b/armi/reactor/reactors.py
@@ -47,11 +47,12 @@ from armi.reactor import geometry
 from armi.reactor import grids
 from armi.reactor import parameters
 from armi.reactor import reactorParameters
-from armi.reactor import systemLayoutInput
 from armi.reactor import zones
 from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
 from armi.reactor.flags import Flags
+from armi.reactor.systemLayoutInput import SystemLayoutInput
 from armi.settings.fwSettings.globalSettings import CONF_MATERIAL_NAMESPACE_ORDER
+from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
 from armi.utils import createFormattedStrWithDelimiter, units
 from armi.utils import directoryChangers
 from armi.utils.iterables import Sequence
@@ -110,9 +111,19 @@ class Reactor(composites.Composite):
             self.core = cores[0]
 
 
-def loadFromCs(cs):
+def loadFromCs(cs) -> Reactor:
     """
     Load a Reactor based on the input settings.
+
+    Parameters
+    ----------
+    cs: CaseSettings
+        A relevant settings object
+
+    Returns
+    -------
+    Reactor
+        Reactor loaded from settings file
     """
     from armi.reactor import blueprints
 
@@ -120,7 +131,7 @@ def loadFromCs(cs):
     return factory(cs, bp)
 
 
-def factory(cs, bp, geom: Optional[systemLayoutInput.SystemLayoutInput] = None):
+def factory(cs, bp, geom: Optional[SystemLayoutInput] = None) -> Reactor:
     """Build a reactor from input settings, blueprints and geometry."""
     from armi.reactor import blueprints
 
@@ -147,6 +158,17 @@ def factory(cs, bp, geom: Optional[systemLayoutInput.SystemLayoutInput] = None):
                 structure.construct(cs, bp, r)
 
     runLog.debug("Reactor: {}".format(r))
+
+    # return a Reactor object
+    if cs[CONF_SORT_REACTOR]:
+        r.sort()
+    else:
+        runLog.warning(
+            "DeprecationWarning: This Reactor is not being sorted on blueprint read. "
+            f"Due to the setting {CONF_SORT_REACTOR}, this Reactor is unsorted. "
+            "But this feature is temporary and will be removed by 2024."
+        )
+
     return r
 
 
diff --git a/armi/settings/fwSettings/globalSettings.py b/armi/settings/fwSettings/globalSettings.py
index 3726c19d..18ff2743 100644
--- a/armi/settings/fwSettings/globalSettings.py
+++ b/armi/settings/fwSettings/globalSettings.py
@@ -99,6 +99,7 @@ CONF_REALLY_SMALL_RUN = "reallySmallRun"
 CONF_RUN_TYPE = "runType"
 CONF_SKIP_CYCLES = "skipCycles"
 CONF_SMALL_RUN = "smallRun"
+CONF_SORT_REACTOR = "sortReactor"
 CONF_START_CYCLE = "startCycle"
 CONF_START_NODE = "startNode"
 CONF_STATIONARY_BLOCK_FLAGS = "stationaryBlockFlags"
@@ -689,6 +690,12 @@ def defineSettings() -> List[setting.Setting]:
             label="Clean Up Files at EOL",
             description="Clean up intermediate files after the run completes (EOL)",
         ),
+        setting.Setting(
+            CONF_SORT_REACTOR,
+            default=True,
+            label="Do we want to automatically sort the Reactor?",
+            description="Deprecation Warning! This setting will be remove by 2024.",
+        ),
         setting.Setting(
             CONF_REALLY_SMALL_RUN,
             default=False,
diff --git a/doc/release/0.2.rst b/doc/release/0.2.rst
index 814dfc5d..df37766f 100644
--- a/doc/release/0.2.rst
+++ b/doc/release/0.2.rst
@@ -9,12 +9,15 @@ Release Date: TBD
 What's new in ARMI
 ------------------
 #. Calculate weighted-average percent burnup of BlockCollections. (`PR#1265 <https://github.com/terrapower/armi/pull/1265>`_)
+#. Added ``Composite.sort()`` to allow the user to recursively sort any piece of the ``Reactor``. (`PR#1280 <https://github.com/terrapower/armi/pull/1280>`_)
+#. TBD
 
 Bug fixes
 ---------
 #. Changed units.FLOAT_DIMENSION_DECIMALS from 10 to 8 (`PR#1183 <https://github.com/terrapower/armi/pull/1183>`_)
 #. Fixed a bug in the ISOTXS file name used for snapshots. (`PR#1277 <https://github.com/terrapower/armi/pull/1277>`_)
 #. Fix a bug in uniform mesh decusping when assemblies of same type have drastically different height. (`PR#1282 <https://github.com/terrapower/armi/pull/1282>`_)
+#. TBD
 
 
 ARMI v0.2.7


Test patch:
diff --git a/armi/bookkeeping/db/tests/test_database3.py b/armi/bookkeeping/db/tests/test_database3.py
index 1b22ac4a..1b113367 100644
--- a/armi/bookkeeping/db/tests/test_database3.py
+++ b/armi/bookkeeping/db/tests/test_database3.py
@@ -25,6 +25,7 @@ from armi.bookkeeping.db import database3
 from armi.bookkeeping.db.databaseInterface import DatabaseInterface
 from armi.reactor import parameters
 from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings
+from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
 from armi.tests import TEST_ROOT
 from armi.utils import getPreviousTimeNode
 from armi.utils.directoryChangers import TemporaryDirectoryChanger
@@ -332,6 +333,7 @@ class TestDatabase3(unittest.TestCase):
         with self.assertRaises(KeyError):
             _r = self.db.load(0, 0)
 
+        # default load, should pass without error
         _r = self.db.load(0, 0, allowMissing=True)
 
         # show that we can use negative indices to load
@@ -350,6 +352,21 @@ class TestDatabase3(unittest.TestCase):
         with self.assertRaises(RuntimeError):
             self.db.fileName = "whatever.h5"
 
+    def test_loadSortSetting(self):
+        self.makeShuffleHistory()
+
+        # default load, should pass without error
+        r0 = self.db.load(0, 0, allowMissing=True)
+
+        # test that the reactor loads differently, dependent on the setting
+        cs = self.db.loadCS()
+        cs = cs.modified(newSettings={CONF_SORT_REACTOR: False})
+        r1 = self.db.load(0, 0, cs=cs, allowMissing=True)
+
+        # the reactor / core should be the same size
+        self.assertEqual(len(r0), len(r1))
+        self.assertEqual(len(r0.core), len(r1.core))
+
     def test_load_updateGlobalAssemNum(self):
         from armi.reactor import assemblies
         from armi.reactor.assemblies import resetAssemNumCounter
diff --git a/armi/reactor/tests/test_composites.py b/armi/reactor/tests/test_composites.py
index 3ec225cd..d69f6141 100644
--- a/armi/reactor/tests/test_composites.py
+++ b/armi/reactor/tests/test_composites.py
@@ -15,6 +15,7 @@
 """Tests for the composite pattern."""
 # pylint: disable=missing-function-docstring,missing-class-docstring,abstract-method,protected-access
 from copy import deepcopy
+from random import random
 import unittest
 
 from armi import nuclearDataIO
@@ -34,6 +35,7 @@ from armi.reactor.blueprints import assemblyBlueprint
 from armi.reactor.components import basicShapes
 from armi.reactor.composites import getReactionRateDict
 from armi.reactor.flags import Flags, TypeSpec
+from armi.reactor import grids
 from armi.reactor.tests.test_blocks import loadTestBlock
 from armi.tests import ISOAA_PATH
 
@@ -54,20 +56,25 @@ def getDummyParamDefs():
     return dummyDefs
 
 
+_testGrid = grids.CartesianGrid.fromRectangle(0.01, 0.01)
+
+
 class DummyComposite(composites.Composite):
     pDefs = getDummyParamDefs()
 
-    def __init__(self, name):
+    def __init__(self, name, i=0):
         composites.Composite.__init__(self, name)
         self.p.type = name
+        self.spatialLocator = grids.IndexLocation(i, i, i, _testGrid)
 
 
 class DummyLeaf(composites.Composite):
     pDefs = getDummyParamDefs()
 
-    def __init__(self, name):
+    def __init__(self, name, i=0):
         composites.Composite.__init__(self, name)
         self.p.type = name
+        self.spatialLocator = grids.IndexLocation(i, i, i, _testGrid)
 
     def getChildren(
         self, deep=False, generationNum=1, includeMaterials=False, predicate=None
@@ -91,15 +98,15 @@ class TestCompositePattern(unittest.TestCase):
     def setUp(self):
         self.cs = settings.Settings()
         runLog.setVerbosity("error")
-        container = DummyComposite("inner test fuel")
+        container = DummyComposite("inner test fuel", 99)
         for i in range(5):
-            leaf = DummyLeaf("duct {}".format(i))
+            leaf = DummyLeaf("duct {}".format(i), i + 100)
             leaf.setType("duct")
             container.add(leaf)
-        nested = DummyComposite("clad")
+        nested = DummyComposite("clad", 98)
         nested.setType("clad")
-        self.secondGen = DummyComposite("liner")
-        self.thirdGen = DummyLeaf("pin 77")
+        self.secondGen = DummyComposite("liner", 97)
+        self.thirdGen = DummyLeaf("pin 77", 33)
         self.secondGen.add(self.thirdGen)
         nested.add(self.secondGen)
         container.add(nested)
@@ -136,6 +143,30 @@ class TestCompositePattern(unittest.TestCase):
         )
         self.assertEqual(len(onlyLiner), 1)
 
+    def test_sort(self):
+        # in this case, the children should start sorted
+        c0 = [c.name for c in self.container.getChildren()]
+        self.container.sort()
+        c1 = [c.name for c in self.container.getChildren()]
+        self.assertNotEqual(c0, c1)
+
+        # verify repeated sortings behave
+        for _ in range(3):
+            self.container.sort()
+            ci = [c.name for c in self.container.getChildren()]
+            self.assertEqual(c1, ci)
+
+        # break the order
+        children = self.container.getChildren()
+        self.container._children = children[2:] + children[:2]
+        c2 = [c.name for c in self.container.getChildren()]
+        self.assertNotEqual(c1, c2)
+
+        # verify the sort order
+        self.container.sort()
+        c3 = [c.name for c in self.container.getChildren()]
+        self.assertEqual(c1, c3)
+
     def test_areChildernOfType(self):
         expectedResults = [False, False, False, False, False, True]
         for i, b in enumerate(self.container.doChildrenHaveFlags(Flags.CLAD)):
diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py
index d7ec7c1a..ec81fcde 100644
--- a/armi/reactor/tests/test_reactors.py
+++ b/armi/reactor/tests/test_reactors.py
@@ -11,7 +11,7 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-"""Resting for reactors.py."""
+"""Testing for reactors.py."""
 # pylint: disable=missing-function-docstring,missing-class-docstring,abstract-method,protected-access
 import copy
 import os
@@ -35,9 +35,10 @@ from armi.reactor.components import Hexagon, Rectangle
 from armi.reactor.converters import geometryConverters
 from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
 from armi.reactor.flags import Flags
+from armi.settings.fwSettings.globalSettings import CONF_ASSEM_FLAGS_SKIP_AXIAL_EXP
+from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
 from armi.tests import ARMI_RUN_PATH, mockRunLogs, TEST_ROOT
 from armi.utils import directoryChangers
-from armi.settings.fwSettings.globalSettings import CONF_ASSEM_FLAGS_SKIP_AXIAL_EXP
 
 TEST_REACTOR = None  # pickled string of test reactor (for fast caching)
 
@@ -48,8 +49,8 @@ def buildOperatorOfEmptyHexBlocks(customSettings=None):
 
     Doesn't depend on inputs and loads quickly.
 
-    Params
-    ------
+    Parameters
+    ----------
     customSettings : dict
         Dictionary of off-default settings to update
     """
@@ -79,6 +80,7 @@ def buildOperatorOfEmptyHexBlocks(customSettings=None):
     a.add(b)
     a.spatialLocator = r.core.spatialGrid[1, 0, 0]
     o.r.core.add(a)
+    o.r.sort()
     return o
 
 
@@ -88,10 +90,10 @@ def buildOperatorOfEmptyCartesianBlocks(customSettings=None):
 
     Doesn't depend on inputs and loads quickly.
 
-    Params
-    ------
+    Parameters
+    ----------
     customSettings : dict
-        Dictionary of off-default settings to update
+        Off-default settings to update
     """
     settings.setMasterCs(None)  # clear
     cs = settings.Settings()  # fetch new
@@ -127,6 +129,7 @@ def buildOperatorOfEmptyCartesianBlocks(customSettings=None):
     a.add(b)
     a.spatialLocator = r.core.spatialGrid[1, 0, 0]
     o.r.core.add(a)
+    o.r.sort()
     return o
 
 
@@ -177,6 +180,7 @@ def loadTestReactor(
         assemblies.setAssemNumCounter(assemNum)
         settings.setMasterCs(o.cs)
         o.reattach(r, o.cs)
+        r.sort()
         return o, r
 
     cs = settings.Settings(fName=fName)
@@ -209,6 +213,7 @@ def loadTestReactor(
         # protocol=2 allows for classes with __slots__ but not __getstate__ to be pickled
         TEST_REACTOR = cPickle.dumps((o, o.r, assemblies.getAssemNum()), protocol=2)
 
+    o.r.sort()
     return o, o.r
 
 
@@ -249,6 +254,25 @@ class HexReactorTests(ReactorTests):
             self.directoryChanger.destination, customSettings={"trackAssems": True}
         )
 
+    def test_factorySortSetting(self):
+        # get a sorted Reactor (the default)
+        cs = settings.Settings(fName="armiRun.yaml")
+        r0 = reactors.loadFromCs(cs)
+
+        # get an unsorted Reactor (for whatever reason)
+        customSettings = {CONF_SORT_REACTOR: False}
+        cs = cs.modified(newSettings=customSettings)
+        r1 = reactors.loadFromCs(cs)
+
+        # the reactor / core should be the same size
+        self.assertEqual(len(r0), len(r1))
+        self.assertEqual(len(r0.core), len(r1.core))
+
+        # the reactor / core should be in a different order
+        a0 = [a.name for a in r0.core]
+        a1 = [a.name for a in r1.core]
+        self.assertNotEqual(a0, a1)
+
     def test_getTotalParam(self):
         # verify that the block params are being read.
         val = self.r.core.getTotalBlockParam("power")