Repository: terrapower/armi

Issue:
Material theoretical density is not written to database and can be lost in restart runs
#1440 is a large ticket that points to a broader problem that if materials have some special attributes that determine properties, those attributes are not written to database and lost in restarts. That's a large architectural problem.

A smaller, specific, and urgent problem is

> A concrete example of this is the TD_frac material modifier in either UraniumOxide or B4C. Because TD_frac (and its associated instance variable theoreticalDensityFrac) is not written to the database directly, it is left at its default when the reactor is loaded back up from the database. So if you do a restart run with a case that has either of those materials in it, your material densities will be wrong if your TD_frac entries don't exactly equal the default values in the material class. For UraniumOxide that is 1.0 and for B4C that is 0.90.

Discussion:
keckler: I have not done a detailed review of this yet, but a few comments:
1. I have added @john-science as a reviewer, because we really need his eyes here
2. In some ways, this PR is very related to https://github.com/terrapower/armi/pull/1062. If you haven't seen that one, I encourage you to take a look.
3. I also do not particularly like this solution. Based on my first look at it, this whole situation is very ugly and messy, and it seems to me like a symptom of a deeper problem with how we are handling materials and their relationship with components. Perhaps it is worth thinking about this some more before we continue down this path.
4. Is there an issue that we need to link this PR against?

Anyways, thank you for the PR and for the thought that you put into it. Clearly this is not the simplest problem to solve!
drewj-tp: Thanks @keckler. Lots of good points here.

Definitely feel the connection to #1062, in addition to #1140 and #1819: materials are not as stateless as we want them to be. And I think that's the deeper problem you're referencing. And that's a larger effort than this symptom (td_frac) but I'm open to discussing with you, @john-science @opotowsky and @albeanth.

> Is there an issue that we need to link this PR against

Yesish? #1440 touches on TD_frac as a symptom of the statefulness. This PR doesn't close that entire issue but does address a part of it. 

> There are some internal materials that we will probably have to migrate.

This _should_ be transparent to materials. It just reinforces / builds the connection between BP -> material modifications -> TD frac attribute <-> component property <-> database. I'll double check
drewj-tp: Chatted w/ @john-science and a maybe better solution is to have `Material.getTD` fall back to `self.parent.p.theoreticalDensityFrac` because we have reasonable confidence these materials will have a component parameters (yes they have state still but this is an okay(ish) state to have). Similarly, `Material.adjustTD` would be an alternate setter for the parents td frac param.

This could also coincide w/ some deprecation warnings about removing the material td frac related things. But we'd need to make sure we shift the scaling by TD from the material side to the component side. e.g., from
```python
# usage by components
dens = self.material.density()
# materials.py
def density(...):
    return actualDensity * self.getTD()
```
to
```python
# usage
dens = self.getTD() * self.material.density()
# materials.py
def density(...):
    return actualDensity
```
Which wouldn't be the worst change. Just would need to make sure the change got propogated well.
drewj-tp: @keckler @john-science I updated the approach to have `Material` theoretical density fallback to `self.parent.p.theoreticalDensityFrac` which kind of breaks statelessness but at least lets the data be written to / loaded from database correctly.

