Repository: terrapower/armi
Pull request #2145 (2025-05-23 19:47:31)

Make axial linking aware of block grids for axial expansion
## What is the change? Why is it being made?

This is the replacement for #1376. 

What: This change enables the axial linking class supporting axial expansion to be aware of the grid blocks. This enables users to have assembly definitions that contain multiple pin-components (e.g., mixing fuel and reflector pins). 

Why: Increases the capabilities of ARMI by allowing more assembly types to be modeled.

## SCR Information

<!-- MANDATORY: uncomment one-and-only-one of these -->
Change Type: features

<!-- MANDATORY: Describe the change in one sentence -->
One-Sentence Description: Enable multi-pin component support for axial expansion.

<!-- MANDATORY: Describe any impact on the requirements, all on one line -->
One-line Impact on Requirements: N/A


---

## Checklist

<!--
    The pull request author should check the box if the condition is met OR if it does not apply.
-->

- [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.
- [x] The [documentation](https://terrapower.github.io/armi/developer/tooling.html#document-it) is still up-to-date in the `doc` folder.
- [x] The code style follows [good practices](https://terrapower.github.io/armi/developer/standards_and_practices.html).
- [x] The dependencies are still up-to-date in `pyproject.toml`.


albeanth: @drewj-tp GH won't let me re-request your review, so I am doing it here. It's ready! 

Patch:
diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py
index 8b013698..a3bd221f 100644
--- a/armi/reactor/assemblies.py
+++ b/armi/reactor/assemblies.py
@@ -31,7 +31,7 @@ from scipy import interpolate
 from armi import runLog
 from armi.materials.material import Fluid
 from armi.reactor import assemblyParameters, blocks, composites, grids
-from armi.reactor.flags import Flags
+from armi.reactor.flags import Flags, TypeSpec
 from armi.reactor.parameters import ParamLocation
 from armi.reactor.spentFuelPool import SpentFuelPool
 
@@ -288,9 +288,9 @@ class Assembly(composites.Composite):
         """Calculate the total assembly volume in cm^3."""
         return self.getArea() * self.getTotalHeight()
 
-    def getPinPlenumVolumeInCubicMeters(self):
+    def getPinPlenumVolumeInCubicMeters(self) -> float:
         """
-        Return the volume of the plenum for a pin in an assembly.
+        Return the total volume of the plenum for an assembly in m^3.
 
         Notes
         -----
@@ -299,12 +299,19 @@ class Assembly(composites.Composite):
         Warning
         -------
         This is a bit design-specific for pinned assemblies.
+
+        Returns
+        -------
+        float: Total plenum volume for an assembly.
         """
         plenumVolume = 0.0
         for b in self.iterChildrenWithFlags(Flags.PLENUM):
-            cladId = b.getComponent(Flags.CLAD).getDimension("id")
             length = b.getHeight()
-            plenumVolume += math.pi * (cladId / 2.0) ** 2.0 * length * 1e-6  # convert cm^3 to m^3
+            for c in b.iterChildrenWithFlags(Flags.CLAD):
+                cladId = c.getDimension("id")
+                plenumVolume += math.pi * (cladId / 2.0) ** 2.0 * length
+        # convert vol from cm^3 to m^3
+        plenumVolume *= 1e-6
         return plenumVolume
 
     def getAveragePlenumTemperature(self):
@@ -848,14 +855,14 @@ class Assembly(composites.Composite):
     def hasContinuousCoolantChannel(self):
         return all(b.containsAtLeastOneChildWithFlags(Flags.COOLANT) for b in self)
 
-    def getFirstBlock(self, typeSpec=None, exact=False):
+    def getFirstBlock(self, typeSpec: TypeSpec = None, exact: bool = False) -> Optional[blocks.Block]:
         """Find the first block that matches the spec.
 
         Parameters
         ----------
-        typeSpec : flag or list of flags, optional
+        typeSpec
             Specification to require on the returned block.
-        exact : bool, optional
+        exact
             Require block to exactly match ``typeSpec``
 
         Returns
@@ -874,7 +881,7 @@ class Assembly(composites.Composite):
             # No items found in the iteration -> no blocks match the request
             return None
 
-    def getFirstBlockByType(self, typeName):
+    def getFirstBlockByType(self, typeName: str) -> Optional[blocks.Block]:
         blocks = filter(lambda b: b.getType() == typeName, self)
         try:
             return next(blocks)
diff --git a/armi/reactor/blueprints/blockBlueprint.py b/armi/reactor/blueprints/blockBlueprint.py
index f267bd77..deeadc7d 100644
--- a/armi/reactor/blueprints/blockBlueprint.py
+++ b/armi/reactor/blueprints/blockBlueprint.py
@@ -168,8 +168,8 @@ class BlockBlueprint(yamlize.KeyedList):
                     mult = c.getDimension("mult")
                     if mult and mult != 1.0 and mult != len(c.spatialLocator):
                         raise ValueError(
-                            f"Conflicting ``mult`` input ({mult}) and number of "
-                            f"lattice positions ({len(c.spatialLocator)}) for {c}. "
+                            f"For {c} in {self.name} there is a conflicting ``mult`` input ({mult}) "
+                            f"and number of lattice positions ({len(c.spatialLocator)}). "
                             "Recommend leaving off ``mult`` input when using grids."
                         )
                     elif not mult or mult == 1.0:
diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py
index 58d2d075..60f063b9 100644
--- a/armi/reactor/components/component.py
+++ b/armi/reactor/components/component.py
@@ -20,7 +20,7 @@ This module contains the abstract definition of a Component.
 
 import copy
 import re
-from typing import Optional
+from typing import Optional, Union
 
 import numpy as np
 
@@ -326,9 +326,12 @@ class Component(composites.Composite, metaclass=ComponentType):
                     self.p[dimName] = _DimensionLink((comp, linkedKey))
                 except Exception:
                     if value.count(".") > 1:
-                        raise ValueError(f"Component names should not have periods in them: `{value}`")
+                        raise ValueError(
+                            f"Name of {self} has a period in it. "
+                            f"Components cannot not have periods in their names: `{value}`"
+                        )
                     else:
-                        raise KeyError(f"Bad component link `{dimName}` defined as `{value}`")
+                        raise KeyError(f"Bad component link `{dimName}` defined as `{value}` in {self}")
 
     def setLink(self, key, otherComp, otherCompKey):
         """Set the dimension link."""
@@ -662,7 +665,7 @@ class Component(composites.Composite, metaclass=ComponentType):
         """
         return self.p.numberDensities.get(nucName, 0.0)
 
-    def getNuclideNumberDensities(self, nucNames):
+    def getNuclideNumberDensities(self, nucNames: list[str]) -> list[float]:
         """Return a list of number densities for the nuc names requested."""
         return [self.p.numberDensities.get(nucName, 0.0) for nucName in nucNames]
 
@@ -826,7 +829,7 @@ class Component(composites.Composite, metaclass=ComponentType):
         except ZeroDivisionError:
             return 0.0
 
-    def getMass(self, nuclideNames=None):
+    def getMass(self, nuclideNames: Union[None, str, list[str]] = None) -> float:
         r"""
         Determine the mass in grams of nuclide(s) and/or elements in this object.
 
diff --git a/armi/reactor/composites.py b/armi/reactor/composites.py
index eccbea84..4ba3993d 100644
--- a/armi/reactor/composites.py
+++ b/armi/reactor/composites.py
@@ -37,7 +37,17 @@ import collections
 import itertools
 import operator
 import timeit
-from typing import Callable, Dict, Iterator, List, Optional, Tuple, Type, Union
+from typing import (
+    TYPE_CHECKING,
+    Callable,
+    Dict,
+    Iterator,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+)
 
 import numpy as np
 
@@ -51,6 +61,9 @@ from armi.utils import densityTools, tabulate, units
 from armi.utils.densityTools import calculateNumberDensity
 from armi.utils.flags import auto
 
+if TYPE_CHECKING:
+    from armi.reactor.components.component import Component
+
 
 class FlagSerializer(parameters.Serializer):
     """
@@ -873,7 +886,7 @@ class ArmiObject(metaclass=CompositeModelType):
         """
         raise NotImplementedError()
 
-    def getMass(self, nuclideNames=None) -> float:
+    def getMass(self, nuclideNames: Union[None, str, list[str]] = None) -> float:
         """
         Determine the mass in grams of nuclide(s) and/or elements in this object.
 
@@ -887,7 +900,7 @@ class ArmiObject(metaclass=CompositeModelType):
 
         Parameters
         ----------
-        nuclideNames : str, optional
+        nuclideNames
             The nuclide/element specifier to get the mass of in the object.
             If omitted, total mass is returned.
 
@@ -917,7 +930,7 @@ class ArmiObject(metaclass=CompositeModelType):
             " of composite such as Blocks or Components have the concept of micro suffixes."
         )
 
-    def _getNuclidesFromSpecifier(self, nucSpec):
+    def _getNuclidesFromSpecifier(self, nucSpec: Union[None, str, list[str]]):
         """
         Convert a nuclide specification to a list of valid nuclide/element keys.
 
@@ -2179,14 +2192,18 @@ class ArmiObject(metaclass=CompositeModelType):
 
         return all(self.getComponents(t, exact) for t in typeSpec)
 
-    def getComponentByName(self, name):
+    def getComponentByName(self, name: str) -> "Component":
         """
         Gets a particular component from this object, based on its name.
 
         Parameters
         ----------
-        name : str
+        name
             The blueprint name of the component to return
+
+        Returns
+        -------
+        Component, c, whose c.name matches name.
         """
         components = [c for c in self.iterComponents() if c.name == name]
         nComp = len(components)
@@ -2197,7 +2214,7 @@ class ArmiObject(metaclass=CompositeModelType):
         else:
             return components[0]
 
-    def getComponent(self, typeSpec: TypeSpec, exact=False, quiet=False):
+    def getComponent(self, typeSpec: TypeSpec, exact: bool = False, quiet: bool = False) -> Optional["Component"]:
         """
         Get a particular component from this object.
 
@@ -2217,6 +2234,10 @@ class ArmiObject(metaclass=CompositeModelType):
         Returns
         -------
         Component : The component that matches the criteria or None
+
+        Raises
+        ------
+        ValueError: more than one Component matches the typeSpec
         """
         results = self.getComponents(typeSpec, exact=exact)
         if len(results) == 1:
@@ -2224,13 +2245,13 @@ class ArmiObject(metaclass=CompositeModelType):
         elif not results:
             if not quiet:
                 runLog.warning(
-                    "No component matched {0} in {1}. Returning None".format(typeSpec, self),
+                    f"No component matched {typeSpec} in {self}. Returning None",
                     single=True,
-                    label="None component returned instead of {0}".format(typeSpec),
+                    label=f"None component returned instead of {typeSpec}",
                 )
             return None
         else:
-            raise ValueError("Multiple components match in {} match typeSpec {}: {}".format(self, typeSpec, results))
+            raise ValueError(f"Multiple components match in {self} match typeSpec {typeSpec}: {results}")
 
     def getNumComponents(self, typeSpec: TypeSpec, exact=False):
         """
@@ -2645,7 +2666,7 @@ class Composite(ArmiObject):
     def getComponents(self, typeSpec: TypeSpec = None, exact=False):
         return list(self.iterComponents(typeSpec, exact))
 
-    def iterComponents(self, typeSpec: TypeSpec = None, exact=False):
+    def iterComponents(self, typeSpec: TypeSpec = None, exact: bool = False) -> Iterator["Component"]:
         """
         Return an iterator of armi.reactor.component.Component objects within this Composite.
 
diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py
index 5b7d945d..798daf78 100644
--- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py
+++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py
@@ -16,6 +16,7 @@ import dataclasses
 import functools
 import itertools
 import typing
+from textwrap import dedent
 
 from armi import runLog
 from armi.reactor.blocks import Block
@@ -23,6 +24,7 @@ from armi.reactor.components import Component, UnshapedComponent
 from armi.reactor.converters.axialExpansionChanger.expansionData import (
     iterSolidComponents,
 )
+from armi.reactor.grids import MultiIndexLocation
 
 if typing.TYPE_CHECKING:
     from armi.reactor.assemblies import Assembly
@@ -31,14 +33,6 @@ if typing.TYPE_CHECKING:
 def areAxiallyLinked(componentA: Component, componentB: Component) -> bool:
     """Determine axial component linkage for two components.
 
-    Components are considered linked if the following are found to be true:
-
-    1. Both contain solid materials.
-    2. They have identical types (e.g., ``Circle``).
-    3. Their multiplicities are the same.
-    4. The biggest inner bounding diameter of the two is less than the smallest outer
-       bounding diameter of the two.
-
     Parameters
     ----------
     componentA : :py:class:`Component <armi.reactor.components.component.Component>`
@@ -48,40 +42,67 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool:
 
     Notes
     -----
-    - Requires that shapes have the getCircleInnerDiameter and getBoundingCircleOuterDiameter
-      defined
-    - 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.
+    If componentA and componentB are both solids and the same type, geometric overlap can be checked via
+    getCircleInnerDiameter and getBoundingCircleOuterDiameter. Four different cases are accounted for.
+    If they do not meet these initial criteria, linkage is assumed to be False.
+    Case #1: Unshaped Components. There is no way to determine overlap so they're assumed to be not linked.
+    Case #2: Blocks with specified grids. If componentA and componentB have identical grid indices (cannot be a partial
+    case, ALL of the indices must be contained by one or the other), then overlap can be checked.
+    Case #3: If Component position is not specified via a grid, the multiplicity is checked. If consistent, they are
+    assumed to be in the same positions and their overlap is checked.
+    Case #4: Components are either not both solids, are not the same type, or Cases 1-3 are not True.
 
     Returns
     -------
     linked : bool
         status is componentA and componentB are axially linked to one another
     """
-    if (
-        (componentA.containsSolidMaterial() and componentB.containsSolidMaterial())
-        and type(componentA) is type(componentB)
-        and (componentA.getDimension("mult") == componentB.getDimension("mult"))
+    ## Cases 4
+    linked = False
+
+    if isinstance(componentA, type(componentB)) and (
+        componentA.containsSolidMaterial() and componentB.containsSolidMaterial()
     ):
         if isinstance(componentA, UnshapedComponent):
+            ## Case 1
             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, "
+                "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,
             )
-            return False
-        # Check if one component could fit within the other
-        idA = componentA.getCircleInnerDiameter(cold=True)
-        odA = componentA.getBoundingCircleOuterDiameter(cold=True)
-        idB = componentB.getCircleInnerDiameter(cold=True)
-        odB = componentB.getBoundingCircleOuterDiameter(cold=True)
-        biggerID = max(idA, idB)
-        smallerOD = min(odA, odB)
-        return biggerID < smallerOD
-    return False
+        elif isinstance(componentA.spatialLocator, MultiIndexLocation) and isinstance(
+            componentB.spatialLocator, MultiIndexLocation
+        ):
+            ## Case 2
+            fromA = set(tuple(index) for index in componentA.spatialLocator.indices)
+            fromB = set(tuple(index) for index in componentB.spatialLocator.indices)
+            if fromA == fromB:
+                linked = _checkOverlap(componentA, componentB)
+        elif componentA.getDimension("mult") == componentB.getDimension("mult"):
+            ## Case 3
+            linked = _checkOverlap(componentA, componentB)
+
+    return linked
+
+
+def _checkOverlap(componentA: Component, componentB: Component) -> bool:
+    """Check two components for geometric overlap by seeing if one can fit within the other.
+
+    Notes
+    -----
+    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.
+    """
+    idA = componentA.getCircleInnerDiameter(cold=True)
+    odA = componentA.getBoundingCircleOuterDiameter(cold=True)
+    idB = componentB.getCircleInnerDiameter(cold=True)
+    odB = componentB.getBoundingCircleOuterDiameter(cold=True)
+    biggerID = max(idA, idB)
+    smallerOD = min(odA, odB)
+    return biggerID < smallerOD
 
 
 # Make a generic type so we can "template" the axial link class based on what could be above/below a thing
@@ -93,17 +114,15 @@ class AxialLink(typing.Generic[Comp]):
     """Small class for named references to objects above and below a specific object.
 
     Axial expansion in ARMI works by identifying what objects occupy the same axial space.
-    For components in blocks, identify which above and below axially align. This is used
+    For components in blocks, identify which below axially align. This is used
     to determine what, if any, mass needs to be re-assigned across blocks during expansion.
     For blocks, the linking determines what blocks need to move as a result of a specific block's
     axial expansion.
 
     Attributes
     ----------
-    lower : Composite or None
+    lower
         Object below, if any.
-    upper : Composite or None
-        Object above, if any.
 
     Notes
     -----
@@ -117,8 +136,7 @@ class AxialLink(typing.Generic[Comp]):
     * :attr:`AxialAssemblyLinkage.linkedComponents`
     """
 
-    lower: typing.Optional[Comp] = dataclasses.field(default=None)
-    upper: typing.Optional[Comp] = dataclasses.field(default=None)
+    lower: typing.Optional[list[Comp]] = dataclasses.field(default=None)
 
 
 class AssemblyAxialLinkage:
@@ -180,10 +198,9 @@ class AssemblyAxialLinkage:
     def _getLinkedBlocks(blocks: typing.Sequence[Block], nBlocks: int) -> dict[Block, AxialLink[Block]]:
         # Use islice to avoid making intermediate lists of subsequences of blocks
         lower = itertools.chain((None,), itertools.islice(blocks, 0, nBlocks - 1))
-        upper = itertools.chain(itertools.islice(blocks, 1, None), (None,))
         links = {}
-        for low, mid, high in zip(lower, blocks, upper):
-            links[mid] = AxialLink(lower=low, upper=high)
+        for low, mid in zip(lower, blocks):
+            links[mid] = AxialLink(lower=low)
         return links
 
     def _determineAxialLinkage(self):
@@ -202,14 +219,17 @@ class AssemblyAxialLinkage:
             if candidate is None:
                 candidate = otherComp
             else:
-                errMsg = (
-                    "Multiple component axial linkages have been found for "
-                    f"Component {c} in Block {c.parent} in Assembly {c.parent.parent}. "
-                    "This is indicative of an error in the blueprints! Linked "
-                    f"components found are {candidate} and {otherComp} in {otherBlock}"
-                )
-                runLog.error(msg=errMsg)
-                raise RuntimeError(errMsg)
+                errMsg = f"""
+                    Multiple component axial linkages have been found for the following component!
+                        Component {c}
+                          -> Block {c.parent}
+                          -> Assembly {c.parent.parent}
+                    This is indicative of an error in the blueprints! Candidate components in {otherBlock}:
+                        {candidate}
+                        {otherComp}
+                """
+                runLog.error(msg=dedent(errMsg))
+                raise RuntimeError(dedent(errMsg))
         return candidate
 
     def _getLinkedComponents(self, b: Block, c: Component):
@@ -227,10 +247,8 @@ class AssemblyAxialLinkage:
         RuntimeError
             multiple candidate components are found to be axially linked to a component
         """
-        linkedBlocks = self.linkedBlocks[b]
-        lowerC = self._findComponentLinkedTo(c, linkedBlocks.lower)
-        upperC = self._findComponentLinkedTo(c, linkedBlocks.upper)
-        lstLinkedC = AxialLink(lowerC, upperC)
+        lowerC = self._findComponentLinkedTo(c, self.linkedBlocks[b].lower)
+        lstLinkedC = AxialLink(lowerC)
         self.linkedComponents[c] = lstLinkedC
 
         if self.linkedBlocks[b].lower is None and lstLinkedC.lower is None:
@@ -238,11 +256,6 @@ class AssemblyAxialLinkage:
                 f"Assembly {self.a}, Block {b}, Component {c} has nothing linked below it!",
                 single=True,
             )
