diff --git a/armi/bookkeeping/db/tests/test_comparedb3.py b/armi/bookkeeping/db/tests/test_comparedb3.py index 3d56ee022..4eb5531d8 100644 --- a/armi/bookkeeping/db/tests/test_comparedb3.py +++ b/armi/bookkeeping/db/tests/test_comparedb3.py @@ -181,7 +181,7 @@ def test_compareDatabaseSim(self): dbs[1]._fullPath, timestepCompare=[(0, 0), (0, 1)], ) - self.assertEqual(len(diffs.diffs), 480) + self.assertEqual(len(diffs.diffs), 504) # Cycle length is only diff (x3) self.assertEqual(diffs.nDiffs(), 3) diff --git a/armi/bookkeeping/historyTracker.py b/armi/bookkeeping/historyTracker.py index 884cb0c0d..4f5a6e752 100644 --- a/armi/bookkeeping/historyTracker.py +++ b/armi/bookkeeping/historyTracker.py @@ -67,7 +67,7 @@ def getHistoryParams(self): See :ref:`detail-assems`. """ -from typing import Tuple +from typing import TYPE_CHECKING import traceback from armi import interfaces @@ -79,6 +79,10 @@ def getHistoryParams(self): ORDER = 2 * interfaces.STACK_ORDER.BEFORE + interfaces.STACK_ORDER.BOOKKEEPING +if TYPE_CHECKING: + from armi.reactor.blocks import Block + from armi.reactor.assemblies import Assembly + def describeInterfaces(cs): """Function for exposing interface(s) to other code.""" @@ -120,6 +124,8 @@ class HistoryTrackerInterface(interfaces.Interface): name = "history" + DETAILED_ASSEMBLY_FLAGS = [Flags.FUEL, Flags.CONTROL] + def __init__(self, r, cs): """ HistoryTracker that uses the database to look up parameter history rather than @@ -146,7 +152,7 @@ def interactBOC(self, cycle=None): """Look for any new assemblies that are asked for and add them to tracking.""" self.addDetailAssemsByAssemNums() if self.cs["detailAllAssems"]: - self.addAllFuelAssems() + self.addAllDetailedAssems() def interactEOL(self): """Generate the history reports.""" @@ -171,16 +177,16 @@ def addDetailAssembliesBOL(self): self.addDetailAssembly(a) if self.cs["detailAllAssems"]: - self.addAllFuelAssems() + self.addAllDetailedAssems() # This also gets called at BOC but we still # do it here for operators that do not call BOC. self.addDetailAssemsByAssemNums() - def addAllFuelAssems(self): - """Add all fuel assems as detail assems.""" + def addAllDetailedAssems(self): + """Add all assems who have the DETAILED_ASSEMBLY_FLAGS as detail assems.""" for a in self.r.core: - if a.hasFlags(Flags.FUEL): + if a.hasFlags(self.DETAILED_ASSEMBLY_FLAGS): self.addDetailAssembly(a) def addDetailAssemsByAssemNums(self): @@ -236,13 +242,13 @@ def getTrackedParams(self): trackedParams.add(newParam) return sorted(trackedParams) - def addDetailAssembly(self, a): + def addDetailAssembly(self, a: "Assembly"): """Track the name of assemblies that are flagged for detailed treatment.""" aName = a.getName() if aName not in self.detailAssemblyNames: self.detailAssemblyNames.append(aName) - def getDetailAssemblies(self): + def getDetailAssemblies(self) -> list["Assembly"]: """Returns the assemblies that have been signaled as detail assemblies.""" assems = [] if not self.detailAssemblyNames: @@ -259,7 +265,7 @@ def getDetailAssemblies(self): ) return assems - def getDetailBlocks(self): + def getDetailBlocks(self) -> list["Block"]: """Get all blocks in all detail assemblies.""" return [block for a in self.getDetailAssemblies() for block in a] @@ -281,7 +287,7 @@ def filterTimeIndices(self, timeIndices, boc=False, moc=False, eoc=False): return filtered - def writeAssemHistory(self, a, fName=""): + def writeAssemHistory(self, a: "Assembly", fName: str = ""): """Write the assembly history report to a text file.""" fName = fName or self._getAssemHistoryFileName(a) dbi = self.getInterface("database") @@ -373,7 +379,7 @@ def unloadBlockHistoryVals(self): """Remove all cached db reads.""" self._preloadedBlockHistory = None - def getBlockHistoryVal(self, name: str, paramName: str, ts: Tuple[int, int]): + def getBlockHistoryVal(self, name: str, paramName: str, ts: tuple[int, int]): """ Use the database interface to return the parameter values for the supplied block names, and timesteps. @@ -422,28 +428,28 @@ def getBlockHistoryVal(self, name: str, paramName: str, ts: Tuple[int, int]): raise return val - def _isCurrentTimeStep(self, ts: Tuple[int, int]): + def _isCurrentTimeStep(self, ts: tuple[int, int]) -> bool: """Return True if the timestep requested is the current time step.""" return ts == (self.r.p.cycle, self.r.p.timeNode) - def _databaseHasDataForTimeStep(self, ts): + def _databaseHasDataForTimeStep(self, ts) -> bool: """Return True if the database has data for the requested time step.""" dbi = self.getInterface("database") return ts in dbi.database.genTimeSteps() - def getTimeSteps(self, a=None): - r""" - Return list of time steps values (in years) that are available. + def getTimeSteps(self, a: "Assembly" = None) -> list[float]: + """ + Given a fuel assembly, return list of time steps values (in years) that are available. Parameters ---------- - a : Assembly object, optional - An assembly object designated a detail assem. If passed, only timesteps + a + A fuel assembly that has been designated a detail assem. If passed, only timesteps where this assembly is in the core will be tracked. Returns ------- - timeSteps : list + timeSteps times in years that are available in the history See Also @@ -462,15 +468,13 @@ def getTimeSteps(self, a=None): return timeInYears @staticmethod - def _getBlockInAssembly(a): - """Get a representative fuel block from an assembly.""" + def _getBlockInAssembly(a: "Assembly") -> "Block": + """Get a representative fuel block from a fuel assembly.""" b = a.getFirstBlock(Flags.FUEL) if not b: - # there is a problem, it doesn't look like we have a fueled assembly - # but that is all we track... what is it? Throw an error - runLog.warning("Assembly {} does not contain fuel".format(a)) + runLog.error("Assembly {} does not contain fuel".format(a)) for b in a: - runLog.warning("Block {}".format(b)) + runLog.error("Block {}".format(b)) raise RuntimeError( "A tracked assembly does not contain fuel and has caused this error, see the details in stdout." ) diff --git a/armi/bookkeeping/tests/test_historyTracker.py b/armi/bookkeeping/tests/test_historyTracker.py index 3a18f7ac3..0b615a37d 100644 --- a/armi/bookkeeping/tests/test_historyTracker.py +++ b/armi/bookkeeping/tests/test_historyTracker.py @@ -231,8 +231,8 @@ def test_historyReport(self): # test that detailAssemblyNames() is working self.assertEqual(len(history.detailAssemblyNames), 1) - history.addAllFuelAssems() - self.assertEqual(len(history.detailAssemblyNames), 51) + history.addAllDetailedAssems() + self.assertEqual(len(history.detailAssemblyNames), 54) def test_getBlockInAssembly(self): history = self.o.getInterface("history") diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index c750717a1..bbbdec230 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -37,7 +37,6 @@ def test_buReducingAssemblyRotation(self): # apply dummy pin-level data to allow intelligent rotation for b in assem.getBlocks(Flags.FUEL): - b.breakFuelComponentsIntoIndividuals() b.initializePinLocations() b.p.percentBuMaxPinLocation = 10 b.p.percentBuMax = 5 diff --git a/armi/reactor/blockParameters.py b/armi/reactor/blockParameters.py index 820ebac9f..ab65301a0 100644 --- a/armi/reactor/blockParameters.py +++ b/armi/reactor/blockParameters.py @@ -151,13 +151,6 @@ def getBlockParameterDefinitions(): location=ParamLocation.MAX, ) - pb.defParam( - "percentBuMin", - units=units.PERCENT_FIMA, - description="Minimum percentage of the initial heavy metal atoms that have been fissioned", - location=ParamLocation.MAX, - ) - pb.defParam( "residence", units=units.DAYS, diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index f19b0381f..a3bf12eae 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -29,7 +29,6 @@ from armi import nuclideBases from armi import runLog from armi.bookkeeping import report -from armi.nucDirectory import elements from armi.nuclearDataIO import xsCollections from armi.physics.neutronics import GAMMA from armi.physics.neutronics import NEUTRON @@ -794,11 +793,22 @@ def completeInitialLoading(self, bolBlock=None): massHmBOL = 0.0 sf = self.getSymmetryFactor() for child in self: + # multiplying by sf ends up cancelling out the symmetry factor used in + # Component.getMass(). So massHmBOL does not respect the symmetry factor. hmMass = child.getHMMass() * sf massHmBOL += hmMass - # Components have a massHmBOL parameter but not every composite will + # Components have the following parameters but not every composite will + # massHmBOL, molesHmBOL, puFrac if isinstance(child, components.Component): child.p.massHmBOL = hmMass + # to stay consistent with massHmBOL, molesHmBOL and puFrac should be + # independent of sf. As such, the need to be scaled by 1/sf. + child.p.molesHmBOL = child.getHMMoles() / sf + child.p.puFrac = ( + self.getPuMoles() / sf / child.p.molesHmBOL + if child.p.molesHmBOL > 0.0 + else 0.0 + ) self.p.massHmBOL = massHmBOL @@ -1432,63 +1442,6 @@ def updateComponentDims(self): except NotImplementedError: runLog.warning("{0} has no updatedDims method -- skipping".format(c)) - def breakFuelComponentsIntoIndividuals(self): - """ - Split block-level components (in fuel blocks) into pin-level components. - - The fuel component will be broken up according to its multiplicity. - - Order matters! The first pin component will be located at a particular (x, y), which - will be used in the fluxRecon module to determine the interpolated flux. - - The fuel will become fuel001 through fuel169 if there are 169 pins. - """ - fuels = self.getChildrenWithFlags(Flags.FUEL) - if len(fuels) != 1: - runLog.error( - "This block contains {0} fuel components: {1}".format(len(fuels), fuels) - ) - raise RuntimeError( - "Cannot break {0} into multiple fuel components b/c there is not a single fuel" - " component.".format(self) - ) - - fuel = fuels[0] - fuelFlags = fuel.p.flags - nPins = self.getNumPins() - runLog.info( - "Creating {} individual {} components on {}".format(nPins, fuel, self) - ) - - # Handle all other components that may be linked to the fuel multiplicity - # by unlinking them and setting them directly. - # TODO: What about other (actual) dimensions? This is a limitation in that only fuel - # components are duplicated, and not the entire pin. It is also a reasonable assumption with - # current/historical usage of ARMI. - for comp, dim in self.getComponentsThatAreLinkedTo(fuel, "mult"): - comp.setDimension(dim, nPins) - - # finish the first pin as a single pin - fuel.setDimension("mult", 1) - fuel.setName("fuel001") - fuel.p.pinNum = 1 - - # create all the new pin components and add them to the block with 'fuel001' names - for i in range(nPins - 1): - # wow, only use of a non-deepcopy - newC = copy.copy(fuel) - newC.setName("fuel{0:03d}".format(i + 2)) # start with 002. - newC.p.pinNum = i + 2 - self.add(newC) - - # update moles at BOL for each pin - self.p.molesHmBOLByPin = [] - for pin in self.iterComponents(Flags.FUEL): - # Update the fuel component flags to be the same as before the split (i.e., DEPLETABLE) - pin.p.flags = fuelFlags - self.p.molesHmBOLByPin.append(pin.getHMMoles()) - pin.p.massHmBOL /= nPins - def getIntegratedMgFlux(self, adjoint=False, gamma=False): """ Return the volume integrated multigroup neutron tracklength in [n-cm/s]. @@ -1739,18 +1692,6 @@ def getBoronMassEnrich(self): return 0.0 return b10 / total - def getPuMoles(self): - """Returns total number of moles of Pu isotopes.""" - nucNames = [nuc.name for nuc in elements.byZ[94].nuclides] - puN = sum(self.getNuclideNumberDensities(nucNames)) - - return ( - puN - / units.MOLES_PER_CC_TO_ATOMS_PER_BARN_CM - * self.getVolume() - * self.getSymmetryFactor() - ) - def getUraniumMassEnrich(self): """Returns U-235 mass fraction assuming U-235 and U-238 only.""" u5 = self.getMass("U235") diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py index e00af7860..5a4da2f3b 100644 --- a/armi/reactor/components/component.py +++ b/armi/reactor/components/component.py @@ -21,6 +21,7 @@ import re import numpy as np +from typing import Optional from armi import materials from armi import runLog @@ -31,6 +32,7 @@ from armi.nucDirectory import nuclideBases from armi.reactor import composites from armi.reactor import flags +from armi.reactor import grids from armi.reactor import parameters from armi.reactor.components import componentParameters from armi.utils import densityTools @@ -1262,6 +1264,71 @@ def getIntegratedMgFlux(self, adjoint=False, gamma=False): return pinFluxes[self.p.pinNum - 1] * self.getVolume() + def getPinMgFluxes( + self, adjoint: Optional[bool] = False, gamma: Optional[bool] = False + ) -> np.ndarray: + """Retrieves the pin multigroup fluxes for the component. + + Parameters + ---------- + adjoint : bool, optional + Return adjoint flux instead of real + gamma : bool, optional + Whether to return the neutron flux or the gamma flux. + + Returns + ------- + np.ndarray + A ``(N, nGroup)`` array of pin multigroup fluxes, where ``N`` is the + equivalent to the multiplicity of the component (``self.p.mult``) + and ``nGroup`` is the number of energy groups of the flux. + + Raises + ------ + ValueError + If the location(s) of the component are not aligned with pin indices + from the block. This would happen if this component is not actually + a pin. + """ + # Get the (i, j, k) location of all pins from the parent block + indicesAll = { + (loc.i, loc.j): i for i, loc in enumerate(self.parent.getPinLocations()) + } + + # Retrieve the indices of this component + if isinstance(self.spatialLocator, grids.MultiIndexLocation): + indices = [(loc.i, loc.j) for loc in self.spatialLocator] + else: + indices = [(self.spatialLocator.i, self.spatialLocator.j)] + + # Map this component's indices to block's pin indices + indexMap = list(map(indicesAll.get, indices)) + if None in indexMap: + msg = f"Failed to retrieve pin indices for component {self}." + runLog.error(msg) + raise ValueError(msg) + + # Get the parameter name we are trying to retrieve + if gamma: + if adjoint: + raise ValueError("Adjoint gamma flux is currently unsupported.") + else: + param = "pinMgFluxesGamma" + else: + if adjoint: + param = "pinMgFluxesAdj" + else: + param = "pinMgFluxes" + + # Return pin fluxes + try: + return self.parent.p[param][indexMap] + except Exception as ee: + msg = f"Failure getting {param} from {self} via parent {self.parent}" + runLog.error(msg) + runLog.error(ee) + raise ValueError(msg) from ee + def density(self) -> float: """Returns the mass density of the object in g/cc.""" density = composites.Composite.density(self) diff --git a/armi/reactor/components/componentParameters.py b/armi/reactor/components/componentParameters.py index 92bde7dcc..548ec7940 100644 --- a/armi/reactor/components/componentParameters.py +++ b/armi/reactor/components/componentParameters.py @@ -15,7 +15,7 @@ """Component parameter definitions.""" from armi.reactor import parameters from armi.reactor.parameters import ParamLocation -from armi.reactor.parameters.parameterDefinitions import isNumpyArray +from armi.reactor.parameters.parameterDefinitions import isNumpyArray, isNumpyF32Array from armi.utils import units @@ -78,6 +78,17 @@ def getComponentParameterDefinitions(): default=None, ) + pb.defParam( + "pinNDens", + setter=isNumpyF32Array("pinNDens"), + units=f"atoms/(bn*{units.CM})", + description="Pin-wise number densities of each nuclide.", + location=ParamLocation.AVERAGE, + saveToDB=True, + categories=["depletion", parameters.Category.pinQuantities], + default=None, + ) + pb.defParam( "percentBu", units=f"{units.PERCENT_FIMA}", @@ -85,6 +96,14 @@ def getComponentParameterDefinitions(): default=0.0, ) + pb.defParam( + "pinPercentBu", + setter=isNumpyArray("pinPercentBu"), + units=units.PERCENT_FIMA, + description="Pin-wise burnup as a percentage of initial (heavy) metal atoms.", + default=None, + ) + pb.defParam( "buRate", units=f"{units.PERCENT_FIMA}/{units.DAYS}", @@ -153,6 +172,21 @@ def _assignTDFrac(self, val): default=1, setter=_assignTDFrac, ) + + pb.defParam( + "molesHmBOL", + units=units.MOLES, + default=0.0, + description="Total number of moles of heavy metal at BOL.", + ) + + pb.defParam( + "puFrac", + default=0.0, + units=units.UNITLESS, + description="Current average Pu fraction. Calculated as the ratio of Pu mass to total HM mass.", + ) + return pDefs diff --git a/armi/reactor/composites.py b/armi/reactor/composites.py index 7f09d8656..f61ad5580 100644 --- a/armi/reactor/composites.py +++ b/armi/reactor/composites.py @@ -1578,6 +1578,9 @@ def changeNDensByFactor(self, factor): # Update detailedNDens if self.p.detailedNDens is not None: self.p.detailedNDens *= factor + # Update pinNDens + if self.p.pinNDens is not None: + self.p.pinNDens *= factor def clearNumberDensities(self): """ @@ -3121,6 +3124,18 @@ def getBoundingCircleOuterDiameter(self, Tc=None, cold=False): getter = operator.methodcaller("getBoundingCircleOuterDiameter", Tc, cold) return sum(map(getter, self)) + def getPuMoles(self): + """Returns total number of moles of Pu isotopes.""" + nucNames = [nuc.name for nuc in elements.byZ[94].nuclides] + puN = sum(self.getNuclideNumberDensities(nucNames)) + + return ( + puN + / units.MOLES_PER_CC_TO_ATOMS_PER_BARN_CM + * self.getVolume() + * self.getSymmetryFactor() + ) + class StateRetainer: """ diff --git a/armi/reactor/parameters/parameterDefinitions.py b/armi/reactor/parameters/parameterDefinitions.py index 17c024e7d..4063a2cf3 100644 --- a/armi/reactor/parameters/parameterDefinitions.py +++ b/armi/reactor/parameters/parameterDefinitions.py @@ -224,6 +224,31 @@ def setParameter(selfObj, value): return setParameter +def isNumpyF32Array(paramStr: str): + """Helper meta-function to create a method that sets a Parameter value to a 32 bit float NumPy array. + + Parameters + ---------- + paramStr + Name of the Parameter we want to set. + + Returns + ------- + function + A setter method on the Parameter class to force the value to be a 32 bit NumPy array. + """ + + def setParameter(selfObj, value): + if value is None: + # allow default of None to exist + setattr(selfObj, "_p_" + paramStr, value) + else: + # force to 32 bit + setattr(selfObj, "_p_" + paramStr, np.array(value, dtype=np.float32)) + + return setParameter + + @functools.total_ordering class Parameter: """Metadata about a specific parameter.""" diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py index 7dc23ab3a..8205bf1d7 100644 --- a/armi/reactor/tests/test_blocks.py +++ b/armi/reactor/tests/test_blocks.py @@ -40,7 +40,7 @@ from armi.reactor.tests.test_reactors import loadTestReactor from armi.reactor.tests.test_reactors import TEST_ROOT from armi.tests import ISOAA_PATH -from armi.utils import hexagon, units +from armi.utils import hexagon, units, densityTools from armi.utils.directoryChangers import TemporaryDirectoryChanger from armi.utils.units import MOLES_PER_CC_TO_ATOMS_PER_BARN_CM from armi.utils.units import ASCII_LETTER_A, ASCII_LETTER_Z, ASCII_LETTER_a @@ -1199,7 +1199,19 @@ def test_adjustDensity(self): self.assertAlmostEqual(mass2 - mass1, massDiff) - def test_completeInitialLoading(self): + @patch.object(blocks.HexBlock, "getSymmetryFactor") + def test_completeInitialLoading(self, mock_sf): + """Ensure that some BOL block and component params are populated properly. + + Notes + ----- + - When checking component-level BOL params, puFrac is skipped due to 1) there's no Pu in the block, and 2) + getPuMoles is functionally identical to getHMMoles (just limits nuclides from heavy metal to just Pu). + - getSymmetryFactor is mocked to return 3. This indicates that the block is in the center-most assembly. + Providing this mock ensures that symmetry factors are tested as well (otherwise it's just a factor of 1 + and it is a less robust test). + """ + mock_sf.return_value = 3 area = self.block.getArea() height = 2.0 self.block.setHeight(height) @@ -1216,10 +1228,43 @@ def test_completeInitialLoading(self): self.block.completeInitialLoading() + sf = self.block.getSymmetryFactor() cur = self.block.p.molesHmBOL - ref = self.block.getHMDens() / MOLES_PER_CC_TO_ATOMS_PER_BARN_CM * height * area - places = 6 - self.assertAlmostEqual(cur, ref, places=places) + ref = ( + self.block.getHMDens() + / MOLES_PER_CC_TO_ATOMS_PER_BARN_CM + * height + * area + * sf + ) + self.assertAlmostEqual(cur, ref, places=12) + + totalHMMass = 0.0 + for c in self.block: + nucs = c.getNuclides() + hmNucs = [nuc for nuc in nucs if nucDir.isHeavyMetal(nuc)] + hmNDens = {hmNuc: c.getNumberDensity(hmNuc) for hmNuc in hmNucs} + hmMass = densityTools.calculateMassDensity(hmNDens) * c.getVolume() + totalHMMass += hmMass + if hmMass: + # hmMass does not need to account for sf since what's calculated in blocks.completeInitialLoading + # ends up cancelling out sf + self.assertAlmostEqual(c.p.massHmBOL, hmMass, places=12) + # since sf is cancelled out in massHmBOL, there needs to be a factor 1/sf here to cancel out the + # factor of sf in getHMMoles. + self.assertAlmostEqual( + c.p.molesHmBOL, + sum(ndens for ndens in hmNDens.values()) + / units.MOLES_PER_CC_TO_ATOMS_PER_BARN_CM + * c.getVolume() + / sf, + places=12, + ) + else: + self.assertEqual(c.p.massHmBOL, 0.0) + self.assertEqual(c.p.molesHmBOL, 0.0) + + self.assertAlmostEqual(self.block.p.massHmBOL, totalHMMass) def test_add(self): numComps = len(self.block.getComponents()) @@ -1767,14 +1812,6 @@ def _testDimensionsAreLinked(self): self.block.getComponent(Flags.INTERCOOLANT).getDimension("ip"), ) - def test_breakFuelComponentsIntoIndividuals(self): - fuel = self.block.getComponent(Flags.FUEL) - mult = fuel.getDimension("mult") - self.assertGreater(mult, 1.0) - self.block.completeInitialLoading() - self.block.breakFuelComponentsIntoIndividuals() - self.assertEqual(fuel.getDimension("mult"), 1.0) - def test_pinMgFluxes(self): """ Test setting/getting of pin-wise fluxes. diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py index 7cad72c91..7ce082a54 100644 --- a/armi/reactor/tests/test_components.py +++ b/armi/reactor/tests/test_components.py @@ -17,34 +17,38 @@ import math import unittest +import numpy as np +from numpy.testing import assert_equal + from armi.materials import air, alloy200 from armi.materials.material import Material from armi.reactor import components from armi.reactor import flags +from armi.reactor.blocks import Block from armi.reactor.components import ( - Component, - UnshapedComponent, - NullComponent, Circle, + Component, + ComponentType, + Cube, + DerivedShape, + DifferentialRadialSegment, + Helix, Hexagon, - HoledHexagon, HexHoledCircle, + HoledHexagon, HoledRectangle, HoledSquare, - Helix, - Sphere, - Cube, + NullComponent, + RadialSegment, Rectangle, SolidRectangle, + Sphere, Square, Triangle, - RadialSegment, - DifferentialRadialSegment, - DerivedShape, + UnshapedComponent, UnshapedVolumetricComponent, - ComponentType, + materials, ) -from armi.reactor.components import materials from armi.reactor.tests.test_reactors import loadTestReactor @@ -698,10 +702,12 @@ def test_changeNumberDensities(self): """Test that demonstates that the number densities on a component can be modified.""" self.component.p.numberDensities = {"NA23": 1.0} self.component.p.detailedNDens = [1.0] + self.component.p.pinNDens = [1.0] self.assertEqual(self.component.getNumberDensity("NA23"), 1.0) self.component.changeNDensByFactor(3.0) self.assertEqual(self.component.getNumberDensity("NA23"), 3.0) self.assertEqual(self.component.p.detailedNDens[0], 3.0) + self.assertEqual(self.component.p.pinNDens[0], 3.0) def test_fuelMass(self): nominalMass = self.component.getMass() @@ -1814,3 +1820,61 @@ def test_finalizeLoadDBAdjustsTD(self): comp.p.theoreticalDensityFrac = tdFrac comp.finalizeLoadingFromDB() self.assertEqual(comp.material.getTD(), tdFrac) + + +class TestPinQuantities(unittest.TestCase): + """Test methods that involve retrieval of pin quantities.""" + + def setUp(self): + self.r = loadTestReactor( + inputFileName="smallestTestReactor/armiRunSmallest.yaml" + )[1] + + def test_getPinMgFluxes(self): + """Test proper retrieval of pin multigroup flux for fuel component.""" + # Get a fuel block and its fuel component from the core + fuelBlock: Block = self.r.core.getFirstBlock(flags.Flags.FUEL) + fuelComponent: Component = fuelBlock.getComponent(flags.Flags.FUEL) + numPins = int(fuelComponent.p.mult) + self.assertEqual(numPins, 169) + + # Set pin fluxes at block level + fuelBlock.initializePinLocations() + pinMgFluxes = np.random.rand(numPins, 33) + pinMgFluxesAdj = np.random.rand(numPins, 33) + pinMgFluxesGamma = np.random.rand(numPins, 33) + fuelBlock.setPinMgFluxes(pinMgFluxes) + fuelBlock.setPinMgFluxes(pinMgFluxesAdj, adjoint=True) + fuelBlock.setPinMgFluxes(pinMgFluxesGamma, gamma=True) + + # Retrieve from component to ensure they match + simPinMgFluxes = fuelComponent.getPinMgFluxes() + simPinMgFluxesAdj = fuelComponent.getPinMgFluxes(adjoint=True) + simPinMgFluxesGamma = fuelComponent.getPinMgFluxes(gamma=True) + assert_equal(pinMgFluxes, simPinMgFluxes) + assert_equal(pinMgFluxesAdj, simPinMgFluxesAdj) + assert_equal(pinMgFluxesGamma, simPinMgFluxesGamma) + + # Mock the spatial locator of the component to raise error + with unittest.mock.patch.object(fuelComponent, "spatialLocator") as mockLocator: + mockLocator.i = 111 + mockLocator.j = 111 + with self.assertRaisesRegex( + ValueError, + f"Failed to retrieve pin indices for component {fuelComponent}", + ): + fuelComponent.getPinMgFluxes() + + # Check assertion for adjoint gamma flux + with self.assertRaisesRegex( + ValueError, "Adjoint gamma flux is currently unsupported." + ): + fuelComponent.getPinMgFluxes(adjoint=True, gamma=True) + + # Check assertion for not-found parameter + fuelBlock.p.pinMgFluxes = None + with self.assertRaisesRegex( + ValueError, + f"Failure getting pinMgFluxes from {fuelComponent} via parent {fuelBlock}", + ): + fuelComponent.getPinMgFluxes() diff --git a/doc/release/0.5.rst b/doc/release/0.5.rst index 6ebbc39ca..8ae4f1a95 100644 --- a/doc/release/0.5.rst +++ b/doc/release/0.5.rst @@ -51,6 +51,7 @@ New Features #. Adding new options for simplifying 1D cross section modeling. (`PR#1949 `_) #. Adding ``--skip-inspection`` flag to ``CompareCases`` CLI. (`PR#1842 `_) #. Exposing ``detailedNDens`` to components. (`PR#1954 `_) +#. Adding a method ``getPinMgFluxes`` to get pin-wise multigroup fluxes from a Block. (`PR#1990 `_) API Changes ----------- @@ -75,6 +76,9 @@ API Changes #. Removing method ``SkippingXsGen_BuChangedLessThanTolerance``. (`PR#1845 `_) #. Removing setting ``autoGenerateBlockGrids``. (`PR#1947 `_) #. Removing setting ``mpiTasksPerNode`` and renaming ``numProcessors`` to ``nTasks``. (`PR#1958 `_) +#. History Tracker: "detail assemblies" are now fuel and control assemblies. (`PR#1990 `_) +#. Removing ``Block.breakFuelComponentsIntoIndividuals()``. (`PR#1990 `_) +#. Moving ``getPuMoles`` from blocks.py up to composites.py. (`PR#1990 `_) Bug Fixes ---------