Repository: terrapower/armi

Issue:
axialExpansionChanger and breakFuelComponentsIntoIndividuals() are not compatible
An internal plugin runs `Block.breakFuelComponentsIntoIndividuals()`, which tears apart the fuel inside of a block (with multiplicity > 1) into individual components and gives them multiplicity = 1. This appears to adversely impact downstream plugins as well as other parts of ARMI, which appear to not be prepared for this (i.e., multiplicity = 1).

An example of this breaking other code is in the `axialExpansionChanger` module. An internal plugin uses this module, and I observe a failure in `AssemblyAxialLinkage._getLinkedComponents()`. This failure occurs because `_determineLinked()` is returning multiple matches for a given pin by matching it with the many pins in the neighboring block(s).


Discussion:
albeanth: @zachmprince a heads up
john-science: @albeanth This PR needs several release notes:

| num | Your Change | Release Note |
| ----- | -------------- | ---------------|
| 1 | History Tracker: "detail assemblies" are now fuel and control assemblies. | Add this to "API Changes" |
| 2 | Removing `breakFuelComponentsIntoIndividuals` | Add this to "API Changes" |
| 3 | Move `getPuMoles` from blocks.py up to composites.py | Add this to "API Changes" |
| 4 | Populate `molesHmBOL` and `puFrac` on components during `completeInitialLoading` | ignore |
| 5 | Add a method `getPinMgFluxes` to retrieve the pin-wise multigroup fluxes from a Block. | Add to "New Features" |

john-science: @albeanth Does this PR require any changes to the ARMI docs?
albeanth: As of right now? No, not that I'm aware of. 
john-science: @zachmprince @albeanth I have bumped ARMI `0.5.0` (release coming soon).

This means that your release notes need to be moved from `0.4.1` in the file `doc/release/0.4.rst` to `0.5.0` in the file `doc/release/0.5.rst`.

Sorry to bother. I would have done this for you, but your PR is coming from a forked repo so I can't.

Patch:
diff --git a/armi/bookkeeping/historyTracker.py b/armi/bookkeeping/historyTracker.py
index 884cb0c0..4f5a6e75 100644
--- a/armi/bookkeeping/historyTracker.py
+++ b/armi/bookkeeping/historyTracker.py
@@ -67,7 +67,7 @@ Specifying blocks and assemblies to track
 See :ref:`detail-assems`.
 
 """
-from typing import Tuple
+from typing import TYPE_CHECKING
 import traceback
 
 from armi import interfaces
@@ -79,6 +79,10 @@ from armi.utils import tabulate
 
 ORDER = 2 * interfaces.STACK_ORDER.BEFORE + interfaces.STACK_ORDER.BOOKKEEPING
 
+if TYPE_CHECKING:
+    from armi.reactor.blocks import Block
+    from armi.reactor.assemblies import Assembly
+
 
 def describeInterfaces(cs):
     """Function for exposing interface(s) to other code."""
@@ -120,6 +124,8 @@ class HistoryTrackerInterface(interfaces.Interface):
 
     name = "history"
 
+    DETAILED_ASSEMBLY_FLAGS = [Flags.FUEL, Flags.CONTROL]
+
     def __init__(self, r, cs):
         """
         HistoryTracker that uses the database to look up parameter history rather than
@@ -146,7 +152,7 @@ class HistoryTrackerInterface(interfaces.Interface):
         """Look for any new assemblies that are asked for and add them to tracking."""
         self.addDetailAssemsByAssemNums()
         if self.cs["detailAllAssems"]:
-            self.addAllFuelAssems()
+            self.addAllDetailedAssems()
 
     def interactEOL(self):
         """Generate the history reports."""
@@ -171,16 +177,16 @@ class HistoryTrackerInterface(interfaces.Interface):
                 self.addDetailAssembly(a)
 
         if self.cs["detailAllAssems"]:
-            self.addAllFuelAssems()
+            self.addAllDetailedAssems()
 
         # This also gets called at BOC but we still
         # do it here for operators that do not call BOC.
         self.addDetailAssemsByAssemNums()
 
-    def addAllFuelAssems(self):
-        """Add all fuel assems as detail assems."""
+    def addAllDetailedAssems(self):
+        """Add all assems who have the DETAILED_ASSEMBLY_FLAGS as detail assems."""
         for a in self.r.core:
-            if a.hasFlags(Flags.FUEL):
+            if a.hasFlags(self.DETAILED_ASSEMBLY_FLAGS):
                 self.addDetailAssembly(a)
 
     def addDetailAssemsByAssemNums(self):
