Repository: terrapower/armi
Pull request #1987 (2024-10-30 15:04:26)

Adding temperature dependence when generating cross sections
## What is the change?

Add temperature dependent capability to armi when generating cross sections 

## Why is the change being made?

to be able to group similar temperature blocks for representative block construction 


---

## Checklist

<!--
    You (the pull requester) should put an `x` in the boxes below you have completed.

    (If a checkbox doesn't apply to your PR, check it anyway.)
-->

- [x] This PR has only [one purpose or idea](https://terrapower.github.io/armi/developer/tooling.html#one-idea-one-pr).
- [x] [Tests](https://terrapower.github.io/armi/developer/tooling.html#test-it) have been added/updated to verify any new/changed code.

<!-- Check the code quality -->

- [x] The code style follows [good practices](https://terrapower.github.io/armi/developer/standards_and_practices.html).
- [x] The commit message(s) follow [good practices](https://terrapower.github.io/armi/developer/tooling.html).

<!-- Check the project-level cruft -->

- [x] The [release notes](https://terrapower.github.io/armi/developer/tooling.html#add-release-notes) have been updated if necessary.
- [x] The [documentation](https://terrapower.github.io/armi/developer/tooling.html#document-it) is still up-to-date in the `doc` folder.
- [x] The dependencies are still up-to-date in `pyproject.toml`.


Patch:
diff --git a/armi/bookkeeping/historyTracker.py b/armi/bookkeeping/historyTracker.py
index 995bbb5e..884cb0c0 100644
--- a/armi/bookkeeping/historyTracker.py
+++ b/armi/bookkeeping/historyTracker.py
@@ -339,7 +339,7 @@ class HistoryTrackerInterface(interfaces.Interface):
             out.write("\n\n\nAssembly info\n")
             out.write("{0} {1}\n".format(a.getName(), a.getType()))
             for b in blocks:
-                out.write('"{}" {} {}\n'.format(b.getType(), b.p.xsType, b.p.buGroup))
+                out.write('"{}" {} {}\n'.format(b.getType(), b.p.xsType, b.p.envGroup))
 
     def preloadBlockHistoryVals(self, names, keys, timesteps):
         """
diff --git a/armi/physics/neutronics/__init__.py b/armi/physics/neutronics/__init__.py
index a14f9ab8..472ace73 100644
--- a/armi/physics/neutronics/__init__.py
+++ b/armi/physics/neutronics/__init__.py
@@ -64,6 +64,11 @@ class NeutronicsPlugin(plugins.ArmiPlugin):
 
         return neutronicsParameters.getNeutronicsParameterDefinitions()
 
+    @staticmethod
+    @plugins.HOOKIMPL
+    def defineParameterRenames():
+        return {"buGroup": "envGroup", "buGroupNum": "envGroupNum"}
+
     @staticmethod
     @plugins.HOOKIMPL
     def defineEntryPoints():
diff --git a/armi/physics/neutronics/crossSectionGroupManager.py b/armi/physics/neutronics/crossSectionGroupManager.py
index 9e8972c6..36022c57 100644
--- a/armi/physics/neutronics/crossSectionGroupManager.py
+++ b/armi/physics/neutronics/crossSectionGroupManager.py
@@ -34,6 +34,7 @@ Examples
 --------
     csm = CrossSectionGroupManager()
     csm._setBuGroupBounds(cs['buGroups'])
+    csm._setTempGroupBounds(cs['tempGroups']) # or empty list
     csm._addXsGroupsFromBlocks(blockList)
     csm.createRepresentativeBlocks()
     representativeBlockList = csm.representativeBlocks.values()
@@ -65,7 +66,7 @@ from armi.reactor import flags
 from armi.reactor.components import basicShapes
 from armi.reactor.converters.blockConverters import stripComponents
 from armi.reactor.flags import Flags
-from armi.utils.units import TRACE_NUMBER_DENSITY
+from armi.utils.units import TRACE_NUMBER_DENSITY, C_TO_K
 
 ORDER = interfaces.STACK_ORDER.BEFORE + interfaces.STACK_ORDER.CROSS_SECTIONS
 
@@ -473,6 +474,12 @@ class AverageBlockCollection(BlockCollection):
             return True
 
 
+def getBlockNuclideTemperature(block, nuclide):
+    """Return the average temperature for 1 nuclide."""
+    tempIntegratedVolume, volume = getBlockNuclideTemperatureAvgTerms(block, [nuclide])
+    return tempIntegratedVolume / volume if volume > 0 else 0.0
+
+
 def getBlockNuclideTemperatureAvgTerms(block, allNucNames):
     """
     Compute terms (numerator, denominator) of average for this block.
@@ -496,12 +503,13 @@ def getBlockNuclideTemperatureAvgTerms(block, allNucNames):
     components, volFracs = zip(*block.getVolumeFractions())
     # D = CxN matrix of number densities
     ndens = np.array([getNumberDensitiesWithTrace(c, allNucNames) for c in components])
-    temperatures = np.array(
-        [c.temperatureInC for c in components]
-    )  # C-length temperature array
-    nvBlock = (
-        ndens.T * np.array(volFracs) * vol
-    )  # multiply each component's values by volume frac, now NxC
+
+    # C-length temperature array
+    temperatures = np.array([c.temperatureInC for c in components])
+
+    # multiply each component's values by volume frac, now NxC
+    nvBlock = ndens.T * np.array(volFracs) * vol
+
     nvt = sum((nvBlock * temperatures).T)  # N-length array summing over components.
     nv = sum(nvBlock.T)  # N-length array
     return nvt, nv
@@ -562,6 +570,7 @@ class CylindricalComponentsAverageBlockCollection(BlockCollection):
             )
             for nuc, aDensity in zip(allNucsNames, densities):
                 c.setNumberDensity(nuc, aDensity)
+        self.calcAvgNuclideTemperatures()
         return repBlock
 
     @staticmethod
@@ -914,11 +923,16 @@ class CrossSectionGroupManager(interfaces.Interface):
 
     def __init__(self, r, cs):
         interfaces.Interface.__init__(self, r, cs)
-        self._upperBuGroupBounds = None
+        self._buGroupBounds = []
+        self._tempGroupBounds = []
         self.representativeBlocks = collections.OrderedDict()
         self.avgNucTemperatures = {}
-        self._buGroupUpdatesEnabled = True
+
+        # this turns off updates for when core changes are made, but dont want to re-evaluate XS
+        # for example if lattice physics was only once per cycle we might not want to re-evaluate groups
+        self._envGroupUpdatesEnabled = True
         self._setBuGroupBounds(self.cs["buGroups"])
+        self._setTempGroupBounds(self.cs["tempGroups"])
         self._unrepresentedXSIDs = []
 
     def interactBOL(self):
@@ -1044,13 +1058,13 @@ class CrossSectionGroupManager(interfaces.Interface):
         self.representativeBlocks = collections.OrderedDict()
         self.avgNucTemperatures = {}
 
-    def _setBuGroupBounds(self, upperBuGroupBounds):
+    def _setBuGroupBounds(self, buGroupBounds):
         """
         Set the burnup group structure.
 
         Parameters
         ----------
-        upperBuGroupBounds : list
+        buGroupBounds : list
             List of upper burnup values in percent.
 
         Raises
@@ -1058,9 +1072,9 @@ class CrossSectionGroupManager(interfaces.Interface):
         ValueError
             If the provided burnup groups are invalid
         """
-        self._upperBuGroupBounds = upperBuGroupBounds
         lastBu = 0.0
-        for upperBu in self._upperBuGroupBounds:
+        # validate structure
+        for upperBu in buGroupBounds:
             if upperBu <= 0 or upperBu > 100:
                 raise ValueError(
                     "Burnup group upper bound {0} is invalid".format(upperBu)
@@ -1069,46 +1083,85 @@ class CrossSectionGroupManager(interfaces.Interface):
                 raise ValueError("Burnup groups must be ascending")
             lastBu = upperBu
 
-    def _updateBurnupGroups(self, blockList):
+        self._buGroupBounds = buGroupBounds + [float("inf")]
+
+    def _setTempGroupBounds(self, tempGroupBounds):
+        """Set the temperature group structure."""
+        lastTemp = -C_TO_K
+        # validate structure
+        for upperTemp in tempGroupBounds:
+            if upperTemp < -C_TO_K:
+                raise ValueError(
+                    "Temperature boundary is below absolute zero {0}.format(upperTemp)"
+                )
+            if upperTemp < lastTemp:
+                raise ValueError("Temp groups must be ascending")
+            lastTemp = upperTemp
+        self._tempGroupBounds = tempGroupBounds + [float("inf")]
+
+    def _updateEnvironmentGroups(self, blockList):
         """
-        Update the burnup group of each block based on its burnup.
+        Update the burnup group of each block based on its burnup and temperature .
 
-        If only one burnup group exists, then this is skipped so as to accomodate the possibility
+        If only one burnup group exists, then this is skipped so as to accommodate the possibility
         of 2-character xsGroup values (useful for detailed V&V models w/o depletion).
 
         See Also
         --------
         armi.reactor.blocks.Block.getMicroSuffix
         """
-        if self._buGroupUpdatesEnabled and len(self._upperBuGroupBounds) > 1:
-            runLog.debug("Updating burnup groups of {0} blocks".format(len(blockList)))
-            for block in blockList:
-                bu = block.p.percentBu
-                for buGroupIndex, upperBu in enumerate(self._upperBuGroupBounds):
-                    if bu <= upperBu:
-                        block.p.buGroupNum = buGroupIndex
-                        break
-                else:
-                    raise ValueError("no bu group found for bu={0}".format(bu))
-        else:
+        if not self._envGroupUpdatesEnabled:
             runLog.debug(
                 "Skipping burnup group update of {0} blocks because it is disabled"
                 "".format(len(blockList))
             )
+            return
+
+        numBuGroups = len(self._buGroupBounds)
+        if numBuGroups == 1 and len(self._tempGroupBounds) == 1:
+            # dont set block.p.envGroupNum since all 1 group and we want to support 2 char xsGroup
+            return
+        runLog.debug("Updating env groups of {0} blocks".format(len(blockList)))
+        for block in blockList:
+            bu = block.p.percentBu
+            for buIndex, upperBu in enumerate(self._buGroupBounds):
+                if bu <= upperBu:
+                    buGroupVal = buIndex
+                    tempGroupVal = 0
+                    isotope = self._initializeXsID(block.getMicroSuffix()).xsTempIsotope
+                    if isotope and len(self._tempGroupBounds) > 1:
+                        # if statement saves this somewhat expensive calc if we are not doing temp groups
+                        tempC = getBlockNuclideTemperature(block, isotope)
+                        for tempIndex, upperTemp in enumerate(self._tempGroupBounds):
+                            if tempC <= upperTemp:
+                                tempGroupVal = tempIndex
+                                break
+                    # this ordering groups like-temperatures together in group number
+                    block.p.envGroupNum = tempGroupVal * numBuGroups + buGroupVal
+                    break
 
     def _addXsGroupsFromBlocks(self, blockCollectionsByXsGroup, blockList):
         """
-        Build all the cross section groups based on their XS type and BU group.
+        Build all the cross section groups based on their XS type and Env group.
 
-        Also ensures that their BU group is up to date with their burnup.
+        Also ensures that their Env group is up to date with their environment.
         """
-        self._updateBurnupGroups(blockList)
+        self._updateEnvironmentGroups(blockList)
         for b in blockList:
             xsID = b.getMicroSuffix()
             xsSettings = self._initializeXsID(xsID)
+            if (
+                self.cs["tempGroups"]
+                and xsSettings.blockRepresentation == MEDIAN_BLOCK_COLLECTION
+            ):
+                runLog.warning(
+                    "Median block currently only consider median burnup block, and "
+                    "not median temperature block in group"
+                )
             blockCollectionType = blockCollectionFactory(
                 xsSettings, self.r.blueprints.allNuclidesInProblem
             )
+
             group = blockCollectionsByXsGroup.get(xsID, blockCollectionType)
             group.append(b)
             blockCollectionsByXsGroup[xsID] = group
@@ -1227,9 +1280,8 @@ class CrossSectionGroupManager(interfaces.Interface):
                 self.avgNucTemperatures[xsID] = collection.avgNucTemperatures
             else:
                 runLog.debug(
-                    "No candidate blocks for {} will apply different burnup group".format(
-                        xsID
-                    )
+                    "No candidate blocks in group for {} (with a valid representative block flag). "
+                    "Will apply different environment group".format(xsID)
                 )
                 self._unrepresentedXSIDs.append(xsID)
 
@@ -1409,22 +1461,23 @@ class CrossSectionGroupManager(interfaces.Interface):
             )
         return availableXsTypes[:howMany]
 
-    def _getUnrepresentedBlocks(self, blockCollectionsByXsGroup):
+    def _getMissingBlueprintBlocks(self, blockCollectionsByXsGroup):
         """
-        Gets all blocks with suffixes not yet represented (for blocks in assemblies in the blueprints but not the core).
+        Gets all blocks with suffixes not yet represented.
+        (for blocks in assemblies in the blueprints but not in the core).
 
         Notes
         -----
         Certain cases (ZPPR validation cases) need to run cross sections for assemblies not in
         the core to get by region cross sections and flux factors.
         """
-        unrepresentedBlocks = []
+        missingBlueprintBlocks = []
         for a in self.r.blueprints.assemblies.values():
             for b in a:
                 if b.getMicroSuffix() not in blockCollectionsByXsGroup:
                     b2 = copy.deepcopy(b)
-                    unrepresentedBlocks.append(b2)
-        return unrepresentedBlocks
+                    missingBlueprintBlocks.append(b2)
+        return missingBlueprintBlocks
 
     def makeCrossSectionGroups(self):
         """Make cross section groups for all blocks in reactor and unrepresented blocks from blueprints."""
@@ -1432,41 +1485,56 @@ class CrossSectionGroupManager(interfaces.Interface):
         bCollectXSGroup = self._addXsGroupsFromBlocks(
             bCollectXSGroup, self.r.core.getBlocks()
         )
+
+        # add blocks that are defined in blueprints, but not in core
         bCollectXSGroup = self._addXsGroupsFromBlocks(
-            bCollectXSGroup, self._getUnrepresentedBlocks(bCollectXSGroup)
+            bCollectXSGroup, self._getMissingBlueprintBlocks(bCollectXSGroup)
         )
         blockCollectionsByXsGroup = collections.OrderedDict(
             sorted(bCollectXSGroup.items())
         )
         return blockCollectionsByXsGroup
 
+    def _getAlternateEnvGroup(self, missingXsType):
+        """Get a substitute block to use since there are no blocks with flags for xs gen."""
+        for otherXsID in self.representativeBlocks:
+            repType, repEnvGroup = otherXsID
+            if repType == missingXsType:
+                return repEnvGroup
+
     def _modifyUnrepresentedXSIDs(self, blockCollectionsByXsGroup):
         """
         Adjust the xsID of blocks in the groups that are not represented.
 
         Try to just adjust the burnup group up to something that is represented
-        (can happen to structure in AA when only AB, AC, AD still remain).
+        (can happen to structure in AA when only AB, AC, AD still remain,
+        but if some fresh AA happened to be added it might be needed).
         """
+        # No blocks in in this ID had a valid representative block flag (such as `fuel` for default),
+        # so nothing valid to run lattice physics on...
         for xsID in self._unrepresentedXSIDs:
-            missingXsType, _missingBuGroup = xsID
-            for otherXsID in self.representativeBlocks:  # order gets closest BU
-                repType, repBuGroup = otherXsID
-                if repType == missingXsType:
-                    nonRepBlocks = blockCollectionsByXsGroup.get(xsID)
-                    if nonRepBlocks:
-                        runLog.extra(
-                            "Changing XSID of {0} blocks from {1} to {2}"
-                            "".format(len(nonRepBlocks), xsID, otherXsID)
+            missingXsType, _missingEnvGroup = xsID
+            nonRepBlocks = blockCollectionsByXsGroup.get(xsID)
+            if nonRepBlocks:
+                newEnvGroup = self._getAlternateEnvGroup(missingXsType)
+                if newEnvGroup:
+                    # there were no blocks flagged to xs gen even though there were some not suitable for
+                    # generation in the group so can't make XS and use different.
+                    runLog.warning(
+                        "Changing XSID of {0} blocks from {1} to {2}"
+                        "".format(
+                            len(nonRepBlocks), xsID, missingXsType[0] + newEnvGroup
                         )
-                        for b in nonRepBlocks:
-                            b.p.buGroup = repBuGroup
-                    break
-            else:
-                runLog.warning(
-                    "No representative blocks with XS type {0} exist in the core. "
-                    "These XS cannot be generated and must exist in the working "
-                    "directory or the run will fail.".format(xsID)
-                )
+                    )
+                    for b in nonRepBlocks:
+                        b.p.envGroup = newEnvGroup
+                else:
+                    runLog.warning(
+                        "No representative blocks with XS type {0} exist in the core. "
+                        "There were also no similar blocks to use. "
+                        "These XS cannot be generated and must exist in the working "
+                        "directory or the run will fail.".format(xsID)
+                    )
 
     def _summarizeGroups(self, blockCollectionsByXsGroup):
         """Summarize current contents of the XS groups."""
@@ -1481,9 +1549,20 @@ class CrossSectionGroupManager(interfaces.Interface):
                 xsIDGroup = self._getXsIDGroup(xsID)
                 if xsIDGroup == self._REPR_GROUP:
                     reprBlock = self.representativeBlocks.get(xsID)
+                    xsSettings = self._initializeXsID(reprBlock.getMicroSuffix())
+                    temp = self.avgNucTemperatures[xsID].get(
+                        xsSettings.xsTempIsotope, "N/A"
+                    )
                     runLog.extra(
-                        "XS ID {} contains {:4d} blocks, represented by: {:65s}".format(
-                            xsID, len(blocks), reprBlock
+                        (
+                            "XS ID {} contains {:4d} blocks, with avg burnup {} "
+                            "and avg fuel temp {}, represented by: {:65s}"
+                        ).format(
+                            xsID,
+                            len(blocks),
+                            reprBlock.p.percentBu,
+                            temp,
+                            reprBlock,
                         )
                     )
                 elif xsIDGroup == self._NON_REPR_GROUP:
@@ -1511,31 +1590,31 @@ class CrossSectionGroupManager(interfaces.Interface):
             return self._NON_REPR_GROUP
         return None
 
-    def disableBuGroupUpdates(self):
+    def disableEnvGroupUpdates(self):
         """
-        Turn off updating bu groups based on burnup.
+        Turn off updating Env groups based on environment.
 
         Useful during reactivity coefficient calculations to be consistent with ref. run.
 
         See Also
         --------
-        enableBuGroupUpdates
+        enableEnvGroupUpdates
         """
-        runLog.extra("Burnup group updating disabled")
-        wasEnabled = self._buGroupUpdatesEnabled
-        self._buGroupUpdatesEnabled = False
+        runLog.extra("Environment xs group updating disabled")
+        wasEnabled = self._envGroupUpdatesEnabled
+        self._envGroupUpdatesEnabled = False
         return wasEnabled
 
-    def enableBuGroupUpdates(self):
+    def enableEnvGroupUpdates(self):
         """
-        Turn on updating bu groups based on burnup.
+        Turn on updating Env groups based on environment.
 
         See Also
         --------
-        disableBuGroupUpdates
+        disableEnvGroupUpdates
         """
-        runLog.extra("Burnup group updating enabled")
-        self._buGroupUpdatesEnabled = True
+        runLog.extra("Environment xs group updating enabled")
+        self._envGroupUpdatesEnabled = True
 
     def getNucTemperature(self, xsID, nucName):
         """
diff --git a/armi/physics/neutronics/crossSectionSettings.py b/armi/physics/neutronics/crossSectionSettings.py
index 14a4bc53..a683f7d7 100644
--- a/armi/physics/neutronics/crossSectionSettings.py
+++ b/armi/physics/neutronics/crossSectionSettings.py
@@ -60,6 +60,7 @@ CONF_XS_MAX_ATOM_NUMBER = "xsMaxAtomNumber"
 CONF_MIN_DRIVER_DENSITY = "minDriverDensity"
 CONF_DUCT_HETEROGENEOUS = "ductHeterogeneous"
 CONF_TRACE_ISOTOPE_THRESHOLD = "traceIsotopeThreshold"
+CONF_XS_TEMP_ISOTOPE = "xsTempIsotope"
 
 
 class XSGeometryTypes(Enum):
@@ -121,6 +122,7 @@ _VALID_INPUTS_BY_GEOMETRY_TYPE = {
         CONF_XS_EXECUTE_EXCLUSIVE,
         CONF_XS_PRIORITY,
         CONF_XS_MAX_ATOM_NUMBER,
+        CONF_XS_TEMP_ISOTOPE,
     },
     XSGeometryTypes.getStr(XSGeometryTypes.ONE_DIMENSIONAL_SLAB): {
         CONF_XSID,
@@ -134,6 +136,7 @@ _VALID_INPUTS_BY_GEOMETRY_TYPE = {
         CONF_XS_PRIORITY,
         CONF_XS_MAX_ATOM_NUMBER,
         CONF_MIN_DRIVER_DENSITY,
+        CONF_XS_TEMP_ISOTOPE,
     },
     XSGeometryTypes.getStr(XSGeometryTypes.ONE_DIMENSIONAL_CYLINDER): {
         CONF_XSID,
@@ -155,6 +158,7 @@ _VALID_INPUTS_BY_GEOMETRY_TYPE = {
         CONF_MIN_DRIVER_DENSITY,
         CONF_DUCT_HETEROGENEOUS,
         CONF_TRACE_ISOTOPE_THRESHOLD,
+        CONF_XS_TEMP_ISOTOPE,
     },
     XSGeometryTypes.getStr(XSGeometryTypes.TWO_DIMENSIONAL_HEX): {
         CONF_XSID,
@@ -171,6 +175,7 @@ _VALID_INPUTS_BY_GEOMETRY_TYPE = {
         CONF_XS_PRIORITY,
         CONF_XS_MAX_ATOM_NUMBER,
         CONF_MIN_DRIVER_DENSITY,
+        CONF_XS_TEMP_ISOTOPE,
     },
 }
 
@@ -203,6 +208,7 @@ _SINGLE_XS_SCHEMA = vol.Schema(
         vol.Optional(CONF_COMPONENT_AVERAGING): bool,
         vol.Optional(CONF_DUCT_HETEROGENEOUS): bool,
         vol.Optional(CONF_TRACE_ISOTOPE_THRESHOLD): vol.Coerce(float),
+        vol.Optional(CONF_XS_TEMP_ISOTOPE): str,
     }
 )
 
@@ -259,19 +265,21 @@ class XSSettings(dict):
         if xsID in self:
             return dict.__getitem__(self, xsID)
 
+        # exact key not present so give lowest env group key, eg AA or BA as the source for
+        # settings since users do not typically provide all combinations of second chars explicitly
         xsType = xsID[0]
-        buGroup = xsID[1]
+        envGroup = xsID[1]
         existingXsOpts = [
             xsOpt
             for xsOpt in self.values()
-            if xsOpt.xsType == xsType and xsOpt.buGroup < buGroup
+            if xsOpt.xsType == xsType and xsOpt.envGroup < envGroup
         ]
 
         if not any(existingXsOpts):
             return self._getDefault(xsID)
 
         else:
-            return sorted(existingXsOpts, key=lambda xsOpt: xsOpt.buGroup)[0]
+            return sorted(existingXsOpts, key=lambda xsOpt: xsOpt.envGroup)[0]
 
     def setDefaults(self, blockRepresentation, validBlockTypes):
         """
@@ -469,6 +477,10 @@ class XSModelingOptions:
         model. The setting takes a float value that represents the number density cutoff
         for isotopes to be considered "trace". If no value is provided, the default is 0.0.
 
+    xsTempIsotope: str
+            The isotope whose temperature is interrogated when placing a block in a temperature cross section group.
+            See `tempGroups`. "U238" is default since it tends to be dominant doppler isotope in most reactors.
+
     Notes
     -----
     Not all default attributes may be useful for your specific application and you may
@@ -503,6 +515,7 @@ class XSModelingOptions:
         minDriverDensity=0.0,
         ductHeterogeneous=False,
         traceIsotopeThreshold=0.0,
+        xsTempIsotope="U238",
     ):
         self.xsID = xsID
         self.geometry = geometry
@@ -531,6 +544,7 @@ class XSModelingOptions:
         # these are related to execution
         self.xsExecuteExclusive = xsExecuteExclusive
         self.xsPriority = xsPriority
+        self.xsTempIsotope = xsTempIsotope
 
     def __repr__(self):
         if self.xsIsPregenerated:
@@ -551,7 +565,7 @@ class XSModelingOptions:
         return self.xsID[0]
 
     @property
-    def buGroup(self):
+    def envGroup(self):
         """Return the single-char burnup group indicator."""
         return self.xsID[1]
 
diff --git a/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py b/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py
index 68da4be9..c52ed664 100644
--- a/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py
+++ b/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py
@@ -76,7 +76,7 @@ class LatticePhysicsWriter(interfaces.InputWriter):
     DEPLETABLE = "Depletable" + 4 * _SPACE
     UNDEPLETABLE = "Non-Depletable"
     REPRESENTED = "Represented" + 2 * _SPACE
-    UNREPRESENTED = "Unrepresented"
+    INF_DILUTE = "Inf Dilute"
 
     def __init__(
         self,
@@ -307,7 +307,7 @@ class LatticePhysicsWriter(interfaces.InputWriter):
             if nucName in objNuclides:
                 nucCategory += self.REPRESENTED + self._SEPARATOR
             else:
-                nucCategory += self.UNREPRESENTED + self._SEPARATOR
+                nucCategory += self.INF_DILUTE + self._SEPARATOR
 
             if nucName in depletableNuclides:
                 nucCategory += self.DEPLETABLE
diff --git a/armi/reactor/blockParameters.py b/armi/reactor/blockParameters.py
index 4e8e399c..820ebac9 100644
--- a/armi/reactor/blockParameters.py
+++ b/armi/reactor/blockParameters.py
@@ -21,7 +21,7 @@ from armi.reactor import parameters
 from armi.reactor.parameters import NoDefault, Parameter, ParamLocation
 from armi.reactor.parameters.parameterDefinitions import isNumpyArray
 from armi.utils import units
-from armi.utils.units import ASCII_LETTER_A
+from armi.utils.units import ASCII_LETTER_A, ASCII_LETTER_Z, ASCII_LETTER_a
 
 
 def getBlockParameterDefinitions():
@@ -196,53 +196,66 @@ def getBlockParameterDefinitions():
 
     with pDefs.createBuilder(default=0.0, location=ParamLocation.AVERAGE) as pb:
 
-        def buGroup(self, buGroupChar):
-            if isinstance(buGroupChar, (int, float)):
-                intValue = int(buGroupChar)
+        def envGroup(self, envGroupChar):
+            if isinstance(envGroupChar, (int, float)):
+                intValue = int(envGroupChar)
                 runLog.warning(
-                    f"Attempting to set `b.p.buGroup` to int value ({buGroupChar}). Possibly loading from old database",
+                    f"Attempting to set `b.p.envGroup` to int value ({envGroupChar})."
+                    "Possibly loading from old database",
                     single=True,
-                    label="bu group as int " + str(intValue),
+                    label="env group as int " + str(intValue),
                 )
-                self.buGroupNum = intValue
+                self.envGroupNum = intValue
                 return
-            elif not isinstance(buGroupChar, six.string_types):
+            elif not isinstance(envGroupChar, six.string_types):
                 raise Exception(
-                    f"Wrong type for buGroupChar {buGroupChar}: {type(buGroupChar)}"
+                    f"Wrong type for envGroupChar {envGroupChar}: {type(envGroupChar)}"
                 )
 
-            buGroupNum = ord(buGroupChar) - ASCII_LETTER_A
-            self._p_buGroup = buGroupChar
-            self._p_buGroupNum = buGroupNum
-            buGroupNumDef = parameters.ALL_DEFINITIONS["buGroupNum"]
-            buGroupNumDef.assigned = parameters.SINCE_ANYTHING
+            if envGroupChar.islower():
+                # if lower case find the distance from lowercase a and add the span of A to Z
+                lowerCaseOffset = ASCII_LETTER_Z - ASCII_LETTER_A + 1  # 26
+                envGroupNum = ord(envGroupChar) - ASCII_LETTER_a + lowerCaseOffset
+            else:
+                envGroupNum = ord(envGroupChar) - ASCII_LETTER_A
+            self._p_envGroup = envGroupChar
+            self._p_envGroupNum = envGroupNum
+            envGroupNumDef = parameters.ALL_DEFINITIONS["envGroupNum"]
+            envGroupNumDef.assigned = parameters.SINCE_ANYTHING
 
         pb.defParam(
-            "buGroup",
+            "envGroup",
             units=units.UNITLESS,
-            description="The burnup group letter of this block",
+            description="The environment group letter of this block",
             default="A",
-            setter=buGroup,
+            setter=envGroup,
         )
 
-        def buGroupNum(self, buGroupNum):
-            if buGroupNum > 26:
+        def envGroupNum(self, envGroupNum):
+            # support capital and lowercase alpha chars (52= 26*2)
+            if envGroupNum > 52:
                 raise RuntimeError(
-                    "Invalid bu group number ({}): too many groups. 26 is the max.".format(
-                        buGroupNum
+                    "Invalid env group number ({}): too many groups. 52 is the max.".format(
+                        envGroupNum
                     )
                 )
-            self._p_buGroupNum = buGroupNum
-            self._p_buGroup = chr(buGroupNum + ASCII_LETTER_A)
-            buGroupDef = parameters.ALL_DEFINITIONS["buGroup"]
-            buGroupDef.assigned = parameters.SINCE_ANYTHING
-
-        pb.defParam(
-            "buGroupNum",
+            self._p_envGroupNum = envGroupNum
+            lowerCaseOffset = ASCII_LETTER_Z - ASCII_LETTER_A
+            if envGroupNum > lowerCaseOffset:
+                envGroupNum = envGroupNum - (lowerCaseOffset + 1)
+                self._p_envGroup = chr(envGroupNum + ASCII_LETTER_a)
+            else:
+                self._p_envGroup = chr(envGroupNum + ASCII_LETTER_A)
+            envGroupDef = parameters.ALL_DEFINITIONS["envGroup"]
+            envGroupDef.assigned = parameters.SINCE_ANYTHING
+
+        pb.defParam(
+            "envGroupNum",
             units=units.UNITLESS,
-            description="An integer representation of the burnup group, linked to buGroup.",
+            description="An integer representation of the environment group "
+            "(burnup/temperature/etc.). linked to envGroup.",
             default=0,
-            setter=buGroupNum,
+            setter=envGroupNum,
         )
 
         pb.defParam(
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index 55613edd..b3eada9d 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -127,11 +127,11 @@ class Block(composites.Composite):
 
     def __repr__(self):
         # be warned, changing this might break unit tests on input file generations
-        return "<{type} {name} at {loc} XS: {xs} BU GP: {bu}>".format(
+        return "<{type} {name} at {loc} XS: {xs} ENV GP: {env}>".format(
             type=self.getType(),
             name=self.getName(),
             xs=self.p.xsType,
-            bu=self.p.buGroup,
+            env=self.p.envGroup,
             loc=self.getLocation(),
         )
 
@@ -403,26 +403,28 @@ class Block(composites.Composite):
 
         Notes
         -----
-        The single-letter use for xsType and buGroup limit users to 26 groups of each.
-        ARMI will allow 2-letter xsType designations if and only if the `buGroups`
-        setting has length 1 (i.e. no burnup groups are defined). This is useful for
+        The single-letter use for xsType and envGroup limit users to 52 groups of each.
+        ARMI will allow 2-letter xsType designations if and only if the `envGroup`
+        setting has length 1 (i.e. no burnup/temp groups are defined). This is useful for
         high-fidelity XS modeling of V&V models such as the ZPPRs.
         """
-        bu = self.p.buGroup
-        if not bu:
+        env = self.p.envGroup
+        if not env:
             raise RuntimeError(
-                "Cannot get MicroXS suffix because {0} in {1} does not have a burnup group"
+                "Cannot get MicroXS suffix because {0} in {1} does not have a environment(env) group"
                 "".format(self, self.parent)
             )
 
         xsType = self.p.xsType
         if len(xsType) == 1:
-            return xsType + bu
-        elif len(xsType) == 2 and ord(bu) > ord("A"):
+            return xsType + env
+        elif len(xsType) == 2 and ord(env) != ord("A"):
+            # default is "A" so if we got an off default 2 char, there is no way to resolve.
             raise ValueError(
-                "Use of multiple burnup groups is not allowed with multi-character xs groups!"
+                "Use of non-default env groups is not allowed with multi-character xs groups!"
             )
         else:
+            # ignore env group, multi Char XS type to support assigning 2 chars in blueprints
             return xsType
 
     def getHeight(self):
@@ -1885,7 +1887,7 @@ class HexBlock(Block):
         # assign macros and LFP
         b.macros = self.macros
         b._lumpedFissionProducts = self._lumpedFissionProducts
-        b.p.buGroup = self.p.buGroup
+        b.p.envGroup = self.p.envGroup
 
         hexComponent = Hexagon(
             "homogenizedHex",
diff --git a/armi/reactor/converters/geometryConverters.py b/armi/reactor/converters/geometryConverters.py
index 83944dc2..e035c4e9 100644
--- a/armi/reactor/converters/geometryConverters.py
+++ b/armi/reactor/converters/geometryConverters.py
@@ -822,9 +822,9 @@ class HexToRZThetaConverter(GeometryConverter):
 
             # Assign the new block cross section type and burn up group
             newBlock.setType(newBlockType)
-            newXsType, newBuGroup = self._createBlendedXSID(newBlock)
+            newXsType, newEnvGroup = self._createBlendedXSID(newBlock)
             newBlock.p.xsType = newXsType
-            newBlock.p.buGroup = newBuGroup
+            newBlock.p.envGroup = newEnvGroup
 
             # Update the block dimensions and set the block densities
             newComponent.updateDims()  # ugh.
@@ -989,12 +989,12 @@ class HexToRZThetaConverter(GeometryConverter):
     def _createBlendedXSID(self, newBlock):
         """Generate the blended XS id using the most common XS id in the hexIdList."""
         ids = [hexBlock.getMicroSuffix() for hexBlock in self.blockMap[newBlock]]
-        xsTypeList, buGroupList = zip(*ids)
+        xsTypeList, envGroupList = zip(*ids)
 
         xsType, _count = collections.Counter(xsTypeList).most_common(1)[0]
-        buGroup, _count = collections.Counter(buGroupList).most_common(1)[0]
+        envGroup, _count = collections.Counter(envGroupList).most_common(1)[0]
 
-        return xsType, buGroup
+        return xsType, envGroup
 
     def _writeRadialThetaZoneHeader(
         self, radIdx, lowerRing, upperRing, thIdx, lowerTheta, upperTheta
diff --git a/armi/settings/fwSettings/globalSettings.py b/armi/settings/fwSettings/globalSettings.py
index cf9ed1dc..353a0cc7 100644
--- a/armi/settings/fwSettings/globalSettings.py
+++ b/armi/settings/fwSettings/globalSettings.py
@@ -40,6 +40,7 @@ CONF_AXIAL_MESH_REFINEMENT_FACTOR = "axialMeshRefinementFactor"
 CONF_BETA = "beta"
 CONF_BRANCH_VERBOSITY = "branchVerbosity"
 CONF_BU_GROUPS = "buGroups"
+CONF_TEMP_GROUPS = "tempGroups"
 CONF_BURN_CHAIN_FILE_NAME = "burnChainFileName"
 CONF_BURN_STEPS = "burnSteps"
 CONF_BURNUP_PEAKING_FACTOR = "burnupPeakingFactor"
@@ -405,18 +406,32 @@ def defineSettings() -> List[setting.Setting]:
         ),
         setting.Setting(
             CONF_BU_GROUPS,
-            default=[10, 20, 30, 100],
-            label="Burnup Groups",
+            default=[10, 20, 30],
+            label="Burnup XS Groups",
             description="The range of burnups where cross-sections will be the same "
-            "for a given assembly type (units of %FIMA)",
+            "for a given cross section type (units of %FIMA)",
             schema=vol.Schema(
                 [
                     vol.All(
-                        vol.Coerce(int), vol.Range(min=0, min_included=False, max=100)
+                        vol.Coerce(int),
+                        vol.Range(
+                            min=0,
+                            min_included=False,
+                        ),
                     )
                 ]
             ),
         ),
+        setting.Setting(
+            CONF_TEMP_GROUPS,
+            default=[],
+            label="Temperature XS Groups",
+            description="The range of fuel temperatures where cross-sections will be the same "
+            "for a given cross section type (units of degrees C)",
+            schema=vol.Schema(
+                [vol.All(vol.Coerce(int), vol.Range(min=0, min_included=False))]
+            ),
+        ),
         setting.Setting(
             CONF_BURNUP_PEAKING_FACTOR,
             default=0.0,
diff --git a/armi/utils/units.py b/armi/utils/units.py
index 01265458..defa6111 100644
--- a/armi/utils/units.py
+++ b/armi/utils/units.py
@@ -105,6 +105,7 @@ G_PER_CM3_TO_KG_PER_M3 = 1000.0
 ASCII_MIN_CHAR = 44  # First char allowed in various FORTRAN inputs
 ASCII_LETTER_A = 65
 ASCII_LETTER_Z = 90
+ASCII_LETTER_a = 97
 ASCII_ZERO = 48
 TRACE_NUMBER_DENSITY = 1e-50
 MIN_FUEL_HM_MOLES_PER_CC = 1e-10
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 056fc906..a619f25a 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -97,6 +97,8 @@ New Features
    (`PR#1729 <https://github.com/terrapower/armi/pull/1729>`_ and `PR#1750 <https://github.com/terrapower/armi/pull/1750>`_)
 #. Density can be specified for components via ``custom isotopics`` in the blueprints. (`PR#1745 <https://github.com/terrapower/armi/pull/1745>`_)
 #. Implement a new ``JaggedArray`` class that handles HDF5 interface for jagged data. (`PR#1726 <https://github.com/terrapower/armi/pull/1726>`_)
+#. Adding temperature dependent representative blocks to cross section group manager. (`PR#1987 <https://github.com/terrapower/armi/pull/1987>`_)
+
 
 API Changes
 -----------
@@ -136,6 +138,7 @@ API Changes
 #. Changing the Doppler constant params to ``VOLUME_INTEGRATED``. (`PR#1659 <https://github.com/terrapower/armi/pull/1659>`_)
 #. Change ``Operator._expandCycleAndTimeNodeArgs`` to be a non-static method. (`PR#1766 <https://github.com/terrapower/armi/pull/1766>`_)
 #. Database now writes state at the last time node of a cycle rather than during the ``DatabaseInterface.interactEOC`` interaction. (`PR#1090 <https://github.com/terrapower/armi/pull/1090>`_)
+#. Renaming ``b.p.buGroup`` to ``b.p.envGroup``. Environment group captures both burnup and temperature. (`PR#1987 <https://github.com/terrapower/armi/pull/1987>`_)
 
 Bug Fixes
 ---------
diff --git a/doc/user/spatial_block_parameters.rst b/doc/user/spatial_block_parameters.rst
index c17c9f74..2dd4e6f0 100644
--- a/doc/user/spatial_block_parameters.rst
+++ b/doc/user/spatial_block_parameters.rst
@@ -186,7 +186,7 @@ component exists in space. If we grab the fuel component from the UO2 block in t
     [ 0.4444 -0.4444  0.    ]
     [0.76972338 0.76972338 0.        ]
     [0. 0. 0.]
-    Anchor: <fuel B0009-001 at 008-040-001 XS: C BU GP: A>
+    Anchor: <fuel B0009-001 at 008-040-001 XS: C ENV GP: A>
     Offset: [0. 0. 0.]
     Num Locations: 400>
     >>> fuel = fuelBlock.getChildrenWithFlags(Flags.FUEL)[0]


Test patch:
diff --git a/armi/nuclearDataIO/tests/library-file-generation/mc2v2-dlayxs.inp b/armi/nuclearDataIO/tests/library-file-generation/mc2v2-dlayxs.inp
index d163aa19..78636969 100644
--- a/armi/nuclearDataIO/tests/library-file-generation/mc2v2-dlayxs.inp
+++ b/armi/nuclearDataIO/tests/library-file-generation/mc2v2-dlayxs.inp
@@ -272,7 +272,7 @@ DATASET=A.MCC2
 22          CM2455     7 202.55000
 22          CM2465     7 202.55000
 22          CM2475     7 202.55000
-01 ARMI generated \\path\to\mc2\2.0\mc2.exe case for caseTitle mrtTWRP600v6rev3OS, block <twrp starter fuel B0064F at A8009F XS: B BU GP: A>
+01 ARMI generated \\path\to\mc2\2.0\mc2.exe case for caseTitle mrtTWRP600v6rev3OS, block <twrp starter fuel B0064F at A8009F XS: B ENV GP: A>
 DATASET=A.DLAY                                                                  
 01    CREATE VERSION VI DLAYXS FILE FROM FUELI                                  
 03         1     0     0     0     0                                            
diff --git a/armi/physics/neutronics/tests/test_crossSectionManager.py b/armi/physics/neutronics/tests/test_crossSectionManager.py
index c78c4bdc..b0ac19e1 100644
--- a/armi/physics/neutronics/tests/test_crossSectionManager.py
+++ b/armi/physics/neutronics/tests/test_crossSectionManager.py
@@ -95,6 +95,22 @@ class TestBlockCollectionMedian(unittest.TestCase):
         avgB = self.bc.createRepresentativeBlock()
         self.assertAlmostEqual(avgB.p.percentBu, 50.0)
 
+    def test_getBlockNuclideTemperature(self):
+        # doesn't have to be in median block tests, but this is a simpler test
+        nuc = "U235"
+        testBlock = self.blockList[0]
+        amt, amtWeightedTemp = 0, 0
+        for c in testBlock:
+            dens = c.getNumberDensity(nuc)
+            if dens > 0:
+                thisAmt = dens * c.getVolume()
+                amt += thisAmt
+                amtWeightedTemp += thisAmt * c.temperatureInC
+        avgTemp = amtWeightedTemp / amt
+        self.assertAlmostEqual(
+            avgTemp, crossSectionGroupManager.getBlockNuclideTemperature(testBlock, nuc)
+        )
+
 
 class TestBlockCollectionAverage(unittest.TestCase):
     @classmethod
@@ -403,7 +419,7 @@ class TestBlockCollectionComponentAverage(unittest.TestCase):
 
         assert "AC" in xsgm.representativeBlocks, (
             "Assemblies not in the core should still have XS groups"
-            "see getUnrepresentedBlocks()"
+            "see _getMissingBlueprintBlocks()"
         )
 
 
@@ -763,29 +779,36 @@ class TestCrossSectionGroupManager(unittest.TestCase):
         self.csm._setBuGroupBounds([3, 10, 30, 100])
         self.csm.interactBOL()
 
-    def test_enableBuGroupUpdates(self):
-        self.csm._buGroupUpdatesEnabled = False
-        self.csm.enableBuGroupUpdates()
-        self.assertTrue(self.csm.enableBuGroupUpdates)
-
-    def test_disableBuGroupUpdates(self):
-        self.csm._buGroupUpdatesEnabled = False
-        res = self.csm.disableBuGroupUpdates()
-        self.assertFalse(res)
+    def test_enableEnvGroupUpdates(self):
+        self.csm._envGroupUpdatesEnabled = False
+        self.csm.enableEnvGroupUpdates()
+        self.assertTrue(self.csm._envGroupUpdatesEnabled)
+        # test flipping again keeps true
+        self.csm.enableEnvGroupUpdates()
+        self.assertTrue(self.csm._envGroupUpdatesEnabled)
+
+    def test_disableEnvGroupUpdates(self):
+        self.csm._envGroupUpdatesEnabled = True
+        wasEnabled = self.csm.disableEnvGroupUpdates()
+        self.assertTrue(wasEnabled)
+        self.assertFalse(self.csm._envGroupUpdatesEnabled)
+        wasEnabled = self.csm.disableEnvGroupUpdates()
+        self.assertFalse(wasEnabled)
+        self.assertFalse(self.csm._envGroupUpdatesEnabled)
 
     def test_updateBurnupGroups(self):
         self.blockList[1].p.percentBu = 3.1
         self.blockList[2].p.percentBu = 10.0
 
-        self.csm._updateBurnupGroups(self.blockList)
+        self.csm._updateEnvironmentGroups(self.blockList)
 
-        self.assertEqual(self.blockList[0].p.buGroup, "A")
-        self.assertEqual(self.blockList[1].p.buGroup, "B")
-        self.assertEqual(self.blockList[2].p.buGroup, "B")
-        self.assertEqual(self.blockList[-1].p.buGroup, "D")
+        self.assertEqual(self.blockList[0].p.envGroup, "A")
+        self.assertEqual(self.blockList[1].p.envGroup, "B")
+        self.assertEqual(self.blockList[2].p.envGroup, "B")
+        self.assertEqual(self.blockList[-1].p.envGroup, "D")
 
     def test_setBuGroupBounds(self):
-        self.assertAlmostEqual(self.csm._upperBuGroupBounds[2], 30.0)
+        self.assertAlmostEqual(self.csm._buGroupBounds[2], 30.0)
 
         with self.assertRaises(ValueError):
             self.csm._setBuGroupBounds([3, 10, 300])
@@ -796,6 +819,14 @@ class TestCrossSectionGroupManager(unittest.TestCase):
         with self.assertRaises(ValueError):
             self.csm._setBuGroupBounds([1, 5, 3])
 
+    def test_setTempGroupBounds(self):
+        # negative temps in C are allowed
+        self.csm._setTempGroupBounds([-5, 3, 10, 300])
+        self.assertAlmostEqual(self.csm._tempGroupBounds[2], 10.0)
+
+        with self.assertRaises(ValueError):
+            self.csm._setTempGroupBounds([1, 5, 3])
+
     def test_addXsGroupsFromBlocks(self):
         blockCollectionsByXsGroup = {}
         blockCollectionsByXsGroup = self.csm._addXsGroupsFromBlocks(
@@ -810,7 +841,7 @@ class TestCrossSectionGroupManager(unittest.TestCase):
         self.blockList[3].p.percentBu = 1.5
         for b in self.blockList[4:]:
             b.p.percentBu = 0.0
-        self.csm._updateBurnupGroups(self.blockList)
+        self.csm._updateEnvironmentGroups(self.blockList)
         blockCollectionsByXsGroup = {}
         blockCollectionsByXsGroup = self.csm._addXsGroupsFromBlocks(
             blockCollectionsByXsGroup, self.blockList
@@ -1077,6 +1108,85 @@ class TestCrossSectionGroupManager(unittest.TestCase):
             self.assertTrue(os.path.exists("rzmflxYA"))
 
 
+class TestCrossSectionGroupManagerWithTempGrouping(unittest.TestCase):
+    def setUp(self):
+        cs = settings.Settings()
+        cs["tempGroups"] = [300, 400, 500]
+        self.blockList = makeBlocks(11)
+        buAndTemps = (
+            (1, 340),
+            (2, 150),
+            (6, 410),
+            (10.5, 290),
+            (2.5, 360),
+            (4, 460),
+            (15, 370),
+            (16, 340),
+            (15, 700),
+            (14, 720),
+        )
+        for b, env in zip(self.blockList, buAndTemps):
+            bu, temp = env
+            comps = b.getComponents(Flags.FUEL)
+            assert len(comps) == 1
+            c = next(iter(comps))
+            c.setTemperature(temp)
+            b.p.percentBu = bu
+        core = self.blockList[0].core
+
+        def getBlocks(includeAll=True):
+            return self.blockList
+
+        # this sets XSGM to only analyze the blocks in the block list.
+        core.getBlocks = getBlocks
+
+        self.csm = CrossSectionGroupManager(self.blockList[0].core.r, cs)
+        self.csm._setBuGroupBounds([3, 10, 30, 100])
+        self.csm.interactBOL()
+
+    def test_updateEnvironmentGroups(self):
+        self.csm.createRepresentativeBlocks()
+        BL = self.blockList
+        loners = [BL[1], BL[3]]
+
+        self.assertNotEqual(loners[0].getMicroSuffix(), loners[1].getMicroSuffix())
+        sameGroups = [(BL[0], BL[4]), (BL[2], BL[5]), (BL[6], BL[7]), (BL[8], BL[9])]
+
+        # check that likes have like and different are different
+        for group in sameGroups:
+            b1, b2 = group
+            xsSuffix = b1.getMicroSuffix()
+            self.assertEqual(xsSuffix, b2.getMicroSuffix())
+            for group in sameGroups:
+                newb1, newb2 = group
+                if b1 is newb1:
+                    continue
+                self.assertNotEqual(xsSuffix, newb1.getMicroSuffix())
+                self.assertNotEqual(xsSuffix, newb2.getMicroSuffix())
+            for lone in loners:
+                self.assertNotEqual(xsSuffix, lone.getMicroSuffix())
+        self.assertNotEqual(loners[0].getMicroSuffix(), loners[1].getMicroSuffix())
+
+        # calculated based on the average of buAndTemps
+        expectedIDs = ["AF", "AA", "AL", "AC", "AH", "AR"]
+        expectedTemps = [
+            (340 + 360) / 2,
+            150,
+            (410 + 460) / 2,
+            290,
+            (370 + 340) / 2,
+            (700 + 720) / 2,
+        ]
+        expectedBurnups = (1.75, 2, 5, 10.5, 15.5, 14.5)
+        for xsID, expectedTemp, expectedBurnup in zip(
+            expectedIDs, expectedTemps, expectedBurnups
+        ):
+            b = self.csm.representativeBlocks[xsID]
+            thisTemp = self.csm.avgNucTemperatures[xsID]["U238"]
+            self.assertAlmostEqual(thisTemp, expectedTemp)
+            self.assertAlmostEqual(b.p.percentBu, expectedBurnup)
+
+
 class TestXSNumberConverters(unittest.TestCase):
     def test_conversion(self):
         label = crossSectionGroupManager.getXSTypeLabelFromNumber(65)
diff --git a/armi/physics/neutronics/tests/test_crossSectionSettings.py b/armi/physics/neutronics/tests/test_crossSectionSettings.py
index 2106ba9b..17499e80 100644
--- a/armi/physics/neutronics/tests/test_crossSectionSettings.py
+++ b/armi/physics/neutronics/tests/test_crossSectionSettings.py
@@ -121,7 +121,7 @@ class TestCrossSectionSettings(unittest.TestCase):
         self.assertEqual(xsModel["YA"].ductHeterogeneous, False)
         self.assertEqual(xsModel["YA"].traceIsotopeThreshold, 0.0)
 
-    def test_setDefaultSettingsByLowestBuGroupHomogeneous(self):
+    def test_setDefaultSettingsByLowestEnvGroupHomogeneous(self):
         # Initialize some micro suffix in the cross sections
         cs = settings.Settings()
         xs = XSSettings()
@@ -147,7 +147,7 @@ class TestCrossSectionSettings(unittest.TestCase):
         self.assertNotIn("JB", xs)
         self.assertNotEqual(xs["JD"], xs["JB"])
 
-    def test_setDefaultSettingsByLowestBuGroupOneDimensional(self):
+    def test_setDefaultSettingsByLowestEnvGroupOneDimensional(self):
         # Initialize some micro suffix in the cross sections
         cs = settings.Settings()
         xsModel = XSSettings()
diff --git a/armi/reactor/blueprints/tests/test_customIsotopics.py b/armi/reactor/blueprints/tests/test_customIsotopics.py
index 4c341b45..e22f0a50 100644
--- a/armi/reactor/blueprints/tests/test_customIsotopics.py
+++ b/armi/reactor/blueprints/tests/test_customIsotopics.py
@@ -471,7 +471,7 @@ assemblies:
         self.assertNotIn("FE51", c.getNumberDensities())  # un-natural
         self.assertNotIn("FE", c.getNumberDensities())
 
-    def test_unrepresentedAreOnlyNatural(self):
+    def test_infDiluteAreOnlyNatural(self):
         """Make sure nuclides specified as In-Problem but not actually in any material are only natural isotopics."""
         self.assertIn("AL27", self.bp.allNuclidesInProblem)
         self.assertNotIn("AL26", self.bp.allNuclidesInProblem)
diff --git a/armi/reactor/tests/test_assemblies.py b/armi/reactor/tests/test_assemblies.py
index 56c21d91..51045bd4 100644
--- a/armi/reactor/tests/test_assemblies.py
+++ b/armi/reactor/tests/test_assemblies.py
@@ -210,7 +210,7 @@ class Assembly_TestCase(unittest.TestCase):
         self.blockParams = {
             "height": self.height,
             "bondRemoved": 0.0,
-            "buGroupNum": 0,
+            "envGroupNum": 0,
             "buLimit": 35,
             "buRate": 0.0,
             "eqRegion": -1,
@@ -229,7 +229,7 @@ class Assembly_TestCase(unittest.TestCase):
         self.blockSettings = {
             "axMesh": 1,
             "bondBOL": 0.0028698019026172574,
-            "buGroup": "A",
+            "envGroup": "A",
             "height": 14.4507,
             "molesHmAtBOL": 65.8572895758245,
             "nHMAtBOL": 0.011241485251783766,
@@ -692,7 +692,7 @@ class Assembly_TestCase(unittest.TestCase):
             "fastFluence": 1.01,
             "fastFluencePeak": 50.0,
             "power": 10000.0,
-            "buGroup": 4,
+            "envGroup": 4,
             "residence": 3.145,
             "eqRegion": -1,
             "id": 299.0,
diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py
index 07f7eb09..7dc23ab3 100644
--- a/armi/reactor/tests/test_blocks.py
+++ b/armi/reactor/tests/test_blocks.py
@@ -43,6 +43,8 @@ from armi.tests import ISOAA_PATH
 from armi.utils import hexagon, units
 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
+
 
 NUM_PINS_IN_TEST_BLOCK = 217
 
@@ -564,18 +566,44 @@ class Block_TestCase(unittest.TestCase):
         ref = "BB"
         self.assertEqual(cur, ref)
 
-    def test_27b_setBuGroup(self):
+    def test_27b_setEnvGroup(self):
         type_ = "A"
-        self.block.p.buGroup = type_
-        cur = self.block.p.buGroupNum
-        ref = ord(type_) - 65
+        self.block.p.envGroup = type_
+        cur = self.block.p.envGroupNum
+        ref = ord(type_) - ASCII_LETTER_A
+        self.assertEqual(cur, ref)
+
+        typeNumber = 25  # this is Z due to 0 based numbers
+        self.block.p.envGroupNum = typeNumber
+        cur = self.block.p.envGroup
+        ref = chr(typeNumber + ASCII_LETTER_A)
+        self.assertEqual(cur, ref)
+        self.assertEqual(cur, "Z")
+
+        before_a = ASCII_LETTER_a - 1
+        type_ = "a"
+        self.block.p.envGroup = type_
+        cur = self.block.p.envGroupNum
+        ref = ord(type_) - (before_a) + (ASCII_LETTER_Z - ASCII_LETTER_A)
+        self.assertEqual(cur, ref)
+
+        typeNumber = 26  # this is a due to 0 based numbers
+        self.block.p.envGroupNum = typeNumber
+        cur = self.block.p.envGroup
+        self.assertEqual(cur, "a")
+
+        type_ = "z"
+        self.block.p.envGroup = type_
+        cur = self.block.p.envGroupNum
+        ref = ord(type_) - before_a + (ASCII_LETTER_Z - ASCII_LETTER_A)
         self.assertEqual(cur, ref)
 
-        typeNumber = 25
-        self.block.p.buGroupNum = typeNumber
-        cur = self.block.p.buGroup
-        ref = chr(typeNumber + 65)
+        typeNumber = 26 * 2 - 1  # 2x letters in alpha with 0 based index
+        self.block.p.envGroupNum = typeNumber
+        cur = self.block.p.envGroup
+        ref = chr((typeNumber - 26) + ASCII_LETTER_a)
         self.assertEqual(cur, ref)
+        self.assertEqual(cur, "z")
 
     def test_setZeroHeight(self):
         """Test that demonstrates that a block's height can be set to zero."""
@@ -1064,7 +1092,7 @@ class Block_TestCase(unittest.TestCase):
         self.block.p.xsType = "RS"
         self.assertEqual(self.block.getMicroSuffix(), "RS")
 
-        self.block.p.buGroup = "X"
+        self.block.p.envGroup = "X"
         self.block.p.xsType = "AB"
         with self.assertRaises(ValueError):
             self.block.getMicroSuffix()