Repository: terrapower/armi

Issue:
Provide more Composite.iter* methods
ARMI does a lot of iteration over the composite tree. Understandably so, we have lots of methods that support iterating over children of an object, with some filtering. These usually follow the pattern of `parent.getChildren*` like `Composite.getChildren`, `Composite.getChildrenWithFlags`, and `Composite.getChildrenOfType`, further expanded to things like `Assembly.getBlocks`.

A lot of instances of the usage of these `get*` methods are for iteration

https://github.com/terrapower/armi/blob/96a5c88d6167cee989378599b6d341c351c291bd/armi/reactor/components/component.py#L965

https://github.com/terrapower/armi/blob/96a5c88d6167cee989378599b6d341c351c291bd/armi/reactor/converters/axialExpansionChanger/expansionData.py#L253

https://github.com/terrapower/armi/blob/96a5c88d6167cee989378599b6d341c351c291bd/armi/reactor/blocks.py#L633

The tradeoff is these `get*` methods create and populate lists, and then return those lists back to the user. This is great if you need to do multiple iterations or check sizing of the returned object, but it's inefficient for iteration.

I propose we provide `iter*` methods with similar signatures as their `get*` counterparts that do not return lists of things. Instead, they could `yield` back components, produce a `filter` object, etc. something that allows one-pass iteration over things. The `get*` methods could then be distilled to
```python
def getChildrenWithFlags(self, ...):
    return list(self.iterChildrenWithFlags(...))
```
for backwards compatibility.

## Candidate methods
To keep the scope clear, I propose we add, test, and use (where appropriate)
- `Composite.iterChildren`
- `Composite.iterChildrenWithFlags`
- `Composite.iterChildrenOfType`

There are other composite subclasses that I believe could benefit from more iteration methods. But for start, I'll keep the scope limited to `Composite` methods

Discussion:
@drewj-tp: # Future work
Things that could be looked at later
- `Assembly.iterBlocks`
- `Assembly.iterBlocksBetweenHeights`
- `Block.iterComponentsThatAreLinkedTo`
- `Core.iterAssemblies`
- `Core.iterAssembliesInRing`
- `Core.iterBlocks`

Non-exhaustive@john-science: ```
Things that could be looked at later

    Assembly.iterBlocks
    Assembly.iterBlocksBetweenHeights
    Block.iterComponentsThatAreLinkedTo
    Core.iterAssemblies
    Core.iterAssembliesInRing
    Core.iterBlocks
```

If I were to be choosy, I would do `Core.iterAssemblies` and `Core.iterAssembliesInRing` last. In an ideal world I would wait until we separate out `Core` and `HexCore` or something.

Just thinking aloud.@drewj-tp: > If I were to be choosy, I would do Core.iterAssemblies and Core.iterAssembliesInRing last. In an ideal world I would wait until we separate out Core and HexCore or something.

Agree. I don't intend on tackling things outside `Composite.iter*` in the first go@drewj-tp: Some additional digging.

`Composite.getChildren` has an option that also adds the materials to the list of returned things found in the recursive dig. That feels weird.

That mode, `includeMaterials=True` is only used twice in armi and internal projects I can access. Both instances are for MPI related distribution and syncing

https://github.com/terrapower/armi/blob/f1f3dbea05c2564404a03d8e053f72c2af15daa9/armi/reactor/composites.py#L2778

https://github.com/terrapower/armi/blob/f1f3dbea05c2564404a03d8e053f72c2af15daa9/armi/reactor/composites.py#L2884

But `Composite.getChildren` is used heavily! And a lot of iteration, e.g., `for c in thing.getChildren`. But in a lot of cases, where no arguments are provided, it seems to be a way to just iterate over the children of a composite. Which is naturally supported with `__iter__`

https://github.com/terrapower/armi/blob/f1f3dbea05c2564404a03d8e053f72c2af15daa9/armi/reactor/composites.py#L2535-L2536

There are few recursive usage of `getChildren`, like for VTK visualization

https://github.com/terrapower/armi/blob/f1f3dbea05c2564404a03d8e053f72c2af15daa9/armi/bookkeeping/visualization/vtk.py#L100-L103

So maybe some wide-spread improvements could be

1. Replace single-level, material-less usage of `for child in parent.getChildren` with plain iteration `for child in parent`.
2. Recursive iterator that digs through the composite tree, completely or at a specific `generationNum`
3. Update the recursive `getChildren`, with materials, to use the recursive iterator

Solution patch:
diff --git a/armi/bookkeeping/db/database.py b/armi/bookkeeping/db/database.py
index c2d8f2ed..d333cc05 100644
--- a/armi/bookkeeping/db/database.py
+++ b/armi/bookkeeping/db/database.py
@@ -828,11 +828,11 @@ class Database:
         return r
 
     @staticmethod
-    def _setParamsBeforeFreezing(r):
+    def _setParamsBeforeFreezing(r: Reactor):
         """Set some special case parameters before they are made read-only."""
-        for child in r.getChildren(deep=True):
-            if not isinstance(child, Component):
-                continue
+        for child in r.iterChildren(
+            deep=True, predicate=lambda c: isinstance(c, Component)
+        ):
             # calling Component.getVolume() sets the volume parameter
             child.getVolume()
 
diff --git a/armi/bookkeeping/report/reportInterface.py b/armi/bookkeeping/report/reportInterface.py
index 05253894..3967dd3d 100644
--- a/armi/bookkeeping/report/reportInterface.py
+++ b/armi/bookkeeping/report/reportInterface.py
@@ -172,7 +172,7 @@ class ReportInterface(interfaces.Interface):
         runLog.important(title)
         runLog.important("-" * len(title))
         totFis = 0.0
