Repository: terrapower/armi
Pull request #1846 (2024-08-30 20:54:00)

Rotation cleanup: Remove Assembly.rotatePins, make Block.rotatePins private
## What is the change?

> 🚨 This change does *not* handle the rotation of all parameters that are tracked on pins and need to be rotated. That will come next after this cleanup because that will be a lot harder to manage and test and handle correctly 🚨 

As described in #1533, assembly rotation needed some love.

`Block.rotatePins` didn't do as much as people maybe would like (e.g., rotate parameters) so it is now private. `Block.rotate` should be preferred and will do (hopefully) everything people expect it to do

`Assembly.rotatePins` suffered from the same problem, and now is removed. `Assembly.rotate` should be preferred.

The `justCompute` flag in `Block._rotatePins` caught my eye and I found it was used when determining the new location of a pin with max burnup post-rotation. This means that in order to figure out where one pin would go, we would compute new locations for *every* pin in the block. And then maybe not use that as we rotate the assembly in all the possible orientations. This felt weird so I the "pin location post-rotate" functionality from that method and made it a new utility in `armi.utils.hexagon`. Now, when we are determining the optimal location of a pin during rotation, we just look at the proposed location of that pin.

`Block.rotate` did a lot (and it should!) but the method was very long. So I broke it up into a few private methods that are a bit more contained.

## Why is the change being made?

A large body of work is in progress to handle the parameter rotation. That work will be made easier after this cleanup.

## Other things to (maybe) do

- Remove `justCompute` switch in the now private `Block._rotatePins`? I can't find any usage of it now.
- Move the `Block.setOrientation` calls from `Block._rotatePins` to `Block.rotate`

---

## Checklist

