Repository: terrapower/armi

Issue:
Single component block axial expansion
Related to https://github.com/terrapower/armi/issues/1193

The axial expander should be able to recognize when there is one solid component in a block without extra blueprint flagging to make it recognize it's target. 

Discussion:
albeanth: This PR is ready, but I am converting to draft while we figure out downstream merge dependencies.

Patch:
diff --git a/armi/reactor/converters/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger.py
index 78fda338..6f10d92d 100644
--- a/armi/reactor/converters/axialExpansionChanger.py
+++ b/armi/reactor/converters/axialExpansionChanger.py
@@ -842,13 +842,18 @@ class ExpansionData:
         else:
             componentWFlag = [c for c in b.getChildren() if c.hasFlags(flagOfInterest)]
         if len(componentWFlag) == 0:
-            raise RuntimeError("No target component found!\n   Block {0}".format(b))
+            # if only 1 solid, be smart enought to snag it
+            solidMaterials = list(
+                c for c in b if not isinstance(c.material, material.Fluid)
+            )
+            if len(solidMaterials) == 1:
+                componentWFlag = solidMaterials
+        if len(componentWFlag) == 0:
+            raise RuntimeError(f"No target component found!\n   Block {b}")
         if len(componentWFlag) > 1:
             raise RuntimeError(
-                "Cannot have more than one component within a block that has the target flag!"
-                "Block {0}\nflagOfInterest {1}\nComponents {2}".format(
-                    b, flagOfInterest, componentWFlag
-                )
+                f"Cannot have more than one component within a block that has the target flag!"
+                f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {componentWFlag}"
             )
         self._componentDeterminesBlockHeight[componentWFlag[0]] = True
 
diff --git a/doc/release/0.2.rst b/doc/release/0.2.rst
index 4c2868a9..6bc28d90 100644
--- a/doc/release/0.2.rst
+++ b/doc/release/0.2.rst
@@ -19,6 +19,7 @@ What's new in ARMI
 #. createRepresentativeBlocksFromExistingBlocks() now returns the mapping of original to new XS IDs. (`PR#1217 <https://github.com/terrapower/armi/pull/1217>`_)
 #. Added a capability to prioritize mpi action execution and exclusivity.  (`PR#1237 <https://github.com/terrapower/armi/pull/1237>`_)
 #. Use ``minimumNuclideDensity`` setting when generating macroscopic XS.  (`PR#1248 <https://github.com/terrapower/armi/pull/1248>`_)
+#. Improve support for single component axial expansion and general cleanup of axial expansion unit tests. (`PR#1230 <https://github.com/terrapower/armi/pull/1230>`_) 
 #. TBD
 
 Bug fixes


Test patch:
diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py
index 20dc49c4..e1206d81 100644
--- a/armi/reactor/converters/tests/test_axialExpansionChanger.py
+++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py
@@ -629,6 +629,8 @@ class TestExceptions(AxialExpansionTestBase, unittest.TestCase):
 
     def test_AssemblyAxialExpansionException(self):
         """test that negative height exception is caught"""
+        # manually set axial exp target component for code coverage
+        self.a[0].axialExpTargetComponent = self.a[0][0]
         temp = Temperature(self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10)
         with self.assertRaises(ArithmeticError) as cm:
             for idt in range(temp.tempSteps):
@@ -641,42 +643,6 @@ class TestExceptions(AxialExpansionTestBase, unittest.TestCase):
             the_exception = cm.exception
             self.assertEqual(the_exception.error_code, 3)
 