-        if self.linkedBlocks[b].upper is None and lstLinkedC.upper is None:
-            runLog.debug(
-                f"Assembly {self.a}, Block {b}, Component {c} has nothing linked above it!",
-                single=True,
-            )
 
     @staticmethod
     def areAxiallyLinked(componentA: Component, componentB: Component) -> bool:
diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
index 9bfbc610..3262f641 100644
--- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
+++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
@@ -18,6 +18,7 @@ import typing
 from numpy import array
 
 from armi import runLog
+from armi.materials.material import Fluid
 from armi.reactor.assemblies import Assembly
 from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import (
     AssemblyAxialLinkage,
@@ -27,6 +28,7 @@ from armi.reactor.converters.axialExpansionChanger.expansionData import (
     iterSolidComponents,
 )
 from armi.reactor.flags import Flags
+from armi.utils.customExceptions import InputError
 
 
 def getDefaultReferenceAssem(assems):
@@ -258,7 +260,27 @@ class AxialExpansionChanger:
         """
         self.linked = AssemblyAxialLinkage(a)
         self.expansionData = ExpansionData(a, setFuel=setFuel, expandFromTinputToThot=expandFromTinputToThot)
+        self._checkAssemblyConstructionIsValid()
+
+    def _checkAssemblyConstructionIsValid(self):
         self._isTopDummyBlockPresent()
+        self._checkForBlocksWithoutSolids()
+
+    def _checkForBlocksWithoutSolids(self):
+        """
+        Makes sure that there aren't any blocks (other than the top-most dummy block)
+        that consist entirely of fluid components. The expansion changer doesn't know
+        what to do with such assemblies.
+        """
+        # skip top most dummy block since that is, by design, all fluid
+        for b in self.linked.a[:-1]:
+            if all(isinstance(c.material, Fluid) for c in b.iterComponents()):
+                raise InputError(
+                    f"Assembly {self.linked.a} is constructed improperly for use with the axial expansion changer "
+                    f"as block, {b}, consists of exclusively fluid component(s). If this is not a mistake, consider "
+                    "using the 'assemFlagsToSkipAxialExpansion' case setting to bypass performing axial expansion "
+                    "on this assembly."
+                )
 
     def applyColdHeightMassIncrease(self):
         """
@@ -271,7 +293,7 @@ class AxialExpansionChanger:
         called when the setting `inputHeightsConsideredHot` is used. This adjusts
         the expansion factor applied during applyMaterialMassFracsToNumberDensities.
         """
-        for c in self.linked.a.getComponents():
+        for c in self.linked.a.iterComponents():
             axialExpansionFactor = 1.0 + c.material.linearExpansionFactor(c.temperatureInC, c.inputTemperatureInC)
             c.changeNDensByFactor(axialExpansionFactor)
 
@@ -401,13 +423,7 @@ def _checkBlockHeight(b):
     3cm is a presumptive lower threshold 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()
-            )
-        )
+        runLog.debug(f"Block {b.name} ({str(b.p.flags)}) has a height less than 3.0 cm. ({b.getHeight():.12e})")
 
     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())
-        )
+        raise ArithmeticError(f"Block {b.name} ({str(b.p.flags)}) has a negative height! ({b.getHeight():.12e})")
diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py
index 7b59ebaa..70615b25 100644
--- a/armi/reactor/converters/axialExpansionChanger/expansionData.py
+++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py
@@ -14,9 +14,9 @@
 """Data container for axial expansion."""
 
 from statistics import mean
+from textwrap import dedent
 from typing import TYPE_CHECKING, Iterable, Optional
 
-from armi import runLog
 from armi.materials import material
 from armi.reactor.flags import Flags
 
@@ -91,7 +91,7 @@ class ExpansionData:
         self.componentReferenceTemperature = {}
         self._expansionFactors = {}
         self._componentDeterminesBlockHeight = {}
-        self._setTargetComponents(setFuel)
+        self._setAllTargetComponents(setFuel)
         self.expandFromTinputToThot = expandFromTinputToThot
 
     def setExpansionFactors(self, components: list["Component"], expFrac: list[float]):
@@ -110,17 +110,16 @@ class ExpansionData:
             If components and expFrac are different lengths
         """
         if len(components) != len(expFrac):
-            runLog.error(
+            raise RuntimeError(
                 "Number of components and expansion fractions must be the same!\n"
                 f"     len(components) = {len(components)}\n"
                 f"        len(expFrac) = {len(expFrac)}"
             )
-            raise RuntimeError
         for exp in expFrac:
             if exp <= 0.0:
-                msg = f"Expansion factor {exp}, L1/L0, is not physical. Expansion fractions should be greater than 0.0."
-                runLog.error(msg)
-                raise RuntimeError(msg)
+                raise RuntimeError(
+                    f"Expansion factor {exp}, L1/L0, is not physical. Expansion fractions should be greater than 0.0."
+                )
         for c, p in zip(components, expFrac):
             self._expansionFactors[c] = p
 
@@ -148,8 +147,7 @@ class ExpansionData:
             if tempGrid and tempField are different lengths
         """
         if len(tempGrid) != len(tempField):
-            runLog.error("tempGrid and tempField must have the same length.")
-            raise RuntimeError
+            raise RuntimeError("tempGrid and tempField must have the same length.")
 
         self.componentReferenceTemperature = {}  # reset, just to be safe
         for b in self._a:
@@ -162,7 +160,7 @@ class ExpansionData:
 
             if len(tmpMapping) == 0:
                 raise ValueError(
-                    f"{b} has no temperature points within it!"
+                    f"{b} has no temperature points within it!\n"
                     "Likely need to increase the refinement of the temperature grid."
                 )
 
@@ -224,28 +222,41 @@ class ExpansionData:
         value = self._expansionFactors.get(c, 1.0)
         return value
 
-    def _setTargetComponents(self, setFuel: bool):
-        """Sets target component for each block.
+    def _setAllTargetComponents(self, setFuel: bool):
+        """Sets axial expansion target component on each block in the expanded assembly.
 
         Parameters
         ----------
-        setFuel : bool
+        setFuel
             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:
-                target = b.getComponentByName(b.p.axialExpTargetComponent)
-                self._setExpansionTarget(b, target)
-            elif b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP):
-                self.determineTargetComponent(b, Flags.CLAD)
-            elif b.hasFlags(Flags.DUMMY):
-                # Dummy blocks are intended to contain only fluid and do not need a target component
-                pass
-            elif setFuel and b.hasFlags(Flags.FUEL):
-                self._isFuelLocked(b)
-            else:
-                self.determineTargetComponent(b)
+            self.setTargetComponent(b, setFuel)
+
+    def setTargetComponent(self, b: "Block", setFuel: bool):
+        """Set the axial expansion target component on a specific Block.
+
+        Parameters
+        ----------
+        b
+            ARMI Block which is to have its axial expansion target component set.
+        setFuel
+            boolean to determine if fuel block should have its target component set. Useful for when
+            target components should be determined on the fly.
+        """
+        if b.p.axialExpTargetComponent:
+            target = b.getComponentByName(b.p.axialExpTargetComponent)
+            self._setExpansionTarget(b, target)
+        elif b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP):
+            self.determineTargetComponent(b, Flags.CLAD)
+        elif b.hasFlags(Flags.DUMMY):
+            # Dummy blocks are intended to contain only fluid and do not need a target component
+            pass
+        elif setFuel and b.hasFlags(Flags.FUEL):
+            self._isFuelLocked(b)
+        else:
+            self.determineTargetComponent(b)
 
     def determineTargetComponent(self, b: "Block", flagOfInterest: Optional[Flags] = None) -> "Component":
         """Determines the component who's expansion will determine block height.
@@ -297,10 +308,13 @@ class ExpansionData:
         if len(candidates) == 0:
             raise RuntimeError(f"No target component found!\n   Block {b}")
         if len(candidates) > 1:
-            raise RuntimeError(
-                "Cannot have more than one component within a block that has the target flag!"
-                f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {candidates}"
-            )
+            msg = f"""
+                Cannot have more than one component within a block that has the target flag!
+                Block {b}
+                    flagOfInterest {flagOfInterest}
+                    Components {candidates}
+            """
+            raise RuntimeError(dedent(msg))
         target = candidates[0]
         self._setExpansionTarget(b, target)
         return target
diff --git a/doc/user/radial_and_axial_expansion.rst b/doc/user/radial_and_axial_expansion.rst
index 0d03402d..630db8ce 100644
--- a/doc/user/radial_and_axial_expansion.rst
+++ b/doc/user/radial_and_axial_expansion.rst
@@ -7,8 +7,6 @@ types function independently of one another and each have their own set of under
 and use-cases. Radial expansion happens by default but axial expansion only occurs if the setting
 ``inputHeightsConsideredHot`` is set to ``False``.
 
-.. _thermalExpansion:
-
 Thermal Expansion
 =================
 ARMI treats thermal expansion as a linear phenomena using a standard linear expansion relationship,
@@ -122,7 +120,7 @@ shown in the table below.
    * - Density
      - 1.0 g/cc
    * - Mean Coefficient Thermal Expansion
-     - 2 x 10-6 1/°C
+     - :math:`2\times 10^{-6}` 1/°C
 
 The figure below illustrates the thermal expansion phenomena in both the radial and axial
 directions.
@@ -133,8 +131,8 @@ directions.
 
 Thermal expansion calculations are performed for each component in the ARMI reactor data model as
 component temperatures change. Since components are constrained within blocks, the height of
-components are determined by the height of their parent block. Equations :eq:`hot_radius` through 
-:eq:`hot_density` illustrate how the radius, height, volume, density, and mass are updated for 
+components are determined by the height of their parent block. Equations :eq:`hot_radius` through
+:eq:`hot_density` illustrate how the radius, height, volume, density, and mass are updated for
 a Component during thermal expansion, respectively.
 
 .. list-table:: Example Calculation of Radial and Axial Thermal Expansion for a Cylindrical Component
@@ -168,48 +166,55 @@ a Component during thermal expansion, respectively.
 .. math::
    :name: hot_height
 
-    h(T_h) = 5.0 \left(1 + \left((2\times 10^{-6}(1000 − 20)\right)\right) = 5.01 cm
+    h(T_h) = 5.0 \left(1 + \left(2\times 10^{-6}(1000 − 20)\right)\right) = 5.01 cm
 
 .. math::
    :name: hot_volume
-   
+
     V(T_h) = \pi (0.251)^2 5.01 = 0.988 cm^3
 
 .. math::
    :name: hot_density
 
-    \rho(T_h) = \frac{1.0}{\left(1 + 2\times 10^{-6}(1000 − 20)\right)} = 0.994 \frac{g}{cc}
+    \rho(T_h) = \frac{1.0}{\left(1 + 2\times 10^{-6}(1000 − 20)\right)^3} = 0.994 \frac{g}{cc}
 
 .. math::
    :name: hot_mass
 
     m(T_h) = 0.994 \times 0.988 = 0.982 g
 
-Radial thermal expansion occurs for each component in a given block. Mechanical contact between
+Radial thermal expansion occurs for each Component in a given Block. Mechanical contact between
 components is not accounted for, meaning that the radial expansion of one Component is independent
-from the radial expansion of the others. Solid components may be linked to gas/fluid components
-(i.e., sodium bond, helium) and the gas/fluid area is allowed to expand and contract with changes in
-Component temperatures. It is worth noting that void components are allowed to have negative areas
+from the radial expansion of the others. Solid components may be radially linked to gas/fluid components
+(i.e., sodium bond, helium) and the gas/fluid area is allowed to radially expand and contract with changes in
+Component temperature. It is worth noting that void components are allowed to have negative areas
 in cases where the expansion of two solid components overlap each other.
 
-When two or more components exist within the Block, the overall height change of the Block is driven
-by an axial expansion "target Component" (e.g., fuel). Alternatively, the axial thermal expansion of
-components within a Block influences the positions of components in axially neighboring blocks for a given
-Assembly. So, while axial thermal expansion evaluations of each Block are treated independently, the
-axial mesh points are updated to account for the physical material displacements across the entire
-assembly length.
+Axial thermal expansion occurs for each solid Component with a given Block. Axial mechanical contact between components
+is accounted for as the expansion or contraction of a Component affects the positions of components in mechanical
+contact in axially neighboring blocks. The logic for determining Component-to-Component mechanical contact is
+described in Section :ref:`axialLink`. When two or more solid components exist within the Block, the change in Block
+height is driven by an axial expansion "target Component" (e.g., fuel). The logic for determining the axial
+expansion "target Component" is provided in Section :ref:`axialExpTargetComp`.
+
+Figures :ref:`components_for_exp_illustration` and :ref:`axial_exp_illustration` provide illustrations of the axial
+thermal expansion process for an example core assembly. In this example there are four main block types defined: Shield,
+Fuel, Plenum, and Dummy.
+
+.. note::
 
-The following two tables provide illustrations of the axial thermal expansion process for an example
-core assembly. In this example there are four main block types defined: Shield, Fuel, Plenum, and
-Dummy.
+  The "dummy" Block is necessary to maintain a consistent core-wide assembly height as this is a common necessity for
+  physics solvers utilizing discrete-ordinates discretization methods.
 
 .. figure:: /.static/axial_expansion_components.png
+  :name: components_for_exp_illustration
 
-    Illustration of Components for Axial Thermal Expansion Process
+  Illustration of Components for Axial Thermal Expansion Process
 
 .. figure:: /.static/axial_expansion_process.png
+  :name: axial_exp_illustration
 
-    Simplified Illustration of Axial Thermal Expansion Process for a Core Assembly
+  Simplified Illustration of Axial Thermal Expansion Process for a Core Assembly
 
 The target components for each Block type are provided in the following table:
 
@@ -218,7 +223,7 @@ The target components for each Block type are provided in the following table:
    :header-rows: 1
 
    * - Block
-     - Target Component 
+     - Target Component
    * - Shield
      - Shield
    * - Fuel
@@ -230,18 +235,155 @@ The target components for each Block type are provided in the following table:
 
 The axial thermal expansion algorithm is applied in four steps:
 
-#. Expand the axial dimensions of target component and non-target components within each
-   block independently. 
-#. Align blocks axially such that common components have consistent alignments (e.g.,
+#. Expand the axial dimensions of each solid Component within each block independently.
+#. Align blocks axially such that axially-linked components have consistent alignments (e.g.,
    overlapping radial dimensions).
-#. Assign the block lower and upper elevations to account for the thermal expansion of blocks
-   below each block.
+#. Assign the Block lower and upper elevations to account for the thermal expansion of blocks
+   below each Block.
 
-   * Create new mesh lines that track the target component.
+   * Create new mesh lines (i.e., Block bounds) that track the target component.
 
-#. Adjust the "Dummy" block located at the top of the assembly to maintain a consistent
+#. Adjust the "dummy" Block located at the top of the assembly to maintain a consistent
    core-wide assembly height before and after axial thermal expansion is applied.
 
+.. _axialLink:
+
+Component-to-Component Axial Linking
+------------------------------------
+For components to be in mechanical contact, and therefore axially linked, they need to meet the following criteria:
+
+#. The same Component class. E.g., both are :py:class:`basicShapes.Circle`.
+#. Both solid materials.
+
+If those are met, then geometric overlap may be checked if the following are met:
+
+#. The components are not :py:class:`components.UnshapedComponent`
+#. The components have the same multiplicity
+#. Or, they share the same grid indices, as specified by a Block :py:class:`<grid> grids.locations.MultiIndexLocation`.
+
+Finally, geometric overlap is established if the biggest inner bounding diameter of the components is less than the
+smallest outer bounding diameter of the components.
+
+Limitations
+^^^^^^^^^^^
+
+A current limitation of the axial linking logic is that multiple Components may not be linked to a single Component.
+E.g., consider the following:
+
+#. A solid cylinder with an outer diameter of 1.0 cm.
+#. Above, a solid cylinder wrapped with an annular cylinder (separate ARMI components) each with the following dimensions:
+
+  * Solid cylinder with an outer diameter of 0.5 cm.
+  * Annulus with inner diameter of 0.5 cm and outer diameter of 0.75 cm.
+
+For the above example, in reality, the annulus wrapped pin (two separate ARMI components) would be affected by any
+changes in height from the solid cylinder. However, this set up is not allowed by the current implementation and will
+raise a ``RuntimeError``.
+
+A second limitation of the component linking implementation involves the Block grid based approach. When Block grids are
+used to specify a pin lattice, the Block-grid should be used throughout the Assembly definition; i.e., a mixture of
+the Block-grid and multiplicity assignment should not be used (and will likely produce unexpected results and may even
+fail). For example, in the following partial blueprint definition, in reality, each shield pin should be in mechanical
+contact with the fuel pins. However, since there is a mixture of mulitiplicity and Block-grid approaches, they are
+assumed to be not-linked. In order to ensure properly linking, ``block_fuel_axial_shield`` needs to be redefined with
+the Block-grid based approach.
+
+.. code-block:: yaml
+
+  axial shield: &block_fuel_axial_shield
+    shield:
+      shape: Circle
+      material: HT9
+      Tinput: 25.0
+      Thot: 600.0
+      id: 0.0
+      mult: 169.0
+      od: 0.86602
+
+  fuel multiPin: &block_fuel_multiPin
+    grid name: twoPin
+    fuel 1: &component_fuelmultiPin
+      shape: Circle
+      material: UZr
+      Tinput: 25.0
+      Thot: 600.0
+      id: 0.0
+      od: 0.86602
+      latticeIDs: [1]
+    fuel 2:
+      <<: *component_fuelmultiPin
+      latticeIDs: [2]
+
+The following incorporates the fix for ``block_fuel_axial_shield`` and illustrates another potentially undesirable
+situation where unexpected results or runtime failure may occur. Here a plenum block is added above the fuel and while
+it does utilize a Block-grid, ``clad`` will not be axially linked to either the ``fuel 1`` or ``fuel 2`` components
+below it. This is because the ``clad`` and ``fuel*`` components have different grids via their ``grid.spatialLocator``
+values. As in the previous example, similar unexpected behavior would also occur if a multiplicity-based definition
+were used for ``clad``.
+
+.. code-block:: yaml
+
+  axial shield multiPin: &block_fuel_multiPin_axial_shield
+    grid name: twoPin
+    shield 1: &component_shield_shield1
+      shape: Circle
+      material: HT9
+      Tinput: 25.0
+      Thot: 600.0
+      id: 0.0
+      od: 0.8
+      latticeIDs: [1]
+    shield 2:
+      <<: *component_shield_shield1
+      latticeIDs: [2]
+
+  fuel multiPin: &block_fuel_multiPin
+    grid name: twoPin
+    fuel 1: &component_fuelmultiPin
+      shape: Circle
+      material: UZr
+      Tinput: 25.0
+      Thot: 600.0
+      id: 0.0
+      od: 0.8
+      latticeIDs: [1]
+    fuel 2:
+      <<: *component_fuelmultiPin
+      latticeIDs: [2]
+
+  plenum 2pin: &block_plenum_multiPin
+    grid name: twoPin
+    clad:
+      shape: Circle
+      material: Void
+      Tinput: 25.0
+      Thot: 600.0
+      id: 0.9
+      od: 1.0
+      latticeIDs: [1,2]
+
+To resolve this potential issue, ``block_plenum_multiPin`` should be replaced with the following definition. See the
+``multi pin fuel`` assembly definition within ``armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml`` for a
+complete example.
+
+.. code-block:: yaml
+
+    plenum 2pin: &block_plenum_multiPin
+    grid name: twoPin
+    clad 1: &component_plenummultiPin_clad1
+        shape: Circle
+        material: Void
+        Tinput: 25.0
+        Thot: 600.0
+        id: 0.9
+        od: 1.0
+        latticeIDs: [1]
+    clad 2:
+      <<: *component_plenummultiPin_clad1
+        latticeIDs: [2]
+
+
+.. _axialExpTargetComp:
 
 Target Component Logic
 ----------------------
@@ -267,7 +409,7 @@ Due to the requirement that all components within a Block be the same height, th
 mass post-axial expansion is not trivial. At the Block-level, the axial expansion target component
 is guaranteed to have its mass conserved post-axial expansion. For pinned-blocks, this is typically
 chosen to be the most neutronically important component; e.g., in a fuel Block this is typically the
-Fuel component. All other components, assuming they expand at a different rate than the fuel, will
+fuel component. All other components, assuming they expand at a different rate than the fuel, will
 exhibit non-conservation on the Block-level as mass is redistributed across the axially-neighboring
 blocks. However, the mass of all solid components at the assembly-level are designed to be conserved
 if the following are met for a given assembly design.


Test patch:
diff --git a/armi/reactor/converters/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/tests/test_assemblyAxialLinkage.py
new file mode 100644
index 00000000..052b4d40
--- /dev/null
+++ b/armi/reactor/converters/tests/test_assemblyAxialLinkage.py
@@ -0,0 +1,402 @@
+# Copyright 2025 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.
+
+import io
+from typing import TYPE_CHECKING, Callable, Type
+from unittest import TestCase
+
+from armi.reactor.assemblies import HexAssembly, grids
+from armi.reactor.blocks import HexBlock
+from armi.reactor.blueprints import Blueprints
+from armi.reactor.components import UnshapedComponent
+from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle
+from armi.reactor.components.complexShapes import Helix
+from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import (
+    AssemblyAxialLinkage,
+    AxialLink,
+    _checkOverlap,
+)
+from armi.reactor.converters.tests.test_axialExpansionChanger import (
+    AxialExpansionTestBase,
+    _buildDummySodium,
+    buildTestAssemblyWithFakeMaterial,
+)
+from armi.reactor.flags import Flags
+from armi.settings.caseSettings import Settings
+
+if TYPE_CHECKING:
+    from armi.reactor.components import Component
+
+TWOPIN_BLOCK = """
+    fuel twoPin: &block_fuel_twoPin
+        grid name: twoPin
+        fuel 1: &component_fueltwoPin
+            shape: Circle
+            material: UZr
+            Tinput: 25.0
+            Thot: 600.0
+            id: 0.0
+            od: 0.8
+            latticeIDs: [1]
+        fuel 2:
+            <<: *component_fueltwoPin
+            latticeIDs: [2]
+        coolant:
+            shape: DerivedShape
+            material: Sodium
+            Tinput: 450.0
+            Thot: 450.0
+        duct:
+            shape: Hexagon
+            material: HT9
+            Tinput: 25.0
+            Thot: 450.0
+            ip: 16.0
+            mult: 1.0
+            op: 16.6
+"""
+
+ONEPIN_BLOCK = """
+    fuel onePin: &block_fuel_onePin
+        grid name: onePin
+        fuel 1:
+            <<: *component_fueltwoPin
+        coolant:
+            shape: DerivedShape
+            material: Sodium
+            Tinput: 450.0
+            Thot: 450.0
+        duct:
+            shape: Hexagon
+            material: HT9
+            Tinput: 25.0
+            Thot: 450.0
+            ip: 16.0
+            mult: 1.0
+            op: 16.6
+"""
+
+CORRECT_ASSEMBLY = """
+    fuel pass:
+        specifier: LA
+        blocks: [*block_fuel_twoPin, *block_fuel_twoPin]
+        height: [25.0, 25.0]
+        axial mesh points: [1, 1]
+        xs types: [A, A]
+"""
+
+WRONG_ASSEMBLY = """
+    fuel fail:
+        specifier: LA
+        blocks: [*block_fuel_twoPin, *block_fuel_onePin]
+        height: [25.0, 25.0]
+        axial mesh points: [1, 1]
+        xs types: [A, A]
+"""
+
+TWOPIN_GRID = """
+    twoPin:
+       geom: hex_corners_up
+       symmetry: full
+       lattice map: |
+         - - -  1 1 1 1
+           - - 1 1 2 1 1
+            - 1 1 1 1 1 1
+             1 2 1 2 1 2 1
+              1 1 1 1 1 1
+               1 1 2 1 1
+                1 1 1 1
+"""
+
+ONEPIN_GRID = """
+    onePin:
+       geom: hex_corners_up
+       symmetry: full
+       lattice map: |
+         - - -  1 1 1 1
+           - - 1 1 1 1 1
+            - 1 1 1 1 1 1
+             1 1 1 1 1 1 1
+              1 1 1 1 1 1
+               1 1 1 1 1
+                1 1 1 1
+"""
+
+
+def createMultipinBlueprints(blockDef: list[str], assemDef: list[str], gridDef: list[str]) -> str:
+    multiPinDef = "blocks:"
+    for block in blockDef:
+        multiPinDef += block
+    multiPinDef += "\nassemblies:"
+    for assem in assemDef:
+        multiPinDef += assem
+    multiPinDef += "\ngrids:"
+    for grid in gridDef:
+        multiPinDef += grid
+
+    return multiPinDef
+
+
+class TestAxialLinkHelper(TestCase):
+    """Tests for the AxialLink dataclass / namedtuple like class."""
+
+    @classmethod
+    def setUpClass(cls):
+        cls.LOWER_BLOCK = _buildDummySodium(20, 10)
+
+    def test_override(self):
+        """Test lower attribute can be set after construction."""
+        empty = AxialLink()
+        self.assertIsNone(empty.lower)
+        empty.lower = self.LOWER_BLOCK
+        self.assertIs(empty.lower, self.LOWER_BLOCK)
+
+    def test_construct(self):
+        """Test lower attributes can be set at construction."""
+        link = AxialLink(self.LOWER_BLOCK)
+        self.assertIs(link.lower, self.LOWER_BLOCK)
+
+
+class TestAreAxiallyLinked(AxialExpansionTestBase):
+    """Provide test coverage for the different cases in assemblyAxialLinkage.areAxiallyLinked."""
+
+    def test_mismatchComponentType(self):
+        """Case 4; component type mismatch."""
+        compDims = ("test", "FakeMat", 25.0, 25.0)  # name, material, Tinput, Thot
+        comp1 = Circle(*compDims, od=1.0, id=0.0)
+        comp2 = Hexagon(*compDims, op=1.0, ip=0.0)
+        self.assertFalse(AssemblyAxialLinkage.areAxiallyLinked(comp1, comp2))
+
+    def test_unshapedComponents(self):
+        """Case 1; unshaped components."""
+        compDims = {"Tinput": 25.0, "Thot": 25.0}
+        comp1 = UnshapedComponent("unshaped_1", "FakeMat", **compDims)
+        comp2 = UnshapedComponent("unshaped_2", "FakeMat", **compDims)
+        self.assertFalse(AssemblyAxialLinkage.areAxiallyLinked(comp1, comp2))
+
+    def test_componentMult(self):
+        """Case 3; multiplicity based linking."""
+        compDims = ("test", "FakeMat", 25.0, 25.0)
+        comp1 = Circle(*compDims, od=1.0, id=0.0)
+        comp2 = Circle(*compDims, od=1.0, id=0.0)
+        # mult are same, comp1 and comp2 are linked
+        self.assertTrue(AssemblyAxialLinkage.areAxiallyLinked(comp1, comp2))
+        # mult is different, now they are not linked
+        comp2.p.mult = 2
+        self.assertFalse(AssemblyAxialLinkage.areAxiallyLinked(comp1, comp2))
+
+    def test_multiIndexLocation(self):
+        """Case 2; block-grid based linking."""
+        cs = Settings()
+        multiPinBPs = createMultipinBlueprints([TWOPIN_BLOCK], [CORRECT_ASSEMBLY], [TWOPIN_GRID])
+        with io.StringIO(multiPinBPs) as stream:
+            bps = Blueprints.load(stream)
+            bps._prepConstruction(cs)
+            lowerB: HexBlock = bps.assemblies["fuel pass"][0]
+            upperB: HexBlock = bps.assemblies["fuel pass"][1]
+            lowerFuel1, lowerFuel2 = lowerB.getComponents(Flags.FUEL)
+            upperFuel1, _upperFuel2 = upperB.getComponents(Flags.FUEL)
+            # same grid locs, are linked
+            self.assertTrue(AssemblyAxialLinkage.areAxiallyLinked(lowerFuel1, upperFuel1))
+            # different grid locs, are not linked
+            self.assertFalse(AssemblyAxialLinkage.areAxiallyLinked(lowerFuel2, upperFuel1))
+
+    def test_multiIndexLocation_Fail(self):
+        """Case 2; block-grid based linking."""
+        cs = Settings()
+        multiPinBPs = createMultipinBlueprints(
+            [TWOPIN_BLOCK, ONEPIN_BLOCK], [WRONG_ASSEMBLY], [TWOPIN_GRID, ONEPIN_GRID]
+        )
+        with io.StringIO(multiPinBPs) as stream:
+            bps = Blueprints.load(stream)
+            bps._prepConstruction(cs)
+            lowerB: HexBlock = bps.assemblies["fuel fail"][0]
+            upperB: HexBlock = bps.assemblies["fuel fail"][1]
+            lowerFuel1, lowerFuel2 = lowerB.getComponents(Flags.FUEL)
+            upperFuel1 = upperB.getComponent(Flags.FUEL)
+            # different/not exact match grid locs, are not linked
+            self.assertFalse(AssemblyAxialLinkage.areAxiallyLinked(lowerFuel1, upperFuel1))
+            # different/not exact match grid locs, are not linked
+            self.assertFalse(AssemblyAxialLinkage.areAxiallyLinked(lowerFuel2, upperFuel1))
+
+
+class TestCheckOverlap(AxialExpansionTestBase):
+    """Test axial linkage between components via the AssemblyAxialLinkage._checkOverlap."""
+
+    @classmethod
+    def setUpClass(cls):
+        """Contains common dimensions for all component class types."""
+        super().setUp(cls)
+        cls.common = ("test", "FakeMat", 25.0, 25.0)  # name, material, Tinput, Thot
+
+    def runTest(
+        self,
+        componentsToTest: dict[Type["Component"], dict[str, float]],
+        assertion: Callable,
+    ):
+        """Runs various linkage tests.
+
+        Parameters
+        ----------
+        componentsToTest
+            dictionary keys indicate the component type for ``typeA`` and ``typeB`` checks. the values indicate the
+            neccessary geometry specifications of the ``typeA`` and ``typeB`` components.
+        assertion
+            unittest.TestCase assertion
+
+        Notes
+        -----
+        - components "typeA" and "typeB" are assumed to be candidates for axial linking
+        - two assertions: 1) comparing "typeB" component to "typeA"; 2) comparing "typeA" component to "typeB"
+        - the different assertions are particularly useful for comparing two annuli
+        """
+        for method, dims in componentsToTest.items():
+            typeA = method(*self.common, **dims[0])
+            typeB = method(*self.common, **dims[1])
+            msg = f"{self._testMethodName} failed for component type {str(method)}!"
+            assertion(_checkOverlap(typeA, typeB), msg=msg)
+            assertion(_checkOverlap(typeB, typeA), msg=msg)
+
+    def test_overlappingSolidPins(self):
+        componentTypesToTest = {
+            Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.0}],
+            Hexagon: [{"op": 0.5, "ip": 0.0}, {"op": 1.0, "ip": 0.0}],
+            Rectangle: [
+                {
+                    "lengthOuter": 0.5,
+                    "lengthInner": 0.0,
+                    "widthOuter": 0.5,
+                    "widthInner": 0.0,
+                },
+                {
+                    "lengthOuter": 1.0,
+                    "lengthInner": 0.0,
+                    "widthOuter": 1.0,
+                    "widthInner": 0.0,
+                },
+            ],
+            Helix: [
+                {"od": 0.5, "axialPitch": 1.0, "helixDiameter": 1.0},
+                {"od": 1.0, "axialPitch": 1.0, "helixDiameter": 1.0},
+            ],
+        }
+        self.runTest(componentTypesToTest, self.assertTrue)
+
+    def test_solidPinNotOverlappingAnnulus(self):
+        componentTypesToTest = {
+            Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.6}],
+        }
+        self.runTest(componentTypesToTest, self.assertFalse)
+
+    def test_solidPinOverlappingWithAnnulus(self):
+        componentTypesToTest = {
+            Circle: [{"od": 0.7, "id": 0.0}, {"od": 1.0, "id": 0.6}],
+        }
+        self.runTest(componentTypesToTest, self.assertTrue)
+
+    def test_annularPinNotOverlappingWithAnnulus(self):
+        componentTypesToTest = {
+            Circle: [{"od": 0.6, "id": 0.3}, {"od": 1.0, "id": 0.6}],
+        }
+        self.runTest(componentTypesToTest, self.assertFalse)
+
+    def test_annularPinOverlappingWithAnnuls(self):
+        componentTypesToTest = {
+            Circle: [{"od": 0.7, "id": 0.3}, {"od": 1.0, "id": 0.6}],
+        }
+        self.runTest(componentTypesToTest, self.assertTrue)
+
+    def test_thinAnnularPinOverlappingWithThickAnnulus(self):
+        componentTypesToTest = {
+            Circle: [{"od": 0.7, "id": 0.3}, {"od": 0.6, "id": 0.5}],
+        }
+        self.runTest(componentTypesToTest, self.assertTrue)
+
+    def test_AnnularHexOverlappingThickAnnularHex(self):
+        componentTypesToTest = {Hexagon: [{"op": 1.0, "ip": 0.8}, {"op": 1.2, "ip": 0.8}]}
+        self.runTest(componentTypesToTest, self.assertTrue)
+
+
+class TestMultipleComponentLinkage(AxialExpansionTestBase):
+    """Ensure that multiple component axial linkage can be caught."""
+
+    def test_getLinkedComponents(self):
+        """Test for multiple component axial linkage."""
+        linked = AssemblyAxialLinkage(buildTestAssemblyWithFakeMaterial("FakeMat"))
+        b = linked.a.getFirstBlockByType("fuel")
+        fuelComp = b.getComponent(Flags.FUEL)
+        cladComp = b.getComponent(Flags.CLAD)
+        fuelComp.setDimension("od", 0.5 * (cladComp.getDimension("id") + cladComp.getDimension("od")))
+        with self.assertRaisesRegex(
+            RuntimeError,
+            expected_regex="Multiple component axial linkages have been found for ",
+        ):
+            linked._getLinkedComponents(b, fuelComp)
+
+
+class TestBlockLink(TestCase):
+    """Test the ability to link blocks in an assembly."""
+
+    def test_singleBlock(self):
+        """Test an edge case where a single block exists."""
+        b = _buildDummySodium(300, 50)
+        links = AssemblyAxialLinkage.getLinkedBlocks([b])
+        self.assertEqual(len(links), 1)
+        self.assertIn(b, links)
+        linked = links.pop(b)
+        self.assertIsNone(linked.lower)
+
+    def test_multiBlock(self):
+        """Test links with multiple blocks."""
+        N_BLOCKS = 5
+        blocks = [_buildDummySodium(300, 50) for _ in range(N_BLOCKS)]
+        links = AssemblyAxialLinkage.getLinkedBlocks(blocks)
+        first = blocks[0]
+        lowLink = links[first]
+        self.assertIsNone(lowLink.lower)
+        for ix in range(1, N_BLOCKS - 1):
+            current = blocks[ix]
+            below = blocks[ix - 1]
+            link = links[current]
+            self.assertIs(link.lower, below)
+        top = blocks[-1]
+        lastLink = links[top]
+        self.assertIs(lastLink.lower, blocks[-2])
+
+    def test_emptyBlocks(self):
+        """Test even smaller edge case when no blocks are passed."""
+        with self.assertRaisesRegex(ValueError, "No blocks passed. Cannot determine links"):
+            AssemblyAxialLinkage.getLinkedBlocks([])
+
+    def test_onAssembly(self):
+        """Test assembly behavior is the same as sequence of blocks."""
+        assembly = HexAssembly("test")
+        N_BLOCKS = 5
+        assembly.spatialGrid = grids.AxialGrid.fromNCells(numCells=N_BLOCKS)
+        assembly.spatialGrid.armiObject = assembly
+
+        blocks = []
+        for _ in range(N_BLOCKS):
+            b = _buildDummySodium(300, 10)
+            assembly.add(b)
+            blocks.append(b)
+
+        fromBlocks = AssemblyAxialLinkage.getLinkedBlocks(blocks)
+        fromAssem = AssemblyAxialLinkage.getLinkedBlocks(assembly)
+
+        self.assertSetEqual(set(fromBlocks), set(fromAssem))
+
+        for b, bLink in fromBlocks.items():
+            aLink = fromAssem[b]
+            self.assertIs(aLink.lower, bLink.lower)
diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py
index 844ff9e6..58b45fc5 100644
--- a/armi/reactor/converters/tests/test_axialExpansionChanger.py
+++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py
@@ -19,6 +19,7 @@ import copy
 import os
 import unittest
 from statistics import mean
