Repository: terrapower/armi
Pull request #2005 (2024-11-04 21:53:27)

Expose some skip inspection options for `armi.init` and `db.loadOperator`
## What is the change?

This exposes a `skipInspection` option to `armi.init` and a `handleInvalids` option `db.loadOperator`. The original behavior is retained by default.

## Why is the change being made?

Folks write scripts that use the API, and when they run these scripts, they are often stuck in settings inspection mode (typing `y` + `<enter>` over and over if they are loading older settings files). This helps them skip said inspections so they can run scripts without babysitting them.

---

## 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. (Test added for loadOperator, but ARMI init is untested.)

<!-- 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`.

john-science: Looks great.  It really could use some unit testing for the DB stuff.  (Testing `armi.init()` can be skipped.)
opotowsky: > Is this only needed for interactive mode? What's the scenario where someone needs to babysit settings queries? Not a huge fan of adding more kwargs to init so I'm curious if we have the right check inputs design.

Yes this is needed for interactive mode, when someone runs a python script on the command line.

Patch:
diff --git a/armi/__init__.py b/armi/__init__.py
index c3603ecc..7a3eadc5 100644
--- a/armi/__init__.py
+++ b/armi/__init__.py
@@ -114,7 +114,7 @@ def isStableReleaseVersion(version=None):
     return "-" not in version
 
 
-def init(choice=None, fName=None, cs=None):
+def init(choice=None, fName=None, cs=None, skipInspection=False):
     """
     Scan a directory for armi inputs and load one to interact with.
 
@@ -144,6 +144,9 @@ def init(choice=None, fName=None, cs=None):
         If supplied, this CS object will supercede the other case input methods and use
         the object directly.
 
+    skipInspection : bool, optional
+        Whether or not the inputs should be checked for valid settings. Default is False.
+
     Examples
     --------
     >>> o = armi.init()
@@ -157,7 +160,8 @@ def init(choice=None, fName=None, cs=None):
         cs = settings.Settings(fName)
 
     armiCase = cases.Case(cs=cs)
-    armiCase.checkInputs()
+    if not skipInspection:
+        armiCase.checkInputs()
 
     try:
         return armiCase.initializeOperator()
diff --git a/armi/bookkeeping/db/__init__.py b/armi/bookkeeping/db/__init__.py
index f6004181..efc5f734 100644
--- a/armi/bookkeeping/db/__init__.py
+++ b/armi/bookkeeping/db/__init__.py
@@ -86,6 +86,7 @@ def loadOperator(
     loadNode,
     statePointName=None,
     allowMissing=False,
+    handleInvalids=True,
 ):
     """
     Return an operator given the path to a database.
@@ -104,6 +105,8 @@ def loadOperator(
     allowMissing : bool
         Whether to emit a warning, rather than crash if reading a database
         with undefined parameters. Default False.
+    handleInvalids : bool
+        Whether to check for invalid settings. Default True.
 
     See Also
     --------
@@ -145,7 +148,7 @@ def loadOperator(
         # init Case here as it keeps track of execution time and assigns a reactor
         # attribute. This attribute includes the time it takes to initialize the reactor
         # so creating a reactor from the database should be included.
-        cs = db.loadCS()
+        cs = db.loadCS(handleInvalids=handleInvalids)
         thisCase = cases.Case(cs)
 
         r = db.load(
@@ -153,6 +156,7 @@ def loadOperator(
             loadNode,
             statePointName=statePointName,
             allowMissing=allowMissing,
+            handleInvalids=handleInvalids,
         )
 
     o = thisCase.initializeOperator(r=r)
diff --git a/armi/bookkeeping/db/database.py b/armi/bookkeeping/db/database.py
index 4e484c0e..884198ae 100644
--- a/armi/bookkeeping/db/database.py
+++ b/armi/bookkeeping/db/database.py
@@ -393,9 +393,14 @@ class Database:
             raise RuntimeError("Cannot change Database file name while it's opened!")
         self._fileName = fName
 
-    def loadCS(self):
+    def loadCS(self, handleInvalids=True):
         """Attempt to load settings from the database file.
 
+        Parameters
+        ----------
+        handleInvalids : bool
+            Whether to check for invalid settings. Default True.
+
         Notes
         -----
         There are no guarantees here. If the database was written from a different version of ARMI than you are using,
@@ -405,7 +410,9 @@ class Database:
         cs = settings.Settings()
         cs.caseTitle = os.path.splitext(os.path.basename(self.fileName))[0]
         try:
-            cs.loadFromString(self.h5db["inputs/settings"].asstr()[()])
+            cs.loadFromString(
+                self.h5db["inputs/settings"].asstr()[()], handleInvalids=handleInvalids
+            )
         except KeyError:
             # not all paths to writing a database require inputs to be written to the
             # database. Technically, settings do affect some of the behavior of database
@@ -708,6 +715,7 @@ class Database:
         bp=None,
         statePointName=None,
         allowMissing=False,
+        handleInvalids=True,
     ):
         """Load a new reactor from a DB at (cycle, node).
 
@@ -743,6 +751,8 @@ class Database:
         allowMissing : bool, optional
             Whether to emit a warning, rather than crash if reading a database
             with undefined parameters. Default False.
+        handleInvalids : bool
+            Whether to check for invalid settings. Default True.
 
         Returns
         -------
@@ -751,7 +761,7 @@ class Database:
         """
         runLog.info("Loading reactor state for time node ({}, {})".format(cycle, node))
 
-        cs = cs or self.loadCS()
+        cs = cs or self.loadCS(handleInvalids=handleInvalids)
         bp = bp or self.loadBlueprints()
 
         if node < 0:
diff --git a/armi/physics/neutronics/settings.py b/armi/physics/neutronics/settings.py
index f9e23cab..d6aaa42b 100644
--- a/armi/physics/neutronics/settings.py
+++ b/armi/physics/neutronics/settings.py
@@ -43,6 +43,7 @@ CONF_GROUP_STRUCTURE = "groupStructure"
 CONF_INNERS_ = "inners"
 CONF_LOADING_FILE = "loadingFile"
 CONF_NEUTRONICS_KERNEL = "neutronicsKernel"
+CONF_MCNP_LIB_BASE = "mcnpLibraryVersion"
 CONF_NEUTRONICS_TYPE = "neutronicsType"
 CONF_NUMBER_MESH_PER_EDGE = "numberMeshPerEdge"
 CONF_OUTERS_ = "outers"
@@ -172,6 +173,13 @@ def defineSettings():
             options=[],
             enforcedOptions=True,
         ),
+        setting.Setting(
+            CONF_MCNP_LIB_BASE,
+            default="ENDF/B-VII.1",
+            description="Library name for MCNP cross sections. ENDF/B-VII.1 is the default library.",
+            label="ENDF data library version to use for MCNP Analysis",
+            options=["ENDF/B-V.0", "ENDF/B-VII.0", "ENDF/B-VII.1", "ENDF/B-VIII.0"],
+        ),
         setting.Setting(
             CONF_NEUTRONICS_TYPE,
             default="real",
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index a3bf12ea..27e91a8f 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -1992,12 +1992,12 @@ class HexBlock(Block):
 
     def rotate(self, rad: float):
         """
-        Rotates a block's spatially-varying parameters by a specified angle in the counter-clockwise
+        Rotates a block's spatially varying parameters by a specified angle in the counter-clockwise
         direction.
 
-        The parameters must have a ParamLocation of either CORNERS or EDGES and must be a list of
-        length 6 in order to be eligible for rotation; all parameters that do not meet these two
-        criteria are not rotated.
+        The parameters must have a ParamLocation of either CORNERS or EDGES and must be a Python
+        list of length 6 in order to be eligible for rotation; all parameters that do not meet these
+        two criteria are not rotated.
 
         .. impl:: Rotating a hex block updates parameters on the boundary, the orientation
             parameter, and the spatial coordinates on contained objects.
@@ -2006,9 +2006,9 @@ class HexBlock(Block):
 
         Parameters
         ----------
-        rad: float
+        rad: float, required
             Angle of counter-clockwise rotation in units of radians. Rotations must be in 60-degree
-            increments (i.e., PI/6, PI/3, PI, 2 * PI/3, 5 * PI/6, and 2 * PI).
+            increments (i.e., PI/6, PI/3, PI, 2 * PI/3, 5 * PI/6, and 2 * PI)
         """
         rotNum = round((rad % (2 * math.pi)) / math.radians(60))
         self._rotateChildLocations(rad, rotNum)
diff --git a/armi/reactor/blueprints/isotopicOptions.py b/armi/reactor/blueprints/isotopicOptions.py
index 506be7b5..37ded762 100644
--- a/armi/reactor/blueprints/isotopicOptions.py
+++ b/armi/reactor/blueprints/isotopicOptions.py
@@ -33,6 +33,7 @@ from armi.physics.neutronics.fissionProductModel.fissionProductModelSettings imp
     CONF_FISSION_PRODUCT_LIBRARY_NAME,
 )
 from armi.physics.neutronics.settings import (
+    CONF_MCNP_LIB_BASE,
     CONF_NEUTRONICS_KERNEL,
     CONF_XS_KERNEL,
 )
@@ -456,7 +457,6 @@ def getDefaultNuclideFlags():
     We will include B10 and B11 without depletion, sodium, and structural elements.
 
     We will include LFPs with depletion.
-
     """
     nuclideFlags = {}
     actinides = {
@@ -511,7 +511,6 @@ def eleExpandInfoBasedOnCodeENDF(cs):
         For example: {oxygen: [oxygen16]} indicates that all
         oxygen should be expanded to O16, ignoring natural
         O17 and O18. (variables are Natural/NuclideBases)
-
     """
     elementalsToKeep = set()
     oxygenElementals = [nuclideBases.byName["O"]]
@@ -536,20 +535,22 @@ def eleExpandInfoBasedOnCodeENDF(cs):
 
     if "MCNP" in cs[CONF_NEUTRONICS_KERNEL]:
         expansionStrings.update(mcnpExpansions)
-        if int(cs["mcnpLibrary"]) == 50:
+        if cs[CONF_MCNP_LIB_BASE] == "ENDF/B-V.0":
+            # ENDF/B V.0
             elementalsToKeep.update(nuclideBases.instances)  # skip expansion
-        # ENDF/B VII.0
-        elif 70 <= int(cs["mcnpLibrary"]) <= 79:
+        elif cs[CONF_MCNP_LIB_BASE] == "ENDF/B-VII.0":
+            # ENDF/B VII.0
             elementalsToKeep.update(endf70Elementals)
-        # ENDF/B VII.1
-        elif 80 <= int(cs["mcnpLibrary"]) <= 89:
+        elif cs[CONF_MCNP_LIB_BASE] == "ENDF/B-VII.1":
+            # ENDF/B VII.1
             elementalsToKeep.update(endf71Elementals)
+        elif cs[CONF_MCNP_LIB_BASE] == "ENDF/B-VIII.0":
+            # ENDF/B VIII.0
+            elementalsToKeep.update(endf80Elementals)
         else:
             raise InputError(
-                "Failed to determine nuclides for modeling. "
-                "The `mcnpLibrary` setting value ({}) is not supported.".format(
-                    cs["mcnpLibrary"]
-                )
+                "Failed to determine nuclides for modeling. The `mcnpLibraryVersion` "
+                f"setting value ({cs[CONF_MCNP_LIB_BASE]}) is not supported."
             )
 
     elif cs[CONF_XS_KERNEL] == "SERPENT":
diff --git a/doc/release/0.5.rst b/doc/release/0.5.rst
index 8ae4f1a9..aaf57f59 100644
--- a/doc/release/0.5.rst
+++ b/doc/release/0.5.rst
@@ -34,6 +34,7 @@ New Features
 #. Adding data models for ex-core structures in ARMI. (`PR#1891 <https://github.com/terrapower/armi/pull/1891>`_)
 #. Opening some DBs without the ``App`` that created them. (`PR#1917 <https://github.com/terrapower/armi/pull/1917>`_)
 #. Adding support for ENDF/B-VII.1-based MC2-3 libraries. (`PR#1982 <https://github.com/terrapower/armi/pull/1982>`_)