Should be good to go!
drewj-tp: Had a chat with @keckler this morning and we both agree the recent changes here are better but still have some yuck. His main critique (correct me if I'm wrong) is that we have multiple sources of truth for the density. And `Component.density()` will diverge from `Component.material.density() for reasons beyond theoretical density (depletion updates composition, thermal expansion). And that `Component.density`, being derived from the compositions, is correctly written to / read from the database.

Therefore, if you were to examine a "from blueprints" reactor with a modified theoretical density compared to a "from database" reactor at the same timestep,
1. `Component.density` would agree for the same component, but
2. `Component.material.density` would not agree (due to the TD frac)

A solution we are circling around involves discouraging calls to `Material.density` except for when freshly creating a component. Or if there is a parent attached to the material. If we can find a way to enforce this (docs, warnings, maybe exceptions (later?)) then users doing restart analysis would see something like
```python
>>> b = r.core[0][0]
>>> c = b[0]
>>> dens = c.material.density(...)
[warn] The density of a component is correct only from that component, not its material
```
Where the warning is logged if the density of a material with a parent is called.

We'd probably want to do something to consume this warning when we are initializing the component and/or for the first time. Somewhere around

https://github.com/terrapower/armi/blob/9ea428bbbc196e80729e6153b2dc7e249d744aad/armi/reactor/components/component.py#L1263-L1271

But we also have a non-zero number of tests that look at the agreement between `Component.density(...)` and `Component.material.density(...)`

And, what do we do about methods like

https://github.com/terrapower/armi/blob/9ea428bbbc196e80729e6153b2dc7e249d744aad/armi/materials/material.py#L653-L674

that are convenience methods but still subject to this problem

We'd also need to make sure internal projects (and external users!) are avoiding calls to `Component.material.density` when appropriate. 
drewj-tp: Related to https://github.com/terrapower/armi/issues/1436
drewj-tp: PR builds are passing but push PRs are failing?

- mac failed on push : https://github.com/terrapower/armi/actions/runs/10778501995/job/29889968100?pr=1852
- mac passed on pr - https://github.com/terrapower/armi/actions/runs/10778502638/job/29889970229?pr=1852
- windows failed on push - https://github.com/terrapower/armi/actions/runs/10778501988/job/29889968010?pr=1852
- windows passed on pr - https://github.com/terrapower/armi/actions/runs/10778502629/job/29889969948?pr=1852

both [failures ](https://github.com/terrapower/armi/actions/runs/10778501995/job/29889968100#step:5:566)are due to xs library read/write?
```python
FAILED armi/nuclearDataIO/tests/test_xsLibraries.py::Isotxs_merge_Tests::test_cannotMergeXSLibWithSameNuclideNames - OSError: Failed to read/write <IsotxsIO /Users/runner/work/armi/armi/armi/nuclearDataIO/tests/fixtures/ISOAB> 


Traceback (most recent call last):
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 329, in readWrite
    nuclideIO.rwNuclide()
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 483, in rwNuclide
    self._rw7DRecord(blockNumIndex, subBlock)
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 686, in _rw7DRecord
    dataVals.append(record.rwFloat(0.0))
                    ^^^^^^^^^^^^^^^^^^^
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/cccc.py", line 381, in rwFloat
    (f,) = struct.unpack("f", self._stream.read(self._floatSize))
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
struct.error: unpack requires a buffer of 4 bytes
```
🤷 
Going to re-roll tests 
john-science: > PR builds are passing but push PRs are failing?
> 🤷 Going to re-roll tests

Well, we can't have tests that only pass _some_ of the time. That's no good. Maybe we'll run them 10 more times and see if they pass.
john-science: A Linux unit test just failed:

```bash
FAILED armi/nuclearDataIO/tests/test_xsLibraries.py::Isotxs_merge_Tests::test_canRemoveIsotopes - OSError: Failed to read/write <IsotxsIO /home/runner/work/armi/armi/armi/nuclearDataIO/tests/fixtures/ISOAB> 


Traceback (most recent call last):
  File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 329, in readWrite
    nuclideIO.rwNuclide()
  File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 483, in rwNuclide
    self._rw7DRecord(blockNumIndex, subBlock)
  File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 686, in _rw7DRecord
    dataVals.append(record.rwFloat(0.0))
  File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/cccc.py", line 381, in rwFloat
    (f,) = struct.unpack("f", self._stream.read(self._floatSize))
struct.error: unpack requires a buffer of 4 bytes
===== 1 failed, 2273 passed, 19 skipped, 26 warnings in 264.15s (0:04:24) ======
```

Interesting.
drewj-tp: > A Linux unit test just failed:
> 
> ```shell
> FAILED armi/nuclearDataIO/tests/test_xsLibraries.py::Isotxs_merge_Tests::test_canRemoveIsotopes - OSError: Failed to read/write <IsotxsIO /home/runner/work/armi/armi/armi/nuclearDataIO/tests/fixtures/ISOAB> 
> 
> 
> Traceback (most recent call last):
>   File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 329, in readWrite
>     nuclideIO.rwNuclide()
>   File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 483, in rwNuclide
>     self._rw7DRecord(blockNumIndex, subBlock)
>   File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 686, in _rw7DRecord
>     dataVals.append(record.rwFloat(0.0))
>   File "/home/runner/work/armi/armi/armi/nuclearDataIO/cccc/cccc.py", line 381, in rwFloat
>     (f,) = struct.unpack("f", self._stream.read(self._floatSize))
> struct.error: unpack requires a buffer of 4 bytes
> ===== 1 failed, 2273 passed, 19 skipped, 26 warnings in 264.15s (0:04:24) ======
> ```
> 
> Interesting.

Very interesting. Same errors I saw in https://github.com/terrapower/armi/pull/1852#issuecomment-2338748072

Maybe it's a race condition w/ the parallel tests? https://github.com/terrapower/armi/actions/runs/10797243037/job/29948075116?pr=1852#step:6:409

```python
OSError: Failed to read/write <IsotxsIO /home/runner/work/armi/armi/armi/nuclearDataIO/tests/fixtures/ISOAB>
```
drewj-tp: In the failed instances of this test, the following have all failed
- `FAILED armi/nuclearDataIO/tests/test_xsLibraries.py::Isotxs_merge_Tests::test_canRemoveIsotopes - OSError: Failed to read/write <IsotxsIO /home/runner/work/armi/armi/armi/nuclearDataIO/tests/fixtures/ISOAA>`
- `FAILED armi/nuclearDataIO/tests/test_xsLibraries.py::Isotxs_merge_Tests::test_canRemoveIsotopes - OSError: Failed to read/write <IsotxsIO D:\a\armi\armi\armi\nuclearDataIO\tests\fixtures\ISOAB>`
- `FAILED armi/nuclearDataIO/tests/test_xsLibraries.py::Isotxs_merge_Tests::test_cannotMergeXSLibWithSameNuclideNames - OSError: Failed to read/write <IsotxsIO /Users/runner/work/armi/armi/armi/nuclearDataIO/tests/fixtures/ISOAB>`
- `FAILED armi/nuclearDataIO/tests/test_xsLibraries.py::Isotxs_merge_Tests::test_canRemoveIsotopes - OSError: Failed to read/write <IsotxsIO D:\a\armi\armi\armi\nuclearDataIO\tests\fixtures\ISOAA>`

What can we learn from this?

1. Confirming operating system agnostic - `D:\a\armi\...` and `/home/runner/...` and `/Users/runner/...`
2. Two fixture files `nuclearDataIO/tests/fixtures/ISOAA` and `nuclearDataIO/tests/fixtures/ISOAB`
3. Occurs in `Isotxs_merge_Tests::test_canRemoveIsotopes` and `Isotxs_merge_Tests::test_cannotMergeXSLibWithSameNuclideNames`

It's possible that separate threads are trying to read from the same fixture resource at the same time. But that _should_ be okay. I've launched a bunch of separate threads all reading from the same file on linux and there are no errors with multiple processes reading the same file.

I updated the script to have some processes open the file for writing and some for reading. That also seems to be working, at least in terms of "the operating system does not yell at me." 

So if multiple parallel workers are trying to read from these fixtures at the same time, we _should_ be good.

If, and this is an unfounded **if**, some worker is **writing** to the fixture file while another process is reading it, **and** the fixture file is not fully complete by the time the reader process tries to read, then we could see these errors about not getting all the right bytes for a float that we see.

**But** these fixtures are tracked in the repository and _should not be edited during tests_. The tests in question create separate empty library objects and do merge actions on those empty libraries with fixture libraries.

The error traceback sure looks like we're trying to **write** a value no matter what in 

```
Traceback (most recent call last):
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 329, in readWrite
    nuclideIO.rwNuclide()
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 483, in rwNuclide
    self._rw7DRecord(blockNumIndex, subBlock)
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/isotxs.py", line 686, in _rw7DRecord
    dataVals.append(record.rwFloat(0.0))
                    ^^^^^^^^^^^^^^^^^^^
  File "/Users/runner/work/armi/armi/armi/nuclearDataIO/cccc/cccc.py", line 381, in rwFloat
    (f,) = struct.unpack("f", self._stream.read(self._floatSize))
```

https://github.com/terrapower/armi/blob/0b3c7c1f8e88731c284fc80b7fadd27dcc6d49bb/armi/nuclearDataIO/cccc/isotxs.py#L685-L686

despite the docstring saying it's only trying to read the record

https://github.com/terrapower/armi/blob/0b3c7c1f8e88731c284fc80b7fadd27dcc6d49bb/armi/nuclearDataIO/cccc/isotxs.py#L603-L605

The files are also opened for reading in binary only during the well named `isotxs.readBinary`

## Test class set up

This might be a red herring. But the test setup for the library merging tests are strange?

https://github.com/terrapower/armi/blob/0b3c7c1f8e88731c284fc80b7fadd27dcc6d49bb/armi/nuclearDataIO/tests/test_xsLibraries.py#L299-L328

There's a `setUpClass` that unsets class attributes. So only called once per process. That's good.

But then the `setUp` method for each *instance* on the test tries to set class attributes?
john-science: Okay, we believe the strange test errors were not related to this branch. And we have fixed them in `main`.
drewj-tp: Test cases found the warning message produced a quarter of a million log entries. Draft while I 1) reduce that, and 2) find a more helpful way to track down calls to `Material.denstiy`
drewj-tp: @john-science @keckler https://github.com/terrapower/armi/pull/1852/commits/8d6f62464ad65577da90fdc35df55ee9038aec07 introduced a mechanism to both reduce the number of log entries and provide a way to track down offending calls
```
[warn] Found call to Material.density in /path/to/a.py at line 126. Calls to Material.density when attached to a component have the potential to induce subtle differences as Component.density and Material.density can diverge.
[warn] Found call to Material.density in /path/to/b.py at line 237. Calls to Material.density when attached to a component have the potential to induce subtle differences as Component.density and Material.density can diverge.
[warn] Found call to Material.density in /path/to/b.py at line 238. Calls to Material.density when attached to a component have the potential to induce subtle differences as Component.density and Material.density can diverge.
[warn] Found call to Material.density in /path/to/c.py at line 189. Calls to Material.density when attached to a component have the potential to induce subtle differences as Component.density and Material.density can diverge.
[warn] Found call to Material.density in /path/to/c.py at line 207. Calls to Material.density when attached to a component have the potential to induce subtle differences as Component.density and Material.density can diverge.
[warn] Found call to Material.density in /path/to/c.py at line 215. Calls to Material.density when attached to a component have the potential to induce subtle differences as Component.density and Material.density can diverge.
[warn] Found call to Material.density in /path/to/c.py at line 74. Calls to Material.density when attached to a component have the potential to induce subtle differences as Component.density and Material.density can diverge.

[info] ----- Final Warning Count --------
[info]     COUNT                LABEL

[info]      222       Found call to Material.density in /path/to/a.py at line 126
[info]      1830      Found call to Material.density in /path/to/b.py at line 237
[info]      1830      Found call to Material.density in /path/to/b.py at line 238
[info]      3498      Found call to Material.density in /path/to/b.py at line 134
[info]      3498      Found call to Material.density in /path/to/b.py at line 135
[info]     13992      Found call to Material.density in /path/to/c.py at line 74
[info]     474765     Found call to Material.density in /path/to/c.py at line 189
[info]     474765     Found call to Material.density in /path/to/c.py at line 207
[info]     474765     Found call to Material.density in /path/to/c.py at line 215

```

