Repository: terrapower/armi

Issue:
Clean up deprecated setting from `armiRun.yaml`
This is the list I get when I try to run the case:

1. `maxRegionDensityIterations`
2. `eqRingSchedule`
3. `maxFlowZones`
4. `transientForSensitivity`
5. `BOL`
6. `percentNaReduction`
7. `nodeGroup`
8. `economics`
9. `clusterExclusive`
10. `max2SigmaCladIDT`


Discussion:
john-science: @opotowsky I will run a battery of internal tests against this branch, in case somebody is using ARMI test data in their tests. #shrug

Patch:
diff --git a/armi/operators/operator.py b/armi/operators/operator.py
index a7e9e39c..09df06b3 100644
--- a/armi/operators/operator.py
+++ b/armi/operators/operator.py
@@ -1256,16 +1256,6 @@ class Operator:
         pathTools.copyOrWarn(
             "Loading definition for snapshot", self.cs[CONF_LOADING_FILE], newFolder
         )
-        pathTools.copyOrWarn(
-            "Flow history for snapshot",
-            self.cs.caseTitle + ".flow_history.txt",
-            newFolder,
-        )
-        pathTools.copyOrWarn(
-            "Pressure history for snapshot",
-            self.cs.caseTitle + ".pressure_history.txt",
-            newFolder,
-        )
 
     @staticmethod
     def setStateToDefault(cs):
diff --git a/armi/plugins.py b/armi/plugins.py
index 77d77d4f..31c72a17 100644
--- a/armi/plugins.py
+++ b/armi/plugins.py
@@ -127,6 +127,7 @@ from armi.utils import flags
 
 if TYPE_CHECKING:
     from armi.reactor.composites import Composite
+    from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
 
 
 HOOKSPEC = pluggy.HookspecMarker("armi")
@@ -662,6 +663,45 @@ class ArmiPlugin:
         and a ``"sfp"`` lookup, triggered to run after all other hooks have been run.
         """
 
+    @staticmethod
+    @HOOKSPEC(firstresult=True)
+    def getAxialExpansionChanger() -> type["AxialExpansionChanger"]:
+        """Produce the class responsible for performing axial expansion.
+
+        Plugins can provide this hook to override or negate axial expansion.
+        Will be used during initial construction of the core and assemblies, and
+        can be a class to perform custom axial expansion routines.
+
+        The first object returned that is not ``None`` will be used.
+        Plugins are encouraged to add the ``tryfirst=True`` arguments to their
+        ``HOOKIMPL`` invocations to make sure their specific are earlier in the
+        hook call sequence.
+
+        Returns
+        -------
+        type of :class:`armi.reactor.converters.axialExpansionChanger.AxialExpansionChanger`
+
+        Notes
+        -----
+        This hook **should not** provide an instance of the class. The construction
+        of the changer will be handled by applications and plugins that need it.
+
+        This hook should only be provided by one additional plugin in your application. Otherwise
+        the `order of hook execution <https://pluggy.readthedocs.io/en/stable/index.html#call-time-order>`_
+        may not provide the behavior you expect.
+
+        Examples
+        --------
+        >>> class MyPlugin(ArmiPlugin):
+        ...     @staticmethod
+        ...     @HOOKIMPL(tryfirst=True)
+        ...     def getAxialExpansionChanger():
+        ...         from myproject.physics import BespokeAxialExpansion
+        ...
+        ...         return BespokeAxialExpansion
+
+        """
+
 
 class UserPlugin(ArmiPlugin):
     """
diff --git a/armi/reactor/__init__.py b/armi/reactor/__init__.py
index 30cf6e77..cfb43c15 100644
--- a/armi/reactor/__init__.py
+++ b/armi/reactor/__init__.py
@@ -90,9 +90,9 @@ class ReactorPlugin(plugins.ArmiPlugin):
 
     @staticmethod
     @plugins.HOOKIMPL(trylast=True)