@@ -236,13 +242,13 @@ class HistoryTrackerInterface(interfaces.Interface):
                     trackedParams.add(newParam)
         return sorted(trackedParams)
 
-    def addDetailAssembly(self, a):
+    def addDetailAssembly(self, a: "Assembly"):
         """Track the name of assemblies that are flagged for detailed treatment."""
         aName = a.getName()
         if aName not in self.detailAssemblyNames:
             self.detailAssemblyNames.append(aName)
 
-    def getDetailAssemblies(self):
+    def getDetailAssemblies(self) -> list["Assembly"]:
         """Returns the assemblies that have been signaled as detail assemblies."""
         assems = []
         if not self.detailAssemblyNames:
@@ -259,7 +265,7 @@ class HistoryTrackerInterface(interfaces.Interface):
                 )
         return assems
 
-    def getDetailBlocks(self):
+    def getDetailBlocks(self) -> list["Block"]:
         """Get all blocks in all detail assemblies."""
         return [block for a in self.getDetailAssemblies() for block in a]
 
@@ -281,7 +287,7 @@ class HistoryTrackerInterface(interfaces.Interface):
 
         return filtered
 
-    def writeAssemHistory(self, a, fName=""):
+    def writeAssemHistory(self, a: "Assembly", fName: str = ""):
         """Write the assembly history report to a text file."""
         fName = fName or self._getAssemHistoryFileName(a)
         dbi = self.getInterface("database")
@@ -373,7 +379,7 @@ class HistoryTrackerInterface(interfaces.Interface):
         """Remove all cached db reads."""
         self._preloadedBlockHistory = None
 
