Repository: terrapower/armi

Issue:
Add support for hexagonal ducts with filleted corners
ARMI currently supports Hexagonal ducts with sharp corners, but in many practical cases the hex ducts have rounded (filleted) corners with a specified corner radius, which reduces both the flow area and wetted perimeter of these ducts and increases the hydraulic diameter by as much as ~2.4% (neglecting pins). Capability should be added to support either specifying a corner radius when defining a component of shape `Hexagon`, or perhaps a new `FilletedHexagon` shape should be added with that capability. 

If $D$ denotes the flat-to-flat inner duct diameter (i.e., the value returned by `duct.getDimension('ip')`) and $r$ denotes the corner radius ($0 \le r \le D/2$), then the perimeter, $P$, of the inner duct surface and the cross-sectional area $A$ inside the duct are given, respectively, by:

$$P(D,r) = \underbrace{2\sqrt{3}D}_{P(D,r=0)} \left[1 - \left(1 - \frac{\pi}{2\sqrt{3}}\right)\left(\frac{2r}{D}\right)\right]$$

and

$$A(D,r) = \underbrace{\frac{\sqrt{3}}{2}D^2}_{A(D,r=0)} \left[1 - \left(1 - \frac{\pi}{2\sqrt{3}}\right)\left(\frac{2r}{D}\right)^2 \right]$$

where $P(D, r=0)$ and $A(D, r=0)$ denote the perimeter and cross-sectional area assuming unrounded corners. Note that in the limiting case where $r = D/2$, the hexagon reduces to a circle and we have $A(D, D/2) = \pi D^2 /4$ and $P(D, D/2) = \pi D$ as expected. 

The hydraulic diameter of a filleted duct is given by

$$D_{hyd} = D \left[ \frac{1 - \left(1 - \frac{\pi}{2\sqrt{3}} \right) \left( \frac{2r}{D} \right)^2}{ 1 - \left(1 - \frac{\pi}{2\sqrt{3}} \right) \left( \frac{2r}{D} \right) } \right]$$

The quantity in brackets is maximum when $r \approx 0.256 D$, in which case $D_{hyd} \approx 1.0224 D$

Discussion:
@john-science: @andfranklin If you want to take this one, great!

This should be a ~10-line change, plus unit tests. 

