Repository: terrapower/armi

Issue:
`DerivedShape.getArea()` doesn't respect `cold=True` argument
Here is a very simple blueprints file that will run with just the framework:
```yaml
nuclide flags:
  NA:
    burn: false
    xs: true
    expandTo:
  ZR:
    burn: false
    xs: true
    expandTo:
  C:
    burn: false
    xs: true
    expandTo:
  FE:
    burn: false
    xs: true
    expandTo:
  W:
    burn: false
    xs: true
    expandTo:
  SI:
    burn: false
    xs: true
    expandTo:
  CR:
    burn: false
    xs: true
    expandTo:
  MN:
    burn: false
    xs: true
    expandTo:
  NI:
    burn: false
    xs: true
    expandTo:
  V:
    burn: false
    xs: true
    expandTo:
  MO:
    burn: false
    xs: true
    expandTo:
blocks:
    fuel inner: &block_inner_fuel
        fuel:
            shape: Circle
            material: HT9
            Tinput: 25.0
            Thot: 450.0
            id: 0.0
            mult: 271
            od: 0.6029
        duct:
            shape: Hexagon
            material: HT9
            Tinput: 25.0
            Thot: 450.0
            ip: 14.922
            op: 15.710
            mult: 1.0
        intercoolant:
            shape: Hexagon
            material: Sodium
            Tinput: 25
            Thot: 450.0
            ip: duct.op
            op: 16.142
            mult: 1.0
        coolant:
            shape: DerivedShape
            material: Sodium
            Tinput: 25
            Thot: 450.0

assemblies:
    inner fuel:
        specifier: IC
        blocks:
            - *block_inner_fuel
        height: [1,]
        axial mesh points: [1,]
        xs types:
            - A
systems:
    core:
      grid name: core
      origin:
          x: 0.0
          y: 0.0
          z: 0.0
grids:
    core:
        geom: hex
        symmetry: third periodic
        lattice map: |
          IC
```

Run this and then load up the resulting DB to interrogate the component areas:
```python
from armi.bookkeeping.db import databaseFactory
db = databaseFactory("anl-afci-177.h5", 'r')
with db:
     r = db.load(0,0)

for c in r.core[0][0]:
     print(c, c.getArea(cold=True))

<Circle: fuel> 77.3659281298001
<DerivedShape: coolant> 116.6107165005503
<Hexagon: duct> 20.904135052955258
<Hexagon: intercoolant> 11.916564981699693

for c in r.core[0][0]:
     print(c, c.getArea(cold=False))

<Circle: fuel> 78.13119460947178
<DerivedShape: coolant> 116.6107165005503
<Hexagon: duct> 21.1109086835348
<Hexagon: intercoolant> 9.802365546297814
```

Notice that the `DerivedShape` component has the same area no matter if `cold=True` or `cold=False`.

This means that the sum of the component areas changes, even though the `op` of the intercoolant region doesn't change.

```python
sum([c.getArea(cold=True) for c in r.core[0][0]])
226.79734466500537

sum([c.getArea(cold=False) for c in r.core[0][0]])
225.65518533985468
```

In reality, the area of the `DerivedShape` should change as the areas of the other components change.

Discussion:
keckler: Everything looks good by me, thanks @john-science !
john-science: I have confirmed that this bug fix does not break any downstream unit tests.

Patch:
diff --git a/armi/bookkeeping/db/database3.py b/armi/bookkeeping/db/database3.py
index f1f35d57..871ff9eb 100644
--- a/armi/bookkeeping/db/database3.py
+++ b/armi/bookkeeping/db/database3.py
@@ -72,9 +72,8 @@ from armi.reactor.assemblies import Assembly
 from armi.reactor.blocks import Block
 from armi.reactor.components import Component
 from armi.reactor.composites import ArmiObject
-from armi.reactor.flags import Flags
 from armi.reactor.parameters import parameterCollections
-from armi.reactor.reactors import Core
+from armi.reactor.reactors import Core, Reactor
 from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
 from armi.utils import getNodesPerCycle
 from armi.utils.textProcessors import resolveMarkupInclusions