-        for a in sfp.getChildren():
+        for a in sfp:
             runLog.important(
                 "{assembly:15s} discharged at t={dTime:10f} after {residence:10f} yrs. It entered at cycle: {cycle}. "
                 "It has {fiss:10f} kg (x {mult}) fissile and peak BU={bu:.2f} %.".format(
@@ -196,16 +196,16 @@ class ReportInterface(interfaces.Interface):
     @staticmethod
     def countAssembliesSFP(sfp):
         """Report on the count of assemblies in the SFP at each timestep."""
-        if not sfp.getChildren():
+        if not len(sfp):
             return
 
         runLog.important("Count:")
         totCount = 0
         thisTimeCount = 0
-        a = sfp.getChildren()[0]
+        a = sfp[0]
         lastTime = a.getAge() / units.DAYS_PER_YEAR + a.p.chargeTime
 
-        for a in sfp.getChildren():
+        for a in sfp:
             thisTime = a.getAge() / units.DAYS_PER_YEAR + a.p.chargeTime
 
             if thisTime != lastTime:
diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py
index 9d5fb952..4fb373c7 100644
--- a/armi/physics/fuelCycle/fuelHandlers.py
+++ b/armi/physics/fuelCycle/fuelHandlers.py
@@ -653,7 +653,7 @@ class FuelHandler:
                 f"or otherwise instantiate a SpentFuelPool object as r.excore['sfp']"
             )
         else:
-            sfpAssems = self.r.excore["sfp"].getChildren()
+            sfpAssems = list(self.r.excore["sfp"])
 
         assemblyList = [[] for _i in range(len(ringList))]  # empty lists for each ring
         if exclusions is None:
diff --git a/armi/physics/neutronics/globalFlux/globalFluxInterface.py b/armi/physics/neutronics/globalFlux/globalFluxInterface.py
index f69a688b..e127a67e 100644
--- a/armi/physics/neutronics/globalFlux/globalFluxInterface.py
+++ b/armi/physics/neutronics/globalFlux/globalFluxInterface.py
@@ -256,7 +256,7 @@ class GlobalFluxInterfaceUsingExecuters(GlobalFluxInterface):
             return self.r.core.p.keff
         if self.coupler.parameter == "power":
             scaledCorePowerDistribution = []
-            for a in self.r.core.getChildren():
+            for a in self.r.core:
                 scaledPower = []
                 assemPower = sum(b.p.power for b in a)
                 for b in a:
diff --git a/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py b/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py
index a7921e3b..3103f7f3 100644
--- a/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py
+++ b/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py
@@ -223,7 +223,7 @@ def makeReactionRateTable(obj, nuclides: List = None):
         for nucName in nuclides
     }
 
-    for armiObject in obj.getChildren():
+    for armiObject in obj:
         for nucName in nuclides:
             rxnRates = armiObject.getReactionRates(nucName, nDensity=1.0)
             for rxName, rxRate in rxnRates.items():
diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py
index eaf5886c..12dc0d06 100644
--- a/armi/reactor/assemblies.py
+++ b/armi/reactor/assemblies.py
@@ -306,10 +306,8 @@ class Assembly(composites.Composite):
         -------
         This is a bit design-specific for pinned assemblies
         """
-        plenumBlocks = self.getBlocks(Flags.PLENUM)
-
         plenumVolume = 0.0
-        for b in plenumBlocks:
+        for b in self.iterChildrenWithFlags(Flags.PLENUM):
             cladId = b.getComponent(Flags.CLAD).getDimension("id")
             length = b.getHeight()
             plenumVolume += (
@@ -319,7 +317,7 @@ class Assembly(composites.Composite):
 
     def getAveragePlenumTemperature(self):
         """Return the average of the plenum block outlet temperatures."""
-        plenumBlocks = self.getBlocks(Flags.PLENUM)
+        plenumBlocks = self.iterChildrenWithFlags(Flags.PLENUM)
         plenumTemps = [b.p.THcoolantOutletT for b in plenumBlocks]
 
         # no plenum blocks, use the top block of the assembly for plenum temperature
@@ -786,7 +784,7 @@ class Assembly(composites.Composite):
         with open(fName, "w") as pkl:
             pickle.dump(self, pkl)
 
-    def getBlocks(self, typeSpec=None, exact=False):
+    def getBlocks(self, typeSpec=None, exact=False) -> list[blocks.Block]:
         """
         Get blocks in an assembly from bottom to top.
 
@@ -803,9 +801,10 @@ class Assembly(composites.Composite):
             List of blocks.
         """
         if typeSpec is None:
-            return self.getChildren()
+            items = iter(self)
         else:
-            return self.getChildrenWithFlags(typeSpec, exactMatch=exact)
+            items = self.iterChildrenWithFlags(typeSpec, exact)
+        return list(items)
 
     def getBlocksAndZ(self, typeSpec=None, returnBottomZ=False, returnTopZ=False):
         """
@@ -853,37 +852,48 @@ class Assembly(composites.Composite):
         return zip(blocks, zCoords)
 
     def hasContinuousCoolantChannel(self):
-        return all(
-            b.containsAtLeastOneChildWithFlags(Flags.COOLANT) for b in self.getBlocks()
-        )
+        return all(b.containsAtLeastOneChildWithFlags(Flags.COOLANT) for b in self)
 
     def getFirstBlock(self, typeSpec=None, exact=False):
-        bs = self.getBlocks(typeSpec, exact=exact)
-        if bs:
-            return bs[0]
+        """Find the first block that matches the spec.
+
+        Parameters
+        ----------
+        typeSpec : flag or list of flags, optional
+            Specification to require on the returned block.
+        exact : bool, optional
+            Require block to exactly match ``typeSpec``
+
+        Returns
+        -------
+        Block or None
+            First block that matches if such a block could be found.
+        """
+        if typeSpec is None:
+            items = iter(self)
         else:
+            items = self.iterChildrenWithFlags(typeSpec, exact)
+        try:
+            # Create an iterator and attempt to advance it to the first value.
+            return next(items)
+        except StopIteration:
+            # No items found in the iteration -> no blocks match the request
             return None
 
     def getFirstBlockByType(self, typeName):
-        bs = [
-            b
-            for b in self.getChildren(deep=False)
-            if isinstance(b, blocks.Block) and b.getType() == typeName
-        ]
-        if bs:
-            return bs[0]
-        return None
+        blocks = filter(lambda b: b.getType() == typeName, self)
+        try:
+            return next(blocks)
+        except StopIteration:
+            return None
 
-    def getBlockAtElevation(self, elevation):
+    def getBlockAtElevation(self, elevation: float) -> Optional[blocks.Block]:
         """
         Returns the block at a specified axial dimension elevation (given in cm).
 
         If height matches the exact top of the block, the block is considered at that
         height.
 
-        Used as a way to determine what block the control rod will be modifying with a
-        mergeBlocks.
-
         Parameters
         ----------
         elevation : float
@@ -891,8 +901,9 @@ class Assembly(composites.Composite):
 
         Returns
         -------
-        targetBlock : block
-            The block that exists at the specified height in the reactor
+        targetBlock : block or None
+            The block that exists at the specified height in the reactor. ``None``
+            if a block was not found.
         """
         bottomOfBlock = 0.0
         for b in self:
@@ -1163,7 +1174,11 @@ class Assembly(composites.Composite):
         blockCounter : int
             number of blocks of this type
         """
-        return len(self.getBlocks(blockTypeSpec))
+        if blockTypeSpec is None:
+            items = iter(self)
+        else:
+            items = self.iterChildrenWithFlags(blockTypeSpec)
+        return sum(1 for _ in items)
 
     def getDim(self, typeSpec, dimName):
         """
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index b67d4ccd..ea3b5d08 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -624,7 +624,7 @@ class Block(composites.Composite):
             return area
 
         a = 0.0
-        for c in self.getChildren():
+        for c in self:
             myArea = c.getArea(cold=cold)
             a += myArea
         fullArea = a
@@ -917,7 +917,7 @@ class Block(composites.Composite):
         self._updatePitchComponent(c)
 
     def removeAll(self, recomputeAreaFractions=True):
-        for c in self.getChildren():
+        for c in list(self):
             self.remove(c, recomputeAreaFractions=False)
         if recomputeAreaFractions:  # only do this once
             self.getVolumeFractions()
@@ -1255,7 +1255,7 @@ class Block(composites.Composite):
     def getDimensions(self, dimension):
         """Return dimensional values of the specified dimension."""
         dimVals = set()
-        for c in self.getChildren():
+        for c in self:
             try:
                 dimVal = c.getDimension(dimension)
             except parameters.ParameterError:
@@ -1504,7 +1504,7 @@ class Block(composites.Composite):
         :meth:`getPinCoordinates` - companion for this method.
         """
         items = []
-        for clad in self.getChildrenWithFlags(Flags.CLAD):
+        for clad in self.iterChildrenWithFlags(Flags.CLAD):
             if isinstance(clad.spatialLocator, grids.MultiIndexLocation):
                 items.extend(clad.spatialLocator)
             else:
diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py
index aa31dddd..1fd6f78b 100644
--- a/armi/reactor/blueprints/__init__.py
+++ b/armi/reactor/blueprints/__init__.py
@@ -521,7 +521,7 @@ class Blueprints(yamlize.Object, metaclass=_BlueprintsPluginCollector):
                     runLog.error("CURRENT COMPARISON BLOCK:")
                     b.printContents(includeNuclides=False)
 
-                    for c in b.getChildren():
+                    for c in b:
                         runLog.error(
                             "{0} area {1} effective area {2}"
                             "".format(c, c.getArea(), c.getVolume() / b.getHeight())
diff --git a/armi/reactor/components/__init__.py b/armi/reactor/components/__init__.py
index 8035e382..8193a998 100644
--- a/armi/reactor/components/__init__.py
+++ b/armi/reactor/components/__init__.py
@@ -396,7 +396,7 @@ class DerivedShape(UnshapedComponent):
         # Determine the volume/areas of the non-derived shape components within the parent.
         siblingVolume = 0.0
         siblingArea = 0.0
-        for sibling in self.parent.getChildren():
+        for sibling in self.parent:
             if sibling is self:
                 continue
             elif not self and isinstance(sibling, DerivedShape):
diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py
index e645bcc6..326f4d5c 100644
--- a/armi/reactor/components/component.py
+++ b/armi/reactor/components/component.py
@@ -1035,7 +1035,7 @@ class Component(composites.Composite, metaclass=ComponentType):
     def getLinkedComponents(self):
         """Find other components that are linked to this component."""
         dependents = []
-        for child in self.parent.getChildren():
+        for child in self.parent:
             for dimName in child.DIMENSION_NAMES:
                 isLinked = child.dimensionIsLinked(dimName)
                 if isLinked and child.p[dimName].getLinkedComponent() is self:
diff --git a/armi/reactor/composites.py b/armi/reactor/composites.py
index 66a6f17c..cfd61527 100644
--- a/armi/reactor/composites.py
+++ b/armi/reactor/composites.py
@@ -36,7 +36,7 @@ import collections
 import itertools
 import operator
 import timeit
-from typing import Dict, List, Optional, Tuple, Type, Union
+from typing import Callable, Dict, Iterator, List, Optional, Tuple, Type, Union
 
 import numpy as np
 
@@ -468,7 +468,7 @@ class ArmiObject(metaclass=CompositeModelType):
     def clearCache(self):
         """Clear the cache so all new values are recomputed."""
         self.cached = {}
-        for child in self.getChildren():
+        for child in self:
             child.clearCache()
 
     def _getCached(self, name):  # TODO: stop the "returns None" nonsense?
@@ -518,13 +518,40 @@ class ArmiObject(metaclass=CompositeModelType):
         for paramName, val in new.p.items():
             self.p[paramName] = val
 
-    def getChildren(self, deep=False, generationNum=1, includeMaterials=False):
+    def iterChildren(
+        self,
+        deep=False,
+        generationNum=1,
+        predicate: Optional[Callable[["ArmiObject"], bool]] = None,
+    ) -> Iterator["ArmiObject"]:
+        """Iterate over children of this object."""
+        raise NotImplementedError()
+
+    def getChildren(
+        self, deep=False, generationNum=1, includeMaterials=False
+    ) -> list["ArmiObject"]:
         """Return the children of this object."""
-        raise NotImplementedError
+        raise NotImplementedError()
 
-    def getChildrenWithFlags(self, typeSpec: TypeSpec, exactMatch=True):
+    def iterChildrenWithFlags(
+        self, typeSpec: TypeSpec, exactMatch=False
+    ) -> Iterator["ArmiObject"]:
+        """Produce an iterator of children that have given flags."""
+        return self.iterChildren(predicate=lambda o: o.hasFlags(typeSpec, exactMatch))
+
+    def getChildrenWithFlags(
+        self, typeSpec: TypeSpec, exactMatch=False
+    ) -> list["ArmiObject"]:
         """Get all children that have given flags."""
-        raise NotImplementedError
+        return list(self.iterChildrenWithFlags(typeSpec, exactMatch))
+
+    def iterChildrenOfType(self, typeName: str) -> Iterator["ArmiObject"]:
+        """Iterate over children that have a specific input type name."""
+        return self.iterChildren(predicate=lambda o: o.getType() == typeName)
+
+    def getChildrenOfType(self, typeName: str) -> list["ArmiObject"]:
+        """Produce a list of children that have a specific input type name."""
+        return list(self.iterChildrenOfType(typeName))
 
     def getComponents(self, typeSpec: TypeSpec = None, exact=False):
         """
