Repository: terrapower/armi
Pull request #1745 (2024-06-21 19:34:32)

Custom densities
## What is the change?

The ability to specify custom densities for non-custom materials is added in this PR.

## Why is the change being made?

Previously, only materials designated "Custom" in the blueprints file were allowed to have custom densities. This change allows perturbation of the component density as the component is generated. This is specifically done to prevent modification of the base material properties, which could have unintended downstream effects.

This PR attempts to resolve https://github.com/terrapower/armi/issues/1272. The use case is for adjustment of library materials to account for changes in density during fabrication (e.g. porosity) without having to use workarounds like smear density adjustments.

---

## 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`.

keckler: At what temperature is the entered density supposed to correspond to?
keckler: This PR should probably update this section of the docs: 
https://terrapower.github.io/armi/user/inputs.html#custom-isotopics
bsculac: > At what temperature is the entered density supposed to correspond to?

The density is set at component construction so it should be the cold dimension unless the blueprints was specified to be at hot dimensions.
keckler: > > At what temperature is the entered density supposed to correspond to?
> 
> The density is set at component construction so it should be the cold dimension unless the blueprint was specified to be at hot dimensions.

I suppose I am asking for this to be outlined somewhere, for instance in the blueprints documentation.
john-science: I would love a unit test that actually tests the new feature you are building here. Show me the _goal_ in your unit test, and show me the edge cases.

And example edge case might be proving that this PR fixes the bug in #1272.

As always, if that's too much of a lift I can help.

Patch:
diff --git a/armi/physics/neutronics/crossSectionGroupManager.py b/armi/physics/neutronics/crossSectionGroupManager.py
index c0cfb808..2875f132 100644
--- a/armi/physics/neutronics/crossSectionGroupManager.py
+++ b/armi/physics/neutronics/crossSectionGroupManager.py
@@ -1235,12 +1235,12 @@ class CrossSectionGroupManager(interfaces.Interface):
 
             # create a new block collection that inherits all of the properties
             # and settings from oldBlockCollection.