-    def getBlockHistoryVal(self, name: str, paramName: str, ts: Tuple[int, int]):
+    def getBlockHistoryVal(self, name: str, paramName: str, ts: tuple[int, int]):
         """
         Use the database interface to return the parameter values for the supplied block
         names, and timesteps.
@@ -422,28 +428,28 @@ class HistoryTrackerInterface(interfaces.Interface):
                 raise
         return val
 
-    def _isCurrentTimeStep(self, ts: Tuple[int, int]):
+    def _isCurrentTimeStep(self, ts: tuple[int, int]) -> bool:
         """Return True if the timestep requested is the current time step."""
         return ts == (self.r.p.cycle, self.r.p.timeNode)
 
-    def _databaseHasDataForTimeStep(self, ts):
+    def _databaseHasDataForTimeStep(self, ts) -> bool:
         """Return True if the database has data for the requested time step."""
         dbi = self.getInterface("database")
         return ts in dbi.database.genTimeSteps()
 
-    def getTimeSteps(self, a=None):
-        r"""
-        Return list of time steps values (in years) that are available.
+    def getTimeSteps(self, a: "Assembly" = None) -> list[float]:
+        """
+        Given a fuel assembly, return list of time steps values (in years) that are available.
 
         Parameters
         ----------
-        a : Assembly object, optional
-            An assembly object designated a detail assem. If passed, only timesteps
+        a
+            A fuel assembly that has been designated a detail assem. If passed, only timesteps
             where this assembly is in the core will be tracked.
 
         Returns
         -------
-        timeSteps : list
+        timeSteps
             times in years that are available in the history
 
         See Also
@@ -462,15 +468,13 @@ class HistoryTrackerInterface(interfaces.Interface):
         return timeInYears
 
     @staticmethod
-    def _getBlockInAssembly(a):
-        """Get a representative fuel block from an assembly."""
+    def _getBlockInAssembly(a: "Assembly") -> "Block":
+        """Get a representative fuel block from a fuel assembly."""
         b = a.getFirstBlock(Flags.FUEL)
         if not b:
-            # there is a problem, it doesn't look like we have a fueled assembly
-            # but that is all we track... what is it? Throw an error
-            runLog.warning("Assembly {} does not contain fuel".format(a))
+            runLog.error("Assembly {} does not contain fuel".format(a))
             for b in a:
-                runLog.warning("Block {}".format(b))
+                runLog.error("Block {}".format(b))
             raise RuntimeError(
                 "A tracked assembly does not contain fuel and has caused this error, see the details in stdout."
             )
diff --git a/armi/reactor/blockParameters.py b/armi/reactor/blockParameters.py
index 820ebac9..ab65301a 100644
--- a/armi/reactor/blockParameters.py
+++ b/armi/reactor/blockParameters.py
@@ -151,13 +151,6 @@ def getBlockParameterDefinitions():
             location=ParamLocation.MAX,
         )
 
-        pb.defParam(
-            "percentBuMin",
-            units=units.PERCENT_FIMA,
-            description="Minimum percentage of the initial heavy metal atoms that have been fissioned",
-            location=ParamLocation.MAX,
-        )
-
         pb.defParam(
             "residence",
             units=units.DAYS,
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index f19b0381..a3bf12ea 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -29,7 +29,6 @@ import numpy as np
 from armi import nuclideBases
 from armi import runLog
 from armi.bookkeeping import report
-from armi.nucDirectory import elements
 from armi.nuclearDataIO import xsCollections
 from armi.physics.neutronics import GAMMA
 from armi.physics.neutronics import NEUTRON
@@ -794,11 +793,22 @@ class Block(composites.Composite):
         massHmBOL = 0.0
         sf = self.getSymmetryFactor()
         for child in self:
+            # multiplying by sf ends up cancelling out the symmetry factor used in
+            # Component.getMass(). So massHmBOL does not respect the symmetry factor.
             hmMass = child.getHMMass() * sf
             massHmBOL += hmMass
-            # Components have a massHmBOL parameter but not every composite will
+            # Components have the following parameters but not every composite will
+            # massHmBOL, molesHmBOL, puFrac
             if isinstance(child, components.Component):
                 child.p.massHmBOL = hmMass
+                # to stay consistent with massHmBOL, molesHmBOL and puFrac should be
+                # independent of sf. As such, the need to be scaled by 1/sf.
+                child.p.molesHmBOL = child.getHMMoles() / sf
+                child.p.puFrac = (
+                    self.getPuMoles() / sf / child.p.molesHmBOL
+                    if child.p.molesHmBOL > 0.0
+                    else 0.0
+                )
 
         self.p.massHmBOL = massHmBOL
 
@@ -1432,63 +1442,6 @@ class Block(composites.Composite):
             except NotImplementedError:
                 runLog.warning("{0} has no updatedDims method -- skipping".format(c))
 
-    def breakFuelComponentsIntoIndividuals(self):
-        """
-        Split block-level components (in fuel blocks) into pin-level components.
-
-        The fuel component will be broken up according to its multiplicity.
-
-        Order matters! The first pin component will be located at a particular (x, y), which
-        will be used in the fluxRecon module to determine the interpolated flux.
-
-        The fuel will become fuel001 through fuel169 if there are 169 pins.
-        """
-        fuels = self.getChildrenWithFlags(Flags.FUEL)
-        if len(fuels) != 1:
-            runLog.error(
-                "This block contains {0} fuel components: {1}".format(len(fuels), fuels)
-            )
-            raise RuntimeError(
-                "Cannot break {0} into multiple fuel components b/c there is not a single fuel"
-                " component.".format(self)
-            )
-
-        fuel = fuels[0]
-        fuelFlags = fuel.p.flags
-        nPins = self.getNumPins()
-        runLog.info(
-            "Creating {} individual {} components on {}".format(nPins, fuel, self)
-        )
-
-        # Handle all other components that may be linked to the fuel multiplicity
-        # by unlinking them and setting them directly.
-        # TODO: What about other (actual) dimensions? This is a limitation in that only fuel
-        # components are duplicated, and not the entire pin. It is also a reasonable assumption with
-        # current/historical usage of ARMI.
-        for comp, dim in self.getComponentsThatAreLinkedTo(fuel, "mult"):
-            comp.setDimension(dim, nPins)
-
-        # finish the first pin as a single pin
-        fuel.setDimension("mult", 1)
-        fuel.setName("fuel001")
-        fuel.p.pinNum = 1
-
-        # create all the new pin components and add them to the block with 'fuel001' names
-        for i in range(nPins - 1):
-            # wow, only use of a non-deepcopy
-            newC = copy.copy(fuel)
-            newC.setName("fuel{0:03d}".format(i + 2))  # start with 002.
-            newC.p.pinNum = i + 2
-            self.add(newC)
-
-        # update moles at BOL for each pin
-        self.p.molesHmBOLByPin = []
-        for pin in self.iterComponents(Flags.FUEL):
-            # Update the fuel component flags to be the same as before the split (i.e., DEPLETABLE)
-            pin.p.flags = fuelFlags
-            self.p.molesHmBOLByPin.append(pin.getHMMoles())
-            pin.p.massHmBOL /= nPins
-
     def getIntegratedMgFlux(self, adjoint=False, gamma=False):
         """
         Return the volume integrated multigroup neutron tracklength in [n-cm/s].