@@ -610,7 +637,7 @@ class ArmiObject(metaclass=CompositeModelType):
         """
         for paramName in paramNames:
             myVal = self.p[paramName]
-            for c in self.getChildren():
+            for c in self:
                 c.p[paramName] = myVal
 
     @classmethod
@@ -883,7 +910,7 @@ class ArmiObject(metaclass=CompositeModelType):
         """
         raise NotImplementedError()
 
-    def getMass(self, nuclideNames=None):
+    def getMass(self, nuclideNames=None) -> float:
         """
         Determine the mass in grams of nuclide(s) and/or elements in this object.
 
@@ -906,7 +933,7 @@ class ArmiObject(metaclass=CompositeModelType):
         mass : float
             The mass in grams.
         """
-        return sum([c.getMass(nuclideNames=nuclideNames) for c in self])
+        return sum(c.getMass(nuclideNames=nuclideNames) for c in self)
 
     def getMassFrac(self, nucName):
         """
@@ -1616,7 +1643,7 @@ class ArmiObject(metaclass=CompositeModelType):
         self._lumpedFissionProducts = lfpCollection
 
     def setChildrenLumpedFissionProducts(self, lfpCollection):
-        for c in self.getChildren():
+        for c in self:
             c.setLumpedFissionProducts(lfpCollection)
 
     def getFissileMassEnrich(self):
@@ -1917,7 +1944,7 @@ class ArmiObject(metaclass=CompositeModelType):
             List of nuclide names that exist in this
         """
         nucs = set()
-        for child in self.getChildren():
+        for child in self:
             nucs.update(child.getNuclides())
         return nucs
 
@@ -2495,6 +2522,8 @@ class Composite(ArmiObject):
 
     """
 
+    _children: list["Composite"]
+
     def __init__(self, name):
         ArmiObject.__init__(self, name)
         self.childrenByLocator = {}
@@ -2592,9 +2621,106 @@ class Composite(ArmiObject):
         for c in items:
             self.add(c)
 
+    def iterChildren(
+        self,
+        deep=False,
+        generationNum=1,
+        predicate: Optional[Callable[["Composite"], bool]] = None,
+    ) -> Iterator["Composite"]:
+        """Iterate over children objects of this composite.
+
+        Parameters
+        ----------
+        deep : bool, optional
+            If true, traverse the entire composite tree. Otherwise, go as far as ``generationNum``.
+        generationNum: int, optional
+            Produce composites at this depth. A depth of ``1`` includes children of ``self``, ``2``
+            is children of children, and so on.
+        predicate: f(Composite) -> bool, optional
+            Function to check on a composite before producing it. All items in the iteration
+            will pass this check.
+
+        Returns
+        -------
+        iterator of Composite
+
+        See Also
+        --------
+        :meth:`getChildren` produces a list for situations where you need to perform
+        multiple iterations or do list operations (append, indexing, sorting, containment, etc.)
+
+        Composites are naturally iterable. The following are identical::
+
+            >>> for child in c.getChildren():
+            ...     pass
+            >>> for child in c.iterChildren():
+            ...     pass
+            >>> for child in c:
+            ...     pass
+
+        If you do not need any depth-traversal, natural iteration should be sufficient.
+
+        The :func:`filter` command may be sufficient if you do not wish to pass a predicate. The following
+        are identical::
+            >>> checker = lambda c: len(c.name) % 3
+            >>> for child in c.getChildren(predicate=checker):
+            ...     pass
+            >>> for child in c.iterChildren(predicate=checker):
+            ...     pass
+            >>> for child in filter(checker, c):
+            ...     pass
+
+        If you're going to be doing traversal beyond the first generation, this method will help you.
+
+        """
+        if deep and generationNum > 1:
+            raise RuntimeError(
+                "Cannot get children with a generation number set and the deep flag set"
+            )
+        if predicate is None:
+            checker = lambda _: True
+        else:
+            checker = predicate
+        yield from self._iterChildren(deep, generationNum, checker)
+
+    def _iterChildren(
+        self, deep: bool, generationNum: int, checker: Callable[["Composite"], bool]
+    ) -> Iterator["Composite"]:
+        if deep or generationNum == 1:
+            yield from filter(checker, self)
+        if deep or generationNum > 1:
+            for c in self:
+                yield from c._iterChildren(deep, generationNum - 1, checker)
+
+    def iterChildrenWithMaterials(self, *args, **kwargs) -> Iterator:
+        """Produce an iterator that also includes any materials found on descendants.
+
+        Arguments are forwarded to :meth:`iterChildren` and control the depth of traversal
+        and filtering of objects.
+
+        This is useful for sending state across MPI tasks where you need a more full
+        representation of the composite tree. Which includes the materials attached
+        to components.
+        """
+        children = self.iterChildren(*args, **kwargs)
+        # Each entry is either (c, ) or (c, c.material) if the child has a material attribute
+        stitched = map(
+            lambda c: (
+                (c,) if getattr(c, "material", None) is None else (c, c.material)
+            ),
+            children,
+        )
+        # Iterator that iterates over each "sub" iterator. If we have ((c0, ), (c1, m1)), this produces a single
+        # iterator of (c0, c1, m1)
+        return itertools.chain.from_iterable(stitched)
+
     def getChildren(
-        self, deep=False, generationNum=1, includeMaterials=False, predicate=None
-    ):
+        self,
+        deep=False,
+        generationNum=1,
+        includeMaterials=False,
+        predicate: Optional[Callable[["Composite"], bool]] = None,
+    ) -> list["Composite"]:
         """
         Return the children objects of this composite.
 
