Repository: terrapower/armi
Pull request #1949 (2024-10-11 23:30:14)

Adding option to create a partially heterogeneous assemblies for 1D XS model
## What is the change?

The `latticePhysicsInterface` and `crossSectionGroupManager` modules are being updated to enable a new type of 1D cylindrical cross section model. The key features added are:

- New type of block collection in `crossSectionGroupManager`, `CylindricalComponentsPartHetAverageBlockCollection`, which extends the existing `CylindricalComponentsAverageBlockCollection` with a modified method for calculating the average nuclide temperatures within the representative block.
- New `crossSectionControlSettings`:
  1. `mergeIntoFuel` allows a user to specify components to be merged into the fuel for the cross section model
  2. `ductHeterogeneous` enables the user to select the partially heterogeneous 1D model (as opposed to fully heterogeneous)
  3. `traceIsotopeThreshold` allows a user to split low-concentration isotopes out of the 1D cross section model and instead run a 0D model to obtain cross sections for those isotopes. 

Much of the implementation details related to these new `crossSectionControl` options would need to be implemented in the downstream plugins that extend the `latticePhysicsInterface`.

## Why is the change being made?

These features are being added to enable downstream lattice physics plugins to have more options and flexibility when it comes to building a 1D cylindrical equivalent model of a 2D hexagonal assembly for cross section generation. Since a fully-heterogeneous 1D cylindrical model can pose a significantly more compute-intensive problem for the lattice physics solver than a simple 0D model, it is helpful to have options for simplifying the 1D model. Combining small/unimportant components into others (`mergeIntoFuel`), homogenizing several regions together (`ductHeterogeneous`), and pulling out trace isotopes that don't contribute significantly to reaction rates (`traceIsotopeThreshold`) can all help to reduce the complexity of the 1D model.

---

## 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`.

jakehader: @mgjarrett - Do you have some images you could share on what this model looks like when enabled? Is there any part of the user manual / documentation should be amended to describe this in more detail with examples?
mgjarrett: > @mgjarrett - Do you have some images you could share on what this model looks like when enabled? Is there any part of the user manual / documentation should be amended to describe this in more detail with examples?

Here are two PNGs generated by ARMI of the converted 1D cylindrical representation of the 2D hexagon. This is for a hexagon with 10 rings of pins (271 total pins in the lattice).