-    def defineSystemBuilders() -> Dict[
-        str, Callable[[str], Union["Core", "SpentFuelPool"]]
-    ]:
+    def defineSystemBuilders() -> (
+        Dict[str, Callable[[str], Union["Core", "SpentFuelPool"]]]
+    ):
         from armi.reactor.reactors import Core
         from armi.reactor.assemblyLists import SpentFuelPool
 
@@ -100,3 +100,10 @@ class ReactorPlugin(plugins.ArmiPlugin):
             "core": Core,
             "sfp": SpentFuelPool,
         }
+
+    @staticmethod
+    @plugins.HOOKIMPL(trylast=True)
+    def getAxialExpansionChanger():
+        from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
+
+        return AxialExpansionChanger
diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py
index a1da6275..a17ddbdf 100644
--- a/armi/reactor/blueprints/__init__.py
+++ b/armi/reactor/blueprints/__init__.py
@@ -151,7 +151,7 @@ class _BlueprintsPluginCollector(yamlize.objects.ObjectType):
         else:
             pluginSections = pm.hook.defineBlueprintsSections()
             for plug in pluginSections:
-                for (attrName, section, resolver) in plug:
+                for attrName, section, resolver in plug:
                     assert isinstance(section, yamlize.Attribute)
                     if attrName in attrs:
                         raise plugins.PluginError(
@@ -326,10 +326,12 @@ class Blueprints(yamlize.Object, metaclass=_BlueprintsPluginCollector):
                     for a in list(self.assemblies.values())
                     if not any(a.hasFlags(f) for f in assemsToSkip)
                 )