Patch:
diff --git a/armi/bookkeeping/db/database3.py b/armi/bookkeeping/db/database3.py
index 320fb6cb..4f85a2b9 100644
--- a/armi/bookkeeping/db/database3.py
+++ b/armi/bookkeeping/db/database3.py
@@ -867,6 +867,8 @@ class Database3:
             comp.processLoading(cs, dbLoad=True)
         elif isinstance(comp, Assembly):
             comp.calculateZCoords()
+        elif isinstance(comp, Component):
+            comp.finalizeLoadingFromDB()
 
         return comp
 
diff --git a/armi/materials/material.py b/armi/materials/material.py
index b38fd398..362b66ec 100644
--- a/armi/materials/material.py
+++ b/armi/materials/material.py
@@ -17,6 +17,8 @@ Base Material classes.
 
 Most temperatures may be specified in either K or C and the functions will convert for you.
 """
+import functools
+import traceback
 import warnings
 
 from scipy.optimize import fsolve
@@ -32,6 +34,33 @@ from armi.utils.units import getTk, getTc
 FAIL_ON_RANGE = False
 
 
+def parentAwareDensityRedirect(f):
+    """Wrap Material.density to warn people about potential problems.
+
+    If a Material is linked to a Component, ``Material.density`` may produce
+    different results from ``Component.density``. The component's density
+    is considered the source of truth because it incorporates changes in volume,
+    composition, and temperature in concert with the state of the reactor.
+    """
+
+    @functools.wraps(f)
+    def inner(self: "Material", *args, **kwargs) -> float:
+        if self.parent is not None:
+            stack = traceback.extract_stack()
+            # last entry is here, second to last is what called this
+            caller = stack[-2]
+            label = f"Found call to Material.density in {caller.filename} at line {caller.lineno}"
+            runLog.warning(
+                f"{label}. Calls to Material.density when attached to a component have the potential to induce "
+                "subtle differences as Component.density and Material.density can diverge.",
+                single=True,
+                label=label,
+            )
+        return f(self, *args, **kwargs)
+
+    return inner
+
+
 class Material:
     r"""
     A material is made up of elements or isotopes. It has bulk properties like density.