@@ -2636,6 +2762,11 @@ class Composite(ArmiObject):
             to meet the predicate only affects the object in question; children will
             still be considered.
 
+        See Also
+        --------
+        :meth:`iterChildren` if you do not need to produce a full list, e.g., just iterating
+        over objects.
+
         Examples
         --------
         >>> obj.getChildren()
@@ -2652,49 +2783,15 @@ class Composite(ArmiObject):
         [grandchild1, grandchild3]
 
         """
-        _pred = predicate or (lambda x: True)
-        if deep and generationNum > 1:
-            raise RuntimeError(
-                "Cannot get children with a generation number set and the deep flag set"
+        if not includeMaterials:
+            items = self.iterChildren(
+                deep=deep, generationNum=generationNum, predicate=predicate
             )
-
-        children = []
-        for child in self._children:
-            if generationNum == 1 or deep:
-                if _pred(child):
-                    children.append(child)
-
-            if generationNum > 1 or deep:
-                children.extend(
-                    child.getChildren(
-                        deep=deep,
-                        generationNum=generationNum - 1,
-                        includeMaterials=includeMaterials,
-                        predicate=predicate,
-                    )
-                )
-        if includeMaterials:
-            material = getattr(self, "material", None)
-            if material:
-                children.append(material)
-
-        return children
-
-    def getChildrenWithFlags(self, typeSpec: TypeSpec, exactMatch=False):
-        """Get all children of a specific type."""
-        children = []
-        for child in self:
-            if child.hasFlags(typeSpec, exact=exactMatch):
-                children.append(child)
-        return children
-
-    def getChildrenOfType(self, typeName):
-        """Get children that have a specific input type name."""
-        children = []
-        for child in self:
-            if child.getType() == typeName:
-                children.append(child)
-        return children
+        else:
+            items = self.iterChildrenWithMaterials(
+                deep=deep, generationNum=generationNum, predicate=predicate
+            )
+        return list(items)
 
     def getComponents(self, typeSpec: TypeSpec = None, exact=False):
         return list(self.iterComponents(typeSpec, exact))
@@ -2748,8 +2845,11 @@ class Composite(ArmiObject):
 
         startTime = timeit.default_timer()
         # sync parameters...
-        allComps = [self] + self.getChildren(deep=True, includeMaterials=True)
-        allComps = [c for c in allComps if hasattr(c, "p")]
+        genItems = itertools.chain(
+            [self],
+            self.iterChildrenWithMaterials(deep=True),
+        )
+        allComps = [c for c in genItems if hasattr(c, "p")]
         sendBuf = [c.p.getSyncData() for c in allComps]
         runLog.debug("syncMpiState has {} comps".format(len(allComps)))
 
@@ -2854,7 +2954,11 @@ class Composite(ArmiObject):
         SINCE_LAST_DISTRIBUTE_STATE.
         """
         paramDefs = set()
-        for child in [self] + self.getChildren(deep=True, includeMaterials=True):
+        items = itertools.chain(
+            [self],
+            self.iterChildrenWithMaterials(deep=True),
+        )
+        for child in items:
             # Materials don't have a "p" / Parameter attribute to sync
             if hasattr(child, "p"):
                 # below reads as: assigned & everything_but(SINCE_LAST_DISTRIBUTE_STATE)
@@ -2935,7 +3039,7 @@ class Composite(ArmiObject):
         """
         lfps = ArmiObject.getLumpedFissionProductCollection(self)
         if lfps is None:
-            for c in self.getChildren():
+            for c in self:
                 lfps = c.getLumpedFissionProductCollection()
                 if lfps is not None:
                     break
@@ -3073,7 +3177,7 @@ class Composite(ArmiObject):
     def printContents(self, includeNuclides=True):
         """Display information about all the comprising children in this object."""
         runLog.important(self)
-        for c in self.getChildren():
+        for c in self:
             c.printContents(includeNuclides=includeNuclides)
 
     def _genChildByLocationLookupTable(self):
@@ -3119,7 +3223,7 @@ class StateRetainer:
 
     """
 
