Repository: terrapower/armi
Pull request #1316 (2023-06-15 21:18:57)

Correction for axial expansion conservation 
## What is the change?
This PR provides necessary corrections for the axial expansion changer to ensure the conservation of mass of the components within an assembly. 

The commit history has been rebased to ease the review. It is suggested that reviewers work their way through each commit  rather than viewing all changes at once. Additional relevant details are provided in the commit messages.
- commit 2514983 contains the primary corrections.
- commit a8acc08 contains a new unit test that considers conservation via thermal expansion for a heterogeneous fuel assembly.

Several key changes have been made:
1) axial expansion factors are now centered around 1.0 instead of 0.0. This enabled a simplification of the equation used to contract components.
2) expansion and contraction unit tests are modified to check mass conservation, number density conservation, and block height conservation. This includes cases where expansion is followed by contraction, returning the assembly to its original state. Previously these tests only ensured conservation to a coarse precision. This adjustments here provide significant improvements. 

## Why is the change being made?
Conserving mass of both fuel, control, and structural components is necessary to accurately capture the physics of the reactor. Prior to this change, only the fuel and control were showing to consistently preserve mass. This PR provides corrections that accurately preserves mass for all components within a given pin-type assembly. 

<!-- MANDATORY: Explain why the change is necessary -->
<!-- Optional: Link to any related GitHub Issues -->

---

## Checklist

<!--
    You (the pull requester) should put an `x` in the boxes below you have completed.
    If you're unsure about any of them, don't hesitate to ask. We're here to help!
    Learn what a "good PR" looks like here:
    https://terrapower.github.io/armi/developer/tooling.html#good-pull-requests
-->

- [x] This PR has only one purpose or idea.
- [x] Tests have been added/updated to verify that the new/changed code works.

<!-- Check the code quality -->

