Repository: terrapower/armi
Pull request #2049 (2025-01-09 21:50:17)

Fix out of date assumption on AZS IDs for "natural nuclides"
## What is the change?
An error was found in a downstream ARMI application that I believe originates in the "byAAAZZZSId" nuclides listing within ARMI.

1. When ARMI is initialized, the full list of known isotopes to ARMI, as well as fictitious dummy and lumped fission product "nuclides", are added here.  https://github.com/terrapower/armi/blob/1920ff0ecb146a050c1c2f0a199cf620834728b2/armi/nucDirectory/nuclideBases.py#L1214-L1241
2. The real and "natural" nuclides are populated into `byMcnpId` and `byAAAZZZSId` here. https://github.com/terrapower/armi/blob/1920ff0ecb146a050c1c2f0a199cf620834728b2/armi/nucDirectory/nuclideBases.py#L1444-L1445
3. The real nuclides are populated correctly. However, the AZS IDs for natural nuclides are not populated correctly for Carbon and Vanadium as the assumptions made in here are invalid for ENDF VII.1 (per @aaronjamesreynolds). https://github.com/terrapower/armi/blob/1920ff0ecb146a050c1c2f0a199cf620834728b2/armi/nucDirectory/nuclideBases.py#L803-L817. 
4. This assumption leads to the following snippet returning the wrong thing:
```python 
import armi
from armi.nucDirectory.nuclideBases import byAAAZZZSId
if __name__ == "__main__":
    armi.configure()
    byAAAZZZSId("120060") # should return C12, however it returns "natural" C
    byAAAZZZDIs("510230") # should return V51, however it returns "natural" V
```


## Why is the change being made?
Being able to retrieve isotopic information for C12 and V51 is important. 

---

## 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 -->