@@ -1739,18 +1692,6 @@ class Block(composites.Composite):
             return 0.0
         return b10 / total
 
-    def getPuMoles(self):
-        """Returns total number of moles of Pu isotopes."""
-        nucNames = [nuc.name for nuc in elements.byZ[94].nuclides]
-        puN = sum(self.getNuclideNumberDensities(nucNames))
-
-        return (
-            puN
-            / units.MOLES_PER_CC_TO_ATOMS_PER_BARN_CM
-            * self.getVolume()
-            * self.getSymmetryFactor()
-        )
-
     def getUraniumMassEnrich(self):
         """Returns U-235 mass fraction assuming U-235 and U-238 only."""
         u5 = self.getMass("U235")
diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py
index e00af786..5a4da2f3 100644
--- a/armi/reactor/components/component.py
+++ b/armi/reactor/components/component.py
@@ -21,6 +21,7 @@ import copy
 import re
 
 import numpy as np
+from typing import Optional
 
 from armi import materials
 from armi import runLog
@@ -31,6 +32,7 @@ from armi.materials import void
 from armi.nucDirectory import nuclideBases
 from armi.reactor import composites
 from armi.reactor import flags
+from armi.reactor import grids
 from armi.reactor import parameters
 from armi.reactor.components import componentParameters
 from armi.utils import densityTools
@@ -1262,6 +1264,71 @@ class Component(composites.Composite, metaclass=ComponentType):
 
         return pinFluxes[self.p.pinNum - 1] * self.getVolume()
 
+    def getPinMgFluxes(
+        self, adjoint: Optional[bool] = False, gamma: Optional[bool] = False
+    ) -> np.ndarray:
+        """Retrieves the pin multigroup fluxes for the component.
+
+        Parameters
+        ----------
+        adjoint : bool, optional
+            Return adjoint flux instead of real
+        gamma : bool, optional
+            Whether to return the neutron flux or the gamma flux.
+
+        Returns
+        -------
+        np.ndarray
+            A ``(N, nGroup)`` array of pin multigroup fluxes, where ``N`` is the
+            equivalent to the multiplicity of the component (``self.p.mult``)
+            and ``nGroup`` is the number of energy groups of the flux.
+
+        Raises
+        ------
+        ValueError
+            If the location(s) of the component are not aligned with pin indices
+            from the block. This would happen if this component is not actually
+            a pin.
+        """
+        # Get the (i, j, k) location of all pins from the parent block
+        indicesAll = {
+            (loc.i, loc.j): i for i, loc in enumerate(self.parent.getPinLocations())
+        }
+
+        # Retrieve the indices of this component
+        if isinstance(self.spatialLocator, grids.MultiIndexLocation):
+            indices = [(loc.i, loc.j) for loc in self.spatialLocator]
+        else:
+            indices = [(self.spatialLocator.i, self.spatialLocator.j)]
+
+        # Map this component's indices to block's pin indices
+        indexMap = list(map(indicesAll.get, indices))
+        if None in indexMap:
+            msg = f"Failed to retrieve pin indices for component {self}."
+            runLog.error(msg)
+            raise ValueError(msg)
+
+        # Get the parameter name we are trying to retrieve
+        if gamma:
+            if adjoint:
+                raise ValueError("Adjoint gamma flux is currently unsupported.")
+            else:
+                param = "pinMgFluxesGamma"
+        else:
+            if adjoint:
+                param = "pinMgFluxesAdj"
+            else:
+                param = "pinMgFluxes"
+
+        # Return pin fluxes
+        try:
+            return self.parent.p[param][indexMap]
+        except Exception as ee:
+            msg = f"Failure getting {param} from {self} via parent {self.parent}"
+            runLog.error(msg)
+            runLog.error(ee)
+            raise ValueError(msg) from ee
+
     def density(self) -> float:
         """Returns the mass density of the object in g/cc."""
         density = composites.Composite.density(self)
diff --git a/armi/reactor/components/componentParameters.py b/armi/reactor/components/componentParameters.py
index 92bde7dc..548ec794 100644
--- a/armi/reactor/components/componentParameters.py
+++ b/armi/reactor/components/componentParameters.py
@@ -15,7 +15,7 @@
 """Component parameter definitions."""
 from armi.reactor import parameters
 from armi.reactor.parameters import ParamLocation
-from armi.reactor.parameters.parameterDefinitions import isNumpyArray
+from armi.reactor.parameters.parameterDefinitions import isNumpyArray, isNumpyF32Array
 from armi.utils import units
 
 