@@ -854,11 +853,7 @@ class Database3:
             comp.add(child)
 
         if isinstance(comp, Core):
-            # TODO: This is also an issue related to geoms and which core is "The Core".
-            # We only have a good geom for the main core, so can't do process loading on
-            # the SFP, etc.
-            if comp.hasFlags(Flags.CORE):
-                comp.processLoading(cs, dbLoad=True)
+            comp.processLoading(cs, dbLoad=True)
         elif isinstance(comp, Assembly):
             comp.calculateZCoords()
 
@@ -1311,8 +1306,7 @@ class Database3:
         Returns
         -------
         dict
-            Dictionary ArmiObject (input): dict of str/list pairs containing ((cycle,
-            node), value).
+            Dictionary ArmiObject (input): dict of str/list pairs containing ((cycle, node), value).
         """
         histData: Histories = {
             c: collections.defaultdict(collections.OrderedDict) for c in comps
@@ -1329,8 +1323,7 @@ class Database3:
             if "layout" not in h5TimeNodeGroup:
                 # Layout hasn't been written for this time step, so whatever is in there
                 # didn't come from the DatabaseInterface. Probably because it's the
-                # current time step and something has created the group to store aux
-                # data
+                # current time step and something has created the group to store aux data
                 continue
 
             cycle = h5TimeNodeGroup.attrs["cycle"]
@@ -1419,7 +1412,7 @@ class Database3:
 
                         histData[c][paramName][cycle, timeNode] = val
 
-        r = comps[0].getAncestorWithFlags(Flags.REACTOR)
+        r = comps[0].getAncestor(lambda c: isinstance(c, Reactor))
         cycleNode = r.p.cycle, r.p.timeNode
         for c, paramHistories in histData.items():
             for paramName, hist in paramHistories.items():
diff --git a/armi/reactor/assemblyLists.py b/armi/reactor/assemblyLists.py
index 0abb974f..1355ad81 100644
--- a/armi/reactor/assemblyLists.py
+++ b/armi/reactor/assemblyLists.py
@@ -201,7 +201,6 @@ class SpentFuelPool(AssemblyList):
         ----------
         assem : Assembly
             The Assembly to add to the list
-
         loc : LocationBase, optional
             If provided, the assembly is inserted at that location. If it is not
             provided, the locator on the Assembly object will be used. If the
diff --git a/armi/reactor/components/__init__.py b/armi/reactor/components/__init__.py
index 3fcbd768..92eef7b8 100644
--- a/armi/reactor/components/__init__.py
+++ b/armi/reactor/components/__init__.py
@@ -158,7 +158,7 @@ class UnshapedComponent(Component):
         Parameters
         ----------
         cold : bool, optional
-            Ignored for this component
+            If True, compute the area with as-input dimensions, instead of thermally-expanded.
         """
         coldArea = self.p.area
         if cold:
@@ -173,6 +173,13 @@ class UnshapedComponent(Component):
         This is the smallest it can possibly be. Since this is used to determine
         the outer component, it will never be allowed to be the outer one.
 
+        Parameters
+        ----------
+        Tc : float
+            Ignored for this component
+        cold : bool, optional
+            If True, compute the area with as-input dimensions, instead of thermally-expanded.
+
         Notes
         -----
         Tc is not used in this method for this particular component.
@@ -451,8 +458,17 @@ class DerivedShape(UnshapedComponent):
         Parameters
         ----------
         cold : bool, optional