A quick reminder, we try to make ["one change per PR"](https://terrapower.github.io/armi/developer/tooling.html#one-idea-one-pr).

Also, just in case you haven't seen it, you should just copy/paste the geometry tests we have already in place:

https://github.com/terrapower/armi/blob/35a0c87c4ccd97c5a04ba8eba330c0bfc90983f6/armi/reactor/tests/test_components.py#L1171-L1174

We will need to verify that correct calculations are being made for wetted perimeter, flow area and hydraulic diameter for unpinned `HexBlock`s with rounded corners.@john-science: My current plan is to create a new `FilletedHexagon` shape by subclassing `Hexagon`.

It will look like `Hexagon`, but with one new parameter: the radius of curvature of the rounded corners.

Solution patch:
diff --git a/armi/physics/neutronics/__init__.py b/armi/physics/neutronics/__init__.py
index 472ace73..8b3fb8f2 100644
--- a/armi/physics/neutronics/__init__.py
+++ b/armi/physics/neutronics/__init__.py
@@ -15,118 +15,12 @@
 """
 The neutronics physics package in the ARMI framework.
 
-Neutronics encompasses the modeling of nuclear chain reactions and their associated transmutation and decay.
-
-The ARMI Framework comes with a neutronics plugin that introduces two
-independent interfaces:
-
-:py:mod:`~armi.physics.neutronics.fissionProductModel`
-    Handles fission product modeling
-
-:py:mod:`~armi.physics.neutronics.crossSectionGroupManager`
-    Handles the management of different cross section "groups"
-
-.. warning:: There is also some legacy and question-raising code in this module that
-    is here temporarily while we finish untangling some of the neutronics
-    plugins outside of ARMI.
+Neutronics encompasses the modeling of nuclear chain reactions and their associated transmutation
+and decay.
 """
-# ruff: noqa: F401, E402
+# ruff: noqa: F401
 from enum import IntEnum
 
-import numpy as np
-
-from armi import plugins, runLog
-from armi.physics.neutronics.const import CONF_CROSS_SECTION
-from armi.utils import tabulate
-
-
-class NeutronicsPlugin(plugins.ArmiPlugin):
-    """The built-in neutronics plugin with a few capabilities and a lot of state parameter definitions."""
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def exposeInterfaces(cs):
-        """Collect and expose all of the interfaces that live under the built-in neutronics package."""
-        from armi.physics.neutronics import crossSectionGroupManager
-        from armi.physics.neutronics.fissionProductModel import fissionProductModel
-
-        interfaceInfo = []
-        for mod in (crossSectionGroupManager, fissionProductModel):
-            interfaceInfo += plugins.collectInterfaceDescriptions(mod, cs)
-
-        return interfaceInfo
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def defineParameters():
-        """Define parameters for the plugin."""
-        from armi.physics.neutronics import parameters as neutronicsParameters
-
-        return neutronicsParameters.getNeutronicsParameterDefinitions()
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def defineParameterRenames():
-        return {"buGroup": "envGroup", "buGroupNum": "envGroupNum"}
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def defineEntryPoints():
-        """Define entry points for the plugin."""
-        from armi.physics.neutronics import diffIsotxs
-
-        entryPoints = [diffIsotxs.CompareIsotxsLibraries]
-
-        return entryPoints
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def defineSettings():
-        """Define settings for the plugin."""
-        from armi.physics.neutronics import crossSectionSettings
-        from armi.physics.neutronics import settings as neutronicsSettings
-        from armi.physics.neutronics.fissionProductModel import (
-            fissionProductModelSettings,
-        )
-
-        settings = [
-            crossSectionSettings.XSSettingDef(
-                CONF_CROSS_SECTION,
-            )
-        ]
-        settings += neutronicsSettings.defineSettings()
-        settings += fissionProductModelSettings.defineSettings()
-
-        return settings
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def defineSettingsValidators(inspector):
-        """Implementation of settings inspections for neutronics settings."""
-        from armi.physics.neutronics.fissionProductModel.fissionProductModelSettings import (
-            getFissionProductModelSettingValidators,
-        )
-        from armi.physics.neutronics.settings import getNeutronicsSettingValidators
-
-        settingsValidators = getNeutronicsSettingValidators(inspector)
-        settingsValidators.extend(getFissionProductModelSettingValidators(inspector))
-        return settingsValidators
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def onProcessCoreLoading(core, cs, dbLoad):
-        """Called whenever a Core object is newly built."""
-        applyEffectiveDelayedNeutronFractionToCore(core, cs)
-
-    @staticmethod
-    @plugins.HOOKIMPL
-    def getReportContents(r, cs, report, stage, blueprint):
-        """Generates the Report Content for the Neutronics Report."""
-        from armi.physics.neutronics import reports
-
-        return reports.insertNeutronicsReport(r, cs, report, stage)
-
-
 from armi.physics.neutronics.const import (
     ALL,
     FLUXFILES,
@@ -136,6 +30,7 @@ from armi.physics.neutronics.const import (
     NEUTRONGAMMA,
     RESTARTFILES,
 )
+from armi.physics.neutronics.plugin import NeutronicsPlugin
 
 # ARC and CCCC cross section file format names
 COMPXS = "COMPXS"
@@ -176,6 +71,7 @@ EXTRAPOLATED = "Extrapolated"
 ZEROFLUX = "ZeroSurfaceFlux"
 ZERO_INWARD_CURRENT = "ZeroInwardCurrent"
 
+
 # Common settings checks
 def gammaTransportIsRequested(cs):
     """
@@ -229,57 +125,6 @@ def realCalculationRequested(cs):
     return cs[CONF_NEUTRONICS_TYPE] in ["real", "both"]
 
 
-def applyEffectiveDelayedNeutronFractionToCore(core, cs):
-    """Process the settings for the delayed neutron fraction and precursor decay constants."""
-    # Verify and set the core beta parameters based on the user-supplied settings
-    beta = cs["beta"]
-    decayConstants = cs["decayConstants"]
-
-    # If beta is interpreted as a float, then assign it to
-    # the total delayed neutron fraction parameter. Otherwise, setup the
-    # group-wise delayed neutron fractions and precursor decay constants.
-    reportTableData = []
-    if isinstance(beta, float):
-        core.p.beta = beta
-        reportTableData.append(("Total Delayed Neutron Fraction", core.p.beta))
-
-    elif isinstance(beta, list) and isinstance(decayConstants, list):
-        if len(beta) != len(decayConstants):
-            raise ValueError(
-                f"The values for `beta` ({beta}) and `decayConstants` "
-                f"({decayConstants}) are not consistent lengths."
-            )
-
-        core.p.beta = sum(beta)
-        core.p.betaComponents = np.array(beta)
-        core.p.betaDecayConstants = np.array(decayConstants)
-
-        reportTableData.append(("Total Delayed Neutron Fraction", core.p.beta))
-        for i, betaComponent in enumerate(core.p.betaComponents):
-            reportTableData.append(
-                (f"Group {i} Delayed Neutron Fractions", betaComponent)
-            )
-        for i, decayConstant in enumerate(core.p.betaDecayConstants):
-            reportTableData.append(
-                ("Group {i} Precursor Decay Constants", decayConstant)
-            )
-
-    # Report to the user the values were not applied.
-    if not reportTableData and (beta is not None or decayConstants is not None):
-        runLog.warning(
-            f"Delayed neutron fraction(s) - {beta} and decay constants"
-            f" - {decayConstants} have not been applied."
-        )
-    else:
-        runLog.extra(
-            tabulate.tabulate(
-                data=reportTableData,
-                headers=["Component", "Value"],
-                tableFmt="armi",
-            )
-        )
-
-
 class LatticePhysicsFrequency(IntEnum):
     """
     Enumeration for lattice physics update frequency options.
@@ -293,12 +138,12 @@ class LatticePhysicsFrequency(IntEnum):
 
     Notes
     -----
-    firstCoupledIteration only updates the cross sections during the first coupled iteration,
-    but not on any subsequent iterations. This may be an appropriate approximation in some cases
-    to save compute time, but each individual user should give careful consideration to whether
-    this is the behavior they want for a particular application. The main purpose of this setting
-    is to capture a large change in temperature distribution when running a snapshot at a different
-    power/flow condition than the original state being loaded from the database.
+    firstCoupledIteration only updates the cross sections during the first coupled iteration, but
+    not on any subsequent iterations. This may be an appropriate approximation in some cases to save
+    compute time, but each individual user should give careful consideration to whether this is the
+    behavior they want for a particular application. The main purpose of this setting is to capture
+    a large change in temperature distribution when running a snapshot at a different power/flow
+    condition than the original state being loaded from the database.
     """
 
     never = 0
diff --git a/armi/physics/neutronics/plugin.py b/armi/physics/neutronics/plugin.py
new file mode 100644
index 00000000..fbb4a343
--- /dev/null
+++ b/armi/physics/neutronics/plugin.py
@@ -0,0 +1,169 @@
+# Copyright 2025 TerraPower, LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+A boilerplate entry for a neutronics physics plugin.
+
+The ARMI Framework comes with a neutronics plugin that introduces two independent interfaces:
+
+:py:mod:`~armi.physics.neutronics.fissionProductModel`
+    Handles fission product modeling
+
+:py:mod:`~armi.physics.neutronics.crossSectionGroupManager`
+    Handles the management of different cross section "groups"
+"""
+
+import numpy as np
+
+from armi import plugins, runLog
+from armi.physics.neutronics.const import CONF_CROSS_SECTION
+from armi.utils import tabulate
+
+
+class NeutronicsPlugin(plugins.ArmiPlugin):
+    """The built-in neutronics plugin with a few capabilities and a lot of state parameter definitions."""
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def exposeInterfaces(cs):
+        """Collect and expose all of the interfaces that live under the built-in neutronics package."""
+        from armi.physics.neutronics import crossSectionGroupManager
+        from armi.physics.neutronics.fissionProductModel import fissionProductModel
+
+        interfaceInfo = []
+        for mod in (crossSectionGroupManager, fissionProductModel):
+            interfaceInfo += plugins.collectInterfaceDescriptions(mod, cs)
+
+        return interfaceInfo
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def defineParameters():
+        """Define parameters for the plugin."""
+        from armi.physics.neutronics import parameters as neutronicsParameters
+
+        return neutronicsParameters.getNeutronicsParameterDefinitions()
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def defineParameterRenames():
+        return {"buGroup": "envGroup", "buGroupNum": "envGroupNum"}
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def defineEntryPoints():
+        """Define entry points for the plugin."""
+        from armi.physics.neutronics import diffIsotxs
+
+        entryPoints = [diffIsotxs.CompareIsotxsLibraries]
+
+        return entryPoints
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def defineSettings():
+        """Define settings for the plugin."""
+        from armi.physics.neutronics import crossSectionSettings
+        from armi.physics.neutronics import settings as neutronicsSettings
+        from armi.physics.neutronics.fissionProductModel import (
+            fissionProductModelSettings,
+        )
+
+        settings = [
+            crossSectionSettings.XSSettingDef(
+                CONF_CROSS_SECTION,
+            )
+        ]
+        settings += neutronicsSettings.defineSettings()
+        settings += fissionProductModelSettings.defineSettings()
+
+        return settings
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def defineSettingsValidators(inspector):
+        """Implementation of settings inspections for neutronics settings."""
+        from armi.physics.neutronics.fissionProductModel.fissionProductModelSettings import (
+            getFissionProductModelSettingValidators,
+        )
+        from armi.physics.neutronics.settings import getNeutronicsSettingValidators
+
+        settingsValidators = getNeutronicsSettingValidators(inspector)
+        settingsValidators.extend(getFissionProductModelSettingValidators(inspector))
+        return settingsValidators
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def onProcessCoreLoading(core, cs, dbLoad):
+        """Called whenever a Core object is newly built."""
+        applyEffectiveDelayedNeutronFractionToCore(core, cs)
+
+    @staticmethod
+    @plugins.HOOKIMPL
+    def getReportContents(r, cs, report, stage, blueprint):
+        """Generates the Report Content for the Neutronics Report."""
+        from armi.physics.neutronics import reports
+
+        return reports.insertNeutronicsReport(r, cs, report, stage)
+
+
+def applyEffectiveDelayedNeutronFractionToCore(core, cs):
+    """Process the settings for the delayed neutron fraction and precursor decay constants."""
+    # Verify and set the core beta parameters based on the user-supplied settings
+    beta = cs["beta"]
+    decayConstants = cs["decayConstants"]
+
+    # If beta is interpreted as a float, then assign it to the total delayed neutron fraction
+    # parameter. Otherwise, setup the group-wise delayed neutron fractions and precursor decay
+    # constants.
+    reportTableData = []
+    if isinstance(beta, float):
+        core.p.beta = beta
+        reportTableData.append(("Total Delayed Neutron Fraction", core.p.beta))
+
+    elif isinstance(beta, list) and isinstance(decayConstants, list):
+        if len(beta) != len(decayConstants):
+            raise ValueError(
+                f"The values for `beta` ({beta}) and `decayConstants` "
+                f"({decayConstants}) are not consistent lengths."
+            )
+
+        core.p.beta = sum(beta)
+        core.p.betaComponents = np.array(beta)
+        core.p.betaDecayConstants = np.array(decayConstants)
+
+        reportTableData.append(("Total Delayed Neutron Fraction", core.p.beta))
+        for i, betaComponent in enumerate(core.p.betaComponents):
+            reportTableData.append(
+                (f"Group {i} Delayed Neutron Fractions", betaComponent)
+            )
+        for i, decayConstant in enumerate(core.p.betaDecayConstants):
+            reportTableData.append(
+                ("Group {i} Precursor Decay Constants", decayConstant)
+            )
+
+    # Report to the user the values were not applied.
+    if not reportTableData and (beta is not None or decayConstants is not None):
+        runLog.warning(
+            f"Delayed neutron fraction(s) - {beta} and decay constants"
+            f" - {decayConstants} have not been applied."
+        )
+    else:
+        runLog.extra(
+            tabulate.tabulate(
+                data=reportTableData,
+                headers=["Component", "Value"],
+                tableFmt="armi",
+            )
+        )
diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py
index 8655daab..a3f04b18 100644
--- a/armi/reactor/blocks.py
+++ b/armi/reactor/blocks.py
@@ -1676,11 +1676,10 @@ class Block(composites.Composite):
         Raises
         ------
         AttributeError
-            If no ancestor of this block contains the input blueprints. Blueprints are
-            usually stored on the reactor object, which is typically an ancestor of
-            the block (block -> assembly -> core -> reactor). However, this may be the case
-            when creating blocks from scratch in testing where the entire composite
-            tree may not exist.
+            If no ancestor of this block contains the input blueprints. Blueprints are usually
+            stored on the reactor object, which is typically an ancestor of the block
+            (block -> assembly -> core -> reactor). However, this may be the case when creating
+            blocks from scratch in testing where the entire composite tree may not exist.
         """
         ancestorWithBp = self.getAncestor(
             lambda o: getattr(o, "blueprints", None) is not None
@@ -1691,12 +1690,13 @@ class Block(composites.Composite):
             heights = assemDesign.height
             myIndex = self.parent.index(self)
             return heights[myIndex]
+
         raise AttributeError(f"No ancestor of {self} has blueprints")
 
 
 class HexBlock(Block):
     """
-    Defines a HexBlock.
+    Defines a Block shaped like a hexagon.
 
     .. impl:: ARMI has the ability to create hex shaped blocks.
         :id: I_ARMI_BLOCK_HEX
@@ -1721,12 +1721,12 @@ class HexBlock(Block):
             :implements: R_ARMI_BLOCK_POSI
 
             Calls to the :py:meth:`~armi.reactor.grids.locations.IndexLocation.getGlobalCoordinates`
-            method of the block's ``spatialLocator`` attribute, which recursively
-            calls itself on all parents of the block to get the coordinates of the
-            block's centroid in 3D cartesian space.
+            method of the block's ``spatialLocator`` attribute, which recursively calls itself on
+            all parents of the block to get the coordinates of the block's centroid in 3D cartesian
+            space.
 
-            Will additionally adjust the x and y coordinates based on the block
-            parameters ``displacementX`` and ``displacementY``.
+            Will additionally adjust the x and y coordinates based on the block parameters
+            ``displacementX`` and ``displacementY``.
         """
         x, y, _z = self.spatialLocator.getGlobalCoordinates()
         x += self.p.displacementX * 100.0
@@ -1744,44 +1744,42 @@ class HexBlock(Block):
             :id: I_ARMI_BLOCK_HOMOG
             :implements: R_ARMI_BLOCK_HOMOG
 
-            This method creates and returns a homogenized representation of itself in the form of a new Block.
-            The homogenization occurs in the following manner. A single Hexagon Component is created
-            and added to the new Block. This Hexagon Component is given the
+            This method creates and returns a homogenized representation of itself in the form of a
+            new Block. The homogenization occurs in the following manner. A single Hexagon Component
+            is created and added to the new Block. This Hexagon Component is given the
             :py:class:`armi.materials.mixture._Mixture` material and a volume averaged temperature
             (``getAverageTempInC``). The number densities of the original Block are also stored on
-            this new Component (:need:`I_ARMI_CMP_GET_NDENS`). Several parameters from the original block
-            are copied onto the homogenized block (e.g., macros, lumped fission products, burnup group,
-            number of pins, and spatial grid).
+            this new Component (:need:`I_ARMI_CMP_GET_NDENS`). Several parameters from the original
+            block are copied onto the homogenized block (e.g., macros, lumped fission products,
+            burnup group, number of pins, and spatial grid).
 
         Notes
         -----
-        This can be used to improve performance when a new copy of a reactor needs to be
-        built, but the full detail of the block (including component geometry, material,
-        number density, etc.) is not required for the targeted physics solver being applied
-        to the new reactor model.
+        This can be used to improve performance when a new copy of a reactor needs to be built, but
+        the full detail of the block (including component geometry, material, number density, etc.)
+        is not required for the targeted physics solver being applied to the new reactor model.
 
         The main use case is for the uniform mesh converter (UMC). Frequently, a deterministic
-        neutronics solver will require a uniform mesh reactor, which is produced by the UMC.
-        Many deterministic solvers for fast spectrum reactors will also treat the individual
-        blocks as homogenized mixtures. Since the neutronics solver does not need to know about
-        the geometric and material details of the individual child components within a block,
-        we can save significant effort while building the uniform mesh reactor with the UMC
-        by omitting this detailed data and only providing the necessary level of detail for
-        the uniform mesh reactor: number densities on each block.
-
-        Individual components within a block can have different temperatures, and this
-        can affect cross sections. This temperature variation is captured by the lattice physics
-        module. As long as temperature distribution is correctly captured during cross section
-        generation, it doesn't need to be transferred to the neutronics solver directly through
-        this copy operation.
+        neutronics solver will require a uniform mesh reactor, which is produced by the UMC. Many
+        deterministic solvers for fast spectrum reactors will also treat the individual blocks as
+        homogenized mixtures. Since the neutronics solver does not need to know about the geometric
+        and material details of the individual child components within a block, we can save
+        significant effort while building the uniform mesh reactor with the UMC by omitting this
+        detailed data and only providing the necessary level of detail for the uniform mesh reactor:
+        number densities on each block.
+
+        Individual components within a block can have different temperatures, and this can affect
+        cross sections. This temperature variation is captured by the lattice physics module. As
+        long as temperature distribution is correctly captured during cross section generation, it
+        does not need to be transferred to the neutronics solver directly through this copy
+        operation.
 
         If you make a new block, you must add it to an assembly and a reactor.
 
         Returns
         -------
-        b
-            A homogenized block containing a single Hexagon Component that contains an
-            average temperature and the number densities from the original block.
+        b : A homogenized block containing a single Hexagon Component that contains an average
+            temperature and the number densities from the original block.
 
         See Also
         --------
@@ -1863,26 +1861,24 @@ class HexBlock(Block):
         Updates the pin linear power densities of this block for the current rotation.
         The linear densities are represented by the *linPowByPin* parameter.
 
-        It is assumed that :py:meth:`.initializePinLocations` has already been executed
-        for fueled blocks in order to access the *pinLocation* parameter. The
-        *pinLocation* parameter is not accessed for non-fueled blocks.
+        It is assumed that :py:meth:`.initializePinLocations` has already been executed for fueled
+        blocks in order to access the *pinLocation* parameter. The *pinLocation* parameter is not
+        accessed for non-fueled blocks.
 
-        The *linPowByPin* parameter can be directly assigned to instead of using this
-        method if the multiplicity of the pins in the block is equal to the number of
-        pins in the block.
+        The *linPowByPin* parameter can be directly assigned to instead of using this method if the
+        multiplicity of the pins in the block is equal to the number of pins in the block.
 
         Parameters
         ----------
         powers : list of floats, required
-            The block-level pin linear power densities. powers[i] represents the average
-            linear power density of pin i. The units of linear power density is watts/cm
-            (i.e., watts produced per cm of pin length). The "ARMI pin ordering" must be
-            be used, which is counter-clockwise from 3 o'clock.
+            The block-level pin linear power densities. powers[i] represents the average linear
+            power density of pin i. The units of linear power density is watts/cm (i.e., watts
+            produced per cm of pin length). The "ARMI pin ordering" must be be used, which is
+            counter-clockwise from 3 o'clock.
 
         powerKeySuffix: str, optional
             Must be either an empty string, :py:const:`NEUTRON <armi.physics.neutronics.const.NEUTRON>`,
-            or :py:const:`GAMMA <armi.physics.neutronics.const.GAMMA>`. Defaults to empty
-            string.
+            or :py:const:`GAMMA <armi.physics.neutronics.const.GAMMA>`. Defaults to empty string.
 
         Notes
         -----
@@ -1898,8 +1894,8 @@ class HexBlock(Block):
         powerKey = f"linPowByPin{powerKeySuffix}"
         self.p[powerKey] = np.zeros(numPins)
 
-        # Loop through rings. The *pinLocation* parameter is only accessed for fueled
-        # blocks; it is assumed that non-fueled blocks do not use a rotation map.
+        # Loop through rings. The *pinLocation* parameter is only accessed for fueled blocks; it is
+        # assumed that non-fueled blocks do not use a rotation map.
         for pinNum in range(numPins):
             if self.hasFlags(Flags.FUEL):
                 # -1 is needed in order to map from pinLocations to list index
@@ -1909,9 +1905,9 @@ class HexBlock(Block):
             pinLinPow = powers[pinLoc]
             self.p[powerKey][pinNum] = pinLinPow
 
-        # If using the *powerKeySuffix* parameter, we also need to set total power, which
-        # is sum of neutron and gamma powers. We assume that a solo gamma calculation
-        # to set total power does not make sense.
+        # If using the *powerKeySuffix* parameter, we also need to set total power, which is sum of
+        # neutron and gamma powers. We assume that a solo gamma calculation to set total power does
+        # not make sense.
         if powerKeySuffix:
             if powerKeySuffix == GAMMA:
                 if self.p[f"linPowByPin{NEUTRON}"] is None:
@@ -1989,9 +1985,8 @@ class HexBlock(Block):
         Parameters
         ----------
         rotNum : int
-            Rotation number between zero and five, inclusive, specifying how many
-            rotations have taken place.
-
+            Rotation number between zero and five, inclusive, specifying how many rotations have
+            taken place.
         """
         names = self.p.paramDefs.atLocation(ParamLocation.CORNERS).names
         names += self.p.paramDefs.atLocation(ParamLocation.EDGES).names
@@ -2024,8 +2019,8 @@ class HexBlock(Block):
                 )
 
     def _rotateDisplacement(self, rad: float):
-        # This specifically uses the .get() functionality to avoid an error if this
-        # parameter does not exist.
+        # This specifically uses the .get() functionality to avoid an error if this parameter does
+        # not exist.
         dispx = self.p.get("displacementX")
         dispy = self.p.get("displacementY")
         if (dispx is not None) and (dispy is not None):
@@ -2045,6 +2040,7 @@ class HexBlock(Block):
                 "are correct!".format(self)
             )
             return