Partially Heterogeneous (everything inside the duct is homogenized):
![1D_CYL_PART_HET_AVG_AA-cyl (1)](https://github.com/user-attachments/assets/2f922b05-d3ea-454b-8f70-b183ccf7a3af)

Fully heterogeneous:
![1D_CYL_AVG_AA-cyl](https://github.com/user-attachments/assets/0adf7d86-0187-47b3-abd7-2192a9d912d0)

john-science: bump

Patch:
diff --git a/armi/nuclearDataIO/nuclearFileMetadata.py b/armi/nuclearDataIO/nuclearFileMetadata.py
index 3726c305..2e1ddb89 100644
--- a/armi/nuclearDataIO/nuclearFileMetadata.py
+++ b/armi/nuclearDataIO/nuclearFileMetadata.py
@@ -252,4 +252,4 @@ class RegionXSMetadata(FileMetadata):
 
 
 class NuclideMetadata(_Metadata):
-    """Simple dictionary for providing metadata about how to read/write a nuclde to/from a file."""
+    """Simple dictionary for providing metadata about how to read/write a nuclide to/from a file."""
diff --git a/armi/nuclearDataIO/xsLibraries.py b/armi/nuclearDataIO/xsLibraries.py
index 12643776..f38bbaa6 100644
--- a/armi/nuclearDataIO/xsLibraries.py
+++ b/armi/nuclearDataIO/xsLibraries.py
@@ -127,7 +127,7 @@ def getISOTXSLibrariesToMerge(xsLibrarySuffix, xsLibFileNames):
         isosWithSuffix = [
             iso
             for iso in isosToMerge
-            if re.match(f".*ISO[A-Z]{{2}}F?{xsLibrarySuffix}$", iso)
+            if re.match(f".*ISO[A-Za-z]{{2}}F?{xsLibrarySuffix}$", iso)
         ]
         isosToMerge = [
             iso
@@ -193,7 +193,7 @@ def mergeXSLibrariesInWorkingDirectory(
     for xsLibFilePath in sorted(xsLibFiles):
         try:
             # get XS ID from the cross section library name
-            xsID = re.search("ISO([A-Z0-9]{2})", xsLibFilePath).group(1)
+            xsID = re.search("ISO([A-Z0-9a-z]{2})", xsLibFilePath).group(1)
         except AttributeError:
             # if glob has matched something that is not actually an ISOXX file,
             # the .group() call will fail
diff --git a/armi/physics/neutronics/crossSectionGroupManager.py b/armi/physics/neutronics/crossSectionGroupManager.py
index 0131d625..9e8972c6 100644
--- a/armi/physics/neutronics/crossSectionGroupManager.py
+++ b/armi/physics/neutronics/crossSectionGroupManager.py
@@ -63,6 +63,7 @@ from armi.physics.neutronics import LatticePhysicsFrequency
 from armi.physics.neutronics.const import CONF_CROSS_SECTION
 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
 
@@ -623,7 +624,11 @@ class CylindricalComponentsAverageBlockCollection(BlockCollection):
             weight = bWeight * c.getArea()
             totalWeight += weight
             densities += weight * np.array(c.getNuclideNumberDensities(allNucNames))
-        return allNucNames, densities / totalWeight
+        if totalWeight > 0.0:
+            weightedDensities = densities / totalWeight
+        else:
+            weightedDensities = np.zeros_like(densities)
+        return allNucNames, weightedDensities
 
     def _orderComponentsInGroup(self, repBlock):
         """Order the components based on dimension and material type within the representative
@@ -648,6 +653,64 @@ class CylindricalComponentsAverageBlockCollection(BlockCollection):
         return nvt, nv
 
 
+class CylindricalComponentsDuctHetAverageBlockCollection(
+    CylindricalComponentsAverageBlockCollection
+):
+    """
+    Creates a representative block for the purpose of cross section generation with a one-
+    dimensional cylindrical model where all material inside the duct is homogenized.
+
+    .. impl:: Create partially heterogeneous representative blocks.
+        :id: I_ARMI_XSGM_CREATE_REPR_BLOCKS2
+        :implements: R_ARMI_XSGM_CREATE_REPR_BLOCKS
+
+        This class constructs representative blocks based on a volume-weighted average using
+        cylindrical blocks from an existing block list. Inheriting functionality from the abstract
+        :py:class:`Reactor <armi.physics.neutronics.crossSectionGroupManager.BlockCollection>`
+        object, this class will construct representative blocks using averaged parameters of all
+        blocks in the given collection. Number density averages are computed at a component level.
+        Nuclide temperatures from a median block-average temperature are used and the average burnup
+        is evaluated across all blocks in the block list.
+
+        The average nuclide temperatures are calculated only for the homogenized region inside of
+        the duct. For the non-homogenized regions, the MC2 writer uses the component temperatures.
+
+    Notes
+    -----
+    The representative block for this collection is the same as the parent. The only difference between
+    the two collection types is that this collection calculates average nuclide temperatures based only
+    on the components that are inside of the duct.
+    """
+
+    def _getNewBlock(self):
+        newBlock = copy.deepcopy(self._selectCandidateBlock())
+        newBlock.name = "1D_CYL_DUCT_HET_AVG_" + newBlock.getMicroSuffix()
+        return newBlock
+
+    def _makeRepresentativeBlock(self):
+        """Build a representative fuel block based on component number densities."""
+        self.calcAvgNuclideTemperatures()
+        return CylindricalComponentsAverageBlockCollection._makeRepresentativeBlock(
+            self
+        )
+
+    def _getNucTempHelper(self):
+        """All candidate blocks are used in the average."""
+        nvt = np.zeros(len(self.allNuclidesInProblem))
+        nv = np.zeros(len(self.allNuclidesInProblem))
+        for block in self.getCandidateBlocks():
+            wt = self.getWeight(block)
+            # remove the duct and intercoolant from the block before
+            # calculating average nuclide temps
+            newBlock, _mixtureFlags = stripComponents(block, Flags.DUCT)
+            nvtBlock, nvBlock = getBlockNuclideTemperatureAvgTerms(
+                newBlock, self.allNuclidesInProblem
+            )
+            nvt += nvtBlock * wt
+            nv += nvBlock * wt
+        return nvt, nv
+
+
 class SlabComponentsAverageBlockCollection(BlockCollection):
     """
     Creates a representative 1D slab block.
@@ -789,7 +852,11 @@ class SlabComponentsAverageBlockCollection(BlockCollection):
             weight = bWeight * c.getArea()
             totalWeight += weight
             densities += weight * np.array(c.getNuclideNumberDensities(allNucNames))
-        return allNucNames, densities / totalWeight
+        if totalWeight > 0.0:
+            weightedDensities = densities / totalWeight
+        else:
+            weightedDensities = np.zeros_like(densities)
+        return allNucNames, weightedDensities
 
     def _orderComponentsInGroup(self, repBlock):
         """Order the components based on dimension and material type within the representative block."""
@@ -1520,6 +1587,9 @@ AVERAGE_BLOCK_COLLECTION = "Average"
 FLUX_WEIGHTED_AVERAGE_BLOCK_COLLECTION = "FluxWeightedAverage"
 SLAB_COMPONENTS_BLOCK_COLLECTION = "ComponentAverage1DSlab"
 CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION = "ComponentAverage1DCylinder"
+CYLINDRICAL_COMPONENTS_DUCT_HET_BLOCK_COLLECTION = (
+    "ComponentAverage1DCylinderDuctHeterogeneous"
+)
 
 # Mapping between block collection string constants and their
 # respective block collection classes.
@@ -1529,12 +1599,17 @@ BLOCK_COLLECTIONS = {
     FLUX_WEIGHTED_AVERAGE_BLOCK_COLLECTION: FluxWeightedAverageBlockCollection,
     SLAB_COMPONENTS_BLOCK_COLLECTION: SlabComponentsAverageBlockCollection,
     CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION: CylindricalComponentsAverageBlockCollection,
+    CYLINDRICAL_COMPONENTS_DUCT_HET_BLOCK_COLLECTION: CylindricalComponentsDuctHetAverageBlockCollection,
 }
 
 
 def blockCollectionFactory(xsSettings, allNuclidesInProblem):
     """Build a block collection based on user settings and input."""
     blockRepresentation = xsSettings.blockRepresentation
+    if (
+        blockRepresentation == CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION
+    ) and xsSettings.ductHeterogeneous:
+        blockRepresentation = CYLINDRICAL_COMPONENTS_DUCT_HET_BLOCK_COLLECTION
     validBlockTypes = xsSettings.validBlockTypes
     averageByComponent = xsSettings.averageByComponent
     return BLOCK_COLLECTIONS[blockRepresentation](
diff --git a/armi/physics/neutronics/crossSectionSettings.py b/armi/physics/neutronics/crossSectionSettings.py
index 22a58cc1..14a4bc53 100644
--- a/armi/physics/neutronics/crossSectionSettings.py
+++ b/armi/physics/neutronics/crossSectionSettings.py
@@ -49,6 +49,7 @@ CONF_GEOM = "geometry"
 CONF_HOMOGBLOCK = "useHomogenizedBlockComposition"
 CONF_INTERNAL_RINGS = "numInternalRings"
 CONF_MERGE_INTO_CLAD = "mergeIntoClad"
+CONF_MERGE_INTO_FUEL = "mergeIntoFuel"
 CONF_MESH_PER_CM = "meshSubdivisionsPerCm"
 CONF_REACTION_DRIVER = "nuclideReactionDriver"
 CONF_XSID = "xsID"
@@ -57,6 +58,8 @@ CONF_XS_PRIORITY = "xsPriority"
 CONF_COMPONENT_AVERAGING = "averageByComponent"
 CONF_XS_MAX_ATOM_NUMBER = "xsMaxAtomNumber"
 CONF_MIN_DRIVER_DENSITY = "minDriverDensity"
+CONF_DUCT_HETEROGENEOUS = "ductHeterogeneous"
+CONF_TRACE_ISOTOPE_THRESHOLD = "traceIsotopeThreshold"
 
 
 class XSGeometryTypes(Enum):
@@ -136,6 +139,7 @@ _VALID_INPUTS_BY_GEOMETRY_TYPE = {
         CONF_XSID,
         CONF_GEOM,
         CONF_MERGE_INTO_CLAD,
+        CONF_MERGE_INTO_FUEL,
         CONF_DRIVER,
         CONF_HOMOGBLOCK,
         CONF_INTERNAL_RINGS,
@@ -149,6 +153,8 @@ _VALID_INPUTS_BY_GEOMETRY_TYPE = {
         CONF_XS_PRIORITY,
         CONF_XS_MAX_ATOM_NUMBER,
         CONF_MIN_DRIVER_DENSITY,
+        CONF_DUCT_HETEROGENEOUS,
+        CONF_TRACE_ISOTOPE_THRESHOLD,
     },
     XSGeometryTypes.getStr(XSGeometryTypes.TWO_DIMENSIONAL_HEX): {
         CONF_XSID,
@@ -186,6 +192,7 @@ _SINGLE_XS_SCHEMA = vol.Schema(
         vol.Optional(CONF_INTERNAL_RINGS): vol.Coerce(int),
         vol.Optional(CONF_EXTERNAL_RINGS): vol.Coerce(int),
         vol.Optional(CONF_MERGE_INTO_CLAD): [str],
+        vol.Optional(CONF_MERGE_INTO_FUEL): [str],
         vol.Optional(CONF_XS_FILE_LOCATION): [str],
         vol.Optional(CONF_EXTERNAL_FLUX_FILE_LOCATION): str,
         vol.Optional(CONF_MESH_PER_CM): vol.Coerce(float),
@@ -194,6 +201,8 @@ _SINGLE_XS_SCHEMA = vol.Schema(
         vol.Optional(CONF_XS_MAX_ATOM_NUMBER): vol.Coerce(int),
         vol.Optional(CONF_MIN_DRIVER_DENSITY): vol.Coerce(float),
         vol.Optional(CONF_COMPONENT_AVERAGING): bool,
+        vol.Optional(CONF_DUCT_HETEROGENEOUS): bool,
+        vol.Optional(CONF_TRACE_ISOTOPE_THRESHOLD): vol.Coerce(float),
     }
 )
 
@@ -410,6 +419,12 @@ class XSModelingOptions:
         and is sometimes used to merge a "gap" or low-density region into
         a "clad" region to avoid numerical issues.
 
+    mergeIntoFuel : list of str
+        This is a lattice physics configuration option that is a list of component
+        names to merge into a "fuel" component. This is highly-design specific
+        and is sometimes used to merge a "gap" or low-density region into
+        a "fuel" region to avoid numerical issues.
+
     meshSubdivisionsPerCm : float
         This is a lattice physics configuration option that can be used to control
         subregion meshing of the representative block in 1D problems.
@@ -419,7 +434,7 @@ class XSModelingOptions:
         no others will allocate to it. This is useful for time balancing when you
         have one task that takes much longer than the others.
 
-    xsPriority:
+    xsPriority: int
         The priority of the mpi tasks that results from this xsID. Lower priority
         will execute first. starting longer jobs first is generally more efficient.
 
@@ -429,10 +444,31 @@ class XSModelingOptions:
         (e.g., fission products) as a depletion product of an isotope with a much
         smaller atomic number.
 
+    averageByComponent: bool
+        Controls whether the representative block averaging is performed on a
+        component-by-component basis or on the block as a whole. If True, the
+        resulting representative block will have component compositions that
+        largely reflect those of the underlying blocks in the collection. If
+        False, the number densities of some nuclides in the individual
+        components may not be reflective of those of the underlying components
+        due to the block number density "dehomogenization".
+
     minDriverDensity: float
         The minimum number density for nuclides included in driver material for a 1D
         lattice physics model.
 
+    ductHeterogeneous : bool
+        This is a lattice physics configuration option used to enable a partially
+        heterogeneous approximation for a 1D cylindrical model. Everything inside of the
+        duct will be treated as homogeneous.
+
+    traceIsotopeThreshold : float
+        This is a lattice physics configuration option used to enable a separate 0D fuel
+        cross section calculation for trace fission products when using a 1D cross section
+        model. This can significantly reduce the memory and run time required for the 1D
+        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.
+
     Notes
     -----
     Not all default attributes may be useful for your specific application and you may
@@ -458,12 +494,15 @@ class XSModelingOptions:
         numInternalRings=None,
         numExternalRings=None,
         mergeIntoClad=None,
+        mergeIntoFuel=None,
         meshSubdivisionsPerCm=None,
         xsExecuteExclusive=None,
         xsPriority=None,
         xsMaxAtomNumber=None,
         averageByComponent=False,
         minDriverDensity=0.0,
+        ductHeterogeneous=False,
+        traceIsotopeThreshold=0.0,
     ):
         self.xsID = xsID
         self.geometry = geometry
@@ -482,10 +521,13 @@ class XSModelingOptions:
         self.numInternalRings = numInternalRings
         self.numExternalRings = numExternalRings
         self.mergeIntoClad = mergeIntoClad
+        self.mergeIntoFuel = mergeIntoFuel
         self.meshSubdivisionsPerCm = meshSubdivisionsPerCm
         self.xsMaxAtomNumber = xsMaxAtomNumber
         self.minDriverDensity = minDriverDensity
         self.averageByComponent = averageByComponent
+        self.ductHeterogeneous = ductHeterogeneous
+        self.traceIsotopeThreshold = traceIsotopeThreshold
         # these are related to execution
         self.xsExecuteExclusive = xsExecuteExclusive
         self.xsPriority = xsPriority
@@ -668,12 +710,15 @@ class XSModelingOptions:
                 CONF_GEOM: self.geometry,
                 CONF_DRIVER: "",
                 CONF_MERGE_INTO_CLAD: ["gap"],
+                CONF_MERGE_INTO_FUEL: [],
                 CONF_MESH_PER_CM: 1.0,
                 CONF_INTERNAL_RINGS: 0,
                 CONF_EXTERNAL_RINGS: 1,
                 CONF_HOMOGBLOCK: False,
                 CONF_BLOCK_REPRESENTATION: crossSectionGroupManager.CYLINDRICAL_COMPONENTS_BLOCK_COLLECTION,
                 CONF_BLOCKTYPES: validBlockTypes,
+                CONF_DUCT_HETEROGENEOUS: False,
+                CONF_TRACE_ISOTOPE_THRESHOLD: 0.0,
             }
         elif self.geometry == XSGeometryTypes.getStr(
             XSGeometryTypes.TWO_DIMENSIONAL_HEX
diff --git a/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py b/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py
index 85c2fe3e..68da4be9 100644
--- a/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py
+++ b/armi/physics/neutronics/latticePhysics/latticePhysicsWriter.py
@@ -110,9 +110,12 @@ class LatticePhysicsWriter(interfaces.InputWriter):
         self.xsId = representativeBlock.getMicroSuffix()
         self.xsSettings = self.cs[CONF_CROSS_SECTION][self.xsId]
         self.mergeIntoClad = self.xsSettings.mergeIntoClad
+        self.mergeIntoFuel = self.xsSettings.mergeIntoFuel
         self.driverXsID = self.xsSettings.driverID
         self.numExternalRings = self.xsSettings.numExternalRings
         self.criticalBucklingSearchActive = self.xsSettings.criticalBuckling
+        self.ductHeterogeneous = self.xsSettings.ductHeterogeneous
+        self.traceIsotopeThreshold = self.xsSettings.traceIsotopeThreshold
 
         self.executeExclusive = self.xsSettings.xsExecuteExclusive
         self.priority = self.xsSettings.xsPriority
@@ -266,8 +269,12 @@ class LatticePhysicsWriter(interfaces.InputWriter):
                 continue  # skip LFPs here but add individual FPs below.
 
             if isinstance(subjectObject, components.Component):
-                # Heterogeneous number densities and temperatures
-                nucTemperatureInC = subjectObject.temperatureInC
+                if self.ductHeterogeneous and "Homogenized" in subjectObject.name:
+                    # Nuclide temperatures representing heterogeneous model component temperatures
+                    nucTemperatureInC = self._getAvgNuclideTemperatureInC(nucName)
+                else:
+                    # Heterogeneous number densities and temperatures
+                    nucTemperatureInC = subjectObject.temperatureInC
             else:
                 # Homogeneous number densities and temperatures
                 nucTemperatureInC = self._getAvgNuclideTemperatureInC(nucName)
diff --git a/armi/reactor/converters/blockConverters.py b/armi/reactor/converters/blockConverters.py
index fc978715..f79576fc 100644
--- a/armi/reactor/converters/blockConverters.py
+++ b/armi/reactor/converters/blockConverters.py
@@ -508,6 +508,11 @@ class HexComponentsToCylConverter(BlockAvgToCylConverter):
     duct/intercoolant pinComponentsRing1 | coolant | pinComponentsRing2 | coolant | ... |
     nonpins ...
 
+    The ``ductHeterogeneous`` option allows the user to treat everything inside the duct
+    as a single homogenized composition. This could significantly reduce the memory and runtime
+    required for the lattice physics solver, and also provide an alternative approximation for
+    the spatial self-shielding effect on microscopic cross sections.
+
     This converter expects the ``sourceBlock`` and ``driverFuelBlock`` to defined and for
     the ``sourceBlock`` to have a spatial grid defined. Additionally, both the ``sourceBlock``
     and ``driverFuelBlock`` must be instances of HexBlocks.
@@ -519,6 +524,8 @@ class HexComponentsToCylConverter(BlockAvgToCylConverter):
         driverFuelBlock=None,
         numExternalRings=None,
         mergeIntoClad=None,
+        mergeIntoFuel=None,
+        ductHeterogeneous=False,
     ):
         BlockAvgToCylConverter.__init__(
             self,
@@ -548,6 +555,8 @@ class HexComponentsToCylConverter(BlockAvgToCylConverter):
                 )
         self.pinPitch = sourceBlock.getPinPitch()
         self.mergeIntoClad = mergeIntoClad or []
+        self.mergeIntoFuel = mergeIntoFuel or []
+        self.ductHeterogeneous = ductHeterogeneous
         self.interRingComponent = sourceBlock.getComponent(Flags.COOLANT, exact=True)
         self._remainingCoolantFillArea = self.interRingComponent.getArea()
         if not self.interRingComponent:
@@ -583,9 +592,12 @@ class HexComponentsToCylConverter(BlockAvgToCylConverter):
             self._sourceBlock.getNumPins()
         )
         pinComponents, nonPins = self._classifyComponents()
-        self._buildFirstRing(pinComponents)
-        for ring in range(2, numRings + 1):
-            self._buildNthRing(pinComponents, ring)
+        if self.ductHeterogeneous:
+            self._buildInsideDuct()
+        else:
+            self._buildFirstRing(pinComponents)
+            for ring in range(2, numRings + 1):
+                self._buildNthRing(pinComponents, ring)
         self._buildNonPinRings(nonPins)
         self._addDriverFuelRings()
 
@@ -606,10 +618,14 @@ class HexComponentsToCylConverter(BlockAvgToCylConverter):
         )
         self._remainingCoolantFillArea = self.interRingComponent.getArea()
 
-        # do user-input merges
+        # do user-input merges into cladding
         for componentName in self.mergeIntoClad:
             self.dissolveComponentIntoComponent(componentName, "clad")
 
+        # do user-input merges into fuel
+        for componentName in self.mergeIntoFuel:
+            self.dissolveComponentIntoComponent(componentName, "fuel")
+
     def _classifyComponents(self):
         """
         Figure out which components are in each pin ring and which are not.
@@ -645,6 +661,25 @@ class HexComponentsToCylConverter(BlockAvgToCylConverter):
 
         return list(sorted(pinComponents)), nonPins
 
+    def _buildInsideDuct(self):
+        """Build a homogenized material of the components inside the duct."""
+        blockType = self._sourceBlock.getType()
+        blockName = f"Homogenized {blockType}"
+        newBlock, mixtureFlags = stripComponents(self._sourceBlock, Flags.DUCT)
+        outerDiam = getOuterDiamFromIDAndArea(0.0, newBlock.getArea())
+        circle = components.Circle(
+            blockName,
+            "_Mixture",
+            newBlock.getAverageTempInC(),
+            newBlock.getAverageTempInC(),
+            id=0.0,
+            od=outerDiam,
+            mult=1,
+        )
+        circle.setNumberDensities(newBlock.getNumberDensities())
+        circle.p.flags = mixtureFlags
+        self.convertedBlock.add(circle)
+
     def _buildFirstRing(self, pinComponents):
         """Add first ring of components to new block."""
         for oldC in pinComponents:
@@ -693,14 +728,17 @@ class HexComponentsToCylConverter(BlockAvgToCylConverter):
         Also needs to add final coolant layer between the outer pins and the non-pins.
         Will crash if there are things that are not circles or hexes.
         """
-        # fill in the last ring of coolant using the rest
-        coolInnerDiam = self.convertedBlock[-1].getDimension("od")
-        coolantOD = getOuterDiamFromIDAndArea(
-            coolInnerDiam, self._remainingCoolantFillArea
-        )
-        self._addCoolantRing(coolantOD, " outer")
+        if not self.ductHeterogeneous:
+            # fill in the last ring of coolant using the rest
+            coolInnerDiam = self.convertedBlock[-1].getDimension("od")
+            coolantOD = getOuterDiamFromIDAndArea(
+                coolInnerDiam, self._remainingCoolantFillArea
+            )
+            self._addCoolantRing(coolantOD, " outer")
+            innerDiameter = coolantOD
+        else:
+            innerDiameter = self.convertedBlock[-1].getDimension("od")
 
-        innerDiameter = coolantOD
         for i, hexagon in enumerate(sorted(nonPins)):
             outerDiam = getOuterDiamFromIDAndArea(
                 innerDiameter, hexagon.getArea()
@@ -824,3 +862,63 @@ def radiiFromRingOfRods(distToRodCenter, numRods, rodRadii, layout="hexagon"):
         rLast, bigRLast = rodRadius, distFromCenterComp
 
     return sorted(radiiFromRodCenter)
+
+
+def stripComponents(block, compFlags):
+    """
+    Remove all components from a block outside of the first component that matches compFlags.
+
+    Parameters
+    ----------
+    block : armi.reactor.blocks.Block
+        Source block from which to produce a modified copy
+    compFlags : armi.reactor.flags.Flags
+        Component flags to indicate which components to strip from the
+        block. All components outside of the first one that matches
+        compFlags are stripped.
+
+    Returns
+    -------
+    newBlock : armi.reactor.blocks.Block
+        Copy of source block with specified components stripped off.
+    mixtureFlags : TypeSpec
+        Combination of all component flags within newBlock.
+
+    Notes
+    -----
+    This is often used for creating a partially heterogeneous representation
+    of a block. For example, one might want to treat everything inside of a
+    specific component (such as the duct) as homogenized, while keeping a
+    heterogeneous representation of the remaining components.
+    """
+    newBlock = copy.deepcopy(block)
+    avgBlockTemp = block.getAverageTempInC()
+    mixtureFlags = newBlock.getComponent(Flags.COOLANT).p.flags
+    innerMostComp = next(
+        i
+        for i, c in enumerate(sorted(newBlock.getComponents()))
+        if c.hasFlags(compFlags)
+    )
+    outsideComp = True
+    indexedComponents = [(i, c) for i, c in enumerate(sorted(newBlock.getComponents()))]
+    for i, c in sorted(indexedComponents, reverse=True):
+        if outsideComp:
+            if i == innerMostComp:
+                compIP = c.getDimension("ip")
+                outsideComp = False
+            newBlock.remove(c, recomputeAreaFractions=False)
+        else:
+            mixtureFlags = mixtureFlags | c.p.flags
+
+    # add pitch defining component with no area
+    newBlock.add(
+        components.Hexagon(
+            "pitchComponent",
+            "Void",
+            avgBlockTemp,
+            avgBlockTemp,
+            ip=compIP,
+            op=compIP,
+        )
+    )
+    return newBlock, mixtureFlags
diff --git a/doc/gallery-src/analysis/run_hexBlockToRZConversion.py b/doc/gallery-src/analysis/run_hexBlockToRZConversion.py
index 78b7cebe..2f06455a 100644
--- a/doc/gallery-src/analysis/run_hexBlockToRZConversion.py
+++ b/doc/gallery-src/analysis/run_hexBlockToRZConversion.py
@@ -50,6 +50,7 @@ configure(permissive=True)
 
 _o, r = test_reactors.loadTestReactor()
 
+# fully heterogeneous
 bFuel = r.core.getBlocks(Flags.FUEL)[0]
 bControl = r.core.getBlocks(Flags.CONTROL)[0]
 converter = blockConverters.HexComponentsToCylConverter(
@@ -57,3 +58,10 @@ converter = blockConverters.HexComponentsToCylConverter(
 )
 converter.convert()
 converter.plotConvertedBlock()
+
+# partially heterogeneous
+converter = blockConverters.HexComponentsToCylConverter(
+    sourceBlock=bFuel, ductHeterogeneous=True
+)
+converter.convert()
+converter.plotConvertedBlock()
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 1269218d..84bd6f38 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -25,6 +25,7 @@ New Features
 #. Provide ``Block.getInputHeight`` for determining the height of a block from blueprints. (`PR#1927 <https://github.com/terrapower/armi/pull/1927>`_)
 #. Improve performance by changing the lattice physics interface so that cross sections are not updated on ``everyNode`` calls during coupled calculations (`PR#1963 <https://github.com/terrapower/armi/pull/1963>`_)
 #. Improve efficiency of reaction rate calculations. (`PR#1887 <https://github.com/terrapower/armi/pull/1887>`_)
+#. Adding new options for simplifying 1D cross section modeling. (`PR#1949 <https://github.com/terrapower/armi/pull/1949>`_)
 #. TBD
 
 API Changes


Test patch:
diff --git a/armi/nuclearDataIO/tests/test_xsLibraries.py b/armi/nuclearDataIO/tests/test_xsLibraries.py
index 324206e9..ae4b5522 100644
--- a/armi/nuclearDataIO/tests/test_xsLibraries.py
+++ b/armi/nuclearDataIO/tests/test_xsLibraries.py
@@ -127,7 +127,7 @@ class TestXSLibrary(TempFileMixin, unittest.TestCase):
             os.remove(dummyFileName)
 
         with TemporaryDirectoryChanger():
-            dummyFileName = "ISOtopics.txt"
+            dummyFileName = "ISO[]"
             with open(dummyFileName, "w") as file:
                 file.write(
                     "This is a file that starts with the letters 'ISO' but will"
diff --git a/armi/physics/neutronics/tests/test_crossSectionSettings.py b/armi/physics/neutronics/tests/test_crossSectionSettings.py
index 631af79c..2106ba9b 100644
--- a/armi/physics/neutronics/tests/test_crossSectionSettings.py
+++ b/armi/physics/neutronics/tests/test_crossSectionSettings.py
@@ -118,6 +118,8 @@ class TestCrossSectionSettings(unittest.TestCase):
         self.assertNotIn("YA", xsModel)
         self.assertEqual(xsModel["YA"].geometry, "0D")
         self.assertEqual(xsModel["YA"].criticalBuckling, True)
+        self.assertEqual(xsModel["YA"].ductHeterogeneous, False)
+        self.assertEqual(xsModel["YA"].traceIsotopeThreshold, 0.0)
 
     def test_setDefaultSettingsByLowestBuGroupHomogeneous(self):
         # Initialize some micro suffix in the cross sections
@@ -177,7 +179,13 @@ class TestCrossSectionSettings(unittest.TestCase):
         """Test that optional key shows up with default value."""
         cs = settings.Settings()
         xsModel = XSSettings()
-        da = XSModelingOptions("DA", geometry="1D cylinder", meshSubdivisionsPerCm=1.0)
+        da = XSModelingOptions(
+            "DA",
+            geometry="1D cylinder",
+            meshSubdivisionsPerCm=1.0,
+            ductHeterogeneous=True,
+            traceIsotopeThreshold=1.0e-5,
+        )
         xsModel["DA"] = da
         xsModel.setDefaults(
             cs[CONF_XS_BLOCK_REPRESENTATION],
@@ -185,6 +193,9 @@ class TestCrossSectionSettings(unittest.TestCase):
         )
         self.assertEqual(xsModel["DA"].mergeIntoClad, ["gap"])
         self.assertEqual(xsModel["DA"].meshSubdivisionsPerCm, 1.0)
+        self.assertEqual(xsModel["DA"].ductHeterogeneous, True)
+        self.assertEqual(xsModel["DA"].traceIsotopeThreshold, 1.0e-5)
+        self.assertEqual(xsModel["DA"].mergeIntoFuel, [])
 
     def test_badCrossSections(self):
         with self.assertRaises(TypeError):