+from typing import Callable
 
 from numpy import array, linspace, zeros
 
@@ -26,9 +27,8 @@ 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 Component, DerivedShape, UnshapedComponent
-from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle
-from armi.reactor.components.complexShapes import Helix
+from armi.reactor.components import Component, DerivedShape
+from armi.reactor.components.basicShapes import Circle, Hexagon
 from armi.reactor.converters.axialExpansionChanger import (
     AssemblyAxialLinkage,
     AxialExpansionChanger,
@@ -36,14 +36,11 @@ from armi.reactor.converters.axialExpansionChanger import (
     getSolidComponents,
     iterSolidComponents,
 )
-from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import (
-    AxialLink,
-    areAxiallyLinked,
-)
 from armi.reactor.flags import Flags
 from armi.testing import loadTestReactor
 from armi.tests import TEST_ROOT
 from armi.utils import units
+from armi.utils.customExceptions import InputError
 
 
 class AxialExpansionTestBase(unittest.TestCase):
@@ -158,11 +155,11 @@ class Temperature:
                 self.tempField[i, :] = tmp[i]
 
 
-class TestAxialExpansionHeight(AxialExpansionTestBase, unittest.TestCase):
+class TestAxialExpansionHeight(AxialExpansionTestBase):
     """Verify that test assembly is expanded correctly."""
 
     def setUp(self):
-        AxialExpansionTestBase.setUp(self)
+        super().setUp()
         self.a = buildTestAssemblyWithFakeMaterial(name="FakeMat")
 
         self.temp = Temperature(self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10)
@@ -175,9 +172,6 @@ class TestAxialExpansionHeight(AxialExpansionTestBase, unittest.TestCase):
             self.obj.performThermalAxialExpansion(self.a, self.temp.tempGrid, self.temp.tempField[idt, :], setFuel=True)
             self._getConservationMetrics(self.a)
 
-    def tearDown(self):
-        AxialExpansionTestBase.tearDown(self)
-
     def test_AssemblyAxialExpansionHeight(self):
         """Test the axial expansion gives correct heights for component-based expansion."""
         for idt in range(self.temp.tempSteps):
@@ -228,16 +222,13 @@ class TestAxialExpansionHeight(AxialExpansionTestBase, unittest.TestCase):
         return mean(tmpMapping)
 
 
-class TestConservation(AxialExpansionTestBase, unittest.TestCase):
+class TestConservation(AxialExpansionTestBase):
     """Verify that conservation is maintained in assembly-level axial expansion."""
 
     def setUp(self):
-        AxialExpansionTestBase.setUp(self)
+        super().setUp()
         self.a = buildTestAssemblyWithFakeMaterial(name="FakeMat")
 
-    def tearDown(self):
-        AxialExpansionTestBase.tearDown(self)
-
     def expandAssemForMassConservationTest(self):
         """Do the thermal expansion and store conservation metrics of interest."""
         # create a semi-realistic/physical variable temperature grid over the assembly
@@ -308,13 +299,13 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
         )
         assems = list(rCold.blueprints.assemblies.values())
         for a in assems:
-            if a.hasFlags([Flags.MIDDLE, Flags.ANNULAR, Flags.TEST]):
+            if a.hasFlags([Flags.MIDDLE, Flags.ANNULAR]):
                 # assemblies with the above flags have liners and conservation of such assemblies is
                 # not currently supported
                 continue
             self.complexConservationTest(a)
 
-    def complexConservationTest(self, a):
+    def complexConservationTest(self, a: HexAssembly):
         origMesh = a.getAxialMesh()[:-1]
         origMasses, origNDens = self._getComponentMassAndNDens(a)
         axialExpChngr = AxialExpansionChanger(detailedAxialExpansion=True)
@@ -327,15 +318,16 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
                     axialExpChngr.expansionData.updateComponentTemp(c, c.temperatureInC + temp)
             # get U235/B10 and FE56 mass pre-expansion
             prevFE56Mass = a.getMass("FE56")
-            prevMass = self._getMass(a)
+            if a.hasFlags([Flags.FUEL, Flags.CONTROL]):
+                prevMass = a.getMass("U235" if a.hasFlags(Flags.FUEL) else "B10")
             # 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:
+            if a.hasFlags([Flags.FUEL, Flags.CONTROL]):
+                newMass = a.getMass("U235" if a.hasFlags(Flags.FUEL) else "B10")
                 self.assertAlmostEqual(newMass / prevMass, 1.0, places=14, msg=f"{a}")
 
         newMasses, newNDens = self._getComponentMassAndNDens(a)