-            if len(oldBlockCollection._validRepresentativeBlockTypes) > 0:
-                validBlockTypes = []
-                for flag in oldBlockCollection._validRepresentativeBlockTypes:
-                    validBlockTypes.append(flags._toString(Flags, flag))
-            else:
-                validBlockTypes = None
+            validBlockTypes = oldBlockCollection._validRepresentativeBlockTypes
+            if validBlockTypes is not None and len(validBlockTypes) > 0:
+                validBlockTypes = [
+                    flags._toString(Flags, flag)
+                    for flag in oldBlockCollection._validRepresentativeBlockTypes
+                ]
             newBlockCollection = oldBlockCollection.__class__(
                 oldBlockCollection.allNuclidesInProblem,
                 validBlockTypes=validBlockTypes,
diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py
index 83237d31..bfe4ea8e 100644
--- a/armi/reactor/assemblies.py
+++ b/armi/reactor/assemblies.py
@@ -40,15 +40,6 @@ class Assembly(composites.Composite):
     """
     A single assembly in a reactor made up of blocks built from the bottom up.
     Append blocks to add them up. Index blocks with 0 being the bottom.
-
-    Attributes
-    ----------
-    pinNum : int
-        The number of pins in this assembly.
-
-    pinPeakingFactors : list of floats
-        The assembly-averaged pin power peaking factors. This is the ratio of pin
-        power to AVERAGE pin power in an assembly.
     """
 
     pDefs = assemblyParameters.getAssemblyParameterDefinitions()
@@ -82,7 +73,6 @@ class Assembly(composites.Composite):
         self.setType(typ)
         self._current = 0  # for iterating
         self.p.buLimit = self.getMaxParam("buLimit")
-        self.pinPeakingFactors = []  # assembly-averaged pin power peaking factors
         self.lastLocationLabel = self.LOAD_QUEUE
 
     def __repr__(self):
diff --git a/armi/reactor/blueprints/componentBlueprint.py b/armi/reactor/blueprints/componentBlueprint.py
index 66caae6e..f6b85228 100644
--- a/armi/reactor/blueprints/componentBlueprint.py
+++ b/armi/reactor/blueprints/componentBlueprint.py
@@ -225,8 +225,66 @@ class ComponentBlueprint(yamlize.Object):
             constructedObject = components.factory(shape, [], kwargs)
             _setComponentFlags(constructedObject, self.flags, blueprint)
             insertDepletableNuclideKeys(constructedObject, blueprint)
+
+        # set the custom density for non-custom material components after construction
+        self.setCustomDensity(constructedObject, blueprint, matMods)
+
         return constructedObject
 
+    def setCustomDensity(self, constructedComponent, blueprint, matMods):
+        """Apply a custom density to a material with custom isotopics but not a 'custom material'."""
+        if self.isotopics is None:
+            # No custom isotopics specified
+            return
+
+        density = blueprint.customIsotopics[self.isotopics].density
+        if density is None:
+            # Nothing to do
+            return
+
+        if density <= 0:
+            runLog.error(
+                "A zero or negative density was specified in a custom isotopics input. "
+                "This is not permitted, if a 0 density material is needed, use 'Void'. "
+                "The component is {} and the isotopics entry is {}.".format(
+                    constructedComponent, self.isotopics
+                )
+            )
+            raise ValueError(
+                "A zero or negative density was specified in the custom isotopics for a component"
+            )
+
+        mat = materials.resolveMaterialClassByName(self.material)()
+        if not isinstance(mat, materials.Custom):
+            # check for some problem cases
+            if "TD_frac" in matMods.keys():
+                runLog.warning(
+                    "Both TD_frac and a custom density (custom isotopics) has been specified for "
+                    "material {}. The custom density will override the density calculated using "
+                    "TD_frac.".format(self.material)
+                )
+            if not mat.density(Tc=self.Tinput) > 0:
+                runLog.error(
+                    "A custom density has been assigned to material '{}', which has no baseline "
+                    "density. Only materials with a starting density may be assigned a density. "
+                    "This comes up e.g. if isotopics are assigned to 'Void'.".format(
+                        self.material
+                    )
+                )
+                raise ValueError(
+                    "Cannot apply custom densities to materials without density."
+                )
+
+            densityRatio = density / constructedComponent.density()
+            constructedComponent.changeNDensByFactor(densityRatio)
+
+            runLog.important(
+                "A custom material density was specified in the custom isotopics for non-custom "
+                "material {}. The component density has been altered to "
+                "{}.".format(mat, constructedComponent.density()),
+                single=True,
+            )
+
     def _conformKwargs(self, blueprint, matMods):
         """This method gets the relevant kwargs to construct the component."""
         kwargs = {"mergeWith": self.mergeWith or "", "isotopics": self.isotopics or ""}
diff --git a/armi/reactor/blueprints/isotopicOptions.py b/armi/reactor/blueprints/isotopicOptions.py
index 077db9f2..1ce62c52 100644
--- a/armi/reactor/blueprints/isotopicOptions.py
+++ b/armi/reactor/blueprints/isotopicOptions.py
@@ -393,15 +393,12 @@ class CustomIsotopic(yamlize.Map):
         material.massFrac = dict(self.massFracs)
         if self.density is not None:
             if not isinstance(material, materials.Custom):
-                runLog.warning(
-                    "You either specified a custom mass density or number densities "
-                    "(which implies a mass density) on `{}` with custom isotopics `{}`. "
-                    "This has no effect on this Material class; you can only "
-                    "override mass density on `Custom` "
-                    "materials. Consider switching to number fraction input. "
-                    "Continuing to use {} mass density.".format(
-                        material, self.name, material
-                    )
+                runLog.important(
+                    "A custom density or number densities has been specified for non-custom "
+                    "material {}. The material object's density will not be updated to prevent unintentional "
+                    "density changes across the model. Only custom materials may have a density "
+                    "specified.".format(material),
+                    single=True,
                 )
                 # specifically, non-Custom materials only use refDensity and dLL, mat.customDensity has no effect
                 return
diff --git a/doc/release/0.3.rst b/doc/release/0.3.rst
index dbff151f..f79e3240 100644
--- a/doc/release/0.3.rst
+++ b/doc/release/0.3.rst
@@ -13,6 +13,7 @@ New Features
 #. System information is now also logged on Linux. (`PR#1689 <https://github.com/terrapower/armi/pull/1689>`_)
 #. Reset ``Reactor`` data on worker processors after every interaction to free memory from state distribution.
    (`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>`_)
 #. TBD
 
 API Changes
@@ -55,7 +56,7 @@ Bug Fixes
 ---------
 #. Fixed four bugs with "corners up" hex grids. (`PR#1649 <https://github.com/terrapower/armi/pull/1649>`_)
 #. Fixed ``safeCopy`` to work on both Windows and Linux with strict permissions (`PR#1691 <https://github.com/terrapower/armi/pull/1691>`_)
-#. When creating a new XS group, inherit settings from initial group. (`PR#1653 <https://github.com/terrapower/armi/pull/1653>`_)
+#. When creating a new XS group, inherit settings from initial group. (`PR#1653 <https://github.com/terrapower/armi/pull/1653>`_, `PR#1751 <https://github.com/terrapower/armi/pull/1751>`_)
 
 #. TBD
 
diff --git a/doc/user/inputs.rst b/doc/user/inputs.rst
index 4d69b7cd..ea521654 100644
--- a/doc/user/inputs.rst
+++ b/doc/user/inputs.rst
@@ -1081,6 +1081,30 @@ nuclear data library).
 
 The (mass) ``density`` input is invalid when specifying ``number densities``; the code will present an error message.
 
+Material density may be specified in custom isotopics either explicitly in a ``mass fractions`` input 
+format (shown above) or implicitly with ``number densities``. This is fairly straightforward for the 
+``Custom`` material, as it has no baseline density. Density may also be specified for components using 
+materials which have entries in the materials library. Users should be aware of the following interactions 
+when specifying a custom density for components using a library material:
+
+    1. The library material density will not be changed. Only the component(s) with the custom isotopics 
+    entry will have the density modification.
+
+    2. Density specified by custom isotopics will override all other density modifications in the component 
+    construction phase (e.g. ``TD_frac`` entries).
+
+    3. Only the component density is changed, not other material properties are altered to account for the 
+    change in composition/density.
+
+    4. Density can only be specified using custom isotopics for non- ``Custom`` materials that have some 
+    initial density. Don't try to make ``Void`` have mass!
+
+Densities specified using ``Custom Isotopics`` are applied in component construction, and should be specified 
+at the input temperature for the component. Note that when overriding the density of a library material, all 
+other properties of that material (e.g. expansion coefficients) will continue to be used as if the component 
+consisted of the library material. In other words, ARMI will still think the component is made out of the 
+original material!
+
 Advanced topics
 ---------------
 


Test patch:
diff --git a/armi/physics/neutronics/tests/test_crossSectionManager.py b/armi/physics/neutronics/tests/test_crossSectionManager.py
index 9e857975..2437420a 100644
--- a/armi/physics/neutronics/tests/test_crossSectionManager.py
+++ b/armi/physics/neutronics/tests/test_crossSectionManager.py
@@ -816,16 +816,8 @@ class TestCrossSectionGroupManager(unittest.TestCase):
         self.assertIsNone(blocks[0].p.detailedNDens)
         self.assertIsNone(blocks[1].p.detailedNDens)
 
-    def test_createRepresentativeBlocksUsingExistingBlocks(self):
-        """
-        Demonstrates that a new representative block can be generated from an existing
-        representative block.
-
-        Notes
-        -----
-        This tests that the XS ID of the new representative block is correct and that the
-        compositions are identical between the original and the new representative blocks.
-        """
+    def _createRepresentativeBlocksUsingExistingBlocks(self, validBlockTypes):
+        """Reusable code used in multiple unit tests."""
         o, r = test_reactors.loadTestReactor(
             TEST_ROOT, inputFileName="smallestTestReactor/armiRunSmallest.yaml"
         )
@@ -843,7 +835,7 @@ class TestCrossSectionGroupManager(unittest.TestCase):
             }
         )
         o.cs[CONF_CROSS_SECTION].setDefaults(
-            crossSectionGroupManager.AVERAGE_BLOCK_COLLECTION, ["fuel"]
+            crossSectionGroupManager.AVERAGE_BLOCK_COLLECTION, validBlockTypes
         )
         aaSettings = o.cs[CONF_CROSS_SECTION]["AA"]
         self.csm.cs = copy.deepcopy(o.cs)
