Repository: terrapower/armi

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.

Hints from issue comments:
@john-science: We can not make "grow to full core" the default. People who use 1/3 cores do it to speed up their code and analysis. But we can make it optional, which is fine.

Right now you can do:
 
```python
r = db.load(5, 2)
r.core.growToFullCore()
```

This ticket will allow people to do this:

```python 
r = db.load(5, 2, growToFullCore=True)
```

That is fine. But I am not sure I see what problem we are solving.@drewj-tp: I intend to rely on the `growToFullCoreAfterLoad` case setting

https://github.com/terrapower/armi/blob/2fc524aecbf32e7270e6348fd4665447bd47d645/armi/settings/fwSettings/globalSettings.py#L552-L559

to expand to full core on load@john-science: @drewj-tp So the notion is that setting is saved inside the Settings object in the Database?  So your feature will only work if the Setting is saved into the run the DB was created with?

Just curious.@drewj-tp: No, that certainty wouldn't help us.

Rough flow

snapshot operator is created with settings read from e.g., `armi run`

snapshot operator `_mainOperator` sequence hits https://github.com/terrapower/armi/blob/2fc524aecbf32e7270e6348fd4665447bd47d645/armi/operators/snapshots.py#L71

with the run time settings

`DatabaseInterface.loadState` calls `db.load` with the runtime settings

https://github.com/terrapower/armi/blob/2fc524aecbf32e7270e6348fd4665447bd47d645/armi/bookkeeping/db/databaseInterface.py#L337-L344

so if the runtime settings contain `growToFullCoreAfterLoad: True`, modifying `db.load` to grow to full core on load is the only implementation change we need to make.

I will add testing on that and the ability for a snapshot operator to take a 1/3 core and grow it to full core before launching into other work

Solution 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()