- [ ] 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/nucDirectory/nuclideBases.py b/armi/nucDirectory/nuclideBases.py
index d9e1130f..2821e116 100644
--- a/armi/nucDirectory/nuclideBases.py
+++ b/armi/nucDirectory/nuclideBases.py
@@ -800,22 +800,6 @@ class NaturalNuclideBase(INuclide, IMcnpNuclide):
         """
         return "{0:d}000".format(self.z)
 
-    def getAAAZZZSId(self):
-        """Gets the AAAZZZS ID for a few elements.
-
-        Notes
-        -----
-        the natural nuclides 'C' and 'V' do not have isotopic nuclide data for MC2 so sometimes they tag along in the
-        list of active nuclides. This method is designed to fail in the same as if there was not getAAAZZZSId method
-        defined.
-        """
-        if self.element.symbol == "C":
-            return "120060"
-        elif self.element.symbol == "V":
-            return "510230"
-        else:
-            return None
-
     def getMcc2Id(self):
         """Return the MC2-2 nuclide identification label based on the ENDF/B-V.2 cross section library."""
         return self.mcc2id
@@ -1442,6 +1426,8 @@ def addGlobalNuclide(nuclide: NuclideBase):
                 f"{nuclide} with McnpId {nuclide.getMcnpId()} has already been added and cannot be duplicated."
             )
         byMcnpId[nuclide.getMcnpId()] = nuclide
+    if not isinstance(nuclide, (NaturalNuclideBase, LumpNuclideBase, DummyNuclideBase)):
+        # There are no AZS ID for elements / natural nuclides, or ficticious lump or dummy nuclides
         byAAAZZZSId[nuclide.getAAAZZZSId()] = nuclide
 
 
diff --git a/armi/reactor/blueprints/componentBlueprint.py b/armi/reactor/blueprints/componentBlueprint.py
index c871d8ca..838c5525 100644
--- a/armi/reactor/blueprints/componentBlueprint.py
+++ b/armi/reactor/blueprints/componentBlueprint.py
@@ -290,20 +290,29 @@ class ComponentBlueprint(yamlize.Object):
                     "Cannot apply custom densities to materials without density."
                 )
 
-            # Apply a density scaling to account for the temperature change between Tinput
-            # Thot. There may be a better place in the initialization to determine
-            # if the block height will be interpreted as hot dimensions, which would
-            # allow us to not have to pass the case settings down this far
-            dLL = comp.material.linearExpansionFactor(
-                Tc=comp.temperatureInC, T0=comp.inputTemperatureInC
-            )
-            if inputHeightsConsideredHot:
-                f = 1.0 / (1 + dLL) ** 2
+            # Apply a density scaling to account for the temperature change between
+            # Tinput and Thot
+            if isinstance(mat, materials.Fluid):
+                densityRatio = densityFromCustomIsotopic / mat.density(
+                    Tc=comp.inputTemperatureInC
+                )
             else:
-                f = 1.0 / (1 + dLL) ** 3
+                # for solids we need to consider if the input heights are hot or
+                # cold, in order to get the density correct.
+                # There may be a better place in the initialization to determine
+                # if the block height will be interpreted as hot dimensions, which would
+                # allow us to not have to pass the case settings down this far
+                dLL = mat.linearExpansionFactor(
+                    Tc=comp.temperatureInC, T0=comp.inputTemperatureInC
+                )
+                if inputHeightsConsideredHot:
+                    f = 1.0 / (1 + dLL) ** 2
+                else:
+                    f = 1.0 / (1 + dLL) ** 3
+
+                scaledDensity = comp.density() / f
+                densityRatio = densityFromCustomIsotopic / scaledDensity
 
-            scaledDensity = comp.density() / f
-            densityRatio = densityFromCustomIsotopic / scaledDensity
             comp.changeNDensByFactor(densityRatio)
 
             runLog.important(
@@ -369,10 +378,18 @@ class ComponentBlueprint(yamlize.Object):
             try:
                 # update material with updated input params from blueprints file.
                 mat.applyInputParams(**matMods)
-            except TypeError:
-                # This component does not accept material modification inputs of the names passed in
-                # Keep going since the modification could work for another component
-                pass
+            except TypeError as ee:
+                errorMessage = ee.args[0]
+                if "got an unexpected keyword argument" in errorMessage:
+                    # This component does not accept material modification inputs of the names passed in
+                    # Keep going since the modification could work for another component
+                    pass
+                else:
+                    raise ValueError(
+                        f"Something went wrong in applying the material modifications {matMods} "
+                        f"to component {self.name}.\n"
+                        f"Error message is: \n{errorMessage}."
+                    )
 
         expandElementals(mat, blueprint)
 
diff --git a/doc/release/0.5.rst b/doc/release/0.5.rst
index 33051845..11a81876 100644
--- a/doc/release/0.5.rst
+++ b/doc/release/0.5.rst
@@ -21,6 +21,7 @@ Bug Fixes
 ---------
 #. Fixing check for jagged arrays during ``_writeParams``. (`PR#2051 <https://github.com/terrapower/armi/pull/2051>`_)
 #. Fixing BP-section ignoring tool in ``PassiveDBLoadPlugin``. (`PR#2055 <https://github.com/terrapower/armi/pull/2055>`_)
+#. Fixing number densities when custom isotopics are combined with Fluid components. (`PR#2071 <https://github.com/terrapower/armi/pull/2071>`_)
 #. Fixing scaling of volume-integrated parameters on edge assemblies. (`PR#2060 <https://github.com/terrapower/armi/pull/2060>`_)
 #. Fixing strictness of ``HexGrid`` rough equality check. (`PR#2058 <https://github.com/terrapower/armi/pull/2058>`_)
 #. TBD


Test patch:
diff --git a/armi/nucDirectory/tests/test_nuclideBases.py b/armi/nucDirectory/tests/test_nuclideBases.py
index 1ef1b590..066d0f5b 100644
--- a/armi/nucDirectory/tests/test_nuclideBases.py
+++ b/armi/nucDirectory/tests/test_nuclideBases.py
@@ -466,11 +466,9 @@ class TestAAAZZZSId(unittest.TestCase):
             :tests: R_ARMI_ND_ISOTOPES
         """
         referenceNucNames = [
-            ("C", "120060"),
+            ("C12", "120060"),
             ("U235", "2350920"),
             ("AM242M", "2420951"),
-            ("LFP35", None),
-            ("DUMP1", None),
         ]
 
         for nucName, refAaazzzs in referenceNucNames:
diff --git a/armi/reactor/blueprints/tests/test_customIsotopics.py b/armi/reactor/blueprints/tests/test_customIsotopics.py
index 27ce95ed..c79a2343 100644
--- a/armi/reactor/blueprints/tests/test_customIsotopics.py
+++ b/armi/reactor/blueprints/tests/test_customIsotopics.py
@@ -19,6 +19,7 @@ from logging import DEBUG
 import yamlize
 
 from armi import runLog, settings