@@ -877,6 +869,30 @@ class TestCrossSectionGroupManager(unittest.TestCase):
                 continue
             self.assertEqual(baSettingValue, aaSettings.__dict__[setting])
 
+    def test_createRepresentativeBlocksUsingExistingBlocks(self):
+        """
+        Demonstrates that a new representative block can be generated from an existing
+        representative block.
+
+        Notes
+        -----
+        This tests that the XS ID of the new representative block is correct and that the
+        compositions are identical between the original and the new representative blocks.
+        """
+        self._createRepresentativeBlocksUsingExistingBlocks(["fuel"])
+
+    def test_createRepresentativeBlocksUsingExistingBlocksDisableValidBlockTypes(self):
+        """
+        Demonstrates that a new representative block can be generated from an existing
+        representative block with the setting `disableBlockTypeExclusionInXsGeneration: true`.
+
+        Notes
+        -----
+        This tests that the XS ID of the new representative block is correct and that the
+        compositions are identical between the original and the new representative blocks.
+        """
+        self._createRepresentativeBlocksUsingExistingBlocks(True)
+
     def test_interactBOL(self):
         """Test `BOL` lattice physics update frequency.
 
diff --git a/armi/reactor/blueprints/tests/test_customIsotopics.py b/armi/reactor/blueprints/tests/test_customIsotopics.py
index 3366a3c3..4c341b45 100644
--- a/armi/reactor/blueprints/tests/test_customIsotopics.py
+++ b/armi/reactor/blueprints/tests/test_customIsotopics.py
@@ -14,6 +14,7 @@
 
 """Unit test custom isotopics."""
 import unittest
+from logging import DEBUG
 
 import yamlize
 
@@ -22,10 +23,12 @@ from armi.physics.neutronics.settings import CONF_XS_KERNEL
 from armi.reactor import blueprints
 from armi.reactor.blueprints import isotopicOptions
 from armi.reactor.flags import Flags
+from armi import runLog
+from armi.tests import mockRunLogs
 
 
 class TestCustomIsotopics(unittest.TestCase):
-    yamlString = r"""
+    yamlPreamble = r"""
 nuclide flags:
     U238: {burn: true, xs: true}
     U235: {burn: true, xs: true}
@@ -75,15 +78,6 @@ custom isotopics:
         U234: 0.000054
         density: 19.1
 
-    # >>> from armi.nucDirectory import elements, nuclideBases
-    # >>> import numpy
-    # >>> u = elements.bySymbol['U']
-    # >>> w_i = numpy.array([n.abundance for n in u.getNaturalIsotopics()])
-    # >>> Mi = numpy.array([n.weight for n in u.getNaturalIsotopics()])
-    # >>> Ni = w_i * 19.1 * 6.0221e23 / Mi
-    # >>> N_norm = Ni / sum(Ni)
-    # >>> N_norm.round(6)
-    # array([  5.50000000e-05,   7.29500000e-03,   9.92650000e-01])
     uranium isotopic number fractions:
         input format: number fractions
         U238: 0.992650
@@ -91,21 +85,26 @@ custom isotopics:
         U234: 0.000055
         density: 19.1
 
-    # >>> from armi.nucDirectory import elements, nuclideBases
-    # >>> import numpy
-    # >>> u = elements.bySymbol['U']
-    # >>> Mi = numpy.array([n.weight for n in u.getNaturalIsotopics()])
-    # >>> w_i = numpy.array([n.abundance for n in u.getNaturalIsotopics()])
-    # >>> Ni = 19.1 * w_i * 6.0221e23 / Mi
-    # array([  2.65398007e+18,   3.52549755e+20,   4.79692055e+22])
-    # >>> for n, ni in zip(u.getNaturalIsotopics(), Ni):
-    # >>>     print '        {}: {:.7e}'.format(n.name, ni) # requires 7 decimal places!
     uranium isotopic number densities: &u_isotopics
         input format: number densities
         U234: 2.6539102e-06
         U235: 3.5254048e-04
         U238: 4.7967943e-02
 
+    bad uranium isotopic mass fractions:
+        input format: mass fractions
+        U238: 0.992742
+        U235: 0.007204
+        U234: 0.000054
+        density: 0
+
+    negative uranium isotopic mass fractions:
+        input format: mass fractions
+        U238: 0.992742
+        U235: 0.007204
+        U234: 0.000054
+        density: -1
+
     linked uranium number densities: *u_isotopics
 
     steel:
@@ -114,6 +113,9 @@ custom isotopics:
         C: 0.3
         density: 7.0
 
+"""
+
+    yamlGoodBlocks = r"""
 blocks:
     uzr fuel: &block_0
         fuel: &basic_fuel
@@ -163,7 +165,72 @@ blocks:
             material: Custom
             isotopics: linked uranium number densities
 
-    steel: &block_6
+    fuel with no modifications: &block_6  # after a custom density has been set
+        fuel:
+            <<: *basic_fuel
+
+    overspecified fuel: &block_7
+        fuel:
+            <<: *basic_fuel
+            material: UraniumOxide
+            isotopics: uranium isotopic number densities
+
+    density set via number density: &block_8
+        fuel:
+            <<: *basic_fuel
+            isotopics: uranium isotopic number densities
+
+    steel: &block_9
+        clad:
+            shape: Hexagon
+            material: Custom
+            isotopics: steel
+            Tinput: 25.0
+            Thot: 600.0
+            ip: 0.0
+            mult: 169.0
+            op: 0.86602
+
+assemblies:
+    fuel a: &assembly_a
+        specifier: IC
+        blocks: [*block_0, *block_1, *block_2, *block_3, *block_4, *block_5, *block_6, *block_7, *block_8, *block_9]
+        height: [10, 10, 10, 10, 10, 10, 10, 10, 10, 10]
+        axial mesh points: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1]
+        xs types: [A, A, A, A, A, A, A, A, A, A]
+        material modifications:
+            TD_frac: ["", "", "", "", "", "", "", 0.1, "", ""]
+
+"""
+
+    yamlBadBlocks = r"""
+blocks:
+    uzr fuel: &block_0
+        fuel: &basic_fuel
+            shape: Hexagon
+            material: UZr
+            Tinput: 25.0
+            Thot: 600.0
+            ip: 0.0
+            mult: 1.0
+            op: 10.0
+
+        clad:
+            shape: Circle
+            material: HT9
+            Tinput: 25.0
+            Thot: 600.0
+            id: 0.0
+            mult: 1.0
+            od: 10.0
+
+    custom void: &block_1
+        fuel:
+            <<: *basic_fuel
+            material: Void
+            isotopics: uranium isotopic number densities
+
+    steel: &block_2
         clad:
             shape: Hexagon
             material: Custom
@@ -174,16 +241,65 @@ blocks:
             mult: 169.0
             op: 0.86602
 
+    no density uo2: &block_3
+        fuel:
+            <<: *basic_fuel
+            material: UraniumOxide
+            isotopics: uranium isotopic number densities
+
+    no density uo2: &block_4
+        fuel:
+            <<: *basic_fuel
+            material: UraniumOxide
+            isotopics: bad uranium isotopic mass fractions
+    
+    no density uo2: &block_5
+        fuel:
+            <<: *basic_fuel
+            material: UraniumOxide
+            isotopics: bad uranium isotopic mass fractions
 
 
 assemblies:
     fuel a: &assembly_a
         specifier: IC
-        blocks: [*block_0, *block_1, *block_2, *block_3, *block_4, *block_5, *block_6]
-        height: [10, 10, 10, 10, 10, 10,10]
-        axial mesh points: [1, 1, 1, 1, 1, 1,1]
-        xs types: [A, A, A, A, A, A,A]
+        blocks: [*block_0, *block_1, *block_2]
+        height: [10, 10, 10]
+        axial mesh points: [1, 1, 1]
+        xs types: [A, A, A]
+        material modifications:
+            TD_frac: ["", "", ""]
+    
+    fuel b: &assembly_b
+        specifier: IC
+        blocks: [*block_0, *block_3, *block_2]
+        height: [10, 10, 10]
+        axial mesh points: [1, 1, 1]
+        xs types: [A, A, A]
+        material modifications:
+            TD_frac: ["", "0.0", ""]  # set density to 0 to cause error in custom density
+
+    fuel c: &assembly_c
+        specifier: IC
+        blocks: [*block_0, *block_4, *block_2]
+        height: [10, 10, 10]
+        axial mesh points: [1, 1, 1]
+        xs types: [A, A, A]
+
+    fuel d: &assembly_d
+        specifier: IC
+        blocks: [*block_0, *block_5, *block_2]
+        height: [10, 10, 10]
+        axial mesh points: [1, 1, 1]
+        xs types: [A, A, A]
+
 """