+
         # check wire wrap in contact with clad
         if (
             self.getComponent(Flags.CLAD) is not None
@@ -2057,10 +2053,10 @@ class HexBlock(Block):
                     "".format(self, wwCladGap),
                     single=True,
                 )
+
         # check clad duct overlap
         pinToDuctGap = self.getPinToDuctGap(cold=True)
-        # Allow for some tolerance; user input precision may lead to slight negative
-        # gaps
+        # Allow for some tolerance; user input precision may lead to slight negative gaps
         if pinToDuctGap is not None and pinToDuctGap < -0.005:
             raise ValueError(
                 "Gap between pins and duct is {0:.4f} cm in {1}. Make more room.".format(
@@ -2111,8 +2107,8 @@ class HexBlock(Block):
         if any(c is None for c in (duct, wire, clad)):
             return None
 
-        # note, if nRings was a None, this could be for a non-hex packed fuel assembly
-        # see thermal hydraulic design basis for description of equation
+        # NOTE: If nRings was a None, this could be for a non-hex packed fuel assembly see thermal
+        # hydraulic design basis for description of equation
         pinCenterFlatToFlat = self.getPinCenterFlatToFlat(cold=cold)
         pinOuterFlatToFlat = (
             pinCenterFlatToFlat
@@ -2126,9 +2122,8 @@ class HexBlock(Block):
 
     def getRotationNum(self) -> int:
         """Get index 0 through 5 indicating number of rotations counterclockwise around the z-axis."""
-        return (
-            np.rint(self.p.orientation[2] / 360.0 * 6) % 6
-        )  # assume rotation only in Z
+        # assume rotation only in Z
+        return np.rint(self.p.orientation[2] / 360.0 * 6) % 6
 
     def setRotationNum(self, rotNum: int):
         """
@@ -2167,9 +2162,9 @@ class HexBlock(Block):
             else:
                 symmetryLine = self.core.spatialGrid.overlapsWhichSymmetryLine(indices)
                 # detect if upper edge assemblies are included. Doing this is the only way to know
-                # definitively whether or not the edge assemblies are half-assems or full.
-                # seeing the first one is the easiest way to detect them.
-                # Check it last in the and statement so we don't waste time doing it.
+                # definitively whether or not the edge assemblies are half-assems or full. Seeing
+                # the first one is the easiest way to detect them. Check it last in the and
+                # statement so we don't waste time doing it.
                 upperEdgeLoc = self.core.spatialGrid[-1, 2, 0]
                 if symmetryLine in [
                     grids.BOUNDARY_0_DEGREES,
@@ -2308,8 +2303,8 @@ class HexBlock(Block):
             wire = self.getComponent(Flags.WIRE)
         except ValueError:
             raise ValueError(
-                "Block {} has multiple clad and wire components,"
-                " so pin pitch is not well-defined.".format(self)
+                f"Block {self} has multiple clad and wire components, so pin pitch is not well-"
+                "defined."
             )
 
         if wire and clad:
@@ -2318,9 +2313,7 @@ class HexBlock(Block):
             )
         else:
             raise ValueError(
-                "Cannot get pin pitch in {} because it does not have a wire and a clad".format(
-                    self
-                )
+                f"Cannot get pin pitch in {self} because it does not have a wire and a clad"
             )
 
     def getWettedPerimeter(self):
@@ -2426,10 +2419,9 @@ class HexBlock(Block):
         r"""
         Return the hydraulic diameter in this block in cm.
 
-        Hydraulic diameter is 4A/P where A is the flow area and P is the wetted perimeter.
-        In a hex assembly, the wetted perimeter includes the cladding, the wire wrap, and the
-        inside of the duct. The flow area is the inner area of the duct minus the area of the
-        pins and the wire.
+        Hydraulic diameter is 4A/P where A is the flow area and P is the wetted perimeter. In a hex
+        assembly, the wetted perimeter includes the cladding, the wire wrap, and the inside of the
+        duct. The flow area is the inner area of the duct minus the area of the pins and the wire.
         """
         return 4.0 * self.getFlowArea() / self.getWettedPerimeter()
 
@@ -2462,6 +2454,7 @@ class CartesianBlock(Block):
                 elif indices[0] == 0 or indices[1] == 0:
                     # edge location
                     return 2.0
+
         return 1.0
 
     def getPinCenterFlatToFlat(self, cold=False):
diff --git a/armi/reactor/blueprints/blockBlueprint.py b/armi/reactor/blueprints/blockBlueprint.py
index fe358c56..ceb90e94 100644
--- a/armi/reactor/blueprints/blockBlueprint.py
+++ b/armi/reactor/blueprints/blockBlueprint.py
@@ -140,6 +140,7 @@ class BlockBlueprint(yamlize.KeyedList):
 
         self._checkByComponentMaterialInput(materialInput)
 
+        allLatticeIds = set()
         for componentDesign in self:
             filteredMaterialInput, byComponentMatModKeys = self._filterMaterialInput(
                 materialInput, componentDesign
@@ -184,6 +185,30 @@ class BlockBlueprint(yamlize.KeyedList):
                         # learn mult from grid definition
                         c.setDimension("mult", len(c.spatialLocator))
 
+                idsInGrid = list(gridDesign.gridContents.values())
+                if componentDesign.latticeIDs:
+                    for latticeID in componentDesign.latticeIDs:
+                        allLatticeIds.add(str(latticeID))
+                        # the user has given this component latticeIDs. check that
+                        # each of the ids appears in the grid, otherwise
+                        # their blueprints are probably wrong
+                        if len([i for i in idsInGrid if i == str(latticeID)]) == 0:
+                            raise ValueError(
+                                f"latticeID {latticeID} in block blueprint '{self.name}' is expected "
+                                "to be present in the associated block grid. "
+                                "Check that the component's latticeIDs align with the block's grid."
+                            )
+
+        # for every id in grid, confirm that at least one component had it
+        if gridDesign:
+            idsInGrid = list(gridDesign.gridContents.values())
+            for idInGrid in idsInGrid:
+                if str(idInGrid) not in allLatticeIds:
+                    raise ValueError(
+                        f"ID {idInGrid} in grid {gridDesign.name} is not in any components of block {self.name}. "
+                        "All IDs in the grid must appear in at least one component."
+                    )
+
         # check that the block level mat mods use valid options in the same way
         # as we did for the by-component mods above
         validMatModOptions = self._getBlockwiseMaterialModifierOptions(
diff --git a/armi/reactor/components/basicShapes.py b/armi/reactor/components/basicShapes.py
index 49dceed5..6c9f8a90 100644
--- a/armi/reactor/components/basicShapes.py
+++ b/armi/reactor/components/basicShapes.py
@@ -95,23 +95,25 @@ class Circle(ShapedComponent):
 class Hexagon(ShapedComponent):
     """A Hexagon.
 
+    This hexagonal shape has a hexagonal hole cut out of the center of it. By default, that inner
+    hole has a diameter of zero, making this a solid object with no hole.
+
     .. impl:: Hexagon shaped Component
         :id: I_ARMI_COMP_SHAPES1
         :implements: R_ARMI_COMP_SHAPES
 
-        This class provides the implementation of a hexagonal Component. This
-        includes setting key parameters such as its material, temperature, and
-        dimensions. It also includes methods for retrieving geometric
-        dimension information unique to hexagons such as the ``getPerimeter`` and
-        ``getPitchData`` methods.
+        This class provides the implementation of a hexagonal Component. This includes setting key
+        parameters such as its material, temperature, and dimensions. It also includes methods for
+        retrieving geometric dimension information unique to hexagons such as the ``getPitchData``
+        method.
     """
 
     is3D = False
 
-    THERMAL_EXPANSION_DIMS = {"ip", "op"}
-
     pDefs = componentParameters.getHexagonParameterDefinitions()
 
+    THERMAL_EXPANSION_DIMS = {"ip", "op"}
+
     def __init__(
         self,
         name,
@@ -149,13 +151,7 @@ class Hexagon(ShapedComponent):
         return 2.0 * sideLength
 
     def getComponentArea(self, cold=False, Tc=None):
-        """
-        Computes the area for the hexagon component in cm^2.
-
-        Notes
-        -----
-        http://www3.wolframalpha.com/input/?i=hexagon
-        """
+        """Computes the area for the hexagon component in cm^2."""
         op = self.getDimension("op", cold=cold, Tc=Tc)
         ip = self.getDimension("ip", cold=cold, Tc=Tc)
         mult = self.getDimension("mult")
@@ -163,13 +159,6 @@ class Hexagon(ShapedComponent):
         area *= mult
         return area
 
-    def getPerimeter(self, Tc=None):
-        """Computes the perimeter of the hexagon component in cm."""
-        ip = self.getDimension("ip", Tc)
-        mult = self.getDimension("mult", Tc)
-        perimeter = 6 * (ip / math.sqrt(3)) * mult
-        return perimeter
-
     def getPitchData(self):
         """
         Return the pitch data that should be used to determine block pitch.
diff --git a/armi/reactor/components/complexShapes.py b/armi/reactor/components/complexShapes.py
index 7eefab9a..08bb5a85 100644
--- a/armi/reactor/components/complexShapes.py
+++ b/armi/reactor/components/complexShapes.py
@@ -13,7 +13,6 @@
 # limitations under the License.
 
 """Components represented by complex shapes, and typically less widely used."""
-
 import math
 
 from armi.reactor.components import ShapedComponent, basicShapes, componentParameters
@@ -26,10 +25,10 @@ class HoledHexagon(basicShapes.Hexagon):
         :id: I_ARMI_COMP_SHAPES5
         :implements: R_ARMI_COMP_SHAPES
 
-        This class provides an implementation for a holed hexagonal Component. This
-        includes setting key parameters such as its material, temperature, and
-        dimensions. It also provides the capability to retrieve the diameter of the
-        inner hole via the ``getCircleInnerDiameter`` method.
+        This class provides an implementation for a holed hexagonal Component. This includes setting
+        key parameters such as its material, temperature, and dimensions. It also provides the
+        capability to retrieve the diameter of the inner hole via the ``getCircleInnerDiameter``
+        method.
     """
 
     THERMAL_EXPANSION_DIMS = {"op", "holeOD"}
@@ -66,7 +65,7 @@ class HoledHexagon(basicShapes.Hexagon):
         )
 
     def getComponentArea(self, cold=False, Tc=None):
-        r"""Computes the area for the hexagon with n number of circular holes in cm^2."""
+        """Computes the area for the hexagon with n number of circular holes in cm^2."""
         op = self.getDimension("op", cold=cold, Tc=Tc)
         holeOD = self.getDimension("holeOD", cold=cold, Tc=Tc)
         nHoles = self.getDimension("nHoles", cold=cold, Tc=Tc)
@@ -78,11 +77,9 @@ class HoledHexagon(basicShapes.Hexagon):
 
     def getCircleInnerDiameter(self, Tc=None, cold=False):
         """
-        For the special case of only one single hole, returns the
-        diameter of that hole.
+        For the special case of only one single hole, returns the diameter of that hole.
 
-        For any other case, returns 0.0 because an "circle inner diameter" becomes
-        undefined.
+        For any other case, returns 0.0 because an "circle inner diameter" becomes undefined.
         """
         if self.getDimension("nHoles") == 1:
             return self.getDimension("holeOD", Tc, cold)
@@ -140,6 +137,77 @@ class HexHoledCircle(basicShapes.Circle):
         return self.getDimension("holeOP", Tc, cold)
 
 
+class FilletedHexagon(basicShapes.Hexagon):
+    """
+    A hexagon with a hexagonal hole cut out of the center of it, where the corners of both the
+    outer and inner hexagons are rounded, with independent radii of curvature.
+
+    By default, the inner hole has a diameter of zero, making this a solid object with no hole.
+    """
+
+    THERMAL_EXPANSION_DIMS = {"iR", "oR", "ip", "op"}
+
+    pDefs = componentParameters.getFilletedHexagonParameterDefinitions()
+
+    def __init__(
+        self,
+        name,
+        material,
+        Tinput,
+        Thot,
+        op,
+        ip=0.0,
+        iR=0.0,
+        oR=0.0,
+        mult=1.0,
+        modArea=None,
+        isotopics=None,
+        mergeWith=None,
+        components=None,
+    ):
+        ShapedComponent.__init__(
+            self,
+            name,
+            material,
+            Tinput,
+            Thot,
+            isotopics=isotopics,
+            mergeWith=mergeWith,
+            components=components,
+        )
+        self._linkAndStoreDimensions(
+            components, op=op, ip=ip, iR=iR, oR=oR, mult=mult, modArea=modArea
+        )
+        assert (
+            oR <= op / 2
+        ), f"The outer radius of curvature is too large: {oR} > {op / 2}."
+        assert (
+            iR <= ip / 2
+        ), f"The inner radius of curvature is too large: {iR} > {ip / 2}."
+
+    @staticmethod
+    def _area(D, r):
+        """Helper function, to calculate the area of a hexagon with rounded corners."""
+        if D <= 0.0:
+            return 0.0
+
+        area = 1.0 - (1.0 - (math.pi / (2.0 * math.sqrt(3)))) * (2 * r / D) ** 2
+        area *= (math.sqrt(3.0) / 2.0) * D**2
+        return area
+
+    def getComponentArea(self, cold=False, Tc=None):
+        """Computes the area for the rounded hexagon component in cm^2."""
+        op = self.getDimension("op", cold=cold, Tc=Tc)
+        ip = self.getDimension("ip", cold=cold, Tc=Tc)
+        oR = self.getDimension("oR", cold=cold, Tc=Tc)
+        iR = self.getDimension("iR", cold=cold, Tc=Tc)
+        mult = self.getDimension("mult")
+
+        area = self._area(op, oR) - self._area(ip, iR)
+        area *= mult
+        return area
+
+
 class HoledRectangle(basicShapes.Rectangle):
     """Rectangle with one circular hole in it."""
 
@@ -182,7 +250,7 @@ class HoledRectangle(basicShapes.Rectangle):
         )
 
     def getComponentArea(self, cold=False, Tc=None):
-        r"""Computes the area (in cm^2) for the the rectangle with one hole in it."""
+        """Computes the area (in cm^2) for the the rectangle with one hole in it."""
         length = self.getDimension("lengthOuter", cold=cold, Tc=Tc)
         width = self.getDimension("widthOuter", cold=cold, Tc=Tc)
         rectangleArea = length * width
@@ -204,11 +272,10 @@ class HoledSquare(basicShapes.Square):
         :id: I_ARMI_COMP_SHAPES6
         :implements: R_ARMI_COMP_SHAPES
 
-        This class provides an implementation for a holed square Component. This
-        includes setting key parameters such as its material, temperature, and
-        dimensions. It also includes methods to retrieve geometric
-        dimension information unique to holed squares via the ``getComponentArea`` and
-        ``getCircleInnerDiameter`` methods.
+        This class provides an implementation for a holed square Component. This includes setting
+        key parameters such as its material, temperature, and dimensions. It also includes methods
+        to retrieve geometric dimension information unique to holed squares via the
+        ``getComponentArea`` and ``getCircleInnerDiameter`` methods.
     """
 
     THERMAL_EXPANSION_DIMS = {"widthOuter", "holeOD"}
@@ -244,7 +311,7 @@ class HoledSquare(basicShapes.Square):
         )
 
     def getComponentArea(self, cold=False, Tc=None):
-        r"""Computes the area (in cm^2) for the the square with one hole in it."""
+        """Computes the area (in cm^2) for the the square with one hole in it."""
         width = self.getDimension("widthOuter", cold=cold, Tc=Tc)
         rectangleArea = width**2
         holeOD = self.getDimension("holeOD", cold=cold, Tc=Tc)
@@ -265,11 +332,10 @@ class Helix(ShapedComponent):
         :id: I_ARMI_COMP_SHAPES7
         :implements: R_ARMI_COMP_SHAPES
 
-        This class provides the implementation for a helical Component. This
-        includes setting key parameters such as its material, temperature, and
-        dimensions. It also includes the ``getComponentArea`` method to retrieve the
-        area of a helix. Helixes can be used for wire wrapping around fuel pins in fast
-        reactor designs.
+        This class provides the implementation for a helical Component. This includes setting key
+        parameters such as its material, temperature, and dimensions. It also includes the
+        ``getComponentArea`` method to retrieve the area of a helix. Helixes can be used for wire
+        wrapping around fuel pins in fast reactor designs.
 
     Notes
     -----
@@ -279,11 +345,11 @@ class Helix(ShapedComponent):
 
     - od: outer diameter of the helix wire
     - id: inner diameter of the helix wire (if non-zero, helix wire is annular.)
-    - axialPitch: vertical distance between wraps. Is also the axial distance required to complete
-                  a full 2*pi rotation.
-    - helixDiameter: The helix diameter is the distance from the center of the
-                     wire-wrap on one side to the center of the wire-wrap on the opposite side
-                     (can be visualized if the axial pitch is 0.0 - creates a circle).
+    - axialPitch: vertical distance between wraps. Is also the axial distance required to complete a
+                  full 2*pi rotation.
+    - helixDiameter: The helix diameter is the distance from the center of the wire-wrap on one side
+                     to the center of the wire-wrap on the opposite side (can be visualized if the
+                     axial pitch is 0.0 - creates a circle).
     """
 
     is3D = False
@@ -337,7 +403,7 @@ class Helix(ShapedComponent):
     def getCircleInnerDiameter(self, Tc=None, cold=False):
         """The diameter of a circle which is encompassed by the interior of the wire-wrap.
 
-        - should be equal to the outer diameter of the pin in which the wire is wrapped around
+        This should be equal to the outer diameter of the pin in which the wire is wrapped around.
         """
         return self.getDimension("helixDiameter", Tc, cold=cold) - self.getDimension(
             "od", Tc, cold
diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py
index f616d4e1..26e06dcc 100644
--- a/armi/reactor/components/component.py
+++ b/armi/reactor/components/component.py
@@ -923,8 +923,7 @@ class Component(composites.Composite, metaclass=ComponentType):
             Dimensions should be set considering the impact of thermal expansion. This
             method allows for a user or plugin to set a dimension and indicate if the
             dimension is for a cold configuration or not. If it is not for a cold
-            configuration, the thermal expansion factor is considered when setting the
-            dimension.
+            configuration, the thermal expansion factor is considered when setting the dimension.
 
             If the ``retainLink`` argument is ``True``, any Components linked to this
             one will also have its dimensions changed consistently. After a dimension
diff --git a/armi/reactor/components/componentParameters.py b/armi/reactor/components/componentParameters.py
index 548ec794..7ad05911 100644
--- a/armi/reactor/components/componentParameters.py
+++ b/armi/reactor/components/componentParameters.py
@@ -239,6 +239,20 @@ def getHexHoledCircleParameterDefinitions():
     return pDefs
 
 
+def getFilletedHexagonParameterDefinitions():
+    """Return parameters for FilletedHexagon."""
+    pDefs = parameters.ParameterDefinitionCollection()
+    with pDefs.createBuilder(location=ParamLocation.AVERAGE, saveToDB=True) as pb:
+        pb.defParam(
+            "iR", units=units.CM, description="Radius of curvature of the inner corners"
+        )
+        pb.defParam(
+            "oR", units=units.CM, description="Radius of curvature of the outer corners"
+        )
+
+    return pDefs
+
+
 def getHoledRectangleParameterDefinitions():
     """Return parameters for HoledRectangle."""
     pDefs = parameters.ParameterDefinitionCollection()


Test patch:
diff --git a/armi/reactor/blueprints/tests/test_blockBlueprints.py b/armi/reactor/blueprints/tests/test_blockBlueprints.py
index 6215bee4..970921a8 100644
--- a/armi/reactor/blueprints/tests/test_blockBlueprints.py
+++ b/armi/reactor/blueprints/tests/test_blockBlueprints.py
@@ -129,13 +129,41 @@ grids:
          - - -  1 1 1 1
            - - 1 1 2 1 1
             - 1 1 1 1 1 1
-             1 3 1 2 1 3 1
+             1 2 1 2 1 2 1
               1 1 1 1 1 1
                1 1 2 1 1
                 1 1 1 1
 
 """
 
+FULL_BP_ERRANT_ID = (
+    FULL_BP.split("lattice map:")[0]
+    + """lattice map: |
+         - - -  1 1 1 1
+           - - 1 1 1 1 1
+            - 1 1 1 1 1 1
+             1 1 1 1 1 1 1
+              1 1 1 1 1 1
+               1 1 1 1 1
+                1 1 1 1
+
+"""
+)
+
+FULL_BP_NO_COMP = (
+    FULL_BP.split("lattice map:")[0]
+    + """lattice map: |
+         - - -  1 1 1 1
+           - - 1 1 1 1 1
+            - 1 1 1 1 1 1
+             1 3 1 1 1 3 1
+              1 1 1 1 1 1
+               1 1 1 1 1
+                1 1 1 1
+
+"""
+)
+
 FULL_BP_GRID = (
     FULL_BP.split("lattice map:")[0]
     + """grid contents:
@@ -276,7 +304,7 @@ class TestGriddedBlock(unittest.TestCase):
         gridDesign = bDesign._getGridDesign(self.blueprints)
         grid = gridDesign.construct()
         locators = gridDesign.getLocators(grid, ["2"])
-        self.assertEqual(len(locators), 3)
+        self.assertEqual(len(locators), 5)
         self.assertIs(grid[locators[0].getCompleteIndices()], locators[0])
 
     def test_blockLattice(self):
@@ -297,6 +325,37 @@ class TestGriddedBlock(unittest.TestCase):
                 seen = True
         self.assertTrue(seen)
 
+    def test_componentsNotInLattice(self):
+        """
+        Ensure that we catch cases when a component is expected to be in the grid,
+        but is not. In this case, latticeID "2" is not in the lattice.
+        """
+        with self.assertRaises(ValueError) as ee:
+            with io.StringIO(FULL_BP_ERRANT_ID) as stream:
+                self.blueprints = blueprints.Blueprints.load(stream)
+                self.blueprints._prepConstruction(self.cs)
+
+            self.assertIn(
+                "Check that the component's latticeIDs align with the block's grid.",
+                ee.args[0],
+            )
+
+    def test_latticeNotInComponents(self):
+        """
+        Ensure that we catch cases when a latticeID listed in the grid is not present
+        in any of the components on the block. In this case, latticeID "2" is not
+        in the lattice.
+        """
+        with self.assertRaises(ValueError) as ee:
+            with io.StringIO(FULL_BP_NO_COMP) as stream:
+                self.blueprints = blueprints.Blueprints.load(stream)
+                self.blueprints._prepConstruction(self.cs)
+
+            self.assertIn(
+                "All IDs in the grid must appear in at least one component.",
+                ee.args[0],
+            )
+
     def test_nonLatticeComponentHasRightMult(self):
         """Make sure non-grid components in blocks with grids get the right multiplicity."""
         aDesign = self.blueprints.assemDesigns.bySpecifier["IC"]
diff --git a/armi/reactor/blueprints/tests/test_gridBlueprints.py b/armi/reactor/blueprints/tests/test_gridBlueprints.py
index 6591008d..d0f6a375 100644
--- a/armi/reactor/blueprints/tests/test_gridBlueprints.py
+++ b/armi/reactor/blueprints/tests/test_gridBlueprints.py
@@ -354,7 +354,7 @@ class TestGridBlueprintsSection(unittest.TestCase):
             before, after = outText.split("lattice map:")
             self.assertGreater(len(before), 100)
             self.assertGreater(len(after), 20)
-            self.assertIn("1 3 1 2 1 3 1", after, msg="lattice map not showing up")
+            self.assertIn("1 2 1 2 1 2 1", after, msg="lattice map not showing up")
             self.assertNotIn(
                 "- -3", after, msg="grid contents are showing up when they shouldn't"
             )
diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py
index fb8b9b89..45539752 100644
--- a/armi/reactor/tests/test_blocks.py
+++ b/armi/reactor/tests/test_blocks.py
@@ -294,10 +294,9 @@ def applyDummyData(block):
         601494405.293505,
     ]
     xslib = isotxs.readBinary(ISOAA_PATH)
-    # slight hack here because the test block was created
-    # by hand rather than via blueprints and so elemental expansion
-    # of isotopics did not occur. But, the ISOTXS library being used
-    # did go through an isotopic expansion, so we map nuclides here.
+    # Slight hack here because the test block was created by hand rather than via blueprints and so
+    # elemental expansion of isotopics did not occur. But, the ISOTXS library being used did go
+    # through an isotopic expansion, so we map nuclides here.
     xslib._nuclides["NAAA"] = xslib._nuclides["NA23AA"]
     xslib._nuclides["WAA"] = xslib._nuclides["W184AA"]
     xslib._nuclides["MNAA"] = xslib._nuclides["MN55AA"]
@@ -2135,7 +2134,6 @@ class TestNegativeVolume(unittest.TestCase):
 
 class HexBlock_TestCase(unittest.TestCase):
     def setUp(self):
-        _ = settings.Settings()
         self.hexBlock = blocks.HexBlock("TestHexBlock")
         hexDims = {"Tinput": 273.0, "Thot": 273.0, "op": 70.6, "ip": 70.0, "mult": 1.0}
         self.hexComponent = components.Hexagon("duct", "UZr", **hexDims)
@@ -2586,7 +2584,6 @@ class TestHexBlockOrientation(unittest.TestCase):
 
 class ThRZBlock_TestCase(unittest.TestCase):
     def setUp(self):
-        _ = settings.Settings()
         self.ThRZBlock = blocks.ThRZBlock("TestThRZBlock")
         self.ThRZBlock.add(
             components.DifferentialRadialSegment(
diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py
index 67097813..7645a2a3 100644
--- a/armi/reactor/tests/test_components.py
+++ b/armi/reactor/tests/test_components.py
@@ -17,6 +17,7 @@ import copy
 import math
 import random
 import unittest
+from unittest.mock import patch
 
 import numpy as np
 from numpy.testing import assert_allclose, assert_equal
@@ -32,6 +33,7 @@ from armi.reactor.components import (
     Cube,
     DerivedShape,
     DifferentialRadialSegment,
+    FilletedHexagon,
     Helix,
     Hexagon,
     HexHoledCircle,
@@ -124,30 +126,35 @@ class TestComponentFactory(unittest.TestCase):
             :tests: R_ARMI_COMP_DEF
         """
         # populate the class/signature dict, and create a basis attrs
-        attrs = self.getCircleVoidDict()
-        del attrs["shape"]
-        del attrs["od"]
-        del attrs["id"]
-        del attrs["mult"]
+        attrs = {
+            "name": "gap",
+            "Tinput": 25,
+            "Thot": 600,
+            "material": "Void",
+            "isotopics": "",
+        }
 
         for i, (name, klass) in enumerate(ComponentType.TYPES.items()):
             # hack together a dictionary input
             thisAttrs = {k: 1.0 for k in set(klass.INIT_SIGNATURE).difference(attrs)}
+            if "oR" in thisAttrs:
+                thisAttrs["oR"] /= 20.0
+            if "iR" in thisAttrs:
+                thisAttrs["iR"] /= 20.0
             del thisAttrs["components"]
             thisAttrs.update(attrs)
-            thisAttrs["name"] = "banana{}".format(i)
+            thisAttrs["name"] = f"banana{i}"
             if "modArea" in thisAttrs:
                 thisAttrs["modArea"] = None
             component = components.factory(name, [], thisAttrs)
             duped = copy.deepcopy(component)
             for key, val in component.p.items():
-                if key not in ["area", "volume", "serialNum"]:  # they get recomputed
+                if key not in ["area", "volume", "serialNum"]:
+                    # they get recomputed
                     self.assertEqual(
                         val,
                         duped.p[key],
-                        msg="Key: {}, val1: {}, val2: {}".format(
-                            key, val, duped.p[key]
-                        ),
+                        msg=f"Key: {key}, val1: {val}, val2: {duped.p[key]}",
                     )
 
     def test_factoryBadShapeName(self):
@@ -1375,19 +1382,6 @@ class TestHexagon(TestShapedComponent):
     componentCls = Hexagon
     componentDims = {"Tinput": 25.0, "Thot": 430.0, "op": 10.0, "ip": 5.0, "mult": 1}
 
-    def test_getPerimeter(self):
-        """Get perimeter of hexagon.
-
-        .. test:: Hexagon shaped component
-            :id: T_ARMI_COMP_SHAPES4
-            :tests: R_ARMI_COMP_SHAPES
-        """
-        ip = self.component.getDimension("ip")
-        mult = self.component.getDimension("mult")
-        ref = 6 * (ip / math.sqrt(3)) * mult
-        cur = self.component.getPerimeter()
-        self.assertAlmostEqual(cur, ref)
-
     def test_getBoundingCircleOuterDiameter(self):
         ref = 2.0 * 10 / math.sqrt(3)
         cur = self.component.getBoundingCircleOuterDiameter(cold=True)
@@ -1424,6 +1418,89 @@ class TestHexagon(TestShapedComponent):
             self.assertEqual(cur, ref[i])
 
 
+class TestFilletedHexagon(TestShapedComponent):
+    """Test FilletedHexagon shaped component."""
+
+    componentCls = FilletedHexagon
+    componentDims = {
+        "Tinput": 25.0,
+        "Thot": 430.0,
+        "op": 10.0,
+        "ip": 5.0,
+        "mult": 1,
+        "oR": 0.2,
+        "iR": 0.1,
+    }
+
+    def test_getBoundingCircleOuterDiameter(self):
+        ref = 2.0 * 10 / math.sqrt(3)
+        cur = self.component.getBoundingCircleOuterDiameter(cold=True)
+        self.assertAlmostEqual(ref, cur)
+
+    def test_getCircleInnerDiameter(self):
+        ref = 2.0 * 5.0 / math.sqrt(3)
+        cur = self.component.getCircleInnerDiameter(cold=True)
+        self.assertAlmostEqual(ref, cur)
+
+    def test_getComponentArea(self):
+        cur = self.component.getComponentArea()
+        op = self.component.getDimension("op")
+        ip = self.component.getDimension("ip")
+        oR = self.component.getDimension("oR")
+        iR = self.component.getDimension("iR")
+        mult = self.component.getDimension("mult")
+
+        ref = mult * (FilletedHexagon._area(op, oR) - FilletedHexagon._area(ip, iR))
+        self.assertAlmostEqual(cur, ref)
+
+    def test_thermallyExpands(self):
+        """Test that ARMI can thermally expands a Hexagon."""
+        self.assertTrue(self.component.THERMAL_EXPANSION_DIMS)
+
+    def test_dimensionThermallyExpands(self):
+        expandedDims = ["op", "ip", "iR", "oR", "mult"]
+        ref = [True, True, True, True, False]
+        for i, d in enumerate(expandedDims):
+            cur = d in self.component.THERMAL_EXPANSION_DIMS
+            self.assertEqual(cur, ref[i])
+
+    def test_filletedMatchesNormal(self):
+        """Prove that if the radius of curvature is 0.0, FilletedHexagon is just a hexagon."""
+        for ip in np.arange(0.1, 1, 0.1):
+            for op in np.arange(1.1, 5, 0.4):
+                componentDims = {
+                    "Tinput": 25.0,
+                    "Thot": 430.0,
+                    "op": op,
+                    "ip": ip,
+                    "mult": 1.0,
+                }
+                f = FilletedHexagon("xyz", "HT9", **componentDims)
+                h = Hexagon("xyz", "HT9", **componentDims)
+
+                self.assertAlmostEqual(
+                    f.getComponentArea(), h.getComponentArea(), delta=1e-7
+                )
+                self.assertGreaterEqual(h.getArea(), f.getArea() - 1e-7)
+
+    def test_filletedBecomesACircle(self):
+        """Prove that as the radius of curvature becomes D/2, the shape becomes a circle."""
+        for op in np.arange(1.0, 5.0, 0.5):
+            componentDims = {
+                "Tinput": 425.0,
+                "Thot": 425.0,
+                "op": op,
+                "ip": 0.0,
+                "oR": op / 2.0,
+                "iR": 0.0,
+                "mult": 1.0,
+            }
+            f = FilletedHexagon("circleHex", "HT9", **componentDims)
+            self.assertAlmostEqual(
+                f.getComponentArea(), math.pi * (op / 2.0) ** 2, delta=1e-7
+            )
+
+
 class TestHoledHexagon(TestShapedComponent):
     """Test holed hexagon shaped component."""
 
@@ -2007,7 +2084,7 @@ class TestPinQuantities(unittest.TestCase):
         assert_equal(pinMgFluxesGamma, simPinMgFluxesGamma)
 
         # Mock the spatial locator of the component to raise error
-        with unittest.mock.patch.object(fuelComponent, "spatialLocator") as mockLocator:
+        with patch.object(fuelComponent, "spatialLocator") as mockLocator:
             mockLocator.i = 111
             mockLocator.j = 111
             with self.assertRaisesRegex(
diff --git a/armi/tests/c5g7/c5g7-blueprints.yaml b/armi/tests/c5g7/c5g7-blueprints.yaml
index f2428b0d..0fdb2345 100644
--- a/armi/tests/c5g7/c5g7-blueprints.yaml
+++ b/armi/tests/c5g7/c5g7-blueprints.yaml
@@ -172,10 +172,6 @@ blocks:
             widthInner: 21.42
             widthOuter: 21.42
             mult: 1.0
-            latticeIDs: [FC]
-            # latticeIDs is needed to ensure that the center of the bounding
-            # Component shares the same center/origin as the rest of the
-            # components in the block. See #332."
 # end-block-uo2
     mox: &block_mox
         grid name: MOX grid