@@ -75,6 +104,11 @@ class Material:
     for that material.
     """
 
+    def __init_subclass__(cls) -> None:
+        # Apply the density decorator to every subclass
+        if not hasattr(cls.density, "__wrapped__"):
+            cls.density = parentAwareDensityRedirect(cls.density)
+
     DATA_SOURCE = "ARMI"
     """Indication of where the material is loaded from (may be plugin name)"""
 
diff --git a/armi/reactor/blueprints/componentBlueprint.py b/armi/reactor/blueprints/componentBlueprint.py
index f6b85228..41dcc0dc 100644
--- a/armi/reactor/blueprints/componentBlueprint.py
+++ b/armi/reactor/blueprints/componentBlueprint.py
@@ -225,6 +225,9 @@ class ComponentBlueprint(yamlize.Object):
             constructedObject = components.factory(shape, [], kwargs)
             _setComponentFlags(constructedObject, self.flags, blueprint)
             insertDepletableNuclideKeys(constructedObject, blueprint)
+            constructedObject.p.theoreticalDensityFrac = (
+                constructedObject.material.getTD()
+            )
 
         # set the custom density for non-custom material components after construction
         self.setCustomDensity(constructedObject, blueprint, matMods)
diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py
index 2a64da27..e00af786 100644
--- a/armi/reactor/components/component.py
+++ b/armi/reactor/components/component.py
@@ -226,6 +226,8 @@ class Component(composites.Composite, metaclass=ComponentType):
 
     pDefs = componentParameters.getComponentParameterDefinitions()
 
+    material: materials.Material
+
     def __init__(
         self,
         name,
@@ -1260,13 +1262,17 @@ class Component(composites.Composite, metaclass=ComponentType):
 
         return pinFluxes[self.p.pinNum - 1] * self.getVolume()
 
-    def density(self):
+    def density(self) -> float:
         """Returns the mass density of the object in g/cc."""
         density = composites.Composite.density(self)
 
-        if not density:
-            # possible that there are no nuclides in this component yet. In that case, defer to Material.
-            density = self.material.density(Tc=self.temperatureInC)
+        if not density and not isinstance(self.material, void.Void):
+            # possible that there are no nuclides in this component yet. In that case,
+            # defer to Material. Material.density is wrapped to warn if it's attached
+            # to a parent. Avoid that by calling the inner function directly
+            density = self.material.density.__wrapped__(
+                self.material, Tc=self.temperatureInC
+            )
 
         return density
 
@@ -1309,6 +1315,21 @@ class Component(composites.Composite, metaclass=ComponentType):
         """Return the mass in grams if this is a fueled component."""
         return self.getMass() if self.hasFlags(flags.Flags.FUEL) else 0.0
 
+    def finalizeLoadingFromDB(self):
+        """Apply any final actions after creating the component from database.
+
+        This should **only** be called internally by the database loader. Otherwise
+        some properties could be doubly applied.
+
+        This exists because the theoretical density is initially defined as a material
+        modification, and then stored as a Material attribute. When reading from blueprints,
+        the blueprint loader sets the theoretical density parameter from the Material
+        attribute. Component parameters are also set when reading from the database.
+        But, we need to set the Material attribute so routines that fetch a material's
+        density property account for the theoretical density.
+        """
+        self.material.adjustTD(self.p.theoreticalDensityFrac)
+
 
 class ShapedComponent(Component):
     """A component with well-defined dimensions."""
diff --git a/armi/reactor/components/componentParameters.py b/armi/reactor/components/componentParameters.py
index 8bd153a6..14292b12 100644
--- a/armi/reactor/components/componentParameters.py
+++ b/armi/reactor/components/componentParameters.py
@@ -120,6 +120,24 @@ def getComponentParameterDefinitions():
             description="Pin number of this component in some mesh. Starts at 1.",
             default=None,
         )
+
+        def _assignTDFrac(self, val):
+            if val > 1 or val < 0:
+                raise ValueError(
+                    f"Theoretical density fraction must be in range [0,1], got {val}"
+                )
+            self._p_theoreticalDensityFrac = val
+
+        pb.defParam(
+            "theoreticalDensityFrac",
+            description=(
+                "Fractional value between zero and one, inclusive, for the theoretical density "
+                "of the material stored on this component."
+            ),
+            units=units.UNITLESS,
+            default=1,
+            setter=_assignTDFrac,
+        )
     return pDefs
 
 
diff --git a/armi/reactor/parameters/parameterCollections.py b/armi/reactor/parameters/parameterCollections.py
index 02d6dbe9..82d7d1a3 100644
--- a/armi/reactor/parameters/parameterCollections.py
+++ b/armi/reactor/parameters/parameterCollections.py
@@ -119,8 +119,16 @@ class ParameterCollection(metaclass=_ParameterCollectionType):
     )
     _allFields: List[str] = []
 
-    # The ArmiObject class that this ParameterCollection belongs to
     _ArmiObject = None
+    """The ArmiObject class that this ParameterCollection belongs to.
+
+    Crucially **not** the instance that owns this collection. For any
+    ``ArmiObject``, the following are true::
+
+        >>> self.p._ArmiObject is not self
+        >>> isinstance(self, self.p._ArmiObject)
+
+    """
 
     # A set of all instance attributes that are settable on an instance. This prevents inadvertent
     # setting of values that aren't proper parameters. Named _slots, as it is used to emulate some
diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst
index 16203254..6a8955bc 100644
--- a/doc/release/0.4.rst
+++ b/doc/release/0.4.rst
@@ -34,6 +34,8 @@ Bug Fixes
 #. Changed data type of ``thKernel`` setting from ``bool`` to ``str`` in ``ThermalHydraulicsPlugin``. (`PR#1855 <https://github.com/terrapower/armi/pull/1855>`_)
 #. Rotate hexagonal assembly patches correctly on facemap plots. (`PR#1883 <https://github.com/terrapower/armi/pull/1883>`_)
 #. Update height of fluid components after axial expansion (`PR#1828 <https://github.com/terrapower/armi/pull/1828>`_)
+#. Material theoretical density is serialized to and read from database.
+   (`PR#1852 <https://github.com/terrapower/armi/pull/1852>`_)
 #. TBD
 
 Quality Work


Test patch:
diff --git a/armi/bookkeeping/db/tests/test_comparedb3.py b/armi/bookkeeping/db/tests/test_comparedb3.py
index 408a69a3..a19b9495 100644
--- a/armi/bookkeeping/db/tests/test_comparedb3.py
+++ b/armi/bookkeeping/db/tests/test_comparedb3.py
@@ -181,7 +181,7 @@ class TestCompareDB3(unittest.TestCase):
             dbs[1]._fullPath,
             timestepCompare=[(0, 0), (0, 1)],
         )
-        self.assertEqual(len(diffs.diffs), 465)
+        self.assertEqual(len(diffs.diffs), 477)
         # Cycle length is only diff (x3)
         self.assertEqual(diffs.nDiffs(), 3)
 
diff --git a/armi/materials/tests/test_materials.py b/armi/materials/tests/test_materials.py
index 44bdd17a..1b27df20 100644
--- a/armi/materials/tests/test_materials.py
+++ b/armi/materials/tests/test_materials.py
@@ -46,7 +46,7 @@ class _Material_Test:
         )
 
     def test_density(self):
-        """Test that all materials produce a zero density from density."""
+        """Test that all materials produce a non-zero density from density."""
         self.assertNotEqual(self.mat.density(500), 0)
 
     def test_TD(self):
@@ -95,6 +95,10 @@ class _Material_Test:
         densKgM3 = self.mat.pseudoDensityKgM3(500)
         self.assertEqual(dens * 1000.0, densKgM3)
 
+    def test_wrappedDensity(self):
+        """Test that the density decorator is applied."""
+        self.assertTrue(hasattr(self.mat.density, "__wrapped__"))
+
 
 class MaterialConstructionTests(unittest.TestCase):
     def test_material_initialization(self):
diff --git a/armi/reactor/blueprints/tests/test_materialModifications.py b/armi/reactor/blueprints/tests/test_materialModifications.py
index bd7b458c..00d45d92 100644
--- a/armi/reactor/blueprints/tests/test_materialModifications.py
+++ b/armi/reactor/blueprints/tests/test_materialModifications.py
@@ -54,13 +54,41 @@ assemblies:
         xs types: [A]
 """
 