@@ -78,6 +78,17 @@ def getComponentParameterDefinitions():
             default=None,
         )
 
+        pb.defParam(
+            "pinNDens",
+            setter=isNumpyF32Array("pinNDens"),
+            units=f"atoms/(bn*{units.CM})",
+            description="Pin-wise number densities of each nuclide.",
+            location=ParamLocation.AVERAGE,
+            saveToDB=True,
+            categories=["depletion", parameters.Category.pinQuantities],
+            default=None,
+        )
+
         pb.defParam(
             "percentBu",
             units=f"{units.PERCENT_FIMA}",
@@ -85,6 +96,14 @@ def getComponentParameterDefinitions():
             default=0.0,
         )
 
+        pb.defParam(
+            "pinPercentBu",
+            setter=isNumpyArray("pinPercentBu"),
+            units=units.PERCENT_FIMA,
+            description="Pin-wise burnup as a percentage of initial (heavy) metal atoms.",
+            default=None,
+        )
+
         pb.defParam(
             "buRate",
             units=f"{units.PERCENT_FIMA}/{units.DAYS}",
@@ -153,6 +172,21 @@ def getComponentParameterDefinitions():
             default=1,
             setter=_assignTDFrac,
         )
+
+        pb.defParam(
+            "molesHmBOL",
+            units=units.MOLES,
+            default=0.0,
+            description="Total number of moles of heavy metal at BOL.",
+        )
+
+        pb.defParam(
+            "puFrac",
+            default=0.0,
+            units=units.UNITLESS,
+            description="Current average Pu fraction. Calculated as the ratio of Pu mass to total HM mass.",
+        )
+
     return pDefs
 
 
diff --git a/armi/reactor/composites.py b/armi/reactor/composites.py
index 7f09d865..f61ad558 100644
--- a/armi/reactor/composites.py
+++ b/armi/reactor/composites.py
@@ -1578,6 +1578,9 @@ class ArmiObject(metaclass=CompositeModelType):
         # Update detailedNDens
         if self.p.detailedNDens is not None:
             self.p.detailedNDens *= factor
+        # Update pinNDens
+        if self.p.pinNDens is not None:
+            self.p.pinNDens *= factor
 
     def clearNumberDensities(self):
         """
@@ -3121,6 +3124,18 @@ class Composite(ArmiObject):
         getter = operator.methodcaller("getBoundingCircleOuterDiameter", Tc, cold)
         return sum(map(getter, self))
 
+    def getPuMoles(self):
+        """Returns total number of moles of Pu isotopes."""
+        nucNames = [nuc.name for nuc in elements.byZ[94].nuclides]
+        puN = sum(self.getNuclideNumberDensities(nucNames))
+
+        return (
+            puN
+            / units.MOLES_PER_CC_TO_ATOMS_PER_BARN_CM
+            * self.getVolume()
+            * self.getSymmetryFactor()
+        )
+
 
 class StateRetainer:
     """
diff --git a/armi/reactor/parameters/parameterDefinitions.py b/armi/reactor/parameters/parameterDefinitions.py
index 17c024e7..4063a2cf 100644
--- a/armi/reactor/parameters/parameterDefinitions.py
+++ b/armi/reactor/parameters/parameterDefinitions.py
@@ -224,6 +224,31 @@ def isNumpyArray(paramStr):
     return setParameter
 
 
+def isNumpyF32Array(paramStr: str):
+    """Helper meta-function to create a method that sets a Parameter value to a 32 bit float NumPy array.
+
+    Parameters
+    ----------
+    paramStr
+        Name of the Parameter we want to set.
+
+    Returns
+    -------
+    function
+        A setter method on the Parameter class to force the value to be a 32 bit NumPy array.
+    """
+
+    def setParameter(selfObj, value):
+        if value is None:
+            # allow default of None to exist
+            setattr(selfObj, "_p_" + paramStr, value)
+        else:
+            # force to 32 bit
+            setattr(selfObj, "_p_" + paramStr, np.array(value, dtype=np.float32))
+
+    return setParameter
+
+
 @functools.total_ordering
 class Parameter:
     """Metadata about a specific parameter."""
diff --git a/doc/release/0.5.rst b/doc/release/0.5.rst
index 6ebbc39c..8ae4f1a9 100644
--- a/doc/release/0.5.rst
+++ b/doc/release/0.5.rst
@@ -51,6 +51,7 @@ New Features
 #. Adding new options for simplifying 1D cross section modeling. (`PR#1949 <https://github.com/terrapower/armi/pull/1949>`_)
 #. Adding ``--skip-inspection`` flag to ``CompareCases`` CLI. (`PR#1842 <https://github.com/terrapower/armi/pull/1842>`_)
 #. Exposing ``detailedNDens`` to components. (`PR#1954 <https://github.com/terrapower/armi/pull/1954>`_)