+#. Adding setting ``mcnpLibraryVersion`` to chosen ENDF library for MCNP. (`PR#1989 <https://github.com/terrapower/armi/pull/1989>`_)
 #. Removing the ``tabulate`` dependency by ingesting it to ``armi.utils.tabulate``. (`PR#1811 <https://github.com/terrapower/armi/pull/1811>`_)
 #. ``HexBlock.rotate`` updates the spatial locator for children of that block. (`PR#1943 <https://github.com/terrapower/armi/pull/1943>`_)
 #. Provide ``Block.getInputHeight`` for determining the height of a block from blueprints. (`PR#1927 <https://github.com/terrapower/armi/pull/1927>`_)
@@ -50,6 +51,7 @@ New Features
 #. 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>`_)
 #. Adding ``--skip-inspection`` flag to ``CompareCases`` CLI. (`PR#1842 <https://github.com/terrapower/armi/pull/1842>`_)
+#. Exposing skip inspection options for ``armi.init`` and ``db.loadOperator``. (`PR#2005 <https://github.com/terrapower/armi/pull/2005>`_)
 #. Exposing ``detailedNDens`` to components. (`PR#1954 <https://github.com/terrapower/armi/pull/1954>`_)
 #. Adding a method ``getPinMgFluxes`` to get pin-wise multigroup fluxes from a Block. (`PR#1990 <https://github.com/terrapower/armi/pull/1990>`_)
 


