Repository: terrapower/armi (python)

Issue:
Support injecting growToFullCore after DB load
There should be a way to grow to full core after database load for snapshots (and maybe restart runs)

Analysis often need to grow to full core for more detailed analysis. ARMI already has `growToFullCoreAfterLoad` setting but it doesn't do anything in the framework. this should do something, probably within database interface.

Putting this hook at the end of `dbi.loadState` would get both snapshots and restarts since `snashots.py` calls it directly, and restart calls `prepRestartRun` which calls in indirectly.

Pull request description:
Supporting growing DBto full core on db load
## What is the change? Why is it being made?

The setting `growToFullCoreAfterLoad` is used to expand the reactor to full core when loading from a database. This supports a workflow where someone needs to perform snapshot analysis on a database that studied a symmetric core, but their specific analyses require operations on the full core. For example, determining the worth a single control rod by moving it independently of all other control rods in the reactor.

Tests are added showing the behavior on `Database.load` but also the `OperatorSnapshots` gets a full core reactor if this setting is active at run time.

Closes #2089 


## SCR Information

<!-- MANDATORY: uncomment one-and-only-one of these -->
Change Type: features
<!-- Change Type: fixes -->
<!-- Change Type: trivial -->
<!-- Change Type: docs -->

<!-- MANDATORY: Describe the change in one sentence -->
One-Sentence Description: The setting `growToFullCoreAfterLoad` is used to expand the reactor to full core when loading from a database.

<!-- MANDATORY: Describe any impact on the requirements, all on one line -->
One-line Impact on Requirements: Functionality added to I_ARMI_DB_TIME1 the implementation of R_ARMI_DB_TIME


---

## Checklist

<!--
    The pull request author should check the box if the condition is met OR if it does not apply.
-->