+#. Adding a method ``getPinMgFluxes`` to get pin-wise multigroup fluxes from a Block. (`PR#1990 <https://github.com/terrapower/armi/pull/1990>`_)
 
 API Changes
 -----------
@@ -75,6 +76,9 @@ API Changes
 #. Removing method ``SkippingXsGen_BuChangedLessThanTolerance``. (`PR#1845 <https://github.com/terrapower/armi/pull/1845>`_)
 #. Removing setting ``autoGenerateBlockGrids``. (`PR#1947 <https://github.com/terrapower/armi/pull/1947>`_)
 #. Removing setting ``mpiTasksPerNode`` and renaming ``numProcessors`` to ``nTasks``. (`PR#1958 <https://github.com/terrapower/armi/pull/1958>`_)
+#. History Tracker: "detail assemblies" are now fuel and control assemblies. (`PR#1990 <https://github.com/terrapower/armi/pull/1990>`_)
+#. Removing ``Block.breakFuelComponentsIntoIndividuals()``. (`PR#1990 <https://github.com/terrapower/armi/pull/1990>`_)
+#. Moving ``getPuMoles`` from blocks.py up to composites.py. (`PR#1990 <https://github.com/terrapower/armi/pull/1990>`_)
 
 Bug Fixes
 ---------


Test patch:
diff --git a/armi/bookkeeping/db/tests/test_comparedb3.py b/armi/bookkeeping/db/tests/test_comparedb3.py
index 3d56ee02..4eb5531d 100644
--- a/armi/bookkeeping/db/tests/test_comparedb3.py
+++ b/armi/bookkeeping/db/tests/test_comparedb3.py
@@ -181,7 +181,7 @@ class TestCompareDB3(unittest.TestCase):
             dbs[1]._fullPath,
             timestepCompare=[(0, 0), (0, 1)],
         )
-        self.assertEqual(len(diffs.diffs), 480)
+        self.assertEqual(len(diffs.diffs), 504)
         # Cycle length is only diff (x3)
         self.assertEqual(diffs.nDiffs(), 3)
 
diff --git a/armi/bookkeeping/tests/test_historyTracker.py b/armi/bookkeeping/tests/test_historyTracker.py
index 3a18f7ac..0b615a37 100644
--- a/armi/bookkeeping/tests/test_historyTracker.py
+++ b/armi/bookkeeping/tests/test_historyTracker.py
@@ -231,8 +231,8 @@ class TestHistoryTracker(ArmiTestHelper):
 
         # test that detailAssemblyNames() is working
         self.assertEqual(len(history.detailAssemblyNames), 1)
-        history.addAllFuelAssems()
-        self.assertEqual(len(history.detailAssemblyNames), 51)
+        history.addAllDetailedAssems()
+        self.assertEqual(len(history.detailAssemblyNames), 54)
 
     def test_getBlockInAssembly(self):
         history = self.o.getInterface("history")
diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py
index c750717a..bbbdec23 100644
--- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py
+++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py
@@ -37,7 +37,6 @@ class TestFuelHandlerMgmtTools(FuelHandlerTestHelper):
 
         # apply dummy pin-level data to allow intelligent rotation
         for b in assem.getBlocks(Flags.FUEL):
-            b.breakFuelComponentsIntoIndividuals()
             b.initializePinLocations()
             b.p.percentBuMaxPinLocation = 10
             b.p.percentBuMax = 5
diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py
index 7dc23ab3..8205bf1d 100644
--- a/armi/reactor/tests/test_blocks.py
+++ b/armi/reactor/tests/test_blocks.py
@@ -40,7 +40,7 @@ from armi.reactor.tests.test_assemblies import makeTestAssembly
 from armi.reactor.tests.test_reactors import loadTestReactor
 from armi.reactor.tests.test_reactors import TEST_ROOT
 from armi.tests import ISOAA_PATH
-from armi.utils import hexagon, units
+from armi.utils import hexagon, units, densityTools
 from armi.utils.directoryChangers import TemporaryDirectoryChanger
 from armi.utils.units import MOLES_PER_CC_TO_ATOMS_PER_BARN_CM
 from armi.utils.units import ASCII_LETTER_A, ASCII_LETTER_Z, ASCII_LETTER_a