+
+    # this yaml is supposed to successfully build
+    yamlString = yamlPreamble + yamlGoodBlocks
+
+    # This yaml is designed to raise an error when built
+    yamlStringWithError = yamlPreamble + yamlBadBlocks
     """:meta hide-value:"""
 
     @classmethod
@@ -216,18 +332,89 @@ assemblies:
             :id: T_ARMI_MAT_USER_INPUT3
             :tests: R_ARMI_MAT_USER_INPUT
         """
-        fuel0 = self.a[0].getComponent(Flags.FUEL)
         fuel1 = self.a[1].getComponent(Flags.FUEL)
         fuel2 = self.a[2].getComponent(Flags.FUEL)
         self.assertEqual(self.numCustomNuclides, len(fuel1.p.numberDensities))
         self.assertAlmostEqual(19.1, fuel1.density())
 
-        # density only works with a Custom material type.
-        self.assertAlmostEqual(fuel0.density(), fuel2.density())
         self.assertEqual(
             set(fuel2.p.numberDensities.keys()), set(fuel1.p.numberDensities.keys())
         )  # keys are same
 
+    def test_densitiesAppliedToNonCustomMaterials(self):
+        """Ensure that a density can be set in custom isotopics for components using library materials."""
+        # The template block
+        fuel0 = self.a[0].getComponent(Flags.FUEL)
+        # The block with custom density but not the 'Custom' material
+        fuel2 = self.a[2].getComponent(Flags.FUEL)
+        # A block like the template block, but made after the custom block
+        fuel6 = self.a[6].getComponent(Flags.FUEL)
+        # A block with custom density set via number density
+        fuel8 = self.a[8].getComponent(Flags.FUEL)
+
+        # Check that the density is set correctly on the custom density block,
+        # and that it is not the same as the original
+        self.assertAlmostEqual(19.1, fuel2.density())
+        self.assertNotAlmostEqual(fuel0.density(), fuel2.density(), places=2)
+        # Check that the custom density block has the correct material
+        self.assertEqual("UZr", fuel2.material.name)
+        # Check that the block with only number densities set has a new density
+        self.assertAlmostEqual(19.1, fuel8.density())
+        # original material density should not be changed after setting a custom density component,
+        # so a new block without custom isotopics and density should have the same density as the original
+        self.assertAlmostEqual(fuel6.density(), fuel0.density())
+        self.assertEqual(fuel6.material.name, fuel0.material.name)
+        self.assertEqual("UZr", fuel0.material.name)
+
+    def test_customDensityLogsAndErrors(self):
+        """Test that the right warning messages and errors are emitted when applying custom densities."""
+        # Check for warnings when specifying both TD_frac and custom isotopics
+        with mockRunLogs.BufferLog() as mockLog:
+            # we should start with a clean slate
+            self.assertEqual("", mockLog.getStdout())
+            runLog.LOG.startLog("test_customDensityLogsAndErrors")
+            runLog.LOG.setVerbosity(DEBUG)
+
+            # rebuild the input to capture the logs
+            cs = settings.Settings()
+            cs = cs.modified(newSettings={CONF_XS_KERNEL: "MC2v2"})
+            bp = blueprints.Blueprints.load(self.yamlString)
+            bp.constructAssem(cs, name="fuel a")
+
+            # Check for log messages
+            streamVal = mockLog.getStdout()
+            self.assertIn("Both TD_frac and a custom density", streamVal, msg=streamVal)
+            self.assertIn(
+                "A custom material density was specified", streamVal, msg=streamVal
+            )
+            self.assertIn(
+                "A custom density or number densities has been specified",
+                streamVal,
+                msg=streamVal,
+            )
+
+        # Check that assigning a custom density to the Void material fails
+        cs = settings.Settings()
+        cs = cs.modified(newSettings={CONF_XS_KERNEL: "MC2v2"})
+        bp = blueprints.Blueprints.load(self.yamlStringWithError)
+        # Ensure we have some Void
+        self.assertEqual(bp.blockDesigns["custom void"]["fuel"].material, "Void")
+        # Can't have stuff in Void
+        with self.assertRaises(ValueError):
+            bp.constructAssem(cs, name="fuel a")
+
+        # Try making a 0 density non-Void material by setting TD_frac to 0.0
+        with self.assertRaises(ValueError):
+            bp.constructAssem(cs, name="fuel b")
+
+        # Try making a material with mass fractions with a density of 0
+        with self.assertRaises(ValueError):
+            bp.constructAssem(cs, name="fuel c")
+
+        # Try making a material with mass fractions with a negative density
+        with self.assertRaises(ValueError):
+            bp.constructAssem(cs, name="fuel d")
+
     def test_numberFractions(self):
         """Ensure that the custom isotopics can be specified via number fractions.
 