@@ -345,18 +337,6 @@ class TestConservation(AxialExpansionTestBase, unittest.TestCase):
         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.
 
@@ -645,17 +625,14 @@ class TestManageCoreMesh(unittest.TestCase):
                 self.assertAlmostEqual(newMass / prevMass, 1.00, msg=f"{c}, {c.parent}")
 
 
-class TestExceptions(AxialExpansionTestBase, unittest.TestCase):
+class TestExceptions(AxialExpansionTestBase):
     """Verify exceptions are caught."""
 
     def setUp(self):
-        AxialExpansionTestBase.setUp(self)
+        super().setUp()
         self.a = buildTestAssemblyWithFakeMaterial(name="FakeMatException")
         self.obj.setAssembly(self.a)
 
-    def tearDown(self):
-        AxialExpansionTestBase.tearDown(self)
-
     def test_isTopDummyBlockPresent(self):
         # build test assembly without dummy
         assembly = HexAssembly("testAssemblyType")
@@ -666,63 +643,55 @@ class TestExceptions(AxialExpansionTestBase, unittest.TestCase):
         assembly.reestablishBlockOrder()
         # create instance of expansion changer
         obj = AxialExpansionChanger(detailedAxialExpansion=True)
-        with self.assertRaises(RuntimeError) as cm:
+        with self.assertRaisesRegex(
+            RuntimeError,
+            "Cannot run detailedAxialExpansion without a dummy block at the top of the assembly!",
+        ):
             obj.setAssembly(assembly)
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
 
     def test_setExpansionFactors(self):
-        with self.assertRaises(RuntimeError) as cm:
-            cList = self.a[0].getChildren()
-            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)
+        cList = self.a.getFirstBlock().getChildren()
+        with self.assertRaisesRegex(
+            RuntimeError,
+            "Number of components and expansion fractions must be the same!",
+        ):
+            self.obj.expansionData.setExpansionFactors(cList, range(len(cList) + 1))
+
+        with self.assertRaisesRegex(
+            RuntimeError,
+            "L1/L0, is not physical. Expansion fractions should be greater than 0.0.",
+        ):
+            self.obj.expansionData.setExpansionFactors(cList, zeros(len(cList)))
+
+        with self.assertRaisesRegex(
+            RuntimeError,
+            "L1/L0, is not physical. Expansion fractions should be greater than 0.0.",
+        ):
+            self.obj.expansionData.setExpansionFactors(cList, zeros(len(cList)) - 10.0)
 
     def test_updateCompTempsBy1DTempFieldValError(self):
         tempGrid = [5.0, 15.0, 35.0]
         tempField = linspace(25.0, 310.0, 3)
-        with self.assertRaises(ValueError) as cm:
+        with self.assertRaisesRegex(ValueError, "has no temperature points within it!"):
             self.obj.expansionData.updateComponentTempsBy1DTempField(tempGrid, tempField)
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
 
     def test_updateCompTempsBy1DTempFieldError(self):
         tempGrid = [5.0, 15.0, 35.0]
         tempField = linspace(25.0, 310.0, 10)