@@ -1199,7 +1199,19 @@ class Block_TestCase(unittest.TestCase):
 
         self.assertAlmostEqual(mass2 - mass1, massDiff)
 
-    def test_completeInitialLoading(self):
+    @patch.object(blocks.HexBlock, "getSymmetryFactor")
+    def test_completeInitialLoading(self, mock_sf):
+        """Ensure that some BOL block and component params are populated properly.
+
+        Notes
+        -----
+        - When checking component-level BOL params, puFrac is skipped due to 1) there's no Pu in the block, and 2)
+          getPuMoles is functionally identical to getHMMoles (just limits nuclides from heavy metal to just Pu).
+        - getSymmetryFactor is mocked to return 3. This indicates that the block is in the center-most assembly.
+          Providing this mock ensures that symmetry factors are tested as well (otherwise it's just a factor of 1
+          and it is a less robust test).
+        """
+        mock_sf.return_value = 3
         area = self.block.getArea()
         height = 2.0
         self.block.setHeight(height)
@@ -1216,10 +1228,43 @@ class Block_TestCase(unittest.TestCase):
 
         self.block.completeInitialLoading()
 
+        sf = self.block.getSymmetryFactor()
         cur = self.block.p.molesHmBOL
-        ref = self.block.getHMDens() / MOLES_PER_CC_TO_ATOMS_PER_BARN_CM * height * area
-        places = 6
-        self.assertAlmostEqual(cur, ref, places=places)
+        ref = (
+            self.block.getHMDens()
+            / MOLES_PER_CC_TO_ATOMS_PER_BARN_CM
+            * height
+            * area
+            * sf
+        )
+        self.assertAlmostEqual(cur, ref, places=12)
+
+        totalHMMass = 0.0
+        for c in self.block:
+            nucs = c.getNuclides()
+            hmNucs = [nuc for nuc in nucs if nucDir.isHeavyMetal(nuc)]
+            hmNDens = {hmNuc: c.getNumberDensity(hmNuc) for hmNuc in hmNucs}
+            hmMass = densityTools.calculateMassDensity(hmNDens) * c.getVolume()
+            totalHMMass += hmMass
+            if hmMass:
+                # hmMass does not need to account for sf since what's calculated in blocks.completeInitialLoading
+                # ends up cancelling out sf
+                self.assertAlmostEqual(c.p.massHmBOL, hmMass, places=12)
+                # since sf is cancelled out in massHmBOL, there needs to be a factor 1/sf here to cancel out the
+                # factor of sf in getHMMoles.
+                self.assertAlmostEqual(
+                    c.p.molesHmBOL,
+                    sum(ndens for ndens in hmNDens.values())
+                    / units.MOLES_PER_CC_TO_ATOMS_PER_BARN_CM
+                    * c.getVolume()
+                    / sf,
+                    places=12,
+                )
+            else:
+                self.assertEqual(c.p.massHmBOL, 0.0)
+                self.assertEqual(c.p.molesHmBOL, 0.0)
+
+        self.assertAlmostEqual(self.block.p.massHmBOL, totalHMMass)
 
     def test_add(self):
         numComps = len(self.block.getComponents())
@@ -1767,14 +1812,6 @@ class Block_TestCase(unittest.TestCase):
             self.block.getComponent(Flags.INTERCOOLANT).getDimension("ip"),
         )
 