diff --git a/armi/tests/test_lwrInputs.py b/armi/tests/test_lwrInputs.py
index 93c10a11..b2e1934d 100644
--- a/armi/tests/test_lwrInputs.py
+++ b/armi/tests/test_lwrInputs.py
@@ -61,8 +61,6 @@ class C5G7ReactorTests(unittest.TestCase):
 
             # test warnings are being logged for malformed isotopics info in the settings file
             streamVal = mock.getStdout()
-            self.assertGreater(streamVal.count("[warn]"), 32, msg=streamVal)
-            self.assertGreater(streamVal.count("custom isotopics"), 32, msg=streamVal)
             self.assertIn("UraniumOxide", streamVal, msg=streamVal)
             self.assertIn("SaturatedWater", streamVal, msg=streamVal)
             self.assertIn("invalid settings: fakeBad", streamVal, msg=streamVal)
diff --git a/armi/tests/tutorials/c5g7-blueprints.yaml b/armi/tests/tutorials/c5g7-blueprints.yaml
index 29d8a9b2..0a477071 100644
--- a/armi/tests/tutorials/c5g7-blueprints.yaml
+++ b/armi/tests/tutorials/c5g7-blueprints.yaml
@@ -162,7 +162,6 @@ blocks:
             # has same od as fuel, so there needs to be something in the gap.
             shape: Circle
             material: Void
-            isotopics: moderator
             Tinput: 25.0
             Thot: 25.0
             id: fission chamber.od