-    boronInput = uZrInput.replace("UZr", "B")
+    b4cInput = r"""
+nuclide flags:
+    B: {burn: false, xs: true}
+    C: {burn: false, xs: true}
+blocks:
+    poison: &block_poison
+        poison:
+            shape: Hexagon
+            material: B4C
+            Tinput: 600.0
+            Thot: 600.0
+            ip: 0.0
+            mult: 1
+            op: 10.0
+assemblies:
+    assem a: &assembly_a
+        specifier: IC
+        blocks: [*block_poison]
+        height: [1.0]
+        axial mesh points: [1]
+        xs types: [A]
+"""
 
     def loadUZrAssembly(self, materialModifications):
-        yamlString = self.uZrInput + "\n" + materialModifications
+        return self._loadAssembly(self.uZrInput, materialModifications, "fuel a")
+
+    @staticmethod
+    def _loadAssembly(bpBase: str, materialModifications: str, assem: str):
+        yamlString = bpBase + "\n" + materialModifications
         design = blueprints.Blueprints.load(yamlString)
         design._prepConstruction(settings.Settings())
-        return design.assemblies["fuel a"]
+        return design.assemblies[assem]
+
+    def loadB4CAssembly(self, materialModifications: str):
+        return self._loadAssembly(self.b4cInput, materialModifications, "assem a")
 
     def test_noMaterialModifications(self):
         a = self.loadUZrAssembly("")
