diff --git a/armi/bookkeeping/db/database.py b/armi/bookkeeping/db/database.py index 06ab9c043..4e484c0e9 100644 --- a/armi/bookkeeping/db/database.py +++ b/armi/bookkeeping/db/database.py @@ -20,7 +20,7 @@ hierarchical Composite Reactor Model. Furthermore, this database format is intended to be more dynamic, permitting as-yet undeveloped levels and classes in the Composite Reactor Model to be supported as they are added. More high-level discussion is -contained in :doc:`/user/outputs/database`. +contained in :ref:`database-file`. The :py:class:`Database` class contains most of the functionality for interacting with the underlying data. This includes things like dumping a Reactor state to the diff --git a/armi/bookkeeping/memoryProfiler.py b/armi/bookkeeping/memoryProfiler.py index c249953cf..12f9aebef 100644 --- a/armi/bookkeeping/memoryProfiler.py +++ b/armi/bookkeeping/memoryProfiler.py @@ -36,6 +36,8 @@ https://pythonhosted.org/psutil/ https://docs.python.org/3/library/gc.html#gc.garbage """ +from math import floor +from os import cpu_count from typing import Optional import gc import sys @@ -46,6 +48,7 @@ from armi import runLog from armi.reactor.composites import ArmiObject from armi.utils import tabulate +from armi.utils.customExceptions import NonexistentSetting try: # psutil is an optional requirement, since it doesnt support MacOS very well @@ -68,6 +71,28 @@ def describeInterfaces(cs): return (MemoryProfiler, {}) +def getTotalJobMemory(nTasksPerNode): + """Function to calculate the total memory of a job. This is a constant during a simulation.""" + cpuPerNode = cpu_count() + ramPerCpuGB = psutil.virtual_memory().total / (1024**3) / cpuPerNode + if nTasksPerNode == 0: + nTasksPerNode = cpuPerNode + cpusPerTask = floor(cpuPerNode / nTasksPerNode) + jobMem = nTasksPerNode * cpusPerTask * ramPerCpuGB + return jobMem + + +def getCurrentMemoryUsage(): + """This scavenges the memory profiler in ARMI to get the current memory usage.""" + memUsageAction = PrintSystemMemoryUsageAction() + memUsageAction.broadcast() + smpu = SystemAndProcessMemoryUsage() + memUsages = memUsageAction.gather(smpu) + # Grab virtual memory instead of physical. There is a large discrepancy, we will be conservative + memoryUsageInMB = sum([mu.processVirtualMemoryInMB for mu in memUsages]) + return memoryUsageInMB + + class MemoryProfiler(interfaces.Interface): name = "memoryProfiler" @@ -78,6 +103,7 @@ def __init__(self, r, cs): def interactBOL(self): interfaces.Interface.interactBOL(self) + self.printCurrentMemoryState() mpiAction = PrintSystemMemoryUsageAction() mpiAction.broadcast().invoke(self.o, self.r, self.cs) mpiAction.printUsage("BOL SYS_MEM") @@ -88,6 +114,8 @@ def interactBOL(self): mpiAction.broadcast().invoke(self.o, self.r, self.cs) def interactEveryNode(self, cycle, node): + self.printCurrentMemoryState() + mp = PrintSystemMemoryUsageAction() mp.broadcast() mp.invoke(self.o, self.r, self.cs) @@ -101,11 +129,30 @@ def interactEveryNode(self, cycle, node): mpiAction.broadcast().invoke(self.o, self.r, self.cs) def interactEOL(self): - r"""End of life hook. Good place to wrap up or print out summary outputs.""" + """End of life hook. Good place to wrap up or print out summary outputs.""" if self.cs["debugMem"]: mpiAction = ProfileMemoryUsageAction("EOL") mpiAction.broadcast().invoke(self.o, self.r, self.cs) + def printCurrentMemoryState(self): + """Print the current memory footprint and available memory.""" + try: + nTasksPerNode = self.cs["nTasksPerNode"] + except NonexistentSetting: + runLog.extra( + "To view memory consumed, remaining available, and total allocated for a case, " + "add the setting 'nTasksPerNode' to your application." + ) + return + totalMemoryInGB = getTotalJobMemory(nTasksPerNode) + currentMemoryUsageInGB = getCurrentMemoryUsage() / 1024 + availableMemoryInGB = totalMemoryInGB - currentMemoryUsageInGB + runLog.info( + f"Currently using {currentMemoryUsageInGB} GB of memory. " + f"There is {availableMemoryInGB} GB of memory left. " + f"There is a total allocation of {totalMemoryInGB} GB." + ) + def displayMemoryUsage(self, timeDescription): r""" Print out some information to stdout about the memory usage of ARMI. diff --git a/armi/bookkeeping/tests/test_memoryProfiler.py b/armi/bookkeeping/tests/test_memoryProfiler.py index b750ae1cd..94854cf85 100644 --- a/armi/bookkeeping/tests/test_memoryProfiler.py +++ b/armi/bookkeeping/tests/test_memoryProfiler.py @@ -13,11 +13,16 @@ # limitations under the License. """Tests for memoryProfiler.""" +from unittest.mock import MagicMock, patch import logging import unittest from armi import runLog from armi.bookkeeping import memoryProfiler +from armi.bookkeeping.memoryProfiler import ( + getCurrentMemoryUsage, + getTotalJobMemory, +) from armi.reactor.tests import test_reactors from armi.tests import mockRunLogs, TEST_ROOT @@ -29,7 +34,9 @@ def setUp(self): {"debugMem": True}, inputFileName="smallestTestReactor/armiRunSmallest.yaml", ) - self.memPro = self.o.getInterface("memoryProfiler") + self.memPro: memoryProfiler.MemoryProfiler = self.o.getInterface( + "memoryProfiler" + ) def tearDown(self): self.o.removeInterface(self.memPro) @@ -123,6 +130,73 @@ def test_profileMemoryUsageAction(self): pmua = memoryProfiler.ProfileMemoryUsageAction("timeDesc") self.assertEqual(pmua.timeDescription, "timeDesc") + @patch("psutil.virtual_memory") + @patch("armi.bookkeeping.memoryProfiler.cpu_count") + def test_getTotalJobMemory(self, mockCpuCount, mockVMem): + """Use an example node with 50 GB of total physical memory and 10 CPUs.""" + mockCpuCount.return_value = 10 + vMem = MagicMock() + vMem.total = (1024**3) * 50 + mockVMem.return_value = vMem + + expectedArrangement = {0: 50, 1: 50, 2: 50, 3: 45, 4: 40, 5: 50} + for nTasksPerNode, jobMemory in expectedArrangement.items(): + self.assertEqual(getTotalJobMemory(nTasksPerNode), jobMemory) + + @patch("armi.bookkeeping.memoryProfiler.PrintSystemMemoryUsageAction") + @patch("armi.bookkeeping.memoryProfiler.SystemAndProcessMemoryUsage") + def test_getCurrentMemoryUsage( + self, mockSysAndProcMemUse, mockPrintSysMemUseAction + ): + """Mock the memory usage across 3 different processes and that the total usage is as expected (6 MB).""" + self._setMemUseMock(mockPrintSysMemUseAction) + self.assertAlmostEqual(getCurrentMemoryUsage(), 6 * 1024) + + @patch("armi.bookkeeping.memoryProfiler.PrintSystemMemoryUsageAction") + @patch("armi.bookkeeping.memoryProfiler.SystemAndProcessMemoryUsage") + @patch("psutil.virtual_memory") + @patch("armi.bookkeeping.memoryProfiler.cpu_count") + def test_printCurrentMemoryState( + self, mockCpuCount, mockVMem, mock1, mockPrintSysMemUseAction + ): + """Use an example node with 50 GB of total physical memory and 10 CPUs while using 6 GB.""" + mockCpuCount.return_value = 10 + vMem = MagicMock() + vMem.total = (1024**3) * 50 + mockVMem.return_value = vMem + self._setMemUseMock(mockPrintSysMemUseAction) + with mockRunLogs.BufferLog() as mockLogs: + csMock = MagicMock() + csMock.__getitem__.return_value = 2 + self.memPro.cs = csMock + self.memPro.printCurrentMemoryState() + stdOut = mockLogs.getStdout() + self.assertIn("Currently using 6.0 GB of memory.", stdOut) + self.assertIn("There is 44.0 GB of memory left.", stdOut) + self.assertIn("There is a total allocation of 50.0 GB", stdOut) + + def test_printCurrentMemoryState_noSetting(self): + """Test that the try/except works as it should.""" + expectedStr = ( + "To view memory consumed, remaining available, and total allocated for a case, " + "add the setting 'nTasksPerNode' to your application." + ) + with mockRunLogs.BufferLog() as mockLogs: + self.memPro.printCurrentMemoryState() + self.assertIn(expectedStr, mockLogs.getStdout()) + + def _setMemUseMock(self, mockPrintSysMemUseAction): + class mockMemUse: + def __init__(self, mem: float): + self.processVirtualMemoryInMB = mem + + instance = mockPrintSysMemUseAction.return_value + instance.gather.return_value = [ + mockMemUse(1 * 1024), + mockMemUse(2 * 1024), + mockMemUse(3 * 1024), + ] + class KlassCounterTests(unittest.TestCase): def get_containers(self): diff --git a/armi/materials/custom.py b/armi/materials/custom.py index d5f6ebc2e..9db7b9a6d 100644 --- a/armi/materials/custom.py +++ b/armi/materials/custom.py @@ -16,7 +16,7 @@ Custom materials are ones that you can specify all the number densities yourself. Useful for benchmarking when you have a particular specified material density. -Use the isotopic input described in :doc:`/user/inputs/blueprints`. +Use the isotopic input described in :ref:`bp-input-file`. The density function gets applied from custom isotopics by :py:meth:`armi.reactor.blueprints.isotopicOptions.CustomIsotopic.apply`. diff --git a/armi/materials/thU.py b/armi/materials/thU.py index e46b9c6a6..e37599df0 100644 --- a/armi/materials/thU.py +++ b/armi/materials/thU.py @@ -15,10 +15,8 @@ """ Thorium Uranium metal. -Data is from [IAEA-TECDOCT-1450]_. +Data is from [IAEA-TECDOC-1450]_. -.. [IAEA-TECDOCT-1450] Thorium fuel cycle -- Potential benefits and challenges, IAEA-TECDOC-1450 (2005). - https://www-pub.iaea.org/mtcd/publications/pdf/te_1450_web.pdf """ from armi import runLog diff --git a/armi/materials/thorium.py b/armi/materials/thorium.py index 62807a873..5daddeb78 100644 --- a/armi/materials/thorium.py +++ b/armi/materials/thorium.py @@ -15,10 +15,8 @@ """ Thorium Metal. -Data is from [IAEA-TECDOCT-1450]_. +Data is from [IAEA-TECDOC-1450]_. -.. [IAEA-TECDOCT-1450] Thorium fuel cycle -- Potential benefits and challenges, IAEA-TECDOC-1450 (2005). - https://www-pub.iaea.org/mtcd/publications/pdf/te_1450_web.pdf """ from armi.materials.material import FuelMaterial from armi.utils.units import getTk diff --git a/armi/materials/thoriumOxide.py b/armi/materials/thoriumOxide.py index 157f4a0c6..af5177c5b 100644 --- a/armi/materials/thoriumOxide.py +++ b/armi/materials/thoriumOxide.py @@ -15,9 +15,9 @@ """ Thorium Oxide solid ceramic. -Data is from [IAEA-TECDOCT-1450]_. +Data is from [IAEA-TECDOC-1450]_. -.. [IAEA-TECDOCT-1450] Thorium fuel cycle -- Potential benefits and challenges, IAEA-TECDOC-1450 (2005). +.. [IAEA-TECDOC-1450] Thorium fuel cycle -- Potential benefits and challenges, IAEA-TECDOC-1450 (2005). https://www-pub.iaea.org/mtcd/publications/pdf/te_1450_web.pdf """ from armi import runLog diff --git a/armi/nuclearDataIO/xsNuclides.py b/armi/nuclearDataIO/xsNuclides.py index e11296a7e..c2c976af4 100644 --- a/armi/nuclearDataIO/xsNuclides.py +++ b/armi/nuclearDataIO/xsNuclides.py @@ -240,11 +240,10 @@ def plotScatterMatrix(scatterMatrix, scatterTypeLabel="", fName=None): pyplot.colorbar() if fName: pyplot.savefig(fName) + pyplot.close() else: pyplot.show() - pyplot.close() - def plotCompareScatterMatrix(scatterMatrix1, scatterMatrix2, fName=None): """Compares scatter matrices graphically between libraries.""" @@ -260,7 +259,6 @@ def plotCompareScatterMatrix(scatterMatrix1, scatterMatrix2, fName=None): pyplot.colorbar() if fName: pyplot.savefig(fName) + pyplot.close() else: pyplot.show() - - pyplot.close() diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index 035bfe962..0c1f8c452 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -18,7 +18,7 @@ The :py:class:`FuelHandlerInterface` instantiates a ``FuelHandler``, which is typically a user-defined subclass the :py:class:`FuelHandler` object in custom shuffle-logic input files. Users point to the code modules with their custom fuel handlers using the -``shuffleLogic`` and ``fuelHandlerName`` settings, as described in :doc:`/user/inputs/fuel_management`. +``shuffleLogic`` and ``fuelHandlerName`` settings, as described in :ref:`fuel-management-input`. These subclasses override ``chooseSwaps`` that determine the particular shuffling of a case. @@ -177,7 +177,7 @@ def getFactorList(cycle, cs=None, fallBack=False): This is the default shuffle control function. Usually you would override this with your own in a custom shuffleLogic.py file. For more details about how this - works, refer to :doc:`/user/inputs/fuel_management`. + works, refer to :ref:`fuel-management-input`. This will get bound to the default FuelHandler as a static method below. This is done to allow a user to mix and match FuelHandler class implementations and diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index 9d5ef7916..10dc8b57f 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -21,6 +21,7 @@ from typing import Optional, Type, Tuple, ClassVar import collections import copy +import functools import math import numpy as np @@ -1559,7 +1560,31 @@ class during axial expansion. """ self.p.axialExpTargetComponent = targetComponent.name - def getPinCoordinates(self): + def getPinLocations(self) -> list[grids.IndexLocation]: + """Produce all the index locations for pins in the block. + + Returns + ------- + list[grids.IndexLocation] + Integer locations where pins can be found in the block. + + Notes + ----- + Only components with ``Flags.CLAD`` are considered to define a pin's location. + + See Also + -------- + :meth:`getPinCoordinates` - companion for this method. + """ + items = [] + for clad in self.getChildrenWithFlags(Flags.CLAD): + if isinstance(clad.spatialLocator, grids.MultiIndexLocation): + items.extend(clad.spatialLocator) + else: + items.append(clad.spatialLocator) + return items + + def getPinCoordinates(self) -> np.ndarray: """ Compute the local centroid coordinates of any pins in this block. @@ -1567,18 +1592,17 @@ def getPinCoordinates(self): Returns ------- - localCoordinates : list - list of (x,y,z) pairs representing each pin in the order they are listed as children + localCoords : numpy.ndarray + ``(N, 3)`` array of coordinates for pins locations. ``localCoords[i]`` contains a triplet of + the x, y, z location for pin ``i``. Ordered according to how they are listed as children + + See Also + -------- + :meth:`getPinLocations` - companion for this method """ - coords = [] - for clad in self.getChildrenWithFlags(Flags.CLAD): - if isinstance(clad.spatialLocator, grids.MultiIndexLocation): - coords.extend( - [locator.getLocalCoordinates() for locator in clad.spatialLocator] - ) - else: - coords.append(clad.spatialLocator.getLocalCoordinates()) - return coords + indices = self.getPinLocations() + coords = [location.getLocalCoordinates() for location in indices] + return np.array(coords) def getTotalEnergyGenerationConstants(self): """ @@ -2017,7 +2041,7 @@ def setPinPowers(self, powers, powerKeySuffix=""): else: self.p.linPowByPin = self.p[powerKey] - def rotate(self, rad): + def rotate(self, rad: float): """ Rotates a block's spatially varying parameters by a specified angle in the counter-clockwise direction. @@ -2026,7 +2050,17 @@ def rotate(self, rad): Python list of length 6 in order to be eligible for rotation; all parameters that do not meet these two criteria are not rotated. - The pin indexing, as stored on the ``pinLocation`` parameter, is also updated. + .. impl:: Rotating a hex block updates the orientation parameter. + :id: I_ARMI_ROTATE_HEX_ORIENTATION + :implements: R_ARMI_ROTATE_HEX_PARAMS + + .. impl:: Rotating a hex block updates parameters on the boundary of the hexagon. + :id: I_ARMI_ROTATE_HEX_BOUNDARY + :tests: R_ARMI_ROTATE_HEX_PARAMS + + .. impl:: Rotating a hex block updates the spatial coordinates on contained objects. + :id: I_ARMI_ROTATE_HEX_PIN + :tests: R_ARMI_ROTATE_HEX Parameters ---------- @@ -2037,10 +2071,44 @@ def rotate(self, rad): """ rotNum = round((rad % (2 * math.pi)) / math.radians(60)) - self._rotatePins(rotNum) + self._rotateChildLocations(rad, rotNum) + self.p.orientation[2] += rotNum * 60 self._rotateBoundaryParameters(rotNum) self._rotateDisplacement(rad) + def _rotateChildLocations(self, radians: float, rotNum: int): + """Update spatial locators for children.""" + if self.spatialGrid is None: + return + + locationRotator = functools.partial( + self.spatialGrid.rotateIndex, rotations=rotNum + ) + rotationMatrix = np.array( + [ + [math.cos(radians), -math.sin(radians)], + [math.sin(radians), math.cos(radians)], + ] + ) + for c in self: + if isinstance(c.spatialLocator, grids.MultiIndexLocation): + newLocations = list(map(locationRotator, c.spatialLocator)) + c.spatialLocator = grids.MultiIndexLocation(self.spatialGrid) + c.spatialLocator.extend(newLocations) + elif isinstance(c.spatialLocator, grids.CoordinateLocation): + oldCoords = c.spatialLocator.getLocalCoordinates() + newXY = rotationMatrix.dot(oldCoords[:2]) + newLocation = grids.CoordinateLocation( + newXY[0], newXY[1], oldCoords[2], self.spatialGrid + ) + c.spatialLocator = newLocation + elif isinstance(c.spatialLocator, grids.IndexLocation): + c.spatialLocator = locationRotator(c.spatialLocator) + elif c.spatialLocator is not None: + msg = f"{c} on {self} has an invalid spatial locator for rotation: {c.spatialLocator}" + runLog.error(msg) + raise TypeError(msg) + def _rotateBoundaryParameters(self, rotNum: int): """Rotate any parameters defined on the corners or edge of bounding hexagon. @@ -2090,98 +2158,6 @@ def _rotateDisplacement(self, rad: float): self.p.displacementX = dispx * math.cos(rad) - dispy * math.sin(rad) self.p.displacementY = dispx * math.sin(rad) + dispy * math.cos(rad) - def _rotatePins(self, rotNum, justCompute=False): - """ - Rotate the pins of a block, which means rotating the indexing of pins. Note that this does - not rotate all block quantities, just the pins. - - Parameters - ---------- - rotNum : int, required - An integer from 0 to 5, indicating the number of counterclockwise 60-degree rotations - from the CURRENT orientation. Degrees of counter-clockwise rotation = 60*rot - - justCompute : boolean, optional - If True, rotateIndexLookup will be returned but NOT assigned to the object parameter - self.p.pinLocation. If False, rotateIndexLookup will be returned AND assigned to the - object variable self.p.pinLocation. Useful for figuring out which rotation is best - to minimize burnup, etc. - - Returns - ------- - rotateIndexLookup : dict of ints - This is an index lookup (or mapping) between pin ids and pin locations. The pin - indexing is 1-D (not ring,pos or GEODST). The "ARMI pin ordering" is used for location, - which is counter-clockwise from 1 o'clock. Pin ids are always consecutively - ordered starting at 1, while pin locations are not once a rotation has been - applied. - - Notes - ----- - Changing (x,y) positions of pins does NOT constitute rotation, because the indexing of pin - atom densities must be re-ordered. Re-order indexing of pin-level quantities, NOT (x,y) - locations of pins. Otherwise, subchannel input will be in wrong order. - - How rotations works is like this. There are pins with unique pin numbers in each block. - These pin numbers will not change no matter what happens to a block, so if you have pin 1, - you always have pin 1. However, these pins are all in pinLocations, and these are what - change with rotations. At BOL, a pin's pinLocation is equal to its pin number, but after - a rotation, this will no longer be so. - - So, all params that don't care about exactly where in space the pin is (such as depletion) - can just use the pin number, but anything that needs to know the spatial location (such as - fluxRecon, which interpolates the flux spatially, or subchannel codes, which needs to know where the - power is) need to map through the pinLocation parameters. - - This method rotates the pins by changing the pinLocation parameter. - - See Also - -------- - armi.reactor.blocks.HexBlock.rotate - Rotates the entire block (pins, ducts, and spatial quantities). - - Examples - -------- - rotateIndexLookup[i_after_rotation-1] = i_before_rotation-1 - """ - if not 0 <= rotNum <= 5: - raise ValueError( - "Cannot rotate {0} to rotNum {1}. Must be 0-5. ".format(self, rotNum) - ) - - numPins = self.getNumPins() - hexRings = hexagon.numRingsToHoldNumCells(numPins) - fullNumPins = hexagon.totalPositionsUpToRing(hexRings) - rotateIndexLookup = dict( - zip(range(1, fullNumPins + 1), range(1, fullNumPins + 1)) - ) - - # Look up the current orientation and add this to it. The math below just rotates - # from the reference point so we need a total rotation. - rotNum = int((self.getRotationNum() + rotNum) % 6) - - # non-trivial rotation requested - # start at 2 because pin 1 never changes (it's in the center!) - for pinNum in range(2, fullNumPins + 1): - if rotNum == 0: - # Rotation to reference orientation. Pin locations are pin IDs. - pass - else: - newPinLocation = hexagon.getIndexOfRotatedCell(pinNum, rotNum) - # Assign "before" and "after" pin indices to the index lookup - rotateIndexLookup[pinNum] = newPinLocation - - # Because the above math creates indices based on the absolute rotation number, - # the old values of pinLocation (if they've been set in the past) can be overwritten - # with new numbers - if not justCompute: - self.setRotationNum(rotNum) - self.p["pinLocation"] = [ - rotateIndexLookup[pinNum] for pinNum in range(1, fullNumPins + 1) - ] - - return rotateIndexLookup - def verifyBlockDims(self): """Perform some checks on this type of block before it is assembled.""" try: @@ -2286,13 +2262,13 @@ def getPinToDuctGap(self, cold=False): return pinToDuctGap - def getRotationNum(self): + 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 - def setRotationNum(self, rotNum): + def setRotationNum(self, rotNum: int): """ Set orientation based on a number 0 through 5 indicating number of rotations counterclockwise around the z-axis. diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index a17ddbdfd..48532ed58 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -22,7 +22,7 @@ This is essentially a wrapper for a yaml loader. The given yaml file is expected to rigidly adhere to given key:value pairings. -See the :doc:`blueprints documentation ` for more details. +See the :ref:`blueprints documentation ` for more details. The file structure is expectation is:: diff --git a/armi/reactor/blueprints/reactorBlueprint.py b/armi/reactor/blueprints/reactorBlueprint.py index d4b6773c1..75b363a64 100644 --- a/armi/reactor/blueprints/reactorBlueprint.py +++ b/armi/reactor/blueprints/reactorBlueprint.py @@ -15,7 +15,7 @@ """ Definitions of top-level reactor arrangements like the Core (default), SFP, etc. -See documentation of blueprints in :doc:`/user/inputs/blueprints` for more context. See example in +See documentation of blueprints in :ref:`bp-input-file` for more context. See example in :py:mod:`armi.reactor.blueprints.tests.test_reactorBlueprints`. This was built to replace the old system that loaded the core geometry from the ``cs['geometry']`` diff --git a/armi/reactor/converters/blockConverters.py b/armi/reactor/converters/blockConverters.py index dac9a9588..fc9787151 100644 --- a/armi/reactor/converters/blockConverters.py +++ b/armi/reactor/converters/blockConverters.py @@ -488,9 +488,9 @@ def plotConvertedBlock(self, fName=None): fig.tight_layout() if fName: plt.savefig(fName) + plt.close() else: plt.show() - plt.close() return fName diff --git a/armi/reactor/flags.py b/armi/reactor/flags.py index cba4efe55..190d58c7a 100644 --- a/armi/reactor/flags.py +++ b/armi/reactor/flags.py @@ -23,7 +23,7 @@ object's name when constructed; if the name contains any valid flag names, those Flags will be assigned to the object. However, specific Flags may be specified within blueprints, in which case the name is ignored and only the explicitly-requested Flags -are applied (see :doc:`/user/inputs/blueprints` for more details). +are applied (see :ref:`bp-input-file` for more details). Individual Flags tend to be various nouns and adjectives that describe common objects that go into a reactor (e.g. "fuel", "shield", "control", "duct", "plenum", etc.). In diff --git a/armi/reactor/grids/hexagonal.py b/armi/reactor/grids/hexagonal.py index 76465d82f..df44d19ef 100644 --- a/armi/reactor/grids/hexagonal.py +++ b/armi/reactor/grids/hexagonal.py @@ -11,6 +11,7 @@ # 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. +from collections import deque from math import sqrt from typing import Tuple, List, Optional @@ -23,7 +24,7 @@ BOUNDARY_60_DEGREES, BOUNDARY_CENTER, ) -from armi.reactor.grids.locations import IJKType, IJType +from armi.reactor.grids.locations import IJKType, IJType, IndexLocation from armi.reactor.grids.structuredGrid import StructuredGrid from armi.utils import hexagon @@ -221,7 +222,8 @@ def indicesToRingPos(i: int, j: int) -> Tuple[int, int]: positionBase = 1 + edge * (ring - 1) return ring, positionBase + offset - def getMinimumRings(self, n: int) -> int: + @staticmethod + def getMinimumRings(n: int) -> int: """ Return the minimum number of rings needed to fit ``n`` objects. @@ -232,7 +234,8 @@ def getMinimumRings(self, n: int) -> int: """ return hexagon.numRingsToHoldNumCells(n) - def getPositionsInRing(self, ring: int) -> int: + @staticmethod + def getPositionsInRing(ring: int) -> int: """Return the number of positions within a ring.""" return hexagon.numPositionsInRing(ring) @@ -572,3 +575,70 @@ def generateSortedHexLocationList(self, nLocs: int): ) return locList[:nLocs] + + def rotateIndex(self, loc: IndexLocation, rotations: int) -> IndexLocation: + """Find the new location of an index after some number of CCW rotations. + + Parameters + ---------- + loc : IndexLocation + Starting index + rotations : int + Number of counter clockwise rotations + + Returns + ------- + IndexLocation + Index in the grid after rotation + + Notes + ----- + Rotation uses a three-dimensional index in what can be known elsewhere + by the confusing name of "cubic" coordinate system for a hexagon. Cubic stems + from the notion of using three dimensions, ``(q, r, s)`` to describe a point in the + hexagonal grid. The conversion from the indexing used in the ARMI framework follows:: + + q = i + r = j + # s = - q - r = - (q + r) + s = -(i + j) + + The motivation for the cubic notation is rotation is far simpler: a clockwise + rotation by 60 degrees results in a shifting and negating of the coordinates. So + the first rotation of ``(q, r, s)`` would produce a new coordinate + ``(-r, -s, -q)``. Another rotation would produce ``(s, q, r)``, and so on. + + Raises + ------ + TypeError + If ``loc.grid`` is populated and not consistent with this grid. For example, + it doesn't make sense to rotate an index from a Cartesian grid in a hexagonal coordinate + system, nor hexagonal grid with different orientation (flats up vs. corners up) + """ + if self._roughlyEqual(loc.grid) or loc.grid is None: + i, j, k = loc[:3] + buffer = deque((i, j, -(i + j))) + buffer.rotate(-rotations) + newI = buffer[0] + newJ = buffer[1] + if rotations % 2: + newI *= -1 + newJ *= -1 + return IndexLocation(newI, newJ, k, loc.grid) + raise TypeError( + f"Refusing to rotate an index {loc} from a grid {loc.grid} that " + f"is not consistent with {self}" + ) + + def _roughlyEqual(self, other) -> bool: + """Check that two hex grids are nearly identical. + + Would the same ``(i, j, k)`` index in ``self`` be the same location in ``other``? + """ + if other is self: + return True + return ( + isinstance(other, HexGrid) + and other.pitch == self.pitch + and other.cornersUp == self.cornersUp + ) diff --git a/armi/reactor/grids/locations.py b/armi/reactor/grids/locations.py index ca9e93e1b..03babf512 100644 --- a/armi/reactor/grids/locations.py +++ b/armi/reactor/grids/locations.py @@ -97,7 +97,7 @@ def __hash__(self) -> Hashable: """ return hash((self.i, self.j, self.k)) - def __eq__(self, other: Union[Tuple[int, int, int], "LocationBase"]) -> bool: + def __eq__(self, other: Union[IJKType, "LocationBase"]) -> bool: if isinstance(other, tuple): return (self.i, self.j, self.k) == other if isinstance(other, LocationBase): diff --git a/armi/reactor/grids/tests/test_grids.py b/armi/reactor/grids/tests/test_grids.py index 26b5a334b..24cc6ccc6 100644 --- a/armi/reactor/grids/tests/test_grids.py +++ b/armi/reactor/grids/tests/test_grids.py @@ -15,12 +15,14 @@ """Tests for grids.""" from io import BytesIO import math +from random import randint import unittest import pickle import numpy as np from numpy.testing import assert_allclose, assert_array_equal +from armi.utils import hexagon from armi.reactor import geometry from armi.reactor import grids @@ -592,6 +594,102 @@ def test_indicesAndEdgeFromRingAndPos(self): with self.assertRaises(ValueError): _ = grids.HexGrid._indicesAndEdgeFromRingAndPos(1, 3) + def test_rotatedIndices(self): + """Test that a hex grid can produce a rotated cell location.""" + g = grids.HexGrid.fromPitch(1.0, numRings=3) + center: grids.IndexLocation = g[(0, 0, 0)] + notRotated = self._rotateAndCheckAngle(g, center, 0) + self.assertEqual(notRotated, center) + + # One rotation for a trivial check + northEast: grids.IndexLocation = g[(1, 0, 0)] + dueNorth: grids.IndexLocation = g[(0, 1, 0)] + northWest: grids.IndexLocation = g[(-1, 1, 0)] + actual = self._rotateAndCheckAngle(g, northEast, 1) + self.assertEqual(actual, dueNorth) + np.testing.assert_allclose(dueNorth.getLocalCoordinates(), [0.0, 1.0, 0.0]) + + actual = self._rotateAndCheckAngle(g, dueNorth, 1) + self.assertEqual(actual, northWest) + np.testing.assert_allclose( + northWest.getLocalCoordinates(), [-hexagon.SQRT3 / 2, 0.5, 0] + ) + + # Two rotations from the "first" object in the first full ring + actual = self._rotateAndCheckAngle(g, northEast, 2) + self.assertEqual(actual, northWest) + + # Fuzzy rotation: if we rotate an location, and then rotate it back, we get the same location + for _ in range(10): + startI = randint(-10, 10) + startJ = randint(-10, 10) + start = g[(startI, startJ, 0)] + rotations = randint(-10, 10) + postRotate = self._rotateAndCheckAngle(g, start, rotations) + if startI == 0 and startJ == 0: + self.assertEqual(postRotate, start) + continue + if rotations % 6: + self.assertNotEqual(postRotate, start, msg=rotations) + else: + self.assertEqual(postRotate, start, msg=rotations) + reversed = self._rotateAndCheckAngle(g, postRotate, -rotations) + self.assertEqual(reversed, start) + + def _rotateAndCheckAngle( + self, g: grids.HexGrid, start: grids.IndexLocation, rotations: int + ) -> grids.IndexLocation: + """Rotate a location and verify it lands where we expected.""" + finish = g.rotateIndex(start, rotations) + self._checkAngle(start, finish, rotations) + return finish + + def _checkAngle( + self, start: grids.IndexLocation, finish: grids.IndexLocation, rotations: int + ): + """Compare two locations that should be some number of 60 degree CCW rotations apart.""" + startXY = start.getLocalCoordinates()[:2] + theta = math.pi / 3 * rotations + rotationMatrix = np.array( + [ + [math.cos(theta), -math.sin(theta)], + [math.sin(theta), math.cos(theta)], + ] + ) + expected = rotationMatrix.dot(startXY) + finishXY = finish.getLocalCoordinates()[:2] + np.testing.assert_allclose(finishXY, expected, atol=1e-8) + + def test_inconsistentRotationGrids(self): + """Test that only locations in consistent grids are rotatable.""" + base = grids.HexGrid.fromPitch(1, cornersUp=False) + larger = grids.HexGrid.fromPitch(base.pitch * 2, cornersUp=base.cornersUp) + fromLarger = larger[1, 0, 0] + with self.assertRaises(TypeError): + base.rotateIndex(fromLarger) + + differentOrientation = grids.HexGrid.fromPitch( + base.pitch, cornersUp=not base.cornersUp + ) + fromDiffOrientation = differentOrientation[0, 1, 0] + with self.assertRaises(TypeError): + base.rotateIndex(fromDiffOrientation) + + axialGrid = grids.AxialGrid.fromNCells(5) + fromAxial = axialGrid[2, 0, 0] + with self.assertRaises(TypeError): + base.rotateIndex(fromAxial) + + def test_rotatedIndexGridAssignment(self): + """Test that the grid of the rotated index is identical through rotation.""" + base = grids.HexGrid.fromPitch(1) + other = grids.HexGrid.fromPitch(base.pitch, cornersUp=base.cornersUp) + + for i, j in ((0, 0), (1, 1), (2, 1), (-1, 3)): + loc = grids.IndexLocation(i, j, k=0, grid=other) + postRotate = base.rotateIndex(loc, rotations=2) + self.assertIs(postRotate.grid, loc.grid) + class TestBoundsDefinedGrid(unittest.TestCase): def test_positions(self): diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py index d53fc3973..e78b640f9 100644 --- a/armi/reactor/tests/test_blocks.py +++ b/armi/reactor/tests/test_blocks.py @@ -74,7 +74,7 @@ def buildSimpleFuelBlock(): return b -def loadTestBlock(cold=True): +def loadTestBlock(cold=True) -> blocks.HexBlock: """Build an annular test block for evaluating unit tests.""" caseSetting = settings.Settings() caseSetting[CONF_XS_KERNEL] = "MC2v2" @@ -202,7 +202,7 @@ def loadTestBlock(cold=True): "Thot": hotTempStructure, "ip": 16.6, "op": 17.3, - "mult": 1.0, + "mult": 1, } duct = components.Hexagon("duct", "HT9", **ductDims) @@ -211,7 +211,7 @@ def loadTestBlock(cold=True): "Thot": hotTempCoolant, "op": 17.8, "ip": "duct.op", - "mult": 1.0, + "mult": 1, } interDims["components"] = {"duct": duct} interSodium = components.Hexagon("interCoolant", "Sodium", **interDims) @@ -1463,37 +1463,6 @@ def test_106_getAreaFractions(self): self.assertAlmostEqual(sum(fracs.values()), sum([a for c, a in cur])) - def test_rotatePins(self): - b = self.block - b.setRotationNum(0) - index = b._rotatePins(0, justCompute=True) - self.assertEqual(b.getRotationNum(), 0) - self.assertEqual(index[5], 5) - self.assertEqual(index[2], 2) # pin 1 is center and never rotates. - - index = b._rotatePins(1) - self.assertEqual(b.getRotationNum(), 1) - self.assertEqual(index[2], 3) - self.assertEqual(b.p.pinLocation[1], 3) - - index = b._rotatePins(1) - self.assertEqual(b.getRotationNum(), 2) - self.assertEqual(index[2], 4) - self.assertEqual(b.p.pinLocation[1], 4) - - index = b._rotatePins(2) - index = b._rotatePins(4) # over-rotate to check modulus - self.assertEqual(b.getRotationNum(), 2) - self.assertEqual(index[2], 4) - self.assertEqual(index[6], 2) - self.assertEqual(b.p.pinLocation[1], 4) - self.assertEqual(b.p.pinLocation[5], 2) - - self.assertRaises(ValueError, b._rotatePins, -1) - self.assertRaises(ValueError, b._rotatePins, 10) - self.assertRaises((ValueError, TypeError), b._rotatePins, None) - self.assertRaises((ValueError, TypeError), b._rotatePins, "a") - def test_expandElementalToIsotopics(self): r"""Tests the expand to elementals capability.""" initialN = {} @@ -2083,13 +2052,41 @@ def test_retainState(self): self.assertAlmostEqual(self.HexBlock.spatialGrid.pitch, 1.0) self.assertTrue(self.HexBlock.hasFlags(Flags.INTERCOOLANT)) + def test_getPinLocations(self): + """Test pin locations can be obtained.""" + locs = set(self.HexBlock.getPinLocations()) + nPins = self.HexBlock.getNumPins() + self.assertEqual(len(locs), nPins) + for l in locs: + self.assertIs(l.grid, self.HexBlock.spatialGrid) + # Check all clad components are represented + for c in self.HexBlock.getChildrenWithFlags(Flags.CLAD): + if isinstance(c.spatialLocator, grids.MultiIndexLocation): + for l in c.spatialLocator: + locs.remove(l) + else: + locs.remove(c.spatialLocator) + self.assertFalse( + locs, + msg="Some clad locations were not found but returned by getPinLocations", + ) + + def test_getPinCoordsAndLocsAgree(self): + """Ensure consistency in ordering of pin locations and coordinates.""" + locs = self.HexBlock.getPinLocations() + coords = self.HexBlock.getPinCoordinates() + self.assertEqual(len(locs), len(coords)) + for loc, coord in zip(locs, coords): + convertedCoords = loc.getLocalCoordinates() + np.testing.assert_array_equal(coord, convertedCoords, err_msg=f"{loc=}") + def test_getPinCoords(self): blockPitch = self.HexBlock.getPitch() pinPitch = self.HexBlock.getPinPitch() nPins = self.HexBlock.getNumPins() side = hexagon.side(blockPitch) xyz = self.HexBlock.getPinCoordinates() - x, y, _z = zip(*xyz) + x, y, z = xyz.T self.assertAlmostEqual( y[1], y[2] ) # first two pins should be side by side on top. @@ -2104,16 +2101,21 @@ def test_getPinCoords(self): self.assertGreater(min(x), -side) # center pin should be at 0 - mags = [(xi**2 + yi**2, (xi, yi)) for xi, yi, zi in xyz] - _centerMag, (cx, cy) = min(mags) + mags = x * x + y * y + minIndex = mags.argmin() + cx = x[minIndex] + cy = y[minIndex] self.assertAlmostEqual(cx, 0.0) self.assertAlmostEqual(cy, 0.0) # extreme pin should be at proper radius - cornerMag, (cx, cy) = max(mags) + cornerMag = mags.max() nRings = hexagon.numRingsToHoldNumCells(nPins) - 1 self.assertAlmostEqual(math.sqrt(cornerMag), nRings * pinPitch) + # all z coords equal to zero + np.testing.assert_equal(z, 0) + def test_getPitchHomogeneousBlock(self): """ Demonstrate how to communicate pitch on a hex block with unshaped components. @@ -2688,23 +2690,3 @@ def test_massConsistency(self): 10, "Sum of component mass {0} != total block mass {1}. ".format(tMass, bMass), ) - - -class EmptyBlockRotateTest(unittest.TestCase): - """Rotation tests on an empty hexagonal block. - - Useful for enforcing rotation works on blocks without pins. - - """ - - def setUp(self): - self.block = blocks.HexBlock("empty") - - def test_orientation(self): - """Test the orientation parameter is updated on a rotated empty block.""" - rotDegrees = 60 - preRotateOrientation = self.block.p.orientation[2] - self.block.rotate(math.radians(rotDegrees)) - postRotationOrientation = self.block.p.orientation[2] - self.assertNotEqual(preRotateOrientation, postRotationOrientation) - self.assertEqual(postRotationOrientation, rotDegrees) diff --git a/armi/reactor/tests/test_hexBlockRotate.py b/armi/reactor/tests/test_hexBlockRotate.py new file mode 100644 index 000000000..d4914aff1 --- /dev/null +++ b/armi/reactor/tests/test_hexBlockRotate.py @@ -0,0 +1,263 @@ +# Copyright 2024 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. +"""Tests for the ability to rotate a hexagonal block.""" + +import copy +import math +import unittest + +import numpy as np + +from armi.reactor.blocks import HexBlock +from armi.reactor.components import Component +from armi.reactor.grids import ( + MultiIndexLocation, + CoordinateLocation, + IndexLocation, + HexGrid, +) +from armi.reactor.tests.test_blocks import loadTestBlock, NUM_PINS_IN_TEST_BLOCK +from armi.utils import iterables + + +class HexBlockRotateTests(unittest.TestCase): + """Tests for various rotation aspects of a hex block.""" + + BOUNDARY_PARAMS = [ + "cornerFastFlux", + "pointsCornerDpa", + "pointsCornerDpaRate", + "pointsCornerFastFluxFr", + "pointsEdgeDpa", + "pointsEdgeDpaRate", + "pointsEdgeFastFluxFr", + "THedgeTemp", + "THcornTemp", + ] + BOUNDARY_DATA = np.arange(6, dtype=float) * 10 + + PIN_PARAMS = [ + "percentBuByPin", + "linPowByPin", + ] + + PIN_DATA = np.arange(NUM_PINS_IN_TEST_BLOCK, dtype=float) + + def setUp(self): + self.baseBlock = loadTestBlock() + self._assignParamData(self.BOUNDARY_PARAMS, self.BOUNDARY_DATA) + self._assignParamData(self.PIN_PARAMS, self.PIN_DATA) + + def _assignParamData(self, names: list[str], referenceData: np.ndarray): + """Assign initial rotatable pararameter data. + + Make some arrays, some lists to make sure we have good coverage of usage. + """ + # Yes we're putting the variable type in the name but that's why this method exists + listData = referenceData.tolist() + for ix, name in enumerate(names): + self.baseBlock.p[name] = referenceData if (ix % 2) else listData + + def test_orientationVector(self): + """Test the z-value in the orientation vector matches rotation. + + .. test:: Demonstrate that a HexBlock can be rotated in 60 degree increments. + :id: T_ARMI_ROTATE_HEX_BLOCK + :tests: R_ARMI_ROTATE_HEX + + .. test:: After rotating a block, the orientation parameter reflects the current rotation. + :id: T_ARMI_ROTATE_HEX_ORIENTATION + :tests: R_ARMI_ROTATE_HEX_PARAMS + """ + for nRotations in range(-10, 10): + rotationAmount = 60 * nRotations + fresh = copy.deepcopy(self.baseBlock) + self.assertEqual(fresh.p.orientation[2], 0.0, msg=nRotations) + fresh.rotate(math.radians(rotationAmount)) + # Ensure rotation is bounded [0, 360) + postRotationOrientation = fresh.p.orientation[2] + self.assertTrue(0 <= postRotationOrientation < 360, msg=nRotations) + # Trim off any extra rotation if beyond 360 or negative + # What is the effective counter clockwise rotation? + expectedOrientation = rotationAmount % 360 + self.assertEqual( + postRotationOrientation, expectedOrientation, msg=nRotations + ) + + def test_rotateBoundaryParameters(self): + """Test that boundary parameters are correctly rotated. + + .. test:: Rotating a hex block updates parameters on the boundary of the hexagon. + :id: T_ARMI_ROTATE_HEX_BOUNDARY + :tests: R_ARMI_ROTATE_HEX_PARAMS + """ + # No rotation == no changes to data + self._rotateAndCompareBoundaryParams(0, self.BOUNDARY_DATA) + for rotNum in range(1, 6): + expected = iterables.pivot(self.BOUNDARY_DATA, -rotNum) + self._rotateAndCompareBoundaryParams(rotNum * 60, expected) + # Six rotations of 60 degrees puts us back to the original layout + self._rotateAndCompareBoundaryParams(360, self.BOUNDARY_DATA) + + def _rotateAndCompareBoundaryParams(self, degrees: float, expected: np.ndarray): + fresh = copy.deepcopy(self.baseBlock) + fresh.rotate(math.radians(degrees)) + for name in self.BOUNDARY_PARAMS: + data = fresh.p[name] + msg = f"{name=} :: {degrees=} :: {data=}" + np.testing.assert_array_equal(data, expected, err_msg=msg) + + def assertIndexLocationEquivalent( + self, actual: IndexLocation, expected: IndexLocation + ): + """More flexible equivalency check on index locations. + + Specifically focused on locations on hex grids because this file + is testing things on hex blocks. + + Checks that + 1. ``i``, ``j``, and ``k`` are equal + 2. Grids are both hex grid + 3. Grids have same pitch and orientation. + """ + self.assertEqual(actual.i, expected.i) + self.assertEqual(actual.j, expected.j) + self.assertEqual(actual.k, expected.k) + self.assertIsInstance(actual.grid, HexGrid) + self.assertIsInstance(expected.grid, HexGrid) + self.assertEqual(actual.grid.cornersUp, expected.grid.cornersUp) + self.assertEqual(actual.grid.pitch, expected.grid.pitch) + + def test_pinRotationLocations(self): + """Test that pin locations are updated through rotation. + + .. test:: HexBlock.getPinLocations is consistent with rotation. + :id: T_ARMI_ROTATE_HEX_PIN_LOCS + :tests: R_ARMI_ROTATE_HEX + """ + preRotation = self.baseBlock.getPinLocations() + for nRotations in range(-10, 10): + degrees = 60 * nRotations + fresh = copy.deepcopy(self.baseBlock) + g = fresh.spatialGrid + fresh.rotate(math.radians(degrees)) + postRotation = fresh.getPinLocations() + self.assertEqual(len(preRotation), len(postRotation)) + for pre, post in zip(preRotation, postRotation): + expected = g.rotateIndex(pre, nRotations) + self.assertIndexLocationEquivalent(post, expected) + + def test_pinRotationCoordinates(self): + """Test that pin coordinates are updated through rotation. + + .. test:: HexBlock.getPinCoordinates is consistent through rotation. + :id: T_ARMI_ROTATE_HEX_PIN_COORDS + :tests: R_ARMI_ROTATE_HEX + """ + preRotation = self.baseBlock.getPinCoordinates() + # Over- and under-rotate to make sure we can handle clockwise and counter + # clockwise rotations, and cases that wrap around a full rotation + for degrees in range(-600, 600, 60): + fresh = copy.deepcopy(self.baseBlock) + rads = math.radians(degrees) + fresh.rotate(rads) + rotationMatrix = np.array( + [ + [math.cos(rads), -math.sin(rads)], + [math.sin(rads), math.cos(rads)], + ] + ) + postRotation = fresh.getPinCoordinates() + self.assertEqual(len(preRotation), len(postRotation)) + for pre, post in zip(preRotation, postRotation): + start = pre[:2] + finish = post[:2] + if np.allclose(start, 0): + np.testing.assert_equal(start, finish) + continue + expected = rotationMatrix.dot(start) + np.testing.assert_allclose(expected, finish, atol=1e-8) + + def test_updateChildLocations(self): + """Test that locations of all children are updated through rotation. + + .. test:: Rotating a hex block updates the spatial coordinates on contained objects. + :id: T_ARMI_ROTATE_HEX_CHILD_LOCS + :tests: R_ARMI_ROTATE_HEX + """ + for nRotations in range(-10, 10): + fresh = copy.deepcopy(self.baseBlock) + degrees = 60 * nRotations + rads = math.radians(degrees) + fresh.rotate(rads) + for originalC, newC in zip(self.baseBlock, fresh): + self._compareComponentLocationsAfterRotation( + originalC, newC, nRotations, rads + ) + + def _compareComponentLocationsAfterRotation( + self, original: Component, updated: Component, nRotations: int, radians: float + ): + if isinstance(original.spatialLocator, MultiIndexLocation): + for originalLoc, newLoc in zip( + original.spatialLocator, updated.spatialLocator + ): + + expected = originalLoc.grid.rotateIndex(originalLoc, nRotations) + self.assertIndexLocationEquivalent(newLoc, expected) + elif isinstance(original.spatialLocator, CoordinateLocation): + ox, oy, oz = original.spatialLocator.getLocalCoordinates() + nx, ny, nz = updated.spatialLocator.getLocalCoordinates() + self.assertEqual(nz, oz, msg=f"{original=} :: {radians=}") + rotationMatrix = np.array( + [ + [math.cos(radians), -math.sin(radians)], + [math.sin(radians), math.cos(radians)], + ] + ) + expectedX, expectedY = rotationMatrix.dot((ox, oy)) + np.testing.assert_allclose( + (nx, ny), (expectedX, expectedY), err_msg=f"{original=} :: {radians=}" + ) + + def test_pinParametersUnmodified(self): + """Test that pin data are not modified through rotation. + + Reinforces the idea that data like ``linPowByPin[i]`` are assigned to + pin ``i``, wherever it may be. Locations are defined instead by ``getPinCoordinates()[i]``. + """ + fresh = copy.deepcopy(self.baseBlock) + fresh.rotate(math.radians(60)) + for paramName in self.PIN_PARAMS: + actual = fresh.p[paramName] + np.testing.assert_equal(actual, self.PIN_DATA, err_msg=paramName) + + +class EmptyBlockRotateTest(unittest.TestCase): + """Rotation tests on an empty hexagonal block. + + Useful for enforcing rotation works on blocks without pins. + """ + + def setUp(self): + self.block = HexBlock("empty") + + def test_orientation(self): + """Test the orientation parameter is updated on a rotated empty block.""" + rotDegrees = 60 + preRotateOrientation = self.block.p.orientation[2] + self.block.rotate(math.radians(rotDegrees)) + postRotationOrientation = self.block.p.orientation[2] + self.assertNotEqual(preRotateOrientation, postRotationOrientation) + self.assertEqual(postRotationOrientation, rotDegrees) diff --git a/armi/settings/settingsValidation.py b/armi/settings/settingsValidation.py index ed11f5b33..6a4534a79 100644 --- a/armi/settings/settingsValidation.py +++ b/armi/settings/settingsValidation.py @@ -778,9 +778,9 @@ def validateVersion(versionThis: str, versionRequired: str) -> bool: bool Does this version match the version in the Settings file/object? """ - fullV = "\d+\.\d+\.\d+" - medV = "\d+\.\d+" - minV = "\d+" + fullV = r"\d+\.\d+\.\d+" + medV = r"\d+\.\d+" + minV = r"\d+" if versionRequired == "uncontrolled": # This default flag means we don't want to check the version. diff --git a/armi/utils/gridEditor.py b/armi/utils/gridEditor.py index 5bf530338..025afa386 100644 --- a/armi/utils/gridEditor.py +++ b/armi/utils/gridEditor.py @@ -27,6 +27,12 @@ $ python -m armi grids FFTF-blueprints.yaml +.. figure:: /.static/gridEditor.png + :align: center + + An example of the Grid Editor being used on a FFTF input file + + **Known Issues** * There is no action stack or undo functionality. Save frequently if you want to @@ -48,6 +54,7 @@ specifiers. Adding zoom would make for a fun first task to a new developer interested in computer graphics. """ + import colorsys import enum import io diff --git a/armi/utils/plotting.py b/armi/utils/plotting.py index 8e713eeb1..0726c5d9b 100644 --- a/armi/utils/plotting.py +++ b/armi/utils/plotting.py @@ -178,10 +178,10 @@ def update(i): if fName: plt.savefig(fName, dpi=150) + plt.close() else: plt.show() - plt.close() return fName @@ -402,13 +402,16 @@ def plotFaceMap( "Cannot update facemap at {0}: IOError. Is the file open?" "".format(fName) ) + plt.close(fig) elif referencesToKeep: # Don't show yet, since it will be updated. return fName else: + # Never close figures after a .show() + # because they're being used interactively e.g. + # in a live tutorial or by the doc gallery plt.show() - plt.close(fig) return fName @@ -823,8 +826,8 @@ def plotAssemblyTypes( if fileName: fig.savefig(fileName) runLog.debug("Writing assem layout {} in {}".format(fileName, os.getcwd())) + plt.close(fig) - plt.close(fig) return fig @@ -1092,11 +1095,11 @@ def getTable(self): os.path.abspath(fName), report.FLUX_PLOT, ) + plt.close() else: + # Never close interactive plots plt.show() - plt.close() - def makeHistogram(x, y): """ @@ -1503,7 +1506,6 @@ def plotNucXs( if fName: plt.savefig(fName) + plt.close() elif not noShow: plt.show() - - plt.close() diff --git a/doc/conf.py b/doc/conf.py index d21b56a81..e06966421 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -212,6 +212,26 @@ def run(self): ) +class SkipNeedsDirective(Directive): + """ + A no-op directive that filters out any sphinx-need directives from docs. + + Temporary patch until we figure out a different/better way to maintain formal QA docs. + """ + + has_content = True + required_arguments = 1 + optional_arguments = 0 + final_argument_whitespace = True + + def run(self): + _delete_opt = self.options.get("delete") + _collapse = self.options.get("collapse") + _jinja_content = self.options.get("jinja_content") + _hide = "hide" in self.options + return [] + + def autodoc_skip_member_handler(app, what, name, obj, skip, options): """Manually exclude certain methods/functions from docs.""" # exclude special methods from unittest @@ -234,8 +254,8 @@ def setup(app): app.add_domain(PatchedPythonDomain, override=True) app.add_directive("exec", ExecDirective) app.add_directive("pyreverse", PyReverse) - app.add_directive("impl", directives.admonitions.Note) - app.add_directive("test", directives.admonitions.Note) + app.add_directive("impl", SkipNeedsDirective) + app.add_directive("test", SkipNeedsDirective) # making tutorial data dir dataDir = pathlib.Path("user") / ".." / "anl-afci-177" diff --git a/doc/gallery-src/analysis/run_hexReactorToRZ.py b/doc/gallery-src/analysis/run_hexReactorToRZ.py index 89b3e6279..1baa4c7e2 100644 --- a/doc/gallery-src/analysis/run_hexReactorToRZ.py +++ b/doc/gallery-src/analysis/run_hexReactorToRZ.py @@ -55,4 +55,3 @@ figs = converter.plotConvertedReactor() plt.show() -plt.close() diff --git a/doc/gallery-src/framework/run_fuelManagement.py b/doc/gallery-src/framework/run_fuelManagement.py index 44a18df1c..d355bbaf0 100644 --- a/doc/gallery-src/framework/run_fuelManagement.py +++ b/doc/gallery-src/framework/run_fuelManagement.py @@ -30,7 +30,6 @@ # sphinx_gallery_thumbnail_number = 2 import math -import matplotlib.pyplot as plt from armi import configure from armi.physics.fuelCycle import fuelHandlers @@ -73,4 +72,3 @@ # show final burnup distribution plotting.plotFaceMap(reactor.core, param="percentBu") -plt.close() diff --git a/doc/gallery-src/framework/run_reactorFacemap.py b/doc/gallery-src/framework/run_reactorFacemap.py index 7b961d461..457aa3f5f 100644 --- a/doc/gallery-src/framework/run_reactorFacemap.py +++ b/doc/gallery-src/framework/run_reactorFacemap.py @@ -33,4 +33,3 @@ b.p.pdens = x**2 + y**2 + z**2 plotting.plotFaceMap(reactor.core, param="pdens", labelFmt="{0:.1e}") -plotting.close() diff --git a/doc/release/0.2.rst b/doc/release/0.2.rst index 9cfed61e6..6650d007f 100644 --- a/doc/release/0.2.rst +++ b/doc/release/0.2.rst @@ -175,7 +175,7 @@ What's new in ARMI #. Changed the default Git branch name to ``main``. #. Moved math utilities into their own module. #. Moved ``newReports`` into their final location in ``armi/bookkeeping/report/``. -#. Removed ``_swapFluxParam`` method (`PR#665 `_) +#. Removed ``_swapFluxParam`` method (`PR#665 `__) #. Removed the last usage of ``settingsRules``; now only use ``settingsValidation``. #. Removed separate blueprints in snapshot runs, they must come from the database (`PR#872 https://github.com/terrapower/armi/pull/872`) #. Added reporting of neutron and gamma energy groups in the XS library ``__repr__``. @@ -183,7 +183,7 @@ What's new in ARMI #. Store thermally expanded block heights at BOL in ``armi/reactor/reactors.py::Core::processLoading``. #. Added neutronics settings: ``inners`` and ``outers`` for downstream support. #. Removed unused Thermal Hydraulics settings. -#. Replaced setting ``stationaryBlocks`` with ``stationaryBlockFlags`` setting (`PR#665 `_) +#. Replaced setting ``stationaryBlocks`` with ``stationaryBlockFlags`` setting (`PR#665 `__)) #. Changed the default value of the ``trackAssems`` setting to ``False``. #. Add setting ``inputHeightsConsideredHot`` to enable thermal expansion of assemblies at BOL. @@ -215,7 +215,7 @@ Release Date: 2022-02-08 What's new in ARMI ------------------ #. Upgrading the version of NumPy for a security alert (`PR#530 `_) -#. Upgraded ThoriumOxide material (`PR#558 `_) +#. Upgraded ThoriumOxide material (`PR#548 `_) #. Upgraded Lithium material (`PR#546 `_) #. Improved Helix class (`PR#558 `_) diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst index e1336f89d..770521b58 100644 --- a/doc/release/0.4.rst +++ b/doc/release/0.4.rst @@ -20,8 +20,9 @@ New Features #. Provide ``ParameterCollection.where`` for efficient iteration over parameters who's definition matches a given condition. (`PR#1899 `_) #. Flags can now be defined with letters and numbers. (`PR#1966 `_) #. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion. (`PR#1870 `_) +#. ``HexBlock.rotate`` updates the spatial locator for children of that block. (`PR#1943 `_) #. New plugin hook ``beforeReactorConstruction`` added to enable plugins to process case settings before reactor init. (`PR#1945 `_) -#. Provide ``Block.getInputHeight`` for determining the height of a block from blueprints. (`PR#1927 `_) #. Improve performance by changing the lattice physics interface so that cross sections are not updated on ``everyNode`` calls during coupled calculations (`PR#1963 `_) #. Improve efficiency of reaction rate calculations. (`PR#1887 `_) #. TBD @@ -41,6 +42,7 @@ API Changes #. Removing ``assemblyLists.py`` and the ``AssemblyList`` class. (`PR#1891 `_) #. Removing ``Assembly.rotatePins`` and ``Block.rotatePins``. Prefer ``Assembly.rotate`` and ``Block.rotate``. (`PR#1846 `_) #. Transposing ``pinMgFluxes`` parameters so that leading dimension is pin index. (`PR#1937 `_) +#. ``Block.getPinCoordinates`` returns an ``(N, 3)`` array, rather than a length ``N`` list of three-length arrays. (`PR#1943 `_) #. Removing ``globalFluxInterface.DoseResultsMapper`` class. (`PR#1952 `_) #. Removing setting ``mpiTasksPerNode`` and renaming ``numProcessors`` to ``nTasks``. (`PR#1958 `_) #. Changing ``synDbAfterWrite`` default to ``True``. (`PR#1968 `_) diff --git a/doc/tutorials/walkthrough_lwr_inputs.rst b/doc/tutorials/walkthrough_lwr_inputs.rst index 5fdc61df7..efb38cf75 100644 --- a/doc/tutorials/walkthrough_lwr_inputs.rst +++ b/doc/tutorials/walkthrough_lwr_inputs.rst @@ -1,3 +1,5 @@ +.. _walkthrough-lwr: + ****************************************** Building input files for a thermal reactor ****************************************** diff --git a/doc/user/index.rst b/doc/user/index.rst index 360dea653..e19524377 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -12,7 +12,6 @@ analyzing ARMI output files, etc. :maxdepth: 2 :numbered: - user_install inputs outputs manual_data_access @@ -21,6 +20,7 @@ analyzing ARMI output files, etc. component_parameters_report assembly_parameters_report block_parameters_report + spatial_block_parameters physics_coupling radial_and_axial_expansion accessingEntryPoints diff --git a/doc/user/inputs.rst b/doc/user/inputs.rst index 4e6500790..918153cc2 100644 --- a/doc/user/inputs.rst +++ b/doc/user/inputs.rst @@ -67,7 +67,7 @@ Here is an excerpt from a settings file: :lines: 3-15 A full listing of settings available in the framework may be found in the -`Table of all global settings <#settings-report>`_ . +:ref:`Table of all global settings ` . Many settings are provided by the ARMI Framework, and others are defined by various plugins. @@ -84,11 +84,12 @@ them. Note that one settings input file is required for each ARMI case, though many ARMI cases can refer to the same Blueprints, Core Map, and Fuel Management inputs. -.. tip:: The ARMI GUI is not yet included in the open-source ARMI framework +.. tip:: The ARMI GUI is not yet included in the open-source ARMI framework, but a simple + grid editor GUI is, as described in :ref:`grids` The assembly clicker ^^^^^^^^^^^^^^^^^^^^ -The assembly clicker (in the ``grids`` editor) allows users to define the 2-D layout of the assemblies defined in the +The assembly clicker (aka the :py:mod:`Grid Editor `) allows users to define the 2-D layout of the assemblies defined in the :ref:`bp-input-file`. This can be done in hexagon or cartesian. The results of this arrangement get written to grids in blueprints. Click on the assembly palette on the right and click on the locations where you want to put the assembly. By default, the input assumes a 1/3 core model, but you can create a full core model through the menu. @@ -1372,6 +1373,7 @@ The code will crash if materials used in :ref:`blocks-and-components` contain nu .. |Tinput| replace:: T\ :sub:`input` .. |Thot| replace:: T\ :sub:`hot` +.. _fuel-management-input: Fuel Management Input ===================== diff --git a/doc/user/outputs.rst b/doc/user/outputs.rst index a0e39f15d..71152caad 100644 --- a/doc/user/outputs.rst +++ b/doc/user/outputs.rst @@ -64,6 +64,8 @@ Some Linux users tend to use the **tail** command to monitor the progress of an This provides live information on the progress. +.. _database-file: + The Database File ================= The **database** file is a self-contained complete (or nearly complete) binary diff --git a/doc/user/radial_and_axial_expansion.rst b/doc/user/radial_and_axial_expansion.rst index 2b47c6006..f1b75cae1 100644 --- a/doc/user/radial_and_axial_expansion.rst +++ b/doc/user/radial_and_axial_expansion.rst @@ -2,7 +2,11 @@ Radial and Axial Expansion and Contraction ****************************************** -ARMI natively supports linear expansion in both the radial and axial dimensions. These expansion types function independently of one another and each have their own set of underlying assumptions and use-cases. The remainder of this section is described as follows: in Section :ref:`thermalExpansion` the methodology used for thermal expansion within ARMI is described; in Sections :ref:`radialExpansion` and :ref:`axialExpansion`, we describe the design, limitations, and intended functionality of radial and axial expansion, respectively. +ARMI natively supports linear expansion in both the radial and axial dimensions. +These expansion types function independently of one another and each have their +own set of underlying assumptions and use-cases. The remainder of this section +is described as follows: in Section :ref:`thermalExpansion` the methodology used +for thermal expansion within ARMI is described. .. _thermalExpansion: @@ -14,13 +18,18 @@ ARMI treats thermal expansion as a linear phenomena using the standard linear ex \frac{\Delta L}{L_0} = \alpha(T) \Delta T, :label: linearExp -where, :math:`\Delta L` and :math:`\Delta T` are the change in length and temperature from the reference state, respectively, and :math:`\alpha` is the thermal expansion coefficient relative to :math:`T_0`. Expanding and rearranging Equation :eq:`linearExp`, we can obtain an expression for the new length, :math:`L_1`, +where, :math:`\Delta L` and :math:`\Delta T` are the change in length and +temperature from the reference state, respectively, and :math:`\alpha` is the +thermal expansion coefficient relative to :math:`T_0`. Expanding and rearranging +Equation :eq:`linearExp`, we can obtain an expression for the new length, +:math:`L_1`, .. math:: L_1 = L_0\left[1 + \alpha(T_1)\left(T_1 - T_0\right) \right]. :label: newLength -Given Equation :eq:`linearExp`, we can create expressions for the change in length between our "hot" temperature (Equation :eq:`hotExp`) +Given Equation :eq:`linearExp`, we can create expressions for the change in +length between our "hot" temperature (Equation :eq:`hotExp`) .. math:: \begin{aligned} @@ -38,7 +47,11 @@ and "non-reference" temperature, :math:`T_c` (Equation :eq:`nonRefExp`), \end{aligned} :label: nonRefExp -These are used within ARMI to enable thermal expansion and contraction with a temperature not equal to the reference temperature, :math:`T_0`. By taking the difference between Equation :eq:`hotExp` and :eq:`nonRefExp`, we can obtain an expression relating the change in length, :math:`L_h - L_c`, to the reference length, :math:`L_0`, +These are used within ARMI to enable thermal expansion and contraction with a +temperature not equal to the reference temperature, :math:`T_0`. By taking the +difference between Equation :eq:`hotExp` and :eq:`nonRefExp`, we can obtain an +expression relating the change in length, :math:`L_h - L_c`, to the reference +length, :math:`L_0`, .. math:: \begin{aligned} @@ -47,20 +60,27 @@ These are used within ARMI to enable thermal expansion and contraction with a te \end{aligned} :label: diffHotNonRef -Using Equations :eq:`diffHotNonRef` and :eq:`nonRefExp`, we can obtain an expression for the change in length, :math:`L_h - L_c`, relative to the non-reference temperature, +Using Equations :eq:`diffHotNonRef` and :eq:`nonRefExp`, we can obtain an +expression for the change in length, :math:`L_h - L_c`, relative to the +non-reference temperature, .. math:: \frac{L_h - L_c}{L_c} &= \frac{L_h - L_c}{L_0} \frac{L_0}{L_c}\\ &= \left( \frac{L_h}{L_0} - \frac{L_c}{L_0} \right) \left( 1 + \alpha(T_c)\left(T_c - T_0\right) \right)^{-1}. :label: expNewRelative -Using Equations :eq:`hotExp` and :eq:`nonRefExp`, we can simplify Equation :eq:`expNewRelative` to find, +Using Equations :eq:`hotExp` and :eq:`nonRefExp`, we can simplify Equation +:eq:`expNewRelative` to find, .. math:: \frac{L_h - L_c}{L_c} = \frac{\alpha(T_h) \left(T_h - T_0\right) - \alpha(T_c)\left(T_c - T_0\right)}{1 + \alpha(T_c)\left(T_c - T_0\right)}. :label: linearExpansionFactor -Equation :eq:`linearExpansionFactor` is the expression used by ARMI in :py:meth:`linearExpansionFactor `. +Equation :eq:`linearExpansionFactor` is the expression used by ARMI in +:py:meth:`linearExpansionFactor +`. .. note:: - :py:meth:`linearExpansionPercent ` returns :math:`\frac{L - L_0}{L_0}` in %. + :py:meth:`linearExpansionPercent + ` returns + :math:`\frac{L - L_0}{L_0}` in %. diff --git a/doc/user/spatial_block_parameters.rst b/doc/user/spatial_block_parameters.rst new file mode 100644 index 000000000..c17c9f741 --- /dev/null +++ b/doc/user/spatial_block_parameters.rst @@ -0,0 +1,329 @@ +************************ +Spatial block parameters +************************ + +Many parameters assigned on a ``Block`` are scalar quantities that are useful for visualization +and simple queries (e.g., block with the maximum burnup in an assembly). Spatial parameters in +a block, such as power produced by each pin, is also of interest. Especially when communicating +data to physics codes that support sub-block geometric modeling. This page will talk about +how spatial information is assigned to components on a block, how spatial data can be assigned +and accessed, and how those data may or may not be updated by the framework. + +Sub-block spatial grid +====================== + +There are two ways to create the block grid: explicitly via blueprints or via an automated builder. The former +is recommended, but the later can work in some specific circumstances. + +Blueprints +---------- + +In your blueprints file, you likely have a core grid that defines where assemblies reside in the reactor. Assemblies +are assigned to locations on that grid according to their ``specifcier`` blueprint attribute. Below is an example +of a "flats up" hexagonal core grid of fuel assemblies with 1/3 symmetry. + +.. code:: yaml + + grids: + core: + geom: hex + symmetry: third periodic + lattice map: | + F + F + F F + F + F F + +We can similarly define a grid for the block with a similar entry in the ``grids`` portion of the blueprints. + +.. code:: yaml + + pins: + geom: hex_corners_up + symmetry: full + 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 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 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 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 + 1 1 1 1 1 1 1 1 1 0 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 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 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 1 1 1 + 1 1 1 1 1 1 1 1 1 1 1 + 1 1 1 1 1 1 1 1 1 1 + +This creates a ten-ring hexagonal lattice in a "corners up" orientation. While the resulting geometry +may look like a flats up lattice, the individual hexagons that make up a lattice site are corners up. + +.. note:: + + The sub-block grid does not need to be of a different orientation of the parent block. A flats up + hex block can have a flats up pin lattice. In most cases, an assembly full of pins will have a pin + lattice that is off a different type to maximally load pins into the block. + +Say we wanted to have a guide tube at the center lattice site with cladding surrounding void and every other lattice +site to contain a fuel pin. We need to add the following items to our block definition to link the grid, and to +assign components to sites on the grid. + +1. The block needs a ``grid name`` entry that points to the grid we want to use for this block. +2. Each component that wants to be placed on a lattice site needs a ``latticeIDs`` entry that contains + the IDs, like assembly specifiers in the core grid, for that component. + +In the example above, we have two lattice IDs: ``0`` for the center site and ``1`` for the other pins. These +are chosen for brevity but we could have also done ``fuel`` and ``guide`` or ``F`` and ``G``. Do what makes sense +for you. + +.. note:: + + Like with assembly specifiers, keeping the lattice IDs to have the same number of characters will + help the grid render nicer in text editors. This is not a requirement, but it may make life easier + for you and your team. + +Our complete block definition would start like + +.. code:: yaml + + blocks: &block_fuel + grid name: pins + fuel: + shape: Circle + material: UO2 + Tinput: 20 + Thot: 20 + od: 0.819 + latticeIDs: [1] + clad: + shape: Circle + material: UO2 + Tinput: 20 + Thot: 20 + id: 0.819 + od: 0.9 + latticeIDs: [0, 1] + void: + shape: Circle + material: Void + Tinput: 20 + Thot: 20 + od: 0.819 + latticeIDs: [0] + +Note that we can assign the same component to multiple lattice sites with multiple entries in the ``latticeIDs`` list. +Also note that we do not need to assign a ``mult`` entry to these components. Their multiplicity will be determined +based on the number of lattice sites they occupy! + +.. seelso:: + + The :ref:`LWR tutorial ` contains additional examples for working with sub-block grids. + +Auto grid +--------- + +In some cases, you may have an assembly that contains one pin type. The framework provides a mechanism for automatically +constructing a spatial grid for the block based only on the multiplicity of pin-like components. When constructing +a block from blueprints, a grid may be added to the block depending on: + +1. The existence of an explicitly defined block grid, like in the previously discussed section, and +2. If the ``autoGenerateBlockGrids`` setting is active. + +Should either of these conditions be met, the framework will attempt to add a grid by calling +:meth:`armi.reactor.blocks.Block.autoCreateSpatialGrids`. However, this behavior is not generalized and only +implemented on :class:`armi.reactor.blocks.HexBlock`, which makes the following assumptions: + +1. You want a corners up hexagonal lattice grid. +2. The pitch of your hexagonal lattice is determined by :meth:`armi.reactor.blocks.HexBlock.getPinPitch` which + may place restrictions on what constitutes a pin. +3. The number of pins is determined by :meth:`armi.reactor.blocks.HexBlock.getNumPins` which may place similar + restrictions on what constitutes a pin. + +If the auto grid creation is successful, components with a multiplicity equal to the number of pins will be assigned +locations on the lattice grid. + +.. warning:: + + Consider subclassing :class:`~armi.reactor.blocks.HexBlock` with specific pin-like methods and + overriding the :meth:`~armi.reactor.blocks.HexBlock.autoCreateSpatialGrids` if you want complete control + over this process. Alternatively, use an explicit grid in blueprints. + + +Interacting with spatial data +============================= + +This section will focus on accessing locations of components in the block, locations of specifically +pins, and examples of some pin data that may be assigned to a block's parameter set. + +Component locations +------------------- + +Components that live on a spatial grid have a ``spatialLocator`` attribute to help indicate where that +component exists in space. If we grab the fuel component from the UO2 block in the +:ref:`ANL AFCI 177 example ` we can see where it exists in the block:: + + >>> import armi + >>> armi.configure() + >>> from armi.reactor.flags import Flags + >>> r = armi.init(fName="anl-afci-177.yaml").r + >>> fuelAssem = r.core[5] + >>> fuelBlock = fuelAssem[1] + >>> fuelBlock.spatialGrid + + Offset: [0. 0. 0.] + Num Locations: 400> + >>> fuel = fuelBlock.getChildrenWithFlags(Flags.FUEL)[0] + >>> fuel.getDimension("mult") + 271 + >>> fuel.spatialLocator + + +This :class:`~armi.reactor.grids.MultiIndexLocation` is a way to indicate this Component exists at multiple +sites. Each item in this locator is one location on the underlying grid where we could find this component:: + + >>> fuel.spatialLocator[0] + + >>> fuel.spatialLocator[0].getLocalCoordinates() + array([0., 0., 0.]) + >>> coordsFromFuel = fuel.spatialLocator.getLocalCoordinates() + >>> coordsFromFuel.shape + (271, 3) + +We get a ``(271, 3)`` array because we have 271 of these fuel components in the block, and each row contains one +(x, y, z) location for that component. We can do this for every component, though some may only exist at a single +site on the grid and be assigned a :class:`~armi.reactor.grids.CoordinateLocation` spatial locator instead. The API +is mostly the same, but attempts to signify such an object does not live on the grid e.g., duct or derived shape +objects:: + + >>> duct = fuelBlock.getChildrenWithFlags(Flags.DUCT)[0] + >>> duct.spatialLocator + + +Pin locations +------------- + +Everything in the before section works for finding center points of pins in your assembly. But often times +you have multiple components that may exist at the same lattice site (e.g., fuel, gap, clad, maybe a wire?). +Or you may have multiple cladded-things that count as pins and but exist in multiple components. In some +circumstances, :meth:`armi.reactor.blocks.HexBlock.getPinCoordinates` may be useful to find +the unique centroids of pins in a block. Using our example above, we get a very similar set of coordinates +when comparing to the coordinates of the fuel pin:: + + >>> coordsFromPin = fuel.spatialLocator.getLocalCoordinates() + >>> coordsFromBlock = fuelBlock.getPinCoordinates() + >>> (coordsFromPin == coordsFromBlock).all() + True + +In this specific case :meth:`~armi.reactor.blocks.HexBlock.getPinCoordinates` looks at +components with ``Flags.CLAD`` and obtains their locations, and we have one cladding component and it +exists at each of the 271 sites we care about. However, if you have multiple cladding components per lattice +site, such as in the :ref:`C5G7 example `, you may see an incorrect number of locations +returned. + +.. note:: + + Consider making application-specific subclasses of ``Block``, ``HexBlock``, and/or ``CartesianBlock`` + with more targeted implementations of :meth:`~armi.reactor.blocks.Block.getNumPins`, + :meth:`~armi.reactor.blocks.Block.getPinPitch`, :meth:`~armi.reactor.blocks.Blocks.getPinLocations` + and other pin-specific methods. + + +Pin parameter data +------------------ + +The ARMI framework defines a few parameters that live on the block, but define data for each of +the child pin components. Two examples are ``Block.p.linPowByPin`` and ``Block.p.pinMgFluxes``. These +parameters are structured and related to the output of ``getPinCoordinates`` such that + +1. Pin ``i`` can be found at ``Block.getPinCoordinates()[i]``. +2. Parameter data for pin ``i`` can be found at location ``i`` in the parameter array, e.g., + ``Block.p.linPowByPin[i]``. + +Parameters like ``Block.p.pinMgFluxes`` may be higher dimensional, storing mutli-group flux for each pin. +In this case, the parameter data array has shape ``(nPins, nGroups)`` such that ``Block.p.pinMgFluxes[i, g]`` +has the group ``g`` flux in pin ``i``, found at ``Block.getPinCoordinates()[i]``. + +Block rotation +============== + +.. warning:: + + Rotation is currently only supported for hexagonal blocks + +Using the logic from the previous section on pin parameter data, it may be useful to know how +rotating a block changes the data stored on that block. + +Spatial locators +---------------- + +First, rotating a block will update the ``spatialLocator`` attribute on every child of +the block. For objects defined at the center of the block, they will still be located at the center. +Objects with a ``MultiIndexLocator`` will have new locations such that ``spatialLocator[i]`` will +be consistent before and after rotation:: + + >>> import math + # zeroth location is the origin so pick a location that changes through rotation + >>>fuel.spatialLocator[1] + >>> + >>> fuel.spatialLocator[1].getLocalCoordinates() + array([0.4444 , 0.76972338, 0. ])) + >>> fuelBlock.rotate(math.radians(60)) + >>> fuel.spatialLocator[1] + + >>> fuel.spatialLocator[1].getLocalCoordinates() + array([-0.4444 , 0.76972338, 0. ]) + +Because this sub-block grid is a corners up hex grid, to tightly fit inside the flats up hex block, +one rotation from the north east location, ``(1,0,0)``, reflects this pin across the y-axis. + +Pin parameters +-------------- + +Parameter data that are defined on children of the block are not updated. Therefore data for +pin ``i`` will be found in e.g., ``Block.p.pinMgFluxes[i]`` before and after rotation. + +Corners and edges +----------------- + +Parameters defined on the edges and corners of the block, i.e., those with :attr:`armi.reactor.parameters.ParamLocation.CORNERS` +and :attr:`~armi.reactor.parameters.ParamLocation.EDGES` will be shuffled in place to reflect the new +rotation. For hexagonal blocks, these parameters should have six entries, e.g., one value for each corner, starting +at the upper right and moving counter clockwise. Let's assign some fake data to our fuel block from above +and see what happens:: + + >>> import numpy as np + >>> fuelBlock.p.cornerFastFlux = np.arange(6, dtype=float) + >>> fuelBlock.p.cornerFastFlux + array([0., 1., 2., 3., 4., 5.]) + # Two clockwise rotations of 60 degrees + >>> fuelBlock.rotate(math.radians(-120)) + >>> fuelBlock.p.cornerFastFlux + array([2., 3., 4., 5., 0., 1.]) + +Visually, the upper right corner, number ``0``, has been rotated to the lower right corner, number ``4``. +And the corner ``2``, the leftmost corner, has been moved to corner ``0``, the upper right corner. + +Other rotated parameters +------------------------ + +Other parameters may be updated to reflect some geometric state. The second position of +``Block.p.orientation`` reflects the cumulative rotation around the z-axis and is updated through +rotation. Displacement parameters like ``Block.p.displacementX`` are updated as the displacement vector +rotates through space. diff --git a/doc/user/user_install.rst b/doc/user/user_install.rst index e45a45d15..b6ace5da5 100644 --- a/doc/user/user_install.rst +++ b/doc/user/user_install.rst @@ -1,3 +1,5 @@ +.. Note that this is currently within the user dir but rendered at the top level. + ************ Installation ************