-    def test_breakFuelComponentsIntoIndividuals(self):
-        fuel = self.block.getComponent(Flags.FUEL)
-        mult = fuel.getDimension("mult")
-        self.assertGreater(mult, 1.0)
-        self.block.completeInitialLoading()
-        self.block.breakFuelComponentsIntoIndividuals()
-        self.assertEqual(fuel.getDimension("mult"), 1.0)
-
     def test_pinMgFluxes(self):
         """
         Test setting/getting of pin-wise fluxes.
diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py
index 7cad72c9..7ce082a5 100644
--- a/armi/reactor/tests/test_components.py
+++ b/armi/reactor/tests/test_components.py
@@ -17,34 +17,38 @@ import copy
 import math
 import unittest
 
+import numpy as np
+from numpy.testing import assert_equal
+
 from armi.materials import air, alloy200
 from armi.materials.material import Material
 from armi.reactor import components
 from armi.reactor import flags
+from armi.reactor.blocks import Block
 from armi.reactor.components import (
-    Component,
-    UnshapedComponent,
-    NullComponent,
     Circle,
+    Component,
+    ComponentType,
+    Cube,
+    DerivedShape,
+    DifferentialRadialSegment,
+    Helix,
     Hexagon,
-    HoledHexagon,
     HexHoledCircle,
+    HoledHexagon,
     HoledRectangle,
     HoledSquare,
-    Helix,
-    Sphere,
-    Cube,
+    NullComponent,
+    RadialSegment,
     Rectangle,
     SolidRectangle,
+    Sphere,
     Square,
     Triangle,
-    RadialSegment,
-    DifferentialRadialSegment,
-    DerivedShape,
+    UnshapedComponent,
     UnshapedVolumetricComponent,
-    ComponentType,
+    materials,
 )
-from armi.reactor.components import materials
 from armi.reactor.tests.test_reactors import loadTestReactor
 
 
@@ -698,10 +702,12 @@ class TestCircle(TestShapedComponent):
         """Test that demonstates that the number densities on a component can be modified."""
         self.component.p.numberDensities = {"NA23": 1.0}
         self.component.p.detailedNDens = [1.0]
+        self.component.p.pinNDens = [1.0]
         self.assertEqual(self.component.getNumberDensity("NA23"), 1.0)
         self.component.changeNDensByFactor(3.0)
         self.assertEqual(self.component.getNumberDensity("NA23"), 3.0)
         self.assertEqual(self.component.p.detailedNDens[0], 3.0)
+        self.assertEqual(self.component.p.pinNDens[0], 3.0)
 
     def test_fuelMass(self):
         nominalMass = self.component.getMass()
@@ -1814,3 +1820,61 @@ class TestMaterialAdjustments(unittest.TestCase):
         comp.p.theoreticalDensityFrac = tdFrac
         comp.finalizeLoadingFromDB()
         self.assertEqual(comp.material.getTD(), tdFrac)
+
+
+class TestPinQuantities(unittest.TestCase):
+    """Test methods that involve retrieval of pin quantities."""
+
+    def setUp(self):
+        self.r = loadTestReactor(
+            inputFileName="smallestTestReactor/armiRunSmallest.yaml"
+        )[1]
+
+    def test_getPinMgFluxes(self):
+        """Test proper retrieval of pin multigroup flux for fuel component."""
+        # Get a fuel block and its fuel component from the core
+        fuelBlock: Block = self.r.core.getFirstBlock(flags.Flags.FUEL)
+        fuelComponent: Component = fuelBlock.getComponent(flags.Flags.FUEL)
+        numPins = int(fuelComponent.p.mult)
+        self.assertEqual(numPins, 169)
+
+        # Set pin fluxes at block level
+        fuelBlock.initializePinLocations()
+        pinMgFluxes = np.random.rand(numPins, 33)
+        pinMgFluxesAdj = np.random.rand(numPins, 33)
+        pinMgFluxesGamma = np.random.rand(numPins, 33)
+        fuelBlock.setPinMgFluxes(pinMgFluxes)
+        fuelBlock.setPinMgFluxes(pinMgFluxesAdj, adjoint=True)
+        fuelBlock.setPinMgFluxes(pinMgFluxesGamma, gamma=True)
+
+        # Retrieve from component to ensure they match
+        simPinMgFluxes = fuelComponent.getPinMgFluxes()
+        simPinMgFluxesAdj = fuelComponent.getPinMgFluxes(adjoint=True)
+        simPinMgFluxesGamma = fuelComponent.getPinMgFluxes(gamma=True)
+        assert_equal(pinMgFluxes, simPinMgFluxes)
+        assert_equal(pinMgFluxesAdj, simPinMgFluxesAdj)
+        assert_equal(pinMgFluxesGamma, simPinMgFluxesGamma)
+
+        # Mock the spatial locator of the component to raise error
+        with unittest.mock.patch.object(fuelComponent, "spatialLocator") as mockLocator:
+            mockLocator.i = 111
+            mockLocator.j = 111
+            with self.assertRaisesRegex(
+                ValueError,
+                f"Failed to retrieve pin indices for component {fuelComponent}",
+            ):
+                fuelComponent.getPinMgFluxes()
+
+        # Check assertion for adjoint gamma flux
+        with self.assertRaisesRegex(
+            ValueError, "Adjoint gamma flux is currently unsupported."
+        ):
+            fuelComponent.getPinMgFluxes(adjoint=True, gamma=True)
+
+        # Check assertion for not-found parameter
+        fuelBlock.p.pinMgFluxes = None
+        with self.assertRaisesRegex(
+            ValueError,
+            f"Failure getting pinMgFluxes from {fuelComponent} via parent {fuelBlock}",
+        ):
+            fuelComponent.getPinMgFluxes()