-            Ignored for this component
+            If True, compute the area with as-input dimensions, instead of thermally-expanded.
         """
+        if cold:
+            # At cold temp, the DerivedShape has the area of the parent minus the other siblings
+            parentArea = self.parent.getArea()
+            # NOTE: the assumption is there is only one DerivedShape in each Component
+            siblings = sum(
+                [c.getArea(cold=True) for c in self.parent if type(c) != DerivedShape]
+            )
+            return parentArea - siblings
+
         if self.parent.derivedMustUpdate:
             self.computeVolume()
 
diff --git a/armi/reactor/flags.py b/armi/reactor/flags.py
index 6366432a..14bcff7e 100644
--- a/armi/reactor/flags.py
+++ b/armi/reactor/flags.py
@@ -205,9 +205,6 @@ class Flags(Flag):
     MEDIUM = auto()
     LOW = auto()
 
-    CORE = auto()
-    REACTOR = auto()
-
     # general kinds of assemblies or blocks
     MATERIAL = auto()
     FUEL = auto()
diff --git a/armi/reactor/reactors.py b/armi/reactor/reactors.py
index 3553fb9b..0b1def1a 100644
--- a/armi/reactor/reactors.py
+++ b/armi/reactor/reactors.py
@@ -98,7 +98,6 @@ class Reactor(composites.Composite):
         self.spatialLocator = None
         self.p.maxAssemNum = 0
         self.p.cycle = 0
-        self.p.flags |= Flags.REACTOR
         self.core = None
         self.sfp = None
         self.blueprints = blueprints
@@ -123,7 +122,7 @@ class Reactor(composites.Composite):
 
     def add(self, container):
         composites.Composite.add(self, container)
-        cores = self.getChildrenWithFlags(Flags.CORE)
+        cores = [c for c in self.getChildren(deep=True) if isinstance(c, Core)]
         if cores:
             if len(cores) != 1:
                 raise ValueError(
@@ -251,10 +250,9 @@ class Core(composites.Composite):
         :implements: R_ARMI_R_CORE
 
         A :py:class:`Core <armi.reactor.reactors.Core>` object is typically a child of a
-        :py:class:`Reactor <armi.reactor.reactors.Reactor>` object. A Reactor can contain multiple
-        objects of the Core type. The instance attribute name ``r.core`` is reserved for the object
-        representating the active core. A reactor may also have a spent fuel pool instance
-        attribute, ``r.sfp``\ , which is also of type :py:class:`core <armi.reactor.reactors.Core>`.
+        :py:class:`Reactor <armi.reactor.reactors.Reactor>` object. A Reactor should only contain
+        one object of the Core type. The instance attribute name ``r.core`` is reserved for the
+        object representating the active core.
 
         Most of the operations to retrieve information from the ARMI reactor data model are mediated
         through Core objects. For example,
@@ -266,7 +264,6 @@ class Core(composites.Composite):
     params : dict
         Core-level parameters are scalar values that have time dependence. Examples are keff,
         maxPercentBu, etc.
-
     assemblies : list
         List of assembly objects that are currently in the core
     """
@@ -283,7 +280,6 @@ class Core(composites.Composite):
             Name of the object. Flags will inherit from this.
         """
         composites.Composite.__init__(self, name)
-        self.p.flags = Flags.fromStringIgnoreErrors(name)
         self.assembliesByName = {}
         self.circularRingList = {}
         self.blocksByName = {}  # lookup tables
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 7ede4100..69169c07 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -14,16 +14,19 @@ New Features
 
 API Changes
 -----------
+#. Removing flags ``CORE`` and ``REACTOR``. (`PR#1835 <https://github.com/terrapower/armi/pull/1835>`_)
 #. TBD
 
 Bug Fixes
 ---------
+#. Fixed ``DerivedShape.getArea`` for ``cold=True``. (`PR#1831 <https://github.com/terrapower/armi/pull/1831>`_)
 #. Fixed error parsing command line integers in ``ReportsEntryPoint``. (`PR#1824 <https://github.com/terrapower/armi/pull/1824>`_)
 #. TBD
 
 Quality Work
 ------------
 #. Removing code that causes a deprecation warning (converting ``axialUnitGrid`` to ``AxialGrid.fromNCells``) (`PR#1809 <https://github.com/terrapower/armi/pull/1809>`_)
+#. TBD
 
 Changes that Affect Requirements
 --------------------------------


Test patch:
diff --git a/armi/physics/neutronics/tests/test_crossSectionTable.py b/armi/physics/neutronics/tests/test_crossSectionTable.py
index b69593a0..8dfc878b 100644
--- a/armi/physics/neutronics/tests/test_crossSectionTable.py
+++ b/armi/physics/neutronics/tests/test_crossSectionTable.py
@@ -20,7 +20,6 @@ from armi.physics.neutronics.isotopicDepletion import (
     isotopicDepletionInterface as idi,
 )
 from armi.physics.neutronics.latticePhysics import ORDER
-from armi.reactor.flags import Flags
 from armi.reactor.tests.test_blocks import loadTestBlock
 from armi.reactor.tests.test_reactors import loadTestReactor
 from armi.settings import Settings
@@ -37,7 +36,7 @@ class TestCrossSectionTable(unittest.TestCase):
         """
         obj = loadTestBlock()
         obj.p.mgFlux = range(33)