+from armi.materials import Fluid, Sodium
 from armi.physics.neutronics.settings import (
     CONF_MCNP_LIB_BASE,
     CONF_NEUTRONICS_KERNEL,
@@ -42,6 +43,7 @@ nuclide flags:
     AL: {burn: false, xs: true}
     FE: {burn: false, xs: true}
     C: {burn: false, xs: true}
+    NA: {burn: false, xs: true}
     DUMP2: {burn: true, xs: true}
     DUMP1: {burn: true, xs: true}
     LFP35: {burn: true, xs: true}
@@ -118,6 +120,11 @@ custom isotopics:
         C: 0.3
         density: 7.0
 
+    sodium custom isotopics:
+        input format: mass fractions
+        NA: 1
+        density: 666
+
 """
 
     yamlGoodBlocks = r"""
@@ -141,6 +148,25 @@ blocks:
             mult: 1.0
             od: 10.0
 
+        sodium1:
+            shape: Circle
+            material: Sodium
+            Tinput: 25
+            Thot: 600
+            id: 0
+            mult: 1
+            od: 1
+
+        sodium2:
+            shape: Circle
+            material: Sodium
+            isotopics: sodium custom isotopics
+            Tinput: 25
+            Thot: 600
+            id: 0
+            mult: 1
+            od: 1
+
     uranium fuel from isotopic mass fractions : &block_1
         fuel:
             <<: *basic_fuel
@@ -381,6 +407,35 @@ assemblies:
         self.assertEqual(fuel6.material.name, fuel0.material.name)
         self.assertEqual("UZr", fuel0.material.name)
 
+    def test_densitiesAppliedToNonCustomMaterialsFluid(self):
+        """
+        Ensure that a density can be set in custom isotopics for components using library materials,
+        specifically in the case of a fluid component. In this case, inputHeightsConsideredHot
+        does not matter, and the material has a zero dLL value.
+        """
+        # The template block
+        sodium1 = self.a[0].getComponentByName("sodium1")
+        sodium2 = self.a[0].getComponentByName("sodium2")
+
+        self.assertEqual(sodium1.material.name, "Sodium")
+        self.assertEqual(sodium2.material.name, "Sodium")
+        self.assertTrue(isinstance(sodium1.material, Fluid))
+        self.assertTrue(isinstance(sodium2.material, Fluid))
+        self.assertEqual(sodium1.p.customIsotopicsName, "")
+        self.assertEqual(sodium2.p.customIsotopicsName, "sodium custom isotopics")
+
+        # show that, even though the two components have the same material class
+        # and the same temperatures, their densities are different
+        self.assertNotEqual(sodium1.density(), sodium2.density())
+
+        # show that sodium1 has a density from the material class, while sodium2
+        # has a density from the blueprint and adjusted from Tinput -> Thot
+        s = Sodium()
+        self.assertAlmostEqual(sodium1.density(), s.density(Tc=600))
+        self.assertAlmostEqual(
+            sodium2.density(), s.density(Tc=600) * (666 / s.density(Tc=25))
+        )
+
     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
diff --git a/armi/reactor/blueprints/tests/test_materialModifications.py b/armi/reactor/blueprints/tests/test_materialModifications.py
index 52ada251..eaccb00d 100644
--- a/armi/reactor/blueprints/tests/test_materialModifications.py
+++ b/armi/reactor/blueprints/tests/test_materialModifications.py
@@ -259,7 +259,7 @@ assemblies:
 
     def test_invalidMatModName(self):
         """
-        This test shows proves that we can detect invalid material modification
+        This test shows that we can detect invalid material modification
         names when they are specified on an assembly blueprint. We happen to know
         that ZR_wt_frac is a valid modification for the UZr material class, so we
         use that in the first call to prove that things initially work fine.
@@ -299,6 +299,35 @@ assemblies:
         """
             )
 
+    def test_invalidMatModType(self):
+        """
+        This test shows that we can detect material modifications that are invalid
+        because of their values, not just their names.
+        We happen to know that ZR_wt_frac is a valid modification for UZr, so we
+        use that in the first call to prove that things initially work fine.
+        """
+        a = self.loadUZrAssembly(
+            """
+        material modifications:
+            ZR_wt_frac: [1]
+        """
+        )
+        # just to prove that the above works fine before we modify it
+        self.assertAlmostEqual(a[0][0].getMassFrac("ZR"), 1)
+
+        with self.assertRaises(ValueError) as ee:
+            a = self.loadUZrAssembly(
+                """
+        material modifications:
+            ZR_wt_frac: [this_is_a_value_of_incompatible_type]
+        """
+            )
+
+            self.assertIn(
+                "Something went wrong in applying the material modifications",
+                ee.args[0],
+            )
+
     def test_matModsUpTheMRO(self):
         """
         Make sure that valid/invalid material modifications are searched up
diff --git a/armi/reactor/blueprints/tests/test_reactorBlueprints.py b/armi/reactor/blueprints/tests/test_reactorBlueprints.py
index 2e107517..0fa76239 100644
--- a/armi/reactor/blueprints/tests/test_reactorBlueprints.py
+++ b/armi/reactor/blueprints/tests/test_reactorBlueprints.py
@@ -143,7 +143,12 @@ class TestReactorBlueprints(unittest.TestCase):
 
     def test_materialDataSummary(self):
         """Test that the material data summary for the core is valid as a printout to the stdout."""
-        expectedMaterialData = [("Custom", "ARMI"), ("HT9", "ARMI"), ("UZr", "ARMI")]
+        expectedMaterialData = [
+            ("Custom", "ARMI"),
+            ("HT9", "ARMI"),
+            ("Sodium", "ARMI"),
+            ("UZr", "ARMI"),
+        ]
         core, _sfp, _evst = self._setupReactor()
         materialData = reactorBlueprint.summarizeMaterialData(core)
         for actual, expected in zip(materialData, expectedMaterialData):