-        with self.assertRaises(RuntimeError) as cm:
+        with self.assertRaisesRegex(RuntimeError, "tempGrid and tempField must have the same length."):
             self.obj.expansionData.updateComponentTempsBy1DTempField(tempGrid, tempField)
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
 
     def test_AssemblyAxialExpansionException(self):
         """Test that negative height exception is caught."""
         # manually set axial exp target component for code coverage
         self.a[0].p.axialExpTargetComponent = self.a[0][0].name
         temp = Temperature(self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10)
-        with self.assertRaises(ArithmeticError) as cm:
+        with self.assertRaisesRegex(ArithmeticError, "has a negative height!"):
             for idt in range(temp.tempSteps):
                 self.obj.expansionData.updateComponentTempsBy1DTempField(temp.tempGrid, temp.tempField[idt, :])
                 self.obj.expansionData.computeThermalExpansionFactors()
                 self.obj.axiallyExpandAssembly()
 
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
-
     def test_isFuelLocked(self):
         """Ensures that the RuntimeError statement in ExpansionData::_isFuelLocked is raised
         appropriately.
@@ -733,7 +702,7 @@ class TestExceptions(AxialExpansionTestBase, unittest.TestCase):
         to ExpansionData::_isFuelLocked.
         """
         expdata = ExpansionData(HexAssembly("testAssemblyType"), setFuel=True, expandFromTinputToThot=False)
-        b_NoFuel = HexBlock("fuel", height=10.0)
+        bNoFuel = HexBlock("fuel", height=10.0)
         shieldDims = {
             "Tinput": 25.0,
             "Thot": 25.0,
@@ -742,40 +711,20 @@ class TestExceptions(AxialExpansionTestBase, unittest.TestCase):
             "mult": 127.0,
         }
         shield = Circle("shield", "FakeMat", **shieldDims)
-        b_NoFuel.add(shield)
-        with self.assertRaises(RuntimeError) as cm:
-            expdata._isFuelLocked(b_NoFuel)
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
-
-    def test_determineLinked(self):
-        compDims = {"Tinput": 25.0, "Thot": 25.0}
-        compA = UnshapedComponent("unshaped_1", "FakeMat", **compDims)
-        compB = UnshapedComponent("unshaped_2", "FakeMat", **compDims)
-        self.assertFalse(areAxiallyLinked(compA, compB))
-
-    def test_getLinkedComponents(self):
-        """Test for multiple component axial linkage."""
-        shieldBlock = self.obj.linked.a[0]
-        shieldComp = shieldBlock[0]
-        shieldComp.setDimension("od", 0.785, cold=True)
-        with self.assertRaises(RuntimeError) as cm:
-            self.obj.linked._getLinkedComponents(shieldBlock, shieldComp)
-            self.assertEqual(cm.exception, 3)
-
-
-class TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase):
+        bNoFuel.add(shield)
+        with self.assertRaisesRegex(RuntimeError, f"No fuel component within {bNoFuel}!"):
+            expdata._isFuelLocked(bNoFuel)
+
+
+class TestDetermineTargetComponent(AxialExpansionTestBase):
     """Verify determineTargetComponent method is properly updating _componentDeterminesBlockHeight."""
 
     def setUp(self):
-        AxialExpansionTestBase.setUp(self)
+        super().setUp()
         self.expData = ExpansionData([], setFuel=True, expandFromTinputToThot=True)
         coolDims = {"Tinput": 25.0, "Thot": 25.0}
         self.coolant = DerivedShape("coolant", "Sodium", **coolDims)
 
-    def tearDown(self):
-        AxialExpansionTestBase.tearDown(self)
-
     def test_determineTargetComponent(self):
         """Provides coverage for searching TARGET_FLAGS_IN_PREFERRED_ORDER."""
         b = HexBlock("fuel", height=10.0)
@@ -822,14 +771,10 @@ class TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase):
         b = HexBlock("fuel", height=10.0)
         b.add(self.coolant)
         b.setType("fuel")
-        with self.assertRaises(RuntimeError) as cm:
+        with self.assertRaisesRegex(RuntimeError, "No target component found!"):
             self.expData.determineTargetComponent(b)
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
-        with self.assertRaises(RuntimeError) as cm:
+        with self.assertRaisesRegex(RuntimeError, "No target component found!"):
             self.expData.determineTargetComponent(b, Flags.FUEL)
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
 
     def test_specifyTargetComp_singleSolid(self):
         """Ensures that specifyTargetComponent is smart enough to set the only solid as the target component."""
@@ -865,10 +810,11 @@ class TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase):
         b.add(fuelAnnular)
         b.add(self.coolant)
         b.setType("FuelBlock")
-        with self.assertRaises(RuntimeError) as cm:
+        with self.assertRaisesRegex(
+            RuntimeError,
+            "Cannot have more than one component within a block that has the target flag!",
+        ):
             self.expData.determineTargetComponent(b, flagOfInterest=Flags.FUEL)
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
 
     def test_manuallySetTargetComponent(self):
         """
@@ -901,12 +847,10 @@ class TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase):
 class TestGetSolidComponents(unittest.TestCase):
     """Verify that getSolidComponents returns just solid components."""
 
-    def setUp(self):
-        self.a = buildTestAssemblyWithFakeMaterial(name="HT9")
-
     def test_getSolidComponents(self):
         """Show that getSolidComponents produces a list of solids, and is consistent with iterSolidComponents."""
-        for b in self.a:
+        a = buildTestAssemblyWithFakeMaterial(name="HT9")
+        for b in a:
             solids = getSolidComponents(b)
             ids = set(map(id, solids))
             for c in iterSolidComponents(b):
@@ -918,6 +862,16 @@ class TestGetSolidComponents(unittest.TestCase):
                 msg="Inconsistency between getSolidComponents and iterSolidComponents",
             )
 
+    def test_checkForBlocksWithoutSolids(self):
+        a = buildTestAssemblyWithFakeMaterial(name="Sodium")
+        changer = AxialExpansionChanger()
+        changer.linked = AssemblyAxialLinkage(a)
+        with self.assertRaisesRegex(
+            InputError,
+            expected_regex="is constructed improperly for use with the axial expansion changer",
+        ):
+            changer._checkForBlocksWithoutSolids()
+
 
 class TestInputHeightsConsideredHot(unittest.TestCase):
     """Verify thermal expansion for process loading of core."""
@@ -970,17 +924,14 @@ class TestInputHeightsConsideredHot(unittest.TestCase):
                 ),
             )
             for bStd, bExp in zip(aStd, aExp):
-                hasCustomMaterial = any(isinstance(c.material, custom.Custom) for c in bStd)
-                if hasCustomMaterial:
+                if any(isinstance(c.material, custom.Custom) for c in bStd):
                     checkColdBlockHeight(bStd, bExp, self.assertAlmostEqual, "the same")
                 else:
                     checkColdBlockHeight(bStd, bExp, self.assertNotEqual, "different")
-                if bStd.hasFlags(Flags.FUEL):
-                    self.checkColdHeightBlockMass(bStd, bExp, Flags.FUEL, "U235")
-                elif bStd.hasFlags(Flags.CONTROL):
-                    self.checkColdHeightBlockMass(bStd, bExp, Flags.CONTROL, "B10")
-
-                if not aStd.hasFlags(Flags.TEST) and not hasCustomMaterial:
+                    if bStd.hasFlags(Flags.FUEL):
+                        self.checkColdHeightBlockMass(bStd, bExp, "U235")
+                    elif bStd.hasFlags(Flags.CONTROL):
+                        self.checkColdHeightBlockMass(bStd, bExp, "B10")
                     for cExp in iterSolidComponents(bExp):
                         if cExp.zbottom == bExp.p.zbottom and cExp.ztop == bExp.p.ztop:
                             matDens = cExp.material.density(Tc=cExp.temperatureInC)
@@ -997,7 +948,7 @@ class TestInputHeightsConsideredHot(unittest.TestCase):
                                 msg=msg,
                             )
 
-    def checkColdHeightBlockMass(self, bStd: HexBlock, bExp: HexBlock, flagType: Flags, nuclide: str):
+    def checkColdHeightBlockMass(self, bStd: HexBlock, bExp: HexBlock, nuclide: str):
         """Checks that nuclide masses for blocks with input cold heights and
         "inputHeightsConsideredHot": True are underpredicted.
 