<!--
    You (the pull requester) should put an `x` in the boxes below you have completed.

    (If a checkbox doesn't apply to your PR, check it anyway.)
-->

- [x] This PR has only [one purpose or idea](https://terrapower.github.io/armi/developer/tooling.html#one-idea-one-pr).
- [x] [Tests](https://terrapower.github.io/armi/developer/tooling.html#test-it) have been added/updated to verify any new/changed code.

<!-- 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/developer/tooling.html#add-release-notes) have been updated if necessary.
- [x] The [documentation](https://terrapower.github.io/armi/developer/tooling.html#document-it) is still up-to-date in the `doc` folder.
- [x] The dependencies are still up-to-date in `pyproject.toml`.

CLAassistant: [![CLA assistant check](https://cla-assistant.io/pull/badge/not_signed)](https://cla-assistant.io/terrapower/armi?pullRequest=1846) <br/>Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our [Contributor License Agreement](https://cla-assistant.io/terrapower/armi?pullRequest=1846) before we can accept your contribution.<br/><sub>You have signed the CLA already but the status is still pending? Let us [recheck](https://cla-assistant.io/check/terrapower/armi?pullRequest=1846) it.</sub>
jakehader: Maybe this exists already, but it would be good to know if something has already been rotated relative to its as-designed (e.g., by construction) and by how much. Maybe this is a good use of flags or some type of flag on the composite?
drewj-tp: > Maybe this exists already, but it would be good to know if something has already been rotated relative to its as-designed layout (e.g., by construction) and by how much. Maybe this is a good use of flags or some type of flag on the composite?

We have `Block.p.orientation` and `Assembly.p.orientation` so assuming a value of zero indicates the "as designed" orientation, that may suffice. If I'm missing something, can you make a ticket so I can track that as part of the upcoming assembly rotation work?
drewj-tp: @john-science I'm not sure where to add this to the release notes. We have a `0.4.1` section that's TBD, but this is a breaking change and feels like it deserves a larger release number bump. That said, there are other removals in the current `0.4.1` section
drewj-tp: @john-science or @albeanth this is ready to go and has some interesting connections to something @mgjarrett put up - #1859 
mgjarrett: I have one of the only internal downstream uses of the public `rotatePins` method with `justCompute=True` (at least I know of). The `justCompute=True` is really just about doing hex lattice logic / math and doesn't need to be bound to a specific instance of a block. I would be fine with removing the `justCompute` argument entirely. I can refactor the downstream application to use the new `armi.utils.hexagon.getIndexOfRotatedCell`

Patch:
diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml
index 5eea821a..7207a57b 100644
--- a/.github/workflows/coverage.yaml
+++ b/.github/workflows/coverage.yaml
@@ -38,6 +38,7 @@ jobs:
           coverage run --rcfile=pyproject.toml -m pytest -n 4 --cov=armi --cov-config=pyproject.toml --cov-report=lcov --ignore=venv armi
           mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiFeatures.py || true
           mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiParameters.py || true
+          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiDirectoryChangers.py || true
           coverage combine --rcfile=pyproject.toml --keep -a
       - name: Publish to coveralls.io
         uses: coverallsapp/github-action@v2
diff --git a/armi/bookkeeping/db/database3.py b/armi/bookkeeping/db/database3.py
index 4e3b021d..320fb6cb 100644
--- a/armi/bookkeeping/db/database3.py
+++ b/armi/bookkeeping/db/database3.py
@@ -33,6 +33,7 @@ Refer to :py:mod:`armi.bookkeeping.db` for information about versioning.
 """
 import collections
 import copy
+import gc
 import io
 import itertools
 import os
@@ -683,8 +684,18 @@ class Database3:
         """
         runLog.extra("Copying DB to shared working directory.")
         self.h5db.flush()
+
+        # Close the h5 file so it can be copied
+        self.h5db.close()
+        self.h5db = None
         shutil.copy(self._fullPath, self._fileName)
 
+        # Garbage collect so we don't have multiple databases hanging around in memory
+        gc.collect()
+
+        # Reload the file in append mode and continue on our merry way
+        self.h5db = h5py.File(self._fullPath, "r+")
+
     def load(
         self,
         cycle,
diff --git a/armi/bookkeeping/visualization/xdmf.py b/armi/bookkeeping/visualization/xdmf.py
index eb4e6b46..a7d2f36c 100644
--- a/armi/bookkeeping/visualization/xdmf.py
+++ b/armi/bookkeeping/visualization/xdmf.py
@@ -33,8 +33,7 @@ visualization tool that supports XDMF.
     wedges. To do that would require splitting the parameter data, which would defeat
     the main benefit of using XMDF in the first place (to be able to plot out of the
     original Database file). Cartesian and R-X-Theta geometries in VisIt seem to work
-    fine. Support for polyhedra is being tracked in `#1287
-    <https://github.com/visit-dav/visit/issues/1287>`_.
+    fine.
 """
 
 import io
diff --git a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py
index a56d3082..30c87ba2 100644
--- a/armi/physics/fuelCycle/assemblyRotationAlgorithms.py
+++ b/armi/physics/fuelCycle/assemblyRotationAlgorithms.py
@@ -19,8 +19,10 @@ Notes
 These algorithms are defined in assemblyRotationAlgorithms.py, but they are used in:
 ``FuelHandler.outage()``.
 
-.. warning:: Nothing should do in this file, but rotation algorithms.
+.. warning:: Nothing should go in this file, but rotation algorithms.
 """
+import math
+
 from armi import runLog
 from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import (
     getOptimalAssemblyOrientation,
@@ -28,8 +30,13 @@ from armi.physics.fuelCycle.hexAssemblyFuelMgmtUtils import (
 from armi.physics.fuelCycle.settings import CONF_ASSEM_ROTATION_STATIONARY
 
 
+def _rotationNumberToRadians(rot: int) -> float:
+    """Convert a rotation number to radians, assuming a HexAssembly."""
+    return rot * math.pi / 3
+
+
 def buReducingAssemblyRotation(fh):
-    r"""
+    """
     Rotates all detail assemblies to put the highest bu pin in the lowest power orientation.
 
     Parameters
@@ -48,31 +55,38 @@ def buReducingAssemblyRotation(fh):
         aNow = fh.r.core.getAssemblyWithStringLocation(aPrev.lastLocationLabel)
         # no point in rotation if there's no pin detail
         if aNow in hist.getDetailAssemblies():
-            rot = getOptimalAssemblyOrientation(aNow, aPrev)
-            aNow.rotatePins(rot)  # rot = integer between 0 and 5
+            _rotateByComparingLocations(aNow, aPrev)
             numRotated += 1
-            # Print out rotation operation (mainly for testing)
-            # hex indices (i,j) = (ring,pos)
-            (i, j) = aNow.spatialLocator.getRingPos()
-            runLog.important(
-                "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, rot)
-            )
 
-    # rotate NON-MOVING assemblies (stationary)
     if fh.cs[CONF_ASSEM_ROTATION_STATIONARY]:
         for a in hist.getDetailAssemblies():
             if a not in fh.moved:
-                rot = getOptimalAssemblyOrientation(a, a)
-                a.rotatePins(rot)  # rot = integer between 0 and 6
+                _rotateByComparingLocations(a, a)
                 numRotated += 1
-                (i, j) = a.spatialLocator.getRingPos()
-                runLog.important(
-                    "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, rot)
-                )
 
     runLog.info("Rotated {0} assemblies".format(numRotated))
 
 
+def _rotateByComparingLocations(aNow, aPrev):
+    """Rotate an assembly based on its previous location.
+
+    Parameters
+    ----------
+    aNow : Assembly
+        Assembly to be rotated
+    aPrev : Assembly
+        Assembly that previously occupied the location of this assembly.
+        If ``aNow`` has not been moved, this should be ``aNow``
+    """
+    rot = getOptimalAssemblyOrientation(aNow, aPrev)
+    radians = _rotationNumberToRadians(rot)
+    aNow.rotate(radians)
+    (ring, pos) = aNow.spatialLocator.getRingPos()
+    runLog.important(
+        "Rotating Assembly ({0},{1}) to Orientation {2}".format(ring, pos, rot)
+    )
+
+
 def simpleAssemblyRotation(fh):
     """
     Rotate all pin-detail assemblies that were just shuffled by 60 degrees.
@@ -98,13 +112,14 @@ def simpleAssemblyRotation(fh):
     runLog.info("Rotating assemblies by 60 degrees")
     numRotated = 0
     hist = fh.o.getInterface("history")
+    rot = math.radians(60)
     for a in hist.getDetailAssemblies():
         if a in fh.moved or fh.cs[CONF_ASSEM_ROTATION_STATIONARY]:
-            a.rotatePins(1)
+            a.rotate(rot)
             numRotated += 1
-            i, j = a.spatialLocator.getRingPos()  # hex indices (i,j) = (ring,pos)
+            ring, pos = a.spatialLocator.getRingPos()
             runLog.extra(
-                "Rotating Assembly ({0},{1}) to Orientation {2}".format(i, j, 1)
+                "Rotating Assembly ({0},{1}) to Orientation {2}".format(ring, pos, 1)
             )
 
     runLog.extra("Rotated {0} assemblies".format(numRotated))
diff --git a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py
index e475fa01..e1c73608 100644
--- a/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py
+++ b/armi/physics/fuelCycle/hexAssemblyFuelMgmtUtils.py
@@ -24,6 +24,7 @@ import math
 import numpy as np
 
 from armi import runLog
+from armi.utils.hexagon import getIndexOfRotatedCell
 from armi.reactor.flags import Flags
 from armi.utils.mathematics import findClosest
 
@@ -101,10 +102,7 @@ def getOptimalAssemblyOrientation(a, aPrev):
             prevAssemPowHereMIN = float("inf")
 
             for possibleRotation in range(6):
-                # get rotated pin index
-                indexLookup = maxBuBlock.rotatePins(possibleRotation, justCompute=True)
-                # rotated index of highest-BU pin
-                index = int(indexLookup[maxBuPinIndexAssem])
+                index = getIndexOfRotatedCell(maxBuPinIndexAssem, possibleRotation)
                 # get pin power at this index in the previously assembly located here
                 # power previously at rotated index
                 prevAssemPowHere = aPrev[bIndexMaxBu].p.linPowByPin[index - 1]
diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py
index 14cdbc25..84bc09e9 100644
--- a/armi/reactor/assemblies.py
+++ b/armi/reactor/assemblies.py
@@ -315,11 +315,6 @@ class Assembly(composites.Composite):
 
         return sum(plenumTemps) / len(plenumTemps)
 
-    def rotatePins(self, *args, **kwargs):
-        """Rotate an assembly, which means rotating the indexing of pins."""
-        for b in self:
-            b.rotatePins(*args, **kwargs)
-
     def doubleResolution(self):
         """
         Turns each block into two half-size blocks.
@@ -1248,8 +1243,7 @@ class Assembly(composites.Composite):
 
             This method loops through every ``Block`` in this ``Assembly`` and rotates
             it by a given angle (in radians). The rotation angle is positive in the
-            counter-clockwise direction, and must be divisible by increments of PI/6
-            (60 degrees). To actually perform the ``Block`` rotation, the
+            counter-clockwise direction. To perform the ``Block`` rotation, the
             :py:meth:`armi.reactor.blocks.Block.rotate` method is called.
 
         Parameters
@@ -1257,11 +1251,8 @@ class Assembly(composites.Composite):
         rad: float
             number (in radians) specifying the angle of counter clockwise rotation
 
-        Warning
-        -------
-        rad must be in 60-degree increments! (i.e., PI/6, PI/3, PI, 2 * PI/3, etc)
         """
-        for b in self.getBlocks():
+        for b in self:
             b.rotate(rad)
 
     def isOnWhichSymmetryLine(self):
@@ -1272,7 +1263,26 @@ class Assembly(composites.Composite):
 class HexAssembly(Assembly):
     """Placeholder, so users can explicitly define a hex-based Assembly."""
 
-    pass
+    def rotate(self, rad: float):
+        """Rotate an assembly and its children.
+
+        Parameters
+        ----------
+        rad : float
+            Counter clockwise rotation in radians. **MUST** be in increments of
+            60 degrees (PI / 3)
+
+        Raises
+        ------
+        ValueError
+            If rotation is not divisible by pi / 3.
+        """
+        if math.isclose(rad % (math.pi / 3), 0, abs_tol=1e-12):
+            return super().rotate(rad)
+        raise ValueError(
+            f"Rotation must be in 60 degree increments, got {math.degrees(rad)} "
+            f"degrees ({rad} radians)"
+        )
 
 
 class CartesianAssembly(Assembly):
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index 0ff4458f..bef0ade5 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -45,6 +45,7 @@ from armi.reactor.flags import Flags
 from armi.reactor.parameters import ParamLocation
 from armi.utils import densityTools
 from armi.utils import hexagon
+from armi.utils import iterables
 from armi.utils import units
 from armi.utils.plotting import plotBlockFlux
 from armi.utils.units import TRACE_NUMBER_DENSITY
@@ -1565,10 +1566,6 @@ class Block(composites.Composite):
         ---------
         targetComponent: :py:class:`Component <armi.reactor.components.component.Component>` object
             Component specified to be target component for axial expansion changer
-
-        See Also
-        --------
-        armi.reactor.converters.axialExpansionChanger.py::ExpansionData::_setTargetComponents
         """
         self.p.axialExpTargetComponent = targetComponent.name
 
@@ -2016,8 +2013,7 @@ class HexBlock(Block):
         Python list of length 6 in order to be eligible for rotation; all parameters that
         do not meet these two criteria are not rotated.
 
-        The pin indexing, as stored on the pinLocation parameter, is also updated via
-        :py:meth:`rotatePins <armi.reactor.blocks.HexBlock.rotatePins>`.
+        The pin indexing, as stored on the ``pinLocation`` parameter, is also updated.
 
         Parameters
         ----------
@@ -2026,54 +2022,53 @@ class HexBlock(Block):
             in 60-degree increments (i.e., PI/6, PI/3, PI, 2 * PI/3, 5 * PI/6,
             and 2 * PI)
 
-        See Also
-        --------
-        :py:meth:`rotatePins <armi.reactor.blocks.HexBlock.rotatePins>`
         """
         rotNum = round((rad % (2 * math.pi)) / math.radians(60))
-        self.rotatePins(rotNum)
-        params = self.p.paramDefs.atLocation(ParamLocation.CORNERS).names
-        params += self.p.paramDefs.atLocation(ParamLocation.EDGES).names
-        for param in params:
-            if isinstance(self.p[param], list):
-                if len(self.p[param]) == 6:
-                    self.p[param] = self.p[param][-rotNum:] + self.p[param][:-rotNum]
-                elif self.p[param] == []:
-                    # List hasn't been defined yet, no warning needed.
-                    pass
-                else:
-                    msg = (
-                        "No rotation method defined for spatial parameters that aren't "
-                        "defined once per hex edge/corner. No rotation performed "
-                        f"on {param}"
-                    )
-                    runLog.warning(msg)
-            elif isinstance(self.p[param], np.ndarray):
-                if len(self.p[param]) == 6:
-                    self.p[param] = np.concatenate(
-                        (self.p[param][-rotNum:], self.p[param][:-rotNum])
-                    )
-                elif len(self.p[param]) == 0:
+        self._rotatePins(rotNum)
+        self._rotateBoundaryParameters(rotNum)
+        self._rotateDisplacement(rad)
+
+    def _rotateBoundaryParameters(self, rotNum: int):
+        """Rotate any parameters defined on the corners or edge of bounding hexagon.
+
+        Parameters
+        ----------
+        rotNum : int
+            Rotation number between zero and five, inclusive, specifying how many
+            rotations have taken place.
+
+        """
+        names = self.p.paramDefs.atLocation(ParamLocation.CORNERS).names
+        names += self.p.paramDefs.atLocation(ParamLocation.EDGES).names
+        for name in names:
+            original = self.p[name]
+            if isinstance(original, (list, np.ndarray)):
+                if len(original) == 6:
+                    # Rotate by making the -rotNum item be first
+                    self.p[name] = iterables.pivot(original, -rotNum)
+                elif len(original) == 0:
                     # Hasn't been defined yet, no warning needed.
                     pass
                 else:
                     msg = (
                         "No rotation method defined for spatial parameters that aren't "
                         "defined once per hex edge/corner. No rotation performed "
-                        f"on {param}"
+                        f"on {name}"
                     )
                     runLog.warning(msg)
-            elif isinstance(self.p[param], (int, float)):
+            elif isinstance(original, (int, float)):
                 # this is a scalar and there shouldn't be any rotation.
                 pass
-            elif self.p[param] is None:
+            elif original is None:
                 # param is not set yet. no rotations as well.
                 pass
             else:
                 raise TypeError(
-                    f"b.rotate() method received unexpected data type for {param} on block {self}\n"
-                    + f"expected list, np.ndarray, int, or float. received {self.p[param]}"
+                    f"b.rotate() method received unexpected data type for {name} on block {self}\n"
+                    + f"expected list, np.ndarray, int, or float. received {original}"
                 )
+
+    def _rotateDisplacement(self, rad: float):
         # This specifically uses the .get() functionality to avoid an error if this
         # parameter does not exist.
         dispx = self.p.get("displacementX")
@@ -2082,7 +2077,7 @@ class HexBlock(Block):
             self.p.displacementX = dispx * math.cos(rad) - dispy * math.sin(rad)
             self.p.displacementY = dispx * math.sin(rad) + dispy * math.cos(rad)
 
-    def rotatePins(self, rotNum, justCompute=False):
+    def _rotatePins(self, rotNum, justCompute=False):
         """
         Rotate the pins of a block, which means rotating the indexing of pins. Note that this does
         not rotate all block quantities, just the pins.
@@ -2157,17 +2152,7 @@ class HexBlock(Block):
                 # Rotation to reference orientation. Pin locations are pin IDs.
                 pass
             else:
-                # Determine the pin ring. Rotation does not change the pin ring!
-                ring = int(
-                    math.ceil((3.0 + math.sqrt(9.0 - 12.0 * (1.0 - pinNum))) / 6.0)
-                )
-
-                # Rotate the pin position (within the ring, which does not change)
-                tot_pins = 1 + 3 * ring * (ring - 1)
-                newPinLocation = pinNum + (ring - 1) * rotNum
-                if newPinLocation > tot_pins:
-                    newPinLocation -= (ring - 1) * 6
-
+                newPinLocation = hexagon.getIndexOfRotatedCell(pinNum, rotNum)
                 # Assign "before" and "after" pin indices to the index lookup
                 rotateIndexLookup[pinNum] = newPinLocation
 
diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py
index c9746bd3..a1da6275 100644
--- a/armi/reactor/blueprints/__init__.py
+++ b/armi/reactor/blueprints/__init__.py
@@ -326,7 +326,7 @@ class Blueprints(yamlize.Object, metaclass=_BlueprintsPluginCollector):
                     for a in list(self.assemblies.values())
                     if not any(a.hasFlags(f) for f in assemsToSkip)
                 )
-                axialExpansionChanger.expandColdDimsToHot(
+                axialExpansionChanger.axialExpansionChanger.expandColdDimsToHot(
                     assemsToExpand,
                     cs[CONF_DETAILED_AXIAL_EXPANSION],
                 )
diff --git a/armi/reactor/converters/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger.py
deleted file mode 100644
index 69fb6739..00000000
--- a/armi/reactor/converters/axialExpansionChanger.py
+++ /dev/null
@@ -1,908 +0,0 @@
-# Copyright 2019 TerraPower, LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-#     http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# 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.
-"""Enable component-wise axial expansion for assemblies and/or a reactor."""
-
-from statistics import mean
-from typing import List
-
-from armi import runLog
-from armi.materials import material
-from armi.reactor.components import UnshapedComponent
-from armi.reactor.flags import Flags
-from numpy import array
-
-TARGET_FLAGS_IN_PREFERRED_ORDER = [
-    Flags.FUEL,
-    Flags.CONTROL,
-    Flags.POISON,
-    Flags.SHIELD,
-    Flags.SLUG,
-]
-
-
-def getDefaultReferenceAssem(assems):
-    """Return a default reference assembly."""
-    # if assemblies are defined in blueprints, handle meshing
-    # assume finest mesh is reference
-    assemsByNumBlocks = sorted(
-        assems,
-        key=lambda a: len(a),
-        reverse=True,
-    )
-    return assemsByNumBlocks[0] if assemsByNumBlocks else None
-
-
-def makeAssemsAbleToSnapToUniformMesh(
-    assems, nonUniformAssemFlags, referenceAssembly=None
-):
-    """Make this set of assemblies aware of the reference mesh so they can stay uniform as they axially expand."""
-    if not referenceAssembly:
-        referenceAssembly = getDefaultReferenceAssem(assems)
-    # make the snap lists so assems know how to expand
-    nonUniformAssems = [Flags.fromStringIgnoreErrors(t) for t in nonUniformAssemFlags]
-    for a in assems:
-        if any(a.hasFlags(f) for f in nonUniformAssems):
-            continue
-        a.makeAxialSnapList(referenceAssembly)
-
-
-def expandColdDimsToHot(
-    assems: list,
-    isDetailedAxialExpansion: bool,
-    referenceAssembly=None,
-):
-    """
-    Expand BOL assemblies, resolve disjoint axial mesh (if needed), and update block BOL heights.
-
-    .. impl:: Perform expansion during core construction based on block heights at a specified temperature.
-        :id: I_ARMI_INP_COLD_HEIGHT
-        :implements: R_ARMI_INP_COLD_HEIGHT
-
-        This method is designed to be used during core construction to axially thermally expand the
-        assemblies to their "hot" temperatures (as determined by ``Thot`` values in blueprints).
-        First, The Assembly is prepared for axial expansion via ``setAssembly``. In
-        ``applyColdHeightMassIncrease``, the number densities on each Component is adjusted to
-        reflect that Assembly inputs are at cold (i.e., ``Tinput``) temperatures. To expand to
-        the requested hot temperatures, thermal expansion factors are then computed in
-        ``computeThermalExpansionFactors``. Finally, the Assembly is axially thermally expanded in
-        ``axiallyExpandAssembly``.
-
-        If the setting ``detailedAxialExpansion`` is ``False``, then each Assembly gets its Block mesh
-        set to match that of the "reference" Assembly (see ``getDefaultReferenceAssem`` and ``setBlockMesh``).
-
-        Once the Assemblies are axially expanded, the Block BOL heights are updated. To account for the change in
-        Block volume from axial expansion, ``completeInitialLoading`` is called to update any volume-dependent
-        Block information.
-
-    Parameters
-    ----------
-    assems: list[:py:class:`Assembly <armi.reactor.assemblies.Assembly>`]
-        list of assemblies to be thermally expanded
-    isDetailedAxialExpansion: bool
-        If False, assemblies will be forced to conform to the reference mesh after expansion
-    referenceAssembly: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`, optional
-        Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False.
-        If not provided, will assume the finest mesh assembly which is typically fuel.
-
-    Notes
-    -----
-    Calling this method will result in an increase in mass via applyColdHeightMassIncrease!
-
-    See Also
-    --------
-    :py:meth:`armi.reactor.converters.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease`
-    """
-    assems = list(assems)
-    if not referenceAssembly:
-        referenceAssembly = getDefaultReferenceAssem(assems)
-    axialExpChanger = AxialExpansionChanger(isDetailedAxialExpansion)
-    for a in assems:
-        axialExpChanger.setAssembly(a, expandFromTinputToThot=True)
-        axialExpChanger.applyColdHeightMassIncrease()
-        axialExpChanger.expansionData.computeThermalExpansionFactors()
-        axialExpChanger.axiallyExpandAssembly()
-    if not isDetailedAxialExpansion:
-        for a in assems:
-            a.setBlockMesh(referenceAssembly.getAxialMesh())
-    # update block BOL heights to reflect hot heights
-    for a in assems:
-        for b in a:
-            b.p.heightBOL = b.getHeight()
-            b.completeInitialLoading()
-
-
-class AxialExpansionChanger:
-    """
-    Axially expand or contract assemblies or an entire core.
-
-    Attributes
-    ----------
-    linked : :py:class:`AssemblyAxialLinkage`
-        establishes object containing axial linkage information
-    expansionData : :py:class:`ExpansionData <armi.reactor.converters.axialExpansionChanger.ExpansionData>`
-        establishes object to store and access relevant expansion data
-
-    Notes
-    -----
-    - Is designed to work with general, vertically oriented, pin-type assembly designs. It is not set up to account
-      for any other assembly type.
-    - Useful for fuel performance, thermal expansion, reactivity coefficients, etc.
-    """
-
-    def __init__(self, detailedAxialExpansion: bool = False):
-        """
-        Build an axial expansion converter.
-
-        Parameters
-        ----------
-        detailedAxialExpansion : bool, optional
-            A boolean to indicate whether or not detailedAxialExpansion is to be utilized.
-        """
-        self._detailedAxialExpansion = detailedAxialExpansion
-        self.linked = None
-        self.expansionData = None
-
-    def performPrescribedAxialExpansion(
-        self, a, componentLst: list, percents: list, setFuel=True
-    ):
-        """Perform axial expansion/contraction of an assembly given prescribed expansion percentages.
-
-        .. impl:: Perform expansion/contraction, given a list of components and expansion coefficients.
-            :id: I_ARMI_AXIAL_EXP_PRESC
-            :implements: R_ARMI_AXIAL_EXP_PRESC
-
-            This method performs component-wise axial expansion for an Assembly given expansion coefficients
-            and a corresponding list of Components. In ``setAssembly``, the Assembly is prepared
-            for axial expansion by determining Component-wise axial linkage and checking to see if a dummy Block
-            is in place (necessary for ensuring conservation properties). The provided expansion factors are
-            then assigned to their corresponding Components in ``setExpansionFactors``. Finally, the axial
-            expansion is performed in ``axiallyExpandAssembly``
-
-        Parameters
-        ----------
-        a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
-            ARMI assembly to be changed
-        componentLst : list[:py:class:`Component <armi.reactor.components.component.Component>`]
-            list of Components to be expanded
-        percents : list[float]
-            list of expansion percentages for each component listed in componentList
-        setFuel : boolean, optional
-            Boolean to determine whether or not fuel blocks should have their target components set
-            This is useful when target components within a fuel block need to be determined on-the-fly.
-
-        Notes
-        -----
-        - percents may be positive (expansion) or negative (contraction)
-        """
-        self.setAssembly(a, setFuel)
-        self.expansionData.setExpansionFactors(componentLst, percents)
-        self.axiallyExpandAssembly()
-
-    def performThermalAxialExpansion(
-        self,
-        a,
-        tempGrid: list,
-        tempField: list,
-        setFuel: bool = True,
-        expandFromTinputToThot: bool = False,
-    ):
-        """Perform thermal expansion/contraction for an assembly given an axial temperature grid and
-        field.
-
-        .. impl:: Perform thermal expansion/contraction, given an axial temperature distribution
-            over an assembly.
-            :id: I_ARMI_AXIAL_EXP_THERM
-            :implements: R_ARMI_AXIAL_EXP_THERM
-
-            This method performs component-wise thermal expansion for an assembly given a discrete
-            temperature distribution over the axial length of the Assembly. In ``setAssembly``, the
-            Assembly is prepared for axial expansion by determining Component-wise axial linkage and
-            checking to see if a dummy Block is in place (necessary for ensuring conservation
-            properties). The discrete temperature distribution is then leveraged to update Component
-            temperatures and compute thermal expansion factors (via
-            ``updateComponentTempsBy1DTempField`` and ``computeThermalExpansionFactors``,
-            respectively). Finally, the axial expansion is performed in ``axiallyExpandAssembly``.
-
-        Parameters
-        ----------
-        a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
-            ARMI assembly to be changed
-        tempGrid : float, list
-            Axial temperature grid (in cm) (i.e., physical locations where temp is stored)
-        tempField : float, list
-            Temperature values (in C) along grid
-        setFuel : boolean, optional
-            Boolean to determine whether or not fuel blocks should have their target components set
-            This is useful when target components within a fuel block need to be determined on-the-fly.
-        expandFromTinputToThot: bool
-            determines if thermal expansion factors should be calculated from c.inputTemperatureInC
-            to c.temperatureInC (True) or some other reference temperature and c.temperatureInC (False)
-        """
-        self.setAssembly(a, setFuel, expandFromTinputToThot)
-        self.expansionData.updateComponentTempsBy1DTempField(tempGrid, tempField)
-        self.expansionData.computeThermalExpansionFactors()
-        self.axiallyExpandAssembly()
-
-    def reset(self):
-        self.linked = None
-        self.expansionData = None
-
-    def setAssembly(self, a, setFuel=True, expandFromTinputToThot=False):
-        """Set the armi assembly to be changed and init expansion data class for assembly.
-
-        Parameters
-        ----------
-         a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
-            ARMI assembly to be changed
-        setFuel : boolean, optional
-            Boolean to determine whether or not fuel blocks should have their target components set
-            This is useful when target components within a fuel block need to be determined on-the-fly.
-        expandFromTinputToThot: bool
-            determines if thermal expansion factors should be calculated from c.inputTemperatureInC
-            to c.temperatureInC (True) or some other reference temperature and c.temperatureInC (False)
-
-        Notes
-        -----
-        When considering thermal expansion, if there is an axial temperature distribution on the
-        assembly, the axial expansion methodology will NOT perfectly preserve mass. The magnitude of
-        the gradient of the temperature distribution is the primary factor in determining the
-        cumulative loss of mass conservation.
-        """
-        self.linked = AssemblyAxialLinkage(a)
-        self.expansionData = ExpansionData(
-            a, setFuel=setFuel, expandFromTinputToThot=expandFromTinputToThot
-        )
-        self._isTopDummyBlockPresent()
-
-    def applyColdHeightMassIncrease(self):
-        """
-        Increase component mass because they are declared at cold dims.
-
-        Notes
-        -----
-        A cold 1 cm tall component will have more mass that a component with the
-        same mass/length as a component with a hot height of 1 cm. This should be
-        called when the setting `inputHeightsConsideredHot` is used. This adjusts
-        the expansion factor applied during applyMaterialMassFracsToNumberDensities.
-        """
-        for c in self.linked.a.getComponents():
-            axialExpansionFactor = 1.0 + c.material.linearExpansionFactor(
-                c.temperatureInC, c.inputTemperatureInC
-            )
-            c.changeNDensByFactor(axialExpansionFactor)
-
-    def _isTopDummyBlockPresent(self):
-        """Determines if top most block of assembly is a dummy block.
-
-        Notes
-        -----
-        - If true, then axial expansion will be physical for all blocks.
-        - If false, the top most block in the assembly is artificially chopped
-          to preserve the assembly height. A runLog.Warning also issued.
-        """
-        blkLst = self.linked.a.getBlocks()
-        if not blkLst[-1].hasFlags(Flags.DUMMY):
-            runLog.warning(
-                f"No dummy block present at the top of {self.linked.a}! "
-                "Top most block will be artificially chopped "
-                "to preserve assembly height"
-            )
-            if self._detailedAxialExpansion:
-                msg = "Cannot run detailedAxialExpansion without a dummy block at the top of the assembly!"
-                runLog.error(msg)
-                raise RuntimeError(msg)
-
-    def axiallyExpandAssembly(self):
-        """Utilizes assembly linkage to do axial expansion.
-
-        .. impl:: Preserve the total height of an ARMI assembly, during expansion.
-            :id: I_ARMI_ASSEM_HEIGHT_PRES
-            :implements: R_ARMI_ASSEM_HEIGHT_PRES
-
-            The total height of an Assembly is preserved by not changing the ``ztop`` position
-            of the top-most Block in an Assembly. The ``zbottom`` of the top-most Block is
-            adjusted to match the Block immediately below it. The ``height`` of the
-            top-most Block is is then updated to reflect any expansion/contraction.
-        """
-        mesh = [0.0]
-        numOfBlocks = self.linked.a.countBlocksWithFlags()
-        runLog.debug(
-            "Printing component expansion information (growth percentage and 'target component')"
-            f"for each block in assembly {self.linked.a}."
-        )
-        for ib, b in enumerate(self.linked.a):
-            runLog.debug(msg=f"  Block {b}")
-            blockHeight = b.getHeight()
-            # set bottom of block equal to top of block below it
-            # if ib == 0, leave block bottom = 0.0
-            if ib > 0:
-                b.p.zbottom = self.linked.linkedBlocks[b][0].p.ztop
-            isDummyBlock = ib == (numOfBlocks - 1)
-            if not isDummyBlock:
-                for c in getSolidComponents(b):
-                    growFrac = self.expansionData.getExpansionFactor(c)
-                    runLog.debug(msg=f"      Component {c}, growFrac = {growFrac:.4e}")
-                    c.height = growFrac * blockHeight
-                    # align linked components
-                    if ib == 0:
-                        c.zbottom = 0.0
-                    else:
-                        if self.linked.linkedComponents[c][0] is not None:
-                            # use linked components below
-                            c.zbottom = self.linked.linkedComponents[c][0].ztop
-                        else:
-                            # otherwise there aren't any linked components
-                            # so just set the bottom of the component to
-                            # the top of the block below it
-                            c.zbottom = self.linked.linkedBlocks[b][0].p.ztop
-                    c.ztop = c.zbottom + c.height
-                    # update component number densities
-                    newNumberDensities = {
-                        nuc: c.getNumberDensity(nuc) / growFrac
-                        for nuc in c.getNuclides()
-                    }
-                    c.setNumberDensities(newNumberDensities)
-                    # redistribute block boundaries if on the target component
-                    if self.expansionData.isTargetComponent(c):
-                        b.p.ztop = c.ztop
-                        b.p.height = b.p.ztop - b.p.zbottom
-            else:
-                b.p.height = b.p.ztop - b.p.zbottom
-
-            b.p.z = b.p.zbottom + b.getHeight() / 2.0
-
-            _checkBlockHeight(b)
-            # Call Component.clearCache to update the Component volume, and therefore the masses,
-            # of all solid components.
-            for c in getSolidComponents(b):
-                c.clearCache()
-            # redo mesh -- functionality based on assembly.calculateZCoords()
-            mesh.append(b.p.ztop)
-            b.spatialLocator = self.linked.a.spatialGrid[0, 0, ib]
-
-        bounds = list(self.linked.a.spatialGrid._bounds)
-        bounds[2] = array(mesh)
-        self.linked.a.spatialGrid._bounds = tuple(bounds)
-
-    def manageCoreMesh(self, r):
-        """Manage core mesh post assembly-level expansion.
-
-        Parameters
-        ----------
-        r : :py:class:`Reactor <armi.reactor.reactors.Reactor>`
-            ARMI reactor to have mesh modified
-
-        Notes
-        -----
-        - if no detailedAxialExpansion, then do "cheap" approach to uniformMesh converter.
-        - update average core mesh values with call to r.core.updateAxialMesh()
-        - oldMesh will be None during initial core construction at processLoading as it has not yet
-          been set.
-        """
-        if not self._detailedAxialExpansion:
-            # loop through again now that the reference is adjusted and adjust the non-fuel assemblies.
-            for a in r.core.getAssemblies():
-                a.setBlockMesh(r.core.refAssem.getAxialMesh())
-
-        oldMesh = r.core.p.axialMesh
-        r.core.updateAxialMesh()
-        if oldMesh:
-            runLog.extra("Updated r.core.p.axialMesh (old, new)")
-            for old, new in zip(oldMesh, r.core.p.axialMesh):
-                runLog.extra(f"{old:.6e}\t{new:.6e}")
-
-
-def getSolidComponents(b):
-    """
-    Return list of components in the block that have solid material.
-
-    Notes
-    -----
-    Axial expansion only needs to be applied to solid materials. We should not update
-    number densities on fluid materials to account for changes in block height.
-    """
-    return [c for c in b if not isinstance(c.material, material.Fluid)]
-
-
-def _checkBlockHeight(b):
-    """
-    Do some basic block height validation.
-
-    Notes
-    -----
-    3cm is a presumptive lower threshhold for DIF3D
-    """
-    if b.getHeight() < 3.0:
-        runLog.debug(
-            "Block {0:s} ({1:s}) has a height less than 3.0 cm. ({2:.12e})".format(
-                b.name, str(b.p.flags), b.getHeight()
-            )
-        )
-
-    if b.getHeight() < 0.0:
-        raise ArithmeticError(
-            "Block {0:s} ({1:s}) has a negative height! ({2:.12e})".format(
-                b.name, str(b.p.flags), b.getHeight()
-            )
-        )
-
-
-class AssemblyAxialLinkage:
-    """Determines and stores the block- and component-wise axial linkage for an assembly.
-
-    Attributes
-    ----------
-    a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
-        reference to original assembly; is directly modified/changed during expansion.
-
-    linkedBlocks : dict
-        - keys = :py:class:`Block <armi.reactor.blocks.Block>`
-        - values = list of axially linked blocks; index 0 = lower linked block; index 1: upper
-          linked block.
-
-    linkedComponents : dict
-        - keys = :py:class:`Component <armi.reactor.components.component.Component>`
-        - values = list of axially linked components; index 0 = lower linked component;
-          index 1: upper linked component.
-
-    See Also
-    --------
-    - self._getLinkedComponents
-    - self._getLinkedBlocks()
-    """
-
-    def __init__(self, StdAssem):
-        self.a = StdAssem
-        self.linkedBlocks = {}
-        self.linkedComponents = {}
-        self._determineAxialLinkage()
-
-    def _determineAxialLinkage(self):
-        """Gets the block and component based linkage."""
-        for b in self.a:
-            self._getLinkedBlocks(b)
-            for c in getSolidComponents(b):
-                self._getLinkedComponents(b, c)
-
-    def _getLinkedBlocks(self, b):
-        """Retrieve the axial linkage for block b.
-
-        Parameters
-        ----------
-        b : :py:class:`Block <armi.reactor.blocks.Block>`
-            block to determine axial linkage for
-
-        Notes
-        -----
-        - block linkage is determined by matching ztop/zbottom (see below)
-        - block linkage is stored in self.linkedBlocks[b]
-         _ _
-        |   |
-        | 2 |  Block 2 is linked to block 1.
-        |_ _|
-        |   |
-        | 1 |  Block 1 is linked to both block 0 and 1.
-        |_ _|
-        |   |
-        | 0 |  Block 0 is linked to block 1.
-        |_ _|
-        """
-        lowerLinkedBlock = None
-        upperLinkedBlock = None
-        block_list = self.a.getChildren()
-        for otherBlk in block_list:
-            if b.name != otherBlk.name:
-                if b.p.zbottom == otherBlk.p.ztop:
-                    lowerLinkedBlock = otherBlk
-                elif b.p.ztop == otherBlk.p.zbottom:
-                    upperLinkedBlock = otherBlk
-
-        self.linkedBlocks[b] = [lowerLinkedBlock, upperLinkedBlock]
-
-        if lowerLinkedBlock is None:
-            runLog.debug(
-                "Assembly {0:22s} at location {1:22s}, Block {2:22s}"
-                "is not linked to a block below!".format(
-                    str(self.a.getName()),
-                    str(self.a.getLocation()),
-                    str(b.p.flags),
-                ),
-                single=True,
-            )
-        if upperLinkedBlock is None:
-            runLog.debug(
-                "Assembly {0:22s} at location {1:22s}, Block {2:22s}"
-                "is not linked to a block above!".format(
-                    str(self.a.getName()),
-                    str(self.a.getLocation()),
-                    str(b.p.flags),
-                ),
-                single=True,
-            )
-
-    def _getLinkedComponents(self, b, c):
-        """Retrieve the axial linkage for component c.
-
-        Parameters
-        ----------
-        b : :py:class:`Block <armi.reactor.blocks.Block>`
-            key to access blocks containing linked components
-        c : :py:class:`Component <armi.reactor.components.component.Component>`
-            component to determine axial linkage for
-
-        Raises
-        ------
-        RuntimeError
-            multiple candidate components are found to be axially linked to a component
-        """
-        lstLinkedC = [None, None]
-        for ib, linkdBlk in enumerate(self.linkedBlocks[b]):
-            if linkdBlk is not None:
-                for otherC in getSolidComponents(linkdBlk.getChildren()):
-                    if _determineLinked(c, otherC):
-                        if lstLinkedC[ib] is not None:
-                            errMsg = (
-                                "Multiple component axial linkages have been found for "
-                                f"Component {c}; Block {b}; Assembly {b.parent}."
-                                " This is indicative of an error in the blueprints! Linked "
-                                f"components found are {lstLinkedC[ib]} and {otherC}"
-                            )
-                            runLog.error(msg=errMsg)
-                            raise RuntimeError(errMsg)
-                        lstLinkedC[ib] = otherC
-
-        self.linkedComponents[c] = lstLinkedC
-
-        if lstLinkedC[0] is None:
-            runLog.debug(
-                f"Assembly {self.a}, Block {b}, Component {c} has nothing linked below it!",
-                single=True,
-            )
-        if lstLinkedC[1] is None:
-            runLog.debug(
-                f"Assembly {self.a}, Block {b}, Component {c} has nothing linked above it!",
-                single=True,
-            )
-
-
-def _determineLinked(componentA, componentB):
-    """Determine axial component linkage for two components.
-
-    Parameters
-    ----------
-    componentA : :py:class:`Component <armi.reactor.components.component.Component>`
-        component of interest
-    componentB : :py:class:`Component <armi.reactor.components.component.Component>`
-        component to compare and see if is linked to componentA
-
-    Notes
-    -----
-    - Requires that shapes have the getCircleInnerDiameter and getBoundingCircleOuterDiameter
-      defined
-    - For axial linkage to be True, components MUST be solids, the same Component Class,
-      multiplicity, and meet inner and outer diameter requirements.
-    - When component dimensions are retrieved, cold=True to ensure that dimensions are evaluated
-      at cold/input temperatures. At temperature, solid-solid interfaces in ARMI may produce
-      slight overlaps due to thermal expansion. Handling these potential overlaps are out of scope.
-
-    Returns
-    -------
-    linked : bool
-        status is componentA and componentB are axially linked to one another
-    """
-    if (
-        (componentA.containsSolidMaterial() and componentB.containsSolidMaterial())
-        and isinstance(componentA, type(componentB))
-        and (componentA.getDimension("mult") == componentB.getDimension("mult"))
-    ):
-        if isinstance(componentA, UnshapedComponent):
-            runLog.warning(
-                f"Components {componentA} and {componentB} are UnshapedComponents "
-                "and do not have 'getCircleInnerDiameter' or getBoundingCircleOuterDiameter "
-                "methods; nor is it physical to do so. Instead of crashing and raising an error, "
-                "they are going to be assumed to not be linked.",
-                single=True,
-            )
-            linked = False
-        else:
-            idA, odA = (
-                componentA.getCircleInnerDiameter(cold=True),
-                componentA.getBoundingCircleOuterDiameter(cold=True),
-            )
-            idB, odB = (
-                componentB.getCircleInnerDiameter(cold=True),
-                componentB.getBoundingCircleOuterDiameter(cold=True),
-            )
-
-            biggerID = max(idA, idB)
-            smallerOD = min(odA, odB)
-            if biggerID >= smallerOD:
-                # one object fits inside the other
-                linked = False
-            else:
-                linked = True
-
-    else:
-        linked = False
-
-    return linked
-
-
-class ExpansionData:
-    """Object containing data needed for axial expansion."""
-
-    def __init__(self, a, setFuel: bool, expandFromTinputToThot: bool):
-        """
-        Parameters
-        ----------
-        a: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
-            Assembly to assign component-wise expansion data to
-        setFuel: bool
-            used to determine if fuel component should be set as
-            axial expansion target component during initialization.
-            see self._isFuelLocked
-        expandFromTinputToThot: bool
-            determines if thermal expansion factors should be calculated
-            from c.inputTemperatureInC to c.temperatureInC (True) or some other
-            reference temperature and c.temperatureInC (False)
-        """
-        self._a = a
-        self.componentReferenceTemperature = {}
-        self._expansionFactors = {}
-        self._componentDeterminesBlockHeight = {}
-        self._setTargetComponents(setFuel)
-        self.expandFromTinputToThot = expandFromTinputToThot
-
-    def setExpansionFactors(self, componentLst: List, expFrac: List):
-        """Sets user defined expansion fractions.
-
-        Parameters
-        ----------
-        componentLst : List[:py:class:`Component <armi.reactor.components.component.Component>`]
-            list of Components to have their heights changed
-        expFrac : List[float]
-            list of L1/L0 height changes that are to be applied to componentLst
-
-        Raises
-        ------
-        RuntimeError
-            If componentLst and expFrac are different lengths
-        """
-        if len(componentLst) != len(expFrac):
-            runLog.error(
-                "Number of components and expansion fractions must be the same!\n"
-                f"    len(componentLst) = {len(componentLst)}\n"
-                f"        len(expFrac) = {len(expFrac)}"
-            )
-            raise RuntimeError
-        if 0.0 in expFrac:
-            msg = (
-                "An expansion fraction, L1/L0, equal to 0.0, is not physical. Expansion fractions "
-                "should be greater than 0.0."
-            )
-            runLog.error(msg)
-            raise RuntimeError(msg)
-        for exp in expFrac:
-            if exp < 0.0:
-                msg = (
-                    "A negative expansion fraction, L1/L0, is not physical. Expansion fractions "
-                    "should be greater than 0.0."
-                )
-                runLog.error(msg)
-                raise RuntimeError(msg)
-        for c, p in zip(componentLst, expFrac):
-            self._expansionFactors[c] = p
-
-    def updateComponentTempsBy1DTempField(self, tempGrid, tempField):
-        """Assign a block-average axial temperature to components.
-
-        Parameters
-        ----------
-        tempGrid : numpy array
-            1D axial temperature grid (i.e., physical locations where temp is stored)
-        tempField : numpy array
-            temperature values along grid
-
-        Notes
-        -----
-        - given a 1D axial temperature grid and distribution, searches for temperatures that fall
-          within the bounds of a block, and averages them
-        - this average temperature is then passed to self.updateComponentTemp()
-
-        Raises
-        ------
-        ValueError
-            if no temperature points found within a block
-        RuntimeError
-            if tempGrid and tempField are different lengths
-        """
-        if len(tempGrid) != len(tempField):
-            runLog.error("tempGrid and tempField must have the same length.")
-            raise RuntimeError
-
-        self.componentReferenceTemperature = {}  # reset, just to be safe
-        for b in self._a:
-            tmpMapping = []
-            for idz, z in enumerate(tempGrid):
-                if b.p.zbottom <= z <= b.p.ztop:
-                    tmpMapping.append(tempField[idz])
-                if z > b.p.ztop:
-                    break
-
-            if len(tmpMapping) == 0:
-                raise ValueError(
-                    f"{b} has no temperature points within it!"
-                    "Likely need to increase the refinement of the temperature grid."
-                )
-
-            blockAveTemp = mean(tmpMapping)
-            for c in b:
-                self.updateComponentTemp(c, blockAveTemp)
-
-    def updateComponentTemp(self, c, temp: float):
-        """Update component temperatures with a provided temperature.
-
-        Parameters
-        ----------
-        c : :py:class:`Component <armi.reactor.components.component.Component>`
-            component to which the temperature, temp, is to be applied
-        temp : float
-            new component temperature in C
-
-        Notes
-        -----
-        - "reference" height and temperature are the current states; i.e. before
-           1) the new temperature, temp, is applied to the component, and
-           2) the component is axially expanded
-        """
-        self.componentReferenceTemperature[c] = c.temperatureInC
-        c.setTemperature(temp)
-
-    def computeThermalExpansionFactors(self):
-        """Computes expansion factors for all components via thermal expansion."""
-        for b in self._a:
-            for c in getSolidComponents(b):
-                if self.expandFromTinputToThot:
-                    # get thermal expansion factor between c.inputTemperatureInC & c.temperatureInC
-                    self._expansionFactors[c] = c.getThermalExpansionFactor()
-                elif c in self.componentReferenceTemperature:
-                    growFrac = c.getThermalExpansionFactor(
-                        T0=self.componentReferenceTemperature[c]
-                    )
-                    self._expansionFactors[c] = growFrac
-                else:
-                    # We want expansion factors relative to componentReferenceTemperature not
-                    # Tinput. But for this component there isn't a componentReferenceTemperature, so
-                    # we'll assume that the expansion factor is 1.0.
-                    self._expansionFactors[c] = 1.0
-
-    def getExpansionFactor(self, c):
-        """Retrieves expansion factor for c.
-
-        Parameters
-        ----------
-        c : :py:class:`Component <armi.reactor.components.component.Component>`
-            Component to retrive expansion factor for
-        """
-        value = self._expansionFactors.get(c, 1.0)
-        return value
-
-    def _setTargetComponents(self, setFuel):
-        """Sets target component for each block.
-
-        Parameters
-        ----------
-        setFuel : bool
-            boolean to determine if fuel block should have its target component set. Useful for when
-            target components should be determined on the fly.
-        """
-        for b in self._a:
-            if b.p.axialExpTargetComponent:
-                self._componentDeterminesBlockHeight[
-                    b.getComponentByName(b.p.axialExpTargetComponent)
-                ] = True
-            elif b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP):
-                self.determineTargetComponent(b, Flags.CLAD)
-            elif b.hasFlags(Flags.DUMMY):
-                self.determineTargetComponent(b, Flags.COOLANT)
-            elif setFuel and b.hasFlags(Flags.FUEL):
-                self._isFuelLocked(b)
-            else:
-                self.determineTargetComponent(b)
-
-    def determineTargetComponent(self, b, flagOfInterest=None):
-        """Determines target component, stores it on the block, and appends it to
-        self._componentDeterminesBlockHeight.
-
-        Parameters
-        ----------
-        b : :py:class:`Block <armi.reactor.blocks.Block>`
-            block to specify target component for
-        flagOfInterest : :py:class:`Flags <armi.reactor.flags.Flags>`
-            the flag of interest to identify the target component
-
-        Notes
-        -----
-        - if flagOfInterest is None, finds the component within b that contains flags that
-          are defined in a preferred order of flags, or barring that, in b.p.flags
-        - if flagOfInterest is not None, finds the component that contains the flagOfInterest.
-
-        Raises
-        ------
-        RuntimeError
-            no target component found
-        RuntimeError
-            multiple target components found
-        """
-        if flagOfInterest is None:
-            # Follow expansion of most neutronically important component, fuel then control/poison
-            for targetFlag in TARGET_FLAGS_IN_PREFERRED_ORDER:
-                componentWFlag = [c for c in b.getChildren() if c.hasFlags(targetFlag)]
-                if componentWFlag != []:
-                    break
-            # some blocks/components are not included in the above list but should still be found
-            if not componentWFlag:
-                componentWFlag = [c for c in b.getChildren() if c.p.flags in b.p.flags]
-        else:
-            componentWFlag = [c for c in b.getChildren() if c.hasFlags(flagOfInterest)]
-        if len(componentWFlag) == 0:
-            # if only 1 solid, be smart enought to snag it
-            solidMaterials = list(
-                c for c in b if not isinstance(c.material, material.Fluid)
-            )
-            if len(solidMaterials) == 1:
-                componentWFlag = solidMaterials
-        if len(componentWFlag) == 0:
-            raise RuntimeError(f"No target component found!\n   Block {b}")
-        if len(componentWFlag) > 1:
-            raise RuntimeError(
-                "Cannot have more than one component within a block that has the target flag!"
-                f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {componentWFlag}"
-            )
-        self._componentDeterminesBlockHeight[componentWFlag[0]] = True
-        b.p.axialExpTargetComponent = componentWFlag[0].name
-
-    def _isFuelLocked(self, b):
-        """Physical/realistic implementation reserved for ARMI plugin.
-
-        Parameters
-        ----------
-        b : :py:class:`Block <armi.reactor.blocks.Block>`
-            block to specify target component for
-
-        Raises
-        ------
-        RuntimeError
-            multiple fuel components found within b
-
-        Notes
-        -----
-        - This serves as an example to check for fuel/clad locking/interaction found in SFRs.
-        - A more realistic/physical implementation is reserved for ARMI plugin(s).
-        """
-        c = b.getComponent(Flags.FUEL)
-        if c is None:
-            raise RuntimeError(f"No fuel component within {b}!")
-        self._componentDeterminesBlockHeight[c] = True
-        b.p.axialExpTargetComponent = c.name
-
-    def isTargetComponent(self, c):
-        """Returns bool if c is a target component.
-
-        Parameters
-        ----------
-        c : :py:class:`Component <armi.reactor.components.component.Component>`
-            Component to check target component status
-        """
-        return bool(c in self._componentDeterminesBlockHeight)
diff --git a/armi/reactor/converters/axialExpansionChanger/__init__.py b/armi/reactor/converters/axialExpansionChanger/__init__.py
new file mode 100644
index 00000000..1eecb064
--- /dev/null
+++ b/armi/reactor/converters/axialExpansionChanger/__init__.py
@@ -0,0 +1,29 @@
+# Copyright 2024 TerraPower, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# 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.
+"""Enable component-wise axial expansion for assemblies and/or a reactor."""
+
+# ruff: noqa: F401
+from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import (
+    AssemblyAxialLinkage,
+)
+from armi.reactor.converters.axialExpansionChanger.axialExpansionChanger import (
+    AxialExpansionChanger,
+)
+from armi.reactor.converters.axialExpansionChanger.axialExpansionChanger import (
+    makeAssemsAbleToSnapToUniformMesh,
+)
+from armi.reactor.converters.axialExpansionChanger.expansionData import ExpansionData
+from armi.reactor.converters.axialExpansionChanger.expansionData import (
+    getSolidComponents,
+)
diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py
new file mode 100644
index 00000000..c04b1281
--- /dev/null
+++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py
@@ -0,0 +1,213 @@
+# Copyright 2024 TerraPower, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# 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.
+
+from armi import runLog
+from armi.reactor.components import UnshapedComponent
+from armi.reactor.converters.axialExpansionChanger.expansionData import (
+    getSolidComponents,
+)
+
+
+def _determineLinked(componentA, componentB):
+    """Determine axial component linkage for two components.
+
+    Parameters
+    ----------
+    componentA : :py:class:`Component <armi.reactor.components.component.Component>`
+        component of interest
+    componentB : :py:class:`Component <armi.reactor.components.component.Component>`
+        component to compare and see if is linked to componentA
+
+    Notes
+    -----
+    - Requires that shapes have the getCircleInnerDiameter and getBoundingCircleOuterDiameter
+      defined
+    - For axial linkage to be True, components MUST be solids, the same Component Class,
+      multiplicity, and meet inner and outer diameter requirements.
+    - When component dimensions are retrieved, cold=True to ensure that dimensions are evaluated
+      at cold/input temperatures. At temperature, solid-solid interfaces in ARMI may produce
+      slight overlaps due to thermal expansion. Handling these potential overlaps are out of scope.
+
+    Returns
+    -------
+    linked : bool
+        status is componentA and componentB are axially linked to one another
+    """
+    if (
+        (componentA.containsSolidMaterial() and componentB.containsSolidMaterial())
+        and isinstance(componentA, type(componentB))
+        and (componentA.getDimension("mult") == componentB.getDimension("mult"))
+    ):
+        if isinstance(componentA, UnshapedComponent):
+            runLog.warning(
+                f"Components {componentA} and {componentB} are UnshapedComponents "
+                "and do not have 'getCircleInnerDiameter' or getBoundingCircleOuterDiameter "
+                "methods; nor is it physical to do so. Instead of crashing and raising an error, "
+                "they are going to be assumed to not be linked.",
+                single=True,
+            )
+            linked = False
+        else:
+            idA, odA = (
+                componentA.getCircleInnerDiameter(cold=True),
+                componentA.getBoundingCircleOuterDiameter(cold=True),
+            )
+            idB, odB = (
+                componentB.getCircleInnerDiameter(cold=True),
+                componentB.getBoundingCircleOuterDiameter(cold=True),
+            )
+
+            biggerID = max(idA, idB)
+            smallerOD = min(odA, odB)
+            if biggerID >= smallerOD:
+                # one object fits inside the other
+                linked = False
+            else:
+                linked = True
+
+    else:
+        linked = False
+
+    return linked
+
+
+class AssemblyAxialLinkage:
+    """Determines and stores the block- and component-wise axial linkage for an assembly.
+
+    Attributes
+    ----------
+    a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
+        reference to original assembly; is directly modified/changed during expansion.
+    linkedBlocks : dict
+        - keys = :py:class:`Block <armi.reactor.blocks.Block>`
+        - values = list of axially linked blocks; index 0 = lower linked block; index 1: upper
+          linked block.
+    linkedComponents : dict
+        - keys = :py:class:`Component <armi.reactor.components.component.Component>`
+        - values = list of axially linked components; index 0 = lower linked component;
+          index 1: upper linked component.
+    """
+
+    def __init__(self, StdAssem):
+        self.a = StdAssem
+        self.linkedBlocks = {}
+        self.linkedComponents = {}
+        self._determineAxialLinkage()
+
+    def _determineAxialLinkage(self):
+        """Gets the block and component based linkage."""
+        for b in self.a:
+            self._getLinkedBlocks(b)
+            for c in getSolidComponents(b):
+                self._getLinkedComponents(b, c)
+
+    def _getLinkedBlocks(self, b):
+        """Retrieve the axial linkage for block b.
+
+        Parameters
+        ----------
+        b : :py:class:`Block <armi.reactor.blocks.Block>`
+            block to determine axial linkage for
+
+        Notes
+        -----
+        - block linkage is determined by matching ztop/zbottom (see below)
+        - block linkage is stored in self.linkedBlocks[b]
+         _ _
+        |   |
+        | 2 |  Block 2 is linked to block 1.
+        |_ _|
+        |   |
+        | 1 |  Block 1 is linked to both block 0 and 1.
+        |_ _|
+        |   |
+        | 0 |  Block 0 is linked to block 1.
+        |_ _|
+        """
+        lowerLinkedBlock = None
+        upperLinkedBlock = None
+        block_list = self.a.getChildren()
+        for otherBlk in block_list:
+            if b.name != otherBlk.name:
+                if b.p.zbottom == otherBlk.p.ztop:
+                    lowerLinkedBlock = otherBlk
+                elif b.p.ztop == otherBlk.p.zbottom:
+                    upperLinkedBlock = otherBlk
+
+        self.linkedBlocks[b] = [lowerLinkedBlock, upperLinkedBlock]
+
+        if lowerLinkedBlock is None:
+            runLog.debug(
+                "Assembly {0:22s} at location {1:22s}, Block {2:22s}"
+                "is not linked to a block below!".format(
+                    str(self.a.getName()),
+                    str(self.a.getLocation()),
+                    str(b.p.flags),
+                ),
+                single=True,
+            )
+        if upperLinkedBlock is None:
+            runLog.debug(
+                "Assembly {0:22s} at location {1:22s}, Block {2:22s}"
+                "is not linked to a block above!".format(
+                    str(self.a.getName()),
+                    str(self.a.getLocation()),
+                    str(b.p.flags),
+                ),
+                single=True,
+            )
+
+    def _getLinkedComponents(self, b, c):
+        """Retrieve the axial linkage for component c.
+
+        Parameters
+        ----------
+        b : :py:class:`Block <armi.reactor.blocks.Block>`
+            key to access blocks containing linked components
+        c : :py:class:`Component <armi.reactor.components.component.Component>`
+            component to determine axial linkage for
+
+        Raises
+        ------
+        RuntimeError
+            multiple candidate components are found to be axially linked to a component
+        """
+        lstLinkedC = [None, None]
+        for ib, linkdBlk in enumerate(self.linkedBlocks[b]):
+            if linkdBlk is not None:
+                for otherC in getSolidComponents(linkdBlk.getChildren()):
+                    if _determineLinked(c, otherC):
+                        if lstLinkedC[ib] is not None:
+                            errMsg = (
+                                "Multiple component axial linkages have been found for "
+                                f"Component {c}; Block {b}; Assembly {b.parent}."
+                                " This is indicative of an error in the blueprints! Linked "
+                                f"components found are {lstLinkedC[ib]} and {otherC}"
+                            )
+                            runLog.error(msg=errMsg)
+                            raise RuntimeError(errMsg)
+                        lstLinkedC[ib] = otherC
+
+        self.linkedComponents[c] = lstLinkedC
+
+        if lstLinkedC[0] is None:
+            runLog.debug(
+                f"Assembly {self.a}, Block {b}, Component {c} has nothing linked below it!",
+                single=True,
+            )
+        if lstLinkedC[1] is None:
+            runLog.debug(
+                f"Assembly {self.a}, Block {b}, Component {c} has nothing linked above it!",
+                single=True,
+            )
diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
new file mode 100644
index 00000000..2800925b
--- /dev/null
+++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
@@ -0,0 +1,421 @@
+# Copyright 2019 TerraPower, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# 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.
+"""Enable component-wise axial expansion for assemblies and/or a reactor."""
+
+from numpy import array
+
+from armi import runLog
+from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import (
+    AssemblyAxialLinkage,
+)
+from armi.reactor.converters.axialExpansionChanger.expansionData import ExpansionData
+from armi.reactor.converters.axialExpansionChanger.expansionData import (
+    getSolidComponents,
+)
+from armi.reactor.flags import Flags
+
+
+def getDefaultReferenceAssem(assems):
+    """Return a default reference assembly."""
+    # if assemblies are defined in blueprints, handle meshing
+    # assume finest mesh is reference
+    assemsByNumBlocks = sorted(
+        assems,
+        key=lambda a: len(a),
+        reverse=True,
+    )
+    return assemsByNumBlocks[0] if assemsByNumBlocks else None
+
+
+def makeAssemsAbleToSnapToUniformMesh(
+    assems, nonUniformAssemFlags, referenceAssembly=None
+):
+    """Make this set of assemblies aware of the reference mesh so they can stay uniform as they axially expand."""
+    if not referenceAssembly:
+        referenceAssembly = getDefaultReferenceAssem(assems)
+    # make the snap lists so assems know how to expand
+    nonUniformAssems = [Flags.fromStringIgnoreErrors(t) for t in nonUniformAssemFlags]
+    for a in assems:
+        if any(a.hasFlags(f) for f in nonUniformAssems):
+            continue
+        a.makeAxialSnapList(referenceAssembly)
+
+
+def expandColdDimsToHot(
+    assems: list,
+    isDetailedAxialExpansion: bool,
+    referenceAssembly=None,
+):
+    """
+    Expand BOL assemblies, resolve disjoint axial mesh (if needed), and update block BOL heights.
+
+    .. impl:: Perform expansion during core construction based on block heights at a specified temperature.
+        :id: I_ARMI_INP_COLD_HEIGHT
+        :implements: R_ARMI_INP_COLD_HEIGHT
+
+        This method is designed to be used during core construction to axially thermally expand the
+        assemblies to their "hot" temperatures (as determined by ``Thot`` values in blueprints).
+        First, The Assembly is prepared for axial expansion via ``setAssembly``. In
+        ``applyColdHeightMassIncrease``, the number densities on each Component is adjusted to
+        reflect that Assembly inputs are at cold (i.e., ``Tinput``) temperatures. To expand to
+        the requested hot temperatures, thermal expansion factors are then computed in
+        ``computeThermalExpansionFactors``. Finally, the Assembly is axially thermally expanded in
+        ``axiallyExpandAssembly``.
+
+        If the setting ``detailedAxialExpansion`` is ``False``, then each Assembly gets its Block mesh
+        set to match that of the "reference" Assembly (see ``getDefaultReferenceAssem`` and ``setBlockMesh``).
+
+        Once the Assemblies are axially expanded, the Block BOL heights are updated. To account for the change in
+        Block volume from axial expansion, ``completeInitialLoading`` is called to update any volume-dependent
+        Block information.
+
+    Parameters
+    ----------
+    assems: list[:py:class:`Assembly <armi.reactor.assemblies.Assembly>`]
+        list of assemblies to be thermally expanded
+    isDetailedAxialExpansion: bool
+        If False, assemblies will be forced to conform to the reference mesh after expansion
+    referenceAssembly: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`, optional
+        Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False.
+        If not provided, will assume the finest mesh assembly which is typically fuel.
+
+    Notes
+    -----
+    Calling this method will result in an increase in mass via applyColdHeightMassIncrease!
+
+    See Also
+    --------
+    :py:meth:`armi.reactor.converters.axialExpansionChanger.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease`
+    """
+    assems = list(assems)
+    if not referenceAssembly:
+        referenceAssembly = getDefaultReferenceAssem(assems)
+    axialExpChanger = AxialExpansionChanger(isDetailedAxialExpansion)
+    for a in assems:
+        axialExpChanger.setAssembly(a, expandFromTinputToThot=True)
+        axialExpChanger.applyColdHeightMassIncrease()
+        axialExpChanger.expansionData.computeThermalExpansionFactors()
+        axialExpChanger.axiallyExpandAssembly()
+    if not isDetailedAxialExpansion:
+        for a in assems:
+            a.setBlockMesh(referenceAssembly.getAxialMesh())
+    # update block BOL heights to reflect hot heights
+    for a in assems:
+        for b in a:
+            b.p.heightBOL = b.getHeight()
+            b.completeInitialLoading()
+
+
+class AxialExpansionChanger:
+    """
+    Axially expand or contract assemblies or an entire core.
+
+    Attributes
+    ----------
+    linked: :py:class:`AssemblyAxialLinkage`
+        establishes object containing axial linkage information
+    expansionData: :py:class:`ExpansionData <armi.reactor.converters.axialExpansionChanger.expansionData.ExpansionData>`
+        establishes object to store and access relevant expansion data
+
+    Notes
+    -----
+    - Is designed to work with general, vertically oriented, pin-type assembly designs. It is not set up to account
+      for any other assembly type.
+    - Useful for fuel performance, thermal expansion, reactivity coefficients, etc.
+    """
+
+    def __init__(self, detailedAxialExpansion: bool = False):
+        """
+        Build an axial expansion converter.
+
+        Parameters
+        ----------
+        detailedAxialExpansion : bool, optional
+            A boolean to indicate whether or not detailedAxialExpansion is to be utilized.
+        """
+        self._detailedAxialExpansion = detailedAxialExpansion
+        self.linked = None
+        self.expansionData = None
+
+    def performPrescribedAxialExpansion(
+        self, a, componentLst: list, percents: list, setFuel=True
+    ):
+        """Perform axial expansion/contraction of an assembly given prescribed expansion percentages.
+
+        .. impl:: Perform expansion/contraction, given a list of components and expansion coefficients.
+            :id: I_ARMI_AXIAL_EXP_PRESC
+            :implements: R_ARMI_AXIAL_EXP_PRESC
+
+            This method performs component-wise axial expansion for an Assembly given expansion coefficients
+            and a corresponding list of Components. In ``setAssembly``, the Assembly is prepared
+            for axial expansion by determining Component-wise axial linkage and checking to see if a dummy Block
+            is in place (necessary for ensuring conservation properties). The provided expansion factors are
+            then assigned to their corresponding Components in ``setExpansionFactors``. Finally, the axial
+            expansion is performed in ``axiallyExpandAssembly``
+
+        Parameters
+        ----------
+        a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
+            ARMI assembly to be changed
+        componentLst : list[:py:class:`Component <armi.reactor.components.component.Component>`]
+            list of Components to be expanded
+        percents : list[float]
+            list of expansion percentages for each component listed in componentList
+        setFuel : boolean, optional
+            Boolean to determine whether or not fuel blocks should have their target components set
+            This is useful when target components within a fuel block need to be determined on-the-fly.
+
+        Notes
+        -----
+        - percents may be positive (expansion) or negative (contraction)
+        """
+        self.setAssembly(a, setFuel)
+        self.expansionData.setExpansionFactors(componentLst, percents)
+        self.axiallyExpandAssembly()
+
+    def performThermalAxialExpansion(
+        self,
+        a,
+        tempGrid: list,
+        tempField: list,
+        setFuel: bool = True,
+        expandFromTinputToThot: bool = False,
+    ):
+        """Perform thermal expansion/contraction for an assembly given an axial temperature grid and
+        field.
+
+        .. impl:: Perform thermal expansion/contraction, given an axial temperature distribution
+            over an assembly.
+            :id: I_ARMI_AXIAL_EXP_THERM
+            :implements: R_ARMI_AXIAL_EXP_THERM
+
+            This method performs component-wise thermal expansion for an assembly given a discrete
+            temperature distribution over the axial length of the Assembly. In ``setAssembly``, the
+            Assembly is prepared for axial expansion by determining Component-wise axial linkage and
+            checking to see if a dummy Block is in place (necessary for ensuring conservation
+            properties). The discrete temperature distribution is then leveraged to update Component
+            temperatures and compute thermal expansion factors (via
+            ``updateComponentTempsBy1DTempField`` and ``computeThermalExpansionFactors``,
+            respectively). Finally, the axial expansion is performed in ``axiallyExpandAssembly``.
+
+        Parameters
+        ----------
+        a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
+            ARMI assembly to be changed
+        tempGrid : float, list
+            Axial temperature grid (in cm) (i.e., physical locations where temp is stored)
+        tempField : float, list
+            Temperature values (in C) along grid
+        setFuel : boolean, optional
+            Boolean to determine whether or not fuel blocks should have their target components set
+            This is useful when target components within a fuel block need to be determined on-the-fly.
+        expandFromTinputToThot: bool
+            determines if thermal expansion factors should be calculated from c.inputTemperatureInC
+            to c.temperatureInC (True) or some other reference temperature and c.temperatureInC (False)
+        """
+        self.setAssembly(a, setFuel, expandFromTinputToThot)
+        self.expansionData.updateComponentTempsBy1DTempField(tempGrid, tempField)
+        self.expansionData.computeThermalExpansionFactors()
+        self.axiallyExpandAssembly()
+
+    def reset(self):
+        self.linked = None
+        self.expansionData = None
+
+    def setAssembly(self, a, setFuel=True, expandFromTinputToThot=False):
+        """Set the armi assembly to be changed and init expansion data class for assembly.
+
+        Parameters
+        ----------
+         a : :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
+            ARMI assembly to be changed
+        setFuel : boolean, optional
+            Boolean to determine whether or not fuel blocks should have their target components set
+            This is useful when target components within a fuel block need to be determined on-the-fly.
+        expandFromTinputToThot: bool
+            determines if thermal expansion factors should be calculated from c.inputTemperatureInC
+            to c.temperatureInC (True) or some other reference temperature and c.temperatureInC (False)
+
+        Notes
+        -----
+        When considering thermal expansion, if there is an axial temperature distribution on the
+        assembly, the axial expansion methodology will NOT perfectly preserve mass. The magnitude of
+        the gradient of the temperature distribution is the primary factor in determining the
+        cumulative loss of mass conservation.
+        """
+        self.linked = AssemblyAxialLinkage(a)
+        self.expansionData = ExpansionData(
+            a, setFuel=setFuel, expandFromTinputToThot=expandFromTinputToThot
+        )
+        self._isTopDummyBlockPresent()
+
+    def applyColdHeightMassIncrease(self):
+        """
+        Increase component mass because they are declared at cold dims.
+
+        Notes
+        -----
+        A cold 1 cm tall component will have more mass that a component with the
+        same mass/length as a component with a hot height of 1 cm. This should be
+        called when the setting `inputHeightsConsideredHot` is used. This adjusts
+        the expansion factor applied during applyMaterialMassFracsToNumberDensities.
+        """
+        for c in self.linked.a.getComponents():
+            axialExpansionFactor = 1.0 + c.material.linearExpansionFactor(
+                c.temperatureInC, c.inputTemperatureInC
+            )
+            c.changeNDensByFactor(axialExpansionFactor)
+
+    def _isTopDummyBlockPresent(self):
+        """Determines if top most block of assembly is a dummy block.
+
+        Notes
+        -----
+        - If true, then axial expansion will be physical for all blocks.
+        - If false, the top most block in the assembly is artificially chopped
+          to preserve the assembly height. A runLog.Warning also issued.
+        """
+        blkLst = self.linked.a.getBlocks()
+        if not blkLst[-1].hasFlags(Flags.DUMMY):
+            runLog.warning(
+                f"No dummy block present at the top of {self.linked.a}! "
+                "Top most block will be artificially chopped "
+                "to preserve assembly height"
+            )
+            if self._detailedAxialExpansion:
+                msg = "Cannot run detailedAxialExpansion without a dummy block at the top of the assembly!"
+                runLog.error(msg)
+                raise RuntimeError(msg)
+
+    def axiallyExpandAssembly(self):
+        """Utilizes assembly linkage to do axial expansion.
+
+        .. impl:: Preserve the total height of an ARMI assembly, during expansion.
+            :id: I_ARMI_ASSEM_HEIGHT_PRES
+            :implements: R_ARMI_ASSEM_HEIGHT_PRES
+
+            The total height of an Assembly is preserved by not changing the ``ztop`` position
+            of the top-most Block in an Assembly. The ``zbottom`` of the top-most Block is
+            adjusted to match the Block immediately below it. The ``height`` of the
+            top-most Block is is then updated to reflect any expansion/contraction.
+        """
+        mesh = [0.0]
+        numOfBlocks = self.linked.a.countBlocksWithFlags()
+        runLog.debug(
+            "Printing component expansion information (growth percentage and 'target component')"
+            f"for each block in assembly {self.linked.a}."
+        )
+        for ib, b in enumerate(self.linked.a):
+            runLog.debug(msg=f"  Block {b}")
+            blockHeight = b.getHeight()
+            # set bottom of block equal to top of block below it
+            # if ib == 0, leave block bottom = 0.0
+            if ib > 0:
+                b.p.zbottom = self.linked.linkedBlocks[b][0].p.ztop
+            isDummyBlock = ib == (numOfBlocks - 1)
+            if not isDummyBlock:
+                for c in getSolidComponents(b):
+                    growFrac = self.expansionData.getExpansionFactor(c)
+                    runLog.debug(msg=f"      Component {c}, growFrac = {growFrac:.4e}")
+                    c.height = growFrac * blockHeight
+                    # align linked components
+                    if ib == 0:
+                        c.zbottom = 0.0
+                    else:
+                        if self.linked.linkedComponents[c][0] is not None:
+                            # use linked components below
+                            c.zbottom = self.linked.linkedComponents[c][0].ztop
+                        else:
+                            # otherwise there aren't any linked components
+                            # so just set the bottom of the component to
+                            # the top of the block below it
+                            c.zbottom = self.linked.linkedBlocks[b][0].p.ztop
+                    c.ztop = c.zbottom + c.height
+                    # update component number densities
+                    newNumberDensities = {
+                        nuc: c.getNumberDensity(nuc) / growFrac
+                        for nuc in c.getNuclides()
+                    }
+                    c.setNumberDensities(newNumberDensities)
+                    # redistribute block boundaries if on the target component
+                    if self.expansionData.isTargetComponent(c):
+                        b.p.ztop = c.ztop
+                        b.p.height = b.p.ztop - b.p.zbottom
+            else:
+                b.p.height = b.p.ztop - b.p.zbottom
+
+            b.p.z = b.p.zbottom + b.getHeight() / 2.0
+
+            _checkBlockHeight(b)
+            # Call Component.clearCache to update the Component volume, and therefore the masses,
+            # of all solid components.
+            for c in getSolidComponents(b):
+                c.clearCache()
+            # redo mesh -- functionality based on assembly.calculateZCoords()
+            mesh.append(b.p.ztop)
+            b.spatialLocator = self.linked.a.spatialGrid[0, 0, ib]
+
+        bounds = list(self.linked.a.spatialGrid._bounds)
+        bounds[2] = array(mesh)
+        self.linked.a.spatialGrid._bounds = tuple(bounds)
+
+    def manageCoreMesh(self, r):
+        """Manage core mesh post assembly-level expansion.
+
+        Parameters
+        ----------
+        r : :py:class:`Reactor <armi.reactor.reactors.Reactor>`
+            ARMI reactor to have mesh modified
+
+        Notes
+        -----
+        - if no detailedAxialExpansion, then do "cheap" approach to uniformMesh converter.
+        - update average core mesh values with call to r.core.updateAxialMesh()
+        - oldMesh will be None during initial core construction at processLoading as it has not yet
+          been set.
+        """
+        if not self._detailedAxialExpansion:
+            # loop through again now that the reference is adjusted and adjust the non-fuel assemblies.
+            for a in r.core.getAssemblies():
+                a.setBlockMesh(r.core.refAssem.getAxialMesh())
+
+        oldMesh = r.core.p.axialMesh
+        r.core.updateAxialMesh()
+        if oldMesh:
+            runLog.extra("Updated r.core.p.axialMesh (old, new)")
+            for old, new in zip(oldMesh, r.core.p.axialMesh):
+                runLog.extra(f"{old:.6e}\t{new:.6e}")
+
+
+def _checkBlockHeight(b):
+    """
+    Do some basic block height validation.
+
+    Notes
+    -----
+    3cm is a presumptive lower threshhold for DIF3D
+    """
+    if b.getHeight() < 3.0:
+        runLog.debug(
+            "Block {0:s} ({1:s}) has a height less than 3.0 cm. ({2:.12e})".format(
+                b.name, str(b.p.flags), b.getHeight()
+            )
+        )
+
+    if b.getHeight() < 0.0:
+        raise ArithmeticError(
+            "Block {0:s} ({1:s}) has a negative height! ({2:.12e})".format(
+                b.name, str(b.p.flags), b.getHeight()
+            )
+        )
diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py
new file mode 100644
index 00000000..bf8259d9
--- /dev/null
+++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py
@@ -0,0 +1,310 @@
+# Copyright 2024 TerraPower, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# 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.
+"""Data container for axial expansion."""
+
+from statistics import mean
+from typing import List
+
+from armi import runLog
+from armi.materials import material
+from armi.reactor.flags import Flags
+
+TARGET_FLAGS_IN_PREFERRED_ORDER = [
+    Flags.FUEL,
+    Flags.CONTROL,
+    Flags.POISON,
+    Flags.SHIELD,
+    Flags.SLUG,
+]
+
+
+def getSolidComponents(b):
+    """
+    Return list of components in the block that have solid material.
+
+    Notes
+    -----
+    Axial expansion only needs to be applied to solid materials. We should not update
+    number densities on fluid materials to account for changes in block height.
+    """
+    return [c for c in b if not isinstance(c.material, material.Fluid)]
+
+
+class ExpansionData:
+    """Data container for axial expansion."""
+
+    def __init__(self, a, setFuel: bool, expandFromTinputToThot: bool):
+        """
+        Parameters
+        ----------
+        a: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`
+            Assembly to assign component-wise expansion data to
+        setFuel: bool
+            used to determine if fuel component should be set as
+            axial expansion target component during initialization.
+            see self._isFuelLocked
+        expandFromTinputToThot: bool
+            determines if thermal expansion factors should be calculated
+            from c.inputTemperatureInC to c.temperatureInC (True) or some other
+            reference temperature and c.temperatureInC (False)
+        """
+        self._a = a
+        self.componentReferenceTemperature = {}
+        self._expansionFactors = {}
+        self._componentDeterminesBlockHeight = {}
+        self._setTargetComponents(setFuel)
+        self.expandFromTinputToThot = expandFromTinputToThot
+
+    def setExpansionFactors(self, componentLst: List, expFrac: List):
+        """Sets user defined expansion fractions.
+
+        Parameters
+        ----------
+        componentLst : List[:py:class:`Component <armi.reactor.components.component.Component>`]
+            list of Components to have their heights changed
+        expFrac : List[float]
+            list of L1/L0 height changes that are to be applied to componentLst
+
+        Raises
+        ------
+        RuntimeError
+            If componentLst and expFrac are different lengths
+        """
+        if len(componentLst) != len(expFrac):
+            runLog.error(
+                "Number of components and expansion fractions must be the same!\n"
+                f"    len(componentLst) = {len(componentLst)}\n"
+                f"        len(expFrac) = {len(expFrac)}"
+            )
+            raise RuntimeError
+        if 0.0 in expFrac:
+            msg = (
+                "An expansion fraction, L1/L0, equal to 0.0, is not physical. Expansion fractions "
+                "should be greater than 0.0."
+            )
+            runLog.error(msg)
+            raise RuntimeError(msg)
+        for exp in expFrac:
+            if exp < 0.0:
+                msg = (
+                    "A negative expansion fraction, L1/L0, is not physical. Expansion fractions "
+                    "should be greater than 0.0."
+                )
+                runLog.error(msg)
+                raise RuntimeError(msg)
+        for c, p in zip(componentLst, expFrac):
+            self._expansionFactors[c] = p
+
+    def updateComponentTempsBy1DTempField(self, tempGrid, tempField):
+        """Assign a block-average axial temperature to components.
+
+        Parameters
+        ----------
+        tempGrid : numpy array
+            1D axial temperature grid (i.e., physical locations where temp is stored)
+        tempField : numpy array
+            temperature values along grid
+
+        Notes
+        -----
+        - given a 1D axial temperature grid and distribution, searches for temperatures that fall
+          within the bounds of a block, and averages them
+        - this average temperature is then passed to self.updateComponentTemp()
+
+        Raises
+        ------
+        ValueError
+            if no temperature points found within a block
+        RuntimeError
+            if tempGrid and tempField are different lengths
+        """
+        if len(tempGrid) != len(tempField):
+            runLog.error("tempGrid and tempField must have the same length.")
+            raise RuntimeError
+
+        self.componentReferenceTemperature = {}  # reset, just to be safe
+        for b in self._a:
+            tmpMapping = []
+            for idz, z in enumerate(tempGrid):
+                if b.p.zbottom <= z <= b.p.ztop:
+                    tmpMapping.append(tempField[idz])
+                if z > b.p.ztop:
+                    break
+
+            if len(tmpMapping) == 0:
+                raise ValueError(
+                    f"{b} has no temperature points within it!"
+                    "Likely need to increase the refinement of the temperature grid."
+                )
+
+            blockAveTemp = mean(tmpMapping)
+            for c in b:
+                self.updateComponentTemp(c, blockAveTemp)
+
+    def updateComponentTemp(self, c, temp: float):
+        """Update component temperatures with a provided temperature.
+
+        Parameters
+        ----------
+        c : :py:class:`Component <armi.reactor.components.component.Component>`
+            component to which the temperature, temp, is to be applied
+        temp : float
+            new component temperature in C
+
+        Notes
+        -----
+        - "reference" height and temperature are the current states; i.e. before
+           1) the new temperature, temp, is applied to the component, and
+           2) the component is axially expanded
+        """
+        self.componentReferenceTemperature[c] = c.temperatureInC
+        c.setTemperature(temp)
+
+    def computeThermalExpansionFactors(self):
+        """Computes expansion factors for all components via thermal expansion."""
+        for b in self._a:
+            for c in getSolidComponents(b):
+                if self.expandFromTinputToThot:
+                    # get thermal expansion factor between c.inputTemperatureInC & c.temperatureInC
+                    self._expansionFactors[c] = c.getThermalExpansionFactor()
+                elif c in self.componentReferenceTemperature:
+                    growFrac = c.getThermalExpansionFactor(
+                        T0=self.componentReferenceTemperature[c]
+                    )
+                    self._expansionFactors[c] = growFrac
+                else:
+                    # We want expansion factors relative to componentReferenceTemperature not
+                    # Tinput. But for this component there isn't a componentReferenceTemperature, so
+                    # we'll assume that the expansion factor is 1.0.
+                    self._expansionFactors[c] = 1.0
+
+    def getExpansionFactor(self, c):
+        """Retrieves expansion factor for c.
+
+        Parameters
+        ----------
+        c : :py:class:`Component <armi.reactor.components.component.Component>`
+            Component to retrive expansion factor for
+        """
+        value = self._expansionFactors.get(c, 1.0)
+        return value
+
+    def _setTargetComponents(self, setFuel):
+        """Sets target component for each block.
+
+        Parameters
+        ----------
+        setFuel : bool
+            boolean to determine if fuel block should have its target component set. Useful for when
+            target components should be determined on the fly.
+        """
+        for b in self._a:
+            if b.p.axialExpTargetComponent:
+                self._componentDeterminesBlockHeight[
+                    b.getComponentByName(b.p.axialExpTargetComponent)
+                ] = True
+            elif b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP):
+                self.determineTargetComponent(b, Flags.CLAD)
+            elif b.hasFlags(Flags.DUMMY):
+                self.determineTargetComponent(b, Flags.COOLANT)
+            elif setFuel and b.hasFlags(Flags.FUEL):
+                self._isFuelLocked(b)
+            else:
+                self.determineTargetComponent(b)
+
+    def determineTargetComponent(self, b, flagOfInterest=None):
+        """Determines target component, stores it on the block, and appends it to
+        self._componentDeterminesBlockHeight.
+
+        Parameters
+        ----------
+        b : :py:class:`Block <armi.reactor.blocks.Block>`
+            block to specify target component for
+        flagOfInterest : :py:class:`Flags <armi.reactor.flags.Flags>`
+            the flag of interest to identify the target component
+
+        Notes
+        -----
+        - if flagOfInterest is None, finds the component within b that contains flags that
+          are defined in a preferred order of flags, or barring that, in b.p.flags
+        - if flagOfInterest is not None, finds the component that contains the flagOfInterest.
+
+        Raises
+        ------
+        RuntimeError
+            no target component found
+        RuntimeError
+            multiple target components found
+        """
+        if flagOfInterest is None:
+            # Follow expansion of most neutronically important component, fuel then control/poison
+            for targetFlag in TARGET_FLAGS_IN_PREFERRED_ORDER:
+                componentWFlag = [c for c in b.getChildren() if c.hasFlags(targetFlag)]
+                if componentWFlag != []:
+                    break
+            # some blocks/components are not included in the above list but should still be found
+            if not componentWFlag:
+                componentWFlag = [c for c in b.getChildren() if c.p.flags in b.p.flags]
+        else:
+            componentWFlag = [c for c in b.getChildren() if c.hasFlags(flagOfInterest)]
+        if len(componentWFlag) == 0:
+            # if only 1 solid, be smart enought to snag it
+            solidMaterials = list(
+                c for c in b if not isinstance(c.material, material.Fluid)
+            )
+            if len(solidMaterials) == 1:
+                componentWFlag = solidMaterials
+        if len(componentWFlag) == 0:
+            raise RuntimeError(f"No target component found!\n   Block {b}")
+        if len(componentWFlag) > 1:
+            raise RuntimeError(
+                "Cannot have more than one component within a block that has the target flag!"
+                f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {componentWFlag}"
+            )
+        self._componentDeterminesBlockHeight[componentWFlag[0]] = True
+        b.p.axialExpTargetComponent = componentWFlag[0].name
+
+    def _isFuelLocked(self, b):
+        """Physical/realistic implementation reserved for ARMI plugin.
+
+        Parameters
+        ----------
+        b : :py:class:`Block <armi.reactor.blocks.Block>`
+            block to specify target component for
+
+        Raises
+        ------
+        RuntimeError
+            multiple fuel components found within b
+
+        Notes
+        -----
+        - This serves as an example to check for fuel/clad locking/interaction found in SFRs.
+        - A more realistic/physical implementation is reserved for ARMI plugin(s).
+        """
+        c = b.getComponent(Flags.FUEL)
+        if c is None:
+            raise RuntimeError(f"No fuel component within {b}!")
+        self._componentDeterminesBlockHeight[c] = True
+        b.p.axialExpTargetComponent = c.name
+
+    def isTargetComponent(self, c):
+        """Returns bool if c is a target component.
+
+        Parameters
+        ----------
+        c : :py:class:`Component <armi.reactor.components.component.Component>`
+            Component to check target component status
+        """
+        return bool(c in self._componentDeterminesBlockHeight)
diff --git a/armi/resources/config/submitter.cfg b/armi/resources/config/submitter.cfg
deleted file mode 100644
index 24248c8e..00000000
--- a/armi/resources/config/submitter.cfg
+++ /dev/null
@@ -1,14 +0,0 @@
-[settings]
-recent_files
-write_style = short
-
-[warnings]
-allow_warnings = True
-condensed_warnings = True
-job_ended = False
-
-[window]
-maximized
-sash_position = -160
-screen_position = (-1, -1)
-size = (1125, 720)
diff --git a/armi/utils/directoryChangersMpi.py b/armi/utils/directoryChangersMpi.py
index b479722c..85c665b8 100644
--- a/armi/utils/directoryChangersMpi.py
+++ b/armi/utils/directoryChangersMpi.py
@@ -54,6 +54,7 @@ class MpiDirectoryChanger(directoryChangers.DirectoryChanger):
 
     def close(self):
         cdma = _ChangeDirectoryMpiAction(self.initial)
+        cdma = cdma.broadcast(cdma)
         cdma.invoke(None, None, None)
 
 
diff --git a/armi/utils/hexagon.py b/armi/utils/hexagon.py
index 3f9b389b..126c55e1 100644
--- a/armi/utils/hexagon.py
+++ b/armi/utils/hexagon.py
@@ -145,3 +145,49 @@ def numPositionsInRing(ring):
         rings is indexed to 1, i.e. the centermost position in the lattice is ``ring=1``.
     """
     return (ring - 1) * 6 if ring != 1 else 1
+
+
+def totalPositionsUpToRing(ring: int) -> int:
+    """Return the number of positions in a hexagon with a given number of rings."""
+    return 1 + 3 * ring * (ring - 1)
+
+
+def getIndexOfRotatedCell(initialCellIndex: int, orientationNumber: int) -> int:
+    """Obtain a new cell number after placing a hexagon in a new orientation.
+
+    Parameters
+    ----------
+    initialCellIndex : int
+        Positive number for this cell's position in a hexagonal lattice.
+    orientationNumber :
+        Orientation in number of 60 degree, counter clockwise rotations. An orientation
+        of zero means the first cell in each ring of a flags up hexagon is in the upper
+        right corner.
+
+    Returns
+    -------
+    int
+        New cell number across the rotation
+
+    Raises
+    ------
+    ValueError
+        If ``initialCellIndex`` is not positive.
+        If ``orientationNumber`` is less than zero or greater than five.
+    """
+    if orientationNumber < 0 or orientationNumber > 5:
+        raise ValueError(
+            f"Orientation number must be in [0:5], got {orientationNumber}"
+        )
+    if initialCellIndex > 1:
+        if orientationNumber == 0:
+            return initialCellIndex
+        ring = numRingsToHoldNumCells(initialCellIndex)
+        tot_pins = totalPositionsUpToRing(ring)
+        newPinLocation = initialCellIndex + (ring - 1) * orientationNumber
+        if newPinLocation > tot_pins:
+            newPinLocation -= (ring - 1) * 6
+        return newPinLocation
+    elif initialCellIndex == 1:
+        return initialCellIndex
+    raise ValueError(f"Cell number must be positive, got {initialCellIndex}")
diff --git a/armi/utils/iterables.py b/armi/utils/iterables.py
index 8f672041..ce204aa0 100644
--- a/armi/utils/iterables.py
+++ b/armi/utils/iterables.py
@@ -18,6 +18,8 @@ import struct
 
 from six.moves import filterfalse, map, xrange, filter
 
+import numpy as np
+
 
 def flatten(lst):
     """Flattens an iterable of iterables by one level.
@@ -241,3 +243,27 @@ class Sequence:
     def __iadd__(self, other):
         self.extend(Sequence(other))
         return self
+
+
+def pivot(items, position: int):
+    """Pivot the items in an iterable to start at a given position.
+
+    Functionally just ``items[position:] + items[:position]`` with
+    some logic to handle numpy arrays (concatenation not summation)
+
+    Parameters
+    ----------
+    items : list or numpy.ndarray
+        Sequence to be re-ordered
+    position : int
+        Position that will be the first item in the sequence after the pivot
+
+    Returns
+    -------
+    list or numpy.ndarray
+    """
+    if isinstance(items, np.ndarray):
+        return np.concatenate((items[position:], items[:position]))
+    elif isinstance(items, list):
+        return items[position:] + items[:position]
+    raise TypeError(f"Pivoting {type(items)} not supported : {items}")
diff --git a/doc/developer/standards_and_practices.rst b/doc/developer/standards_and_practices.rst
index be2be833..7b864180 100644
--- a/doc/developer/standards_and_practices.rst
+++ b/doc/developer/standards_and_practices.rst
@@ -91,7 +91,7 @@ functions, and methods and their signatures (the signature includes the paramete
 
 * Variables that you designate as unused should be prefaced with an underscore (``_``).
 * Do not use Python `reserved keywords <https://realpython.com/lessons/reserved-keywords/>`_ as variable names.
-* Try to use names that are pronounceable. (Well-established variable names from equations are exceptable.)
+* Try to use names that are pronounceable. (Well-established variable names from equations are acceptable.)
 * Keep names concise and expressive. (An exception is test method names, which may be longer and more
   descriptive.)
 * Avoid abbreviations and acronyms, unless they are well understood by subject-matter experts (e.g. DB for database,
@@ -195,7 +195,7 @@ Avoid repeating code
 ====================
 In other words, don't repeat yourself. (`D. R. Y. <https://en.wikipedia.org/wiki/Don't_repeat_yourself>`_).
 Repetitious code is harder to read, and harderd for others to update. If you ever find yourself copying and pasting
-code, consider pulling the repeated code out into it's own function, or using a loop.
+code, consider pulling the repeated code out into its own function, or using a loop.
 
 Public methods should have docstrings
 =====================================
@@ -298,8 +298,8 @@ Input files
 ARMI developers **shall** use one of the following well-defined, Python-supported, input file formats.
 
 .json
-    JSON files are used for a variety of data-object representations. There are some limitations of JSON, in that it
-    does not easily support comments. JSON is also very strict.
+    JSON files are used for a variety of data-object representations. There are some limitations of
+    JSON, in that it does not easily support comments. JSON is also very strict.
 
 .yaml
     YAML files are like JSON files but can have comments in them.
@@ -307,8 +307,14 @@ ARMI developers **shall** use one of the following well-defined, Python-supporte
 General do's and don'ts
 =======================
 
-do not use ``print``
+Do not use ``print``
     ARMI code should not use the ``print`` function; use one of the methods within ``armi.runLog``.
 
 Do not add new ``TODO`` statements in your commits and PRs.
-    If your new ``TODO`` statement is important, it should be a GitHub Issue. Yes, we have existing ``TODO`` statements in the code, those are relic and need to be removed. Also, never mark the code with ``FIXME`` or ``XXX```; open a ticket.
+    If your new ``TODO`` statement is important, it should be a GitHub Issue. Yes, we have existing
+    ``TODO`` statements in the code, those are relic and need to be removed. Similarly, never mark
+    the code with ``FIXME`` or ``XXX```; open a ticket.
+
+Do not link GitHub tickets or PRs in code.
+    The idea in ARMI is that either something is worth documenting well in a docstring, or the docs,
+    or it is not. And just linking a ticket or PR in a docstring is not helpful.
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 7696b0d3..700d90bd 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -11,6 +11,9 @@ New Features
 #. ARMI now supports Python 3.12. (`PR#1813 <https://github.com/terrapower/armi/pull/1813>`_)
 #. Removing the ``tabulate`` dependency by ingesting it to ``armi.utils.tabulate``. (`PR#1811 <https://github.com/terrapower/armi/pull/1811>`_)
 #. Adding ``--skip-inspection`` flag to ``CompareCases`` CLI. (`PR#1842 <https://github.com/terrapower/armi/pull/1842>`_)
+#. Provide utilities for determining location of a rotated object in a hexagonal lattice:
+   :func:`armi.utils.hexagon.getIndexOfRotatedCell`
+   (`PR#1846 <https://github.com/terrapower/armi/1846`)
 #. Allow merging a component with zero area into another component (`PR#1858 <https://github.com/terrapower/armi/pull/1858>`_)
 #. TBD
 
@@ -20,18 +23,24 @@ API Changes
 #. Removing deprecated method ``prepSearch``. (`PR#1845 <https://github.com/terrapower/armi/pull/1845>`_)
 #. Removing unused function ``SkippingXsGen_BuChangedLessThanTolerance``. (`PR#1845 <https://github.com/terrapower/armi/pull/1845>`_)
 #. Allow for unknown Flags when opening a DB. (`PR#1844 <https://github.com/terrapower/armi/pull/1835>`_)
+#. ``Assembly.rotatePins`` and ``Block.rotatePins`` have been removed. Prefer to use
+   :meth:`armi.reactor.assemblies.Assembly.rotate` and meth:`armi.reactor.block.Block.rotate`
+   (`PR#1846 <https://github.com/terrapower/armi/1846`)
 #. TBD
 
 Bug Fixes
 ---------
 #. Fixed ``DerivedShape.getArea`` for ``cold=True``. (`PR#1831 <https://github.com/terrapower/armi/pull/1831>`_)
 #. Fixed error parsing command line integers in ``ReportsEntryPoint``. (`PR#1824 <https://github.com/terrapower/armi/pull/1824>`_)
+#. Fixed ``PermissionError`` when using ``syncDbAfterWrite``. (`PR#1857 <https://github.com/terrapower/armi/pull/1857>`_)
+#. Fixed ``MpiDirectoryChanger``. (`PR#1853 <https://github.com/terrapower/armi/pull/1853>`_)
 #. Changed data type of ``thKernel`` setting from ``bool`` to ``str`` in ``ThermalHydraulicsPlugin``. (`PR#1855 <https://github.com/terrapower/armi/pull/1855>`_)
 #. TBD
 
 Quality Work
 ------------
 #. Removing deprecated code (``axialUnitGrid``) (`PR#1809 <https://github.com/terrapower/armi/pull/1809>`_)
+#. Refactoring (``axialExpansionChanger``) (`PR#1861 <https://github.com/terrapower/armi/pull/1861>`_)
 #. TBD
 
 Changes that Affect Requirements
diff --git a/pyproject.toml b/pyproject.toml
index 8d74fb8b..f56f1fba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -326,7 +326,6 @@ armi = [
     "tests/refSmallReactorBase.yaml",
     "tests/refSmallSfpGrid.yaml",
     "tests/refTestCartesian.yaml",
-    "tests/sfpGeom.yaml",
     "tests/smallestTestReactor/armiRunSmallest.yaml",
     "tests/smallestTestReactor/refOneBlockReactor.yaml",
     "tests/smallestTestReactor/refSmallestReactor.yaml",


Test patch:
diff --git a/.github/workflows/unittests.yaml b/.github/workflows/unittests.yaml
index dafc04a8..d53b8b4d 100644
--- a/.github/workflows/unittests.yaml
+++ b/.github/workflows/unittests.yaml
@@ -35,3 +35,4 @@ jobs:
           pytest -n 4 armi
           mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --ignore=venv armi/tests/test_mpiFeatures.py || true
           mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --ignore=venv armi/tests/test_mpiParameters.py || true
+          mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=pyproject.toml -m pytest --cov=armi --cov-config=pyproject.toml --ignore=venv armi/utils/tests/test_directoryChangersMpi.py || true
diff --git a/armi/bookkeeping/db/tests/test_databaseInterface.py b/armi/bookkeeping/db/tests/test_databaseInterface.py
index 664ed193..b1542dfa 100644
--- a/armi/bookkeeping/db/tests/test_databaseInterface.py
+++ b/armi/bookkeeping/db/tests/test_databaseInterface.py
@@ -194,6 +194,55 @@ class TestDatabaseInterface(unittest.TestCase):
         self.o._timeNodeLoop(0, 0)
         self.assertTrue(self.dbi._db.hasTimeStep(0, 0))
 
+    def test_syncDbAfterWrite(self):
+        """
+        Test to ensure that the fast-path database is copied to working
+        directory at every time node when ``syncDbAfterWrite`` is ``True``.
+        """
+        r = self.r
+
+        self.o.cs["syncDbAfterWrite"] = True
+        self.o.cs["burnSteps"] = 2  # make test insensitive to burn steps
+
+        self.dbi.interactBOL()
+        self.assertFalse(os.path.exists(self.dbi.database.fileName))
+
+        # Go through a few time nodes to ensure appending is working
+        for timeNode in range(self.o.cs["burnSteps"]):
+            r.p.cycle = 0
+            r.p.timeNode = timeNode
+            self.dbi.interactEveryNode(r.p.cycle, r.p.timeNode)
+
+            # The file should have been copied to working directory
+            self.assertTrue(os.path.exists(self.dbi.database.fileName))
+
+            # The copied file should have the newest time node
+            with Database3(self.dbi.database.fileName, "r") as db:
+                for tn in range(timeNode + 1):
+                    self.assertTrue(db.hasTimeStep(r.p.cycle, tn))
+
+            # The in-memory database should have been reloaded properly
+            for tn in range(timeNode + 1):
+                self.assertTrue(self.dbi.database.hasTimeStep(r.p.cycle, tn))
+
+        # Make sure EOL runs smoothly
+        self.dbi.interactEOL()
+        self.assertTrue(os.path.exists(self.dbi.database.fileName))
+
+    def test_noSyncDbAfterWrite(self):
+        """
+        Test to ensure that the fast-path database is NOT copied to working
+        directory at every time node when ``syncDbAfterWrite`` is ``False``.
+        """
+        self.o.cs["syncDbAfterWrite"] = False
+
+        self.dbi.interactBOL()
+        self.assertFalse(os.path.exists(self.dbi.database.fileName))
+        self.dbi.interactEveryNode(0, 0)
+        self.assertFalse(os.path.exists(self.dbi.database.fileName))
+        self.dbi.interactEOL()
+        self.assertTrue(os.path.exists(self.dbi.database.fileName))
+
 
 class TestDatabaseWriter(unittest.TestCase):
     def setUp(self):
diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py
index 9edfec33..9906952e 100644
--- a/armi/reactor/converters/tests/test_axialExpansionChanger.py
+++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py
@@ -18,6 +18,8 @@ import os
 import unittest
 from statistics import mean
 
+from numpy import array, linspace, zeros
+
 from armi import materials
 from armi.materials import _MATERIAL_NAMESPACE_ORDER, custom
 from armi.reactor.assemblies import HexAssembly, grids
@@ -28,14 +30,15 @@ from armi.reactor.components.complexShapes import Helix
 from armi.reactor.converters.axialExpansionChanger import (
     AxialExpansionChanger,
     ExpansionData,
-    _determineLinked,
     getSolidComponents,
 )
+from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import (
+    _determineLinked,
+)
 from armi.reactor.flags import Flags
 from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings
 from armi.tests import TEST_ROOT
 from armi.utils import units
-from numpy import array, linspace, zeros
 
 
 class AxialExpansionTestBase(unittest.TestCase):
diff --git a/armi/reactor/tests/test_assemblies.py b/armi/reactor/tests/test_assemblies.py
index 477f7067..978e8b3e 100644
--- a/armi/reactor/tests/test_assemblies.py
+++ b/armi/reactor/tests/test_assemblies.py
@@ -1090,6 +1090,9 @@ class Assembly_TestCase(unittest.TestCase):
             a.rotate(math.radians(120))
             self.assertIn("No rotation method defined", mock.getStdout())
 
+        with self.assertRaisesRegex(ValueError, expected_regex="60 degree"):
+            a.rotate(math.radians(40))
+
     def test_assem_block_types(self):
         """Test that all children of an assembly are blocks, ordered from top to bottom.
 
diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py
index abd6d547..0645a76c 100644
--- a/armi/reactor/tests/test_blocks.py
+++ b/armi/reactor/tests/test_blocks.py
@@ -1454,33 +1454,33 @@ class Block_TestCase(unittest.TestCase):
     def test_rotatePins(self):
         b = self.block
         b.setRotationNum(0)
-        index = b.rotatePins(0, justCompute=True)
+        index = b._rotatePins(0, justCompute=True)
         self.assertEqual(b.getRotationNum(), 0)
         self.assertEqual(index[5], 5)
         self.assertEqual(index[2], 2)  # pin 1 is center and never rotates.
 
-        index = b.rotatePins(1)
+        index = b._rotatePins(1)
         self.assertEqual(b.getRotationNum(), 1)
         self.assertEqual(index[2], 3)
         self.assertEqual(b.p.pinLocation[1], 3)
 
-        index = b.rotatePins(1)
+        index = b._rotatePins(1)
         self.assertEqual(b.getRotationNum(), 2)
         self.assertEqual(index[2], 4)
         self.assertEqual(b.p.pinLocation[1], 4)
 
-        index = b.rotatePins(2)
-        index = b.rotatePins(4)  # over-rotate to check modulus
+        index = b._rotatePins(2)
+        index = b._rotatePins(4)  # over-rotate to check modulus
         self.assertEqual(b.getRotationNum(), 2)
         self.assertEqual(index[2], 4)
         self.assertEqual(index[6], 2)
         self.assertEqual(b.p.pinLocation[1], 4)
         self.assertEqual(b.p.pinLocation[5], 2)
 
-        self.assertRaises(ValueError, b.rotatePins, -1)
-        self.assertRaises(ValueError, b.rotatePins, 10)
-        self.assertRaises((ValueError, TypeError), b.rotatePins, None)
-        self.assertRaises((ValueError, TypeError), b.rotatePins, "a")
+        self.assertRaises(ValueError, b._rotatePins, -1)
+        self.assertRaises(ValueError, b._rotatePins, 10)
+        self.assertRaises((ValueError, TypeError), b._rotatePins, None)
+        self.assertRaises((ValueError, TypeError), b._rotatePins, "a")
 
     def test_expandElementalToIsotopics(self):
         r"""Tests the expand to elementals capability."""
@@ -2627,3 +2627,23 @@ class MassConservationTests(unittest.TestCase):
             10,
             "Sum of component mass {0} != total block mass {1}. ".format(tMass, bMass),
         )
+
+
+class EmptyBlockRotateTest(unittest.TestCase):
+    """Rotation tests on an empty hexagonal block.
+
+    Useful for enforcing rotation works on blocks without pins.
+
+    """
+
+    def setUp(self):
+        self.block = blocks.HexBlock("empty")
+
+    def test_orientation(self):
+        """Test the orientation parameter is updated on a rotated empty block."""
+        rotDegrees = 60
+        preRotateOrientation = self.block.p.orientation[2]
+        self.block.rotate(math.radians(rotDegrees))
+        postRotationOrientation = self.block.p.orientation[2]
+        self.assertNotEqual(preRotateOrientation, postRotationOrientation)
+        self.assertEqual(postRotationOrientation, rotDegrees)
diff --git a/armi/reactor/tests/test_rz_reactors.py b/armi/reactor/tests/test_rz_reactors.py
index 8f46627b..a3b86cad 100644
--- a/armi/reactor/tests/test_rz_reactors.py
+++ b/armi/reactor/tests/test_rz_reactors.py
@@ -18,8 +18,8 @@ import os
 import unittest
 
 from armi import settings
-from armi.tests import TEST_ROOT
 from armi.reactor import reactors
+from armi.tests import TEST_ROOT
 
 
 class TestRZTReactor(unittest.TestCase):
@@ -48,12 +48,11 @@ class TestRZTReactorModern(unittest.TestCase):
         """
         The Godiva benchmark model is a HEU sphere with a radius of 8.74 cm.
 
-        This unit tests loading and verifies the reactor is loaded correctly by
-        comparing volumes against expected volumes for full core (including
-        void boundary conditions) and just the fuel.
+        This tests loading and verifies the reactor is loaded correctly by comparing volumes against
+        expected volumes for full core (including void boundary conditions) and just the fuel.
         """
         cs = settings.Settings(
-            fName=os.path.join(TEST_ROOT, "Godiva.armi.unittest.yaml")
+            fName=os.path.join(TEST_ROOT, "godiva", "godiva.armi.unittest.yaml")
         )
         r = reactors.loadFromCs(cs)
 
@@ -69,7 +68,7 @@ class TestRZTReactorModern(unittest.TestCase):
         for b in r.core.getBlocks():
             reactorVolumes.append(b.getVolume())
             for c in b:
-                if "Godiva" in c.name:
+                if "godiva" in c.name:
                     fuelVolumes.append(c.getVolume())
         # verify the total reactor volume is as expected
         tolerance = 1e-3
diff --git a/armi/tests/Godiva-blueprints.yaml b/armi/tests/godiva/godiva-blueprints.yaml
similarity index 97%
rename from armi/tests/Godiva-blueprints.yaml
rename to armi/tests/godiva/godiva-blueprints.yaml
index 272337d4..26d508d6 100644
--- a/armi/tests/Godiva-blueprints.yaml
+++ b/armi/tests/godiva/godiva-blueprints.yaml
@@ -62,7 +62,7 @@ assemblies:
     specifier: assembly1_1
     blocks:
       - name: block1_1_1
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -85,7 +85,7 @@ assemblies:
           inner_radius: 0.0
           mult: 0.0773080587387085
       - name: block1_1_2
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -97,7 +97,7 @@ assemblies:
           inner_radius: 0.0
           mult: 1.0
       - name: block1_1_3
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -109,7 +109,7 @@ assemblies:
           inner_radius: 0.0
           mult: 1.0
       - name: block1_1_4
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -121,7 +121,7 @@ assemblies:
           inner_radius: 0.0
           mult: 1.0
       - name: block1_1_5
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -180,7 +180,7 @@ assemblies:
     specifier: assembly2_1
     blocks:
       - name: block2_1_1
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -203,7 +203,7 @@ assemblies:
           inner_radius: 3.001
           mult: 0.4045467972755432
       - name: block2_1_2
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -215,7 +215,7 @@ assemblies:
           inner_radius: 3.001
           mult: 1.0
       - name: block2_1_3
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -227,7 +227,7 @@ assemblies:
           inner_radius: 3.001
           mult: 1.0
       - name: block2_1_4
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -239,7 +239,7 @@ assemblies:
           inner_radius: 3.001
           mult: 1.0
       - name: block2_1_5
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -298,7 +298,7 @@ assemblies:
     specifier: assembly3_1
     blocks:
       - name: block3_1_1
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -321,7 +321,7 @@ assemblies:
           inner_radius: 6.002
           mult: 0.9538451991975307
       - name: block3_1_2
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -344,7 +344,7 @@ assemblies:
           inner_radius: 6.002
           mult: 0.3964693546295166
       - name: block3_1_3
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -367,7 +367,7 @@ assemblies:
           inner_radius: 6.002
           mult: 0.12437152862548828
       - name: block3_1_4
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
@@ -390,7 +390,7 @@ assemblies:
           inner_radius: 6.002
           mult: 0.4006919860839844
       - name: block3_1_5
-        Godiva:
+        godiva:
           shape: RadialSegment
           material: UZr
           Tinput: 26.85
diff --git a/armi/tests/Godiva.armi.unittest.yaml b/armi/tests/godiva/godiva.armi.unittest.yaml
similarity index 93%
rename from armi/tests/Godiva.armi.unittest.yaml
rename to armi/tests/godiva/godiva.armi.unittest.yaml
index 8aa29ea2..3ffa71b4 100644
--- a/armi/tests/Godiva.armi.unittest.yaml
+++ b/armi/tests/godiva/godiva.armi.unittest.yaml
@@ -12,7 +12,7 @@ settings:
   genReports: false
   genXS: Neutron
   groupStructure: ARMI45
-  loadingFile: Godiva-blueprints.yaml
+  loadingFile: godiva-blueprints.yaml
   neutronicsKernel: DIF3D-FD
   neutronicsOutputsToSave: All
   neutronicsType: both
diff --git a/armi/tests/sfpGeom.yaml b/armi/tests/sfpGeom.yaml
deleted file mode 100644
index e69de29b..00000000
diff --git a/armi/tests/tutorials/c5g7-blueprints.yaml b/armi/tests/tutorials/c5g7-blueprints.yaml
index a037c4b8..f2428b0d 100644
--- a/armi/tests/tutorials/c5g7-blueprints.yaml
+++ b/armi/tests/tutorials/c5g7-blueprints.yaml
@@ -46,28 +46,22 @@ custom isotopics:
         input format: number densities
         H: 6.70e-2
         O: 3.35E-2
-        B: 2.87E-5
+        B: 2.78E-5
     Zr clad:
         input format: number densities
         ZR: 4.30E-2
     Al clad:
         input format: number densities
-        AL: 6.00e-2
+        AL27: 6.00e-2
     fission chamber:
-        # Fission Chamber composition isn't well defined so this is a guess...
-        # U235 density is based on Macros of UO2 for fission XS in group 3. Group 3
-        # was chosen because it is below the U-238 fast fission cutoff, but also the
-        # U-235 fission cross section is not high enough the self shielding will play
-        # a large role. This assuming U235 is the only fissioning isotope. Assuming the
-        # fission chamber is ~90% enriched (90.9% by atom but wanted only 1 sig fig).
-        # Had very similar macro absorption XS as guide tube, so using same
-        # Al and water with just a little U.
+        # NEA/NSC/DOC(96)2 Documents:
+        # "Central guide tube contains: moderator (as defined in Table 2)
+        # and 1.0E-8 at/(b cm) of U-235"
         input format: number densities
         H: 6.70e-2
         O: 3.35E-2
-        B: 2.87E-5
-        U235: 6.0e-8
-        U238: 6.0e-9
+        B: 2.78E-5
+        U235: 1.0e-8
 # end-custom-isotopics
 blocks:
     uo2: &block_uo2
@@ -77,15 +71,15 @@ blocks:
             shape: Circle
             material: UO2
             isotopics: UO2
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             od: .8190
             latticeIDs: [U]
         gap 1: &fuel_gap_1
             shape: Circle
             material: Void
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             id: fuel.od
             od: zirconium clad.id
             latticeIDs: [U]
@@ -93,16 +87,16 @@ blocks:
             shape: Circle
             material: Custom
             isotopics: Zr clad
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             id: .8360
             od: .9500
             latticeIDs: [U]
         gap 2: &fuel_gap_2
             shape: Circle
             material: Void
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             id: zirconium clad.od
             od: aluminum clad.id
             latticeIDs: [U]
@@ -113,8 +107,8 @@ blocks:
             shape: Circle
             material: Custom
             isotopics: Al clad
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             id: .9700
             od: 1.0800
             latticeIDs: [U]
@@ -129,16 +123,16 @@ blocks:
             shape: Circle
             material: SaturatedWater
             isotopics: moderator
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             od: guide tube.id
             latticeIDs: [GT]
         guide tube: &guide_tube
             shape: Circle
             material: Custom
             isotopics: Al clad
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             id: .6800
             od: 1.0800
             latticeIDs: [GT]
@@ -155,8 +149,8 @@ blocks:
             shape: Circle
             material: Custom
             isotopics: fission chamber
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             od: .8190 # No documentation fission chamber dims of composition
             latticeIDs: [FC]
         inner moderator FC: &fission_chamber_mod
@@ -164,8 +158,8 @@ blocks:
             # has same od as fuel, so there needs to be something in the gap.
             shape: Circle
             material: Void
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             id: fission chamber.od
             od: guide tube.id
             latticeIDs: [FC]
@@ -173,8 +167,8 @@ blocks:
         # dummy component for assembly sizing
             shape: Square
             material: Void
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             widthInner: 21.42
             widthOuter: 21.42
             mult: 1.0
@@ -189,24 +183,24 @@ blocks:
             shape: Circle
             material: UO2
             isotopics: mox low
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             od: .8190
             latticeIDs: [ML]
         mox medium fuel:
             shape: Circle
             material: UO2
             isotopics: mox medium
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             od: .8190
             latticeIDs: [MM]
         mox high fuel:
             shape: Circle
             material: UO2
             isotopics: mox high
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             od: .8190
             latticeIDs: [MH]
         void 1:
@@ -236,8 +230,8 @@ blocks:
             shape: Square
             material: SaturatedWater
             isotopics: moderator
-            Tinput: 25.0
-            Thot: 25.0
+            Tinput: 20.0
+            Thot: 20.0
             widthOuter: 21.42
             mult: 1.0
 # end-block-mod
diff --git a/armi/utils/tests/test_directoryChangersMpi.py b/armi/utils/tests/test_directoryChangersMpi.py
new file mode 100644
index 00000000..45377bf0
--- /dev/null
+++ b/armi/utils/tests/test_directoryChangersMpi.py
@@ -0,0 +1,96 @@
+# Copyright 2024 TerraPower, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# 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.
+
+"""
+Test the MpiDirectoryChanger.
+
+These tests will be generally ignored by pytest if you are trying to run
+them in an environment without MPI installed.
+
+To run these tests from the command line, install MPI and mpi4py, and do:
+
+mpiexec -n 2 python -m pytest test_parallel.py
+or
+mpiexec.exe -n 2 python -m pytest test_parallel.py
+"""
+
+import os
+import shutil
+import unittest
+
+from armi import context, mpiActions
+from armi.utils.directoryChangersMpi import MpiDirectoryChanger
+
+# determine if this is a parallel run, and MPI is installed
+MPI_EXE = None
+if shutil.which("mpiexec.exe") is not None:
+    MPI_EXE = "mpiexec.exe"
+elif shutil.which("mpiexec") is not None:
+    MPI_EXE = "mpiexec"
+
+
+class RevealYourDirectory(mpiActions.MpiAction):
+    def invokeHook(self):
+        # make a dir with name corresponding to the rank, that way we can confirm
+        # that all ranks actually executed this code
+        os.mkdir(str(context.MPI_RANK))
+        return True
+
+
+class TestMPI(unittest.TestCase):
+    def setUp(self):
+        self.targetDir = "mpiDir"
+        if context.MPI_RANK == 0:
+            os.mkdir(self.targetDir)
+
+    def tearDown(self):
+        context.MPI_COMM.barrier()
+        if context.MPI_RANK == 0:
+            shutil.rmtree(self.targetDir)
+
+    @unittest.skipIf(context.MPI_SIZE <= 1 or MPI_EXE is None, "Parallel test only")
+    def test_MpiDirectoryChanger(self):
+        # make sure all workers start outside the targetDir
+        self.assertNotIn(self.targetDir, os.getcwd())
+
+        # put the workers in a loop, waiting for command from the main process
+        if context.MPI_RANK != 0:
+            while True:
+                cmd = context.MPI_COMM.bcast(None, root=0)
+                print(cmd)
+                if cmd == "quit":
+                    break
+                cmd.invoke(None, None, None)
+
+        # from main, send commands to the workers to move into the targetDir
+        # and then create folders within there
+        if context.MPI_RANK == 0:
+            with MpiDirectoryChanger(self.targetDir):
+                RevealYourDirectory.invokeAsMaster(None, None, None)
+
+            # make the workers exit the waiting loop
+            context.MPI_COMM.bcast("quit", root=0)
+
+        context.MPI_COMM.barrier()
+        if context.MPI_RANK == 0:
+            # from main, confirm that subdirectories were created by all workers
+            for i in range(context.MPI_SIZE):
+                self.assertTrue(
+                    os.path.isdir(os.path.join(os.getcwd(), self.targetDir, str(i)))
+                )
+
+        # make sure all workers have moved back out from the targetDir
+        self.assertNotIn(self.targetDir, os.getcwd())
+
+        context.MPI_COMM.barrier()
diff --git a/armi/utils/tests/test_hexagon.py b/armi/utils/tests/test_hexagon.py
index ea0873f8..c3ea046e 100644
--- a/armi/utils/tests/test_hexagon.py
+++ b/armi/utils/tests/test_hexagon.py
@@ -13,12 +13,16 @@
 # limitations under the License.
 """Test hexagon tools."""
 import math
+import random
 import unittest
 
 from armi.utils import hexagon
 
 
 class TestHexagon(unittest.TestCase):
+    N_FUZZY_DRAWS: int = 10
+    """Number of random draws to use in some fuzzy testing"""
+
     def test_hexagon_area(self):
         """
         Area of a hexagon.
@@ -43,3 +47,90 @@ class TestHexagon(unittest.TestCase):
         self.assertEqual(hexagon.numPositionsInRing(2), 6)
         self.assertEqual(hexagon.numPositionsInRing(3), 12)
         self.assertEqual(hexagon.numPositionsInRing(4), 18)
+
+    def test_rotatedCellCenter(self):
+        """Test that location of the center cell is invariant through rotation."""
+        for rot in range(6):
+            self.assertTrue(hexagon.getIndexOfRotatedCell(1, rot), 1)
+
+    def test_rotatedFirstRing(self):
+        """Simple test for the corners of the first ring are maintained during rotation."""
+        # A 60 degree rotation is just incrementing the cell index by one here
+        locations = list(range(2, 8))
+        for locIndex, initialPosition in enumerate(locations):
+            for rot in range(6):
+                actual = hexagon.getIndexOfRotatedCell(initialPosition, rot)
+                newIndex = (locIndex + rot) % 6
+                expectedPosition = locations[newIndex]
+                self.assertEqual(
+                    actual, expectedPosition, msg=f"{initialPosition=}, {rot=}"
+                )
+
+    def test_rotateFuzzy(self):
+        """Select some position number and rotation and check for consistency."""
+        N_DRAWS = 100
+        for _ in range(N_DRAWS):
+            self._rotateFuzzyInner()
+
+    def _rotateFuzzyInner(self):
+        rot = random.randint(1, 5)
+        initialCell = random.randint(2, 300)
+        testInfoMsg = f"{rot=}, {initialCell=}"
+        newCell = hexagon.getIndexOfRotatedCell(initialCell, rot)
+        self.assertNotEqual(newCell, initialCell, msg=testInfoMsg)
+        # should be in the same ring
+        initialRing = hexagon.numRingsToHoldNumCells(initialCell)
+        newRing = hexagon.numRingsToHoldNumCells(newCell)
+        self.assertEqual(newRing, initialRing, msg=testInfoMsg)
+        # If we un-rotate, we should get our initial cell
+        reverseRot = (6 - rot) % 6
+        reverseCell = hexagon.getIndexOfRotatedCell(newCell, reverseRot)
+        self.assertEqual(reverseCell, initialCell, msg=testInfoMsg)
+
+    def test_positionsUpToRing(self):
+        """Test totalPositionsUpToRing is consistent with numPositionsInRing."""
+        self.assertEqual(hexagon.totalPositionsUpToRing(1), 1)
+        self.assertEqual(hexagon.totalPositionsUpToRing(2), 7)
+        self.assertEqual(hexagon.totalPositionsUpToRing(3), 19)
+
+        totalPositions = 19
+        for ring in range(4, 30):
+            posInThisRing = hexagon.numPositionsInRing(ring)
+            totalPositions += posInThisRing
+            self.assertEqual(
+                hexagon.totalPositionsUpToRing(ring), totalPositions, msg=f"{ring=}"
+            )
+
+    def test_rotatedCellIndexErrors(self):
+        """Test errors for non-positive initial cell indices during rotation."""
+        self._testNonPosRotIndex(0)
+        for _ in range(self.N_FUZZY_DRAWS):
+            index = random.randint(-100, -1)
+            self._testNonPosRotIndex(index)
+
+    def _testNonPosRotIndex(self, index: int):
+        with self.assertRaisesRegex(ValueError, ".*must be positive", msg=f"{index=}"):
+            hexagon.getIndexOfRotatedCell(index, 0)
+
+    def test_rotatedCellOrientationErrors(self):
+        """Test errors for invalid orientation numbers during rotation."""
+        for _ in range(self.N_FUZZY_DRAWS):
+            upper = random.randint(6, 100)
+            self._testRotOrientation(upper)
+            lower = random.randint(-100, -1)
+            self._testRotOrientation(lower)
+
+    def _testRotOrientation(self, orientation: int):
+        with self.assertRaisesRegex(
+            ValueError, "Orientation number", msg=f"{orientation=}"
+        ):
+            hexagon.getIndexOfRotatedCell(
+                initialCellIndex=1, orientationNumber=orientation
+            )
+
+    def test_indexWithNoRotation(self):
+        """Test that the initial cell location is returned if not rotated."""
+        for _ in range(self.N_FUZZY_DRAWS):
+            ix = random.randint(1, 300)
+            postRotation = hexagon.getIndexOfRotatedCell(ix, orientationNumber=0)
+            self.assertEqual(postRotation, ix)
diff --git a/armi/utils/tests/test_iterables.py b/armi/utils/tests/test_iterables.py
index e681cd0e..f1ebcb8e 100644
--- a/armi/utils/tests/test_iterables.py
+++ b/armi/utils/tests/test_iterables.py
@@ -16,6 +16,8 @@
 import time
 import unittest
 
+import numpy as np
+
 from armi.utils import iterables
 
 # CONSTANTS
@@ -174,3 +176,21 @@ class TestIterables(unittest.TestCase):
         self.assertEqual(vals[0], 0)
         self.assertEqual(vals[-1], 5)
         self.assertEqual(len(vals), 6)
+
+    def test_listPivot(self):
+        data = list(range(10))
+        loc = 4
+        actual = iterables.pivot(data, loc)
+        self.assertEqual(actual, data[loc:] + data[:loc])
+
+    def test_arrayPivot(self):
+        data = np.arange(10)
+        loc = -7
+        actual = iterables.pivot(data, loc)
+        expected = np.array(iterables.pivot(data.tolist(), loc))
+        self.assertTrue((actual == expected).all(), msg=f"{actual=} != {expected=}")
+        # Catch a silent failure case where pivot doesn't change the iterable
+        self.assertTrue(
+            (actual != data).all(),
+            msg=f"Pre-pivot {data=} should not equal post-pivot {actual=}",
+        )