-    def __init__(self, composite, paramsToApply=None):
+    def __init__(self, composite: Composite, paramsToApply=None):
         """
         Create an instance of a StateRetainer.
 
@@ -3147,9 +3251,11 @@ class StateRetainer:
         ``backUp()`` or ``restoreBackup()``.
         """
         paramDefs = set()
-        for child in [self.composite] + self.composite.getChildren(
-            deep=True, includeMaterials=True
-        ):
+        items = itertools.chain(
+            (self.composite,),
+            self.composite.iterChildrenWithMaterials(deep=True),
+        )
+        for child in items:
             if hasattr(child, "p"):
                 # materials don't have Parameters
                 paramDefs.update(child.p.paramDefs)
diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py
index b3aa73ae..3620a1e6 100644
--- a/armi/reactor/converters/axialExpansionChanger/expansionData.py
+++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py
@@ -288,19 +288,17 @@ class ExpansionData:
         if flagOfInterest is None:
             # Follow expansion of most neutronically important component, fuel then control/poison
             for targetFlag in TARGET_FLAGS_IN_PREFERRED_ORDER:
-                candidates = [c for c in b.getChildren() if c.hasFlags(targetFlag)]
+                candidates = b.getChildrenWithFlags(targetFlag)
                 if candidates:
                     break
             # some blocks/components are not included in the above list but should still be found
             if not candidates:
                 candidates = [c for c in b.getChildren() if c.p.flags in b.p.flags]
         else:
-            candidates = [c for c in b.getChildren() if c.hasFlags(flagOfInterest)]
+            candidates = b.getChildrenWithFlags(flagOfInterest)
         if len(candidates) == 0:
             # if only 1 solid, be smart enought to snag it
-            solidMaterials = list(
-                c for c in b if not isinstance(c.material, material.Fluid)
-            )
+            solidMaterials = getSolidComponents(b)
             if len(solidMaterials) == 1:
                 candidates = solidMaterials
         if len(candidates) == 0:
diff --git a/armi/reactor/reactorParameters.py b/armi/reactor/reactorParameters.py
index 70ea6306..3c7c4702 100644
--- a/armi/reactor/reactorParameters.py
+++ b/armi/reactor/reactorParameters.py
@@ -789,5 +789,5 @@ def makeParametersReadOnly(r):
     Once you make one Reactor read-only, you cannot make it writeable again.
     """
     r.p.readOnly = True
-    for child in r.getChildren(deep=True):
+    for child in r.iterChildren(deep=True):
         child.p.readOnly = True
diff --git a/armi/reactor/spentFuelPool.py b/armi/reactor/spentFuelPool.py
index d6a224a3..b4568350 100644
--- a/armi/reactor/spentFuelPool.py
+++ b/armi/reactor/spentFuelPool.py
@@ -77,7 +77,7 @@ class SpentFuelPool(ExcoreStructure):
 
     def getAssembly(self, name):
         """Get a specific assembly by name."""
-        for a in self.getChildren():
+        for a in self:
             if a.getName() == name:
                 return a
 
@@ -123,7 +123,7 @@ class SpentFuelPool(ExcoreStructure):
             The new max Assembly number.
         """
         ind = startIndex
-        for a in self.getChildren():
+        for a in self:
             oldName = a.getName()
             newName = a.makeNameFromAssemNum(ind)
             if oldName == newName:


Test patch:
diff --git a/armi/physics/fuelCycle/tests/test_fuelHandlers.py b/armi/physics/fuelCycle/tests/test_fuelHandlers.py
index 958dd6f1..fd13b63d 100644
--- a/armi/physics/fuelCycle/tests/test_fuelHandlers.py
+++ b/armi/physics/fuelCycle/tests/test_fuelHandlers.py
@@ -468,7 +468,7 @@ class TestFuelHandler(FuelHandlerTestHelper):
             self.r.p.cycle = cycle
             fh.cycle = cycle
             fh.manageFuel(cycle)
-            for a in self.r.excore["sfp"].getChildren():
+            for a in self.r.excore["sfp"]:
                 self.assertEqual(a.getLocation(), "SFP")
             for b in self.r.core.getBlocks(Flags.FUEL):
                 self.assertGreater(b.p.kgHM, 0.0, "b.p.kgHM not populated!")
@@ -494,7 +494,7 @@ class TestFuelHandler(FuelHandlerTestHelper):
         ensure repeatability.
         """
         # check labels before shuffling:
-        for a in self.r.excore["sfp"].getChildren():
+        for a in self.r.excore["sfp"]:
             self.assertEqual(a.getLocation(), "SFP")
 
         # do some shuffles
@@ -528,7 +528,7 @@ class TestFuelHandler(FuelHandlerTestHelper):
         # make sure the shuffle was repeated perfectly.
         for a in self.r.core.getAssemblies():
             self.assertEqual(a.getName(), firstPassResults[a.getLocation()])
-        for a in self.r.excore["sfp"].getChildren():
+        for a in self.r.excore["sfp"]:
             self.assertEqual(a.getLocation(), "SFP")
 
         # Do some cleanup, since the fuelHandler Interface has code that gets
@@ -803,7 +803,7 @@ class TestFuelHandler(FuelHandlerTestHelper):
 
         # grab an arbitrary fuel assembly from the core and from the SFP
         a1 = self.r.core.getAssemblies(Flags.FUEL)[0]
-        a2 = self.r.excore["sfp"].getChildren(Flags.FUEL)[0]
+        a2 = self.r.excore["sfp"].getChildrenWithFlags(Flags.FUEL)[0]
 
         # grab the stationary blocks pre swap
         a1PreSwapStationaryBlocks = [
diff --git a/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py b/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py
index 53aa51e5..95ba51fd 100644
--- a/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py
+++ b/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py
@@ -280,7 +280,7 @@ class TestGlobalFluxInterfaceWithExecuters(unittest.TestCase):
         self._setTightCouplingTrue()
         self.assertEqual(self.gfi.getTightCouplingValue(), 1.0)  # set in setUp
         self.gfi.coupler.parameter = "power"
-        for a in self.r.core.getChildren():
+        for a in self.r.core:
             for b in a:
                 b.p.power = 10.0
         self.assertEqual(
diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py
index c036525c..83746210 100644
--- a/armi/reactor/tests/test_components.py
+++ b/armi/reactor/tests/test_components.py
@@ -186,8 +186,9 @@ class TestGeneralComponents(unittest.TestCase):
             def clearCache(self):
                 pass
 
-            def getChildren(self):
-                return []
+            def __iter__(self):
+                """Act like an iterator but don't actually iterate."""
+                return iter(())
 
             derivedMustUpdate = False
 