@@ -1008,12 +959,10 @@ class TestInputHeightsConsideredHot(unittest.TestCase):
         ultimately results in nuclide masses being underpredicted relative to the case where both
         nuclide densities and block heights are thermally expanded.
         """
-        # custom materials don't expand
-        if not isinstance(bStd.getComponent(flagType).material, custom.Custom):
-            self.assertGreater(bExp.getMass(nuclide), bStd.getMass(nuclide))
+        self.assertGreater(bExp.getMass(nuclide), bStd.getMass(nuclide))
 
 
-def checkColdBlockHeight(bStd, bExp, assertType, strForAssertion):
+def checkColdBlockHeight(bStd: HexBlock, bExp: HexBlock, assertType: Callable, strForAssertion: str):
     assertType(
         bStd.getHeight(),
         bExp.getHeight(),
@@ -1028,160 +977,6 @@ def checkColdBlockHeight(bStd, bExp, assertType, strForAssertion):
     )
 
 
-class TestComponentLinks(AxialExpansionTestBase, unittest.TestCase):
-    """Test axial linkage between components."""
-
-    def setUp(self):
-        """Contains common dimensions for all component class types."""
-        AxialExpansionTestBase.setUp(self)
-        self.common = ("test", "FakeMat", 25.0, 25.0)  # name, material, Tinput, Thot
-
-    def tearDown(self):
-        AxialExpansionTestBase.tearDown(self)
-
-    def runTest(
-        self,
-        componentsToTest: dict,
-        assertionBool: bool,
-        name: str,
-        commonArgs: tuple = None,
-    ):
-        """Runs various linkage tests.
-
-        Parameters
-        ----------
-        componentsToTest : dict
-            keys --> component class type; values --> dimensions specific to key
-        assertionBool : boolean
-            expected truth value for test
-        name : str
-            the name of the test
-        commonArgs : tuple, optional
-            arguments common to all Component class types
-
-        Notes
-        -----
-        - components "typeA" and "typeB" are assumed to be vertically stacked
-        - two assertions: 1) comparing "typeB" component to "typeA"; 2) comparing "typeA" component
-          to "typeB"
-        - the different assertions are particularly useful for comparing two annuli
-        - to add Component class types to a test add dictionary entry with following:
-          {Component Class Type: [{<settings for component 1>}, {<settings for component 2>}]
-        """
-        if commonArgs is None:
-            common = self.common
-        else:
-            common = commonArgs
-        for method, dims in componentsToTest.items():
-            typeA = method(*common, **dims[0])
-            typeB = method(*common, **dims[1])
-            if assertionBool:
-                self.assertTrue(
-                    areAxiallyLinked(typeA, typeB),
-                    msg="Test {0:s} failed for component type {1:s}!".format(name, str(method)),
-                )
-                self.assertTrue(
-                    areAxiallyLinked(typeB, typeA),
-                    msg="Test {0:s} failed for component type {1:s}!".format(name, str(method)),
-                )
-            else:
-                self.assertFalse(
-                    areAxiallyLinked(typeA, typeB),
-                    msg="Test {0:s} failed for component type {1:s}!".format(name, str(method)),
-                )
-                self.assertFalse(
-                    areAxiallyLinked(typeB, typeA),
-                    msg="Test {0:s} failed for component type {1:s}!".format(name, str(method)),
-                )
-
-    def test_overlappingSolidPins(self):
-        componentTypesToTest = {
-            Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.0}],
-            Hexagon: [{"op": 0.5, "ip": 0.0}, {"op": 1.0, "ip": 0.0}],
-            Rectangle: [
-                {
-                    "lengthOuter": 0.5,
-                    "lengthInner": 0.0,
-                    "widthOuter": 0.5,
-                    "widthInner": 0.0,
-                },
-                {
-                    "lengthOuter": 1.0,
-                    "lengthInner": 0.0,
-                    "widthOuter": 1.0,
-                    "widthInner": 0.0,
-                },
-            ],
-            Helix: [
-                {"od": 0.5, "axialPitch": 1.0, "helixDiameter": 1.0},
-                {"od": 1.0, "axialPitch": 1.0, "helixDiameter": 1.0},
-            ],
-        }
-        self.runTest(componentTypesToTest, True, "test_overlappingSolidPins")
-
-    def test_differentMultNotOverlapping(self):
-        componentTypesToTest = {
-            Circle: [{"od": 0.5, "mult": 10}, {"od": 0.5, "mult": 20}],
-            Hexagon: [{"op": 0.5, "mult": 10}, {"op": 1.0, "mult": 20}],
-            Rectangle: [
-                {"lengthOuter": 1.0, "widthOuter": 1.0, "mult": 10},
-                {"lengthOuter": 1.0, "widthOuter": 1.0, "mult": 20},
-            ],
-            Helix: [
-                {"od": 0.5, "axialPitch": 1.0, "helixDiameter": 1.0, "mult": 10},
-                {"od": 1.0, "axialPitch": 1.0, "helixDiameter": 1.0, "mult": 20},
-            ],
-        }
-        self.runTest(componentTypesToTest, False, "test_differentMultNotOverlapping")
-
-    def test_solidPinNotOverlappingAnnulus(self):
-        componentTypesToTest = {
-            Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.6}],
-        }
-        self.runTest(componentTypesToTest, False, "test_solidPinNotOverlappingAnnulus")
-
-    def test_solidPinOverlappingWithAnnulus(self):
-        componentTypesToTest = {
-            Circle: [{"od": 0.7, "id": 0.0}, {"od": 1.0, "id": 0.6}],
-        }
-        self.runTest(componentTypesToTest, True, "test_solidPinOverlappingWithAnnulus")
-
-    def test_annularPinNotOverlappingWithAnnulus(self):
-        componentTypesToTest = {
-            Circle: [{"od": 0.6, "id": 0.3}, {"od": 1.0, "id": 0.6}],
-        }
-        self.runTest(componentTypesToTest, False, "test_annularPinNotOverlappingWithAnnulus")
-
-    def test_annularPinOverlappingWithAnnuls(self):
-        componentTypesToTest = {
-            Circle: [{"od": 0.7, "id": 0.3}, {"od": 1.0, "id": 0.6}],
-        }
-        self.runTest(componentTypesToTest, True, "test_annularPinOverlappingWithAnnuls")
-
-    def test_thinAnnularPinOverlappingWithThickAnnulus(self):
-        componentTypesToTest = {
-            Circle: [{"od": 0.7, "id": 0.3}, {"od": 0.6, "id": 0.5}],
-        }
-        self.runTest(componentTypesToTest, True, "test_thinAnnularPinOverlappingWithThickAnnulus")
-
-    def test_AnnularHexOverlappingThickAnnularHex(self):
-        componentTypesToTest = {Hexagon: [{"op": 1.0, "ip": 0.8}, {"op": 1.2, "ip": 0.8}]}
-        self.runTest(componentTypesToTest, True, "test_AnnularHexOverlappingThickAnnularHex")
-
-    def test_liquids(self):
-        componentTypesToTest = {
-            Circle: [{"od": 1.0, "id": 0.0}, {"od": 1.0, "id": 0.0}],
-            Hexagon: [{"op": 1.0, "ip": 0.0}, {"op": 1.0, "ip": 0.0}],
-        }
-        liquid = ("test", "Sodium", 425.0, 425.0)  # name, material, Tinput, Thot
-        self.runTest(componentTypesToTest, False, "test_liquids", commonArgs=liquid)
-
-    def test_unshapedComponentAndCircle(self):
-        comp1 = Circle(*self.common, od=1.0, id=0.0)
-        comp2 = UnshapedComponent(*self.common, area=1.0)
-        self.assertFalse(areAxiallyLinked(comp1, comp2))
-
-
 def buildTestAssemblyWithFakeMaterial(name: str, hot: bool = False):
     """Create test assembly consisting of list of fake material.
 
@@ -1301,91 +1096,3 @@ class FakeMatException(materials.ht9.HT9):
         """A fake linear expansion percent."""
         Tc = units.getTc(Tc, Tk)
         return 0.08 * Tc
-
-
-class TestAxialLinkHelper(unittest.TestCase):
-    """Tests for the AxialLink dataclass / namedtuple like class."""
-
-    @classmethod
-    def setUpClass(cls):
-        cls.LOWER_BLOCK = _buildDummySodium(20, 10)
-        cls.UPPER_BLOCK = _buildDummySodium(300, 50)
-
-    def test_override(self):
-        """Test the upper and lower attributes can be set after construction."""
-        empty = AxialLink()
-        self.assertIsNone(empty.lower)
-        self.assertIsNone(empty.upper)
-        empty.lower = self.LOWER_BLOCK
-        empty.upper = self.UPPER_BLOCK
-        self.assertIs(empty.lower, self.LOWER_BLOCK)
-        self.assertIs(empty.upper, self.UPPER_BLOCK)
-
-    def test_construct(self):
-        """Test the upper and lower attributes can be set at construction."""
-        link = AxialLink(self.LOWER_BLOCK, self.UPPER_BLOCK)
-        self.assertIs(link.lower, self.LOWER_BLOCK)
-        self.assertIs(link.upper, self.UPPER_BLOCK)
-
-
-class TestBlockLink(unittest.TestCase):
-    """Test the ability to link blocks in an assembly."""
-
-    def test_singleBlock(self):
-        """Test an edge case where a single block exists."""
-        b = _buildDummySodium(300, 50)
-        links = AssemblyAxialLinkage.getLinkedBlocks([b])
-        self.assertEqual(len(links), 1)
-        self.assertIn(b, links)
-        linked = links.pop(b)
-        self.assertIsNone(linked.lower)
-        self.assertIsNone(linked.upper)
-
-    def test_multiBlock(self):
-        """Test links with multiple blocks."""
-        N_BLOCKS = 5
-        blocks = [_buildDummySodium(300, 50) for _ in range(N_BLOCKS)]
-        links = AssemblyAxialLinkage.getLinkedBlocks(blocks)
-        first = blocks[0]
-        lowLink = links[first]
-        self.assertIsNone(lowLink.lower)
-        self.assertIs(lowLink.upper, blocks[1])
-        for ix in range(1, N_BLOCKS - 1):
-            current = blocks[ix]
-            below = blocks[ix - 1]
-            above = blocks[ix + 1]
-            link = links[current]
-            self.assertIs(link.lower, below)
-            self.assertIs(link.upper, above)
-        top = blocks[-1]
-        lastLink = links[top]
-        self.assertIsNone(lastLink.upper)
-        self.assertIs(lastLink.lower, blocks[-2])
-
-    def test_emptyBlocks(self):
-        """Test even smaller edge case when no blocks are passed."""
-        with self.assertRaisesRegex(ValueError, "No blocks passed. Cannot determine links"):
-            AssemblyAxialLinkage.getLinkedBlocks([])
-
-    def test_onAssembly(self):
-        """Test assembly behavior is the same as sequence of blocks."""
-        assembly = HexAssembly("test")
-        N_BLOCKS = 5
-        assembly.spatialGrid = grids.AxialGrid.fromNCells(numCells=N_BLOCKS)
-        assembly.spatialGrid.armiObject = assembly
-
-        blocks = []
-        for _ in range(N_BLOCKS):
-            b = _buildDummySodium(300, 10)
-            assembly.add(b)
-            blocks.append(b)
-
-        fromBlocks = AssemblyAxialLinkage.getLinkedBlocks(blocks)
-        fromAssem = AssemblyAxialLinkage.getLinkedBlocks(assembly)
-
-        self.assertSetEqual(set(fromBlocks), set(fromAssem))
-
-        for b, bLink in fromBlocks.items():
-            aLink = fromAssem[b]
-            self.assertIs(aLink.lower, bLink.lower)
-            self.assertIs(aLink.upper, bLink.upper)
diff --git a/armi/reactor/converters/tests/test_uniformMesh.py b/armi/reactor/converters/tests/test_uniformMesh.py
index 2f38a280..387b2414 100644
--- a/armi/reactor/converters/tests/test_uniformMesh.py
+++ b/armi/reactor/converters/tests/test_uniformMesh.py
@@ -39,7 +39,6 @@ class DummyFluxOptions:
 class TestConverterFactory(unittest.TestCase):
     def setUp(self):
         self.o, self.r = loadTestReactor(inputFilePath=os.path.join(TEST_ROOT, "detailedAxialExpansion"))
-        reduceTestReactorRings(self.r, self.o.cs, 2)
 
         self.dummyOptions = DummyFluxOptions(self.o.cs)
 
@@ -62,7 +61,6 @@ class TestAssemblyUniformMesh(unittest.TestCase):
 
     def setUp(self):
         self.o, self.r = loadTestReactor(inputFilePath=os.path.join(TEST_ROOT, "detailedAxialExpansion"))
-        reduceTestReactorRings(self.r, self.o.cs, 2)
 
         self.converter = uniformMesh.NeutronicsUniformMeshConverter(cs=self.o.cs)
         self.converter._sourceReactor = self.r
@@ -81,7 +79,8 @@ class TestAssemblyUniformMesh(unittest.TestCase):
         )
 
         prevB = None
-        for newB, sourceB in zip(newAssem, sourceAssem):
+        for newB in newAssem:
+            sourceB = sourceAssem.getBlockAtElevation(newB.p.z)
             if newB.isFuel() and sourceB.isFuel():
                 self.assertEqual(newB.p["xsType"], sourceB.p["xsType"])
             elif not newB.isFuel() and not sourceB.isFuel():
diff --git a/armi/tests/detailedAxialExpansion/refSmallCoreGrid.yaml b/armi/tests/detailedAxialExpansion/refSmallCoreGrid.yaml
index 6d7e7fec..15178b20 100644
--- a/armi/tests/detailedAxialExpansion/refSmallCoreGrid.yaml
+++ b/armi/tests/detailedAxialExpansion/refSmallCoreGrid.yaml
@@ -2,12 +2,20 @@ core:
   geom: hex
   symmetry: third periodic
   lattice map: |
-         -  PC
-          IC  IC
-            IC  OC
-          IC  LA  IC
-            IC  IC  SC
-              LA  IC
-            IC  IC  OC
-              IC  IC
-            AF  IC  OC
+    SH
+      AF
+    MC  SH
+      LA
+    PC  IC
+
+fourPin:
+  geom: hex_corners_up
+  symmetry: full
+  lattice map: |
+    - - -  1 4 4 1
+      - - 1 4 2 4 1
+       - 1 4 3 3 4 1
+        1 2 3 2 3 2 1
+         1 4 3 3 4 1
+          1 4 2 4 1
+           1 4 4 1
diff --git a/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml b/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml
index 65fb02d8..ad74cd94 100644
--- a/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml
+++ b/armi/tests/detailedAxialExpansion/refSmallReactorBase.yaml
@@ -40,7 +40,7 @@ blocks:
             Thot: 450.0
             ip: grid.op
             mult: 1.0
-            op: 16.75
+            op: 19.0
 
     duct: &block_duct
         coolant: *component_coolant
@@ -49,9 +49,9 @@ blocks:
             material: HT9
             Tinput: 25.0
             Thot: 450.0
-            ip: 16.0
+            ip: 18.0
             mult: 1.0
-            op: 16.6
+            op: 18.5
         intercoolant: &component_intercoolant
             shape: Hexagon
             material: Sodium
@@ -59,7 +59,7 @@ blocks:
             Thot: 450.0
             ip: duct.op
             mult: 1.0
-            op: 16.75
+            op: 19.0
 
     SodiumBlock: &block_dummy
         flags: dummy
@@ -70,7 +70,7 @@ blocks:
             Thot: 450.0
             ip: 0.0
             mult: 1.0
-            op: 16.75
+            op: 19.0
 
     ## ------------------------------------------------------------------------------------
     ## fuel blocks
@@ -113,32 +113,33 @@ blocks:
         duct: *component_duct
         intercoolant: *component_intercoolant
 
-    fuel: &block_fuel
-        fuel: &component_fuel_fuel
+    axial shield multiPin: &block_fuel_multiPin_axial_shield
+        grid name: fourPin
+        shield 1: &component_shield_shield1
             shape: Circle
-            material: UZr
+            material: HT9
             Tinput: 25.0
             Thot: 600.0
             id: 0.0
-            mult: 169.0
             od: 0.86602
-        bond: &component_fuel_bond
+            latticeIDs: [1]
+        bond 1: &component_shield_bond1
             shape: Circle
             material: Sodium
             Tinput: 25.0
             Thot: 470.0
-            id: fuel.od
-            mult: fuel.mult
-            od: clad.id
-        clad: &component_fuel_clad
+            id: shield 1.od
+            od: clad 1.id
+            latticeIDs: [1]
+        clad 1: &component_shield_clad1
             shape: Circle
             material: HT9
             Tinput: 25.0
             Thot: 470.0
             id: 1.0
-            mult: fuel.mult
             od: 1.09
-        wire: &component_fuel_wire
+            latticeIDs: [1]
+        wire 1: &component_shield_wire1
             shape: Helix
             material: HT9
             Tinput: 25.0
@@ -146,30 +147,75 @@ blocks:
             axialPitch: 30.15
             helixDiameter: 1.19056
             id: 0.0
-            mult: fuel.mult
             od: 0.10056