-                axialExpansionChanger.axialExpansionChanger.expandColdDimsToHot(
-                    assemsToExpand,
-                    cs[CONF_DETAILED_AXIAL_EXPANSION],
-                )
+                axialExpander = getPluginManagerOrFail().hook.getAxialExpansionChanger()
+                if axialExpander is not None:
+                    axialExpander.expandColdDimsToHot(
+                        assemsToExpand,
+                        cs[CONF_DETAILED_AXIAL_EXPANSION],
+                    )
 
             getPluginManagerOrFail().hook.afterConstructionOfAssemblies(
                 assemblies=self.assemblies.values(), cs=cs
diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
index 390305be..d7369d25 100644
--- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
+++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py
@@ -52,71 +52,6 @@ def makeAssemsAbleToSnapToUniformMesh(
         a.makeAxialSnapList(referenceAssembly)
 
 
-def expandColdDimsToHot(
-    assems: list,
-    isDetailedAxialExpansion: bool,
-    referenceAssembly=None,
-):
-    """
-    Expand BOL assemblies, resolve disjoint axial mesh (if needed), and update block BOL heights.
-
-    .. impl:: Perform expansion during core construction based on block heights at a specified temperature.
-        :id: I_ARMI_INP_COLD_HEIGHT
-        :implements: R_ARMI_INP_COLD_HEIGHT
-
-        This method is designed to be used during core construction to axially thermally expand the
-        assemblies to their "hot" temperatures (as determined by ``Thot`` values in blueprints).
-        First, The Assembly is prepared for axial expansion via ``setAssembly``. In
-        ``applyColdHeightMassIncrease``, the number densities on each Component is adjusted to
-        reflect that Assembly inputs are at cold (i.e., ``Tinput``) temperatures. To expand to
-        the requested hot temperatures, thermal expansion factors are then computed in
-        ``computeThermalExpansionFactors``. Finally, the Assembly is axially thermally expanded in
-        ``axiallyExpandAssembly``.
-
-        If the setting ``detailedAxialExpansion`` is ``False``, then each Assembly gets its Block mesh
-        set to match that of the "reference" Assembly (see ``getDefaultReferenceAssem`` and ``setBlockMesh``).
-
-        Once the Assemblies are axially expanded, the Block BOL heights are updated. To account for the change in
-        Block volume from axial expansion, ``completeInitialLoading`` is called to update any volume-dependent
-        Block information.
-
-    Parameters
-    ----------
-    assems: list[:py:class:`Assembly <armi.reactor.assemblies.Assembly>`]
-        list of assemblies to be thermally expanded
-    isDetailedAxialExpansion: bool
-        If False, assemblies will be forced to conform to the reference mesh after expansion
-    referenceAssembly: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`, optional
-        Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False.
-        If not provided, will assume the finest mesh assembly which is typically fuel.
-
-    Notes
-    -----
-    Calling this method will result in an increase in mass via applyColdHeightMassIncrease!
-
-    See Also
-    --------
-    :py:meth:`armi.reactor.converters.axialExpansionChanger.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease`
-    """
-    assems = list(assems)
-    if not referenceAssembly:
-        referenceAssembly = getDefaultReferenceAssem(assems)
-    axialExpChanger = AxialExpansionChanger(isDetailedAxialExpansion)
-    for a in assems:
-        axialExpChanger.setAssembly(a, expandFromTinputToThot=True)
-        axialExpChanger.applyColdHeightMassIncrease()
-        axialExpChanger.expansionData.computeThermalExpansionFactors()
-        axialExpChanger.axiallyExpandAssembly()
-    if not isDetailedAxialExpansion:
-        for a in assems:
-            a.setBlockMesh(referenceAssembly.getAxialMesh())
-    # update block BOL heights to reflect hot heights
-    for a in assems:
-        for b in a:
-            b.p.heightBOL = b.getHeight()
-            b.completeInitialLoading()
-
-
 class AxialExpansionChanger:
     """
     Axially expand or contract assemblies or an entire core.
@@ -148,6 +83,71 @@ class AxialExpansionChanger:
         self.linked = None
         self.expansionData = None
 
+    @classmethod
+    def expandColdDimsToHot(
+        cls,
+        assems: list,
+        isDetailedAxialExpansion: bool,
+        referenceAssembly=None,
+    ):
+        """Expand BOL assemblies, resolve disjoint axial mesh (if needed), and update block BOL heights.
+
+        .. impl:: Perform expansion during core construction based on block heights at a specified temperature.
+            :id: I_ARMI_INP_COLD_HEIGHT
+            :implements: R_ARMI_INP_COLD_HEIGHT
+
+            This method is designed to be used during core construction to axially thermally expand the
+            assemblies to their "hot" temperatures (as determined by ``Thot`` values in blueprints).
+            First, The Assembly is prepared for axial expansion via ``setAssembly``. In
+            ``applyColdHeightMassIncrease``, the number densities on each Component is adjusted to
+            reflect that Assembly inputs are at cold (i.e., ``Tinput``) temperatures. To expand to
+            the requested hot temperatures, thermal expansion factors are then computed in
+            ``computeThermalExpansionFactors``. Finally, the Assembly is axially thermally expanded in
+            ``axiallyExpandAssembly``.
+
+            If the setting ``detailedAxialExpansion`` is ``False``, then each Assembly gets its Block mesh
+            set to match that of the "reference" Assembly (see ``getDefaultReferenceAssem`` and ``setBlockMesh``).
+
+            Once the Assemblies are axially expanded, the Block BOL heights are updated. To account for the change in
+            Block volume from axial expansion, ``completeInitialLoading`` is called to update any volume-dependent
+            Block information.
+
+        Parameters
+        ----------
+        assems: list[:py:class:`Assembly <armi.reactor.assemblies.Assembly>`]
+            list of assemblies to be thermally expanded
+        isDetailedAxialExpansion: bool
+            If False, assemblies will be forced to conform to the reference mesh after expansion
+        referenceAssembly: :py:class:`Assembly <armi.reactor.assemblies.Assembly>`, optional
+            Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False.
+            If not provided, will assume the finest mesh assembly which is typically fuel.
+
+        Notes
+        -----
+        Calling this method will result in an increase in mass via applyColdHeightMassIncrease!
+
+        See Also
+        --------
+        :py:meth:`armi.reactor.converters.axialExpansionChanger.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease`
+        """
+        assems = list(assems)
+        if not referenceAssembly:
+            referenceAssembly = getDefaultReferenceAssem(assems)
+        axialExpChanger = cls(isDetailedAxialExpansion)
+        for a in assems:
+            axialExpChanger.setAssembly(a, expandFromTinputToThot=True)
+            axialExpChanger.applyColdHeightMassIncrease()
+            axialExpChanger.expansionData.computeThermalExpansionFactors()
+            axialExpChanger.axiallyExpandAssembly()
+        if not isDetailedAxialExpansion:
+            for a in assems:
+                a.setBlockMesh(referenceAssembly.getAxialMesh())
+        # update block BOL heights to reflect hot heights
+        for a in assems:
+            for b in a:
+                b.p.heightBOL = b.getHeight()
+                b.completeInitialLoading()
+
     def performPrescribedAxialExpansion(
         self, a, componentLst: list, percents: list, setFuel=True
     ):
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index cc7eafa6..24bc4973 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -11,8 +11,11 @@ New Features
 #. ARMI now supports Python 3.12. (`PR#1813 <https://github.com/terrapower/armi/pull/1813>`_)
 #. Removing the ``tabulate`` dependency by ingesting it to ``armi.utils.tabulate``. (`PR#1811 <https://github.com/terrapower/armi/pull/1811>`_)
 #. Adding ``--skip-inspection`` flag to ``CompareCases`` CLI. (`PR#1842 <https://github.com/terrapower/armi/pull/1842>`_)
+#. Allow merging a component with zero area into another component (`PR#1858 <https://github.com/terrapower/armi/pull/1858>`_)
 #. Provide utilities for determining location of a rotated object in a hexagonal lattice (``getIndexOfRotatedCell``). (`PR#1846 <https://github.com/terrapower/armi/1846`)
 #. Allow merging a component with zero area into another component. (`PR#1858 <https://github.com/terrapower/armi/pull/1858>`_)
+#. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion.
+   (`PR#1870 <https://github.com/terrapower/armi/pull/1870`_)
 #. TBD
 
 API Changes
diff --git a/pyproject.toml b/pyproject.toml
index 3adcd76d..069d04d4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -31,7 +31,7 @@ authors = [
 ]
 dependencies = [
     "coverage>=7.2.0", # Code coverage tool. Sadly baked into every Case.
-    "h5py>=3.9 ; python_version >= '3.11.0'", # Needed because our database files are H5 format
+    "h5py>=3.9,<3.12 ; python_version >= '3.11.0'", # Needed because our database files are H5 format
     "h5py>=3.0,<=3.9 ; python_version < '3.11.0'",
     "htmltree>=0.7.6", # Our reports have HTML output
     "matplotlib>=3.5.3,<3.8.0", # Important plotting library


Test patch:
diff --git a/armi/operators/tests/test_operators.py b/armi/operators/tests/test_operators.py
index 9b29dd48..54199a5b 100644
--- a/armi/operators/tests/test_operators.py
+++ b/armi/operators/tests/test_operators.py
@@ -257,8 +257,6 @@ class OperatorTests(unittest.TestCase):
                 self.assertIn("Shuffle logic for snapshot", mock.getStdout())
                 self.assertIn("Geometry file for snapshot", mock.getStdout())
                 self.assertIn("Loading definition for snapshot", mock.getStdout())
-                self.assertIn("Flow history for snapshot", mock.getStdout())
-                self.assertIn("Pressure history for snapshot", mock.getStdout())
             self.assertTrue(os.path.exists("snapShot0_1"))
 
         with TemporaryDirectoryChanger():
@@ -269,8 +267,6 @@ class OperatorTests(unittest.TestCase):
                 self.assertIn("Shuffle logic for snapshot", mock.getStdout())
                 self.assertIn("Geometry file for snapshot", mock.getStdout())
                 self.assertIn("Loading definition for snapshot", mock.getStdout())
-                self.assertIn("Flow history for snapshot", mock.getStdout())
-                self.assertIn("Pressure history for snapshot", mock.getStdout())
             self.assertTrue(os.path.exists("snapShot0_2"))
 
 
diff --git a/armi/settings/tests/test_settings.py b/armi/settings/tests/test_settings.py
index 518a8bd6..4deeb9d8 100644
--- a/armi/settings/tests/test_settings.py
+++ b/armi/settings/tests/test_settings.py
@@ -151,7 +151,7 @@ class TestAddingOptions(unittest.TestCase):
         # modify the default/text settings YAML file to include neutronicsKernel
         fin = os.path.join(TEST_ROOT, "armiRun.yaml")
         txt = open(fin, "r").read()
-        txt = txt.replace("\n  nodeGroup:", "\n  neutronicsKernel: MCNP\n  nodeGroup:")
+        txt = txt.replace("\n  nCycles:", "\n  neutronicsKernel: MCNP\n  nCycles:")
         fout = "test_addingOptions.yaml"
         open(fout, "w").write(txt)
 
@@ -322,7 +322,7 @@ assemblyRotationAlgorithm: buReducingAssemblyRotatoin
         settingsList = cs.getSettingsSetByUser(ARMI_RUN_PATH)
         # This test is dependent on the current setup of armiRun.yaml, which includes
         # some default settings values
-        for sett in ["availabilityFactor", "economics"]:
+        for sett in ["availabilityFactor", "db"]:
             self.assertIn(sett, settingsList)
         self.assertNotIn("numProcessors", settingsList)
 
diff --git a/armi/tests/ThRZSettings.yaml b/armi/tests/ThRZSettings.yaml
index bcec8026..2fee7d11 100644
--- a/armi/tests/ThRZSettings.yaml
+++ b/armi/tests/ThRZSettings.yaml
@@ -5,7 +5,6 @@ settings:
   burnSteps: 0
   comment: "Revised benchmark  "
   db: false
-  economics: false
   genXS: Neutron
   geomFile: ThRZGeom.xml
   groupStructure: ARMI45
diff --git a/armi/tests/armiRun.yaml b/armi/tests/armiRun.yaml
index 33a86082..3206c021 100644
--- a/armi/tests/armiRun.yaml
+++ b/armi/tests/armiRun.yaml
@@ -1,12 +1,10 @@
 settings:
   availabilityFactor: 1
   beta: 0.003454
-  BOL: true
   branchVerbosity: debug
   buGroups:
     - 100
   burnSteps: 2
-  clusterExclusive: false
   comment: Simple test input.
   crossSectionControl:
     DA:
@@ -37,7 +35,6 @@ settings:
   db: false
   detailAssemLocationsBOL:
     - 002-001
-  economics: false
   epsBurnTime: 0.001
   epsFSAvg: 1e-06
   epsFSPoint: 1e-06
@@ -51,22 +48,16 @@ settings:
   startCycle: 1
   startNode: 2
   loadPadElevation: 200.0
-  max2SigmaCladIDT: 630.0
   genReports: false
-  maxFlowZones: 12
-  maxRegionDensityIterations: 5
   moduleVerbosity:
     armi.reactor.reactors: info
   nCycles: 6
-  nodeGroup: OnlineNodes,TP
   outputFileExtension: png
-  percentNaReduction: 10.0
   power: 100000000.0
   shuffleLogic: refSmallReactorShuffleLogic.py
   smallRun: true
   summarizeAssemDesign: false
   targetK: 1.002
-  transientForSensitivity: ""
   verbosity: extra
   versions:
     armi: uncontrolled
diff --git a/armi/tests/refTestCartesian.yaml b/armi/tests/refTestCartesian.yaml
index 999b467b..7dee4686 100644
--- a/armi/tests/refTestCartesian.yaml
+++ b/armi/tests/refTestCartesian.yaml
@@ -1,13 +1,10 @@
 settings:
   beta: 0.003454
-  BOL: true
   buGroups:
     - 100
   burnSteps: 0
-  clusterExclusive: false
   comment: Full-core Cartesian input file with a 10x10 cm square pitch.
   cycleLength: 2000.0
-  economics: false
   epsBurnTime: 0.001
   epsFSAvg: 1e-06
   epsFSPoint: 1e-06
@@ -19,14 +16,9 @@ settings:
   loadingFile: refSmallCartesian.yaml
   startNode: 1
   loadPadElevation: 200.0
-  max2SigmaCladIDT: 630.0
-  maxFlowZones: 12
-  maxRegionDensityIterations: 5
   outputFileExtension: png
-  percentNaReduction: 10.0
   power: 400000000.0
   summarizeAssemDesign: false
   targetK: 1.002
-  transientForSensitivity: ""
   versions:
     armi: uncontrolled
diff --git a/armi/tests/test_plugins.py b/armi/tests/test_plugins.py
index 3654721f..209fe733 100644
--- a/armi/tests/test_plugins.py
+++ b/armi/tests/test_plugins.py
@@ -26,6 +26,7 @@ from armi import interfaces
 from armi import plugins
 from armi import settings
 from armi import utils
+from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger
 from armi.physics.neutronics import NeutronicsPlugin
 from armi.reactor.blocks import Block
 from armi.reactor.flags import Flags
@@ -42,13 +43,27 @@ class PluginFlags1(plugins.ArmiPlugin):
         return {"SUPER_FLAG": utils.flags.auto()}
 
 
+class SillyAxialExpansionChanger(AxialExpansionChanger):
+    """Fake, test-specific axial expansion changer that a plugin will register."""
+
+
+class SillyAxialPlugin(plugins.ArmiPlugin):
+    """Trivial plugin that implements the axial expansion hook."""
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def getAxialExpansionChanger() -> type[SillyAxialExpansionChanger]:
+        return SillyAxialExpansionChanger
+
+
 class TestPluginRegistration(unittest.TestCase):
     def setUp(self):
         """
         Manipulate the standard App. We can't just configure our own, since the
         pytest environment bleeds between tests.
         """
-        self._backupApp = deepcopy(getApp())
+        self.app = getApp()
+        self._backupApp = deepcopy(self.app)
 
     def tearDown(self):
         """Restore the App to its original state."""
@@ -92,6 +107,18 @@ class TestPluginRegistration(unittest.TestCase):
         # show the flag exists now
         self.assertEqual(type(Flags.SUPER_FLAG._value), int)
 
+    def test_axialExpansionHook(self):
+        """Test that plugins can override the axial expansion of assemblies via a hook."""
+        pm = self.app.pluginManager
+        first = pm.hook.getAxialExpansionChanger()
+        # By default, make sure we get the armi-shipped expansion class
+        self.assertIs(first, AxialExpansionChanger)
+        pm.register(SillyAxialPlugin)
+        second = pm.hook.getAxialExpansionChanger()
+        # Registering a plugin that implements the hook means we get
+        # that plugin's axial expander
+        self.assertIs(second, SillyAxialExpansionChanger)
+
 
 class TestPluginBasics(unittest.TestCase):
     def test_defineParameters(self):
diff --git a/armi/tests/zpprTest.yaml b/armi/tests/zpprTest.yaml
index 4a597f2c..66094e64 100644
--- a/armi/tests/zpprTest.yaml
+++ b/armi/tests/zpprTest.yaml
@@ -28,13 +28,10 @@ settings:
       numExternalRings: 1
       meshSubdivisionsPerCm: 10
   cycleLength: 365.25
-  economics: false
   epsEig: 1e-10
   genXS: Neutron
   geomFile: zpprTestGeom.xml
   loadingFile: 1DslabXSByCompTest.yaml
-  max2SigmaCladIDT: 630.0
-  maxFlowZones: 12
   mpiTasksPerNode: 6
   numProcessors: 12
   outputFileExtension: pdf