Test patch:
diff --git a/armi/bookkeeping/db/tests/test_database3.py b/armi/bookkeeping/db/tests/test_database3.py
index 4c73c944..94607833 100644
--- a/armi/bookkeeping/db/tests/test_database3.py
+++ b/armi/bookkeeping/db/tests/test_database3.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 """Tests for the Database class."""
 from glob import glob
+import io
 import os
 import shutil
 import subprocess
@@ -23,6 +24,7 @@ import numpy as np
 
 from armi.bookkeeping.db import _getH5File
 from armi.bookkeeping.db import database
+from armi.bookkeeping.db import loadOperator
 from armi.bookkeeping.db.databaseInterface import DatabaseInterface
 from armi.bookkeeping.db.jaggedArray import JaggedArray
 from armi.reactor import parameters
@@ -32,6 +34,7 @@ from armi.reactor.reactors import Reactor
 from armi.reactor.spentFuelPool import SpentFuelPool
 from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings
 from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
+from armi.tests import mockRunLogs
 from armi.tests import TEST_ROOT
 from armi.utils import getPreviousTimeNode
 from armi.utils.directoryChangers import TemporaryDirectoryChanger
@@ -206,10 +209,6 @@ class TestDatabaseSmaller(unittest.TestCase):
         self.db: database.Database = self.dbi.database
         self.stateRetainer = self.r.retainState().__enter__()
 