@@ -306,3 +334,15 @@ custom isotopics:
         U: 1
 """
             )
+
+    def test_theoreticalDensity(self):
+        """Test the theoretical density can be loaded from material modifications."""
+        mods = """
+        material modifications:
+            TD_frac: [0.5]
+        """
+        a = self.loadB4CAssembly(mods)
+        comp = a[0][0]
+        mat = comp.material
+        self.assertEqual(mat.getTD(), 0.5)
+        self.assertEqual(comp.p.theoreticalDensityFrac, 0.5)
diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py
index fc6569bb..46b93609 100644
--- a/armi/reactor/tests/test_components.py
+++ b/armi/reactor/tests/test_components.py
@@ -708,6 +708,22 @@ class TestCircle(TestShapedComponent):
         self.component.p.flags = flags.Flags.MODERATOR
         self.assertEqual(self.component.getFuelMass(), 0.0)
 
+    def test_theoreticalDensitySetter(self):
+        """Ensure only fraction theoretical densities are supported."""
+        self.assertEqual(self.component.p.theoreticalDensityFrac, 1)
+        with self.assertRaises(ValueError):
+            self.component.p.theoreticalDensityFrac = 2.0
+        self.assertEqual(self.component.p.theoreticalDensityFrac, 1)
+        self.component.p.theoreticalDensityFrac = 0.2
+        self.assertEqual(self.component.p.theoreticalDensityFrac, 0.2)
+        with self.assertRaises(ValueError):
+            self.component.p.theoreticalDensityFrac = -1.0
+        self.assertEqual(self.component.p.theoreticalDensityFrac, 0.2)
+        self.component.p.theoreticalDensityFrac = 1.0
+        self.assertEqual(self.component.p.theoreticalDensityFrac, 1)
+        self.component.p.theoreticalDensityFrac = 0.0
+        self.assertEqual(self.component.p.theoreticalDensityFrac, 0)
+
 
 class TestComponentExpansion(unittest.TestCase):
     tCold = 20
@@ -1788,3 +1804,11 @@ class TestMaterialAdjustments(unittest.TestCase):
     def test_getEnrichment(self):
         self.fuel.adjustMassEnrichment(0.3)
         self.assertAlmostEqual(self.fuel.getEnrichment(), 0.3)
+
+    def test_finalizeLoadDBAdjustsTD(self):
+        """Ensure component is fully loaded through finalize methods."""
+        tdFrac = 0.54321
+        comp = self.fuel
+        comp.p.theoreticalDensityFrac = tdFrac
+        comp.finalizeLoadingFromDB()
+        self.assertEqual(comp.material.getTD(), tdFrac)