+            latticeIDs: [1]
+        shield 2:
+            <<: *component_shield_shield1
+            latticeIDs: [2]
+        bond 2:
+            <<: *component_shield_bond1
+            latticeIDs: [2]
+        clad 2:
+            <<: *component_shield_clad1
+            latticeIDs: [2]
+        wire 2:
+            <<: *component_shield_wire1
+            latticeIDs: [2]
+        shield 3:
+            <<: *component_shield_shield1
+            latticeIDs: [3]
+        bond 3:
+            <<: *component_shield_bond1
+            latticeIDs: [3]
+        clad 3:
+            <<: *component_shield_clad1
+            latticeIDs: [3]
+        wire 3:
+            <<: *component_shield_wire1
+            latticeIDs: [3]
+        shield 4:
+            <<: *component_shield_shield1
+            latticeIDs: [4]
+        bond 4:
+            <<: *component_shield_bond1
+            latticeIDs: [4]
+        clad 4:
+            <<: *component_shield_clad1
+            latticeIDs: [4]
+        wire 4:
+            <<: *component_shield_wire1
+            latticeIDs: [4]
         coolant: *component_coolant
         duct: *component_duct
         intercoolant: *component_intercoolant
+        axial expansion target component: shield 1
 
-    plenum: &block_plenum
-        gap: &component_plenum_gap
+    fuel: &block_fuelPin
+        fuel: &component_fuel_fuel
             shape: Circle
-            material: Void
+            material: UZr
             Tinput: 25.0
             Thot: 600.0
             id: 0.0
-            mult: clad.mult
+            mult: 169.0
+            od: 0.86602
+        bond:
+            shape: Circle
+            material: Sodium
+            Tinput: 25.0
+            Thot: 470.0
+            id: fuel.od
+            mult: fuel.mult
             od: clad.id
-        clad: &component_plenum_clad
+        clad: &component_fuel_clad
             shape: Circle
             material: HT9
             Tinput: 25.0
             Thot: 470.0
             id: 1.0
-            mult: 169.0
+            mult: fuel.mult
             od: 1.09
-        wire: &component_plenum_wire
+        wire: &component_fuel_wire
             shape: Helix
             material: HT9
             Tinput: 25.0
@@ -183,92 +229,40 @@ blocks:
         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: *component_duct
-        intercoolant: *component_intercoolant
-
-    fuel2: &block_fuel2
+    fuel lined clad: &block_fuelPin_linedClad
         fuel:
-            shape: Circle
+            <<: *component_fuel_fuel
             material: Custom
-            Tinput: 25.0
-            Thot: 600.0
-            id: 0.0
             isotopics: MOX
-            mult: 169.0
-            od: 0.86602
         bond:
             shape: Circle
             material: Sodium
             Tinput: 25.0
-            Thot: 600.0
+            Thot: 470.0
             id: fuel.od
             mult: fuel.mult
-            od: liner2.id
-        liner2: &component_fuel2_liner2
-            shape: Circle
-            material: HT9
-            Tinput: 25.0
-            Thot: 600.0
-            id: 0.98
-            mergeWith: clad
-            mult: 169.0
-            od: 0.99
-        liner1: &component_fuel2_liner1
+            od: liner.id
+        liner: &component_fuel_liner
             shape: Circle
             material: HT9
             Tinput: 25.0
-            Thot: 600.0
-            id: 0.99
+            Thot: 470.0
+            id: 0.90
             mergeWith: clad
             mult: 169.0
-            od: 1.0
-        clad: *component_fuel_clad
-        wire: *component_fuel_wire
-        coolant: *component_coolant
-        duct: *component_duct
-        intercoolant: *component_intercoolant
-
-    lta fuel a: &block_lta1_fuel
-        fuel: *component_fuel_fuel
-        bond: *component_fuel_bond
-        liner2: *component_fuel2_liner2
-        liner1: *component_fuel2_liner1
-        clad: *component_fuel_clad
-        wire: *component_fuel_wire
-        coolant: *component_coolant
-        duct: *component_duct
-        intercoolant: *component_intercoolant
-
-    lta fuel b: &block_lta2_fuel
-        fuel:
-            shape: Circle
-            material: UZr
-            Tinput: 25.0
-            Thot: 600.0
-            id: 0.0
-            isotopics: PuUZr
-            mult: 169.0
-            od: 0.86602
-        bond: *component_fuel_bond
-        liner2: *component_fuel2_liner2
-        liner1: *component_fuel2_liner1
+            od: clad.id
         clad: *component_fuel_clad
         wire: *component_fuel_wire
         coolant: *component_coolant
         duct: *component_duct
         intercoolant: *component_intercoolant
 
-    annular fuel gap: &block_fuel3
+    annular fuel lined clad: &block_fuelAnnular_linedClad
         gap1:
             shape: Circle
             material: Void
             Tinput: 25.0
-            Thot: 430.0
+            Thot: 600.0
             id: 0.0
             mult: fuel.mult
             od: fuel.id
@@ -281,58 +275,158 @@ blocks:
             mult: 169.0
             od: 0.86602
             flags: annular fuel depletable
-        gap2:
+        gap:
             shape: Circle
             material: Void
             Tinput: 25.0
-            Thot: 430.0
+            Thot: 600.0
             id: fuel.od
             mult: fuel.mult
-            od: inner liner.id
-        inner liner:
+            od: liner.id
+        liner: *component_fuel_liner
+        clad: *component_fuel_clad
+        wire: *component_fuel_wire
+        coolant: *component_coolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
+
+    fuel multiPin: &block_fuel_multiPin
+        grid name: fourPin
+        fuel 1: &component_fuelmultiPin
             shape: Circle
-            material: HT9
+            material: UZr
             Tinput: 25.0
-            Thot: 430.0
-            id: 0.878
-            mult: fuel.mult
-            od: 0.898
-        gap3:
+            Thot: 600.0
+            id: 0.0
+            od: 0.86602
+            latticeIDs: [1]
+        bond 1: &component_fuelmultiPin_bond
             shape: Circle
-            material: Void
+            material: Sodium
             Tinput: 25.0
-            Thot: 430.0
-            id: inner liner.od
-            mult: fuel.mult
-            od: outer liner.id
-        outer liner:
+            Thot: 470.0
+            id: fuel 1.od
+            od: clad 1.id
+            latticeIDs: [1]
+        clad 1: &component_fuelmultiPin_clad1
             shape: Circle
-            material: Zr
+            material: HT9
             Tinput: 25.0
-            Thot: 430.0
-            id: 0.898
-            mult: fuel.mult
-            od: 0.900
-        gap4:
+            Thot: 470.0
+            id: 1.0
+            od: 1.09
+            latticeIDs: [1]
+        wire 1: &component_fuelmultiPin_wire1
+            shape: Helix
+            material: HT9
+            Tinput: 25.0
+            Thot: 470.0
+            axialPitch: 30.15
+            helixDiameter: 1.19056
+            id: 0.0
+            od: 0.10056
+            latticeIDs: [1]
+        fuel 2:
+            <<: *component_fuelmultiPin
+            latticeIDs: [2]
+        bond 2:
+            <<: *component_fuelmultiPin_bond
+            latticeIDs: [2]
+        clad 2: &component_fuelmultiPin_clad2
+            <<: *component_fuelmultiPin_clad1
+            latticeIDs: [2]
+        wire 2: &component_fuelmultiPin_wire2
+            <<: *component_fuelmultiPin_wire1
+            latticeIDs: [2]
+        fuel 3:
+            <<: *component_fuelmultiPin
+            latticeIDs: [3]
+        bond 3:
+            <<: *component_fuelmultiPin_bond
+            latticeIDs: [3]
+        clad 3: &component_fuelmultiPin_clad3
+            <<: *component_fuelmultiPin_clad1
+            latticeIDs: [3]
+        wire 3: &component_fuelmultiPin_wire3
+            <<: *component_fuelmultiPin_wire1
+            latticeIDs: [3]
+        fuel 4:
+            <<: *component_fuelmultiPin
+            latticeIDs: [4]
+        bond 4:
+            <<: *component_fuelmultiPin_bond
+            latticeIDs: [4]
+        clad 4: &component_fuelmultiPin_clad4
+            <<: *component_fuelmultiPin_clad1
+            latticeIDs: [4]
+        wire 4: &component_fuelmultiPin_wire4
+            <<: *component_fuelmultiPin_wire1
+            latticeIDs: [4]
+        coolant: *component_coolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
+        axial expansion target component: fuel 1
+
+    plenum: &block_plenum
+        gap:
             shape: Circle
             material: Void
             Tinput: 25.0
-            Thot: 430.0
-            id: outer liner.od
-            mult: fuel.mult
+            Thot: 600.0
+            id: 0.0
+            mult: clad.mult
             od: clad.id
         clad:
             shape: Circle
             material: HT9
             Tinput: 25.0
             Thot: 470.0
-            id: 0.900
-            mult: fuel.mult
-            od: 1.000
+            id: 1.0
+            mult: 169.0
+            od: 1.09
         wire: *component_fuel_wire
         coolant: *component_coolant
         duct: *component_duct
         intercoolant: *component_intercoolant
+        axial expansion target component: clad
+
+    aclp plenum: &block_aclp
+        <<: *block_plenum
+
+    plenum 4pin: &block_plenum_multiPin
+        grid name: fourPin
+        gap 1: &component_plenummultiPin_gap1
+            shape: Circle
+            material: Void
+            Tinput: 25.0
+            Thot: 600.0
+            id: 0.0
+            od: clad 1.id
+            latticeIDs: [1]
+        clad 1: *component_fuelmultiPin_clad1
+        wire 1: *component_fuelmultiPin_wire1
+        gap 2:
+            <<: *component_plenummultiPin_gap1
+            latticeIDs: [2]
+        clad 2: *component_fuelmultiPin_clad2
+        wire 2: *component_fuelmultiPin_wire2
+        gap 3:
+            <<: *component_plenummultiPin_gap1
+            latticeIDs: [3]
+        clad 3: *component_fuelmultiPin_clad3
+        wire 3: *component_fuelmultiPin_wire3
+        gap 4:
+            <<: *component_plenummultiPin_gap1
+            latticeIDs: [4]
+        clad 4: *component_fuelmultiPin_clad4
+        wire 4: *component_fuelmultiPin_wire4
+        coolant: *component_coolant
+        duct: *component_duct
+        intercoolant: *component_intercoolant
+        axial expansion target component: clad 1
+
+    aclp plenum 4pin: &block_aclp_multiPin
+        <<: *block_plenum_multiPin
 
     ## ------------------------------------------------------------------------------------
     ## control
@@ -353,7 +447,7 @@ blocks:
             Thot: 450.0
             ip: duct.op
             mult: 1.0
-            op: 16.75
+            op: 19.0
 
     moveable control: &block_control
         control:
@@ -461,12 +555,7 @@ blocks:
         intercoolant: *component_intercoolant
 
     radial shield aclp: &block_shield_aclp
-        gap: *component_radial_shield_gap
-        clad: *component_radial_shield_clad
-        wire: *component_radial_shield_wire
-        coolant: *component_coolant
-        duct: *component_duct
-        intercoolant: *component_intercoolant
+        <<: *block_shield_plenum
         axial expansion target component: clad # not necessary, but useful for testing coverage
 
 assemblies:
@@ -474,59 +563,35 @@ assemblies:
     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_duct, *block_dummy]
+        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_fuelPin, *block_fuelPin, *block_fuelPin, *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, '', '', '', '', '']
+            U235_wt_frac: ['', '', 0.11, 0.11, 0.11, '', '', '', '', '']
+            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_duct, *block_dummy]
+        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_fuelPin_linedClad, *block_fuelPin_linedClad, *block_fuelPin_linedClad, *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_duct, *block_dummy]
+        blocks: [*block_grid_plate, *block_fuel_axial_shield, *block_fuelAnnular_linedClad, *block_fuelAnnular_linedClad, *block_fuelAnnular_linedClad, *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:
+    multi pin 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_duct, *block_dummy]
+        blocks: [*block_grid_plate, *block_fuel_multiPin_axial_shield, *block_fuel_multiPin, *block_fuel_multiPin, *block_fuel_multiPin, *block_plenum_multiPin, *block_aclp_multiPin, *block_plenum_multiPin, *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, '', '', '', '', '']
-        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_duct, *block_dummy]
-        height: *highOffset_height
-        axial mesh points: *standard_axial_mesh_points
-        material modifications:
-            U235_wt_frac: *lta_fuel_u235_wt_frac
-            ZR_wt_frac: *lta_fuel_zr_wt_frac
-        xs types: *igniter_fuel_xs_types
-    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, 25.0, 17.5]
-        axial mesh points: *standard_axial_mesh_points
-        material modifications:
-            U235_wt_frac: *igniter_fuel_u235_wt_frac
-            ZR_wt_frac: *igniter_fuel_zr_wt_frac
-        xs types: *igniter_fuel_xs_types
-    secondary control:
-        specifier: SC
-        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
+            U235_wt_frac: ['', '', 0.2, 0.2, 0.2, '', '', '', '', '']
+            ZR_wt_frac: ['', '', 0.07, 0.07, 0.06, '', '', '', '', '']
         xs types: *igniter_fuel_xs_types
-    primary control:
+    control:
         specifier: PC
         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]