-    def test_determineTargetComponentRuntimeErrorFirst(self):
-        # build block for testing
-        b = HexBlock("test", height=10.0)
-        fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.76, "id": 0.00, "mult": 127.0}
-        cladDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.80, "id": 0.77, "mult": 127.0}
-        mainType = Circle("main", "FakeMat", **fuelDims)
-        clad = Circle("clad", "FakeMat", **cladDims)
-        b.add(mainType)
-        b.add(clad)
-        b.setType("test")
-        b.getVolumeFractions()
-        # do test
-        with self.assertRaises(RuntimeError) as cm:
-            self.obj.expansionData.determineTargetComponent(b)
-
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
-
-    def test_determineTargetComponentRuntimeErrorSecond(self):
-        # build block for testing
-        b = HexBlock("test", height=10.0)
-        fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.76, "id": 0.00, "mult": 127.0}
-        cladDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.80, "id": 0.77, "mult": 127.0}
-        mainType = Circle("test", "FakeMat", **fuelDims)
-        clad = Circle("test", "FakeMat", **cladDims)
-        b.add(mainType)
-        b.add(clad)
-        b.setType("test")
-        b.getVolumeFractions()
-        # do test
-        with self.assertRaises(RuntimeError) as cm:
-            self.obj.expansionData.determineTargetComponent(b)
-
-            the_exception = cm.exception
-            self.assertEqual(the_exception.error_code, 3)
-
     def test_isFuelLocked(self):
         """ensures that the RuntimeError statement in ExpansionData::_isFuelLocked is raised appropriately
 
@@ -722,17 +688,15 @@ class TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase):
 
     def setUp(self):
         AxialExpansionTestBase.setUp(self)
-        self.obj = AxialExpansionChanger()
-        self.a = buildTestAssemblyWithFakeMaterial(name="FakeMatException")
-        self.obj.setAssembly(self.a)
-        # need an empty dictionary because we want to test for the added component only
-        self.obj.expansionData._componentDeterminesBlockHeight = {}
+        self.expData = ExpansionData([], None)
+        coolDims = {"Tinput": 25.0, "Thot": 25.0}
+        self.coolant = DerivedShape("coolant", "Sodium", **coolDims)
 
     def tearDown(self):
         AxialExpansionTestBase.tearDown(self)
 
     def test_determineTargetComponent(self):
-        # build a test block
+        """provides coverage for searching TARGET_FLAGS_IN_PREFERRED_ORDER"""
         b = HexBlock("fuel", height=10.0)
         fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.76, "id": 0.00, "mult": 127.0}
         cladDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.80, "id": 0.77, "mult": 127.0}
@@ -740,85 +704,109 @@ class TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase):
         clad = Circle("clad", "FakeMat", **cladDims)
         b.add(fuel)
         b.add(clad)
+        b.add(self.coolant)
         # call method, and check that target component is correct
-        self.obj.expansionData.determineTargetComponent(b)
+        self.expData.determineTargetComponent(b)
         self.assertTrue(
-            self.obj.expansionData.isTargetComponent(fuel),
-            msg="determineTargetComponent failed to recognize intended component: {}".format(
-                fuel
-            ),
+            self.expData.isTargetComponent(fuel),
+            msg=f"determineTargetComponent failed to recognize intended component: {fuel}",
         )
 
     def test_determineTargetComponentBlockWithMultipleFlags(self):
+        """provides coverage for searching TARGET_FLAGS_IN_PREFERRED_ORDER with multiple flags"""
         # build a block that has two flags as well as a component matching each
-        # flag
         b = HexBlock("fuel poison", height=10.0)
-        fuelDims = {"Tinput": 25.0, "Thot": 600.0, "od": 0.9, "id": 0.5, "mult": 200.0}
-        poisonDims = {"Tinput": 25.0, "Thot": 400.0, "od": 0.5, "id": 0.0, "mult": 10.0}
+        fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.9, "id": 0.5, "mult": 200.0}
+        poisonDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.5, "id": 0.0, "mult": 10.0}
         fuel = Circle("fuel", "FakeMat", **fuelDims)
         poison = Circle("poison", "FakeMat", **poisonDims)
         b.add(fuel)
         b.add(poison)
+        b.add(self.coolant)
         # call method, and check that target component is correct
-        self.obj.expansionData.determineTargetComponent(b)
+        self.expData.determineTargetComponent(b)
         self.assertTrue(
-            self.obj.expansionData.isTargetComponent(fuel),
-            msg="determineTargetComponent failed to recognize intended component: {}".format(
-                fuel
-            ),
+            self.expData.isTargetComponent(fuel),
+            msg=f"determineTargetComponent failed to recognize intended component: {fuel}",
         )
 
-    def test_specifyTargetComponet_BlueprintSpecified(self):
-        b = HexBlock("SodiumBlock", height=10.0)
-        sodiumDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0}
-        ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 16, "ip": 15.0, "mult": 1.0}
-        dummy = Hexagon("coolant", "Sodium", **sodiumDims)
-        dummyDuct = Hexagon("duct", "FakeMat", **sodiumDims)
-        b.add(dummy)
-        b.add(dummyDuct)
+    def test_specifyTargetComponent_NotFound(self):
+        """ensure RuntimeError gets raised when no target component is found"""
+        b = HexBlock("fuel", height=10.0)
+        b.add(self.coolant)
+        b.setType("fuel")
+        with self.assertRaises(RuntimeError) as cm:
+            self.expData.determineTargetComponent(b)
+            the_exception = cm.exception
+            self.assertEqual(the_exception.error_code, 3)
+        with self.assertRaises(RuntimeError) as cm:
+            self.expData.determineTargetComponent(b, Flags.FUEL)
+            the_exception = cm.exception
+            self.assertEqual(the_exception.error_code, 3)
+
+    def test_specifyTargetComponent_singleSolid(self):
+        """ensures that specifyTargetComponent is smart enough to set the only solid as the target component"""
+        b = HexBlock("plenum", height=10.0)
+        ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0}
+        duct = Hexagon("duct", "FakeMat", **ductDims)
+        b.add(duct)
+        b.add(self.coolant)
         b.getVolumeFractions()
-        b.setType("DuctBlock")
+        b.setType("plenum")
+        self.expData.determineTargetComponent(b)
+        self.assertTrue(
+            self.expData.isTargetComponent(duct),
+            msg=f"determineTargetComponent failed to recognize intended component: {duct}",
+        )
+
+    def test_specifyTargetComponet_MultipleFound(self):
+        """ensure RuntimeError is hit when multiple target components are found
 