-        # used to test location-based history. see details below
-        self.centralAssemSerialNums = []
-        self.centralTopBlockSerialNums = []
-
     def tearDown(self):
         self.db.close()
         self.stateRetainer.__exit__()
@@ -226,6 +225,41 @@ class TestDatabaseSmaller(unittest.TestCase):
 
             self.db.writeToDB(self.r)
 
+    def test_loadOperator(self):
+        self.makeHistory()
+        self.db.close()
+        # Write a bad setting to the DB
+        with h5py.File(self.db.fileName, "r+") as hf:
+            settingz = hf["inputs/settings"].asstr()[()]
+            settingz += "  fakeTerminator: I'll be back"
+            stream = io.StringIO(settingz)
+            csString = stream.read()
+            del hf["inputs/settings"]
+            hf["inputs/settings"] = csString
+
+        # Test with no complaints
+        with mockRunLogs.BufferLog() as mock:
+            _o = loadOperator(
+                self._testMethodName + ".h5",
+                0,
+                0,
+                allowMissing=True,
+                handleInvalids=False,
+            )
+            self.assertNotIn("fakeTerminator", mock.getStdout())
+
+        # Test with complaints
+        with mockRunLogs.BufferLog() as mock:
+            _o = loadOperator(
+                self._testMethodName + ".h5",
+                0,
+                0,
+                allowMissing=True,
+                handleInvalids=True,
+            )
+            self.assertIn("Ignoring invalid settings", mock.getStdout())
+            self.assertIn("fakeTerminator", mock.getStdout())
+
     def _compareArrays(self, ref, src):
         """
         Compare two numpy arrays.
diff --git a/armi/reactor/blueprints/tests/test_customIsotopics.py b/armi/reactor/blueprints/tests/test_customIsotopics.py
index e22f0a50..f20cec81 100644
--- a/armi/reactor/blueprints/tests/test_customIsotopics.py
+++ b/armi/reactor/blueprints/tests/test_customIsotopics.py
@@ -18,13 +18,17 @@ from logging import DEBUG
 
 import yamlize
 
+from armi import runLog
 from armi import settings
+from armi.physics.neutronics.settings import CONF_MCNP_LIB_BASE
+from armi.physics.neutronics.settings import CONF_NEUTRONICS_KERNEL
 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
+from armi.utils.customExceptions import InputError
+from armi.utils.directoryChangers import TemporaryDirectoryChanger
 
 
 class TestCustomIsotopics(unittest.TestCase):
@@ -529,6 +533,8 @@ nuclide flags:
     SI: {burn: true, xs: true}
     MO: {burn: true, xs: true}
     W: {burn: true, xs: true}
+    ZN: {burn: true, xs: true}
+    O: {burn: true, xs: true}
 blocks:
     uzr fuel: &block_0
         fuel:
@@ -546,6 +552,14 @@ blocks:
             id: 0.0
             mult: 1.0
             od: 10.0
+        dummy:
+            shape: Circle
+            material: ZnO
+            Tinput: 25.0
+            Thot: 600.0
+            id: 0.0
+            mult: 1.0
+            od: 10.0
 assemblies:
     fuel a:
         specifier: IC
@@ -568,3 +582,47 @@ assemblies:
         self.assertNotIn("FE56", nd)  # natural isotopic not requested
         self.assertNotIn("FE51", nd)  # un-natural
         self.assertNotIn("FE", nd)
+
+    def test_eleExpandInfoBasedOnCodeENDF(self):
+        with TemporaryDirectoryChanger():
+            # Reference elements to expand by library
+            ref_E70_elem = ["C", "V", "ZN"]
+            ref_E71_elem = ["C"]
+            ref_E80_elem = []
+
+            # Load settings and set neutronics kernel to MCNP
+            cs = settings.Settings()
+            cs = cs.modified(newSettings={CONF_NEUTRONICS_KERNEL: "MCNP"})
+
+            # Set ENDF/B-VII.0 as MCNP cross section library base
+            cs = cs.modified(newSettings={CONF_MCNP_LIB_BASE: "ENDF/B-VII.0"})
+            eleToKeep, expansions = isotopicOptions.eleExpandInfoBasedOnCodeENDF(cs)
+            E70_elem = [x.label for x in eleToKeep]
+
+            # Set ENDF/B-VII.1 as MCNP cross section library base
+            cs = cs.modified(newSettings={CONF_MCNP_LIB_BASE: "ENDF/B-VII.1"})
+            eleToKeep, expansions = isotopicOptions.eleExpandInfoBasedOnCodeENDF(cs)
+            E71_elem = [x.label for x in eleToKeep]
+
+            # Set ENDF/B-VIII.0 as MCNP cross section library base
+            cs = cs.modified(newSettings={CONF_MCNP_LIB_BASE: "ENDF/B-VIII.0"})
+            eleToKeep, expansions = isotopicOptions.eleExpandInfoBasedOnCodeENDF(cs)
+            E80_elem = [x.label for x in eleToKeep]
+
+            # Assert equality of returned elements to reference elements
+            self.assertEqual(sorted(E70_elem), sorted(ref_E70_elem))
+            self.assertEqual(sorted(E71_elem), sorted(ref_E71_elem))
+            self.assertEqual(sorted(E80_elem), sorted(ref_E80_elem))
+
+            # Disallowed inputs
+            not_allowed = ["ENDF/B-VIIII.0", "ENDF/B-VI.0", "JEFF-3.3"]
+            # Assert raise InputError in case of invalid library setting
+            for x in not_allowed:
+                with self.assertRaises(InputError) as context:
+                    cs = cs.modified(newSettings={CONF_MCNP_LIB_BASE: x})
+                    _ = isotopicOptions.eleExpandInfoBasedOnCodeENDF(cs)
+
+                self.assertTrue(
+                    "Failed to determine nuclides for modeling"
+                    in str(context.exception)
+                )