- [x] The code style follows [good practices](https://terrapower.github.io/armi/developer/standards_and_practices.html).
- [x] The commit message(s) follow [good practices](https://terrapower.github.io/armi/developer/tooling.html).

<!-- Check the project-level cruft -->

- [ ] The [release notes](https://terrapower.github.io/armi/release/index.html) (location `doc/release/0.X.rst`) are up-to-date with any important changes.
- [x] The documentation is still up-to-date in the `doc` folder.
- [x] The dependencies are still up-to-date in `setup.py`.


albeanth: @john-science can you help me figure out where the coverage drop is coming from? Coveralls isn't even showing that any files had a measurable change.... And if I go down the tree to axialExpansionChanger.py, it still shows a 100% coverage. Is this just a specially odd case of coverage spray? 

![image](https://github.com/terrapower/armi/assets/18561046/8b9e16ef-c158-4f29-9cda-9dce12c463e1)

keckler: @albeanth I don't really understand how this happened, but there are somehow some merge conflicts here.
jakehader: For future tracking purposes I'd love to see some of the mass values for a test being compared in the ticket here to summarize that everything is working as expected. I didn't review, but nice work @albeanth 
albeanth: > For future tracking purposes I'd love to see some of the mass values for a test being compared in the ticket here to summarize that everything is working as expected. I didn't review, but nice work @albeanth 

I'm not entirely sure what you mean by this, but I did add a unit test that might(?) address this test_ThermalExpansionContractionConservation_Complex
jakehader: > > For future tracking purposes I'd love to see some of the mass values for a test being compared in the ticket here to summarize that everything is working as expected. I didn't review, but nice work @albeanth
> 
> I'm not entirely sure what you mean by this, but I did add a unit test that might(?) address this test_ThermalExpansionContractionConservation_Complex

Print out the masses before and after and just make a table or several tables here for comparison. 
albeanth: > 

Ok. I can do this post-mortem after we get our internal release out the door. I have all of this already in a speradsheet, it just needs to be put into a comment here.  
mgjarrett: I just see one more doc string that needs to be udpated:

![image](https://github.com/terrapower/armi/assets/11633797/9768e2bd-c54a-46c1-9147-3216da8d2316)

albeanth: The coverage drop here looks like coverage spray to me. 

Patch:
diff --git a/armi/reactor/converters/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger.py
index 2991d68e..e9a9842c 100644
--- a/armi/reactor/converters/axialExpansionChanger.py
+++ b/armi/reactor/converters/axialExpansionChanger.py
@@ -14,11 +14,13 @@
 """Enable component-wise axial expansion for assemblies and/or a reactor."""
 
 from statistics import mean
-from numpy import array
+from typing import List
+
 from armi import runLog
 from armi.materials import material
-from armi.reactor.flags import Flags
 from armi.reactor.components import UnshapedComponent
+from armi.reactor.flags import Flags
+from numpy import array
 
 TARGET_FLAGS_IN_PREFERRED_ORDER = [
     Flags.FUEL,
@@ -154,7 +156,6 @@ class AxialExpansionChanger:
         tempGrid: list,
         tempField: list,
         setFuel: bool = True,
-        updateNDensForRadialExp: bool = True,
     ):
         """Perform thermal expansion for an assembly given an axial temperature grid and field.
 
@@ -169,21 +170,9 @@ class AxialExpansionChanger:
         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.
-        updateNDensForRadialExp: optional, bool
-            boolean to determine whether or not the component number densities should be updated
-            to account for radial expansion/contraction
-
-        Notes
-        -----
-        - Setting updateNDensForRadialExp to False isolates the number density changes due to the
-          temp change to just the axial dim. This is useful for testing. However, in practical use
-          updateNDensForRadialExp should be set to True to capture radial expansion/contraction
-          effects associated with updating the component temperature.
         """
         self.setAssembly(a, setFuel)
-        self.expansionData.updateComponentTempsBy1DTempField(
-            tempGrid, tempField, updateNDensForRadialExp
-        )
+        self.expansionData.updateComponentTempsBy1DTempField(tempGrid, tempField)
         self.expansionData.computeThermalExpansionFactors()
         self.axiallyExpandAssembly()
 
@@ -261,13 +250,10 @@ class AxialExpansionChanger:
                 b.p.zbottom = self.linked.linkedBlocks[b][0].p.ztop
             isDummyBlock = ib == (numOfBlocks - 1)
             if not isDummyBlock:
-                for c in _getSolidComponents(b):
+                for c in getSolidComponents(b):
                     growFrac = self.expansionData.getExpansionFactor(c)
                     runLog.debug(msg=f"      Component {c}, growFrac = {growFrac:.4e}")
-                    if growFrac >= 0.0:
-                        c.height = (1.0 + growFrac) * blockHeight
-                    else:
-                        c.height = (1.0 / (1.0 - growFrac)) * blockHeight
+                    c.height = growFrac * blockHeight
                     # align linked components
                     if ib == 0:
                         c.zbottom = 0.0
@@ -281,21 +267,26 @@ class AxialExpansionChanger:
                             # 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
 
-            # see also b.setHeight()
-            # - the above not chosen due to call to calculateZCoords
-            oldComponentVolumes = [c.getVolume() for c in b]
-            oldHeight = b.getHeight()
-            b.p.height = b.p.ztop - b.p.zbottom
+            b.p.z = b.p.zbottom + b.getHeight() / 2.0
 
             _checkBlockHeight(b)
-            self._conserveComponentDensity(b, oldHeight, oldComponentVolumes)
-            # set block mid point and redo mesh
-            # - functionality based on assembly.calculateZCoords()
-            b.p.z = b.p.zbottom + b.getHeight() / 2.0
+            # 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]
 
@@ -330,35 +321,8 @@ class AxialExpansionChanger:
             for old, new in zip(oldMesh, r.core.p.axialMesh):
                 runLog.extra(f"{old:.6e}\t{new:.6e}")
 
-    def _conserveComponentDensity(self, b, oldHeight, oldVolume):
-        """Update block height dependent component parameters.
-
-        1) update component volume for all materials (used to compute block volume)
-        2) update number density for solid materials only (no fluid)
-
-        Parameters
-        ----------
-        oldHeight : list of floats
-            list containing block heights pre-expansion
-        oldVolume : list of floats
-            list containing component volumes pre-expansion
-        """
-        solidComponents = _getSolidComponents(b)
-        for ic, c in enumerate(b):
-            c.p.volume = oldVolume[ic] * b.getHeight() / oldHeight
-            if c in solidComponents:
-                growFrac = self.expansionData.getExpansionFactor(c)
-                if growFrac >= 0.0:
-                    growth = 1.0 + growFrac
-                else:
-                    growth = 1.0 / (1.0 - growFrac)
-                newNumberDensities = {
-                    nuc: c.getNumberDensity(nuc) / growth for nuc in c.getNuclides()
-                }
-                c.setNumberDensities(newNumberDensities)
-
 
-def _getSolidComponents(b):
+def getSolidComponents(b):
     """
     Return list of components in the block that have solid material.
 
@@ -616,40 +580,41 @@ class ExpansionData:
         self._componentDeterminesBlockHeight = {}
         self._setTargetComponents(setFuel)
 
-    def setExpansionFactors(self, componentLst, percents):
-        """Sets user defined expansion factors.
+    def setExpansionFactors(self, componentLst: List, expFrac: List):
+        """Sets user defined expansion fractions.
 
         Parameters
         ----------
-        componentLst : list[:py:class:`Component <armi.reactor.components.component.Component>`]
+        componentLst : List[:py:class:`Component <armi.reactor.components.component.Component>`]
             list of Components to have their heights changed
-        percents : list[float]
-            list of height changes in percent that are to be applied to componentLst
+        expFrac : List[float]
+            list of L1/L0 height changes that are to be applied to componentLst
 
         Raises
         ------
         RuntimeError
-            If componentLst and percents are different lengths
-
-        Notes
-        -----
-        - requires that the length of componentLst and percents be the same
+            If componentLst and expFrac are different lengths
         """
-        if len(componentLst) != len(percents):
+        if len(componentLst) != len(expFrac):
             runLog.error(
-                "Number of components and percent changes must be the same!\n\
-                    len(componentLst) = {0:d}\n\
-                        len(percents) = {1:d}".format(
-                    len(componentLst), len(percents)
-                )
+                f"Number of components and expansion fractions must be the same!\n"
+                f"    len(componentLst) = {len(componentLst)}\n"
+                f"        len(expFrac) = {len(expFrac)}"
             )
             raise RuntimeError
-        for c, p in zip(componentLst, percents):
+        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, updateNDensForRadialExp: bool = True
-    ):
+    def updateComponentTempsBy1DTempField(self, tempGrid, tempField):
         """Assign a block-average axial temperature to components.
 
         Parameters
@@ -658,19 +623,12 @@ class ExpansionData:
             1D axial temperature grid (i.e., physical locations where temp is stored)
         tempField : numpy array
             temperature values along grid
-        updateNDensForRadialExp: optional, bool
-            boolean to determine whether or not the component number densities should be updated
-            to account for radial expansion/contraction
 
         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()
-        - Setting updateNDensForRadialExp to False isolates the number density changes due to the
-          temp change to just the axial dim. This is useful for testing. However, in practical use
-          updateNDensForRadialExp should be set to True to capture radial expansion/contraction
-          effects associated with updating the component temperature.
 
         Raises
         ------
@@ -694,72 +652,49 @@ class ExpansionData:
 
             if len(tmpMapping) == 0:
                 raise ValueError(
-                    "Block {0:s} has no temperature points within it! \
-                        Likely need to increase the refinement of the temperature grid.".format(
-                        str(b.name)
-                    )
+                    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(b, c, blockAveTemp, updateNDensForRadialExp)
+                self.updateComponentTemp(c, blockAveTemp)
 
-    def updateComponentTemp(
-        self, b, c, temp: float, updateNDensForRadialExp: bool = True
-    ):
+    def updateComponentTemp(self, c, temp: float):
         """Update component temperatures with a provided temperature.
 
         Parameters
         ----------
-        b : :py:class:`Block <armi.reactor.blocks.Block>`
-            parent block for c
         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
-        updateNDensForRadialExp : bool
-            boolean to determine whether or not the component number densities should be updated
-            to account for the radial expansion/contraction associated with the new temperature
 
         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
-        - Setting updateNDensForRadialExp to False isolates the number density changes due to the
-          temp change to just the axial dim. This is useful for testing. However, in practical use
-          updateNDensForRadialExp should be set to True to capture radial expansion/contraction
-          effects associated with updating the component temperature.
         """
         self.componentReferenceTemperature[c] = c.temperatureInC
-        if not updateNDensForRadialExp:
-            # Update component temp manually to avoid the call to changeNDensByFactor(f) within c.setTemperature().
-            # This isolates the number density changes due to the temp change to just the axial dim.
-            # This is useful for testing.
-            c.temperatureInC = temp
-            c.p.volume = c.getArea(cold=True) * b.getHeight()
-        else:
-            c.setTemperature(temp)
-            c.p.volume = c.getArea(cold=False) * b.getHeight()
+        c.setTemperature(temp)
 
     def computeThermalExpansionFactors(self):
         """Computes expansion factors for all components via thermal expansion."""
         for b in self._a:
-            for c in b:
+            for c in getSolidComponents(b):
                 if c in self.componentReferenceTemperature:
-                    self._expansionFactors[c] = (
-                        c.getThermalExpansionFactor(
-                            T0=self.componentReferenceTemperature[c]
-                        )
-                        - 1.0
+                    growFrac = c.getThermalExpansionFactor(
+                        T0=self.componentReferenceTemperature[c]
                     )
+                    self._expansionFactors[c] = growFrac
                 elif self.componentReferenceTemperature:
                     # 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 0.0.
-                    self._expansionFactors[c] = 0.0
+                    # so we'll assume that the expansion factor is 1.0.
+                    self._expansionFactors[c] = 1.0
                 else:
-                    self._expansionFactors[c] = c.getThermalExpansionFactor() - 1.0
+                    self._expansionFactors[c] = c.getThermalExpansionFactor()
 
     def getExpansionFactor(self, c):
         """Retrieves expansion factor for c.
@@ -772,7 +707,7 @@ class ExpansionData:
         if c in self._expansionFactors:
             value = self._expansionFactors[c]
         else:
-            value = 0.0
+            value = 1.0
         return value
 
     def _setTargetComponents(self, setFuel):


Test patch:
diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py
index 506ebfc3..81b8b2e6 100644
--- a/armi/reactor/converters/tests/test_axialExpansionChanger.py
+++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py
@@ -13,34 +13,29 @@
 # limitations under the License.
 
 """Test axialExpansionChanger."""
-from statistics import mean
+import collections
 import os
 import unittest
+from statistics import mean
 
-from numpy import linspace, array, vstack, zeros
-
-from armi.materials import material
-from armi.reactor.assemblies import grids
-from armi.reactor.assemblies import HexAssembly
+from armi import materials
+from armi.materials import _MATERIAL_NAMESPACE_ORDER, custom
+from armi.reactor.assemblies import HexAssembly, grids
 from armi.reactor.blocks import HexBlock
 from armi.reactor.components import DerivedShape, UnshapedComponent
-from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings
-from armi.tests import TEST_ROOT
-from armi.reactor.components.basicShapes import (
-    Circle,
-    Hexagon,
-    Rectangle,
-)
+from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle
 from armi.reactor.components.complexShapes import Helix
 from armi.reactor.converters.axialExpansionChanger import (
     AxialExpansionChanger,
     ExpansionData,
     _determineLinked,
+    getSolidComponents,
 )
-from armi import materials
-from armi.materials import custom, _MATERIAL_NAMESPACE_ORDER
 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):
@@ -59,9 +54,10 @@ class AxialExpansionTestBase(unittest.TestCase):
 
     def setUp(self):
         self.obj = AxialExpansionChanger()
-        self.massAndDens = {}
-        self.steelMass = []
-        self.blockHeights = {}
+        self.componentMass = collections.defaultdict(list)
+        self.componentDensity = collections.defaultdict(list)
+        self.totalAssemblySteelMass = []
+        self.blockZtop = collections.defaultdict(list)
         self.origNameSpace = _MATERIAL_NAMESPACE_ORDER
         # set namespace order for materials so that fake HT9 material can be found
         materials.setMaterialNamespaceOrder(
@@ -84,38 +80,21 @@ class AxialExpansionTestBase(unittest.TestCase):
             2. mass of assembly steel
             3. block heights
         """
-        mass = 0.0
+        totalSteelMass = 0.0
         for b in a:
-            for c in b:
-                # store mass and density of target component
-                if self.obj.expansionData.isTargetComponent(c):
-                    self._storeTargetComponentMassAndDensity(c)
+            # store block ztop
+            self.blockZtop[b].append(b.p.ztop)
+            for c in getSolidComponents(b):
+                # store mass and density of component
+                self.componentMass[c].append(c.getMass())
+                self.componentDensity[c].append(
+                    c.material.getProperty("density", c.temperatureInK)
+                )
                 # store steel mass for assembly
                 if c.p.flags in self.Steel_Component_Lst:
-                    mass += c.getMass()
-
-            # store block heights
-            tmp = array([b.p.zbottom, b.p.ztop, b.p.height, b.getVolume()])
-            if b.name not in self.blockHeights:
-                self.blockHeights[b.name] = tmp
-            else:
-                self.blockHeights[b.name] = vstack((self.blockHeights[b.name], tmp))
+                    totalSteelMass += c.getMass()
 
-        self.steelMass.append(mass)
-
-    def _storeTargetComponentMassAndDensity(self, c):
-        tmp = array(
-            [
-                c.getMass(),
-                c.material.getProperty("density", c.temperatureInK),
-            ]
-        )
-        if c.parent.name not in self.massAndDens:
-            self.massAndDens[c.parent.name] = tmp
-        else:
-            self.massAndDens[c.parent.name] = vstack(
-                (self.massAndDens[c.parent.name], tmp)
-            )
+        self.totalAssemblySteelMass.append(totalSteelMass)
 
 
 class Temperature:
@@ -189,13 +168,11 @@ class TestAxialExpansionHeight(AxialExpansionTestBase, unittest.TestCase):
         self._generateComponentWiseExpectedHeight()
 
         # do the axial expansion
-        self.axialMeshLocs = zeros((self.temp.tempSteps, len(self.a)))
         for idt in range(self.temp.tempSteps):
             self.obj.performThermalAxialExpansion(
                 self.a, self.temp.tempGrid, self.temp.tempField[idt, :], setFuel=True
             )
             self._getConservationMetrics(self.a)
-            self.axialMeshLocs[idt, :] = self.a.getAxialMesh()
 
     def tearDown(self):
         AxialExpansionTestBase.tearDown(self)
@@ -206,30 +183,9 @@ class TestAxialExpansionHeight(AxialExpansionTestBase, unittest.TestCase):
             for ib, b in enumerate(self.a):
                 self.assertAlmostEqual(
                     self.trueZtop[ib, idt],
-                    self.blockHeights[b.name][idt][1],
+                    self.blockZtop[b][idt],
                     places=7,
-                    msg="Block height is not correct.\
-                         Temp Step = {0:d}, Block ID = {1:}.".format(
-                        idt, b.name
-                    ),
-                )
-
-    def test_AxialMesh(self):
-        """Test that mesh aligns with block tops for component-based expansion."""
-        for idt in range(self.temp.tempSteps):
-            for ib, b in enumerate(self.a):
-                self.assertEqual(
-                    self.axialMeshLocs[idt][ib],
-                    self.blockHeights[b.name][idt][1],
-                    msg="\
-                        Axial mesh and block top do not align and invalidate the axial mesh.\
-                        Block ID = {0:s},\n\
-                            Top = {1:.12e}\n\
-                        Mesh Loc = {2:.12e}".format(
-                        str(b.name),
-                        self.blockHeights[b.name][idt][1],
-                        self.axialMeshLocs[idt][ib],
-                    ),
+                    msg=f"Block height is not correct. {b}; Temp Step = {idt}",
                 )
 
     def _generateComponentWiseExpectedHeight(self):
@@ -282,114 +238,117 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
         AxialExpansionTestBase.tearDown(self)
 
     def expandAssemForMassConservationTest(self):
-        """Initialize class variables for mass conservation checks."""
-        self.oldMass = {}
-        for b in self.a:
-            self.oldMass[b.name] = 0.0
-
-        # do the expansion and store mass and density info
-        self.temp = Temperature(
-            self.a.getTotalHeight(), coldTemp=1.0, hotInletTemp=1000.0
-        )
-        for idt in range(self.temp.tempSteps):
+        """do the thermal expansion and store conservation metrics of interest"""
+        # create a semi-realistic/physical variable temperature grid over the assembly
+        temp = Temperature(self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10)
+        for idt in range(temp.tempSteps):
             self.obj.performThermalAxialExpansion(
                 self.a,
-                self.temp.tempGrid,
-                self.temp.tempField[idt, :],
-                setFuel=True,
-                updateNDensForRadialExp=False,
+                temp.tempGrid,
+                temp.tempField[idt, :],
             )
             self._getConservationMetrics(self.a)
 
-    def test_ColdThermalExpansionContractionConservation(self):
-        """Thermally expand and then contract to ensure original state is recovered.
+    def test_ThermalExpansionContractionConservation_Simple(self):
+        r"""Thermally expand and then contract to ensure original state is recovered.
 
         Notes
         -----
-        Temperature field is isothermal and initially at 25 C.
+        Temperature field is always isothermal and initially at 25 C.
         """
-        isothermalTempList = [20.0, 25.0, 30.0]
-        a = buildTestAssemblyWithFakeMaterial(name="FakeMat")
-        originalMesh = a.getAxialMesh()
+        isothermalTempList = [100.0, 350.0, 250.0, 25.0]
+        a = buildTestAssemblyWithFakeMaterial(name="HT9")
+        origMesh = a.getAxialMesh()[:-1]
+        origMasses, origNDens = self._getComponentMassAndNDens(a)
         axialExpChngr = AxialExpansionChanger(detailedAxialExpansion=True)
 
         tempGrid = linspace(0.0, a.getHeight())
         for temp in isothermalTempList:
-            # Set hot isothermal temp and expand
-            tempField = array([temp] * len(tempGrid))
-            axialExpChngr.performThermalAxialExpansion(
-                a, tempGrid, tempField, updateNDensForRadialExp=False
+            # compute expected change in number densities
+            c = a[0][0]
+            radialGrowthFrac = c.material.getThermalExpansionDensityReduction(
+                prevTempInC=c.temperatureInC, newTempInC=temp
             )
-            if temp == 25.0:
-                for new, old in zip(
-                    a.getAxialMesh()[:-1], originalMesh[:-1]
-                ):  # skip dummy block
-                    self.assertAlmostEqual(
-                        new,
-                        old,
-                        msg="At original temp (250 C) block height is {0:.5f}. "
-                        "Current temp is {1:.5f} and block height is {2:.5f}".format(
-                            old, temp, new
-                        ),
-                        places=3,
-                    )
-            else:
-                for new, old in zip(
-                    a.getAxialMesh()[:-1], originalMesh[:-1]
-                ):  # skip dummy block
-                    self.assertNotEqual(
-                        new,
-                        old,
-                        msg="At original temp (250 C) block height is {0:.5f}. "
-                        "Current temp is {1:.5f} and block height is {2:.5f}".format(
-                            old, temp, new
-                        ),
-                    )
-
-    def test_HotThermalExpansionContractionConservation(self):
+            axialGrowthFrac = c.getThermalExpansionFactor(T0=c.temperatureInC, Tc=temp)
+            totGrowthFrac = axialGrowthFrac / radialGrowthFrac
+            # Set new isothermal temp and expand
+            tempField = array([temp] * len(tempGrid))
+            oldMasses, oldNDens = self._getComponentMassAndNDens(a)
+            axialExpChngr.performThermalAxialExpansion(a, tempGrid, tempField)
+            newMasses, newNDens = self._getComponentMassAndNDens(a)
+            self._checkMass(oldMasses, newMasses)
+            self._checkNDens(oldNDens, newNDens, totGrowthFrac)
+
+        # make sure that the assembly returned to the original state
+        for orig, new in zip(origMesh, a.getAxialMesh()):
+            self.assertAlmostEqual(orig, new, places=12)
+        self._checkMass(origMasses, newMasses)
+        self._checkNDens(origNDens, newNDens, 1.0)
+
+    def test_ThermalExpansionContractionConservation_Complex(self):
         r"""Thermally expand and then contract to ensure original state is recovered.
 
         Notes
         -----
-        Temperature field is isothermal and initially at 250 C.
+        Assemblies with liners are not supported and not considered for conservation testing.
         """
-        isothermalTempList = [200.0, 250.0, 300.0]
-        a = buildTestAssemblyWithFakeMaterial(name="FakeMat", hot=True)
-        originalMesh = a.getAxialMesh()
+        _oCold, rCold = loadTestReactor(
+            os.path.join(TEST_ROOT, "detailedAxialExpansion"),
+            customSettings={"inputHeightsConsideredHot": False},
+        )
+        assems = list(rCold.blueprints.assemblies.values())
+        for a in assems:
+            if a.hasFlags([Flags.MIDDLE, Flags.ANNULAR, Flags.TEST]):
+                # assemblies with the above flags have liners and conservation
+                # of such assemblies is not currently supported
+                continue
+            self.complexConservationTest(a)
+
+    def complexConservationTest(self, a):
+        origMesh = a.getAxialMesh()[:-1]
+        origMasses, origNDens = self._getComponentMassAndNDens(a)
         axialExpChngr = AxialExpansionChanger(detailedAxialExpansion=True)
-
-        tempGrid = linspace(0.0, a.getHeight())
-        for temp in isothermalTempList:
-            # Set hot isothermal temp and expand
-            tempField = array([temp] * len(tempGrid))
-            axialExpChngr.performThermalAxialExpansion(
-                a, tempGrid, tempField, updateNDensForRadialExp=False
-            )
-            if temp == 250.0:
-                for new, old in zip(
-                    a.getAxialMesh()[:-1], originalMesh[:-1]
-                ):  # skip dummy block
-                    self.assertAlmostEqual(
-                        new,
-                        old,
-                        msg="At original temp (250 C) block height is {0:.5f}. "
-                        "Current temp is {1:.5f} and block height is {2:.5f}".format(
-                            old, temp, new
-                        ),
-                        places=1,
-                    )
-            else:
-                for new, old in zip(
-                    a.getAxialMesh()[:-1], originalMesh[:-1]
-                ):  # skip dummy block
-                    self.assertNotEqual(
-                        new,
-                        old,
-                        msg="At original temp (250 C) block height is {0:.5f}. "
-                        "Current temp is {1:.5f} and block height is {2:.5f}".format(
-                            old, temp, new
-                        ),
+        axialExpChngr.setAssembly(a)
+        tempAdjust = [50.0, 50.0, -50.0, -50.0]
+        for temp in tempAdjust:
+            # adjust component temperatures by temp
+            for b in a:
+                for c in getSolidComponents(b):
+                    axialExpChngr.expansionData.updateComponentTemp(
+                        c, c.temperatureInC + temp
                     )
+            # get U235/B10 and FE56 mass pre-expansion
+            prevFE56Mass = a.getMass("FE56")
+            prevMass = self._getMass(a)
+            # compute thermal expansion coeffs and expand
+            axialExpChngr.expansionData.computeThermalExpansionFactors()
+            axialExpChngr.axiallyExpandAssembly()
+            # ensure that total U235/B10 and FE56 mass is conserved post-expansion
+            newFE56Mass = a.getMass("FE56")
+            newMass = self._getMass(a)
+            self.assertAlmostEqual(
+                newFE56Mass / prevFE56Mass, 1.0, places=14, msg=f"{a}"
+            )
+            if newMass:
+                self.assertAlmostEqual(newMass / prevMass, 1.0, places=14, msg=f"{a}")
+
+        newMasses, newNDens = self._getComponentMassAndNDens(a)
+        # make sure that the assembly returned to the original state
+        for orig, new in zip(origMesh, a.getAxialMesh()):
+            self.assertAlmostEqual(orig, new, places=12, msg=f"{a}")
+        self._checkMass(origMasses, newMasses)
+        self._checkNDens(origNDens, newNDens, 1.0)
+
+    @staticmethod
+    def _getMass(a):
+        """get the mass of an assembly. The conservation of HT9 pins in shield assems
+        are accounted for in FE56 conservation checks."""
+        newMass = None
+        if a.hasFlags(Flags.FUEL):
+            newMass = a.getMass("U235")
+        elif a.hasFlags(Flags.CONTROL):
+            newMass = a.getMass("B10")
+        return newMass
 
     def test_PrescribedExpansionContractionConservation(self):
         """Expand all components and then contract back to original state.
@@ -397,75 +356,81 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
         Notes
         -----
         - uniform expansion over all components within the assembly
-        - 10 total expansion steps: 5 at +1%, and 5 at -1%
-        - assertion on if original axial mesh matches the final axial mesh
+        - 10 total expansion steps: 5 at +1.01 L1/L0, and 5 at -(1.01^-1) L1/L0
         """
         a = buildTestAssemblyWithFakeMaterial(name="FakeMat")
-        obj = AxialExpansionChanger()
-        oldMesh = a.getAxialMesh()
-        componentLst = [c for b in a for c in b]
+        axExpChngr = AxialExpansionChanger()
+        origMesh = a.getAxialMesh()
+        origMasses, origNDens = self._getComponentMassAndNDens(a)
+        componentLst = [c for b in a for c in getSolidComponents(b)]
+        expansionGrowthFrac = 1.01
+        contractionGrowthFrac = 1.0 / expansionGrowthFrac
         for i in range(0, 10):
-            # get the percentage change
             if i < 5:
-                percents = 0.01 + zeros(len(componentLst))
+                growthFrac = expansionGrowthFrac
+                fracLst = growthFrac + zeros(len(componentLst))
             else:
-                percents = -0.01 + zeros(len(componentLst))
-            # set the expansion factors
-            oldMasses = [
-                c.getMass()
-                for b in a
-                for c in b
-                if not isinstance(c.material, material.Fluid)
-            ]
+                growthFrac = contractionGrowthFrac
+                fracLst = growthFrac + zeros(len(componentLst))
+            oldMasses, oldNDens = self._getComponentMassAndNDens(a)
             # do the expansion
-            obj.performPrescribedAxialExpansion(a, componentLst, percents, setFuel=True)
-            newMasses = [
-                c.getMass()
-                for b in a
-                for c in b
-                if not isinstance(c.material, material.Fluid)
-            ]
-            for old, new in zip(oldMasses, newMasses):
-                self.assertAlmostEqual(old, new)
-
-        self.assertEqual(
-            oldMesh,
-            a.getAxialMesh(),
-            msg="Axial mesh is not the same after the expansion and contraction!",
-        )
+            axExpChngr.performPrescribedAxialExpansion(
+                a, componentLst, fracLst, setFuel=True
+            )
+            newMasses, newNDens = self._getComponentMassAndNDens(a)
+            self._checkMass(oldMasses, newMasses)
+            self._checkNDens(oldNDens, newNDens, growthFrac)
+
+        # make sure that the assembly returned to the original state
+        for orig, new in zip(origMesh, a.getAxialMesh()):
+            self.assertAlmostEqual(orig, new, places=13)
+        self._checkMass(origMasses, newMasses)
+        self._checkNDens(origNDens, newNDens, 1.0)
+
+    def _checkMass(self, prevMass, newMass):
+        for prev, new in zip(prevMass.values(), newMass.values()):
+            # scaling helps check the assertion closer to machine precision
+            ave = (new + prev) / 2.0
+            prevScaled = prev / ave
+            newScaled = new / ave
+            self.assertAlmostEqual(prevScaled, newScaled, places=14)
+
+    def _checkNDens(self, prevNDen, newNDens, ratio):
+        for prevComp, newComp in zip(prevNDen.values(), newNDens.values()):
+            for prev, new in zip(prevComp.values(), newComp.values()):
+                if prev:
+                    self.assertAlmostEqual(prev / new, ratio, msg=f"{prev} / {new}")
+
+    @staticmethod
+    def _getComponentMassAndNDens(a):
+        masses = {}
+        nDens = {}
+        for b in a:
+            for c in getSolidComponents(b):
+                masses[c] = c.getMass()
+                nDens[c] = c.getNumberDensities()
+        return masses, nDens
 
     def test_TargetComponentMassConservation(self):
         """Tests mass conservation for target components."""
         self.expandAssemForMassConservationTest()
-        for idt in range(self.temp.tempSteps):
-            for b in self.a[:-1]:  # skip the dummy sodium block
-                if idt != 0:
-                    self.assertAlmostEqual(
-                        self.oldMass[b.name],
-                        self.massAndDens[b.name][idt][0],
-                        places=7,
-                        msg="Conservation of Mass Failed on time step {0:d}, block name {1:s},\
-                            with old mass {2:.7e}, and new mass {3:.7e}.".format(
-                            idt,
-                            b.name,
-                            self.oldMass[b.name],
-                            self.massAndDens[b.name][idt][0],
-                        ),
-                    )
-                self.oldMass[b.name] = self.massAndDens[b.name][idt][0]
+        for cName, masses in self.componentMass.items():
+            for i in range(1, len(masses)):
+                self.assertAlmostEqual(
+                    masses[i], masses[i - 1], msg=f"{cName} mass not right"
+                )
 
-    def test_SteelConservation(self):
-        """Tests mass conservation for total assembly steel.
+        for cName, density in self.componentDensity.items():
+            for i in range(1, len(density)):
+                self.assertLess(
+                    density[i], density[i - 1], msg=f"{cName} density not right."
+                )
 
-        Component list defined by, Steel_Component_List, in GetSteelMass()
-        """
-        self.expandAssemForMassConservationTest()
-        for idt in range(self.temp.tempSteps - 1):
+        for i in range(1, len(self.totalAssemblySteelMass)):
             self.assertAlmostEqual(
-                self.steelMass[idt],
-                self.steelMass[idt + 1],
-                places=7,
-                msg="Conservation of steel mass failed on time step {0:d}".format(idt),
+                self.totalAssemblySteelMass[i],
+                self.totalAssemblySteelMass[i - 1],
+                msg="Total assembly steel mass is not conserved.",
             )
 
     def test_NoMovementACLP(self):
@@ -494,8 +459,8 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
         # expand fuel
         # get fuel components
         cList = [c for b in assembly for c in b if c.hasFlags(Flags.FUEL)]
-        # 10% growth of fuel components
-        pList = zeros(len(cList)) + 0.1
+        # 1.01 L1/L0 growth of fuel components
+        pList = zeros(len(cList)) + 1.01
         chngr = AxialExpansionChanger()
         chngr.performPrescribedAxialExpansion(assembly, cList, pList, setFuel=True)
 
@@ -525,8 +490,8 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
         # apply new temp to the pin and clad components of each block
         for b in self.a:
             for c in b[0:2]:
-                stdThermExpFactor[c] = c.getThermalExpansionFactor() - 1.0
-                self.obj.expansionData.updateComponentTemp(b, c, newTemp)
+                stdThermExpFactor[c] = c.getThermalExpansionFactor()
+                self.obj.expansionData.updateComponentTemp(c, newTemp)
 
         self.obj.expansionData.computeThermalExpansionFactors()
 
@@ -542,7 +507,7 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
                 else:
                     self.assertEqual(
                         self.obj.expansionData.getExpansionFactor(c),
-                        0.0,
+                        1.0,
                         msg=f"Block {b}, Component {c}, thermExpCoeff not right.\n",
                     )
 
@@ -556,11 +521,11 @@ class TestManageCoreMesh(unittest.TestCase):
         reduceTestReactorRings(self.r, o.cs, 3)
 
         self.oldAxialMesh = self.r.core.p.axialMesh
-        # expand refAssem by 10%
+        # expand refAssem by 1.01 L1/L0
         componentLst = [c for b in self.r.core.refAssem for c in b]
-        percents = 0.01 + zeros(len(componentLst))
+        expansionGrowthFracs = 1.01 + zeros(len(componentLst))
         self.axialExpChngr.performPrescribedAxialExpansion(
-            self.r.core.refAssem, componentLst, percents, setFuel=True
+            self.r.core.refAssem, componentLst, expansionGrowthFracs, setFuel=True
         )
 
     def test_manageCoreMesh(self):
@@ -600,8 +565,22 @@ class TestExceptions(AxialExpansionTestBase, unittest.TestCase):
     def test_setExpansionFactors(self):
         with self.assertRaises(RuntimeError) as cm:
             cList = self.a[0].getChildren()
-            percents = range(len(cList) + 1)
-            self.obj.expansionData.setExpansionFactors(cList, percents)
+            expansionGrowthFracs = range(len(cList) + 1)
+            self.obj.expansionData.setExpansionFactors(cList, expansionGrowthFracs)
+            the_exception = cm.exception
+            self.assertEqual(the_exception.error_code, 3)
+
+        with self.assertRaises(RuntimeError) as cm:
+            cList = self.a[0].getChildren()
+            expansionGrowthFracs = zeros(len(cList))
+            self.obj.expansionData.setExpansionFactors(cList, expansionGrowthFracs)
+            the_exception = cm.exception
+            self.assertEqual(the_exception.error_code, 3)
+
+        with self.assertRaises(RuntimeError) as cm:
+            cList = self.a[0].getChildren()
+            expansionGrowthFracs = zeros(len(cList)) - 10.0
+            self.obj.expansionData.setExpansionFactors(cList, expansionGrowthFracs)
             the_exception = cm.exception
             self.assertEqual(the_exception.error_code, 3)
 
@@ -871,9 +850,8 @@ class TestInputHeightsConsideredHot(unittest.TestCase):
                     self.checkColdHeightBlockMass(bStd, bExp, Flags.CONTROL, "B10")
 
                 if not aStd.hasFlags(Flags.TEST) and not hasCustomMaterial:
-                    # skip blocks of custom material where liner is merged with clad
-                    for cExp in bExp:
-                        if not isinstance(cExp.material, custom.Custom):
+                    for cExp in getSolidComponents(bExp):
+                        if cExp.zbottom == bExp.p.zbottom and cExp.ztop == bExp.p.ztop:
                             matDens = cExp.material.density(Tc=cExp.temperatureInC)
                             compDens = cExp.density()
                             msg = (
@@ -884,7 +862,7 @@ class TestInputHeightsConsideredHot(unittest.TestCase):
                             self.assertAlmostEqual(
                                 matDens,
                                 compDens,
-                                places=7,
+                                places=12,
                                 msg=msg,
                             )
 
@@ -1126,7 +1104,7 @@ def _buildTestBlock(blockType: str, name: str, hotTemp: float, height: float):
     blockType : string
         determines which type of block you're building
     name : string
-        determines which fake material to use
+        determines which material to use
     """
     b = HexBlock(blockType, height=height)
 
@@ -1179,7 +1157,7 @@ class FakeMat(materials.ht9.HT9):
 
     Notes
     -----
-    - specifically used TestAxialExpansionHeight to verify axialExpansionChanger produces
+    - specifically used in TestAxialExpansionHeight to verify axialExpansionChanger produces
       expected heights from hand calculation
     - also used to verify mass and height conservation resulting from even amounts of expansion
       and contraction. See TestConservation.
@@ -1187,9 +1165,6 @@ class FakeMat(materials.ht9.HT9):
 
     name = "FakeMat"
 
-    def __init__(self):
-        materials.ht9.HT9.__init__(self)
-
     def linearExpansionPercent(self, Tk=None, Tc=None):
         """A fake linear expansion percent."""
         Tc = units.getTc(Tc, Tk)
@@ -1207,9 +1182,6 @@ class FakeMatException(materials.ht9.HT9):
 
     name = "FakeMatException"
 
-    def __init__(self):
-        materials.ht9.HT9.__init__(self)
-
     def linearExpansionPercent(self, Tk=None, Tc=None):
         """A fake linear expansion percent."""
         Tc = units.getTc(Tc, Tk)
diff --git a/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml b/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml
index 41734760..623b8b8b 100644
--- a/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml
+++ b/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml
@@ -41,6 +41,25 @@ blocks:
             ip: grid.op
             mult: 1.0
             op: 16.75
+    
+    duct: &block_duct
+        coolant: *component_coolant
+        duct: &component_duct
+            shape: Hexagon
+            material: HT9
+            Tinput: 25.0
+            Thot: 450.0
+            ip: 16.0
+            mult: 1.0
+            op: 16.6
+        intercoolant: &component_intercoolant
+            shape: Hexagon
+            material: Sodium
+            Tinput: 25.0
+            Thot: 450.0
+            ip: duct.op
+            mult: 1.0
+            op: 16.75
 
     SodiumBlock : &block_dummy
         flags: dummy
@@ -52,7 +71,6 @@ blocks:
             ip: 0.0
             mult: 1.0
             op: 16.75
-        axial expansion target component: coolant
 
     ## ------------------------------------------------------------------------------------
     ## fuel blocks
@@ -69,7 +87,7 @@ blocks:
             shape: Circle
             material: Sodium
             Tinput: 25.0
-            Thot: 450.0
+            Thot: 470.0
             id: shield.od
             mult: shield.mult
             od: clad.id
@@ -85,29 +103,15 @@ blocks:
             shape: Helix
             material: HT9
             Tinput: 25.0
-            Thot: 450.0
+            Thot: 470.0
             axialPitch: 30.15
             helixDiameter: 1.19056
             id: 0.0
             mult: shield.mult
             od: 0.10056
         coolant: *component_coolant      
-        duct: &component_fuel_duct
-            shape: Hexagon
-            material: HT9
-            Tinput: 25.0
-            Thot: 450.0
-            ip: 16.0
-            mult: 1.0
-            op: 16.6
-        intercoolant: &component_fuel_intercoolant
-            shape: Hexagon
-            material: Sodium
-            Tinput: 25.0
-            Thot: 450.0
-            ip: duct.op
-            mult: 1.0
-            op: 16.75
+        duct: *component_duct
+        intercoolant: *component_intercoolant
 
     fuel: &block_fuel    
         fuel: &component_fuel_fuel
@@ -122,7 +126,7 @@ blocks:
             shape: Circle
             material: Sodium
             Tinput: 25.0
-            Thot: 450.0
+            Thot: 470.0
             id: fuel.od
             mult: fuel.mult
             od: clad.id
@@ -138,15 +142,15 @@ blocks:
             shape: Helix
             material: HT9
             Tinput: 25.0
-            Thot: 450.0
+            Thot: 470.0
             axialPitch: 30.15
             helixDiameter: 1.19056
             id: 0.0
             mult: fuel.mult
             od: 0.10056
         coolant: *component_coolant
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
     
     plenum: &block_plenum
         gap: &component_plenum_gap
@@ -169,30 +173,23 @@ blocks:
             shape: Helix
             material: HT9
             Tinput: 25.0
-            Thot: 450.0
+            Thot: 470.0
             axialPitch: 30.15
             helixDiameter: 1.19056
             id: 0.0
             mult: clad.mult
             od: 0.10056
         coolant: *component_coolant
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
     
     aclp plenum : &block_aclp
         gap: *component_plenum_gap
         clad: *component_plenum_clad
         wire: *component_plenum_wire
         coolant: *component_coolant
-        duct:
-            shape: Hexagon
-            material: HT9
-            Tinput: 25.0
-            Thot: 450.0
-            ip: 16.0
-            mult: 1.0
-            op: 16.65
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
 
     fuel2: &block_fuel2
         fuel:
@@ -208,7 +205,7 @@ blocks:
             shape: Circle
             material: Sodium
             Tinput: 25.0
-            Thot: 450.0
+            Thot: 600.0
             id: fuel.od
             mult: fuel.mult
             od: liner2.id
@@ -233,8 +230,8 @@ blocks:
         clad: *component_fuel_clad
         wire: *component_fuel_wire
         coolant: *component_coolant
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
     
     lta fuel a: &block_lta1_fuel
         fuel: *component_fuel_fuel
@@ -244,8 +241,8 @@ blocks:
         clad: *component_fuel_clad        
         wire: *component_fuel_wire
         coolant: *component_coolant
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
     
     lta fuel b: &block_lta2_fuel
         fuel:
@@ -263,8 +260,8 @@ blocks:
         clad: *component_fuel_clad        
         wire: *component_fuel_wire
         coolant: *component_coolant
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
     
     annular fuel gap: &block_fuel3
         gap1:
@@ -328,18 +325,18 @@ blocks:
             shape: Circle
             material: HT9
             Tinput: 25.0
-            Thot: 430.0
+            Thot: 470.0
             id: 0.900
             mult: fuel.mult
             od: 1.000
         wire: *component_fuel_wire
         coolant: *component_coolant
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
     
     ## ------------------------------------------------------------------------------------
     ## control
-    moveable duct: &block_duct
+    moveable duct: &block_ctrl_duct
         coolant: *component_coolant
         duct: &component_control_duct
             shape: Hexagon
@@ -365,25 +362,25 @@ blocks:
             Tinput: 25.0
             Thot: 600.0
             id: 0.0
-            mult: 61.0
-            od: 1.286
-        gap:
+            mult: clad.mult
+            od: gap.id
+        gap: &component_control_gap
             shape: Circle
             material: Void
             Tinput: 25.0
             Thot: 450.0
-            id: control.od
-            mult: control.mult
+            id: 1.286
+            mult: clad.mult
             od: clad.id
-        clad:
+        clad: &component_control_clad
             shape: Circle
             material: HT9
             Tinput: 25.0
             Thot: 450.0
             id: 1.358
-            mult: control.mult
+            mult: 61.0
             od: 1.686
-        wire:
+        wire: &component_control_wire
             shape: Helix
             material: HT9
             Tinput: 25.0
@@ -391,9 +388,9 @@ blocks:
             axialPitch: 50.0
             helixDiameter: 1.771
             id: 0.0
-            mult: control.mult
-            od: 0.085
-        innerDuct:
+            mult: clad.mult
+            od: 0.085 
+        innerDuct: &component_control_innerDuct
             shape: Hexagon
             material: HT9
             Tinput: 25.0
@@ -406,33 +403,11 @@ blocks:
         intercoolant: *component_control_intercoolant
     
     moveable plenum: &block_control_plenum
-        gap:
-            shape: Circle
-            material: Void
-            Tinput: 25.0
-            Thot: 600.0
-            id: 0.0
-            mult: clad.mult
-            od: clad.id
-        clad:
-            shape: Circle
-            material: HT9
-            Tinput: 25.0
-            Thot: 450.0
-            id: 1.358
-            mult: 61.0
-            od: 1.686
-        wire:
-            shape: Helix
-            material: HT9
-            Tinput: 25.0
-            Thot: 450.0
-            axialPitch: 30.15
-            helixDiameter: 1.19056
-            id: 0.0
-            mult: clad.mult
-            od: 0.10056
+        gap: *component_control_gap
+        clad: *component_control_clad
+        wire: *component_control_wire
         coolant: *component_coolant
+        innderDuct: *component_control_innerDuct
         duct: *component_control_duct
         intercoolant: *component_control_intercoolant
 
@@ -447,23 +422,23 @@ blocks:
             id: 0.0
             mult: 169.0
             od: 0.90362
-        gap:
+        gap: &component_radial_shield_gap
             shape: Circle
             material: Void
             Tinput: 25.0
             Thot: 450.0
-            id: shield.od
-            mult: shield.mult
+            id: 0.90362
+            mult: 169.0
             od: clad.id
-        clad:
+        clad: &component_radial_shield_clad
             shape: Circle
             material: HT9
             Tinput: 25.0
             Thot: 450.0
             id: 0.90562
-            mult: shield.mult
+            mult: 169.0
             od: 1.05036
-        wire:
+        wire: &component_radial_shield_wire
             shape: Helix
             material: HT9
             Tinput: 25.0
@@ -473,92 +448,63 @@ blocks:
             id: 0.0
             mult: 169.0
             od: 0.10056
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
         coolant: *component_coolant
 
     radial shield plenum: &block_shield_plenum
-        gap: &component_radial_shield_plenum_gap
-            shape: Circle
-            material: Void
-            Tinput: 25.0
-            Thot: 600.0
-            id: 0.0
-            mult: clad.mult
-            od: clad.id
-        clad: &component_radial_shield_clad
-            shape: Circle
-            material: HT9
-            Tinput: 25.0
-            Thot: 450.0
-            id: 0.90562
-            mult: 169.0
-            od: 1.05036
-        wire: &component_radial_shield_plenum_wire
-            shape: Helix
-            material: HT9
-            Tinput: 25.0
-            Thot: 450.0
-            axialPitch: 30.15
-            helixDiameter: 1.19056
-            id: 0.0
-            mult: clad.mult
-            od: 0.10056
+        gap: *component_radial_shield_gap
+        clad: *component_radial_shield_clad
+        wire: *component_radial_shield_wire
         coolant: *component_coolant
-        duct: *component_fuel_duct
-        intercoolant: *component_fuel_intercoolant
-    
+        duct: *component_duct
+        intercoolant: *component_intercoolant
+
     radial shield aclp: &block_shield_aclp
-        gap: *component_radial_shield_plenum_gap
+        gap: *component_radial_shield_gap
         clad: *component_radial_shield_clad
-        wire: *component_radial_shield_plenum_wire
+        wire: *component_radial_shield_wire
         coolant: *component_coolant
-        duct:
-            shape: Hexagon
-            material: HT9
-            Tinput: 25.0
-            Thot: 450.0
-            ip: 16.0
-            mult: 1.0
-            op: 16.65
-        intercoolant: *component_fuel_intercoolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
+        axial expansion target component: clad # not necessary, but useful for testing coverage
 
 assemblies:
-    heights: &highOffset_height [25.0, 27.5, 27.5, 27.5, 27.5, 15.0, 25.0, 25.0, 17.5]
-    axial mesh points: &standard_axial_mesh_points [1, 1, 1, 1, 1, 1, 1, 1, 1]
+    heights: &highOffset_height [25.0, 27.5, 27.5, 27.5, 27.5, 15.0, 25.0, 25.0, 25.0, 17.5]
+    axial mesh points: &standard_axial_mesh_points [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
     igniter fuel:
         specifier: IC
-        blocks: &igniter_fuel_blocks [*block_grid_plate, *block_fuel_axial_shield, *block_fuel, *block_fuel, *block_fuel, *block_plenum, *block_aclp, *block_plenum, *block_dummy]
+        blocks: &igniter_fuel_blocks [*block_grid_plate, *block_fuel_axial_shield, *block_fuel, *block_fuel, *block_fuel, *block_plenum, *block_aclp, *block_plenum, *block_duct, *block_dummy]
         height: *highOffset_height
         axial mesh points: *standard_axial_mesh_points
         material modifications:
-            U235_wt_frac: &igniter_fuel_u235_wt_frac ['', '', 0.11, 0.11, 0.11, '', '', '', '']
-            ZR_wt_frac: &igniter_fuel_zr_wt_frac ['', '', 0.06, 0.06, 0.06, '', '', '', '']
-        xs types: &igniter_fuel_xs_types [A, A, B, C, C, D, A, A, A]
+            U235_wt_frac: &igniter_fuel_u235_wt_frac ['', '', 0.11, 0.11, 0.11, '', '', '', '', '']
+            ZR_wt_frac: &igniter_fuel_zr_wt_frac ['', '', 0.06, 0.06, 0.06, '', '', '', '', '']
+        xs types: &igniter_fuel_xs_types [A, A, B, C, C, D, A, A, A, A]
     middle fuel:
         specifier: MC
-        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_fuel2, *block_fuel2, *block_fuel2, *block_plenum, *block_aclp, *block_plenum, *block_dummy]
-        height: [25.0, 26.25, 26.25, 26.25, 26.25, 20.0, 25.0, 25.0, 17.5]
+        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_fuel2, *block_fuel2, *block_fuel2, *block_plenum, *block_aclp, *block_plenum, *block_duct, *block_dummy]
+        height: [25.0, 26.25, 26.25, 26.25, 26.25, 20.0, 25.0, 25.0, 25.0, 17.5]
         axial mesh points: *standard_axial_mesh_points
         xs types: *igniter_fuel_xs_types
     annular fuel:
         specifier: AF
-        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_fuel3, *block_fuel3, *block_fuel3, *block_plenum, *block_aclp, *block_plenum, *block_dummy]
+        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_fuel3, *block_fuel3, *block_fuel3, *block_plenum, *block_aclp, *block_plenum, *block_duct, *block_dummy]
         height: *highOffset_height
         axial mesh points: *standard_axial_mesh_points
         xs types: *igniter_fuel_xs_types
     lead test fuel:
         specifier: LA
-        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_lta1_fuel, *block_lta1_fuel, *block_lta1_fuel, *block_plenum,  *block_aclp, *block_plenum, *block_dummy]
+        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_lta1_fuel, *block_lta1_fuel, *block_lta1_fuel, *block_plenum,  *block_aclp, *block_plenum, *block_duct, *block_dummy]
         height: *highOffset_height
         axial mesh points: *standard_axial_mesh_points
         material modifications:
-            U235_wt_frac: &lta_fuel_u235_wt_frac ['', '', 0.2, 0.2, 0.2, '', '', '', '']
-            ZR_wt_frac: &lta_fuel_zr_wt_frac ['', '', 0.07, 0.07, 0.06, '', '', '', '']
+            U235_wt_frac: &lta_fuel_u235_wt_frac ['', '', 0.2, 0.2, 0.2, '', '', '', '', '']
+            ZR_wt_frac: &lta_fuel_zr_wt_frac ['', '', 0.07, 0.07, 0.06, '', '', '', '', '']
         xs types: *igniter_fuel_xs_types
     lead test fuel b:
         specifier: LB
-        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_lta2_fuel, *block_lta2_fuel, *block_lta2_fuel, *block_plenum, *block_aclp, *block_plenum, *block_dummy]
+        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_lta2_fuel, *block_lta2_fuel, *block_lta2_fuel, *block_plenum, *block_aclp, *block_plenum, *block_duct, *block_dummy]
         height: *highOffset_height
         axial mesh points: *standard_axial_mesh_points
         material modifications:
@@ -568,7 +514,7 @@ assemblies:
     feed fuel:
         specifier: OC
         blocks: *igniter_fuel_blocks
-        height: [25.0, 25.625, 25.625, 25.625, 25.625, 22.5, 25.0, 25.0, 17.5]
+        height: [25.0, 25.625, 25.625, 25.625, 25.625, 22.5, 25.0, 25.0, 25.0, 17.5]
         axial mesh points: *standard_axial_mesh_points
         material modifications:
             U235_wt_frac: *igniter_fuel_u235_wt_frac
@@ -576,20 +522,19 @@ assemblies:
         xs types: *igniter_fuel_xs_types
     secondary control:
         specifier: SC
-        blocks: [*block_grid_plate, *block_duct, *block_duct, *block_control, *block_control, *block_control_plenum, *block_duct, *block_duct, *block_dummy]
-        height: [25.0, 49.0, 49.0, 25.0, 25.0, 25.0, 1.0, 1.0, 17.5]
+        blocks: [*block_grid_plate, *block_ctrl_duct, *block_ctrl_duct, *block_control, *block_control, *block_control, *block_control_plenum, *block_ctrl_duct, *block_ctrl_duct, *block_dummy]
+        height: [25.0, 49.0, 49.0, 25.0, 25.0, 25.0, 25.0, 1.0, 1.0, 17.5]
         axial mesh points: *standard_axial_mesh_points
         xs types: *igniter_fuel_xs_types
     primary control:
         specifier: PC
-        blocks: [*block_grid_plate, *block_duct, *block_duct, *block_control, *block_control, *block_control_plenum, *block_duct, *block_duct, *block_dummy]
-        height: [25.0, 1.0, 1.0, 25.0, 25.0, 25.0, 49.0, 49.0, 17.5]
+        blocks: [*block_grid_plate, *block_ctrl_duct, *block_ctrl_duct, *block_control, *block_control, *block_control, *block_control_plenum, *block_ctrl_duct, *block_ctrl_duct, *block_dummy]
+        height: [25.0, 1.0, 1.0, 25.0, 25.0, 25.0, 25.0, 49.0, 49.0, 17.5]
         axial mesh points: *standard_axial_mesh_points
         xs types: *igniter_fuel_xs_types
     radial shield:
         specifier: SH
-        blocks: [*block_grid_plate, *block_radial_shield, *block_radial_shield, *block_radial_shield, *block_radial_shield, *block_shield_plenum, *block_shield_aclp, *block_shield_plenum, *block_dummy]
-        height: [25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 17.5]
+        blocks: [*block_grid_plate, *block_radial_shield, *block_radial_shield, *block_radial_shield, *block_radial_shield, *block_shield_plenum, *block_shield_aclp, *block_shield_plenum, *block_duct, *block_dummy]
+        height: [25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 17.5]
         axial mesh points: *standard_axial_mesh_points
         xs types: *igniter_fuel_xs_types
-