-        # check for no target component found
+        Notes
+        -----
+        This can occur if a block has a mixture of fuel types. E.g., different fuel materials,
+        or different fuel geometries.
+        """
+        b = HexBlock("fuel", height=10.0)
+        fuelAnnularDims = {
+            "Tinput": 25.0,
+            "Thot": 25.0,
+            "od": 0.9,
+            "id": 0.5,
+            "mult": 100.0,
+        }
+        fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 1.0, "id": 0.0, "mult": 10.0}
+        fuel = Circle("fuel", "FakeMat", **fuelDims)
+        fuelAnnular = Circle("fuel annular", "FakeMat", **fuelAnnularDims)
+        b.add(fuel)
+        b.add(fuelAnnular)
+        b.add(self.coolant)
+        b.setType("FuelBlock")
         with self.assertRaises(RuntimeError) as cm:
-            self.obj.expansionData.determineTargetComponent(b)
+            self.expData.determineTargetComponent(b, flagOfInterest=Flags.FUEL)
             the_exception = cm.exception
             self.assertEqual(the_exception.error_code, 3)
 
-        # check that target component is explicitly specified
-        b.setAxialExpTargetComp(dummyDuct)
+    def test_manuallySetTargetComponent(self):
+        """ensures that target components can be manually set (is done in practice via blueprints)"""
+        b = HexBlock("dummy", height=10.0)
+        ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0}
+        duct = Hexagon("duct", "FakeMat", **ductDims)
+        b.add(duct)
+        b.add(self.coolant)
+        b.getVolumeFractions()
+        b.setType("duct")
+
+        # manually set target component
+        b.setAxialExpTargetComp(duct)
         self.assertEqual(
             b.axialExpTargetComponent,
-            dummyDuct,
+            duct,
         )
 
         # check that target component is stored on expansionData object correctly
-        self.obj.expansionData._componentDeterminesBlockHeight[
-            b.axialExpTargetComponent
-        ] = True
-        self.assertTrue(
-            self.obj.expansionData._componentDeterminesBlockHeight[
-                b.axialExpTargetComponent
-            ]
-        )
-
-        # get coverage for runLog statements on origination of target components
-        # axial exp changer skips formal expansion of the top most block so we
-        # need three blocks.
-        b0 = _buildTestBlock("b0", "FakeMat", 25.0, 10.0)
-        b2 = _buildTestBlock("b1", "FakeMat", 25.0, 10.0)
-        assembly = HexAssembly("testAssemblyType")
-        assembly.spatialGrid = grids.axialUnitGrid(numCells=1)
-        assembly.spatialGrid.armiObject = assembly
-        assembly.add(b0)
-        assembly.add(b)
-        assembly.add(b2)
-        assembly.calculateZCoords()
-        assembly.reestablishBlockOrder()
-        with mockRunLogs.BufferLog() as mock:
-            self.obj.performPrescribedAxialExpansion(assembly, [dummy], [0.01])
-            self.assertIn("(blueprints defined)", mock.getStdout())
-            self.assertIn("(inferred)", mock.getStdout())
+        self.expData._componentDeterminesBlockHeight[b.axialExpTargetComponent] = True
+        self.assertTrue(self.expData.isTargetComponent(duct))
 
 
 class TestInputHeightsConsideredHot(unittest.TestCase):