-        core = obj.getAncestorWithFlags(Flags.CORE)
+        core = obj.parent.parent
         core.lib = isotxs.readBinary(ISOAA_PATH)
         table = crossSectionTable.makeReactionRateTable(obj)
 
diff --git a/armi/reactor/tests/test_assemblies.py b/armi/reactor/tests/test_assemblies.py
index 248aa743..9bde09ff 100644
--- a/armi/reactor/tests/test_assemblies.py
+++ b/armi/reactor/tests/test_assemblies.py
@@ -605,8 +605,8 @@ class Assembly_TestCase(unittest.TestCase):
 
         for refBlock, curBlock in zip(self.assembly, assembly2):
             numNucs = 0
-            for nuc in self.assembly.getAncestorWithFlags(
-                Flags.REACTOR
+            for nuc in self.assembly.getAncestor(
+                lambda c: isinstance(c, reactors.Reactor)
             ).blueprints.allNuclidesInProblem:
                 numNucs += 1
                 # Block level density
diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py
index 4b57f422..fc6569bb 100644
--- a/armi/reactor/tests/test_components.py
+++ b/armi/reactor/tests/test_components.py
@@ -17,6 +17,7 @@ import copy
 import math
 import unittest
 
+from armi.materials import air, alloy200
 from armi.materials.material import Material
 from armi.reactor import components
 from armi.reactor import flags
@@ -44,7 +45,7 @@ from armi.reactor.components import (
     ComponentType,
 )
 from armi.reactor.components import materials
-from armi.materials import air, alloy200
+from armi.reactor.tests.test_reactors import loadTestReactor
 
 
 class TestComponentFactory(unittest.TestCase):
@@ -482,6 +483,31 @@ class TestDerivedShape(TestShapedComponent):
         self.assertAlmostEqual(c.computeVolume(), 1386.5232044586771)
 
 
+class TestDerivedShapeGetArea(unittest.TestCase):
+    def test_getAreaColdTrue(self):
+        """Prove that the DerivedShape.getArea() works at cold=True."""
+        # load one-block test reactor
+        _o, r = loadTestReactor(
+            inputFileName="smallestTestReactor/armiRunSmallest.yaml"
+        )
+        b = r.core[0][0]
+
+        # ensure there is a DerivedShape in this Block
+        shapes = set([type(c) for c in b])
+        self.assertIn(Circle, shapes)
+        self.assertIn(DerivedShape, shapes)
+        self.assertIn(Helix, shapes)
+        self.assertIn(Hexagon, shapes)
+
+        # prove that getArea works on the block level
+        self.assertAlmostEqual(b.getArea(cold=True), b.getArea(cold=False), delta=1e-10)
+
+        # prove that getArea preserves the sum of all the areas, even if there is a DerivedShape
+        totalAreaCold = sum([c.getArea(cold=True) for c in b])
+        totalAreaHot = sum([c.getArea(cold=False) for c in b])
+        self.assertAlmostEqual(totalAreaCold, totalAreaHot, delta=1e-10)
+
+
 class TestCircle(TestShapedComponent):
     """Test circle shaped component."""