- [x] This PR has only [one purpose or idea](https://terrapower.github.io/armi/developer/tooling.html#one-idea-one-pr).
- [x] [Tests](https://terrapower.github.io/armi/developer/tooling.html#test-it) have been added/updated to verify any new/changed code.
- [x] The [documentation](https://terrapower.github.io/armi/developer/tooling.html#document-it) is still up-to-date in the `doc` folder.
- [x] The code style follows [good practices](https://terrapower.github.io/armi/developer/standards_and_practices.html).
- [x] The dependencies are still up-to-date in `pyproject.toml`.


Patch:
diff --git a/armi/bookkeeping/db/database.py b/armi/bookkeeping/db/database.py
index 0d372c37..c7e0ce61 100644
--- a/armi/bookkeeping/db/database.py
+++ b/armi/bookkeeping/db/database.py
@@ -50,6 +50,7 @@ from typing import (
     Sequence,
     Tuple,
     Type,
+    Union,
 )
 
 import h5py
@@ -74,7 +75,10 @@ from armi.reactor.composites import ArmiObject
 from armi.reactor.parameters import parameterCollections
 from armi.reactor.reactorParameters import makeParametersReadOnly
 from armi.reactor.reactors import Core, Reactor
-from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
+from armi.settings.fwSettings.globalSettings import (
+    CONF_GROW_TO_FULL_CORE_AFTER_LOAD,
+    CONF_SORT_REACTOR,
+)
 from armi.utils import getNodesPerCycle, safeCopy, safeMove
 from armi.utils.textProcessors import resolveMarkupInclusions
 
@@ -782,6 +786,9 @@ class Database:
                 "temporary and will be removed by 2024."
             )
 
+        if cs[CONF_GROW_TO_FULL_CORE_AFTER_LOAD] and not root.core.isFullCore:
+            root.core.growToFullCore(cs)
+
         return root
 
     def loadReadOnly(self, cycle, node, statePointName=None):
@@ -892,7 +899,7 @@ class Database:
         return comp
 
     @staticmethod
-    def _getArrayShape(arr: [np.ndarray, List, Tuple]):
+    def _getArrayShape(arr: Union[np.ndarray, List, Tuple]):
         """Get the shape of a np.ndarray, list, or tuple."""
         if isinstance(arr, np.ndarray):
             return arr.shape
diff --git a/armi/bookkeeping/db/databaseInterface.py b/armi/bookkeeping/db/databaseInterface.py
index c0f9f0d7..f6c2b212 100644
--- a/armi/bookkeeping/db/databaseInterface.py
+++ b/armi/bookkeeping/db/databaseInterface.py
@@ -309,15 +309,15 @@ class DatabaseInterface(interfaces.Interface):
 
         Notes
         -----
-        Will load preferentially from the `fileName` if passed. Otherwise will load from
-        existing database in memory or `cs["reloadDBName"]` in that order.
+        Will load preferentially from the ``fileName`` if passed. Otherwise will load from
+        existing database in memory or ``cs["reloadDBName"`]` in that order.
 
         Raises
         ------
         RuntimeError
             If fileName is specified and that  file does not have the time step.
             If fileName is not specified and neither the database in memory, nor the
-            `cs["reloadDBName"]` have the time step specified.
+            ``cs["reloadDBName"]`` have the time step specified.
         """
         for potentialDatabase in self._getLoadDB(fileName):
             with potentialDatabase as loadDB:
diff --git a/armi/cases/case.py b/armi/cases/case.py
index f26de268..e41f2a94 100644
--- a/armi/cases/case.py
+++ b/armi/cases/case.py
@@ -473,7 +473,7 @@ class Case:
         with DirectoryChanger(self.cs.inputDirectory, dumpOnException=False):
             self._initBurnChain()
             o = operators.factory(self.cs)
-            if not r:
+            if r is None:
                 r = reactors.factory(self.cs, self.bp)
             o.initializeInterfaces(r)
             # Set this here to make sure the full duration of initialization is properly captured.


Test patch:
diff --git a/armi/bookkeeping/db/tests/test_database3.py b/armi/bookkeeping/db/tests/test_database3.py
index d5777216..9d974d96 100644
--- a/armi/bookkeeping/db/tests/test_database3.py
+++ b/armi/bookkeeping/db/tests/test_database3.py
@@ -18,6 +18,7 @@ import shutil
 import subprocess
 import unittest
 from glob import glob
+from unittest.mock import Mock, patch
 
 import h5py
 import numpy as np
@@ -30,7 +31,10 @@ from armi.reactor import parameters
 from armi.reactor.excoreStructure import ExcoreCollection, ExcoreStructure
 from armi.reactor.reactors import Core, Reactor
 from armi.reactor.spentFuelPool import SpentFuelPool
-from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR
+from armi.settings.fwSettings.globalSettings import (
+    CONF_GROW_TO_FULL_CORE_AFTER_LOAD,
+    CONF_SORT_REACTOR,
+)
 from armi.testing import loadTestReactor, reduceTestReactorRings
 from armi.tests import TEST_ROOT, mockRunLogs
 from armi.utils import getPreviousTimeNode, safeCopy
@@ -188,6 +192,29 @@ class TestDatabase(unittest.TestCase):
         self.assertIn((2, 0), hist["chargeTime"].keys())
         self.assertEqual(hist["chargeTime"][(2, 0)], 2)
 
+    def test_fullCoreOnDbLoad(self):
+        """Test we can expand a reactor to full core when loading from DB via settings."""
+        self.assertFalse(self.r.core.isFullCore)
+        self.db.writeToDB(self.r)
+        cs = self.db.loadCS()
+        cs = cs.modified(newSettings={CONF_GROW_TO_FULL_CORE_AFTER_LOAD: True})
+        r: Reactor = self.db.load(0, 0, cs=cs)
+        self.assertTrue(r.core.isFullCore)
+
+    def test_dontExpandIfFullCoreInDB(self):
+        """Test that a full core reactor in the database is not expanded further."""
+        self.assertFalse(self.r.core.isFullCore)
+        self.db.writeToDB(self.r)
+        cs = self.db.loadCS()
+        cs = cs.modified(newSettings={CONF_GROW_TO_FULL_CORE_AFTER_LOAD: True})
+        mockGrow = Mock()
+        with (
+            patch("armi.reactor.cores.Core.isFullCore", Mock(return_value=True)),
+            patch("armi.reactor.cores.Core.growToFullCore", mockGrow),
+        ):
+            self.db.load(0, 0, cs=cs)
+        mockGrow.assert_not_called()
+
 
 class TestDatabaseSmaller(unittest.TestCase):
     """Tests for the Database class, that can use a smaller test reactor."""
diff --git a/armi/operators/tests/test_operatorSnapshots.py b/armi/operators/tests/test_operatorSnapshots.py
index cb07aaab..5d4dc2e1 100644
--- a/armi/operators/tests/test_operatorSnapshots.py
+++ b/armi/operators/tests/test_operatorSnapshots.py
@@ -14,13 +14,17 @@
 
 """Tests for operator snapshots."""
 import unittest
+from pathlib import Path
+from unittest.mock import Mock
 
 from armi import settings
 from armi.bookkeeping.db.databaseInterface import DatabaseInterface
 from armi.operators import getOperatorClassFromSettings
 from armi.operators.runTypes import RunTypes
 from armi.operators.snapshots import OperatorSnapshots
-from armi.reactor.tests import test_reactors
+from armi.settings.fwSettings.globalSettings import CONF_GROW_TO_FULL_CORE_AFTER_LOAD
+from armi.testing import loadTestReactor, reduceTestReactorRings
+from armi.tests import TEST_ROOT
 
 
 class TestOperatorSnapshots(unittest.TestCase):
@@ -32,7 +36,7 @@ class TestOperatorSnapshots(unittest.TestCase):
         newSettings["branchVerbosity"] = "important"
         newSettings["nCycles"] = 1
         newSettings["dumpSnapshot"] = ["000000", "008000", "016005"]
-        o1, self.r = test_reactors.loadTestReactor(
+        o1, self.r = loadTestReactor(
             customSettings=newSettings,
             inputFileName="smallestTestReactor/armiRunSmallest.yaml",
         )
@@ -57,8 +61,8 @@ class TestOperatorSnapshots(unittest.TestCase):
     def test_mainOperate(self):
         # Mock some tooling that we aren't testing
         self.o.interactBOL = lambda: None
-        self.o.getInterface = (
-            lambda s: self.dbi if s == "database" else super().getInterface(s)
+        self.o.getInterface = lambda s: (
+            self.dbi if s == "database" else super().getInterface(s)
         )
 
         self.assertEqual(self.r.core.p.power, 0.0)
@@ -100,3 +104,50 @@ class TestOperatorSnapshotsSettings(unittest.TestCase):
         cs = cs.modified(newSettings={"runType": RunTypes.SNAPSHOTS})
         clazz = getOperatorClassFromSettings(cs)
         self.assertEqual(clazz, OperatorSnapshots)
+
+
+class TestOperatorSnapshotFullCoreExpansion(unittest.TestCase):
+    """Test that a snapshot operator can do full core analysis with a 1/3 core DB."""
+
+    DB_PATH = Path("test_operator_snapshot_full_core_expansion.h5")
+
+    @classmethod
+    def setUpClass(cls):
+        o, cls.symmetricReactor = loadTestReactor(TEST_ROOT)
+        reduceTestReactorRings(cls.symmetricReactor, o.cs, maxNumRings=2)
+        dbi: DatabaseInterface = next(
+            filter(lambda i: isinstance(i, DatabaseInterface), o.interfaces)
+        )
+        dbi.initDB(cls.DB_PATH)
+        dbi.writeDBEveryNode()
+        dbi.closeDB()
+
+        cls.snapshotSettings: settings.Settings = o.cs.modified(
+            newSettings={
+                "runType": RunTypes.SNAPSHOTS,
+                "reloadDBName": str(cls.DB_PATH),
+            }
+        )
+
+    def test_fullCoreFromThirdCore(self):
+        self.assertFalse(self.symmetricReactor.core.isFullCore)
+        cs = self.snapshotSettings.modified(
+            newSettings={
+                CONF_GROW_TO_FULL_CORE_AFTER_LOAD: True,
+                "dumpSnapshot": ["0000"],
+            }
+        )
+        o = getOperatorClassFromSettings(cs)(cs)
+        self.assertIsInstance(o, OperatorSnapshots)
+        o.r = self.symmetricReactor
+        # Just want Database interface not history tracker not reporting not etc.
+        o.addInterface(DatabaseInterface(o.r, o.cs))
+        # Mock interactAllBOC so we don't do iteract every nodes
+        # We just want to trigger the re-attachment of the loaded reactor
+        o.interactAllBOC = Mock(return_value=True)
+        o.operate()
+        self.assertTrue(o.r.core.isFullCore)
+
+    @classmethod
+    def tearDownClass(cls):
+        cls.DB_PATH.unlink()