Repository: terrapower/armi

Issue:
Allow a way to efficiently iterate over some parameters
(Pulling a discussion out of https://github.com/terrapower/armi/pull/1877#discussion_r1769233599)

We have a lot of parameters. Not all of them are used in every location. For example, in working on `HexBlock.rotate`, lots of things needs to be rotated. Two cases are
1. Parameters defined on the midpoints and vertices of a hexagon (e.g., with `ParamLocation.CORNERS`)
2. Parameters defined on the children of the block (e.g., with `ParamLocation.CHILDREN`)

For parameters meeting condition 1, we have

https://github.com/terrapower/armi/blob/3dd2763535f0a5694215a0f1f876db49eea3a11c/armi/reactor/blocks.py#L2041-L2042

`ParameterDefinitions.atLocation` involves iterating over all parameters, making an intermediate `ParameterDefinitionCollection`, [extending](https://github.com/terrapower/armi/blob/3dd2763535f0a5694215a0f1f876db49eea3a11c/armi/reactor/parameters/parameterDefinitions.py#L492-L506) to it, twice (once for corners, once for edges).
And then iterate again over the combined names.

In #1877, I added a helper onto `HexBlock` that allowed a pattern like
```python
for param in self._getParamsWhere(lambda pd: pd.atLocation(ParamLocation.CORNERS)):
    pass
```
This allowed for one iteration over all the parameters and used [`filter`](https://docs.python.org/3/library/functions.html#filter) to only produce parameters that met the condition.

This seems more generally useful so let's add it.

## Where?

I don't think it should be added to a composite. Those already have too many methods.

I'm thinking a `ParameterCollection` so you can do something like
```python
assem = r.core[0]
assem.p.where(lambda pd: pd.hasCategory("neutronics"))
```
There could be some confusion about `where` needing a condition for the **values** of the parameter collection, not so much their definitions. The same way [`numpy.where`](https://numpy.org/doc/stable/reference/generated/numpy.where.html#numpy.where) and [`pandas.where`](https://pandas.pydata.org/pandas-docs/stable/reference/api/pandas.DataFrame.where.html#pandas.DataFrame.where) behave. 

So maybe a `ParameterDefinitionCollection`? Then you'd need to do
```python
assem.p.pDefs.where(pd: pd.atLocation(ParamLocation.EDGES))
```
that feels a bit better? Grabbing parameters where their *definition* meets a certain category. Yeah, I think I like that better

Discussion:

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/reactor/parameters/parameterCollections.py b/armi/reactor/parameters/parameterCollections.py
index 82d7d1a3..c9621e54 100644
--- a/armi/reactor/parameters/parameterCollections.py
+++ b/armi/reactor/parameters/parameterCollections.py
@@ -14,7 +14,7 @@
 
 import copy
 import pickle
-from typing import Any, Optional, List, Set
+from typing import Any, Optional, List, Set, Iterator, Callable
 import sys
 
 import numpy as np
@@ -367,7 +367,7 @@ class ParameterCollection(metaclass=_ParameterCollectionType):
         else:
             return name in self._hist
 
-    def __eq__(self, other):
+    def __eq__(self, other: "ParameterCollection"):
         if not isinstance(other, self.__class__):
             return False
 
@@ -382,7 +382,8 @@ class ParameterCollection(metaclass=_ParameterCollectionType):
 
         return True
 
-    def __iter__(self):
+    def __iter__(self) -> Iterator[str]:
+        """Iterate over names of assigned parameters define on this collection."""
         return (
             pd.name
             for pd in self.paramDefs
@@ -501,6 +502,32 @@ class ParameterCollection(metaclass=_ParameterCollectionType):
                 pd.assigned = SINCE_ANYTHING
                 self.assigned = SINCE_ANYTHING
 
+    def where(
+        self, f: Callable[[parameterDefinitions.Parameter], bool]
+    ) -> Iterator[parameterDefinitions.Parameter]:
+        """Produce an iterator over parameters that meet some criteria.
+
+        Parameters
+        ----------
+        f : callable function f(parameter) -> bool
+            Function to check if a parameter should be fetched during the iteration.
+
+        Returns
+        -------
+        iterator of :class:`armi.reactor.parameters.Parameter`
+            Iterator, **not** list or tuple, that produces each parameter that
+            meets ``f(parameter) == True``.
+
+        Examples
+        --------
+        >>> block = r.core[0][0]
+        >>> pdef = block.p.paramDefs
+        >>> for param in pdef.where(lambda pd: pd.atLocation(ParamLocation.EDGES)):
+        ...     print(param.name, block.p[param.name])
+
+        """
+        return filter(f, self.paramDefs)
+
 
 def collectPluginParameters(pm):
     """Apply parameters from plugins to their respective object classes."""
diff --git a/armi/reactor/parameters/parameterDefinitions.py b/armi/reactor/parameters/parameterDefinitions.py
index 472c7367..7ad5ccec 100644
--- a/armi/reactor/parameters/parameterDefinitions.py
+++ b/armi/reactor/parameters/parameterDefinitions.py
@@ -421,6 +421,10 @@ class Parameter:
         """True if parameter is defined at location."""
         return self.location and self.location & loc
 
+    def hasCategory(self, category: str) -> bool:
+        """True if a parameter has a specific category."""
+        return category in self.categories
+
 
 class ParameterDefinitionCollection:
     """
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 24bc4973..45f7dba3 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -14,6 +14,10 @@ New Features
 #. 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>`_)
+#. Provide ``Parameter.hasCategory`` for quickly checking if a parameter is defined with a given category.
+   (`PR#1899 <https://github.com/terrapower/armi/pull/1899>`_)
+#. Provide ``ParameterCollection.where`` for efficient iteration over parameters who's definition.
+   matches a given condition. (`PR#1899 <https://github.com/terrapower/armi/pull/1899>`_)
 #. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion.
    (`PR#1870 <https://github.com/terrapower/armi/pull/1870`_)
 #. TBD


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/reactor/tests/test_parameters.py b/armi/reactor/tests/test_parameters.py
index e2068e8a..38f47141 100644
--- a/armi/reactor/tests/test_parameters.py
+++ b/armi/reactor/tests/test_parameters.py
@@ -456,10 +456,18 @@ class ParameterTests(unittest.TestCase):
         self.assertEqual(p2.categories, set(["awesome", "stuff", "bacon"]))
         self.assertEqual(p3.categories, set(["bacon"]))
 
+        for p in [p1, p2, p3]:
+            self._testCategoryConsistency(p)
+
         self.assertEqual(set(pc.paramDefs.inCategory("awesome")), set([p1, p2]))
         self.assertEqual(set(pc.paramDefs.inCategory("stuff")), set([p1, p2]))
         self.assertEqual(set(pc.paramDefs.inCategory("bacon")), set([p2, p3]))
 
+    def _testCategoryConsistency(self, p: parameters.Parameter):
+        for category in p.categories:
+            self.assertTrue(p.hasCategory(category))
+        self.assertFalse(p.hasCategory("this_shouldnot_exist"))
+
     def test_parameterCollectionsHave__slots__(self):
         """Tests we prevent accidental creation of attributes."""
         self.assertEqual(
@@ -502,3 +510,80 @@ class ParameterTests(unittest.TestCase):
         pcc = MockPCChild()
         with self.assertRaises(AssertionError):
             pcc.whatever = 33
+
+
+class ParamCollectionWhere(unittest.TestCase):
+    """Tests for ParameterCollection.where."""
+
+    class ScopeParamCollection(parameters.ParameterCollection):
+        pDefs = parameters.ParameterDefinitionCollection()
+        with pDefs.createBuilder() as pb:
+            pb.defParam(
+                name="empty",
+                description="Bare",
+                location=None,
+                categories=None,
+                units="",
+            )
+            pb.defParam(
+                name="keff",
+                description="keff",
+                location=parameters.ParamLocation.VOLUME_INTEGRATED,
+                categories=[parameters.Category.neutronics],
+                units="",
+            )
+            pb.defParam(
+                name="cornerFlux",
+                description="corner flux",
+                location=parameters.ParamLocation.CORNERS,
+                categories=[
+                    parameters.Category.neutronics,
+                ],
+                units="",
+            )
+            pb.defParam(
+                name="edgeTemperature",
+                description="edge temperature",
+                location=parameters.ParamLocation.EDGES,
+                categories=[parameters.Category.thermalHydraulics],
+                units="",
+            )
+
+    @classmethod
+    def setUpClass(cls) -> None:
+        """Define a couple useful parameters with categories, locations, etc."""
+        cls.pc = cls.ScopeParamCollection()
+
+    def test_onCategory(self):
+        """Test the use of Parameter.hasCategory on filtering."""
+        names = {"keff", "cornerFlux"}
+        for p in self.pc.where(
+            lambda pd: pd.hasCategory(parameters.Category.neutronics)
+        ):
+            self.assertTrue(p.hasCategory(parameters.Category.neutronics), msg=p)
+            names.remove(p.name)
+        self.assertFalse(names, msg=f"{names=} should be empty!")
+
+    def test_onLocation(self):
+        """Test the use of Parameter.atLocation in filtering."""
+        names = {"edgeTemperature"}
+        for p in self.pc.where(
+            lambda pd: pd.atLocation(parameters.ParamLocation.EDGES)
+        ):
+            self.assertTrue(p.atLocation(parameters.ParamLocation.EDGES), msg=p)
+            names.remove(p.name)
+        self.assertFalse(names, msg=f"{names=} should be empty!")
+
+    def test_complicated(self):
+        """Test a multi-condition filter."""
+        names = {"cornerFlux"}
+
+        def check(p: parameters.Parameter) -> bool:
+            return p.atLocation(parameters.ParamLocation.CORNERS) and p.hasCategory(
+                parameters.Category.neutronics
+            )
+
+        for p in self.pc.where(check):
+            self.assertTrue(check(p), msg=p)
+            names.remove(p.name)
+        self.assertFalse(names, msg=f"{names=} should be empty")
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/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