Repository: terrapower/armi

Issue:
Multiple components with same name in a block
As best as I can tell, it seems that if one defines multiple components that have the same name within given block, previous components with that name will be overwritten by components further down in the input file. Please correct me if I am wrong on this, though. I am having a hard time convincing myself, but some simple testing makes me inclined to believe.

Further, I believe that no warning is given to the user in this case.

The relevant code, I think, is [here](https://terrapower.github.io/armi/_modules/armi/reactor/blueprints/blockBlueprint.html#BlockBlueprint.construct), where components are being added to a KeyedList using their names. I think a check could be put in there to first see if a component with the same name has already been added.

Some might argue that this is a feature, rather than a bug. That could be debated, but my opinion is that a warning message would be helpful in this scenario for reducing input bugs. 

Discussion:
john-science: Bump

Patch:
diff --git a/README.rst b/README.rst
index b7b03454..37e7d2e1 100644
--- a/README.rst
+++ b/README.rst
@@ -34,7 +34,7 @@ peak structural temperature in a design-basis transient.
 
 .. note:: ARMI does not come with a full selection of physics kernels. They will need to
    be acquired or developed for your specific project in order to make full use of this
-   tool.  Many of the example use-cases discussed in this manual require functionality
+   tool. Many of the example use-cases discussed in this manual require functionality
    that is not included in the open-source ARMI Framework.
 
 In general, ARMI aims to enhance the quality, ease, and rigor of computational nuclear
@@ -132,7 +132,7 @@ Nuclear reactor design requires, among other things, answers to the following qu
 * How does the building handle earthquakes?
 
 Digital computers have assisted in nuclear technology development since the days of the
-ENIAC in the 1940s.  We now understand reactor physics well enough to build detailed
+ENIAC in the 1940s. We now understand reactor physics well enough to build detailed
 simulations, which can answer many of these design questions in a cost-effective, and
 flexible manner. This allows us to simulate all kinds of different reactors with
 different fuels, coolants, moderators, power levels, safety systems, and power cycles.
@@ -144,7 +144,7 @@ economics, and safety.
 
 Perhaps surprisingly, some nuclear software written in the 1960s is still in use today
 (mostly ported to Fortran 90 by now). These codes are validated against physical
-experiments that no longer exist.  Meanwhile, new cutting-edge nuclear software is being
+experiments that no longer exist. Meanwhile, new cutting-edge nuclear software is being
 developed today for powerful computers. Both old and new, these tools are often
 challenging to operate and to use in concert with other sub-specialty codes that are
 necessary to reach a full system analysis.
@@ -153,7 +153,7 @@ The ARMI approach was born out of this situation: how can we best leverage an ec
 mix of legacy and modern tools with a small team to do full-scope analysis? We built an
 environment that lets us automate the tedious, uncoupled, and error-prone parts of
 reactor engineering/analysis work. We can turn around a very meaningful and detailed
-core analysis given a major change (e.g. change power by 50%) in just a few weeks.  We
+core analysis given a major change (e.g. change power by 50%) in just a few weeks. We
 can dispatch hundreds of parameter sweeps to multiple machines and then perform
 multiobjective optimization on the resulting design space.
 
@@ -190,7 +190,7 @@ Automation
 ----------
 
 ARMI can quickly and easily produce complex input files with high levels of detail in
-various approximations.  This enables users to perform rapid high-fidelity analyses to
+various approximations. This enables users to perform rapid high-fidelity analyses to
 make sure all important physics are captured. It also enables sensitivity studies of
 different modeling approximations (e.g. symmetries, transport vs. diffusion vs. Monte
 Carlo, subchannel vs. CFD, etc.).
@@ -223,7 +223,7 @@ and finding the peak power density is easy::
 
 Any ARMI state can be written out to whichever format the user desires, meaning that
 nominally identical cases can be produced for multiple similar codes in sensitivity
-studies.  To read power densities, simply read them off the assembly objects. Instead of
+studies. To read power densities, simply read them off the assembly objects. Instead of
 producing spreadsheets and making plots manually, analysts may write scripts to generate
 output reports that run automatically.
 
@@ -242,7 +242,7 @@ Use cases
 Given input describing a reactor, a typical ARMI run loops over a set of plugins in a
 certain sequence. Some plugins trigger third-party simulation codes, producing input
 files for them, executing them, and translating the output back onto the reactor model
-as state information. Other plugins perform physics simulations directly.  A variety of
+as state information. Other plugins perform physics simulations directly. A variety of
 plugins are available from TerraPower LLC with certain licensing terms, and it is our
 hope that a rich ecosystem of useful plugins will be developed and curated by the
 community (university research teams, national labs, other companies, etc.).
@@ -266,7 +266,7 @@ For example, one ARMI sequence may involve the calculation of:
   transients.
 
 Another sequence may simply compute the cost of feed uranium and enrichment in an
-initial core and quit.  The possibilities are limited only by our creativity.
+initial core and quit. The possibilities are limited only by our creativity.
 
 These large runs may also be run through the multiobjective design optimization system,
 which runs many cases with input perturbations to help find the best overall system,
@@ -322,7 +322,7 @@ ARMI was originally created by TerraPower, LLC near Seattle WA starting in 2009.
 founding mission was to determine the optimal fuel management operations required to
 transition a fresh Traveling Wave Reactor core from startup into an equilibrium state.
 It started out automating the Argonne National Lab (ANL) fast reactor neutronics codes,
-MC2 and REBUS.  The reactor model design was made with the intention of adding other
+MC2 and REBUS. The reactor model design was made with the intention of adding other
 physics capabilities later. Soon, simple thermal hydraulics were added and it's grown
 ever since. It has continuously evolved towards a general reactor analysis framework.
 
@@ -331,16 +331,16 @@ architecture for ARMI, allowing some of the intertwined physics capabilities to
 separated out as plugins from the standalone framework.
 
 The nuclear industry is small, and it faces many challenges. It also has a tradition of
-secrecy.  As a result, there is risk of overlapping work being done by other entities.
+secrecy. As a result, there is risk of overlapping work being done by other entities.
 
 We hypothesize that collaborating on software systems can help align some efforts
 worldwide, increasing quality and efficiency. In reactor development, the idea is
-generally cheap.  It's the shakedown, technology and supply chain development,
+generally cheap. It's the shakedown, technology and supply chain development,
 engineering demo, and commercial demo that are the hard parts.
 
 Thus, ARMI was released under an open-source license in 2019 to facilitate mutually
 beneficial collaboration across the nuclear industry, where many teams are independently
-developing similar reactor analysis/automation frameworks.  TerraPower will make its
+developing similar reactor analysis/automation frameworks. TerraPower will make its
 proprietary analysis routines, physics kernels, and material properties available under
 commercial licenses.
 
@@ -381,16 +381,15 @@ needs of thermal reactors (like a good spatial description of pin maps) exists b
 has not been subject to as much use.
 
 ARMI was developed within a rapidly changing R&D environment. It evolved accordingly,
-and naturally carries some legacy.  We continuously attempt to identify and update
-problematic parts of the code.  Users should understand that ARMI is not a polished
-consumer software product, but rather a powerful and flexible engineering tool.  It has
+and naturally carries some legacy. We continuously attempt to identify and update
+problematic parts of the code. Users should understand that ARMI is not a polished
+consumer software product, but rather a powerful and flexible engineering tool. It has
 the potential to accelerate work on many kinds of reactors. But in many cases, it will
 require serious and targeted investment.
 
 ARMI was largely written by nuclear and mechanical engineers. We (as a whole) only
 really, truly, recognized the value of things like static typing in a complex system
-like ARMI somewhat recently.  Contributions from software engineers are *more than*
-welcome!
+like ARMI somewhat recently.
 
 ARMI has been written to support specific engineering/design tasks. As such, polish in
 the GUIs and output is somewhat lacking.
@@ -427,7 +426,7 @@ Be careful when including any dependency in ARMI (say in the ``pyproject.toml``
 to include anything with a license that superceeds our Apache license. For instance,
 any third-party Python library included in ARMI with a GPL license will make the whole
 project fall under the GPL license. But a lot of potential users of ARMI will want to
-keep some of their work private, so we can't allow any GPL tools.
+keep some of their work private, so we can't allow any GPL dependencies.
 
 For that reason, it is generally considered best-practice in the ARMI ecosystem to
 only use third-party Python libraries that have MIT or BSD licenses.
diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py
index 85f4cfea..35360e7d 100644
--- a/armi/reactor/assemblies.py
+++ b/armi/reactor/assemblies.py
@@ -32,6 +32,7 @@ from armi.reactor import blocks
 from armi.reactor import composites
 from armi.reactor import grids
 from armi.reactor.flags import Flags
+from armi.materials.material import Fluid
 from armi.reactor.parameters import ParamLocation
 
 
@@ -191,15 +192,10 @@ class Assembly(composites.Composite):
         """
         composites.Composite.add(self, obj)
         obj.spatialLocator = self.spatialGrid[0, 0, len(self) - 1]
-        # assemblies have bounds-based 1-D spatial grids. Adjust it to the right value.
-        if len(self.spatialGrid._bounds[2]) < len(self):
-            self.spatialGrid._bounds[2][len(self)] = (
-                self.spatialGrid._bounds[2][len(self) - 1] + obj.getHeight()
-            )
-        else:
-            # more work is needed, make a new mesh
-            self.reestablishBlockOrder()
-            self.calculateZCoords()
+
+        # more work is needed, make a new mesh
+        self.reestablishBlockOrder()
+        self.calculateZCoords()
 
     def moveTo(self, locator):
         """Move an assembly somewhere else."""
@@ -322,9 +318,8 @@ class Assembly(composites.Composite):
         plenumBlocks = self.getBlocks(Flags.PLENUM)
         plenumTemps = [b.p.THcoolantOutletT for b in plenumBlocks]
 
-        if (
-            not plenumTemps
-        ):  # no plenum blocks, use the top block of the assembly for plenum temperature
+        # no plenum blocks, use the top block of the assembly for plenum temperature
+        if not plenumTemps:
             runLog.warning("No plenum blocks exist. Using outlet coolant temperature.")
             plenumTemps = [self[-1].p.THcoolantOutletT]
 
@@ -673,7 +668,7 @@ class Assembly(composites.Composite):
 
     def _shouldMassBeConserved(self, belowFuelColumn, b):
         """
-        Determine from a rule set if the mass of a block should be conserved during axial expansion.
+        Determine from a rule set if the mass of a block component should be conserved during axial expansion.
 
         Parameters
         ----------
@@ -689,8 +684,8 @@ class Assembly(composites.Composite):
         conserveMass : boolean
             Should the mass be conserved in this block
 
-        adjustList : list of nuclides
-            What nuclides should have their mass conserved (if any)
+        conserveComponents : list of components
+            What components should have their mass conserved (if any)
 
         belowFuelColumn : boolean
             Update whether the block is above or below a fuel column
@@ -703,32 +698,31 @@ class Assembly(composites.Composite):
         if b.hasFlags(Flags.FUEL):
             # fuel block
             conserveMass = True
-            adjustList = b.getComponent(Flags.FUEL).getNuclides()
+            conserveComponents = b.getComponents(Flags.FUEL)
         elif self.hasFlags(Flags.FUEL):
             # non-fuel block of a fuel assembly.
             if belowFuelColumn:
                 # conserve mass of everything below the fuel so as to not invalidate
                 # grid-plate dose calcs.
                 conserveMass = True
-                adjustList = b.getNuclides()
-                # conserve mass of everything except coolant.
-                coolant = b.getComponent(Flags.COOLANT)
-                coolantList = coolant.getNuclides() if coolant else []
-                for nuc in coolantList:
-                    if nuc in adjustList:
-                        adjustList.remove(nuc)
+                # conserve mass of everything except fluids.
+                conserveComponents = [
+                    comp
+                    for comp in b.getComponents()
+                    if not isinstance(comp.material, Fluid)
+                ]
             else:
                 # plenum or above block in fuel assembly. don't conserve mass.
                 conserveMass = False
-                adjustList = None
+                conserveComponents = []
         else:
             # non fuel block in non-fuel assem. Don't conserve mass.
             conserveMass = False
-            adjustList = None
+            conserveComponents = []
 
-        return conserveMass, adjustList
+        return conserveMass, conserveComponents
 
-    def setBlockMesh(self, blockMesh, conserveMassFlag=False, adjustList=None):
+    def setBlockMesh(self, blockMesh, conserveMassFlag=False):
         """
         Snaps the axial mesh points of this assembly to correspond with the reference mesh.
 
@@ -756,7 +750,14 @@ class Assembly(composites.Composite):
         Parameters
         ----------
         blockMesh : iterable
-            a list of floats describing the upper mesh points of each block in cm.
+            A list of floats describing the upper mesh points of each block in cm.
+
+        conserveMassFlag : bool or str
+            Option for how to treat mass conservation when the block mesh changes.
+            Conservation of mass for fuel components is enabled by
+            conserveMassFlag="auto". If not auto, a boolean value should be
+            passed. The default is False, which does not conserve any masses.
+            True conserves mass for all components.
 
         See Also
         --------
@@ -796,15 +797,19 @@ class Assembly(composites.Composite):
                 return
 
             if conserveMassFlag == "auto":
-                conserveMass, adjustList = self._shouldMassBeConserved(
+                conserveMass, conserveComponents = self._shouldMassBeConserved(
                     belowFuelColumn, b
                 )
             else:
                 conserveMass = conserveMassFlag
-
-            b.setHeight(
-                newTop - zBottom, conserveMass=conserveMass, adjustList=adjustList
-            )
+                conserveComponents = b.getComponents()
+
+            oldBlockHeight = b.getHeight()
+            b.setHeight(newTop - zBottom)
+            if conserveMass:
+                heightRatio = oldBlockHeight / b.getHeight()
+                for c in conserveComponents:
+                    c.changeNDensByFactor(heightRatio)
             zBottom = newTop
 
         self.calculateZCoords()
diff --git a/doc/index.rst b/doc/index.rst
index b8f039dc..fb39ea91 100644
--- a/doc/index.rst
+++ b/doc/index.rst
@@ -9,10 +9,10 @@ ARMI
    :maxdepth: 2
 
    installation
-   gallery/index
-   tutorials/index
    user/index
    developer/index
+   gallery/index
+   tutorials/index
    release/index
    glossary
    API Docs <.apidocs/modules>
@@ -26,4 +26,3 @@ ARMI
 *  :ref:`modindex`
 *  :ref:`search`
 
-
diff --git a/doc/release/0.3.rst b/doc/release/0.3.rst
index f241dff6..55fc52f2 100644
--- a/doc/release/0.3.rst
+++ b/doc/release/0.3.rst
@@ -8,6 +8,7 @@ Release Date: TBD
 
 New Features
 ------------
+#. Conserve mass by component in assembly.setBlockMesh(). (`PR#1665 <https://github.com/terrapower/armi/pull/1665>`_)
 #. TBD
 
 API Changes
@@ -29,6 +30,7 @@ Changes that Affect Requirements
 --------------------------------
 #. Very minor change to ``Block.coords()``, removing unused argument. (`PR#1651 <https://github.com/terrapower/armi/pull/1651>`_)
 #. Touched ``HexGrid`` by adding a "cornersUp" property and fixing two bugs. (`PR#1649 <https://github.com/terrapower/armi/pull/1649>`_)
+#. Very slightly modified the implementation of ``Assembly.add()``. (`PR#1670 <https://github.com/terrapower/armi/pull/1670>`_)
 #. TBD
 
 
diff --git a/doc/user/inputs.rst b/doc/user/inputs.rst
index 01212e1b..87006788 100644
--- a/doc/user/inputs.rst
+++ b/doc/user/inputs.rst
@@ -2,37 +2,51 @@
 Inputs
 ******
 
-ARMI input files define the initial state of the reactor model and tell ARMI what kind of analysis should be
-performed on it.
+ARMI input files define the initial state of the reactor model and tell ARMI what kind of analysis
+should be performed on it.
 
-.. note:: We have a :ref:`walkthrough-inputs` tutorial for a quick 
-    overview of the inputs.
+.. note:: We have a :ref:`walkthrough-inputs` tutorial for a quick overview of the inputs.
 
 There are several input files:
 
 Settings file
-	Contains simulation parameters (like full power, cycle length, and which physics modules to
-  	activate) and all kind of modeling approximation settings (e.g. convergence criteria)
+    Contains simulation parameters (like full power, cycle length, and which physics modules to
+    activate) and all kind of modeling approximation settings (e.g. convergence criteria)
 
 Blueprints file
-	Contains dimensions and composition of the components/blocks/assemblies in your reactor systems, from fuel 
-  	pins to heat exchangers
- 
+    Contains dimensions and composition of the components/blocks/assemblies in your reactor systems,
+    from fuel pins to heat exchangers
+
 Fuel management file
-	Describes how fuel moves around during a simulation
+    Describes how fuel moves around during a simulation
 
 
-Depending on the type of analysis, there may be additional inputs required. These include things like
-control logic, ex-core models for transients and shielding, etc.
+Depending on the type of analysis, developers may create other input files for things like: control
+logic, ex-core models for transients and shielding, etc.
 
-The core map input files can be graphically manipulated with the 
-:py:mod:`Grid editor <armi.utils.gridEditor>`.
+
+YAML Files
+==========
+ARMI's input files all use the `YAML <https://en.wikipedia.org/wiki/YAML>`_ format. This is a well-
+known file format, chosen because it is human-readable and easy to hand-write. That being said,
+there are two details about the YAML format that are important to know:
+
+Ordering
+    YAML is not order specific; however, one of the techniques used to limit the size of the input
+    includes using YAML anchors to resuse block and component definitions. YAML anchors (e.g.
+    ``&block_name``) must be defined before their corresponding alias (e.g. ``*block_name``) used.
+
+Duplicate Keys
+    YAML allows for duplicate keys. However, in ARMI, duplicates might be erroneous. Unfortunately,
+    because the international YAML specification allows for duplicates, none of the YAML-parsing
+    libraries see it as an error. You will have to hand-verify your inputs are correct.
 
 
 The Settings Input File
 =======================
-The **settings** input file defines a series of key/value pairs the define various information about the system you are
-modeling as well as which modules to run and various modeling/approximation settings. For example, it includes:
+The **settings** input file defines a series of key/value pairs the define various information about
+the system you are modeling as well as which modules to run and various modeling/approximation
+settings. For example, it includes:
 
 * The case title
 * The reactor power
@@ -52,7 +66,8 @@ Here is an excerpt from a settings file:
     :language: yaml
     :lines: 3-15
 
-A full listing of settings available in the framework may be found in the `Table of all global settings <#settings-report>`_ .
+A full listing of settings available in the framework may be found in the
+`Table of all global settings <#settings-report>`_ .
 
 Many settings are provided by the ARMI Framework, and others are defined by various plugins.
 
@@ -60,13 +75,14 @@ Many settings are provided by the ARMI Framework, and others are defined by vari
 
 The ARMI GUI
 ------------
-The ARMI GUI may be used to manipulate many common settings (though the GUI can't change all of the settings).  The GUI
-also enables the graphical manipulation of a reactor core map, and convenient automation of commands required to submit to a
-cluster.  The GUI is a front-end to
-these files. You can choose to use the GUI or not, ARMI doesn't know or care --- it just reads these files and runs them.
+The ARMI GUI may be used to manipulate many common settings (though the GUI can't change all of the
+settings).  The GUI also enables the graphical manipulation of a reactor core map, and convenient
+automation of commands required to submit to a cluster.  The GUI is a front-end to these files. You
+can choose to use the GUI or not, ARMI doesn't know or care --- it just reads these files and runs
+them.
 
-Note that one settings input file is required for each ARMI case, though many ARMI cases can refer to the same
-Blueprints, Core Map, and Fuel Management inputs.
+Note that one settings input file is required for each ARMI case, though many ARMI cases can refer
+to the same Blueprints, Core Map, and Fuel Management inputs.
 
 .. tip:: The ARMI GUI is not yet included in the open-source ARMI framework
 
@@ -481,11 +497,8 @@ The ARMI data model is represented schematically below, and the blueprints are d
 :ref:`custom isotopics <custom-isotopics>`:
     Special setting: defines user-specified isotopic compositions.
 
-.. warning::
-
-    YAML is not order specific; however, one of the techniques used to limit the size of the input
-    includes using YAML anchors to resuse block and component definitions. YAML anchors (e.g.
-    ``&block_name``) must be defined before their corresponding alias (e.g. ``*block_name``) used.
+The core map input files can be graphically manipulated with the 
+:py:mod:`Grid editor <armi.utils.gridEditor>`.
 
 
 .. _blocks-and-components:


Test patch:
diff --git a/armi/reactor/tests/test_assemblies.py b/armi/reactor/tests/test_assemblies.py
index 37e9df87..5eaed62e 100644
--- a/armi/reactor/tests/test_assemblies.py
+++ b/armi/reactor/tests/test_assemblies.py
@@ -320,10 +320,15 @@ class Assembly_TestCase(unittest.TestCase):
 
     def test_add(self):
         a = makeTestAssembly(1, 1)
-        b = blocks.HexBlock("TestBlock")
-        a.add(b)
-        self.assertIn(b, a)
-        self.assertEqual(b.parent, a)
+
+        # successfully add some Blocks to an Assembly
+        for n in range(3):
+            self.assertEqual(len(a), n)
+            b = blocks.HexBlock("TestBlock")
+            a.add(b)
+            self.assertIn(b, a)
+            self.assertEqual(b.parent, a)
+            self.assertEqual(len(a), n + 1)
 
     def test_moveTo(self):
         ref = self.r.core.spatialGrid.getLocatorFromRingAndPos(3, 10)
@@ -363,10 +368,14 @@ class Assembly_TestCase(unittest.TestCase):
             :id: T_ARMI_ASSEM_DIMS0
             :tests: R_ARMI_ASSEM_DIMS
         """
+        # Default case: for assemblies with no blocks
+        a = HexAssembly("TestAssem", assemNum=10)
+        self.assertEqual(a.getArea(), 1)
+
+        # more realistic case: a hex block/assembly
         cur = self.assembly.getArea()
         ref = math.sqrt(3) / 2.0 * self.hexDims["op"] ** 2
-        places = 6
-        self.assertAlmostEqual(cur, ref, places=places)
+        self.assertAlmostEqual(cur, ref, places=6)
 
     def test_getVolume(self):
         """Tests volume calculation for hex assembly.
@@ -870,6 +879,11 @@ class Assembly_TestCase(unittest.TestCase):
             :id: T_ARMI_ASSEM_DIMS3
             :tests: R_ARMI_ASSEM_DIMS
         """
+        # quick test, if there are no blocks
+        a = HexAssembly("TestAssem", assemNum=10)
+        self.assertIsNone(a.getDim(Flags.FUEL, "op"))
+
+        # more interesting test, with blocks
         cur = self.assembly.getDim(Flags.FUEL, "op")
         ref = self.hexDims["op"]
         places = 6
@@ -1163,6 +1177,12 @@ class AssemblyInReactor_TestCase(unittest.TestCase):
 
         igniterFuel = self.r.core.childrenByLocator[grid[0, 0, 0]]
         # gridplate, fuel, fuel, fuel, plenum
+        for b in igniterFuel.getBlocks(Flags.FUEL):
+            fuelComp = b.getComponent(Flags.FUEL)
+            # add isotopes from clad and coolant to fuel component to test mass conservation
+            # mass should only be conserved within fuel component, not over the whole block
+            fuelComp.setNumberDensity("FE56", 1e-10)
+            fuelComp.setNumberDensity("NA23", 1e-10)
         b = igniterFuel[0]
         coolantNucs = b.getComponent(Flags.COOLANT).getNuclides()
         coolMass = 0
@@ -1175,6 +1195,8 @@ class AssemblyInReactor_TestCase(unittest.TestCase):
         igniterHMMass1 = b.getHMMass()
         igniterZircMass1 = b.getMass("ZR")
         igniterFuelBlockMass = b.getMass()
+        igniterDuctMass = b.getComponent(Flags.DUCT).getMass()
+        igniterCoolMass = b.getComponent(Flags.COOLANT).getMass()
 
         coolMass = 0
         b = igniterFuel[4]
@@ -1199,6 +1221,8 @@ class AssemblyInReactor_TestCase(unittest.TestCase):
         b = igniterFuel[1]
         igniterHMMass1AfterExpand = b.getHMMass()
         igniterZircMass1AfterExpand = b.getMass("ZR")
+        igniterDuctMassAfterExpand = b.getComponent(Flags.DUCT).getMass()
+        igniterCoolMassAfterExpand = b.getComponent(Flags.COOLANT).getMass()
 
         coolMass = 0
         b = igniterFuel[4]
@@ -1209,6 +1233,14 @@ class AssemblyInReactor_TestCase(unittest.TestCase):
         self.assertAlmostEqual(igniterMassGrid, igniterMassGridAfterExpand, 7)
         self.assertAlmostEqual(igniterHMMass1, igniterHMMass1AfterExpand, 7)
         self.assertAlmostEqual(igniterZircMass1, igniterZircMass1AfterExpand, 7)
+        # demonstrate that the duct and coolant mass are not conserved.
+        # number density stays constant, mass is scaled by ratio of new to old height
+        self.assertAlmostEqual(
+            igniterDuctMass, igniterDuctMassAfterExpand * 25.0 / 26.0, 7
+        )
+        self.assertAlmostEqual(
+            igniterCoolMass, igniterCoolMassAfterExpand * 25.0 / 26.0, 7
+        )
         # Note the masses are linearly different by the amount that the plenum shrunk
         self.assertAlmostEqual(
             igniterPlenumMass, igniterPlenumMassAfterExpand * 75 / 67.0, 7
@@ -1233,6 +1265,8 @@ class AssemblyInReactor_TestCase(unittest.TestCase):
         igniterHMMass1AfterShrink = b.getHMMass()
         igniterZircMass1AfterShrink = b.getMass("ZR")
         igniterFuelBlockMassAfterShrink = b.getMass()
+        igniterDuctMassAfterShrink = b.getComponent(Flags.DUCT).getMass()
+        igniterCoolMassAfterShrink = b.getComponent(Flags.COOLANT).getMass()
 
         coolMass = 0
         b = igniterFuel[4]
@@ -1245,6 +1279,8 @@ class AssemblyInReactor_TestCase(unittest.TestCase):
         self.assertAlmostEqual(igniterHMMass1, igniterHMMass1AfterShrink, 7)
         self.assertAlmostEqual(igniterZircMass1, igniterZircMass1AfterShrink, 7)
         self.assertAlmostEqual(igniterFuelBlockMass, igniterFuelBlockMassAfterShrink, 7)
+        self.assertAlmostEqual(igniterDuctMass, igniterDuctMassAfterShrink, 7)
+        self.assertAlmostEqual(igniterCoolMass, igniterCoolMassAfterShrink, 7)
         self.assertAlmostEqual(igniterPlenumMass, igniterPlenumMassAfterShrink, 7)
 
     def test_snapAxialMeshToReferenceConservingMassBasedOnBlockShield(self):