diff --git a/armi/reactor/tests/test_composites.py b/armi/reactor/tests/test_composites.py
index d2e38d77..dcb080d4 100644
--- a/armi/reactor/tests/test_composites.py
+++ b/armi/reactor/tests/test_composites.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 """Tests for the composite pattern."""
+import itertools
 import logging
 import unittest
 from copy import deepcopy
@@ -68,6 +69,8 @@ class DummyLeaf(composites.Composite):
         composites.Composite.__init__(self, name)
         self.p.type = name
         self.spatialLocator = grids.IndexLocation(i, i, i, _testGrid)
+        # Some special material attribute for testing getChildren(includeMaterials=True)
+        self.material = ("hello", "world")
 
     def getChildren(
         self, deep=False, generationNum=1, includeMaterials=False, predicate=None
@@ -98,12 +101,21 @@ class TestCompositePattern(unittest.TestCase):
             container.add(leaf)
         nested = DummyComposite("clad", 98)
         nested.setType("clad")
+        self.cladChild = nested
         self.secondGen = DummyComposite("liner", 97)
         self.thirdGen = DummyLeaf("pin 77", 33)
         self.secondGen.add(self.thirdGen)
         nested.add(self.secondGen)
         container.add(nested)
         self.container = container
+        # Composite tree structure in list of lists for testing
+        # tree[i] contains the children at "generation" or "depth" i
+        self.tree: list[list[composites.Composite]] = [
+            [self.container],
+            list(self.container),
+            [self.secondGen],
+            [self.thirdGen],
+        ]
 
     def test_composite(self):
         """Test basic Composite things.
@@ -131,22 +143,95 @@ class TestCompositePattern(unittest.TestCase):
             :id: T_ARMI_CMP1
             :tests: R_ARMI_CMP
         """
-        # There are 5 leaves and 1 composite in container. The composite has one leaf.
         firstGen = self.container.getChildren()
-        self.assertEqual(len(firstGen), 6)
+        self.assertEqual(firstGen, self.tree[1])
+
         secondGen = self.container.getChildren(generationNum=2)
-        self.assertEqual(len(secondGen), 1)
+        self.assertEqual(secondGen, self.tree[2])
+
         self.assertIs(secondGen[0], self.secondGen)
         third = self.container.getChildren(generationNum=3)
-        self.assertEqual(len(third), 1)
+        self.assertEqual(third, self.tree[3])
         self.assertIs(third[0], self.thirdGen)
+
         allC = self.container.getChildren(deep=True)
-        self.assertEqual(len(allC), 8)
+        expected = self.tree[1] + self.tree[2] + self.tree[3]
+        self.assertTrue(
+            all(a is e for a, e in itertools.zip_longest(allC, expected)),
+            msg=f"Deep traversal differs: {allC=} != {expected=}",
+        )
 
         onlyLiner = self.container.getChildren(
             deep=True, predicate=lambda o: o.p.type == "liner"
         )
         self.assertEqual(len(onlyLiner), 1)
+        self.assertIs(onlyLiner[0], self.secondGen)
+
+    def test_getChildrenWithMaterials(self):
+        """Test the ability for getChildren to place the material after the object."""
+        withMaterials = self.container.getChildren(deep=True, includeMaterials=True)
+        # Grab the iterable so we can control the progression
+        items = iter(withMaterials)
+        for item in items:
+            expectedMat = getattr(item, "material", None)
+            if expectedMat is None:
+                continue
+            # Material should be the next item in the list
+            actualMat = next(items)
+            self.assertIs(actualMat, expectedMat)
+            break
+        else:
+            raise RuntimeError("No materials found with includeMaterials=True")
+
+    def test_iterChildren(self):
+        """Detailed testing on Composite.iterChildren."""
+
+        def compareIterables(actual, expected: list[composites.Composite]):
+            for e in expected:
+                a = next(actual)
+                self.assertIs(a, e)
+            # Ensure we've consumed the actual iterator and there's nothing left
+            with self.assertRaises(StopIteration):
+                next(actual)
+
+        compareIterables(self.container.iterChildren(), self.tree[1])
+        compareIterables(self.container.iterChildren(generationNum=2), self.tree[2])
+        compareIterables(self.container.iterChildren(generationNum=3), self.tree[3])
+        compareIterables(
+            self.container.iterChildren(deep=True),
+            self.tree[1] + self.tree[2] + self.tree[3],
+        )
+
+    def test_iterAndGetChildren(self):
+        """Compare that iter children and get children are consistent."""
+        self._compareIterGetChildren()
+        self._compareIterGetChildren(deep=True)
+        self._compareIterGetChildren(generationNum=2)
+        # Some wacky predicate just to check we can use that too
+        self._compareIterGetChildren(deep=True, predicate=lambda c: len(c.name) % 3)
+
+    def _compareIterGetChildren(self, **kwargs):
+        fromIter = self.container.iterChildren(**kwargs)
+        fromGetter = self.container.getChildren(**kwargs)
+        msg = repr(kwargs)
+        # Use zip longest just in case one iterator comes up short
+        for count, (it, gt) in enumerate(itertools.zip_longest(fromIter, fromGetter)):
+            self.assertIs(it, gt, msg=f"{count=} :: {msg}")
+
+    def test_simpleIterChildren(self):
+        """Test that C.iterChildren() is identical to iter(C)."""
+        for count, (fromNative, fromIterChildren) in enumerate(
+            itertools.zip_longest(self.container, self.container.iterChildren())
+        ):
+            self.assertIs(fromIterChildren, fromNative, msg=count)
+
+    def test_iterChildrenWithMaterials(self):
+        """Test that C.iterChildrenWithMaterials gets materials following their parent component."""
+        items = iter(self.container.iterChildrenWithMaterials(deep=True))
+        for item in items:
+            if isinstance(item, components.Component):
+                mat = next(items)
+                self.assertIs(mat, item.material)
 
     def test_getName(self):
         """Test the getName method."""
@@ -157,26 +242,26 @@ class TestCompositePattern(unittest.TestCase):
 
     def test_sort(self):
         # in this case, the children should start sorted
-        c0 = [c.name for c in self.container.getChildren()]
+        c0 = [c.name for c in self.container]
         self.container.sort()
-        c1 = [c.name for c in self.container.getChildren()]
+        c1 = [c.name for c in self.container]
         self.assertNotEqual(c0, c1)
 
         # verify repeated sortings behave
         for _ in range(3):
             self.container.sort()
-            ci = [c.name for c in self.container.getChildren()]
+            ci = [c.name for c in self.container]
             self.assertEqual(c1, ci)
 
         # break the order
         children = self.container.getChildren()
         self.container._children = children[2:] + children[:2]
-        c2 = [c.name for c in self.container.getChildren()]
+        c2 = [c.name for c in self.container]
         self.assertNotEqual(c1, c2)
 
         # verify the sort order
         self.container.sort()
-        c3 = [c.name for c in self.container.getChildren()]
+        c3 = [c.name for c in self.container]
         self.assertEqual(c1, c3)
 
     def test_areChildernOfType(self):
@@ -307,12 +392,12 @@ class TestCompositePattern(unittest.TestCase):
 
         # validate that the LFP collection is None
         self.container.setChildrenLumpedFissionProducts(None)
-        for c in self.container.getChildren():
+        for c in self.container:
             self.assertIsNone(c._lumpedFissionProducts)
 
         # validate that the LFP collection is not None
         self.container.setChildrenLumpedFissionProducts(lfps)
-        for c in self.container.getChildren():
+        for c in self.container:
             self.assertIsNotNone(c._lumpedFissionProducts)
 
     def test_requiresLumpedFissionProducts(self):
@@ -404,6 +489,43 @@ class TestCompositePattern(unittest.TestCase):
         numSynced = self.container._syncParameters(data, {})
         self.assertEqual(numSynced, 2)
 
+    def test_iterChildrenWithFlags(self):
+        expectedChildren = {c for c in self.container if c.hasFlags(Flags.DUCT)}
+        found = set()
+        for c in self.container.iterChildrenWithFlags(Flags.DUCT):
+            self.assertIn(c, expectedChildren)
+            found.add(c)
+        self.assertSetEqual(found, expectedChildren)
+
+    def test_iterChildrenOfType(self):
+        clads = self.container.iterChildrenOfType("clad")
+        first = next(clads)
+        self.assertIs(first, self.cladChild)
+        with self.assertRaises(StopIteration):
+            next(clads)
+
+    def test_removeAll(self):
+        """Test the ability to remove all children of a composite."""
+        self.container.removeAll()
+        self.assertEqual(len(self.container), 0)
+        # Nothing to iterate over
+        items = iter(self.container)
+        with self.assertRaises(StopIteration):
+            next(items)
+        for child in self.tree[1]:
+            self.assertIsNone(child.parent)
+
+    def test_setChildren(self):
+        """Test the ability to override children on a composite."""
+        newChildren = self.tree[2] + self.tree[3]
+        oldChildren = list(self.container)
+        self.container.setChildren(newChildren)
+        self.assertEqual(len(self.container), len(newChildren))
+        for old in oldChildren:
+            self.assertIsNone(old.parent)
+        for actualNew, expectedNew in zip(newChildren, self.container):
+            self.assertIs(actualNew, expectedNew)
+
 
 class TestCompositeTree(unittest.TestCase):
     blueprintYaml = """
@@ -773,7 +895,7 @@ class TestMiscMethods(unittest.TestCase):
         # sum nuc densities from children components
         totalVolume = self.obj.getVolume()
         childDensities = {}
-        for o in self.obj.getChildren():
+        for o in self.obj:
             m = o.getVolume()
             d = o.getNumberDensities()
             for nuc, val in d.items():
@@ -811,7 +933,7 @@ class TestMiscMethods(unittest.TestCase):
         # sum nuc densities from children components
         totalVolume = self.obj.getVolume()
         childDensities = {}
-        for o in self.obj.getChildren():
+        for o in self.obj:
             # get the number densities with and without fission products
             d0 = o.getNumberDensities(expandFissionProducts=False)
             d = o.getNumberDensities(expandFissionProducts=True)
diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py
index b5400db7..8c856962 100644
--- a/armi/reactor/tests/test_reactors.py
+++ b/armi/reactor/tests/test_reactors.py
@@ -260,7 +260,7 @@ class HexReactorTests(ReactorTests):
         self.assertFalse(self.r.core.isFullCore)
         self.r.core.growToFullCore(self.o.cs)
         aNums = []
-        for a in self.r.core.getChildren():
+        for a in self.r.core:
             self.assertNotIn(a.getNum(), aNums)
             aNums.append(a.getNum())