From 5764e4321edf2c25d6d7cf4b82d044f625a9951e Mon Sep 17 00:00:00 2001 From: aalberti Date: Wed, 13 Sep 2023 13:29:36 -0700 Subject: [PATCH 01/25] mv axial expansion files into their own dir --- .../converters/{ => axialExpansion}/axialExpansionChanger.py | 0 .../{ => axialExpansion}/tests/test_axialExpansionChanger.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename armi/reactor/converters/{ => axialExpansion}/axialExpansionChanger.py (100%) rename armi/reactor/converters/{ => axialExpansion}/tests/test_axialExpansionChanger.py (100%) diff --git a/armi/reactor/converters/axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py similarity index 100% rename from armi/reactor/converters/axialExpansionChanger.py rename to armi/reactor/converters/axialExpansion/axialExpansionChanger.py diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py similarity index 100% rename from armi/reactor/converters/tests/test_axialExpansionChanger.py rename to armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py From 2874172cd4528830ed452288ba7493701705973c Mon Sep 17 00:00:00 2001 From: aalberti Date: Wed, 13 Sep 2023 14:16:01 -0700 Subject: [PATCH 02/25] add __init__ files, split off AssemblyAxialLinkage + ExpansionData classes into their own files, and move a few methods onto __init__.py. --- armi/reactor/blueprints/__init__.py | 7 +- .../converters/axialExpansion/__init__.py | 55 ++ .../axialExpansion/assemblyAxialLinkage.py | 216 ++++++++ .../axialExpansion/axialExpansionChanger.py | 510 +----------------- .../axialExpansion/expansionData.py | 288 ++++++++++ .../axialExpansion/tests/__init__.py | 13 + 6 files changed, 586 insertions(+), 503 deletions(-) create mode 100644 armi/reactor/converters/axialExpansion/__init__.py create mode 100644 armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py create mode 100644 armi/reactor/converters/axialExpansion/expansionData.py create mode 100644 armi/reactor/converters/axialExpansion/tests/__init__.py diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index 00930d788..8a31aadc7 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -107,7 +107,8 @@ from armi.reactor.blueprints.componentBlueprint import ComponentKeyedList from armi.reactor.blueprints.gridBlueprint import Grids, Triplet from armi.reactor.blueprints.reactorBlueprint import Systems, SystemBlueprint -from armi.reactor.converters import axialExpansionChanger +from armi.reactor.converters.axialExpansion.axialExpansionChanger import expandColdDimsToHot +from armi.reactor.converters.axialExpansion import makeAssemsAbleToSnapToUniformMesh context.BLUEPRINTS_IMPORTED = True context.BLUEPRINTS_IMPORT_CONTEXT = "".join(traceback.format_stack()) @@ -317,7 +318,7 @@ def _prepConstruction(self, cs): # this is required to set up assemblies so they know how to snap # to the reference mesh. They wont know the mesh to conform to # otherwise.... - axialExpansionChanger.makeAssemsAbleToSnapToUniformMesh( + makeAssemsAbleToSnapToUniformMesh( self.assemblies.values(), cs[CONF_NON_UNIFORM_ASSEM_FLAGS] ) @@ -336,7 +337,7 @@ def _prepConstruction(self, cs): for a in list(self.assemblies.values()) if not any(a.hasFlags(f) for f in assemsToSkip) ) - axialExpansionChanger.expandColdDimsToHot( + expandColdDimsToHot( assemsToExpand, cs[CONF_DETAILED_AXIAL_EXPANSION], ) diff --git a/armi/reactor/converters/axialExpansion/__init__.py b/armi/reactor/converters/axialExpansion/__init__.py new file mode 100644 index 000000000..2a6d82dbb --- /dev/null +++ b/armi/reactor/converters/axialExpansion/__init__.py @@ -0,0 +1,55 @@ +# Copyright 2023 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. +"""Particles! Expand axially!.""" + +from armi.materials import material +from armi.reactor.flags import Flags + + +def getSolidComponents(b): + """ + Return list of components in the block that have solid material. + + Notes + ----- + Axial expansion only needs to be applied to solid materials. We should not update + number densities on fluid materials to account for changes in block height. + """ + return [c for c in b if not isinstance(c.material, material.Fluid)] + + +def _getDefaultReferenceAssem(assems): + """Return a default reference assembly.""" + # if assemblies are defined in blueprints, handle meshing + # assume finest mesh is reference + assemsByNumBlocks = sorted( + assems, + key=lambda a: len(a), + reverse=True, + ) + return assemsByNumBlocks[0] if assemsByNumBlocks else None + + +def makeAssemsAbleToSnapToUniformMesh( + assems, nonUniformAssemFlags, referenceAssembly=None +): + """Make this set of assemblies aware of the reference mesh so they can stay uniform as they axially expand.""" + if not referenceAssembly: + referenceAssembly = _getDefaultReferenceAssem(assems) + # make the snap lists so assems know how to expand + nonUniformAssems = [Flags.fromStringIgnoreErrors(t) for t in nonUniformAssemFlags] + for a in assems: + if any(a.hasFlags(f) for f in nonUniformAssems): + continue + a.makeAxialSnapList(referenceAssembly) diff --git a/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py new file mode 100644 index 000000000..c85d78ee3 --- /dev/null +++ b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py @@ -0,0 +1,216 @@ +# Copyright 2023 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. + +from armi import runLog +from armi.reactor.components import UnshapedComponent +from armi.reactor.converters.axialExpansion import getSolidComponents + + +class AssemblyAxialLinkage: + """Determines and stores the block- and component-wise axial linkage for an assembly. + + Attributes + ---------- + a : :py:class:`Assembly ` + reference to original assembly; is directly modified/changed during expansion. + + linkedBlocks : dict + keys --> :py:class:`Block ` + + values --> list of axially linked blocks; index 0 = lower linked block; index 1: upper linked block. + + see also: self._getLinkedBlocks() + + linkedComponents : dict + keys --> :py:class:`Component ` + + values --> list of axially linked components; index 0 = lower linked component; index 1: upper linked component. + + see also: self._getLinkedComponents + """ + + def __init__(self, StdAssem): + self.a = StdAssem + self.linkedBlocks = {} + self.linkedComponents = {} + self._determineAxialLinkage() + + def _determineAxialLinkage(self): + """Gets the block and component based linkage.""" + for b in self.a: + self._getLinkedBlocks(b) + for c in getSolidComponents(b): + self._getLinkedComponents(b, c) + + def _getLinkedBlocks(self, b): + """Retrieve the axial linkage for block b. + + Parameters + ---------- + b : :py:class:`Block ` + block to determine axial linkage for + + Notes + ----- + - block linkage is determined by matching ztop/zbottom (see below) + - block linkage is stored in self.linkedBlocks[b] + _ _ + | | + | 2 | Block 2 is linked to block 1. + |_ _| + | | + | 1 | Block 1 is linked to both block 0 and 1. + |_ _| + | | + | 0 | Block 0 is linked to block 1. + |_ _| + """ + lowerLinkedBlock = None + upperLinkedBlock = None + block_list = self.a.getChildren() + for otherBlk in block_list: + if b.name != otherBlk.name: + if b.p.zbottom == otherBlk.p.ztop: + lowerLinkedBlock = otherBlk + elif b.p.ztop == otherBlk.p.zbottom: + upperLinkedBlock = otherBlk + + self.linkedBlocks[b] = [lowerLinkedBlock, upperLinkedBlock] + + if lowerLinkedBlock is None: + runLog.debug( + "Assembly {0:22s} at location {1:22s}, Block {2:22s}" + "is not linked to a block below!".format( + str(self.a.getName()), + str(self.a.getLocation()), + str(b.p.flags), + ), + single=True, + ) + if upperLinkedBlock is None: + runLog.debug( + "Assembly {0:22s} at location {1:22s}, Block {2:22s}" + "is not linked to a block above!".format( + str(self.a.getName()), + str(self.a.getLocation()), + str(b.p.flags), + ), + single=True, + ) + + def _getLinkedComponents(self, b, c): + """Retrieve the axial linkage for component c. + + Parameters + ---------- + b : :py:class:`Block ` + key to access blocks containing linked components + c : :py:class:`Component ` + component to determine axial linkage for + + Raises + ------ + RuntimeError + multiple candidate components are found to be axially linked to a component + """ + lstLinkedC = [None, None] + for ib, linkdBlk in enumerate(self.linkedBlocks[b]): + if linkdBlk is not None: + for otherC in getSolidComponents(linkdBlk.getChildren()): + if _determineLinked(c, otherC): + if lstLinkedC[ib] is not None: + errMsg = ( + "Multiple component axial linkages have been found for " + f"Component {c}; Block {b}; Assembly {b.parent}." + " This is indicative of an error in the blueprints! Linked components found are" + f"{lstLinkedC[ib]} and {otherC}" + ) + runLog.error(msg=errMsg) + raise RuntimeError(errMsg) + lstLinkedC[ib] = otherC + + self.linkedComponents[c] = lstLinkedC + + if lstLinkedC[0] is None: + runLog.debug( + f"Assembly {self.a}, Block {b}, Component {c} has nothing linked below it!", + single=True, + ) + if lstLinkedC[1] is None: + runLog.debug( + f"Assembly {self.a}, Block {b}, Component {c} has nothing linked above it!", + single=True, + ) + + +def _determineLinked(componentA, componentB): + """Determine axial component linkage for two components. + + Parameters + ---------- + componentA : :py:class:`Component ` + component of interest + componentB : :py:class:`Component ` + component to compare and see if is linked to componentA + + Notes + ----- + - Requires that shapes have the getCircleInnerDiameter and getBoundingCircleOuterDiameter defined + - For axial linkage to be True, components MUST be solids, the same Component Class, multiplicity, and meet inner + and outer diameter requirements. + - When component dimensions are retrieved, cold=True to ensure that dimensions are evaluated + at cold/input temperatures. At temperature, solid-solid interfaces in ARMI may produce + slight overlaps due to thermal expansion. Handling these potential overlaps are out of scope. + + Returns + ------- + linked : bool + status is componentA and componentB are axially linked to one another + """ + if ( + (componentA.containsSolidMaterial() and componentB.containsSolidMaterial()) + and isinstance(componentA, type(componentB)) + and (componentA.getDimension("mult") == componentB.getDimension("mult")) + ): + if isinstance(componentA, UnshapedComponent): + runLog.warning( + f"Components {componentA} and {componentB} are UnshapedComponents " + "and do not have 'getCircleInnerDiameter' or getBoundingCircleOuterDiameter methods; " + "nor is it physical to do so. Instead of crashing and raising an error, " + "they are going to be assumed to not be linked.", + single=True, + ) + linked = False + else: + idA, odA = ( + componentA.getCircleInnerDiameter(cold=True), + componentA.getBoundingCircleOuterDiameter(cold=True), + ) + idB, odB = ( + componentB.getCircleInnerDiameter(cold=True), + componentB.getBoundingCircleOuterDiameter(cold=True), + ) + + biggerID = max(idA, idB) + smallerOD = min(odA, odB) + if biggerID >= smallerOD: + # one object fits inside the other + linked = False + else: + linked = True + + else: + linked = False + + return linked diff --git a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py index b3dd509a7..5dd4b40f7 100644 --- a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py @@ -1,4 +1,4 @@ -# Copyright 2019 TerraPower, LLC +# Copyright 2023 TerraPower, LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,49 +13,18 @@ # limitations under the License. """Enable component-wise axial expansion for assemblies and/or a reactor.""" -from statistics import mean -from typing import List - from armi import runLog -from armi.materials import material -from armi.reactor.components import UnshapedComponent from armi.reactor.flags import Flags +from armi.reactor.converters.axialExpansion import ( + getSolidComponents, + _getDefaultReferenceAssem, +) +from armi.reactor.converters.axialExpansion.expansionData import ExpansionData +from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import ( + AssemblyAxialLinkage, +) from numpy import array -TARGET_FLAGS_IN_PREFERRED_ORDER = [ - Flags.FUEL, - Flags.CONTROL, - Flags.POISON, - Flags.SHIELD, - Flags.SLUG, -] - - -def getDefaultReferenceAssem(assems): - """Return a default reference assembly.""" - # if assemblies are defined in blueprints, handle meshing - # assume finest mesh is reference - assemsByNumBlocks = sorted( - assems, - key=lambda a: len(a), - reverse=True, - ) - return assemsByNumBlocks[0] if assemsByNumBlocks else None - - -def makeAssemsAbleToSnapToUniformMesh( - assems, nonUniformAssemFlags, referenceAssembly=None -): - """Make this set of assemblies aware of the reference mesh so they can stay uniform as they axially expand.""" - if not referenceAssembly: - referenceAssembly = getDefaultReferenceAssem(assems) - # make the snap lists so assems know how to expand - nonUniformAssems = [Flags.fromStringIgnoreErrors(t) for t in nonUniformAssemFlags] - for a in assems: - if any(a.hasFlags(f) for f in nonUniformAssems): - continue - a.makeAxialSnapList(referenceAssembly) - def expandColdDimsToHot( assems: list, @@ -85,7 +54,7 @@ def expandColdDimsToHot( """ assems = list(assems) if not referenceAssembly: - referenceAssembly = getDefaultReferenceAssem(assems) + referenceAssembly = _getDefaultReferenceAssem(assems) axialExpChanger = AxialExpansionChanger(isDetailedAxialExpansion) for a in assems: axialExpChanger.setAssembly(a, expandFromTinputToThot=True) @@ -381,462 +350,3 @@ def _checkBlockHeight(b): ) -class AssemblyAxialLinkage: - """Determines and stores the block- and component-wise axial linkage for an assembly. - - Attributes - ---------- - a : :py:class:`Assembly ` - reference to original assembly; is directly modified/changed during expansion. - - linkedBlocks : dict - keys --> :py:class:`Block ` - - values --> list of axially linked blocks; index 0 = lower linked block; index 1: upper linked block. - - see also: self._getLinkedBlocks() - - linkedComponents : dict - keys --> :py:class:`Component ` - - values --> list of axially linked components; index 0 = lower linked component; index 1: upper linked component. - - see also: self._getLinkedComponents - """ - - def __init__(self, StdAssem): - self.a = StdAssem - self.linkedBlocks = {} - self.linkedComponents = {} - self._determineAxialLinkage() - - def _determineAxialLinkage(self): - """Gets the block and component based linkage.""" - for b in self.a: - self._getLinkedBlocks(b) - for c in getSolidComponents(b): - self._getLinkedComponents(b, c) - - def _getLinkedBlocks(self, b): - """Retrieve the axial linkage for block b. - - Parameters - ---------- - b : :py:class:`Block ` - block to determine axial linkage for - - Notes - ----- - - block linkage is determined by matching ztop/zbottom (see below) - - block linkage is stored in self.linkedBlocks[b] - _ _ - | | - | 2 | Block 2 is linked to block 1. - |_ _| - | | - | 1 | Block 1 is linked to both block 0 and 1. - |_ _| - | | - | 0 | Block 0 is linked to block 1. - |_ _| - """ - lowerLinkedBlock = None - upperLinkedBlock = None - block_list = self.a.getChildren() - for otherBlk in block_list: - if b.name != otherBlk.name: - if b.p.zbottom == otherBlk.p.ztop: - lowerLinkedBlock = otherBlk - elif b.p.ztop == otherBlk.p.zbottom: - upperLinkedBlock = otherBlk - - self.linkedBlocks[b] = [lowerLinkedBlock, upperLinkedBlock] - - if lowerLinkedBlock is None: - runLog.debug( - "Assembly {0:22s} at location {1:22s}, Block {2:22s}" - "is not linked to a block below!".format( - str(self.a.getName()), - str(self.a.getLocation()), - str(b.p.flags), - ), - single=True, - ) - if upperLinkedBlock is None: - runLog.debug( - "Assembly {0:22s} at location {1:22s}, Block {2:22s}" - "is not linked to a block above!".format( - str(self.a.getName()), - str(self.a.getLocation()), - str(b.p.flags), - ), - single=True, - ) - - def _getLinkedComponents(self, b, c): - """Retrieve the axial linkage for component c. - - Parameters - ---------- - b : :py:class:`Block ` - key to access blocks containing linked components - c : :py:class:`Component ` - component to determine axial linkage for - - Raises - ------ - RuntimeError - multiple candidate components are found to be axially linked to a component - """ - lstLinkedC = [None, None] - for ib, linkdBlk in enumerate(self.linkedBlocks[b]): - if linkdBlk is not None: - for otherC in getSolidComponents(linkdBlk.getChildren()): - if _determineLinked(c, otherC): - if lstLinkedC[ib] is not None: - errMsg = ( - "Multiple component axial linkages have been found for " - f"Component {c}; Block {b}; Assembly {b.parent}." - " This is indicative of an error in the blueprints! Linked components found are" - f"{lstLinkedC[ib]} and {otherC}" - ) - runLog.error(msg=errMsg) - raise RuntimeError(errMsg) - lstLinkedC[ib] = otherC - - self.linkedComponents[c] = lstLinkedC - - if lstLinkedC[0] is None: - runLog.debug( - f"Assembly {self.a}, Block {b}, Component {c} has nothing linked below it!", - single=True, - ) - if lstLinkedC[1] is None: - runLog.debug( - f"Assembly {self.a}, Block {b}, Component {c} has nothing linked above it!", - single=True, - ) - - -def _determineLinked(componentA, componentB): - """Determine axial component linkage for two components. - - Parameters - ---------- - componentA : :py:class:`Component ` - component of interest - componentB : :py:class:`Component ` - component to compare and see if is linked to componentA - - Notes - ----- - - Requires that shapes have the getCircleInnerDiameter and getBoundingCircleOuterDiameter defined - - For axial linkage to be True, components MUST be solids, the same Component Class, multiplicity, and meet inner - and outer diameter requirements. - - When component dimensions are retrieved, cold=True to ensure that dimensions are evaluated - at cold/input temperatures. At temperature, solid-solid interfaces in ARMI may produce - slight overlaps due to thermal expansion. Handling these potential overlaps are out of scope. - - Returns - ------- - linked : bool - status is componentA and componentB are axially linked to one another - """ - if ( - (componentA.containsSolidMaterial() and componentB.containsSolidMaterial()) - and isinstance(componentA, type(componentB)) - and (componentA.getDimension("mult") == componentB.getDimension("mult")) - ): - if isinstance(componentA, UnshapedComponent): - runLog.warning( - f"Components {componentA} and {componentB} are UnshapedComponents " - "and do not have 'getCircleInnerDiameter' or getBoundingCircleOuterDiameter methods; " - "nor is it physical to do so. Instead of crashing and raising an error, " - "they are going to be assumed to not be linked.", - single=True, - ) - linked = False - else: - idA, odA = ( - componentA.getCircleInnerDiameter(cold=True), - componentA.getBoundingCircleOuterDiameter(cold=True), - ) - idB, odB = ( - componentB.getCircleInnerDiameter(cold=True), - componentB.getBoundingCircleOuterDiameter(cold=True), - ) - - biggerID = max(idA, idB) - smallerOD = min(odA, odB) - if biggerID >= smallerOD: - # one object fits inside the other - linked = False - else: - linked = True - - else: - linked = False - - return linked - - -class ExpansionData: - """Object containing data needed for axial expansion.""" - - def __init__(self, a, setFuel: bool, expandFromTinputToThot: bool): - """ - Parameters - ---------- - a: :py:class:`Assembly ` - Assembly to assign component-wise expansion data to - setFuel: bool - used to determine if fuel component should be set as - axial expansion target component during initialization. - see self._isFuelLocked - expandFromTinputToThot: bool - determines if thermal expansion factors should be calculated - from c.inputTemperatureInC to c.temperatureInC (True) or some other - reference temperature and c.temperatureInC (False) - """ - self._a = a - self.componentReferenceTemperature = {} - self._expansionFactors = {} - self._componentDeterminesBlockHeight = {} - self._setTargetComponents(setFuel) - self.expandFromTinputToThot = expandFromTinputToThot - - def setExpansionFactors(self, componentLst: List, expFrac: List): - """Sets user defined expansion fractions. - - Parameters - ---------- - componentLst : List[:py:class:`Component `] - list of Components to have their heights changed - expFrac : List[float] - list of L1/L0 height changes that are to be applied to componentLst - - Raises - ------ - RuntimeError - If componentLst and expFrac are different lengths - """ - if len(componentLst) != len(expFrac): - runLog.error( - "Number of components and expansion fractions must be the same!\n" - f" len(componentLst) = {len(componentLst)}\n" - f" len(expFrac) = {len(expFrac)}" - ) - raise RuntimeError - if 0.0 in expFrac: - msg = "An expansion fraction, L1/L0, equal to 0.0, is not physical. Expansion fractions should be greater than 0.0." - runLog.error(msg) - raise RuntimeError(msg) - for exp in expFrac: - if exp < 0.0: - msg = "A negative expansion fraction, L1/L0, is not physical. Expansion fractions should be greater than 0.0." - runLog.error(msg) - raise RuntimeError(msg) - for c, p in zip(componentLst, expFrac): - self._expansionFactors[c] = p - - def updateComponentTempsBy1DTempField(self, tempGrid, tempField): - """Assign a block-average axial temperature to components. - - Parameters - ---------- - tempGrid : numpy array - 1D axial temperature grid (i.e., physical locations where temp is stored) - tempField : numpy array - temperature values along grid - - Notes - ----- - - given a 1D axial temperature grid and distribution, searches for temperatures that fall - within the bounds of a block, and averages them - - this average temperature is then passed to self.updateComponentTemp() - - Raises - ------ - ValueError - if no temperature points found within a block - RuntimeError - if tempGrid and tempField are different lengths - """ - if len(tempGrid) != len(tempField): - runLog.error("tempGrid and tempField must have the same length.") - raise RuntimeError - - self.componentReferenceTemperature = {} # reset, just to be safe - for b in self._a: - tmpMapping = [] - for idz, z in enumerate(tempGrid): - if b.p.zbottom <= z <= b.p.ztop: - tmpMapping.append(tempField[idz]) - if z > b.p.ztop: - break - - if len(tmpMapping) == 0: - raise ValueError( - f"{b} has no temperature points within it!" - "Likely need to increase the refinement of the temperature grid." - ) - - blockAveTemp = mean(tmpMapping) - for c in b: - self.updateComponentTemp(c, blockAveTemp) - - def updateComponentTemp(self, c, temp: float): - """Update component temperatures with a provided temperature. - - Parameters - ---------- - c : :py:class:`Component ` - component to which the temperature, temp, is to be applied - temp : float - new component temperature in C - - Notes - ----- - - "reference" height and temperature are the current states; i.e. before - 1) the new temperature, temp, is applied to the component, and - 2) the component is axially expanded - """ - self.componentReferenceTemperature[c] = c.temperatureInC - c.setTemperature(temp) - - def computeThermalExpansionFactors(self): - """Computes expansion factors for all components via thermal expansion.""" - for b in self._a: - for c in getSolidComponents(b): - if self.expandFromTinputToThot: - # get thermal expansion factor between c.inputTemperatureInC and c.temperatureInC - self._expansionFactors[c] = c.getThermalExpansionFactor() - elif c in self.componentReferenceTemperature: - growFrac = c.getThermalExpansionFactor( - T0=self.componentReferenceTemperature[c] - ) - self._expansionFactors[c] = growFrac - else: - # we want expansion factors relative to componentReferenceTemperature not Tinput. - # But for this component there isn't a componentReferenceTemperature, - # so we'll assume that the expansion factor is 1.0. - self._expansionFactors[c] = 1.0 - - def getExpansionFactor(self, c): - """Retrieves expansion factor for c. - - Parameters - ---------- - c : :py:class:`Component ` - Component to retrive expansion factor for - """ - value = self._expansionFactors.get(c, 1.0) - return value - - def _setTargetComponents(self, setFuel): - """Sets target component for each block. - - Parameters - ---------- - setFuel : bool - boolean to determine if fuel block should have its target component set. Useful for when - target components should be determined on the fly. - """ - for b in self._a: - if b.p.axialExpTargetComponent: - self._componentDeterminesBlockHeight[ - b.getComponentByName(b.p.axialExpTargetComponent) - ] = True - elif b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP): - self.determineTargetComponent(b, Flags.CLAD) - elif b.hasFlags(Flags.DUMMY): - self.determineTargetComponent(b, Flags.COOLANT) - elif setFuel and b.hasFlags(Flags.FUEL): - self._isFuelLocked(b) - else: - self.determineTargetComponent(b) - - def determineTargetComponent(self, b, flagOfInterest=None): - """Determines target component, stores it on the block, and appends it to self._componentDeterminesBlockHeight. - - Parameters - ---------- - b : :py:class:`Block ` - block to specify target component for - flagOfInterest : :py:class:`Flags ` - the flag of interest to identify the target component - - Notes - ----- - - if flagOfInterest is None, finds the component within b that contains flags that - are defined in a preferred order of flags, or barring that, in b.p.flags - - if flagOfInterest is not None, finds the component that contains the flagOfInterest. - - Raises - ------ - RuntimeError - no target component found - RuntimeError - multiple target components found - """ - if flagOfInterest is None: - # Follow expansion of most neutronically important component, fuel first then control/poison - for targetFlag in TARGET_FLAGS_IN_PREFERRED_ORDER: - componentWFlag = [c for c in b.getChildren() if c.hasFlags(targetFlag)] - if componentWFlag != []: - break - # some blocks/components are not included in the above list but should still be found - if not componentWFlag: - componentWFlag = [c for c in b.getChildren() if c.p.flags in b.p.flags] - else: - componentWFlag = [c for c in b.getChildren() if c.hasFlags(flagOfInterest)] - if len(componentWFlag) == 0: - # if only 1 solid, be smart enought to snag it - solidMaterials = list( - c for c in b if not isinstance(c.material, material.Fluid) - ) - if len(solidMaterials) == 1: - componentWFlag = solidMaterials - if len(componentWFlag) == 0: - raise RuntimeError(f"No target component found!\n Block {b}") - if len(componentWFlag) > 1: - raise RuntimeError( - "Cannot have more than one component within a block that has the target flag!" - f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {componentWFlag}" - ) - self._componentDeterminesBlockHeight[componentWFlag[0]] = True - b.p.axialExpTargetComponent = componentWFlag[0].name - - def _isFuelLocked(self, b): - """Physical/realistic implementation reserved for ARMI plugin. - - Parameters - ---------- - b : :py:class:`Block ` - block to specify target component for - - Raises - ------ - RuntimeError - multiple fuel components found within b - - Notes - ----- - - This serves as an example to check for fuel/clad locking/interaction found in SFRs. - - A more realistic/physical implementation is reserved for ARMI plugin(s). - """ - c = b.getComponent(Flags.FUEL) - if c is None: - raise RuntimeError(f"No fuel component within {b}!") - self._componentDeterminesBlockHeight[c] = True - b.p.axialExpTargetComponent = c.name - - def isTargetComponent(self, c): - """Returns bool if c is a target component. - - Parameters - ---------- - c : :py:class:`Component ` - Component to check target component status - """ - return bool(c in self._componentDeterminesBlockHeight) diff --git a/armi/reactor/converters/axialExpansion/expansionData.py b/armi/reactor/converters/axialExpansion/expansionData.py new file mode 100644 index 000000000..eeb6c7539 --- /dev/null +++ b/armi/reactor/converters/axialExpansion/expansionData.py @@ -0,0 +1,288 @@ +# Copyright 2023 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. +from statistics import mean +from typing import List + +from armi import runLog +from armi.materials.material import Fluid +from armi.reactor.flags import Flags +from armi.reactor.converters.axialExpansion import getSolidComponents + + +class ExpansionData: + """Object containing data needed for axial expansion.""" + + TARGET_FLAGS_IN_PREFERRED_ORDER = [ + Flags.FUEL, + Flags.CONTROL, + Flags.POISON, + Flags.SHIELD, + Flags.SLUG, + ] + + def __init__(self, a, setFuel: bool, expandFromTinputToThot: bool): + """ + Parameters + ---------- + a: :py:class:`Assembly ` + Assembly to assign component-wise expansion data to + setFuel: bool + used to determine if fuel component should be set as + axial expansion target component during initialization. + see self._isFuelLocked + expandFromTinputToThot: bool + determines if thermal expansion factors should be calculated + from c.inputTemperatureInC to c.temperatureInC (True) or some other + reference temperature and c.temperatureInC (False) + """ + self._a = a + self.componentReferenceTemperature = {} + self._expansionFactors = {} + self._componentDeterminesBlockHeight = {} + self._setTargetComponents(setFuel) + self.expandFromTinputToThot = expandFromTinputToThot + + def setExpansionFactors(self, componentLst: List, expFrac: List): + """Sets user defined expansion fractions. + + Parameters + ---------- + componentLst : List[:py:class:`Component `] + list of Components to have their heights changed + expFrac : List[float] + list of L1/L0 height changes that are to be applied to componentLst + + Raises + ------ + RuntimeError + If componentLst and expFrac are different lengths + """ + if len(componentLst) != len(expFrac): + runLog.error( + "Number of components and expansion fractions must be the same!\n" + f" len(componentLst) = {len(componentLst)}\n" + f" len(expFrac) = {len(expFrac)}" + ) + raise RuntimeError + if 0.0 in expFrac: + msg = "An expansion fraction, L1/L0, equal to 0.0, is not physical. Expansion fractions should be greater than 0.0." + runLog.error(msg) + raise RuntimeError(msg) + for exp in expFrac: + if exp < 0.0: + msg = "A negative expansion fraction, L1/L0, is not physical. Expansion fractions should be greater than 0.0." + runLog.error(msg) + raise RuntimeError(msg) + for c, p in zip(componentLst, expFrac): + self._expansionFactors[c] = p + + def updateComponentTempsBy1DTempField(self, tempGrid, tempField): + """Assign a block-average axial temperature to components. + + Parameters + ---------- + tempGrid : numpy array + 1D axial temperature grid (i.e., physical locations where temp is stored) + tempField : numpy array + temperature values along grid + + Notes + ----- + - given a 1D axial temperature grid and distribution, searches for temperatures that fall + within the bounds of a block, and averages them + - this average temperature is then passed to self.updateComponentTemp() + + Raises + ------ + ValueError + if no temperature points found within a block + RuntimeError + if tempGrid and tempField are different lengths + """ + if len(tempGrid) != len(tempField): + runLog.error("tempGrid and tempField must have the same length.") + raise RuntimeError + + self.componentReferenceTemperature = {} # reset, just to be safe + for b in self._a: + tmpMapping = [] + for idz, z in enumerate(tempGrid): + if b.p.zbottom <= z <= b.p.ztop: + tmpMapping.append(tempField[idz]) + if z > b.p.ztop: + break + + if len(tmpMapping) == 0: + raise ValueError( + f"{b} has no temperature points within it!" + "Likely need to increase the refinement of the temperature grid." + ) + + blockAveTemp = mean(tmpMapping) + for c in b: + self.updateComponentTemp(c, blockAveTemp) + + def updateComponentTemp(self, c, temp: float): + """Update component temperatures with a provided temperature. + + Parameters + ---------- + c : :py:class:`Component ` + component to which the temperature, temp, is to be applied + temp : float + new component temperature in C + + Notes + ----- + - "reference" height and temperature are the current states; i.e. before + 1) the new temperature, temp, is applied to the component, and + 2) the component is axially expanded + """ + self.componentReferenceTemperature[c] = c.temperatureInC + c.setTemperature(temp) + + def computeThermalExpansionFactors(self): + """Computes expansion factors for all components via thermal expansion.""" + for b in self._a: + for c in getSolidComponents(b): + if self.expandFromTinputToThot: + # get thermal expansion factor between c.inputTemperatureInC and c.temperatureInC + self._expansionFactors[c] = c.getThermalExpansionFactor() + elif c in self.componentReferenceTemperature: + growFrac = c.getThermalExpansionFactor( + T0=self.componentReferenceTemperature[c] + ) + self._expansionFactors[c] = growFrac + else: + # we want expansion factors relative to componentReferenceTemperature not Tinput. + # But for this component there isn't a componentReferenceTemperature, + # so we'll assume that the expansion factor is 1.0. + self._expansionFactors[c] = 1.0 + + def getExpansionFactor(self, c): + """Retrieves expansion factor for c. + + Parameters + ---------- + c : :py:class:`Component ` + Component to retrive expansion factor for + """ + value = self._expansionFactors.get(c, 1.0) + return value + + def _setTargetComponents(self, setFuel): + """Sets target component for each block. + + Parameters + ---------- + setFuel : bool + boolean to determine if fuel block should have its target component set. Useful for when + target components should be determined on the fly. + """ + for b in self._a: + if b.p.axialExpTargetComponent: + self._componentDeterminesBlockHeight[ + b.getComponentByName(b.p.axialExpTargetComponent) + ] = True + elif b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP): + self.determineTargetComponent(b, Flags.CLAD) + elif b.hasFlags(Flags.DUMMY): + self.determineTargetComponent(b, Flags.COOLANT) + elif setFuel and b.hasFlags(Flags.FUEL): + self._isFuelLocked(b) + else: + self.determineTargetComponent(b) + + def determineTargetComponent(self, b, flagOfInterest=None): + """Determines target component, stores it on the block, and appends it to self._componentDeterminesBlockHeight. + + Parameters + ---------- + b : :py:class:`Block ` + block to specify target component for + flagOfInterest : :py:class:`Flags ` + the flag of interest to identify the target component + + Notes + ----- + - if flagOfInterest is None, finds the component within b that contains flags that + are defined in a preferred order of flags, or barring that, in b.p.flags + - if flagOfInterest is not None, finds the component that contains the flagOfInterest. + + Raises + ------ + RuntimeError + no target component found + RuntimeError + multiple target components found + """ + if flagOfInterest is None: + # Follow expansion of most neutronically important component, fuel first then control/poison + for targetFlag in self.TARGET_FLAGS_IN_PREFERRED_ORDER: + componentWFlag = [c for c in b.getChildren() if c.hasFlags(targetFlag)] + if componentWFlag != []: + break + # some blocks/components are not included in the above list but should still be found + if not componentWFlag: + componentWFlag = [c for c in b.getChildren() if c.p.flags in b.p.flags] + else: + componentWFlag = [c for c in b.getChildren() if c.hasFlags(flagOfInterest)] + if len(componentWFlag) == 0: + # if only 1 solid, be smart enought to snag it + solidMaterials = list(c for c in b if not isinstance(c.material, Fluid)) + if len(solidMaterials) == 1: + componentWFlag = solidMaterials + if len(componentWFlag) == 0: + raise RuntimeError(f"No target component found!\n Block {b}") + if len(componentWFlag) > 1: + raise RuntimeError( + "Cannot have more than one component within a block that has the target flag!" + f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {componentWFlag}" + ) + self._componentDeterminesBlockHeight[componentWFlag[0]] = True + b.p.axialExpTargetComponent = componentWFlag[0].name + + def _isFuelLocked(self, b): + """Physical/realistic implementation reserved for ARMI plugin. + + Parameters + ---------- + b : :py:class:`Block ` + block to specify target component for + + Raises + ------ + RuntimeError + multiple fuel components found within b + + Notes + ----- + - This serves as an example to check for fuel/clad locking/interaction found in SFRs. + - A more realistic/physical implementation is reserved for ARMI plugin(s). + """ + c = b.getComponent(Flags.FUEL) + if c is None: + raise RuntimeError(f"No fuel component within {b}!") + self._componentDeterminesBlockHeight[c] = True + b.p.axialExpTargetComponent = c.name + + def isTargetComponent(self, c): + """Returns bool if c is a target component. + + Parameters + ---------- + c : :py:class:`Component ` + Component to check target component status + """ + return bool(c in self._componentDeterminesBlockHeight) diff --git a/armi/reactor/converters/axialExpansion/tests/__init__.py b/armi/reactor/converters/axialExpansion/tests/__init__.py new file mode 100644 index 000000000..0b9fcc60d --- /dev/null +++ b/armi/reactor/converters/axialExpansion/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2023 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. From 3f9a0a745a7db45e5dae6cb5b98aad3a1f4528aa Mon Sep 17 00:00:00 2001 From: aalberti Date: Wed, 13 Sep 2023 14:16:17 -0700 Subject: [PATCH 03/25] make _checkBlockHeight a static method --- .../axialExpansion/axialExpansionChanger.py | 52 +++++++------------ 1 file changed, 19 insertions(+), 33 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py index 5dd4b40f7..3467f0457 100644 --- a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py @@ -275,7 +275,7 @@ def axiallyExpandAssembly(self): b.p.z = b.p.zbottom + b.getHeight() / 2.0 - _checkBlockHeight(b) + self._checkBlockHeight(b) # call component.clearCache to update the component volume, and therefore the masses, of all solid components. for c in getSolidComponents(b): c.clearCache() @@ -314,39 +314,25 @@ def manageCoreMesh(self, r): for old, new in zip(oldMesh, r.core.p.axialMesh): runLog.extra(f"{old:.6e}\t{new:.6e}") + @staticmethod + def _checkBlockHeight(b): + """ + Do some basic block height validation. -def getSolidComponents(b): - """ - Return list of components in the block that have solid material. - - Notes - ----- - Axial expansion only needs to be applied to solid materials. We should not update - number densities on fluid materials to account for changes in block height. - """ - return [c for c in b if not isinstance(c.material, material.Fluid)] - - -def _checkBlockHeight(b): - """ - Do some basic block height validation. - - Notes - ----- - 3cm is a presumptive lower threshhold for DIF3D - """ - if b.getHeight() < 3.0: - runLog.debug( - "Block {0:s} ({1:s}) has a height less than 3.0 cm. ({2:.12e})".format( - b.name, str(b.p.flags), b.getHeight() + Notes + ----- + 3cm is a presumptive lower threshhold for DIF3D + """ + if b.getHeight() < 3.0: + runLog.debug( + "Block {0:s} ({1:s}) has a height less than 3.0 cm. ({2:.12e})".format( + b.name, str(b.p.flags), b.getHeight() + ) ) - ) - if b.getHeight() < 0.0: - raise ArithmeticError( - "Block {0:s} ({1:s}) has a negative height! ({2:.12e})".format( - b.name, str(b.p.flags), b.getHeight() + if b.getHeight() < 0.0: + raise ArithmeticError( + "Block {0:s} ({1:s}) has a negative height! ({2:.12e})".format( + b.name, str(b.p.flags), b.getHeight() + ) ) - ) - - From abea5755339ade37722635226d450dde089739dc Mon Sep 17 00:00:00 2001 From: aalberti Date: Wed, 13 Sep 2023 14:44:39 -0700 Subject: [PATCH 04/25] get tests working with refactor and make AssemblyAxialLinkage._determineLinked a static method --- .../axialExpansion/assemblyAxialLinkage.py | 112 +++++++++--------- .../tests/test_axialExpansionChanger.py | 36 +++--- 2 files changed, 73 insertions(+), 75 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py index c85d78ee3..0baa56421 100644 --- a/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py @@ -128,7 +128,7 @@ def _getLinkedComponents(self, b, c): for ib, linkdBlk in enumerate(self.linkedBlocks[b]): if linkdBlk is not None: for otherC in getSolidComponents(linkdBlk.getChildren()): - if _determineLinked(c, otherC): + if self._determineLinked(c, otherC): if lstLinkedC[ib] is not None: errMsg = ( "Multiple component axial linkages have been found for " @@ -153,64 +153,64 @@ def _getLinkedComponents(self, b, c): single=True, ) + @staticmethod + def _determineLinked(componentA, componentB): + """Determine axial component linkage for two components. -def _determineLinked(componentA, componentB): - """Determine axial component linkage for two components. - - Parameters - ---------- - componentA : :py:class:`Component ` - component of interest - componentB : :py:class:`Component ` - component to compare and see if is linked to componentA - - Notes - ----- - - Requires that shapes have the getCircleInnerDiameter and getBoundingCircleOuterDiameter defined - - For axial linkage to be True, components MUST be solids, the same Component Class, multiplicity, and meet inner - and outer diameter requirements. - - When component dimensions are retrieved, cold=True to ensure that dimensions are evaluated - at cold/input temperatures. At temperature, solid-solid interfaces in ARMI may produce - slight overlaps due to thermal expansion. Handling these potential overlaps are out of scope. - - Returns - ------- - linked : bool - status is componentA and componentB are axially linked to one another - """ - if ( - (componentA.containsSolidMaterial() and componentB.containsSolidMaterial()) - and isinstance(componentA, type(componentB)) - and (componentA.getDimension("mult") == componentB.getDimension("mult")) - ): - if isinstance(componentA, UnshapedComponent): - runLog.warning( - f"Components {componentA} and {componentB} are UnshapedComponents " - "and do not have 'getCircleInnerDiameter' or getBoundingCircleOuterDiameter methods; " - "nor is it physical to do so. Instead of crashing and raising an error, " - "they are going to be assumed to not be linked.", - single=True, - ) - linked = False - else: - idA, odA = ( - componentA.getCircleInnerDiameter(cold=True), - componentA.getBoundingCircleOuterDiameter(cold=True), - ) - idB, odB = ( - componentB.getCircleInnerDiameter(cold=True), - componentB.getBoundingCircleOuterDiameter(cold=True), - ) + Parameters + ---------- + componentA : :py:class:`Component ` + component of interest + componentB : :py:class:`Component ` + component to compare and see if is linked to componentA - biggerID = max(idA, idB) - smallerOD = min(odA, odB) - if biggerID >= smallerOD: - # one object fits inside the other + Notes + ----- + - Requires that shapes have the getCircleInnerDiameter and getBoundingCircleOuterDiameter defined + - For axial linkage to be True, components MUST be solids, the same Component Class, multiplicity, and meet inner + and outer diameter requirements. + - When component dimensions are retrieved, cold=True to ensure that dimensions are evaluated + at cold/input temperatures. At temperature, solid-solid interfaces in ARMI may produce + slight overlaps due to thermal expansion. Handling these potential overlaps are out of scope. + + Returns + ------- + linked : bool + status is componentA and componentB are axially linked to one another + """ + if ( + (componentA.containsSolidMaterial() and componentB.containsSolidMaterial()) + and isinstance(componentA, type(componentB)) + and (componentA.getDimension("mult") == componentB.getDimension("mult")) + ): + if isinstance(componentA, UnshapedComponent): + runLog.warning( + f"Components {componentA} and {componentB} are UnshapedComponents " + "and do not have 'getCircleInnerDiameter' or getBoundingCircleOuterDiameter methods; " + "nor is it physical to do so. Instead of crashing and raising an error, " + "they are going to be assumed to not be linked.", + single=True, + ) linked = False else: - linked = True + idA, odA = ( + componentA.getCircleInnerDiameter(cold=True), + componentA.getBoundingCircleOuterDiameter(cold=True), + ) + idB, odB = ( + componentB.getCircleInnerDiameter(cold=True), + componentB.getBoundingCircleOuterDiameter(cold=True), + ) + + biggerID = max(idA, idB) + smallerOD = min(odA, odB) + if biggerID >= smallerOD: + # one object fits inside the other + linked = False + else: + linked = True - else: - linked = False + else: + linked = False - return linked + return linked diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 4ba6af5e8..3b2cf676d 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -1,4 +1,4 @@ -# Copyright 2019 TerraPower, LLC +# Copyright 2023 TerraPower, LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -25,12 +25,10 @@ from armi.reactor.components import DerivedShape, UnshapedComponent from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle from armi.reactor.components.complexShapes import Helix -from armi.reactor.converters.axialExpansionChanger import ( - AxialExpansionChanger, - ExpansionData, - _determineLinked, - getSolidComponents, -) +from armi.reactor.converters.axialExpansion import getSolidComponents +from armi.reactor.converters.axialExpansion.axialExpansionChanger import AxialExpansionChanger +from armi.reactor.converters.axialExpansion.expansionData import ExpansionData +from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import AssemblyAxialLinkage from armi.reactor.flags import Flags from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings from armi.tests import TEST_ROOT @@ -62,7 +60,7 @@ def setUp(self): # set namespace order for materials so that fake HT9 material can be found materials.setMaterialNamespaceOrder( [ - "armi.reactor.converters.tests.test_axialExpansionChanger", + "armi.reactor.converters.axialExpansion.tests.test_axialExpansionChanger", "armi.materials", ] ) @@ -153,7 +151,7 @@ def _generateTempField(self, coldTemp, hotInletTemp, uniform): self.tempField[i, :] = tmp[i] -class TestAxialExpansionHeight(AxialExpansionTestBase, unittest.TestCase): +class TestAxialExpansionHeight(AxialExpansionTestBase): """Verify that test assembly is expanded correctly.""" def setUp(self): @@ -227,7 +225,7 @@ def _getAveTemp(self, ib, idt, assem): return mean(tmpMapping) -class TestConservation(AxialExpansionTestBase, unittest.TestCase): +class TestConservation(AxialExpansionTestBase): """Verify that conservation is maintained in assembly-level axial expansion.""" def setUp(self): @@ -537,7 +535,7 @@ def test_manageCoreMesh(self): self.assertLess(old, new) -class TestExceptions(AxialExpansionTestBase, unittest.TestCase): +class TestExceptions(AxialExpansionTestBase): """Verify exceptions are caught.""" def setUp(self): @@ -651,7 +649,7 @@ def test_determineLinked(self): compDims = {"Tinput": 25.0, "Thot": 25.0} compA = UnshapedComponent("unshaped_1", "FakeMat", **compDims) compB = UnshapedComponent("unshaped_2", "FakeMat", **compDims) - self.assertFalse(_determineLinked(compA, compB)) + self.assertFalse(AssemblyAxialLinkage._determineLinked(compA, compB)) def test_getLinkedComponents(self): """Test for multiple component axial linkage.""" @@ -663,7 +661,7 @@ def test_getLinkedComponents(self): self.assertEqual(cm.exception, 3) -class TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase): +class TestDetermineTargetComponent(AxialExpansionTestBase): """Verify determineTargetComponent method is properly updating _componentDeterminesBlockHeight.""" def setUp(self): @@ -912,7 +910,7 @@ def checkColdBlockHeight(bStd, bExp, assertType, strForAssertion): ) -class TestLinkage(AxialExpansionTestBase, unittest.TestCase): +class TestLinkage(AxialExpansionTestBase): """Test axial linkage between components.""" def setUp(self): @@ -961,26 +959,26 @@ def runTest( typeB = method(*common, **dims[1]) if assertionBool: self.assertTrue( - _determineLinked(typeA, typeB), + AssemblyAxialLinkage._determineLinked(typeA, typeB), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), ) self.assertTrue( - _determineLinked(typeB, typeA), + AssemblyAxialLinkage._determineLinked(typeB, typeA), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), ) else: self.assertFalse( - _determineLinked(typeA, typeB), + AssemblyAxialLinkage._determineLinked(typeA, typeB), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), ) self.assertFalse( - _determineLinked(typeB, typeA), + AssemblyAxialLinkage._determineLinked(typeB, typeA), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), @@ -1079,7 +1077,7 @@ def test_liquids(self): def test_unshapedComponentAndCircle(self): comp1 = Circle(*self.common, od=1.0, id=0.0) comp2 = UnshapedComponent(*self.common, area=1.0) - self.assertFalse(_determineLinked(comp1, comp2)) + self.assertFalse(AssemblyAxialLinkage._determineLinked(comp1, comp2)) def buildTestAssemblyWithFakeMaterial(name: str, hot: bool = False): From 085b171d76806b8e287967107487fdb0414ee59c Mon Sep 17 00:00:00 2001 From: aalberti Date: Wed, 13 Sep 2023 14:45:09 -0700 Subject: [PATCH 05/25] fix a couple imports from the refactor --- armi/reactor/blocks.py | 2 +- armi/reactor/tests/test_reactors.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index a7b80150f..a3a52087c 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -1545,7 +1545,7 @@ def setAxialExpTargetComp(self, targetComponent): See Also -------- - armi.reactor.converters.axialExpansionChanger.py::ExpansionData::_setTargetComponents + armi.reactor.converters.axialExpansion.expansionData.py::ExpansionData::_setTargetComponents """ self.p.axialExpTargetComponent = targetComponent.name diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py index 14437fe71..1af12ff2f 100644 --- a/armi/reactor/tests/test_reactors.py +++ b/armi/reactor/tests/test_reactors.py @@ -33,7 +33,7 @@ from armi.reactor import reactors from armi.reactor.components import Hexagon, Rectangle from armi.reactor.converters import geometryConverters -from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger +from armi.reactor.converters.axialExpansion.axialExpansionChanger import AxialExpansionChanger from armi.reactor.flags import Flags from armi.settings.fwSettings.globalSettings import CONF_ASSEM_FLAGS_SKIP_AXIAL_EXP from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR From d5d0bf4615674d407c7182b4f921178f07c1fb48 Mon Sep 17 00:00:00 2001 From: aalberti Date: Fri, 15 Sep 2023 15:00:24 -0700 Subject: [PATCH 06/25] reorg unit tests --- .../axialExpansion/tests/__init__.py | 70 +++ .../tests/buildAxialExpAssembly.py | 139 ++++++ .../tests/test_assemblyAxialLinkage.py | 191 +++++++++ .../tests/test_axialExpansionChanger.py | 403 ++---------------- .../tests/test_expansionData.py | 0 5 files changed, 430 insertions(+), 373 deletions(-) create mode 100644 armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py create mode 100644 armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py create mode 100644 armi/reactor/converters/axialExpansion/tests/test_expansionData.py diff --git a/armi/reactor/converters/axialExpansion/tests/__init__.py b/armi/reactor/converters/axialExpansion/tests/__init__.py index 0b9fcc60d..0e8043c35 100644 --- a/armi/reactor/converters/axialExpansion/tests/__init__.py +++ b/armi/reactor/converters/axialExpansion/tests/__init__.py @@ -11,3 +11,73 @@ # 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. + +import collections +import unittest + +from armi import materials +from armi.materials import _MATERIAL_NAMESPACE_ORDER +from armi.reactor.flags import Flags +from armi.reactor.converters.axialExpansion import getSolidComponents +from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( + AxialExpansionChanger, +) + + +class AxialExpansionTestBase(unittest.TestCase): + """Common methods and variables for unit tests.""" + + Steel_Component_Lst = [ + Flags.DUCT, + Flags.GRID_PLATE, + Flags.HANDLING_SOCKET, + Flags.INLET_NOZZLE, + Flags.CLAD, + Flags.WIRE, + Flags.ACLP, + Flags.GUIDE_TUBE, + ] + + def setUp(self): + self.obj = AxialExpansionChanger() + self.componentMass = collections.defaultdict(list) + self.componentDensity = collections.defaultdict(list) + self.totalAssemblySteelMass = [] + self.blockZtop = collections.defaultdict(list) + self.origNameSpace = _MATERIAL_NAMESPACE_ORDER + # set namespace order for materials so that fake HT9 material can be found + materials.setMaterialNamespaceOrder( + [ + "armi.reactor.converters.axialExpansion.tests.buildAxialExpAssembly", + "armi.materials", + ] + ) + + def tearDown(self): + # reset global namespace + materials.setMaterialNamespaceOrder(self.origNameSpace) + + def _getConservationMetrics(self, a): + """Retrieves and stores various conservation metrics. + + - useful for verification and unittesting + - Finds and stores: + 1. mass and density of target components + 2. mass of assembly steel + 3. block heights + """ + totalSteelMass = 0.0 + for b in a: + # store block ztop + self.blockZtop[b].append(b.p.ztop) + for c in getSolidComponents(b): + # store mass and density of component + self.componentMass[c].append(c.getMass()) + self.componentDensity[c].append( + c.material.getProperty("density", c.temperatureInK) + ) + # store steel mass for assembly + if c.p.flags in self.Steel_Component_Lst: + totalSteelMass += c.getMass() + + self.totalAssemblySteelMass.append(totalSteelMass) diff --git a/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py new file mode 100644 index 000000000..d93800ebd --- /dev/null +++ b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py @@ -0,0 +1,139 @@ +# Copyright 2023 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. +from armi import materials +from armi.utils import units +from armi.reactor.assemblies import HexAssembly, grids +from armi.reactor.blocks import HexBlock +from armi.reactor.components.basicShapes import Circle, Hexagon +from armi.reactor.components import DerivedShape + + +def buildTestAssembly(name: str, hot: bool = False): + """Create test assembly consisting of list of fake material. + + Parameters + ---------- + name : string + determines which fake material to use + """ + if not hot: + hotTemp = 25.0 + height = 10.0 + else: + hotTemp = 250.0 + height = 10.0 + 0.02 * (250.0 - 25.0) + + assembly = HexAssembly("testAssemblyType") + assembly.spatialGrid = grids.axialUnitGrid(numCells=1) + assembly.spatialGrid.armiObject = assembly + assembly.add(buildTestBlock("shield", name, hotTemp, height)) + assembly.add(buildTestBlock("fuel", name, hotTemp, height)) + assembly.add(buildTestBlock("fuel", name, hotTemp, height)) + assembly.add(buildTestBlock("plenum", name, hotTemp, height)) + assembly.add(buildDummySodium(hotTemp, height)) + assembly.calculateZCoords() + assembly.reestablishBlockOrder() + return assembly + + +def buildTestBlock(blockType: str, name: str, hotTemp: float, height: float): + """Return a simple pin type block filled with coolant and surrounded by duct. + + Parameters + ---------- + blockType : string + determines which type of block you're building + name : string + determines which material to use + """ + b = HexBlock(blockType, height=height) + + fuelDims = {"Tinput": 25.0, "Thot": hotTemp, "od": 0.76, "id": 0.00, "mult": 127.0} + cladDims = {"Tinput": 25.0, "Thot": hotTemp, "od": 0.80, "id": 0.77, "mult": 127.0} + ductDims = {"Tinput": 25.0, "Thot": hotTemp, "op": 16, "ip": 15.3, "mult": 1.0} + intercoolantDims = { + "Tinput": 25.0, + "Thot": hotTemp, + "op": 17.0, + "ip": ductDims["op"], + "mult": 1.0, + } + coolDims = {"Tinput": 25.0, "Thot": hotTemp} + mainType = Circle(blockType, name, **fuelDims) + clad = Circle("clad", name, **cladDims) + duct = Hexagon("duct", name, **ductDims) + + coolant = DerivedShape("coolant", "Sodium", **coolDims) + intercoolant = Hexagon("intercoolant", "Sodium", **intercoolantDims) + + b.add(mainType) + b.add(clad) + b.add(duct) + b.add(coolant) + b.add(intercoolant) + b.setType(blockType) + + b.getVolumeFractions() + + return b + + +def buildDummySodium(hotTemp: float, height: float): + """Build a dummy sodium block.""" + b = HexBlock("dummy", height=height) + + sodiumDims = {"Tinput": 25.0, "Thot": hotTemp, "op": 17, "ip": 0.0, "mult": 1.0} + dummy = Hexagon("dummy coolant", "Sodium", **sodiumDims) + + b.add(dummy) + b.getVolumeFractions() + b.setType("dummy") + + return b + + +class FakeMat(materials.ht9.HT9): + """Fake material used to verify armi.reactor.converters.axialExpansionChanger. + + Notes + ----- + - specifically used in TestAxialExpansionHeight to verify axialExpansionChanger produces + expected heights from hand calculation + - also used to verify mass and height conservation resulting from even amounts of expansion + and contraction. See TestConservation. + """ + + name = "FakeMat" + + def linearExpansionPercent(self, Tk=None, Tc=None): + """A fake linear expansion percent.""" + Tc = units.getTc(Tc, Tk) + return 0.02 * Tc + + +class FakeMatException(FakeMat): + """Fake material used to verify TestExceptions. + + Notes + ----- + - higher thermal expansion factor to ensure that a negative block height + is caught in TestExceptions:test_AssemblyAxialExpansionException. + """ + + name = "FakeMatException" + + def linearExpansionPercent(self, Tk=None, Tc=None): + """A fake linear expansion percent.""" + Tc = units.getTc(Tc, Tk) + return 0.08 * Tc diff --git a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py new file mode 100644 index 000000000..c9b723162 --- /dev/null +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py @@ -0,0 +1,191 @@ +# Copyright 2023 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. + +from armi.reactor.components import UnshapedComponent +from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle +from armi.reactor.components.complexShapes import Helix +from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import ( + AssemblyAxialLinkage, +) +from armi.reactor.converters.axialExpansion.tests import AxialExpansionTestBase + + +class TestLinkage(AxialExpansionTestBase): + """Test axial linkage between components.""" + + def setUp(self): + """Contains common dimensions for all component class types.""" + AxialExpansionTestBase.setUp(self) + self.common = ("test", "FakeMat", 25.0, 25.0) # name, material, Tinput, Thot + + def tearDown(self): + AxialExpansionTestBase.tearDown(self) + + def runTest( + self, + componentsToTest: dict, + assertionBool: bool, + name: str, + commonArgs: tuple = None, + ): + """Runs various linkage tests. + + Parameters + ---------- + componentsToTest : dict + keys --> component class type; values --> dimensions specific to key + assertionBool : boolean + expected truth value for test + name : str + the name of the test + commonArgs : tuple, optional + arguments common to all Component class types + + Notes + ----- + - components "typeA" and "typeB" are assumed to be vertically stacked + - two assertions: 1) comparing "typeB" component to "typeA"; 2) comparing "typeA" component to "typeB" + - the different assertions are particularly useful for comparing two annuli + - to add Component class types to a test: + Add dictionary entry with following: + {Component Class Type: [{}, {}] + """ + if commonArgs is None: + common = self.common + else: + common = commonArgs + for method, dims in componentsToTest.items(): + typeA = method(*common, **dims[0]) + typeB = method(*common, **dims[1]) + if assertionBool: + self.assertTrue( + AssemblyAxialLinkage._determineLinked(typeA, typeB), + msg="Test {0:s} failed for component type {1:s}!".format( + name, str(method) + ), + ) + self.assertTrue( + AssemblyAxialLinkage._determineLinked(typeB, typeA), + msg="Test {0:s} failed for component type {1:s}!".format( + name, str(method) + ), + ) + else: + self.assertFalse( + AssemblyAxialLinkage._determineLinked(typeA, typeB), + msg="Test {0:s} failed for component type {1:s}!".format( + name, str(method) + ), + ) + self.assertFalse( + AssemblyAxialLinkage._determineLinked(typeB, typeA), + msg="Test {0:s} failed for component type {1:s}!".format( + name, str(method) + ), + ) + + def test_overlappingSolidPins(self): + componentTypesToTest = { + Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.0}], + Hexagon: [{"op": 0.5, "ip": 0.0}, {"op": 1.0, "ip": 0.0}], + Rectangle: [ + { + "lengthOuter": 0.5, + "lengthInner": 0.0, + "widthOuter": 0.5, + "widthInner": 0.0, + }, + { + "lengthOuter": 1.0, + "lengthInner": 0.0, + "widthOuter": 1.0, + "widthInner": 0.0, + }, + ], + Helix: [ + {"od": 0.5, "axialPitch": 1.0, "helixDiameter": 1.0}, + {"od": 1.0, "axialPitch": 1.0, "helixDiameter": 1.0}, + ], + } + self.runTest(componentTypesToTest, True, "test_overlappingSolidPins") + + def test_differentMultNotOverlapping(self): + componentTypesToTest = { + Circle: [{"od": 0.5, "mult": 10}, {"od": 0.5, "mult": 20}], + Hexagon: [{"op": 0.5, "mult": 10}, {"op": 1.0, "mult": 20}], + Rectangle: [ + {"lengthOuter": 1.0, "widthOuter": 1.0, "mult": 10}, + {"lengthOuter": 1.0, "widthOuter": 1.0, "mult": 20}, + ], + Helix: [ + {"od": 0.5, "axialPitch": 1.0, "helixDiameter": 1.0, "mult": 10}, + {"od": 1.0, "axialPitch": 1.0, "helixDiameter": 1.0, "mult": 20}, + ], + } + self.runTest(componentTypesToTest, False, "test_differentMultNotOverlapping") + + def test_solidPinNotOverlappingAnnulus(self): + componentTypesToTest = { + Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.6}], + } + self.runTest(componentTypesToTest, False, "test_solidPinNotOverlappingAnnulus") + + def test_solidPinOverlappingWithAnnulus(self): + componentTypesToTest = { + Circle: [{"od": 0.7, "id": 0.0}, {"od": 1.0, "id": 0.6}], + } + self.runTest(componentTypesToTest, True, "test_solidPinOverlappingWithAnnulus") + + def test_annularPinNotOverlappingWithAnnulus(self): + componentTypesToTest = { + Circle: [{"od": 0.6, "id": 0.3}, {"od": 1.0, "id": 0.6}], + } + self.runTest( + componentTypesToTest, False, "test_annularPinNotOverlappingWithAnnulus" + ) + + def test_annularPinOverlappingWithAnnuls(self): + componentTypesToTest = { + Circle: [{"od": 0.7, "id": 0.3}, {"od": 1.0, "id": 0.6}], + } + self.runTest(componentTypesToTest, True, "test_annularPinOverlappingWithAnnuls") + + def test_thinAnnularPinOverlappingWithThickAnnulus(self): + componentTypesToTest = { + Circle: [{"od": 0.7, "id": 0.3}, {"od": 0.6, "id": 0.5}], + } + self.runTest( + componentTypesToTest, True, "test_thinAnnularPinOverlappingWithThickAnnulus" + ) + + def test_AnnularHexOverlappingThickAnnularHex(self): + componentTypesToTest = { + Hexagon: [{"op": 1.0, "ip": 0.8}, {"op": 1.2, "ip": 0.8}] + } + self.runTest( + componentTypesToTest, True, "test_AnnularHexOverlappingThickAnnularHex" + ) + + def test_liquids(self): + componentTypesToTest = { + Circle: [{"od": 1.0, "id": 0.0}, {"od": 1.0, "id": 0.0}], + Hexagon: [{"op": 1.0, "ip": 0.0}, {"op": 1.0, "ip": 0.0}], + } + liquid = ("test", "Sodium", 425.0, 425.0) # name, material, Tinput, Thot + self.runTest(componentTypesToTest, False, "test_liquids", commonArgs=liquid) + + def test_unshapedComponentAndCircle(self): + comp1 = Circle(*self.common, od=1.0, id=0.0) + comp2 = UnshapedComponent(*self.common, area=1.0) + self.assertFalse(AssemblyAxialLinkage._determineLinked(comp1, comp2)) diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 3b2cf676d..5c532c138 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -13,90 +13,37 @@ # limitations under the License. """Test axialExpansionChanger.""" -import collections import os import unittest from statistics import mean -from armi import materials -from armi.materials import _MATERIAL_NAMESPACE_ORDER, custom +from armi.materials import custom from armi.reactor.assemblies import HexAssembly, grids from armi.reactor.blocks import HexBlock from armi.reactor.components import DerivedShape, UnshapedComponent -from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle -from armi.reactor.components.complexShapes import Helix +from armi.reactor.components.basicShapes import Circle, Hexagon from armi.reactor.converters.axialExpansion import getSolidComponents -from armi.reactor.converters.axialExpansion.axialExpansionChanger import AxialExpansionChanger +from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( + AxialExpansionChanger, +) from armi.reactor.converters.axialExpansion.expansionData import ExpansionData -from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import AssemblyAxialLinkage +from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import ( + AssemblyAxialLinkage, +) +from armi.reactor.converters.axialExpansion.tests import AxialExpansionTestBase +from armi.reactor.converters.axialExpansion.tests.buildAxialExpAssembly import ( + buildTestAssembly, + buildTestBlock, + buildDummySodium, +) from armi.reactor.flags import Flags from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings from armi.tests import TEST_ROOT -from armi.utils import units from numpy import array, linspace, zeros -class AxialExpansionTestBase(unittest.TestCase): - """Common methods and variables for unit tests.""" - - Steel_Component_Lst = [ - Flags.DUCT, - Flags.GRID_PLATE, - Flags.HANDLING_SOCKET, - Flags.INLET_NOZZLE, - Flags.CLAD, - Flags.WIRE, - Flags.ACLP, - Flags.GUIDE_TUBE, - ] - - def setUp(self): - self.obj = AxialExpansionChanger() - self.componentMass = collections.defaultdict(list) - self.componentDensity = collections.defaultdict(list) - self.totalAssemblySteelMass = [] - self.blockZtop = collections.defaultdict(list) - self.origNameSpace = _MATERIAL_NAMESPACE_ORDER - # set namespace order for materials so that fake HT9 material can be found - materials.setMaterialNamespaceOrder( - [ - "armi.reactor.converters.axialExpansion.tests.test_axialExpansionChanger", - "armi.materials", - ] - ) - - def tearDown(self): - # reset global namespace - materials.setMaterialNamespaceOrder(self.origNameSpace) - - def _getConservationMetrics(self, a): - """Retrieves and stores various conservation metrics. - - - useful for verification and unittesting - - Finds and stores: - 1. mass and density of target components - 2. mass of assembly steel - 3. block heights - """ - totalSteelMass = 0.0 - for b in a: - # store block ztop - self.blockZtop[b].append(b.p.ztop) - for c in getSolidComponents(b): - # store mass and density of component - self.componentMass[c].append(c.getMass()) - self.componentDensity[c].append( - c.material.getProperty("density", c.temperatureInK) - ) - # store steel mass for assembly - if c.p.flags in self.Steel_Component_Lst: - totalSteelMass += c.getMass() - - self.totalAssemblySteelMass.append(totalSteelMass) - - class Temperature: - """Create and store temperature grid/field.""" + """Create and store temperature grid/field for verification testing.""" def __init__( self, @@ -156,7 +103,7 @@ class TestAxialExpansionHeight(AxialExpansionTestBase): def setUp(self): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssemblyWithFakeMaterial(name="FakeMat") + self.a = buildTestAssembly(name="FakeMat") self.temp = Temperature( self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10 @@ -188,7 +135,7 @@ def test_AssemblyAxialExpansionHeight(self): def _generateComponentWiseExpectedHeight(self): """Calculate the expected height, external of AssemblyAxialExpansion().""" - assem = buildTestAssemblyWithFakeMaterial(name="FakeMat") + assem = buildTestAssembly(name="FakeMat") aveBlockTemp = zeros((len(assem), self.temp.tempSteps)) self.trueZtop = zeros((len(assem), self.temp.tempSteps)) self.trueHeight = zeros((len(assem), self.temp.tempSteps)) @@ -230,7 +177,7 @@ class TestConservation(AxialExpansionTestBase): def setUp(self): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssemblyWithFakeMaterial(name="FakeMat") + self.a = buildTestAssembly(name="FakeMat") def tearDown(self): AxialExpansionTestBase.tearDown(self) @@ -255,7 +202,7 @@ def test_ThermalExpansionContractionConservation_Simple(self): Temperature field is always isothermal and initially at 25 C. """ isothermalTempList = [100.0, 350.0, 250.0, 25.0] - a = buildTestAssemblyWithFakeMaterial(name="HT9") + a = buildTestAssembly(name="HT9") origMesh = a.getAxialMesh()[:-1] origMasses, origNDens = self._getComponentMassAndNDens(a) axialExpChngr = AxialExpansionChanger(detailedAxialExpansion=True) @@ -357,7 +304,7 @@ def test_PrescribedExpansionContractionConservation(self): - uniform expansion over all components within the assembly - 10 total expansion steps: 5 at +1.01 L1/L0, and 5 at -(1.01^-1) L1/L0 """ - a = buildTestAssemblyWithFakeMaterial(name="FakeMat") + a = buildTestAssembly(name="FakeMat") axExpChngr = AxialExpansionChanger() origMesh = a.getAxialMesh() origMasses, origNDens = self._getComponentMassAndNDens(a) @@ -438,15 +385,15 @@ def test_NoMovementACLP(self): assembly = HexAssembly("testAssemblyType") assembly.spatialGrid = grids.axialUnitGrid(numCells=1) assembly.spatialGrid.armiObject = assembly - assembly.add(_buildTestBlock("shield", "FakeMat", 25.0, 10.0)) - assembly.add(_buildTestBlock("fuel", "FakeMat", 25.0, 10.0)) - assembly.add(_buildTestBlock("fuel", "FakeMat", 25.0, 10.0)) - assembly.add(_buildTestBlock("plenum", "FakeMat", 25.0, 10.0)) + assembly.add(buildTestBlock("shield", "FakeMat", 25.0, 10.0)) + assembly.add(buildTestBlock("fuel", "FakeMat", 25.0, 10.0)) + assembly.add(buildTestBlock("fuel", "FakeMat", 25.0, 10.0)) + assembly.add(buildTestBlock("plenum", "FakeMat", 25.0, 10.0)) assembly.add( - _buildTestBlock("aclp", "FakeMat", 25.0, 10.0) + buildTestBlock("aclp", "FakeMat", 25.0, 10.0) ) # "aclp plenum" also works - assembly.add(_buildTestBlock("plenum", "FakeMat", 25.0, 10.0)) - assembly.add(_buildDummySodium(25.0, 10.0)) + assembly.add(buildTestBlock("plenum", "FakeMat", 25.0, 10.0)) + assembly.add(buildDummySodium(25.0, 10.0)) assembly.calculateZCoords() assembly.reestablishBlockOrder() @@ -540,7 +487,7 @@ class TestExceptions(AxialExpansionTestBase): def setUp(self): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssemblyWithFakeMaterial(name="FakeMatException") + self.a = buildTestAssembly(name="FakeMatException") self.obj.setAssembly(self.a) def tearDown(self): @@ -551,7 +498,7 @@ def test_isTopDummyBlockPresent(self): assembly = HexAssembly("testAssemblyType") assembly.spatialGrid = grids.axialUnitGrid(numCells=1) assembly.spatialGrid.armiObject = assembly - assembly.add(_buildTestBlock("shield", "FakeMat", 25.0, 10.0)) + assembly.add(buildTestBlock("shield", "FakeMat", 25.0, 10.0)) assembly.calculateZCoords() assembly.reestablishBlockOrder() # create instance of expansion changer @@ -800,7 +747,7 @@ class TestGetSolidComponents(unittest.TestCase): """Verify that getSolidComponents returns just solid components.""" def setUp(self): - self.a = buildTestAssemblyWithFakeMaterial(name="HT9") + self.a = buildTestAssembly(name="HT9") def test_getSolidComponents(self): for b in self.a: @@ -908,293 +855,3 @@ def checkColdBlockHeight(bStd, bExp, assertType, strForAssertion): strForAssertion, ), ) - - -class TestLinkage(AxialExpansionTestBase): - """Test axial linkage between components.""" - - def setUp(self): - """Contains common dimensions for all component class types.""" - AxialExpansionTestBase.setUp(self) - self.common = ("test", "FakeMat", 25.0, 25.0) # name, material, Tinput, Thot - - def tearDown(self): - AxialExpansionTestBase.tearDown(self) - - def runTest( - self, - componentsToTest: dict, - assertionBool: bool, - name: str, - commonArgs: tuple = None, - ): - """Runs various linkage tests. - - Parameters - ---------- - componentsToTest : dict - keys --> component class type; values --> dimensions specific to key - assertionBool : boolean - expected truth value for test - name : str - the name of the test - commonArgs : tuple, optional - arguments common to all Component class types - - Notes - ----- - - components "typeA" and "typeB" are assumed to be vertically stacked - - two assertions: 1) comparing "typeB" component to "typeA"; 2) comparing "typeA" component to "typeB" - - the different assertions are particularly useful for comparing two annuli - - to add Component class types to a test: - Add dictionary entry with following: - {Component Class Type: [{}, {}] - """ - if commonArgs is None: - common = self.common - else: - common = commonArgs - for method, dims in componentsToTest.items(): - typeA = method(*common, **dims[0]) - typeB = method(*common, **dims[1]) - if assertionBool: - self.assertTrue( - AssemblyAxialLinkage._determineLinked(typeA, typeB), - msg="Test {0:s} failed for component type {1:s}!".format( - name, str(method) - ), - ) - self.assertTrue( - AssemblyAxialLinkage._determineLinked(typeB, typeA), - msg="Test {0:s} failed for component type {1:s}!".format( - name, str(method) - ), - ) - else: - self.assertFalse( - AssemblyAxialLinkage._determineLinked(typeA, typeB), - msg="Test {0:s} failed for component type {1:s}!".format( - name, str(method) - ), - ) - self.assertFalse( - AssemblyAxialLinkage._determineLinked(typeB, typeA), - msg="Test {0:s} failed for component type {1:s}!".format( - name, str(method) - ), - ) - - def test_overlappingSolidPins(self): - componentTypesToTest = { - Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.0}], - Hexagon: [{"op": 0.5, "ip": 0.0}, {"op": 1.0, "ip": 0.0}], - Rectangle: [ - { - "lengthOuter": 0.5, - "lengthInner": 0.0, - "widthOuter": 0.5, - "widthInner": 0.0, - }, - { - "lengthOuter": 1.0, - "lengthInner": 0.0, - "widthOuter": 1.0, - "widthInner": 0.0, - }, - ], - Helix: [ - {"od": 0.5, "axialPitch": 1.0, "helixDiameter": 1.0}, - {"od": 1.0, "axialPitch": 1.0, "helixDiameter": 1.0}, - ], - } - self.runTest(componentTypesToTest, True, "test_overlappingSolidPins") - - def test_differentMultNotOverlapping(self): - componentTypesToTest = { - Circle: [{"od": 0.5, "mult": 10}, {"od": 0.5, "mult": 20}], - Hexagon: [{"op": 0.5, "mult": 10}, {"op": 1.0, "mult": 20}], - Rectangle: [ - {"lengthOuter": 1.0, "widthOuter": 1.0, "mult": 10}, - {"lengthOuter": 1.0, "widthOuter": 1.0, "mult": 20}, - ], - Helix: [ - {"od": 0.5, "axialPitch": 1.0, "helixDiameter": 1.0, "mult": 10}, - {"od": 1.0, "axialPitch": 1.0, "helixDiameter": 1.0, "mult": 20}, - ], - } - self.runTest(componentTypesToTest, False, "test_differentMultNotOverlapping") - - def test_solidPinNotOverlappingAnnulus(self): - componentTypesToTest = { - Circle: [{"od": 0.5, "id": 0.0}, {"od": 1.0, "id": 0.6}], - } - self.runTest(componentTypesToTest, False, "test_solidPinNotOverlappingAnnulus") - - def test_solidPinOverlappingWithAnnulus(self): - componentTypesToTest = { - Circle: [{"od": 0.7, "id": 0.0}, {"od": 1.0, "id": 0.6}], - } - self.runTest(componentTypesToTest, True, "test_solidPinOverlappingWithAnnulus") - - def test_annularPinNotOverlappingWithAnnulus(self): - componentTypesToTest = { - Circle: [{"od": 0.6, "id": 0.3}, {"od": 1.0, "id": 0.6}], - } - self.runTest( - componentTypesToTest, False, "test_annularPinNotOverlappingWithAnnulus" - ) - - def test_annularPinOverlappingWithAnnuls(self): - componentTypesToTest = { - Circle: [{"od": 0.7, "id": 0.3}, {"od": 1.0, "id": 0.6}], - } - self.runTest(componentTypesToTest, True, "test_annularPinOverlappingWithAnnuls") - - def test_thinAnnularPinOverlappingWithThickAnnulus(self): - componentTypesToTest = { - Circle: [{"od": 0.7, "id": 0.3}, {"od": 0.6, "id": 0.5}], - } - self.runTest( - componentTypesToTest, True, "test_thinAnnularPinOverlappingWithThickAnnulus" - ) - - def test_AnnularHexOverlappingThickAnnularHex(self): - componentTypesToTest = { - Hexagon: [{"op": 1.0, "ip": 0.8}, {"op": 1.2, "ip": 0.8}] - } - self.runTest( - componentTypesToTest, True, "test_AnnularHexOverlappingThickAnnularHex" - ) - - def test_liquids(self): - componentTypesToTest = { - Circle: [{"od": 1.0, "id": 0.0}, {"od": 1.0, "id": 0.0}], - Hexagon: [{"op": 1.0, "ip": 0.0}, {"op": 1.0, "ip": 0.0}], - } - liquid = ("test", "Sodium", 425.0, 425.0) # name, material, Tinput, Thot - self.runTest(componentTypesToTest, False, "test_liquids", commonArgs=liquid) - - def test_unshapedComponentAndCircle(self): - comp1 = Circle(*self.common, od=1.0, id=0.0) - comp2 = UnshapedComponent(*self.common, area=1.0) - self.assertFalse(AssemblyAxialLinkage._determineLinked(comp1, comp2)) - - -def buildTestAssemblyWithFakeMaterial(name: str, hot: bool = False): - """Create test assembly consisting of list of fake material. - - Parameters - ---------- - name : string - determines which fake material to use - """ - if not hot: - hotTemp = 25.0 - height = 10.0 - else: - hotTemp = 250.0 - height = 10.0 + 0.02 * (250.0 - 25.0) - - assembly = HexAssembly("testAssemblyType") - assembly.spatialGrid = grids.axialUnitGrid(numCells=1) - assembly.spatialGrid.armiObject = assembly - assembly.add(_buildTestBlock("shield", name, hotTemp, height)) - assembly.add(_buildTestBlock("fuel", name, hotTemp, height)) - assembly.add(_buildTestBlock("fuel", name, hotTemp, height)) - assembly.add(_buildTestBlock("plenum", name, hotTemp, height)) - assembly.add(_buildDummySodium(hotTemp, height)) - assembly.calculateZCoords() - assembly.reestablishBlockOrder() - return assembly - - -def _buildTestBlock(blockType: str, name: str, hotTemp: float, height: float): - """Return a simple pin type block filled with coolant and surrounded by duct. - - Parameters - ---------- - blockType : string - determines which type of block you're building - name : string - determines which material to use - """ - b = HexBlock(blockType, height=height) - - fuelDims = {"Tinput": 25.0, "Thot": hotTemp, "od": 0.76, "id": 0.00, "mult": 127.0} - cladDims = {"Tinput": 25.0, "Thot": hotTemp, "od": 0.80, "id": 0.77, "mult": 127.0} - ductDims = {"Tinput": 25.0, "Thot": hotTemp, "op": 16, "ip": 15.3, "mult": 1.0} - intercoolantDims = { - "Tinput": 25.0, - "Thot": hotTemp, - "op": 17.0, - "ip": ductDims["op"], - "mult": 1.0, - } - coolDims = {"Tinput": 25.0, "Thot": hotTemp} - mainType = Circle(blockType, name, **fuelDims) - clad = Circle("clad", name, **cladDims) - duct = Hexagon("duct", name, **ductDims) - - coolant = DerivedShape("coolant", "Sodium", **coolDims) - intercoolant = Hexagon("intercoolant", "Sodium", **intercoolantDims) - - b.add(mainType) - b.add(clad) - b.add(duct) - b.add(coolant) - b.add(intercoolant) - b.setType(blockType) - - b.getVolumeFractions() - - return b - - -def _buildDummySodium(hotTemp: float, height: float): - """Build a dummy sodium block.""" - b = HexBlock("dummy", height=height) - - sodiumDims = {"Tinput": 25.0, "Thot": hotTemp, "op": 17, "ip": 0.0, "mult": 1.0} - dummy = Hexagon("dummy coolant", "Sodium", **sodiumDims) - - b.add(dummy) - b.getVolumeFractions() - b.setType("dummy") - - return b - - -class FakeMat(materials.ht9.HT9): - """Fake material used to verify armi.reactor.converters.axialExpansionChanger. - - Notes - ----- - - specifically used in TestAxialExpansionHeight to verify axialExpansionChanger produces - expected heights from hand calculation - - also used to verify mass and height conservation resulting from even amounts of expansion - and contraction. See TestConservation. - """ - - name = "FakeMat" - - def linearExpansionPercent(self, Tk=None, Tc=None): - """A fake linear expansion percent.""" - Tc = units.getTc(Tc, Tk) - return 0.02 * Tc - - -class FakeMatException(materials.ht9.HT9): - """Fake material used to verify TestExceptions. - - Notes - ----- - - the only difference between this and `class Fake(HT9)` above is that the thermal expansion factor - is higher to ensure that a negative block height is caught in TestExceptions:test_AssemblyAxialExpansionException. - """ - - name = "FakeMatException" - - def linearExpansionPercent(self, Tk=None, Tc=None): - """A fake linear expansion percent.""" - Tc = units.getTc(Tc, Tk) - return 0.08 * Tc diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py new file mode 100644 index 000000000..e69de29bb From dcacedb65f29120e7c041b1ea5b616d9b10f2a48 Mon Sep 17 00:00:00 2001 From: aalberti Date: Thu, 12 Oct 2023 10:01:11 -0700 Subject: [PATCH 07/25] mv some tests to test_expansionData.py --- .../tests/test_assemblyAxialLinkage.py | 7 +- .../tests/test_axialExpansionChanger.py | 139 +----------------- .../tests/test_expansionData.py | 137 +++++++++++++++++ 3 files changed, 144 insertions(+), 139 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py index c9b723162..70120b3a5 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py @@ -21,8 +21,11 @@ from armi.reactor.converters.axialExpansion.tests import AxialExpansionTestBase -class TestLinkage(AxialExpansionTestBase): - """Test axial linkage between components.""" +class TestDetermineLinked(AxialExpansionTestBase): + """Test assemblyAxialLinkage.py::AssemblyAxialLinkage::_determineLinked + + This is the primary method used to determined if two components are linked axial linkage between components. + """ def setUp(self): """Contains common dimensions for all component class types.""" diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 5c532c138..49502852e 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -20,8 +20,8 @@ from armi.materials import custom from armi.reactor.assemblies import HexAssembly, grids from armi.reactor.blocks import HexBlock -from armi.reactor.components import DerivedShape, UnshapedComponent -from armi.reactor.components.basicShapes import Circle, Hexagon +from armi.reactor.components import UnshapedComponent +from armi.reactor.components.basicShapes import Circle from armi.reactor.converters.axialExpansion import getSolidComponents from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( AxialExpansionChanger, @@ -608,141 +608,6 @@ def test_getLinkedComponents(self): self.assertEqual(cm.exception, 3) -class TestDetermineTargetComponent(AxialExpansionTestBase): - """Verify determineTargetComponent method is properly updating _componentDeterminesBlockHeight.""" - - def setUp(self): - AxialExpansionTestBase.setUp(self) - self.expData = ExpansionData([], setFuel=True, expandFromTinputToThot=True) - coolDims = {"Tinput": 25.0, "Thot": 25.0} - self.coolant = DerivedShape("coolant", "Sodium", **coolDims) - - def tearDown(self): - AxialExpansionTestBase.tearDown(self) - - def test_determineTargetComponent(self): - """Provides coverage for searching TARGET_FLAGS_IN_PREFERRED_ORDER.""" - b = HexBlock("fuel", height=10.0) - fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.76, "id": 0.00, "mult": 127.0} - cladDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.80, "id": 0.77, "mult": 127.0} - fuel = Circle("fuel", "FakeMat", **fuelDims) - clad = Circle("clad", "FakeMat", **cladDims) - b.add(fuel) - b.add(clad) - b.add(self.coolant) - # make sure that b.p.axialExpTargetComponent is empty initially - self.assertFalse(b.p.axialExpTargetComponent) - # call method, and check that target component is correct - self.expData.determineTargetComponent(b) - self.assertTrue( - self.expData.isTargetComponent(fuel), - msg=f"determineTargetComponent failed to recognize intended component: {fuel}", - ) - self.assertEqual( - b.p.axialExpTargetComponent, - fuel.name, - msg=f"determineTargetComponent failed to recognize intended component: {fuel}", - ) - - def test_determineTargetComponentBlockWithMultipleFlags(self): - """Provides coverage for searching TARGET_FLAGS_IN_PREFERRED_ORDER with multiple flags.""" - # build a block that has two flags as well as a component matching each - b = HexBlock("fuel poison", height=10.0) - fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.9, "id": 0.5, "mult": 200.0} - poisonDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.5, "id": 0.0, "mult": 10.0} - fuel = Circle("fuel", "FakeMat", **fuelDims) - poison = Circle("poison", "FakeMat", **poisonDims) - b.add(fuel) - b.add(poison) - b.add(self.coolant) - # call method, and check that target component is correct - self.expData.determineTargetComponent(b) - self.assertTrue( - self.expData.isTargetComponent(fuel), - msg=f"determineTargetComponent failed to recognize intended component: {fuel}", - ) - - def test_specifyTargetComponent_NotFound(self): - """Ensure RuntimeError gets raised when no target component is found.""" - b = HexBlock("fuel", height=10.0) - b.add(self.coolant) - b.setType("fuel") - with self.assertRaises(RuntimeError) as cm: - self.expData.determineTargetComponent(b) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - with self.assertRaises(RuntimeError) as cm: - self.expData.determineTargetComponent(b, Flags.FUEL) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - - def test_specifyTargetComponent_singleSolid(self): - """Ensures that specifyTargetComponent is smart enough to set the only solid as the target component.""" - b = HexBlock("plenum", height=10.0) - ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0} - duct = Hexagon("duct", "FakeMat", **ductDims) - b.add(duct) - b.add(self.coolant) - b.getVolumeFractions() - b.setType("plenum") - self.expData.determineTargetComponent(b) - self.assertTrue( - self.expData.isTargetComponent(duct), - msg=f"determineTargetComponent failed to recognize intended component: {duct}", - ) - - def test_specifyTargetComponet_MultipleFound(self): - """Ensure RuntimeError is hit when multiple target components are found. - - Notes - ----- - This can occur if a block has a mixture of fuel types. E.g., different fuel materials, - or different fuel geometries. - """ - b = HexBlock("fuel", height=10.0) - fuelAnnularDims = { - "Tinput": 25.0, - "Thot": 25.0, - "od": 0.9, - "id": 0.5, - "mult": 100.0, - } - fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 1.0, "id": 0.0, "mult": 10.0} - fuel = Circle("fuel", "FakeMat", **fuelDims) - fuelAnnular = Circle("fuel annular", "FakeMat", **fuelAnnularDims) - b.add(fuel) - b.add(fuelAnnular) - b.add(self.coolant) - b.setType("FuelBlock") - with self.assertRaises(RuntimeError) as cm: - self.expData.determineTargetComponent(b, flagOfInterest=Flags.FUEL) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - - def test_manuallySetTargetComponent(self): - """Ensures that target components can be manually set (is done in practice via blueprints).""" - b = HexBlock("dummy", height=10.0) - ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0} - duct = Hexagon("duct", "FakeMat", **ductDims) - b.add(duct) - b.add(self.coolant) - b.getVolumeFractions() - b.setType("duct") - - # manually set target component - b.setAxialExpTargetComp(duct) - self.assertEqual( - b.p.axialExpTargetComponent, - duct.name, - ) - - # check that target component is stored on expansionData object correctly - self.expData._componentDeterminesBlockHeight[ - b.getComponentByName(b.p.axialExpTargetComponent) - ] = True - self.assertTrue(self.expData.isTargetComponent(duct)) - - class TestGetSolidComponents(unittest.TestCase): """Verify that getSolidComponents returns just solid components.""" diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index e69de29bb..3f37e82ec 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -0,0 +1,137 @@ +import unittest +from armi.reactor.flags import Flags +from armi.reactor.blocks import HexBlock +from armi.reactor.components import DerivedShape +from armi.reactor.components.basicShapes import Circle, Hexagon +from armi.reactor.converters.axialExpansion.expansionData import ExpansionData + + +class TestDetermineTargetComponent(unittest.TestCase): + """Verify determineTargetComponent method is properly updating _componentDeterminesBlockHeight.""" + + def setUp(self): + self.expData = ExpansionData([], setFuel=True, expandFromTinputToThot=True) + coolDims = {"Tinput": 25.0, "Thot": 25.0} + self.coolant = DerivedShape("coolant", "Sodium", **coolDims) + + def test_determineTargetComponent(self): + """Provides coverage for searching TARGET_FLAGS_IN_PREFERRED_ORDER.""" + b = HexBlock("fuel", height=10.0) + fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.76, "id": 0.00, "mult": 127.0} + cladDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.80, "id": 0.77, "mult": 127.0} + fuel = Circle("fuel", "HT9", **fuelDims) + clad = Circle("clad", "HT9", **cladDims) + b.add(fuel) + b.add(clad) + b.add(self.coolant) + # make sure that b.p.axialExpTargetComponent is empty initially + self.assertFalse(b.p.axialExpTargetComponent) + # call method, and check that target component is correct + self.expData.determineTargetComponent(b) + self.assertTrue( + self.expData.isTargetComponent(fuel), + msg=f"determineTargetComponent failed to recognize intended component: {fuel}", + ) + self.assertEqual( + b.p.axialExpTargetComponent, + fuel.name, + msg=f"determineTargetComponent failed to recognize intended component: {fuel}", + ) + + def test_determineTargetComponentBlockWithMultipleFlags(self): + """Provides coverage for searching TARGET_FLAGS_IN_PREFERRED_ORDER with multiple flags.""" + # build a block that has two flags as well as a component matching each + b = HexBlock("fuel poison", height=10.0) + fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.9, "id": 0.5, "mult": 200.0} + poisonDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.5, "id": 0.0, "mult": 10.0} + fuel = Circle("fuel", "HT9", **fuelDims) + poison = Circle("poison", "HT9", **poisonDims) + b.add(fuel) + b.add(poison) + b.add(self.coolant) + # call method, and check that target component is correct + self.expData.determineTargetComponent(b) + self.assertTrue( + self.expData.isTargetComponent(fuel), + msg=f"determineTargetComponent failed to recognize intended component: {fuel}", + ) + + def test_specifyTargetComponent_NotFound(self): + """Ensure RuntimeError gets raised when no target component is found.""" + b = HexBlock("fuel", height=10.0) + b.add(self.coolant) + b.setType("fuel") + with self.assertRaises(RuntimeError) as cm: + self.expData.determineTargetComponent(b) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + with self.assertRaises(RuntimeError) as cm: + self.expData.determineTargetComponent(b, Flags.FUEL) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_specifyTargetComponent_singleSolid(self): + """Ensures that specifyTargetComponent is smart enough to set the only solid as the target component.""" + b = HexBlock("plenum", height=10.0) + ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0} + duct = Hexagon("duct", "HT9", **ductDims) + b.add(duct) + b.add(self.coolant) + b.getVolumeFractions() + b.setType("plenum") + self.expData.determineTargetComponent(b) + self.assertTrue( + self.expData.isTargetComponent(duct), + msg=f"determineTargetComponent failed to recognize intended component: {duct}", + ) + + def test_specifyTargetComponet_MultipleFound(self): + """Ensure RuntimeError is hit when multiple target components are found. + + Notes + ----- + This can occur if a block has a mixture of fuel types. E.g., different fuel materials, + or different fuel geometries. + """ + b = HexBlock("fuel", height=10.0) + fuelAnnularDims = { + "Tinput": 25.0, + "Thot": 25.0, + "od": 0.9, + "id": 0.5, + "mult": 100.0, + } + fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 1.0, "id": 0.0, "mult": 10.0} + fuel = Circle("fuel", "HT9", **fuelDims) + fuelAnnular = Circle("fuel annular", "HT9", **fuelAnnularDims) + b.add(fuel) + b.add(fuelAnnular) + b.add(self.coolant) + b.setType("FuelBlock") + with self.assertRaises(RuntimeError) as cm: + self.expData.determineTargetComponent(b, flagOfInterest=Flags.FUEL) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_manuallySetTargetComponent(self): + """Ensures that target components can be manually set (is done in practice via blueprints).""" + b = HexBlock("dummy", height=10.0) + ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0} + duct = Hexagon("duct", "HT9", **ductDims) + b.add(duct) + b.add(self.coolant) + b.getVolumeFractions() + b.setType("duct") + + # manually set target component + b.setAxialExpTargetComp(duct) + self.assertEqual( + b.p.axialExpTargetComponent, + duct.name, + ) + + # check that target component is stored on expansionData object correctly + self.expData._componentDeterminesBlockHeight[ + b.getComponentByName(b.p.axialExpTargetComponent) + ] = True + self.assertTrue(self.expData.isTargetComponent(duct)) From 76734c47650e8eb6e2867e0b47946247f6149428 Mon Sep 17 00:00:00 2001 From: aalberti Date: Fri, 13 Oct 2023 15:56:51 -0700 Subject: [PATCH 08/25] make parameter name better --- .../tests/buildAxialExpAssembly.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py index d93800ebd..a394c9b08 100644 --- a/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py +++ b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py @@ -19,13 +19,15 @@ from armi.reactor.components import DerivedShape -def buildTestAssembly(name: str, hot: bool = False): - """Create test assembly consisting of list of fake material. +def buildTestAssembly(materialName: str, hot: bool = False): + """Create test assembly Parameters ---------- - name : string - determines which fake material to use + materialName: string + determines which material to use + hot: boolean + determines if assembly should be at hot temperatures """ if not hot: hotTemp = 25.0 @@ -37,24 +39,24 @@ def buildTestAssembly(name: str, hot: bool = False): assembly = HexAssembly("testAssemblyType") assembly.spatialGrid = grids.axialUnitGrid(numCells=1) assembly.spatialGrid.armiObject = assembly - assembly.add(buildTestBlock("shield", name, hotTemp, height)) - assembly.add(buildTestBlock("fuel", name, hotTemp, height)) - assembly.add(buildTestBlock("fuel", name, hotTemp, height)) - assembly.add(buildTestBlock("plenum", name, hotTemp, height)) + assembly.add(buildTestBlock("shield", materialName, hotTemp, height)) + assembly.add(buildTestBlock("fuel", materialName, hotTemp, height)) + assembly.add(buildTestBlock("fuel", materialName, hotTemp, height)) + assembly.add(buildTestBlock("plenum", materialName, hotTemp, height)) assembly.add(buildDummySodium(hotTemp, height)) assembly.calculateZCoords() assembly.reestablishBlockOrder() return assembly -def buildTestBlock(blockType: str, name: str, hotTemp: float, height: float): +def buildTestBlock(blockType: str, materialName: str, hotTemp: float, height: float): """Return a simple pin type block filled with coolant and surrounded by duct. Parameters ---------- blockType : string determines which type of block you're building - name : string + materialName : string determines which material to use """ b = HexBlock(blockType, height=height) @@ -70,9 +72,9 @@ def buildTestBlock(blockType: str, name: str, hotTemp: float, height: float): "mult": 1.0, } coolDims = {"Tinput": 25.0, "Thot": hotTemp} - mainType = Circle(blockType, name, **fuelDims) - clad = Circle("clad", name, **cladDims) - duct = Hexagon("duct", name, **ductDims) + mainType = Circle(blockType, materialName, **fuelDims) + clad = Circle("clad", materialName, **cladDims) + duct = Hexagon("duct", materialName, **ductDims) coolant = DerivedShape("coolant", "Sodium", **coolDims) intercoolant = Hexagon("intercoolant", "Sodium", **intercoolantDims) From 2f8b34ff96bba2f3cdaf48ebcd4f8c3c554c4f52 Mon Sep 17 00:00:00 2001 From: aalberti Date: Fri, 13 Oct 2023 15:57:57 -0700 Subject: [PATCH 09/25] remove unneccesary inheritance --- .../tests/test_assemblyAxialLinkage.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py index 70120b3a5..0036d5e4e 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py @@ -11,29 +11,22 @@ # 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. - +import unittest from armi.reactor.components import UnshapedComponent from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle from armi.reactor.components.complexShapes import Helix from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import ( AssemblyAxialLinkage, ) -from armi.reactor.converters.axialExpansion.tests import AxialExpansionTestBase - - -class TestDetermineLinked(AxialExpansionTestBase): - """Test assemblyAxialLinkage.py::AssemblyAxialLinkage::_determineLinked +class TestDetermineLinked(unittest.TestCase): + """Test assemblyAxialLinkage.py::AssemblyAxialLinkage::_determineLinked for anticipated configrations This is the primary method used to determined if two components are linked axial linkage between components. """ def setUp(self): """Contains common dimensions for all component class types.""" - AxialExpansionTestBase.setUp(self) - self.common = ("test", "FakeMat", 25.0, 25.0) # name, material, Tinput, Thot - - def tearDown(self): - AxialExpansionTestBase.tearDown(self) + self.common = ("test", "HT9", 25.0, 25.0) # name, material, Tinput, Thot def runTest( self, From f62006077ab8128d31b683b17428dfa04773ff89 Mon Sep 17 00:00:00 2001 From: aalberti Date: Sat, 14 Oct 2023 08:43:06 -0700 Subject: [PATCH 10/25] add better unit testing for assemblyAxialLinkage - also improve the frequency in which the runLogs get hit --- .../axialExpansion/assemblyAxialLinkage.py | 33 +++----- .../tests/test_assemblyAxialLinkage.py | 80 +++++++++++++++++++ .../tests/test_axialExpansionChanger.py | 15 ---- 3 files changed, 90 insertions(+), 38 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py index 0baa56421..b0dc63d16 100644 --- a/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py @@ -13,6 +13,7 @@ # limitations under the License. from armi import runLog +from armi.reactor.flags import Flags from armi.reactor.components import UnshapedComponent from armi.reactor.converters.axialExpansion import getSolidComponents @@ -88,27 +89,6 @@ def _getLinkedBlocks(self, b): self.linkedBlocks[b] = [lowerLinkedBlock, upperLinkedBlock] - if lowerLinkedBlock is None: - runLog.debug( - "Assembly {0:22s} at location {1:22s}, Block {2:22s}" - "is not linked to a block below!".format( - str(self.a.getName()), - str(self.a.getLocation()), - str(b.p.flags), - ), - single=True, - ) - if upperLinkedBlock is None: - runLog.debug( - "Assembly {0:22s} at location {1:22s}, Block {2:22s}" - "is not linked to a block above!".format( - str(self.a.getName()), - str(self.a.getLocation()), - str(b.p.flags), - ), - single=True, - ) - def _getLinkedComponents(self, b, c): """Retrieve the axial linkage for component c. @@ -142,12 +122,19 @@ def _getLinkedComponents(self, b, c): self.linkedComponents[c] = lstLinkedC - if lstLinkedC[0] is None: + if lstLinkedC[0] is None and self.linkedBlocks[b][0] is not None: + # only print debug if there is a linked block below in the first place runLog.debug( f"Assembly {self.a}, Block {b}, Component {c} has nothing linked below it!", single=True, ) - if lstLinkedC[1] is None: + if ( + lstLinkedC[1] is None + and self.linkedBlocks[b][1] is not None + and not self.linkedBlocks[b][1].hasFlags(Flags.DUMMY) + ): + # only print debug is there is a linked block above in the first place, + # and if that linked block is not the DUMMY block runLog.debug( f"Assembly {self.a}, Block {b}, Component {c} has nothing linked above it!", single=True, diff --git a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py index 0036d5e4e..bac5c5e48 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py @@ -12,12 +12,92 @@ # See the License for the specific language governing permissions and # limitations under the License. import unittest +from armi.tests import mockRunLogs +from armi.reactor.flags import Flags from armi.reactor.components import UnshapedComponent from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle from armi.reactor.components.complexShapes import Helix from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import ( AssemblyAxialLinkage, ) +from armi.reactor.converters.axialExpansion.tests import buildAxialExpAssembly + + +class TestGetLinkedComponents(unittest.TestCase): + """Runs through AssemblyAxialLinkage::_determineAxialLinkage() and does full linkage + + The individual methods, _getLinkedBlocks, _getLinkedComponents, and _determineLinked are then + tested in individual tests by asserting that the linkage is as expected. + """ + + @classmethod + def setUpClass(cls): + cls.a = buildAxialExpAssembly.buildTestAssembly("HT9") + cls.assemblyLinkage = AssemblyAxialLinkage(cls.a) + + def test_getLinkedBlocks(self): + for ib, b in enumerate(self.a): + if ib == 0: + self.assertIsNone(self.assemblyLinkage.linkedBlocks[b][0]) + self.assertEqual( + self.assemblyLinkage.linkedBlocks[b][1], self.a[ib + 1] + ) + elif ib == len(self.a) - 1: + self.assertEqual( + self.assemblyLinkage.linkedBlocks[b][0], self.a[ib - 1] + ) + self.assertIsNone(self.assemblyLinkage.linkedBlocks[b][1]) + else: + self.assemblyLinkage._getLinkedBlocks(b) + self.assertEqual( + self.assemblyLinkage.linkedBlocks[b][0], self.a[ib - 1] + ) + self.assertEqual( + self.assemblyLinkage.linkedBlocks[b][1], self.a[ib + 1] + ) + + def test_getLinkedComponents(self): + """spot check to ensure component linkage is as expected""" + ## Test 1: check for shield -- fuel -- fuel linkage + shieldBlock = self.a[0] + shieldComp = shieldBlock.getComponent(Flags.SHIELD) + firstFuelBlock = self.a[1] + fuelComp1 = firstFuelBlock.getComponent(Flags.FUEL) + secondFuelBlock = self.a[2] + fuelComp2 = secondFuelBlock.getComponent(Flags.FUEL) + self.assertEqual( + self.assemblyLinkage.linkedComponents[fuelComp1], [shieldComp, fuelComp2] + ) + ### Test 2: check for clad -- clad -- None linkage + fuelCladComp = secondFuelBlock.getComponent(Flags.CLAD) + plenumBlock = self.a[3] + plenumCladComp = plenumBlock.getComponent(Flags.CLAD) + self.assertEqual( + self.assemblyLinkage.linkedComponents[plenumCladComp], [fuelCladComp, None] + ) + + def test_getLinkedComponent_runLogs(self): + """check runLogs get hit right""" + a = buildAxialExpAssembly.buildTestAssembly("HT9") + a[0].remove(a[0][1]) # remove clad from shield block + a[3].remove(a[3][1]) # remove clad from plenum block + with mockRunLogs.BufferLog() as mock: + _assemblyLinkage = AssemblyAxialLinkage(a) + self.assertIn("has nothing linked below it!", mock.getStdout()) + self.assertIn("has nothing linked above it!", mock.getStdout()) + + def test_getLinkedComponent_RuntimeError(self): + """Test for multiple component axial linkage.""" + # check the + a = buildAxialExpAssembly.buildTestAssembly("HT9") + shieldBlock = a[0] + shieldComp = shieldBlock[0] + shieldComp.setDimension("od", 0.785, cold=True) + with self.assertRaises(RuntimeError) as cm: + _assemblyLinkage = AssemblyAxialLinkage(a) + self.assertEqual(cm.exception, 3) + + class TestDetermineLinked(unittest.TestCase): """Test assemblyAxialLinkage.py::AssemblyAxialLinkage::_determineLinked for anticipated configrations diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 49502852e..04c460349 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -592,21 +592,6 @@ def test_isFuelLocked(self): the_exception = cm.exception self.assertEqual(the_exception.error_code, 3) - def test_determineLinked(self): - compDims = {"Tinput": 25.0, "Thot": 25.0} - compA = UnshapedComponent("unshaped_1", "FakeMat", **compDims) - compB = UnshapedComponent("unshaped_2", "FakeMat", **compDims) - self.assertFalse(AssemblyAxialLinkage._determineLinked(compA, compB)) - - def test_getLinkedComponents(self): - """Test for multiple component axial linkage.""" - shieldBlock = self.obj.linked.a[0] - shieldComp = shieldBlock[0] - shieldComp.setDimension("od", 0.785, cold=True) - with self.assertRaises(RuntimeError) as cm: - self.obj.linked._getLinkedComponents(shieldBlock, shieldComp) - self.assertEqual(cm.exception, 3) - class TestGetSolidComponents(unittest.TestCase): """Verify that getSolidComponents returns just solid components.""" From 278a5e840cbba66a8186cf097a31f9dcfce6044d Mon Sep 17 00:00:00 2001 From: aalberti Date: Sat, 14 Oct 2023 08:48:11 -0700 Subject: [PATCH 11/25] add back test for two unshaped comps --- .../axialExpansion/tests/test_assemblyAxialLinkage.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py index bac5c5e48..01e5a35c0 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py @@ -265,3 +265,14 @@ def test_unshapedComponentAndCircle(self): comp1 = Circle(*self.common, od=1.0, id=0.0) comp2 = UnshapedComponent(*self.common, area=1.0) self.assertFalse(AssemblyAxialLinkage._determineLinked(comp1, comp2)) + + def test_determineLinked(self): + comp1 = UnshapedComponent(*self.common, area=1.0) + comp2 = UnshapedComponent(*self.common, area=1.0) + with mockRunLogs.BufferLog() as mock: + linked = AssemblyAxialLinkage._determineLinked(comp1, comp2) + self.assertFalse(linked) + self.assertIn( + "nor is it physical to do so. Instead of crashing and raising an error, ", + mock.getStdout(), + ) From bbaa5c96723550b252b8c0cbe24183524544dcfb Mon Sep 17 00:00:00 2001 From: aalberti Date: Sat, 14 Oct 2023 09:04:52 -0700 Subject: [PATCH 12/25] mv and add unit test for setExpansionFactors --- .../tests/test_axialExpansionChanger.py | 22 ---------- .../tests/test_expansionData.py | 41 +++++++++++++++++++ 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 04c460349..7260fa85d 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -508,28 +508,6 @@ def test_isTopDummyBlockPresent(self): the_exception = cm.exception self.assertEqual(the_exception.error_code, 3) - def test_setExpansionFactors(self): - with self.assertRaises(RuntimeError) as cm: - cList = self.a[0].getChildren() - expansionGrowthFracs = range(len(cList) + 1) - self.obj.expansionData.setExpansionFactors(cList, expansionGrowthFracs) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - - with self.assertRaises(RuntimeError) as cm: - cList = self.a[0].getChildren() - expansionGrowthFracs = zeros(len(cList)) - self.obj.expansionData.setExpansionFactors(cList, expansionGrowthFracs) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - - with self.assertRaises(RuntimeError) as cm: - cList = self.a[0].getChildren() - expansionGrowthFracs = zeros(len(cList)) - 10.0 - self.obj.expansionData.setExpansionFactors(cList, expansionGrowthFracs) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - def test_updateComponentTempsBy1DTempFieldValueError(self): tempGrid = [5.0, 15.0, 35.0] tempField = linspace(25.0, 310.0, 3) diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index 3f37e82ec..c2d629596 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -1,9 +1,50 @@ import unittest +from numpy import zeros + from armi.reactor.flags import Flags from armi.reactor.blocks import HexBlock from armi.reactor.components import DerivedShape from armi.reactor.components.basicShapes import Circle, Hexagon from armi.reactor.converters.axialExpansion.expansionData import ExpansionData +from armi.reactor.converters.axialExpansion.tests.buildAxialExpAssembly import ( + buildTestAssembly, +) + + +class TestSetExpansionFactors(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.a = buildTestAssembly("HT9") + cls.expData = ExpansionData(cls.a, False, False) + + def test_setExpansionFactors(self): + cList = self.a[0].getChildren() + expansionGrowthFracs = range(1, len(cList) + 1) + self.expData.setExpansionFactors(cList, expansionGrowthFracs) + for c, expFrac in zip(cList, expansionGrowthFracs): + self.assertEqual(self.expData._expansionFactors[c], expFrac) + + def test_setExpansionFactors_Exceptions(self): + with self.assertRaises(RuntimeError) as cm: + cList = self.a[0].getChildren() + expansionGrowthFracs = range(len(cList) + 1) + self.expData.setExpansionFactors(cList, expansionGrowthFracs) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + with self.assertRaises(RuntimeError) as cm: + cList = self.a[0].getChildren() + expansionGrowthFracs = zeros(len(cList)) + self.expData.setExpansionFactors(cList, expansionGrowthFracs) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + with self.assertRaises(RuntimeError) as cm: + cList = self.a[0].getChildren() + expansionGrowthFracs = zeros(len(cList)) - 10.0 + self.expData.setExpansionFactors(cList, expansionGrowthFracs) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) class TestDetermineTargetComponent(unittest.TestCase): From 6f3b005b09c55cb3bc463fe2d7a07044ffa473d9 Mon Sep 17 00:00:00 2001 From: aalberti Date: Mon, 16 Oct 2023 12:42:23 -0700 Subject: [PATCH 13/25] add unit tests for updating comp temp --- .../tests/test_axialExpansionChanger.py | 24 ---------- .../tests/test_expansionData.py | 47 ++++++++++++++++++- 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 7260fa85d..0f11162b6 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -20,16 +20,12 @@ from armi.materials import custom from armi.reactor.assemblies import HexAssembly, grids from armi.reactor.blocks import HexBlock -from armi.reactor.components import UnshapedComponent from armi.reactor.components.basicShapes import Circle from armi.reactor.converters.axialExpansion import getSolidComponents from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( AxialExpansionChanger, ) from armi.reactor.converters.axialExpansion.expansionData import ExpansionData -from armi.reactor.converters.axialExpansion.assemblyAxialLinkage import ( - AssemblyAxialLinkage, -) from armi.reactor.converters.axialExpansion.tests import AxialExpansionTestBase from armi.reactor.converters.axialExpansion.tests.buildAxialExpAssembly import ( buildTestAssembly, @@ -508,26 +504,6 @@ def test_isTopDummyBlockPresent(self): the_exception = cm.exception self.assertEqual(the_exception.error_code, 3) - def test_updateComponentTempsBy1DTempFieldValueError(self): - tempGrid = [5.0, 15.0, 35.0] - tempField = linspace(25.0, 310.0, 3) - with self.assertRaises(ValueError) as cm: - self.obj.expansionData.updateComponentTempsBy1DTempField( - tempGrid, tempField - ) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - - def test_updateComponentTempsBy1DTempFieldRuntimeError(self): - tempGrid = [5.0, 15.0, 35.0] - tempField = linspace(25.0, 310.0, 10) - with self.assertRaises(RuntimeError) as cm: - self.obj.expansionData.updateComponentTempsBy1DTempField( - tempGrid, tempField - ) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - def test_AssemblyAxialExpansionException(self): """Test that negative height exception is caught.""" # manually set axial exp target component for code coverage diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index c2d629596..2aa03cfdb 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -1,5 +1,5 @@ import unittest -from numpy import zeros +from numpy import zeros, linspace, ones from armi.reactor.flags import Flags from armi.reactor.blocks import HexBlock @@ -47,6 +47,51 @@ def test_setExpansionFactors_Exceptions(self): self.assertEqual(the_exception.error_code, 3) +class TestUpdateComponentTemps(unittest.TestCase): + @classmethod + def setUpClass(cls): + a = buildTestAssembly("HT9") + cls.expData = ExpansionData(a, False, False) + + def test_updateComponentTemp(self): + newTemp = 250.0 + shieldB = self.expData._a[0] + shieldComp = shieldB.getComponent(Flags.SHIELD) + self.expData.updateComponentTemp(shieldComp, newTemp) + self.assertEqual( + self.expData.componentReferenceTemperature[shieldComp], + shieldComp.inputTemperatureInC, + ) + self.assertEqual(shieldComp.temperatureInC, newTemp) + + def test_updateComponentTempsBy1DTempField(self): + newTemp = 125.0 + bottom = self.expData._a[0].p.zbottom + top = self.expData._a[-1].p.ztop + tempGrid = linspace(bottom, top, 11) + tempField = ones(11) * newTemp + self.expData.updateComponentTempsBy1DTempField(tempGrid, tempField) + for b in self.expData._a: + for c in b: + self.assertEqual(c.temperatureInC, newTemp) + + def test_updateComponentTempsBy1DTempFieldValueError(self): + tempGrid = [5.0, 15.0, 35.0] + tempField = linspace(25.0, 310.0, 3) + with self.assertRaises(ValueError) as cm: + self.expData.updateComponentTempsBy1DTempField(tempGrid, tempField) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_updateComponentTempsBy1DTempFieldRuntimeError(self): + tempGrid = [5.0, 15.0, 35.0] + tempField = linspace(25.0, 310.0, 10) + with self.assertRaises(RuntimeError) as cm: + self.expData.updateComponentTempsBy1DTempField(tempGrid, tempField) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + class TestDetermineTargetComponent(unittest.TestCase): """Verify determineTargetComponent method is properly updating _componentDeterminesBlockHeight.""" From b5eca6c731053686482c25ee9edddbb68020049d Mon Sep 17 00:00:00 2001 From: aalberti Date: Mon, 16 Oct 2023 15:01:48 -0700 Subject: [PATCH 14/25] add unit tests for exp factors --- .../tests/test_expansionData.py | 54 ++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index 2aa03cfdb..c1cae217b 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -5,7 +5,11 @@ from armi.reactor.blocks import HexBlock from armi.reactor.components import DerivedShape from armi.reactor.components.basicShapes import Circle, Hexagon -from armi.reactor.converters.axialExpansion.expansionData import ExpansionData +from armi.reactor.converters.axialExpansion.tests import AxialExpansionTestBase +from armi.reactor.converters.axialExpansion.expansionData import ( + ExpansionData, + getSolidComponents, +) from armi.reactor.converters.axialExpansion.tests.buildAxialExpAssembly import ( buildTestAssembly, ) @@ -17,6 +21,14 @@ def setUpClass(cls): cls.a = buildTestAssembly("HT9") cls.expData = ExpansionData(cls.a, False, False) + def test_getExpansionFactor(self): + expansionFactor = 1.15 + shieldComp = self.a[0].getComponent(Flags.SHIELD) + cladComp = self.a[0].getComponent(Flags.CLAD) + self.expData.setExpansionFactors([shieldComp], [expansionFactor]) + self.assertEqual(self.expData.getExpansionFactor(shieldComp), expansionFactor) + self.assertEqual(self.expData.getExpansionFactor(cladComp), 1.0) + def test_setExpansionFactors(self): cList = self.a[0].getChildren() expansionGrowthFracs = range(1, len(cList) + 1) @@ -47,6 +59,46 @@ def test_setExpansionFactors_Exceptions(self): self.assertEqual(the_exception.error_code, 3) +class TestComputeThermalExpansionFactors(AxialExpansionTestBase): + @classmethod + def setUpClass(cls): + AxialExpansionTestBase.setUp(cls) + + @classmethod + def tearDownClass(cls): + return AxialExpansionTestBase.tearDown(cls) + + def setUp(self): + self.a = buildTestAssembly("FakeMat", hot=True) + + def test_computeThermalExpansionFactors_FromTinput2Thot(self): + """expand from Tinput to Thot""" + self.expData = ExpansionData(self.a, False, True) + self.expData.computeThermalExpansionFactors() + for b in self.a: + for c in getSolidComponents(b): + self.assertEqual(self.expData._expansionFactors[c], 1.044776119402985) + + def test_computeThermalExpansionFactors_NoRefTemp(self): + """occurs when not expanding from Tinput to Thot and no new temperature prescribed""" + self.expData = ExpansionData(self.a, False, False) + self.expData.computeThermalExpansionFactors() + for b in self.a: + for c in getSolidComponents(b): + self.assertEqual(self.expData._expansionFactors[c], 1.0) + + def test_computeThermalExpansionFactors_withRefTemp(self): + """occurs when expanding from some reference temp (not equal to Tinput) to Thot""" + self.expData = ExpansionData(self.a, False, False) + for b in self.a: + for c in getSolidComponents(b): + self.expData.updateComponentTemp(c, 175.0) + self.expData.computeThermalExpansionFactors() + for b in self.a: + for c in getSolidComponents(b): + self.assertEqual(self.expData._expansionFactors[c], 0.9857142857142858) + + class TestUpdateComponentTemps(unittest.TestCase): @classmethod def setUpClass(cls): From 7a7428e586cda7c14efe166c040a2451b762cd05 Mon Sep 17 00:00:00 2001 From: aalberti Date: Mon, 16 Oct 2023 15:37:38 -0700 Subject: [PATCH 15/25] add unit test for setTargetComp --- .../tests/test_axialExpansionChanger.py | 26 ----------- .../tests/test_expansionData.py | 46 +++++++++++++++++++ 2 files changed, 46 insertions(+), 26 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 0f11162b6..bedbd2f5b 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -520,32 +520,6 @@ def test_AssemblyAxialExpansionException(self): the_exception = cm.exception self.assertEqual(the_exception.error_code, 3) - def test_isFuelLocked(self): - """Ensures that the RuntimeError statement in ExpansionData::_isFuelLocked is raised appropriately. - - Notes - ----- - This is implemented by creating a fuel block that contains no fuel component - and passing it to ExpansionData::_isFuelLocked. - """ - expdata = ExpansionData( - HexAssembly("testAssemblyType"), setFuel=True, expandFromTinputToThot=False - ) - b_NoFuel = HexBlock("fuel", height=10.0) - shieldDims = { - "Tinput": 25.0, - "Thot": 25.0, - "od": 0.76, - "id": 0.00, - "mult": 127.0, - } - shield = Circle("shield", "FakeMat", **shieldDims) - b_NoFuel.add(shield) - with self.assertRaises(RuntimeError) as cm: - expdata._isFuelLocked(b_NoFuel) - the_exception = cm.exception - self.assertEqual(the_exception.error_code, 3) - class TestGetSolidComponents(unittest.TestCase): """Verify that getSolidComponents returns just solid components.""" diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index c1cae217b..27acedf70 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -144,6 +144,52 @@ def test_updateComponentTempsBy1DTempFieldRuntimeError(self): self.assertEqual(the_exception.error_code, 3) +class TestSetTargetComponents(unittest.TestCase): + """Runs through _setTargetComponents in the init and checks to make sure they're all set right + + Coverage for isTargetComponent is provided when querying each component for their target component + """ + + @classmethod + def setUpClass(cls): + cls.a = buildTestAssembly("HT9") + + def test_checkTargetComponents(self): + """make sure target components are set right. Skip the dummy block.""" + expData = ExpansionData(self.a, False, False) + for b in self.a[-1]: + for c in b: + if b.hasFlags(Flags.PLENUM): + if c.hasFlags(Flags.CLAD): + self.assertTrue(expData.isTargetComponent(c)) + else: + self.assertFalse(expData.isTargetComponent(c)) + else: + if c.p.flags == b.p.flags: + self.assertTrue(expData.isTargetComponent(c)) + else: + self.assertFalse(expData.isTargetComponent(c)) + + def test_isFuelLocked(self): + """Ensures that the RuntimeError statement in ExpansionData::_isFuelLocked is raised appropriately. + + Notes + ----- + This is implemented by modifying the fuel block to contain no fuel component + and passing it to ExpansionData::_isFuelLocked. + """ + expData = ExpansionData(self.a, False, False) + fuelBlock = self.a[1] + fuelComp = fuelBlock.getComponent(Flags.FUEL) + self.assertEqual(fuelBlock.p.axialExpTargetComponent, fuelComp.name) + ## Delete fuel comp and throw the error + fuelBlock.remove(fuelComp) + with self.assertRaises(RuntimeError) as cm: + expData._isFuelLocked(fuelBlock) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + class TestDetermineTargetComponent(unittest.TestCase): """Verify determineTargetComponent method is properly updating _componentDeterminesBlockHeight.""" From fd6fb5843ef2b5421882efe29c73373cec86dd6a Mon Sep 17 00:00:00 2001 From: aalberti Date: Mon, 16 Oct 2023 16:08:45 -0700 Subject: [PATCH 16/25] black formatting --- armi/reactor/blueprints/__init__.py | 4 +++- armi/reactor/tests/test_reactors.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index 8a31aadc7..77fc7b40a 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -107,7 +107,9 @@ from armi.reactor.blueprints.componentBlueprint import ComponentKeyedList from armi.reactor.blueprints.gridBlueprint import Grids, Triplet from armi.reactor.blueprints.reactorBlueprint import Systems, SystemBlueprint -from armi.reactor.converters.axialExpansion.axialExpansionChanger import expandColdDimsToHot +from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( + expandColdDimsToHot, +) from armi.reactor.converters.axialExpansion import makeAssemsAbleToSnapToUniformMesh context.BLUEPRINTS_IMPORTED = True diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py index 1af12ff2f..6c1612a5a 100644 --- a/armi/reactor/tests/test_reactors.py +++ b/armi/reactor/tests/test_reactors.py @@ -33,7 +33,9 @@ from armi.reactor import reactors from armi.reactor.components import Hexagon, Rectangle from armi.reactor.converters import geometryConverters -from armi.reactor.converters.axialExpansion.axialExpansionChanger import AxialExpansionChanger +from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( + AxialExpansionChanger, +) from armi.reactor.flags import Flags from armi.settings.fwSettings.globalSettings import CONF_ASSEM_FLAGS_SKIP_AXIAL_EXP from armi.settings.fwSettings.globalSettings import CONF_SORT_REACTOR From acd932bc13ebb161a0a3f590e50f5e39ebd862a6 Mon Sep 17 00:00:00 2001 From: aalberti Date: Mon, 16 Oct 2023 16:13:10 -0700 Subject: [PATCH 17/25] fix license headers --- armi/reactor/converters/axialExpansion/__init__.py | 1 + .../axialExpansion/axialExpansionChanger.py | 1 + .../converters/axialExpansion/expansionData.py | 1 + .../axialExpansion/tests/buildAxialExpAssembly.py | 1 + .../tests/test_assemblyAxialLinkage.py | 1 + .../axialExpansion/tests/test_expansionData.py | 14 ++++++++++++++ 6 files changed, 19 insertions(+) diff --git a/armi/reactor/converters/axialExpansion/__init__.py b/armi/reactor/converters/axialExpansion/__init__.py index 2a6d82dbb..400e8010f 100644 --- a/armi/reactor/converters/axialExpansion/__init__.py +++ b/armi/reactor/converters/axialExpansion/__init__.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. + """Particles! Expand axially!.""" from armi.materials import material diff --git a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py index 3467f0457..b62c72174 100644 --- a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/axialExpansionChanger.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. + """Enable component-wise axial expansion for assemblies and/or a reactor.""" from armi import runLog diff --git a/armi/reactor/converters/axialExpansion/expansionData.py b/armi/reactor/converters/axialExpansion/expansionData.py index eeb6c7539..d4b1f38c3 100644 --- a/armi/reactor/converters/axialExpansion/expansionData.py +++ b/armi/reactor/converters/axialExpansion/expansionData.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 statistics import mean from typing import List diff --git a/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py index a394c9b08..1e48535fd 100644 --- a/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py +++ b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.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 armi import materials from armi.utils import units from armi.reactor.assemblies import HexAssembly, grids diff --git a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py index 01e5a35c0..2cdcfe171 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.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. + import unittest from armi.tests import mockRunLogs from armi.reactor.flags import Flags diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index 27acedf70..0d2d060e4 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -1,3 +1,17 @@ +# Copyright 2023 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. + import unittest from numpy import zeros, linspace, ones From 79b551d537d339ee54546949c7e6dc7c53e26aca Mon Sep 17 00:00:00 2001 From: aalberti Date: Tue, 17 Oct 2023 07:31:39 -0700 Subject: [PATCH 18/25] fix axial exp unit tests.... --- .../tests/test_axialExpansionChanger.py | 14 +++++++------- .../axialExpansion/tests/test_expansionData.py | 12 ++++-------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index bedbd2f5b..13320672e 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -99,7 +99,7 @@ class TestAxialExpansionHeight(AxialExpansionTestBase): def setUp(self): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssembly(name="FakeMat") + self.a = buildTestAssembly(materialName="FakeMat") self.temp = Temperature( self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10 @@ -131,7 +131,7 @@ def test_AssemblyAxialExpansionHeight(self): def _generateComponentWiseExpectedHeight(self): """Calculate the expected height, external of AssemblyAxialExpansion().""" - assem = buildTestAssembly(name="FakeMat") + assem = buildTestAssembly(materialName="FakeMat") aveBlockTemp = zeros((len(assem), self.temp.tempSteps)) self.trueZtop = zeros((len(assem), self.temp.tempSteps)) self.trueHeight = zeros((len(assem), self.temp.tempSteps)) @@ -173,7 +173,7 @@ class TestConservation(AxialExpansionTestBase): def setUp(self): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssembly(name="FakeMat") + self.a = buildTestAssembly(materialName="FakeMat") def tearDown(self): AxialExpansionTestBase.tearDown(self) @@ -198,7 +198,7 @@ def test_ThermalExpansionContractionConservation_Simple(self): Temperature field is always isothermal and initially at 25 C. """ isothermalTempList = [100.0, 350.0, 250.0, 25.0] - a = buildTestAssembly(name="HT9") + a = buildTestAssembly(materialName="HT9") origMesh = a.getAxialMesh()[:-1] origMasses, origNDens = self._getComponentMassAndNDens(a) axialExpChngr = AxialExpansionChanger(detailedAxialExpansion=True) @@ -300,7 +300,7 @@ def test_PrescribedExpansionContractionConservation(self): - uniform expansion over all components within the assembly - 10 total expansion steps: 5 at +1.01 L1/L0, and 5 at -(1.01^-1) L1/L0 """ - a = buildTestAssembly(name="FakeMat") + a = buildTestAssembly(materialName="FakeMat") axExpChngr = AxialExpansionChanger() origMesh = a.getAxialMesh() origMasses, origNDens = self._getComponentMassAndNDens(a) @@ -483,7 +483,7 @@ class TestExceptions(AxialExpansionTestBase): def setUp(self): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssembly(name="FakeMatException") + self.a = buildTestAssembly(materialName="FakeMatException") self.obj.setAssembly(self.a) def tearDown(self): @@ -525,7 +525,7 @@ class TestGetSolidComponents(unittest.TestCase): """Verify that getSolidComponents returns just solid components.""" def setUp(self): - self.a = buildTestAssembly(name="HT9") + self.a = buildTestAssembly(materialName="HT9") def test_getSolidComponents(self): for b in self.a: diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index 0d2d060e4..b5e186bf3 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -74,17 +74,13 @@ def test_setExpansionFactors_Exceptions(self): class TestComputeThermalExpansionFactors(AxialExpansionTestBase): - @classmethod - def setUpClass(cls): - AxialExpansionTestBase.setUp(cls) - - @classmethod - def tearDownClass(cls): - return AxialExpansionTestBase.tearDown(cls) - def setUp(self): + AxialExpansionTestBase.setUp(self) self.a = buildTestAssembly("FakeMat", hot=True) + def tearDown(self): + AxialExpansionTestBase.tearDown(self) + def test_computeThermalExpansionFactors_FromTinput2Thot(self): """expand from Tinput to Thot""" self.expData = ExpansionData(self.a, False, True) From d904bfe522c8805beafab5e5efc6454777369c4e Mon Sep 17 00:00:00 2001 From: aalberti Date: Fri, 22 Mar 2024 15:48:36 -0700 Subject: [PATCH 19/25] update feature branch with main --- .github/pull_request_template.md | 12 +- .github/workflows/coverage.yaml | 4 +- .github/workflows/find_test_crumbs.py | 53 ++ .github/workflows/linting.yaml | 1 - .github/workflows/wintests.yaml | 2 + README.rst | 14 +- armi/__init__.py | 27 +- armi/apps.py | 64 +- armi/bookkeeping/__init__.py | 3 - armi/bookkeeping/db/__init__.py | 83 -- armi/bookkeeping/db/database3.py | 102 ++- armi/bookkeeping/db/databaseInterface.py | 20 +- armi/bookkeeping/db/layout.py | 22 +- armi/bookkeeping/db/tests/__init__.py | 13 - armi/bookkeeping/db/tests/test_comparedb3.py | 2 +- armi/bookkeeping/db/tests/test_database3.py | 60 +- .../db/tests/test_databaseInterface.py | 54 +- armi/bookkeeping/historyTracker.py | 20 +- armi/bookkeeping/report/newReportUtils.py | 7 +- armi/bookkeeping/report/reportingUtils.py | 38 +- armi/bookkeeping/report/tests/test_report.py | 11 + armi/bookkeeping/snapshotInterface.py | 16 +- armi/bookkeeping/tests/test_historyTracker.py | 61 +- armi/bookkeeping/tests/test_snapshot.py | 14 + armi/cases/__init__.py | 2 +- armi/cases/case.py | 58 +- armi/cases/inputModifiers/inputModifiers.py | 32 +- armi/cases/suite.py | 36 +- armi/cases/suiteBuilder.py | 45 +- armi/cases/tests/test_cases.py | 138 +++- armi/cases/tests/test_suiteBuilder.py | 103 ++- armi/cli/__init__.py | 19 +- armi/cli/database.py | 59 -- armi/cli/entryPoint.py | 19 +- armi/cli/modify.py | 2 +- armi/cli/tests/test_runEntryPoint.py | 53 +- armi/cli/tests/test_runSuite.py | 29 +- armi/interfaces.py | 87 ++- armi/materials/__init__.py | 75 +- armi/materials/material.py | 45 +- armi/materials/mixture.py | 4 +- armi/materials/tests/test_air.py | 7 +- armi/materials/tests/test_materials.py | 136 +++- armi/materials/tests/test_uZr.py | 94 ++- armi/materials/tests/test_water.py | 4 + armi/materials/thU.py | 4 +- armi/materials/thorium.py | 4 +- armi/materials/thoriumOxide.py | 4 +- armi/materials/uraniumOxide.py | 9 +- armi/materials/void.py | 13 + armi/materials/zr.py | 6 +- armi/nucDirectory/elements.py | 106 ++- armi/nucDirectory/nucDir.py | 22 +- armi/nucDirectory/nuclideBases.py | 214 ++++-- armi/nucDirectory/tests/test_elements.py | 28 + armi/nucDirectory/tests/test_nuclideBases.py | 49 +- armi/nuclearDataIO/cccc/cccc.py | 75 +- armi/nuclearDataIO/cccc/compxs.py | 5 +- armi/nuclearDataIO/cccc/dif3d.py | 42 +- armi/nuclearDataIO/cccc/dlayxs.py | 45 +- armi/nuclearDataIO/cccc/gamiso.py | 17 +- armi/nuclearDataIO/cccc/geodst.py | 44 +- armi/nuclearDataIO/cccc/isotxs.py | 67 +- armi/nuclearDataIO/cccc/nhflux.py | 150 ++-- armi/nuclearDataIO/cccc/pmatrx.py | 33 +- armi/nuclearDataIO/cccc/tests/test_dif3d.py | 22 +- armi/nuclearDataIO/cccc/tests/test_dlayxs.py | 20 +- armi/nuclearDataIO/cccc/tests/test_gamiso.py | 19 + armi/nuclearDataIO/cccc/tests/test_geodst.py | 14 +- armi/nuclearDataIO/cccc/tests/test_isotxs.py | 34 + armi/nuclearDataIO/cccc/tests/test_pmatrx.py | 9 +- .../nuclearDataIO/tests/test_xsCollections.py | 10 + armi/nuclearDataIO/tests/test_xsLibraries.py | 11 +- armi/nuclearDataIO/xsCollections.py | 56 +- armi/nuclearDataIO/xsNuclides.py | 12 +- armi/operators/operator.py | 223 ++++-- armi/operators/operatorMPI.py | 23 +- armi/operators/settingsValidation.py | 45 +- armi/operators/tests/test_inspectors.py | 4 + armi/operators/tests/test_operators.py | 204 ++++- armi/physics/constants.py | 2 + armi/physics/executers.py | 39 +- armi/physics/fuelCycle/__init__.py | 1 + .../physics/fuelCycle/fuelHandlerInterface.py | 22 + armi/physics/fuelCycle/fuelHandlers.py | 123 ++- .../tests/test_assemblyRotationAlgorithms.py | 1 + .../fuelCycle/tests/test_fuelHandlers.py | 104 ++- armi/physics/fuelPerformance/plugin.py | 1 + armi/physics/neutronics/__init__.py | 4 + .../neutronics/crossSectionGroupManager.py | 123 ++- .../neutronics/crossSectionSettings.py | 4 +- armi/physics/neutronics/energyGroups.py | 38 +- .../fissionProductModelSettings.py | 1 + .../lumpedFissionProduct.py | 4 +- .../tests/test_fissionProductModel.py | 29 +- .../globalFlux/globalFluxInterface.py | 147 +++- .../tests/test_globalFluxInterface.py | 116 ++- .../isotopicDepletion/crossSectionTable.py | 75 +- .../isotopicDepletionInterface.py | 19 +- .../tests/test_latticeInterface.py | 27 +- .../neutronics/macroXSGenerationInterface.py | 54 +- armi/physics/neutronics/parameters.py | 5 +- armi/physics/neutronics/settings.py | 64 +- .../tests/test_crossSectionManager.py | 132 ++-- .../tests/test_crossSectionSettings.py | 2 +- .../tests/test_crossSectionTable.py | 13 + .../neutronics/tests/test_energyGroups.py | 30 +- .../tests/test_macroXSGenerationInterface.py | 39 +- .../neutronics/tests/test_neutronicsPlugin.py | 2 +- armi/physics/safety/__init__.py | 1 + armi/physics/tests/test_executers.py | 82 +- armi/physics/thermalHydraulics/plugin.py | 1 + armi/plugins.py | 103 ++- armi/reactor/__init__.py | 23 +- armi/reactor/assemblies.py | 150 +++- armi/reactor/assemblyParameters.py | 42 - armi/reactor/blockParameters.py | 305 ++------ armi/reactor/blocks.py | 274 ++++++- armi/reactor/blueprints/__init__.py | 6 +- armi/reactor/blueprints/assemblyBlueprint.py | 47 +- armi/reactor/blueprints/blockBlueprint.py | 33 +- armi/reactor/blueprints/componentBlueprint.py | 66 +- armi/reactor/blueprints/gridBlueprint.py | 54 +- armi/reactor/blueprints/isotopicOptions.py | 90 ++- armi/reactor/blueprints/reactorBlueprint.py | 22 +- .../tests/test_assemblyBlueprints.py | 7 + .../blueprints/tests/test_blockBlueprints.py | 20 +- .../blueprints/tests/test_blueprints.py | 74 +- .../tests/test_componentBlueprint.py | 4 +- .../blueprints/tests/test_customIsotopics.py | 36 +- .../blueprints/tests/test_gridBlueprints.py | 32 +- .../tests/test_materialModifications.py | 21 + .../tests/test_reactorBlueprints.py | 16 +- armi/reactor/components/__init__.py | 25 +- armi/reactor/components/basicShapes.py | 59 +- armi/reactor/components/complexShapes.py | 35 +- armi/reactor/components/component.py | 136 +++- armi/reactor/composites.py | 289 +++++-- .../axialExpansion/axialExpansionChanger.py | 62 +- armi/reactor/converters/blockConverters.py | 86 ++- armi/reactor/converters/geometryConverters.py | 180 +++-- .../converters/tests/test_blockConverter.py | 37 +- .../tests/test_geometryConverters.py | 134 +++- .../converters/tests/test_uniformMesh.py | 68 +- armi/reactor/converters/uniformMesh.py | 121 ++- armi/reactor/flags.py | 30 + armi/reactor/grids/__init__.py | 2 +- armi/reactor/grids/axial.py | 2 +- armi/reactor/grids/cartesian.py | 2 +- armi/reactor/grids/grid.py | 42 +- armi/reactor/grids/hexagonal.py | 324 +++++--- armi/reactor/grids/locations.py | 47 +- .../{structuredgrid.py => structuredGrid.py} | 39 +- armi/reactor/grids/tests/test_grids.py | 178 ++++- armi/reactor/grids/thetarz.py | 2 +- armi/reactor/parameters/__init__.py | 2 +- .../parameters/parameterCollections.py | 2 +- .../parameters/parameterDefinitions.py | 47 +- armi/reactor/reactorParameters.py | 76 +- armi/reactor/reactors.py | 344 ++++++--- armi/reactor/systemLayoutInput.py | 2 +- armi/reactor/tests/test_assemblies.py | 142 +++- armi/reactor/tests/test_blocks.py | 172 ++++- armi/reactor/tests/test_components.py | 324 +++++++- armi/reactor/tests/test_composites.py | 136 +++- armi/reactor/tests/test_flags.py | 10 +- armi/reactor/tests/test_parameters.py | 284 +------ armi/reactor/tests/test_reactors.py | 268 ++++++- armi/reactor/tests/test_rz_reactors.py | 15 +- armi/reactor/tests/test_zones.py | 35 + armi/reactor/zones.py | 43 +- armi/runLog.py | 53 ++ armi/settings/caseSettings.py | 33 +- armi/settings/fwSettings/databaseSettings.py | 1 + armi/settings/fwSettings/globalSettings.py | 40 +- armi/settings/fwSettings/reportSettings.py | 1 + .../fwSettings/tightCouplingSettings.py | 2 +- armi/settings/setting.py | 32 +- armi/settings/settingsIO.py | 15 +- armi/settings/tests/test_settings.py | 72 +- armi/settings/tests/test_settingsIO.py | 13 +- armi/tests/Godiva-blueprints.yaml | 220 ++---- armi/tests/test_apps.py | 18 +- armi/tests/test_interfaces.py | 11 + armi/tests/test_lwrInputs.py | 5 +- armi/tests/test_mpiFeatures.py | 21 +- armi/tests/test_mpiParameters.py | 116 +++ armi/tests/test_notebooks.py | 3 + armi/tests/test_plugins.py | 165 +++- armi/tests/test_runLog.py | 89 ++- armi/tests/test_tests.py | 2 +- armi/tests/test_user_plugins.py | 8 + armi/tests/tutorials/data_model.ipynb | 2 +- armi/utils/asciimaps.py | 72 +- armi/utils/codeTiming.py | 5 +- armi/utils/densityTools.py | 64 +- armi/utils/dochelpers.py | 307 -------- armi/utils/flags.py | 35 +- armi/utils/hexagon.py | 35 +- armi/utils/mathematics.py | 2 +- armi/utils/plotting.py | 83 -- armi/utils/reportPlotting.py | 2 - armi/utils/tests/test_asciimaps.py | 113 ++- armi/utils/tests/test_densityTools.py | 53 +- armi/utils/tests/test_directoryChangers.py | 3 +- armi/utils/tests/test_dochelpers.py | 75 -- armi/utils/tests/test_flags.py | 34 +- armi/utils/tests/test_hexagon.py | 45 ++ armi/utils/tests/test_reportPlotting.py | 4 - armi/utils/tests/test_utils.py | 12 + doc/.static/__init__.py | 14 + doc/.static/dochelpers.py | 144 ++++ .../looseCouplingIllustration.dot | 0 .../tightCouplingIllustration.dot | 0 doc/conf.py | 144 +++- doc/developer/documenting.rst | 20 +- doc/developer/entrypoints.rst | 3 +- doc/developer/first_time_contributors.rst | 8 +- doc/developer/guide.rst | 40 +- doc/developer/making_armi_based_apps.rst | 17 +- doc/developer/parallel_coding.rst | 111 +-- doc/developer/profiling.rst | 3 +- doc/developer/reports.rst | 30 +- doc/developer/standards_and_practices.rst | 2 + doc/developer/tooling.rst | 63 +- doc/release/0.1.rst | 4 +- doc/release/0.2.rst | 23 +- doc/release/0.3.rst | 55 ++ doc/release/index.rst | 3 +- doc/tutorials/armi-example-app | 1 + doc/tutorials/index.rst | 2 + doc/tutorials/making_your_first_app.rst | 8 +- doc/tutorials/walkthrough_inputs.rst | 8 +- doc/tutorials/walkthrough_lwr_inputs.rst | 6 +- doc/user/accessingEntryPoints.rst | 6 +- doc/user/assembly_parameters_report.rst | 10 +- doc/user/block_parameters_report.rst | 8 +- doc/user/component_parameters_report.rst | 8 +- doc/user/core_parameters_report.rst | 9 +- doc/user/index.rst | 4 +- .../{inputs/blueprints.rst => inputs.rst} | 718 +++++++++++++++++- doc/user/inputs/fuel_management.rst | 194 ----- doc/user/inputs/index.rst | 39 - doc/user/inputs/settings.rst | 387 ---------- doc/user/inputs/settings_report.rst | 30 - doc/user/manual_data_access.rst | 16 +- .../{outputs/database.rst => outputs.rst} | 83 +- doc/user/outputs/index.rst | 31 - doc/user/outputs/stdout.rst | 43 -- doc/user/physics_coupling.rst | 18 +- doc/user/radial_and_axial_expansion.rst | 28 +- doc/user/reactor_parameters_report.rst | 9 +- doc/user/user_install.rst | 36 +- pyproject.toml | 71 +- tox.ini | 10 +- 255 files changed, 10041 insertions(+), 4217 deletions(-) create mode 100644 .github/workflows/find_test_crumbs.py rename armi/reactor/grids/{structuredgrid.py => structuredGrid.py} (92%) create mode 100644 armi/tests/test_mpiParameters.py delete mode 100644 armi/utils/dochelpers.py delete mode 100644 armi/utils/tests/test_dochelpers.py create mode 100644 armi/utils/tests/test_hexagon.py create mode 100644 doc/.static/__init__.py create mode 100644 doc/.static/dochelpers.py rename doc/{user/inputs => .static}/looseCouplingIllustration.dot (100%) rename doc/{user/inputs => .static}/tightCouplingIllustration.dot (100%) create mode 100644 doc/release/0.3.rst create mode 160000 doc/tutorials/armi-example-app rename doc/user/{inputs/blueprints.rst => inputs.rst} (55%) delete mode 100644 doc/user/inputs/fuel_management.rst delete mode 100644 doc/user/inputs/index.rst delete mode 100644 doc/user/inputs/settings.rst delete mode 100644 doc/user/inputs/settings_report.rst rename doc/user/{outputs/database.rst => outputs.rst} (70%) delete mode 100644 doc/user/outputs/index.rst delete mode 100644 doc/user/outputs/stdout.rst diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c13378ea6..6600a0c6b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -7,18 +7,19 @@ + --- ## Checklist - [ ] This PR has only [one purpose or idea](https://terrapower.github.io/armi/developer/tooling.html#one-idea-one-pr). -- [ ] [Tests](https://terrapower.github.io/armi/developer/tooling.html#test-it) have been added/updated to verify that the new/changed code works. +- [ ] [Tests](https://terrapower.github.io/armi/developer/tooling.html#test-it) have been added/updated to verify any new/changed code. @@ -27,7 +28,6 @@ -- [ ] The [release notes](https://terrapower.github.io/armi/release/index.html) (location `doc/release/0.X.rst`) are up-to-date with any important changes. +- [ ] The [release notes](https://terrapower.github.io/armi/developer/tooling.html#add-release-notes) have been updated if necessary. - [ ] The [documentation](https://terrapower.github.io/armi/developer/tooling.html#document-it) is still up-to-date in the `doc` folder. -- [ ] No [requirements](https://terrapower.github.io/armi/developer/tooling.html#watch-for-requirements) were altered. -- [ ] The dependencies are still up-to-date in `pyproject.toml`. +- [ ] The dependencies are still up-to-date in `pyproject.toml`. \ No newline at end of file diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 114f5acd5..0effcb038 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -29,9 +29,9 @@ jobs: - name: Install Tox and any other packages run: pip install tox - name: Run Coverage Part 1 - run: tox -e cov1 || true + run: tox -e cov1 - name: Run Coverage Part 2 - run: tox -e cov2 + run: tox -e cov2 || true - name: Publish to coveralls.io uses: coverallsapp/github-action@v1.1.2 with: diff --git a/.github/workflows/find_test_crumbs.py b/.github/workflows/find_test_crumbs.py new file mode 100644 index 000000000..a8bc9d296 --- /dev/null +++ b/.github/workflows/find_test_crumbs.py @@ -0,0 +1,53 @@ +# 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. + +"""This script exists so we can determine if new tests in CI are leaving crumbs.""" + +import subprocess + +# A list of objects we expect during a run, and don't mind (like pycache dirs). +IGNORED_OBJECTS = [ + ".pytest_cache", + ".tox", + "__pycache__", + "armi.egg-info", + "armi/logs/ARMI.armiRun.", + "armi/logs/armiRun.mpi.log", + "armi/tests/tutorials/case-suite/", + "armi/tests/tutorials/logs/", +] + + +def main(): + # use "git clean" to find all non-tracked files + proc = subprocess.Popen(["git", "clean", "-xnd"], stdout=subprocess.PIPE) + lines = proc.communicate()[0].decode("utf-8").split("\n") + + # clean up the whitespace + lines = [ln.strip() for ln in lines if len(ln.strip())] + + # ignore certain untracked object, like __pycache__ dirs + for ignore in IGNORED_OBJECTS: + lines = [ln for ln in lines if ignore not in ln] + + # fail hard if there are still untracked files + if len(lines): + for line in lines: + print(line) + + raise ValueError("The workspace is dirty; the tests are leaving crumbs!") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml index 28c960695..af340c393 100644 --- a/.github/workflows/linting.yaml +++ b/.github/workflows/linting.yaml @@ -18,5 +18,4 @@ jobs: - name: Install Tox and any other packages run: pip install tox - name: Run Linter - continue-on-error: true run: tox -e lint diff --git a/.github/workflows/wintests.yaml b/.github/workflows/wintests.yaml index 354ae0813..9faf86900 100644 --- a/.github/workflows/wintests.yaml +++ b/.github/workflows/wintests.yaml @@ -25,3 +25,5 @@ jobs: run: python -m pip install tox tox-gh-actions - name: Run Tox run: tox -e test + - name: Find Test Crumbs + run: python .github/workflows/find_test_crumbs.py diff --git a/README.rst b/README.rst index a5a9e43f7..b7b03454b 100644 --- a/README.rst +++ b/README.rst @@ -68,17 +68,21 @@ commands. You probably want to do this in a virtual environment as described in documentation `_. Otherwise, the dependencies could conflict with your system dependencies. -:: +First, upgrade your version of pip:: + + $ pip install pip>=22.1 + +Now clone and install ARMI:: $ git clone https://github.com/terrapower/armi $ cd armi $ pip install -e . - $ armi + $ armi --help The easiest way to run the tests is to install `tox `_ and then run:: - $ pip install -e .[test] + $ pip install -e ".[test]" $ tox -- -n 6 This runs the unit tests in parallel on 6 processes. Omit the ``-n 6`` argument @@ -86,7 +90,7 @@ to run on a single process. The tests can also be run directly, using ``pytest``:: - $ pip install -e .[test] + $ pip install -e ".[test]" $ pytest -n 4 armi From here, we recommend going through a few of our `gallery examples @@ -405,7 +409,7 @@ The ARMI system is licensed as follows: .. code-block:: none - Copyright 2009-2023 TerraPower, LLC + Copyright 2009-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. diff --git a/armi/__init__.py b/armi/__init__.py index abb40fff0..c3603ecc4 100644 --- a/armi/__init__.py +++ b/armi/__init__.py @@ -1,4 +1,4 @@ -# Copyright 2009-2019 TerraPower, LLC +# Copyright 2019 TerraPower, LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -118,22 +118,35 @@ def init(choice=None, fName=None, cs=None): """ Scan a directory for armi inputs and load one to interact with. + .. impl:: Settings are used to define an ARMI run. + :id: I_ARMI_SETTING1 + :implements: R_ARMI_SETTING + + This method initializes an ARMI run, and if successful returns an Operator. + That operator is designed to drive the reactor simulation through time steps to + simulate its operation. This method takes in a settings file or object to + initialize the operator. Whether a settings file or object is supplied, the + operator will be built based on the those settings. Because the total + collection of settings can be modified by developers of ARMI applications, + providing these settings allow ARMI end-users to define their simulation as + granularly as they need. + Parameters ---------- choice : int, optional - Automatically run with this item out of the menu - that would be produced of existing xml files. + Automatically run with this item out of the menu that would be produced by the + existing YAML files. fName : str, optional - An actual case name to load. e.g. ntTwr1.xml + The path to a settings file to load: my_case.yaml - cs : object, optional - If supplied, supercede the other case input methods and use the object directly + cs : Settings, optional + If supplied, this CS object will supercede the other case input methods and use + the object directly. Examples -------- >>> o = armi.init() - """ from armi import cases from armi import settings diff --git a/armi/apps.py b/armi/apps.py index cdd83be0e..22b620239 100644 --- a/armi/apps.py +++ b/armi/apps.py @@ -19,13 +19,14 @@ Framework for a specific application. An ``App`` implements a simple interface for customizing much of the Framework's behavior. -.. admonition:: Historical Fun Fact - - This pattern is used by many frameworks as a way of encapsulating what would - otherwise be global state. The ARMI Framework has historically made heavy use of - global state (e.g., :py:mod:`armi.nucDirectory.nuclideBases`), and it will take - quite a bit of effort to refactor the code to access such things through an App - object. +Notes +----- +Historical Fun Fact + +This pattern is used by many frameworks as a way of encapsulating what would otherwise be global +state. The ARMI Framework has historically made heavy use of global state (e.g., +:py:mod:`armi.nucDirectory.nuclideBases`), and it will take quite a bit of effort to refactor the +code to access such things through an App object. """ # ruff: noqa: E402 from typing import Dict, Optional, Tuple, List @@ -42,19 +43,23 @@ class App: """ - The main point of customization for the ARMI Framework. - - The App class is intended to be subclassed in order to customize the functionality - and look-and-feel of the ARMI Framework for a specific use case. An App contains a - plugin manager, which should be populated in ``__init__()`` with a collection of - plugins that are deemed suitable for a given application, as well as other methods - which provide further customization. - - The base App class is also a good place to expose some more convenient ways to get - data out of the Plugin API; calling the ``pluggy`` hooks directly can sometimes be a - pain, as the results returned by the individual plugins may need to be merged and/or - checked for errors. Adding that logic here reduces boilerplate throughout the rest - of the code. + The highest-level of abstraction for defining what happens during an ARMI run. + + .. impl:: An App has a plugin manager. + :id: I_ARMI_APP_PLUGINS + :implements: R_ARMI_APP_PLUGINS + + The App class is intended to be subclassed in order to customize the functionality + and look-and-feel of the ARMI Framework for a specific use case. An App contains a + plugin manager, which should be populated in ``__init__()`` with a collection of + plugins that are deemed suitable for a given application, as well as other methods + which provide further customization. + + The base App class is also a good place to expose some more convenient ways to get + data out of the Plugin API; calling the ``pluggy`` hooks directly can sometimes be a + pain, as the results returned by the individual plugins may need to be merged and/or + checked for errors. Adding that logic here reduces boilerplate throughout the rest + of the code. """ name = "armi" @@ -120,7 +125,24 @@ def pluginManager(self) -> pluginManager.ArmiPluginManager: return self._pm def getSettings(self) -> Dict[str, Setting]: - """Return a dictionary containing all Settings defined by the framework and all plugins.""" + """ + Return a dictionary containing all Settings defined by the framework and all plugins. + + .. impl:: Applications will not allow duplicate settings. + :id: I_ARMI_SETTINGS_UNIQUE + :implements: R_ARMI_SETTINGS_UNIQUE + + Each ARMI application includes a collection of Plugins. Among other + things, these plugins can register new settings in addition to + the default settings that come with ARMI. This feature provides a + lot of utility, so application developers can easily configure + their ARMI appliction in customizable ways. + + However, it would get confusing if two different plugins registered + a setting with the same name string. Or if a plugin registered a + setting with the same name as an ARMI default setting. So this + method throws an error if such a situation arises. + """ # Start with framework settings settingDefs = { setting.name: setting for setting in fwSettings.getFrameworkSettings() diff --git a/armi/bookkeeping/__init__.py b/armi/bookkeeping/__init__.py index 0e30a7fff..00c8f149d 100644 --- a/armi/bookkeeping/__init__.py +++ b/armi/bookkeeping/__init__.py @@ -44,9 +44,6 @@ def defineEntryPoints(): from armi.cli import database entryPoints = [] - # Disabling ConvertDB because there is no other format to convert between. The - # entry point is rather general so leaving this here so we don't forget about it - # entryPoints.append(database.ConvertDB) entryPoints.append(database.ExtractInputs) entryPoints.append(database.InjectInputs) entryPoints.append(visualization.VisFileEntryPoint) diff --git a/armi/bookkeeping/db/__init__.py b/armi/bookkeeping/db/__init__.py index 336d4c85d..ca0f5b4ed 100644 --- a/armi/bookkeeping/db/__init__.py +++ b/armi/bookkeeping/db/__init__.py @@ -60,12 +60,10 @@ location, without having to compose the full model. """ import os -from typing import Optional, List, Tuple from armi import runLog # re-export package components for easier import -from armi.bookkeeping.db.permissions import Permissions from armi.bookkeeping.db.database3 import Database3 from armi.bookkeeping.db.databaseInterface import DatabaseInterface from armi.bookkeeping.db.compareDB3 import compareDatabases @@ -154,87 +152,6 @@ def loadOperator(pathToDb, loadCycle, loadNode, allowMissing=False): return o -def convertDatabase( - inputDBName: str, - outputDBName: Optional[str] = None, - outputVersion: Optional[str] = None, - nodes: Optional[List[Tuple[int, int]]] = None, -): - """ - Convert database files between different versions. - - Parameters - ---------- - inputDB - name of the complete hierarchy database - outputDB - name of the output database that should be consistent with XTView - outputVersion - version of the database to convert to. Defaults to latest version - nodes - optional list of specific (cycle,node)s to convert - """ - dbIn = databaseFactory(inputDBName, permission=Permissions.READ_ONLY_FME) - - if dbIn.version == outputVersion: - runLog.important( - "The input database ({}) appears to already be in the desired " - "format ({})".format(inputDBName, dbIn.version) - ) - return - - outputDBName = outputDBName or "-converted".join(os.path.splitext(inputDBName)) - dbOut = databaseFactory( - outputDBName, permission=Permissions.CREATE_FILE_TIE, version=outputVersion - ) - # each DB load resets the verbosity to that of the run. Here we allow - # conversion users to overpower it. - conversionVerbosity = runLog.getVerbosity() - runLog.extra(f"Converting {dbIn} to DB version {outputVersion}") - with dbIn, dbOut: - dbNodes = list(dbIn.genTimeSteps()) - - if nodes is not None and any(node not in dbNodes for node in nodes): - raise RuntimeError( - "Some of the requested nodes are not in the source database.\n" - "Requested: {}\n" - "Present: {}".format(nodes, dbNodes) - ) - - # Making the bold assumption that we are working with HDF5 - h5In = _getH5File(dbIn) - h5Out = _getH5File(dbOut) - dbOut.writeInputsToDB(None, *dbIn.readInputsFromDB()) - - for cycle, timeNode in dbNodes: - if nodes is not None and (cycle, timeNode) not in nodes: - continue - runLog.extra(f"Converting cycle={cycle}, timeNode={timeNode}") - timeStepsInOutDB = set(dbOut.genTimeSteps()) - r = dbIn.load(cycle, timeNode) - if (r.p.cycle, r.p.timeNode) in timeStepsInOutDB: - runLog.warning( - "Time step ({}, {}) is already in the output DB. This " - "is probably due to repeated cycle/timeNode in the source DB; " - "deleting the existing time step and re-writing".format( - r.p.cycle, r.p.timeNode - ) - ) - del dbOut[r.p.cycle, r.p.timeNode, None] - runLog.setVerbosity(conversionVerbosity) - dbOut.writeToDB(r) - - for auxPath in dbIn.genAuxiliaryData((cycle, timeNode)): - name = next(reversed(auxPath.split("/"))) - auxOutPath = dbOut.getAuxiliaryDataPath((cycle, timeNode), name) - runLog.important( - "Copying auxiliary data for time ({}, {}): {} -> {}".format( - cycle, timeNode, auxPath, auxOutPath - ) - ) - h5In.copy(auxPath, h5Out, name=auxOutPath) - - def _getH5File(db): """Return the underlying h5py File that provides the backing storage for a database. diff --git a/armi/bookkeeping/db/database3.py b/armi/bookkeeping/db/database3.py index d7bbf9eba..ee67f70dd 100644 --- a/armi/bookkeeping/db/database3.py +++ b/armi/bookkeeping/db/database3.py @@ -109,6 +109,16 @@ class Database3: handles the packing and unpacking of the structure of the objects, their relationships, and their non-parameter attributes. + .. impl:: The database files are H5, and thus language agnostic. + :id: I_ARMI_DB_H51 + :implements: R_ARMI_DB_H5 + + This class implements a light wrapper around H5 files, so they can be used to + store ARMI outputs. H5 files are commonly used in scientific applications in + Fortran and C++. As such, they are entirely language agnostic binary files. The + implementation here is that ARMI wraps the ``h5py`` library, and uses its + extensive tooling, instead of re-inventing the wheel. + See Also -------- `doc/user/outputs/database` for more details. @@ -197,23 +207,14 @@ def open(self): "Cannot open database with permission `{}`".format(self._permission) ) + # open the database, and write a bunch of metadata to it runLog.info("Opening database file at {}".format(os.path.abspath(filePath))) self.h5db = h5py.File(filePath, self._permission) self.h5db.attrs["successfulCompletion"] = False self.h5db.attrs["version"] = meta.__version__ self.h5db.attrs["databaseVersion"] = self.version - self.h5db.attrs["user"] = context.USER - self.h5db.attrs["python"] = sys.version - self.h5db.attrs["armiLocation"] = os.path.dirname(context.ROOT) - self.h5db.attrs["startTime"] = context.START_TIME - self.h5db.attrs["machines"] = numpy.array(context.MPI_NODENAMES).astype("S") - # store platform data - platform_data = uname() - self.h5db.attrs["platform"] = platform_data.system - self.h5db.attrs["hostname"] = platform_data.node - self.h5db.attrs["platformRelease"] = platform_data.release - self.h5db.attrs["platformVersion"] = platform_data.version - self.h5db.attrs["platformArch"] = platform_data.processor + self.writeSystemAttributes(self.h5db) + # store app and plugin data app = getApp() self.h5db.attrs["appName"] = app.name @@ -224,9 +225,37 @@ def open(self): ] ps = numpy.array([str(p[0]) + ":" + str(p[1]) for p in ps]).astype("S") self.h5db.attrs["pluginPaths"] = ps - # store the commit hash of the local repo self.h5db.attrs["localCommitHash"] = Database3.grabLocalCommitHash() + @staticmethod + def writeSystemAttributes(h5db): + """Write system attributes to the database. + + .. impl:: Add system attributes to the database. + :id: I_ARMI_DB_QA + :implements: R_ARMI_DB_QA + + This method writes some basic system information to the H5 file. This is + designed as a starting point, so users can see information about the system + their simulations were run on. As ARMI is used on Windows and Linux, the + tooling here has to be platform independent. The two major sources of + information are the ARMI :py:mod:`context ` module and the + Python standard library ``platform``. + """ + h5db.attrs["user"] = context.USER + h5db.attrs["python"] = sys.version + h5db.attrs["armiLocation"] = os.path.dirname(context.ROOT) + h5db.attrs["startTime"] = context.START_TIME + h5db.attrs["machines"] = numpy.array(context.MPI_NODENAMES).astype("S") + + # store platform data + platform_data = uname() + h5db.attrs["platform"] = platform_data.system + h5db.attrs["hostname"] = platform_data.node + h5db.attrs["platformRelease"] = platform_data.release + h5db.attrs["platformVersion"] = platform_data.version + h5db.attrs["platformArch"] = platform_data.processor + @staticmethod def grabLocalCommitHash(): """ @@ -423,6 +452,7 @@ def loadBlueprints(self): def loadGeometry(self): """ This is primarily just used for migrations. + The "geometry files" were replaced by ``systems:`` and ``grids:`` sections of ``Blueprints``. """ geom = systemLayoutInput.SystemLayoutInput() @@ -431,12 +461,30 @@ def loadGeometry(self): def writeInputsToDB(self, cs, csString=None, geomString=None, bpString=None): """ - Write inputs into the database based the CaseSettings. + Write inputs into the database based the Settings. This is not DRY on purpose. The goal is that any particular Database implementation should be very stable, so we dont want it to be easy to change one Database implementation's behavior when trying to change another's. + .. impl:: The run settings are saved the settings file. + :id: I_ARMI_DB_CS + :implements: R_ARMI_DB_CS + + A ``Settings`` object is passed into this method, and then the settings are + converted into a YAML string stream. That stream is then written to the H5 + file. Optionally, this method can take a pre-build settings string to be + written directly to the file. + + .. impl:: The reactor blueprints are saved the settings file. + :id: I_ARMI_DB_BP + :implements: R_ARMI_DB_BP + + A ``Blueprints`` string is optionally passed into this method, and then + written to the H5 file. If it is not passed in, this method will attempt to + find the blueprints input file in the settings, and read the contents of + that file into a stream to be written to the H5 file. + Notes ----- This is hard-coded to read the entire file contents into memory and write that @@ -653,11 +701,24 @@ def load( ): """Load a new reactor from (cycle, node). - Case settings and blueprints can be provided by the client, or read from the database itself. - Providing these from the client could be useful when performing snapshot runs - or where it is expected to use results from a run using different settings and - continue with new settings (or if blueprints are not on the database). - Geometry is read from the database itself. + Case settings and blueprints can be provided by the client, or read from the + database itself. Providing these from the client could be useful when + performing snapshot runs or where it is expected to use results from a run + using different settings and continue with new settings (or if blueprints are + not on the database). Geometry is read from the database itself. + + .. impl:: Users can load a reactor from a DB. + :id: I_ARMI_DB_R_LOAD + :implements: R_ARMI_DB_R_LOAD + + This method creates a ``Reactor`` object by reading the reactor state out + of an ARMI database file. This is done by passing in mandatory arguements + that specify the exact place in time you want to load the reactor from. + (That is, the cycle and node numbers.) Users can either pass the settings + and blueprints directly into this method, or it will attempt to read them + from the database file. The primary work done here is to read the hierarchy + of reactor objects from the data file, then reconstruct them in the correct + order. Parameters ---------- @@ -725,6 +786,7 @@ def load( f"Due to the setting {CONF_SORT_REACTOR}, this Reactor is unsorted. " "But this feature is temporary and will be removed by 2024." ) + return root @staticmethod @@ -1458,7 +1520,6 @@ def packSpecialData( ``None`` with a magical value that shouldn't be encountered in realistic scenarios. - Parameters ---------- data @@ -1610,7 +1671,6 @@ def unpackSpecialData(data: numpy.ndarray, attrs, paramName: str) -> numpy.ndarr An ndarray containing the closest possible representation of the data that was originally written to the database. - See Also -------- packSpecialData diff --git a/armi/bookkeeping/db/databaseInterface.py b/armi/bookkeeping/db/databaseInterface.py index eddfd129a..5c90a6d03 100644 --- a/armi/bookkeeping/db/databaseInterface.py +++ b/armi/bookkeeping/db/databaseInterface.py @@ -222,10 +222,28 @@ def prepRestartRun(self): `startCycle` and `startNode`, having loaded the state from all cycles prior to that in the requested database. + .. impl:: Runs at a particular timenode can be re-instantiated for a snapshot. + :id: I_ARMI_SNAPSHOT_RESTART + :implements: R_ARMI_SNAPSHOT_RESTART + + This method loads the state of a reactor from a particular point in time + from a standard ARMI + :py:class:`Database `. This is a + major use-case for having ARMI databases in the first case. And restarting + from such a database is easy, you just need to set a few settings:: + + * reloadDBName - Path to existing H5 file to reload from. + * startCycle - Operational cycle to restart from. + * startNode - Time node to start from. + Notes ----- Mixing the use of simple vs detailed cycles settings is allowed, provided that the cycle histories prior to `startCycle`/`startNode` are equivalent. + + ARMI expects the reload DB to have been made in the same version of ARMI as you + are running. ARMI does not gaurantee that a DB from a decade ago will be easily + used to restart a run. """ reloadDBName = self.cs["reloadDBName"] runLog.info( @@ -244,7 +262,6 @@ def prepRestartRun(self): self.cs, ) - # check that cycle histories are equivalent up to this point self._checkThatCyclesHistoriesAreEquivalentUpToRestartTime( loadDbCs, dbCycle, dbNode ) @@ -255,6 +272,7 @@ def prepRestartRun(self): def _checkThatCyclesHistoriesAreEquivalentUpToRestartTime( self, loadDbCs, dbCycle, dbNode ): + """Check that cycle histories are equivalent up to this point.""" dbStepLengths = getStepLengths(loadDbCs) currentCaseStepLengths = getStepLengths(self.cs) dbStepHistory = [] diff --git a/armi/bookkeeping/db/layout.py b/armi/bookkeeping/db/layout.py index a49877a4e..4dad92606 100644 --- a/armi/bookkeeping/db/layout.py +++ b/armi/bookkeeping/db/layout.py @@ -381,6 +381,22 @@ def _initComps(self, caseTitle, bp): return comps, groupedComps def writeToDB(self, h5group): + """Write a chunk of data to the database. + + .. impl:: Write data to the DB for a given time step. + :id: I_ARMI_DB_TIME + :implements: R_ARMI_DB_TIME + + This method writes a snapshot of the current state of the reactor to the + database. It takes a pointer to an existing HDF5 file as input, and it + writes the reactor data model to the file in depth-first search order. + Other than this search order, there are no guarantees as to what order the + objects are written to the file. Though, this turns out to still be very + powerful. For instance, the data for all ``HexBlock`` children of a given + parent are stored contiguously within the ``HexBlock`` group, and will not + be interleaved with data from the ``HexBlock`` children of any of the + parent's siblings. + """ if "layout/type" in h5group: # It looks like we have already written the layout to DB, skip for now return @@ -587,7 +603,7 @@ def _packLocationsV1( def _packLocationsV2( locations: List[grids.LocationBase], ) -> Tuple[List[str], List[Tuple[int, int, int]]]: - """Location packing implementation for minor version 3. See release notes above.""" + """Location packing implementation for minor version 3. See module docstring above.""" locTypes = [] locData: List[Tuple[int, int, int]] = [] for loc in locations: @@ -614,7 +630,7 @@ def _packLocationsV2( def _packLocationsV3( locations: List[grids.LocationBase], ) -> Tuple[List[str], List[Tuple[int, int, int]]]: - """Location packing implementation for minor version 4. See release notes above.""" + """Location packing implementation for minor version 4. See module docstring above.""" locTypes = [] locData: List[Tuple[int, int, int]] = [] @@ -673,7 +689,7 @@ def _unpackLocationsV1(locationTypes, locData): def _unpackLocationsV2(locationTypes, locData): - """Location unpacking implementation for minor version 3+. See release notes above.""" + """Location unpacking implementation for minor version 3+. See module docstring above.""" locsIter = iter(locData) unpackedLocs = [] for lt in locationTypes: diff --git a/armi/bookkeeping/db/tests/__init__.py b/armi/bookkeeping/db/tests/__init__.py index 03f780d3b..7f2271639 100644 --- a/armi/bookkeeping/db/tests/__init__.py +++ b/armi/bookkeeping/db/tests/__init__.py @@ -13,16 +13,3 @@ # limitations under the License. """Database tests.""" -# Copyright 2009-2019 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. diff --git a/armi/bookkeeping/db/tests/test_comparedb3.py b/armi/bookkeeping/db/tests/test_comparedb3.py index 0c72a2bc2..f482e6fa2 100644 --- a/armi/bookkeeping/db/tests/test_comparedb3.py +++ b/armi/bookkeeping/db/tests/test_comparedb3.py @@ -94,7 +94,7 @@ def test_diffResultsBasic(self): self.assertEqual(dr.nDiffs(), 10) def test_compareDatabaseDuplicate(self): - """end-to-end test of compareDatabases() on a photocopy database.""" + """End-to-end test of compareDatabases() on a photocopy database.""" # build two super-simple H5 files for testing o, r = test_reactors.loadTestReactor( TEST_ROOT, customSettings={"reloadDBName": "reloadingDB.h5"} diff --git a/armi/bookkeeping/db/tests/test_database3.py b/armi/bookkeeping/db/tests/test_database3.py index a20e49d8a..d7c4eca7d 100644 --- a/armi/bookkeeping/db/tests/test_database3.py +++ b/armi/bookkeeping/db/tests/test_database3.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Tests for the Database3 class.""" +from distutils.spawn import find_executable import subprocess import unittest @@ -28,6 +29,13 @@ from armi.utils import getPreviousTimeNode from armi.utils.directoryChangers import TemporaryDirectoryChanger +# determine if this is a parallel run, and git is installed +GIT_EXE = None +if find_executable("git") is not None: + GIT_EXE = "git" +elif find_executable("git.exe") is not None: + GIT_EXE = "git.exe" + class TestDatabase3(unittest.TestCase): """Tests for the Database3 class.""" @@ -55,6 +63,12 @@ def tearDown(self): self.td.__exit__(None, None, None) def test_writeToDB(self): + """Test writing to the database. + + .. test:: Write a single time step of data to the database. + :id: T_ARMI_DB_TIME + :tests: R_ARMI_DB_TIME + """ self.r.p.cycle = 0 self.r.p.timeNode = 0 self.r.p.cycleLength = 0 @@ -67,6 +81,7 @@ def test_writeToDB(self): self.db.writeToDB(self.r) self.assertEqual(sorted(self.db.h5db.keys()), ["c00n00", "inputs"]) + # check the keys for a single time step keys = [ "Circle", "Core", @@ -95,6 +110,13 @@ def test_writeToDB(self): ) def test_getH5File(self): + """ + Get the h5 file for the database, because that file format is language-agnostic. + + .. test:: Show the database is H5-formatted. + :id: T_ARMI_DB_H5 + :tests: R_ARMI_DB_H5 + """ with self.assertRaises(TypeError): _getH5File(None) @@ -185,6 +207,10 @@ def test_prepRestartRun(self): above. In that cs, `reloadDBName` is set to 'reloadingDB.h5', `startCycle` = 1, and `startNode` = 2. The nonexistent 'reloadingDB.h5' must first be created here for this test. + + .. test:: Runs can be restarted from a snapshot. + :id: T_ARMI_SNAPSHOT_RESTART + :tests: R_ARMI_SNAPSHOT_RESTART """ # first successfully call to prepRestartRun o, r = loadTestReactor( @@ -334,6 +360,12 @@ def test_computeParents(self): ) def test_load(self): + """Load a reactor at different time steps, from the database. + + .. test:: Load the reactor from the database. + :id: T_ARMI_DB_R_LOAD + :tests: R_ARMI_DB_R_LOAD + """ self.makeShuffleHistory() with self.assertRaises(KeyError): _r = self.db.load(0, 0) @@ -529,6 +561,7 @@ def test_splitDatabase(self): [(c, n) for c in (0, 1) for n in range(2)], "-all-iterations" ) + @unittest.skipIf(GIT_EXE is None, "This test needs Git.") def test_grabLocalCommitHash(self): """Test of static method to grab a local commit hash with ARMI version.""" # 1. test outside a Git repo @@ -536,11 +569,16 @@ def test_grabLocalCommitHash(self): self.assertEqual(localHash, "unknown") # 2. test inside an empty git repo - code = subprocess.run( - ["git", "init", "."], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ).returncode + try: + code = subprocess.run( + ["git", "init", "."], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode + except FileNotFoundError: + print("Skipping this test because it is being run outside a git repo.") + return + self.assertEqual(code, 0) localHash = database3.Database3.grabLocalCommitHash() self.assertEqual(localHash, "unknown") @@ -590,14 +628,26 @@ def test_fileName(self): self.assertEqual(str(self.db.fileName), "thing.h5") def test_readInputsFromDB(self): + """Test that we can read inputs from the database. + + .. test:: Save and retrieve settings from the database. + :id: T_ARMI_DB_CS + :tests: R_ARMI_DB_CS + + .. test:: Save and retrieve blueprints from the database. + :id: T_ARMI_DB_BP + :tests: R_ARMI_DB_BP + """ inputs = self.db.readInputsFromDB() self.assertEqual(len(inputs), 3) + # settings self.assertGreater(len(inputs[0]), 100) self.assertIn("settings:", inputs[0]) self.assertEqual(len(inputs[1]), 0) + # blueprints self.assertGreater(len(inputs[2]), 100) self.assertIn("custom isotopics:", inputs[2]) self.assertIn("blocks:", inputs[2]) diff --git a/armi/bookkeeping/db/tests/test_databaseInterface.py b/armi/bookkeeping/db/tests/test_databaseInterface.py index 860d32362..342f5345d 100644 --- a/armi/bookkeeping/db/tests/test_databaseInterface.py +++ b/armi/bookkeeping/db/tests/test_databaseInterface.py @@ -27,6 +27,7 @@ from armi.bookkeeping.db.database3 import Database3 from armi.bookkeeping.db.databaseInterface import DatabaseInterface from armi.cases import case +from armi.context import PROJECT_ROOT from armi.physics.neutronics.settings import CONF_LOADING_FILE from armi.reactor import grids from armi.reactor.flags import Flags @@ -92,6 +93,11 @@ def tearDown(self): self.db.close() self.stateRetainer.__exit__() self.td.__exit__(None, None, None) + # test_interactBOL leaves behind some dirt (accessible after db close) that the + # TempDirChanger is not catching + bolDirt = os.path.join(PROJECT_ROOT, "armiRun.h5") + if os.path.exists(bolDirt): + os.remove(bolDirt) def test_interactEveryNodeReturn(self): """Test that the DB is NOT written to if cs["tightCoupling"] = True.""" @@ -135,13 +141,41 @@ def setUp(self): def tearDown(self): self.td.__exit__(None, None, None) + def test_writeSystemAttributes(self): + """Test the writeSystemAttributes method. + + .. test:: Validate that we can directly write system attributes to a database file. + :id: T_ARMI_DB_QA0 + :tests: R_ARMI_DB_QA + """ + with h5py.File("test_writeSystemAttributes.h5", "w") as h5: + Database3.writeSystemAttributes(h5) + + with h5py.File("test_writeSystemAttributes.h5", "r") as h5: + self.assertIn("user", h5.attrs) + self.assertIn("python", h5.attrs) + self.assertIn("armiLocation", h5.attrs) + self.assertIn("startTime", h5.attrs) + self.assertIn("machines", h5.attrs) + self.assertIn("platform", h5.attrs) + self.assertIn("hostname", h5.attrs) + self.assertIn("platformRelease", h5.attrs) + self.assertIn("platformVersion", h5.attrs) + self.assertIn("platformArch", h5.attrs) + def test_metaData_endSuccessfully(self): - def goodMethod(cycle, node): - pass + """Test databases have the correct metadata in them. + .. test:: Validate that databases have system attributes written to them during the usual workflow. + :id: T_ARMI_DB_QA1 + :tests: R_ARMI_DB_QA + """ # the power should start at zero self.assertEqual(self.r.core.p.power, 0) + def goodMethod(cycle, node): + pass + self.o.interfaces.append(MockInterface(self.o.r, self.o.cs, goodMethod)) with self.o: self.o.operate() @@ -152,15 +186,23 @@ def goodMethod(cycle, node): with h5py.File(self.o.cs.caseTitle + ".h5", "r") as h5: self.assertTrue(h5.attrs["successfulCompletion"]) self.assertEqual(h5.attrs["version"], version) + + self.assertIn("caseTitle", h5.attrs) + self.assertIn("geomFile", h5["inputs"]) + self.assertIn("settings", h5["inputs"]) + self.assertIn("blueprints", h5["inputs"]) + + # validate system attributes self.assertIn("user", h5.attrs) self.assertIn("python", h5.attrs) self.assertIn("armiLocation", h5.attrs) self.assertIn("startTime", h5.attrs) self.assertIn("machines", h5.attrs) - self.assertIn("caseTitle", h5.attrs) - self.assertIn("geomFile", h5["inputs"]) - self.assertIn("settings", h5["inputs"]) - self.assertIn("blueprints", h5["inputs"]) + self.assertIn("platform", h5.attrs) + self.assertIn("hostname", h5.attrs) + self.assertIn("platformRelease", h5.attrs) + self.assertIn("platformVersion", h5.attrs) + self.assertIn("platformArch", h5.attrs) # after operating, the power will be greater than zero self.assertGreater(self.r.core.p.power, 1e9) diff --git a/armi/bookkeeping/historyTracker.py b/armi/bookkeeping/historyTracker.py index 766b1e444..bfd52d4bc 100644 --- a/armi/bookkeeping/historyTracker.py +++ b/armi/bookkeeping/historyTracker.py @@ -95,6 +95,22 @@ class HistoryTrackerInterface(interfaces.Interface): """ Makes reports of the state that individual assemblies encounter. + .. impl:: This interface allows users to retrieve run data from somewhere other + than the database. + :id: I_ARMI_HIST_TRACK + :implements: R_ARMI_HIST_TRACK + + This is a special :py:class:`Interface ` that is + designed to store assembly and cross section data throughout time. This is done + directly, with time-based lists of assembly data, and dictionaries of cross- + section data. Users turn this feature on or off using the ``"detailAllAssems"`` + setting. + + Notes + ----- + This pre-dates the ARMI database system, and we would like to stop supporting this. + Please don't find new uses for this; use the databases. + Attributes ---------- detailAssemblyNames : list @@ -102,14 +118,14 @@ class HistoryTrackerInterface(interfaces.Interface): time : list list of reactor time in years - """ name = "history" def __init__(self, r, cs): """ - HistoryTracker that uses the database to look up parameter history rather than storing them in memory. + HistoryTracker that uses the database to look up parameter history rather than + storing them in memory. Warning ------- diff --git a/armi/bookkeeping/report/newReportUtils.py b/armi/bookkeeping/report/newReportUtils.py index 8fcd43faa..9b8bf0554 100644 --- a/armi/bookkeeping/report/newReportUtils.py +++ b/armi/bookkeeping/report/newReportUtils.py @@ -638,7 +638,12 @@ def insertCoreAndAssemblyMaps( } core = r.core - imageCaption = "The axial block and enrichment distributions of assemblies in the core at beginning of life. The percentage represents the block enrichment (U-235 or B-10), where as the additional character represents the cross section id of the block. The number of fine-mesh subdivisions are provided on the secondary y-axis." + imageCaption = ( + "The axial block and enrichment distributions of assemblies in the core at beginning of " + + "life. The percentage represents the block enrichment (U-235 or B-10), where as the " + + "additional character represents the cross section id of the block. The number of fine-" + + "mesh subdivisions are provided on the secondary y-axis." + ) report[DESIGN]["Assembly Designs"] = newReports.Section("Assembly Designs") currentSection = report[DESIGN]["Assembly Designs"] diff --git a/armi/bookkeeping/report/reportingUtils.py b/armi/bookkeeping/report/reportingUtils.py index ad0fed510..3a0cfcf3b 100644 --- a/armi/bookkeeping/report/reportingUtils.py +++ b/armi/bookkeeping/report/reportingUtils.py @@ -21,6 +21,7 @@ import os import pathlib import re +import subprocess import sys import tabulate import textwrap @@ -137,14 +138,23 @@ def _listInputFiles(cs): inputInfo.append((label, fName, shaHash)) # bonus: grab the files stored in the crossSectionControl section - for fluxSection, fluxData in cs["crossSectionControl"].items(): - if fluxData.xsFileLocation is not None: - label = f"crossSectionControl-{fluxSection}" - fName = fluxData.xsFileLocation - if isinstance(fName, list): - fName = fName[0] + for xsID, xsSetting in cs["crossSectionControl"].items(): + fNames = [] + # Users shouldn't ever have both of these defined, but this is not the place + # for code to fail if they do. Allow for both to not be None. + if xsSetting.xsFileLocation is not None: + # possibly a list of files + if isinstance(xsSetting.xsFileLocation, list): + fNames.extend(xsSetting.xsFileLocation) + else: + fNames.append(xsSetting.xsFileLocation) + if xsSetting.fluxFileLocation is not None: + # single file + fNames.append(xsSetting.fluxFileLocation) + for fName in fNames: + label = f"crossSectionControl-{xsID}" if fName and os.path.exists(fName): - shaHash = getFileSHA1Hash(fName, digits=10) + shaHash = getFileSHA1Hash(os.path.abspath(fName), digits=10) inputInfo.append((label, fName, shaHash)) return inputInfo @@ -170,6 +180,7 @@ def _writeMachineInformation(): processorNames = context.MPI_NODENAMES uniqueNames = set(processorNames) nodeMappingData = [] + sysInfo = "" for uniqueName in uniqueNames: matchingProcs = [ str(rank) @@ -180,6 +191,16 @@ def _writeMachineInformation(): nodeMappingData.append( (uniqueName, numProcessors, ", ".join(matchingProcs)) ) + # If this is on Windows: run sys info on each unique node too + if "win" in sys.platform: + sysInfoCmd = ( + 'systeminfo | findstr /B /C:"OS Name" /B /C:"OS Version" /B ' + '/C:"Processor" && systeminfo | findstr /E /C:"Mhz"' + ) + out = subprocess.run( + sysInfoCmd, capture_output=True, text=True, shell=True + ) + sysInfo += out.stdout runLog.header("=========== Machine Information ===========") runLog.info( tabulate.tabulate( @@ -188,6 +209,9 @@ def _writeMachineInformation(): tablefmt="armi", ) ) + if sysInfo: + runLog.header("=========== System Information ===========") + runLog.info(sysInfo) def _writeReactorCycleInformation(o, cs): """Verify that all the operating parameters are defined for the same number of cycles.""" diff --git a/armi/bookkeeping/report/tests/test_report.py b/armi/bookkeeping/report/tests/test_report.py index 577b97276..648d1bde3 100644 --- a/armi/bookkeeping/report/tests/test_report.py +++ b/armi/bookkeeping/report/tests/test_report.py @@ -32,6 +32,7 @@ ) from armi.reactor.tests.test_reactors import loadTestReactor from armi.tests import mockRunLogs +from armi.utils.directoryChangers import TemporaryDirectoryChanger class TestReport(unittest.TestCase): @@ -141,6 +142,7 @@ def test_writeWelcomeHeaders(self): # pass that random file into the settings o.cs["crossSectionControl"]["DA"].xsFileLocation = randoFile + o.cs["crossSectionControl"]["DA"].fluxFileLocation = randoFile with mockRunLogs.BufferLog() as mock: # we should start with a clean slate @@ -158,6 +160,15 @@ def test_writeWelcomeHeaders(self): class TestReportInterface(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.td = TemporaryDirectoryChanger() + cls.td.__enter__() + + @classmethod + def tearDownClass(cls): + cls.td.__exit__(None, None, None) + def test_printReports(self): """Testing printReports method.""" repInt = reportInterface.ReportInterface(None, None) diff --git a/armi/bookkeeping/snapshotInterface.py b/armi/bookkeeping/snapshotInterface.py index e93353e11..087c0a6b9 100644 --- a/armi/bookkeeping/snapshotInterface.py +++ b/armi/bookkeeping/snapshotInterface.py @@ -42,7 +42,21 @@ def describeInterfaces(cs): class SnapshotInterface(interfaces.Interface): - """Snapshot managerial interface.""" + """ + Snapshot managerial interface. + + .. impl:: Save extra data to be saved from a run, at specified time nodes. + :id: I_ARMI_SNAPSHOT0 + :implements: R_ARMI_SNAPSHOT + + This is a special :py:class:`Interface ` that is + designed to run along all the other Interfaces during a simulation, to save off + important or helpful data. By default, this is designed to be used with the + ``"defaultSnapshots"`` and ``""dumpSnapshot""`` settings. These settings were + added so users can control if snapshot data will be recorded during their run. + Broadly, this class is implemented to run the Operator method + :py:meth:`o.snapshotRequest `. + """ name = "snapshot" diff --git a/armi/bookkeeping/tests/test_historyTracker.py b/armi/bookkeeping/tests/test_historyTracker.py index 8a2bbb0a2..a75beb943 100644 --- a/armi/bookkeeping/tests/test_historyTracker.py +++ b/armi/bookkeeping/tests/test_historyTracker.py @@ -107,6 +107,10 @@ def test_calcMGFluence(self): armi.bookeeping.db.hdf.hdfDB.readBlocksHistory requires historical_values[historical_indices] to be cast as a list to read more than the first energy group. This test shows that this behavior is preserved. + + .. test:: Demonstrate that a parameter stored at differing time nodes can be recovered. + :id: T_ARMI_HIST_TRACK0 + :tests: R_ARMI_HIST_TRACK """ o = self.o b = o.r.core.childrenByLocator[o.r.core.spatialGrid[0, 0, 0]].getFirstBlock( @@ -115,9 +119,8 @@ def test_calcMGFluence(self): bVolume = b.getVolume() bName = b.name - hti = o.getInterface("history") - # duration is None in this DB + hti = o.getInterface("history") timesInYears = [duration or 1.0 for duration in hti.getTimeSteps()] timeStepsToRead = [ utils.getCycleNodeFromCumulativeNode(i, self.o.cs) @@ -128,7 +131,7 @@ def test_calcMGFluence(self): mgFluence = None for ts, years in enumerate(timesInYears): cycle, node = utils.getCycleNodeFromCumulativeNode(ts, self.o.cs) - # b.p.mgFlux is vol integrated + # b.p.mgFlux is vol integrated mgFlux = hti.getBlockHistoryVal(bName, "mgFlux", (cycle, node)) / bVolume timeInSec = years * 365 * 24 * 3600 if mgFluence is None: @@ -143,6 +146,58 @@ def test_calcMGFluence(self): hti.unloadBlockHistoryVals() self.assertIsNone(hti._preloadedBlockHistory) + def test_historyParameters(self): + """Retrieve various paramaters from the history. + + .. test:: Demonstrate that various parameters stored at differing time nodes can be recovered. + :id: T_ARMI_HIST_TRACK1 + :tests: R_ARMI_HIST_TRACK + """ + o = self.o + b = o.r.core.childrenByLocator[o.r.core.spatialGrid[0, 0, 0]].getFirstBlock( + Flags.FUEL + ) + b.getVolume() + bName = b.name + + # duration is None in this DB + hti = o.getInterface("history") + timesInYears = [duration or 1.0 for duration in hti.getTimeSteps()] + timeStepsToRead = [ + utils.getCycleNodeFromCumulativeNode(i, self.o.cs) + for i in range(len(timesInYears)) + ] + hti.preloadBlockHistoryVals([bName], ["power"], timeStepsToRead) + + # read some parameters + params = {} + for param in ["height", "pdens", "power"]: + params[param] = [] + for ts, years in enumerate(timesInYears): + cycle, node = utils.getCycleNodeFromCumulativeNode(ts, self.o.cs) + + params[param].append( + hti.getBlockHistoryVal(bName, param, (cycle, node)) + ) + + # verify the height parameter doesn't change over time + self.assertGreater(params["height"][0], 0) + self.assertEqual(params["height"][0], params["height"][1]) + + # verify the power parameter is retrievable from the history + self.assertEqual(o.cs["power"], 1000000000.0) + self.assertAlmostEqual(params["power"][0], 360, delta=0.1) + self.assertEqual(params["power"][0], params["power"][1]) + + # verify the power density parameter is retrievable from the history + self.assertAlmostEqual(params["pdens"][0], 0.0785, delta=0.001) + self.assertEqual(params["pdens"][0], params["pdens"][1]) + + # test that unloadBlockHistoryVals() is working + self.assertIsNotNone(hti._preloadedBlockHistory) + hti.unloadBlockHistoryVals() + self.assertIsNone(hti._preloadedBlockHistory) + def test_historyReport(self): """ Test generation of history report. diff --git a/armi/bookkeeping/tests/test_snapshot.py b/armi/bookkeeping/tests/test_snapshot.py index 5bd670e4e..d22871191 100644 --- a/armi/bookkeeping/tests/test_snapshot.py +++ b/armi/bookkeeping/tests/test_snapshot.py @@ -59,6 +59,13 @@ def test_interactCoupled(self, mockSnapshotRequest): self.assertTrue(mockSnapshotRequest.called) def test_activeateDefaultSnapshots_30cycles2BurnSteps(self): + """ + Test snapshots for 30 cycles and 2 burnsteps, checking the dumpSnapshot setting. + + .. test:: Allow extra data to be saved from a run, at specified time nodes. + :id: T_ARMI_SNAPSHOT0 + :tests: R_ARMI_SNAPSHOT + """ self.assertEqual([], self.cs["dumpSnapshot"]) newSettings = {} @@ -72,6 +79,13 @@ def test_activeateDefaultSnapshots_30cycles2BurnSteps(self): self.assertEqual(["000000", "014000", "029002"], self.si.cs["dumpSnapshot"]) def test_activeateDefaultSnapshots_17cycles5BurnSteps(self): + """ + Test snapshots for 17 cycles and 5 burnsteps, checking the dumpSnapshot setting. + + .. test:: Allow extra data to be saved from a run, at specified time nodes. + :id: T_ARMI_SNAPSHOT1 + :tests: R_ARMI_SNAPSHOT + """ self.assertEqual([], self.cs["dumpSnapshot"]) newSettings = {} diff --git a/armi/cases/__init__.py b/armi/cases/__init__.py index f01b81939..7b6a9b48a 100644 --- a/armi/cases/__init__.py +++ b/armi/cases/__init__.py @@ -46,7 +46,7 @@ suite.discover('my-cases*.yaml', recursive=True) suite.run() -.. warning: Suite running may not work yet if the cases have interdependencies. +.. warning:: Suite running may not work yet if the cases have interdependencies. Create a ``burnStep`` sensitivity study from some base CS:: diff --git a/armi/cases/case.py b/armi/cases/case.py index e42879e6c..dec8e69b4 100644 --- a/armi/cases/case.py +++ b/armi/cases/case.py @@ -13,11 +13,12 @@ # limitations under the License. """ -The ``Case`` object is responsible for running, and executing a set of user inputs. Many -entry points redirect into ``Case`` methods, such as ``clone``, ``compare``, and ``run``. +The ``Case`` object is responsible for running, and executing a set of +user inputs. Many entry points redirect into ``Case`` methods, such as +``clone``, ``compare``, and ``run``. -The ``Case`` object provides an abstraction around ARMI inputs to allow for manipulation and -collection of cases. +The ``Case`` object provides an abstraction around ARMI inputs to allow +for manipulation and collection of cases. See Also -------- @@ -81,8 +82,8 @@ def __init__(self, cs, caseSuite=None, bp=None, geom=None): Parameters ---------- - cs : CaseSettings - CaseSettings for this Case + cs : Settings + Settings for this Case caseSuite : CaseSuite, optional CaseSuite this particular case belongs. Passing this in allows dependency @@ -96,8 +97,8 @@ def __init__(self, cs, caseSuite=None, bp=None, geom=None): ``cs`` as needed. geom : SystemLayoutInput, optional - SystemLayoutInput for this case. If not supplied, it will be loaded from the ``cs`` as - needed. + SystemLayoutInput for this case. If not supplied, it will be loaded from the + ``cs`` as needed. """ self._startTime = time.time() self._caseSuite = caseSuite @@ -330,11 +331,19 @@ def run(self): """ Run an ARMI case. - This initializes an ``Operator``, a ``Reactor`` and invokes - :py:meth:`Operator.operate`! - It also activates supervisory things like code coverage checking, profiling, - or tracing, if requested by users during debugging. + .. impl:: The case class allows for a generic ARMI simulation. + :id: I_ARMI_CASE + :implements: R_ARMI_CASE + + This method is responsible for "running" the ARMI simulation + instigated by the inputted settings. This initializes an + :py:class:`~armi.operators.operator.Operator`, a + :py:class:`~armi.reactor.reactors.Reactor` and invokes + :py:meth:`Operator.operate + `. It also activates + supervisory things like code coverage checking, profiling, or + tracing, if requested by users during debugging. Notes ----- @@ -370,7 +379,7 @@ def run(self): def _startCoverage(self): """Helper to the Case.run(): spin up the code coverage tooling, - if the CaseSettings file says to. + if the Settings file says to. Returns ------- @@ -398,7 +407,7 @@ def _startCoverage(self): @staticmethod def _endCoverage(userCovFile, cov=None): """Helper to the Case.run(): stop and report code coverage, - if the CaseSettings file says to. + if the Settings file says to. Parameters ---------- @@ -464,7 +473,7 @@ def _getCoverageRcFile(userCovFile, makeCopy=False): def _startProfiling(self): """Helper to the Case.run(): start the Python profiling, - if the CaseSettings file says to. + if the Settings file says to. Returns ------- @@ -481,7 +490,7 @@ def _startProfiling(self): @staticmethod def _endProfiling(profiler=None): """Helper to the Case.run(): stop and report python profiling, - if the CaseSettings file says to. + if the Settings file says to. Parameters ---------- @@ -556,6 +565,21 @@ def checkInputs(self): """ Checks ARMI inputs for consistency. + .. impl:: Perform validity checks on case inputs. + :id: I_ARMI_CASE_CHECK + :implements: R_ARMI_CASE_CHECK + + This method checks the validity of the current settings. It relies + on an :py:class:`~armi.operators.settingsValidation.Inspector` + object from the :py:class:`~armi.operators.operator.Operator` to + generate a list of + :py:class:`~armi.operators.settingsValidation.Query` objects that + represent potential issues in the settings. After gathering the + queries, this method prints a table of query "statements" and + "questions" to the console. If running in an interactive mode, the + user then has the opportunity to address the questions posed by the + queries by either addressing the potential issue or ignoring it. + Returns ------- bool @@ -904,7 +928,7 @@ def copyInterfaceInputs( Parameters ---------- - cs : CaseSettings + cs : Settings The source case settings to find input files destination : str The target directory to copy input files to diff --git a/armi/cases/inputModifiers/inputModifiers.py b/armi/cases/inputModifiers/inputModifiers.py index 4e2bd14e8..ebc07a166 100644 --- a/armi/cases/inputModifiers/inputModifiers.py +++ b/armi/cases/inputModifiers/inputModifiers.py @@ -18,18 +18,22 @@ class InputModifier: """ Object that modifies input definitions in some well-defined way. - (This class is abstract.) - - Subclasses must implement a ``__call__`` method accepting a ``CaseSettings``, - ``Blueprints``, and ``SystemLayoutInput``. - - The class attribute ``FAIL_IF_AFTER`` should be a tuple defining what, if any, - modifications this should fail if performed after. For example, one should not - adjust the smear density (a function of Cladding ID) before adjusting the Cladding - ID. - - Some subclasses are provided, but you are expected to make your own design-specific - modifiers in most cases. + .. impl:: A generic tool to modify user inputs on multiple cases. + :id: I_ARMI_CASE_MOD1 + :implements: R_ARMI_CASE_MOD + + This class serves as an abstract base class for modifying the inputs of + a case, typically case settings. Child classes must implement a + ``__call__`` method accepting a + :py:class:`~armi.settings.caseSettings.Settings`, + :py:class:`~armi.reactor.blueprints.Blueprints`, and + :py:class:`~armi.reactor.systemLayoutInput.SystemLayoutInput` and return + the appropriately modified version of these objects. The class attribute + ``FAIL_IF_AFTER`` should be a tuple defining what, if any, modifications + this should fail if performed after. For example, one should not adjust + the smear density (a function of Cladding ID) before adjusting the + Cladding ID. Some generic child classes are provided in this module, but + it is expected that design-specific modifiers are built individually. """ FAIL_IF_AFTER = () @@ -60,7 +64,7 @@ class SamplingInputModifier(InputModifier): (This class is abstract.) - Subclasses must implement a ``__call__`` method accepting a ``CaseSettings``, + Subclasses must implement a ``__call__`` method accepting a ``Settings``, ``Blueprints``, and ``SystemLayoutInput``. This is a modified version of the InputModifier abstract class that imposes @@ -106,7 +110,7 @@ class FullCoreModifier(InputModifier): Notes ----- - Besides the core, other grids may also be of interest for expansion, like + Besides the Core, other grids may also be of interest for expansion, like a grid that defines fuel management. However, the expansion of a fuel management schedule to full core is less trivial than just expanding the core itself. Thus, this modifier currently does not attempt diff --git a/armi/cases/suite.py b/armi/cases/suite.py index f6aeba914..f3928403e 100644 --- a/armi/cases/suite.py +++ b/armi/cases/suite.py @@ -44,11 +44,16 @@ class CaseSuite: """ A CaseSuite is a collection of possibly related Case objects. - A CaseSuite is intended to be both a pre-processing and post-processing tool to - facilitate case generation and analysis. Under most circumstances one may wish to - subclass a CaseSuite to meet the needs of a specific calculation. - - A CaseSuite is a collection that is keyed off Case titles. + .. impl:: CaseSuite allows for one case to start after another completes. + :id: I_ARMI_CASE_SUITE + :implements: R_ARMI_CASE_SUITE + + The CaseSuite object allows multiple, often related, + :py:class:`~armi.cases.case.Case` objects to be run sequentially. A CaseSuite + is intended to be both a pre-processing or a post-processing tool to facilitate + case generation and analysis. Under most circumstances one may wish to subclass + a CaseSuite to meet the needs of a specific calculation. A CaseSuite is a + collection that is keyed off Case titles. """ def __init__(self, cs): @@ -86,9 +91,10 @@ def discover( self, rootDir=None, patterns=None, ignorePatterns=None, recursive=True ): """ - Finds case objects by searching for a pattern of inputs, and adds them to the suite. + Finds case objects by searching for a pattern of file paths, and adds them to + the suite. - This searches for CaseSettings input files and loads them to create Case objects. + This searches for Settings input files and loads them to create Case objects. Parameters ---------- @@ -120,8 +126,8 @@ def echoConfiguration(self): Notes ----- - Some of these printouts won't make sense for all users, and may - make sense to be delegated to the plugins/app. + Some of these printouts won't make sense for all users, and may make sense to + be delegated to the plugins/app. """ for setting in self.cs.environmentSettings: runLog.important( @@ -152,8 +158,8 @@ def clone(self, oldRoot=None, writeStyle="short"): Creates a clone for each case within a CaseSuite. If ``oldRoot`` is not specified, then each case clone is made in a directory with the title of the - case. If ``oldRoot`` is specified, then a relative path from ``oldRoot`` will be - used to determine a new relative path to the current directory ``oldRoot``. + case. If ``oldRoot`` is specified, then a relative path from ``oldRoot`` will + be used to determine a new relative path to the current directory ``oldRoot``. Parameters ---------- @@ -197,9 +203,10 @@ def run(self): """ Run each case, one after the other. - .. warning: Suite running may not work yet if the cases have interdependencies. - We typically run on a HPC but are still working on a platform - independent way of handling HPCs. + Warning + ------- + Suite running may not work yet if the cases have interdependencies. We typically run on a + HPC but are still working on a platform independent way of handling HPCs. """ for ci, case in enumerate(self): runLog.important(f"Running case {ci+1}/{len(self)}: {case}") @@ -307,6 +314,7 @@ def writeTable(tableResults): userFile, refFile, caseIssues = tableResults[testName] data.append((testName, userFile, refFile, caseIssues)) totalDiffs += caseIssues + print(tabulate.tabulate(data, header, tablefmt=fmt)) print( tabulate.tabulate( diff --git a/armi/cases/suiteBuilder.py b/armi/cases/suiteBuilder.py index e2ce1de57..fd846086d 100644 --- a/armi/cases/suiteBuilder.py +++ b/armi/cases/suiteBuilder.py @@ -45,6 +45,22 @@ class SuiteBuilder: """ Class for constructing a CaseSuite from combinations of modifications on base inputs. + .. impl:: A generic tool to modify user inputs on multiple cases. + :id: I_ARMI_CASE_MOD0 + :implements: R_ARMI_CASE_MOD + + This class provides the capability to create a + :py:class:`~armi.cases.suite.CaseSuite` based on programmatic + perturbations/modifications to case settings. It works by being + constructed with a base or nominal :py:class:`~armi.cases.case.Case` + object. Children classes then append the ``self.modifierSets`` member. + Each entry in ``self.modifierSets`` is a + :py:class:`~armi.cases.inputModifiers.inputModifiers.InputModifier` + representing a case to add to the suite by specifying modifications to + the settings of the base case. :py:meth:`SuiteBuilder.buildSuite` is + then invoked, returning an instance of the :py:class:`~armi.cases.suite.CaseSuite` + containing all the cases with modified settings. + Attributes ---------- baseCase : armi.cases.case.Case @@ -86,9 +102,9 @@ def addDegreeOfFreedom(self, inputModifiers): Parameters ---------- - inputModifiers : list(callable(CaseSettings, Blueprints, SystemLayoutInput)) + inputModifiers : list(callable(Settings, Blueprints, SystemLayoutInput)) A list of callable objects with the signature - ``(CaseSettings, Blueprints, SystemLayoutInput)``. When these objects are called + ``(Settings, Blueprints, SystemLayoutInput)``. When these objects are called they should perturb the settings, blueprints, and/or geometry by some amount determined by their construction. """ @@ -117,7 +133,7 @@ def buildSuite(self, namingFunc=None): and a tuple of InputModifiers used to edit the case. This should be enough information for someone to derive a meaningful name. - The function should return a string specifying the path of the ``CaseSettings``, this + The function should return a string specifying the path of the ``Settings``, this allows the user to specify the directories where each case will be run. If not supplied the path will be ``./case-suite/<0000>/-<0000>``, where @@ -200,7 +216,7 @@ def __init__(self, settingName, value): self.value = value def __call__(self, cs, bp, geom): - cs = cs.modified(newSettings={settignName: value}) + cs = cs.modified(newSettings={self.settingName: self.value}) return cs, bp, geom builder = FullFactorialSuiteBuilder(someCase) @@ -209,14 +225,21 @@ def __call__(self, cs, bp, geom): would result in 6 cases: + +-------+------------------+------------------+ | Index | ``settingName1`` | ``settingName2`` | - | ----- | ---------------- | ---------------- | + +=======+==================+==================+ | 0 | 1 | 3 | + +-------+------------------+------------------+ | 1 | 2 | 3 | + +-------+------------------+------------------+ | 2 | 1 | 4 | + +-------+------------------+------------------+ | 3 | 2 | 4 | + +-------+------------------+------------------+ | 4 | 1 | 5 | + +-------+------------------+------------------+ | 5 | 2 | 5 | + +-------+------------------+------------------+ See Also -------- @@ -286,7 +309,7 @@ def __init__(self, settingName, value): self.value = value def __call__(self, cs, bp, geom): - cs = cs.modified(newSettings={settignName: value}) + cs = cs.modified(newSettings={self.settignName: self.value}) return cs, bp, geom builder = SeparateEffectsSuiteBuilder(someCase) @@ -295,13 +318,19 @@ def __call__(self, cs, bp, geom): would result in 5 cases: + +-------+------------------+------------------+ | Index | ``settingName1`` | ``settingName2`` | - | ----- | ---------------- | ---------------- | + +=======+==================+==================+ | 0 | 1 | default | + +-------+------------------+------------------+ | 1 | 2 | default | + +-------+------------------+------------------+ | 2 | default | 3 | + +-------+------------------+------------------+ | 3 | default | 4 | + +-------+------------------+------------------+ | 4 | default | 5 | + +-------+------------------+------------------+ See Also -------- @@ -392,7 +421,7 @@ def buildSuite(self, namingFunc=None): and a tuple of InputModifiers used to edit the case. This should be enough information for someone to derive a meaningful name. - The function should return a string specifying the path of the ``CaseSettings``, this + The function should return a string specifying the path of the ``Settings``, this allows the user to specify the directories where each case will be run. If not supplied the path will be ``./case-suite/<0000>/<title>-<0000>``, where diff --git a/armi/cases/tests/test_cases.py b/armi/cases/tests/test_cases.py index f030c5077..0483868fa 100644 --- a/armi/cases/tests/test_cases.py +++ b/armi/cases/tests/test_cases.py @@ -20,6 +20,8 @@ import platform import unittest +import h5py + from armi import cases from armi import context from armi import getApp @@ -27,9 +29,11 @@ from armi import plugins from armi import runLog from armi import settings +from armi.bookkeeping.db.databaseInterface import DatabaseInterface from armi.physics.fuelCycle.settings import CONF_SHUFFLE_LOGIC from armi.reactor import blueprints from armi.reactor import systemLayoutInput +from armi.reactor.tests import test_reactors from armi.tests import ARMI_RUN_PATH from armi.tests import mockRunLogs from armi.tests import TEST_ROOT @@ -199,6 +203,17 @@ def test_endProfiling(self): self.assertTrue(isinstance(prof, cProfile.Profile)) def test_run(self): + """ + Test running a case. + + .. test:: There is a generic mechanism to allow simulation runs. + :id: T_ARMI_CASE + :tests: R_ARMI_CASE + + .. test:: Test case settings object is created, settings can be edited, and case can run. + :id: T_ARMI_SETTING + :tests: R_ARMI_SETTING + """ with directoryChangers.TemporaryDirectoryChanger(): cs = settings.Settings(ARMI_RUN_PATH) newSettings = { @@ -284,17 +299,22 @@ def test_clone(self): with self.assertRaises(RuntimeError): _clone = self.suite.clone("test_clone") - def test_dependenciesWithObscurePaths(self): + def test_checkInputs(self): """ - Test directory dependence. + Test the checkInputs() method on a couple of cases. - .. tip:: This should be updated to use the Python pathlib - so the tests can work in both Linux and Windows identically. + .. test:: Check the ARMI inputs for consistency and validity. + :id: T_ARMI_CASE_CHECK + :tests: R_ARMI_CASE_CHECK """ + self.c1.checkInputs() + self.c2.checkInputs() + + def test_dependenciesWithObscurePaths(self): + """Test directory dependence for strangely-written file paths (escape characters).""" checks = [ ("c1.yaml", "c2.yaml", "c1.h5", True), (r"\\case\1\c1.yaml", r"\\case\2\c2.yaml", "c1.h5", False), - # below doesn't work due to some windows path obscurities (r"\\case\1\c1.yaml", r"\\case\2\c2.yaml", r"..\1\c1.h5", False), ] if platform.system() == "Windows": @@ -313,7 +333,7 @@ def test_dependenciesWithObscurePaths(self): r"c2.yaml", r".\c1.h5", True, - ), # py bug in 3.6.4 and 3.7.1 fails here + ), ( r"\\cas\es\1\c1.yaml", r"\\cas\es\2\c2.yaml", @@ -393,6 +413,13 @@ def test_dependencyFromExplictRepeatShuffles(self): self.assertIn(self.c1, self.c2.dependencies) def test_explicitDependency(self): + """ + Test dependencies for case suites. + + .. test:: Dependence allows for one case to start after the completion of another. + :id: T_ARMI_CASE_SUITE + :tests: R_ARMI_CASE_SUITE + """ self.c1.addExplicitDependency(self.c2) self.assertIn(self.c2, self.c1.dependencies) @@ -407,6 +434,73 @@ def test_buildCommand(self): self.assertEqual(cmd, 'python -u -m armi run "c1.yaml"') +class TestCaseSuiteComparison(unittest.TestCase): + """CaseSuite.compare() tests.""" + + def setUp(self): + self.td = directoryChangers.TemporaryDirectoryChanger() + self.td.__enter__() + + def tearDown(self): + self.td.__exit__(None, None, None) + + def test_compareNoDiffs(self): + """As a baseline, this test should always reveal zero diffs.""" + # build two super-simple H5 files for testing + o, r = test_reactors.loadTestReactor( + TEST_ROOT, customSettings={"reloadDBName": "reloadingDB.h5"} + ) + + suites = [] + for _i in range(2): + # Build the cases + suite = cases.CaseSuite(settings.Settings()) + + geom = systemLayoutInput.SystemLayoutInput() + geom.readGeomFromStream(io.StringIO(GEOM_INPUT)) + bp = blueprints.Blueprints.load(BLUEPRINT_INPUT) + + c1 = cases.Case(cs=settings.Settings(), geom=geom, bp=bp) + c1.cs.path = "c1.yaml" + suite.add(c1) + + c2 = cases.Case(cs=settings.Settings(), geom=geom, bp=bp) + c2.cs.path = "c2.yaml" + suite.add(c2) + + suites.append(suite) + + # create two DBs, identical but for file names + tmpDir = os.getcwd() + dbs = [] + for i in range(1, 3): + # create the tests DB + dbi = DatabaseInterface(r, o.cs) + dbi.initDB(fName=f"{tmpDir}/c{i}.h5") + db = dbi.database + + # validate the file exists, and force it to be readable again + b = h5py.File(db._fullPath, "r") + self.assertEqual(list(b.keys()), ["inputs"]) + self.assertEqual( + sorted(b["inputs"].keys()), ["blueprints", "geomFile", "settings"] + ) + b.close() + + # append to lists + dbs.append(db) + + # do a comparison that should have no diffs + diff = c1.compare(c2) + self.assertEqual(diff, 0) + + diff = suites[0].compare(suites[1]) + self.assertEqual(diff, 0) + + diff = suites[1].compare(suites[0]) + self.assertEqual(diff, 0) + + class TestExtraInputWriting(unittest.TestCase): """Make sure extra inputs from interfaces are written.""" @@ -447,10 +541,26 @@ def specifyInputs(cs): return {settingName: cs[settingName]} +class TestPluginWithDuplicateSetting(plugins.ArmiPlugin): + @staticmethod + @plugins.HOOKIMPL + def defineSettings(): + """Define a duplicate setting.""" + return [ + settings.setting.Setting( + "power", + default=123, + label="power", + description="duplicate power", + ) + ] + + class TestPluginForCopyInterfacesMultipleFiles(plugins.ArmiPlugin): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" return [ settings.setting.Setting( "multipleFilesSetting", @@ -463,6 +573,7 @@ def defineSettings(): @staticmethod @plugins.HOOKIMPL def exposeInterfaces(cs): + """A plugin is mostly just a vehicle to add Interfaces to an Application.""" return [ interfaces.InterfaceInfo( interfaces.STACK_ORDER.PREPROCESSING, @@ -549,6 +660,21 @@ def test_copyInterfaceInputs_nonFilePath(self): self.assertFalse(os.path.exists(newSettings[testSetting])) self.assertEqual(newSettings[testSetting], fakeShuffle) + def test_failOnDuplicateSetting(self): + """ + That that if a plugin attempts to add a duplicate setting, it raises an error. + + .. test:: Plugins cannot register duplicate settings. + :id: T_ARMI_SETTINGS_UNIQUE + :tests: R_ARMI_SETTINGS_UNIQUE + """ + # register the new Plugin + app = getApp() + app.pluginManager.register(TestPluginWithDuplicateSetting) + + with self.assertRaises(ValueError): + _ = settings.Settings(ARMI_RUN_PATH) + def test_copyInterfaceInputs_multipleFiles(self): # register the new Plugin app = getApp() diff --git a/armi/cases/tests/test_suiteBuilder.py b/armi/cases/tests/test_suiteBuilder.py index fc48d64f1..e1681091f 100644 --- a/armi/cases/tests/test_suiteBuilder.py +++ b/armi/cases/tests/test_suiteBuilder.py @@ -17,8 +17,15 @@ import unittest from armi import cases, settings -from armi.cases.inputModifiers.inputModifiers import SamplingInputModifier -from armi.cases.suiteBuilder import LatinHyperCubeSuiteBuilder +from armi.cases.inputModifiers.inputModifiers import ( + SamplingInputModifier, + InputModifier, +) +from armi.cases.suiteBuilder import ( + LatinHyperCubeSuiteBuilder, + FullFactorialSuiteBuilder, + SeparateEffectsSuiteBuilder, +) cs = settings.Settings( os.path.join( @@ -45,6 +52,16 @@ def __call__(self, cs, bp, geom): return cs, bp, geom +class SettingModifier(InputModifier): + def __init__(self, settingName, value): + self.settingName = settingName + self.value = value + + def __call__(self, cs, bp, geom): + cs = cs.modified(newSettings={self.settingName: self.value}) + return cs, bp, geom + + class TestLatinHyperCubeSuiteBuilder(unittest.TestCase): """Class to test LatinHyperCubeSuiteBuilder.""" @@ -53,6 +70,13 @@ def test_initialize(self): assert builder.modifierSets == [] def test_buildSuite(self): + """ + Initialize an LHC suite. + + .. test:: A generic mechanism to allow users to modify user inputs in cases. + :id: T_ARMI_CASE_MOD0 + :tests: R_ARMI_CASE_MOD + """ builder = LatinHyperCubeSuiteBuilder(case, size=20) powerMod = LatinHyperCubeModifier("power", "continuous", [0, 1e6]) availabilityMod = LatinHyperCubeModifier( @@ -72,3 +96,78 @@ def test_addDegreeOfFreedom(self): with self.assertRaises(ValueError): builder.addDegreeOfFreedom([powerMod, morePowerMod]) + + +class TestFullFactorialSuiteBuilder(unittest.TestCase): + """Class to test FullFactorialSuiteBuilder.""" + + def test_buildSuite(self): + """Initialize a full factorial suite of cases. + + .. test:: A generic mechanism to allow users to modify user inputs in cases. + :id: T_ARMI_CASE_MOD1 + :tests: R_ARMI_CASE_MOD + """ + builder = FullFactorialSuiteBuilder(case) + builder.addDegreeOfFreedom( + SettingModifier("settingName1", value) for value in (1, 2) + ) + builder.addDegreeOfFreedom( + SettingModifier("settingName2", value) for value in (3, 4, 5) + ) + + self.assertEquals(builder.modifierSets[0][0].value, 1) + self.assertEquals(builder.modifierSets[0][1].value, 3) + + self.assertEquals(builder.modifierSets[1][0].value, 2) + self.assertEquals(builder.modifierSets[1][1].value, 3) + + self.assertEquals(builder.modifierSets[2][0].value, 1) + self.assertEquals(builder.modifierSets[2][1].value, 4) + + self.assertEquals(builder.modifierSets[3][0].value, 2) + self.assertEquals(builder.modifierSets[3][1].value, 4) + + self.assertEquals(builder.modifierSets[4][0].value, 1) + self.assertEquals(builder.modifierSets[4][1].value, 5) + + self.assertEquals(builder.modifierSets[5][0].value, 2) + self.assertEquals(builder.modifierSets[5][1].value, 5) + + self.assertEquals(len(builder.modifierSets), 6) + + +class TestSeparateEffectsBuilder(unittest.TestCase): + """Class to test separate effects builder.""" + + def test_buildSuite(self): + """Initialize a full factorial suite of cases. + + .. test:: A generic mechanism to allow users to modify user inputs in cases. + :id: T_ARMI_CASE_MOD2 + :tests: R_ARMI_CASE_MOD + """ + builder = SeparateEffectsSuiteBuilder(case) + builder.addDegreeOfFreedom( + SettingModifier("settingName1", value) for value in (1, 2) + ) + builder.addDegreeOfFreedom( + SettingModifier("settingName2", value) for value in (3, 4, 5) + ) + + self.assertEquals(builder.modifierSets[0][0].value, 1) + self.assertEquals(builder.modifierSets[0][0].settingName, "settingName1") + + self.assertEquals(builder.modifierSets[1][0].value, 2) + self.assertEquals(builder.modifierSets[1][0].settingName, "settingName1") + + self.assertEquals(builder.modifierSets[2][0].value, 3) + self.assertEquals(builder.modifierSets[2][0].settingName, "settingName2") + + self.assertEquals(builder.modifierSets[3][0].value, 4) + self.assertEquals(builder.modifierSets[3][0].settingName, "settingName2") + + self.assertEquals(builder.modifierSets[4][0].value, 5) + self.assertEquals(builder.modifierSets[4][0].settingName, "settingName2") + + self.assertEquals(len(builder.modifierSets), 5) diff --git a/armi/cli/__init__.py b/armi/cli/__init__.py index 30a789324..dd46f65c2 100644 --- a/armi/cli/__init__.py +++ b/armi/cli/__init__.py @@ -100,10 +100,17 @@ def print_help(self, file=None): class ArmiCLI: """ - ARMI CLI -- The main entry point into ARMI. There are various commands - available, to get help for the individual commands, run again with - `<command> --help`. Generically, the CLI implements functions that already - exists within ARMI. + ARMI CLI -- The main entry point into ARMI. There are various commands available. To get help + for the individual commands, run again with `<command> --help`. Typically, the CLI implements + functions that already exist within ARMI. + + .. impl:: The basic ARMI CLI, for running a simulation. + :id: I_ARMI_CLI_CS + :implements: R_ARMI_CLI_CS + + Provides a basic command-line interface (CLI) for running an ARMI simulation. Available + commands can be listed with ``-l``. Information on individual commands can be obtained by + running with ``<command> --help``. """ def __init__(self): @@ -124,7 +131,7 @@ def __init__(self): parser = ArmiParser( prog=context.APP_NAME, - description=self.__doc__, + description=self.__doc__.split(".. impl")[0], usage="%(prog)s [-h] [-l | command [args]]", ) @@ -198,7 +205,7 @@ def run(self) -> Optional[int]: return self.executeCommand(args.command, args.args) def executeCommand(self, command, args) -> Optional[int]: - r"""Execute `command` with arguments `args`, return optional exit code.""" + """Execute `command` with arguments `args`, return optional exit code.""" command = command.lower() if command not in self._entryPoints: print( diff --git a/armi/cli/database.py b/armi/cli/database.py index dedd575da..651cb5dfd 100644 --- a/armi/cli/database.py +++ b/armi/cli/database.py @@ -15,7 +15,6 @@ """Entry point into ARMI for manipulating output databases.""" import os import pathlib -import re from armi import context from armi import runLog @@ -23,64 +22,6 @@ from armi.utils.textProcessors import resolveMarkupInclusions -class ConvertDB(EntryPoint): - """Convert databases between different versions.""" - - name = "convert-db" - mode = context.Mode.BATCH - - def addOptions(self): - self.parser.add_argument("h5db", help="Input database path", type=str) - self.parser.add_argument( - "--output-name", "-o", help="output database name", type=str, default=None - ) - self.parser.add_argument( - "--output-version", - help=( - "output database version. '2' or 'xtview' for older XTView database; '3' " - "for new format." - ), - type=str, - default=None, - ) - - self.parser.add_argument( - "--nodes", - help="An optional list of time nodes to migrate. Should look like " - "`(1,0)(1,1)(1,2)`, etc", - type=str, - default=None, - ) - - def parse_args(self, args): - EntryPoint.parse_args(self, args) - if self.args.output_version is None: - self.args.output_version = "3" - elif self.args.output_version.lower() == "xtview": - self.args.output_version = "2" - - if self.args.nodes is not None: - self.args.nodes = [ - (int(cycle), int(node)) - for cycle, node in re.findall(r"\((\d+),(\d+)\)", self.args.nodes) - ] - - def invoke(self): - from armi.bookkeeping.db import convertDatabase - - if self.args.nodes is not None: - runLog.info( - "Converting the following time nodes: {}".format(self.args.nodes) - ) - - convertDatabase( - self.args.h5db, - outputDBName=self.args.output_name, - outputVersion=self.args.output_version, - nodes=self.args.nodes, - ) - - class ExtractInputs(EntryPoint): """ Recover input files from a database file. diff --git a/armi/cli/entryPoint.py b/armi/cli/entryPoint.py index 2a9c2fc82..ef1ffef5b 100644 --- a/armi/cli/entryPoint.py +++ b/armi/cli/entryPoint.py @@ -51,8 +51,23 @@ class EntryPoint: """ Generic command line entry point. - A valid subclass must provide at least a ``name`` class attribute, and may also - specify the other class attributes described below. + A valid subclass must provide at least a ``name`` class attribute, and may also specify the + other class attributes described below. + + .. impl:: Generic CLI base class for developers to use. + :id: I_ARMI_CLI_GEN + :implements: R_ARMI_CLI_GEN + + Provides a base class for plugin developers to use in creating application-specific CLIs. + Valid subclasses must at least provide a ``name`` class attribute. + + Optional class attributes that a subclass may provide include ``description``, a string + describing the command's actions, ``splash``, a boolean specifying whether to display a + splash screen upon execution, and ``settingsArgument``. If ``settingsArgument`` is specified + as ``required``, then a settings files is a required positional argument. If + ``settingsArgument`` is set to ``optional``, then a settings file is an optional positional + argument. If None is specified for the ``settingsArgument``, then no settings file argument + is added. """ #: The <command-name> that is used to call the command from the command line diff --git a/armi/cli/modify.py b/armi/cli/modify.py index 480476436..75c557a58 100644 --- a/armi/cli/modify.py +++ b/armi/cli/modify.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -r""" +""" Search through a directory tree and modify ARMI settings in existing input file(s). All valid settings may be used as keyword arguments. """ diff --git a/armi/cli/tests/test_runEntryPoint.py b/armi/cli/tests/test_runEntryPoint.py index 069ad20e3..3ec38f25f 100644 --- a/armi/cli/tests/test_runEntryPoint.py +++ b/armi/cli/tests/test_runEntryPoint.py @@ -22,7 +22,7 @@ from armi.cli.checkInputs import CheckInputEntryPoint, ExpandBlueprints from armi.cli.clone import CloneArmiRunCommandBatch, CloneSuiteCommand from armi.cli.compareCases import CompareCases, CompareSuites -from armi.cli.database import ConvertDB, ExtractInputs, InjectInputs +from armi.cli.database import ExtractInputs, InjectInputs from armi.cli.entryPoint import EntryPoint from armi.cli.migrateInputs import MigrateInputs from armi.cli.modify import ModifyCaseSettingsCommand @@ -37,11 +37,16 @@ class TestInitializationEntryPoints(unittest.TestCase): def test_entryPointInitialization(self): - """Tests the initialization of all subclasses of `EntryPoint`.""" + """Tests the initialization of all subclasses of `EntryPoint`. + + .. test:: Test initialization of many basic CLIs. + :id: T_ARMI_CLI_GEN0 + :tests: R_ARMI_CLI_GEN + """ entryPoints = getEntireFamilyTree(EntryPoint) # Comparing to a minimum number of entry points, in case more are added. - self.assertGreater(len(entryPoints), 16) + self.assertGreater(len(entryPoints), 15) for e in entryPoints: entryPoint = e() @@ -74,6 +79,12 @@ def test_checkInputEntryPointBasics(self): self.assertEqual(ci.args.generate_design_summary, False) def test_checkInputEntryPointInvoke(self): + """Test the "check inputs" entry point. + + .. test:: A working CLI child class, to validate inputs. + :id: T_ARMI_CLI_GEN1 + :tests: R_ARMI_CLI_GEN + """ ci = CheckInputEntryPoint() ci.addOptions() ci.parse_args([ARMI_RUN_PATH]) @@ -124,6 +135,12 @@ def test_cloneArmiRunCommandBatchInvokeShort(self): self.assertNotIn("availabilityFactor", txt) def test_cloneArmiRunCommandBatchInvokeMedium(self): + """Test the "clone armi run" batch entry point, on medium detail. + + .. test:: A working CLI child class, to clone a run. + :id: T_ARMI_CLI_GEN2 + :tests: R_ARMI_CLI_GEN + """ # Test medium write style ca = CloneArmiRunCommandBatch() ca.addOptions() @@ -174,36 +191,6 @@ def test_compareSuitesBasics(self): self.assertIsNone(cs.args.weights) -class TestConvertDB(unittest.TestCase): - def test_convertDbBasics(self): - cdb = ConvertDB() - cdb.addOptions() - cdb.parse_args(["/path/to/fake.h5"]) - - self.assertEqual(cdb.name, "convert-db") - self.assertEqual(cdb.args.output_version, "3") - self.assertIsNone(cdb.args.nodes) - - # Since the file is fake, invoke() should exit early. - with mockRunLogs.BufferLog() as mock: - cdb.args.nodes = [1, 2, 3] - with self.assertRaises(ValueError): - cdb.invoke() - self.assertIn("Converting the", mock.getStdout()) - - def test_convertDbOutputVersion(self): - cdb = ConvertDB() - cdb.addOptions() - cdb.parse_args(["/path/to/fake.h5", "--output-version", "XtView"]) - self.assertEqual(cdb.args.output_version, "2") - - def test_convertDbOutputNodes(self): - cdb = ConvertDB() - cdb.addOptions() - cdb.parse_args(["/path/to/fake.h5", "--nodes", "(1,2)"]) - self.assertEqual(cdb.args.nodes, [(1, 2)]) - - class TestExpandBlueprints(unittest.TestCase): def test_expandBlueprintsBasics(self): ebp = ExpandBlueprints() diff --git a/armi/cli/tests/test_runSuite.py b/armi/cli/tests/test_runSuite.py index 32bf55915..8c663b8c1 100644 --- a/armi/cli/tests/test_runSuite.py +++ b/armi/cli/tests/test_runSuite.py @@ -15,6 +15,7 @@ import io import sys import unittest +from unittest.mock import patch from armi import meta from armi.cli import ArmiCLI @@ -22,7 +23,12 @@ class TestRunSuiteSuite(unittest.TestCase): def test_listCommand(self): - """Ensure run-suite entry point is registered.""" + """Ensure run-suite entry point is registered. + + .. test:: The ARMI CLI can be correctly initialized. + :id: T_ARMI_CLI_CS0 + :tests: R_ARMI_CLI_CS + """ acli = ArmiCLI() origout = sys.stdout @@ -36,7 +42,12 @@ def test_listCommand(self): self.assertIn("run-suite", out.getvalue()) def test_showVersion(self): - """Test the ArmiCLI.showVersion method.""" + """Test the ArmiCLI.showVersion method. + + .. test:: The ARMI CLI's basic "--version" functionality works. + :id: T_ARMI_CLI_CS1 + :tests: R_ARMI_CLI_CS + """ origout = sys.stdout try: out = io.StringIO() @@ -47,3 +58,17 @@ def test_showVersion(self): self.assertIn("armi", out.getvalue()) self.assertIn(meta.__version__, out.getvalue()) + + @patch("armi.cli.ArmiCLI.executeCommand") + def test_run(self, mockExeCmd): + """Test the ArmiCLI.run method. + + .. test:: The ARMI CLI's import run() method works. + :id: T_ARMI_CLI_CS2 + :tests: R_ARMI_CLI_CS + """ + correct = 0 + acli = ArmiCLI() + mockExeCmd.return_value = correct + ret = acli.run() + self.assertEqual(ret, correct) diff --git a/armi/interfaces.py b/armi/interfaces.py index 8e96b3ffb..2855274b4 100644 --- a/armi/interfaces.py +++ b/armi/interfaces.py @@ -21,9 +21,7 @@ See Also -------- armi.operators : Schedule calls to various interfaces - armi.plugins : Register various interfaces - """ import copy from typing import Union @@ -44,20 +42,23 @@ class STACK_ORDER: # noqa: invalid-class-name """ Constants that help determine the order of modules in the interface stack. - Each module specifies an ``ORDER`` constant that specifies where in this order it + Each module defines an ``ORDER`` constant that specifies where in this order it should be placed in the Interface Stack. - Notes - ----- - Originally, the ordering was accomplished with a very large if/else construct in ``createInterfaces``. - This made more modular by moving the add/activate logic into each module and replacing the if/else with - just a large hard-coded list of modules in order that could possibly be added. That hard-coded - list presented ``ImportError`` problems when building various subset distributions of ARMI so this ordering - mechanism was created to replace it, allowing the modules to define their required order internally. + .. impl:: Define an ordered list of interfaces. + :id: I_ARMI_OPERATOR_INTERFACES0 + :implements: R_ARMI_OPERATOR_INTERFACES + + At each time node during a simulation, an ordered colletion of Interfaces + are run (referred to as the interface stack). But ARMI does not force the order upon the analyst. + Instead, each Interface registers where in that ordered list it belongs by + giving itself an order number (which can be an integer or a decimal). + This class defines a set of constants which can be imported and used + by Interface developers to define that Interface's position in the stack. - Future improvements may include simply defining what information is required to perform a calculation - and figuring out the ordering from that. It's complex because in coupled simulations, everything - depends on everything. + The constants defined are given names, based on common stack orderings + in the ARMI ecosystem. But in the end, these are just constant values, + and the names they are given are merely suggestions. See Also -------- @@ -84,7 +85,25 @@ class STACK_ORDER: # noqa: invalid-class-name class TightCoupler: """ Data structure that defines tight coupling attributes that are implemented - within an Interface and called upon when ``interactCoupled`` is called. + within an Interface and called upon when ``interactAllCoupled`` is called. + + .. impl:: The TightCoupler defines the convergence criteria for physics coupling. + :id: I_ARMI_OPERATOR_PHYSICS0 + :implements: R_ARMI_OPERATOR_PHYSICS + + During a simulation, the developers of an ARMI application frequently want to + iterate on some physical calculation until that calculation has converged to + within some small tolerance. This is typically done to solve the nonlinear + dependence of different physical properties of the reactor, like fuel + performance. However, what parameter is being tightly coupled is configurable + by the developer. + + This class provides a way to calculate if a single parameter has converged + based on some convergence tolerance. The user provides the parameter, + tolerance, and a maximum number of iterations to define a basic convergence + calculation. If in the ``isConverged`` method the parameter has not converged, + the number of iterations is incremented, and this class will wait, presuming + another iteration is forthcoming. Parameters ---------- @@ -92,9 +111,8 @@ class TightCoupler: The name of a parameter defined in the ARMI Reactor model. tolerance : float - Defines the allowable error, epsilon, between the current previous - parameter value(s) to determine if the selected coupling parameter has - been converged. + Defines the allowable error between the current and previous parameter values + to determine if the selected coupling parameter has converged. maxIters : int Maximum number of tight coupling iterations allowed @@ -111,7 +129,10 @@ def __init__(self, param, tolerance, maxIters): self.eps = numpy.inf def __repr__(self): - return f"<{self.__class__.__name__}, Parameter: {self.parameter}, Convergence Criteria: {self.tolerance}, Maximum Coupled Iterations: {self.maxIters}>" + return ( + f"<{self.__class__.__name__}, Parameter: {self.parameter}, Convergence Criteria: " + + f"{self.tolerance}, Maximum Coupled Iterations: {self.maxIters}>" + ) def storePreviousIterationValue(self, val: _SUPPORTED_TYPES): """ @@ -233,13 +254,23 @@ def getListDimension(listToCheck: list, dim: int = 1) -> int: class Interface: """ - The eponymous Interface between the ARMI Reactor model and modules that operate upon it. + The eponymous Interface between the ARMI reactor data model and the Plugins. - This defines the operator's contract for interacting with the ARMI reactor model. - It is expected that interact* methods are defined as appropriate for the physics modeling. + .. impl:: The interface shall allow code execution at important operational points in time. + :id: I_ARMI_INTERFACE + :implements: R_ARMI_INTERFACE - Interface instances are gathered into an interface stack in - :py:meth:`armi.operators.operator.Operator.createInterfaces`. + The Interface class defines a number methods with names like ``interact***``. + These methods are called in order at each time node. This allows for an + individual Plugin defining multiple interfaces to insert code at the start + or end of a particular time node or cycle during reactor simulation. In this + fashion, the Plugins and thus the Operator control when their code is run. + + The end goal of all this work is to allow the Plugins to carefully tune + when and how they interact with the reactor data model. + + Interface instances are gathered into an interface stack in + :py:meth:`armi.operators.operator.Operator.createInterfaces`. """ # list containing interfaceClass @@ -258,7 +289,7 @@ def getInputFiles(cls, cs): overridden by any concrete class that extends this one. """ - # TODO: This is a terrible variable name. + # TODO: This is a terrible name. function = None """ The function performed by an Interface. This is not required be be defined @@ -332,7 +363,7 @@ def preDistributeState(self): Examples -------- - return {'neutronsPerFission',self.neutronsPerFission} + >>> return {'neutronsPerFission',self.neutronsPerFission} """ return {} @@ -589,7 +620,7 @@ def specifyInputs(cs) -> Dict[Union[str, settings.Setting], List[str]]: relative to the input directory. Absolute paths will not be copied anywhere. - The returned dictionary will enable the source CaseSettings object to + The returned dictionary will enable the source Settings object to be updated to the new file location. While the dictionary keys are recommended to be Setting objects, the name of the setting as a string, e.g., "shuffleLogic", is still interpreted. If the string name does not @@ -606,7 +637,7 @@ def specifyInputs(cs) -> Dict[Union[str, settings.Setting], List[str]]: Parameters ---------- - cs : CaseSettings + cs : Settings The case settings for a particular Case """ return {} @@ -728,7 +759,7 @@ def getActiveInterfaceInfo(cs): Parameters ---------- - cs : CaseSettings + cs : Settings The case settings that activate relevant Interfaces """ interfaceInfo = [] diff --git a/armi/materials/__init__.py b/armi/materials/__init__.py index 02eaa38cb..84fd7028f 100644 --- a/armi/materials/__init__.py +++ b/armi/materials/__init__.py @@ -15,37 +15,57 @@ """ The material package defines compositions and material-specific properties. -Properties in scope include temperature dependent thermo/mechanical properties +Properties in scope include temperature dependent thermo/mechanical properties (like heat capacity, linear expansion coefficients, viscosity, density), -and material-specific nuclear properties that can't exist at the nuclide level +and material-specific nuclear properties that can't exist at the nuclide level alone (like :py:mod:`thermal scattering laws <armi.nucDirectory.thermalScattering>`). As the fundamental macroscopic building blocks of any physical object, these are highly important to reactor analysis. -This module handles the dynamic importing of all the materials defined here at the framework -level as well as in all the attached plugins. It is expected that most teams will -have special material definitions that they will want to define. +This module handles the dynamic importing of all the materials defined here at the +framework level as well as in all the attached plugins. It is expected that most teams +will have special material definitions that they will want to define. It may also make sense in the future to support user-input materials that are not hard-coded into the app. The base class for all materials is in :py:mod:`armi.materials.material`. """ -import pkgutil -import importlib from typing import List +import importlib import inspect +import pkgutil from armi.materials.material import Material -# this will frequently be updated by the CONF_MATERIAL_NAMESPACE_ORDER setting -# during reactor construction (see armi.reactor.reactors.factory) -# This may also be replaced by a more global material registry at some point. +# This will frequently be updated by the CONF_MATERIAL_NAMESPACE_ORDER setting +# during reactor construction (see armi.reactor.reactors.factory). _MATERIAL_NAMESPACE_ORDER = ["armi.materials"] def setMaterialNamespaceOrder(order): + """ + Set the material namespace order at the Python interpreter, global level. + + .. impl:: Material collections are defined with an order of precedence in the case + of duplicates. + :id: I_ARMI_MAT_ORDER + :implements: R_ARMI_MAT_ORDER + + An ARMI application will need materials. Materials can be imported from + any code the application has access to, like plugin packages. This leads to + the situation where one ARMI application will want to import multiple + collections of materials. To handle this, ARMI keeps a list of material + namespaces. This is an ordered list of importable packages that ARMI + can search for a particular material by name. + + This automatic exploration of an importable package saves the user the + tedium have having to import or include hundreds of materials manually somehow. + But it comes with a caveat; the list is ordered. If two different namespaces in + the list include a material with the same name, the first one found in the list + is chosen, i.e. earlier namespaces in the list have precedence. + """ global _MATERIAL_NAMESPACE_ORDER _MATERIAL_NAMESPACE_ORDER = order @@ -113,26 +133,41 @@ def resolveMaterialClassByName(name: str, namespaceOrder: List[str] = None): Find the first material class that matches a name in an ordered namespace. Names can either be fully resolved class paths (e.g. ``armi.materials.uZr:UZr``) - or simple class names (e.g. ``UZr``). In the latter case, the ``CONF_MATERIAL_NAMESPACE_ORDER`` - setting to allows users to choose which particular material of a common name (like UO2 or HT9) - gets used. + or simple class names (e.g. ``UZr``). In the latter case, the + ``CONF_MATERIAL_NAMESPACE_ORDER`` setting to allows users to choose which + particular material of a common name (like UO2 or HT9) gets used. Input files usually specify a material like UO2. Which particular implementation gets used (Framework's UO2 vs. a user plugins UO2 vs. the Kentucky Transportation Cabinet's UO2) is up to the user at runtime. + .. impl:: Materials can be searched across packages in a defined namespace. + :id: I_ARMI_MAT_NAMESPACE + :implements: R_ARMI_MAT_NAMESPACE + + During the runtime of an ARMI application, but particularly during the + construction of the reactor in memory, materials will be requested by name. At + that point, this code is called to search for that material name. The search + goes through the ordered list of Python namespaces provided. The first time an + instance of that material is found, it is returned. In this way, the first + items in the material namespace list take precedence. + + When a material name is passed to this function, it may be either a simple + name like the string ``"UO2"`` or it may be much more specific, like + ``armi.materials.uraniumOxide:UO2``. + Parameters ---------- name : str The material class name to find, e.g. ``"UO2"``. Optionally, a module path - and class name can be provided with a colon separator as ``module:className``, e.g. - ``armi.materials.uraniumOxide:UO2`` for direct specification. + and class name can be provided with a colon separator as ``module:className``, + e.g. ``armi.materials.uraniumOxide:UO2`` for direct specification. namespaceOrder : list of str, optional - A list of namespaces in order of preference in which to search for the material. - If not passed, the value in the global ``MATERIAL_NAMESPACE_ORDER`` will be used, - which is often set by the ``CONF_MATERIAL_NAMESPACE_ORDER`` setting (e.g. - during reactor construction). Any value passed into this argument will be ignored - if the ``name`` is provided with a ``modulePath``. + A list of namespaces in order of preference in which to search for the + material. If not passed, the value in the global ``MATERIAL_NAMESPACE_ORDER`` + will be used, which is often set by the ``CONF_MATERIAL_NAMESPACE_ORDER`` + setting (e.g. during reactor construction). Any value passed into this argument + will be ignored if the ``name`` is provided with a ``modulePath``. Returns ------- diff --git a/armi/materials/material.py b/armi/materials/material.py index c5592db4d..9ba80ec59 100644 --- a/armi/materials/material.py +++ b/armi/materials/material.py @@ -34,7 +34,28 @@ class Material: """ - A material is made up of elements or isotopes. It has bulk properties like mass density. + A material is made up of elements or isotopes. It has bulk properties like density. + + .. impl:: The abstract material class. + :id: I_ARMI_MAT_PROPERTIES + :implements: R_ARMI_MAT_PROPERTIES + + The ARMI Materials library is based on the Object-Oriented Programming design + approach, and uses this generic ``Material`` base class. In this class we + define a large number of material properties like density, heat capacity, or + linear expansion coefficient. Specific materials then subclass this base class to + assign particular values to those properties. + + .. impl:: Materials generate nuclide mass fractions at instantiation. + :id: I_ARMI_MAT_FRACS + :implements: R_ARMI_MAT_FRACS + + An ARMI material is meant to be able to represent real world materials that + might be used in the construction of a nuclear reactor. As such, they are + not just individual nuclides, but practical materials like a particular + concrete, steel, or water. One of the main things that will be needed to + describe such a material is the exact nuclide fractions. As such, the + constructor of every Material subclass attempts to set these mass fractions. Attributes ---------- @@ -94,7 +115,18 @@ def __repr__(self): @property def name(self): - """Getter for the private name attribute of this Material.""" + """ + Getter for the private name attribute of this Material. + + .. impl:: The name of a material is accessible. + :id: I_ARMI_MAT_NAME + :implements: R_ARMI_MAT_NAME + + Every instance of an ARMI material must have a simple, human-readable + string name. And, if possible, we want this string to match the class + name. (This, of course, puts some limits on both the string and the + class name.) These names are easily retrievable as a class property. + """ return self._name @name.setter @@ -707,6 +739,15 @@ def getThermalExpansionDensityReduction(self, prevTempInC, newTempInC): def linearExpansion(self, Tk=None, Tc=None): """For void, lets just not allow temperature changes to change dimensions since it is a liquid it will fill its space. + + .. impl:: Fluid materials are not thermally expandable. + :id: I_ARMI_MAT_FLUID + :implements: R_ARMI_MAT_FLUID + + ARMI does not model thermal expansion of fluids. The ``Fluid`` superclass + therefore sets the thermal expansion coefficient to zero. All fluids + subclassing the ``Fluid`` material will inherit this method which sets the + linear expansion coefficient to zero at all temperatures. """ return 0.0 diff --git a/armi/materials/mixture.py b/armi/materials/mixture.py index b2f3d7d44..91f5a8613 100644 --- a/armi/materials/mixture.py +++ b/armi/materials/mixture.py @@ -21,6 +21,8 @@ class _Mixture(materials.Material): """ Homogenized mixture of materials. + :meta public: + .. warning:: This class is meant to be used for homogenized block models for neutronics and other physics solvers. @@ -33,5 +35,5 @@ class _Mixture(materials.Material): See Also -------- - armi.reactor.blocks.HexBlock._createHomogenizedCopy + armi.reactor.blocks.HexBlock.createHomogenizedCopy """ diff --git a/armi/materials/tests/test_air.py b/armi/materials/tests/test_air.py index e183cb644..78311118f 100644 --- a/armi/materials/tests/test_air.py +++ b/armi/materials/tests/test_air.py @@ -178,7 +178,12 @@ class Test_Air(unittest.TestCase): - """unit tests for air materials.""" + """unit tests for air materials. + + .. test:: There is a base class for fluid materials. + :id: T_ARMI_MAT_FLUID1 + :tests: R_ARMI_MAT_FLUID + """ def test_pseudoDensity(self): """ diff --git a/armi/materials/tests/test_materials.py b/armi/materials/tests/test_materials.py index e60d692be..aa670d947 100644 --- a/armi/materials/tests/test_materials.py +++ b/armi/materials/tests/test_materials.py @@ -19,7 +19,8 @@ from numpy import testing -from armi import materials, settings +from armi import context, materials, settings +from armi.materials import _MATERIAL_NAMESPACE_ORDER, setMaterialNamespaceOrder from armi.nucDirectory import nuclideBases from armi.reactor import blueprints from armi.tests import mockRunLogs @@ -49,6 +50,7 @@ def test_density(self): self.assertNotEqual(self.mat.density(500), 0) def test_TD(self): + """Test the material density.""" self.assertEqual(self.mat.getTD(), self.mat.theoreticalDensityFrac) self.mat.clearCache() @@ -59,6 +61,7 @@ def test_TD(self): self.assertEqual(self.mat.cached, {}) def test_duplicate(self): + """Test the material duplication.""" mat = self.mat.duplicate() self.assertEqual(len(mat.massFrac), len(self.mat.massFrac)) @@ -70,6 +73,7 @@ def test_duplicate(self): self.assertEqual(mat.theoreticalDensityFrac, self.mat.theoreticalDensityFrac) def test_cache(self): + """Test the material cache.""" self.mat.clearCache() self.assertEqual(len(self.mat.cached), 0) @@ -80,11 +84,13 @@ def test_cache(self): self.assertEqual(val, "Noether") def test_densityKgM3(self): + """Test the density for kg/m^3.""" dens = self.mat.density(500) densKgM3 = self.mat.densityKgM3(500) self.assertEqual(dens * 1000.0, densKgM3) def test_pseudoDensityKgM3(self): + """Test the pseudo density for kg/m^3.""" dens = self.mat.pseudoDensity(500) densKgM3 = self.mat.pseudoDensityKgM3(500) self.assertEqual(dens * 1000.0, densKgM3) @@ -101,6 +107,16 @@ class MaterialFindingTests(unittest.TestCase): """Make sure materials are discoverable as designed.""" def test_findMaterial(self): + """Test resolveMaterialClassByName() function. + + .. test:: Materials can be grabbed from a list of namespaces. + :id: T_ARMI_MAT_NAMESPACE0 + :tests: R_ARMI_MAT_NAMESPACE + + .. test:: You can find a material by name. + :id: T_ARMI_MAT_NAME + :tests: R_ARMI_MAT_NAME + """ self.assertIs( materials.resolveMaterialClassByName( "Void", namespaceOrder=["armi.materials"] @@ -128,6 +144,52 @@ def test_findMaterial(self): "Unobtanium", namespaceOrder=["armi.materials"] ) + def __validateMaterialNamespace(self): + """Helper method to validate the material namespace a little.""" + self.assertTrue(isinstance(_MATERIAL_NAMESPACE_ORDER, list)) + self.assertGreater(len(_MATERIAL_NAMESPACE_ORDER), 0) + for nameSpace in _MATERIAL_NAMESPACE_ORDER: + self.assertTrue(isinstance(nameSpace, str)) + + @unittest.skipUnless(context.MPI_RANK == 0, "test only on root node") + def test_namespacing(self): + """Test loading materials with different material namespaces, to cover how they work. + + .. test:: Material can be found in defined packages. + :id: T_ARMI_MAT_NAMESPACE1 + :tests: R_ARMI_MAT_NAMESPACE + + .. test:: Material namespaces register materials with an order of priority. + :id: T_ARMI_MAT_ORDER + :tests: R_ARMI_MAT_ORDER + """ + # let's do a quick test of getting a material from the default namespace + setMaterialNamespaceOrder(["armi.materials"]) + uraniumOxide = materials.resolveMaterialClassByName( + "UraniumOxide", namespaceOrder=["armi.materials"] + ) + self.assertGreater(uraniumOxide().density(500), 0) + + # validate the default namespace in ARMI + self.__validateMaterialNamespace() + + # show you can add a material namespace + newMats = "armi.utils.tests.test_densityTools" + setMaterialNamespaceOrder(["armi.materials", newMats]) + self.__validateMaterialNamespace() + + # in the case of duplicate materials, show that the material namespace determines + # which material is chosen + uraniumOxideTest = materials.resolveMaterialClassByName( + "UraniumOxide", namespaceOrder=[newMats, "armi.materials"] + ) + for t in range(200, 600): + self.assertEqual(uraniumOxideTest().density(t), 0) + self.assertEqual(uraniumOxideTest().pseudoDensity(t), 0) + + # for safety, reset the material namespace list and order + setMaterialNamespaceOrder(["armi.materials"]) + class Californium_TestCase(_Material_Test, unittest.TestCase): @@ -705,6 +767,12 @@ def test_getTempChangeForDensityChange(self): self.assertAlmostEqual(expectedDeltaT, actualDeltaT) def test_duplicate(self): + """Test the material duplication. + + .. test:: Materials shall calc mass fracs at init. + :id: T_ARMI_MAT_FRACS4 + :tests: R_ARMI_MAT_FRACS + """ duplicateU = self.mat.duplicate() for key in self.mat.massFrac: @@ -737,6 +805,13 @@ class Thorium_TestCase(_Material_Test, unittest.TestCase): MAT_CLASS = materials.Thorium def test_setDefaultMassFracs(self): + """ + Test default mass fractions. + + .. test:: The materials generate nuclide mass fractions. + :id: T_ARMI_MAT_FRACS0 + :tests: R_ARMI_MAT_FRACS + """ self.mat.setDefaultMassFracs() cur = self.mat.massFrac ref = {"TH232": 1.0} @@ -810,12 +885,23 @@ class Void_TestCase(_Material_Test, unittest.TestCase): MAT_CLASS = materials.Void def test_pseudoDensity(self): + """This material has a no pseudo-density. + + .. test:: There is a void material. + :id: T_ARMI_MAT_VOID0 + :tests: R_ARMI_MAT_VOID + """ self.mat.setDefaultMassFracs() cur = self.mat.pseudoDensity() self.assertEqual(cur, 0.0) def test_density(self): - """This material has no density function.""" + """This material has no density. + + .. test:: There is a void material. + :id: T_ARMI_MAT_VOID1 + :tests: R_ARMI_MAT_VOID + """ self.assertEqual(self.mat.density(500), 0) self.mat.setDefaultMassFracs() @@ -823,11 +909,23 @@ def test_density(self): self.assertEqual(cur, 0.0) def test_linearExpansion(self): + """This material does not expand linearly. + + .. test:: There is a void material. + :id: T_ARMI_MAT_VOID2 + :tests: R_ARMI_MAT_VOID + """ cur = self.mat.linearExpansion(400) ref = 0.0 self.assertEqual(cur, ref) def test_propertyValidTemperature(self): + """This material has no valid temperatures. + + .. test:: There is a void material. + :id: T_ARMI_MAT_VOID3 + :tests: R_ARMI_MAT_VOID + """ self.assertEqual(len(self.mat.propertyValidTemperature), 0) @@ -839,6 +937,13 @@ def test_density(self): self.assertEqual(self.mat.density(500), 0) def test_setDefaultMassFracs(self): + """ + Test default mass fractions. + + .. test:: The materials generate nuclide mass fractions. + :id: T_ARMI_MAT_FRACS1 + :tests: R_ARMI_MAT_FRACS + """ self.mat.setDefaultMassFracs() cur = self.mat.pseudoDensity(500) self.assertEqual(cur, 0.0) @@ -852,6 +957,7 @@ def test_propertyValidTemperature(self): class Lead_TestCase(_Material_Test, unittest.TestCase): + MAT_CLASS = materials.Lead def test_volumetricExpansion(self): @@ -873,11 +979,24 @@ def test_volumetricExpansion(self): ) def test_linearExpansion(self): - cur = self.mat.linearExpansion(400) - ref = 0.0 - self.assertEqual(cur, ref) + """Unit tests for lead materials linear expansion. + + .. test:: Fluid materials do not linearly expand, at any temperature. + :id: T_ARMI_MAT_FLUID2 + :tests: R_ARMI_MAT_FLUID + """ + for t in range(300, 901, 25): + cur = self.mat.linearExpansion(t) + self.assertEqual(cur, 0) def test_setDefaultMassFracs(self): + """ + Test default mass fractions. + + .. test:: The materials generate nuclide mass fractions. + :id: T_ARMI_MAT_FRACS2 + :tests: R_ARMI_MAT_FRACS + """ self.mat.setDefaultMassFracs() cur = self.mat.massFrac ref = {"PB": 1} @@ -908,6 +1027,13 @@ class LeadBismuth_TestCase(_Material_Test, unittest.TestCase): MAT_CLASS = materials.LeadBismuth def test_setDefaultMassFracs(self): + """ + Test default mass fractions. + + .. test:: The materials generate nuclide mass fractions. + :id: T_ARMI_MAT_FRACS3 + :tests: R_ARMI_MAT_FRACS + """ self.mat.setDefaultMassFracs() cur = self.mat.massFrac ref = {"BI209": 0.555, "PB": 0.445} diff --git a/armi/materials/tests/test_uZr.py b/armi/materials/tests/test_uZr.py index c1ecfaa79..e2b4eb270 100644 --- a/armi/materials/tests/test_uZr.py +++ b/armi/materials/tests/test_uZr.py @@ -13,16 +13,104 @@ # limitations under the License. """Tests for simplified UZr material.""" -import unittest +from unittest import TestCase +import pickle -from armi.materials.tests import test_materials from armi.materials.uZr import UZr -class UZR_TestCase(test_materials._Material_Test, unittest.TestCase): +class UZR_TestCase(TestCase): + MAT_CLASS = UZr + def setUp(self): + self.mat = self.MAT_CLASS() + + def test_isPicklable(self): + """Test that materials are picklable so we can do MPI communication of state. + + .. test:: Test the material base class has temp-dependent thermal conductivity curves. + :id: T_ARMI_MAT_PROPERTIES0 + :tests: R_ARMI_MAT_PROPERTIES + """ + stream = pickle.dumps(self.mat) + mat = pickle.loads(stream) + + # check a property that is sometimes interpolated. + self.assertEqual( + self.mat.thermalConductivity(500), mat.thermalConductivity(500) + ) + + def test_TD(self): + """Test the material theoretical density.""" + self.assertEqual(self.mat.getTD(), self.mat.theoreticalDensityFrac) + + self.mat.clearCache() + self.mat._setCache("dummy", 666) + self.assertEqual(self.mat.cached, {"dummy": 666}) + self.mat.adjustTD(0.5) + self.assertEqual(0.5, self.mat.theoreticalDensityFrac) + self.assertEqual(self.mat.cached, {}) + + def test_duplicate(self): + """Test the material duplication. + + .. test:: Materials shall calc mass fracs at init. + :id: T_ARMI_MAT_FRACS5 + :tests: R_ARMI_MAT_FRACS + """ + mat = self.mat.duplicate() + + self.assertEqual(len(mat.massFrac), len(self.mat.massFrac)) + for key in self.mat.massFrac: + self.assertEqual(mat.massFrac[key], self.mat.massFrac[key]) + + self.assertEqual(mat.parent, self.mat.parent) + self.assertEqual(mat.refDens, self.mat.refDens) + self.assertEqual(mat.theoreticalDensityFrac, self.mat.theoreticalDensityFrac) + + def test_cache(self): + """Test the material cache.""" + self.mat.clearCache() + self.assertEqual(len(self.mat.cached), 0) + + self.mat._setCache("Emmy", "Noether") + self.assertEqual(len(self.mat.cached), 1) + + val = self.mat._getCached("Emmy") + self.assertEqual(val, "Noether") + + def test_densityKgM3(self): + """Test the density for kg/m^3. + + .. test:: Test the material base class has temp-dependent density. + :id: T_ARMI_MAT_PROPERTIES2 + :tests: R_ARMI_MAT_PROPERTIES + """ + dens = self.mat.density(500) + densKgM3 = self.mat.densityKgM3(500) + self.assertEqual(dens * 1000.0, densKgM3) + + def test_pseudoDensityKgM3(self): + """Test the pseudo density for kg/m^3. + + .. test:: Test the material base class has temp-dependent 2D density. + :id: T_ARMI_MAT_PROPERTIES3 + :tests: R_ARMI_MAT_PROPERTIES + """ + dens = self.mat.pseudoDensity(500) + densKgM3 = self.mat.pseudoDensityKgM3(500) + self.assertEqual(dens * 1000.0, densKgM3) + def test_density(self): + """Test that all materials produce a zero density from density. + + .. test:: Test the material base class has temp-dependent density. + :id: T_ARMI_MAT_PROPERTIES1 + :tests: R_ARMI_MAT_PROPERTIES + """ + self.assertNotEqual(self.mat.density(500), 0) + cur = self.mat.density(400) ref = 15.94 delta = ref * 0.01 diff --git a/armi/materials/tests/test_water.py b/armi/materials/tests/test_water.py index fc6381b83..d62c190c4 100644 --- a/armi/materials/tests/test_water.py +++ b/armi/materials/tests/test_water.py @@ -26,6 +26,10 @@ def test_water_at_freezing(self): Reproduce verification results from IAPWS-IF97 for water at 0C. http://www.iapws.org/relguide/supsat.pdf + + .. test:: There is a base class for fluid materials. + :id: T_ARMI_MAT_FLUID0 + :tests: R_ARMI_MAT_FLUID """ water = SaturatedWater() steam = SaturatedSteam() diff --git a/armi/materials/thU.py b/armi/materials/thU.py index abb3301f6..e46b9c6a6 100644 --- a/armi/materials/thU.py +++ b/armi/materials/thU.py @@ -15,9 +15,9 @@ """ Thorium Uranium metal. -Data is from [#IAEA-TECDOCT-1450]_. +Data is from [IAEA-TECDOCT-1450]_. -.. [#IAEA-TECDOCT-1450] Thorium fuel cycle -- Potential benefits and challenges, IAEA-TECDOC-1450 (2005). +.. [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 """ diff --git a/armi/materials/thorium.py b/armi/materials/thorium.py index 74723201f..62807a873 100644 --- a/armi/materials/thorium.py +++ b/armi/materials/thorium.py @@ -15,9 +15,9 @@ """ Thorium Metal. -Data is from [#IAEA-TECDOCT-1450]_. +Data is from [IAEA-TECDOCT-1450]_. -.. [#IAEA-TECDOCT-1450] Thorium fuel cycle -- Potential benefits and challenges, IAEA-TECDOC-1450 (2005). +.. [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 diff --git a/armi/materials/thoriumOxide.py b/armi/materials/thoriumOxide.py index df2dc6c91..157f4a0c6 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-TECDOCT-1450]_. -.. [#IAEA-TECDOCT-1450] Thorium fuel cycle -- Potential benefits and challenges, IAEA-TECDOC-1450 (2005). +.. [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/uraniumOxide.py b/armi/materials/uraniumOxide.py index 6d6a689b2..64ce7f1b0 100644 --- a/armi/materials/uraniumOxide.py +++ b/armi/materials/uraniumOxide.py @@ -62,8 +62,10 @@ class UraniumOxide(material.FuelMaterial, material.SimpleSolid): } references = { - "thermal conductivity": "Thermal conductivity of uranium dioxide by nonequilibrium molecular dynamics simulation. S. Motoyama. Physical Review B, Volume 60, Number 1, July 1999", - "linear expansion": "Thermophysical Properties of MOX and UO2 Fuels Including the Effects of Irradiation. S.G. Popov, et.al. Oak Ridge National Laboratory. ORNL/TM-2000/351", + "thermal conductivity": "Thermal conductivity of uranium dioxide by nonequilibrium molecular dynamics " + + "simulation. S. Motoyama. Physical Review B, Volume 60, Number 1, July 1999", + "linear expansion": "Thermophysical Properties of MOX and UO2 Fuels Including the Effects of Irradiation. " + + "S.G. Popov, et.al. Oak Ridge National Laboratory. ORNL/TM-2000/351", "heat capacity": "ORNL/TM-2000/351", } @@ -73,7 +75,8 @@ class UraniumOxide(material.FuelMaterial, material.SimpleSolid): ) # Thermal conductivity values taken from: - # Thermal conductivity of uranium dioxide by nonequilibrium molecular dynamics simulation. S. Motoyama. Physical Review B, Volume 60, Number 1, July 1999 + # Thermal conductivity of uranium dioxide by nonequilibrium molecular dynamics simulation. S. Motoyama. + # Physical Review B, Volume 60, Number 1, July 1999 thermalConductivityTableK = [ 300, 600, diff --git a/armi/materials/void.py b/armi/materials/void.py index 34d3ffec9..de0ef63b8 100644 --- a/armi/materials/void.py +++ b/armi/materials/void.py @@ -21,6 +21,19 @@ class Void(material.Fluid): + """A Void material is a bookkeeping material with zero density. + + .. impl:: Define a void material with zero density. + :id: I_ARMI_MAT_VOID + :implements: R_ARMI_MAT_VOID + + To help with expansion, it is sometimes useful to put a small section of void + material into the reactor model. This is not meant to represent a true void, + that would cause negative pressure in a system, but just as a bookkeeping tool. + Sometimes this helps users define the geometry of an expanding and conctracting + reactor. It is called a "void" because it has zero density at all temperatures. + """ + def pseudoDensity(self, Tk: float = None, Tc: float = None) -> float: return 0.0 diff --git a/armi/materials/zr.py b/armi/materials/zr.py index f5616e841..eca912d61 100644 --- a/armi/materials/zr.py +++ b/armi/materials/zr.py @@ -33,8 +33,10 @@ class Zr(Material): references = { "density": "AAA Materials Handbook 45803", "thermal conductivity": "AAA Fuels handbook. ANL", - "linear expansion": "Y.S. Touloukian, R.K. Kirby, R.E. Taylor and P.D. Desai, Thermal Expansion, Thermophysical Properties of Matter, Vol. 12, IFI/Plenum, New York-Washington (1975)", - "linear expansion percent": "Y.S. Touloukian, R.K. Kirby, R.E. Taylor and P.D. Desai, Thermal Expansion, Thermophysical Properties of Matter, Vol. 12, IFI/Plenum, New York-Washington (1975)", + "linear expansion": "Y.S. Touloukian, R.K. Kirby, R.E. Taylor and P.D. Desai, Thermal Expansion, " + + "Thermophysical Properties of Matter, Vol. 12, IFI/Plenum, New York-Washington (1975)", + "linear expansion percent": "Y.S. Touloukian, R.K. Kirby, R.E. Taylor and P.D. Desai, Thermal Expansion, " + + "Thermophysical Properties of Matter, Vol. 12, IFI/Plenum, New York-Washington (1975)", } linearExpansionTableK = [ diff --git a/armi/nucDirectory/elements.py b/armi/nucDirectory/elements.py index a69975914..ac201687c 100644 --- a/armi/nucDirectory/elements.py +++ b/armi/nucDirectory/elements.py @@ -16,6 +16,24 @@ This module provides fundamental element information to be used throughout the framework and applications. +.. impl:: A tool for querying basic data for elements of the periodic table. + :id: I_ARMI_ND_ELEMENTS0 + :implements: R_ARMI_ND_ELEMENTS + + The :py:mod:`elements <armi.nucDirectory.elements>` module defines the + :py:class:`Element <armi.nucDirectory.elements.Element>` class which acts as + a data structure for organizing information about an individual element, + including number of protons, name, chemical symbol, phase (at STP), periodic + table group, standard weight, and a list of isotope :py:class:`nuclideBase + <armi.nucDirectory.nuclideBases.NuclideBase>` instances. The module includes + a factory that generates the :py:class:`Element + <armi.nucDirectory.elements.Element>` instances by reading from the + ``elements.dat`` file stored in the ARMI resources folder. When an + :py:class:`Element <armi.nucDirectory.elements.Element>` instance is + initialized, it is added to a set of global dictionaries that are keyed by + number of protons, element name, and element symbol. The module includes + several helper functions for querying these global dictionaries. + The element class structure is outlined :ref:`here <elements-class-diagram>`. .. _elements-class-diagram: @@ -72,41 +90,42 @@ <Element LR (Z=103), Lawrencium, ChemicalGroup.ACTINIDE, ChemicalPhase.SOLID>] -For specific data on nuclides within each element, refer to the -:ref:`nuclide bases summary table <nuclide-bases-table>`. - - -.. exec:: - from tabulate import tabulate - from armi.nucDirectory import elements - - attributes = ['z', - 'name', - 'symbol', - 'phase', - 'group', - 'is naturally occurring?', - 'is heavy metal?', - 'num. nuclides',] - - def getAttributes(element): - return [ - f'``{element.z}``', - f'``{element.name}``', - f'``{element.symbol}``', - f'``{element.phase}``', - f'``{element.group}``', - f'``{element.isNaturallyOccurring()}``', - f'``{element.isHeavyMetal()}``', - f'``{len(element.nuclides)}``', - ] - - sortedElements = sorted(elements.byZ.values()) - return create_table(tabulate(tabular_data=[getAttributes(elem) for elem in sortedElements], - headers=attributes, - tablefmt='rst'), - caption='List of elements') - +.. only:: html + + For specific data on nuclides within each element, refer to the + :ref:`nuclide bases summary table <nuclide-bases-table>`. + + .. exec:: + from tabulate import tabulate + from armi.nucDirectory import elements + from dochelpers import createTable + + attributes = ['z', + 'name', + 'symbol', + 'phase', + 'group', + 'is naturally occurring?', + 'is heavy metal?', + 'num. nuclides',] + + def getAttributes(element): + return [ + f'``{element.z}``', + f'``{element.name}``', + f'``{element.symbol}``', + f'``{element.phase}``', + f'``{element.group}``', + f'``{element.isNaturallyOccurring()}``', + f'``{element.isHeavyMetal()}``', + f'``{len(element.nuclides)}``', + ] + + sortedElements = sorted(elements.byZ.values()) + return createTable(tabulate(tabular_data=[getAttributes(elem) for elem in sortedElements], + headers=attributes, + tablefmt='rst'), + caption='List of elements') """ import os @@ -149,6 +168,23 @@ def __init__(self, z, symbol, name, phase="UNKNOWN", group="UNKNOWN"): """ Creates an instance of an Element. + .. impl:: An element of the periodic table. + :id: I_ARMI_ND_ELEMENTS1 + :implements: R_ARMI_ND_ELEMENTS + + The :py:class:`Element <armi.nucDirectory.elements.Element>` class + acts as a data structure for organizing information about an + individual element, including number of protons, name, chemical + symbol, phase (at STP), periodic table group, standard weight, and a + list of isotope + :py:class:`nuclideBase <armi.nucDirectory.nuclideBases.NuclideBase>` + instances. + + The :py:class:`Element <armi.nucDirectory.elements.Element>` class + has a few methods for appending additional isotopes, checking + whether an isotope is naturally occurring, retrieving the natural + isotopic abundance, or whether the element is a heavy metal. + Parameters ---------- z : int diff --git a/armi/nucDirectory/nucDir.py b/armi/nucDirectory/nucDir.py index ffbaea42d..1a5ec7ca5 100644 --- a/armi/nucDirectory/nucDir.py +++ b/armi/nucDirectory/nucDir.py @@ -66,7 +66,7 @@ def getNuclideFromName(name): def getNaturalIsotopics(elementSymbol=None, z=None): - r""" + """ Determines the atom fractions of all natural isotopes. Parameters @@ -90,7 +90,8 @@ def getNaturalIsotopics(elementSymbol=None, z=None): def getNaturalMassIsotopics(elementSymbol=None, z=None): - r"""Return mass fractions of all natural isotopes. + """Return mass fractions of all natural isotopes. + To convert number fractions to mass fractions, we multiply by A. """ numIso = getNaturalIsotopics(elementSymbol, z) @@ -107,7 +108,7 @@ def getNaturalMassIsotopics(elementSymbol=None, z=None): def getMc2Label(name): - r""" + """ Return a MC2 prefix label without a xstype suffix. MC**2 has labels and library names. The labels are like @@ -146,7 +147,7 @@ def getMc2Label(name): def getElementName(z=None, symbol=None): - r""" + """ Returns element name. Parameters @@ -173,7 +174,7 @@ def getElementName(z=None, symbol=None): def getElementSymbol(z=None, name=None): - r""" + """ Returns element abbreviation given atomic number Z. Parameters @@ -200,7 +201,7 @@ def getElementSymbol(z=None, name=None): def getNuclide(nucName): - r""" + """ Looks up the ARMI nuclide object that has this name. Parameters @@ -212,7 +213,6 @@ def getNuclide(nucName): ------- nuc : Nuclide An armi nuclide object. - """ nuc = nuclideBases.byName.get(nucName, None) if nucName and not nuc: @@ -223,7 +223,7 @@ def getNuclide(nucName): def getNuclides(nucName=None, elementSymbol=None): - r""" + """ Returns a list of nuclide names in a particular nuclide or element. If no arguments, returns all nuclideBases in the directory @@ -250,7 +250,7 @@ def getNuclides(nucName=None, elementSymbol=None): def getNuclideNames(nucName=None, elementSymbol=None): - r""" + """ Returns a list of nuclide names in a particular nuclide or element. If no arguments, returns all nuclideBases in the directory. @@ -269,7 +269,7 @@ def getNuclideNames(nucName=None, elementSymbol=None): def getAtomicWeight(lab=None, z=None, a=None): - r""" + """ Returns atomic weight in g/mole. Parameters @@ -339,7 +339,7 @@ def isFissile(name): def getThresholdDisplacementEnergy(nuc): - r""" + """ Return the Lindhard cutoff; the energy required to displace an atom. From SPECTER.pdf Table II diff --git a/armi/nucDirectory/nuclideBases.py b/armi/nucDirectory/nuclideBases.py index 6a2143ba5..0d92103b4 100644 --- a/armi/nucDirectory/nuclideBases.py +++ b/armi/nucDirectory/nuclideBases.py @@ -11,11 +11,49 @@ # 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. -""" -This module provides fundamental nuclide information to be used throughout the framework -and applications. - -The nuclide class structure is outlined :ref:`here <nuclide-bases-class-diagram>`. +r""" +This module provides fundamental nuclide information to be used throughout the +framework and applications. + +.. impl:: Isotopes and isomers can be queried by name, label, MC2-3 ID, MCNP ID, and AAAZZZS ID. + :id: I_ARMI_ND_ISOTOPES0 + :implements: R_ARMI_ND_ISOTOPES + + The :py:mod:`nuclideBases <armi.nucDirectory.nuclideBases>` module defines + the :py:class:`NuclideBase <armi.nucDirectory.nuclideBases.NuclideBase>` + class which is used to organize and store metadata about each nuclide. The + metadata is read from ``nuclides.dat`` file in the ARMI resources folder, + which contains metadata for 4,614 isotopes. The module also contains classes + for special types of nuclides, including :py:class:`DummyNuclideBase + <armi.nucDirectory.nuclideBases.DummyNuclideBase>` for dummy nuclides, + :py:class:`LumpNuclideBase + <armi.nucDirectory.nuclideBases.LumpNuclideBase>`, for lumped fission + product nuclides, and :py:class:`NaturalNuclideBase + <armi.nucDirectory.nuclideBases.NaturalNuclideBase>` for when data is given + collectively for an element at natural abundance rather than for individual + isotopes. + + The :py:class:`NuclideBase <armi.nucDirectory.nuclideBases.NuclideBase>` + provides a data structure for information about a single nuclide, including + the atom number, atomic weight, element, isomeric state, half-life, and + name. + + The :py:mod:`nuclideBases <armi.nucDirectory.nuclideBases>` module provides + a factory and associated functions for instantiating the + :py:class:`NuclideBase <armi.nucDirectory.nuclideBases.NuclideBase>` objects + and building the global nuclide dictionaries, including: + + * ``instances`` (list of nuclides) + * ``byName`` (keyed by name, e.g., ``U235``) + * ``byDBName`` (keyed by database name, e.g., ``nU235``) + * ``byLabel`` (keyed by label, e.g., ``U235``) + * ``byMcc2Id`` (keyed by MC\ :sup:`2`-2 ID, e.g., ``U-2355``) + * ``byMcc3Id`` (keyed by MC\ :sup:`2`-3 ID, e.g., ``U235_7``) + * ``byMcnpId`` (keyed by MCNP ID, e.g., ``92235``) + * ``byAAAZZZSId`` (keyed by AAAZZZS, e.g., ``2350920``) + +The nuclide class structure is outlined :ref:`here +<nuclide-bases-class-diagram>`. .. _nuclide-bases-class-diagram: @@ -53,44 +91,6 @@ >>> nuclideBases.byAAAZZZSId['2350920'] <NuclideBase U235: Z:92, A:235, S:0, W:2.350439e+02, Label:U235>, HL:2.22160758861e+16, Abund:7.204000e-03> -.. _nuclide-bases-table: - -.. exec:: - import numpy - from tabulate import tabulate - from armi.nucDirectory import nuclideBases - - attributes = ['name', - 'type', - 'a', - 'z', - 'state', - 'abundance', - 'weight', - 'halflife'] - - def getAttributes(nuc): - if nuc.halflife == numpy.inf: - halflife = "inf" - else: - halflife = f'{nuc.halflife:<12.6e}' - return [ - f'``{nuc.name}``', - f':py:class:`~armi.nucDirectory.nuclideBases.{nuc.__class__.__name__}`', - f'``{nuc.a}``', - f'``{nuc.z}``', - f'``{nuc.state}``', - f'``{nuc.abundance:<12.6e}``', - f'``{nuc.weight:<12.6e}``', - f'``{halflife}``', - ] - - sortedNucs = sorted(nuclideBases.instances) - return create_table(tabulate(tabular_data=[getAttributes(nuc) for nuc in sortedNucs], - headers=attributes, - tablefmt='rst'), - caption='List of nuclides') - """ import os @@ -423,7 +423,7 @@ def _processBurnData(self, burnInfo): ) def getDecay(self, decayType): - r"""Get a :py:class:`~armi.nucDirectory.transmutations.DecayMode`. + """Get a :py:class:`~armi.nucDirectory.transmutations.DecayMode`. Retrieve the first :py:class:`~armi.nucDirectory.transmutations.DecayMode` matching the specified decType. @@ -493,7 +493,22 @@ def getAAAZZZSId(self): class NuclideBase(INuclide, IMcnpNuclide): - """Represents an individual nuclide/isotope.""" + r"""Represents an individual nuclide/isotope. + + .. impl:: Isotopes and isomers can be queried by name and label. + :id: I_ARMI_ND_ISOTOPES1 + :implements: R_ARMI_ND_ISOTOPES + + The :py:class:`NuclideBase <armi.nucDirectory.nuclideBases.NuclideBase>` + class provides a data structure for information about a single nuclide, + including the atom number, atomic weight, element, isomeric state, + half-life, and name. The class contains static methods for creating an + internal ARMI name or label for a nuclide. There are instance methods + for generating the nuclide ID for external codes, e.g. MCNP or Serpent, + and retrieving the nuclide ID for MC\ :sup:`2`-2 or MC\ :sup:`2`-3. + There are also instance methods for generating an AAAZZZS ID and an ENDF + MAT number. + """ def __init__(self, element, a, weight, abundance, state, halflife): IMcnpNuclide.__init__(self) @@ -510,7 +525,11 @@ def __init__(self, element, a, weight, abundance, state, halflife): ) def __repr__(self): - return f"<{self.__class__.__name__} {self.name}: Z:{self.z}, A:{self.a}, S:{self.state}, W:{self.weight:<12.6e}, Label:{self.label}>, HL:{self.halflife:<15.11e}, Abund:{self.abundance:<8.6e}>" + return ( + f"<{self.__class__.__name__} {self.name}: Z:{self.z}, A:{self.a}, S:{self.state}, " + + f"W:{self.weight:<12.6e}, Label:{self.label}>, HL:{self.halflife:<15.11e}, " + + f"Abund:{self.abundance:<8.6e}>" + ) @staticmethod def _createName(element, a, state): @@ -558,29 +577,58 @@ def getNaturalIsotopics(self): return self.element.getNaturalIsotopics() def getMcc2Id(self): - """Return the MC2-2 nuclide identification label based on the ENDF/B-V.2 cross section library.""" + """Return the MC2-2 nuclide identification label based on the ENDF/B-V.2 cross section library. + + .. impl:: Isotopes and isomers can be queried by MC2-2 ID. + :id: I_ARMI_ND_ISOTOPES2 + :implements: R_ARMI_ND_ISOTOPES + + This method returns the ``mcc2id`` attribute of a + :py:class:`NuclideBase <armi.nucDirectory.nuclideBases.NuclideBase>` + instance. This attribute is initially populated by reading from the + mcc-nuclides.yaml file in the ARMI resources folder. + """ return self.mcc2id def getMcc3Id(self): - """Return the MC2-3 nuclide identification label based on the ENDF/B-VII.0 cross section library.""" + """Return the MC2-3 nuclide identification label based on the ENDF/B-VII.0 cross section library. + + .. impl:: Isotopes and isomers can be queried by MC2-3 ID. + :id: I_ARMI_ND_ISOTOPES3 + :implements: R_ARMI_ND_ISOTOPES + + This method returns the ``mcc3id`` attribute of a + :py:class:`NuclideBase <armi.nucDirectory.nuclideBases.NuclideBase>` + instance. This attribute is initially populated by reading from the + mcc-nuclides.yaml file in the ARMI resources folder. + """ return self.mcc3id def getMcnpId(self): """ Gets the MCNP label for this nuclide. + .. impl:: Isotopes and isomers can be queried by MCNP ID. + :id: I_ARMI_ND_ISOTOPES4 + :implements: R_ARMI_ND_ISOTOPES + + This method generates the MCNP ID for an isotope using the standard + MCNP format based on the atomic number A, number of protons Z, and + excited state. The implementation includes the special rule for + Am-242m, which is 95242. 95642 is used for the less common ground + state Am-242. + Returns ------- id : str The MCNP ID e.g. ``92235``, ``94239``, ``6000`` - """ z, a = self.z, self.a if z == 95 and a == 242: # Am242 has special rules if self.state != 1: - # MCNP uses base state for the common metastable state AM242M , so AM242M is just 95242 + # MCNP uses base state for the common metastable state AM242M, so AM242M is just 95242 # AM242 base state is called 95642 (+400) in mcnp. # see https://mcnp.lanl.gov/pdf_files/la-ur-08-1999.pdf # New ACE-Formatted Neutron and Proton Libraries Based on ENDF/B-VII.0 @@ -595,6 +643,15 @@ def getAAAZZZSId(self): """ Return a string that is ordered by the mass number, A, the atomic number, Z, and the isomeric state, S. + .. impl:: Isotopes and isomers can be queried by AAAZZZS ID. + :id: I_ARMI_ND_ISOTOPES5 + :implements: R_ARMI_ND_ISOTOPES + + This method generates the AAAZZZS format ID for an isotope. Where + AAA is the mass number, ZZZ is the atomic number, and S is the + isomeric state. This is a general format independent of any code that + precisely defines an isotope or isomer. + Notes ----- An example would be for U235, where A=235, Z=92, and S=0, returning ``2350920``. @@ -629,7 +686,6 @@ def getEndfMatNum(self): ------- id : str The MAT number e.g. ``9237`` for U238 - """ z, a = self.z, self.a if self.element.symbol in BASE_ENDFB7_MAT_NUM: @@ -679,7 +735,7 @@ def __repr__(self): return f"<{self.__class__.__name__} {self.name}: Z:{self.z}, W:{self.weight:<12.6e}, Label:{self.label}>" def getNaturalIsotopics(self): - r"""Gets the natural isotopics root :py:class:`~elements.Element`. + """Gets the natural isotopics root :py:class:`~elements.Element`. Gets the naturally occurring nuclides for this nuclide. @@ -789,7 +845,7 @@ def __lt__(self, other): ) def getNaturalIsotopics(self): - r"""Gets the natural isotopics, an empty iterator. + """Gets the natural isotopics, an empty iterator. Gets the naturally occurring nuclides for this nuclide. @@ -855,7 +911,7 @@ def __lt__(self, other): ) def getNaturalIsotopics(self): - r"""Gets the natural isotopics, an empty iterator. + """Gets the natural isotopics, an empty iterator. Gets the naturally occurring nuclides for this nuclide. @@ -1115,12 +1171,12 @@ def factory(): "Nuclides are already initialized and cannot be re-initialized unless " "`nuclideBases.destroyGlobalNuclides` is called first." ) - __addNuclideBases() + addNuclideBases() __addNaturalNuclideBases() __addDummyNuclideBases() __addLumpedFissionProductNuclideBases() - __updateNuclideBasesForSpecialCases() - __readMCCNuclideData() + updateNuclideBasesForSpecialCases() + readMCCNuclideData() __renormalizeNuclideToElementRelationship() __deriveElementalWeightsByNaturalNuclideAbundances() @@ -1130,11 +1186,23 @@ def factory(): thermalScattering.factory() -def __addNuclideBases(): +def addNuclideBases(): """ Read natural abundances of any natural nuclides. This adjusts already-existing NuclideBases and Elements with the new information. + + .. impl:: Separating natural abundance data from code. + :id: I_ARMI_ND_DATA0 + :implements: R_ARMI_ND_DATA + + This function reads the ``nuclides.dat`` file from the ARMI resources + folder. This file contains metadata for 4,614 nuclides, including + number of protons, number of neutrons, atomic number, excited + state, element symbol, atomic mass, natural abundance, half-life, + and spontaneous fission yield. The data in ``nuclides.dat`` have been + collected from multiple different sources; the references are given + in comments at the top of that file. """ with open(os.path.join(context.RES, "nuclides.dat")) as f: for line in f: @@ -1190,8 +1258,22 @@ def __addLumpedFissionProductNuclideBases(): LumpNuclideBase(name="LREGN", weight=1.0) -def __readMCCNuclideData(): - """Read in the label data for the MC2-2 and MC2-3 cross section codes to the nuclide bases.""" +def readMCCNuclideData(): + r"""Read in the label data for the MC2-2 and MC2-3 cross section codes to the nuclide bases. + + .. impl:: Separating MCC data from code. + :id: I_ARMI_ND_DATA1 + :implements: R_ARMI_ND_DATA + + This function reads the mcc-nuclides.yaml file from the ARMI resources + folder. This file contains the MC\ :sup:`2`-2 ID (from ENDF/B-V.2) and MC\ :sup:`2`-3 ID + (from ENDF/B-VII.0) for all nuclides in MC\ :sup:`2`. The ``mcc2id`` and + ``mcc3id`` attributes of each :py:class:`NuclideBase + <armi.nucDirectory.nuclideBases.NuclideBase>` instance are updated as + the data is read, and the global dictionaries ``byMcc2Id`` and + ``byMcc3Id`` are populated with the nuclide bases keyed by their + corresponding ID for each code. + """ with open(os.path.join(context.RES, "mcc-nuclides.yaml"), "r") as f: yaml = YAML(typ="rt") nuclides = yaml.load(f) @@ -1208,10 +1290,20 @@ def __readMCCNuclideData(): byMcc3Id[nb.getMcc3Id()] = nb -def __updateNuclideBasesForSpecialCases(): +def updateNuclideBasesForSpecialCases(): """ Update the nuclide bases for special case name changes. + .. impl:: The special case name Am242g is supported. + :id: I_ARMI_ND_ISOTOPES6 + :implements: R_ARMI_ND_ISOTOPES + + This function updates the keys for the :py:class:`NuclideBase + <armi.nucDirectory.nuclideBases.NuclideBase>` instances for Am-242m and + Am-242 in the ``byName`` and ``byDBName`` global dictionaries. This + function associates the more common isomer Am-242m with the name + "AM242", and uses "AM242G" to denote the ground state. + Notes ----- This function is specifically added to change the definition of diff --git a/armi/nucDirectory/tests/test_elements.py b/armi/nucDirectory/tests/test_elements.py index 08199fc70..e508c8596 100644 --- a/armi/nucDirectory/tests/test_elements.py +++ b/armi/nucDirectory/tests/test_elements.py @@ -35,14 +35,32 @@ def test_elements_elementBulkProperties(self): self.assertIsNotNone(ee.standardWeight) def test_element_elementByNameReturnsElement(self): + """Get elements by name. + + .. test:: Get elements by name. + :id: T_ARMI_ND_ELEMENTS0 + :tests: R_ARMI_ND_ELEMENTS + """ for ee in elements.byZ.values(): self.assertIs(ee, elements.byName[ee.name]) def test_element_elementByZReturnsElement(self): + """Get elements by Z. + + .. test:: Get elements by Z. + :id: T_ARMI_ND_ELEMENTS1 + :tests: R_ARMI_ND_ELEMENTS + """ for ee in elements.byZ.values(): self.assertIs(ee, elements.byZ[ee.z]) def test_element_elementBySymbolReturnsElement(self): + """Get elements by symbol. + + .. test:: Get elements by symbol. + :id: T_ARMI_ND_ELEMENTS2 + :tests: R_ARMI_ND_ELEMENTS + """ for ee in elements.byZ.values(): self.assertIs(ee, elements.bySymbol[ee.symbol]) @@ -84,6 +102,10 @@ def test_element_isNaturallyOccurring(self): Uses RIPL definitions of naturally occurring. Protactinium is debated as naturally occurring. Yeah it exists as a U235 decay product but it's kind of pseudo-natural. + + .. test:: Get elements by Z to show if they are naturally occurring. + :id: T_ARMI_ND_ELEMENTS3 + :tests: R_ARMI_ND_ELEMENTS """ for ee in elements.byZ.values(): if ee.z == 43 or ee.z == 61 or 84 <= ee.z <= 89 or ee.z >= 93: @@ -104,6 +126,12 @@ def test_abundancesAddToOne(self): ) def test_isHeavyMetal(self): + """Get elements by Z. + + .. test:: Get elements by Z to show if they are heavy metals. + :id: T_ARMI_ND_ELEMENTS4 + :tests: R_ARMI_ND_ELEMENTS + """ for ee in elements.byZ.values(): if ee.z > 89: self.assertTrue(ee.isHeavyMetal()) diff --git a/armi/nucDirectory/tests/test_nuclideBases.py b/armi/nucDirectory/tests/test_nuclideBases.py index d8403276c..0d0773852 100644 --- a/armi/nucDirectory/tests/test_nuclideBases.py +++ b/armi/nucDirectory/tests/test_nuclideBases.py @@ -137,6 +137,12 @@ def test_NaturalNuclide_atomicWeightIsAverageOfNaturallyOccuringIsotopes(self): ) def test_nucBases_labelAndNameCollsionsAreForSameNuclide(self): + """The name and labels for correct for nuclides. + + .. test:: Validate the name, label, and DB name are accessible for nuclides. + :id: T_ARMI_ND_ISOTOPES0 + :tests: R_ARMI_ND_ISOTOPES + """ count = 0 for nuc in nuclideBases.where(lambda nn: nn.name == nn.label): count += 1 @@ -185,6 +191,12 @@ def test_nucBases_imposeBurnChainTransmutationBulkStatistics(self): ) # ternary fission def test_nucBases_imposeBurn_nuSF(self): + """Test the nuclide data from file (specifically neutrons / sponaneous fission). + + .. test:: Test that nuclide data was read from file instead of code. + :id: T_ARMI_ND_DATA0 + :tests: R_ARMI_ND_DATA + """ actual = { nn.name: nn.nuSF for nn in nuclideBases.where(lambda nn: nn.nuSF > 0.0) } @@ -235,6 +247,12 @@ def test_nucBases_AllDatabaseNamesAreUnique(self): ) def test_nucBases_Am242m(self): + """Test the correct am242g and am242m abbreviations are supported. + + .. test:: Specifically test for Am242 and Am242g because it is a special case. + :id: T_ARMI_ND_ISOTOPES1 + :tests: R_ARMI_ND_ISOTOPES + """ am242m = nuclideBases.byName["AM242"] self.assertEqual(am242m, nuclideBases.byName["AM242M"]) self.assertEqual("nAm242m", am242m.getDatabaseName()) @@ -263,6 +281,12 @@ def test_getDecay(self): self.assertIsNone(nb.getDecay("sf")) def test_getEndfMatNum(self): + """Test get nuclides by name. + + .. test:: Test get nuclides by name. + :id: T_ARMI_ND_ISOTOPES2 + :tests: R_ARMI_ND_ISOTOPES + """ self.assertEqual(nuclideBases.byName["U235"].getEndfMatNum(), "9228") self.assertEqual(nuclideBases.byName["U238"].getEndfMatNum(), "9237") self.assertEqual(nuclideBases.byName["PU239"].getEndfMatNum(), "9437") @@ -365,7 +389,12 @@ def test_curieDefinitionWithRa226(self): self.assertAlmostEqual(activity, 0.9885593, places=6) def test_loadMcc2Data(self): - """Tests consistency with the `mcc-nuclides.yaml` input and the nuclides in the data model.""" + """Tests consistency with the `mcc-nuclides.yaml` input and the nuclides in the data model. + + .. test:: Test that MCC v2 IDs can be queried by nuclides. + :id: T_ARMI_ND_ISOTOPES3 + :tests: R_ARMI_ND_ISOTOPES + """ with open(os.path.join(RES, "mcc-nuclides.yaml")) as f: yaml = YAML(typ="rt") data = yaml.load(f) @@ -381,7 +410,16 @@ def test_loadMcc2Data(self): self.assertEqual(len(nuclideBases.byMcc2Id), len(expectedNuclides)) def test_loadMcc3Data(self): - """Tests consistency with the `mcc-nuclides.yaml` input and the nuclides in the data model.""" + """Tests consistency with the `mcc-nuclides.yaml` input and the nuclides in the data model. + + .. test:: Test that MCC v3 IDs can be queried by nuclides. + :id: T_ARMI_ND_ISOTOPES4 + :tests: R_ARMI_ND_ISOTOPES + + .. test:: Test the MCC nuclide data that was read from file instead of code. + :id: T_ARMI_ND_DATA1 + :tests: R_ARMI_ND_DATA + """ with open(os.path.join(RES, "mcc-nuclides.yaml")) as f: yaml = YAML(typ="rt") data = yaml.load(f) @@ -398,9 +436,14 @@ def test_loadMcc3Data(self): self.assertEqual(len(nuclideBases.byMcc3Id), len(expectedNuclides) - 1) -class test_getAAAZZZSId(unittest.TestCase): +class TestAAAZZZSId(unittest.TestCase): def test_AAAZZZSNameGenerator(self): + """Test that AAAZZS ID name generator. + .. test:: Query the AAAZZS IDs can be retrieved for nuclides. + :id: T_ARMI_ND_ISOTOPES5 + :tests: R_ARMI_ND_ISOTOPES + """ referenceNucNames = [ ("C", "120060"), ("U235", "2350920"), diff --git a/armi/nuclearDataIO/cccc/cccc.py b/armi/nuclearDataIO/cccc/cccc.py index 0f97ae39f..dd3133481 100644 --- a/armi/nuclearDataIO/cccc/cccc.py +++ b/armi/nuclearDataIO/cccc/cccc.py @@ -13,8 +13,69 @@ # limitations under the License. """ -Defines containers for the reading and writing standard interface files +Defines containers for the reading and writing standard interface files for reactor physics codes. + +.. impl:: Generic tool for reading and writing Committee on Computer Code Coordination (CCCC) format files for reactor physics codes + :id: I_ARMI_NUCDATA + :implements: R_ARMI_NUCDATA_ISOTXS, + R_ARMI_NUCDATA_GAMISO, + R_ARMI_NUCDATA_GEODST, + R_ARMI_NUCDATA_DIF3D, + R_ARMI_NUCDATA_PMATRX, + R_ARMI_NUCDATA_DLAYXS + + This module provides a number of base classes that implement general + capabilities for binary and ASCII file I/O. The :py:class:`IORecord` serves + as an abstract base class that instantiates a number of methods that the + binary and ASCII children classes are meant to implement. These methods, + prefixed with ``rw``, are meant to convert literal data types, e.g. float or + int, to either binary or ASCII. This base class does its own conversion for + container data types, e.g. list or matrix, relying on the child + implementation of the literal types that the container possesses. The binary + conversion is implemented in :py:class:`BinaryRecordReader` and + :py:class:`BinaryRecordWriter`. The ASCII conversion is implemented in + :py:class:`AsciiRecordReader` and :py:class:`AsciiRecordWriter`. + + These :py:class:`IORecord` classes are used within :py:class:`Stream` objects + for the data conversion. :py:class:`Stream` is a context manager that opens + a file for reading or writing on the ``__enter__`` and closes that file upon + ``__exit__``. :py:class:`Stream` is an abstract base class that is + subclassed for each CCCC file. It is subclassed directly for the CCCC files + that contain cross-section data: + + * :py:class:`ISOTXS <armi.nuclearDataIO.cccc.isotxs.IsotxsIO>` + * :py:mod:`GAMISO <armi.nuclearDataIO.cccc.gamiso>` + * :py:class:`PMATRX <armi.nuclearDataIO.cccc.pmatrx.PmatrxIO>` + * :py:class:`DLAYXS <armi.nuclearDataIO.cccc.dlayxs.DlayxsIO>` + * :py:mod:`COMPXS <armi.nuclearDataIO.cccc.compxs>` + + For the CCCC file types that are outputs from a flux solver such as DIF3D + (e.g., GEODST, DIF3D, NHFLUX) the streams are subclassed from + :py:class:`StreamWithDataContainer`, which is a special abstract subclass of + :py:class:`Stream` that implements a common pattern used for these file + types. In a :py:class:`StreamWithDataContainer`, the data is directly read + to or written from a specialized data container. + + The data container structure for each type of CCCC file is implemented in + the module for that file, as a subclass of :py:class:`DataContainer`. The + subclasses for each CCCC file type define standard attribute names for the + data that will be read from or written to the CCCC file. CCCC file types + that follow this pattern include: + + * :py:class:`GEODST <armi.nuclearDataIO.cccc.geodst.GeodstData>` + * :py:class:`DIF3D <armi.nuclearDataIO.cccc.dif3d.Dif3dData>` + * :py:class:`NHFLUX <armi.nuclearDataIO.cccc.nhflux.NHFLUX>` + (and multiple sub-classes thereof) + * :py:class:`LABELS <armi.nuclearDataIO.cccc.labels.LabelsData>` + * :py:class:`PWDINT <armi.nuclearDataIO.cccc.pwdint.PwdintData>` + * :py:class:`RTFLUX <armi.nuclearDataIO.cccc.rtflux.RtfluxData>` + * :py:class:`RZFLUX <armi.nuclearDataIO.cccc.rzflux.RzfluxData>` + * :py:class:`RTFLUX <armi.nuclearDataIO.cccc.rtflux.RtfluxData>` + + The logic to parse or write each specific file format is contained within + the :py:meth:`Stream.readWrite` implementations of the respective + subclasses. """ import io import itertools @@ -274,7 +335,8 @@ def rwImplicitlyTypedMap(self, keys: List[str], contents) -> dict: class BinaryRecordReader(IORecord): - """Writes a single CCCC record in binary format. + """ + Writes a single CCCC record in binary format. Notes ----- @@ -345,7 +407,8 @@ def rwString(self, val, length): class BinaryRecordWriter(IORecord): - r"""a single record from a CCCC file. + r""" + Reads a single CCCC record in binary format. Reads binary information sequentially. """ @@ -406,7 +469,8 @@ def rwString(self, val, length): class AsciiRecordReader(BinaryRecordReader): - """Reads a single CCCC record in ASCII format. + """ + Reads a single CCCC record in ASCII format. See Also -------- @@ -441,7 +505,8 @@ def rwString(self, val, length): class AsciiRecordWriter(IORecord): - r"""Writes a single CCCC record in ASCII format. + r""" + Writes a single CCCC record in ASCII format. Since there is no specific format of an ASCII CCCC record, the format is roughly the same as the :py:class:`BinaryRecordWriter`, except that the :class:`AsciiRecordReader` puts a space in diff --git a/armi/nuclearDataIO/cccc/compxs.py b/armi/nuclearDataIO/cccc/compxs.py index acc8883ba..6256347eb 100644 --- a/armi/nuclearDataIO/cccc/compxs.py +++ b/armi/nuclearDataIO/cccc/compxs.py @@ -46,7 +46,6 @@ Examples -------- -:: >>> from armi.nuclearDataIO import compxs >>> lib = compxs.readBinary('COMPXS') >>> r0 = lib.regions[0] @@ -162,7 +161,7 @@ class _CompxsIO(cccc.Stream): See Also -------- - armi.nuclearDataIO.cccc.isotxs._IsotxsIO + armi.nuclearDataIO.cccc.isotxs.IsotxsIO """ _METADATA_TAGS = ( @@ -223,7 +222,7 @@ def readWrite(self): See Also -------- - armi.nuclearDataIO.cccc.isotxs._IsotxsIO.readWrite : reading/writing ISOTXS files + armi.nuclearDataIO.cccc.isotxs.IsotxsIO.readWrite : reading/writing ISOTXS files """ runLog.info( "{} macroscopic cross library {}".format( diff --git a/armi/nuclearDataIO/cccc/dif3d.py b/armi/nuclearDataIO/cccc/dif3d.py index f2e5083ce..e5d8443ee 100644 --- a/armi/nuclearDataIO/cccc/dif3d.py +++ b/armi/nuclearDataIO/cccc/dif3d.py @@ -93,6 +93,8 @@ def __init__(self): class Dif3dStream(cccc.StreamWithDataContainer): + """Tool to read and write DIF3D files.""" + @staticmethod def _getDataContainer() -> Dif3dData: return Dif3dData() @@ -197,7 +199,45 @@ def _rw5DRecord(self) -> None: ) def readWrite(self): - """Reads or writes metadata and data from 5 records.""" + """Reads or writes metadata and data from the five records of the DIF3D binary file. + + .. impl:: Tool to read and write DIF3D files. + :id: I_ARMI_NUCDATA_DIF3D + :implements: R_ARMI_NUCDATA_DIF3D + + The reading and writing of the DIF3D binary file is performed using + :py:class:`StreamWithDataContainer <.cccc.StreamWithDataContainer>` + from the :py:mod:`~armi.nuclearDataIO.cccc` package. This class + allows for the reading and writing of CCCC binary files, processing + one record at a time using subclasses of the :py:class:`IORecord + <.cccc.IORecord>`. Each record in a CCCC binary file consists of + words that represent integers (short or long), floating-point + numbers (single or double precision), or strings of data. One or + more of these words are parsed one at a time by the reader. Multiple + words processed together have meaning, such as such as groupwise + overrelaxation factors. While reading, the data is stored in a + Python dictionary as an attribute on the object, one for each + record. The keys in each dictionary represent the parsed grouping of + words in the records; for example, for the 4D record (stored as the + attribute ``fourD``), each groupwise overrelaxation factor is stored + as the key ``OMEGA{i}``, where ``i`` is the group number. See + :need:`I_ARMI_NUCDATA` for more details on the general + implementation. + + Each record is also embedded with the record size at the beginning + and end of the record (always assumed to be present), which is used + for error checking at the end of processing each record. + + The DIF3D reader processes the file identification record (stored as + the attribute ``_metadata``) and the five data records for the DIF3D + file, as defined in the specification for the file distributed with + the DIF3D software. + + This class can also read and write an ASCII version of the DIF3D + file. While this format is not used by the DIF3D software, it can be + a useful representation for users to access the file in a + human-readable format. + """ msg = f"{'Reading' if 'r' in self._fileMode else 'Writing'} DIF3D binary data {self}" runLog.info(msg) diff --git a/armi/nuclearDataIO/cccc/dlayxs.py b/armi/nuclearDataIO/cccc/dlayxs.py index 8d7e99fcc..f62cc3750 100644 --- a/armi/nuclearDataIO/cccc/dlayxs.py +++ b/armi/nuclearDataIO/cccc/dlayxs.py @@ -108,7 +108,7 @@ def _write(delay, fileName, fileMode): def _readWrite(delay, fileName, fileMode): - with _DlayxsIO(fileName, fileMode, delay) as rw: + with DlayxsIO(fileName, fileMode, delay) as rw: rw.readWrite() return delay @@ -126,7 +126,6 @@ class Dlayxs(collections.OrderedDict): If you want an average over all nuclides, then you need to produce it using the properly-computed average contributions of each nuclide. - Attributes ---------- nuclideFamily : dict @@ -216,13 +215,53 @@ def _checkContributions(self): ) -class _DlayxsIO(cccc.Stream): +class DlayxsIO(cccc.Stream): + """Contains DLAYXS read/writers.""" + def __init__(self, fileName, fileMode, dlayxs): cccc.Stream.__init__(self, fileName, fileMode) self.dlayxs = dlayxs self.metadata = dlayxs.metadata def readWrite(self): + r"""Read and write DLAYXS files. + + .. impl:: Tool to read and write DLAYXS files. + :id: I_ARMI_NUCDATA_DLAYXS + :implements: R_ARMI_NUCDATA_DLAYXS + + Reading and writing DLAYXS delayed neutron data files is performed + using the general nuclear data I/O functionalities described in + :need:`I_ARMI_NUCDATA`. Reading/writing a DLAYXS file is performed + through the following steps: + + #. Read/write the data ``label`` for identification. + + .. note:: + + MC\ :sup:`2`-3 file does not use the expected number of + characters for the ``label``, so its length needs to be + stored in the :py:class:`~.cccc.IORecord`. + + #. Read/write file control information, i.e. the 1D record, which includes: + + * Number of energy groups + * Number of nuclides + * Number of precursor families + + #. Read/write spectral data, including: + + * Nuclide IDs + * Decay constants + * Emission spectra + * Energy group bounds + * Number of families to which fission in a given nuclide + contributes delayed neutron precursors + + #. Read/write 3D delayed neutron yield matrix on the 3D record, + indexed by nuclide, precursor family, and outgoing neutron energy + group. + """ runLog.info( "{} DLAYXS library {}".format( "Reading" if "r" in self._fileMode else "Writing", self diff --git a/armi/nuclearDataIO/cccc/gamiso.py b/armi/nuclearDataIO/cccc/gamiso.py index 2db1ac40f..af5e7f493 100644 --- a/armi/nuclearDataIO/cccc/gamiso.py +++ b/armi/nuclearDataIO/cccc/gamiso.py @@ -18,9 +18,20 @@ GAMISO is a binary file created by MC**2-v3 that contains multigroup microscopic gamma cross sections. GAMISO data is contained within a :py:class:`~armi.nuclearDataIO.xsLibraries.XSLibrary`. -See [GAMSOR]_. +.. impl:: Tool to read and write GAMISO files. + :id: I_ARMI_NUCDATA_GAMISO + :implements: R_ARMI_NUCDATA_GAMISO -.. [GAMSOR] Smith, M. A., Lee, C. H., and Hill, R. N. GAMSOR: Gamma Source Preparation and DIF3D Flux Solution. United States: + The majority of the functionality in this module is inherited from the + :py:mod:`~armi.nuclearDataIO.cccc.isotxs` module. See + :py:class:`~armi.nuclearDataIO.cccc.isotxs.IsotxsIO` and its associated + implementation :need:`I_ARMI_NUCDATA_ISOTXS` for more information. The only + difference from ISOTXS neutron data is a special treatment for gamma + velocities, which is done by overriding ``_rwLibraryEnergies``. + +See [GAMSOR]_. + +.. [GAMSOR] Smith, M. A., Lee, C. H., and Hill, R. N. GAMSOR: Gamma Source Preparation and DIF3D Flux Solution. United States: N. p., 2016. Web. doi:10.2172/1343095. `On OSTI <https://www.osti.gov/biblio/1343095-gamsor-gamma-source-preparation-dif3d-flux-solution>`_ """ @@ -110,7 +121,7 @@ def addDummyNuclidesToLibrary(lib, dummyNuclides): return any(dummyNuclideKeysAddedToLibrary) -class _GamisoIO(isotxs._IsotxsIO): +class _GamisoIO(isotxs.IsotxsIO): """ A reader/writer for GAMISO data files. diff --git a/armi/nuclearDataIO/cccc/geodst.py b/armi/nuclearDataIO/cccc/geodst.py index afe9a2470..2207f48e3 100644 --- a/armi/nuclearDataIO/cccc/geodst.py +++ b/armi/nuclearDataIO/cccc/geodst.py @@ -120,7 +120,6 @@ class GeodstStream(cccc.StreamWithDataContainer): fileMode: str string indicating if ``fileName`` is being read or written, and in ascii or binary format - """ @staticmethod @@ -133,6 +132,43 @@ def readWrite(self): Logic to control which records will be present is here, which comes directly off the File specification. + + .. impl:: Tool to read and write GEODST files. + :id: I_ARMI_NUCDATA_GEODST + :implements: R_ARMI_NUCDATA_GEODST + + Reading and writing GEODST files is performed using the general + nuclear data I/O functionalities described in + :need:`I_ARMI_NUCDATA`. Reading/writing a GEODST file is performed + through the following steps: + + #. Read/write file ID record + + #. Read/write file specifications on 1D record. + + #. Based on the geometry type (``IGOM``), one of following records + are read/written: + + * Slab (1), cylinder (3), or sphere (3): Read/write 1-D coarse + mesh boundaries and fine mesh intervals. + * X-Y (6), R-Z (7), Theta-R (8), uniform triangular (9), + hexagonal (10), or R-Theta (11): Read/write 2-D coarse mesh + boundaries and fine mesh intervals. + * R-Theta-Z (12, 15), R-Theta-Alpha (13, 16), X-Y-Z (14), + uniform triangular-Z (17), hexagonal-Z(18): Read/write 3-D + coarse mesh boundaries and fine mesh intervals. + + #. If the geometry is not zero-dimensional (``IGOM`` > 0) and + buckling values are specified (``NBS`` > 0): Read/write geometry + data from 5D record. + + #. If the geometry is not zero-dimensional (``IGOM`` > 0) and region + assignments are coarse-mesh-based (``NRASS`` = 0): Read/write + region assignments to coarse mesh interval. + + #. If the geometry is not zero-dimensional (``IGOM`` > 0) and region + assignments are fine-mesh-based (``NRASS`` = 1): Read/write + region assignments to fine mesh interval. """ self._rwFileID() self._rw1DRecord() @@ -159,8 +195,7 @@ def _rwFileID(self): Notes ----- - The username, version, etc are embedded in this string but it's - usually blank. The number 28 was actually obtained from + The number 28 was actually obtained from a hex editor and may be code specific. """ with self.createRecord() as record: @@ -179,7 +214,6 @@ def _rw1DRecord(self): def _rw2DRecord(self): """Read/write 1-D coarse mesh boundaries and fine mesh intervals.""" with self.createRecord() as record: - self._data.xmesh = record.rwList( self._data.xmesh, "double", self._metadata["NCINTI"] + 1 ) @@ -190,7 +224,6 @@ def _rw2DRecord(self): def _rw3DRecord(self): """Read/write 2-D coarse mesh boundaries and fine mesh intervals.""" with self.createRecord() as record: - self._data.xmesh = record.rwList( self._data.xmesh, "double", self._metadata["NCINTI"] + 1 ) @@ -207,7 +240,6 @@ def _rw3DRecord(self): def _rw4DRecord(self): """Read/write 3-D coarse mesh boundaries and fine mesh intervals.""" with self.createRecord() as record: - self._data.xmesh = record.rwList( self._data.xmesh, "double", self._metadata["NCINTI"] + 1 ) diff --git a/armi/nuclearDataIO/cccc/isotxs.py b/armi/nuclearDataIO/cccc/isotxs.py index 7ad8822dd..213529c0d 100644 --- a/armi/nuclearDataIO/cccc/isotxs.py +++ b/armi/nuclearDataIO/cccc/isotxs.py @@ -18,7 +18,7 @@ ISOTXS is a binary file that contains multigroup microscopic cross sections. ISOTXS stands for *Isotope Cross Sections*. -ISOTXS files are often created by a lattice physics code such as MC2 or DRAGON and +ISOTXS files are often created by a lattice physics code such as MC2 or DRAGON and used as input to a global flux solver such as DIF3D. This module implements reading and writing of the @@ -187,7 +187,7 @@ def addDummyNuclidesToLibrary(lib, dummyNuclides): return any(dummyNuclideKeysAddedToLibrary) -class _IsotxsIO(cccc.Stream): +class IsotxsIO(cccc.Stream): """ A semi-abstract stream for reading and writing to a :py:class:`~armi.nuclearDataIO.isotxs.Isotxs`. @@ -263,6 +263,54 @@ def _updateFileLabel(self): self._metadata["label"] = self._FILE_LABEL def readWrite(self): + """Read and write ISOTSX file. + + .. impl:: Tool to read and write ISOTXS files. + :id: I_ARMI_NUCDATA_ISOTXS + :implements: R_ARMI_NUCDATA_ISOTXS + + Reading and writing ISOTXS files is performed using the general + nuclear data I/O functionalities described in + :need:`I_ARMI_NUCDATA`. Reading/writing a ISOTXS file is performed + through the following steps: + + #. Read/write file ID record + #. Read/write file 1D record, which includes: + + * Number of energy groups (``NGROUP``) + * Maximum number of up-scatter groups (``MAXUP``) + * Maximum number of down-scatter groups (``MAXDN``) + * Maximum scattering order (``MAXORD``) + * File-wide specification on fission spectrum type, i.e. vector + or matrix (``ICHIST``) + * Maximum number of blocks of scattering data (``MSCMAX``) + * Subblocking control for scatter matrices (``NSBLOK``) + + #. Read/write file 2D record, which includes: + + * Library IDs for each isotope (``HSETID(I)``) + * Isotope names (``HISONM(I)``) + * Global fission spectrum (``CHI(J)``) if file-wide spectrum is + specified (``ICHIST`` = 1) + * Energy group structure (``EMAX(J)`` and ``EMIN``) + * Locations of each nuclide record in the file (``LOCA(I)``) + + .. note:: + + The offset data is not read from the binary file because + the ISOTXS reader can dynamically calculate the offset + itself. Therefore, during a read operation, this data is + ignored. + + #. Read/write file 4D record for each nuclide, which includes + isotope-dependent, group-independent data. + #. Read/write file 5D record for each nuclide, which includes + principal cross sections. + #. Read/write file 6D record for each nuclide, which includes + fission spectrum if it is flagged as a matrix (``ICHI`` > 1). + #. Read/write file 7D record for each nuclide, which includes the + scattering matrices. + """ self._rwMessage() properties.unlockImmutableProperties(self._lib) try: @@ -363,8 +411,9 @@ def _computeNuclideRecordOffset(self): Notes ----- - This is not used within ARMI, because it can compute it arbitrarily. Other codes use this to seek to a - specific position within an ISOTXS file. + The offset data is not read from the binary file because the ISOTXS + reader can dynamically calculate the offset itself. Therefore, during a + read operation, this data is ignored. """ recordsPerNuclide = [ self._computeNumIsotxsRecords(nuc) for nuc in self._lib.nuclides @@ -381,10 +430,10 @@ def _computeNumIsotxsRecords(self, nuclide): return numRecords -readBinary = _IsotxsIO.readBinary -readAscii = _IsotxsIO.readAscii -writeBinary = _IsotxsIO.writeBinary -writeAscii = _IsotxsIO.writeAscii +readBinary = IsotxsIO.readBinary +readAscii = IsotxsIO.readAscii +writeBinary = IsotxsIO.writeBinary +writeAscii = IsotxsIO.writeAscii class _IsotxsNuclideIO: @@ -393,7 +442,7 @@ class _IsotxsNuclideIO: Notes ----- - This is to be used in conjunction with an _IsotxsIO object. + This is to be used in conjunction with an IsotxsIO object. """ def __init__(self, nuclide, isotxsIO, lib): diff --git a/armi/nuclearDataIO/cccc/nhflux.py b/armi/nuclearDataIO/cccc/nhflux.py index ac6e7d66b..7d740cf95 100644 --- a/armi/nuclearDataIO/cccc/nhflux.py +++ b/armi/nuclearDataIO/cccc/nhflux.py @@ -58,82 +58,81 @@ class NHFLUX(cccc.DataContainer): """ - An abstraction of a NHFLUX file. This format is defined in the DIF3D manual. Note - that the format for DIF3D-Nodal and DIF3D-VARIANT are not the same. The VARIANT - NHFLUX format has recently changed, so this reader is only compatible with files - produced by v11.0 of the solver. - - .. warning:: - DIF3D outputs NHFLUX at every time node, but REBUS outputs NHFLUX only at every cycle. - - See also [VARIANT-95]_ and [VARIANT-2014]_. - - .. [VARIANT-95] G. Palmiotti, E. E. Lewis, and C. B. Carrico, VARIANT: VARIational - Anisotropic Nodal Transport for Multidimensional Cartesian and Hexagonal Geometry - Calculation, ANL-95/40, Argonne National Laboratory, Argonne, IL (October 1995). - - .. [VARIANT-2014] Smith, M. A., Lewis, E. E., and Shemon, E. R. DIF3D-VARIANT 11.0: A - Decade of Updates. United States: N. p., 2014. Web. doi:10.2172/1127298. - https://publications.anl.gov/anlpubs/2014/04/78313.pdf + An abstraction of a NHFLUX file. This format is defined in the DIF3D manual. Note that the + format for DIF3D-Nodal and DIF3D-VARIANT are not the same. The VARIANT NHFLUX format has + recently changed, so this reader is only compatible with files produced by v11.0 of the solver. Attributes ---------- metadata : file control - The NHFLUX file control info (sort of global for this library). This is the contents - of the 1D data block on the file. + The NHFLUX file control info (sort of global for this library). This is the contents of the + 1D data block on the file. incomingPointersToAllAssemblies: 2-D list of floats - This is an index map for the "internal surfaces" between DIF3D nodal - indexing and DIF3D GEODST indexing. It can be used to process incoming partial - currents. This uses the same ordering as the geodstCoordMap attribute. + This is an index map for the "internal surfaces" between DIF3D nodal indexing and DIF3D + GEODST indexing. It can be used to process incoming partial currents. This uses the same + ordering as the geodstCoordMap attribute. externalCurrentPointers : list of ints - This is an index map for the "external surfaces" between DIF3D nodal - indexing and DIF3D GEODST indexing. "External surfaces" are important because they - contain the INCOMING partial currents from the outer reactor boundary. This uses - the same ordering as geodstCoordMap, except that each assembly now has multiple - subsequent indices. For example, for a hexagonal core, if hex of index n (0 to N-1) - has a surface of index k (0 to 5) that lies on the vacuum boundary, then the index - of that surface is N*6 + k + 1. + This is an index map for the "external surfaces" between DIF3D nodal indexing and DIF3D + GEODST indexing. "External surfaces" are important because they contain the INCOMING partial + currents from the outer reactor boundary. This uses the same ordering as geodstCoordMap, + except that each assembly now has multiple subsequent indices. For example, for a hexagonal + core, if hex of index n (0 to N-1) has a surface of index k (0 to 5) that lies on the vacuum + boundary, then the index of that surface is N*6 + k + 1. geodstCoordMap : list of ints - This is an index map between DIF3D nodal and DIF3D GEODST. It is - necessary for interpreting the ordering of flux and partial current data in the - NHFLUX file. Note that this mapping between DIF3D-Nodal and DIF3D-VARIANT is not - the same. + This is an index map between DIF3D nodal and DIF3D GEODST. It is necessary for interpreting + the ordering of flux and partial current data in the NHFLUX file. Note that this mapping + between DIF3D-Nodal and DIF3D-VARIANT is not the same. outgoingPCSymSeCPointers: list of ints - This is an index map for the outpgoing partial currents on the symmetric and sector - lateral boundary. It is only present for DIF3D-VARIANT for hexagonal cores. + This is an index map for the outpgoing partial currents on the symmetric and sector lateral + boundary. It is only present for DIF3D-VARIANT for hexagonal cores. ingoingPCSymSeCPointers: list of ints - This is an index map for the ingoing (or incoming) partial currents on the symmetric - and sector lateral boundary. It is only present for DIF3D-VARIANT for hexagonal cores. + This is an index map for the ingoing (or incoming) partial currents on the symmetric and + sector lateral boundary. It is only present for DIF3D-VARIANT for hexagonal cores. fluxMomentsAll : 4-D list of floats - This contains all the flux moments for all core assemblies. The jth planar flux moment - of assembly i in group g in axial node k is fluxMoments[i][k][j][g]. The - assemblies are ordered according to the geodstCoordMap attribute. For DIF3D-VARIANT, - this includes both even and odd parity moments. + This contains all the flux moments for all core assemblies. The jth planar flux moment of + assembly i in group g in axial node k is fluxMoments[i][k][j][g]. The assemblies are ordered + according to the geodstCoordMap attribute. For DIF3D-VARIANT, this includes both even and + odd parity moments. partialCurrentsHexAll : 5-D list of floats This contains all the OUTGOING partial currents for all core assemblies. The OUTGOING partial current on surface j in assembly i in axial node k in group g is partialCurrentsHex[i][k][j][g][m], where m=0. The assemblies are ordered according to the - geodstCoordMap attribute. For DIF3D-VARIANT, higher-order data is available for the - m axis. + geodstCoordMap attribute. For DIF3D-VARIANT, higher-order data is available for the m axis. partialCurrentsHex_extAll : 4-D list of floats - This contains all the INCOMING partial currents on "external surfaces", which are - adjacent to the reactor outer boundary (usually vacuum). Internal reflective surfaces - are NOT included in this! These "external surfaces" are ordered according to - externalCurrentPointers. For DIF3D-VARIANT, higher-order data is available for the - last axis. + This contains all the INCOMING partial currents on "external surfaces", which are adjacent + to the reactor outer boundary (usually vacuum). Internal reflective surfaces are NOT + included in this! These "external surfaces" are ordered according to + externalCurrentPointers. For DIF3D-VARIANT, higher-order data is available for the last + axis. partialCurrentsZAll : 5-D list of floats - This contains all the upward and downward partial currents for all core assemblies - The assemblies are ordered according to the geodstCoordMap attribute. For DIF3D-VARIANT, - higher-order data is available for the last axis. + This contains all the upward and downward partial currents for all core assemblies. The + assemblies are ordered according to the geodstCoordMap attribute. For DIF3D-VARIANT, higher- + order data is available for the last axis. + + Warning + ------- + DIF3D outputs NHFLUX at every time node, but REBUS outputs NHFLUX only at every cycle. + + See Also + -------- + [VARIANT-95]_ and [VARIANT-2014]_. + + .. [VARIANT-95] G. Palmiotti, E. E. Lewis, and C. B. Carrico, VARIANT: VARIational Anisotropic + Nodal Transport for Multidimensional Cartesian and Hexagonal Geometry Calculation, ANL-95/40, + Argonne National Laboratory, Argonne, IL (October 1995). + + .. [VARIANT-2014] Smith, M. A., Lewis, E. E., and Shemon, E. R. DIF3D-VARIANT 11.0: A Decade of + Updates. United States: N. p., 2014. Web. doi:10.2172/1127298. + https://publications.anl.gov/anlpubs/2014/04/78313.pdf """ def __init__(self, fName="NHFLUX", variant=False, numDataSetsToRead=1): @@ -146,8 +145,8 @@ def __init__(self, fName="NHFLUX", variant=False, numDataSetsToRead=1): Filename of the NHFLUX binary file to be read. variant : bool, optional - Whether or not this NHFLUX/NAFLUX file has the DIF3D-VARIANT output format, which - is different than the DIF3D-Nodal format. + Whether or not this NHFLUX/NAFLUX file has the DIF3D-VARIANT output format, which is + different than the DIF3D-Nodal format. """ cccc.DataContainer.__init__(self) @@ -181,8 +180,8 @@ def fluxMoments(self): def partialCurrentsHex(self): """ For DIF3D-Nodal, this property is almost always equivalent to the attribute - `partialCurrentsHex`. For DIF3D-VARIANT, this property returns the zeroth-order - moment of the outgoing radial currents. + ``partialCurrentsHex``. For DIF3D-VARIANT, this property returns the zeroth-order moment of + the outgoing radial currents. Read-only property (there is no setter). """ @@ -217,11 +216,11 @@ def _getDataContainer() -> NHFLUX: return NHFLUX() def readWrite(self): - r""" + """ Read everything from the DIF3D binary file NHFLUX. - Read all surface-averaged partial currents, all planar moments, and the DIF3D - nodal coordinate mapping system. + Read all surface-averaged partial currents, all planar moments, and the DIF3D nodal + coordinate mapping system. Notes ----- @@ -231,18 +230,17 @@ def readWrite(self): Parameters ---------- numDataSetsToRead : int, optional - The number of whole-core flux data sets included in this NHFLUX/NAFLUX file - that one wishes to be read. Some NHFLUX/NAFLUX files, such as NAFLUX files - written by SASSYS/DIF3D-K, contain more than one flux data set. Each data set - overwrites the previous one on the NHFLUX class object, which will contain only the - numDataSetsToRead-th data set. The first numDataSetsToRead-1 data sets are essentially - skipped over. + The number of whole-core flux data sets included in this NHFLUX/NAFLUX file that one + wishes to be read. Some NHFLUX/NAFLUX files, such as NAFLUX files written by + SASSYS/DIF3D-K, contain more than one flux data set. Each data set overwrites the + previous one on the NHFLUX class object, which will contain only the + ``numDataSetsToRead-th`` data set. The first numDataSetsToRead-1 data sets are + essentially skipped over. """ self._rwFileID() self._rwBasicFileData1D() - # This control info only exists for VARIANT. We can only process entries with 0 - # or 1. + # This control info only exists for VARIANT. We can only process entries with 0 or 1. if self._metadata["variantFlag"] and self._metadata["iwnhfl"] == 2: msg = ( "This reader can only read VARIANT NHFLUX files where 'iwnhfl'=0 (both " @@ -413,7 +411,7 @@ def _rwGeodstCoordMap2D(self): Examples -------- - geodstCoordMap[NodalIndex] = geodstIndex + geodstCoordMap[NodalIndex] = geodstIndex See Also -------- @@ -619,9 +617,9 @@ def _rwZPartialCurrents5D(self, surfCurrents): return surfCurrents def _getEnergyGroupIndex(self, g): - r""" - Real fluxes stored in NHFLUX have "normal" (or "forward") energy groups. - Also see the subclass method NAFLUX.getEnergyGroupIndex(). + """ + Real fluxes stored in NHFLUX have "normal" (or "forward") energy groups. Also see the + subclass method NAFLUX.getEnergyGroupIndex(). """ return g @@ -634,7 +632,7 @@ class NafluxStream(NhfluxStream): """ def _getEnergyGroupIndex(self, g): - r"""Adjoint fluxes stored in NAFLUX have "reversed" (or "backward") energy groups.""" + """Adjoint fluxes stored in NAFLUX have "reversed" (or "backward") energy groups.""" ng = self._metadata["ngroup"] return ng - g - 1 @@ -645,7 +643,7 @@ class NhfluxStreamVariant(NhfluxStream): Notes ----- - Can be deleted after have the NHFLUX data container be the public interface + Can be deleted after have the NHFLUX data container be the public interface. """ @staticmethod @@ -659,7 +657,7 @@ class NafluxStreamVariant(NafluxStream): Notes ----- - Can be deleted after have the NHFLUX data container be the public interface + Can be deleted after have the NHFLUX data container be the public interface. """ @staticmethod @@ -668,9 +666,9 @@ def _getDataContainer() -> NHFLUX: def getNhfluxReader(adjointFlag, variantFlag): - r""" - Returns the appropriate DIF3D nodal flux binary file reader class, - either NHFLUX (real) or NAFLUX (adjoint). + """ + Returns the appropriate DIF3D nodal flux binary file reader class, either NHFLUX (real) or + NAFLUX (adjoint). """ if adjointFlag: reader = NafluxStreamVariant if variantFlag else NafluxStream diff --git a/armi/nuclearDataIO/cccc/pmatrx.py b/armi/nuclearDataIO/cccc/pmatrx.py index 53b155f7a..9bdbe5f25 100644 --- a/armi/nuclearDataIO/cccc/pmatrx.py +++ b/armi/nuclearDataIO/cccc/pmatrx.py @@ -17,8 +17,8 @@ See [GAMSOR]_ and [MC23]_. -.. [MC23] Lee, Changho, Jung, Yeon Sang, and Yang, Won Sik. MC2-3: Multigroup Cross Section Generation Code for Fast Reactor - Analysis Nuclear. United States: N. p., 2018. Web. doi:10.2172/1483949. +.. [MC23] Lee, Changho, Jung, Yeon Sang, and Yang, Won Sik. MC2-3: Multigroup Cross Section Generation Code for Fast Reactor + Analysis Nuclear. United States: N. p., 2018. Web. doi:10.2172/1483949. (`OSTI <https://www.osti.gov/biblio/1483949-mc2-multigroup-cross-section-generation-code-fast-reactor-analysis-nuclear>`_) """ @@ -162,12 +162,12 @@ def _write(lib, fileName, fileMode): def _readWrite(lib, fileName, fileMode, getNuclideFunc): - with _PmatrxIO(fileName, lib, fileMode, getNuclideFunc) as rw: + with PmatrxIO(fileName, lib, fileMode, getNuclideFunc) as rw: rw.readWrite() return lib -class _PmatrxIO(cccc.Stream): +class PmatrxIO(cccc.Stream): def __init__(self, fileName, xsLib, fileMode, getNuclideFunc): cccc.Stream.__init__(self, fileName, fileMode) self._lib = xsLib @@ -184,6 +184,31 @@ def _rwMessage(self): ) def readWrite(self): + """Read and write PMATRX files. + + .. impl:: Tool to read and write PMATRX files. + :id: I_ARMI_NUCDATA_PMATRX + :implements: R_ARMI_NUCDATA_PMATRX + + Reading and writing PMATRX files is performed using the general + nuclear data I/O functionalities described in + :need:`I_ARMI_NUCDATA`. Reading/writing a PMATRX file is performed + through the following steps: + + #. Read/write global information including: + + * Number of gamma energy groups + * Number of neutron energy groups + * Maximum scattering order + * Maximum number of compositions + * Maximum number of materials + * Maximum number of regions + + #. Read/write energy group structure for neutrons and gammas + #. Read/write dose conversion factors + #. Read/write gamma production matrices for each nuclide, as well as + other reaction constants related to neutron-gamma production. + """ self._rwMessage() properties.unlockImmutableProperties(self._lib) try: diff --git a/armi/nuclearDataIO/cccc/tests/test_dif3d.py b/armi/nuclearDataIO/cccc/tests/test_dif3d.py index 5720c9a1e..11cb86063 100644 --- a/armi/nuclearDataIO/cccc/tests/test_dif3d.py +++ b/armi/nuclearDataIO/cccc/tests/test_dif3d.py @@ -13,7 +13,6 @@ # limitations under the License. """Test reading/writing of DIF3D binary input.""" - import os import unittest @@ -37,14 +36,24 @@ def setUpClass(cls): cls.df = dif3d.Dif3dStream.readBinary(SIMPLE_HEXZ_DIF3D) def test__rwFileID(self): - """Verify the file identification info.""" + """Verify the file identification info. + + .. test:: Test reading DIF3D files. + :id: T_ARMI_NUCDATA_DIF3D0 + :tests: R_ARMI_NUCDATA_DIF3D + """ self.assertEqual(self.df.metadata["HNAME"], "DIF3D") self.assertEqual(self.df.metadata["HUSE1"], "") self.assertEqual(self.df.metadata["HUSE2"], "") self.assertEqual(self.df.metadata["VERSION"], 1) def test__rwFile1DRecord(self): - """Verify the rest of the metadata.""" + """Verify the rest of the metadata. + + .. test:: Test reading DIF3D files. + :id: T_ARMI_NUCDATA_DIF3D1 + :tests: R_ARMI_NUCDATA_DIF3D + """ TITLE_A6 = ["3D Hex", "-Z to", "genera", "te NHF", "LUX fi", "le"] EXPECTED_TITLE = TITLE_A6 + [""] * 5 for i in range(dif3d.TITLE_RANGE): @@ -133,7 +142,12 @@ def test__rw5DRecord(self): self.assertEqual(self.df.fiveD, None) def test_writeBinary(self): - """Verify binary equivalence of written DIF3D file.""" + """Verify binary equivalence of written DIF3D file. + + .. test:: Test writing DIF3D files. + :id: T_ARMI_NUCDATA_DIF3D2 + :tests: R_ARMI_NUCDATA_DIF3D + """ with TemporaryDirectoryChanger(): dif3d.Dif3dStream.writeBinary(self.df, "DIF3D2") with open(SIMPLE_HEXZ_DIF3D, "rb") as f1, open("DIF3D2", "rb") as f2: diff --git a/armi/nuclearDataIO/cccc/tests/test_dlayxs.py b/armi/nuclearDataIO/cccc/tests/test_dlayxs.py index 50411e6f9..929b7450b 100644 --- a/armi/nuclearDataIO/cccc/tests/test_dlayxs.py +++ b/armi/nuclearDataIO/cccc/tests/test_dlayxs.py @@ -32,7 +32,12 @@ def setUpClass(cls): cls.dlayxs3 = dlayxs.readBinary(test_xsLibraries.DLAYXS_MCC3) def test_decayConstants(self): - """Test that all emission spectrum delayEmissionSpectrum is normalized.""" + """Test that all emission spectrum delayEmissionSpectrum is normalized. + + .. test:: Test reading DLAYXS files. + :id: T_ARMI_NUCDATA_DLAYXS0 + :tests: R_ARMI_NUCDATA_DLAYXS + """ delay = self.dlayxs3 self.assertTrue( numpy.allclose( @@ -933,7 +938,7 @@ def _assertDC(self, nucName, endfProvidedData): "All the delayNeutronsPerFission data from mcc-v3 does not agree, this may be because they are from ENDV/B VI.8." ) def test_ENDFVII1NeutronsPerFission(self): - r""" + """ Build delayed nu based on ENDF/B-VII data. Notes @@ -1071,6 +1076,12 @@ def test_compare(self): self.assertTrue(dlayxs.compare(self.dlayxs3, copy.deepcopy(self.dlayxs3))) def test_writeBinary_mcc3(self): + """Verify binary equivalence of written DLAYXS file. + + .. test:: Test writing DLAYXS files. + :id: T_ARMI_NUCDATA_DLAYXS1 + :tests: R_ARMI_NUCDATA_DLAYXS + """ with TemporaryDirectoryChanger(): dlayxs.writeBinary(self.dlayxs3, "test_writeBinary_mcc3.temp") self.assertTrue( @@ -1112,8 +1123,3 @@ def test_avg(self): self.assertTrue( numpy.allclose(avg.delayEmissionSpectrum, dlayU235.delayEmissionSpectrum) ) - - -if __name__ == "__main__": - # import sys;sys.argv = ['', 'DlayxsTests.test_writeBinary_mcc3'] - unittest.main(verbosity=2) diff --git a/armi/nuclearDataIO/cccc/tests/test_gamiso.py b/armi/nuclearDataIO/cccc/tests/test_gamiso.py index e40ca6316..6fd45bfa3 100644 --- a/armi/nuclearDataIO/cccc/tests/test_gamiso.py +++ b/armi/nuclearDataIO/cccc/tests/test_gamiso.py @@ -20,6 +20,7 @@ from armi.nuclearDataIO import xsLibraries from armi.nuclearDataIO.cccc import gamiso from armi.nuclearDataIO.xsNuclides import XSNuclide +from armi.utils.directoryChangers import TemporaryDirectoryChanger THIS_DIR = os.path.dirname(__file__) FIXTURE_DIR = os.path.join(THIS_DIR, "..", "..", "tests", "fixtures") @@ -31,10 +32,28 @@ def setUp(self): self.xsLib = xsLibraries.IsotxsLibrary() def test_compare(self): + """Compare the input binary GAMISO file. + + .. test:: Test reading GAMISO files. + :id: T_ARMI_NUCDATA_GAMISO0 + :tests: R_ARMI_NUCDATA_GAMISO + """ gamisoAA = gamiso.readBinary(GAMISO_AA) self.xsLib.merge(deepcopy(gamisoAA)) self.assertTrue(gamiso.compare(self.xsLib, gamisoAA)) + def test_writeBinary(self): + """Write a binary GAMISO file. + + .. test:: Test writing GAMISO files. + :id: T_ARMI_NUCDATA_GAMISO1 + :tests: R_ARMI_NUCDATA_GAMISO + """ + with TemporaryDirectoryChanger(): + data = gamiso.readBinary(GAMISO_AA) + binData = gamiso.writeBinary(data, "gamiso.out") + self.assertTrue(gamiso.compare(data, binData)) + def test_addDummyNuclidesToLibrary(self): dummyNuclides = [XSNuclide(None, "U238AA")] before = self.xsLib.getNuclides("") diff --git a/armi/nuclearDataIO/cccc/tests/test_geodst.py b/armi/nuclearDataIO/cccc/tests/test_geodst.py index 2d4cd516e..12d51fa1c 100644 --- a/armi/nuclearDataIO/cccc/tests/test_geodst.py +++ b/armi/nuclearDataIO/cccc/tests/test_geodst.py @@ -33,7 +33,12 @@ class TestGeodst(unittest.TestCase): """ def test_readGeodst(self): - """Ensure we can read a GEODST file.""" + """Ensure we can read a GEODST file. + + .. test:: Test reading GEODST files. + :id: T_ARMI_NUCDATA_GEODST0 + :tests: R_ARMI_NUCDATA_GEODST + """ geo = geodst.readBinary(SIMPLE_GEODST) self.assertEqual(geo.metadata["IGOM"], 18) self.assertAlmostEqual(geo.xmesh[1], 16.79, places=5) # hex pitch @@ -43,7 +48,12 @@ def test_readGeodst(self): self.assertEqual(geo.coarseMeshRegions.max(), geo.metadata["NREG"]) def test_writeGeodst(self): - """Ensure that we can write a modified GEODST.""" + """Ensure that we can write a modified GEODST. + + .. test:: Test writing GEODST files. + :id: T_ARMI_NUCDATA_GEODST1 + :tests: R_ARMI_NUCDATA_GEODST + """ with TemporaryDirectoryChanger(): geo = geodst.readBinary(SIMPLE_GEODST) geo.zmesh[-1] *= 2 diff --git a/armi/nuclearDataIO/cccc/tests/test_isotxs.py b/armi/nuclearDataIO/cccc/tests/test_isotxs.py index f4bb35db5..b54e3a29b 100644 --- a/armi/nuclearDataIO/cccc/tests/test_isotxs.py +++ b/armi/nuclearDataIO/cccc/tests/test_isotxs.py @@ -19,6 +19,7 @@ from armi.nuclearDataIO import xsLibraries from armi.nuclearDataIO.cccc import isotxs from armi.tests import ISOAA_PATH +from armi.utils.directoryChangers import TemporaryDirectoryChanger class TestIsotxs(unittest.TestCase): @@ -30,6 +31,33 @@ def setUpClass(cls): # be a small library with LFPs, Actinides, structure, and coolant cls.lib = isotxs.readBinary(ISOAA_PATH) + def test_writeBinary(self): + """Test reading in an ISOTXS file, and then writing it back out again. + + Now, the library here can't guarantee the output will be the same as the + input. But we can guarantee the written file is still valid, by reading + it again. + + .. test:: Write ISOTSX binary files. + :id: T_ARMI_NUCDATA_ISOTXS0 + :tests: R_ARMI_NUCDATA_ISOTXS + """ + with TemporaryDirectoryChanger(): + origLib = isotxs.readBinary(ISOAA_PATH) + + fname = self._testMethodName + "temp-aa.isotxs" + isotxs.writeBinary(origLib, fname) + lib = isotxs.readBinary(fname) + + # validate the written file is still valid + nucs = lib.nuclides + self.assertTrue(nucs) + self.assertIn("AA", lib.xsIDs) + nuc = lib["U235AA"] + self.assertIsNotNone(nuc) + with self.assertRaises(KeyError): + lib.getNuclide("nonexistent", "zz") + def test_isotxsGeneralData(self): nucs = self.lib.nuclides self.assertTrue(nucs) @@ -164,6 +192,12 @@ def test_getGAMISOFileName(self): class Isotxs_merge_Tests(unittest.TestCase): def test_mergeMccV2FilesRemovesTheFileWideChi(self): + """Test merging ISOTXS files. + + .. test:: Read ISOTXS files. + :id: T_ARMI_NUCDATA_ISOTXS1 + :tests: R_ARMI_NUCDATA_ISOTXS + """ isoaa = isotxs.readBinary(ISOAA_PATH) self.assertAlmostEqual(1.0, sum(isoaa.isotxsMetadata["chi"]), 5) self.assertAlmostEqual(1, isoaa.isotxsMetadata["fileWideChiFlag"]) diff --git a/armi/nuclearDataIO/cccc/tests/test_pmatrx.py b/armi/nuclearDataIO/cccc/tests/test_pmatrx.py index cf4180c22..75b33f70a 100644 --- a/armi/nuclearDataIO/cccc/tests/test_pmatrx.py +++ b/armi/nuclearDataIO/cccc/tests/test_pmatrx.py @@ -117,7 +117,7 @@ def test_pmatrxGammaEnergies(self): ] self.assertTrue((energies == self.lib.gammaEnergyUpperBounds).all()) - def test_pmatrxNeutronEneries(self): + def test_pmatrxNeutronEnergies(self): energies = [ 14190675.0, 10000000.0, @@ -201,7 +201,12 @@ class TestProductionMatrix_FromWritten(TestPmatrx): """ def test_writtenIsIdenticalToOriginal(self): - """Make sure our writer produces something identical to the original.""" + """Make sure our writer produces something identical to the original. + + .. test:: Test reading and writing PMATRIX files. + :id: T_ARMI_NUCDATA_PMATRX + :tests: R_ARMI_NUCDATA_PMATRX + """ origLib = pmatrx.readBinary(test_xsLibraries.PMATRX_AA) fname = self._testMethodName + "temp-aa.pmatrx" diff --git a/armi/nuclearDataIO/tests/test_xsCollections.py b/armi/nuclearDataIO/tests/test_xsCollections.py index 9c997031a..6a85e1734 100644 --- a/armi/nuclearDataIO/tests/test_xsCollections.py +++ b/armi/nuclearDataIO/tests/test_xsCollections.py @@ -78,6 +78,12 @@ def test_plotNucXs(self): self.assertTrue(os.path.exists(fName)) def test_createMacrosFromMicros(self): + """Test calculating macroscopic cross sections from microscopic cross sections. + + .. test:: Compute macroscopic cross sections from microscopic cross sections and number densities. + :id: T_ARMI_NUCDATA_MACRO + :tests: R_ARMI_NUCDATA_MACRO + """ self.assertEqual(self.mc.minimumNuclideDensity, 1e-13) self.mc.createMacrosFromMicros(self.microLib, self.block) totalMacroFissionXs = 0.0 @@ -138,16 +144,20 @@ def r(self, r): self._r = r def getVolume(self, *args, **kwargs): + """Return the volume of a block.""" return 1.0 def getNuclideNumberDensities(self, nucNames): + """Return a list of number densities in atoms/barn-cm for the nuc names requested.""" return [self.density.get(nucName, 0.0) for nucName in nucNames] def _getNdensHelper(self): return {nucName: density for nucName, density in self.density.items()} def setNumberDensity(self, key, val, *args, **kwargs): + """Set the number density of this nuclide to this value.""" self.density[key] = val def getNuclides(self): + """Determine which nuclides are present in this armi block.""" return self.density.keys() diff --git a/armi/nuclearDataIO/tests/test_xsLibraries.py b/armi/nuclearDataIO/tests/test_xsLibraries.py index bbe29afe2..35d7f6066 100644 --- a/armi/nuclearDataIO/tests/test_xsLibraries.py +++ b/armi/nuclearDataIO/tests/test_xsLibraries.py @@ -54,7 +54,7 @@ UFG_FLUX_EDIT = os.path.join(FIXTURE_DIR, "mc2v3-AA.flux_ufg") -class TempFileMixin: +class TempFileMixin(unittest.TestCase): """really a test case.""" def setUp(self): @@ -67,12 +67,12 @@ def tearDown(self): @property def testFileName(self): return os.path.join( - THIS_DIR, + self.td.destination, "{}-{}.nucdata".format(self.__class__.__name__, self._testMethodName), ) -class TestXSLibrary(unittest.TestCase, TempFileMixin): +class TestXSLibrary(TempFileMixin): @classmethod def setUpClass(cls): cls.isotxsAA = isotxs.readBinary(ISOTXS_AA) @@ -235,7 +235,7 @@ def _canWritefromCombined(self, writer, refFile): self.assertTrue(filecmp.cmp(refFile, self.testFileName)) -class Test_GetISOTXSFilesInWorkingDirectory(unittest.TestCase): +class TestGetISOTXSFilesInWorkingDirectory(unittest.TestCase): def test_getISOTXSFilesWithoutLibrarySuffix(self): shouldBeThere = ["ISOAA", "ISOBA", os.path.join("file-path", "ISOCA")] shouldNotBeThere = [ @@ -289,7 +289,7 @@ def assert_contains_only(self, container, shouldBeThere, shouldNotBeThere): # NOTE: This is just a base class, so it isn't run directly. -class TestXSlibraryMerging(unittest.TestCase, TempFileMixin): +class TestXSlibraryMerging(TempFileMixin): """A shared class that defines tests that should be true for all IsotxsLibrary merging.""" @classmethod @@ -311,6 +311,7 @@ def tearDownClass(cls): del cls.libLumped def setUp(self): + TempFileMixin.setUp(self) # load a library that is in the ARMI tree. This should # be a small library with LFPs, Actinides, structure, and coolant for attrName, path in [ diff --git a/armi/nuclearDataIO/xsCollections.py b/armi/nuclearDataIO/xsCollections.py index 3f1357568..ed1a70f6f 100644 --- a/armi/nuclearDataIO/xsCollections.py +++ b/armi/nuclearDataIO/xsCollections.py @@ -27,14 +27,14 @@ Examples -------- -# creating a MicroscopicXSCollection by loading one from ISOTXS. -microLib = armi.nuclearDataIO.ISOTXS('ISOTXS') -micros = myLib.nuclides['U235AA'].micros + # creating a MicroscopicXSCollection by loading one from ISOTXS. + microLib = armi.nuclearDataIO.ISOTXS('ISOTXS') + micros = myLib.nuclides['U235AA'].micros -# creating macroscopic XS: -mc = MacroscopicCrossSectionCreator() -macroCollection = mc.createMacrosFromMicros(microLib, block) -blocksWithMacros = mc.createMacrosOnBlocklist(microLib, blocks) + # creating macroscopic XS: + mc = MacroscopicCrossSectionCreator() + macroCollection = mc.createMacrosFromMicros(microLib, block) + blocksWithMacros = mc.createMacrosOnBlocklist(microLib, blocks) """ import numpy @@ -55,7 +55,7 @@ NT = "nt" # (n, triton) FISSION_XS = "fission" # (n, fission) N2N_XS = "n2n" # (n,2n) -NUSIGF = "nuSigF" +NUSIGF = "nuSigF" NU = "neutronsPerFission" # fmt: on CAPTURE_XS = [NGAMMA, NAPLHA, NP, ND, NT] @@ -274,7 +274,6 @@ def compare(self, other, flux, relativeTolerance=0, verbose=False): """Compare the cross sections between two XSCollections objects.""" equal = True for xsName in ALL_COLLECTION_DATA: - myXsData = self.__dict__[xsName] theirXsData = other.__dict__[xsName] @@ -381,6 +380,7 @@ def __init__( def createMacrosOnBlocklist( self, microLibrary, blockList, nucNames=None, libType="micros" ): + """Create macroscopic cross sections for a list of blocks.""" for block in blockList: block.macros = self.createMacrosFromMicros( microLibrary, block, nucNames, libType=libType @@ -414,7 +414,6 @@ def createMacrosFromMicros( ------- macros : xsCollection.XSCollection A new XSCollection full of macroscopic cross sections - """ runLog.debug("Building macroscopic cross sections for {0}".format(block)) if nucNames is None: @@ -785,9 +784,44 @@ def computeMacroscopicGroupConstants( multConstant=None, multLib=None, ): - """ + r""" Compute any macroscopic group constants given number densities and a microscopic library. + .. impl:: Compute macroscopic cross sections from microscopic cross sections and number densities. + :id: I_ARMI_NUCDATA_MACRO + :implements: R_ARMI_NUCDATA_MACRO + + This function computes the macroscopic cross sections of a specified + reaction type from inputted microscopic cross sections and number + densities. The ``constantName`` parameter specifies what type of + reaction is requested. The ``numberDensities`` parameter is a dictionary + mapping the nuclide to its number density. The ``lib`` parameter is a library + object like :py:class:`~armi.nuclearDataIO.xsLibraries.IsotxsLibrary` or + :py:class:`~armi.nuclearDataIO.xsLibraries.CompxsLibrary` that holds the + microscopic cross-section data. The ``microSuffix`` parameter specifies + from which part of the library the microscopic cross sections are + gathered; this is typically gathered from a components + ``getMicroSuffix`` method like :py:meth:`Block.getMicroSuffix + <armi.reactor.blocks.Block.getMicroSuffix>`. ``libType`` is an optional + parameter specifying whether the reaction is for neutrons or gammas. + This function also has the optional parameters ``multConstant`` and + ``multLib``, which allows another constant from the library, such as + neutrons per fission (nu) or energy per fission (kappa), to be + multiplied to the primary one. The macroscopic cross sections are then + computed as: + + .. math:: + + \Sigma_{g} = \sum_{n} N_n \sigma_{n,g}\nu_n \quad g=1,...,G + + where :math:`n` is the isotope index, :math:`g` is the energy group + index, :math:`\sigma` is the microscopic cross section, and :math:`\nu` + is the scalar multiplier. If the library (``lib``) with suffix + ``microSuffix`` is missing a cross section for the ``constantName`` + reaction for one or more of the nuclides in ``numberDensities`` an error + is raised; but if ``multConstant`` is missing that cross section, then + those nuclides are printed as a warning. + Parameters ---------- constantName : str diff --git a/armi/nuclearDataIO/xsNuclides.py b/armi/nuclearDataIO/xsNuclides.py index 78f2da1d1..e11296a7e 100644 --- a/armi/nuclearDataIO/xsNuclides.py +++ b/armi/nuclearDataIO/xsNuclides.py @@ -14,11 +14,11 @@ r""" This module contains cross section nuclides, which are a wrapper around the -:py:class:`~armi.nucDirectory.nuclideBases.INuclide` objects. The cross section nuclide objects contain -cross section information from a specific calculation (e.g. neutron, or gamma cross sections). +:py:class:`~armi.nucDirectory.nuclideBases.INuclide` objects. The cross section nuclide objects +contain cross section information from a specific calculation (e.g. neutron, or gamma cross sections). -:py:class:`XSNuclide` objects also contain meta data from the original file, so that another file can be -reconstructed. +:py:class:`XSNuclide` objects also contain meta data from the original file, so that another file +can be reconstructed. .. warning:: :py:class:`XSNuclide` objects should only be created by reading data into @@ -95,7 +95,7 @@ def updateBaseNuclide(self): self._base = nuclideBase def getMicroXS(self, interaction, group): - r"""Returns the microscopic xs as the ISOTXS value if it exists or a 0 since it doesn't.""" + """Returns the microscopic xs as the ISOTXS value if it exists or a 0 since it doesn't.""" if interaction in self.micros.__dict__: try: return self.micros[interaction][group] @@ -109,7 +109,7 @@ def getMicroXS(self, interaction, group): return 0 def getXS(self, interaction): - r"""Get the cross section of a particular interaction. + """Get the cross section of a particular interaction. See Also -------- diff --git a/armi/operators/operator.py b/armi/operators/operator.py index 48a83e19e..1d567e6bf 100644 --- a/armi/operators/operator.py +++ b/armi/operators/operator.py @@ -27,6 +27,7 @@ import re import shutil import time +from typing import Tuple from armi import context from armi import interfaces @@ -43,6 +44,8 @@ CONF_TIGHT_COUPLING, CONF_TIGHT_COUPLING_MAX_ITERS, CONF_CYCLES_SKIP_TIGHT_COUPLING_INTERACTION, + CONF_DEFERRED_INTERFACE_NAMES, + CONF_DEFERRED_INTERFACES_CYCLE, ) from armi.utils import codeTiming from armi.utils import ( @@ -59,9 +62,10 @@ class Operator: """ - Orchestrates an ARMI run, building all the pieces, looping through the interfaces, and manipulating the reactor. + Orchestrate an ARMI run, building all the pieces, looping through the interfaces, + and manipulating the reactor. - This Standard Operator loops over a user-input number of cycles, each with a + This Operator loops over a user-input number of cycles, each with a user-input number of subcycles (called time nodes). It calls a series of interaction hooks on each of the :py:class:`~armi.interfaces.Interface` in the Interface Stack. @@ -73,9 +77,37 @@ class Operator: .. note:: The :doc:`/developer/guide` has some additional narrative on this topic. + .. impl:: An operator will have a reactor object to communicate between plugins. + :id: I_ARMI_OPERATOR_COMM + :implements: R_ARMI_OPERATOR_COMM + + A major design feature of ARMI is that the Operator orchestrates the + simulation, and as part of that, the Operator has access to the + Reactor data model. In code, this just means the reactor object is + a mandatory attribute of an instance of the Operator. But conceptually, + this means that while the Operator drives the simulation of the + reactor, all code has access to the same copy of the reactor data + model. This is a crucial idea that allows disparate external nuclear + models to interact; they interact with the ARMI reactor data model. + + .. impl:: An operator is built from user settings. + :id: I_ARMI_OPERATOR_SETTINGS + :implements: R_ARMI_OPERATOR_SETTINGS + + A major design feature of ARMI is that a run is built from user settings. + In code, this means that a case ``Settings`` object is passed into this + class to intialize an Operator. Conceptually, this means that the + Operator that controls a reactor simulation is defined by user settings. + Because developers can create their own settings, the user can + control an ARMI simulation with arbitrary granularity in this way. In + practice, settings common control things like: how many cycles a + reactor is being modeled for, how many timesteps are to be modeled + per time node, the verbosity of the logging during the run, and + which modeling steps (such as economics) will be run. + Attributes ---------- - cs : CaseSettings object + cs : Settings Global settings that define the run. cycleNames : list of str @@ -116,7 +148,7 @@ def __init__(self, cs): Parameters ---------- - cs : CaseSettings object + cs : Settings Global settings that define the run. Raises @@ -165,6 +197,23 @@ def maxBurnSteps(self): @property def stepLengths(self): + """ + Calculate step lengths. + + .. impl:: Calculate step lengths from cycles and burn steps. + :id: I_ARMI_FW_HISTORY + :implements: R_ARMI_FW_HISTORY + + In all computational modeling of physical systems, it is + necessary to break time into discrete chunks. In reactor + modeling, it is common to first break the time a reactor + is simulated for into the practical cycles the reactor + runs. And then those cycles are broken down into smaller + chunks called burn steps. The final step lengths this + method returns is a two-tiered list, where primary indices + correspond to the cycle and secondary indices correspond to + the length of each intra-cycle step (in days). + """ if not self._stepLengths: self._stepLengths = getStepLengths(self.cs) if self._stepLengths == [] and self.cs["nCycles"] == 1: @@ -559,8 +608,7 @@ def _debugDB(self, interactionName, interfaceName, statePointIndex=0): def interactAllInit(self): """Call interactInit on all interfaces in the stack after they are initialized.""" - allInterfaces = self.interfaces[:] # copy just in case - self._interactAll("Init", allInterfaces) + self._interactAll("Init", self.getInterfaces()) def interactAllBOL(self, excludedInterfaceNames=()): """ @@ -568,30 +616,15 @@ def interactAllBOL(self, excludedInterfaceNames=()): All enabled or bolForce interfaces will be called excluding interfaces with excludedInterfaceNames. """ - activeInterfaces = [ - ii - for ii in self.interfaces - if (ii.enabled() or ii.bolForce()) and ii.name not in excludedInterfaceNames - ] - activeInterfaces = [ - ii - for ii in activeInterfaces - if ii.name not in self.cs["deferredInterfaceNames"] - ] + activeInterfaces = self.getActiveInterfaces("BOL", excludedInterfaceNames) self._interactAll("BOL", activeInterfaces) def interactAllBOC(self, cycle): """Interact at beginning of cycle of all enabled interfaces.""" - activeInterfaces = [ii for ii in self.interfaces if ii.enabled()] - if cycle < self.cs["deferredInterfacesCycle"]: - activeInterfaces = [ - ii - for ii in activeInterfaces - if ii.name not in self.cs["deferredInterfaceNames"] - ] + activeInterfaces = self.getActiveInterfaces("BOC", cycle=cycle) return self._interactAll("BOC", activeInterfaces, cycle) - def interactAllEveryNode(self, cycle, tn, excludedInterfaceNames=None): + def interactAllEveryNode(self, cycle, tn, excludedInterfaceNames=()): """ Call the interactEveryNode hook for all enabled interfaces. @@ -606,22 +639,12 @@ def interactAllEveryNode(self, cycle, tn, excludedInterfaceNames=None): excludedInterfaceNames : list, optional Names of interface names that will not be interacted with. """ - excludedInterfaceNames = excludedInterfaceNames or () - activeInterfaces = [ - ii - for ii in self.interfaces - if ii.enabled() and ii.name not in excludedInterfaceNames - ] + activeInterfaces = self.getActiveInterfaces("EveryNode", excludedInterfaceNames) self._interactAll("EveryNode", activeInterfaces, cycle, tn) - def interactAllEOC(self, cycle, excludedInterfaceNames=None): + def interactAllEOC(self, cycle, excludedInterfaceNames=()): """Interact end of cycle for all enabled interfaces.""" - excludedInterfaceNames = excludedInterfaceNames or () - activeInterfaces = [ - ii - for ii in self.interfaces - if ii.enabled() and ii.name not in excludedInterfaceNames - ] + activeInterfaces = self.getActiveInterfaces("EOC", excludedInterfaceNames) self._interactAll("EOC", activeInterfaces, cycle) def interactAllEOL(self): @@ -630,29 +653,40 @@ def interactAllEOL(self): Notes ----- - If the interfaces are flagged to be reversed at EOL, they are separated from the main stack and appended - at the end in reverse order. This allows, for example, an interface that must run first to also run last. + If the interfaces are flagged to be reversed at EOL, they are + separated from the main stack and appended at the end in reverse + order. This allows, for example, an interface that must run + first to also run last. """ - activeInterfaces = [ii for ii in self.interfaces if ii.enabled()] - interfacesAtEOL = [ii for ii in activeInterfaces if not ii.reverseAtEOL] - activeReverseInterfaces = [ii for ii in activeInterfaces if ii.reverseAtEOL] - interfacesAtEOL.extend(reversed(activeReverseInterfaces)) - self._interactAll("EOL", interfacesAtEOL) + activeInterfaces = self.getActiveInterfaces("EOL") + self._interactAll("EOL", activeInterfaces) def interactAllCoupled(self, coupledIteration): """ - Interact for tight physics coupling over all enabled interfaces. - - Tight coupling implies operator-split iterations between two or more physics solvers at the same solution - point in time. For example, a flux solution might be computed, then a temperature solution, and then - another flux solution based on updated temperatures (which updated densities, dimensions, and Doppler). - - This is distinct from loose coupling, which would simply uses the temperature values from the previous timestep - in the current flux solution. It's also distinct from full coupling where all fields are solved simultaneously. - ARMI supports tight and loose coupling. + Run all interfaces that are involved in tight physics coupling. + + .. impl:: Physics coupling is driven from Operator. + :id: I_ARMI_OPERATOR_PHYSICS1 + :implements: R_ARMI_OPERATOR_PHYSICS + + This method runs all the interfaces that are defined as part + of the tight physics coupling of the reactor. Then it returns + if the coupling has converged or not. + + Tight coupling implies the operator has split iterations + between two or more physics solvers at the same solution point + in simulated time. For example, a flux solution might be + computed, then a temperature solution, and then another flux + solution based on updated temperatures (which updates + densities, dimensions, and Doppler). + + This is distinct from loose coupling, which simply uses + the temperature values from the previous timestep in the + current flux solution. It's also distinct from full coupling + where all fields are solved simultaneously. ARMI supports + tight and loose coupling. """ - activeInterfaces = [ii for ii in self.interfaces if ii.enabled()] - + activeInterfaces = self.getActiveInterfaces("Coupled") # Store the previous iteration values before calling interactAllCoupled # for each interface. for interface in activeInterfaces: @@ -660,7 +694,6 @@ def interactAllCoupled(self, coupledIteration): interface.coupler.storePreviousIterationValue( interface.getTightCouplingValue() ) - self._interactAll("Coupled", activeInterfaces, coupledIteration) return self._checkTightCouplingConvergence(activeInterfaces) @@ -915,7 +948,13 @@ def getInterface(self, name=None, function=None): return candidateI def interfaceIsActive(self, name): - """True if named interface exists and is active.""" + """True if named interface exists and is enabled. + + Notes + ----- + This logic is significantly simpler that getActiveInterfaces. This logic only + touches the enabled() flag, but doesn't take into account the case settings. + """ i = self.getInterface(name) return i and i.enabled() @@ -923,12 +962,82 @@ def getInterfaces(self): """ Get list of interfaces in interface stack. + .. impl:: An operator will expose an ordered list of interfaces. + :id: I_ARMI_OPERATOR_INTERFACES + :implements: R_ARMI_OPERATOR_INTERFACES + + This method returns an ordered list of instances of the Interface + class. This list is useful because at any time node in the + reactor simulation, these interfaces will be called in + sequence to perform various types of calculations. It is + important to note that this Operator instance has a list of + Plugins, and each of those Plugins potentially defines + multiple Interfaces. And these Interfaces define their own + order, separate from the ordering of the Plugins. + Notes ----- Returns a copy so you can manipulate the list in an interface, like dependencies. """ return self.interfaces[:] + def getActiveInterfaces( + self, + interactState: str, + excludedInterfaceNames: Tuple[str] = (), + cycle: int = 0, + ): + """Retrieve the interfaces which are active for a given interaction state. + + Parameters + ---------- + interactState: str + A string dictating which interaction state the interfaces should be pulled for. + excludedInterfaceNames: Tuple[str] + A tuple of strings dictating which interfaces should be manually skipped. + cycle: int + The given cycle. 0 by default. + + Returns + ------- + activeInterfaces: List[Interfaces] + The interfaces deemed active for the given interactState. + """ + # Validate the inputs + if excludedInterfaceNames is None: + excludedInterfaceNames = () + + if interactState not in ("BOL", "BOC", "EveryNode", "EOC", "EOL", "Coupled"): + raise ValueError(f"{interactState} is an unknown interaction state!") + + # Ensure the interface is enabled. + enabled = lambda i: i.enabled() + if interactState == "BOL": + enabled = lambda i: i.enabled() or i.bolForce() + + # Ensure the name of the interface isn't in some exclusion list. + nameCheck = lambda i: True + if interactState == "EveryNode" or interactState == "EOC": + nameCheck = lambda i: i.name not in excludedInterfaceNames + elif interactState == "BOC" and cycle < self.cs[CONF_DEFERRED_INTERFACES_CYCLE]: + nameCheck = lambda i: i.name not in self.cs[CONF_DEFERRED_INTERFACE_NAMES] + elif interactState == "BOL": + nameCheck = ( + lambda i: i.name not in self.cs[CONF_DEFERRED_INTERFACE_NAMES] + and i.name not in excludedInterfaceNames + ) + + # Finally, find the active interfaces. + activeInterfaces = [i for i in self.interfaces if enabled(i) and nameCheck(i)] + + # Special Case: At EOL we reverse the order of some interfaces. + if interactState == "EOL": + actInts = [ii for ii in activeInterfaces if not ii.reverseAtEOL] + actInts.extend(reversed([ii for ii in activeInterfaces if ii.reverseAtEOL])) + activeInterfaces = actInts + + return activeInterfaces + def reattach(self, r, cs=None): """Add links to globally-shared objects to this operator and all interfaces. diff --git a/armi/operators/operatorMPI.py b/armi/operators/operatorMPI.py index 6cf7d64a1..191ae7d4d 100644 --- a/armi/operators/operatorMPI.py +++ b/armi/operators/operatorMPI.py @@ -15,20 +15,24 @@ """ The MPI-aware variant of the standard ARMI operator. -See :py:class:`~armi.operators.operator.Operator` for the parent class. +.. impl:: There is an MPI-aware variant of the ARMI Operator. + :id: I_ARMI_OPERATOR_MPI + :implements: R_ARMI_OPERATOR_MPI -This sets up the main Operator on the primary MPI node and initializes worker -processes on all other MPI nodes. At certain points in the run, particular interfaces -might call into action all the workers. For example, a depletion or -subchannel T/H module may ask the MPI pool to perform a few hundred -independent physics calculations in parallel. In many cases, this can -speed up the overall execution of an analysis manyfold, if a big enough -computer or computer cluster is available. + This sets up the main Operator on the primary MPI node and initializes + worker processes on all other MPI nodes. At certain points in the run, + particular interfaces might call into action all the workers. For + example, a depletion or subchannel T/H module may ask the MPI pool to + perform a few hundred independent physics calculations in parallel. In + many cases, this can speed up the overall execution of an analysis, + if a big enough computer or computing cluster is available. + + See :py:class:`~armi.operators.operator.Operator` for the parent class. Notes ----- This is not *yet* smart enough to use shared memory when the MPI -tasks are on the same machine. Everything goes through MPI. This can +tasks are on the same machine. Everything goes through MPI. This can be optimized as needed. """ import gc @@ -185,6 +189,7 @@ def workerOperate(self): cmd ) ) + pm = getPluginManager() resetFlags = pm.hook.mpiActionRequiresReset(cmd=cmd) # only reset if all the plugins agree to reset diff --git a/armi/operators/settingsValidation.py b/armi/operators/settingsValidation.py index e9b4ad37e..6680b01b0 100644 --- a/armi/operators/settingsValidation.py +++ b/armi/operators/settingsValidation.py @@ -13,9 +13,10 @@ # limitations under the License. """ -A system to check user settings for validity and provide users with meaningful suggestions to fix. +A system to check user settings for validity and provide users with meaningful +suggestions to fix. -This allows developers to specify a rich set of rules and suggestions for user settings. +This allows developers to define a rich set of rules and suggestions for user settings. These then pop up during initialization of a run, either on the command line or as dialogues in the GUI. They say things like: "Your ___ setting has the value ___, which is impossible. Would you like to switch to ___?" @@ -42,7 +43,22 @@ class Query: - """An individual query.""" + """ + An individual setting validator. + + .. impl:: Rules to validate and customize a setting's behavior. + :id: I_ARMI_SETTINGS_RULES + :implements: R_ARMI_SETTINGS_RULES + + This class is meant to represent a generic validation test against a setting. + The goal is: developers create new settings and they want to make sure those + settings are used correctly. As an implementation, users pass in a + ``condition`` function to this class that returns ``True`` or ``False`` based + on the setting name and value. And then this class has a ``resolve`` method + which tests if the condition is met. Optionally, this class also contains a + ``correction`` function that allows users to automatically correct a bad + setting, if the developers can find a clear path forward. + """ def __init__(self, condition, statement, question, correction): """ @@ -51,8 +67,8 @@ def __init__(self, condition, statement, question, correction): Parameters ---------- condition : callable - A callable that returns True or False. If True, - then the query activates its question and potential correction. + A callable that returns True or False. If True, then the query activates + its question and potential correction. statement : str A statement of the problem indicated by a True condition question : str @@ -452,16 +468,16 @@ def _inspectSettings(self): ) def _willBeCopiedFrom(fName): - for copyFile in self.cs["copyFilesFrom"]: - if fName == os.path.split(copyFile)[1]: - return True - return False + return any( + fName == os.path.split(copyFile)[1] + for copyFile in self.cs["copyFilesFrom"] + ) self.addQuery( lambda: self.cs["explicitRepeatShuffles"] and not self._csRelativePathExists(self.cs["explicitRepeatShuffles"]) and not _willBeCopiedFrom(self.cs["explicitRepeatShuffles"]), - "The specified repeat shuffle file `{0}` does not exist, and won't be copied from elsewhere. " + "The specified repeat shuffle file `{0}` does not exist, and won't be copied. " "Run will crash.".format(self.cs["explicitRepeatShuffles"]), "", self.NO_ACTION, @@ -624,11 +640,10 @@ def decayCyclesHaveInputThatWillBeIgnored(): except: # noqa: bare-except return True - for pf, af in zip(powerFracs, availabilities): - if pf > 0.0 and af == 0.0: - # this will be a full decay step and any power fraction will be ignored. May be ok, but warn. - return True - return False + # This will be a full decay step and any power fraction will be ignored. May be ok. + return any( + pf > 0.0 and af == 0.0 for pf, af in zip(powerFracs, availabilities) + ) self.addQuery( lambda: ( diff --git a/armi/operators/tests/test_inspectors.py b/armi/operators/tests/test_inspectors.py index 9e6d175b4..3645bf0a1 100644 --- a/armi/operators/tests/test_inspectors.py +++ b/armi/operators/tests/test_inspectors.py @@ -66,6 +66,10 @@ def test_overwriteSettingsCorrectiveQuery(self): """ Tests the case where a corrective query is resolved. Checks to make sure the settings file is overwritten with the resolved setting. + + .. test:: Settings have validation and correction tools. + :id: T_ARMI_SETTINGS_RULES0 + :tests: R_ARMI_SETTINGS_RULES """ # load settings from test settings file self.cs["cycleLength"] = 300.0 diff --git a/armi/operators/tests/test_operators.py b/armi/operators/tests/test_operators.py index 55c795e8b..581a2a923 100644 --- a/armi/operators/tests/test_operators.py +++ b/armi/operators/tests/test_operators.py @@ -13,30 +13,34 @@ # limitations under the License. """Tests for operators.""" -import os -import unittest from unittest.mock import patch import collections +import io +import os +import sys +import unittest from armi import settings +from armi.bookkeeping.db.databaseInterface import DatabaseInterface from armi.interfaces import Interface, TightCoupler from armi.operators.operator import Operator -from armi.reactor.tests import test_reactors -from armi.settings.caseSettings import Settings -from armi.utils.directoryChangers import TemporaryDirectoryChanger from armi.physics.neutronics.globalFlux.globalFluxInterface import ( GlobalFluxInterfaceUsingExecuters, ) -from armi.utils import directoryChangers -from armi.bookkeeping.db.databaseInterface import DatabaseInterface -from armi.tests import mockRunLogs from armi.reactor.reactors import Reactor, Core +from armi.reactor.tests import test_reactors +from armi.settings.caseSettings import Settings from armi.settings.fwSettings.globalSettings import ( CONF_RUN_TYPE, CONF_TIGHT_COUPLING, CONF_CYCLES_SKIP_TIGHT_COUPLING_INTERACTION, CONF_TIGHT_COUPLING_SETTINGS, + CONF_DEFERRED_INTERFACE_NAMES, + CONF_DEFERRED_INTERFACES_CYCLE, ) +from armi.tests import mockRunLogs +from armi.utils import directoryChangers +from armi.utils.directoryChangers import TemporaryDirectoryChanger class InterfaceA(Interface): @@ -56,12 +60,84 @@ class InterfaceC(Interface): name = "Third" -# TODO: Add a test that shows time evolution of Reactor (R_EVOLVING_STATE) class OperatorTests(unittest.TestCase): def setUp(self): self.o, self.r = test_reactors.loadTestReactor() self.activeInterfaces = [ii for ii in self.o.interfaces if ii.enabled()] + def test_operatorData(self): + """Test that the operator has input data, a reactor model. + + .. test:: The Operator includes input data and the reactor data model. + :id: T_ARMI_OPERATOR_COMM + :tests: R_ARMI_OPERATOR_COMM + """ + self.assertEqual(self.o.r, self.r) + self.assertEqual(type(self.o.cs), settings.Settings) + + @patch("armi.operators.Operator._interactAll") + def test_orderedInterfaces(self, interactAll): + """Test the default interfaces are in an ordered list, looped over at each time step. + + .. test:: An ordered list of interfaces are run at each time step. + :id: T_ARMI_OPERATOR_INTERFACES + :tests: R_ARMI_OPERATOR_INTERFACES + + .. test:: Interfaces are run at BOC, EOC, and at time points between. + :id: T_ARMI_INTERFACE + :tests: R_ARMI_INTERFACE + + .. test:: When users set the time discretization, it is enforced. + :id: T_ARMI_FW_HISTORY2 + :tests: R_ARMI_FW_HISTORY + """ + # an ordered list of interfaces + self.assertGreater(len(self.o.interfaces), 0) + for i in self.o.interfaces: + self.assertTrue(isinstance(i, Interface)) + + # make sure we only iterate one time step + self.o.cs = self.o.cs.modified(newSettings={"nCycles": 2}) + self.r.p.cycle = 1 + + # mock some stdout logging of what's happening when + def sideEffect(node, activeInts, *args, **kwargs): + print(node) + print(activeInts) + + interactAll.side_effect = sideEffect + + # run the operator through one cycle + origout = sys.stdout + try: + out = io.StringIO() + sys.stdout = out + self.o.operate() + finally: + sys.stdout = origout + + # grab the log data + log = out.getvalue() + + # verify we have some common interfaces listed + self.assertIn("main", log) + self.assertIn("fuelHandler", log) + self.assertIn("fissionProducts", log) + self.assertIn("history", log) + self.assertIn("snapshot", log) + + # At the first time step, we get one ordered list of interfaces + interfaces = log.split("BOL")[1].split("EOL")[0].split(",") + self.assertGreater(len(interfaces), 0) + for i in interfaces: + self.assertIn("Interface", i) + + # verify the various time nodes are hit in order + timeNodes = ["BOL", "BOC"] + ["EveryNode"] * 3 + ["EOC", "EOL"] + for node in timeNodes: + self.assertIn(node, log) + log = node.join(log.split(node)[1:]) + def test_addInterfaceSubclassCollision(self): cs = settings.Settings() @@ -97,6 +173,45 @@ def test_interfaceIsActive(self): self.assertTrue(self.o.interfaceIsActive("main")) self.assertFalse(self.o.interfaceIsActive("Fake-o")) + def test_getActiveInterfaces(self): + """Ensure that the right interfaces are returned for a given interaction state.""" + self.o.cs[CONF_DEFERRED_INTERFACES_CYCLE] = 1 + self.o.cs[CONF_DEFERRED_INTERFACE_NAMES] = ["history"] + + # Test invalid inputs. + with self.assertRaises(ValueError): + self.o.getActiveInterfaces("notAnInterface") + + # Test BOL + interfaces = self.o.getActiveInterfaces( + "BOL", excludedInterfaceNames=("xsGroups") + ) + interfaceNames = [interface.name for interface in interfaces] + self.assertNotIn("xsGroups", interfaceNames) + self.assertNotIn("history", interfaceNames) + + # Test BOC + interfaces = self.o.getActiveInterfaces("BOC", cycle=0) + interfaceNames = [interface.name for interface in interfaces] + self.assertNotIn("history", interfaceNames) + + # Test EveryNode and EOC + interfaces = self.o.getActiveInterfaces( + "EveryNode", excludedInterfaceNames=("xsGroups") + ) + interfaceNames = [interface.name for interface in interfaces] + self.assertIn("history", interfaceNames) + self.assertNotIn("xsGroups", interfaceNames) + + # Test EOL + interfaces = self.o.getActiveInterfaces("EOL") + self.assertEqual(interfaces[-1].name, "main") + + # Test Coupled + interfaces = self.o.getActiveInterfaces("Coupled") + for test, ref in zip(interfaces, self.activeInterfaces): + self.assertEqual(test.name, ref.name) + def test_loadStateError(self): """The ``loadTestReactor()`` test tool does not have any history in the DB to load from.""" # a first, simple test that this method fails correctly @@ -156,6 +271,34 @@ def test_snapshotRequest(self, fakeDirList, fakeCopy): self.assertTrue(os.path.exists("snapShot0_2")) +class TestCreateOperator(unittest.TestCase): + def test_createOperator(self): + """Test that an operator can be created from settings. + + .. test:: Create an operator from settings. + :id: T_ARMI_OPERATOR_SETTINGS + :tests: R_ARMI_OPERATOR_SETTINGS + """ + cs = settings.Settings() + o = Operator(cs) + # high-level items + self.assertTrue(isinstance(o, Operator)) + self.assertTrue(isinstance(o.cs, settings.Settings)) + + # validate some more nitty-gritty operator details come from settings + burnStepsSetting = cs["burnSteps"] + if type(burnStepsSetting) != list: + burnStepsSetting = [burnStepsSetting] + self.assertEqual(o.burnSteps, burnStepsSetting) + self.assertEqual(o.maxBurnSteps, max(burnStepsSetting)) + + powerFracsSetting = cs["powerFractions"] + if powerFracsSetting: + self.assertEqual(o.powerFractions, powerFracsSetting) + else: + self.assertEqual(o.powerFractions, [[1] * cs["burnSteps"]]) + + class TestTightCoupling(unittest.TestCase): def setUp(self): self.cs = settings.Settings() @@ -164,6 +307,20 @@ def setUp(self): self.o.r = Reactor("empty", None) self.o.r.core = Core("empty") + def test_getStepLengths(self): + """Test the step lengths are correctly calculated, based on settings. + + .. test:: Users can control time discretization of the simulation through settings. + :id: T_ARMI_FW_HISTORY0 + :tests: R_ARMI_FW_HISTORY + """ + self.assertEqual(self.cs["nCycles"], 1) + self.assertAlmostEqual(self.cs["cycleLength"], 365.242199) + self.assertEqual(self.cs["burnSteps"], 4) + + self.assertEqual(len(self.o.stepLengths), 1) + self.assertEqual(len(self.o.stepLengths[0]), 4) + def test_couplingIsActive(self): """Ensure that ``cs[CONF_TIGHT_COUPLING]`` controls ``couplingIsActive``.""" self.assertTrue(self.o.couplingIsActive()) @@ -185,7 +342,12 @@ def test_performTightCoupling_skip(self): self.assertEqual(self.o.r.core.p.coupledIteration, 0) def test_performTightCoupling_notConverged(self): - """Ensure that the appropriate ``runLog.warning`` is addressed in tight coupling reaches max num of iters.""" + """Ensure that the appropriate ``runLog.warning`` is addressed in tight coupling reaches max num of iters. + + .. test:: The tight coupling logic can fail if there is no convergence. + :id: T_ARMI_OPERATOR_PHYSICS0 + :tests: R_ARMI_OPERATOR_PHYSICS + """ class NoConverge(TightCoupler): def isConverged(self, _val: TightCoupler._SUPPORTED_TYPES) -> bool: @@ -317,6 +479,12 @@ def setUp(self): self.detailedOperator = Operator(self.standaloneDetailedCS) def test_getPowerFractions(self): + """Test that the power fractions are calculated correctly. + + .. test:: Test the powerFractions are retrieved correctly for multiple cycles. + :id: T_ARMI_SETTINGS_POWER1 + :tests: R_ARMI_SETTINGS_POWER + """ self.assertEqual( self.detailedOperator.powerFractions, self.powerFractionsSolution ) @@ -345,11 +513,25 @@ def test_getAvailabilityFactors(self): ) def test_getStepLengths(self): - self.assertEqual(self.detailedOperator.stepLengths, self.stepLengthsSolution) + """Test that the manually-set, detailed time steps are retrievable. + .. test:: Users can manually control time discretization of the simulation. + :id: T_ARMI_FW_HISTORY1 + :tests: R_ARMI_FW_HISTORY + """ + # detailed step lengths can be set manually + self.assertEqual(self.detailedOperator.stepLengths, self.stepLengthsSolution) self.detailedOperator._stepLength = None self.assertEqual(self.detailedOperator.stepLengths, self.stepLengthsSolution) + # when doing detailed step information, we don't get step information from settings + cs = self.detailedOperator.cs + self.assertEqual(cs["nCycles"], 3) + with self.assertRaises(ValueError): + cs["cycleLength"] + with self.assertRaises(ValueError): + cs["burnSteps"] + def test_getCycleLengths(self): self.assertEqual(self.detailedOperator.cycleLengths, self.cycleLengthsSolution) diff --git a/armi/physics/constants.py b/armi/physics/constants.py index 6e26747ff..30ee6db73 100644 --- a/armi/physics/constants.py +++ b/armi/physics/constants.py @@ -22,6 +22,8 @@ Notes ----- This data structure can be updated by plugins with design-specific dpa data. + +:meta hide-value: """ # The following are multigroup DPA XS for EBR II. They were generated using an ultra hard MCC spectrum diff --git a/armi/physics/executers.py b/armi/physics/executers.py index e9e755c74..d99c11c6d 100644 --- a/armi/physics/executers.py +++ b/armi/physics/executers.py @@ -29,6 +29,23 @@ class ExecutionOptions: """ A data structure representing all options needed for a physics kernel. + .. impl:: Options for executing external calculations. + :id: I_ARMI_EX0 + :implements: R_ARMI_EX + + Implements a basic container to hold and report options to be used in + the execution of an external code (see :need:`I_ARMI_EX1`). + Options are stored as instance attibutes and can be dumped as a string + using :py:meth:`~armi.physics.executers.ExecutionOptions.describe`, which + will include the name and value of all public attributes of the instance. + + Also facilitates the ability to execute parallel instances of a code by + providing the ability to resolve a ``runDir`` that is aware of the + executing MPI rank. This is done via :py:meth:`~armi.physics.executers.ExecutionOptions.setRunDirFromCaseTitle`, + where the user passes in a ``caseTitle`` string, which is hashed and combined + with the MPI rank to provide a unique directory name to be used by each parallel + instance. + Attributes ---------- inputFile : str @@ -85,7 +102,7 @@ def __repr__(self): return f"<{self.__class__.__name__}: {self.label}>" def fromUserSettings(self, cs): - """Set options from a particular CaseSettings object.""" + """Set options from a particular Settings object.""" raise NotImplementedError() def fromReactor(self, reactor): @@ -166,6 +183,26 @@ class DefaultExecuter(Executer): * Clean up run directory * Un-apply geometry transformations as needed * Update ARMI data model as desired + + .. impl:: Default tool for executing external calculations. + :id: I_ARMI_EX1 + :implements: R_ARMI_EX + + Facilitates the execution of external calculations by accepting ``options`` (an + :py:class:`~armi.physics.executers.ExecutionOptions` object) and providing + methods that build run directories and execute a code based on the values in + ``options``. + + The :py:meth:`~armi.physics.executers.DefaultExecuter.run` method will first + resolve any derived options in the ``options`` object and check if the specified + ``executablePath`` option is valid, raising an error if not. If it is, + preparation work for executing the code is performed, such as performing any geometry + transformations specified in subclasses or building the directories needed + to save input and output files. Once the temporary working directory is created, + the executer moves into it and runs the external code, applying any results + from the run as specified in subclasses. + + Finally, any geometry perturbations that were performed are undone. """ def run(self): diff --git a/armi/physics/fuelCycle/__init__.py b/armi/physics/fuelCycle/__init__.py index 12a55a6b5..95f4e96d8 100644 --- a/armi/physics/fuelCycle/__init__.py +++ b/armi/physics/fuelCycle/__init__.py @@ -71,6 +71,7 @@ def exposeInterfaces(cs): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" return settings.getFuelCycleSettings() @staticmethod diff --git a/armi/physics/fuelCycle/fuelHandlerInterface.py b/armi/physics/fuelCycle/fuelHandlerInterface.py index aa7098bbd..79637c320 100644 --- a/armi/physics/fuelCycle/fuelHandlerInterface.py +++ b/armi/physics/fuelCycle/fuelHandlerInterface.py @@ -31,6 +31,28 @@ class FuelHandlerInterface(interfaces.Interface): power or temperatures have been updated. This allows pre-run fuel management steps for highly customized fuel loadings. In typical runs, no fuel management occurs at the beginning of the first cycle and the as-input state is left as is. + + .. impl:: ARMI provides a shuffle logic interface. + :id: I_ARMI_SHUFFLE + :implements: R_ARMI_SHUFFLE + + This interface allows for a user to define custom shuffle logic that + modifies to the core model. Being based on the :py:class:`~armi.interfaces.Interface` + class, it has direct access to the current core model. + + User logic is able to be executed from within the + :py:meth:`~armi.physics.fuelCycle.fuelHandlerInterface.FuelHandlerInterface.manageFuel` method, + which will use the :py:meth:`~armi.physics.fuelCycle.fuelHandlerFactory.fuelHandlerFactory` + to search for a Python file specified by the case setting ``shuffleLogic``. + If it exists, the fuel handler with name specified by the user via the ``fuelHandlerName`` + case setting will be imported, and any actions in its ``outage`` method + will be executed at the :py:meth:`~armi.physics.fuelCycle.fuelHandlerInterface.FuelHandlerInterface.interactBOC` + hook. + + If no class with the name specified by the ``fuelHandlerName`` setting is found + in the file with path ``shuffleLogic``, an error is returned. + + See the user manual for how the custom shuffle logic file should be constructed. """ name = "fuelHandler" diff --git a/armi/physics/fuelCycle/fuelHandlers.py b/armi/physics/fuelCycle/fuelHandlers.py index 2c05fa9d4..e4f8f2986 100644 --- a/armi/physics/fuelCycle/fuelHandlers.py +++ b/armi/physics/fuelCycle/fuelHandlers.py @@ -124,7 +124,10 @@ def outage(self, factor=1.0): # The user can choose the algorithm method name directly in the settings if hasattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG]): rotationMethod = getattr(rotAlgos, self.cs[CONF_ASSEMBLY_ROTATION_ALG]) - rotationMethod() + try: + rotationMethod() + except TypeError: + rotationMethod(self) else: raise RuntimeError( "FuelHandler {0} does not have a rotation algorithm called {1}.\n" @@ -368,14 +371,15 @@ def findAssembly( Examples -------- - feed = self.findAssembly(targetRing=4, - width=(0,0), - param='maxPercentBu', - compareTo=100, - typeSpec=Flags.FEED | Flags.FUEL) - - returns the feed fuel assembly in ring 4 that has a burnup closest to 100% (the highest - burnup assembly) + This returns the feed fuel assembly in ring 4 that has a burnup closest to 100% + (the highest burnup assembly):: + + feed = self.findAssembly(targetRing=4, + width=(0,0), + param='maxPercentBu', + compareTo=100, + typeSpec=Flags.FEED | Flags.FUEL) + """ def compareAssem(candidate, current): @@ -541,17 +545,9 @@ def getParamWithBlockLevelMax(a, paramName): # this assembly is in the excluded location list. skip it. continue - # only continue of the Assembly is in a Zone - if zoneList: - found = False # guilty until proven innocent - for zone in zoneList: - if a.getLocation() in zone: - # great! it's in there, so we'll accept this assembly - found = True # innocent - break - if not found: - # this assembly is not in any of the zones in the zone list. skip it. - continue + # only process of the Assembly is in a Zone + if not self.isAssemblyInAZone(zoneList, a): + continue # Now find the assembly with the param closest to the target val. if param: @@ -618,6 +614,21 @@ def getParamWithBlockLevelMax(a, paramName): else: return minDiff[1] + @staticmethod + def isAssemblyInAZone(zoneList, a): + """Does the given assembly in one of these zones.""" + if zoneList: + # ruff: noqa: SIM110 + for zone in zoneList: + if a.getLocation() in zone: + # Success! + return True + + return False + else: + # A little counter-intuitively, if there are no zones, we return True. + return True + def _getAssembliesInRings( self, ringList, @@ -626,7 +637,7 @@ def _getAssembliesInRings( exclusions=None, circularRingFlag=False, ): - r""" + """ find assemblies in particular rings. Parameters @@ -711,14 +722,42 @@ def _getAssembliesInRings( return assemblyList def swapAssemblies(self, a1, a2): - r""" - Moves a whole assembly from one place to another. + """Moves a whole assembly from one place to another. + + .. impl:: Assemblies can be moved from one place to another. + :id: I_ARMI_SHUFFLE_MOVE + :implements: R_ARMI_SHUFFLE_MOVE + + For the two assemblies that are passed in, call to their :py:meth:`~armi.reactor.assemblies.Assembly.moveTo` + methods to transfer their underlying ``spatialLocator`` attributes to + each other. This will also update the ``childrenByLocator`` list on the + core as well as the assembly parameters ``numMoves`` and ``daysSinceLastMove``. + + .. impl:: User-specified blocks can be left in place during within-core swaps. + :id: I_ARMI_SHUFFLE_STATIONARY0 + :implements: R_ARMI_SHUFFLE_STATIONARY + + Before assemblies are moved, + the ``_transferStationaryBlocks`` class method is called to + check if there are any block types specified by the user as stationary + via the ``stationaryBlockFlags`` case setting. Using these flags, blocks + are gathered from each assembly which should remain stationary and + checked to make sure that both assemblies have the same number + and same height of stationary blocks. If not, return an error. + + If all checks pass, the :py:meth:`~armi.reactor.assemblies.Assembly.remove` + and :py:meth:`~armi.reactor.assemblies.Assembly.insert` + methods are used to swap the stationary blocks between the two assemblies. + + Once this process is complete, the actual assembly movement can take + place. Through this process, the stationary blocks remain in the same + core location. Parameters ---------- - a1 : Assembly + a1 : :py:class:`Assembly <armi.reactor.assemblies.Assembly>` The first assembly - a2 : Assembly + a2 : :py:class:`Assembly <armi.reactor.assemblies.Assembly>` The second assembly See Also @@ -800,8 +839,33 @@ def _transferStationaryBlocks(self, assembly1, assembly2): assembly2.insert(assem2BlockIndex, assem1Block) def dischargeSwap(self, incoming, outgoing): - r""" - Removes one assembly from the core and replace it with another assembly. + """Removes one assembly from the core and replace it with another assembly. + + .. impl:: User-specified blocks can be left in place for the discharge swap. + :id: I_ARMI_SHUFFLE_STATIONARY1 + :implements: R_ARMI_SHUFFLE_STATIONARY + + Before assemblies are moved, the ``_transferStationaryBlocks`` class method is called to + check if there are any block types specified by the user as stationary via the + ``stationaryBlockFlags`` case setting. Using these flags, blocks are gathered from each + assembly which should remain stationary and checked to make sure that both assemblies + have the same number and same height of stationary blocks. If not, return an error. + + If all checks pass, the :py:meth:`~armi.reactor.assemblies.Assembly.remove` and + :py:meth:`~armi.reactor.assemblies.Assembly.insert`` methods are used to swap the + stationary blocks between the two assemblies. + + Once this process is complete, the actual assembly movement can take place. Through this + process, the stationary blocks from the outgoing assembly remain in the original core + position, while the stationary blocks from the incoming assembly are discharged with the + outgoing assembly. + + Parameters + ---------- + incoming : :py:class:`Assembly <armi.reactor.assemblies.Assembly>` + The assembly getting swapped into the core. + outgoing : :py:class:`Assembly <armi.reactor.assemblies.Assembly>` + The assembly getting discharged out the core. See Also -------- @@ -971,7 +1035,10 @@ def readMoves(fname): elif "assembly" in line: # this is the new load style where an actual assembly type is written to the shuffle logic # due to legacy reasons, the assembly type will be put into group 4 - pat = r"([A-Za-z0-9!\-]+) moved to ([A-Za-z0-9!\-]+) with assembly type ([A-Za-z0-9!\s]+)\s*(ANAME=\S+)?\s*with enrich list: (.+)" + pat = ( + r"([A-Za-z0-9!\-]+) moved to ([A-Za-z0-9!\-]+) with assembly type " + + r"([A-Za-z0-9!\s]+)\s*(ANAME=\S+)?\s*with enrich list: (.+)" + ) m = re.search(pat, line) if not m: raise InputError( diff --git a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py index ccf7929ca..c750717a1 100644 --- a/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py +++ b/armi/physics/fuelCycle/tests/test_assemblyRotationAlgorithms.py @@ -49,6 +49,7 @@ def test_buReducingAssemblyRotation(self): self.assertNotEqual(b.getRotationNum(), rotNum) def test_simpleAssemblyRotation(self): + """Test rotating assemblies 120 degrees.""" fh = fuelHandlers.FuelHandler(self.o) newSettings = {CONF_ASSEM_ROTATION_STATIONARY: True} self.o.cs = self.o.cs.modified(newSettings=newSettings) diff --git a/armi/physics/fuelCycle/tests/test_fuelHandlers.py b/armi/physics/fuelCycle/tests/test_fuelHandlers.py index 764ef22cb..c905d28d7 100644 --- a/armi/physics/fuelCycle/tests/test_fuelHandlers.py +++ b/armi/physics/fuelCycle/tests/test_fuelHandlers.py @@ -19,13 +19,16 @@ """ import collections import copy +import os import unittest +from unittest.mock import patch import numpy as np from armi.physics.fuelCycle import fuelHandlers, settings from armi.physics.fuelCycle.settings import ( CONF_ASSEM_ROTATION_STATIONARY, + CONF_ASSEMBLY_ROTATION_ALG, CONF_PLOT_SHUFFLE_ARROWS, CONF_RUN_LATTICE_BEFORE_SHUFFLING, ) @@ -36,6 +39,7 @@ from armi.reactor import assemblies, blocks, components, grids from armi.reactor.flags import Flags from armi.reactor.tests import test_reactors +from armi.reactor.zones import Zone from armi.settings import caseSettings from armi.tests import ArmiTestHelper from armi.tests import mockRunLogs @@ -177,6 +181,53 @@ def test_findHighBu(self): ) self.assertIs(a, a1) + @patch("armi.physics.fuelCycle.fuelHandlers.FuelHandler.chooseSwaps") + def test_outage(self, mockChooseSwaps): + # mock up a fuel handler + fh = fuelHandlers.FuelHandler(self.o) + mockChooseSwaps.return_value = list(self.r.core.getAssemblies()) + + # edge case: cannot perform two outages on the same FuelHandler + fh.moved = [self.r.core.getFirstAssembly()] + with self.assertRaises(ValueError): + fh.outage(factor=1.0) + + # edge case: fail if the shuffle file is missing + fh.moved = [] + self.o.cs = self.o.cs.modified( + newSettings={"explicitRepeatShuffles": "fakePath"} + ) + with self.assertRaises(RuntimeError): + fh.outage(factor=1.0) + + # a successful run + fh.moved = [] + self.o.cs = self.o.cs.modified( + newSettings={ + "explicitRepeatShuffles": "", + "fluxRecon": True, + CONF_ASSEMBLY_ROTATION_ALG: "simpleAssemblyRotation", + } + ) + fh.outage(factor=1.0) + self.assertEqual(len(fh.moved), 0) + + def test_isAssemblyInAZone(self): + # build a fuel handler + fh = fuelHandlers.FuelHandler(self.o) + + # test the default value if there are no zones + a = self.r.core.getFirstAssembly() + self.assertTrue(fh.isAssemblyInAZone(None, a)) + + # If our assembly isn't in one of the supplied zones + z = Zone("test_isAssemblyInAZone") + self.assertFalse(fh.isAssemblyInAZone([z], a)) + + # If our assembly IS in one of the supplied zones + z.addLoc(a.getLocation()) + self.assertTrue(fh.isAssemblyInAZone([z], a)) + def test_width(self): """Tests the width capability of findAssembly.""" fh = fuelHandlers.FuelHandler(self.o) @@ -385,14 +436,22 @@ def runShuffling(self, fh): fh.interactEOL() def test_repeatShuffles(self): - """ - Builds a dummy core. Does some shuffles. Repeats the shuffles. Checks that it was a perfect repeat. - - Checks some other things in the meantime - - See Also - -------- - runShuffling : creates the shuffling file to be read in. + """Loads the ARMI test reactor with a custom shuffle logic file and shuffles assemblies twice. + + Notes + ----- + The custom shuffle logic is executed by :py:meth:`armi.physics.fuelCycle.fuelHandlerInterface.FuelHandlerInterface.manageFuel` + within :py:meth:`armi.physics.fuelCycle.tests.test_fuelHandlers.TestFuelHandler.runShuffling`. There are + two primary assertions: spent fuel pool assemblies are in the correct location and the assemblies were shuffled + into their correct locations. This process is repeated twice to ensure repeatability. + + .. test:: Execute user-defined shuffle operations based on a reactor model. + :id: T_ARMI_SHUFFLE + :tests: R_ARMI_SHUFFLE + + .. test:: Move an assembly from one position in the core to another. + :id: T_ARMI_SHUFFLE_MOVE0 + :tests: R_ARMI_SHUFFLE_MOVE """ # check labels before shuffling: for a in self.r.sfp.getChildren(): @@ -432,6 +491,13 @@ def test_repeatShuffles(self): for a in self.r.sfp.getChildren(): self.assertEqual(a.getLocation(), "SFP") + # Do some cleanup, since the fuelHandler Interface has code that gets + # around the TempDirectoryChanger + os.remove("armiRun2-SHUFFLES.txt") + os.remove("armiRun2.shuffles_0.png") + os.remove("armiRun2.shuffles_1.png") + os.remove("armiRun2.shuffles_2.png") + def test_readMoves(self): """ Depends on the ``shuffleLogic`` created by ``repeatShuffles``. @@ -457,6 +523,10 @@ def test_readMoves(self): self.assertEqual(sfpMove[1], "005-003") self.assertEqual(sfpMove[4], "A0073") # name of assem in SFP + # make sure we fail hard if the file doesn't exist + with self.assertRaises(RuntimeError): + fh.readMoves("totall_fictional_file.txt") + def test_processMoveList(self): fh = fuelHandlers.FuelHandler(self.o) moves = fh.readMoves("armiRun-SHUFFLES.txt") @@ -522,7 +592,12 @@ def test_linPowByPinGamma(self): self.assertEqual(type(b.p.linPowByPinGamma), np.ndarray) def test_transferStationaryBlocks(self): - """Test the _transferStationaryBlocks method.""" + """Test the _transferStationaryBlocks method. + + .. test:: User-specified blocks can remain in place during shuffling + :id: T_ARMI_SHUFFLE_STATIONARY0 + :tests: R_ARMI_SHUFFLE_STATIONARY + """ # grab stationary block flags sBFList = self.r.core.stationaryBlockFlagsList @@ -679,7 +754,16 @@ def test_transferIncompatibleHeightStationaryBlocks(self): self.assertIn("top elevation of stationary", mock.getStdout()) def test_dischargeSwap(self): - """Test the dischargeSwap method.""" + """Remove an assembly from the core and replace it with one from the SFP. + + .. test:: Move an assembly from one position in the core to another. + :id: T_ARMI_SHUFFLE_MOVE1 + :tests: R_ARMI_SHUFFLE_MOVE + + .. test:: User-specified blocks can remain in place during shuffling + :id: T_ARMI_SHUFFLE_STATIONARY1 + :tests: R_ARMI_SHUFFLE_STATIONARY + """ # grab stationary block flags sBFList = self.r.core.stationaryBlockFlagsList diff --git a/armi/physics/fuelPerformance/plugin.py b/armi/physics/fuelPerformance/plugin.py index 4753200e3..9e82f7f50 100644 --- a/armi/physics/fuelPerformance/plugin.py +++ b/armi/physics/fuelPerformance/plugin.py @@ -46,6 +46,7 @@ def defineSettingsValidators(inspector): @staticmethod @plugins.HOOKIMPL def defineParameters(): + """Define parameters for the plugin.""" from armi.physics.fuelPerformance import parameters return parameters.getFuelPerformanceParameterDefinitions() diff --git a/armi/physics/neutronics/__init__.py b/armi/physics/neutronics/__init__.py index bc5ac4e27..463166c1a 100644 --- a/armi/physics/neutronics/__init__.py +++ b/armi/physics/neutronics/__init__.py @@ -60,6 +60,7 @@ def exposeInterfaces(cs): @staticmethod @plugins.HOOKIMPL def defineParameters(): + """Define parameters for the plugin.""" from armi.physics.neutronics import parameters as neutronicsParameters return neutronicsParameters.getNeutronicsParameterDefinitions() @@ -67,6 +68,7 @@ def defineParameters(): @staticmethod @plugins.HOOKIMPL def defineEntryPoints(): + """Define entry points for the plugin.""" from armi.physics.neutronics import diffIsotxs entryPoints = [diffIsotxs.CompareIsotxsLibraries] @@ -76,6 +78,7 @@ def defineEntryPoints(): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" from armi.physics.neutronics import settings as neutronicsSettings from armi.physics.neutronics import crossSectionSettings from armi.physics.neutronics.fissionProductModel import ( @@ -108,6 +111,7 @@ def defineSettingsValidators(inspector): @staticmethod @plugins.HOOKIMPL def onProcessCoreLoading(core, cs, dbLoad): + """Called whenever a Core object is newly built.""" applyEffectiveDelayedNeutronFractionToCore(core, cs) @staticmethod diff --git a/armi/physics/neutronics/crossSectionGroupManager.py b/armi/physics/neutronics/crossSectionGroupManager.py index b9ee666a5..f93d24cd7 100644 --- a/armi/physics/neutronics/crossSectionGroupManager.py +++ b/armi/physics/neutronics/crossSectionGroupManager.py @@ -315,6 +315,18 @@ class AverageBlockCollection(BlockCollection): Averages number densities, fission product yields, and fission gas removal fractions. + + .. impl:: Create representative blocks using volume-weighted averaging. + :id: I_ARMI_XSGM_CREATE_REPR_BLOCKS0 + :implements: R_ARMI_XSGM_CREATE_REPR_BLOCKS + + This class constructs new blocks from an existing block list based on a + volume-weighted average. Inheriting functionality from the abstract + :py:class:`Reactor <armi.physics.neutronics.crossSectionGroupManager.BlockCollection>` object, this class + will construct representative blocks using averaged parameters of all blocks in the given collection. + Number density averages can be computed at a component level + or at a block level by default. Average nuclide temperatures and burnup are also included when constructing a representative block. + """ def _makeRepresentativeBlock(self): @@ -391,7 +403,7 @@ def _getAverageComponentNumberDensities(self, compIndex): def _getAverageComponentTemperature(self, compIndex): """ - Get weighted average component temperature for the collection + Get weighted average component temperature for the collection. Notes ----- @@ -426,7 +438,7 @@ def _getAverageComponentTemperature(self, compIndex): def _performAverageByComponent(self): """ - Check if block collection averaging can/should be performed by component + Check if block collection averaging can/should be performed by component. If the components of blocks in the collection are similar and the user has requested component-level averaging, return True. @@ -439,7 +451,7 @@ def _performAverageByComponent(self): def _checkBlockSimilarity(self): """ - Check if blocks in the collection have similar components + Check if blocks in the collection have similar components. If the components of blocks in the collection are similar and the user has requested component-level averaging, return True. @@ -474,9 +486,7 @@ def getBlockNuclideTemperatureAvgTerms(block, allNucNames): """ def getNumberDensitiesWithTrace(component, allNucNames): - """ - Needed to make sure temperature of 0-density nuclides in fuel get fuel temperature - """ + """Needed to make sure temperature of 0-density nuclides in fuel get fuel temperature.""" return [ component.p.numberDensities[nucName] or TRACE_NUMBER_DENSITY if nucName in component.p.numberDensities @@ -506,6 +516,17 @@ class CylindricalComponentsAverageBlockCollection(BlockCollection): Creates a representative block for the purpose of cross section generation with a one-dimensional cylindrical model. + .. impl:: Create representative blocks using custom cylindrical averaging. + :id: I_ARMI_XSGM_CREATE_REPR_BLOCKS1 + :implements: R_ARMI_XSGM_CREATE_REPR_BLOCKS + + This class constructs representative blocks based on a volume-weighted average + using cylindrical blocks from an existing block list. Inheriting functionality from the abstract + :py:class:`Reactor <armi.physics.neutronics.crossSectionGroupManager.BlockCollection>` object, this class + will construct representative blocks using averaged parameters of all blocks in the given collection. + Number density averages are computed at a component level. Nuclide temperatures from a median block-average temperature + are used and the average burnup is evaluated across all blocks in the block list. + Notes ----- When generating the representative block within this collection, the geometry is checked @@ -832,6 +853,21 @@ def __init__(self, r, cs): self._unrepresentedXSIDs = [] def interactBOL(self): + """Called at the Beginning-of-Life of a run, before any cycles start. + + .. impl:: The lattice physics interface and cross-section group manager are connected at + BOL. + :id: I_ARMI_XSGM_FREQ0 + :implements: R_ARMI_XSGM_FREQ + + This method sets the cross-section block averaging method and and logic for whether all + blocks in a cross section group should be used when generating a representative block. + Furthermore, if the control logic for lattice physics frequency updates is set at + beginning-of-life (`BOL`) through the :py:class:`LatticePhysicsInterface + <armi.physics.neutronics.latticePhysics>`, the cross-section group manager will + construct representative blocks for each cross-section IDs at the beginning of the + reactor state. + """ # now that all cs settings are loaded, apply defaults to compound XS settings from armi.physics.neutronics.settings import CONF_XS_BLOCK_REPRESENTATION from armi.physics.neutronics.settings import ( @@ -853,6 +889,18 @@ def interactBOC(self, cycle=None): """ Update representative blocks and block burnup groups. + .. impl:: The lattice physics interface and cross-section group manager are connected at + BOC. + :id: I_ARMI_XSGM_FREQ1 + :implements: R_ARMI_XSGM_FREQ + + This method updates representative blocks and block burnups at the beginning-of-cycle + for each cross-section ID if the control logic for lattice physics frequency updates is + set at beginning-of-cycle (`BOC`) through the :py:class:`LatticePhysicsInterface + <armi.physics.neutronics.latticePhysics>`. At the beginning-of-cycle, the cross-section + group manager will construct representative blocks for each cross-section IDs for the + current reactor state. + Notes ----- The block list each each block collection cannot be emptied since it is used to derive nuclide temperatures. @@ -861,26 +909,54 @@ def interactBOC(self, cycle=None): self.createRepresentativeBlocks() def interactEOC(self, cycle=None): - """ - EOC interaction. + """EOC interaction. Clear out big dictionary of all blocks to avoid memory issues and out-of-date representers. """ self.clearRepresentativeBlocks() def interactEveryNode(self, cycle=None, tn=None): + """Interaction at every time node. + + .. impl:: The lattice physics interface and cross-section group manager are connected at + every time node. + :id: I_ARMI_XSGM_FREQ2 + :implements: R_ARMI_XSGM_FREQ + + This method updates representative blocks and block burnups at every node for each + cross-section ID if the control logic for lattices physics frequency updates is set for + every node (`everyNode`) through the :py:class:`LatticePhysicsInterface + <armi.physics.neutronics.latticePhysics>`. At every node, the cross-section group + manager will construct representative blocks for each cross-section ID in the current + reactor state. + """ if self._latticePhysicsFrequency >= LatticePhysicsFrequency.everyNode: self.createRepresentativeBlocks() def interactCoupled(self, iteration): - """Update XS groups on each physics coupling iteration to get latest temperatures. + """Update cross-section groups on each physics coupling iteration to get latest + temperatures. + + .. impl:: The lattice physics interface and cross-section group manager are connected + during coupling. + :id: I_ARMI_XSGM_FREQ3 + :implements: R_ARMI_XSGM_FREQ + + This method updates representative blocks and block burnups at every node and the first + coupled iteration for each cross-section ID if the control logic for lattices physics + frequency updates is set for the first coupled iteration (``firstCoupledIteration``) + through the + :py:class:`LatticePhysicsInterface <armi.physics.neutronics.latticePhysics>`. + The cross-section group manager will construct representative blocks for each + cross-section ID at the first iteration of every time node. Notes ----- - Updating the XS on only the first (i.e., iteration == 0) timenode can be a reasonable approximation to - get new cross sections with some temperature updates but not have to run lattice physics on each - coupled iteration. If the user desires to have the cross sections updated with every coupling iteration, - the ``latticePhysicsFrequency: all`` option. + Updating the cross-section on only the first (i.e., iteration == 0) timenode can be a + reasonable approximation to get new cross sections with some temperature updates but not + have to run lattice physics on each coupled iteration. If the user desires to have the + cross sections updated with every coupling iteration, the ``latticePhysicsFrequency: all`` + option. See Also -------- @@ -1052,7 +1128,17 @@ def _getPregeneratedFluxFileLocationData(self, xsID): return (filePath, fileName) def createRepresentativeBlocks(self): - """Get a representative block from each cross section ID managed here.""" + """Get a representative block from each cross-section ID managed here. + + .. impl:: Create collections of blocks based on cross-section type and burn-up group. + :id: I_ARMI_XSGM_CREATE_XS_GROUPS + :implements: R_ARMI_XSGM_CREATE_XS_GROUPS + + This method constructs the representative blocks and block burnups + for each cross-section ID in the reactor model. Starting with the making of cross-section groups, it will + find candidate blocks and create representative blocks from that selection. + + """ representativeBlocks = {} self.avgNucTemperatures = {} self._unrepresentedXSIDs = [] @@ -1184,6 +1270,7 @@ def _getModifiedReprBlocks(self, blockList, originalRepresentativeBlocks): modifiedBlockXSTypes[origXSType] + origXSID[1] ) # New XS Type + Old Burnup Group origXSIDsFromNew[newXSID] = origXSID + # Create new representative blocks based on the original XS IDs for newXSID, origXSID in origXSIDsFromNew.items(): runLog.extra( @@ -1200,6 +1287,7 @@ def _getModifiedReprBlocks(self, blockList, originalRepresentativeBlocks): for b in blockList: if b.getMicroSuffix() == origXSID: b.p.xsType = newXSType + return modifiedReprBlocks, origXSIDsFromNew def getNextAvailableXsTypes(self, howMany=1, excludedXSTypes=None): @@ -1236,8 +1324,8 @@ def getNextAvailableXsTypes(self, howMany=1, excludedXSTypes=None): return availableXsTypes[:howMany] def _getUnrepresentedBlocks(self, blockCollectionsByXsGroup): - r""" - gets all blocks with suffixes not yet represented (for blocks in assemblies in the blueprints but not the core). + """ + Gets all blocks with suffixes not yet represented (for blocks in assemblies in the blueprints but not the core). Notes ----- @@ -1268,11 +1356,10 @@ def makeCrossSectionGroups(self): def _modifyUnrepresentedXSIDs(self, blockCollectionsByXsGroup): """ - adjust the xsID of blocks in the groups that are not represented. + Adjust the xsID of blocks in the groups that are not represented. Try to just adjust the burnup group up to something that is represented (can happen to structure in AA when only AB, AC, AD still remain). - """ for xsID in self._unrepresentedXSIDs: missingXsType, _missingBuGroup = xsID diff --git a/armi/physics/neutronics/crossSectionSettings.py b/armi/physics/neutronics/crossSectionSettings.py index 5cdbdf55b..439cf7abf 100644 --- a/armi/physics/neutronics/crossSectionSettings.py +++ b/armi/physics/neutronics/crossSectionSettings.py @@ -87,8 +87,8 @@ def getStr(cls, typeSpec: Enum): Examples -------- - XSGeometryTypes.getStr(XSGeometryTypes.ZERO_DIMENSIONAL) == "0D" - XSGeometryTypes.getStr(XSGeometryTypes.TWO_DIMENSIONAL_HEX) == "2D hex" + XSGeometryTypes.getStr(XSGeometryTypes.ZERO_DIMENSIONAL) == "0D" + XSGeometryTypes.getStr(XSGeometryTypes.TWO_DIMENSIONAL_HEX) == "2D hex" """ geometryTypes = list(cls) if typeSpec not in geometryTypes: diff --git a/armi/physics/neutronics/energyGroups.py b/armi/physics/neutronics/energyGroups.py index 5ecda047d..9cee0d887 100644 --- a/armi/physics/neutronics/energyGroups.py +++ b/armi/physics/neutronics/energyGroups.py @@ -30,7 +30,22 @@ def getFastFluxGroupCutoff(eGrpStruc): - """Given a constant "fast" energy threshold, return which ARMI energy group index contains this threshold.""" + """ + Given a constant "fast" energy threshold, return which ARMI energy group + index contains this threshold. + + .. impl:: Return the energy group index which contains a given energy threshold. + :id: I_ARMI_EG_FE + :implements: R_ARMI_EG_FE + + This function returns the energy group within a given group structure + that contains the fast flux threshold energy. The threshold energy is + imported from the :py:mod:`constants <armi.physics.neutronics.const>` in + the neutronics module, where it is defined as 100 keV. This is a + standard definition for fast flux. This function also calculates and + returns the fraction of the threshold energy group that is above the 100 + keV threshold. + """ gThres = -1 for g, eV in enumerate(eGrpStruc): if eV < FAST_FLUX_THRESHOLD_EV: @@ -65,12 +80,24 @@ def _create_anl_energies_with_group_lethargies(*group_lethargies): def getGroupStructure(name): """ - Return descending neutron energy group upper bounds in eV for a given structure name. + Return descending neutron energy group upper bounds in eV for a given + structure name. + + .. impl:: Provide the neutron energy group bounds for a given group structure. + :id: I_ARMI_EG_NE + :implements: R_ARMI_EG_NE + + There are several built-in group structures that are defined in this + module, which are stored in a dictionary. This function takes a group + structure name as an input parameter, which it uses as a key for the + group structure dictionary. If the group structure name is valid, it + returns a copy of the energy group structure resulting from the + dictionary lookup. Otherwise, it throws an error. Notes ----- - Copy of the group structure is return so that modifications of the energy bounds does - not propagate back to the `GROUP_STRUCTURE` dictionary. + Copy of the group structure is return so that modifications of the energy + bounds does not propagate back to the `GROUP_STRUCTURE` dictionary. """ try: return copy.copy(GROUP_STRUCTURE[name]) @@ -104,6 +131,8 @@ def getGroupStructureType(neutronEnergyBoundsInEv): Values are the upper bound of each energy in eV from highest energy to lowest (because neutrons typically downscatter...) + +:meta hide-value: """ GROUP_STRUCTURE["2"] = [HIGH_ENERGY_EV, 6.25e-01] @@ -287,6 +316,7 @@ def getGroupStructureType(neutronEnergyBoundsInEv): itertools.repeat(1, 2082) ) + # fmt: on def _create_multigroup_structures_on_finegroup_energies( multigroup_energy_bounds, finegroup_energy_bounds diff --git a/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py b/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py index 2cdf22c98..f42669afc 100644 --- a/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py +++ b/armi/physics/neutronics/fissionProductModel/fissionProductModelSettings.py @@ -24,6 +24,7 @@ def defineSettings(): + """Define settings for the plugin.""" settings = [ setting.Setting( CONF_FP_MODEL, diff --git a/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py b/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py index d937b81b2..0a3f849ad 100644 --- a/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py +++ b/armi/physics/neutronics/fissionProductModel/lumpedFissionProduct.py @@ -413,7 +413,8 @@ def _buildMo99LumpedFissionProduct(): for lfp in nuclideBases.where( lambda nb: isinstance(nb, nuclideBases.LumpNuclideBase) ): - # Not all lump nuclides bases defined are fission products, so ensure that only fission products are considered. + # Not all lump nuclides bases defined are fission products, so ensure that only fission + # products are considered. if not ("FP" in lfp.name or "REGN" in lfp.name): continue mo99FP = LumpedFissionProduct(lfp.name) @@ -424,6 +425,7 @@ def _buildMo99LumpedFissionProduct(): def isGas(nuc): """True if nuclide is considered a gas.""" + # ruff: noqa: SIM110 for element in elements.getElementsByChemicalPhase(elements.ChemicalPhase.GAS): if element == nuc.element: return True diff --git a/armi/physics/neutronics/fissionProductModel/tests/test_fissionProductModel.py b/armi/physics/neutronics/fissionProductModel/tests/test_fissionProductModel.py index 3be98d29e..569168e7d 100644 --- a/armi/physics/neutronics/fissionProductModel/tests/test_fissionProductModel.py +++ b/armi/physics/neutronics/fissionProductModel/tests/test_fissionProductModel.py @@ -126,13 +126,27 @@ def test_nuclidesInModelFuel(self): self.assertIn(nb.name, nuclideList) def test_nuclidesInModelAllDepletableBlocks(self): - """Test that the depletable blocks contain all the MC2-3 modeled nuclides.""" + """Test that the depletable blocks contain all the MC2-3 modeled nuclides. + + .. test:: Determine if any component is depletable. + :id: T_ARMI_DEPL_DEPLETABLE + :tests: R_ARMI_DEPL_DEPLETABLE + """ # Check that there are some fuel and control blocks in the core model. fuelBlocks = self.r.core.getBlocks(Flags.FUEL) controlBlocks = self.r.core.getBlocks(Flags.CONTROL) self.assertGreater(len(fuelBlocks), 0) self.assertGreater(len(controlBlocks), 0) + # prove that the control blocks are not depletable + for b in controlBlocks: + self.assertFalse(isDepletable(b)) + + # as a corrolary of the above, prove that no components in the control blocks are depletable + for b in controlBlocks: + for c in b.getComponents(): + self.assertFalse(isDepletable(c)) + # Force the the first component in the control blocks # to be labeled as depletable for checking that explicit # fission products can be assigned. @@ -140,6 +154,19 @@ def test_nuclidesInModelAllDepletableBlocks(self): c = b.getComponents()[0] c.p.flags |= Flags.DEPLETABLE + # now each control block should be depletable + for b in controlBlocks: + self.assertTrue(isDepletable(b)) + + # as a corrolary of the above, prove that only the first component in each control block is depletable + for b in controlBlocks: + comps = list(b.getComponents()) + for i, c in enumerate(comps): + if i == 0: + self.assertTrue(isDepletable(c)) + else: + self.assertFalse(isDepletable(c)) + # Run the ``interactBOL`` here to trigger setting up the fission # products in the reactor data model. self.fpModel.interactBOL() diff --git a/armi/physics/neutronics/globalFlux/globalFluxInterface.py b/armi/physics/neutronics/globalFlux/globalFluxInterface.py index 5c5d20471..1b202f91c 100644 --- a/armi/physics/neutronics/globalFlux/globalFluxInterface.py +++ b/armi/physics/neutronics/globalFlux/globalFluxInterface.py @@ -119,8 +119,21 @@ def interactEOC(self, cycle=None): * units.ABS_REACTIVITY_TO_PCM ) - def _checkEnergyBalance(self): - """Check that there is energy balance between the power generated and the specified power is the system.""" + def checkEnergyBalance(self): + """Check that there is energy balance between the power generated and the specified power. + + .. impl:: Validate the energy generation matches user specifications. + :id: I_ARMI_FLUX_CHECK_POWER + :implements: R_ARMI_FLUX_CHECK_POWER + + This method checks that the global power computed from flux + evaluation matches the global power specified from the user within a + tolerance; if it does not, a ``ValueError`` is raised. The + global power from the flux solve is computed by summing the + block-wise power in the core. This value is then compared to the + user-specified power and raises an error if relative difference is + above :math:`10^{-5}`. + """ powerGenerated = ( self.r.core.calcTotalParam( "power", calcBasedOnFullObj=False, generationNum=2 @@ -237,7 +250,23 @@ def interactCoupled(self, iteration): GlobalFluxInterface.interactCoupled(self, iteration) def getTightCouplingValue(self): - """Return the parameter value.""" + """Return the parameter value. + + .. impl:: Return k-eff or assembly-wise power distribution for coupled interactions. + :id: I_ARMI_FLUX_COUPLING_VALUE + :implements: R_ARMI_FLUX_COUPLING_VALUE + + This method either returns the k-eff or assembly-wise power + distribution. If the :py:class:`coupler + <armi.interfaces.TightCoupler>` ``parameter`` member is ``"keff"``, + then this method returns the computed k-eff from the global flux + evaluation. If the ``parameter`` value is ``"power"``, then it + returns a list of power distributions in each assembly. The assembly + power distributions are lists of values representing the block + powers that are normalized to unity based on the assembly total + power. If the value is neither ``"keff"`` or ``"power"``, then this + method returns ``None``. + """ if self.coupler.parameter == "keff": return self.r.core.p.keff if self.coupler.parameter == "power": @@ -334,6 +363,25 @@ def getLabel(caseTitle, cycle, node, iteration=None): class GlobalFluxOptions(executers.ExecutionOptions): """Data structure representing common options in Global Flux Solvers. + .. impl:: Options for neutronics solvers. + :id: I_ARMI_FLUX_OPTIONS + :implements: R_ARMI_FLUX_OPTIONS + + This class functions as a data structure for setting and retrieving + execution options for performing flux evaluations, these options + involve: + + * What sort of problem is to be solved, i.e. real/adjoint, + eigenvalue/fixed-source, neutron/gamma, boundary conditions + * Convergence criteria for iterative algorithms + * Geometry type and mesh conversion details + * Specific parameters to be calculated after flux has been evaluated + + These options can be retrieved by directly accessing class members. The + options are set by specifying a :py:class:`Settings + <armi.settings.caseSettings.Settings>` object and optionally specifying + a :py:class:`Reactor <armi.reactor.reactors.Reactor>` object. + Attributes ---------- adjoint : bool @@ -505,6 +553,19 @@ class GlobalFluxExecuter(executers.DefaultExecuter): and copying certain user-defined files back to the working directory on error or completion. Given all these options and possible needs for information from global flux, this class provides a unified interface to everything. + + .. impl:: Ensure the mesh in the reactor model is appropriate for neutronics solver execution. + :id: I_ARMI_FLUX_GEOM_TRANSFORM + :implements: R_ARMI_FLUX_GEOM_TRANSFORM + + The primary purpose of this class is perform geometric and mesh + transformations on the reactor model to ensure a flux evaluation can + properly perform. This includes: + + * Applying a uniform axial mesh for the 3D flux solve + * Expanding symmetrical geometries to full-core if necessary + * Adding/removing edge assemblies if necessary + * Undoing any transformations that might affect downstream calculations """ def __init__(self, options: GlobalFluxOptions, reactor): @@ -1178,31 +1239,38 @@ def computeDpaRate(mgFlux, dpaXs): dpaPerSecond : float The dpa/s in this material due to this flux - Notes - ----- - Displacements calculated by displacement XS - .. math:: + .. impl:: Compute DPA rates. + :id: I_ARMI_FLUX_DPA + :implements: R_ARMI_FLUX_DPA + + This method calculates DPA rates using the inputted multigroup flux and DPA cross sections. + Displacements calculated by displacement cross-section: + + .. math:: + :nowrap: - \text{Displacement rate} &= \phi N_{\text{HT9}} \sigma \\ - &= (\#/\text{cm}^2/s) \cdot (1/cm^3) \cdot (\text{barn})\\ - &= (\#/\text{cm}^5/s) \cdot \text{(barn)} * 10^{-24} \text{cm}^2/\text{barn} \\ - &= \#/\text{cm}^3/s + \begin{aligned} + \text{Displacement rate} &= \phi N_{\text{HT9}} \sigma \\ + &= (\#/\text{cm}^2/s) \cdot (1/cm^3) \cdot (\text{barn})\\ + &= (\#/\text{cm}^5/s) \cdot \text{(barn)} * 10^{-24} \text{cm}^2/\text{barn} \\ + &= \#/\text{cm}^3/s + \end{aligned} - :: + :: - DPA rate = displacement density rate / (number of atoms/cc) - = dr [#/cm^3/s] / (nHT9) [1/cm^3] - = flux * barn * 1e-24 + DPA rate = displacement density rate / (number of atoms/cc) + = dr [#/cm^3/s] / (nHT9) [1/cm^3] + = flux * barn * 1e-24 - .. math:: + .. math:: - \frac{\text{dpa}}{s} = \frac{\phi N \sigma}{N} = \phi * \sigma + \frac{\text{dpa}}{s} = \frac{\phi N \sigma}{N} = \phi * \sigma - the Number density of the structural material cancels out. It's in the macroscopic - XS and in the original number of atoms. + the number density of the structural material cancels out. It's in the macroscopic + cross-section and in the original number of atoms. Raises ------ @@ -1258,33 +1326,40 @@ def calcReactionRates(obj, keff, lib): lib : XSLibrary Microscopic cross sections to use in computing the reaction rates. - Notes - ----- - Values include: - * Fission - * nufission - * n2n - * absorption + .. impl:: Return the reaction rates for a given ArmiObject + :id: I_ARMI_FLUX_RX_RATES + :implements: R_ARMI_FLUX_RX_RATES + + This method computes 1-group reaction rates for the inputted + :py:class:`ArmiObject <armi.reactor.composites.ArmiObject>` These + reaction rates include: + + * fission + * nufission + * n2n + * absorption - Scatter could be added as well. This function is quite slow so it is - skipped for now as it is uncommonly needed. + Scatter could be added as well. This function is quite slow so it is + skipped for now as it is uncommonly needed. - Reaction rates are: + Reaction rates are: - .. math:: + .. math:: - \Sigma \phi = \sum_{\text{nuclides}} \sum_{\text{energy}} \Sigma \phi + \Sigma \phi = \sum_{\text{nuclides}} \sum_{\text{energy}} \Sigma + \phi - The units of :math:`N \sigma \phi` are:: + The units of :math:`N \sigma \phi` are:: - [#/bn-cm] * [bn] * [#/cm^2/s] = [#/cm^3/s] + [#/bn-cm] * [bn] * [#/cm^2/s] = [#/cm^3/s] - The group-averaged microscopic cross section is: + The group-averaged microscopic cross section is: - .. math:: + .. math:: - \sigma_g = \frac{\int_{E g}^{E_{g+1}} \phi(E) \sigma(E) dE}{\int_{E_g}^{E_{g+1}} \phi(E) dE} + \sigma_g = \frac{\int_{E g}^{E_{g+1}} \phi(E) \sigma(E) + dE}{\int_{E_g}^{E_{g+1}} \phi(E) dE} """ rate = {} for simple in RX_PARAM_NAMES: diff --git a/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py b/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py index ff8957660..5b34fbdd1 100644 --- a/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py +++ b/armi/physics/neutronics/globalFlux/tests/test_globalFluxInterface.py @@ -13,6 +13,7 @@ # limitations under the License. """Tests for generic global flux interface.""" import unittest +from unittest.mock import patch import numpy @@ -104,13 +105,27 @@ def getKeff(self): class TestGlobalFluxOptions(unittest.TestCase): + """Tests for GlobalFluxOptions.""" + def test_readFromSettings(self): + """Test reading global flux options from case settings. + + .. test:: Tests GlobalFluxOptions. + :id: T_ARMI_FLUX_OPTIONS_CS + :tests: R_ARMI_FLUX_OPTIONS + """ cs = settings.Settings() opts = globalFluxInterface.GlobalFluxOptions("neutronics-run") opts.fromUserSettings(cs) self.assertFalse(opts.adjoint) def test_readFromReactors(self): + """Test reading global flux options from reactor objects. + + .. test:: Tests GlobalFluxOptions. + :id: T_ARMI_FLUX_OPTIONS_R + :tests: R_ARMI_FLUX_OPTIONS + """ reactor = MockReactor() opts = globalFluxInterface.GlobalFluxOptions("neutronics-run") opts.fromReactor(reactor) @@ -133,6 +148,19 @@ def test_savePhysicsFiles(self): class TestGlobalFluxInterface(unittest.TestCase): + def test_computeDpaRate(self): + """ + Compute DPA and DPA rates from multi-group neutron flux and cross sections. + + .. test:: Compute DPA rates. + :id: T_ARMI_FLUX_DPA + :tests: R_ARMI_FLUX_DPA + """ + xs = [1, 2, 3] + flx = [0.5, 0.75, 2] + res = globalFluxInterface.computeDpaRate(flx, xs) + self.assertEqual(res, 10**-24 * (0.5 + 1.5 + 6)) + def test_interaction(self): """ Ensure the basic interaction hooks work. @@ -159,10 +187,21 @@ def test_getHistoryParams(self): self.assertIn("detailedDpa", params) def test_checkEnergyBalance(self): + """Test energy balance check. + + .. test:: Block-wise power is consistent with reactor data model power. + :id: T_ARMI_FLUX_CHECK_POWER + :tests: R_ARMI_FLUX_CHECK_POWER + """ cs = settings.Settings() _o, r = test_reactors.loadTestReactor() gfi = MockGlobalFluxInterface(r, cs) - gfi._checkEnergyBalance() + self.assertEqual(gfi.checkEnergyBalance(), None) + + # Test when nameplate power doesn't equal sum of block power + r.core.p.power = 1e-10 + with self.assertRaises(ValueError): + gfi.checkEnergyBalance() class TestGlobalFluxInterfaceWithExecuters(unittest.TestCase): @@ -177,14 +216,28 @@ def setUp(self): self.r.core.p.keff = 1.0 self.gfi = MockGlobalFluxWithExecuters(self.r, self.cs) - def test_executerInteraction(self): - gfi, r = self.gfi, self.r + @patch( + "armi.physics.neutronics.globalFlux.globalFluxInterface.GlobalFluxExecuter._execute" + ) + @patch( + "armi.physics.neutronics.globalFlux.globalFluxInterface.GlobalFluxExecuter._performGeometryTransformations" + ) + def test_executerInteraction(self, mockGeometryTransform, mockExecute): + """Run the global flux interface and executer though one time now. + + .. test:: Run the global flux interface to check that the mesh converter is called before the neutronics solver. + :id: T_ARMI_FLUX_GEOM_TRANSFORM_ORDER + :tests: R_ARMI_FLUX_GEOM_TRANSFORM + """ + call_order = [] + mockGeometryTransform.side_effect = lambda *a, **kw: call_order.append( + mockGeometryTransform + ) + mockExecute.side_effect = lambda *a, **kw: call_order.append(mockExecute) + gfi = self.gfi gfi.interactBOC() gfi.interactEveryNode(0, 0) - r.p.timeNode += 1 - gfi.interactEveryNode(0, 1) - gfi.interactEOC() - self.assertAlmostEqual(r.core.p.rxSwing, (1.02 - 1.01) / 1.01 * 1e5) + self.assertEqual([mockGeometryTransform, mockExecute], call_order) def test_calculateKeff(self): self.assertEqual(self.gfi.calculateKeff(), 1.05) # set in mock @@ -201,16 +254,33 @@ def test_setTightCouplingDefaults(self): self._setTightCouplingFalse() def test_getTightCouplingValue(self): - """Test getTightCouplingValue returns the correct value for keff and type for power.""" + """Test getTightCouplingValue returns the correct value for keff and type for power. + + .. test:: Return k-eff or assembly-wise power for coupling interactions. + :id: T_ARMI_FLUX_COUPLING_VALUE + :tests: R_ARMI_FLUX_COUPLING_VALUE + """ self._setTightCouplingTrue() self.assertEqual(self.gfi.getTightCouplingValue(), 1.0) # set in setUp self.gfi.coupler.parameter = "power" for a in self.r.core.getChildren(): for b in a: b.p.power = 10.0 - self.assertIsInstance(self.gfi.getTightCouplingValue(), list) + self.assertEqual( + self.gfi.getTightCouplingValue(), + self._getCouplingPowerDistributions(self.r.core), + ) self._setTightCouplingFalse() + @staticmethod + def _getCouplingPowerDistributions(core): + scaledPowers = [] + for a in core: + assemblyPower = sum(b.p.power for b in a) + scaledPowers.append([b.p.power / assemblyPower for b in a]) + + return scaledPowers + def _setTightCouplingTrue(self): self.cs["tightCoupling"] = True self.gfi._setTightCouplingDefaults() @@ -229,14 +299,22 @@ def setUpClass(cls): cls.r.core.p.keff = 1.0 cls.gfi = MockGlobalFluxWithExecutersNonUniform(cls.r, cs) - def test_executerInteractionNonUniformAssems(self): - gfi, r = self.gfi, self.r + @patch("armi.reactor.converters.uniformMesh.converterFactory") + def test_executerInteractionNonUniformAssems(self, mockConverterFactory): + """Run the global flux interface with non-uniform assemblies. + + This will serve as a broad end-to-end test of the interface, and also + stress test the mesh issues with non-uniform assemblies. + + .. test:: Run the global flux interface to show the geometry converter is called when the nonuniform mesh option is used. + :id: T_ARMI_FLUX_GEOM_TRANSFORM_CONV + :tests: R_ARMI_FLUX_GEOM_TRANSFORM + """ + gfi = self.gfi gfi.interactBOC() gfi.interactEveryNode(0, 0) - r.p.timeNode += 1 - gfi.interactEveryNode(0, 1) - gfi.interactEOC() - self.assertAlmostEqual(r.core.p.rxSwing, (1.02 - 1.01) / 1.01 * 1e5) + self.assertTrue(gfi.getExecuterOptions().hasNonUniformAssems) + mockConverterFactory.assert_called() def test_calculateKeff(self): self.assertEqual(self.gfi.calculateKeff(), 1.05) # set in mock @@ -347,11 +425,15 @@ def test_calcReactionRates(self): """ Test that the reaction rate code executes and sets a param > 0.0. - .. warning: This does not validate the reaction rate calculation. + .. test:: Return the reaction rates for a given ArmiObject. + :id: T_ARMI_FLUX_RX_RATES + :tests: R_ARMI_FLUX_RX_RATES + + .. warning:: This does not validate the reaction rate calculation. """ b = test_blocks.loadTestBlock() test_blocks.applyDummyData(b) - self.assertAlmostEqual(b.p.rateAbs, 0.0) + self.assertEqual(b.p.rateAbs, 0.0) globalFluxInterface.calcReactionRates(b, 1.01, b.r.core.lib) self.assertGreater(b.p.rateAbs, 0.0) vfrac = b.getComponentAreaFrac(Flags.FUEL) diff --git a/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py b/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py index fa719a9db..4120de81f 100644 --- a/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py +++ b/armi/physics/neutronics/isotopicDepletion/crossSectionTable.py @@ -15,12 +15,11 @@ """ Module containing the CrossSectionTable class. -The CrossSectionTable is useful for performing isotopic depletion analysis by storing -one-group cross sections of interest to such an analysis. This used to live alongside -the isotopicDepletionInterface, but that proved to be an unpleasant coupling between the -ARMI composite model and the physics code contained therein. Separating it out at least -means that the composite model doesn't need to import the isotopicDepletionInterface to -function. +The CrossSectionTable is useful for performing isotopic depletion analysis by storing one-group +cross sections of interest to such an analysis. This used to live alongside the +isotopicDepletionInterface, but that proved to be an unpleasant coupling between the ARMI composite +model and the physics code contained therein. Separating it out at least means that the composite +model doesn't need to import the isotopicDepletionInterface to function. """ import collections from typing import List @@ -34,7 +33,7 @@ class CrossSectionTable(collections.OrderedDict): """ This is a set of one group cross sections for use with isotopicDepletion analysis. - Really it's a reaction rate table. + It can also double as a reaction rate table. XStable is indexed by nucNames (nG), (nF), (n2n), (nA), (nP) and (n3n) are expected @@ -116,10 +115,10 @@ def addMultiGroupXS(self, nucName, microMultiGroupXS, mgFlux, totalFlux=None): def hasValues(self): """Determines if there are non-zero values in this cross section table.""" - for nuclideCrossSectionSet in self.values(): - if any(nuclideCrossSectionSet.values()): - return True - return False + return any( + any(nuclideCrossSectionSet.values()) + for nuclideCrossSectionSet in self.values() + ) def getXsecTable( self, @@ -127,20 +126,31 @@ def getXsecTable( tableFormat="\n{{mcnpId}} {nG:.5e} {nF:.5e} {n2n:.5e} {n3n:.5e} {nA:.5e} {nP:.5e}", ): """ - make a cross section table for external depletion physics code input decks. + Make a cross section table for external depletion physics code input decks. + + .. impl:: Generate a formatted cross section table. + :id: I_ARMI_DEPL_TABLES1 + :implements: R_ARMI_DEPL_TABLES + + Loops over the reaction rates stored as ``self`` to produce a string with the cross + sections for each nuclide in the block. Cross sections may be populated by + :py:meth:`~armi.physics.neutronics.isotopicDepletion.crossSectionTable.makeReactionRateTable` + + The string will have a header with the table's name formatted according to + ``headerFormat`` followed by rows for each unique nuclide/reaction combination, where + each line is formatted according to ``tableFormat``. Parameters ---------- headerFormat: string (optional) - this is the format in which the elements of the header with be returned - -- i.e. if you use a .format() call with the case name you'll return a - formatted list of string elements + This is the format in which the elements of the header with be returned -- i.e. if you + use a .format() call with the case name you'll return a formatted list of strings. tableFormat: string (optional) - this is the format in which the elements of the table with be returned - -- i.e. if you use a .format() call with mcnpId, nG, nF, n2n, n3n, nA, - and nP you'll get the format you want. If you use a .format() call with the case name you'll return a - formatted list of string elements + This is the format in which the elements of the table with be returned -- i.e. if you + use a .format() call with mcnpId, nG, nF, n2n, n3n, nA, and nP you'll get the format you + want. If you use a .format() call with the case name you'll return a formatted list of + string elements Results ------- @@ -163,6 +173,32 @@ def makeReactionRateTable(obj, nuclides: List = None): Often useful in support of depletion. + .. impl:: Generate a reaction rate table with entries for (nG), (nF), (n2n), (nA), and (nP) + reactions. + :id: I_ARMI_DEPL_TABLES0 + :implements: R_ARMI_DEPL_TABLES + + For a given composite object ``obj`` and a list of nuclides ``nuclides`` in that object, + call ``obj.getReactionRates()`` for each nuclide with a ``nDensity`` parameter of 1.0. If + ``nuclides`` is not specified, use a list of all nuclides in ``obj``. This will reach + upwards through the parents of ``obj`` to the associated + :py:class:`~armi.reactor.reactors.Core` object and pull the ISOTXS library that is stored + there. If ``obj`` does not belong to a ``Core``, a warning is printed. + + For each child of ``obj``, use the ISOTXS library and the cross-section ID for the + associated block to produce a reaction rate dictionary in units of inverse seconds for the + nuclide specified in the original call to ``obj.getReactionRates()``. Because ``nDensity`` + was originally specified as 1.0, this dictionary actually represents the reaction rates per + unit volume. If the nuclide is not in the ISOTXS library, a warning is printed. + + Combine the reaction rates for all nuclides into a combined dictionary by summing together + reaction rates of the same type on the same isotope from each of the children of ``obj``. + + If ``obj`` has a non-zero multi-group flux, sum the group-wise flux into the total flux and + normalize the reaction rates by the total flux, producing a one-group macroscopic cross + section for each reaction type on each nuclide. Store these values in a + :py:class:`~armi.physics.neutronics.isotopicDepletion.crossSectionTable.CrossSectionTable`. + Parameters ---------- nuclides : list, optional @@ -177,6 +213,7 @@ def makeReactionRateTable(obj, nuclides: List = None): See Also -------- armi.physics.neutronics.isotopicDepletion.isotopicDepletionInterface.CrossSectionTable + armi.reactor.composites.Composite.getReactionRates """ if nuclides is None: nuclides = obj.getNuclides() diff --git a/armi/physics/neutronics/isotopicDepletion/isotopicDepletionInterface.py b/armi/physics/neutronics/isotopicDepletion/isotopicDepletionInterface.py index 4e641c744..57a591d7e 100644 --- a/armi/physics/neutronics/isotopicDepletion/isotopicDepletionInterface.py +++ b/armi/physics/neutronics/isotopicDepletion/isotopicDepletionInterface.py @@ -41,6 +41,14 @@ def isDepletable(obj: composites.ArmiObject): to figure out how often to replace them. But in conceptual design, they may want to just leave them as they are as an approximation. + .. impl:: Determine if any component is depletable. + :id: I_ARMI_DEPL_DEPLETABLE + :implements: R_ARMI_DEPL_DEPLETABLE + + Uses :py:meth:`~armi.reactor.composite.ArmiObject.hasFlags` or + :py:meth:`~armi.reactor.composite.ArmiObject.containsAtLeastOneChildWithFlags` + to determine if the "depletable" flag is in the ``obj``. If so, returns True. + .. warning:: The ``DEPLETABLE`` flag is automatically added to compositions that have active nuclides. If you explicitly define any flags at all, you must also manually include ``DEPLETABLE`` or else the objects will silently not deplete. @@ -68,12 +76,21 @@ class AbstractIsotopicDepleter: interface The depletion in this analysis only depends on the flux, material vectors, - nuclear data and countinuous source and loss objects. + nuclear data and continuous source and loss objects. The depleters derived from this abstract class use all the fission products armi can handle -- i.e. do not form lumped fission products. _depleteByName contains a ARMI objects to deplete keyed by name. + + .. impl:: ARMI provides a base class to deplete isotopes. + :id: I_ARMI_DEPL_ABC + :implements: R_ARMI_DEPL_ABC + + This class provides some basic infrastructure typically needed in depletion + calculations within the ARMI framework. It stores a reactor, operator, + and case settings object, and also defines methods to store and retrieve + the objects which should be depleted based on their names. """ name = None diff --git a/armi/physics/neutronics/latticePhysics/tests/test_latticeInterface.py b/armi/physics/neutronics/latticePhysics/tests/test_latticeInterface.py index 8b79b3536..b88c8cf58 100644 --- a/armi/physics/neutronics/latticePhysics/tests/test_latticeInterface.py +++ b/armi/physics/neutronics/latticePhysics/tests/test_latticeInterface.py @@ -20,9 +20,12 @@ LatticePhysicsInterface, ) from armi import settings +from armi.nuclearDataIO.cccc import isotxs from armi.operators.operator import Operator +from armi.physics.neutronics import LatticePhysicsFrequency from armi.physics.neutronics.crossSectionGroupManager import CrossSectionGroupManager from armi.physics.neutronics.settings import CONF_GEN_XS +from armi.physics.neutronics.settings import CONF_GLOBAL_FLUX_ACTIVE from armi.reactor.reactors import Reactor, Core from armi.reactor.tests.test_blocks import buildSimpleFuelBlock from armi.tests import mockRunLogs @@ -30,8 +33,6 @@ HexAssembly, grids, ) -from armi.nuclearDataIO.cccc import isotxs -from armi.physics.neutronics import LatticePhysicsFrequency from armi.tests import ISOAA_PATH # As an interface, LatticePhysicsInterface must be subclassed to be used @@ -86,7 +87,27 @@ def setUp(self): self.o.r.core.lib = "Nonsense" self.latticeInterface.testVerification = False - def test_LatticePhysicsInterface(self): + def test_includeGammaXS(self): + """Test that we can correctly flip the switch to calculate gamma XS. + + .. test:: Users can flip a setting to determine if gamma XS are generated. + :id: T_ARMI_GAMMA_XS + :tests: R_ARMI_GAMMA_XS + """ + # The default operator here turns off Gamma XS generation + self.assertFalse(self.latticeInterface.includeGammaXS) + self.assertEqual(self.o.cs[CONF_GLOBAL_FLUX_ACTIVE], "Neutron") + + # but we can create an operator that turns on Gamma XS generation + cs = settings.Settings().modified( + newSettings={CONF_GLOBAL_FLUX_ACTIVE: "Neutron and Gamma"} + ) + newOperator = Operator(cs) + newLatticeInterface = LatticeInterfaceTesterLibFalse(newOperator.r, cs) + self.assertTrue(newLatticeInterface.includeGammaXS) + self.assertEqual(cs[CONF_GLOBAL_FLUX_ACTIVE], "Neutron and Gamma") + + def test_latticePhysicsInterface(self): """Super basic test of the LatticePhysicsInterface.""" self.assertEqual(self.latticeInterface._updateBlockNeutronVelocities, True) self.assertEqual(self.latticeInterface.executablePath, "/tmp/fake_path") diff --git a/armi/physics/neutronics/macroXSGenerationInterface.py b/armi/physics/neutronics/macroXSGenerationInterface.py index 0303cb92f..18ecb33aa 100644 --- a/armi/physics/neutronics/macroXSGenerationInterface.py +++ b/armi/physics/neutronics/macroXSGenerationInterface.py @@ -67,7 +67,6 @@ def __reduce__(self): ) def invokeHook(self): - # logic here gets messy due to all the default arguments in the calling # method. There exists a large number of permutations to be handled. @@ -137,35 +136,56 @@ def buildMacros( libType="micros", ): """ - Builds block-level macroscopic cross sections for making diffusion equation matrices. + Builds block-level macroscopic cross sections for making diffusion + equation matrices. This will use MPI if armi.context.MPI_SIZE > 1 - Builds G-vectors of the basic XS ('nGamma','fission','nalph','np','n2n','nd','nt') - Builds GxG matrices for scatter matrices + Builds G-vectors of the basic XS + ('nGamma','fission','nalph','np','n2n','nd','nt') Builds GxG matrices + for scatter matrices + + .. impl:: Build macroscopic cross sections for blocks. + :id: I_ARMI_MACRO_XS + :implements: R_ARMI_MACRO_XS + + This method builds macroscopic cross sections for a user-specified + set of blocks using a specified microscopic neutron or gamma cross + section library. If no blocks are specified, cross sections are + calculated for all blocks in the core. If no library is specified, + the existing r.core.lib is used. The basic arithmetic involved in + generating macroscopic cross sections consists of multiplying + isotopic number densities by isotopic microscopic cross sections and + summing over all isotopes in a composition. The calculation is + implemented in :py:func:`computeMacroscopicGroupConstants + <armi.nuclearDataIO.xsCollections.computeMacroscopicGroupConstants>`. + This method uses an :py:class:`mpiAction + <armi.mpiActions.MpiAction>` to distribute the work of calculating + macroscopic cross sections across the worker processes. Parameters ---------- lib : library object , optional - If lib is specified, then buildMacros will build macro XS using micro XS data from lib. - If lib = None, then buildMacros will use the existing library self.r.core.lib. If that does - not exist, then buildMacros will use a new nuclearDataIO.ISOTXS object. + If lib is specified, then buildMacros will build macro XS using + micro XS data from lib. If lib = None, then buildMacros will use the + existing library self.r.core.lib. If that does not exist, then + buildMacros will use a new nuclearDataIO.ISOTXS object. buildScatterMatrix : Boolean, optional - If True, all macro XS will be built, including the time-consuming scatter matrix. - If False, only the macro XS that are needed for fluxRecon.computePinMGFluxAndPower - will be built. These include 'transport', 'fission', and a few others. No ng x ng - matrices (such as 'scatter' or 'chi') will be built. Essentially, this option - saves huge runtime for the fluxRecon module. + If True, all macro XS will be built, including the time-consuming + scatter matrix. If False, only the macro XS that are needed for + fluxRecon.computePinMGFluxAndPower will be built. These include + 'transport', 'fission', and a few others. No ng x ng matrices (such + as 'scatter' or 'chi') will be built. Essentially, this option saves + huge runtime for the fluxRecon module. buildOnlyCoolant : Boolean, optional - If True, homogenized macro XS will be built only for NA-23. - If False, the function runs normally. + If True, homogenized macro XS will be built only for NA-23. If + False, the function runs normally. libType : str, optional - The block attribute containing the desired microscopic XS for this block: - either "micros" for neutron XS or "gammaXS" for gamma XS. - + The block attribute containing the desired microscopic XS for this + block: either "micros" for neutron XS or "gammaXS" for gamma XS. """ cycle = self.r.p.cycle self.macrosLastBuiltAt = ( diff --git a/armi/physics/neutronics/parameters.py b/armi/physics/neutronics/parameters.py index 07b4bb552..269598980 100644 --- a/armi/physics/neutronics/parameters.py +++ b/armi/physics/neutronics/parameters.py @@ -542,7 +542,10 @@ def _getNeutronicsBlockParams(): pb.defParam( "kInf", units=units.UNITLESS, - description="Neutron production rate in this block/neutron absorption rate in this block. Not truly kinf but a reasonable approximation of reactivity.", + description=( + "Neutron production rate in this block/neutron absorption rate in this " + "block. Not truly kinf but a reasonable approximation of reactivity." + ), ) pb.defParam( diff --git a/armi/physics/neutronics/settings.py b/armi/physics/neutronics/settings.py index 46139d961..fcbb49c12 100644 --- a/armi/physics/neutronics/settings.py +++ b/armi/physics/neutronics/settings.py @@ -83,7 +83,17 @@ def defineSettings(): - """Standard function to define settings - for neutronics.""" + """Standard function to define settings; for neutronics. + + .. impl:: Users to select if gamma cross sections are generated. + :id: I_ARMI_GAMMA_XS + :implements: R_ARMI_GAMMA_XS + + A single boolean setting can be used to turn on/off the calculation of gamma + cross sections. This is implemented with the usual boolean ``Setting`` logic. + The goal here is performance; save the compute time if the analyst doesn't need + those cross sections. + """ settings = [ setting.Setting( CONF_GROUP_STRUCTURE, @@ -120,8 +130,8 @@ def defineSettings(): label="Multigroup Cross Sections Generation", description="Generate multigroup cross sections for the selected particle " "type(s) using the specified lattice physics kernel (see Lattice Physics " - "tab). When not set, the XS library will be auto-loaded from an existing ISOTXS " - "within then working directory and fail if the ISOTXS does not exist.", + "tab). When not set, the XS library will be auto-loaded from an existing " + "ISOTXS in the working directory, but fail if there is no ISOTXS.", options=["", "Neutron", "Neutron and Gamma"], ), setting.Setting( @@ -173,7 +183,7 @@ def defineSettings(): CONF_EIGEN_PROB, default=True, label="Eigenvalue Problem", - description="Whether this is a eigenvalue problem or a fixed source problem", + description="Is this a eigenvalue problem or a fixed source problem?", ), setting.Setting( CONF_EXISTING_FIXED_SOURCE, @@ -194,7 +204,8 @@ def defineSettings(): CONF_EPS_EIG, default=1e-07, label="Eigenvalue Epsilon", - description="Convergence criteria for calculating the eigenvalue in the global flux solver", + description="Convergence criteria for calculating the eigenvalue in the " + "global flux solver", ), setting.Setting( CONF_EPS_FSAVG, @@ -214,8 +225,8 @@ def defineSettings(): label="Load pad elevation (cm)", description=( "The elevation of the bottom of the above-core load pad (ACLP) in cm " - "from the bottom of the upper grid plate. Used for calculating the load " - "pad dose" + "from the bottom of the upper grid plate. Used for calculating the " + "load pad dose" ), ), setting.Setting( @@ -228,7 +239,8 @@ def defineSettings(): CONF_ACLP_DOSE_LIMIT, default=80.0, label="ALCP dose limit", - description="Dose limit in dpa used to position the above-core load pad (if one exists)", + description="Dose limit in dpa used to position the above-core load pad" + "(if one exists)", ), setting.Setting( CONF_RESTART_NEUTRONICS, @@ -246,7 +258,8 @@ def defineSettings(): CONF_INNERS_, default=0, label="Inner Iterations", - description="XY and Axial partial current sweep inner iterations. 0 lets the neutronics code pick a default.", + description="XY and Axial partial current sweep inner iterations. 0 lets " + "the neutronics code pick a default.", ), setting.Setting( CONF_GRID_PLATE_DPA_XS_SET, @@ -275,34 +288,40 @@ def defineSettings(): CONF_MINIMUM_FISSILE_FRACTION, default=0.045, label="Minimum Fissile Fraction", - description="Minimum fissile fraction (fissile number densities / heavy metal number densities).", + description="Minimum fissile fraction (fissile number densities / heavy " + "metal number densities).", oldNames=[("mc2.minimumFissileFraction", None)], ), setting.Setting( CONF_MINIMUM_NUCLIDE_DENSITY, default=1e-15, label="Minimum nuclide density", - description="Density to use for nuclides and fission products at infinite dilution. " - + "This is also used as the minimum density considered for computing macroscopic cross " - + "sections. It can also be passed to physics plugins.", + description="Density to use for nuclides and fission products at infinite " + "dilution. This is also used as the minimum density considered for " + "computing macroscopic cross sections. It can also be passed to physics " + "plugins.", ), setting.Setting( CONF_INFINITE_DILUTE_CUTOFF, default=1e-10, label="Infinite Dillute Cutoff", - description="Do not model nuclides with density less than this cutoff. Used with PARTISN and SERPENT.", + description="Do not model nuclides with density less than this cutoff. " + "Used with PARTISN and SERPENT.", ), setting.Setting( CONF_TOLERATE_BURNUP_CHANGE, default=0.0, label="Cross Section Burnup Group Tolerance", - description="Burnup window for computing cross sections. If the prior cross sections were computed within the window, new cross sections will not be generated and the prior calculated cross sections will be used.", + description="Burnup window for computing cross sections. If the prior " + "cross sections were computed within the window, new cross sections will " + "not be generated and the prior calculated cross sections will be used.", ), setting.Setting( CONF_XS_BLOCK_REPRESENTATION, default="Average", label="Cross Section Block Averaging Method", - description="The type of averaging to perform when creating cross sections for a group of blocks", + description="The type of averaging to perform when creating cross " + "sections for a group of blocks", options=[ "Median", "Average", @@ -314,7 +333,9 @@ def defineSettings(): CONF_DISABLE_BLOCK_TYPE_EXCLUSION_IN_XS_GENERATION, default=False, label="Include All Block Types in XS Generation", - description="Use all blocks in a cross section group when generating a representative block. When this is disabled only `fuel` blocks will be considered", + description="Use all blocks in a cross section group when generating a " + "representative block. When this is disabled only `fuel` blocks will be " + "considered", ), setting.Setting( CONF_XS_KERNEL, @@ -327,7 +348,8 @@ def defineSettings(): CONF_LATTICE_PHYSICS_FREQUENCY, default="BOC", label="Frequency of lattice physics updates", - description="Define the frequency at which cross sections are updated with new lattice physics interactions.", + description="Define the frequency at which cross sections are updated with " + "new lattice physics interactions.", options=[opt.name for opt in list(LatticePhysicsFrequency)], enforcedOptions=True, ), @@ -341,7 +363,8 @@ def defineSettings(): CONF_XS_BUCKLING_CONVERGENCE, default=1e-05, label="Buckling Convergence Criteria", - description="Convergence criteria for the buckling iteration if it is available in the lattice physics solver", + description="Convergence criteria for the buckling iteration if it is " + "available in the lattice physics solver", oldNames=[ ("mc2BucklingConvergence", None), ("bucklingConvergence", None), @@ -351,7 +374,8 @@ def defineSettings(): CONF_XS_EIGENVALUE_CONVERGENCE, default=1e-05, label="Eigenvalue Convergence Criteria", - description="Convergence criteria for the eigenvalue in the lattice physics kernel", + description="Convergence criteria for the eigenvalue in the lattice " + "physics kernel", ), ] diff --git a/armi/physics/neutronics/tests/test_crossSectionManager.py b/armi/physics/neutronics/tests/test_crossSectionManager.py index 995100896..dc4f80379 100644 --- a/armi/physics/neutronics/tests/test_crossSectionManager.py +++ b/armi/physics/neutronics/tests/test_crossSectionManager.py @@ -93,7 +93,6 @@ def setUp(self): self.bc.extend(self.blockList) def test_createRepresentativeBlock(self): - avgB = self.bc.createRepresentativeBlock() self.assertAlmostEqual(avgB.p.percentBu, 50.0) @@ -132,25 +131,24 @@ def setUp(self): self.bc.averageByComponent = True def test_performAverageByComponent(self): - """ - Check the averageByComponent attribute - """ + """Check the averageByComponent attribute.""" self.bc._checkBlockSimilarity = MagicMock(return_value=True) self.assertTrue(self.bc._performAverageByComponent()) self.bc.averageByComponent = False self.assertFalse(self.bc._performAverageByComponent()) def test_checkBlockSimilarity(self): - """ - Check the block similarity test - """ + """Check the block similarity test.""" self.assertTrue(self.bc._checkBlockSimilarity()) self.bc.append(test_blocks.loadTestBlock()) self.assertFalse(self.bc._checkBlockSimilarity()) def test_createRepresentativeBlock(self): - """ - Test creation of a representative block + """Test creation of a representative block. + + .. test:: Create representative blocks using a volume-weighted averaging. + :id: T_ARMI_XSGM_CREATE_REPR_BLOCKS0 + :tests: R_ARMI_XSGM_CREATE_REPR_BLOCKS """ avgB = self.bc.createRepresentativeBlock() self.assertNotIn(avgB, self.bc) @@ -171,10 +169,7 @@ def test_createRepresentativeBlock(self): self.assertAlmostEqual(newBc.avgNucTemperatures["NA23"], 402.0) def test_createRepresentativeBlockDissimilar(self): - """ - Test creation of a representative block from a collection with dissimilar blocks - """ - + """Test creation of a representative block from a collection with dissimilar blocks.""" uniqueBlock = test_blocks.loadTestBlock() uniqueBlock.p.percentBu = 50.0 fpFactory = test_lumpedFissionProduct.getDummyLFPFile() @@ -262,9 +257,7 @@ def setUp(self): self.bc.extend(blockCopies) def test_getAverageComponentNumberDensities(self): - """ - Test component number density averaging - """ + """Test component number density averaging.""" # becaue of the way densities are set up, the middle block (index 1 of 0-2) component densities are equivalent to the average b = self.bc[1] for compIndex, c in enumerate(b.getComponents()): @@ -278,9 +271,7 @@ def test_getAverageComponentNumberDensities(self): ) def test_getAverageComponentTemperature(self): - """ - Test mass-weighted component temperature averaging - """ + """Test mass-weighted component temperature averaging.""" b = self.bc[0] massWeightedIncrease = 5.0 / 3.0 baseTemps = [600, 400, 500, 500, 400, 500, 400] @@ -294,9 +285,7 @@ def test_getAverageComponentTemperature(self): ) def test_getAverageComponentTemperatureVariedWeights(self): - """ - Test mass-weighted component temperature averaging with variable weights - """ + """Test mass-weighted component temperature averaging with variable weights.""" # make up a fake weighting with power param self.bc.weightingParam = "power" for i, b in enumerate(self.bc): @@ -313,9 +302,7 @@ def test_getAverageComponentTemperatureVariedWeights(self): ) def test_getAverageComponentTemperatureNoMass(self): - """ - Test component temperature averaging when the components have no mass - """ + """Test component temperature averaging when the components have no mass.""" for b in self.bc: for nuc in b.getNuclides(): b.setNumberDensity(nuc, 0.0) @@ -379,10 +366,10 @@ def setUp(self): self.expectedAreas = [[1, 6, 1], [1, 2, 1, 4]] def test_ComponentAverageRepBlock(self): - r""" - tests that the XS group manager calculates the expected component atom density - and component area correctly. Order of components is also checked since in - 1D cases the order of the components matters. + """Tests that the XS group manager calculates the expected component atom density + and component area correctly. + + Order of components is also checked since in 1D cases the order of the components matters. """ xsgm = self.o.getInterface("xsGroups") @@ -421,11 +408,11 @@ def test_ComponentAverageRepBlock(self): class TestBlockCollectionComponentAverage1DCylinder(unittest.TestCase): - r"""tests for 1D cylinder XS gen cases.""" + """tests for 1D cylinder XS gen cases.""" def setUp(self): - r""" - First part of setup same as test_Cartesian. + """First part of setup same as test_Cartesian. + Second part of setup builds lists/dictionaries of expected values to compare to. has expected values for component isotopic atom density and component area. """ @@ -498,10 +485,14 @@ def setUp(self): ] def test_ComponentAverage1DCylinder(self): - r""" - tests that the XS group manager calculates the expected component atom density - and component area correctly. Order of components is also checked since in - 1D cases the order of the components matters. + """Tests that the cross-section group manager calculates the expected component atom density + and component area correctly. + + Order of components is also checked since in 1D cases the order of the components matters. + + .. test:: Create representative blocks using custom cylindrical averaging. + :id: T_ARMI_XSGM_CREATE_REPR_BLOCKS1 + :tests: R_ARMI_XSGM_CREATE_REPR_BLOCKS """ xsgm = self.o.getInterface("xsGroups") @@ -546,7 +537,6 @@ def test_ComponentAverage1DCylinder(self): ) def test_checkComponentConsistency(self): - xsgm = self.o.getInterface("xsGroups") xsgm.interactBOL() blockCollectionsByXsGroup = xsgm.makeCrossSectionGroups() @@ -701,7 +691,7 @@ def test_invalidWeights(self): self.bc.createRepresentativeBlock() -class Test_CrossSectionGroupManager(unittest.TestCase): +class TestCrossSectionGroupManager(unittest.TestCase): def setUp(self): cs = settings.Settings() self.blockList = makeBlocks(20) @@ -775,6 +765,12 @@ def test_getNextAvailableXsType(self): self.assertEqual("D", xsType3) def test_getRepresentativeBlocks(self): + """Test that we can create the representative blocks for a reactor. + + .. test:: Build representative blocks for a reactor. + :id: T_ARMI_XSGM_CREATE_XS_GROUPS + :tests: R_ARMI_XSGM_CREATE_XS_GROUPS + """ _o, r = test_reactors.loadTestReactor(TEST_ROOT) self.csm.r = r @@ -793,7 +789,7 @@ def test_getRepresentativeBlocks(self): intercoolant.setNumberDensity("NA23", units.TRACE_NUMBER_DENSITY) self.csm.createRepresentativeBlocks() - blocks = self.csm.representativeBlocks + blocks = list(self.csm.representativeBlocks.values()) self.assertGreater(len(blocks), 0) # Test ability to get average nuclide temperature in block. @@ -811,6 +807,16 @@ def test_getRepresentativeBlocks(self): # Test that retrieving temperatures fails if a representative block for a given XS ID does not exist self.assertEqual(self.csm.getNucTemperature("Z", "U235"), None) + # Test dimensions + self.assertEqual(blocks[0].getHeight(), 25.0) + self.assertEqual(blocks[1].getHeight(), 25.0) + self.assertAlmostEqual(blocks[0].getVolume(), 6074.356308731789) + self.assertAlmostEqual(blocks[1].getVolume(), 6074.356308731789) + + # Number densities haven't been calculated yet + self.assertIsNone(blocks[0].p.detailedNDens) + self.assertIsNone(blocks[1].p.detailedNDens) + def test_createRepresentativeBlocksUsingExistingBlocks(self): """ Demonstrates that a new representative block can be generated from an existing representative block. @@ -844,14 +850,26 @@ def test_createRepresentativeBlocksUsingExistingBlocks(self): self.assertEqual(origXSIDsFromNew["BA"], "AA") def test_interactBOL(self): - """Test `BOL` lattice physics update frequency.""" + """Test `BOL` lattice physics update frequency. + + .. test:: The cross-section group manager frequency depends on the LPI frequency at BOL. + :id: T_ARMI_XSGM_FREQ0 + :tests: R_ARMI_XSGM_FREQ + """ + self.assertFalse(self.csm.representativeBlocks) self.blockList[0].r.p.timeNode = 0 self.csm.cs[CONF_LATTICE_PHYSICS_FREQUENCY] = "BOL" self.csm.interactBOL() self.assertTrue(self.csm.representativeBlocks) def test_interactBOC(self): - """Test `BOC` lattice physics update frequency.""" + """Test `BOC` lattice physics update frequency. + + .. test:: The cross-section group manager frequency depends on the LPI frequency at BOC. + :id: T_ARMI_XSGM_FREQ1 + :tests: R_ARMI_XSGM_FREQ + """ + self.assertFalse(self.csm.representativeBlocks) self.blockList[0].r.p.timeNode = 0 self.csm.cs[CONF_LATTICE_PHYSICS_FREQUENCY] = "BOC" self.csm.interactBOL() @@ -859,7 +877,12 @@ def test_interactBOC(self): self.assertTrue(self.csm.representativeBlocks) def test_interactEveryNode(self): - """Test `everyNode` lattice physics update frequency.""" + """Test `everyNode` lattice physics update frequency. + + .. test:: The cross-section group manager frequency depends on the LPI frequency at every time node. + :id: T_ARMI_XSGM_FREQ2 + :tests: R_ARMI_XSGM_FREQ + """ self.csm.cs[CONF_LATTICE_PHYSICS_FREQUENCY] = "BOC" self.csm.interactBOL() self.csm.interactEveryNode() @@ -870,7 +893,12 @@ def test_interactEveryNode(self): self.assertTrue(self.csm.representativeBlocks) def test_interactFirstCoupledIteration(self): - """Test `firstCoupledIteration` lattice physics update frequency.""" + """Test `firstCoupledIteration` lattice physics update frequency. + + .. test:: The cross-section group manager frequency depends on the LPI frequency during first coupled iteration. + :id: T_ARMI_XSGM_FREQ3 + :tests: R_ARMI_XSGM_FREQ + """ self.csm.cs[CONF_LATTICE_PHYSICS_FREQUENCY] = "everyNode" self.csm.interactBOL() self.csm.interactCoupled(iteration=0) @@ -881,7 +909,12 @@ def test_interactFirstCoupledIteration(self): self.assertTrue(self.csm.representativeBlocks) def test_interactAllCoupled(self): - """Test `all` lattice physics update frequency.""" + """Test `all` lattice physics update frequency. + + .. test:: The cross-section group manager frequency depends on the LPI frequency during coupling. + :id: T_ARMI_XSGM_FREQ4 + :tests: R_ARMI_XSGM_FREQ + """ self.csm.cs[CONF_LATTICE_PHYSICS_FREQUENCY] = "firstCoupledIteration" self.csm.interactBOL() self.csm.interactCoupled(iteration=1) @@ -891,6 +924,17 @@ def test_interactAllCoupled(self): self.csm.interactCoupled(iteration=1) self.assertTrue(self.csm.representativeBlocks) + def test_xsgmIsRunBeforeXS(self): + """Test that the XSGM is run before the cross sections are calculated. + + .. test:: Test that the cross-section group manager is run before the cross sections are calculated. + :id: T_ARMI_XSGM_FREQ5 + :tests: R_ARMI_XSGM_FREQ + """ + from armi.interfaces import STACK_ORDER + + self.assertLess(crossSectionGroupManager.ORDER, STACK_ORDER.CROSS_SECTIONS) + def test_copyPregeneratedFiles(self): """ Tests copying pre-generated cross section and flux files diff --git a/armi/physics/neutronics/tests/test_crossSectionSettings.py b/armi/physics/neutronics/tests/test_crossSectionSettings.py index 4f82b9566..631af79c1 100644 --- a/armi/physics/neutronics/tests/test_crossSectionSettings.py +++ b/armi/physics/neutronics/tests/test_crossSectionSettings.py @@ -207,7 +207,7 @@ def test_badCrossSections(self): xsSettingsValidator({"AAA": {CONF_BLOCK_REPRESENTATION: "Average"}}) -class Test_XSSettings(unittest.TestCase): +class TestXSSettings(unittest.TestCase): def test_yamlIO(self): """Ensure we can read/write this custom setting object to yaml.""" yaml = YAML() diff --git a/armi/physics/neutronics/tests/test_crossSectionTable.py b/armi/physics/neutronics/tests/test_crossSectionTable.py index 159d19cf0..b77387606 100644 --- a/armi/physics/neutronics/tests/test_crossSectionTable.py +++ b/armi/physics/neutronics/tests/test_crossSectionTable.py @@ -29,6 +29,12 @@ class TestCrossSectionTable(unittest.TestCase): def test_makeTable(self): + """Test making a cross section table. + + .. test:: Generate cross section table. + :id: T_ARMI_DEPL_TABLES + :tests: R_ARMI_DEPL_TABLES + """ obj = loadTestBlock() obj.p.mgFlux = range(33) core = obj.getAncestorWithFlags(Flags.CORE) @@ -47,6 +53,13 @@ def test_makeTable(self): self.assertIn("mcnpId", xSecTable[-1]) def test_isotopicDepletionInterface(self): + """ + Test isotopic depletion interface. + + .. test:: ARMI provides a base class to deplete isotopes. + :id: T_ARMI_DEPL_ABC + :tests: R_ARMI_DEPL_ABC + """ _o, r = loadTestReactor() cs = Settings() diff --git a/armi/physics/neutronics/tests/test_energyGroups.py b/armi/physics/neutronics/tests/test_energyGroups.py index 23f7d903f..6d789cd49 100644 --- a/armi/physics/neutronics/tests/test_energyGroups.py +++ b/armi/physics/neutronics/tests/test_energyGroups.py @@ -20,7 +20,12 @@ class TestEnergyGroups(unittest.TestCase): def test_invalidGroupStructureType(self): - """Test that the reverse lookup fails on non-existent energy group bounds.""" + """Test that the reverse lookup fails on non-existent energy group bounds. + + .. test:: Check the neutron energy group bounds logic fails correctly for the wrong structure. + :id: T_ARMI_EG_NE0 + :tests: R_ARMI_EG_NE + """ modifier = 1e-5 for groupStructureType in energyGroups.GROUP_STRUCTURE: energyBounds = energyGroups.getGroupStructure(groupStructureType) @@ -29,13 +34,11 @@ def test_invalidGroupStructureType(self): energyGroups.getGroupStructureType(energyBounds) def test_consistenciesBetweenGroupStructureAndGroupStructureType(self): - """ - Test that the reverse lookup of the energy group structures work. + """Test that the reverse lookup of the energy group structures work. - Notes - ----- - Several group structures point to the same energy group structure so the reverse lookup will fail to - get the correct group structure type. + .. test:: Check the neutron energy group bounds for a given group structure. + :id: T_ARMI_EG_NE1 + :tests: R_ARMI_EG_NE """ for groupStructureType in energyGroups.GROUP_STRUCTURE: self.assertEqual( @@ -44,3 +47,16 @@ def test_consistenciesBetweenGroupStructureAndGroupStructureType(self): energyGroups.getGroupStructure(groupStructureType) ), ) + + def test_getFastFluxGroupCutoff(self): + """Test ability to get the ARMI energy group index contained in energy threshold. + + .. test:: Return the energy group index which contains a given energy threshold. + :id: T_ARMI_EG_FE + :tests: R_ARMI_EG_FE + """ + group, frac = energyGroups.getFastFluxGroupCutoff( + [100002, 100001, 100000, 99999, 0] + ) + + self.assertListEqual([group, frac], [2, 0]) diff --git a/armi/physics/neutronics/tests/test_macroXSGenerationInterface.py b/armi/physics/neutronics/tests/test_macroXSGenerationInterface.py index fd12b9162..9214916a5 100644 --- a/armi/physics/neutronics/tests/test_macroXSGenerationInterface.py +++ b/armi/physics/neutronics/tests/test_macroXSGenerationInterface.py @@ -13,20 +13,51 @@ # limitations under the License. """MacroXSGenerationInterface tests.""" import unittest +from collections import defaultdict +from armi.nuclearDataIO import isotxs +from armi.nuclearDataIO.xsCollections import XSCollection from armi.physics.neutronics.macroXSGenerationInterface import ( MacroXSGenerationInterface, ) -from armi.reactor.tests.test_reactors import loadTestReactor +from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings from armi.settings import Settings +from armi.tests import ISOAA_PATH class TestMacroXSGenerationInterface(unittest.TestCase): - def test_macroXSGenerationInterface(self): + def test_macroXSGenerationInterfaceBasics(self): + """Test the macroscopic XS generating interfaces. + + .. test:: Build macroscopic cross sections for all blocks in the reactor. + :id: T_ARMI_MACRO_XS + :tests: R_ARMI_MACRO_XS + """ cs = Settings() _o, r = loadTestReactor() - i = MacroXSGenerationInterface(r, cs) + reduceTestReactorRings(r, cs, 2) - self.assertIsNone(i.macrosLastBuiltAt) + # Before: verify there are no macro XS on each block + for b in r.core.getBlocks(): + self.assertIsNone(b.macros) + + # create the macro XS interface + i = MacroXSGenerationInterface(r, cs) self.assertEqual(i.minimumNuclideDensity, 1e-15) self.assertEqual(i.name, "macroXsGen") + + # Mock up a nuclide library + mockLib = isotxs.readBinary(ISOAA_PATH) + mockLib.__dict__["_nuclides"] = defaultdict( + lambda: mockLib.__dict__["_nuclides"]["CAA"], mockLib.__dict__["_nuclides"] + ) + + # This is the meat of it: build the macro XS + self.assertIsNone(i.macrosLastBuiltAt) + i.buildMacros(mockLib, buildScatterMatrix=False) + self.assertEqual(i.macrosLastBuiltAt, 0) + + # After: verify there are macro XS on each block + for b in r.core.getBlocks(): + self.assertIsNotNone(b.macros) + self.assertTrue(isinstance(b.macros, XSCollection)) diff --git a/armi/physics/neutronics/tests/test_neutronicsPlugin.py b/armi/physics/neutronics/tests/test_neutronicsPlugin.py index dabeb0c2d..d10c510af 100644 --- a/armi/physics/neutronics/tests/test_neutronicsPlugin.py +++ b/armi/physics/neutronics/tests/test_neutronicsPlugin.py @@ -53,7 +53,7 @@ """ -class Test_NeutronicsPlugin(TestPlugin): +class TestNeutronicsPlugin(TestPlugin): plugin = neutronics.NeutronicsPlugin def setUp(self): diff --git a/armi/physics/safety/__init__.py b/armi/physics/safety/__init__.py index 82f82e49f..df073d1d2 100644 --- a/armi/physics/safety/__init__.py +++ b/armi/physics/safety/__init__.py @@ -20,4 +20,5 @@ class SafetyPlugin(plugins.ArmiPlugin): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" return [] diff --git a/armi/physics/tests/test_executers.py b/armi/physics/tests/test_executers.py index ae5512bc0..e4e9fefad 100644 --- a/armi/physics/tests/test_executers.py +++ b/armi/physics/tests/test_executers.py @@ -14,36 +14,33 @@ """This module provides tests for the generic Executers.""" import os +import subprocess import unittest +from armi.physics import executers from armi.reactor import geometry from armi.utils import directoryChangers -from armi.physics import executers -class MockReactorParams: +class MockParams: def __init__(self): self.cycle = 1 self.timeNode = 2 -class MockCoreParams: - pass - - class MockCore: def __init__(self): # just pick a random geomType self.geomType = geometry.GeomType.CARTESIAN self.symmetry = "full" - self.p = MockCoreParams() + self.p = MockParams() class MockReactor: def __init__(self): self.core = MockCore() self.o = None - self.p = MockReactorParams() + self.p = MockParams() class TestExecutionOptions(unittest.TestCase): @@ -106,3 +103,72 @@ def test_updateRunDir(self): self.executer.dcType = directoryChangers.ForcedCreationDirectoryChanger self.executer._updateRunDir("notThisString") self.assertEqual(self.executer.options.runDir, "runDir") + + def test_runExternalExecutable(self): + """Run an external executable with an Executer. + + .. test:: Run an external executable with an Executer. + :id: T_ARMI_EX + :tests: R_ARMI_EX + """ + filePath = "test_runExternalExecutable.py" + outFile = "tmp.txt" + label = "printExtraStuff" + + class MockExecutionOptions(executers.ExecutionOptions): + pass + + class MockExecuter(executers.Executer): + def run(self, args): + if self.options.label == label: + subprocess.run(["python", filePath, "extra stuff"]) + else: + subprocess.run(["python", filePath, args]) + + with directoryChangers.TemporaryDirectoryChanger(): + # build a mock external program (a little Python script) + self.__makeALittleTestProgram(filePath, outFile) + + # make sure the output file doesn't exist yet + self.assertFalse(os.path.exists(outFile)) + + # set up an executer for our little test program + opts = MockExecutionOptions() + exe = MockExecuter(opts, None) + exe.run("") + + # make sure the output file exists now + self.assertTrue(os.path.exists(outFile)) + + # run the executer with options + testString = "some options" + exe.run(testString) + + # make sure the output file exists now + self.assertTrue(os.path.exists(outFile)) + newTxt = open(outFile, "r").read() + self.assertIn(testString, newTxt) + + # now prove the options object can affect the execution + exe.options.label = label + exe.run("") + newerTxt = open(outFile, "r").read() + self.assertIn("extra stuff", newerTxt) + + @staticmethod + def __makeALittleTestProgram(filePath, outFile): + """Helper method to write a tiny Python script. + + We need "an external program" for testing. + """ + txt = f"""import sys + +def main(): + with open("{outFile}", "w") as f: + f.write(str(sys.argv)) + +if __name__ == "__main__": + main() +""" + with open(filePath, "w") as f: + f.write(txt) diff --git a/armi/physics/thermalHydraulics/plugin.py b/armi/physics/thermalHydraulics/plugin.py index 43366977a..0d675a054 100644 --- a/armi/physics/thermalHydraulics/plugin.py +++ b/armi/physics/thermalHydraulics/plugin.py @@ -52,6 +52,7 @@ def defineSettingsValidators(inspector): @staticmethod @plugins.HOOKIMPL def defineParameters(): + """Define additional parameters for the reactor data model.""" from armi.physics.thermalHydraulics import parameters return parameters.getParameterDefinitions() diff --git a/armi/plugins.py b/armi/plugins.py index d128bcddb..61d0830c5 100644 --- a/armi/plugins.py +++ b/armi/plugins.py @@ -89,7 +89,6 @@ deliberate design choice to keep the plugin system simple and to preclude a large class of potential bugs. At some point it may make sense to revisit this. - Other customization points -------------------------- While the Plugin API is the main place for ARMI framework customization, there are @@ -139,8 +138,22 @@ class ArmiPlugin: """ - An ArmiPlugin provides a namespace to collect hook implementations provided by a - single "plugin". This API is incomplete, unstable, and expected to change. + An ArmiPlugin exposes a collection of hooks that allow users to add a + variety of things to their ARMI application: Interfaces, parameters, + settings, flags, and much more. + + .. impl:: Plugins add code to the application through interfaces. + :id: I_ARMI_PLUGIN + :implements: R_ARMI_PLUGIN + + Each plugin has the option of implementing the ``exposeInterfaces`` method, and + this will be used as a plugin hook to add one or more Interfaces to the ARMI + Application. Interfaces can wrap external executables with nuclear modeling + codes in them, or directly implement their logic in Python. But because + Interfaces are Python code, they have direct access to read and write from + ARMI's reactor data model. This Plugin to multiple Interfaces to reactor data + model connection is the primary way that developers add code to an ARMI + application and simulation. """ @staticmethod @@ -149,6 +162,15 @@ def exposeInterfaces(cs) -> List: """ Function for exposing interface(s) to other code. + .. impl:: Plugins can add interfaces to the operator. + :id: I_ARMI_PLUGIN_INTERFACES + :implements: R_ARMI_PLUGIN_INTERFACES + + This method takes in a Settings object and returns a list of Interfaces, + the position of each Interface in the Interface stack, and a list of + arguments to pass to the Interface when initializing it later. These + Interfaces can then be used to add code to a simulation. + Returns ------- list @@ -167,13 +189,36 @@ def exposeInterfaces(cs) -> List: @HOOKSPEC def defineParameters() -> Dict: """ - Function for defining additional parameters. + Define additional parameters for the reactor data model. + + .. impl:: Plugins can add parameters to the reactor data model. + :id: I_ARMI_PLUGIN_PARAMS + :implements: R_ARMI_PLUGIN_PARAMS + + Through this method, plugin developers can create new Parameters. A + parameter can represent any physical property an analyst might want to + track. And they can be added at any level of the reactor data model. + Through this, the developers can extend ARMI and what physical properties + of the reactor they want to calculate, track, and store to the database. + + .. impl:: Define an arbitrary physical parameter. + :id: I_ARMI_PARAM + :implements: R_ARMI_PARAM + + Through this method, plugin developers can create new Parameters. A + parameter can represent any physical property an analyst might want to + track. For example, through this method, a plugin developer can add a new + thermodynamic property that adds a thermodynamic parameter to every block + in the reactor. Or they could add a neutronics parameter to every fuel + assembly. A parameter is quite generic. But these parameters will be + tracked in the reactor data model, extend what developers can do with ARMI, + and will be saved to the output database. Returns ------- dict Keys should be subclasses of ArmiObject, values being a - ParameterDefinitionCollection should be added to the key's perameter + ParameterDefinitionCollection should be added to the key's parameter definitions. Example @@ -228,18 +273,18 @@ def onProcessCoreLoading(core, cs, dbLoad) -> None: @HOOKSPEC def defineFlags() -> Dict[str, Union[int, flags.auto]]: """ - Function to provide new Flags definitions. + Add new flags to the reactor data model, and the simulation. - This allows a plugin to provide novel values for the Flags system. - Implementations should return a dictionary mapping flag names to their desired - numerical values. In most cases, no specific value is needed, in which case - :py:class:`armi.utils.flags.auto` should be used. + .. impl:: Plugins can define new, unique flags to the system. + :id: I_ARMI_FLAG_EXTEND1 + :implements: R_ARMI_FLAG_EXTEND - Flags should be added to the ARMI system with great care; flag values for each - object are stored in a bitfield, so each additional flag increases the width of - the data needed to store them. Also, due to the `what things are` interpretation - of flags (see :py:mod:`armi.reactor.flags`), new flags should probably refer to - novel design elements, rather than novel behaviors. + This method allows a plugin developers to provide novel values for + the Flags system. This method returns a dictionary mapping flag names + to their desired numerical values. In most cases, no specific value + is needed, one can be automatically generated using + :py:class:`armi.utils.flags.auto`. (For more information, see + :py:mod:`armi.reactor.flags`.) See Also -------- @@ -361,16 +406,22 @@ def defineSettings() -> List: """ Define configuration settings for this plugin. - This hook allows plugins to provide their own configuration settings, which can - participate in the :py:class:`armi.settings.caseSettings.CaseSettings`. Plugins - may provide entirely new settings to what are already provided by ARMI, as well - as new options or default values for existing settings. For instance, the - framework provides a ``neutronicsKernel`` setting for selecting which global - physics solver to use. Since we wish to enforce that the user specify a valid - kernel, the settings validator will check to make sure that the user's requested - kernel is among the available options. If a plugin were to provide a new - neutronics kernel (let's say MCNP), it should also define a new option to tell - the settings system that ``"MCNP"`` is a valid option. + .. impl:: Plugins can add settings to the run. + :id: I_ARMI_PLUGIN_SETTINGS + :implements: R_ARMI_PLUGIN_SETTINGS + + This hook allows plugin developers to provide their own configuration + settings, which can participate in the + :py:class:`armi.settings.caseSettings.Settings`. Plugins may provide + entirely new settings to what are already provided by ARMI, as well as + new options or default values for existing settings. For instance, the + framework provides a ``neutronicsKernel`` setting for selecting which + global physics solver to use. Since we wish to enforce that the user + specify a valid kernel, the settings validator will check to make sure + that the user's requested kernel is among the available options. If a + plugin were to provide a new neutronics kernel (let's say MCNP), it + should also define a new option to tell the settings system that + ``"MCNP"`` is a valid option. Returns ------- @@ -647,7 +698,7 @@ def __enforceLimitations(self): assert ( len(self.__class__.defineSettings()) == 0 ), "UserPlugins cannot define new Settings, consider using an ArmiPlugin." - # NOTE: These are the class methods that we are staunchly _not_ allowing people + # NOTE: These are the methods that we are staunchly _not_ allowing people # to change in this class. If you need these, please use a regular ArmiPlugin. self.defineParameterRenames = lambda: {} self.defineSettings = lambda: [] diff --git a/armi/reactor/__init__.py b/armi/reactor/__init__.py index dc3b1fdd8..a25977afc 100644 --- a/armi/reactor/__init__.py +++ b/armi/reactor/__init__.py @@ -20,7 +20,28 @@ .. _reactor-class-diagram: -.. pyreverse:: armi.reactor -A -k --ignore=complexShapes.py,grids.py,componentParameters.py,dodecaShapes.py,volumetricShapes.py,tests,converters,blockParameters.py,assemblyParameters.py,reactorParameters.py,batchParameters.py,basicShapes.py,shapes.py,zones.py,parameters,flags.py,geometry.py,blueprints,batch.py,assemblyLists.py,plugins.py +.. pyreverse:: armi.reactor -A -k --ignore= + assemblyLists.py, + assemblyParameters.py, + basicShapes.py, + batch.py, + batchParameters.py, + blockParameters.py, + blueprints, + complexShapes.py, + componentParameters.py, + converters, + dodecaShapes.py, + flags.py, + geometry.py, + grids.py, + parameters, + plugins.py, + reactorParameters.py, + shapes.py, + tests, + volumetricShapes.py, + zones.py :align: center :alt: Reactor class diagram :width: 90% diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py index 529a645ad..85f4cfea9 100644 --- a/armi/reactor/assemblies.py +++ b/armi/reactor/assemblies.py @@ -126,8 +126,7 @@ def renameBlocksAccordingToAssemblyNum(self): Notes ----- - You must run armi.reactor.reactors.Reactor.regenAssemblyLists after calling - this. + You must run armi.reactor.reactors.Reactor.regenAssemblyLists after calling this. """ assemNum = self.getNum() for bi, b in enumerate(self): @@ -170,12 +169,25 @@ def makeUnique(self): self.p.assemNum = randint(-9e12, -1) self.renumber(self.p.assemNum) - def add(self, obj): + def add(self, obj: blocks.Block): """ Add an object to this assembly. The simple act of adding a block to an assembly fully defines the location of the block in 3-D. + + .. impl:: Assemblies are made up of type Block. + :id: I_ARMI_ASSEM_BLOCKS + :implements: R_ARMI_ASSEM_BLOCKS + + Adds a unique Block to the top of the Assembly. If the Block already + exists in the Assembly, an error is raised in + :py:meth:`armi.reactor.composites.Composite.add`. + The spatialLocator of the Assembly is updated to account for + the new Block. In ``reestablishBlockOrder``, the Assembly spatialGrid + is reinitialized and Block-wise spatialLocator and name objects + are updated. The axial mesh and other Block geometry parameters are + updated in ``calculateZCoords``. """ composites.Composite.add(self, obj) obj.spatialLocator = self.spatialGrid[0, 0, len(self) - 1] @@ -212,12 +224,16 @@ def getLocation(self): """ Get string label representing this object's location. - Notes - ----- - This function (and its friends) were created before the advent of both the - grid/spatialLocator system and the ability to represent things like the SFP as - siblings of a Core. In future, this will likely be re-implemented in terms of - just spatialLocator objects. + .. impl:: Assembly location is retrievable. + :id: I_ARMI_ASSEM_POSI0 + :implements: R_ARMI_ASSEM_POSI + + This method returns a string label indicating the location + of an Assembly. There are three options: 1) the Assembly + is not within a Core object and is interpreted as in the + "load queue"; 2) the Assembly is within the spent fuel pool; + 3) the Assembly is within a Core object, so it has a physical + location within the Core. """ # just use ring and position, not axial (which is 0) if not self.parent: @@ -229,7 +245,16 @@ def getLocation(self): ) def coords(self): - """Return the location of the assembly in the plane using cartesian global coordinates.""" + """Return the location of the assembly in the plane using cartesian global + coordinates. + + .. impl:: Assembly coordinates are retrievable. + :id: I_ARMI_ASSEM_POSI1 + :implements: R_ARMI_ASSEM_POSI + + In this method, the spatialLocator of an Assembly is leveraged to return + its physical (x,y) coordinates in cm. + """ x, y, _z = self.spatialLocator.getGlobalCoordinates() return (x, y) @@ -238,6 +263,15 @@ def getArea(self): Return the area of the assembly by looking at its first block. The assumption is that all blocks in an assembly have the same area. + Calculate the total assembly volume in cm^3. + + .. impl:: Assembly area is retrievable. + :id: I_ARMI_ASSEM_DIMS0 + :implements: R_ARMI_ASSEM_DIMS + + Returns the area of the first block in the Assembly. If there are no + blocks in the Assembly, a warning is issued and a default area of 1.0 + is returned. """ try: return self[0].getArea() @@ -248,7 +282,16 @@ def getArea(self): return 1.0 def getVolume(self): - """Calculate the total assembly volume in cm^3.""" + """Calculate the total assembly volume in cm^3. + + .. impl:: Assembly volume is retrievable. + :id: I_ARMI_ASSEM_DIMS1 + :implements: R_ARMI_ASSEM_DIMS + + The volume of the Assembly is calculated as the product of the + area of the first block (via ``getArea``) and the total height + of the assembly (via ``getTotalHeight``). + """ return self.getArea() * self.getTotalHeight() def getPinPlenumVolumeInCubicMeters(self): @@ -259,7 +302,9 @@ def getPinPlenumVolumeInCubicMeters(self): ----- If there is no plenum blocks in the assembly, a plenum volume of 0.0 is returned - .. warning:: This is a bit design-specific for pinned assemblies + Warning + ------- + This is a bit design-specific for pinned assemblies """ plenumBlocks = self.getBlocks(Flags.PLENUM) @@ -298,8 +343,9 @@ def doubleResolution(self): ----- Used for mesh sensitivity studies. - .. warning:: This is likely destined for a geometry converter rather than - this instance method. + Warning + ------- + This is likely destined for a geometry converter rather than this instance method. """ newBlockStack = [] topIndex = -1 @@ -368,6 +414,7 @@ def adjustResolution(self, refA): newBlocks -= ( 1 # subtract one because we eliminated the original b completely. ) + self.removeAll() self.spatialGrid = grids.axialUnitGrid(len(newBlockStack)) for b in newBlockStack: @@ -395,7 +442,6 @@ def getAxialMesh(self, centers=False, zeroAtFuel=False): armi.reactor.reactors.Reactor.findAllAxialMeshPoints : gets a global list of all of these, plus finer res. - """ bottom = 0.0 meshVals = [] @@ -449,6 +495,14 @@ def getTotalHeight(self, typeSpec=None): """ Determine the height of this assembly in cm. + .. impl:: Assembly height is retrievable. + :id: I_ARMI_ASSEM_DIMS2 + :implements: R_ARMI_ASSEM_DIMS + + The height of the Assembly is calculated by taking the sum of the + constituent Blocks. If a ``typeSpec`` is provided, the total height + of the blocks containing Flags that match the ``typeSpec`` is returned. + Parameters ---------- typeSpec : See :py:meth:`armi.composites.Composite.hasFlags` @@ -457,7 +511,6 @@ def getTotalHeight(self, typeSpec=None): ------- height : float the height in cm - """ h = 0.0 for b in self: @@ -509,7 +562,6 @@ def getElevationBoundariesByBlockType(self, blockType=None): elevation : list of floats Every float in the list is an elevation of a block boundary for the block type specified (has duplicates) - """ elevation, elevationsWithBlockBoundaries = 0.0, [] @@ -812,8 +864,8 @@ def getBlocksAndZ(self, typeSpec=None, returnBottomZ=False, returnTopZ=False): Examples -------- - for block, bottomZ in a.getBlocksAndZ(returnBottomZ=True): - print({0}'s bottom mesh point is {1}'.format(block, bottomZ)) + for block, bottomZ in a.getBlocksAndZ(returnBottomZ=True): + print({0}'s bottom mesh point is {1}'.format(block, bottomZ)) """ if returnBottomZ and returnTopZ: raise ValueError("Both returnTopZ and returnBottomZ are set to `True`") @@ -837,10 +889,9 @@ def getBlocksAndZ(self, typeSpec=None, returnBottomZ=False, returnTopZ=False): return zip(blocks, zCoords) def hasContinuousCoolantChannel(self): - for b in self.getBlocks(): - if not b.containsAtLeastOneChildWithFlags(Flags.COOLANT): - return False - return True + return all( + b.containsAtLeastOneChildWithFlags(Flags.COOLANT) for b in self.getBlocks() + ) def getFirstBlock(self, typeSpec=None, exact=False): bs = self.getBlocks(typeSpec, exact=exact) @@ -930,9 +981,9 @@ def getBlocksBetweenElevations(self, zLower, zUpper): Examples -------- If the block structure looks like: - 50.0 to 100.0 Block3 - 25.0 to 50.0 Block2 - 0.0 to 25.0 Block1 + 50.0 to 100.0 Block3 + 25.0 to 50.0 Block2 + 0.0 to 25.0 Block1 Then, @@ -1125,7 +1176,7 @@ def getParamOfZFunction(self, param, interpType="linear", fillValue=numpy.NaN): def reestablishBlockOrder(self): """ - After children have been mixed up axially, this re-locates each block with the proper axial mesh. + The block ordering has changed, so the spatialGrid and Block-wise spatialLocator and name objects need updating. See Also -------- @@ -1157,10 +1208,21 @@ def countBlocksWithFlags(self, blockTypeSpec=None): def getDim(self, typeSpec, dimName): """ - Search through blocks in this assembly and find the first component of compName. - Then, look on that component for dimName. + With a preference for fuel blocks, find the first component in the Assembly with + flags that match ``typeSpec`` and return dimension as specified by ``dimName``. Example: getDim(Flags.WIRE, 'od') will return a wire's OD in cm. + + .. impl:: Assembly dimensions are retrievable. + :id: I_ARMI_ASSEM_DIMS3 + :implements: R_ARMI_ASSEM_DIMS + + This method searches for the first Component that matches the + given ``typeSpec`` and returns the dimension as specified by + ``dimName``. There is a hard-coded preference for Components + to be within fuel Blocks. If there are no Blocks, then ``None`` + is returned. If ``typeSpec`` is not within the first Block, an + error is raised within :py:meth:`~armi.reactor.blocks.Block.getDim`. """ # prefer fuel blocks. bList = self.getBlocks(Flags.FUEL) @@ -1181,20 +1243,36 @@ def getSymmetryFactor(self): return self[0].getSymmetryFactor() def rotate(self, rad): - """Rotates the spatial variables on an assembly the specified angle. + """Rotates the spatial variables on an assembly by the specified angle. + + Each Block on the Assembly is rotated in turn. - Each block on the assembly is rotated in turn. + .. impl:: An assembly can be rotated about its z-axis. + :id: I_ARMI_SHUFFLE_ROTATE + :implements: R_ARMI_SHUFFLE_ROTATE + + This method loops through every ``Block`` in this ``Assembly`` and rotates + it by a given angle (in radians). The rotation angle is positive in the + counter-clockwise direction, and must be divisible by increments of PI/6 + (60 degrees). To actually perform the ``Block`` rotation, the + :py:meth:`armi.reactor.blocks.Block.rotate` method is called. Parameters ---------- rad: float number (in radians) specifying the angle of counter clockwise rotation + + Warning + ------- + rad must be in 60-degree increments! (i.e., PI/6, PI/3, PI, 2 * PI/3, etc) """ for b in self.getBlocks(): b.rotate(rad) class HexAssembly(Assembly): + """Placeholder, so users can explicitly define a hex-based Assembly.""" + pass @@ -1208,7 +1286,9 @@ class RZAssembly(Assembly): HexAssembly because they use different locations and need to have Radial Meshes in their setting. - note ThRZAssemblies should be a subclass of Assemblies (similar to Hex-Z) because + Notes + ----- + ThRZAssemblies should be a subclass of Assemblies (similar to Hex-Z) because they should have a common place to put information about subdividing the global mesh for transport - this is similar to how blocks have 'AxialMesh' in their blocks. """ @@ -1219,7 +1299,7 @@ def __init__(self, name, assemNum=None): def radialOuter(self): """ - returns the outer radial boundary of this assembly. + Returns the outer radial boundary of this assembly. See Also -------- @@ -1266,8 +1346,8 @@ class ThRZAssembly(RZAssembly): Notes ----- - This is a subclass of RZAssemblies, which is its a subclass of the Generics Assembly - Object + This is a subclass of RZAssemblies, which is itself a subclass of the generic + Assembly class. """ def __init__(self, assemType, assemNum=None): diff --git a/armi/reactor/assemblyParameters.py b/armi/reactor/assemblyParameters.py index 9a4bfa8ff..5e5ff700b 100644 --- a/armi/reactor/assemblyParameters.py +++ b/armi/reactor/assemblyParameters.py @@ -319,40 +319,12 @@ def _enforceNotesRestrictions(self, value): pb.defParam("assemNum", units=units.UNITLESS, description="Assembly number") - pb.defParam( - "axExpWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Axial swelling reactivity", - location=ParamLocation.AVERAGE, - ) - - pb.defParam( - "coolFlowingWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Flowing coolant reactivity", - location=ParamLocation.AVERAGE, - ) - - pb.defParam( - "coolWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Coolant reactivity", - location=ParamLocation.AVERAGE, - ) - pb.defParam( "dischargeTime", units=units.YEARS, description="Time the Assembly was removed from the Reactor.", ) - pb.defParam( - "fuelWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Fuel reactivity", - location=ParamLocation.AVERAGE, - ) - pb.defParam( "hotChannelFactors", units=units.UNITLESS, @@ -362,20 +334,6 @@ def _enforceNotesRestrictions(self, value): categories=[parameters.Category.assignInBlueprints], ) - pb.defParam( - "radExpWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Radial swelling reactivity", - location=ParamLocation.AVERAGE, - ) - - pb.defParam( - "structWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Structure reactivity", - location=ParamLocation.AVERAGE, - ) - with pDefs.createBuilder(categories=["radialGeometry"]) as pb: pb.defParam( diff --git a/armi/reactor/blockParameters.py b/armi/reactor/blockParameters.py index 478c2cda8..fe1eb895c 100644 --- a/armi/reactor/blockParameters.py +++ b/armi/reactor/blockParameters.py @@ -429,187 +429,6 @@ def xsTypeNum(self, value): saveToDB=True, ) - with pDefs.createBuilder( - default=0.0, - location=ParamLocation.AVERAGE, - categories=["reactivity coefficients"], - ) as pb: - - pb.defParam( - "VoideddopplerWorth", - units=f"{units.REACTIVITY}*{units.DEGK}^(n-1)", - description="Distributed Voided Doppler constant.", - ) - - pb.defParam( - "dopplerWorth", - units=f"{units.REACTIVITY}*{units.DEGK}^(n-1)", - description="Distributed Doppler constant.", - ) - - pb.defParam( - "fuelWorth", - units=f"{units.REACTIVITY}/{units.KG})", - description="Reactivity worth of fuel material per unit mass", - ) - - pb.defParam( - "fuelWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Fuel reactivity", - ) - - pb.defParam( - "structWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Structure reactivity", - ) - - pb.defParam( - "radExpWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Radial swelling reactivity", - ) - - pb.defParam( - "coolWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Coolant reactivity", - ) - - pb.defParam( - "coolFlowingWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Flowing coolant reactivity", - ) - - pb.defParam( - "axExpWorthPT", - units=f"{units.PCM}/{units.PERCENT}/{units.CM}^3", - description="Axial swelling reactivity", - ) - - pb.defParam( - "coolantWorth", - units=f"{units.REACTIVITY}/{units.KG})", - description="Reactivity worth of coolant material per unit mass", - ) - - pb.defParam( - "cladWorth", - units=f"{units.REACTIVITY}/{units.KG})", - description="Reactivity worth of clad material per unit mass", - ) - - pb.defParam( - "rxAxialCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Axial temperature reactivity coefficient", - ) - - pb.defParam( - "rxAxialCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Axial power reactivity coefficient", - ) - - pb.defParam( - "rxCoolantCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Coolant temperature reactivity coefficient", - ) - - pb.defParam( - "rxCoolantCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Coolant power reactivity coefficient", - ) - - pb.defParam( - "rxDopplerCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Doppler temperature reactivity coefficient", - ) - - pb.defParam( - "rxDopplerCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Doppler power reactivity coefficient", - ) - - pb.defParam( - "rxFuelCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Fuel temperature reactivity coefficient", - ) - - pb.defParam( - "rxFuelCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Fuel power reactivity coefficient", - ) - - pb.defParam( - "rxNetCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Net temperature reactivity coefficient", - ) - - pb.defParam( - "rxNetCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Net power reactivity coefficient", - location=ParamLocation.AVERAGE, - ) - - pb.defParam( - "rxNetPosNeg", - units=f"{units.CENTS}/{units.DEGK}", - description="Net temperature reactivity coefficient: positive or negative", - ) - - pb.defParam( - "rxNetPosNegPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Net power reactivity coefficient: positive or negative", - ) - - pb.defParam( - "rxRadialCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Radial temperature reactivity coefficient", - ) - - pb.defParam( - "rxRadialCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Radial power reactivity coefficient", - ) - - pb.defParam( - "rxStructCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Structure temperature reactivity coefficient", - ) - - pb.defParam( - "rxStructCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Structure power reactivity coefficient", - ) - - pb.defParam( - "rxVoidedDopplerCentsPerK", - units=f"{units.CENTS}/{units.DEGK}", - description="Voided Doppler temperature reactivity coefficient", - ) - - pb.defParam( - "rxVoidedDopplerCentsPerPow", - units=f"{units.CENTS}/{units.DEGK}", - description="Voided Doppler power reactivity coefficient", - ) - with pDefs.createBuilder( default=0.0, location=ParamLocation.AVERAGE, @@ -623,83 +442,83 @@ def xsTypeNum(self, value): # FUEL COEFFICIENTS pb.defParam( "rxFuelDensityCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Fuel Density Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Fuel density coefficient", ) pb.defParam( "rxFuelDopplerConstant", units=f"{units.REACTIVITY}*{units.DEGK}^(n-1)", - description="Fuel Doppler Constant", + description="Fuel Doppler constant", ) pb.defParam( "rxFuelVoidedDopplerConstant", units=f"{units.REACTIVITY}*{units.DEGK}^(n-1)", - description="Fuel Voided-Coolant Constant", + description="Fuel voided-coolant Doppler constant", ) pb.defParam( "rxFuelTemperatureCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Fuel Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Fuel temperature coefficient", ) pb.defParam( "rxFuelVoidedTemperatureCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Fuel Voided-Coolant Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Fuel voided-coolant temperature coefficient", ) # CLAD COEFFICIENTS pb.defParam( "rxCladDensityCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Clad Density Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Clad density coefficient", ) pb.defParam( "rxCladDopplerConstant", units=f"{units.REACTIVITY}*{units.DEGK}^(n-1)", - description="Clad Doppler Constant", + description="Clad Doppler constant", ) pb.defParam( "rxCladTemperatureCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Clad Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Clad temperature coefficient", ) # STRUCTURE COEFFICIENTS pb.defParam( "rxStructureDensityCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Structure Density Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Structure density coefficient", ) pb.defParam( "rxStructureDopplerConstant", units=f"{units.REACTIVITY}*{units.DEGK}^(n-1)", - description="Structure Doppler Constant", + description="Structure Doppler constant", ) pb.defParam( "rxStructureTemperatureCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Structure Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Structure temperature coefficient", ) # COOLANT COEFFICIENTS pb.defParam( "rxCoolantDensityCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Coolant Density Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Coolant density coefficient", ) pb.defParam( "rxCoolantTemperatureCoeffPerMass", - units=f"{units.REACTIVITY}/{units.KG})", - description="Coolant Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.KG}", + description="Coolant temperature coefficient", ) with pDefs.createBuilder( @@ -715,83 +534,83 @@ def xsTypeNum(self, value): # FUEL COEFFICIENTS pb.defParam( "rxFuelDensityCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Fuel Density Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Fuel density coefficient", ) pb.defParam( "rxFuelDopplerCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Fuel Doppler Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Fuel Doppler coefficient", ) pb.defParam( "rxFuelVoidedDopplerCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Fuel Voided-Coolant Doppler Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Fuel voided-coolant Doppler coefficient", ) pb.defParam( "rxFuelTemperatureCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Fuel Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Fuel temperature coefficient", ) pb.defParam( "rxFuelVoidedTemperatureCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Fuel Voided-Coolant Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Fuel voided-coolant temperature coefficient", ) # CLAD COEFFICIENTS pb.defParam( "rxCladDensityCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Clad Density Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Clad density coefficient", ) pb.defParam( "rxCladDopplerCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Clad Doppler Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Clad Doppler coefficient", ) pb.defParam( "rxCladTemperatureCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Clad Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Clad temperature coefficient", ) # STRUCTURE COEFFICIENTS pb.defParam( "rxStructureDensityCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Structure Density Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Structure density coefficient", ) pb.defParam( "rxStructureDopplerCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Structure Doppler Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Structure Doppler coefficient", ) pb.defParam( "rxStructureTemperatureCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Structure Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Structure temperature coefficient", ) # COOLANT COEFFICIENTS pb.defParam( "rxCoolantDensityCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Coolant Density Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Coolant density coefficient", ) pb.defParam( "rxCoolantTemperatureCoeffPerTemp", - units=f"{units.REACTIVITY}/{units.DEGK})", - description="Coolant Temperature Coefficient", + units=f"{units.REACTIVITY}/{units.DEGK}", + description="Coolant temperature coefficient", ) with pDefs.createBuilder(default=0.0) as pb: @@ -883,13 +702,6 @@ def xsTypeNum(self, value): location=ParamLocation.AVERAGE, ) - pb.defParam( - "coolRemFrac", - units=units.UNITLESS, - description="Fractional sodium density change for each block", - location=ParamLocation.AVERAGE, - ) - pb.defParam( "crWastage", units=units.MICRONS, @@ -904,27 +716,6 @@ def xsTypeNum(self, value): location=ParamLocation.AVERAGE, ) - pb.defParam( - "deltaTclad", - units=f"{units.DEGK}/{units.PERCENT}", - description=r"Change in fuel temperature due to 1% rise in power.", - location=ParamLocation.AVERAGE, - ) - - pb.defParam( - "deltaTduct", - units=f"{units.DEGK}/{units.PERCENT}", - description=r"Change in fuel temperature due to 1% rise in power.", - location=ParamLocation.AVERAGE, - ) - - pb.defParam( - "deltaTfuel", - units=f"{units.DEGK}/{units.PERCENT}", - description=r"Change in fuel temperature due to 1% rise in power.", - location=ParamLocation.AVERAGE, - ) - pb.defParam( "heightBOL", units=units.CM, diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index 4cbc9dd26..fcc75f3c3 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -157,7 +157,7 @@ def __deepcopy__(self, memo): return b - def _createHomogenizedCopy(self, pinSpatialLocators=False): + def createHomogenizedCopy(self, pinSpatialLocators=False): """ Create a copy of a block. @@ -581,7 +581,23 @@ def adjustUEnrich(self, newEnrich): self.completeInitialLoading() def getLocation(self): - """Return a string representation of the location.""" + """Return a string representation of the location. + + .. impl:: Location of a block is retrievable. + :id: I_ARMI_BLOCK_POSI0 + :implements: R_ARMI_BLOCK_POSI + + If the block does not have its ``core`` attribute set, if the block's + parent does not have a ``spatialGrid`` attribute, or if the block + does not have its location defined by its ``spatialLocator`` attribute, + return a string indicating that it is outside of the core. + + Otherwise, use the :py:class:`~armi.reactor.grids.Grid.getLabel` static + method to convert the block's indices into a string like "XXX-YYY-ZZZ". + For hexagonal geometry, "XXX" is the zero-padded hexagonal core ring, + "YYY" is the zero-padded position in that ring, and "ZZZ" is the zero-padded + block axial index from the bottom of the core. + """ if self.core and self.parent.spatialGrid and self.spatialLocator: return self.core.spatialGrid.getLabel( self.spatialLocator.getCompleteIndices() @@ -589,13 +605,23 @@ def getLocation(self): else: return "ExCore" - def coords(self, rotationDegreesCCW=0.0): - if rotationDegreesCCW: - raise NotImplementedError("Cannot get coordinates with rotation.") + def coords(self): + """ + Returns the coordinates of the block. + + .. impl:: Coordinates of a block are queryable. + :id: I_ARMI_BLOCK_POSI1 + :implements: R_ARMI_BLOCK_POSI + + Calls to the :py:meth:`~armi.reactor.grids.locations.IndexLocation.getGlobalCoordinates` + method of the block's ``spatialLocator`` attribute, which recursively + calls itself on all parents of the block to get the coordinates of the + block's centroid in 3D cartesian space. + """ return self.spatialLocator.getGlobalCoordinates() def setBuLimitInfo(self): - r"""Sets burnup limit based on igniter, feed, etc.""" + """Sets burnup limit based on igniter, feed, etc.""" if self.p.buRate == 0: # might be cycle 1 or a non-burning block self.p.timeToLimit = 0.0 @@ -670,6 +696,16 @@ def getVolume(self): """ Return the volume of a block. + .. impl:: Volume of block is retrievable. + :id: I_ARMI_BLOCK_DIMS0 + :implements: R_ARMI_BLOCK_DIMS + + Loops over all the components in the block, calling + :py:meth:`~armi.reactor.components.component.Component.getVolume` on + each and summing the result. The summed value is then divided by + the symmetry factor of the block to account for reduced volumes of + blocks in certain symmetric representations. + Returns ------- volume : float @@ -1065,7 +1101,24 @@ def getSortedComponentsInsideOfComponent(self, component): return sortedComponents def getNumPins(self): - """Return the number of pins in this block.""" + """Return the number of pins in this block. + + .. impl:: Get the number of pins in a block. + :id: I_ARMI_BLOCK_NPINS + :implements: R_ARMI_BLOCK_NPINS + + Uses some simple criteria to infer the number of pins in the block. + + For every flag in the module list :py:data:`~armi.reactor.blocks.PIN_COMPONENTS`, + loop over all components of that type in the block. If the component + is an instance of :py:class:`~armi.reactor.components.basicShapes.Circle`, + add its multiplicity to a list, and sum that list over all components + with each given flag. + + After looping over all possibilities, return the maximum value returned + from the process above, or if no compatible components were found, + return zero. + """ nPins = [ sum( [ @@ -1218,6 +1271,21 @@ def getPitch(self, returnComp=False): """ Return the center-to-center hex pitch of this block. + .. impl:: Pitch of block is retrievable. + :id: I_ARMI_BLOCK_DIMS1 + :implements: R_ARMI_BLOCK_DIMS + + Uses the block's ``_pitchDefiningComponent`` to identify the component + in the block that defines the pitch. Then uses the + :py:meth:`~armi.reactor.components.component.Component.getPitchData` + method of that component to return the pitch for the block, accounting + for the component's current temperature. + + The ``_pitchDefiningComponent`` attribute can be set by + :py:meth:`~armi.reactor.blocks.Block.setPitch`, but is typically + set via a calls to :py:meth:`~armi.reactor.blocks.Block._updatePitchComponent` + as components are added to the block with :py:meth:`~armi.reactor.blocks.Block.add`. + Parameters ---------- returnComp : bool, optional @@ -1242,7 +1310,6 @@ def getPitch(self, returnComp=False): See Also -------- setPitch : sets pitch - """ c, _p = self._pitchDefiningComponent if c is None: @@ -1529,14 +1596,26 @@ def rotate(self, rad): Parameters ---------- - rad - float - number (in radians) specifying the angle of counter clockwise rotation + rad: float + Number (in radians) specifying the angle of counter clockwise rotation. """ raise NotImplementedError def setAxialExpTargetComp(self, targetComponent): """Sets the targetComponent for the axial expansion changer. + .. impl:: Set the target axial expansion components on a given block. + :id: I_ARMI_MANUAL_TARG_COMP + :implements: R_ARMI_MANUAL_TARG_COMP + + Sets the ``axialExpTargetComponent`` parameter on the block to the name + of the Component which is passed in. This is then used by the + :py:class:`~armi.reactor.converters.axialExpansionChanger.AxialExpansionChanger` + class during axial expansion. + + This method is typically called from within :py:meth:`~armi.reactor.blueprints.blockBlueprint.BlockBlueprint.construct` + during the process of building a Block from the blueprints. + Parameter --------- targetComponent: :py:class:`Component <armi.reactor.components.component.Component>` object @@ -1576,13 +1655,40 @@ def getPinCoordinates(self): class HexBlock(Block): + """ + Defines a HexBlock. + + .. impl:: ARMI has the ability to create hex shaped blocks. + :id: I_ARMI_BLOCK_HEX + :implements: R_ARMI_BLOCK_HEX + + This class defines hexagonal-shaped Blocks. It inherits functionality from the parent + class, Block, and defines hexagonal-specific methods including, but not limited to, + querying pin pitch, pin linear power densities, hydraulic diameter, and retrieving + inner and outer pitch. + """ PITCH_COMPONENT_TYPE: ClassVar[_PitchDefiningComponent] = (components.Hexagon,) def __init__(self, name, height=1.0): Block.__init__(self, name, height) - def coords(self, rotationDegreesCCW=0.0): + def coords(self): + """ + Returns the coordinates of the block. + + .. impl:: Coordinates of a block are queryable. + :id: I_ARMI_BLOCK_POSI2 + :implements: R_ARMI_BLOCK_POSI + + Calls to the :py:meth:`~armi.reactor.grids.locations.IndexLocation.getGlobalCoordinates` + method of the block's ``spatialLocator`` attribute, which recursively + calls itself on all parents of the block to get the coordinates of the + block's centroid in 3D cartesian space. + + Will additionally adjust the x and y coordinates based on the block + parameters ``displacementX`` and ``displacementY``. + """ x, y, _z = self.spatialLocator.getGlobalCoordinates() x += self.p.displacementX * 100.0 y += self.p.displacementY * 100.0 @@ -1591,10 +1697,23 @@ def coords(self, rotationDegreesCCW=0.0): round(y, units.FLOAT_DIMENSION_DECIMALS), ) - def _createHomogenizedCopy(self, pinSpatialLocators=False): + def createHomogenizedCopy(self, pinSpatialLocators=False): """ Create a new homogenized copy of a block that is less expensive than a full deepcopy. + .. impl:: Block compositions can be homogenized. + :id: I_ARMI_BLOCK_HOMOG + :implements: R_ARMI_BLOCK_HOMOG + + This method creates and returns a homogenized representation of itself in the form of a new Block. + The homogenization occurs in the following manner. A single Hexagon Component is created + and added to the new Block. This Hexagon Component is given the + :py:class:`armi.materials.mixture._Mixture` material and a volume averaged temperature + (``getAverageTempInC``). The number densities of the original Block are also stored on + this new Component (:need:`I_ARMI_CMP_GET_NDENS`). Several parameters from the original block + are copied onto the homogenized block (e.g., macros, lumped fission products, burnup group, + number of pins, and spatial grid). + Notes ----- This can be used to improve performance when a new copy of a reactor needs to be @@ -1619,6 +1738,12 @@ def _createHomogenizedCopy(self, pinSpatialLocators=False): .. note: If you make a new block, you must add it to an assembly and a reactor. + Returns + ------- + b + A homogenized block containing a single Hexagon Component that contains an + average temperature and the number densities from the original block. + See Also -------- armi.reactor.converters.uniformMesh.UniformMeshGeometryConverter.makeAssemWithUniformMesh @@ -1673,21 +1798,55 @@ def _createHomogenizedCopy(self, pinSpatialLocators=False): return b def getMaxArea(self): - """Compute the max area of this block if it was totally full.""" + """ + Compute the max area of this block if it was totally full. + + .. impl:: Area of block is retrievable. + :id: I_ARMI_BLOCK_DIMS2 + :implements: R_ARMI_BLOCK_DIMS + + This method first retrieves the pitch of the hexagonal Block + (:need:`I_ARMI_UTIL_HEXAGON0`) and then leverages the + area calculation via :need:`I_ARMI_UTIL_HEXAGON0`. + + """ pitch = self.getPitch() if not pitch: return 0.0 return hexagon.area(pitch) def getDuctIP(self): + """ + Returns the duct IP dimension. + + .. impl:: IP dimension is retrievable. + :id: I_ARMI_BLOCK_DIMS3 + :implements: R_ARMI_BLOCK_DIMS + + This method retrieves the duct Component and quieries + it's inner pitch directly. If the duct is missing or if there + are multiple duct Components, an error will be raised. + """ duct = self.getComponent(Flags.DUCT, exact=True) return duct.getDimension("ip") def getDuctOP(self): + """ + Returns the duct OP dimension. + + .. impl:: OP dimension is retrievable. + :id: I_ARMI_BLOCK_DIMS4 + :implements: R_ARMI_BLOCK_DIMS + + This method retrieves the duct Component and quieries + its outer pitch directly. If the duct is missing or if there + are multiple duct Components, an error will be raised. + """ duct = self.getComponent(Flags.DUCT, exact=True) return duct.getDimension("op") def initializePinLocations(self): + """Initialize pin locations.""" nPins = self.getNumPins() self.p.pinLocation = list(range(1, nPins + 1)) @@ -1884,7 +2043,7 @@ def rotatePins(self, rotNum, justCompute=False): Examples -------- - rotateIndexLookup[i_after_rotation-1] = i_before_rotation-1 + rotateIndexLookup[i_after_rotation-1] = i_before_rotation-1 """ if not 0 <= rotNum <= 5: raise ValueError( @@ -1983,6 +2142,18 @@ def getPinToDuctGap(self, cold=False): """ Returns the distance in cm between the outer most pin and the duct in a block. + .. impl:: Pin to duct gap of block is retrievable. + :id: I_ARMI_BLOCK_DIMS5 + :implements: R_ARMI_BLOCK_DIMS + + Requires that the outer most duct be Hexagonal and wire and clad Components + be present. The flat-to-flat distance between the radial exterior of opposing + pins in the outermost ring is computed by computing the distance between + pin centers (``getPinCenterFlatToFlat``) and adding the outer diameter of + the clad Component and the outer diameter of the wire Component twice. The + total margin between the inner pitch of the duct Component and the wire is then + computed. The pin to duct gap is then half this distance. + Parameters ---------- cold : boolean @@ -2115,7 +2286,7 @@ def autoCreateSpatialGrids(self): # note that it's the pointed end of the cell hexes that are up (but the # macro shape of the pins forms a hex with a flat top fitting in the assembly) grid = grids.HexGrid.fromPitch( - self.getPinPitch(cold=True), numRings=0, pointedEndUp=True + self.getPinPitch(cold=True), numRings=0, cornersUp=True ) spatialLocators = grids.MultiIndexLocation(grid=self.spatialGrid) numLocations = 0 @@ -2163,6 +2334,15 @@ def getPinPitch(self, cold=False): Assumes that the pin pitch is defined entirely by contacting cladding tubes and wire wraps. Grid spacers not yet supported. + .. impl:: Pin pitch within block is retrievable. + :id: I_ARMI_BLOCK_DIMS6 + :implements: R_ARMI_BLOCK_DIMS + + This implementation requires that clad and wire Components are present. + If not, an error is raised. If present, the pin pitch is calculated + as the sum of the outer diameter of the clad and outer diameter of + the wire. + Parameters ---------- cold : boolean @@ -2194,7 +2374,42 @@ def getPinPitch(self, cold=False): ) def getWettedPerimeter(self): - """Return the total wetted perimeter of the block in cm.""" + r"""Return the total wetted perimeter of the block in cm. + + .. impl:: Wetted perimeter of block is retrievable. + :id: I_ARMI_BLOCK_DIMS7 + :implements: R_ARMI_BLOCK_DIMS + + This implementation computes wetted perimeters for specific Components, as specified + by their Flags (:need:`R_ARMI_FLAG_DEFINE`). Hollow hexagons and circular pin Components + are supported. The latter supports both instances where the exterior is wetted + (e.g., clad, wire) as well as when the interior and exterior are wetted (hollow circle). + + Hollow hexagons are calculated via, + + .. math:: + + \frac{6 \times \text{ip}}{\sqrt{3}}, + + where :math:`\text{ip}` is the inner pitch of the hollow hexagon. Circular pin Components + where the exterior is wetted is calculated via, + + .. math:: + + N \pi \left( \text{OD}_c + \text{OD}_w \right), + + where :math:`N` is the total number of pins, :math:`\text{OD}_c` is the outer diameter + of the clad, and :math:`\text{OD}_w` is the outer diameter of the wire, respectively. + When both the interior and exterior are wetted, the wetted perimeter is calculated as + + .. math:: + + \pi \left( \text{OD} + \text{ID} \right), + + where :math:`\text{OD}` and :math:`\text{ID}` are the outer and inner diameters of the pin + Component, respectively. + + """ # flags pertaining to hexagon components where the interior of the hexagon is wetted wettedHollowHexagonComponentFlags = ( Flags.DUCT, @@ -2267,11 +2482,19 @@ def getWettedPerimeter(self): ) def getFlowArea(self): - """Return the total flowing coolant area of the block in cm^2.""" + """Return the total flowing coolant area of the block in cm^2. + + .. impl:: Flow area of block is retrievable. + :id: I_ARMI_BLOCK_DIMS8 + :implements: R_ARMI_BLOCK_DIMS + + Retrieving the flow area requires that there be a single coolant Component. + If available, the area is calculated (:need:`I_ARMI_COMP_VOL0`). + """ return self.getComponent(Flags.COOLANT, exact=True).getArea() def getHydraulicDiameter(self): - """ + r""" Return the hydraulic diameter in this block in cm. Hydraulic diameter is 4A/P where A is the flow area and P is the wetted perimeter. @@ -2279,11 +2502,18 @@ def getHydraulicDiameter(self): inside of the duct. The flow area is the inner area of the duct minus the area of the pins and the wire. - To convert the inner hex pitch into a perimeter, first convert to side, then - multiply by 6. + .. impl:: Hydraulic diameter of block is retrievable. + :id: I_ARMI_BLOCK_DIMS9 + :implements: R_ARMI_BLOCK_DIMS + + The hydraulic diamter is calculated via + + .. math:: + + 4\frac{A}{P}, - p = sqrt(3)*s - l = 6*p/sqrt(3) + where :math:`A` is the flow area (:need:`I_ARMI_BLOCK_DIMS8`) and :math:`P` is the + wetted perimeter (:need:`I_ARMI_BLOCK_DIMS7`). """ return 4.0 * self.getFlowArea() / self.getWettedPerimeter() diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index d0b26a9fb..1b6f4da70 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -138,7 +138,7 @@ def loadFromCs(cs, roundTrip=False): - """Function to load Blueprints based on supplied ``CaseSettings``.""" + """Function to load Blueprints based on supplied ``Settings``.""" from armi.utils import directoryChangers with directoryChangers.DirectoryChanger(cs.inputDirectory, dumpOnException=False): @@ -259,7 +259,7 @@ def constructAssem(self, cs, name=None, specifier=None): Parameters ---------- - cs : CaseSettings object + cs : Settings Used to apply various modeling options when constructing an assembly. name : str (optional, and should be exclusive with specifier) @@ -574,7 +574,7 @@ def migrate(cls, inp: typing.TextIO): @classmethod def load(cls, stream, roundTrip=False): - """This class method is a wrapper around the `yamlize.Object.load()` method. + """This method is a wrapper around the `yamlize.Object.load()` method. The reason for the wrapper is to allow us to default to `Cloader`. Essentially, the `CLoader` class is 10x faster, but doesn't allow for "round trip" (read- diff --git a/armi/reactor/blueprints/assemblyBlueprint.py b/armi/reactor/blueprints/assemblyBlueprint.py index 99fd33293..8e13d1fcb 100644 --- a/armi/reactor/blueprints/assemblyBlueprint.py +++ b/armi/reactor/blueprints/assemblyBlueprint.py @@ -75,6 +75,28 @@ class MaterialModifications(yamlize.Map): If the user wishes to specify material modifications specific to a component within the block, they should use the `by component` attribute, specifying the keys/values underneath the name of a specific component in the block. + + .. impl:: User-impact on material definitions. + :id: I_ARMI_MAT_USER_INPUT0 + :implements: R_ARMI_MAT_USER_INPUT + + Defines a yaml map attribute for the assembly portion of the blueprints + (see :py:class:`~armi.blueprints.assemblyBlueprint.AssemblyBlueprint`) that + allows users to specify material attributes as lists corresponding to + each axial block in the assembly. Two types of specifications can be made: + + 1. Key-value pairs can be specified directly, where the key is the + name of the modification and the value is the list of block values. + + 2. The "by component" attribute can be used, in which case the user + can specify material attributes that are specific to individual components + in each block. This is enabled through the :py:class:`~armi.reactor.blueprints.assemblyBlueprint.ByComponentModifications` + class, which basically just allows for one additional layer of attributes + corresponding to the component names. + + These material attributes can be used during the resolution of material + classes during core instantiation (see :py:meth:`~armi.reactor.blueprints.blockBlueprint.BlockBlueprint.construct` + and :py:meth:`~armi.reactor.blueprints.componentBlueprint.ComponentBlueprint.construct`). """ key_type = yamlize.Typed(str) @@ -92,6 +114,27 @@ class AssemblyBlueprint(yamlize.Object): This class utilizes ``yamlize`` to enable serialization to and from the blueprints YAML file. + + .. impl:: Create assembly from blueprint file. + :id: I_ARMI_BP_ASSEM + :implements: R_ARMI_BP_ASSEM + + Defines a yaml construct that allows the user to specify attributes of an + assembly from within their blueprints file, including a name, flags, specifier + for use in defining a core map, a list of blocks, a list of block heights, + a list of axial mesh points in each block, a list of cross section identifiers + for each block, and material options (see :need:`I_ARMI_MAT_USER_INPUT0`). + + Relies on the underlying infrastructure from the ``yamlize`` package for + reading from text files, serialization, and internal storage of the data. + + Is implemented as part of a blueprints file by being imported and used + as an attribute within the larger :py:class:`~armi.reactor.blueprints.Blueprints` + class. + + Includes a ``construct`` method, which instantiates an instance of + :py:class:`~armi.reactor.assemblies.Assembly` with the characteristics + as specified in the blueprints. """ name = yamlize.Attribute(type=str) @@ -140,8 +183,8 @@ def construct(self, cs, blueprint): Parameters ---------- - cs : CaseSettings - CaseSettings object which containing relevant modeling options. + cs : Settings + Settings object which containing relevant modeling options. blueprint : Blueprint Root blueprint object containing relevant modeling options. """ diff --git a/armi/reactor/blueprints/blockBlueprint.py b/armi/reactor/blueprints/blockBlueprint.py index 68443f8af..7c11ded56 100644 --- a/armi/reactor/blueprints/blockBlueprint.py +++ b/armi/reactor/blueprints/blockBlueprint.py @@ -42,7 +42,32 @@ def _configureGeomOptions(): class BlockBlueprint(yamlize.KeyedList): - """Input definition for Block.""" + """Input definition for Block. + + .. impl:: Create a Block from blueprint file. + :id: I_ARMI_BP_BLOCK + :implements: R_ARMI_BP_BLOCK + + Defines a yaml construct that allows the user to specify attributes of a + block from within their blueprints file, including a name, flags, a radial + grid to specify locations of pins, and the name of a component which + drives the axial expansion of the block (see :py:mod:`~armi.reactor.converters.axialExpansionChanger`). + + In addition, the user may specify key-value pairs to specify the components + contained within the block, where the keys are component names and the + values are component blueprints (see :py:class:`~armi.reactor.blueprints.ComponentBlueprint.ComponentBlueprint`). + + Relies on the underlying infrastructure from the ``yamlize`` package for + reading from text files, serialization, and internal storage of the data. + + Is implemented into a blueprints file by being imported and used + as an attribute within the larger :py:class:`~armi.reactor.blueprints.Blueprints` + class. + + Includes a ``construct`` method, which instantiates an instance of + :py:class:`~armi.reactor.blocks.Block` with the characteristics + as specified in the blueprints. + """ item_type = componentBlueprint.ComponentBlueprint key_attr = componentBlueprint.ComponentBlueprint.name @@ -81,8 +106,8 @@ def construct( Parameters ---------- - cs : CaseSettings - CaseSettings object for the appropriate simulation. + cs : Settings + Settings object for the appropriate simulation. blueprint : Blueprints Blueprints object containing various detailed information, such as nuclides to model @@ -246,7 +271,7 @@ def _getMaterialModsFromBlockChildren(self, c: Composite) -> Set[str]: materialParentClass.applyInputParams ).parameters.keys() ) - # self is a parameter to class methods, so it gets picked up here + # self is a parameter to methods, so it gets picked up here # but that's obviously not a real material modifier perChildModifiers.discard("self") return perChildModifiers diff --git a/armi/reactor/blueprints/componentBlueprint.py b/armi/reactor/blueprints/componentBlueprint.py index 6ef83be55..e1404cd2b 100644 --- a/armi/reactor/blueprints/componentBlueprint.py +++ b/armi/reactor/blueprints/componentBlueprint.py @@ -119,6 +119,34 @@ class ComponentBlueprint(yamlize.Object): """ This class defines the inputs necessary to build ARMI component objects. It uses ``yamlize`` to enable serialization to and from YAML. + + .. impl:: Construct component from blueprint file. + :id: I_ARMI_BP_COMP + :implements: R_ARMI_BP_COMP + + Defines a yaml construct that allows the user to specify attributes of a + component from within their blueprints file, including a name, flags, shape, + material and/or isotopic vector, input temperature, corresponding component dimensions, + and ID for placement in a block lattice (see :py:class:`~armi.reactor.blueprints.blockBlueprint.BlockBlueprint`). + Component dimensions that can be defined for a given component are dependent + on the component's ``shape`` attribute, and the dimensions defining each + shape can be found in the :py:mod:`~armi.reactor.components` module. + + Limited validation on the inputs is performed to ensure that the component + shape corresponds to a valid shape defined by the ARMI application. + + Relies on the underlying infrastructure from the ``yamlize`` package for + reading from text files, serialization, and internal storage of the data. + + Is implemented as part of a blueprints file by being imported and used + as an attribute within the larger :py:class:`~armi.reactor.blueprints.Blueprints` + class. Can also be used within the :py:class:`~armi.reactor.blueprints.blockBlueprint.BlockBlueprint` + class to enable specification of components directly within the "blocks" + portion of the blueprint file. + + Includes a ``construct`` method, which instantiates an instance of + :py:class:`~armi.reactor.components.component.Component` with the characteristics + specified in the blueprints (see :need:`I_ARMI_MAT_USER_INPUT1`). """ name = yamlize.Attribute(type=str) @@ -158,7 +186,25 @@ def shape(self, shape): area = yamlize.Attribute(type=float, default=None) def construct(self, blueprint, matMods): - """Construct a component or group.""" + """Construct a component or group. + + .. impl:: User-defined on material alterations are applied here. + :id: I_ARMI_MAT_USER_INPUT1 + :implements: R_ARMI_MAT_USER_INPUT + + Allows for user input to impact a component's materials by applying + the "material modifications" section of a blueprints file (see :need:`I_ARMI_MAT_USER_INPUT0`) + to the material during construction. This takes place during lower + calls to ``_conformKwargs()`` and subsequently ``_constructMaterial()``, + which operate using the component blueprint and associated material + modifications from the component's block. + + Within ``_constructMaterial()``, the material class is resolved into a material + object by calling :py:func:`~armi.materials.resolveMaterialClassByName`. + The ``applyInputParams()`` method of that material class is then called, + passing in the associated material modifications data, which the material + class can then use to modify the isotopics as necessary. + """ runLog.debug("Constructing component {}".format(self.name)) kwargs = self._conformKwargs(blueprint, matMods) shape = self.shape.lower().strip() @@ -287,6 +333,24 @@ def insertDepletableNuclideKeys(c, blueprint): """ Auto update number density keys on all DEPLETABLE components. + .. impl:: Insert any depletable blueprint flags onto this component. + :id: I_ARMI_BP_NUC_FLAGS0 + :implements: R_ARMI_BP_NUC_FLAGS + + This is called during the component construction process for each component from within + :py:meth:`~armi.reactor.blueprints.componentBlueprint.ComponentBlueprint.construct`. + + For a given initialized component, check its flags to determine if it + has been marked as depletable. If it is, use :py:func:`~armi.nucDirectory.nuclideBases.initReachableActiveNuclidesThroughBurnChain` + to apply the user-specifications in the "nuclide flags" section of the blueprints + to the component such that all active isotopes and derivatives of those + isotopes in the burn chain are initialized to have an entry in the component's + ``numberDensities`` dictionary. + + Note that certain case settings, including ``fpModel`` and ``fpModelLibrary``, + may trigger modifications to the active nuclides specified by the user + in the "nuclide flags" section of the blueprints. + Notes ----- This should be moved to a neutronics/depletion plugin hook but requires some diff --git a/armi/reactor/blueprints/gridBlueprint.py b/armi/reactor/blueprints/gridBlueprint.py index 1cc6eb1d7..bb6dba989 100644 --- a/armi/reactor/blueprints/gridBlueprint.py +++ b/armi/reactor/blueprints/gridBlueprint.py @@ -144,6 +144,28 @@ class GridBlueprint(yamlize.Object): The grids get origins either from a parent block (for pin lattices) or from a System (for Cores, SFPs, and other components). + .. impl:: Define a lattice map in reactor core. + :id: I_ARMI_BP_GRID + :implements: R_ARMI_BP_GRID + + Defines a yaml construct that allows the user to specify a grid + from within their blueprints file, including a name, geometry, dimensions, + symmetry, and a map with the relative locations of components within that grid. + + Relies on the underlying infrastructure from the ``yamlize`` package for + reading from text files, serialization, and internal storage of the data. + + Is implemented as part of a blueprints file by being used in key-value pairs + within the :py:class:`~armi.reactor.blueprints.gridBlueprint.Grid` class, + which is imported and used as an attribute within the larger :py:class:`~armi.reactor.blueprints.Blueprints` + class. + + Includes a ``construct`` method, which instantiates an instance of one + of the subclasses of :py:class:`~armi.reactor.grids.structuredGrid.StructuredGrid`. + This is typically called from within :py:meth:`~armi.reactor.blueprints.blockBlueprint.BlockBlueprint.construct`, + which then also associates the individual components in the block with + locations specifed in the grid. + Attributes ---------- name : str @@ -163,7 +185,6 @@ class GridBlueprint(yamlize.Object): gridContents : dict A {(i,j): str} dictionary mapping spatialGrid indices in 2-D to string specifiers of what's supposed to be in the grid. - """ name = yamlize.Attribute(key="name", type=str) @@ -297,7 +318,7 @@ def _constructSpatialGrid(self): spatialGrid = grids.HexGrid.fromPitch( pitch, numRings=maxIndex + 2, - pointedEndUp=geom == geometry.HEX_CORNERS_UP, + cornersUp=geom == geometry.HEX_CORNERS_UP, ) elif geom == geometry.CARTESIAN: # if full core or not cut-off, bump the first assembly from the center of @@ -523,17 +544,34 @@ def _filterOutsideDomain(gridBp): def saveToStream(stream, bluep, full=False, tryMap=False): - """Save the blueprints to the passed stream. + """ + Save the blueprints to the passed stream. This can save either the entire blueprints, or just the `grids:` section of the blueprints, based on the passed ``full`` argument. Saving just the grid blueprints can be useful when cobbling blueprints together with !include flags. - stream: file output stream of some kind - bluep: armi.reactor.blueprints.Blueprints, or Grids - full: bool ~ Is this a full output file, or just a partial/grids? - tryMap: regardless of input form, attempt to output as a lattice map. let's face it; - they're prettier. + .. impl:: Write a blueprint file from a blueprint object. + :id: I_ARMI_BP_TO_DB + :implements: R_ARMI_BP_TO_DB + + First makes a copy of the blueprints that are passed in. Then modifies + any grids specified in the blueprints into a canonical lattice map style, + if needed. Then uses the ``dump`` method that is inherent to all ``yamlize`` + subclasses to write the blueprints to the given ``stream`` object. + + If called with the ``full`` argument, the entire blueprints is dumped. + If not, only the grids portion is dumped. + + Parameters + ---------- + stream : + file output stream of some kind + bluep : armi.reactor.blueprints.Blueprints, or Grids + full : bool + Is this a full output file, or just a partial/grids? + tryMap : bool + regardless of input form, attempt to output as a lattice map """ # To save, we want to try our best to output our grid blueprints in the lattice # map style. However, we do not want to wreck the state that the current diff --git a/armi/reactor/blueprints/isotopicOptions.py b/armi/reactor/blueprints/isotopicOptions.py index 0e37b4cfb..077db9f21 100644 --- a/armi/reactor/blueprints/isotopicOptions.py +++ b/armi/reactor/blueprints/isotopicOptions.py @@ -44,40 +44,56 @@ class NuclideFlag(yamlize.Object): """ Defines whether or not each nuclide is included in the burn chain and cross sections. - Also controls which nuclides get expanded from elementals to isotopics - and which natural isotopics to exclude (if any). Oftentimes, cross section - library creators include some natural isotopes but not all. For example, - it is common to include O16 but not O17 or O18. Each code has slightly - different interpretations of this so we give the user full control here. + Also controls which nuclides get expanded from elementals to isotopics and which natural + isotopics to exclude (if any). Oftentimes, cross section library creators include some natural + isotopes but not all. For example, it is common to include O16 but not O17 or O18. Each code has + slightly different interpretations of this so we give the user full control here. We also try to provide useful defaults. - There are lots of complications that can arise in these choices. - It makes reasonable sense to use elemental compositions - for things that are typically used without isotopic modifications - (Fe, O, Zr, Cr, Na). If we choose to expand some or all of these - to isotopics at initialization based on cross section library - requirements, a single case will work fine with a given lattice - physics option. However, restarting from that case with different - cross section needs is challenging. + There are lots of complications that can arise in these choices. It makes reasonable sense to + use elemental compositions for things that are typically used without isotopic modifications + (Fe, O, Zr, Cr, Na). If we choose to expand some or all of these to isotopics at initialization + based on cross section library requirements, a single case will work fine with a given lattice + physics option. However, restarting from that case with different cross section needs is + challenging. + + .. impl:: The blueprint object that represents a nuclide flag. + :id: I_ARMI_BP_NUC_FLAGS1 + :implements: R_ARMI_BP_NUC_FLAGS + + This class creates a yaml interface for the user to specify in their blueprints which + isotopes should be depleted. It is incorporated into the "nuclide flags" section of a + blueprints file by being included as key-value pairs within the + :py:class:`~armi.reactor.blueprints.isotopicOptions.NuclideFlags` class, which is in turn + included into the overall blueprints within :py:class:`~armi.reactor.blueprints.Blueprints`. + + This class includes a boolean ``burn`` attribute which can be specified for any nuclide. + This attribute is examined by the + :py:meth:`~armi.reactor.blueprints.isotopicOptions.NuclideFlag.fileAsActiveOrInert` method + to sort the nuclides into sets of depletable or not, which is typically called during + construction of assemblies in :py:meth:`~armi.reactor.blueprints.Blueprints.constructAssem`. + + Note that while the ``burn`` attribute can be set by the user in the blueprints, other + methods may also set it based on case settings (see, for instance, + :py:func:`~armi.reactor.blueprints.isotopicOptions.genDefaultNucFlags`, + :py:func:`~armi.reactor.blueprints.isotopicOptions.autoUpdateNuclideFlags`, and + :py:func:`~armi.reactor.blueprints.isotopicOptions.getAllNuclideBasesByLibrary`). Attributes ---------- nuclideName : str The name of the nuclide burn : bool - True if this nuclide should be added to the burn chain. - If True, all reachable nuclides via transmutation - and decay must be included as well. + True if this nuclide should be added to the burn chain. If True, all reachable nuclides via + transmutation and decay must be included as well. xs : bool - True if this nuclide should be included in the cross - section libraries. Effectively, if this nuclide is in the problem - at all, this should be true. + True if this nuclide should be included in the cross section libraries. Effectively, if this + nuclide is in the problem at all, this should be true. expandTo : list of str, optional - isotope nuclideNames to expand to. For example, if nuclideName is - ``O`` then this could be ``["O16", "O17"]`` to expand it into - those two isotopes (but not ``O18``). The nuclides will be scaled - up uniformly to account for any missing natural nuclides. + isotope nuclideNames to expand to. For example, if nuclideName is ``O`` then this could be + ``["O16", "O17"]`` to expand it into those two isotopes (but not ``O18``). The nuclides will + be scaled up uniformly to account for any missing natural nuclides. """ nuclideName = yamlize.Attribute(type=str) @@ -144,8 +160,32 @@ class NuclideFlags(yamlize.KeyedList): class CustomIsotopic(yamlize.Map): """ - User specified, custom isotopics input defined by a name (such as MOX), and key/pairs of nuclide names and numeric - values consistent with the ``input format``. + User specified, custom isotopics input defined by a name (such as MOX), and key/pairs of nuclide + names and numeric values consistent with the ``input format``. + + .. impl:: Certain material modifications will be applied using this code. + :id: I_ARMI_MAT_USER_INPUT2 + :implements: R_ARMI_MAT_USER_INPUT + + Defines a yaml construct that allows the user to define a custom isotopic vector from within + their blueprints file, including a name and key-value pairs corresponding to nuclide names + and their concentrations. + + Relies on the underlying infrastructure from the ``yamlize`` package for reading from text + files, serialization, and internal storage of the data. + + Is implemented as part of a blueprints file by being used in key-value pairs within the + :py:class:`~armi.reactor.blueprints.isotopicOptions.CustomIsotopics` class, which is + imported and used as an attribute within the larger + :py:class:`~armi.reactor.blueprints.Blueprints` class. + + These isotopics are linked to a component during calls to + :py:meth:`~armi.reactor.blueprints.componentBlueprint.ComponentBlueprint.construct`, where + the name specified in the ``isotopics`` attribute of the component blueprint is searched + against the available ``CustomIsotopics`` defined in the "custom isotopics" section of the + blueprints. Once linked, the + :py:meth:`~armi.reactor.blueprints.isotopicOptions.CustomIsotopic.apply` method is called, + which adjusts the ``massFrac`` attribute of the component's material class. """ key_type = yamlize.Typed(str) diff --git a/armi/reactor/blueprints/reactorBlueprint.py b/armi/reactor/blueprints/reactorBlueprint.py index 6a67b7e45..40e802cc5 100644 --- a/armi/reactor/blueprints/reactorBlueprint.py +++ b/armi/reactor/blueprints/reactorBlueprint.py @@ -49,6 +49,26 @@ class SystemBlueprint(yamlize.Object): """ The reactor-level structure input blueprint. + .. impl:: Build core and spent fuel pool from blueprints + :id: I_ARMI_BP_SYSTEMS + :implements: R_ARMI_BP_SYSTEMS, R_ARMI_BP_CORE + + This class creates a yaml interface for the user to define systems with + grids, such as cores or spent fuel pools, each having their own name, + type, grid, and position in space. It is incorporated into the "systems" + section of a blueprints file by being included as key-value pairs within + the :py:class:`~armi.reactor.blueprints.reactorBlueprint.Systems` class, + which is in turn included into the overall blueprints within + :py:class:`~armi.reactor.blueprints.Blueprints`. + + This class includes a :py:meth:`~armi.reactor.blueprints.reactorBlueprint.SystemBlueprint.construct` + method, which is typically called from within :py:func:`~armi.reactor.reactors.factory` + during the initialization of the reactor object to instantiate the core + and/or spent fuel pool objects. During that process, a spatial grid is + constructed based on the grid blueprints specified in the "grids" section + of the blueprints (see :need:`I_ARMI_BP_GRID`) and the assemblies needed + to fill the lattice are built from blueprints using :py:meth:`~armi.reactor.blueprints.Blueprints.constructAssem`. + .. note:: We use string keys to link grids to objects that use them. This differs from how blocks/assembies are specified, which use YAML anchors. YAML anchors have proven to be problematic and difficult to work with @@ -113,7 +133,7 @@ def construct(self, cs, bp, reactor, geom=None, loadAssems=True): loadAssems : bool, optional whether to fill reactor with assemblies, as defined in blueprints, or not. Is False in :py:class:`UniformMeshGeometryConverter <armi.reactor.converters.uniformMesh.UniformMeshGeometryConverter>` - within the initNewReactor() class method. + within the initNewReactor() method. Raises ------ diff --git a/armi/reactor/blueprints/tests/test_assemblyBlueprints.py b/armi/reactor/blueprints/tests/test_assemblyBlueprints.py index 1031de24b..fd6863b9d 100644 --- a/armi/reactor/blueprints/tests/test_assemblyBlueprints.py +++ b/armi/reactor/blueprints/tests/test_assemblyBlueprints.py @@ -183,6 +183,13 @@ def loadCustomAssembly(self, assemblyInput): return design.assemblies["fuel a"] def test_checkParamConsistency(self): + """ + Load assembly from a blueprint file. + + .. test:: Create assembly from blueprint file. + :id: T_ARMI_BP_ASSEM + :tests: R_ARMI_BP_ASSEM + """ # make sure a good example doesn't error a = self.loadCustomAssembly(self.twoBlockInput_correct) blockAxialMesh = a.getAxialMesh() diff --git a/armi/reactor/blueprints/tests/test_blockBlueprints.py b/armi/reactor/blueprints/tests/test_blockBlueprints.py index 36882a74d..a06964b59 100644 --- a/armi/reactor/blueprints/tests/test_blockBlueprints.py +++ b/armi/reactor/blueprints/tests/test_blockBlueprints.py @@ -63,7 +63,7 @@ op: 16.75 other fuel: &block_fuel_other grid name: fuelgrid - flags: fuel test + flags: fuel test depletable fuel: shape: Circle material: UZr @@ -280,7 +280,12 @@ def test_getLocatorsAtLatticePositions(self): self.assertIs(grid[locators[0].getCompleteIndices()], locators[0]) def test_blockLattice(self): - """Make sure constructing a block with grid specifiers works as a whole.""" + """Make sure constructing a block with grid specifiers works as a whole. + + .. test:: Create block with blueprint file. + :id: T_ARMI_BP_BLOCK + :tests: R_ARMI_BP_BLOCK + """ aDesign = self.blueprints.assemDesigns.bySpecifier["IC"] a = aDesign.construct(self.cs, self.blueprints) fuelBlock = a.getFirstBlock(Flags.FUEL) @@ -301,6 +306,13 @@ def test_nonLatticeComponentHasRightMult(self): self.assertEqual(duct.getDimension("mult"), 1.0) def test_explicitFlags(self): + """ + Test flags are created from blueprint file. + + .. test:: Nuc flags can define depletable objects. + :id: T_ARMI_BP_NUC_FLAGS0 + :tests: R_ARMI_BP_NUC_FLAGS + """ a1 = self.blueprints.assemDesigns.bySpecifier["IC"].construct( self.cs, self.blueprints ) @@ -312,7 +324,9 @@ def test_explicitFlags(self): ) self.assertTrue(b1.hasFlags(Flags.FUEL, exact=True)) - self.assertTrue(b2.hasFlags(Flags.FUEL | Flags.TEST, exact=True)) + self.assertTrue( + b2.hasFlags(Flags.FUEL | Flags.TEST | Flags.DEPLETABLE, exact=True) + ) self.assertEqual(a1.p.flags, Flags.FUEL) self.assertTrue(a1.hasFlags(Flags.FUEL, exact=True)) diff --git a/armi/reactor/blueprints/tests/test_blueprints.py b/armi/reactor/blueprints/tests/test_blueprints.py index 118e54e99..596816371 100644 --- a/armi/reactor/blueprints/tests/test_blueprints.py +++ b/armi/reactor/blueprints/tests/test_blueprints.py @@ -13,6 +13,7 @@ # limitations under the License. """Tests the blueprints (loading input) file.""" +import io import os import pathlib import unittest @@ -30,6 +31,7 @@ from armi.tests import TEST_ROOT from armi.utils import directoryChangers from armi.utils import textProcessors +from armi.reactor.blueprints.gridBlueprint import saveToStream class TestBlueprints(unittest.TestCase): @@ -64,6 +66,64 @@ def setUpClass(cls): def tearDownClass(cls): cls.directoryChanger.close() + @staticmethod + def __stubify(latticeMap): + """Little helper method to allow lattie maps to be compared free of whitespace.""" + return latticeMap.replace(" ", "").replace("-", "").replace("\n", "") + + def test_roundTripCompleteBP(self): + """Test the round-tip of reading and writing blueprint files. + + .. test:: Validates the round trip of reading and writing blueprints. + :id: T_ARMI_BP_TO_DB1 + :tests: R_ARMI_BP_TO_DB + """ + # the correct lattice map + latticeMap = """- - SH + - SH SH +- SH OC SH + SH OC OC SH + OC IC OC SH + OC IC IC OC SH + IC IC IC OC SH + IC IC PC OC SH + IC PC IC IC OC SH + LA IC IC IC OC + IC IC IC IC SH + IC LB IC IC OC + IC IC PC IC SH + LA IC IC OC + IC IC IC IC SH + IC IC IC OC + IC IC IC PC SH""" + latticeMap = self.__stubify(latticeMap) + + # validate some core elements from the blueprints + self.assertEqual(self.blueprints.gridDesigns["core"].symmetry, "third periodic") + map0 = self.__stubify(self.blueprints.gridDesigns["core"].latticeMap) + self.assertEqual(map0, latticeMap) + + # save the blueprint to a stream + stream = io.StringIO() + stream.seek(0) + self.blueprints.dump(self.blueprints) + saveToStream(stream, self.blueprints, True, True) + stream.seek(0) + + with directoryChangers.TemporaryDirectoryChanger(): + # save the stream to a file + filePath = "test_roundTripCompleteBP.yaml" + with open(filePath, "w") as fout: + fout.write(stream.read()) + + # load the blueprint from that file again + bp = blueprints.Blueprints.load(open(filePath, "r").read()) + + # re-validate some core elements from the blueprints + self.assertEqual(bp.gridDesigns["core"].symmetry, "third periodic") + map1 = self.__stubify(bp.gridDesigns["core"].latticeMap) + self.assertEqual(map1, latticeMap) + def test_nuclides(self): """Tests the available sets of nuclides work as expected.""" actives = set(self.blueprints.activeNuclides) @@ -87,7 +147,12 @@ def test_specialIsotopicVectors(self): self.assertAlmostEqual(mox["PU239"], 0.00286038) def test_componentDimensions(self): - """Tests that the user can specifiy the dimensions of a component with arbitray fidelity.""" + """Tests that the user can specify the dimensions of a component with arbitrary fidelity. + + .. test:: A component can be correctly created from a blueprint file. + :id: T_ARMI_BP_COMP + :tests: R_ARMI_BP_COMP + """ fuelAssem = self.blueprints.constructAssem(self.cs, name="igniter fuel") fuel = fuelAssem.getComponents(Flags.FUEL)[0] self.assertAlmostEqual(fuel.getDimension("od", cold=True), 0.86602) @@ -97,7 +162,12 @@ def test_componentDimensions(self): self.assertAlmostEqual(fuel.getDimension("mult"), 169) def test_traceNuclides(self): - """Ensure that armi.reactor.blueprints.componentBlueprint.insertDepletableNuclideKeys runs.""" + """Ensure that armi.reactor.blueprints.componentBlueprint.insertDepletableNuclideKeys runs. + + .. test:: Users marking components as depletable will affect number densities. + :id: T_ARMI_BP_NUC_FLAGS1 + :tests: R_ARMI_BP_NUC_FLAGS + """ fuel = ( self.blueprints.constructAssem(self.cs, "igniter fuel") .getFirstBlock(Flags.FUEL) diff --git a/armi/reactor/blueprints/tests/test_componentBlueprint.py b/armi/reactor/blueprints/tests/test_componentBlueprint.py index 729e4cbc4..b96e7c88e 100644 --- a/armi/reactor/blueprints/tests/test_componentBlueprint.py +++ b/armi/reactor/blueprints/tests/test_componentBlueprint.py @@ -68,7 +68,7 @@ def test_componentInitializationIncompleteBurnChain(self): def test_componentInitializationControlCustomIsotopics(self): nuclideFlags = ( inspect.cleandoc( - r""" + """ nuclide flags: U234: {burn: true, xs: true} U235: {burn: true, xs: true} @@ -99,7 +99,7 @@ def test_componentInitializationControlCustomIsotopics(self): def test_autoDepletable(self): nuclideFlags = ( inspect.cleandoc( - r""" + """ nuclide flags: U234: {burn: true, xs: true} U235: {burn: true, xs: true} diff --git a/armi/reactor/blueprints/tests/test_customIsotopics.py b/armi/reactor/blueprints/tests/test_customIsotopics.py index 6471a6388..3366a3c36 100644 --- a/armi/reactor/blueprints/tests/test_customIsotopics.py +++ b/armi/reactor/blueprints/tests/test_customIsotopics.py @@ -25,7 +25,6 @@ class TestCustomIsotopics(unittest.TestCase): - yamlString = r""" nuclide flags: U238: {burn: true, xs: true} @@ -185,6 +184,7 @@ class TestCustomIsotopics(unittest.TestCase): axial mesh points: [1, 1, 1, 1, 1, 1,1] xs types: [A, A, A, A, A, A,A] """ + """:meta hide-value:""" @classmethod def setUpClass(cls): @@ -210,6 +210,12 @@ def test_unmodified(self): self.assertAlmostEqual(15.5, fuel.density(), 0) # i.e. it is not 19.1 def test_massFractionsAreApplied(self): + """Ensure that the custom isotopics can be specified via mass fractions. + + .. test:: Test that custom isotopics can be specified via mass fractions. + :id: T_ARMI_MAT_USER_INPUT3 + :tests: R_ARMI_MAT_USER_INPUT + """ fuel0 = self.a[0].getComponent(Flags.FUEL) fuel1 = self.a[1].getComponent(Flags.FUEL) fuel2 = self.a[2].getComponent(Flags.FUEL) @@ -223,25 +229,37 @@ def test_massFractionsAreApplied(self): ) # keys are same def test_numberFractions(self): - # fuel 2 and 3 should be the same, one is defined as mass fractions, and the other as number fractions + """Ensure that the custom isotopics can be specified via number fractions. + + .. test:: Test that custom isotopics can be specified via number fractions. + :id: T_ARMI_MAT_USER_INPUT4 + :tests: R_ARMI_MAT_USER_INPUT + """ + # fuel blocks 2 and 4 should be the same, one is defined as mass fractions, and the other as number fractions fuel2 = self.a[1].getComponent(Flags.FUEL) - fuel3 = self.a[3].getComponent(Flags.FUEL) - self.assertAlmostEqual(fuel2.density(), fuel3.density()) + fuel4 = self.a[3].getComponent(Flags.FUEL) + self.assertAlmostEqual(fuel2.density(), fuel4.density()) for nuc in fuel2.p.numberDensities.keys(): self.assertAlmostEqual( - fuel2.p.numberDensities[nuc], fuel3.p.numberDensities[nuc] + fuel2.p.numberDensities[nuc], fuel4.p.numberDensities[nuc] ) def test_numberDensities(self): - # fuel 2 and 3 should be the same, one is defined as mass fractions, and the other as number fractions + """Ensure that the custom isotopics can be specified via number densities. + + .. test:: Test that custom isotopics can be specified via number fractions. + :id: T_ARMI_MAT_USER_INPUT5 + :tests: R_ARMI_MAT_USER_INPUT + """ + # fuel blocks 2 and 5 should be the same, one is defined as mass fractions, and the other as number densities fuel2 = self.a[1].getComponent(Flags.FUEL) - fuel3 = self.a[4].getComponent(Flags.FUEL) - self.assertAlmostEqual(fuel2.density(), fuel3.density()) + fuel5 = self.a[4].getComponent(Flags.FUEL) + self.assertAlmostEqual(fuel2.density(), fuel5.density()) for nuc in fuel2.p.numberDensities.keys(): self.assertAlmostEqual( - fuel2.p.numberDensities[nuc], fuel3.p.numberDensities[nuc] + fuel2.p.numberDensities[nuc], fuel5.p.numberDensities[nuc] ) def test_numberDensitiesAnchor(self): diff --git a/armi/reactor/blueprints/tests/test_gridBlueprints.py b/armi/reactor/blueprints/tests/test_gridBlueprints.py index 6735888f7..076a68186 100644 --- a/armi/reactor/blueprints/tests/test_gridBlueprints.py +++ b/armi/reactor/blueprints/tests/test_gridBlueprints.py @@ -23,7 +23,6 @@ from armi.reactor import systemLayoutInput from armi.reactor.blueprints import Blueprints from armi.reactor.blueprints.gridBlueprint import Grids, saveToStream -from armi.reactor.blueprints.tests.test_blockBlueprints import FULL_BP, FULL_BP_GRID from armi.utils.directoryChangers import TemporaryDirectoryChanger @@ -193,6 +192,7 @@ [8,6]: assembly9_7 fuel """ +# ruff: noqa: E501 RTH_GEOM = """ <reactor geom="ThetaRZ" symmetry="eighth core periodic"> <assembly azimuthalMesh="4" name="assembly1_1 fuel" rad1="0.0" rad2="14.2857142857" radialMesh="4" theta1="0.0" theta2="0.11556368446681414" /> @@ -306,7 +306,7 @@ """ -class TestRoundTrip(unittest.TestCase): +class TestGridBPRoundTrip(unittest.TestCase): def setUp(self): self.grids = Grids.load(SMALL_HEX) @@ -314,13 +314,27 @@ def test_contents(self): self.assertIn("core", self.grids) def test_roundTrip(self): + """ + Test saving blueprint data to a stream. + + .. test:: Grid blueprints can be written to disk. + :id: T_ARMI_BP_TO_DB0 + :tests: R_ARMI_BP_TO_DB + """ stream = io.StringIO() saveToStream(stream, self.grids, False, True) stream.seek(0) gridBp = Grids.load(stream) self.assertIn("third", gridBp["core"].symmetry) - def test_tiny_map(self): + def test_tinyMap(self): + """ + Test that a lattice map can be defined, written, and read in from blueprint file. + + .. test:: Define a lattice map in reactor core. + :id: T_ARMI_BP_GRID1 + :tests: R_ARMI_BP_GRID + """ grid = Grids.load(TINY_GRID) stream = io.StringIO() saveToStream(stream, grid, full=True, tryMap=True) @@ -347,7 +361,7 @@ def tearDown(self): def test_simpleRead(self): gridDesign = self.grids["control"] _ = gridDesign.construct() - self.assertEqual(gridDesign.gridContents[0, -8], "6") + self.assertEqual(gridDesign.gridContents[-8, 0], "6") # Cartesian full, odd gridDesign2 = self.grids["sfp"] @@ -381,6 +395,14 @@ def test_simpleRead(self): self.assertEqual(gridDesign4.gridContents[-4, -3], "1") def test_simpleReadLatticeMap(self): + """Read lattice map and create a grid. + + .. test:: Define a lattice map in reactor core. + :id: T_ARMI_BP_GRID0 + :tests: R_ARMI_BP_GRID + """ + from armi.reactor.blueprints.tests.test_blockBlueprints import FULL_BP + # Cartesian full, even/odd hybrid gridDesign4 = self.grids["sfp even"] _grid = gridDesign4.construct() @@ -412,6 +434,8 @@ def test_simpleReadLatticeMap(self): self.assertTrue(os.path.exists(filePath)) def test_simpleReadNoLatticeMap(self): + from armi.reactor.blueprints.tests.test_blockBlueprints import FULL_BP_GRID + # Cartesian full, even/odd hybrid gridDesign4 = self.grids["sfp even"] _grid = gridDesign4.construct() diff --git a/armi/reactor/blueprints/tests/test_materialModifications.py b/armi/reactor/blueprints/tests/test_materialModifications.py index 9a5b63ab4..bd7b458cb 100644 --- a/armi/reactor/blueprints/tests/test_materialModifications.py +++ b/armi/reactor/blueprints/tests/test_materialModifications.py @@ -73,6 +73,13 @@ def test_noMaterialModifications(self): assert_allclose(uzr.massFrac[nucName], massFrac) def test_u235_wt_frac_modification(self): + """Test constructing a component where the blueprints specify a material + modification for one nuclide. + + .. test:: A material modification can be applied to all the components in an assembly. + :id: T_ARMI_MAT_USER_INPUT0 + :tests: R_ARMI_MAT_USER_INPUT + """ a = self.loadUZrAssembly( """ material modifications: @@ -90,6 +97,13 @@ def test_u235_wt_frac_modification(self): assert_allclose(0.20, u235 / u) def test_u235_wt_frac_byComponent_modification1(self): + """Test constructing a component where the blueprints specify a material + modification for one nuclide, for just one component. + + .. test:: A material modification can be applied to one component in an assembly. + :id: T_ARMI_MAT_USER_INPUT1 + :tests: R_ARMI_MAT_USER_INPUT + """ a = self.loadUZrAssembly( """ material modifications: @@ -110,6 +124,13 @@ def test_u235_wt_frac_byComponent_modification1(self): assert_allclose(0.30, u235 / u) def test_u235_wt_frac_byComponent_modification2(self): + """Test constructing a component where the blueprints specify a material + modification for one nuclide, for multiple components. + + .. test:: A material modification can be applied to multiple components in an assembly. + :id: T_ARMI_MAT_USER_INPUT2 + :tests: R_ARMI_MAT_USER_INPUT + """ a = self.loadUZrAssembly( """ material modifications: diff --git a/armi/reactor/blueprints/tests/test_reactorBlueprints.py b/armi/reactor/blueprints/tests/test_reactorBlueprints.py index 20cc9666b..c50645c52 100644 --- a/armi/reactor/blueprints/tests/test_reactorBlueprints.py +++ b/armi/reactor/blueprints/tests/test_reactorBlueprints.py @@ -16,7 +16,9 @@ import os import unittest +from armi.reactor.assemblyLists import SpentFuelPool from armi.reactor import blueprints +from armi.reactor.reactors import Core from armi import settings from armi.reactor import reactors from armi.reactor.blueprints import reactorBlueprint @@ -101,11 +103,23 @@ def _setupReactor(self): return core, sfp def test_construct(self): - """Actually construct some reactor systems.""" + """Actually construct some reactor systems. + + .. test:: Create core and spent fuel pool with blueprint. + :id: T_ARMI_BP_SYSTEMS + :tests: R_ARMI_BP_SYSTEMS + + .. test:: Create core object with blueprint. + :id: T_ARMI_BP_CORE + :tests: R_ARMI_BP_CORE + """ core, sfp = self._setupReactor() self.assertEqual(len(core), 2) self.assertEqual(len(sfp), 4) + self.assertIsInstance(core, Core) + self.assertIsInstance(sfp, SpentFuelPool) + def test_materialDataSummary(self): """Test that the material data summary for the core is valid as a printout to the stdout.""" expectedMaterialData = [ diff --git a/armi/reactor/components/__init__.py b/armi/reactor/components/__init__.py index 9d3e940d2..ccb1b8c22 100644 --- a/armi/reactor/components/__init__.py +++ b/armi/reactor/components/__init__.py @@ -88,17 +88,17 @@ def _removeDimensionNameSpaces(attrs): class NullComponent(Component): - r"""Returns zero for all dimensions. is none.""" + """Returns zero for all dimensions.""" def __cmp__(self, other): - r"""Be smaller than everything.""" + """Be smaller than everything.""" return -1 def __lt__(self, other): return True def __bool__(self): - r"""Handles truth testing.""" + """Handles truth testing.""" return False __nonzero__ = __bool__ # Python2 compatibility @@ -323,7 +323,22 @@ def getBoundingCircleOuterDiameter(self, Tc=None, cold=False): return math.sqrt(4.0 * self.getComponentArea() / math.pi) def computeVolume(self): - """Cannot compute volume until it is derived.""" + """Cannot compute volume until it is derived. + + .. impl:: The volume of a DerivedShape depends on the solid shapes surrounding + them. + :id: I_ARMI_COMP_FLUID0 + :implements: R_ARMI_COMP_FLUID + + Computing the volume of a ``DerivedShape`` means looking at the solid + materials around it, and finding what shaped space is left over in between + them. This method calls the method ``_deriveVolumeAndArea``, which makes + use of the fact that the ARMI reactor data model is hierarchical. It starts + by finding the parent of this object, and then finding the volume of all + the other objects at this level. Whatever is left over, is the volume of + this object. Obviously, you can only have one ``DerivedShape`` child of any + parent for this logic to work. + """ return self._deriveVolumeAndArea() def _deriveVolumeAndArea(self): @@ -395,6 +410,7 @@ def _deriveVolumeAndArea(self): self.p.area = remainingArea else: self.p.area = remainingVolume / height + return remainingVolume def getVolume(self): @@ -412,7 +428,6 @@ def getVolume(self): ------- float volume of component in cm^3. - """ if self.parent.derivedMustUpdate: # tell _updateVolume to update it during the below getVolume call diff --git a/armi/reactor/components/basicShapes.py b/armi/reactor/components/basicShapes.py index c91ed9109..4e395009b 100644 --- a/armi/reactor/components/basicShapes.py +++ b/armi/reactor/components/basicShapes.py @@ -25,7 +25,17 @@ class Circle(ShapedComponent): - """A Circle.""" + """A Circle. + + .. impl:: Circle shaped Component + :id: I_ARMI_COMP_SHAPES0 + :implements: R_ARMI_COMP_SHAPES + + This class provides the implementation of a Circle Component. This includes + setting key parameters such as its material, temperature, and dimensions. It + also includes a method to retrieve the area of a Circle + Component via the ``getComponentArea`` method. + """ is3D = False @@ -84,7 +94,18 @@ def isEncapsulatedBy(self, other): class Hexagon(ShapedComponent): - """A Hexagon.""" + """A Hexagon. + + .. impl:: Hexagon shaped Component + :id: I_ARMI_COMP_SHAPES1 + :implements: R_ARMI_COMP_SHAPES + + This class provides the implementation of a hexagonal Component. This + includes setting key parameters such as its material, temperature, and + dimensions. It also includes methods for retrieving geometric + dimension information unique to hexagons such as the ``getPerimeter`` and + ``getPitchData`` methods. + """ is3D = False @@ -164,7 +185,18 @@ def getPitchData(self): class Rectangle(ShapedComponent): - """A rectangle component.""" + """A Rectangle. + + .. impl:: Rectangle shaped Component + :id: I_ARMI_COMP_SHAPES2 + :implements: R_ARMI_COMP_SHAPES + + This class provides the implementation for a rectangular Component. This + includes setting key parameters such as its material, temperature, and + dimensions. It also includes methods for computing geometric + information related to rectangles, such as the + ``getBoundingCircleOuterDiameter`` and ``getPitchData`` methods. + """ is3D = False @@ -304,7 +336,17 @@ def getComponentArea(self, cold=False): class Square(Rectangle): - """Square component that can be solid or hollow.""" + """Square component that can be solid or hollow. + + .. impl:: Square shaped Component + :id: I_ARMI_COMP_SHAPES3 + :implements: R_ARMI_COMP_SHAPES + + This class provides the implementation for a square Component. This class + subclasses the ``Rectangle`` class because a square is a type of rectangle. + This includes setting key parameters such as its material, temperature, and + dimensions. + """ is3D = False @@ -377,6 +419,15 @@ class Triangle(ShapedComponent): """ Triangle with defined base and height. + .. impl:: Triangle shaped Component + :id: I_ARMI_COMP_SHAPES4 + :implements: R_ARMI_COMP_SHAPES + + This class provides the implementation for defining a triangular Component. This + includes setting key parameters such as its material, temperature, and + dimensions. It also includes providing a method for retrieving the area of a + Triangle Component via the ``getComponentArea`` method. + Notes ----- The exact angles of the triangle are undefined. The exact side lenths and angles diff --git a/armi/reactor/components/complexShapes.py b/armi/reactor/components/complexShapes.py index ac456e26f..2f1ff9f6f 100644 --- a/armi/reactor/components/complexShapes.py +++ b/armi/reactor/components/complexShapes.py @@ -22,7 +22,17 @@ class HoledHexagon(basicShapes.Hexagon): - """Hexagon with n uniform circular holes hollowed out of it.""" + """Hexagon with n uniform circular holes hollowed out of it. + + .. impl:: Holed hexagon shaped Component + :id: I_ARMI_COMP_SHAPES5 + :implements: R_ARMI_COMP_SHAPES + + This class provides an implementation for a holed hexagonal Component. This + includes setting key parameters such as its material, temperature, and + dimensions. It also provides the capability to retrieve the diameter of the + inner hole via the ``getCircleInnerDiameter`` method. + """ THERMAL_EXPANSION_DIMS = {"op", "holeOD"} @@ -190,7 +200,18 @@ def getCircleInnerDiameter(self, Tc=None, cold=False): class HoledSquare(basicShapes.Square): - """Square with one circular hole in it.""" + """Square with one circular hole in it. + + .. impl:: Holed square shaped Component + :id: I_ARMI_COMP_SHAPES6 + :implements: R_ARMI_COMP_SHAPES + + This class provides an implementation for a holed square Component. This + includes setting key parameters such as its material, temperature, and + dimensions. It also includes methods to retrieve geometric + dimension information unique to holed squares via the ``getComponentArea`` and + ``getCircleInnerDiameter`` methods. + """ THERMAL_EXPANSION_DIMS = {"widthOuter", "holeOD"} @@ -242,6 +263,16 @@ def getCircleInnerDiameter(self, Tc=None, cold=False): class Helix(ShapedComponent): """A spiral wire component used to model a pin wire-wrap. + .. impl:: Helix shaped Component + :id: I_ARMI_COMP_SHAPES7 + :implements: R_ARMI_COMP_SHAPES + + This class provides the implementation for a helical Component. This + includes setting key parameters such as its material, temperature, and + dimensions. It also includes the ``getComponentArea`` method to retrieve the + area of a helix. Helixes can be used for wire wrapping around fuel pins in fast + reactor designs. + Notes ----- http://mathworld.wolfram.com/Helix.html diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py index ed667ccc0..4cebed503 100644 --- a/armi/reactor/components/component.py +++ b/armi/reactor/components/component.py @@ -129,7 +129,7 @@ class ComponentType(composites.CompositeModelType): system. """ - TYPES = dict() + TYPES = dict() #: :meta hide-value: NON_DIMENSION_NAMES = ( "Tinput", @@ -169,6 +169,27 @@ class Component(composites.Composite, metaclass=ComponentType): Could be fuel pins, cladding, duct, wire wrap, etc. One component object may represent multiple physical components via the ``multiplicity`` mechanism. + .. impl:: Define a physical piece of a reactor. + :id: I_ARMI_COMP_DEF + :implements: R_ARMI_COMP_DEF + + The primitive object in an ARMI reactor is a Component. A Component is comprised + of a shape and composition. This class serves as a base class which all + Component types within ARMI are built upon. All primitive shapes (such as a + square, circle, holed hexagon, helix etc.) are derived from this base class. + + Fundamental capabilities of this class include the ability to store parameters + and attributes which describe the physical state of each Component within the + ARMI data model. + + .. impl:: Order Components by their outermost diameter (using the < operator). + :id: I_ARMI_COMP_ORDER + :implements: R_ARMI_COMP_ORDER + + Determining Component order by outermost diameters is implemented via + the ``__lt__()`` method, which is used to control ``sort()`` as the + standard approach in Python. However, ``__lt__()`` does not show up in the API. + Attributes ---------- temperatureInC : float @@ -278,7 +299,19 @@ def _linkAndStoreDimensions(self, components, **dims): self.resolveLinkedDims(components) def resolveLinkedDims(self, components): - """Convert dimension link strings to actual links.""" + """Convert dimension link strings to actual links. + + .. impl:: The volume of some defined shapes depend on the solid components surrounding them. + :id: I_ARMI_COMP_FLUID1 + :implements: R_ARMI_COMP_FLUID + + Some Components are fluids and are thus defined by the shapes surrounding + them. This method cycles through each dimension defining the border of this + Component and converts the name of that Component to a link to the object + itself. This series of links is then used downstream to resolve + dimensional information. + + """ for dimName in self.DIMENSION_NAMES: value = self.p[dimName] if not isinstance(value, str): @@ -378,7 +411,20 @@ def getHeightFactor(self, newHot): return self.getThermalExpansionFactor(Tc=newHot, T0=self.temperatureInC) def getProperties(self): - """Return the active Material object defining thermo-mechanical properties.""" + """Return the active Material object defining thermo-mechanical properties. + + .. impl:: Material properties are retrievable. + :id: I_ARMI_COMP_MAT0 + :implements: R_ARMI_COMP_MAT + + This method returns the material object that is assigned to the Component. + + .. impl:: Components have one-and-only-one material. + :id: I_ARMI_COMP_1MAT + :implements: R_ARMI_COMP_1MAT + + This method returns the material object that is assigned to the Component. + """ return self.material @property @@ -416,7 +462,13 @@ def setLumpedFissionProducts(self, lfpCollection): def getArea(self, cold=False): """ - Get the area of a component in cm^2. + Get the area of a Component in cm^2. + + .. impl:: Get a dimension of a Component. + :id: I_ARMI_COMP_VOL0 + :implements: R_ARMI_COMP_VOL + + This method returns the area of a Component. See Also -------- @@ -437,7 +489,13 @@ def getArea(self, cold=False): def getVolume(self): """ - Return the volume [cm^3] of the component. + Return the volume [cm^3] of the Component. + + .. impl:: Get a dimension of a Component. + :id: I_ARMI_COMP_VOL1 + :implements: R_ARMI_COMP_VOL + + This method returns the volume of a Component. Notes ----- @@ -494,7 +552,6 @@ def _checkNegativeArea(self, area, cold): Overlapping is allowed to maintain conservation of atoms while sticking close to the as-built geometry. Modules that need true geometries will have to handle this themselves. - """ if numpy.isnan(area): return @@ -537,7 +594,16 @@ def containsVoidMaterial(self): return isinstance(self.material, void.Void) def containsSolidMaterial(self): - """Returns True if the component material is a solid.""" + """Returns True if the component material is a solid. + + .. impl:: Determine if a material is solid. + :id: I_ARMI_COMP_SOLID + :implements: R_ARMI_COMP_SOLID + + For certain operations it is important to know if a Component is a solid or + fluid material. This method will return a boolean indicating if the material + is solid or not by checking if the material is an instance of the ``material.Fluid`` class. + """ return not isinstance(self.material, material.Fluid) def getComponentArea(self, cold=False): @@ -636,6 +702,14 @@ def setNumberDensity(self, nucName, val): """ Set heterogeneous number density. + .. impl:: Setting nuclide fractions. + :id: I_ARMI_COMP_NUCLIDE_FRACS0 + :implements: R_ARMI_COMP_NUCLIDE_FRACS + + The method allows a user or plugin to set the number density of a Component. + It also indicates to other processes that may depend on a Component's + status about this change via the ``assigned`` attribute. + Parameters ---------- nucName : str @@ -654,6 +728,14 @@ def setNumberDensities(self, numberDensities): """ Set one or more multiple number densities. Clears out any number density not listed. + .. impl:: Setting nuclide fractions. + :id: I_ARMI_COMP_NUCLIDE_FRACS1 + :implements: R_ARMI_COMP_NUCLIDE_FRACS + + The method allows a user or plugin to set the number densities of a + Component. In contrast to the ``setNumberDensity`` method, it sets all + densities within a Component. + Parameters ---------- numberDensities : dict @@ -757,6 +839,22 @@ def setDimension(self, key, val, retainLink=False, cold=True): """ Set a single dimension on the component. + .. impl:: Set a Component dimension, considering thermal expansion. + :id: I_ARMI_COMP_EXPANSION1 + :implements: R_ARMI_COMP_EXPANSION + + Dimensions should be set considering the impact of thermal expansion. This + method allows for a user or plugin to set a dimension and indicate if the + dimension is for a cold configuration or not. If it is not for a cold + configuration, the thermal expansion factor is considered when setting the + dimension. + + If the ``retainLink`` argument is ``True``, any Components linked to this + one will also have its dimensions changed consistently. After a dimension + is updated, the ``clearLinkedCache`` method is called which sets the + volume of this Component to ``None``. This ensures that when the volume is + next accessed it is recomputed using the updated dimensions. + Parameters ---------- key : str @@ -790,6 +888,17 @@ def getDimension(self, key, Tc=None, cold=False): """ Return a specific dimension at temperature as determined by key. + .. impl:: Retrieve a dimension at a specified temperature. + :id: I_ARMI_COMP_DIMS + :implements: R_ARMI_COMP_DIMS + + Due to thermal expansion, Component dimensions depend on their temperature. + This method retrieves a dimension from the Component at a particular + temperature, if provided. If the Component is a LinkedComponent then the + dimensions are resolved to ensure that any thermal expansion that has + occurred to the Components that the LinkedComponent depends on is reflected + in the returned dimension. + Parameters ---------- key : str @@ -862,6 +971,19 @@ def getThermalExpansionFactor(self, Tc=None, T0=None): """ Retrieves the material thermal expansion fraction. + .. impl:: Calculates radial thermal expansion factor. + :id: I_ARMI_COMP_EXPANSION0 + :implements: R_ARMI_COMP_EXPANSION + + This method enables the calculation of the thermal expansion factor + for a given material. If the material is solid, the difference + between ``T0`` and ``Tc`` is used to calculate the thermal expansion + factor. If a solid material does not have a linear expansion factor + defined and the temperature difference is greater than + a predetermined tolerance, an + error is raised. Thermal expansion of fluids or custom materials is + neglected, currently. + Parameters ---------- Tc : float, optional diff --git a/armi/reactor/composites.py b/armi/reactor/composites.py index 1d3a8092b..f0a7baf4e 100644 --- a/armi/reactor/composites.py +++ b/armi/reactor/composites.py @@ -16,18 +16,21 @@ This module contains the basic composite pattern underlying the reactor package. This follows the principles of the `Composite Design Pattern -<https://en.wikipedia.org/wiki/Composite_pattern>`_ to allow the construction of a -part/whole hierarchy representing a physical nuclear reactor. The composite objects act -somewhat like lists: they can be indexed, iterated over, appended, extended, inserted, -etc. Each member of the hierarchy knows its children and its parent, so full access to -the hierarchy is available from everywhere. This design was chosen because of the close -analogy of the model to the physical nature of nuclear reactors. - -.. warning:: Because each member of the hierarchy is linked to the entire tree, - it is often unsafe to save references to individual members; it can cause - large and unexpected memory inefficiencies. - -See Also: :doc:`/developer/index`. +<https://en.wikipedia.org/wiki/Composite_pattern>`_ to allow the construction of a part/whole +hierarchy representing a physical nuclear reactor. The composite objects act somewhat like lists: +they can be indexed, iterated over, appended, extended, inserted, etc. Each member of the hierarchy +knows its children and its parent, so full access to the hierarchy is available from everywhere. +This design was chosen because of the close analogy of the model to the physical nature of nuclear +reactors. + +Warning +------- +Because each member of the hierarchy is linked to the entire tree, it is often unsafe to save +references to individual members; it can cause large and unexpected memory inefficiencies. + +See Also +-------- +:doc:`/developer/index`. """ import collections import itertools @@ -244,13 +247,17 @@ class CompositeModelType(resolveCollections.ResolveParametersMeta): """ Metaclass for tracking subclasses of ArmiObject subclasses. - It is often useful to have an easily-accessible collection of all classes that - participate in the ARMI composite reactor model. This metaclass maintains a - collection of all defined subclasses, called TYPES. + It is often useful to have an easily-accessible collection of all classes that participate in + the ARMI composite reactor model. This metaclass maintains a collection of all defined + subclasses, called TYPES. """ - # Dictionary mapping class name -> class object for all subclasses TYPES: Dict[str, Type] = dict() + """ + Dictionary mapping class name to class object for all subclasses. + + :meta hide-value: + """ def __new__(cls, name, bases, attrs): newType = resolveCollections.ResolveParametersMeta.__new__( @@ -269,32 +276,41 @@ class ArmiObject(metaclass=CompositeModelType): This: * declares the interface for objects in the composition - * implements default behavior for the interface common to all - classes - * Declares an interface for accessing and managing - child objects + * implements default behavior for the interface common to all classes + * Declares an interface for accessing and managing child objects * Defines an interface for accessing parents. - Called "component" in gang of four, this is an ArmiObject here because the word - component was already taken in ARMI. - - The :py:class:`armi.reactor.parameters.ResolveParametersMeta` metaclass is used to - automatically create ``ParameterCollection`` subclasses for storing parameters - associated with any particular subclass of ArmiObject. Defining a ``pDefs`` class - attribute in the definition of a subclass of ArmiObject will lead to the creation of - a new subclass of py:class:`armi.reactor.parameters.ParameterCollection`, which will - contain the definitions from that class's ``pDefs`` as well as the definitions for - all of its parents. A new ``paramCollectionType`` class attribute will be added to - the ArmiObject subclass to reflect which type of parameter collection should be - used. - - .. warning:: - This class has far too many public methods. We are in the midst of a composite - tree cleanup that will likely break these out onto a number of separate functional - classes grouping things like composition, location, shape/dimensions, and - various physics queries. Methods are being collected here from the various - specialized subclasses (Block, Assembly) in preparation for this next step. - As a result, the public API on this method should be considered unstable. + Called "component" in gang of four, this is an ArmiObject here because the word component was + already taken in ARMI. + + The :py:class:`armi.reactor.parameters.ResolveParametersMeta` metaclass is used to automatically + create ``ParameterCollection`` subclasses for storing parameters associated with any particular + subclass of ArmiObject. Defining a ``pDefs`` class attribute in the definition of a subclass of + ArmiObject will lead to the creation of a new subclass of + py:class:`armi.reactor.parameters.ParameterCollection`, which will contain the definitions from + that class's ``pDefs`` as well as the definitions for all of its parents. A new + ``paramCollectionType`` class attribute will be added to the ArmiObject subclass to reflect + which type of parameter collection should be used. + + Warning + ------- + This class has far too many public methods. We are in the midst of a composite tree cleanup that + will likely break these out onto a number of separate functional classes grouping things like + composition, location, shape/dimensions, and various physics queries. Methods are being + collected here from the various specialized subclasses (Block, Assembly) in preparation for this + next step. As a result, the public API on this method should be considered unstable. + + .. impl:: Parameters are accessible throughout the armi tree. + :id: I_ARMI_PARAM_PART + :implements: R_ARMI_PARAM_PART + + An ARMI reactor model is composed of collections of ARMIObject objects. These + objects are combined in a hierarchical manner. Each level of the composite tree + is able to be assigned parameters which define it, such as temperature, flux, + or keff values. This class defines an attribute of type ``ParameterCollection``, + which contains all the functionality of an ARMI ``Parameter`` object. Because + the entire model is composed of ARMIObjects at the most basic level, each level + of the Composite tree contains this parameter attribute and can thus be queried. Attributes ---------- @@ -550,7 +566,6 @@ def doChildrenHaveFlags(self, typeSpec: TypeSpec, deep=False): ---------- typeSpec : TypeSpec Requested type of the child - """ for c in self.getChildren(deep): if c.hasFlags(typeSpec, exact=False): @@ -571,7 +586,6 @@ def containsAtLeastOneChildWithFlags(self, typeSpec: TypeSpec): -------- self.doChildrenHaveFlags self.containsOnlyChildrenWithFlags - """ return any(self.doChildrenHaveFlags(typeSpec)) @@ -588,7 +602,6 @@ def containsOnlyChildrenWithFlags(self, typeSpec: TypeSpec): -------- self.doChildrenHaveFlags self.containsAtLeastOneChildWithFlags - """ return all(self.doChildrenHaveFlags(typeSpec)) @@ -617,9 +630,17 @@ def getParameterCollection(cls): ``paramCollectionType`` is not a top-level object and therefore cannot be trivially pickled. Since we know that by the time we want to make any instances of/unpickle a given ``ArmiObject``, such a class attribute will have been - created and associated. So, we use this top-level class method to dig + created and associated. So, we use this top-level method to dig dynamically down to the underlying parameter collection type. + .. impl:: Composites (and all ARMI objects) have parameter collections. + :id: I_ARMI_CMP_PARAMS + :implements: R_ARMI_CMP_PARAMS + + This class method allows a user to obtain the + ``paramCollection`` object, which is the object containing the interface for + all parameters of an ARMI object. + See Also -------- :py:meth:`armi.reactor.parameters.parameterCollections.ParameterCollection.__reduce__` @@ -649,6 +670,14 @@ def nameContains(self, s): return s.lower() in name def getName(self): + """Get composite name. + + .. impl:: Composite name is accessible. + :id: I_ARMI_CMP_GET_NAME + :implements: R_ARMI_CMP_GET_NAME + + This method returns the name of a Composite. + """ return self.name def setName(self, name): @@ -658,6 +687,21 @@ def hasFlags(self, typeID: TypeSpec, exact=False): """ Determine if this object is of a certain type. + .. impl:: Composites have queryable flags. + :id: I_ARMI_CMP_FLAG0 + :implements: R_ARMI_CMP_FLAG + + This method queries the flags (i.e. the ``typeID``) of the Composite for a + given type, returning a boolean representing whether or not the candidate + flag is present in this ArmiObject. Candidate flags cannot be passed as a + ``string`` type and must be of a type ``Flag``. If no flags exist in the + object then ``False`` is returned. + + If a list of flags is provided, then all input flags will be + checked against the flags of the object. If exact is ``False``, then the + object must have at least one of candidates exactly. If it is ``True`` then + the object flags and candidates must match exactly. + Parameters ---------- typeID : TypeSpec @@ -749,6 +793,12 @@ def setType(self, typ, flags: Optional[Flags] = None): """ Set the object type. + .. impl:: Composites have modifiable flags. + :id: I_ARMI_CMP_FLAG1 + :implements: R_ARMI_CMP_FLAG + + This method allows for the setting of flags parameter of the Composite. + Parameters ---------- typ : str @@ -863,6 +913,14 @@ def getMass(self, nuclideNames=None): """ Determine the mass in grams of nuclide(s) and/or elements in this object. + .. impl:: Return mass of composite. + :id: I_ARMI_CMP_GET_MASS + :implements: R_ARMI_CMP_GET_MASS + + This method allows for the querying of the mass of a Composite. + If the ``nuclideNames`` argument is included, it will filter for the mass + of those nuclide names and provide the sum of the mass of those nuclides. + Parameters ---------- nuclideNames : str, optional @@ -1206,6 +1264,14 @@ def getNumberDensity(self, nucName): """ Return the number density of a nuclide in atoms/barn-cm. + .. impl:: Get number density for a specific nuclide + :id: I_ARMI_CMP_NUC0 + :implements: R_ARMI_CMP_NUC + + This method queries the number density + of a specific nuclide within the Composite. It invokes the + ``getNuclideNumberDensities`` method for just the requested nuclide. + Notes ----- This can get called very frequently and has to do volume computations so should @@ -1221,7 +1287,18 @@ def getNumberDensity(self, nucName): return self.getNuclideNumberDensities([nucName])[0] def getNuclideNumberDensities(self, nucNames): - """Return a list of number densities in atoms/barn-cm for the nuc names requested.""" + """Return a list of number densities in atoms/barn-cm for the nuc names requested. + + .. impl:: Get number densities for specific nuclides. + :id: I_ARMI_CMP_NUC1 + :implements: R_ARMI_CMP_NUC + + This method provides the capability to query the volume weighted number + densities for a list of nuclides within a given Composite. It provides the + result in units of atoms/barn-cm. The volume weighting is accomplished by + multiplying the number densities within each child Composite by the volume + of the child Composite and dividing by the total volume of the Composite. + """ volumes = numpy.array( [ c.getVolume() / (c.parent.getSymmetryFactor() if c.parent else 1.0) @@ -1258,6 +1335,18 @@ def getNumberDensities(self, expandFissionProducts=False): """ Retrieve the number densities in atoms/barn-cm of all nuclides (or those requested) in the object. + .. impl:: Number density of composite is retrievable. + :id: I_ARMI_CMP_GET_NDENS + :implements: R_ARMI_CMP_GET_NDENS + + This method provides a way for retrieving the number densities + of all nuclides within the Composite. It does this by leveraging the + ``_getNdensHelper`` method, which invokes the ``getNuclideNumberDensities`` + method. This method considers the nuclides within each child Composite of + this composite (if they exist). If the ``expandFissionProducts`` flag is + ``True``, then the lumped fission products are expanded to include their + constituent elements via the ``_expandLFPs`` method. + Parameters ---------- expandFissionProducts : bool (optional) @@ -2413,6 +2502,16 @@ def getComponentByName(self, name): """ Gets a particular component from this object, based on its name. + .. impl:: Get child component by name. + :id: I_ARMI_CMP_BY_NAME + :implements: R_ARMI_CMP_BY_NAME + + Each Composite has a name, and some Composites are made up + of collections of child Composites. This method retrieves a child + Component from this Composite by searching for it by name. If more than + one Component shares the same name, it raises a ``ValueError``. If no + Components are found by the input name then ``None`` is returned. + Parameters ---------- name : str @@ -2576,7 +2675,6 @@ def getDominantMaterial(self, typeSpec: TypeSpec = None, exact=False): Gets components that are made of a particular material gatherMaterialsByVolume Classifies all materials by volume - """ return getDominantMaterial([self], typeSpec, exact) @@ -2624,6 +2722,19 @@ class Composite(ArmiObject): mixed with siblings in a grid. This allows mixing grid-representation with explicit representation, often useful in advanced assemblies and thermal reactors. + + .. impl:: Composites are a physical part of the reactor in a hierarchical data model. + :id: I_ARMI_CMP0 + :implements: R_ARMI_CMP + + An ARMI reactor model is composed of collections of ARMIObject objects. This + class is a child-class of the ARMIObject class and provides a structure + allowing a reactor model to be composed of Composites. + + This class provides various methods to query and modify the hierarchical ARMI + reactor model, including but not limited to, iterating, sorting, and adding or + removing child Composites. + """ def __init__(self, name): @@ -2729,6 +2840,22 @@ def getChildren( """ Return the children objects of this composite. + .. impl:: Composites have children in the hierarchical data model. + :id: I_ARMI_CMP1 + :implements: R_ARMI_CMP + + This method retrieves all children within a given Composite object. Children + of any generation can be retrieved. This is achieved by visiting all + children and calling this method recursively for each generation requested. + + If the method is called with ``includeMaterials``, it will additionally + include information about the material for each child. If a function is + supplied as the ``predicate`` argument, then this method will be used + to evaluate all children as a filter to include or not. For example, if the + caller of this method only desires children with a certain flag, or children + which only contain a certain material, then the ``predicate`` function + can be used to perform this filtering. + Parameters ---------- deep : boolean, optional @@ -2843,6 +2970,17 @@ def syncMpiState(self): In parallelized runs, if each process has its own copy of the entire reactor hierarchy, this method synchronizes the state of all parameters on all objects. + .. impl:: Composites can be synchronized across MPI threads. + :id: I_ARMI_CMP_MPI + :implements: R_ARMI_CMP_MPI + + Parameters need to be handled properly during parallel code execution.This + method synchronizes all parameters of the composite object across all + processes by cycling through all the children of the Composite and ensuring + that their parameters are properly synchronized. If it fails to synchronize, + an error message is displayed which alerts the user to which Composite has + inconsistent data across the processes. + Returns ------- int @@ -2871,9 +3009,8 @@ def syncMpiState(self): runLog.error("\n".join(msg)) raise - errors = collections.defaultdict( - list - ) # key is (comp, paramName) value is conflicting nodes + # key is (comp, paramName) value is conflicting nodes + errors = collections.defaultdict(list) syncCount = 0 compsPerNode = {len(nodeSyncData) for nodeSyncData in allSyncData} @@ -3055,6 +3192,7 @@ def requiresLumpedFissionProducts(self, nuclides=None): if nuclides is None: nuclides = self.getNuclides() + # ruff: noqa: SIM110 for nucName in nuclides: if isinstance(nuclideBases.byName[nucName], nuclideBases.LumpNuclideBase): return True @@ -3197,19 +3335,18 @@ class StateRetainer: """ Retains state during some operations. - This can be used to temporarily cache state, perform an operation, extract some info, and - then revert back to the original state. + This can be used to temporarily cache state, perform an operation, extract some info, and then + revert back to the original state. - * A state retainer is faster than restoring state from a database as it reduces - the number of IO reads; however, it does use more memory. + * A state retainer is faster than restoring state from a database as it reduces the number of IO + reads; however, it does use more memory. * This can be used on any object within the composite pattern via with ``[rabc].retainState([list], [of], [parameters], [to], [retain]):``. Use on an object up in the hierarchy applies to all objects below as well. - * This is intended to work across MPI, so that if you were to broadcast the - reactor the state would be correct; however the exact implication on - ``parameters`` may be unclear. + * This is intended to work across MPI, so that if you were to broadcast the reactor the state + would be correct; however the exact implication on ``parameters`` may be unclear. """ @@ -3223,9 +3360,8 @@ def __init__(self, composite, paramsToApply=None): composite object to retain state (recursively) paramsToApply: iterable of parameters.Parameter - Iterable of parameters.Parameter to retain updated values after `__exit__`. - All other parameters are reverted to the original state, i.e. retained at - the original value. + Iterable of parameters.Parameter to retain updated values after `__exit__`. All other + parameters are reverted to the original state, i.e. retained at the original value. """ self.composite = composite self.paramsToApply = set(paramsToApply or []) @@ -3238,7 +3374,9 @@ def __exit__(self, *args): self._enterExitHelper(lambda obj: obj.restoreBackup(self.paramsToApply)) def _enterExitHelper(self, func): - """Helper method for ``__enter__`` and ``__exit__``. ``func`` is a lambda to either ``backUp()`` or ``restoreBackup()``.""" + """Helper method for ``__enter__`` and ``__exit__``. ``func`` is a lambda to either + ``backUp()`` or ``restoreBackup()``. + """ paramDefs = set() for child in [self.composite] + self.composite.getChildren( deep=True, includeMaterials=True @@ -3260,8 +3398,8 @@ def gatherMaterialsByVolume( Parameters ---------- objects : list of ArmiObject - Objects to look within. This argument allows clients to search though some subset - of the three (e.g. when you're looking for all CLADDING components within FUEL blocks) + Objects to look within. This argument allows clients to search though some subset of the + three (e.g. when you're looking for all CLADDING components within FUEL blocks) typeSpec : TypeSpec Flags for the components to look at @@ -3271,9 +3409,9 @@ def gatherMaterialsByVolume( Notes ----- - This helper method is outside the main ArmiObject tree for the special clients that need - to filter both by container type (e.g. Block type) with one set of flags, and Components - with another set of flags. + This helper method is outside the main ArmiObject tree for the special clients that need to + filter both by container type (e.g. Block type) with one set of flags, and Components with + another set of flags. .. warning:: This is a **composition** related helper method that will likely be filed into classes/modules that deal specifically with the composition of things in the data model. @@ -3298,18 +3436,19 @@ def getDominantMaterial( """ Return the first sample of the most dominant material (by volume) in a set of objects. - .. warning:: This is a **composition** related helper method that will likely be filed into - classes/modules that deal specifically with the composition of things in the data model. - Thus clients that use it from here should expect to need updates soon. + Warning + ------- + This is a **composition** related helper method that will likely be filed into classes/modules + that deal specifically with the composition of things in the data model. Thus clients that use + it from here should expect to need updates soon. """ volumes, samples = gatherMaterialsByVolume(objects, typeSpec, exact) if volumes: # find matName with max volume maxMatName = list(sorted(volumes.items(), key=lambda item: item[1])).pop()[0] - # return this material. Note that if this material - # has properties like Zr-frac, enrichment, etc. then this will - # just return one in the batch, not an average. + # return this material. Note that if this material has properties like Zr-frac, enrichment, + # etc. then this will just return one in the batch, not an average. return samples[maxMatName] return None @@ -3320,13 +3459,13 @@ def getReactionRateDict(nucName, lib, xsSuffix, mgFlux, nDens): Parameters ---------- nucName : str - nuclide name -- e.g. 'U235', 'PU239', etc. Not to be confused with the nuclide - _label_, see the nucDirectory module for a description of the difference. + nuclide name -- e.g. 'U235', 'PU239', etc. Not to be confused with the nuclide _label_, see + the nucDirectory module for a description of the difference. lib : isotxs cross section library xsSuffix : str - cross section suffix, consisting of the type followed by the burnup group, - e.g. 'AB' for the second burnup group of type A + cross section suffix, consisting of the type followed by the burnup group, e.g. 'AB' for the + second burnup group of type A mgFlux : numpy.nArray integrated mgFlux (n-cm/s) nDens : float diff --git a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py index b62c72174..bf165845b 100644 --- a/armi/reactor/converters/axialExpansion/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py @@ -35,6 +35,26 @@ def expandColdDimsToHot( """ Expand BOL assemblies, resolve disjoint axial mesh (if needed), and update block BOL heights. + .. impl:: Perform expansion during core construction based on block heights at a specified temperature. + :id: I_ARMI_INP_COLD_HEIGHT + :implements: R_ARMI_INP_COLD_HEIGHT + + This method is designed to be used during core construction to axially thermally expand the + assemblies to their "hot" temperatures (as determined by ``Thot`` values in blueprints). + First, The Assembly is prepared for axial expansion via ``setAssembly``. In + ``applyColdHeightMassIncrease``, the number densities on each Component is adjusted to + reflect that Assembly inputs are at cold (i.e., ``Tinput``) temperatures. To expand to + the requested hot temperatures, thermal expansion factors are then computed in + ``computeThermalExpansionFactors``. Finally, the Assembly is axially thermally expanded in + ``axiallyExpandAssembly``. + + If the setting ``detailedAxialExpansion`` is ``False``, then each Assembly gets its Block mesh + set to match that of the "reference" Assembly (see ``getDefaultReferenceAssem`` and ``setBlockMesh``). + + Once the Assemblies are axially expanded, the Block BOL heights are updated. To account for the change in + Block volume from axial expansion, ``completeInitialLoading`` is called to update any volume-dependent + Block information. + Parameters ---------- assems: list[:py:class:`Assembly <armi.reactor.assemblies.Assembly>`] @@ -106,7 +126,18 @@ def __init__(self, detailedAxialExpansion: bool = False): def performPrescribedAxialExpansion( self, a, componentLst: list, percents: list, setFuel=True ): - """Perform axial expansion of an assembly given prescribed expansion percentages. + """Perform axial expansion/contraction of an assembly given prescribed expansion percentages. + + .. impl:: Perform expansion/contraction, given a list of components and expansion coefficients. + :id: I_ARMI_AXIAL_EXP_PRESC + :implements: R_ARMI_AXIAL_EXP_PRESC + + This method performs component-wise axial expansion for an Assembly given expansion coefficients + and a corresponding list of Components. In ``setAssembly``, the Assembly is prepared + for axial expansion by determining Component-wise axial linkage and checking to see if a dummy Block + is in place (necessary for ensuring conservation properties). The provided expansion factors are + then assigned to their corresponding Components in ``setExpansionFactors``. Finally, the axial + expansion is performed in ``axiallyExpandAssembly`` Parameters ---------- @@ -136,7 +167,22 @@ def performThermalAxialExpansion( setFuel: bool = True, expandFromTinputToThot: bool = False, ): - """Perform thermal expansion for an assembly given an axial temperature grid and field. + """Perform thermal expansion/contraction for an assembly given an axial temperature grid and + field. + + .. impl:: Perform thermal expansion/contraction, given an axial temperature distribution + over an assembly. + :id: I_ARMI_AXIAL_EXP_THERM + :implements: R_ARMI_AXIAL_EXP_THERM + + This method performs component-wise thermal expansion for an assembly given a discrete + temperature distribution over the axial length of the Assembly. In ``setAssembly``, the + Assembly is prepared for axial expansion by determining Component-wise axial linkage and + checking to see if a dummy Block is in place (necessary for ensuring conservation + properties). The discrete temperature distribution is then leveraged to update Component + temperatures and compute thermal expansion factors (via + ``updateComponentTempsBy1DTempField`` and ``computeThermalExpansionFactors``, + respectively). Finally, the axial expansion is performed in ``axiallyExpandAssembly``. Parameters ---------- @@ -228,7 +274,17 @@ def _isTopDummyBlockPresent(self): raise RuntimeError(msg) def axiallyExpandAssembly(self): - """Utilizes assembly linkage to do axial expansion.""" + """Utilizes assembly linkage to do axial expansion. + + .. impl:: Preserve the total height of an ARMI assembly, during expansion. + :id: I_ARMI_ASSEM_HEIGHT_PRES + :implements: R_ARMI_ASSEM_HEIGHT_PRES + + The total height of an Assembly is preserved by not changing the ``ztop`` position + of the top-most Block in an Assembly. The ``zbottom`` of the top-most Block is + adjusted to match the Block immediately below it. The ``height`` of the + top-most Block is is then updated to reflect any expansion/contraction. + """ mesh = [0.0] numOfBlocks = self.linked.a.countBlocksWithFlags() runLog.debug( diff --git a/armi/reactor/converters/blockConverters.py b/armi/reactor/converters/blockConverters.py index 245f0eae4..62f7873f4 100644 --- a/armi/reactor/converters/blockConverters.py +++ b/armi/reactor/converters/blockConverters.py @@ -211,7 +211,26 @@ def convert(self): class ComponentMerger(BlockConverter): - """For a provided block, merged the solute component into the solvent component.""" + """For a provided block, merged the solute component into the solvent component. + + .. impl:: Homogenize one component into another. + :id: I_ARMI_BLOCKCONV0 + :implements: R_ARMI_BLOCKCONV + + This subclass of ``BlockConverter`` is meant as a one-time-use tool, to convert + a ``Block`` into one ``Component``. A ``Block`` is a ``Composite`` that may + probably has multiple ``Components`` somewhere in it. This means averaging the + material properties in the original ``Block``, and ensuring that the final + ``Component`` has the same shape and volume as the original ``Block``. This + subclass essentially just uses the base class method + ``dissolveComponentIntoComponent()`` given prescribed solute and solvent + materials, to define the merger. + + Notes + ----- + It is the job of the developer to determine if merging a Block into one Component + will yield valid or sane results. + """ def __init__(self, sourceBlock, soluteName, solventName): """ @@ -245,13 +264,23 @@ class MultipleComponentMerger(BlockConverter): liner was dissolved first, this would normally cause a ValueError in _verifyExpansion since the clad would be completely expanded over a non void component. - This could be implemented on the regular ComponentMerger, as the Flags system has enough power - in the type specification arguments to things like ``getComponents()``, ``hasFlags()``, etc., to - do single and multiple components with the same code. + .. impl:: Homogenize multiple components into one. + :id: I_ARMI_BLOCKCONV1 + :implements: R_ARMI_BLOCKCONV + + This subclass of ``BlockConverter`` is meant as a one-time-use tool, to convert + a multiple ``Components`` into one. This means averaging the material + properties in the original ``Components``, and ensuring that the final + ``Component`` has the same shape and volume as all of the originals. This + subclass essentially just uses the base class method + ``dissolveComponentIntoComponent()`` given prescribed solute and solvent + materials, to define the merger. Though care is taken here to ensure the merger + isn't verified until it is completely finished. """ def __init__(self, sourceBlock, soluteNames, solventName, specifiedMinID=0.0): - """ + """Standard constructor method. + Parameters ---------- sourceBlock : :py:class:`armi.reactor.blocks.Block` @@ -526,7 +555,21 @@ def __init__( ) def convert(self): - """Perform the conversion.""" + """Perform the conversion. + + .. impl:: Convert hex blocks to cylindrical blocks. + :id: I_ARMI_BLOCKCONV_HEX_TO_CYL + :implements: R_ARMI_BLOCKCONV_HEX_TO_CYL + + This method converts a ``HexBlock`` to a cylindrical ``Block``. Obviously, + this is not a physically meaningful transition; it is a helpful + approximation tool for analysts. This is a subclass of + ``BlockAvgToCylConverter`` which is a subclass of ``BlockConverter``. This + converter expects the ``sourceBlock`` and ``driverFuelBlock`` to defined + and for the ``sourceBlock`` to have a spatial grid defined. Additionally, + both the ``sourceBlock`` and ``driverFuelBlock`` must be instances of + ``HexBlocks``. + """ runLog.info( "Converting representative block {} to its equivalent cylindrical model".format( self._sourceBlock @@ -715,7 +758,7 @@ def radiiFromHexSides(sideLengths): def radiiFromRingOfRods(distToRodCenter, numRods, rodRadii, layout="hexagon"): - """ + r""" Return list of radii from ring of rods. Parameters @@ -735,17 +778,24 @@ def radiiFromRingOfRods(distToRodCenter, numRods, rodRadii, layout="hexagon"): Notes ----- There are two assumptions when making circles: - 1) the rings are concentric about the radToRodCenter; - 2) the ring area of the fuel rods are distributed to the inside and outside rings with the same thickness. - thicknessOnEachSide (t) is calculated as follows: - r1 = inner rad that thickness is added to on inside - r2 = outer rad that thickness is added to on outside - radToRodCenter = (r1 + r2) / 2.0 due to being concentric; - Total Area = Area of annulus 1 + Area of annulus 2 - Area of annulus 1 = pi * r1 ** 2 - pi * (r1 - t) ** 2 - Area of annulus 2 = pi * (r2 + t) ** 2 - pi * r2 ** 2 - Solving for thicknessOnEachSide(t): - t = Total Area / (4 * pi * radToRodCenter) + + #. The rings are concentric about the ``radToRodCenter``. + #. The ring area of the fuel rods are distributed to the inside and outside + rings with the same thickness. ``thicknessOnEachSide`` (:math:`t`) is calculated + as follows: + + .. math:: + :nowrap: + + \begin{aligned} + r_1 &\equiv \text{inner rad that thickness is added to on inside} \\ + r_2 &\equiv \text{outer rad that thickness is added to on outside} \\ + \texttt{radToRodCenter} &= \frac{r_1 + r_2}{2} \text{(due to being concentric)} \\ + \text{Total Area} &= \text{Area of annulus 1} + \text{Area of annulus 2} \\ + \text{Area of annulus 1} &= \pi r_1^2 - \pi (r_1 - t)^2 \\ + \text{Area of annulus 2} &= \pi (r_2 + t)^2 - \pi r_2^2 \\ + t &= \frac{\text{Total Area}}{4\pi\times\texttt{radToRodCenter}} + \end{aligned} """ if layout == "polygon": alpha = 2.0 * math.pi / float(numRods) diff --git a/armi/reactor/converters/geometryConverters.py b/armi/reactor/converters/geometryConverters.py index 7ddfdd727..ed1ba1011 100644 --- a/armi/reactor/converters/geometryConverters.py +++ b/armi/reactor/converters/geometryConverters.py @@ -15,14 +15,15 @@ """ Change a reactor from one geometry to another. -Examples may include going from Hex to R-Z or from Third-core to full core. This module -contains **converters** (which create new reactor objects with different geometry), and -**changers** (which modify a given reactor in place) in this module. +Examples may include going from Hex to R-Z or from Third-core to full core. This module contains +**converters** (which create new reactor objects with different geometry), and **changers** (which +modify a given reactor in place) in this module. Generally, mass is conserved in geometry conversions. -.. warning:: These are mostly designed for hex geometry. - +Warning +------- +These are mostly designed for hex geometry. """ import collections import copy @@ -107,7 +108,8 @@ class GeometryConverter(GeometryChanger): -------- To convert a hex case to a R-Z case, do this: - >>> geomConv = armi.reactorConverters.HexToRZConverter(useMostCommonXsId=False, expandReactor=False) + >>> from armi.reactorConverters import HexToRZConverter + >>> HexToRZConverter(useMostCommonXsId=False, expandReactor=False) >>> geomConv.convert(r) >>> newR = geomConv.convReactor >>> dif3d = dif3dInterface.Dif3dInterface('dif3dRZ', newR) @@ -126,7 +128,8 @@ class FuelAssemNumModifier(GeometryChanger): Notes ----- - - The number of fuel assemblies should ALWAYS be set for the third-core regardless of the reactor geometry model. + - The number of fuel assemblies should ALWAYS be set for the third-core regardless of the + reactor geometry model. - The modification is only valid for third-core and full-core geometry models. """ @@ -144,8 +147,8 @@ def convert(self, r): Notes ----- - - While adding fuel, does not modify existing fuel/control positions, but does overwrite assemblies in the - overwriteList (e.g. reflectors, shields) + - While adding fuel, does not modify existing fuel/control positions, but does overwrite + assemblies in the overwriteList (e.g. reflectors, shields) - Once specified amount of fuel is in place, removes all assemblies past the outer fuel boundary - To re-add reflector/shield assemblies around the new core, use the ringsToAdd attribute """ @@ -323,8 +326,8 @@ class HexToRZThetaConverter(GeometryConverter): Parameters ---------- converterSettings: dict - Settings that specify how the mesh of the RZTheta reactor should be generated. - Controls the number of theta regions, how to group regions, etc. + Settings that specify how the mesh of the RZTheta reactor should be generated. Controls the + number of theta regions, how to group regions, etc. uniformThetaMesh bool flag that determines if the theta mesh should be uniform or not @@ -336,19 +339,21 @@ class HexToRZThetaConverter(GeometryConverter): * ``Ring Compositions`` -- to convert by composition axialConversionType - * ``Axial Coordinates`` -- use :py:class:`armi.reactor.converters.meshConverters._RZThetaReactorMeshConverterByAxialCoordinates` - * ``Axial Bins`` -- use :py:class:`armi.reactor.converters.meshConverters._RZThetaReactorMeshConverterByAxialBins` + * ``Axial Coordinates`` -- use + :py:class:`armi.reactor.converters.meshConverters._RZThetaReactorMeshConverterByAxialCoordinates` + * ``Axial Bins`` -- use + :py:class:`armi.reactor.converters.meshConverters._RZThetaReactorMeshConverterByAxialBins` homogenizeAxiallyByFlags - Boolean that if set to True will ignore the `axialConversionType` input and determine a mesh based on the - material boundaries for each RZ region axially. + Boolean that if set to True will ignore the `axialConversionType` input and determine a + mesh based on the material boundaries for each RZ region axially. expandReactor : bool - If True, the HEX-Z reactor will be expanded to full core geometry prior to converting to the RZT reactor. - Either way the converted RZTheta core will be full core. + If True, the HEX-Z reactor will be expanded to full core geometry prior to converting to the + RZT reactor. Either way the converted RZTheta core will be full core. strictHomogenization : bool - If True, the converter will restrict HEX-Z blocks with dissimilar XS types from being homogenized into an - RZT block. + If True, the converter will restrict HEX-Z blocks with dissimilar XS types from being + homogenized into an RZT block. """ _GEOMETRY_TYPE = geometry.GeomType.RZT @@ -430,6 +435,17 @@ def convert(self, r): """ Run the conversion to 3 dimensional R-Z-Theta. + .. impl:: Tool to convert a hex core to an RZTheta core. + :id: I_ARMI_CONV_3DHEX_TO_2DRZ + :implements: R_ARMI_CONV_3DHEX_TO_2DRZ + + This method converts the hex-z mesh to r-theta-z mesh. + It first verifies that the geometry type of the input reactor ``r`` + has the expected HEX geometry. Upon conversion, it determines the inner + and outer diameters of each ring in the r-theta-z mesh and calls + ``_createRadialThetaZone`` to create a radial theta zone with a homogenized mixture. + The axial dimension of the r-theta-z mesh is then updated by ``updateAxialMesh``. + Attributes ---------- r : Reactor object @@ -437,18 +453,22 @@ def convert(self, r): Notes ----- - As a part of the RZT mesh converters it is possible to obtain a radial mesh that - has repeated ring numbers. For instance, if there are fuel assemblies and control - assemblies within the same radial hex ring then it's possible that a radial mesh - output from the byRingComposition mesh converter method will look something like: + The linked requirement technically points to a child class of this class, HexToRZConverter. + However, this is the method where the conversion actually happens and thus the + implementation tag is noted here. + + As a part of the RZT mesh converters it is possible to obtain a radial mesh that has + repeated ring numbers. For instance, if there are fuel assemblies and control assemblies + within the same radial hex ring then it's possible that a radial mesh output from the + byRingComposition mesh converter method will look something like: self.meshConverter.radialMesh = [2, 3, 4, 4, 5, 5, 6, 6, 6, 7, 8, 8, 9, 10] - In this instance the hex ring will remain the same for multiple iterations over - radial direction when homogenizing the hex core into the RZT geometry. In this - case, the converter needs to keep track of the compositions within this ring so - that it can separate this repeated ring into multiple RZT rings. Each of the RZT - rings should have a single composition (fuel1, fuel2, control, etc.) + In this instance the hex ring will remain the same for multiple iterations over radial + direction when homogenizing the hex core into the RZT geometry. In this case, the converter + needs to keep track of the compositions within this ring so that it can separate this + repeated ring into multiple RZT rings. Each of the RZT rings should have a single + composition (fuel1, fuel2, control, etc.) See Also -------- @@ -541,13 +561,15 @@ def _setNextAssemblyTypeInRadialZone(self, lowerRing, upperRing): def _getSortedAssemblyTypesInRadialZone(self, lowerRing, upperRing): """ - Retrieve assembly types in a radial zone between (lowerRing, upperRing), sort from highest occurrence to lowest. + Retrieve assembly types in a radial zone between (lowerRing, upperRing), sort from highest + occurrence to lowest. Notes ----- - - Assembly types are based on the assembly names and not the direct composition within each assembly. For - instance, if two assemblies are named `fuel 1` and `fuel 2` but they have the same composition at some reactor - state then they will still be separated as two different assembly types. + - Assembly types are based on the assembly names and not the direct composition within each + assembly. For instance, if two assemblies are named `fuel 1` and `fuel 2` but they have + the same composition at some reactor state then they will still be separated as two + different assembly types. """ aCountByTypes = collections.Counter() for a in self._getAssembliesInCurrentRadialZone(lowerRing, upperRing): @@ -594,8 +616,8 @@ def _setAssemsInRadialZone(self, radialIndex, lowerRing, upperRing): Notes ----- - self._assemsInRadialZone keeps track of the unique assemblies that are in each radial ring. This - ensures that no assemblies are duplicated when using self._getAssemsInRadialThetaZone() + self._assemsInRadialZone keeps track of the unique assemblies that are in each radial ring. + This ensures that no assemblies are duplicated when using self._getAssemsInRadialThetaZone() """ lowerTheta = 0.0 for _thetaIndex, upperTheta in enumerate(self.meshConverter.thetaMesh): @@ -662,7 +684,9 @@ def _getAssembliesInSector(core, theta1, theta2): return aList def _getAssemsInRadialThetaZone(self, lowerRing, upperRing, lowerTheta, upperTheta): - """Retrieve list of assemblies in the reactor between (lowerRing, upperRing) and (lowerTheta, upperTheta).""" + """Retrieve list of assemblies in the reactor between (lowerRing, upperRing) and + (lowerTheta, upperTheta). + """ thetaAssems = self._getAssembliesInSector( self._sourceReactor.core, math.degrees(lowerTheta), math.degrees(upperTheta) ) @@ -841,8 +865,10 @@ def _checkVolumeConservation(self, newBlock): if abs(newBlockVolumeFraction - 1.0) > 0.00001: raise ValueError( - "The volume fraction of block {} is {} and not 1.0. An error occurred when converting the reactor" - " geometry.".format(newBlock, newBlockVolumeFraction) + "The volume fraction of block {} is {} and not 1.0. An error occurred when " + "converting the reactor geometry.".format( + newBlock, newBlockVolumeFraction + ) ) def createHomogenizedRZTBlock( @@ -851,8 +877,9 @@ def createHomogenizedRZTBlock( """ Create the homogenized RZT block by computing the average atoms in the zone. - Additional calculations are performed to determine the homogenized block type, the block average temperature, - and the volume fraction of each hex block that is in the new homogenized block. + Additional calculations are performed to determine the homogenized block type, the block + average temperature, and the volume fraction of each hex block that is in the new + homogenized block. """ homBlockXsTypes = set() numHexBlockByType = collections.Counter() @@ -881,8 +908,8 @@ def createHomogenizedRZTBlock( # Notify if blocks with different xs types are being homogenized. May be undesired behavior. if len(homBlockXsTypes) > 1: msg = ( - "Blocks {} with dissimilar XS IDs are being homogenized in {} between axial heights {} " - "cm and {} cm. ".format( + "Blocks {} with dissimilar XS IDs are being homogenized in {} between axial heights" + " {} cm and {} cm. ".format( self.blockMap[homBlock], self.convReactor.core, lowerAxialZ, @@ -907,16 +934,17 @@ def createHomogenizedRZTBlock( def _getHomogenizedBlockType(self, numHexBlockByType): """ - Generate the homogenized block mixture type based on the frequency of hex block types that were merged - together. + Generate the homogenized block mixture type based on the frequency of hex block types that + were merged together. Notes ----- self._BLOCK_MIXTURE_TYPE_EXCLUSIONS: - The normal function of this method is to assign the mixture name based on the number of occurrences of the - block type. This list stops that and assigns the mixture based on the first occurrence. - (i.e. if the mixture has a set of blocks but it comes across one with the name of 'control' the process will - stop and the new mixture type will be set to 'mixture control' + The normal function of this method is to assign the mixture name based on the number of + occurrences of the block type. This list stops that and assigns the mixture based on the + first occurrence. (i.e. if the mixture has a set of blocks but it comes across one with + the name of 'control' the process will stop and the new mixture type will be set to + 'mixture control'. self._BLOCK_MIXTURE_TYPE_MAP: A dictionary that provides the name of blocks that are condensed together @@ -1194,11 +1222,11 @@ def reset(self): class HexToRZConverter(HexToRZThetaConverter): - r""" + """ Create a new reactor with R-Z coordinates from the Hexagonal-Z reactor. - This is a subclass of the HexToRZThetaConverter. See the HexToRZThetaConverter for explanation and setup of - the converterSettings. + This is a subclass of the HexToRZThetaConverter. See the HexToRZThetaConverter for + explanation and setup of the converterSettings. """ _GEOMETRY_TYPE = geometry.GeomType.RZ @@ -1243,6 +1271,23 @@ def convert(self, r): """ Run the conversion. + .. impl:: Convert a one-third-core geometry to a full-core geometry. + :id: I_ARMI_THIRD_TO_FULL_CORE0 + :implements: R_ARMI_THIRD_TO_FULL_CORE + + This method first checks if the input reactor is already full core. + If full-core symmetry is detected, the input reactor is returned. + If not, it then verifies that the input reactor has the expected one-third + core symmetry and HEX geometry. + + Upon conversion, it loops over the assembly vector of the source + one-third core model, copies and rotates each source assembly to create + new assemblies, and adds them on the full-core grid. For the center assembly, + it modifies its parameters. + + Finally, it sets the domain type to full core. + + Parameters ---------- sourceReactor : Reactor object @@ -1329,7 +1374,17 @@ def convert(self, r): ) def restorePreviousGeometry(self, r=None): - """Undo the changes made by convert by going back to 1/3 core.""" + """Undo the changes made by convert by going back to 1/3 core. + + .. impl:: Restore a one-third-core geometry to a full-core geometry. + :id: I_ARMI_THIRD_TO_FULL_CORE1 + :implements: R_ARMI_THIRD_TO_FULL_CORE + + This method is a reverse process of the method ``convert``. It converts + the full-core reactor model back to the original one-third core reactor model by removing + the added assemblies and changing the parameters of the center + assembly from full core to one third core. + """ r = r or self._sourceReactor # remove the assemblies that were added when the conversion happened. @@ -1357,15 +1412,24 @@ class EdgeAssemblyChanger(GeometryChanger): Examples -------- - edgeChanger = EdgeAssemblyChanger() - edgeChanger.removeEdgeAssemblies(reactor.core) + edgeChanger = EdgeAssemblyChanger() + edgeChanger.removeEdgeAssemblies(reactor.core) """ def addEdgeAssemblies(self, core): """ Add the assemblies on the 120 degree symmetric line to 1/3 symmetric cases. - Needs to be called before a finite difference (DIF3D, DIFNT) or MCNP calculation + Needs to be called before a finite difference (DIF3D, DIFNT) or MCNP calculation. + + .. impl:: Add assemblies along the 120-degree line to a reactor. + :id: I_ARMI_ADD_EDGE_ASSEMS0 + :implements: R_ARMI_ADD_EDGE_ASSEMS + + Edge assemblies on the 120-degree symmetric line of a one-third core reactor model are added + because they are needed for DIF3D-finite difference or MCNP models. This is done + by copying the assemblies from the lower boundary and placing them in their + reflective positions on the upper boundary of the symmetry line. Parameters ---------- @@ -1433,6 +1497,14 @@ def removeEdgeAssemblies(self, core): This makes use of the assemblies knowledge of if it is in a region that it needs to be removed. + .. impl:: Remove assemblies along the 120-degree line from a reactor. + :id: I_ARMI_ADD_EDGE_ASSEMS1 + :implements: R_ARMI_ADD_EDGE_ASSEMS + + This method is the reverse process of the method ``addEdgeAssemblies``. It is + needed for the DIF3D-Nodal calculation. It removes the assemblies on the 120-degree + symmetry line. + See Also -------- addEdgeAssemblies : adds the edge assemblies diff --git a/armi/reactor/converters/tests/test_blockConverter.py b/armi/reactor/converters/tests/test_blockConverter.py index 2e587a151..9218a73f0 100644 --- a/armi/reactor/converters/tests/test_blockConverter.py +++ b/armi/reactor/converters/tests/test_blockConverter.py @@ -41,6 +41,13 @@ def tearDown(self): self.td.__exit__(None, None, None) def test_dissolveWireIntoCoolant(self): + """ + Test dissolving wire into coolant. + + .. test:: Homogenize one component into another. + :id: T_ARMI_BLOCKCONV0 + :tests: R_ARMI_BLOCKCONV + """ self._test_dissolve(loadTestBlock(), "wire", "coolant") hotBlock = loadTestBlock(cold=False) self._test_dissolve(hotBlock, "wire", "coolant") @@ -48,6 +55,13 @@ def test_dissolveWireIntoCoolant(self): self._test_dissolve(hotBlock, "wire", "coolant") def test_dissolveLinerIntoClad(self): + """ + Test dissolving liner into clad. + + .. test:: Homogenize one component into another. + :id: T_ARMI_BLOCKCONV1 + :tests: R_ARMI_BLOCKCONV + """ self._test_dissolve(loadTestBlock(), "outer liner", "clad") hotBlock = loadTestBlock(cold=False) self._test_dissolve(hotBlock, "outer liner", "clad") @@ -91,20 +105,21 @@ def test_build_NthRing(self): ) def test_convert(self): - """Test conversion with no fuel driver.""" + """Test conversion with no fuel driver. + + .. test:: Convert hex blocks to cylindrical blocks. + :id: T_ARMI_BLOCKCONV_HEX_TO_CYL1 + :tests: R_ARMI_BLOCKCONV_HEX_TO_CYL + """ block = ( loadTestReactor(TEST_ROOT)[1] .core.getAssemblies(Flags.FUEL)[2] .getFirstBlock(Flags.FUEL) ) - block.spatialGrid = grids.HexGrid.fromPitch(1.0) - area = block.getArea() converter = blockConverters.HexComponentsToCylConverter(block) converter.convert() - self.assertAlmostEqual(area, converter.convertedBlock.getArea()) - self.assertAlmostEqual(area, block.getArea()) for compType in [Flags.FUEL, Flags.CLAD, Flags.DUCT]: self.assertAlmostEqual( @@ -117,12 +132,22 @@ def test_convert(self): ] ), ) + for c in converter.convertedBlock.getComponents(compType): + self.assertEqual( + block.getComponent(compType).temperatureInC, c.temperatureInC + ) + self.assertEqual(block.getHeight(), converter.convertedBlock.getHeight()) self._checkAreaAndComposition(block, converter.convertedBlock) self._checkCiclesAreInContact(converter.convertedBlock) def test_convertHexWithFuelDriver(self): - """Test conversion with fuel driver.""" + """Test conversion with fuel driver. + + .. test:: Convert hex blocks to cylindrical blocks. + :id: T_ARMI_BLOCKCONV_HEX_TO_CYL0 + :tests: R_ARMI_BLOCKCONV_HEX_TO_CYL + """ driverBlock = ( loadTestReactor(TEST_ROOT)[1] .core.getAssemblies(Flags.FUEL)[2] diff --git a/armi/reactor/converters/tests/test_geometryConverters.py b/armi/reactor/converters/tests/test_geometryConverters.py index 68559983f..0e31b84c8 100644 --- a/armi/reactor/converters/tests/test_geometryConverters.py +++ b/armi/reactor/converters/tests/test_geometryConverters.py @@ -16,7 +16,6 @@ import math import os import unittest - from numpy.testing import assert_allclose from armi import runLog @@ -27,7 +26,7 @@ from armi.reactor.converters import uniformMesh from armi.reactor.flags import Flags from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings -from armi.tests import TEST_ROOT +from armi.tests import TEST_ROOT, mockRunLogs from armi.utils import directoryChangers @@ -40,7 +39,7 @@ def setUp(self): self.cs = self.o.cs def test_addRing(self): - r"""Tests that the addRing method adds the correct number of fuel assemblies to the test reactor.""" + """Tests that the addRing method adds the correct number of fuel assemblies to the test reactor.""" converter = geometryConverters.FuelAssemNumModifier(self.cs) converter.numFuelAssems = 7 converter.ringsToAdd = 1 * ["radial shield"] @@ -65,7 +64,7 @@ def test_addRing(self): ) # should wind up with 11 reflector assemblies per 1/3rd core def test_setNumberOfFuelAssems(self): - r"""Tests that the setNumberOfFuelAssems method properly changes the number of fuel assemblies.""" + """Tests that the setNumberOfFuelAssems method properly changes the number of fuel assemblies.""" # tests ability to add fuel assemblies converter = geometryConverters.FuelAssemNumModifier(self.cs) converter.numFuelAssems = 60 @@ -142,6 +141,19 @@ def tearDown(self): del self.r def test_convert(self): + """Test HexToRZConverter.convert(). + + Notes + ----- + Ensure the converted reactor has 1) nuclides and nuclide masses that match the + original reactor, 2) for a given (r,z,theta) location the expected block type exists, + 3) the converted reactor has the right (r,z,theta) coordinates, and 4) the converted + reactor blocks all have a single (homogenized) component. + + .. test:: Convert a 3D hex reactor core to an RZ-Theta core. + :id: T_ARMI_CONV_3DHEX_TO_2DRZ + :tests: R_ARMI_CONV_3DHEX_TO_2DRZ + """ # make the reactor smaller, because of a test parallelization edge case for ring in [9, 8, 7, 6, 5, 4, 3]: self.r.core.removeAssembliesInRing(ring, self.o.cs) @@ -266,7 +278,7 @@ def test_createHomogenizedRZTBlock(self): class TestEdgeAssemblyChanger(unittest.TestCase): def setUp(self): - r"""Use the related setup in the testFuelHandlers module.""" + """Use the related setup in the testFuelHandlers module.""" self.o, self.r = loadTestReactor(TEST_ROOT) reduceTestReactorRings(self.r, self.o.cs, 3) @@ -275,33 +287,53 @@ def tearDown(self): del self.r def test_edgeAssemblies(self): - r"""Sanity check on adding edge assemblies.""" + """Sanity check on adding edge assemblies. + + .. test:: Test adding/removing assemblies from a reactor. + :id: T_ARMI_ADD_EDGE_ASSEMS + :tests: R_ARMI_ADD_EDGE_ASSEMS + """ + + def getAssemByRingPos(ringPos: tuple): + for a in self.r.core.getAssemblies(): + if a.spatialLocator.getRingPos() == ringPos: + return a + return None + + numAssemsOrig = len(self.r.core.getAssemblies()) + # assert that there is no assembly in the (3, 4) (ring, position). + self.assertIsNone(getAssemByRingPos((3, 4))) + # add the assembly converter = geometryConverters.EdgeAssemblyChanger() converter.addEdgeAssemblies(self.r.core) + numAssemsWithEdgeAssem = len(self.r.core.getAssemblies()) + # assert that there is an assembly in the (3, 4) (ring, position). + self.assertIsNotNone(getAssemByRingPos((3, 4))) + self.assertTrue(numAssemsWithEdgeAssem > numAssemsOrig) + + # try to add the assembly again (you can't) + with mockRunLogs.BufferLog() as mock: + converter.addEdgeAssemblies(self.r.core) + self.assertIn("Skipping addition of edge assemblies", mock.getStdout()) + self.assertTrue(numAssemsWithEdgeAssem, len(self.r.core.getAssemblies())) # must be added after geom transform for b in self.o.r.core.getBlocks(): b.p.power = 1.0 - - numAssems = len(self.r.core.getAssemblies()) converter.scaleParamsRelatedToSymmetry(self.r) - a = self.r.core.getAssembliesOnSymmetryLine(grids.BOUNDARY_0_DEGREES)[0] self.assertTrue(all(b.p.power == 2.0 for b in a), "Powers were not scaled") + # remove the assembly that was added converter.removeEdgeAssemblies(self.r.core) - self.assertTrue(numAssems > len(self.r.core.getAssemblies())) - converter.addEdgeAssemblies(self.r.core) - self.assertEqual(numAssems, len(self.r.core.getAssemblies())) - # make sure it can be called twice. - converter.addEdgeAssemblies(self.r.core) - self.assertEqual(numAssems, len(self.r.core.getAssemblies())) + self.assertIsNone(getAssemByRingPos((3, 4))) + self.assertEqual(numAssemsOrig, len(self.r.core.getAssemblies())) class TestThirdCoreHexToFullCoreChanger(unittest.TestCase): def setUp(self): self.o, self.r = loadTestReactor(TEST_ROOT) - reduceTestReactorRings(self.r, self.o.cs, 2) + reduceTestReactorRings(self.r, self.o.cs, 3) # initialize the block powers to a uniform power profile, accounting for # the loaded reactor being 1/3 core @@ -327,7 +359,20 @@ def tearDown(self): del self.r def test_growToFullCoreFromThirdCore(self): - """Test that a hex core can be converted from a third core to a full core geometry.""" + """Test that a hex core can be converted from a third core to a full core geometry. + + .. test:: Convert a third-core to a full-core geometry and then restore it. + :id: T_ARMI_THIRD_TO_FULL_CORE0 + :tests: R_ARMI_THIRD_TO_FULL_CORE + """ + + def getLTAAssems(): + aList = [] + for a in self.r.core.getAssemblies(): + if a.getType == "lta fuel": + aList.append(a) + return aList + # Check the initialization of the third core model self.assertFalse(self.r.core.isFullCore) self.assertEqual( @@ -337,7 +382,10 @@ def test_growToFullCoreFromThirdCore(self): ), ) initialNumBlocks = len(self.r.core.getBlocks()) - + assems = getLTAAssems() + expectedLoc = [(3, 2)] + for i, a in enumerate(assems): + self.assertEqual(a.spatialLocator.getRingPos(), expectedLoc[i]) self.assertAlmostEqual( self.r.core.getTotalBlockParam("power"), self.o.cs["power"] / 3, places=5 ) @@ -354,6 +402,10 @@ def test_growToFullCoreFromThirdCore(self): self.assertTrue(self.r.core.isFullCore) self.assertGreater(len(self.r.core.getBlocks()), initialNumBlocks) self.assertEqual(self.r.core.symmetry.domain, geometry.DomainType.FULL_CORE) + assems = getLTAAssems() + expectedLoc = [(3, 2), (3, 6), (3, 10)] + for i, a in enumerate(assems): + self.assertEqual(a.spatialLocator.getRingPos(), expectedLoc[i]) # ensure that block power is handled correctly self.assertAlmostEqual( @@ -378,6 +430,10 @@ def test_growToFullCoreFromThirdCore(self): self.assertAlmostEqual( self.r.core.getTotalBlockParam("power"), self.o.cs["power"] / 3, places=5 ) + assems = getLTAAssems() + expectedLoc = [(3, 2)] + for i, a in enumerate(assems): + self.assertEqual(a.spatialLocator.getRingPos(), expectedLoc[i]) def test_initNewFullReactor(self): """Test that initNewReactor will growToFullCore if necessary.""" @@ -394,7 +450,13 @@ def test_initNewFullReactor(self): self.assertEqual(newR.core.symmetry.domain, geometry.DomainType.FULL_CORE) def test_skipGrowToFullCoreWhenAlreadyFullCore(self): - """Test that hex core is not modified when third core to full core changer is called on an already full core geometry.""" + """Test that hex core is not modified when third core to full core changer is called on an already full core geometry. + + .. test: Convert a one-third core to full core and restore back to one-third core. + :id: T_ARMI_THIRD_TO_FULL_CORE2 + :tests: R_ARMI_THIRD_TO_FULL_CORE + + """ # Check the initialization of the third core model and convert to a full core self.assertFalse(self.r.core.isFullCore) self.assertEqual( @@ -403,15 +465,31 @@ def test_skipGrowToFullCoreWhenAlreadyFullCore(self): geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC ), ) + numBlocksThirdCore = len(self.r.core.getBlocks()) + # convert the third core to full core changer = geometryConverters.ThirdCoreHexToFullCoreChanger(self.o.cs) - changer.convert(self.r) - # Check that the changer does not affect the full core model on converting and restoring - initialNumBlocks = len(self.r.core.getBlocks()) - self.assertEqual(self.r.core.symmetry.domain, geometry.DomainType.FULL_CORE) - changer = geometryConverters.ThirdCoreHexToFullCoreChanger(self.o.cs) - changer.convert(self.r) + with mockRunLogs.BufferLog() as mock: + changer.convert(self.r) + self.assertIn("Expanding to full core geometry", mock.getStdout()) + numBlocksFullCore = len(self.r.core.getBlocks()) self.assertEqual(self.r.core.symmetry.domain, geometry.DomainType.FULL_CORE) - self.assertEqual(initialNumBlocks, len(self.r.core.getBlocks())) - changer.restorePreviousGeometry(self.r) - self.assertEqual(initialNumBlocks, len(self.r.core.getBlocks())) + # try to convert to full core again (it shouldn't do anything) + with mockRunLogs.BufferLog() as mock: + changer.convert(self.r) + self.assertIn( + "Detected that full core reactor already exists. Cannot expand.", + mock.getStdout(), + ) self.assertEqual(self.r.core.symmetry.domain, geometry.DomainType.FULL_CORE) + self.assertEqual(numBlocksFullCore, len(self.r.core.getBlocks())) + # restore back to 1/3 core + with mockRunLogs.BufferLog() as mock: + changer.restorePreviousGeometry(self.r) + self.assertIn("revert from full to 1/3 core", mock.getStdout()) + self.assertEqual(numBlocksThirdCore, len(self.r.core.getBlocks())) + self.assertEqual( + self.r.core.symmetry, + geometry.SymmetryType( + geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC + ), + ) diff --git a/armi/reactor/converters/tests/test_uniformMesh.py b/armi/reactor/converters/tests/test_uniformMesh.py index a5a0869bd..11f781536 100644 --- a/armi/reactor/converters/tests/test_uniformMesh.py +++ b/armi/reactor/converters/tests/test_uniformMesh.py @@ -251,7 +251,13 @@ def test_computeAverageAxialMesh(self): self.assertNotEqual(refMesh[4], avgMesh[4], "Not equal above the fuel.") def test_filterMesh(self): - """Test that the mesh can be correctly filtered.""" + """ + Test that the mesh can be correctly filtered. + + .. test:: Produce a uniform mesh with a size no smaller than a user-specified value. + :id: T_ARMI_UMC_MIN_MESH1 + :tests: R_ARMI_UMC_MIN_MESH + """ meshList = [1.0, 3.0, 4.0, 7.0, 9.0, 12.0, 16.0, 19.0, 20.0] anchorPoints = [4.0, 16.0] combinedMesh = self.generator._filterMesh( @@ -295,7 +301,17 @@ def test_filteredTopAndBottom(self): self.assertListEqual(ctrlAndFuelTops, [75.0, 101.25, 105.0]) def test_generateCommonMesh(self): - """Covers generateCommonmesh() and _decuspAxialMesh().""" + """ + Covers generateCommonmesh() and _decuspAxialMesh(). + + .. test:: Produce a uniform mesh with a size no smaller than a user-specified value. + :id: T_ARMI_UMC_MIN_MESH0 + :tests: R_ARMI_UMC_MIN_MESH + + .. test:: Preserve the boundaries of fuel and control material. + :id: T_ARMI_UMC_NON_UNIFORM0 + :tests: R_ARMI_UMC_NON_UNIFORM + """ self.generator.generateCommonMesh() expectedMesh = [ 25.0, @@ -346,7 +362,7 @@ def test_blueprintCopy(self): "allNuclidesInProblem", "elementsToExpand", "inertNuclides", - ] # note, items within toCompare must be list or "list-like", like an ordered set + ] # Note: items within toCompare must be list or "list-like", like an ordered set for attr in toCompare: for c, o in zip(getattr(converted, attr), getattr(original, attr)): self.assertEqual(c, o) @@ -394,10 +410,16 @@ def setUp(self): ) def test_convertNumberDensities(self): + """ + Test the reactor mass before and after conversion. + + .. test:: Make a copy of the reactor where the new reactor core has a uniform axial mesh. + :id: T_ARMI_UMC + :tests: R_ARMI_UMC + """ refMass = self.r.core.getMass("U235") - applyNonUniformHeightDistribution( - self.r - ) # this changes the mass of everything in the core + # perturb the heights of the assemblies -> changes the mass of everything in the core + applyNonUniformHeightDistribution(self.r) perturbedCoreMass = self.r.core.getMass("U235") self.assertNotEqual(refMass, perturbedCoreMass) self.converter.convert(self.r) @@ -405,14 +427,25 @@ def test_convertNumberDensities(self): uniformReactor = self.converter.convReactor uniformMass = uniformReactor.core.getMass("U235") - self.assertAlmostEqual( - perturbedCoreMass, uniformMass - ) # conversion conserved mass - self.assertAlmostEqual( - self.r.core.getMass("U235"), perturbedCoreMass - ) # conversion didn't change source reactor mass + # conversion conserved mass + self.assertAlmostEqual(perturbedCoreMass, uniformMass) + # conversion didn't change source reactor mass + self.assertAlmostEqual(self.r.core.getMass("U235"), perturbedCoreMass) + # conversion results in uniform axial mesh + refAssemMesh = self.converter.convReactor.core.refAssem.getAxialMesh() + for a in self.converter.convReactor.core: + mesh = a.getAxialMesh() + for ref, check in zip(refAssemMesh, mesh): + self.assertEqual(ref, check) def test_applyStateToOriginal(self): + """ + Test applyStateToOriginal() to revert mesh conversion. + + .. test:: Map select parameters from composites on the new mesh to the original mesh. + :id: T_ARMI_UMC_PARAM_BACKWARD0 + :tests: R_ARMI_UMC_PARAM_BACKWARD + """ applyNonUniformHeightDistribution(self.r) # note: this perturbs the ref mass self.converter.convert(self.r) @@ -505,6 +538,13 @@ def test_convertNumberDensities(self): ) # conversion didn't change source reactor mass def test_applyStateToOriginal(self): + """ + Test applyStateToOriginal() to revert mesh conversion. + + .. test:: Map select parameters from composites on the new mesh to the original mesh. + :id: T_ARMI_UMC_PARAM_BACKWARD1 + :tests: R_ARMI_UMC_PARAM_BACKWARD + """ applyNonUniformHeightDistribution(self.r) # note: this perturbs the ref. mass # set original parameters on pre-mapped core with non-uniform assemblies @@ -612,6 +652,10 @@ def test_setStateFromOverlaps(self): Test that state is translated correctly from source to dest assems. Here we set flux and pdens to 3 on the source blocks. + + .. test:: Map select parameters from composites on the original mesh to the new mesh. + :id: T_ARMI_UMC_PARAM_FORWARD + :tests: R_ARMI_UMC_PARAM_FORWARD """ paramList = ["flux", "pdens"] for pName in paramList: diff --git a/armi/reactor/converters/uniformMesh.py b/armi/reactor/converters/uniformMesh.py index daeb320f5..8b8e3c129 100644 --- a/armi/reactor/converters/uniformMesh.py +++ b/armi/reactor/converters/uniformMesh.py @@ -31,7 +31,8 @@ well as the multigroup real and adjoint flux. -.. warning: This procedure can cause numerical diffusion in some cases. For example, +.. warning:: + This procedure can cause numerical diffusion in some cases. For example, if a control rod tip block has a large coolant block below it, things like peak absorption rate can get lost into it. We recalculate some but not all reaction rates in the re-mapping process based on a flux remapping. To avoid this, @@ -40,11 +41,11 @@ Examples -------- -converter = uniformMesh.NeutronicsUniformMeshConverter() -converter.convert(reactor) -uniformReactor = converter.convReactor -# do calcs, then: -converter.applyStateToOriginal() + converter = uniformMesh.NeutronicsUniformMeshConverter() + converter.convert(reactor) + uniformReactor = converter.convReactor + # do calcs, then: + converter.applyStateToOriginal() The mesh mapping happens as described in the figure: @@ -108,7 +109,7 @@ def __init__(self, r, minimumMeshSize=None): Reactor for which a common mesh is generated minimumMeshSize : float, optional Minimum allowed separation between axial mesh points in cm - If no miminmum mesh size is provided, no "decusping" is performed + If no minimum mesh size is provided, no "decusping" is performed """ self._sourceReactor = r self.minimumMeshSize = minimumMeshSize @@ -118,11 +119,35 @@ def generateCommonMesh(self): """ Generate a common axial mesh to use. + .. impl:: Try to preserve the boundaries of fuel and control material. + :id: I_ARMI_UMC_NON_UNIFORM + :implements: R_ARMI_UMC_NON_UNIFORM + + A core-wide mesh is computed via ``_computeAverageAxialMesh`` which + operates by first collecting all the mesh points for every assembly + (``allMeshes``) and then averaging them together using + ``average1DWithinTolerance``. An attempt to preserve fuel and control + material boundaries is accomplished by moving fuel region boundaries + to accomodate control rod boundaries. Note this behavior only occurs + by calling ``_decuspAxialMesh`` which is dependent on ``minimumMeshSize`` + being defined (this is controlled by the ``uniformMeshMinimumSize`` setting). + + .. impl:: Produce a mesh with a size no smaller than a user-specified value. + :id: I_ARMI_UMC_MIN_MESH + :implements: R_ARMI_UMC_MIN_MESH + + If a minimum mesh size ``minimumMeshSize`` is provided, calls + ``_decuspAxialMesh`` on the core-wide mesh to maintain that minimum size + while still attempting to honor fuel and control material boundaries. Relies + ultimately on ``_filterMesh`` to remove mesh points that violate the minimum + size. Note that ``_filterMesh`` will always respect the minimum mesh size, + even if this means losing a mesh point that represents a fuel or control + material boundary. + Notes ----- Attempts to reduce the effect of fuel and control rod absorber smearing - ("cusping" effect) by keeping important material boundaries in the - common mesh. + ("cusping" effect) by keeping important material boundaries in the common mesh. """ self._computeAverageAxialMesh() if self.minimumMeshSize is not None: @@ -338,21 +363,31 @@ def lastBlockTop(a, flags): class UniformMeshGeometryConverter(GeometryConverter): """ - This geometry converter can be used to change the axial mesh structure of the reactor core. + This geometry converter can be used to change the axial mesh structure of the + reactor core. Notes ----- There are several staticmethods available on this class that allow for: - - Creation of a new reactor without applying a new uniform axial mesh. See: `<UniformMeshGeometryConverter.initNewReactor>` - - Creation of a new assembly with a new axial mesh applied. See: `<UniformMeshGeometryConverter.makeAssemWithUniformMesh>` - - Resetting the parameter state of an assembly back to the defaults for the provided block parameters. See: `<UniformMeshGeometryConverter.clearStateOnAssemblies>` - - Mapping number densities and block parameters between one assembly to another. See: `<UniformMeshGeometryConverter.setAssemblyStateFromOverlaps>` - - This class is meant to be extended for specific physics calculations that require a uniform mesh. - The child types of this class should define custom `reactorParamsToMap` and `blockParamsToMap` attributes, and the `_setParamsToUpdate` method - to specify the precise parameters that need to be mapped in each direction between the non-uniform and uniform mesh assemblies. The definitions should avoid mapping - block parameters in both directions because the mapping process will cause numerical diffusion. The behavior of `setAssemblyStateFromOverlaps` is dependent on the - direction in which the mapping is being applied to prevent the numerical diffusion problem. + - Creation of a new reactor without applying a new uniform axial mesh. See: + `<UniformMeshGeometryConverter.initNewReactor>` + - Creation of a new assembly with a new axial mesh applied. See: + `<UniformMeshGeometryConverter.makeAssemWithUniformMesh>` + - Resetting the parameter state of an assembly back to the defaults for the + provided block parameters. See: + `<UniformMeshGeometryConverter.clearStateOnAssemblies>` + - Mapping number densities and block parameters between one assembly to + another. See: `<UniformMeshGeometryConverter.setAssemblyStateFromOverlaps>` + + This class is meant to be extended for specific physics calculations that require a + uniform mesh. The child types of this class should define custom + `reactorParamsToMap` and `blockParamsToMap` attributes, and the + `_setParamsToUpdate` method to specify the precise parameters that need to be + mapped in each direction between the non-uniform and uniform mesh assemblies. The + definitions should avoid mapping block parameters in both directions because the + mapping process will cause numerical diffusion. The behavior of + `setAssemblyStateFromOverlaps` is dependent on the direction in which the mapping + is being applied to prevent the numerical diffusion problem. - "in" is used when mapping parameters into the uniform assembly from the non-uniform assembly. @@ -407,7 +442,32 @@ def __init__(self, cs=None): self._minimumMeshSize = cs[CONF_UNIFORM_MESH_MINIMUM_SIZE] def convert(self, r=None): - """Create a new reactor core with a uniform mesh.""" + """ + Create a new reactor core with a uniform mesh. + + .. impl:: Make a copy of the reactor where the new core has a uniform axial mesh. + :id: I_ARMI_UMC + :implements: R_ARMI_UMC + + Given a source Reactor, ``r``, as input and when ``_hasNonUniformAssems`` is ``False``, + a new Reactor is created in ``initNewReactor``. This new Reactor contains copies of select + information from the input source Reactor (e.g., Operator, Blueprints, cycle, timeNode, etc). + The uniform mesh to be applied to the new Reactor is calculated in ``_generateUniformMesh`` + (see :need:`I_ARMI_UMC_NON_UNIFORM` and :need:`I_ARMI_UMC_MIN_MESH`). New assemblies with this + uniform mesh are created in ``_buildAllUniformAssemblies`` and added to the new Reactor. + Core-level parameters are then mapped from the source Reactor to the new Reactor in + ``_mapStateFromReactorToOther``. Finally, the core-wide axial mesh is updated on the new Reactor + via ``updateAxialMesh``. + + + .. impl:: Map select parameters from composites on the original mesh to the new mesh. + :id: I_ARMI_UMC_PARAM_FORWARD + :implements: R_ARMI_UMC_PARAM_FORWARD + + In ``_mapStateFromReactorToOther``, Core-level parameters are mapped from the source Reactor + to the new Reactor. If requested, block-level parameters can be mapped using an averaging + equation as described in ``setAssemblyStateFromOverlaps``. + """ if r is None: raise ValueError(f"No reactor provided in {self}") @@ -489,7 +549,7 @@ def initNewReactor(sourceReactor, cs): ---------- sourceReactor : :py:class:`Reactor <armi.reactor.reactors.Reactor>` object. original reactor to be copied - cs: CaseSetting object + cs: Setting Complete settings object """ # developer note: deepcopy on the blueprint object ensures that all relevant blueprints @@ -519,7 +579,20 @@ def initNewReactor(sourceReactor, cs): return newReactor def applyStateToOriginal(self): - """Apply the state of the converted reactor back to the original reactor, mapping number densities and block parameters.""" + """ + Apply the state of the converted reactor back to the original reactor, + mapping number densities and block parameters. + + .. impl:: Map select parameters from composites on the new mesh to the original mesh. + :id: I_ARMI_UMC_PARAM_BACKWARD + :implements: R_ARMI_UMC_PARAM_BACKWARD + + To ensure that the parameters on the original Reactor are from the converted Reactor, + the first step is to clear the Reactor-level parameters on the original Reactor + (see ``_clearStateOnReactor``). ``_mapStateFromReactorToOther`` is then called + to map Core-level parameters and, optionally, averaged Block-level parameters + (see :need:`I_ARMI_UMC_PARAM_FORWARD`). + """ runLog.extra( f"Applying uniform neutronics results from {self.convReactor} to {self._sourceReactor}" ) @@ -694,7 +767,7 @@ def checkPriorityFlags(b): heightFrac = h / totalHeight runLog.debug(f"XSType {xs}: {heightFrac:.4f}") - block = sourceBlock._createHomogenizedCopy(includePinCoordinates) + block = sourceBlock.createHomogenizedCopy(includePinCoordinates) block.p.xsType = xsType block.setHeight(topMeshPoint - bottom) block.p.axMesh = 1 diff --git a/armi/reactor/flags.py b/armi/reactor/flags.py index c59775a80..6366432a8 100644 --- a/armi/reactor/flags.py +++ b/armi/reactor/flags.py @@ -284,10 +284,40 @@ def fromStringIgnoreErrors(cls, typeSpec): @classmethod def fromString(cls, typeSpec): + """ + Retrieve flag from a string. + + .. impl:: Retrieve flag from a string. + :id: I_ARMI_FLAG_TO_STR0 + :implements: R_ARMI_FLAG_TO_STR + + For a string passed as ``typeSpec``, first converts the whole string + to uppercase. Then tries to parse the string for any special phrases, as + defined in the module dictionary ``_CONVERSIONS``, and converts those + phrases to flags directly. + + Then it splits the remaining string into separate words based on the presence + of spaces. Looping over each of the words, any numbers are stripped out + and the remaining string is matched up to any class attribute names. + If any matches are found these are returned as flags. + """ return _fromString(cls, typeSpec) @classmethod def toString(cls, typeSpec): + """ + Convert a flag to a string. + + .. impl:: Convert a flag to string. + :id: I_ARMI_FLAG_TO_STR1 + :implements: R_ARMI_FLAG_TO_STR + + This converts the representation of a bunch of flags from ``typeSpec``, + which might look like ``Flags.A|B``, + into a string with spaces in between the flag names, which would look + like ``'A B'``. This is done via nesting string splitting and replacement + actions. + """ return _toString(cls, typeSpec) diff --git a/armi/reactor/grids/__init__.py b/armi/reactor/grids/__init__.py index db5eb3492..5daf84c68 100644 --- a/armi/reactor/grids/__init__.py +++ b/armi/reactor/grids/__init__.py @@ -79,7 +79,7 @@ ) from armi.reactor.grids.grid import Grid -from armi.reactor.grids.structuredgrid import StructuredGrid, GridParameters, _tuplify +from armi.reactor.grids.structuredGrid import StructuredGrid, GridParameters, _tuplify from armi.reactor.grids.axial import AxialGrid, axialUnitGrid from armi.reactor.grids.cartesian import CartesianGrid from armi.reactor.grids.hexagonal import HexGrid, COS30, SIN30, TRIANGLES_IN_HEXAGON diff --git a/armi/reactor/grids/axial.py b/armi/reactor/grids/axial.py index d2a02a25a..b297c4596 100644 --- a/armi/reactor/grids/axial.py +++ b/armi/reactor/grids/axial.py @@ -17,7 +17,7 @@ import numpy from armi.reactor.grids.locations import IJType, LocationBase -from armi.reactor.grids.structuredgrid import StructuredGrid +from armi.reactor.grids.structuredGrid import StructuredGrid if TYPE_CHECKING: from armi.reactor.composites import ArmiObject diff --git a/armi/reactor/grids/cartesian.py b/armi/reactor/grids/cartesian.py index 73ff01f55..6de012184 100644 --- a/armi/reactor/grids/cartesian.py +++ b/armi/reactor/grids/cartesian.py @@ -19,7 +19,7 @@ from armi.reactor import geometry from armi.reactor.grids.locations import IJType -from armi.reactor.grids.structuredgrid import StructuredGrid +from armi.reactor.grids.structuredGrid import StructuredGrid class CartesianGrid(StructuredGrid): diff --git a/armi/reactor/grids/grid.py b/armi/reactor/grids/grid.py index 7d509eda4..6961816b5 100644 --- a/armi/reactor/grids/grid.py +++ b/armi/reactor/grids/grid.py @@ -35,6 +35,23 @@ class Grid(ABC): So here, we define an interface so things that rely on grids can worry less about how the location data are stored. + .. impl:: Grids can nest. + :id: I_ARMI_GRID_NEST + :implements: R_ARMI_GRID_NEST + + The reactor will usually have (i,j,k) coordinates to define a + simple mesh for locating objects in the reactor. But inside that mesh can + be a smaller mesh to define the layout of pins in a reactor, or fuel pellets in + a pin, or the layout of some intricate ex-core structure. + + Every time the :py:class:`armi.reactor.grids.locations.IndexLocation` of an + object in the reactor is returned, ARMI will look to see if the grid this object + is in has a :py:meth:`parent <armi.reactor.grids.locations.IndexLocation.parentLocation>`, + and if so, ARMI will try to sum the + :py:meth:`indices <armi.reactor.grids.locations.IndexLocation.indices>` of the two + nested grids to give a resultant, more finely-grained grid position. ARMI can only + handle grids nested 3 deep. + Parameters ---------- geomType : str or armi.reactor.geometry.GeomType @@ -44,7 +61,6 @@ class Grid(ABC): armiObject : optional, armi.reactor.composites.ArmiObject If given, what is this grid attached to or what does it describe? Something like a :class:`armi.reactor.Core` - """ _geomType: str @@ -81,7 +97,27 @@ def geomType(self, geomType: Union[str, geometry.GeomType]): @property def symmetry(self) -> str: - """Symmetry applied to the grid.""" + """Symmetry applied to the grid. + + .. impl:: Grids shall be able to repesent 1/3 and full core symmetries. + :id: I_ARMI_GRID_SYMMETRY0 + :implements: R_ARMI_GRID_SYMMETRY + + Every grid contains a :py:class:`armi.reactor.geometry.SymmetryType` or + string that defines a grid as full core or a partial core: 1/3, 1/4, 1/8, or 1/16 + core. The idea is that the user can define 1/3 or 1/4 of the reactor, so + the analysis can be run faster on a smaller reactor. And if a non-full + core reactor grid is defined, the boundaries of the grid can be reflective + or periodic, to determine what should happen at the boundaries of the + reactor core. + + It is important to note, that not all of these geometries will apply to + every reactor or core. If your core is made of hexagonal assemblies, then a + 1/3 core grid would make sense, but not if your reactor core was made up of + square assemblies. Likewise, a hexagonal core would not make be able to + support a 1/4 grid. You want to leave assemblies (and other objects) whole + when dividing a grid up fractionally. + """ return geometry.SymmetryType.fromStr(self._symmetry) @symmetry.setter @@ -183,7 +219,6 @@ def overlapsWhichSymmetryLine(self, indices: IJType) -> Optional[int]: None if not line of symmetry goes through the object at the requested index. Otherwise, some grid constants like ``BOUNDARY_CENTER`` will be returned. - """ @abstractmethod @@ -244,5 +279,4 @@ def reduce(self) -> Tuple[Hashable, ...]: Notes ----- For consistency, the second to last argument **must** be the geomType - """ diff --git a/armi/reactor/grids/hexagonal.py b/armi/reactor/grids/hexagonal.py index 60b0fc241..d3c75a540 100644 --- a/armi/reactor/grids/hexagonal.py +++ b/armi/reactor/grids/hexagonal.py @@ -25,12 +25,12 @@ BOUNDARY_60_DEGREES, BOUNDARY_CENTER, ) -from armi.reactor.grids.locations import IndexLocation, IJKType, IJType -from armi.reactor.grids.structuredgrid import StructuredGrid +from armi.reactor.grids.locations import IJKType, IJType +from armi.reactor.grids.structuredGrid import StructuredGrid COS30 = sqrt(3) / 2.0 SIN30 = 1.0 / 2.0 -# going CCW from "position 1" (top right) +# going counter-clockwise from "position 1" (top right) TRIANGLES_IN_HEXAGON = numpy.array( [ (+COS30, SIN30), @@ -44,36 +44,101 @@ class HexGrid(StructuredGrid): - """ + r""" Has 6 neighbors in plane. - It is recommended to use :meth:`fromPitch` rather than - calling the ``__init__`` constructor directly. - - Notes - ----- - In an axial plane (i, j) are as follows (second one is pointedEndUp):: + It is recommended to use :meth:`fromPitch` rather than calling the ``__init__`` + constructor directly. + .. impl:: Construct a hexagonal lattice. + :id: I_ARMI_GRID_HEX + :implements: R_ARMI_GRID_HEX - ( 0, 1) - (-1, 1) ( 1, 0) - ( 0, 0) - (-1, 0) ( 1,-1) - ( 0,-1) + This class represents a hexagonal ``StructuredGrid``, that is one where the + mesh maps to real, physical coordinates. This hexagonal grid is 2D, and divides + the plane up into regular hexagons. That is, each hexagon is symmetric and + is precisely flush with six neighboring hexagons. This class only allows for + two rotational options: flats up (where two sides of the hexagons are parallel + with the X-axis), and points up (where two sides are parallel with the Y-axis). + Notes + ----- + In an axial plane (i, j) are as follows (flats up):: + _____ + / \ + _____/ 0,1 \_____ + / \ / \ + / -1,1 \_____/ 1,0 \ + \ / \ / + \_____/ 0,0 \_____/ + / \ / \ + / -1,0 \_____/ 1,-1 \ + \ / \ / + \_____/ 0,-1 \_____/ + \ / + \_____/ + + In an axial plane (i, j) are as follows (corners up):: + + / \ / \ + / \ / \ + | 0,1 | 1,0 | + | | | + / \ / \ / \ + / \ / \ / \ + | -1,1 | 0,0 | 1,-1 | + | | | | + \ / \ / \ / + \ / \ / \ / + | -1,0 | 0,-1 | + | | | + \ / \ / + \ / \ / + + Basic hexagon geometry:: + + - pitch = sqrt(3) * side + - long diagonal = 2 * side + - Area = (sqrt(3) / 4) * side^2 + - perimeter = 6 * side - ( 0, 1) ( 1, 0) + """ - (-1, 1) ( 0, 0) ( 1,-1) + @property + def cornersUp(self) -> bool: + """ + Check whether the hexagonal grid is "corners up" or "flats up". - (-1, 0) ( 0,-1) - """ + See the armi.reactor.grids.HexGrid class documentation for an + illustration of the two types of grid indexing. + """ + return self._unitSteps[0][1] != 0.0 @staticmethod - def fromPitch(pitch, numRings=25, armiObject=None, pointedEndUp=False, symmetry=""): + def fromPitch(pitch, numRings=25, armiObject=None, cornersUp=False, symmetry=""): """ Build a finite step-based 2-D hex grid from a hex pitch in cm. + .. impl:: Hexagonal grids can be points-up or flats-up. + :id: I_ARMI_GRID_HEX_TYPE + :implements: R_ARMI_GRID_HEX_TYPE + + When this method creates a ``HexGrid`` object, it can create a hexagonal + grid with one of two rotations: flats up (where two sides of the hexagons + are parallel with the X-axis), and points up (where two sides are parallel + with the Y-axis). While it is possible to imagine the hexagons being + rotated at other arbitrary angles, those are not supported here. + + .. impl:: When creating a hexagonal grid, the user can specify the symmetry. + :id: I_ARMI_GRID_SYMMETRY1 + :implements: R_ARMI_GRID_SYMMETRY + + When this method creates a ``HexGrid`` object, it takes as an input the + symmetry of the resultant grid. This symmetry can be a string (e.g. "full") + or a ``SymmetryType`` object (e.g. ``FULL_CORE``). If the grid is not full- + core, the method ``getSymmetricEquivalents()`` will be usable to map any + possible grid cell to the ones that are being modeled in the sub-grid. + Parameters ---------- pitch : float @@ -85,9 +150,9 @@ def fromPitch(pitch, numRings=25, armiObject=None, pointedEndUp=False, symmetry= armiObject : ArmiObject, optional The object that this grid is anchored to (i.e. the reactor for a grid of assemblies) - pointedEndUp : bool, optional - Rotate the hexagons 30 degrees so that the pointed end faces up instead of - the flat. + cornersUp : bool, optional + Rotate the hexagons 30 degrees so that the corners point up instead of + the flat faces. symmetry : string, optional A string representation of the symmetry options for the grid. @@ -96,27 +161,15 @@ def fromPitch(pitch, numRings=25, armiObject=None, pointedEndUp=False, symmetry= HexGrid A functional hexagonal grid object. """ - side = hexagon.side(pitch) - if pointedEndUp: - # rotated 30 degrees CCW from normal - # increases in i move you in x and y - # increases in j also move you in x and y - unitSteps = ( - (pitch / 2.0, -pitch / 2.0, 0), - (1.5 * side, 1.5 * side, 0), - (0, 0, 0), - ) - else: - # x direction is only a function of i because j-axis is vertical. - # y direction is a function of both. - unitSteps = ((1.5 * side, 0.0, 0.0), (pitch / 2.0, pitch, 0.0), (0, 0, 0)) + unitSteps = HexGrid._getRawUnitSteps(pitch, cornersUp) - return HexGrid( + hex = HexGrid( unitSteps=unitSteps, unitStepLimits=((-numRings, numRings), (-numRings, numRings), (0, 1)), armiObject=armiObject, symmetry=symmetry, ) + return hex @property def pitch(self) -> float: @@ -127,7 +180,7 @@ def pitch(self) -> float: -------- armi.reactor.grids.HexGrid.fromPitch """ - return self._unitSteps[1][1] + return sqrt(self._unitSteps[0][0] ** 2 + self._unitSteps[1][0] ** 2) @staticmethod def indicesToRingPos(i: int, j: int) -> Tuple[int, int]: @@ -190,7 +243,7 @@ def getNeighboringCellIndices( Return the indices of the immediate neighbors of a mesh point in the plane. Note that these neighbors are ordered counter-clockwise beginning from the - 30 or 60 degree direction. Exact direction is dependent on pointedEndUp arg. + 30 or 60 degree direction. Exact direction is dependent on cornersUp arg. """ return [ (i + 1, j, k), @@ -217,20 +270,42 @@ def getLabel(self, indices): @staticmethod def _indicesAndEdgeFromRingAndPos(ring, position): + """Given the ring and position, return the (I,J) coordinates, and which edge the grid + cell is on. + + Parameters + ---------- + ring : int + Starting with 1 (not zero), the ring of the grid cell. + position : int + Starting with 1 (not zero), the position of the grid cell, in the ring. + + Returns + ------- + (int, int, int) : I coordinate, J coordinate, which edge of the hex ring + + Notes + ----- + - Edge indicates which edge of the ring in which the hexagon resides. + - Edge 0 is the NE edge, edge 1 is the N edge, etc. + - Offset is (0-based) index of the hexagon in that edge. For instance, + ring 3, pos 12 resides in edge 5 at index 1; it is the second hexagon + in ring 3, edge 5. + """ + # The inputs start counting at 1, but the grid starts counting at zero. ring = ring - 1 pos = position - 1 + # Handle the center grid cell. if ring == 0: if pos != 0: raise ValueError(f"Position in center ring must be 1, not {position}") return 0, 0, 0 - # Edge indicates which edge of the ring in which the hexagon resides. - # Edge 0 is the NE edge, edge 1 is the N edge, etc. - # Offset is (0-based) index of the hexagon in that edge. For instance, - # ring 3, pos 12 resides in edge 5 at index 1; it is the second hexagon - # in ring 3, edge 5. - edge, offset = divmod(pos, ring) # = pos//ring, pos%ring + # find the edge and offset (pos//ring or pos%ring) + edge, offset = divmod(pos, ring) + + # find (I,J) based on the ring, edge, and offset if edge == 0: i = ring - offset j = offset @@ -239,9 +314,9 @@ def _indicesAndEdgeFromRingAndPos(ring, position): j = ring elif edge == 2: i = -ring - j = -offset + ring + j = ring - offset elif edge == 3: - i = -ring + offset + i = offset - ring j = -offset elif edge == 4: i = offset @@ -250,13 +325,47 @@ def _indicesAndEdgeFromRingAndPos(ring, position): i = ring j = offset - ring else: - raise ValueError( - "Edge {} is invalid. From ring {}, pos {}".format(edge, ring, pos) - ) + raise ValueError(f"Edge {edge} is invalid. From ring {ring}, pos {pos}") + return i, j, edge @staticmethod def getIndicesFromRingAndPos(ring: int, pos: int) -> IJType: + r"""Given the ring and position, return the (I,J) coordinates in the hex grid. + + Parameters + ---------- + ring : int + Starting with 1 (not zero), the ring of the grid cell. + position : int + Starting with 1 (not zero), the position of the grid cell, in the ring. + + Returns + ------- + (int, int) : I coordinate, J coordinate + + Notes + ----- + In an axial plane, the (ring, position) coordinates are as follows:: + + Flat-to-Flat Corners Up + _____ + / \ / \ / \ + _____/ 2,2 \_____ / \ / \ + / \ / \ | 2,2 | 2,1 | + / 2,3 \_____/ 2,1 \ | | | + \ / \ / / \ / \ / \ + \_____/ 1,1 \_____/ / \ / \ / \ + / \ / \ | 2,3 | 1,1 | 2,6 | + / 2,4 \_____/ 2,6 \ | | | | + \ / \ / \ / \ / \ / + \_____/ 2,5 \_____/ \ / \ / \ / + \ / | 2,4 | 2,5 | + \_____/ | | | + \ / \ / + \ / \ / + + """ i, j, _edge = HexGrid._indicesAndEdgeFromRingAndPos(ring, pos) return i, j @@ -290,7 +399,6 @@ def overlapsWhichSymmetryLine(self, indices: IJType) -> Optional[int]: ----- - Only the 1/3 core view geometry is actually coded in here right now. - Being "on" a symmetry line means the line goes through the middle of you. - """ i, j = indices[:2] @@ -311,6 +419,19 @@ def overlapsWhichSymmetryLine(self, indices: IJType) -> Optional[int]: return symmetryLine def getSymmetricEquivalents(self, indices: IJKType) -> List[IJType]: + """Retrieve the equivalent indices. If full core return nothing, if 1/3-core grid, + return the symmetric equivalents, if any other grid, raise an error. + + .. impl:: Equivalent contents in 1/3-core geometries are retrievable. + :id: I_ARMI_GRID_EQUIVALENTS + :implements: R_ARMI_GRID_EQUIVALENTS + + This method takes in (I,J,K) indices, and if this ``HexGrid`` is full core, + it returns nothing. If this ``HexGrid`` is 1/3-core, this method will return + the 1/3-core symmetric equivalent of just (I,J). If this grid is any other kind, + this method will just return an error; a hexagonal grid with any other + symmetry is probably an error. + """ if ( self.symmetry.domain == geometry.DomainType.THIRD_CORE and self.symmetry.boundary == geometry.BoundaryType.PERIODIC @@ -336,7 +457,8 @@ def _getSymmetricIdenticalsThird(indices) -> List[IJType]: def triangleCoords(self, indices: IJKType) -> numpy.ndarray: """ - Return 6 coordinate pairs representing the centers of the 6 triangles in a hexagon centered here. + Return 6 coordinate pairs representing the centers of the 6 triangles in a + hexagon centered here. Ignores z-coordinate and only operates in 2D for now. """ @@ -344,12 +466,42 @@ def triangleCoords(self, indices: IJKType) -> numpy.ndarray: scale = self.pitch / 3.0 return xy + scale * TRIANGLES_IN_HEXAGON + @staticmethod + def _getRawUnitSteps(pitch, cornersUp=False): + """Get the raw unit steps (ignore step dimensions), for a hex grid. + + Parameters + ---------- + pitch : float + The short diameter of the hexagons (flat to flat). + cornersUp : bool, optional + If True, the hexagons have a corner pointing in the Y direction. Default: False + + Returns + ------- + tuple : The full 3D set of derivatives of X,Y,Z in terms of i,j,k. + """ + side = hexagon.side(pitch) + if cornersUp: + # rotated 30 degrees counter-clockwise from normal + # increases in i moves you in x and y + # increases in j also moves you in x and y + unitSteps = ( + (pitch / 2.0, -pitch / 2.0, 0), + (1.5 * side, 1.5 * side, 0), + (0, 0, 0), + ) + else: + # x direction is only a function of i because j-axis is vertical. + # y direction is a function of both. + unitSteps = ((1.5 * side, 0.0, 0.0), (pitch / 2.0, pitch, 0.0), (0, 0, 0)) + + return unitSteps + def changePitch(self, newPitchCm: float): """Change the hex pitch.""" - side = hexagon.side(newPitchCm) - self._unitSteps = numpy.array( - ((1.5 * side, 0.0, 0.0), (newPitchCm / 2.0, newPitchCm, 0.0), (0, 0, 0)) - )[self._stepDims] + unitSteps = numpy.array(HexGrid._getRawUnitSteps(newPitchCm, self.cornersUp)) + self._unitSteps = unitSteps[self._stepDims] def locatorInDomain(self, locator, symmetryOverlap: Optional[bool] = False) -> bool: # This will include the "top" 120-degree symmetry lines. This is to support @@ -360,22 +512,37 @@ def locatorInDomain(self, locator, symmetryOverlap: Optional[bool] = False) -> b return True def isInFirstThird(self, locator, includeTopEdge=False) -> bool: - """True if locator is in first third of hex grid.""" + """Test if the given locator is in the first 1/3 of the HexGrid. + + .. impl:: Determine if grid is in first third. + :id: I_ARMI_GRID_SYMMETRY_LOC + :implements: R_ARMI_GRID_SYMMETRY_LOC + + This is a simple helper method to determine if a given locator (from an + ArmiObject) is in the first 1/3 of the ``HexGrid``. This method does not + attempt to check if this grid is full or 1/3-core. It just does the basic + math of dividing up a hex-assembly reactor core into thirds and testing if + the given location is in the first 1/3 or not. + """ ring, pos = self.getRingPos(locator.indices) if ring == 1: return True + maxPosTotal = self.getPositionsInRing(ring) maxPos1 = ring + ring // 2 - 1 maxPos2 = maxPosTotal - ring // 2 + 1 if ring % 2: - # odd ring. Upper edge assem typically not included. + # Odd ring; upper edge assem typically not included. if includeTopEdge: maxPos1 += 1 else: - maxPos2 += 1 # make a table to understand this. + # Even ring; upper edge assem included. + maxPos2 += 1 + if pos <= maxPos1 or pos >= maxPos2: return True + return False def generateSortedHexLocationList(self, nLocs: int): @@ -388,7 +555,7 @@ def generateSortedHexLocationList(self, nLocs: int): by ring number then position number. """ # first, roughly calculate how many rings need to be created to cover nLocs worth of assemblies - nLocs = int(nLocs) # need to make this an integer + nLocs = int(nLocs) # next, generate a list of locations and corresponding distances locList = [] @@ -397,41 +564,14 @@ def generateSortedHexLocationList(self, nLocs: int): for position in range(1, positions + 1): i, j = self.getIndicesFromRingAndPos(ring, position) locList.append(self[(i, j, 0)]) + # round to avoid differences due to floating point math locList.sort( key=lambda loc: ( round(numpy.linalg.norm(loc.getGlobalCoordinates()), 6), - loc.i, # loc.i=ring + loc.i, loc.j, ) - ) # loc.j= pos - return locList[:nLocs] - - # TODO: this is only used by testing and another method that just needs the count of assemblies - # in a ring, not the actual positions - def allPositionsInThird(self, ring, includeEdgeAssems=False): - """ - Returns a list of all the positions in a ring (in the first third). - - Parameters - ---------- - ring : int - The ring to check - includeEdgeAssems : bool, optional - If True, include repeated positions in odd ring numbers. Default: False - - Notes - ----- - Rings start at 1, positions start at 1 + ) - Returns - ------- - positions : int - """ - positions = [] - for pos in range(1, self.getPositionsInRing(ring) + 1): - i, j = self.getIndicesFromRingAndPos(ring, pos) - loc = IndexLocation(i, j, 0, None) - if self.isInFirstThird(loc, includeEdgeAssems): - positions.append(pos) - return positions + return locList[:nLocs] diff --git a/armi/reactor/grids/locations.py b/armi/reactor/grids/locations.py index 5622f4d7b..9bfff1f76 100644 --- a/armi/reactor/grids/locations.py +++ b/armi/reactor/grids/locations.py @@ -351,14 +351,19 @@ class MultiIndexLocation(IndexLocation): """ A collection of index locations that can be used as a spatialLocator. - This allows components with multiplicity>1 to have location information - within a parent grid. The implication is that there are multiple - discrete components, each one residing in one of the actual locators - underlying this collection. - - This class contains an implementation that allows a multi-index - location to be used in the ARMI data model similar to a - individual IndexLocation. + This allows components with multiplicity>1 to have location information within a + parent grid. The implication is that there are multiple discrete components, each + one residing in one of the actual locators underlying this collection. + + .. impl:: Store components with multiplicity greater than 1 + :id: I_ARMI_GRID_MULT + :implements: R_ARMI_GRID_MULT + + As not all grids are "full core symmetry", ARMI will sometimes need to track + multiple positions for a single object: one for each symmetric portion of the + reactor. This class doesn't calculate those positions in the reactor, it just + tracks the multiple positions given to it. In practice, this class is mostly + just a list of ``IndexLocation`` objects. """ # MIL's cannot be hashed, so we need to scrape off the implementation from @@ -378,8 +383,8 @@ def __getstate__(self) -> List[IndexLocation]: def __setstate__(self, state: List[IndexLocation]): """ - Unpickle a locator, the grid will attach itself if it was also pickled, otherwise this will - be detached. + Unpickle a locator, the grid will attach itself if it was also pickled, + otherwise this will be detached. """ self.__init__(None) self._locations = state @@ -428,13 +433,14 @@ def indices(self) -> List[numpy.ndarray]: """ Return indices for all locations. - Notes - ----- - Notice that this returns a list of all of the indices, unlike the ``indices()`` - implementation for :py:class:`IndexLocation`. This is intended to make the - behavior of getting the indices from the Locator symmetric with passing a list - of indices to the Grid's ``__getitem__()`` function, which constructs and - returns a ``MultiIndexLocation`` containing those indices. + .. impl:: Return the location of all instances of grid components with + multiplicity greater than 1. + :id: I_ARMI_GRID_ELEM_LOC + :implements: R_ARMI_GRID_ELEM_LOC + + This method returns the indices of all the ``IndexLocation`` objects. To be + clear, this does not return the ``IndexLocation`` objects themselves. This + is designed to be consistent with the Grid's ``__getitem__()`` method. """ return [loc.indices for loc in self._locations] @@ -468,9 +474,8 @@ def addingIsValid(myGrid: "Grid", parentGrid: "Grid"): """ True if adding a indices from one grid to another is considered valid. - In ARMI we allow the addition of a 1-D axial grid with a 2-D grid. - We do not allow any other kind of adding. This enables the 2D/1D - grid layout in Assemblies/Blocks but does not allow 2D indexing - in pins to become inconsistent. + In ARMI we allow the addition of a 1-D axial grid with a 2-D grid. We do not allow + any other kind of adding. This enables the 2D/1D grid layout in Assemblies/Blocks + but does not allow 2D indexing in pins to become inconsistent. """ return myGrid.isAxialOnly and not parentGrid.isAxialOnly diff --git a/armi/reactor/grids/structuredgrid.py b/armi/reactor/grids/structuredGrid.py similarity index 92% rename from armi/reactor/grids/structuredgrid.py rename to armi/reactor/grids/structuredGrid.py index 07ec5f2af..81d74add4 100644 --- a/armi/reactor/grids/structuredgrid.py +++ b/armi/reactor/grids/structuredGrid.py @@ -58,7 +58,7 @@ class StructuredGrid(Grid): variety of geometries, including hexagonal and Cartesian. The tuples are not vectors in the direction of the translation, but rather grouped by direction. If the bounds argument is described for a direction, the bounds will be used rather - than the unit step information. The default of (0, 0, 0) makes all dimensions + than the unit step information. The default of (0, 0, 0) makes all dimensions insensitive to indices since the coordinates are calculated by the dot product of this and the indices. With this default, any dimension that is desired to change with indices should be defined with bounds. RZtheta grids are created @@ -73,7 +73,7 @@ class StructuredGrid(Grid): grids to be finite so we can populate them with SpatialLocator objects. offset : 3-tuple, optional Offset in cm for each axis. By default the center of the (0,0,0)-th object is in - the center of the grid. Offsets can move it so that the (0,0,0)-th object can + the center of the grid. Offsets can move it so that the (0,0,0)-th object can be fully within a quadrant (i.e. in a Cartesian grid). armiObject : ArmiObject, optional The ArmiObject that this grid describes. For example if it's a 1-D assembly @@ -290,7 +290,24 @@ def restoreBackup(self): self._unitSteps, self._bounds, self._offset = self._backup def getCoordinates(self, indices, nativeCoords=False) -> numpy.ndarray: - """Return the coordinates of the center of the mesh cell at the given given indices in cm.""" + """Return the coordinates of the center of the mesh cell at the given indices + in cm. + + .. impl:: Get the coordinates from a location in a grid. + :id: I_ARMI_GRID_GLOBAL_POS + :implements: R_ARMI_GRID_GLOBAL_POS + + Probably the most common request of a structure grid will be to give the + grid indices and return the physical coordinates of the center of the mesh + cell. This is super handy in any situation where the coordinates have + physical meaning. + + The math for finding the centroid turns out to be very easy, as the mesh is + defined on the coordinates. So finding the mid-point along one axis is just + taking the upper and lower bounds and dividing by two. And this is done for + all axes. There are no more complicated situations where we need to find + the centroid of a octagon on a rectangular mesh, or the like. + """ indices = numpy.array(indices) return self._evaluateMesh( indices, self._centroidBySteps, self._centroidByBounds @@ -314,16 +331,14 @@ def _evaluateMesh(self, indices, stepOperator, boundsOperator) -> numpy.ndarray: """ Evaluate some function of indices on this grid. - Recall from above that steps are mesh centered and bounds are mesh edged. + Recall from above that steps are mesh-centered and bounds are mesh-edged. Notes ----- - This method may be able to be simplified. Complications from arbitrary - mixtures of bounds-based and step-based meshing caused it to get bad. - These were separate subclasses first, but in practice almost all cases have some mix - of step-based (hexagons, squares), and bounds based (radial, zeta). - - Improvements welcome! + This method may be simplifiable. Complications arose from mixtures of bounds- + based and step-based meshing. These were separate subclasses, but in practice + many cases have some mix of step-based (hexagons, squares), and bounds based + (radial, zeta). """ boundCoords = [] for ii, bounds in enumerate(self._bounds): @@ -337,6 +352,7 @@ def _evaluateMesh(self, indices, stepOperator, boundsOperator) -> numpy.ndarray: result = numpy.zeros(len(indices)) result[self._stepDims] = stepCoords result[self._boundDims] = boundCoords + return result + self._offset def _centroidBySteps(self, indices): @@ -363,7 +379,7 @@ def _meshBaseByBounds(index, bounds): @staticmethod def getNeighboringCellIndices(i, j=0, k=0): """Return the indices of the immediate neighbors of a mesh point in the plane.""" - return ((i + 1, j, k), (1, j + 1, k), (i - 1, j, k), (i, j - 1, k)) + return ((i + 1, j, k), (i, j + 1, k), (i - 1, j, k), (i, j - 1, k)) @staticmethod def getAboveAndBelowCellIndices(indices): @@ -495,7 +511,6 @@ def pitch(self) -> Union[float, Tuple[float, float]]: ------- float or tuple of (float, float) Grid spacing in cm - """ diff --git a/armi/reactor/grids/tests/test_grids.py b/armi/reactor/grids/tests/test_grids.py index 0cae25308..99b5934a1 100644 --- a/armi/reactor/grids/tests/test_grids.py +++ b/armi/reactor/grids/tests/test_grids.py @@ -13,7 +13,6 @@ # limitations under the License. """Tests for grids.""" -# pylint: disable=missing-function-docstring,missing-class-docstring,abstract-method,protected-access,no-self-use,attribute-defined-outside-init from io import BytesIO import math import unittest @@ -126,7 +125,9 @@ def test_recursion(self): assert_allclose(pinIndexLoc.getCompleteIndices(), (1, 5, 0)) def test_recursionPin(self): - """Ensure pin the center assem has axial coordinates consistent with a pin in an off-center assembly.""" + """Ensure pin the center assem has axial coordinates consistent with a pin in + an off-center assembly. + """ core = MockArmiObject() assem = MockArmiObject(core) block = MockArmiObject(assem) @@ -182,6 +183,7 @@ def test_label(self): def test_isAxialOnly(self): grid = grids.HexGrid.fromPitch(1.0, numRings=3) + self.assertAlmostEqual(grid.pitch, 1.0) self.assertEqual(grid.isAxialOnly, False) grid2 = grids.AxialGrid.fromNCells(10) @@ -189,11 +191,13 @@ def test_isAxialOnly(self): def test_lookupFactory(self): grid = grids.HexGrid.fromPitch(1.0, numRings=3) + self.assertAlmostEqual(grid.pitch, 1.0) self.assertEqual(grid[10, 5, 0].i, 10) def test_quasiReduce(self): """Make sure our DB-friendly version of reduce works.""" grid = grids.HexGrid.fromPitch(1.0, numRings=3) + self.assertAlmostEqual(grid.pitch, 1.0) reduction = grid.reduce() self.assertAlmostEqual(reduction[0][1][1], 1.0) @@ -201,8 +205,13 @@ def test_getitem(self): """ Test that locations are created on demand, and the multi-index locations are returned when necessary. + + .. test:: Return the locations of grid items with multiplicity greater than one. + :id: T_ARMI_GRID_ELEM_LOC + :tests: R_ARMI_GRID_ELEM_LOC """ grid = grids.HexGrid.fromPitch(1.0, numRings=0) + self.assertAlmostEqual(grid.pitch, 1.0) self.assertNotIn((0, 0, 0), grid._locations) _ = grid[0, 0, 0] self.assertIn((0, 0, 0), grid._locations) @@ -211,6 +220,10 @@ def test_getitem(self): self.assertIsInstance(multiLoc, grids.MultiIndexLocation) self.assertIn((1, 0, 0), grid._locations) + i = multiLoc.indices + i = [ii.tolist() for ii in i] + self.assertEqual(i, [[0, 0, 0], [1, 0, 0], [0, 1, 0]]) + def test_ringPosFromIndicesIncorrect(self): """Test the getRingPos fails if there is no armiObect or parent.""" grid = MockStructuredGrid( @@ -227,6 +240,7 @@ class TestHexGrid(unittest.TestCase): def test_positions(self): grid = grids.HexGrid.fromPitch(1.0) + self.assertAlmostEqual(grid.pitch, 1.0) side = 1.0 / math.sqrt(3) assert_allclose(grid.getCoordinates((0, 0, 0)), (0.0, 0.0, 0.0)) assert_allclose(grid.getCoordinates((1, 0, 0)), (1.5 * side, 0.5, 0.0)) @@ -302,26 +316,69 @@ def test_overlapsWhichSymmetryLine(self): ) def test_getSymmetricIdenticalsThird(self): - grid = grids.HexGrid.fromPitch(1.0) - grid.symmetry = str( + """Retrieve equivalent contents based on 3rd symmetry. + + .. test:: Equivalent contents in 3rd geometry are retrievable. + :id: T_ARMI_GRID_EQUIVALENTS + :tests: R_ARMI_GRID_EQUIVALENTS + """ + g = grids.HexGrid.fromPitch(1.0) + g.symmetry = str( geometry.SymmetryType( geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC ) ) - self.assertEqual(grid.getSymmetricEquivalents((3, -2)), [(-1, 3), (-2, -1)]) - self.assertEqual(grid.getSymmetricEquivalents((2, 1)), [(-3, 2), (1, -3)]) + self.assertEqual(g.getSymmetricEquivalents((3, -2)), [(-1, 3), (-2, -1)]) + self.assertEqual(g.getSymmetricEquivalents((2, 1)), [(-3, 2), (1, -3)]) - symmetrics = grid.getSymmetricEquivalents(grid.getIndicesFromRingAndPos(5, 3)) + symmetrics = g.getSymmetricEquivalents(g.getIndicesFromRingAndPos(5, 3)) self.assertEqual( - [(5, 11), (5, 19)], [grid.getRingPos(indices) for indices in symmetrics] + [(5, 11), (5, 19)], [g.getRingPos(indices) for indices in symmetrics] ) + def test_thirdAndFullSymmetry(self): + """Test that we can construct a full and a 1/3 core grid. + + .. test:: Test 1/3 and full cores have the correct positions and rings. + :id: T_ARMI_GRID_SYMMETRY + :tests: R_ARMI_GRID_SYMMETRY + """ + full = grids.HexGrid.fromPitch(1.0, symmetry="full core") + third = grids.HexGrid.fromPitch(1.0, symmetry="third core periodic") + + # check full core + self.assertEqual(full.getMinimumRings(2), 2) + self.assertEqual(full.getIndicesFromRingAndPos(2, 2), (0, 1)) + self.assertEqual(full.getPositionsInRing(3), 12) + self.assertEqual(full.getSymmetricEquivalents((3, -2)), []) + + # check 1/3 core + self.assertEqual(third.getMinimumRings(2), 2) + self.assertEqual(third.getIndicesFromRingAndPos(2, 2), (0, 1)) + self.assertEqual(third.getPositionsInRing(3), 12) + self.assertEqual(third.getSymmetricEquivalents((3, -2)), [(-1, 3), (-2, -1)]) + + def test_cornersUpFlatsUp(self): + """Test the cornersUp attribute of the fromPitch method. + + .. test:: Build a points-up and a flats-up hexagonal grids. + :id: T_ARMI_GRID_HEX_TYPE + :tests: R_ARMI_GRID_HEX_TYPE + """ + flatsUp = grids.HexGrid.fromPitch(1.0, cornersUp=False) + self.assertAlmostEqual(flatsUp._unitSteps[0][0], math.sqrt(3) / 2) + self.assertAlmostEqual(flatsUp.pitch, 1.0) + + cornersUp = grids.HexGrid.fromPitch(1.0, cornersUp=True) + self.assertAlmostEqual(cornersUp._unitSteps[0][0], 0.5) + self.assertAlmostEqual(cornersUp.pitch, 1.0) + def test_triangleCoords(self): - grid = grids.HexGrid.fromPitch(8.15) - indices1 = grid.getIndicesFromRingAndPos(5, 3) + (0,) - indices2 = grid.getIndicesFromRingAndPos(5, 23) + (0,) - indices3 = grid.getIndicesFromRingAndPos(3, 4) + (0,) - cur = grid.triangleCoords(indices1) + g = grids.HexGrid.fromPitch(8.15) + indices1 = g.getIndicesFromRingAndPos(5, 3) + (0,) + indices2 = g.getIndicesFromRingAndPos(5, 23) + (0,) + indices3 = g.getIndicesFromRingAndPos(3, 4) + (0,) + cur = g.triangleCoords(indices1) ref = [ (16.468_916_428_634_078, 25.808_333_333_333_337), (14.116_214_081_686_351, 27.166_666_666_666_67), @@ -356,8 +413,8 @@ def test_triangleCoords(self): def test_getIndexBounds(self): numRings = 5 - grid = grids.HexGrid.fromPitch(1.0, numRings=numRings) - boundsIJK = grid.getIndexBounds() + g = grids.HexGrid.fromPitch(1.0, numRings=numRings) + boundsIJK = g.getIndexBounds() self.assertEqual( boundsIJK, ((-numRings, numRings), (-numRings, numRings), (0, 1)) ) @@ -383,12 +440,85 @@ def test_is_pickleable(self): newLoc = pickle.load(buf) assert_allclose(loc.indices, newLoc.indices) - def test_adjustPitch(self): - grid = grids.HexGrid.fromPitch(1.0, numRings=3) - v1 = grid.getCoordinates((1, 0, 0)) - grid.changePitch(2.0) - v2 = grid.getCoordinates((1, 0, 0)) - assert_allclose(2 * v1, v2) + def test_adjustPitchFlatsUp(self): + """Adjust the pitch of a hexagonal lattice, for a "flats up" grid. + + .. test:: Construct a hexagonal lattice with three rings. + :id: T_ARMI_GRID_HEX0 + :tests: R_ARMI_GRID_HEX + + .. test:: Return the grid coordinates of different locations. + :id: T_ARMI_GRID_GLOBAL_POS0 + :tests: R_ARMI_GRID_GLOBAL_POS + """ + # run this test for a grid with no offset, and then a few random offset values + for offset in [0, 1, 1.123, 3.14]: + # build a hex grid with pitch=1, 3 rings, and the above offset + grid = grids.HexGrid( + unitSteps=((1.5 / math.sqrt(3), 0.0, 0.0), (0.5, 1, 0.0), (0, 0, 0)), + unitStepLimits=((-3, 3), (-3, 3), (0, 1)), + offset=numpy.array([offset, offset, offset]), + ) + + # test number of rings before converting pitch + self.assertEqual(grid._unitStepLimits[0][1], 3) + + # test that we CAN change the pitch, and it scales the grid (but not the offset) + v1 = grid.getCoordinates((1, 0, 0)) + grid.changePitch(2.0) + self.assertAlmostEqual(grid.pitch, 2.0) + v2 = grid.getCoordinates((1, 0, 0)) + assert_allclose(2 * v1 - offset, v2) + + # basic sanity: test number of rings has not changed + self.assertEqual(grid._unitStepLimits[0][1], 3) + + # basic sanity: check the offset exists and is correct + for i in range(3): + self.assertEqual(grid.offset[i], offset) + + def test_adjustPitchCornersUp(self): + """Adjust the pich of a hexagonal lattice, for a "corners up" grid. + + .. test:: Construct a hexagonal lattice with three rings. + :id: T_ARMI_GRID_HEX1 + :tests: R_ARMI_GRID_HEX + + .. test:: Return the grid coordinates of different locations. + :id: T_ARMI_GRID_GLOBAL_POS1 + :tests: R_ARMI_GRID_GLOBAL_POS + """ + # run this test for a grid with no offset, and then a few random offset values + for offset in [0, 1, 1.123, 3.14]: + offsets = [offset, 0, 0] + # build a hex grid with pitch=1, 3 rings, and the above offset + grid = grids.HexGrid( + unitSteps=( + (0.5, -0.5, 0), + (1.5 / math.sqrt(3), 1.5 / math.sqrt(3), 0), + (0, 0, 0), + ), + unitStepLimits=((-3, 3), (-3, 3), (0, 1)), + offset=numpy.array(offsets), + ) + + # test number of rings before converting pitch + self.assertEqual(grid._unitStepLimits[0][1], 3) + + # test that we CAN change the pitch, and it scales the grid (but not the offset) + v1 = grid.getCoordinates((1, 0, 0)) + grid.changePitch(2.0) + self.assertAlmostEqual(grid.pitch, 2.0, delta=1e-9) + v2 = grid.getCoordinates((1, 0, 0)) + correction = numpy.array([0.5, math.sqrt(3) / 2, 0]) + assert_allclose(v1 + correction, v2) + + # basic sanity: test number of rings has not changed + self.assertEqual(grid._unitStepLimits[0][1], 3) + + # basic sanity: check the offset exists and is correct + for i, off in enumerate(offsets): + self.assertEqual(grid.offset[i], off) def test_badIndices(self): grid = grids.HexGrid.fromPitch(1.0, numRings=3) @@ -401,6 +531,12 @@ def test_badIndices(self): grid.getCoordinates((0, 5, -1)) def test_isInFirstThird(self): + """Determine if grid is in first third. + + .. test:: Determine if grid in first third. + :id: T_ARMI_GRID_SYMMETRY_LOC + :tests: R_ARMI_GRID_SYMMETRY_LOC + """ grid = grids.HexGrid.fromPitch(1.0, numRings=10) self.assertTrue(grid.isInFirstThird(grid[0, 0, 0])) self.assertTrue(grid.isInFirstThird(grid[1, 0, 0])) diff --git a/armi/reactor/grids/thetarz.py b/armi/reactor/grids/thetarz.py index ec6ed2774..27148ae98 100644 --- a/armi/reactor/grids/thetarz.py +++ b/armi/reactor/grids/thetarz.py @@ -17,7 +17,7 @@ import numpy from armi.reactor.grids.locations import IJType, IJKType -from armi.reactor.grids.structuredgrid import StructuredGrid +from armi.reactor.grids.structuredGrid import StructuredGrid if TYPE_CHECKING: # Avoid circular imports diff --git a/armi/reactor/parameters/__init__.py b/armi/reactor/parameters/__init__.py index 5fe4de07e..d03773a20 100644 --- a/armi/reactor/parameters/__init__.py +++ b/armi/reactor/parameters/__init__.py @@ -179,7 +179,7 @@ class instance to have a ``__dict__``. This saves memory when there are many * - Parameters are just fancy properties with meta data. - Implementing the descriptor interface on a :py:class:`Parameter` removes the need to construct a :py:class:`Parameter` without a name, then come back through - with the ``applyParameters()`` class method to apply the + with the ``applyParameters()`` method to apply the :py:class:`Parameter` as a descriptor. .. _thefreedictionary: http://www.thefreedictionary.com/parameter diff --git a/armi/reactor/parameters/parameterCollections.py b/armi/reactor/parameters/parameterCollections.py index be380617b..0163a416e 100644 --- a/armi/reactor/parameters/parameterCollections.py +++ b/armi/reactor/parameters/parameterCollections.py @@ -83,7 +83,7 @@ def __new__(mcl, name, bases, attrs): class ParameterCollection(metaclass=_ParameterCollectionType): - r"""An empty class for holding state information in the ARMI data structure. + """An empty class for holding state information in the ARMI data structure. A parameter collection stores one or more formally-defined values ("parameters"). Until a given ParameterCollection subclass has been instantiated, new parameters may diff --git a/armi/reactor/parameters/parameterDefinitions.py b/armi/reactor/parameters/parameterDefinitions.py index 51d298d10..a1d6c3d88 100644 --- a/armi/reactor/parameters/parameterDefinitions.py +++ b/armi/reactor/parameters/parameterDefinitions.py @@ -152,6 +152,21 @@ class Serializer: their version. It is also good practice, whenever possible, to support reading old versions so that database files written by old versions can still be read. + .. impl:: Users can define custom parameter serializers. + :id: I_ARMI_PARAM_SERIALIZE + :implements: R_ARMI_PARAM_SERIALIZE + + Important physical parameters are stored in every ARMI object. + These parameters represent the plant's state during execution + of the model. Currently, this requires that the parameters be serializable to a + numpy array of a datatype supported by the ``h5py`` package so that the data can + be written to, and subsequently read from, an HDF5 file. + + This class allows for these parameters to be serialized in a custom manner by + providing interfaces for packing and unpacking parameter data. The user or + downstream plugin is able to specify how data is serialized if that data is not + naturally serializable. + See Also -------- armi.bookkeeping.db.database3.packSpecialData @@ -336,6 +351,17 @@ def __get__(self, obj, cls=None): def setter(self, setter): """Decorator method for assigning setter. + .. impl:: Provide a way to signal if a parameter needs updating across processes. + :id: I_ARMI_PARAM_PARALLEL + :implements: R_ARMI_PARAM_PARALLEL + + Parameters need to be handled properly during parallel code execution. This + includes notifying processes if a parameter has been updated by + another process. This method allows for setting a parameter's value as well + as an attribute that signals whether this parameter has been updated. Future + processes will be able to query this attribute so that the parameter's + status is properly communicated. + Notes ----- Unlike the traditional Python ``property`` class, this does not return a new @@ -457,7 +483,7 @@ def __getitem__(self, name): return matches[0] def add(self, paramDef): - r"""Add a :py:class:`Parameter` to this collection.""" + """Add a :py:class:`Parameter` to this collection.""" assert not self._locked, "This ParameterDefinitionCollection has been locked." self._paramDefs.append(paramDef) self._paramDefDict[paramDef.name, paramDef.collectionType] = paramDef @@ -519,7 +545,7 @@ def unchanged_since(self, mask): return self._filter(lambda pd: not (pd.assigned & mask)) def forType(self, compositeType): - r""" + """ Create a :py:class:`ParameterDefinitionCollection` that contains definitions for a specific composite type. """ @@ -545,16 +571,16 @@ def setAssignmentFlag(self, mask): pd.assigned |= mask def byNameAndType(self, name, compositeType): - r"""Get a :py:class:`Parameter` by compositeType and name.""" + """Get a :py:class:`Parameter` by compositeType and name.""" return self._paramDefDict[name, compositeType.paramCollectionType] def byNameAndCollectionType(self, name, collectionType): - r"""Get a :py:class:`Parameter` by collectionType and name.""" + """Get a :py:class:`Parameter` by collectionType and name.""" return self._paramDefDict[name, collectionType] @property def categories(self): - r"""Get the categories of all the :py:class:`~Parameter` instances within this collection.""" + """Get the categories of all the :py:class:`~Parameter` instances within this collection.""" categories = set() for paramDef in self: categories |= paramDef.categories @@ -575,6 +601,15 @@ def toWriteToDB(self, assignedMask: Optional[int] = None): """ Get a list of acceptable parameters to store to the database for a level of the data model. + .. impl:: Filter parameters to write to DB. + :id: I_ARMI_PARAM_DB + :implements: R_ARMI_PARAM_DB + + This method is called when writing the parameters to the database file. It + queries the parameter's ``saveToDB`` attribute to ensure that this parameter + is desired for saving to the database file. It returns a list of parameters + that should be included in the database write operation. + Parameters ---------- assignedMask : int @@ -732,6 +767,6 @@ def defParam( # Container for all parameter definition collections that have been bound to an -# ArmiObject or subclass. These are added from the applyParameters() class method on +# ArmiObject or subclass. These are added from the applyParameters() method on # the ParameterCollection class. ALL_DEFINITIONS = ParameterDefinitionCollection() diff --git a/armi/reactor/reactorParameters.py b/armi/reactor/reactorParameters.py index e738535e3..cc9fe3454 100644 --- a/armi/reactor/reactorParameters.py +++ b/armi/reactor/reactorParameters.py @@ -521,78 +521,6 @@ def defineCoreParameters(): ), ) - with pDefs.createBuilder( - default=0.0, - location=ParamLocation.AVERAGE, - categories=["reactivity coefficients"], - ) as pb: - - pb.defParam( - "axial", - units=f"{units.CENTS}/{units.DEGK}", - description="Axial expansion coefficient", - ) - - pb.defParam( - "doppler", - units=f"{units.CENTS}/{units.DEGK}", - description="Doppler coefficient", - ) - - pb.defParam( - "dopplerConst", - units=f"{units.CENTS}*{units.DEGK}^(n-1)", - description="Doppler constant", - ) - - pb.defParam( - "fuelDensity", - units=f"{units.CENTS}/{units.DEGK}", - description="Fuel temperature coefficient", - ) - - pb.defParam( - "coolantDensity", - units=f"{units.CENTS}/{units.DEGK}", - description="Coolant temperature coefficient", - ) - - pb.defParam( - "totalCoolantDensity", - units=f"{units.CENTS}/{units.DEGK}", - description="Coolant temperature coefficient weighted to include bond and interstitial effects", - ) - - pb.defParam( - "Voideddoppler", - units=f"{units.CENTS}/{units.DEGK}", - description="Voided Doppler coefficient", - ) - - pb.defParam( - "VoideddopplerConst", - units=f"{units.CENTS}*{units.DEGK}^(n-1)", - description="Voided Doppler constant", - ) - - pb.defParam( - "voidWorth", units=f"{units.DOLLARS}", description="Coolant void worth" - ) - - pb.defParam("voidedKeff", units=units.UNITLESS, description="Voided keff") - - pb.defParam( - "radialHT9", - units=f"{units.CENTS}/{units.DEGK}", - description="Radial expansion coefficient when driven by thermal expansion of HT9.", - ) - - pb.defParam( - "radialSS316", - units=f"{units.CENTS}/{units.DEGK}", - description="Radial expansion coefficient when driven by thermal expansion of SS316.", - ) - with pDefs.createBuilder( default=0.0, location=ParamLocation.AVERAGE, @@ -609,7 +537,7 @@ def defineCoreParameters(): pb.defParam( "betaComponents", units=units.UNITLESS, - description="Group-wise delayed neutron fractions.", + description="Group-wise delayed neutron fractions", default=None, ) @@ -646,7 +574,7 @@ def defineCoreParameters(): pb.defParam( "rxFuelAxialExpansionCoeffPerPercent", - units="dk/kk'-%", + units=f"{units.REACTIVITY}/{units.PERCENT}", description="Fuel Axial Expansion Coefficient", ) diff --git a/armi/reactor/reactors.py b/armi/reactor/reactors.py index 610d7052e..7c10e204e 100644 --- a/armi/reactor/reactors.py +++ b/armi/reactor/reactors.py @@ -13,12 +13,15 @@ # limitations under the License. """ -Reactor objects represent the highest level in the hierarchy of structures that compose the system -to be modeled. Core objects represent collections of assemblies. - -Core is a high-level object in the data model in ARMI. They contain assemblies which in turn contain -more refinement in representing the physical reactor. The reactor is the owner of many of the -plant-wide state variables such as keff, cycle, and node. +Reactor objects represent the highest level in the hierarchy of +structures that compose the system to be modeled. Core objects +represent collections of assemblies. + +Core is a high-level object in the data model in ARMI. They +contain assemblies which in turn contain more refinement in +representing the physical reactor. The reactor is the owner of +many of the plant-wide state variables such as keff, cycle, +and node. """ from typing import Optional import collections @@ -64,12 +67,30 @@ class Reactor(composites.Composite): """ - Top level of the composite structure, potentially representing all components in a reactor. + Top level of the composite structure, potentially representing all + components in a reactor. This class contains the core and any ex-core structures that are to be represented in the ARMI - model. Historically, the `Reactor` contained only the core. To support better representation of - ex-core structures, the old `Reactor` functionality was moved to the newer `Core` class, which - has a `Reactor` parent. + model. Historically, the ``Reactor`` contained only the core. To support better representation + of ex-core structures, the old ``Reactor`` functionality was moved to the newer `Core` class, + which has a ``Reactor`` parent. + + .. impl:: The user-specified reactor. + :id: I_ARMI_R + :implements: R_ARMI_R + + The :py:class:`Reactor <armi.reactor.reactors.Reactor>` is the top level of the composite + structure, which can represent all components within a reactor core. The reactor contains a + :py:class:`Core <armi.reactor.reactors.Core>`, which contains a collection of + :py:class:`Assembly <armi.reactor.assemblies.Assembly>` objects arranged in a hexagonal or + Cartesian grid. Each Assembly consists of a stack of + :py:class:`Block <armi.reactor.blocks.Block>` objects, which are each composed of one or + more :py:class:`Component <armi.reactor.components.component.Component>` objects. Each + :py:class:`Interface <armi.interfaces.Interface>` is able to interact with the reactor and + its child :py:class:`Composites <armi.reactor.composites.Composite>` by retrieving data from + it or writing new data to it. This is the main medium through which input information and + the output of physics calculations is exchanged between interfaces and written to an ARMI + database. """ pDefs = reactorParameters.defineReactorParameters() @@ -124,8 +145,8 @@ def incrementAssemNum(self): Notes ----- - The "max assembly number" is not currently used in the Reactor. So the idea - is that we return the current number, then iterate it for the next assembly. + The "max assembly number" is not currently used in the Reactor. So the idea is that we + return the current number, then iterate it for the next assembly. Obviously, this method will be unused for non-assembly-based reactors. @@ -166,7 +187,7 @@ def loadFromCs(cs) -> Reactor: Parameters ---------- - cs: CaseSettings + cs: Settings A relevant settings object Returns @@ -229,6 +250,21 @@ class Core(composites.Composite): This has the bulk of the data management operations. + .. impl:: Represent a reactor core as a composite object. + :id: I_ARMI_R_CORE + :implements: R_ARMI_R_CORE + + A :py:class:`Core <armi.reactor.reactors.Core>` object is typically a child of a + :py:class:`Reactor <armi.reactor.reactors.Reactor>` object. A Reactor can contain multiple + objects of the Core type. The instance attribute name ``r.core`` is reserved for the object + representating the active core. A reactor may also have a spent fuel pool instance + attribute, ``r.sfp``, which is also of type :py:class:`core <armi.reactor.reactors.Core>`. + + Most of the operations to retrieve information from the ARMI reactor data model are mediated + through Core objects. For example, + :py:meth:`getAssemblies() <armi.reactor.reactors.Core.getAssemblies>` is used to get a list + of all assemblies in the Core. + Attributes ---------- params : dict @@ -332,12 +368,34 @@ def r(self): @property def symmetry(self) -> geometry.SymmetryType: + """Getter for symmetry type. + + .. impl:: Get core symmetry. + :id: I_ARMI_R_SYMM + :implements: R_ARMI_R_SYMM + + This property getter returns the symmetry attribute of the spatialGrid instance + attribute. The spatialGrid is an instance of a child of the abstract base class + :py:class:`Grid <armi.reactor.grids.grid.Grid>` type. The symmetry attribute is an + instance of the :py:class:`SymmetryType <armi.reactor.geometry.SymmetryType>` class, + which is a wrapper around the :py:class:`DomainType <armi.reactor.geometry.DomainType>` + and :py:class:`BoundaryType <armi.reactor.geometry.BoundaryType>` enumerations used to + classify the domain (e.g., 1/3 core, quarter core, full core) and symmetry boundary + conditions (e.g., periodic, reflective, none) of a reactor, respectively. + + Only specific combinations of :py:class:`Grid <armi.reactor.grids.grid.Grid>` type, + :py:class:`DomainType <armi.reactor.geometry.DomainType>`, and :py:class:`BoundaryType + <armi.reactor.geometry.BoundaryType>` are valid. The validity of a user-specified + geometry and symmetry is verified by a settings :py:class:`Inspector + <armi.operators.settingsValidation.Inspector`. + """ if not self.spatialGrid: raise ValueError("Cannot access symmetry before a spatialGrid is attached.") return self.spatialGrid.symmetry @symmetry.setter def symmetry(self, val: str): + """Setter for symmetry type.""" self.spatialGrid.symmetry = str(val) self.clearCache() @@ -364,12 +422,10 @@ def lib(self) -> Optional[xsLibraries.IsotxsLibrary]: """ Return the microscopic cross section library if one exists. - - If there is a library currently associated with the core, - it will be returned - - Otherwise, an ``ISOTXS`` file will be searched for in the working directory, - opened as ``ISOTXS`` object and returned. - - Finally, if no ``ISOTXS`` file exists in the working directory, - a None will be returned. + - If there is a library currently associated with the core, it will be returned + - Otherwise, an ``ISOTXS`` file will be searched for in the working directory, opened as + ``ISOTXS`` object and returned. + - Finally, if no ``ISOTXS`` file exists in the working directory, a None will be returned. """ isotxsFileName = nuclearDataIO.getExpectedISOTXSFileName() if self._lib is None and os.path.exists(isotxsFileName): @@ -399,14 +455,14 @@ def refAssem(self): """ Return the "reference" assembly for this Core. - The reference assembly is defined as the center-most assembly with a FUEL flag, - if any are present, or the center-most of any assembly otherwise. + The reference assembly is defined as the center-most assembly with a FUEL flag, if any are + present, or the center-most of any assembly otherwise. Warning ------- - The convenience of this property should be weighed against it's somewhat - arbitrary nature for any particular client. The center-most fueled assembly is - not particularly representative of the state of the core as a whole. + The convenience of this property should be weighed against it's somewhat arbitrary nature + for any particular client. The center-most fueled assembly is not particularly + representative of the state of the core as a whole. """ key = lambda a: a.spatialLocator.getRingPos() assems = self.getAssemblies(Flags.FUEL, sortKey=key) @@ -457,9 +513,8 @@ def setPowerFromDensity(self): def setPowerIfNecessary(self): """Set the core power, from the power density. - If the power density is set, but the power isn't, we set the calculate the - total heavy metal mass of the reactor, and set the total power. Which will - then be the real source of truth again. + If the power density is set, but the power isn't, calculate the total heavy metal mass of + the reactor, and set the total power. Which will then be the real source of truth again. """ if self.p.power == 0 and self.p.powerDensity > 0: self.setPowerFromDensity() @@ -480,8 +535,7 @@ def locateAllAssemblies(self): """ Store the current location of all assemblies. - This is required for shuffle printouts, repeat shuffling, and - MCNP shuffling. + This is required for shuffle printouts, repeat shuffling, and MCNP shuffling. """ for a in self.getAssemblies(includeAll=True): a.lastLocationLabel = a.getLocation() @@ -544,7 +598,7 @@ def removeAssembliesInRing(self, ringNum, cs, overrideCircularRingMode=False): ---------- ringNum : int The ring to remove - cs: CaseSettings + cs: Settings A relevant settings object overrideCircularRingMode : bool, optional False ~ default: use circular/square/hex rings, just as the reactor defines them @@ -754,6 +808,20 @@ def getNumRings(self, indexBased=False): """ Returns the number of rings in this reactor. Based on location so indexing will start at 1. + Circular ring shuffling changes the interpretation of this result. + + .. impl:: Retrieve number of rings in core. + :id: I_ARMI_R_NUM_RINGS + :implements: R_ARMI_R_NUM_RINGS + + This method determines the number of rings in the reactor. If the + setting ``circularRingMode`` is enabled (by default it is false), the + assemblies will be grouped into roughly circular rings based on + their positions and the number of circular rings is returned. + Otherwise, the number of hex rings is returned. This parameter is + mostly used to facilitate certain fuel management strategies where + the fuel is categorized and moved based on ring indexing. + Warning ------- If you loop through range(maxRing) then ring+1 is the one you want! @@ -762,9 +830,6 @@ def getNumRings(self, indexBased=False): ---------- indexBased : bool, optional If true, will force location-index interpretation, even if "circular shuffling" is enabled. - - When circular ring shuffling is activated, this changes interpretation. - Developers plan on making this another method for the secondary interpretation. """ if self.circularRingList and not indexBased: return max(self.circularRingList) @@ -1110,6 +1175,15 @@ def getAssemblyByName(self, name): """ Find the assembly that has this name. + .. impl:: Get assembly by name. + :id: I_ARMI_R_GET_ASSEM_NAME + :implements: R_ARMI_R_GET_ASSEM_NAME + + This method returns the :py:class:`assembly + <armi.reactor.core.assemblies.Assembly>` with a name matching the + value provided as an input parameter to this function. The ``name`` of + an assembly is based on the ``assemNum`` parameter. + Parameters ---------- name : str @@ -1621,7 +1695,24 @@ def getAssemblyWithAssemNum(self, assemNum): return self.getAssembly(assemNum=assemNum) def getAssemblyWithStringLocation(self, locationString): - """Returns an assembly or none if given a location string like 'B0014'.""" + """Returns an assembly or none if given a location string like '001-001'. + + .. impl:: Get assembly by location. + :id: I_ARMI_R_GET_ASSEM_LOC + :implements: R_ARMI_R_GET_ASSEM_LOC + + This method returns the :py:class:`assembly + <armi.reactor.core.assemblies.Assembly>` located in the requested + location. The location is provided to this method as an input + parameter in a string with the format "001-001". For a :py:class:`HexGrid + <armi.reactor.grids.hexagonal.HexGrid>`, the first number indicates + the hexagonal ring and the second number indicates the position + within that ring. For a :py:class:`CartesianGrid + <armi.reactor.grids.cartesian.CartesianGrid>`, the first number + represents the x index and the second number represents the y index. + If there is no assembly in the grid at the requested location, this + method returns None. + """ ring, pos, _ = grids.locatorLabelToIndices(locationString) loc = self.spatialGrid.getLocatorFromRingAndPos(ring, pos) assem = self.childrenByLocator.get(loc) @@ -1647,10 +1738,39 @@ def findNeighbors( self, a, showBlanks=True, duplicateAssembliesOnReflectiveBoundary=False ): """ - Find assemblies that are next this assembly. + Find assemblies that are next to this assembly. + + Return a list of neighboring assemblies. + + For a hexagonal grid, the list begins from the 30 degree point (point 1) + then moves counterclockwise around. + + For a Cartesian grid, the order of the neighbors is east, north, west, + south. - Return a list of neighboring assemblies from the 30 degree point (point 1) then - counterclockwise around. + .. impl:: Retrieve neighboring assemblies of a given assembly. + :id: I_ARMI_R_FIND_NEIGHBORS + :implements: R_ARMI_R_FIND_NEIGHBORS + + This method takes an :py:class:`Assembly + <armi.reactor.assemblies.Assembly>` as an input parameter and returns + a list of the assemblies neighboring that assembly. There are 6 + neighbors in a hexagonal grid and 4 neighbors in a Cartesian grid. + The (i, j) indices of the neighbors are provided by + :py:meth:`getNeighboringCellIndices + <armi.reactor.grids.StructuredGrid.getNeighboringCellIndices>`. For + a hexagonal grid, the (i, j) indices are converted to (ring, + position) indexing using the ``core.spatialGrid`` instance attribute. + + The ``showBlanks`` option determines whether non-existing assemblies + will be indicated with a ``None`` in the list or just excluded from + the list altogether. + + The ``duplicateAssembliesOnReflectiveBoundary`` setting only works for + 1/3 core symmetry with periodic boundary conditions. For these types + of geometries, if this setting is ``True``, neighbor lists for + assemblies along a periodic boundary will include the assemblies + along the opposite periodic boundary that are effectively neighbors. Parameters ---------- @@ -1658,55 +1778,64 @@ def findNeighbors( The assembly to find neighbors of. showBlanks : Boolean, optional - If True, the returned array of 6 neighbors will return "None" for neighbors - that do not explicitly exist in the 1/3 core model (including many that WOULD - exist in a full core model). + If True, the returned array of 6 neighbors will return "None" for + neighbors that do not explicitly exist in the 1/3 core model + (including many that WOULD exist in a full core model). - If False, the returned array will not include the "None" neighbors. If one or - more neighbors does not explicitly exist in the 1/3 core model, the returned - array will have a length of less than 6. + If False, the returned array will not include the "None" neighbors. + If one or more neighbors does not explicitly exist in the 1/3 core + model, the returned array will have a length of less than 6. duplicateAssembliesOnReflectiveBoundary : Boolean, optional - If True, findNeighbors duplicates neighbor assemblies into their "symmetric - identicals" so that even assemblies that border symmetry lines will have 6 - neighbors. The only assemblies that will have fewer than 6 neighbors are those - that border the outer core boundary (usually vacuum). - - If False, findNeighbors returns None for assemblies that do not exist in a 1/3 - core model (but WOULD exist in a full core model). - - For example, applying findNeighbors for the central assembly (ring, pos) = (1, - 1) in 1/3 core symmetry (with duplicateAssembliesOnReflectiveBoundary = True) - would return a list of 6 assemblies, but those 6 would really only be - assemblies (2, 1) and (2, 2) repeated 3 times each. - - Note that the value of duplicateAssembliesOnReflectiveBoundary only really if - showBlanks = True. This will have no effect if the model is full core since - asymmetric models could find many duplicates in the other thirds + If True, findNeighbors duplicates neighbor assemblies into their + "symmetric identicals" so that even assemblies that border symmetry + lines will have 6 neighbors. The only assemblies that will have + fewer than 6 neighbors are those that border the outer core boundary + (usually vacuum). + + If False, findNeighbors returns None for assemblies that do not + exist in a 1/3 core model (but WOULD exist in a full core model). + + For example, applying findNeighbors for the central assembly (ring, + pos) = (1, 1) in 1/3 core symmetry (with + duplicateAssembliesOnReflectiveBoundary = True) would return a list + of 6 assemblies, but those 6 would really only be assemblies (2, 1) + and (2, 2) repeated 3 times each. + + Note that the value of duplicateAssembliesOnReflectiveBoundary only + really matters if showBlanks == True. This will have no effect if + the model is full core since asymmetric models could find many + duplicates in the other thirds Notes ----- - This only works for 1/3 or full core symmetry. + The duplicateAssembliesOnReflectiveBoundary setting only works for third + core symmetry. - This uses the 'mcnp' index map (MCNP GEODST hex coordinates) instead of the - standard (ring, pos) map. because neighbors have consistent indices this way. We - then convert over to (ring, pos) using the lookup table that a reactor has. + This uses the 'mcnp' index map (MCNP GEODST hex coordinates) instead of + the standard (ring, pos) map. because neighbors have consistent indices + this way. We then convert over to (ring, pos) using the lookup table + that a reactor has. Returns ------- neighbors : list of assembly objects This is a list of "nearest neighbors" to assembly a. - If showBlanks = False, it will return fewer than 6 neighbors if not all 6 - neighbors explicitly exist in the core model. + If showBlanks = False, it will return fewer than the maximum number + of neighbors if not all neighbors explicitly exist in the core + model. For a hexagonal grid, the maximum number of neighbors is 6. + For a Cartesian grid, the maximum number is 4. - If showBlanks = True and duplicateAssembliesOnReflectiveBoundary = False, it - will have a "None" for assemblies that do not exist in the 1/3 model. + If showBlanks = True and duplicateAssembliesOnReflectiveBoundary = + False, it will have a "None" for assemblies that do not exist in the + 1/3 model. - If showBlanks = True and duplicateAssembliesOnReflectiveBoundary = True, it - will return the existing "symmetric identical" assembly of a non-existing - assembly. It will only return "None" for an assembly when that assembly is - non-existing AND has no existing "symmetric identical". + If showBlanks = True and duplicateAssembliesOnReflectiveBoundary = + True, it will return the existing "symmetric identical" assembly of + a non-existing assembly. It will only return "None" for an assembly + when that assembly is non-existing AND has no existing "symmetric + identical". See Also -------- @@ -1739,7 +1868,7 @@ def findNeighbors( def _getReflectiveDuplicateAssembly(self, neighborLoc): """ - Return duplicate assemblies accross symmetry line. + Return duplicate assemblies across symmetry line. Notes ----- @@ -1779,7 +1908,7 @@ def createFreshFeed(self, cs=None): Parameters ---------- - cs : CaseSettings object + cs : Settings Global settings for the case See Also @@ -1798,7 +1927,7 @@ def createAssemblyOfType(self, assemType=None, enrichList=None, cs=None): The assembly type to create enrichList : list weight percent enrichments of each block - cs : CaseSettings object + cs : Settings Global settings for the case Returns @@ -1897,6 +2026,21 @@ def findAllMeshPoints(self, assems=None, applySubMesh=True): """ Return all mesh positions in core including both endpoints. + .. impl:: Construct a mesh based on core blocks. + :id: I_ARMI_R_MESH + :implements: R_ARMI_R_MESH + + This method iterates through all of the assemblies provided, or all + assemblies in the core if no list of ``assems`` is provided, and + constructs a tuple of three lists which contain the unique i, j, and + k mesh coordinates, respectively. The ``applySubMesh`` setting + controls whether the mesh will include the submesh coordinates. For + a standard assembly-based reactor geometry with a hexagonal or + Cartesian assembly grid, this method is only used to produce axial + (k) mesh points. If multiple assemblies are provided with different + axial meshes, the axial mesh list will contain the union of all + unique mesh points. Duplicate mesh points are removed. + Parameters ---------- assems : list, optional @@ -1904,7 +2048,6 @@ def findAllMeshPoints(self, assems=None, applySubMesh=True): applySubMesh : bool, optional Apply submeshing parameters to make mesh points smaller than blocks. Default=True. - Returns ------- meshVals : tuple @@ -2109,8 +2252,8 @@ def getMaxNumPins(self): def getMinimumPercentFluxInFuel(self, target=0.005): """ - Goes through the entire reactor to determine what percentage of flux occures at - each ring. Starting with the outer ring, this function helps determine the effective + Goes through the entire reactor to determine what percentage of flux occurs at + each ring. Starting with the outer ring, this function helps determine the effective size of the core where additional assemblies will not help the breeding in the TWR. Parameters @@ -2207,41 +2350,6 @@ def getAvgTemp(self, typeSpec, blockList=None, flux2Weight=False): else: raise RuntimeError("no temperature average for {0}".format(typeSpec)) - def getAllNuclidesIn(self, mats): - """ - Find all nuclides that are present in these materials anywhere in the core. - - Parameters - ---------- - mats : iterable or Material - List (or single) of materials to scan the full core for, accumulating a nuclide list - - Returns - ------- - allNucNames : list - All nuclide names in this material anywhere in the reactor - - See Also - -------- - getDominantMaterial : finds the most prevalent material in a certain type of blocks - Block.adjustDensity : modifies nuclides in a block - - Notes - ----- - If you need to know the nuclides in a fuel pin, you can't just use the sample returned - from getDominantMaterial, because it may be a fresh fuel material (U and Zr) even though - there are burned materials elsewhere (with U, Zr, Pu, LFP, etc.). - """ - if not isinstance(mats, list): - # single material passed in - mats = [mats] - names = set(m.name for m in mats) - allNucNames = set() - for c in self.iterComponents(): - if c.material.name in names: - allNucNames.update(c.getNuclides()) - return list(allNucNames) - def growToFullCore(self, cs): """Copies symmetric assemblies to build a full core model out of a 1/3 core model. @@ -2415,22 +2523,22 @@ def processLoading(self, cs, dbLoad: bool = False): def buildManualZones(self, cs): """ - Build the Zones that are defined manually in the given CaseSettings file, + Build the Zones that are defined manually in the given Settings file, in the `zoneDefinitions` setting. Parameters ---------- - cs : CaseSettings + cs : Settings The standard ARMI settings object Examples -------- Manual zones will be defined in a special string format, e.g.: - zoneDefinitions: - - ring-1: 001-001 - - ring-2: 002-001, 002-002 - - ring-3: 003-001, 003-002, 003-003 + >>> zoneDefinitions: + >>> - ring-1: 001-001 + >>> - ring-2: 002-001, 002-002 + >>> - ring-3: 003-001, 003-002, 003-003 Notes ----- diff --git a/armi/reactor/systemLayoutInput.py b/armi/reactor/systemLayoutInput.py index cac411c7f..38573b029 100644 --- a/armi/reactor/systemLayoutInput.py +++ b/armi/reactor/systemLayoutInput.py @@ -551,7 +551,7 @@ def fromReactor(cls, reactor): @classmethod def loadFromCs(cls, cs): - """Function to load Geoemtry based on supplied ``CaseSettings``.""" + """Function to load Geoemtry based on supplied ``Settings``.""" if not cs["geomFile"]: return None diff --git a/armi/reactor/tests/test_assemblies.py b/armi/reactor/tests/test_assemblies.py index 47ee795d5..37e9df87b 100644 --- a/armi/reactor/tests/test_assemblies.py +++ b/armi/reactor/tests/test_assemblies.py @@ -13,38 +13,39 @@ # limitations under the License. """Tests assemblies.py.""" -import numpy as np +import math import pathlib import random import unittest + +import numpy as np from numpy.testing import assert_allclose from armi import settings from armi import tests +from armi.physics.neutronics.settings import ( + CONF_LOADING_FILE, + CONF_XS_KERNEL, +) from armi.reactor import assemblies +from armi.reactor import blocks from armi.reactor import blueprints from armi.reactor import components +from armi.reactor import geometry from armi.reactor import parameters from armi.reactor import reactors -from armi.reactor import geometry from armi.reactor.assemblies import ( - blocks, copy, Flags, grids, HexAssembly, - math, numpy, runLog, ) +from armi.reactor.tests import test_reactors from armi.tests import TEST_ROOT, mockRunLogs from armi.utils import directoryChangers from armi.utils import textProcessors -from armi.reactor.tests import test_reactors -from armi.physics.neutronics.settings import ( - CONF_LOADING_FILE, - CONF_XS_KERNEL, -) NUM_BLOCKS = 3 @@ -344,17 +345,36 @@ def test_getNum(self): self.assertEqual(cur, ref) def test_getLocation(self): + """ + Test for getting string location of assembly. + + .. test:: Assembly location is retrievable. + :id: T_ARMI_ASSEM_POSI0 + :tests: R_ARMI_ASSEM_POSI + """ cur = self.assembly.getLocation() ref = str("005-003") self.assertEqual(cur, ref) def test_getArea(self): + """Tests area calculation for hex assembly. + + .. test:: Assembly area is retrievable. + :id: T_ARMI_ASSEM_DIMS0 + :tests: R_ARMI_ASSEM_DIMS + """ cur = self.assembly.getArea() ref = math.sqrt(3) / 2.0 * self.hexDims["op"] ** 2 places = 6 self.assertAlmostEqual(cur, ref, places=places) def test_getVolume(self): + """Tests volume calculation for hex assembly. + + .. test:: Assembly volume is retrievable. + :id: T_ARMI_ASSEM_DIMS1 + :tests: R_ARMI_ASSEM_DIMS + """ cur = self.assembly.getVolume() ref = math.sqrt(3) / 2.0 * self.hexDims["op"] ** 2 * self.height * NUM_BLOCKS places = 6 @@ -432,6 +452,14 @@ def test_getTotalHeight(self): self.assertAlmostEqual(cur, ref, places=places) def test_getHeight(self): + """ + Test height of assembly calculation. + + .. test:: Assembly height is retrievable. + :id: T_ARMI_ASSEM_DIMS2 + :tests: R_ARMI_ASSEM_DIMS + + """ cur = self.assembly.getHeight() ref = self.height * NUM_BLOCKS places = 6 @@ -836,6 +864,12 @@ def test_countBlocksOfType(self): self.assertEqual(cur, 3) def test_getDim(self): + """Tests dimensions are retrievable. + + .. test:: Assembly dimensions are retrievable. + :id: T_ARMI_ASSEM_DIMS3 + :tests: R_ARMI_ASSEM_DIMS + """ cur = self.assembly.getDim(Flags.FUEL, "op") ref = self.hexDims["op"] places = 6 @@ -849,7 +883,7 @@ def test_getDominantMaterial(self): self.assertEqual(self.assembly.getDominantMaterial().getName(), ref) def test_iteration(self): - r"""Tests the ability to doubly-loop over assemblies (under development).""" + """Tests the ability to doubly-loop over assemblies (under development).""" a = self.assembly for bi, b in enumerate(a): @@ -884,7 +918,6 @@ def test_getBlocksAndZ(self): def test_getBlocksBetweenElevations(self): # assembly should have 3 blocks of 10 cm in it - blocksAndHeights = self.assembly.getBlocksBetweenElevations(0, 10) self.assertEqual(blocksAndHeights[0], (self.assembly[0], 10.0)) @@ -969,7 +1002,12 @@ def test_hasContinuousCoolantChannel(self): self.assertTrue(modifiedAssem.hasContinuousCoolantChannel()) def test_carestianCoordinates(self): - """Check the coordinates of the assembly within the core with a CarestianGrid.""" + """Check the coordinates of the assembly within the core with a CarestianGrid. + + .. test:: Cartesian coordinates are retrievable. + :id: T_ARMI_ASSEM_POSI1 + :tests: R_ARMI_ASSEM_POSI + """ a = makeTestAssembly( numBlocks=1, assemNum=1, @@ -1000,7 +1038,12 @@ def test_averagePlenumTemperature(self): self.assertEqual(averagePlenumTemp, self.assembly.getAveragePlenumTemperature()) def test_rotate(self): - """Test rotation of an assembly spatial objects.""" + """Test rotation of an assembly spatial objects. + + .. test:: An assembly can be rotated about its z-axis. + :id: T_ARMI_SHUFFLE_ROTATE + :tests: R_ARMI_SHUFFLE_ROTATE + """ a = makeTestAssembly(1, 1) b = blocks.HexBlock("TestBlock") b.p.THcornTemp = [400, 450, 500, 550, 600, 650] @@ -1062,6 +1105,49 @@ def test_rotate(self): a.rotate(math.radians(120)) self.assertIn("No rotation method defined", mock.getStdout()) + def test_assem_block_types(self): + """Test that all children of an assembly are blocks, ordered from top to bottom. + + .. test:: Validate child types of assembly are blocks, ordered from top to bottom. + :id: T_ARMI_ASSEM_BLOCKS + :tests: R_ARMI_ASSEM_BLOCKS + """ + coords = [] + for b in self.assembly.getBlocks(): + # Confirm children are blocks + self.assertIsInstance(b, blocks.Block) + + # get coords from the child blocks + coords.append(b.getLocation()) + + # get the Z-coords for each block + zCoords = [int(c.split("-")[-1]) for c in coords] + + # verify the blocks are ordered top-to-bottom, vertically + for i in range(1, len(zCoords)): + self.assertGreater(zCoords[i], zCoords[i - 1]) + + def test_assem_hex_type(self): + """Test that all children of a hex assembly are hexagons.""" + for b in self.assembly.getBlocks(): + + # For a hex assem, confirm they are of type "Hexagon" + pitch_comp_type = b.PITCH_COMPONENT_TYPE[0] + self.assertEqual(pitch_comp_type.__name__, "Hexagon") + + def test_getBIndexFromZIndex(self): + # make sure the axMesh parameters are set in our test block + for b in self.assembly: + b.p.axMesh = 1 + + for zIndex in range(6): + bIndex = self.assembly.getBIndexFromZIndex(zIndex * 0.5) + self.assertEqual(bIndex, math.ceil(zIndex / 2) if zIndex < 5 else -1) + + def test_getElevationBoundariesByBlockType(self): + elevations = self.assembly.getElevationBoundariesByBlockType() + self.assertEqual(elevations, [0.0, 10.0, 10.0, 20.0, 20.0, 30.0]) + class AssemblyInReactor_TestCase(unittest.TestCase): def setUp(self): @@ -1073,9 +1159,8 @@ def test_snapAxialMeshToReferenceConservingMassBasedOnBlockIgniter(self): grid = self.r.core.spatialGrid - ################################ - # examine mass change in igniterFuel - ################################ + # 1. examine mass change in igniterFuel + igniterFuel = self.r.core.childrenByLocator[grid[0, 0, 0]] # gridplate, fuel, fuel, fuel, plenum b = igniterFuel[0] @@ -1101,9 +1186,8 @@ def test_snapAxialMeshToReferenceConservingMassBasedOnBlockIgniter(self): for a in self.r.core.getAssemblies(): a.setBlockMesh(refMesh, conserveMassFlag="auto") - ############################# - # check igniter mass after expansion - ############################# + # 2. check igniter mass after expansion + # gridplate, fuel, fuel, fuel, plenum b = igniterFuel[0] coolantNucs = b.getComponent(Flags.COOLANT).getNuclides() @@ -1134,9 +1218,8 @@ def test_snapAxialMeshToReferenceConservingMassBasedOnBlockIgniter(self): for a in self.r.core.getAssemblies(): a.setBlockMesh(originalMesh, conserveMassFlag="auto") - ############################# - # check igniter mass after shrink to original - ############################# + # 3. check igniter mass after shrink to original + # gridplate, fuel, fuel, fuel, plenum b = igniterFuel[0] coolantNucs = b.getComponent(Flags.COOLANT).getNuclides() @@ -1172,9 +1255,8 @@ def test_snapAxialMeshToReferenceConservingMassBasedOnBlockShield(self): grid = self.r.core.spatialGrid i, j = grid.getIndicesFromRingAndPos(9, 2) - ################################ - # examine mass change in radial shield - ################################ + # 1. examine mass change in radial shield + a = self.r.core.childrenByLocator[grid[i, j, 0]] # gridplate, axial shield, axial shield, axial shield, plenum b = a[0] @@ -1202,9 +1284,8 @@ def test_snapAxialMeshToReferenceConservingMassBasedOnBlockShield(self): for a in self.r.core.getAssemblies(): a.setBlockMesh(refMesh, conserveMassFlag="auto") - ################################ - # examine mass change in radial shield after expansion - ################################ + # 2. examine mass change in radial shield after expansion + # gridplate, axial shield, axial shield, axial shield, plenum b = a[0] coolantNucs = b.getComponent(Flags.COOLANT).getNuclides() @@ -1242,9 +1323,8 @@ def test_snapAxialMeshToReferenceConservingMassBasedOnBlockShield(self): for a in self.r.core.getAssemblies(): a.setBlockMesh(originalMesh, conserveMassFlag="auto") - ################################ - # examine mass change in radial shield after shrink to original - ################################ + # 3. examine mass change in radial shield after shrink to original + # gridplate, axial shield, axial shield, axial shield, plenum b = a[0] coolantNucs = b.getComponent(Flags.COOLANT).getNuclides() diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py index 3d1a0135d..ff61a120f 100644 --- a/armi/reactor/tests/test_blocks.py +++ b/armi/reactor/tests/test_blocks.py @@ -23,7 +23,6 @@ from armi import materials, runLog, settings, tests from armi.reactor import blueprints -from armi.reactor.blueprints.tests.test_blockBlueprints import FULL_BP from armi.reactor.components import basicShapes, complexShapes from armi.nucDirectory import nucDir, nuclideBases from armi.nuclearDataIO.cccc import isotxs @@ -306,7 +305,9 @@ def getComponentData(component): class TestDetailedNDensUpdate(unittest.TestCase): - def setUp(self): + def test_updateDetailedNdens(self): + from armi.reactor.blueprints.tests.test_blockBlueprints import FULL_BP + cs = settings.Settings() with io.StringIO(FULL_BP) as stream: bps = blueprints.Blueprints.load(stream) @@ -317,7 +318,6 @@ def setUp(self): a.add(buildSimpleFuelBlock()) self.r.core.add(a) - def test_updateDetailedNdens(self): # get first block in assembly with 'fuel' key block = self.r.core[0][0] # get nuclides in first component in block @@ -436,7 +436,7 @@ def test_setType(self): self.assertFalse(self.block.hasFlags(Flags.IGNITER | Flags.FUEL)) def test_duplicate(self): - Block2 = blocks.Block._createHomogenizedCopy(self.block) + Block2 = blocks.Block.createHomogenizedCopy(self.block) originalComponents = self.block.getComponents() newComponents = Block2.getComponents() for c1, c2 in zip(originalComponents, newComponents): @@ -473,6 +473,13 @@ def test_duplicate(self): self.assertEqual(self.block.p.flags, Block2.p.flags) def test_homogenizedMixture(self): + """ + Confirms homogenized blocks have correct properties. + + .. test:: Homogenize the compositions of a block. + :id: T_ARMI_BLOCK_HOMOG + :tests: R_ARMI_BLOCK_HOMOG + """ args = [False, True] # pinSpatialLocator argument expectedShapes = [ [basicShapes.Hexagon], @@ -480,7 +487,7 @@ def test_homogenizedMixture(self): ] for arg, shapes in zip(args, expectedShapes): - homogBlock = self.block._createHomogenizedCopy(pinSpatialLocators=arg) + homogBlock = self.block.createHomogenizedCopy(pinSpatialLocators=arg) for shapeType in shapes: for c in homogBlock.getComponents(): if isinstance(c, shapeType): @@ -843,6 +850,13 @@ def test_adjustUEnrich(self): self.assertAlmostEqual(cur, ref, places=places) def test_setLocation(self): + """ + Retrieve a blocks location. + + .. test:: Location of a block is retrievable. + :id: T_ARMI_BLOCK_POSI0 + :tests: R_ARMI_BLOCK_POSI + """ b = self.block # a bit obvious, but location is a property now... i, j = grids.HexGrid.getIndicesFromRingAndPos(2, 3) @@ -1223,13 +1237,26 @@ def test_getComponentsOfMaterial(self): ) def test_getComponentByName(self): + """Test children by name. + + .. test:: Get children by name. + :id: T_ARMI_CMP_BY_NAME0 + :tests: R_ARMI_CMP_BY_NAME + """ self.assertIsNone( self.block.getComponentByName("not the droid youre looking for") ) self.assertIsNotNone(self.block.getComponentByName("annular void")) - def test_getSortedComponentsInsideOfComponent(self): - """Test that components can be sorted within a block and returned in the correct order.""" + def test_getSortedComponentsInsideOfComponentClad(self): + """Test that components can be sorted within a block and returned in the correct order. + + For an arbitrary example: a clad component. + + .. test:: Get children by name. + :id: T_ARMI_CMP_BY_NAME1 + :tests: R_ARMI_CMP_BY_NAME + """ expected = [ self.block.getComponentByName(c) for c in [ @@ -1247,7 +1274,11 @@ def test_getSortedComponentsInsideOfComponent(self): actual = self.block.getSortedComponentsInsideOfComponent(clad) self.assertListEqual(actual, expected) - def test_getSortedComponentsInsideOfComponentSpecifiedTypes(self): + def test_getSortedComponentsInsideOfComponentDuct(self): + """Test that components can be sorted within a block and returned in the correct order. + + For an arbitrary example: a duct. + """ expected = [ self.block.getComponentByName(c) for c in [ @@ -1259,9 +1290,12 @@ def test_getSortedComponentsInsideOfComponentSpecifiedTypes(self): "gap2", "outer liner", "gap3", + "clad", + "wire", + "coolant", ] ] - clad = self.block.getComponent(Flags.CLAD) + clad = self.block.getComponent(Flags.DUCT) actual = self.block.getSortedComponentsInsideOfComponent(clad) self.assertListEqual(actual, expected) @@ -1275,6 +1309,12 @@ def test_getNumComponents(self): self.assertEqual(1, self.block.getNumComponents(Flags.DUCT)) def test_getNumPins(self): + """Test that we can get the number of pins from various blocks. + + .. test:: Retrieve the number of pins from various blocks. + :id: T_ARMI_BLOCK_NPINS + :tests: R_ARMI_BLOCK_NPINS + """ cur = self.block.getNumPins() ref = self.block.getDim(Flags.FUEL, "mult") self.assertEqual(cur, ref) @@ -1534,6 +1574,28 @@ def test_setPitch(self): moles3 = b.p.molesHmBOL self.assertAlmostEqual(moles2, moles3) + def test_setImportantParams(self): + """Confirm that important block parameters can be set and get.""" + # Test ability to set and get flux + applyDummyData(self.block) + self.assertEqual(self.block.p.mgFlux[0], 161720716762.12997) + self.assertEqual(self.block.p.mgFlux[-1], 601494405.293505) + + # Test ability to set and get number density + fuel = self.block.getComponent(Flags.FUEL) + + u235_dens = fuel.getNumberDensity("U235") + self.assertEqual(u235_dens, 0.003695461770836022) + + fuel.setNumberDensity("U235", 0.5) + u235_dens = fuel.getNumberDensity("U235") + self.assertEqual(u235_dens, 0.5) + + # TH parameter test + self.assertEqual(0, self.block.p.THmassFlowRate) + self.block.p.THmassFlowRate = 10 + self.assertEqual(10, self.block.p.THmassFlowRate) + def test_getMfp(self): """Test mean free path.""" applyDummyData(self.block) @@ -1718,7 +1780,7 @@ def test_getReactionRates(self): ) -class Test_NegativeVolume(unittest.TestCase): +class TestNegativeVolume(unittest.TestCase): def test_negativeVolume(self): """Build a block with WAY too many fuel pins and show that the derived volume is negative.""" block = blocks.HexBlock("TestHexBlock") @@ -1783,12 +1845,52 @@ def setUp(self): r.core.add(a, loc1) def test_getArea(self): - cur = self.HexBlock.getArea() - ref = math.sqrt(3) / 2.0 * 70.6**2 - places = 6 - self.assertAlmostEqual(cur, ref, places=places) + """Test that we can correctly calculate the area of a hexagonal block. + + .. test:: Users can create blocks that have the correct hexagonal area. + :id: T_ARMI_BLOCK_HEX0 + :tests: R_ARMI_BLOCK_HEX + """ + # Test for various outer and inner pitches for HexBlocks with hex holes + for op in (20.0, 20.4, 20.1234, 25.001): + for ip in (0.0, 5.0001, 7.123, 10.0): + # generate a block with a different outer pitch + hBlock = blocks.HexBlock("TestAreaHexBlock") + hexDims = { + "Tinput": 273.0, + "Thot": 273.0, + "op": op, + "ip": ip, + "mult": 1.0, + } + hComponent = components.Hexagon("duct", "UZr", **hexDims) + hBlock.add(hComponent) + + # verify the area of the hexagon (with a hex hole) is correct + cur = hBlock.getArea() + ref = math.sqrt(3) / 2.0 * op**2 + ref -= math.sqrt(3) / 2.0 * ip**2 + self.assertAlmostEqual(cur, ref, places=6, msg=str(op)) + + def test_component_type(self): + """ + Test that a hex block has the proper "hexagon" __name__. + + .. test:: Users can create blocks with a hexagonal shape. + :id: T_ARMI_BLOCK_HEX1 + :tests: R_ARMI_BLOCK_HEX + """ + pitch_comp_type = self.HexBlock.PITCH_COMPONENT_TYPE[0] + self.assertEqual(pitch_comp_type.__name__, "Hexagon") def test_coords(self): + """ + Test that coordinates are retrievable from a block. + + .. test:: Coordinates of a block are queryable. + :id: T_ARMI_BLOCK_POSI1 + :tests: R_ARMI_BLOCK_POSI + """ r = self.HexBlock.r a = self.HexBlock.parent loc1 = r.core.spatialGrid[0, 1, 0] @@ -1812,6 +1914,28 @@ def test_coords(self): def test_getNumPins(self): self.assertEqual(self.HexBlock.getNumPins(), 169) + def test_block_dims(self): + """ + Tests that the block class can provide basic dimensionality information about + itself. + + .. test:: Important block dimensions are retrievable. + :id: T_ARMI_BLOCK_DIMS + :tests: R_ARMI_BLOCK_DIMS + """ + self.assertAlmostEqual(4316.582, self.HexBlock.getVolume(), 3) + self.assertAlmostEqual(70.6, self.HexBlock.getPitch(), 1) + self.assertAlmostEqual(4316.582, self.HexBlock.getMaxArea(), 3) + + self.assertEqual(70, self.HexBlock.getDuctIP()) + self.assertEqual(70.6, self.HexBlock.getDuctOP()) + + self.assertAlmostEqual(34.273, self.HexBlock.getPinToDuctGap(), 3) + self.assertEqual(0.11, self.HexBlock.getPinPitch()) + self.assertAlmostEqual(300.889, self.HexBlock.getWettedPerimeter(), 3) + self.assertAlmostEqual(4242.184, self.HexBlock.getFlowArea(), 3) + self.assertAlmostEqual(56.395, self.HexBlock.getHydraulicDiameter(), 3) + def test_symmetryFactor(self): # full hex self.HexBlock.spatialLocator = self.HexBlock.r.core.spatialGrid[2, 0, 0] @@ -1839,7 +1963,7 @@ def test_retainState(self): with self.HexBlock.retainState(): self.HexBlock.setType("fuel") self.HexBlock.spatialGrid.changePitch(2.0) - self.assertEqual(self.HexBlock.spatialGrid.pitch, 1.0) + self.assertAlmostEqual(self.HexBlock.spatialGrid.pitch, 1.0) self.assertTrue(self.HexBlock.hasFlags(Flags.INTERCOOLANT)) def test_getPinCoords(self): @@ -1961,6 +2085,13 @@ def test_getPinCenterFlatToFlat(self): self.assertAlmostEqual(pinCenterFlatToFlat, f2f) def test_gridCreation(self): + """Create a grid for a block, and show that it can handle components with + multiplicity > 1. + + .. test:: Grids can handle components with multiplicity > 1. + :id: T_ARMI_GRID_MULT + :tests: R_ARMI_GRID_MULT + """ b = self.HexBlock # The block should have a spatial grid at construction, # since it has mults = 1 or 169 from setup @@ -1971,9 +2102,18 @@ def test_gridCreation(self): # Then it's spatialLocator must be of size 169 locations = c.spatialLocator self.assertEqual(type(locations), grids.MultiIndexLocation) + mult = 0 - for _ in locations: + uniqueLocations = set() + for loc in locations: mult = mult + 1 + + # test for the uniqueness of the locations (since mult > 1) + if loc not in uniqueLocations: + uniqueLocations.add(loc) + else: + self.assertTrue(False, msg="Duplicate location found!") + self.assertEqual(mult, 169) def test_gridNumPinsAndLocations(self): diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py index 074b6e9cc..fdd031f43 100644 --- a/armi/reactor/tests/test_components.py +++ b/armi/reactor/tests/test_components.py @@ -44,6 +44,7 @@ ComponentType, ) from armi.reactor.components import materials +from armi.materials import air, alloy200 class TestComponentFactory(unittest.TestCase): @@ -74,6 +75,12 @@ def getCircleFuelDict(self): ) def test_factory(self): + """Creating and verifying void and fuel components. + + .. test:: Example void and fuel components are initialized. + :id: T_ARMI_COMP_DEF0 + :tests: R_ARMI_COMP_DEF + """ voidAttrs = self.getCircleVoidDict() voidComp = components.factory(voidAttrs.pop("shape"), [], voidAttrs) fuelAttrs = self.getCircleFuelDict() @@ -84,6 +91,12 @@ def test_factory(self): self.assertIsInstance(fuelComp.material, materials.UZr) def test_componentInitializationAndDuplication(self): + """Initialize and duplicate a component, veifying the parameters. + + .. test:: Verify the parameters of an initialized component. + :id: T_ARMI_COMP_DEF1 + :tests: R_ARMI_COMP_DEF + """ # populate the class/signature dict, and create a basis attrs attrs = self.getCircleVoidDict() del attrs["shape"] @@ -167,7 +180,13 @@ class TestComponent(TestGeneralComponents): componentCls = Component - def test_initializeComponent(self): + def test_initializeComponentMaterial(self): + """Creating component with single material. + + .. test:: Components are made of one material. + :id: T_ARMI_COMP_1MAT0 + :tests: R_ARMI_COMP_1MAT + """ expectedName = "TestComponent" actualName = self.component.getName() expectedMaterialName = "HT9" @@ -175,11 +194,62 @@ def test_initializeComponent(self): self.assertEqual(expectedName, actualName) self.assertEqual(expectedMaterialName, actualMaterialName) + def test_setNumberDensity(self): + """Test setting a single number density. + + .. test:: Users can set Component number density. + :id: T_ARMI_COMP_NUCLIDE_FRACS0 + :tests: R_ARMI_COMP_NUCLIDE_FRACS + """ + component = self.component + self.assertAlmostEqual(component.getNumberDensity("C"), 0.000780, 6) + component.setNumberDensity("C", 0.57) + self.assertEqual(component.getNumberDensity("C"), 0.57) + + def test_setNumberDensities(self): + """Test setting multiple number densities. + + .. test:: Users can set Component number densities. + :id: T_ARMI_COMP_NUCLIDE_FRACS1 + :tests: R_ARMI_COMP_NUCLIDE_FRACS + """ + component = self.component + self.assertAlmostEqual(component.getNumberDensity("MN"), 0.000426, 6) + component.setNumberDensities({"C": 1, "MN": 0.58}) + self.assertEqual(component.getNumberDensity("C"), 1.0) + self.assertEqual(component.getNumberDensity("MN"), 0.58) + + def test_solid_material(self): + """Determine if material is solid. + + .. test:: Determine if material is solid. + :id: T_ARMI_COMP_SOLID + :tests: R_ARMI_COMP_SOLID + + .. test:: Components have material properties. + :id: T_ARMI_COMP_MAT + :tests: R_ARMI_COMP_MAT + """ + self.assertTrue(isinstance(self.component.getProperties(), Material)) + self.assertTrue(hasattr(self.component.material, "density")) + self.assertIn("HT9", str(self.component.getProperties())) + + self.component.material = air.Air() + self.assertFalse(self.component.containsSolidMaterial()) + + self.component.material = alloy200.Alloy200() + self.assertTrue(self.component.containsSolidMaterial()) + + self.assertTrue(isinstance(self.component.getProperties(), Material)) + self.assertTrue(hasattr(self.component.material, "density")) + self.assertIn("Alloy200", str(self.component.getProperties())) + class TestNullComponent(TestGeneralComponents): componentCls = NullComponent def test_cmp(self): + """Test null component.""" cur = self.component ref = DerivedShape("DerivedShape", "Material", 0, 0) self.assertLess(cur, ref) @@ -190,7 +260,14 @@ def test_nonzero(self): self.assertEqual(cur, ref) def test_getDimension(self): - self.assertEqual(self.component.getDimension(""), 0.0) + """Test getting empty component. + + .. test:: Retrieve a null dimension. + :id: T_ARMI_COMP_DIMS0 + :tests: R_ARMI_COMP_DIMS + """ + for temp in range(400, 901, 25): + self.assertEqual(self.component.getDimension("", Tc=temp), 0.0) class TestUnshapedComponent(TestGeneralComponents): @@ -247,6 +324,28 @@ def test_getBoundingCircleOuterDiameter(self): * self.component.getThermalExpansionFactor(self.component.temperatureInC), ) + def test_component_less_than(self): + """Ensure that comparisons between components properly reference bounding circle outer diameter. + + .. test:: Order components by their outermost diameter + :id: T_ARMI_COMP_ORDER + :tests: R_ARMI_COMP_ORDER + """ + componentCls = UnshapedComponent + componentMaterial = "HT9" + + smallDims = {"Tinput": 25.0, "Thot": 430.0, "area": 0.5 * math.pi} + sameDims = {"Tinput": 25.0, "Thot": 430.0, "area": 1.0 * math.pi} + bigDims = {"Tinput": 25.0, "Thot": 430.0, "area": 2.0 * math.pi} + + smallComponent = componentCls("TestComponent", componentMaterial, **smallDims) + sameComponent = componentCls("TestComponent", componentMaterial, **sameDims) + bigComponent = componentCls("TestComponent", componentMaterial, **bigDims) + + self.assertTrue(smallComponent < self.component) + self.assertFalse(bigComponent < self.component) + self.assertFalse(sameComponent < self.component) + def test_fromComponent(self): circle = components.Circle("testCircle", "HT9", 25, 500, 1.0) unshaped = components.UnshapedComponent.fromComponent(circle) @@ -285,12 +384,20 @@ def test_preserveMassDuringThermalExpansion(self): ) def test_volumeAfterClearCache(self): + """ + Test volume after cache has been cleared. + + .. test:: Clear cache after a dimensions updated. + :id: T_ARMI_COMP_VOL0 + :tests: R_ARMI_COMP_VOL + """ c = UnshapedVolumetricComponent("testComponent", "Custom", 0, 0, volume=1) self.assertAlmostEqual(c.getVolume(), 1, 6) c.clearCache() self.assertAlmostEqual(c.getVolume(), 1, 6) def test_densityConsistent(self): + """Testing the Component matches quick hand calc.""" c = self.component # no volume defined @@ -350,8 +457,34 @@ def test_getBoundingCircleOuterDiameter(self): self.component.getBoundingCircleOuterDiameter(cold=True), 0.0 ) + def test_computeVolume(self): + """Test the computeVolume method on a number of components in a block. + + .. test:: Compute the volume of a DerivedShape inside solid shapes. + :id: T_ARMI_COMP_FLUID + :tests: R_ARMI_COMP_FLUID + """ + from armi.reactor.tests.test_blocks import buildSimpleFuelBlock + + # Calculate the total volume of the block + b = buildSimpleFuelBlock() + totalVolume = b.getVolume() + + # calculate the total volume by adding up all the components + c = b.getComponent(flags.Flags.COOLANT) + totalByParts = 0 + for co in b.getComponents(): + totalByParts += co.computeVolume() + + self.assertAlmostEqual(totalByParts, totalVolume) + + # test the computeVolume method on the one DerivedShape in thi block + self.assertAlmostEqual(c.computeVolume(), 1386.5232044586771) + class TestCircle(TestShapedComponent): + """Test circle shaped component.""" + componentCls = Circle _id = 5.0 _od = 10 @@ -365,7 +498,12 @@ class TestCircle(TestShapedComponent): } def test_getThermalExpansionFactorConservedMassByLinearExpansionPercent(self): - """Test that when ARMI thermally expands a circle, mass is conserved.""" + """Test that when ARMI thermally expands a circle, mass is conserved. + + .. test:: Calculate thermal expansion. + :id: T_ARMI_COMP_EXPANSION0 + :tests: R_ARMI_COMP_EXPANSION + """ hotTemp = 700.0 dLL = self.component.material.linearExpansionFactor( Tc=hotTemp, T0=self._coldTemp @@ -375,10 +513,20 @@ def test_getThermalExpansionFactorConservedMassByLinearExpansionPercent(self): self.assertAlmostEqual(cur, ref) def test_getDimension(self): - hotTemp = 700.0 - ref = self._od * self.component.getThermalExpansionFactor(Tc=hotTemp) - cur = self.component.getDimension("od", Tc=hotTemp) - self.assertAlmostEqual(cur, ref) + """Test getting component dimension at specific temperature. + + .. test:: Retrieve a dimension at a temperature. + :id: T_ARMI_COMP_DIMS1 + :tests: R_ARMI_COMP_DIMS + + .. test:: Calculate thermal expansion. + :id: T_ARMI_COMP_EXPANSION1 + :tests: R_ARMI_COMP_EXPANSION + """ + for hotTemp in range(600, 901, 25): + ref = self._od * self.component.getThermalExpansionFactor(Tc=hotTemp) + cur = self.component.getDimension("od", Tc=hotTemp) + self.assertAlmostEqual(cur, ref) def test_thermallyExpands(self): """Test that ARMI can thermally expands a circle.""" @@ -401,6 +549,13 @@ def test_dimensionThermallyExpands(self): self.assertEqual(cur, ref[i]) def test_getArea(self): + """Calculate area of circle. + + .. test:: Calculate area of circle. + :id: T_ARMI_COMP_VOL1 + :tests: R_ARMI_COMP_VOL + """ + # show we can calculate the area once od = self.component.getDimension("od") idd = self.component.getDimension("id") mult = self.component.getDimension("mult") @@ -408,8 +563,35 @@ def test_getArea(self): cur = self.component.getArea() self.assertAlmostEqual(cur, ref) + # show we can clear the cache, change the temp, and correctly re-calc the area + for newTemp in range(500, 690, 19): + self.component.clearCache() + + # re-calc area + self.component.temperatureInC = newTemp + od = self.component.getDimension("od", Tc=newTemp) + idd = self.component.getDimension("id", Tc=newTemp) + ref = math.pi * ((od / 2) ** 2 - (idd / 2) ** 2) * mult + cur = self.component.getArea() + self.assertAlmostEqual(cur, ref) + def test_componentInteractionsLinkingByDimensions(self): - r"""Tests linking of components by dimensions.""" + """Tests linking of Components by dimensions. + + .. test:: Show the dimensions of a liquid Component can be defined to depend on the solid Components that bound it. + :id: T_ARMI_COMP_FLUID1 + :tests: R_ARMI_COMP_FLUID + + The component ``gap``, representing the fuel-clad gap filled with Void, + is defined with dimensions that depend on the fuel outer diameter and + clad inner diameter. The + :py:meth:`~armi.reactor.components.component.Component.resolveLinkedDims` + method links the gap dimensions appropriately when the Component is + constructed, and the test shows the area of the gap is calculated + correctly based on the thermally-expanded dimensions of the fuel and + clad Components. + + """ nPins = 217 fuelDims = {"Tinput": 25.0, "Thot": 430.0, "od": 0.9, "id": 0.0, "mult": nPins} cladDims = {"Tinput": 25.0, "Thot": 430.0, "od": 1.1, "id": 1.0, "mult": nPins} @@ -450,7 +632,7 @@ def test_badComponentName(self): _gap = Circle("gap", "Void", **gapDims) def test_componentInteractionsLinkingBySubtraction(self): - r"""Tests linking of components by subtraction.""" + """Tests linking of components by subtraction.""" nPins = 217 gapDims = {"Tinput": 25.0, "Thot": 430.0, "od": 1.0, "id": 0.9, "mult": nPins} gap = Circle("gap", "Void", **gapDims) @@ -676,6 +858,8 @@ def expansionConservationColdHeightDefined(self, mat: str): class TestTriangle(TestShapedComponent): + """Test triangle shaped component.""" + componentCls = Triangle componentDims = { "Tinput": 25.0, @@ -686,6 +870,16 @@ class TestTriangle(TestShapedComponent): } def test_getArea(self): + """Calculate area of triangle. + + .. test:: Calculate area of triangle. + :id: T_ARMI_COMP_VOL2 + :tests: R_ARMI_COMP_VOL + + .. test:: Triangle shaped component + :id: T_ARMI_COMP_SHAPES1 + :tests: R_ARMI_COMP_SHAPES + """ b = self.component.getDimension("base") h = self.component.getDimension("height") mult = self.component.getDimension("mult") @@ -706,6 +900,8 @@ def test_dimensionThermallyExpands(self): class TestRectangle(TestShapedComponent): + """Test rectangle shaped component.""" + componentCls = Rectangle componentDims = { "Tinput": 25.0, @@ -738,15 +934,34 @@ def test_negativeArea(self): negativeRectangle.getArea() def test_getBoundingCircleOuterDiameter(self): + """Get outer diameter bounding circle. + + .. test:: Rectangle shaped component + :id: T_ARMI_COMP_SHAPES2 + :tests: R_ARMI_COMP_SHAPES + """ ref = math.sqrt(61.0) cur = self.component.getBoundingCircleOuterDiameter(cold=True) self.assertAlmostEqual(ref, cur) + # verify the area of the rectangle is correct + ref = self.componentDims["lengthOuter"] * self.componentDims["widthOuter"] + ref -= self.componentDims["lengthInner"] * self.componentDims["widthInner"] + ref *= self.componentDims["mult"] + cur = self.component.getArea(cold=True) + self.assertAlmostEqual(cur, ref) + def test_getCircleInnerDiameter(self): cur = self.component.getCircleInnerDiameter(cold=True) self.assertAlmostEqual(math.sqrt(25.0), cur) def test_getArea(self): + """Calculate area of rectangle. + + .. test:: Calculate area of rectangle. + :id: T_ARMI_COMP_VOL3 + :tests: R_ARMI_COMP_VOL + """ outerL = self.component.getDimension("lengthOuter") innerL = self.component.getDimension("lengthInner") outerW = self.component.getDimension("widthOuter") @@ -785,11 +1000,18 @@ class TestSolidRectangle(TestShapedComponent): } def test_getBoundingCircleOuterDiameter(self): + """Test get bounding circle of the outer diameter.""" ref = math.sqrt(50) cur = self.component.getBoundingCircleOuterDiameter(cold=True) self.assertAlmostEqual(ref, cur) def test_getArea(self): + """Calculate area of solid rectangle. + + .. test:: Calculate area of solid rectangle. + :id: T_ARMI_COMP_VOL4 + :tests: R_ARMI_COMP_VOL + """ outerL = self.component.getDimension("lengthOuter") outerW = self.component.getDimension("widthOuter") mult = self.component.getDimension("mult") @@ -810,6 +1032,8 @@ def test_dimensionThermallyExpands(self): class TestSquare(TestShapedComponent): + """Test square shaped component.""" + componentCls = Square componentDims = { "Tinput": 25.0, @@ -838,16 +1062,36 @@ def test_negativeArea(self): negativeRectangle.getArea() def test_getBoundingCircleOuterDiameter(self): + """Get bounding circle outer diameter. + + .. test:: Square shaped component + :id: T_ARMI_COMP_SHAPES3 + :tests: R_ARMI_COMP_SHAPES + """ ref = math.sqrt(18.0) cur = self.component.getBoundingCircleOuterDiameter(cold=True) self.assertAlmostEqual(ref, cur) + # verify the area of the circle is correct + ref = ( + self.componentDims["widthOuter"] ** 2 + - self.componentDims["widthInner"] ** 2 + ) + cur = self.component.getComponentArea(cold=True) + self.assertAlmostEqual(cur, ref) + def test_getCircleInnerDiameter(self): ref = math.sqrt(8.0) cur = self.component.getCircleInnerDiameter(cold=True) self.assertAlmostEqual(ref, cur) def test_getArea(self): + """Calculate area of square. + + .. test:: Calculate area of square. + :id: T_ARMI_COMP_VOL5 + :tests: R_ARMI_COMP_VOL + """ outerW = self.component.getDimension("widthOuter") innerW = self.component.getDimension("widthInner") mult = self.component.getDimension("mult") @@ -904,6 +1148,12 @@ def test_negativeVolume(self): negativeCube.getVolume() def test_getVolume(self): + """Calculate area of cube. + + .. test:: Calculate area of cube. + :id: T_ARMI_COMP_VOL6 + :tests: R_ARMI_COMP_VOL + """ lengthO = self.component.getDimension("lengthOuter") widthO = self.component.getDimension("widthOuter") heightO = self.component.getDimension("heightOuter") @@ -921,10 +1171,18 @@ def test_thermallyExpands(self): class TestHexagon(TestShapedComponent): + """Test hexagon shaped component.""" + componentCls = Hexagon componentDims = {"Tinput": 25.0, "Thot": 430.0, "op": 10.0, "ip": 5.0, "mult": 1} def test_getPerimeter(self): + """Get perimeter of hexagon. + + .. test:: Hexagon shaped component + :id: T_ARMI_COMP_SHAPES4 + :tests: R_ARMI_COMP_SHAPES + """ ip = self.component.getDimension("ip") mult = self.component.getDimension("mult") ref = 6 * (ip / math.sqrt(3)) * mult @@ -942,6 +1200,12 @@ def test_getCircleInnerDiameter(self): self.assertAlmostEqual(ref, cur) def test_getArea(self): + """Calculate area of hexagon. + + .. test:: Calculate area of hexagon. + :id: T_ARMI_COMP_VOL7 + :tests: R_ARMI_COMP_VOL + """ cur = self.component.getArea() mult = self.component.getDimension("mult") op = self.component.getDimension("op") @@ -962,6 +1226,8 @@ def test_dimensionThermallyExpands(self): class TestHoledHexagon(TestShapedComponent): + """Test holed hexagon shaped component.""" + componentCls = HoledHexagon componentDims = { "Tinput": 25.0, @@ -998,6 +1264,12 @@ def test_getCircleInnerDiameter(self): ) def test_getArea(self): + """Calculate area of holed hexagon. + + .. test:: Calculate area of holed hexagon. + :id: T_ARMI_COMP_VOL8 + :tests: R_ARMI_COMP_VOL + """ op = self.component.getDimension("op") odHole = self.component.getDimension("holeOD") nHoles = self.component.getDimension("nHoles") @@ -1045,6 +1317,12 @@ def test_getCircleInnerDiameter(self): ) def test_getArea(self): + """Calculate area of hex holed circle. + + .. test:: Calculate area of hex holed circle. + :id: T_ARMI_COMP_VOL9 + :tests: R_ARMI_COMP_VOL + """ od = self.component.getDimension("od") holeOP = self.component.getDimension("holeOP") mult = self.component.getDimension("mult") @@ -1102,6 +1380,12 @@ def test_getCircleInnerDiameter(self): self.assertEqual(ref, cur) def test_getArea(self): + """Calculate area of holed rectangle. + + .. test:: Calculate area of holed rectangle. + :id: T_ARMI_COMP_VOL10 + :tests: R_ARMI_COMP_VOL + """ rectArea = self.length * self.width odHole = self.component.getDimension("holeOD") mult = self.component.getDimension("mult") @@ -1122,6 +1406,7 @@ def test_dimensionThermallyExpands(self): class TestHoledSquare(TestHoledRectangle): + """Test holed square shaped component.""" componentCls = HoledSquare @@ -1149,6 +1434,8 @@ def test_getCircleInnerDiameter(self): class TestHelix(TestShapedComponent): + """Test helix shaped component.""" + componentCls = Helix componentDims = { "Tinput": 25.0, @@ -1171,6 +1458,12 @@ def test_getCircleInnerDiameter(self): self.assertAlmostEqual(ref, cur) def test_getArea(self): + """Calculate area of helix. + + .. test:: Calculate area of helix. + :id: T_ARMI_COMP_VOL11 + :tests: R_ARMI_COMP_VOL + """ cur = self.component.getArea() axialPitch = self.component.getDimension("axialPitch") helixDiameter = self.component.getDimension("helixDiameter") @@ -1250,6 +1543,12 @@ class TestSphere(TestShapedComponent): componentDims = {"Tinput": 25.0, "Thot": 430.0, "od": 1.0, "id": 0.0, "mult": 3} def test_getVolume(self): + """Calculate area of sphere. + + .. test:: Calculate volume of sphere. + :id: T_ARMI_COMP_VOL12 + :tests: R_ARMI_COMP_VOL + """ od = self.component.getDimension("od") idd = self.component.getDimension("id") mult = self.component.getDimension("mult") @@ -1319,6 +1618,13 @@ def test_getVolume(self): self.assertAlmostEqual(cur, ref) def test_updateDims(self): + """ + Test Update dimensions. + + .. test:: Dimensions can be updated. + :id: T_ARMI_COMP_VOL13 + :tests: R_ARMI_COMP_VOL + """ self.assertEqual(self.component.getDimension("inner_radius"), 110) self.assertEqual(self.component.getDimension("radius_differential"), 60) self.component.updateDims() diff --git a/armi/reactor/tests/test_composites.py b/armi/reactor/tests/test_composites.py index 6094b4010..bd5c65d6b 100644 --- a/armi/reactor/tests/test_composites.py +++ b/armi/reactor/tests/test_composites.py @@ -39,7 +39,9 @@ class MockBP: allNuclidesInProblem = set(nuclideBases.byName.keys()) + """:meta hide-value:""" activeNuclides = allNuclidesInProblem + """:meta hide-value:""" inactiveNuclides = set() elementsToExpand = set() customIsotopics = {} @@ -108,7 +110,13 @@ def setUp(self): container.add(nested) self.container = container - def test_Composite(self): + def test_composite(self): + """Test basic Composite things. + + .. test:: Composites are part of a hierarchical model. + :id: T_ARMI_CMP0 + :tests: R_ARMI_CMP + """ container = self.container children = container.getChildren() @@ -122,6 +130,12 @@ def test_iterComponents(self): self.assertIn(self.thirdGen, list(self.container.iterComponents())) def test_getChildren(self): + """Test the get children method. + + .. test:: Composites are part of a hierarchical model. + :id: T_ARMI_CMP1 + :tests: R_ARMI_CMP + """ # There are 5 leaves and 1 composite in container. The composite has one leaf. firstGen = self.container.getChildren() self.assertEqual(len(firstGen), 6) @@ -139,6 +153,18 @@ def test_getChildren(self): ) self.assertEqual(len(onlyLiner), 1) + def test_getName(self): + """Test the getName method. + + .. test:: Composites names should be accessible. + :id: T_ARMI_CMP_GET_NAME + :tests: R_ARMI_CMP_GET_NAME + """ + self.assertEqual(self.secondGen.getName(), "liner") + self.assertEqual(self.thirdGen.getName(), "pin 77") + self.assertEqual(self.secondGen.getName(), "liner") + self.assertEqual(self.container.getName(), "inner test fuel") + def test_sort(self): # in this case, the children should start sorted c0 = [c.name for c in self.container.getChildren()] @@ -216,6 +242,12 @@ def test_nucSpec(self): ) def test_hasFlags(self): + """Ensure flags are queryable. + + .. test:: Flags can be queried. + :id: T_ARMI_CMP_FLAG + :tests: R_ARMI_CMP_FLAG + """ self.container.setType("fuel") self.assertFalse(self.container.hasFlags(Flags.SHIELD | Flags.FUEL, exact=True)) self.assertTrue(self.container.hasFlags(Flags.FUEL)) @@ -514,6 +546,12 @@ def test_getFissileMass(self): self.assertAlmostEqual(cur, ref, places=places) def test_getMaxParam(self): + """Test getMaxParam(). + + .. test:: Composites have parameter collections. + :id: T_ARMI_CMP_PARAMS0 + :tests: R_ARMI_CMP_PARAMS + """ for ci, c in enumerate(self.Block): if isinstance(c, basicShapes.Circle): c.p.id = ci @@ -524,6 +562,12 @@ def test_getMaxParam(self): self.assertIs(comp, lastSeen) def test_getMinParam(self): + """Test getMinParam(). + + .. test:: Composites have parameter collections. + :id: T_ARMI_CMP_PARAMS1 + :tests: R_ARMI_CMP_PARAMS + """ for ci, c in reversed(list(enumerate(self.Block))): if isinstance(c, basicShapes.Circle): c.p.id = ci @@ -606,6 +650,12 @@ def setUp(self): self.obj = loadTestBlock() def test_setMass(self): + """Test setting and retrieving mass. + + .. test:: Mass of a composite is retrievable. + :id: T_ARMI_CMP_GET_MASS + :tests: R_ARMI_CMP_GET_MASS + """ masses = {"U235": 5.0, "U238": 3.0} self.obj.setMasses(masses) self.assertAlmostEqual(self.obj.getMass("U235"), 5.0) @@ -622,6 +672,90 @@ def test_setMass(self): group.setMass("U235", 5) self.assertAlmostEqual(group.getMass("U235"), 5) + # ad a second block, and confirm it works + group.add(loadTestBlock()) + self.assertGreater(group.getMass("U235"), 5) + self.assertAlmostEqual(group.getMass("U235"), 1364.28376185) + + def test_getNumberDensities(self): + """Get number densities from composite. + + .. test:: Number density of composite is retrievable. + :id: T_ARMI_CMP_GET_NDENS0 + :tests: R_ARMI_CMP_GET_NDENS + """ + # verify the number densities from the composite + ndens = self.obj.getNumberDensities() + self.assertAlmostEqual(0.0001096, ndens["SI"], 7) + self.assertAlmostEqual(0.0000368, ndens["W"], 7) + + ndens = self.obj.getNumberDensity("SI") + self.assertAlmostEqual(0.0001096, ndens, 7) + + # sum nuc densities from children components + totalVolume = self.obj.getVolume() + childDensities = {} + for o in self.obj.getChildren(): + m = o.getVolume() + d = o.getNumberDensities() + for nuc, val in d.items(): + if nuc not in childDensities: + childDensities[nuc] = val * (m / totalVolume) + else: + childDensities[nuc] += val * (m / totalVolume) + + # verify the children match this composite + for nuc in ["FE", "SI"]: + self.assertAlmostEqual( + self.obj.getNumberDensity(nuc), childDensities[nuc], 4, msg=nuc + ) + + def test_getNumberDensitiesWithExpandedFissionProducts(self): + """Get number densities from composite. + + .. test:: Get number densities. + :id: T_ARMI_CMP_NUC + :tests: R_ARMI_CMP_NUC + """ + # verify the number densities from the composite + ndens = self.obj.getNumberDensities(expandFissionProducts=True) + self.assertAlmostEqual(0.0001096, ndens["SI"], 7) + self.assertAlmostEqual(0.0000368, ndens["W"], 7) + + ndens = self.obj.getNumberDensity("SI") + self.assertAlmostEqual(0.0001096, ndens, 7) + + # set the lumped fission product mapping + fpd = getDummyLFPFile() + lfps = fpd.createLFPsFromFile() + self.obj.setLumpedFissionProducts(lfps) + + # sum nuc densities from children components + totalVolume = self.obj.getVolume() + childDensities = {} + for o in self.obj.getChildren(): + # get the number densities with and without fission products + d0 = o.getNumberDensities(expandFissionProducts=False) + d = o.getNumberDensities(expandFissionProducts=True) + + # prove that the expanded fission products have more isotopes + if len(d0) > 0: + self.assertGreater(len(d), len(d0)) + + # sum the child nuclide densites (weighted by mass fraction) + m = o.getVolume() + for nuc, val in d.items(): + if nuc not in childDensities: + childDensities[nuc] = val * (m / totalVolume) + else: + childDensities[nuc] += val * (m / totalVolume) + + # verify the children match this composite + for nuc in ["FE", "SI"]: + self.assertAlmostEqual( + self.obj.getNumberDensity(nuc), childDensities[nuc], 4, msg=nuc + ) + def test_dimensionReport(self): report = self.obj.setComponentDimensionsReport() self.assertEqual(len(report), len(self.obj)) diff --git a/armi/reactor/tests/test_flags.py b/armi/reactor/tests/test_flags.py index 01abc7f87..bbc4c1cb8 100644 --- a/armi/reactor/tests/test_flags.py +++ b/armi/reactor/tests/test_flags.py @@ -26,9 +26,17 @@ def test_fromString(self): self._help_fromString(flags.Flags.fromStringIgnoreErrors) self.assertEqual(flags.Flags.fromStringIgnoreErrors("invalid"), flags.Flags(0)) - def test_toString(self): + def test_flagsToAndFromString(self): + """ + Convert flag to and from string for serialization. + + .. test:: Convert flag to a string. + :id: T_ARMI_FLAG_TO_STR + :tests: R_ARMI_FLAG_TO_STR + """ f = flags.Flags.FUEL self.assertEqual(flags.Flags.toString(f), "FUEL") + self.assertEqual(f, flags.Flags.fromString("FUEL")) def test_fromStringStrict(self): self._help_fromString(flags.Flags.fromString) diff --git a/armi/reactor/tests/test_parameters.py b/armi/reactor/tests/test_parameters.py index 4449f3a0d..e2068e8ae 100644 --- a/armi/reactor/tests/test_parameters.py +++ b/armi/reactor/tests/test_parameters.py @@ -13,11 +13,8 @@ # limitations under the License. """Tests of the Parameters class.""" import copy -import traceback import unittest -from armi import context -from armi.reactor import composites from armi.reactor import parameters @@ -68,16 +65,19 @@ class Mock(parameters.ParameterCollection): def test_writeSomeParamsToDB(self): """ - This test tests the ability to specify which parameters should be + This tests the ability to specify which parameters should be written to the database. It assumes that the list returned by ParameterDefinitionCollection.toWriteToDB() is used to filter for which parameters to include in the database. - .. test:: Test to restrict some parameters from being written to the database. - :id: T_ARMI_RESTRICT_DB_WRITE - :links: R_ARMI_RESTRICT_DB_WRITE - """ + .. test:: Restrict parameters from DB write. + :id: T_ARMI_PARAM_DB + :tests: R_ARMI_PARAM_DB + .. test:: Ensure that new parameters can be defined. + :id: T_ARMI_PARAM + :tests: R_ARMI_PARAM + """ pDefs = parameters.ParameterDefinitionCollection() with pDefs.createBuilder() as pb: pb.defParam("write_me", "units", "description", "location", default=42) @@ -100,9 +100,9 @@ def test_serializer_pack_unpack(self): will be called during storage to and reading from the database. See database3._writeParams for an example use of this functionality. - .. test:: Tests for ability to serialize data to database in a custom manner. + .. test:: Custom parameter serializer :id: T_ARMI_PARAM_SERIALIZE - :links: R_ARMI_PARAM_SERIALIZER + :tests: R_ARMI_PARAM_SERIALIZE """ class TestSerializer(parameters.Serializer): @@ -214,6 +214,13 @@ class Mock(parameters.ParameterCollection): self.assertEqual("encapsulated", mock.noSetter) def test_setter(self): + """Test the Parameter setter() tooling, that signifies if a Parameter has been updated. + + .. test:: Tooling that allows a Parameter to signal it needs to be updated across processes. + :id: T_ARMI_PARAM_PARALLEL0 + :tests: R_ARMI_PARAM_PARALLEL + """ + class Mock(parameters.ParameterCollection): pDefs = parameters.ParameterDefinitionCollection() with pDefs.createBuilder() as pb: @@ -242,6 +249,7 @@ def nPlus1(self, value): print(mock.n) with self.assertRaises(parameters.ParameterError): print(mock.nPlus1) + mock.n = 15 self.assertEqual(15, mock.n) self.assertEqual(16, mock.nPlus1) @@ -249,9 +257,16 @@ def nPlus1(self, value): mock.nPlus1 = 22 self.assertEqual(21, mock.n) self.assertEqual(22, mock.nPlus1) - self.assertTrue(all(pd.assigned for pd in mock.paramDefs)) + self.assertTrue(all(pd.assigned != parameters.NEVER for pd in mock.paramDefs)) def test_setterGetterBasics(self): + """Test the Parameter setter/getter tooling, through the lifecycle of a Parameter being updated. + + .. test:: Tooling that allows a Parameter to signal it needs to be updated across processes. + :id: T_ARMI_PARAM_PARALLEL1 + :tests: R_ARMI_PARAM_PARALLEL + """ + class Mock(parameters.ParameterCollection): pDefs = parameters.ParameterDefinitionCollection() with pDefs.createBuilder() as pb: @@ -373,7 +388,8 @@ class MockPCChild(MockPCParent): _ = MockPCChild() - # same name along a different branch from the base ParameterCollection should be fine + # same name along a different branch from the base ParameterCollection should + # be fine class MockPCUncle(parameters.ParameterCollection): pDefs = parameters.ParameterDefinitionCollection() with pDefs.createBuilder() as pb: @@ -445,7 +461,7 @@ class MockPC(parameters.ParameterCollection): self.assertEqual(set(pc.paramDefs.inCategory("bacon")), set([p2, p3])) def test_parameterCollectionsHave__slots__(self): - """Make sure something is implemented to prevent accidental creation of attributes.""" + """Tests we prevent accidental creation of attributes.""" self.assertEqual( set(["_hist", "_backup", "assigned", "_p_serialNum", "serialNum"]), set(parameters.ParameterCollection._slots), @@ -455,12 +471,6 @@ class MockPC(parameters.ParameterCollection): pass pc = MockPC() - # No longer protecting against __dict__ access. If someone REALLY wants to - # staple something to a parameter collection with no guarantees of anything, - # that's on them - # with self.assertRaises(AttributeError): - # pc.__dict__["foo"] = 5 - with self.assertRaises(AssertionError): pc.whatever = 22 @@ -492,239 +502,3 @@ class MockPCChild(MockPC): pcc = MockPCChild() with self.assertRaises(AssertionError): pcc.whatever = 33 - - -class MockSyncPC(parameters.ParameterCollection): - pDefs = parameters.ParameterDefinitionCollection() - with pDefs.createBuilder( - default=0.0, location=parameters.ParamLocation.AVERAGE - ) as pb: - pb.defParam("param1", "units", "p1 description", categories=["cat1"]) - pb.defParam("param2", "units", "p2 description", categories=["cat2"]) - pb.defParam("param3", "units", "p3 description", categories=["cat3"]) - - -def makeComp(name): - c = composites.Composite(name) - c.p = MockSyncPC() - return c - - -class SynchronizationTests: - """Some unit tests that must be run with mpirun instead of the standard unittest system.""" - - def setUp(self): - self.r = makeComp("reactor") - self.r.core = makeComp("core") - self.r.add(self.r.core) - for ai in range(context.MPI_SIZE * 4): - a = makeComp("assembly{}".format(ai)) - self.r.core.add(a) - for bi in range(10): - a.add(makeComp("block{}-{}".format(ai, bi))) - self.comps = [self.r.core] + self.r.core.getChildren(deep=True) - for pd in MockSyncPC().paramDefs: - pd.assigned = parameters.NEVER - - def tearDown(self): - del self.r - - def run(self, testNamePrefix="mpitest_"): - with open("mpitest{}.temp".format(context.MPI_RANK), "w") as self.l: - for methodName in sorted(dir(self)): - if methodName.startswith(testNamePrefix): - self.write("{}.{}".format(self.__class__.__name__, methodName)) - try: - self.setUp() - getattr(self, methodName)() - except Exception: - self.write("failed, big time") - traceback.print_exc(file=self.l) - self.write("*** printed exception") - try: - self.tearDown() - except: # noqa: bare-except - pass - - self.l.write("done.") - - def write(self, msg): - self.l.write("{}\n".format(msg)) - self.l.flush() - - def assertRaises(self, exceptionType): - class ExceptionCatcher: - def __enter__(self): - pass - - def __exit__(self, exc_type, exc_value, traceback): - if exc_type is exceptionType: - return True - raise AssertionError( - "Expected {}, but got {}".format(exceptionType, exc_type) - ) - - return ExceptionCatcher() - - def assertEqual(self, expected, actual): - if expected != actual: - raise AssertionError( - "(expected) {} != {} (actual)".format(expected, actual) - ) - - def assertNotEqual(self, expected, actual): - if expected == actual: - raise AssertionError( - "(expected) {} == {} (actual)".format(expected, actual) - ) - - def mpitest_noConflicts(self): - for ci, comp in enumerate(self.comps): - if ci % context.MPI_SIZE == context.MPI_RANK: - comp.p.param1 = (context.MPI_RANK + 1) * 30.0 - else: - self.assertNotEqual((context.MPI_RANK + 1) * 30.0, comp.p.param1) - - self.assertEqual(len(self.comps), self.r.syncMpiState()) - - for ci, comp in enumerate(self.comps): - self.assertEqual((ci % context.MPI_SIZE + 1) * 30.0, comp.p.param1) - - def mpitest_noConflicts_setByString(self): - """Make sure params set by string also work with sync.""" - for ci, comp in enumerate(self.comps): - if ci % context.MPI_SIZE == context.MPI_RANK: - comp.p.param2 = (context.MPI_RANK + 1) * 30.0 - else: - self.assertNotEqual((context.MPI_RANK + 1) * 30.0, comp.p.param2) - - self.assertEqual(len(self.comps), self.r.syncMpiState()) - - for ci, comp in enumerate(self.comps): - self.assertEqual((ci % context.MPI_SIZE + 1) * 30.0, comp.p.param2) - - def mpitest_withConflicts(self): - self.r.core.p.param1 = (context.MPI_RANK + 1) * 99.0 - with self.assertRaises(ValueError): - self.r.syncMpiState() - - def mpitest_withConflictsButSameValue(self): - self.r.core.p.param1 = (context.MPI_SIZE + 1) * 99.0 - self.r.syncMpiState() - self.assertEqual((context.MPI_SIZE + 1) * 99.0, self.r.core.p.param1) - - def mpitest_noConflictsMaintainWithStateRetainer(self): - assigned = [] - with self.r.retainState(parameters.inCategory("cat1")): - for ci, comp in enumerate(self.comps): - comp.p.param2 = 99 * ci - if ci % context.MPI_SIZE == context.MPI_RANK: - comp.p.param1 = (context.MPI_RANK + 1) * 30.0 - assigned.append(parameters.SINCE_ANYTHING) - else: - self.assertNotEqual((context.MPI_RANK + 1) * 30.0, comp.p.param1) - assigned.append(parameters.NEVER) - - # 1st inside state retainer - self.assertEqual( - True, all(c.p.assigned == parameters.SINCE_ANYTHING for c in self.comps) - ) - - # confirm outside state retainer - self.assertEqual(assigned, [c.p.assigned for ci, c in enumerate(self.comps)]) - - # this rank's "assigned" components are not assigned on the workers, and so will be updated - self.assertEqual(len(self.comps), self.r.syncMpiState()) - - for ci, comp in enumerate(self.comps): - self.assertEqual((ci % context.MPI_SIZE + 1) * 30.0, comp.p.param1) - - def mpitest_conflictsMaintainWithStateRetainer(self): - with self.r.retainState(parameters.inCategory("cat2")): - for _, comp in enumerate(self.comps): - comp.p.param2 = 99 * context.MPI_RANK - - with self.assertRaises(ValueError): - self.r.syncMpiState() - - def mpitest_rxCoeffsProcess(self): - """This test mimics the process for rxCoeffs when doing distributed doppler.""" - - def do(): - # we will do this over 4 passes (there are 4 * MPI_SIZE assemblies) - for passNum in range(4): - with self.r.retainState(parameters.inCategory("cat2")): - self.r.p.param3 = "hi" - for c in self.comps: - c.p.param1 = ( - 99 * context.MPI_RANK - ) # this will get reset after state retainer - a = self.r.core[passNum * context.MPI_SIZE + context.MPI_RANK] - a.p.param2 = context.MPI_RANK * 20.0 - for b in a: - b.p.param2 = context.MPI_RANK * 10.0 - - for ai, a2 in enumerate(self.r): - if ai % context.MPI_SIZE != context.MPI_RANK: - assert "param2" not in a2.p - - self.assertEqual(parameters.SINCE_ANYTHING, param1.assigned) - self.assertEqual(parameters.SINCE_ANYTHING, param2.assigned) - self.assertEqual(parameters.SINCE_ANYTHING, param3.assigned) - self.assertEqual(parameters.SINCE_ANYTHING, a.p.assigned) - - self.r.syncMpiState() - - self.assertEqual( - parameters.SINCE_ANYTHING - & ~parameters.SINCE_LAST_DISTRIBUTE_STATE, - param1.assigned, - ) - self.assertEqual( - parameters.SINCE_ANYTHING - & ~parameters.SINCE_LAST_DISTRIBUTE_STATE, - param2.assigned, - ) - self.assertEqual( - parameters.SINCE_ANYTHING - & ~parameters.SINCE_LAST_DISTRIBUTE_STATE, - param3.assigned, - ) - self.assertEqual( - parameters.SINCE_ANYTHING - & ~parameters.SINCE_LAST_DISTRIBUTE_STATE, - a.p.assigned, - ) - - self.assertEqual(parameters.NEVER, param1.assigned) - self.assertEqual(parameters.SINCE_ANYTHING, param2.assigned) - self.assertEqual(parameters.NEVER, param3.assigned) - self.assertEqual(parameters.SINCE_ANYTHING, a.p.assigned) - do_assert(passNum) - - param1 = self.r.p.paramDefs["param1"] - param2 = self.r.p.paramDefs["param2"] - param3 = self.r.p.paramDefs["param3"] - - def do_assert(passNum): - # ensure all assemblies and blocks set values for param2, but param1 is empty - for rank in range(context.MPI_SIZE): - a = self.r.core[passNum * context.MPI_SIZE + rank] - assert "param1" not in a.p - assert "param3" not in a.p - self.assertEqual(rank * 20, a.p.param2) - for b in a: - self.assertEqual(rank * 10, b.p.param2) - assert "param1" not in b.p - assert "param3" not in b.p - - if context.MPI_RANK == 0: - with self.r.retainState(parameters.inCategory("cat2")): - context.MPI_COMM.bcast(self.r) - do() - [do_assert(passNum) for passNum in range(4)] - [do_assert(passNum) for passNum in range(4)] - else: - del self.r - self.r = context.MPI_COMM.bcast(None) - do() diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py index 1769ed551..06f0445b7 100644 --- a/armi/reactor/tests/test_reactors.py +++ b/armi/reactor/tests/test_reactors.py @@ -16,6 +16,7 @@ import logging import os import unittest +from math import sqrt from unittest.mock import patch from numpy.testing import assert_allclose, assert_equal @@ -32,7 +33,9 @@ from armi.reactor import geometry from armi.reactor import grids from armi.reactor import reactors +from armi.reactor.assemblyLists import SpentFuelPool from armi.reactor.components import Hexagon, Rectangle +from armi.reactor.composites import Composite from armi.reactor.converters import geometryConverters from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( AxialExpansionChanger, @@ -43,6 +46,7 @@ from armi.tests import ARMI_RUN_PATH, mockRunLogs, TEST_ROOT from armi.utils import directoryChangers +THIS_DIR = os.path.dirname(__file__) TEST_REACTOR = None # pickled string of test reactor (for fast caching) @@ -161,8 +165,6 @@ def loadTestReactor( o : Operator r : Reactor """ - # TODO: it would be nice to have this be more stream-oriented. Juggling files is - # devilishly difficult. global TEST_REACTOR fName = os.path.join(inputFilePath, inputFileName) customSettings = customSettings or {} @@ -238,7 +240,28 @@ def setUp(self): self.directoryChanger.destination, customSettings={"trackAssems": True} ) + def test_coreSfp(self): + """The reactor object includes a core and an SFP. + + .. test:: The reactor object is a composite. + :id: T_ARMI_R + :tests: R_ARMI_R + """ + self.assertTrue(isinstance(self.r.core, reactors.Core)) + self.assertTrue(isinstance(self.r.sfp, SpentFuelPool)) + + self.assertTrue(isinstance(self.r, Composite)) + self.assertTrue(isinstance(self.r.core, Composite)) + self.assertTrue(isinstance(self.r.sfp, Composite)) + def test_factorySortSetting(self): + """ + Create a core object from an input yaml. + + .. test:: Create core object from input yaml. + :id: T_ARMI_R_CORE + :tests: R_ARMI_R_CORE + """ # get a sorted Reactor (the default) cs = settings.Settings(fName="armiRun.yaml") r0 = reactors.loadFromCs(cs) @@ -257,6 +280,51 @@ def test_factorySortSetting(self): a1 = [a.name for a in r1.core] self.assertNotEqual(a0, a1) + # The reactor object is a Composite + self.assertTrue(isinstance(r0.core, Composite)) + + def test_getSetParameters(self): + """ + This test works through multiple levels of the hierarchy to test ability to + modify parameters at different levels. + + .. test:: Parameters are accessible throughout the armi tree. + :id: T_ARMI_PARAM_PART + :tests: R_ARMI_PARAM_PART + + .. test:: Ensure there is a setting for total core power. + :id: T_ARMI_SETTINGS_POWER0 + :tests: R_ARMI_SETTINGS_POWER + """ + # Test at reactor level + self.assertEqual(self.r.p.cycle, 0) + self.assertEqual(self.r.p.availabilityFactor, 1.0) + + # Test at core level + core = self.r.core + self.assertGreater(core.p.power, -1) + + core.p.power = 123 + self.assertEqual(core.p.power, 123) + + # Test at assembly level + assembly = core.getFirstAssembly() + self.assertGreater(assembly.p.crRodLength, -1) + + assembly.p.crRodLength = 234 + self.assertEqual(assembly.p.crRodLength, 234) + + # Test at block level + block = core.getFirstBlock() + self.assertGreater(block.p.THTfuelCL, -1) + + block.p.THTfuelCL = 57 + self.assertEqual(block.p.THTfuelCL, 57) + + # Test at component level + component = block[0] + self.assertEqual(component.p.temperatureInC, 450.0) + def test_sortChildren(self): self.assertEqual(next(self.r.core.__iter__()), self.r.core[0]) self.assertEqual(self.r.core._children, sorted(self.r.core._children)) @@ -393,6 +461,18 @@ def test_getMaxNumPins(self): numPins = self.r.core.getMaxNumPins() self.assertEqual(169, numPins) + def test_addMultipleCores(self): + """Test the catch that a reactor can only have one core.""" + with self.assertRaises(RuntimeError): + self.r.add(self.r.core) + + def test_getReactor(self): + """The Core object can return its Reactor parent; test that getter.""" + self.assertTrue(isinstance(self.r.core.r, reactors.Reactor)) + + self.r.core.parent = None + self.assertIsNone(self.r.core.r) + def test_addMoreNodes(self): originalMesh = self.r.core.p.axialMesh bigMesh = list(originalMesh) @@ -514,6 +594,13 @@ def test_findAllRadMeshPoints(self): assert_allclose(expectedPoints, radPoints) def test_findNeighbors(self): + """ + Find neighbors of a given assembly. + + .. test:: Retrieve neighboring assemblies of a given assembly. + :id: T_ARMI_R_FIND_NEIGHBORS + :tests: R_ARMI_R_FIND_NEIGHBORS + """ loc = self.r.core.spatialGrid.getLocatorFromRingAndPos(1, 1) a = self.r.core.childrenByLocator[loc] neighbs = self.r.core.findNeighbors( @@ -643,19 +730,73 @@ def test_getMinimumPercentFluxInFuel(self): with self.assertRaises(ZeroDivisionError): _targetRing, _fluxFraction = self.r.core.getMinimumPercentFluxInFuel() - def test_getAssembly(self): + def test_getAssemblyWithLoc(self): + """ + Get assembly by location, in a couple different ways to ensure they all work. + + .. test:: Get assembly by location. + :id: T_ARMI_R_GET_ASSEM_LOC + :tests: R_ARMI_R_GET_ASSEM_LOC + """ + a0 = self.r.core.getAssemblyWithStringLocation("003-001") a1 = self.r.core.getAssemblyWithAssemNum(assemNum=10) a2 = self.r.core.getAssembly(locationString="003-001") - a3 = self.r.core.getAssembly(assemblyName="A0010") - self.assertEqual(a1, a3) + self.assertEqual(a0, a2) + self.assertEqual(a1, a2) + self.assertEqual(a1.getLocation(), "003-001") + + def test_getAssemblyWithName(self): + """ + Get assembly by name. + + .. test:: Get assembly by name. + :id: T_ARMI_R_GET_ASSEM_NAME + :tests: R_ARMI_R_GET_ASSEM_NAME + """ + a1 = self.r.core.getAssemblyWithAssemNum(assemNum=10) + a2 = self.r.core.getAssembly(assemblyName="A0010") + self.assertEqual(a1, a2) + self.assertEqual(a1.name, "A0010") def test_restoreReactor(self): - aListLength = len(self.r.core.getAssemblies()) + """Restore a reactor after growing it from third to full core. + + .. test:: Convert a third-core to a full-core geometry and then restore it. + :id: T_ARMI_THIRD_TO_FULL_CORE1 + :tests: R_ARMI_THIRD_TO_FULL_CORE + """ + numOfAssembliesOneThird = len(self.r.core.getAssemblies()) + self.assertFalse(self.r.core.isFullCore) + self.assertEqual( + self.r.core.symmetry, + geometry.SymmetryType( + geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC + ), + ) + # grow to full core converter = self.r.core.growToFullCore(self.o.cs) + self.assertTrue(self.r.core.isFullCore) + self.assertGreater(len(self.r.core.getAssemblies()), numOfAssembliesOneThird) + self.assertEqual(self.r.core.symmetry.domain, geometry.DomainType.FULL_CORE) + # restore back to 1/3 core converter.restorePreviousGeometry(self.r) - self.assertEqual(aListLength, len(self.r.core.getAssemblies())) + self.assertEqual(numOfAssembliesOneThird, len(self.r.core.getAssemblies())) + self.assertEqual( + self.r.core.symmetry, + geometry.SymmetryType( + geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC + ), + ) + self.assertFalse(self.r.core.isFullCore) + self.assertEqual(numOfAssembliesOneThird, len(self.r.core.getAssemblies())) + self.assertEqual( + self.r.core.symmetry, + geometry.SymmetryType( + geometry.DomainType.THIRD_CORE, geometry.BoundaryType.PERIODIC + ), + ) def test_differentNuclideModels(self): self.assertEqual(self.o.cs[CONF_XS_KERNEL], "MC2v3") @@ -688,6 +829,13 @@ def test_getDominantMaterial(self): self.assertEqual(list(dominantCool.getNuclides()), ["NA23"]) def test_getSymmetryFactor(self): + """ + Test getSymmetryFactor(). + + .. test:: Get the core symmetry. + :id: T_ARMI_R_SYMM + :tests: R_ARMI_R_SYMM + """ for b in self.r.core.getBlocks(): sym = b.getSymmetryFactor() i, j, _ = b.spatialLocator.getCompleteIndices() @@ -713,7 +861,8 @@ class MockLib: for b in self.r.core.getBlocks(): b.p.mgFlux = range(5) b.p.adjMgFlux = range(5) - self.r.core.saveAllFlux() + with directoryChangers.TemporaryDirectoryChanger(root=THIS_DIR): + self.r.core.saveAllFlux() def test_getFluxVector(self): class MockLib: @@ -748,9 +897,20 @@ def test_getFuelBottomHeight(self): self.assertEqual(fuelBottomHeightInCm, fuelBottomHeightRef) def test_getGridBounds(self): - (_minI, maxI), (_minJ, maxJ), (minK, maxK) = self.r.core.getBoundingIndices() - self.assertEqual((maxI, maxJ), (8, 8)) - self.assertEqual((minK, maxK), (0, 0)) + """Test getGridBounds() works on different scales. + + .. test:: Test that assembly grids nest inside core grids. + :id: T_ARMI_GRID_NEST + :tests: R_ARMI_GRID_NEST + """ + (minI, maxI), (minJ, maxJ), (_minK, _maxK) = self.r.core.getBoundingIndices() + self.assertEqual((minI, maxI), (-3, 8)) + self.assertEqual((minJ, maxJ), (-4, 8)) + + randomBlock = self.r.core.getFirstAssembly() + (minI, maxI), (minJ, maxJ), (_minK, _maxK) = randomBlock.getBoundingIndices() + self.assertEqual((minI, maxI), (8, 8)) + self.assertEqual((minJ, maxJ), (-4, -4)) def test_locations(self): loc = self.r.core.spatialGrid.getLocatorFromRingAndPos(3, 2) @@ -857,10 +1017,35 @@ def test_removeAssembliesInRing(self): self.assertEqual(a.spatialLocator.grid, self.r.sfp.spatialGrid) def test_removeAssembliesInRingByCount(self): + """Tests retrieving ring numbers and removing a ring. + + .. test:: Retrieve number of rings in core. + :id: T_ARMI_R_NUM_RINGS + :tests: R_ARMI_R_NUM_RINGS + """ self.assertEqual(self.r.core.getNumRings(), 9) self.r.core.removeAssembliesInRing(9, self.o.cs) self.assertEqual(self.r.core.getNumRings(), 8) + def test_getNumRings(self): + self.assertEqual(len(self.r.core.circularRingList), 0) + self.assertEqual(self.r.core.getNumRings(indexBased=True), 9) + self.assertEqual(self.r.core.getNumRings(indexBased=False), 9) + + self.r.core.circularRingList = {1, 2, 3} + self.assertEqual(len(self.r.core.circularRingList), 3) + self.assertEqual(self.r.core.getNumRings(indexBased=True), 9) + self.assertEqual(self.r.core.getNumRings(indexBased=False), 3) + + @patch("armi.reactor.reactors.Core.getAssemblies") + def test_whenNoAssemblies(self, mockGetAssemblies): + """Test various edge cases when there are no assemblies.""" + mockGetAssemblies.return_value = [] + + self.assertEqual(self.r.core.countBlocksWithFlags(Flags.FUEL), 0) + self.assertEqual(self.r.core.countFuelAxialBlocks(), 0) + self.assertGreater(self.r.core.getFirstFuelBlockAxialNode(), 9e9) + def test_removeAssembliesInRingHex(self): """ Since the test reactor is hex, we need to use the overrideCircularRingMode option @@ -1124,6 +1309,40 @@ def test_setPowerIfNecessary(self): self.r.core.setPowerIfNecessary() self.assertAlmostEqual(self.r.core.p.power, 3e9) + def test_findAllMeshPoints(self): + """Test findAllMeshPoints(). + + .. test:: Test that the reactor can calculate its core block mesh. + :id: T_ARMI_R_MESH + :tests: R_ARMI_R_MESH + """ + # lets do some basic sanity checking of the meshpoints + x, y, z = self.r.core.findAllMeshPoints() + + # no two meshpoints should be the same, and they should all be monotonically increasing + for xx in range(1, len(x)): + self.assertGreater(x[xx], x[xx - 1], msg=f"x={xx}") + + for yy in range(1, len(y)): + self.assertGreater(y[yy], y[yy - 1], msg=f"y={yy}") + + for zz in range(1, len(z)): + self.assertGreater(z[zz], z[zz - 1], msg=f"z={zz}") + + # the z-index should start at zero (the bottom) + self.assertEqual(z[0], 0) + + # ensure the X and Y mesh spacing is correct (for a hex core) + pitch = self.r.core.spatialGrid.pitch + + xPitch = sqrt(3) * pitch / 2 + for xx in range(1, len(x)): + self.assertAlmostEqual(x[xx] - x[xx - 1], xPitch, delta=0.0001) + + yPitch = pitch / 2 + for yy in range(1, len(y)): + self.assertAlmostEqual(y[yy] - y[yy - 1], yPitch, delta=0.001) + class CartesianReactorTests(ReactorTests): def setUp(self): @@ -1159,3 +1378,30 @@ def test_getNuclideCategoriesLogging(self): self.assertIn("Nuclide categorization", messages) self.assertIn("Structure", messages) + + +class CartesianReactorNeighborTests(ReactorTests): + def setUp(self): + self.r = loadTestReactor(TEST_ROOT, inputFileName="zpprTest.yaml")[1] + + def test_findNeighborsCartesian(self): + """Find neighbors of a given assembly in a Cartesian grid.""" + loc = self.r.core.spatialGrid[1, 1, 0] + a = self.r.core.childrenByLocator[loc] + neighbs = self.r.core.findNeighbors(a) + locs = [tuple(a.spatialLocator.indices[:2]) for a in neighbs] + self.assertEqual(len(neighbs), 4) + self.assertIn((2, 1), locs) + self.assertIn((1, 2), locs) + self.assertIn((0, 1), locs) + self.assertIn((1, 0), locs) + + # try with edge assembly + loc = self.r.core.spatialGrid[0, 0, 0] + a = self.r.core.childrenByLocator[loc] + neighbs = self.r.core.findNeighbors(a, showBlanks=False) + locs = [tuple(a.spatialLocator.indices[:2]) for a in neighbs] + self.assertEqual(len(neighbs), 2) + # in this case no locations that aren't actually in the core should be returned + self.assertIn((1, 0), locs) + self.assertIn((0, 1), locs) diff --git a/armi/reactor/tests/test_rz_reactors.py b/armi/reactor/tests/test_rz_reactors.py index 15d02c527..8f46627b2 100644 --- a/armi/reactor/tests/test_rz_reactors.py +++ b/armi/reactor/tests/test_rz_reactors.py @@ -22,7 +22,7 @@ from armi.reactor import reactors -class Test_RZT_Reactor(unittest.TestCase): +class TestRZTReactor(unittest.TestCase): """Tests for RZT reactors.""" @classmethod @@ -38,18 +38,19 @@ def test_loadRZT(self): self.assertTrue(all(aziMesh == 8 for aziMesh in aziMeshes)) def test_findAllMeshPoints(self): + """Test findAllMeshPoints().""" i, _, _ = self.r.core.findAllMeshPoints() self.assertLess(i[-1], 2 * math.pi) -class Test_RZT_Reactor_modern(unittest.TestCase): +class TestRZTReactorModern(unittest.TestCase): def test_loadRZT_reactor(self): """ The Godiva benchmark model is a HEU sphere with a radius of 8.74 cm. This unit tests loading and verifies the reactor is loaded correctly by comparing volumes against expected volumes for full core (including - void boundary conditions) and just the fuel + void boundary conditions) and just the fuel. """ cs = settings.Settings( fName=os.path.join(TEST_ROOT, "Godiva.armi.unittest.yaml") @@ -70,15 +71,11 @@ def test_loadRZT_reactor(self): for c in b: if "Godiva" in c.name: fuelVolumes.append(c.getVolume()) - """ - verify the total reactor volume is as expected - """ + # verify the total reactor volume is as expected tolerance = 1e-3 error = math.fabs((refReactorVolume - sum(reactorVolumes)) / refReactorVolume) self.assertLess(error, tolerance) - """ - verify the total fuel volume is as expected - """ + # verify the total fuel volume is as expected error = math.fabs((refFuelVolume - sum(fuelVolumes)) / refFuelVolume) self.assertLess(error, tolerance) diff --git a/armi/reactor/tests/test_zones.py b/armi/reactor/tests/test_zones.py index 9d703363d..4ded16430 100644 --- a/armi/reactor/tests/test_zones.py +++ b/armi/reactor/tests/test_zones.py @@ -72,6 +72,13 @@ def setUp(self): self.bList.append(b) def test_addItem(self): + """ + Test adding an item. + + .. test:: Add item to a zone. + :id: T_ARMI_ZONE0 + :tests: R_ARMI_ZONE + """ zone = zones.Zone("test_addItem") zone.addItem(self.aList[0]) self.assertIn(self.aList[0].getLocation(), zone) @@ -86,6 +93,13 @@ def test_removeItem(self): self.assertRaises(AssertionError, zone.removeItem, "also nope") def test_addItems(self): + """ + Test adding items. + + .. test:: Add multiple items to a zone. + :id: T_ARMI_ZONE1 + :tests: R_ARMI_ZONE + """ zone = zones.Zone("test_addItems") zone.addItems(self.aList) for a in self.aList: @@ -98,6 +112,13 @@ def test_removeItems(self): self.assertNotIn(a.getLocation(), zone) def test_addLoc(self): + """ + Test adding a location. + + .. test:: Add location to a zone. + :id: T_ARMI_ZONE2 + :tests: R_ARMI_ZONE + """ zone = zones.Zone("test_addLoc") zone.addLoc(self.aList[0].getLocation()) self.assertIn(self.aList[0].getLocation(), zone) @@ -112,6 +133,13 @@ def test_removeLoc(self): self.assertRaises(AssertionError, zone.removeLoc, 1234) def test_addLocs(self): + """ + Test adding locations. + + .. test:: Add multiple locations to a zone. + :id: T_ARMI_ZONE3 + :tests: R_ARMI_ZONE + """ zone = zones.Zone("test_addLocs") zone.addLocs([a.getLocation() for a in self.aList]) for a in self.aList: @@ -179,6 +207,13 @@ def setUp(self): self.zonez = self.r.core.zones def test_dictionaryInterface(self): + """ + Test creating and interacting with the Zones object. + + .. test:: Create collection of Zones. + :id: T_ARMI_ZONES + :tests: R_ARMI_ZONES + """ zs = zones.Zones() # validate the addZone() and __len__() work diff --git a/armi/reactor/zones.py b/armi/reactor/zones.py index 718ea89ca..163ddce27 100644 --- a/armi/reactor/zones.py +++ b/armi/reactor/zones.py @@ -28,6 +28,22 @@ class Zone: """ A group of locations in the Core, used to divide it up for analysis. Each location represents an Assembly or a Block. + + .. impl:: A user can define a collection of armi locations. + :id: I_ARMI_ZONE + :implements: R_ARMI_ZONE + + The Zone class facilitates the creation of a Zone object representing a + collection of locations in the Core. A Zone contains a group of locations + in the Core, used to subdivide it for analysis. Each location represents + an Assembly or a Block, where a single Zone must contain items of the same + type (i.e., Assembly or Block). Methods are provided to add or remove + one or more locations to/from the Zone, and similarly, add or remove one or + more items with a Core location (i.e., Assemblies or Blocks) to/from the + Zone. In addition, several methods are provided to facilitate the + retrieval of locations from a Zone by performing functions to check if a + location exists in the Zone, looping through the locations in the Zone in + alphabetical order, and returning the number of locations in the Zone, etc. """ VALID_TYPES = (Assembly, Block) @@ -196,7 +212,22 @@ def removeItems(self, items: List) -> None: class Zones: - """Collection of Zone objects.""" + """Collection of Zone objects. + + .. impl:: A user can define a collection of armi zones. + :id: I_ARMI_ZONES + :implements: R_ARMI_ZONES + + The Zones class facilitates the creation of a Zones object representing a + collection of Zone objects. Methods are provided to add or remove one + or more Zone to/from the Zones object. Likewise, methods are provided + to validate that the zones are mutually exclusive, obtain the location + labels of zones, return the Zone object where a particular Assembly or Block + resides, sort the Zone objects alphabetically, and summarize the zone + definitions. In addition, methods are provided to facilitate the + retrieval of Zone objects by name, loop through the Zones in order, and + return the number of Zone objects. + """ def __init__(self): """Build a Zones object.""" @@ -303,7 +334,7 @@ def removeZones(self, names: List) -> None: def checkDuplicates(self) -> None: """ - Validate that the the zones are mutually exclusive. + Validate that the zones are mutually exclusive. That is, make sure that no item appears in more than one Zone. @@ -415,10 +446,10 @@ def summary(self) -> None: Examples -------- - zoneDefinitions: - - ring-1: 001-001 - - ring-2: 002-001, 002-002 - - ring-3: 003-001, 003-002, 003-003 + zoneDefinitions: + - ring-1: 001-001 + - ring-2: 002-001, 002-002 + - ring-3: 003-001, 003-002, 003-003 """ # log a quick header runLog.info("zoneDefinitions:") diff --git a/armi/runLog.py b/armi/runLog.py index faa4aa974..dcf0e1ff6 100644 --- a/armi/runLog.py +++ b/armi/runLog.py @@ -322,6 +322,20 @@ def concatenateLogs(logDir=None): Concatenate the armi run logs and delete them. Should only ever be called by parent. + + .. impl:: Log files from different processes are combined. + :id: I_ARMI_LOG_MPI + :implements: R_ARMI_LOG_MPI + + The log files are plain text files. Since ARMI is frequently run in parallel, + the situation arises where each ARMI process generates its own plain text log + file. This function combines the separate log files, per process, into one log + file. + + The files are written in numerical order, with the lead process stdout first + then the lead process stderr. Then each other process is written to the + combined file, in order, stdout then stderr. Finally, the original stdout and + stderr files are deleted. """ if logDir is None: logDir = LOG_DIR @@ -494,6 +508,45 @@ class RunLogger(logging.Logger): 1. Giving users the option to de-duplicate warnings 2. Piping stderr to a log file + + .. impl:: A simulation-wide log, with user-specified verbosity. + :id: I_ARMI_LOG + :implements: R_ARMI_LOG + + Log statements are any text a user wants to record during a run. For instance, + basic notifications of what is happening in the run, simple warnings, or hard + errors. Every log message has an associated log level, controlled by the + "verbosity" of the logging statement in the code. In the ARMI codebase, you + can see many examples of logging: + + .. code-block:: python + + runLog.error("This sort of error might usually terminate the run.") + runLog.warning("Users probably want to know.") + runLog.info("This is the usual verbosity.") + runLog.debug("This is only logged during a debug run.") + + The full list of logging levels is defined in ``_RunLog.getLogLevels()``, and + the developer specifies the verbosity of a run via ``_RunLog.setVerbosity()``. + + At the end of the ARMI-based simulation, the analyst will have a full record of + potentially interesting information they can use to understand their run. + + .. impl:: Logging is done to the screen and to file. + :id: I_ARMI_LOG_IO + :implements: R_ARMI_LOG_IO + + This logger makes it easy for users to add log statements to and ARMI + application, and ARMI will control the flow of those log statements. In + particular, ARMI overrides the normal Python logging tooling, to allow + developers to pipe their log statements to both screen and file. This works for + stdout and stderr. + + At any place in the ARMI application, developers can interject a plain text + logging message, and when that code is hit during an ARMI simulation, the text + will be piped to screen and a log file. By default, the ``logging`` module only + logs to screen, but ARMI adds a ``FileHandler`` in the ``RunLog`` constructor + and in ``_RunLog.startLog``. """ FMT = "%(levelname)s%(message)s" diff --git a/armi/settings/caseSettings.py b/armi/settings/caseSettings.py index 6713eca38..8b76f72e0 100644 --- a/armi/settings/caseSettings.py +++ b/armi/settings/caseSettings.py @@ -48,11 +48,16 @@ class Settings: """ A container for run settings, such as case title, power level, and many more. - It is accessible to most ARMI objects through self.cs (for 'Case Settings'). - It acts largely as a dictionary, and setting values are accessed by keys. + .. impl:: Settings are used to define an ARMI run. + :id: I_ARMI_SETTING0 + :implements: R_ARMI_SETTING - The Settings object has a 1-to-1 correspondence with the ARMI settings input file. - This file may be created by hand or by the GUI in submitter.py. + The Settings object is accessible to most ARMI objects through self.cs + (for 'case settings'). It acts largely as a dictionary, and setting values + are accessed by keys. + + The Settings object has a 1-to-1 correspondence with the ARMI settings + input file. This file may be created by hand or by a GUI. Notes ----- @@ -103,7 +108,21 @@ def inputDirectory(self): @property def caseTitle(self): - """Getter for settings case title.""" + """Getter for settings case title. + + .. impl:: Define a case title to go with the settings. + :id: I_ARMI_SETTINGS_META0 + :implements: R_ARMI_SETTINGS_META + + Every Settings object has a "case title"; a string for users to + help identify their run. This case title is used in log file + names, it is printed during a run, it is frequently used to + name the settings file. It is designed to be an easy-to-use + and easy-to-understand way to keep track of simulations. The + general idea here is that the average analyst that is using + ARMI will run many ARMI-based simulations, and there needs + to be an easy to identify them all. + """ if not self.path: return self.defaultCaseTitle else: @@ -412,7 +431,7 @@ def writeToYamlStream(self, stream, style="short", settingsSetByUser=[]): Returns ------- - writer : SettingsWriter object + writer : SettingsWriter """ writer = settingsIO.SettingsWriter( self, style=style, settingsSetByUser=settingsSetByUser @@ -426,7 +445,7 @@ def updateEnvironmentSettingsFrom(self, otherCs): Parameters ---------- - otherCs : Settings object + otherCs : Settings A cs object that environment settings will be inherited from. This enables users to run tests with their environment rather than the reference environment diff --git a/armi/settings/fwSettings/databaseSettings.py b/armi/settings/fwSettings/databaseSettings.py index 79c24a129..7424c7fec 100644 --- a/armi/settings/fwSettings/databaseSettings.py +++ b/armi/settings/fwSettings/databaseSettings.py @@ -30,6 +30,7 @@ def defineSettings(): + """Define settings for the interface.""" settings = [ setting.Setting( CONF_DB, diff --git a/armi/settings/fwSettings/globalSettings.py b/armi/settings/fwSettings/globalSettings.py index 72410a04e..4f88367ba 100644 --- a/armi/settings/fwSettings/globalSettings.py +++ b/armi/settings/fwSettings/globalSettings.py @@ -15,8 +15,9 @@ """ Framework-wide settings definitions and constants. -This should contain Settings definitions for general-purpose "framework" settings. These -should only include settings that are not related to any particular physics or plugins. +This should contain Settings definitions for general-purpose "framework" +settings. These should only include settings that are not related to any +particular physics or plugins. """ import os from typing import List @@ -119,7 +120,40 @@ def defineSettings() -> List[setting.Setting]: - """Return a list of global framework settings.""" + """ + Return a list of global framework settings. + + .. impl:: There is a setting for total core power. + :id: I_ARMI_SETTINGS_POWER + :implements: R_ARMI_SETTINGS_POWER + + ARMI defines a collection of settings by default to be associated + with all runs, and one such setting is ``power``. This is the + total thermal power of the reactor. This is designed to be the + standard power of the reactor core, to be easily set by the user. + There is frequently the need to adjust the power of the reactor + at different cycles. That is done by setting the ``powerFractions`` + setting to a list of fractions of this power. + + .. impl:: Define a comment and a versions list to go with the settings. + :id: I_ARMI_SETTINGS_META1 + :implements: R_ARMI_SETTINGS_META + + Because nuclear analysts have a lot to keep track of when doing + various simulations of a reactor, ARMI provides a ``comment`` + setting that takes an arbitrary string and stores it. This string + will be preserved in the settings file and thus in the database, + and can provide helpful notes for analysts in the future. + + Likewise, it is helpful to know what versions of software were + used in an ARMI application. There is a dictionary-like setting + called ``versions`` that allows users to track the versions of: + ARMI, their ARMI application, and the versions of all the plugins + in their simulation. While it is always helpful to know what + versions of software you run, it is particularly needed in nuclear + engineering where demands will be made to track the exact + versions of code used in simulations. + """ settings = [ setting.Setting( CONF_NUM_PROCESSORS, diff --git a/armi/settings/fwSettings/reportSettings.py b/armi/settings/fwSettings/reportSettings.py index c2ed49942..67c5a8322 100644 --- a/armi/settings/fwSettings/reportSettings.py +++ b/armi/settings/fwSettings/reportSettings.py @@ -26,6 +26,7 @@ def defineSettings(): + """Define settings for the interface.""" settings = [ setting.Setting( CONF_GEN_REPORTS, diff --git a/armi/settings/fwSettings/tightCouplingSettings.py b/armi/settings/fwSettings/tightCouplingSettings.py index 983c40c5d..77be098b0 100644 --- a/armi/settings/fwSettings/tightCouplingSettings.py +++ b/armi/settings/fwSettings/tightCouplingSettings.py @@ -49,7 +49,7 @@ class TightCouplingSettings(dict): Examples -------- - couplingSettings = TightCouplingSettings({'globalFlux': {'parameter': 'keff', 'convergence': 1e-05}}) + couplingSettings = TightCouplingSettings({'globalFlux': {'parameter': 'keff', 'convergence': 1e-05}}) """ def __repr__(self): diff --git a/armi/settings/setting.py b/armi/settings/setting.py index 2b63fbc8a..9146c4e09 100644 --- a/armi/settings/setting.py +++ b/armi/settings/setting.py @@ -17,13 +17,11 @@ Notes ----- -Rather than having subclases for each setting type, we simply derive -the type based on the type of the default, and we enforce it with -schema validation. This also allows for more complex schema validation -for settings that are more complex dictionaries (e.g. XS, rx coeffs, etc.). - -One reason for complexity of the previous settings implementation was -good interoperability with the GUI widgets. +The type of each setting is derived from the type of the default +value. When users set values to their settings, ARMI enforces +these types with schema validation. This also allows for more +complex schema validation for settings that are more complex +dictionaries (e.g. XS, rx coeffs). """ from collections import namedtuple from typing import List, Optional, Tuple @@ -48,17 +46,19 @@ class Setting: """ A particular setting. - Setting objects hold all associated information of a setting in ARMI and should - typically be accessed through the Settings class methods rather than directly. The - exception being the SettingAdapter class designed for additional GUI related - functionality. + .. impl:: The setting default is mandatory. + :id: I_ARMI_SETTINGS_DEFAULTS + :implements: R_ARMI_SETTINGS_DEFAULTS - Setting subclasses can implement custom ``load`` and ``dump`` methods - that can enable serialization (to/from dicts) of custom objects. When - you set a setting's value, the value will be unserialized into - the custom object and when you call ``dump``, it will be serialized. - Just accessing the value will return the actual object in this case. + Setting objects hold all associated information of a setting in ARMI and should + typically be accessed through the Settings methods rather than directly. + Settings require a mandatory default value. + Setting subclasses can implement custom ``load`` and ``dump`` methods that can + enable serialization (to/from dicts) of custom objects. When you set a + setting's value, the value will be unserialized into the custom object and when + you call ``dump``, it will be serialized. Just accessing the value will return + the actual object in this case. """ def __init__( diff --git a/armi/settings/settingsIO.py b/armi/settings/settingsIO.py index 475a61352..4447979e4 100644 --- a/armi/settings/settingsIO.py +++ b/armi/settings/settingsIO.py @@ -141,9 +141,17 @@ def renameSetting(self, name) -> Tuple[str, bool]: class SettingsReader: """Abstract class for processing settings files. + .. impl:: The setting use a human-readable, plain text file as input. + :id: I_ARMI_SETTINGS_IO_TXT + :implements: R_ARMI_SETTINGS_IO_TXT + + ARMI uses the YAML standard for settings files. ARMI uses industry-standard + ``ruamel.yaml`` Python libraray to read these files. ARMI does not bend or + change the YAML file format standard in any way. + Parameters ---------- - cs : CaseSettings + cs : Settings The settings object to read into """ @@ -167,9 +175,9 @@ def __init__(self, cs): self._renamer = SettingRenamer(dict(self.cs.items())) - # the input version will be overwritten if explicitly stated in input file. + # The input version will be overwritten if explicitly stated in input file. # otherwise, it's assumed to precede the version inclusion change and should be - # treated as alright + # treated as alright. def __getitem__(self, key): return self.cs[key] @@ -280,7 +288,6 @@ class SettingsWriter: preserves all settings originally in file even if they match the default value full all setting values regardless of default status - """ def __init__(self, settings_instance, style="short", settingsSetByUser=[]): diff --git a/armi/settings/tests/test_settings.py b/armi/settings/tests/test_settings.py index 343a3c3c0..79c29f812 100644 --- a/armi/settings/tests/test_settings.py +++ b/armi/settings/tests/test_settings.py @@ -41,10 +41,11 @@ THIS_DIR = os.path.dirname(__file__) -class DummyPlugin1(plugins.ArmiPlugin): +class DummySettingPlugin1(plugins.ArmiPlugin): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" return [ setting.Setting( "extendableOption", @@ -53,14 +54,21 @@ def defineSettings(): description="The neutronics / depletion solver for global flux solve.", enforcedOptions=True, options=["DEFAULT", "OTHER"], - ) + ), + setting.Setting( + "avocado", + default=0, + label="Avocados", + description="Avocados are delicious.", + ), ] -class DummyPlugin2(plugins.ArmiPlugin): +class DummySettingPlugin2(plugins.ArmiPlugin): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" return [ setting.Option("PLUGIN", "extendableOption"), setting.Default("PLUGIN", "extendableOption"), @@ -71,13 +79,14 @@ class PluginAddsOptions(plugins.ArmiPlugin): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" return [ setting.Option("MCNP", CONF_NEUTRONICS_KERNEL), setting.Option("MCNP_Slab", CONF_NEUTRONICS_KERNEL), ] -class TestCaseSettings(unittest.TestCase): +class TestSettings(unittest.TestCase): def setUp(self): self.cs = caseSettings.Settings() @@ -98,6 +107,33 @@ def test_updateEnvironmentSettingsFrom(self): self.cs.updateEnvironmentSettingsFrom(newEnv) self.assertEqual(self.cs["verbosity"], "9") + def test_metaData(self): + """Test we can get and set the important settings metadata. + + .. test:: Test getting and setting import settings metadata. + :id: T_ARMI_SETTINGS_META + :tests: R_ARMI_SETTINGS_META + """ + # test get/set on caseTitle + self.assertEqual(self.cs.caseTitle, "armi") + testTitle = "test_metaData" + self.cs.caseTitle = testTitle + self.assertEqual(self.cs.caseTitle, testTitle) + + # test get/set on comment + self.assertEqual(self.cs["comment"], "") + testComment = "Comment: test_metaData" + self.cs = self.cs.modified(newSettings={"comment": testComment}) + self.assertEqual(self.cs["comment"], testComment) + + # test get/set on version + self.assertEqual(len(self.cs["versions"]), 0) + self.cs = self.cs.modified(newSettings={"versions": {"something": 1.234}}) + + d = self.cs["versions"] + self.assertEqual(len(d), 1) + self.assertEqual(d["something"], 1.234) + class TestAddingOptions(unittest.TestCase): def setUp(self): @@ -231,37 +267,51 @@ def test_pluginValidatorsAreDiscovered(self): ) def test_pluginSettings(self): + """Test settings change depending on what plugins are registered. + + .. test:: Registering a plugin can change what settings exist. + :id: T_ARMI_PLUGIN_SETTINGS + :tests: R_ARMI_PLUGIN_SETTINGS + """ pm = getPluginManagerOrFail() - pm.register(DummyPlugin1) + pm.register(DummySettingPlugin1) # We have a setting; this should be fine cs = caseSettings.Settings() self.assertEqual(cs["extendableOption"], "DEFAULT") + self.assertEqual(cs["avocado"], 0) # We shouldn't have any settings from the other plugin, so this should be an # error. with self.assertRaises(vol.error.MultipleInvalid): newSettings = {"extendableOption": "PLUGIN"} cs = cs.modified(newSettings=newSettings) - pm.register(DummyPlugin2) + pm.register(DummySettingPlugin2) cs = caseSettings.Settings() self.assertEqual(cs["extendableOption"], "PLUGIN") # Now we should have the option from plugin 2; make sure that works cs = cs.modified(newSettings=newSettings) cs["extendableOption"] = "PLUGIN" self.assertIn("extendableOption", cs.keys()) - pm.unregister(DummyPlugin2) - pm.unregister(DummyPlugin1) + pm.unregister(DummySettingPlugin2) + pm.unregister(DummySettingPlugin1) # Now try the same, but adding the plugins in a different order. This is to make # sure that it doesnt matter if the Setting or its Options come first - pm.register(DummyPlugin2) - pm.register(DummyPlugin1) + pm.register(DummySettingPlugin2) + pm.register(DummySettingPlugin1) cs = caseSettings.Settings() self.assertEqual(cs["extendableOption"], "PLUGIN") + self.assertEqual(cs["avocado"], 0) def test_default(self): - """Make sure default updating mechanism works.""" + """ + Make sure default updating mechanism works. + + .. test:: The setting default is mandatory. + :id: T_ARMI_SETTINGS_DEFAULTS + :tests: R_ARMI_SETTINGS_DEFAULTS + """ a = setting.Setting("testsetting", 0) newDefault = setting.Default(5, "testsetting") a.changeDefault(newDefault) diff --git a/armi/settings/tests/test_settingsIO.py b/armi/settings/tests/test_settingsIO.py index d9054db31..a2c1185cc 100644 --- a/armi/settings/tests/test_settingsIO.py +++ b/armi/settings/tests/test_settingsIO.py @@ -70,6 +70,12 @@ def test_basicSettingsReader(self): self.assertEqual(getattr(reader, "path"), "") def test_readFromFile(self): + """Read settings from a (human-readable) YAML file. + + .. test:: Settings can be input from a human-readable text file. + :id: T_ARMI_SETTINGS_IO_TXT0 + :tests: R_ARMI_SETTINGS_IO_TXT + """ with directoryChangers.TemporaryDirectoryChanger(): inPath = os.path.join(TEST_ROOT, "armiRun.yaml") outPath = "test_readFromFile.yaml" @@ -160,7 +166,12 @@ def test_writeMedium(self): self.assertIn("numProcessors: 1", txt) def test_writeFull(self): - """Setting output as a full, all defaults included file.""" + """Setting output as a full, all defaults included file. + + .. test:: Settings can be output to a human-readable text file. + :id: T_ARMI_SETTINGS_IO_TXT1 + :tests: R_ARMI_SETTINGS_IO_TXT + """ self.cs.writeToYamlFile(self.filepathYaml, style="full") txt = open(self.filepathYaml, "r").read() self.assertIn("nCycles: 55", txt) diff --git a/armi/tests/Godiva-blueprints.yaml b/armi/tests/Godiva-blueprints.yaml index b24c97d1b..519725dd4 100644 --- a/armi/tests/Godiva-blueprints.yaml +++ b/armi/tests/Godiva-blueprints.yaml @@ -1,180 +1,48 @@ nuclide flags: - PU237: - burn: false - xs: true - expandTo: [] - PU240: - burn: false - xs: true - expandTo: [] - PU241: - burn: false - xs: true - expandTo: [] - AR: - burn: false - xs: true - expandTo: [] - PA233: - burn: false - xs: true - expandTo: [] - NP238: - burn: false - xs: true - expandTo: [] - AR36: - burn: false - xs: true - expandTo: [] - TH230: - burn: false - xs: true - expandTo: [] - AR38: - burn: false - xs: true - expandTo: [] - U238: - burn: false - xs: true - expandTo: [] - U239: - burn: false - xs: true - expandTo: [] - C: - burn: false - xs: true - expandTo: [] - LFP35: - burn: false - xs: true - expandTo: [] - U233: - burn: false - xs: true - expandTo: [] - U234: - burn: false - xs: true - expandTo: [] - U235: - burn: false - xs: true - expandTo: [] - U236: - burn: false - xs: true - expandTo: [] - U237: - burn: false - xs: true - expandTo: [] - PU239: - burn: false - xs: true - expandTo: [] - PU238: - burn: false - xs: true - expandTo: [] - TH234: - burn: false - xs: true - expandTo: [] - TH232: - burn: false - xs: true - expandTo: [] - AR40: - burn: false - xs: true - expandTo: [] - LFP39: - burn: false - xs: true - expandTo: [] - DUMP2: - burn: false - xs: true - expandTo: [] - LFP41: - burn: false - xs: true - expandTo: [] - LFP40: - burn: false - xs: true - expandTo: [] - PU242: - burn: false - xs: true - expandTo: [] - PU236: - burn: false - xs: true - expandTo: [] - U232: - burn: false - xs: true - expandTo: [] - DUMP1: - burn: false - xs: true - expandTo: [] - LFP38: - burn: false - xs: true - expandTo: [] - AM243: - burn: false - xs: true - expandTo: [] - PA231: - burn: false - xs: true - expandTo: [] - CM244: - burn: false - xs: true - expandTo: [] - CM242: - burn: false - xs: true - expandTo: [] - AM242: - burn: false - xs: true - expandTo: [] - CM245: - burn: false - xs: true - expandTo: [] - CM243: - burn: false - xs: true - expandTo: [] - CM246: - burn: false - xs: true - expandTo: [] - CM247: - burn: false - xs: true - expandTo: [] - O: - burn: false - xs: true - expandTo: [O16] - N: - burn: false - xs: true - expandTo: [N14] - ZR: - burn: false - xs: true - expandTo: [] + PU237: {burn: false, xs: true, expandTo: []} + PU240: {burn: false, xs: true, expandTo: []} + PU241: {burn: false, xs: true, expandTo: []} + AR: {burn: false, xs: true, expandTo: []} + PA233: {burn: false, xs: true, expandTo: []} + NP238: {burn: false, xs: true, expandTo: []} + AR36: {burn: false, xs: true, expandTo: []} + TH230: {burn: false, xs: true, expandTo: []} + AR38: {burn: false, xs: true, expandTo: []} + U238: {burn: false, xs: true, expandTo: []} + U239: {burn: false, xs: true, expandTo: []} + C: {burn: false, xs: true, expandTo: []} + LFP35: {burn: false, xs: true, expandTo: []} + U233: {burn: false, xs: true, expandTo: []} + U234: {burn: false, xs: true, expandTo: []} + U235: {burn: false, xs: true, expandTo: []} + U236: {burn: false, xs: true, expandTo: []} + U237: {burn: false, xs: true, expandTo: []} + PU239: {burn: false, xs: true, expandTo: []} + PU238: {burn: false, xs: true, expandTo: []} + TH234: {burn: false, xs: true, expandTo: []} + TH232: {burn: false, xs: true, expandTo: []} + AR40: {burn: false, xs: true, expandTo: []} + LFP39: {burn: false, xs: true, expandTo: []} + DUMP2: {burn: false, xs: true, expandTo: []} + LFP41: {burn: false, xs: true, expandTo: []} + LFP40: {burn: false, xs: true, expandTo: []} + PU242: {burn: false, xs: true, expandTo: []} + PU236: {burn: false, xs: true, expandTo: []} + U232: {burn: false, xs: true, expandTo: []} + DUMP1: {burn: false, xs: true, expandTo: []} + LFP38: {burn: false, xs: true, expandTo: []} + AM243: {burn: false, xs: true, expandTo: []} + PA231: {burn: false, xs: true, expandTo: []} + CM244: {burn: false, xs: true, expandTo: []} + CM242: {burn: false, xs: true, expandTo: []} + AM242: {burn: false, xs: true, expandTo: []} + CM245: {burn: false, xs: true, expandTo: []} + CM243: {burn: false, xs: true, expandTo: []} + CM246: {burn: false, xs: true, expandTo: []} + CM247: {burn: false, xs: true, expandTo: []} + O: {burn: false, xs: true, expandTo: [O16]} + N: {burn: false, xs: true, expandTo: [N14]} + ZR: {burn: false, xs: true, expandTo: []} custom isotopics: {} blocks: {} assemblies: diff --git a/armi/tests/test_apps.py b/armi/tests/test_apps.py index 6b534b545..a6b6ea7ee 100644 --- a/armi/tests/test_apps.py +++ b/armi/tests/test_apps.py @@ -16,7 +16,6 @@ import copy import unittest -from armi import cli from armi import configure from armi import context from armi import getApp @@ -212,15 +211,26 @@ def test_disableFutureConfigures(self): armi._ignoreConfigures = old -class TestArmi(unittest.TestCase): +class TestArmiHighLevel(unittest.TestCase): """Tests for functions in the ARMI __init__ module.""" - def test_getDefaultPlugMan(self): + def test_getDefaultPluginManager(self): + """Test the default plugin manager. + + .. test:: The default application consists of a list of default plugins. + :id: T_ARMI_APP_PLUGINS + :tests: R_ARMI_APP_PLUGINS + """ pm = getDefaultPluginManager() pm2 = getDefaultPluginManager() self.assertNotEqual(pm, pm2) - self.assertIn(cli.EntryPointsPlugin, pm.get_plugins()) + pluginsList = "".join([str(p) for p in pm.get_plugins()]) + + self.assertIn("BookkeepingPlugin", pluginsList) + self.assertIn("EntryPointsPlugin", pluginsList) + self.assertIn("NeutronicsPlugin", pluginsList) + self.assertIn("ReactorPlugin", pluginsList) def test_overConfigured(self): with self.assertRaises(RuntimeError): diff --git a/armi/tests/test_interfaces.py b/armi/tests/test_interfaces.py index b5924af5e..2ae280457 100644 --- a/armi/tests/test_interfaces.py +++ b/armi/tests/test_interfaces.py @@ -127,12 +127,17 @@ def test_isConvergedValueError(self): def test_isConverged(self): """Ensure TightCoupler.isConverged() works with float, 1D list, and ragged 2D list. + .. test:: The tight coupling logic is based around a convergence criteria. + :id: T_ARMI_OPERATOR_PHYSICS1 + :tests: R_ARMI_OPERATOR_PHYSICS + Notes ----- 2D lists can end up being ragged as assemblies can have different number of blocks. Ragged lists are easier to manage with lists as opposed to numpy.arrays, namely, their dimension is preserved. """ + # show a situation where it doesn't converge previousValues = { "float": 1.0, "list1D": [1.0, 2.0], @@ -147,6 +152,12 @@ def test_isConverged(self): self.interface.coupler.storePreviousIterationValue(previous) self.assertFalse(self.interface.coupler.isConverged(current)) + # show a situation where it DOES converge + previousValues = updatedValues + for previous, current in zip(previousValues.values(), updatedValues.values()): + self.interface.coupler.storePreviousIterationValue(previous) + self.assertTrue(self.interface.coupler.isConverged(current)) + def test_isConvergedRuntimeError(self): """Test to ensure 3D arrays do not work.""" previous = [[[1, 2, 3]], [[1, 2, 3]], [[1, 2, 3]]] diff --git a/armi/tests/test_lwrInputs.py b/armi/tests/test_lwrInputs.py index 1da99bf03..93c10a111 100644 --- a/armi/tests/test_lwrInputs.py +++ b/armi/tests/test_lwrInputs.py @@ -90,8 +90,9 @@ def loadLocs(o, locs): o = armi_init(fName=TEST_INPUT_TITLE + ".yaml") locsInput, locsDB = {}, {} loadLocs(o, locsInput) - o.operate() - o2 = db.loadOperator(TEST_INPUT_TITLE + ".h5", 0, 0) + with directoryChangers.TemporaryDirectoryChanger(): + o.operate() + o2 = db.loadOperator(TEST_INPUT_TITLE + ".h5", 0, 0) loadLocs(o2, locsDB) for indices, coordsInput in sorted(locsInput.items()): diff --git a/armi/tests/test_mpiFeatures.py b/armi/tests/test_mpiFeatures.py index 4ee46b494..abf16fa10 100644 --- a/armi/tests/test_mpiFeatures.py +++ b/armi/tests/test_mpiFeatures.py @@ -25,6 +25,7 @@ mpiexec.exe -n 2 python -m pytest armi/tests/test_mpiFeatures.py """ from distutils.spawn import find_executable +from unittest.mock import patch import os import unittest @@ -40,6 +41,7 @@ from armi.reactor.parameters import parameterDefinitions from armi.reactor.tests import test_reactors from armi.tests import ARMI_RUN_PATH, TEST_ROOT +from armi.tests import mockRunLogs from armi.utils import pathTools from armi.utils.directoryChangers import TemporaryDirectoryChanger @@ -97,12 +99,27 @@ def setUp(self): self.o = OperatorMPI(cs=self.old_op.cs) self.o.r = self.r + @patch("armi.operators.Operator.operate") @unittest.skipIf(context.MPI_SIZE <= 1 or MPI_EXE is None, "Parallel test only") - def test_basicOperatorMPI(self): - self.o.operate() + def test_basicOperatorMPI(self, mockOpMpi): + """Test we can drive a parallel operator. + + .. test:: Run a parallel operator. + :id: T_ARMI_OPERATOR_MPI0 + :tests: R_ARMI_OPERATOR_MPI + """ + with mockRunLogs.BufferLog() as mock: + self.o.operate() + self.assertIn("OperatorMPI.operate", mock.getStdout()) @unittest.skipIf(context.MPI_SIZE <= 1 or MPI_EXE is None, "Parallel test only") def test_primaryException(self): + """Test a custom interface that only fails on the main process. + + .. test:: Run a parallel operator that fails online on the main process. + :id: T_ARMI_OPERATOR_MPI1 + :tests: R_ARMI_OPERATOR_MPI + """ self.o.removeAllInterfaces() failer = FailingInterface1(self.o.r, self.o.cs) self.o.addInterface(failer) diff --git a/armi/tests/test_mpiParameters.py b/armi/tests/test_mpiParameters.py new file mode 100644 index 000000000..a5a6bd5e9 --- /dev/null +++ b/armi/tests/test_mpiParameters.py @@ -0,0 +1,116 @@ +# Copyright 2023 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 of the MPI portion of the Parameters class.""" +from distutils.spawn import find_executable +import unittest + +from armi import context +from armi.reactor import composites +from armi.reactor import parameters + +# determine if this is a parallel run, and MPI is installed +MPI_EXE = None +if find_executable("mpiexec.exe") is not None: + MPI_EXE = "mpiexec.exe" +elif find_executable("mpiexec") is not None: + MPI_EXE = "mpiexec" + + +class MockSyncPC(parameters.ParameterCollection): + pDefs = parameters.ParameterDefinitionCollection() + with pDefs.createBuilder( + default=0.0, location=parameters.ParamLocation.AVERAGE + ) as pb: + pb.defParam("param1", "units", "p1 description", categories=["cat1"]) + pb.defParam("param2", "units", "p2 description", categories=["cat2"]) + pb.defParam("param3", "units", "p3 description", categories=["cat3"]) + + +def makeComp(name): + """Helper method for MPI sync tests: mock up a Composite with a minimal param collections.""" + c = composites.Composite(name) + c.p = MockSyncPC() + return c + + +class SynchronizationTests(unittest.TestCase): + """Some tests that must be run with mpirun instead of the standard unittest system.""" + + def setUp(self): + self.r = makeComp("reactor") + self.r.core = makeComp("core") + self.r.add(self.r.core) + for ai in range(context.MPI_SIZE * 3): + a = makeComp("assembly{}".format(ai)) + self.r.core.add(a) + for bi in range(3): + a.add(makeComp("block{}-{}".format(ai, bi))) + + self.comps = [self.r.core] + self.r.core.getChildren(deep=True) + + @unittest.skipIf(context.MPI_SIZE <= 1 or MPI_EXE is None, "Parallel test only") + def test_noConflicts(self): + """Make sure sync works across processes. + + .. test:: Synchronize a reactor's state across processes. + :id: T_ARMI_CMP_MPI0 + :tests: R_ARMI_CMP_MPI + """ + _syncCount = self.r.syncMpiState() + + for ci, comp in enumerate(self.comps): + if ci % context.MPI_SIZE == context.MPI_RANK: + comp.p.param1 = (context.MPI_RANK + 1) * 30.0 + else: + self.assertNotEqual((context.MPI_RANK + 1) * 30.0, comp.p.param1) + + syncCount = self.r.syncMpiState() + self.assertEqual(len(self.comps), syncCount) + + for ci, comp in enumerate(self.comps): + self.assertEqual((ci % context.MPI_SIZE + 1) * 30.0, comp.p.param1) + + @unittest.skipIf(context.MPI_SIZE <= 1 or MPI_EXE is None, "Parallel test only") + def test_withConflicts(self): + """Test conflicts arise correctly if we force a conflict. + + .. test:: Raise errors when there are conflicts across processes. + :id: T_ARMI_CMP_MPI1 + :tests: R_ARMI_CMP_MPI + """ + self.r.core.p.param1 = (context.MPI_RANK + 1) * 99.0 + with self.assertRaises(ValueError): + self.r.syncMpiState() + + @unittest.skipIf(context.MPI_SIZE <= 1 or MPI_EXE is None, "Parallel test only") + def test_withConflictsButSameValue(self): + """Test that conflicts are ignored if the values are the same. + + .. test:: Don't raise errors when multiple processes make the same changes. + :id: T_ARMI_CMP_MPI2 + :tests: R_ARMI_CMP_MPI + """ + self.r.core.p.param1 = (context.MPI_SIZE + 1) * 99.0 + self.r.syncMpiState() + self.assertEqual((context.MPI_SIZE + 1) * 99.0, self.r.core.p.param1) + + @unittest.skipIf(context.MPI_SIZE <= 1 or MPI_EXE is None, "Parallel test only") + def test_conflictsMaintainWithStateRetainer(self): + """Test that the state retainer fails correctly when it should.""" + with self.r.retainState(parameters.inCategory("cat2")): + for _, comp in enumerate(self.comps): + comp.p.param2 = 99 * context.MPI_RANK + + with self.assertRaises(ValueError): + self.r.syncMpiState() diff --git a/armi/tests/test_notebooks.py b/armi/tests/test_notebooks.py index b4eb4e31a..e1ce2848a 100644 --- a/armi/tests/test_notebooks.py +++ b/armi/tests/test_notebooks.py @@ -36,6 +36,9 @@ def test_runParamSweep(self): def test_runDataModel(self): runNotebook(os.path.join(TUTORIALS, "data_model.ipynb")) + # Do some cleanup because some code run in the notebook doesn't honor the + # TempDirectoryChanger + os.remove(os.path.join(TUTORIALS, "anl-afci-177.h5")) def runNotebook(filename): diff --git a/armi/tests/test_plugins.py b/armi/tests/test_plugins.py index ede37c965..d63b4957f 100644 --- a/armi/tests/test_plugins.py +++ b/armi/tests/test_plugins.py @@ -14,13 +14,175 @@ """Provides functionality for testing implementations of plugins.""" import unittest +from copy import deepcopy from typing import Optional import yamlize +from armi import context +from armi import getApp +from armi import getPluginManagerOrFail from armi import interfaces from armi import plugins from armi import settings +from armi import utils +from armi.physics.neutronics import NeutronicsPlugin +from armi.reactor.blocks import Block +from armi.reactor.flags import Flags +from armi.reactor.tests.test_reactors import loadTestReactor, TEST_ROOT + + +class PluginFlags1(plugins.ArmiPlugin): + """Simple Plugin that defines a single, new flag.""" + + @staticmethod + @plugins.HOOKIMPL + def defineFlags(): + """Function to provide new Flags definitions.""" + return {"SUPER_FLAG": utils.flags.auto()} + + +class TestPluginRegistration(unittest.TestCase): + def setUp(self): + """ + Manipulate the standard App. We can't just configure our own, since the + pytest environment bleeds between tests. + """ + self._backupApp = deepcopy(getApp()) + + def tearDown(self): + """Restore the App to its original state.""" + import armi + + armi._app = self._backupApp + context.APP_NAME = "armi" + + def test_defineFlags(self): + """Define a new flag using the plugin defineFlags() method. + + .. test:: Define a new, unique flag through the plugin pathway. + :id: T_ARMI_FLAG_EXTEND1 + :tests: R_ARMI_FLAG_EXTEND + + .. test:: Load a plugin into an app and show it is loaded. + :id: T_ARMI_PLUGIN_REGISTER + :tests: R_ARMI_PLUGIN + """ + app = getApp() + + # show the new plugin isn't loaded yet + pluginNames = [p[0] for p in app.pluginManager.list_name_plugin()] + self.assertNotIn("PluginFlags1", pluginNames) + + # show the flag doesn't exist yet + with self.assertRaises(AttributeError): + Flags.SUPER_FLAG + + # load the plugin + app.pluginManager.register(PluginFlags1) + + # show the new plugin is loaded now + pluginNames = [p[0] for p in app.pluginManager.list_name_plugin()] + self.assertIn("PluginFlags1", pluginNames) + + # force-register new flags from the new plugin + app._pluginFlagsRegistered = False + app.registerPluginFlags() + + # show the flag exists now + self.assertEqual(type(Flags.SUPER_FLAG._value), int) + + +class TestPluginBasics(unittest.TestCase): + def test_defineParameters(self): + """Test that the default ARMI plugins are correctly defining parameters. + + .. test:: ARMI plugins define parameters, which appear on a new Block. + :id: T_ARMI_PLUGIN_PARAMS + :tests: R_ARMI_PLUGIN_PARAMS + """ + # create a block + b = Block("fuel", height=10.0) + + # unless a plugin has registerd a param, it doesn't exist + with self.assertRaises(AttributeError): + b.p.fakeParam + + # Check the default values of parameters defined by the neutronics plugin + self.assertIsNone(b.p.axMesh) + self.assertEqual(b.p.flux, 0) + self.assertEqual(b.p.power, 0) + self.assertEqual(b.p.pdens, 0) + + # Check the default values of parameters defined by the fuel peformance plugin + self.assertEqual(b.p.gasPorosity, 0) + self.assertEqual(b.p.liquidPorosity, 0) + + def test_exposeInterfaces(self): + """Make sure that the exposeInterfaces hook is properly implemented. + + .. test:: Plugins can add interfaces to the interface stack. + :id: T_ARMI_PLUGIN_INTERFACES0 + :tests: R_ARMI_PLUGIN_INTERFACES + """ + plugin = NeutronicsPlugin() + + cs = settings.Settings() + results = plugin.exposeInterfaces(cs) + + # each plugin should return a list + self.assertIsInstance(results, list) + self.assertGreater(len(results), 0) + for result in results: + # Make sure all elements in the list satisfy the constraints of the hookspec + self.assertIsInstance(result, tuple) + self.assertEqual(len(result), 3) + + order, interface, kwargs = result + + self.assertIsInstance(order, (int, float)) + self.assertTrue(issubclass(interface, interfaces.Interface)) + self.assertIsInstance(kwargs, dict) + + def test_pluginsExposeInterfaces(self): + """Make sure that plugins properly expose their interfaces, by checking some + known examples. + + .. test:: Check that some known plugins correctly add interfaces to the stack. + :id: T_ARMI_PLUGIN_INTERFACES1 + :tests: R_ARMI_PLUGIN_INTERFACES + """ + # generate a test operator, with a full set of interfaces from plugsin + o = loadTestReactor(TEST_ROOT)[0] + pm = getPluginManagerOrFail() + + # test the plugins were generated + plugins = pm.get_plugins() + self.assertGreater(len(plugins), 0) + + # test interfaces were generated from those plugins + ints = o.interfaces + self.assertGreater(len(ints), 0) + + # test that certain plugins exist and correctly registered their interfaces + pluginStrings = " ".join([str(p) for p in plugins]) + interfaceStrings = " ".join([str(i) for i in ints]) + + # Test that the BookkeepingPlugin registered the DatabaseInterface + self.assertIn("BookkeepingPlugin", pluginStrings) + self.assertIn("DatabaseInterface", interfaceStrings) + + # Test that the BookkeepingPlugin registered the history interface + self.assertIn("BookkeepingPlugin", pluginStrings) + self.assertIn("history", interfaceStrings) + + # Test that the EntryPointsPlugin registered the main interface + self.assertIn("EntryPointsPlugin", pluginStrings) + self.assertIn("main", interfaceStrings) + + # Test that the FuelHandlerPlugin registered the fuelHandler interface + self.assertIn("FuelHandlerPlugin", pluginStrings) + self.assertIn("fuelHandler", interfaceStrings) class TestPlugin(unittest.TestCase): @@ -62,8 +224,7 @@ def test_exposeInterfaces(self): # each plugin should return a list self.assertIsInstance(results, list) for result in results: - # Make sure that all elements in the list satisfy the constraints of the - # hookspec + # Make sure all elements in the list satisfy the constraints of the hookspec self.assertIsInstance(result, tuple) self.assertEqual(len(result), 3) diff --git a/armi/tests/test_runLog.py b/armi/tests/test_runLog.py index 15e5c1011..c79bfd139 100644 --- a/armi/tests/test_runLog.py +++ b/armi/tests/test_runLog.py @@ -25,7 +25,12 @@ class TestRunLog(unittest.TestCase): def test_setVerbosityFromInteger(self): - """Test that the log verbosity can be set with an integer.""" + """Test that the log verbosity can be set with an integer. + + .. test:: The run log verbosity can be configured with an integer. + :id: T_ARMI_LOG0 + :tests: R_ARMI_LOG + """ log = runLog._RunLog(1) expectedStrVerbosity = "debug" verbosityRank = log.getLogVerbosityRank(expectedStrVerbosity) @@ -34,7 +39,13 @@ def test_setVerbosityFromInteger(self): self.assertEqual(verbosityRank, logging.DEBUG) def test_setVerbosityFromString(self): - """Test that the log verbosity can be set with a string.""" + """ + Test that the log verbosity can be set with a string. + + .. test:: The run log verbosity can be configured with a string. + :id: T_ARMI_LOG1 + :tests: R_ARMI_LOG + """ log = runLog._RunLog(1) expectedStrVerbosity = "error" verbosityRank = log.getLogVerbosityRank(expectedStrVerbosity) @@ -79,6 +90,7 @@ def test_parentRunLogging(self): log.log("debug", "You shouldn't see this.", single=False, label=None) log.log("warning", "Hello, ", single=False, label=None) log.log("error", "world!", single=False, label=None) + log.logger.flush() log.logger.close() runLog.close(99) @@ -98,7 +110,12 @@ def test_getWhiteSpace(self): self.assertEqual(space1, space9) def test_warningReport(self): - """A simple test of the warning tracking and reporting logic.""" + """A simple test of the warning tracking and reporting logic. + + .. test:: Generate a warning report after a simulation is complete. + :id: T_ARMI_LOG2 + :tests: R_ARMI_LOG + """ # create the logger and do some logging log = runLog.LOG = runLog._RunLog(321) log.startLog("test_warningReport") @@ -140,7 +157,12 @@ def test_warningReport(self): log.logger = backupLog def test_warningReportInvalid(self): - """A test of warningReport in an invalid situation.""" + """A test of warningReport in an invalid situation. + + .. test:: Test an important edge case for a warning report. + :id: T_ARMI_LOG3 + :tests: R_ARMI_LOG + """ # create the logger and do some logging testName = "test_warningReportInvalid" log = runLog.LOG = runLog._RunLog(323) @@ -206,7 +228,16 @@ def validate_loggers(log): runLog.close(0) def test_setVerbosity(self): - """Let's test the setVerbosity() method carefully.""" + """Let's test the setVerbosity() method carefully. + + .. test:: The run log has configurable verbosity. + :id: T_ARMI_LOG4 + :tests: R_ARMI_LOG + + .. test:: The run log can log to stream. + :id: T_ARMI_LOG_IO0 + :tests: R_ARMI_LOG_IO + """ with mockRunLogs.BufferLog() as mock: # we should start with a clean slate self.assertEqual("", mock.getStdout()) @@ -249,9 +280,15 @@ def test_setVerbosity(self): self.assertEqual(runLog.LOG.getVerbosity(), logging.WARNING) def test_setVerbosityBeforeStartLog(self): - """The user/dev may accidentally call ``setVerbosity()`` before ``startLog()``, this should be mostly supportable.""" + """The user/dev may accidentally call ``setVerbosity()`` before ``startLog()``, + this should be mostly supportable. This is just an edge case. + + .. test:: Test that we support the user setting log verbosity BEFORE the logging starts. + :id: T_ARMI_LOG5 + :tests: R_ARMI_LOG + """ with mockRunLogs.BufferLog() as mock: - # we should start with a clean slate + # we should start with a clean slate, before debug logging self.assertEqual("", mock.getStdout()) runLog.LOG.setVerbosity(logging.DEBUG) runLog.LOG.startLog("test_setVerbosityBeforeStartLog") @@ -262,6 +299,19 @@ def test_setVerbosityBeforeStartLog(self): self.assertIn("hi", mock.getStdout()) mock.emptyStdout() + # we should start with a clean slate, before info loggin + self.assertEqual("", mock.getStdout()) + runLog.LOG.setVerbosity(logging.INFO) + runLog.LOG.startLog("test_setVerbosityBeforeStartLog2") + + # we should start at info level, and that should be working correctly + self.assertEqual(runLog.LOG.getVerbosity(), logging.INFO) + runLog.debug("nope") + runLog.info("hi") + self.assertIn("hi", mock.getStdout()) + self.assertNotIn("nope", mock.getStdout()) + mock.emptyStdout() + def test_callingStartLogMultipleTimes(self): """Calling startLog() multiple times will lead to multiple output files, but logging should still work.""" with mockRunLogs.BufferLog() as mock: @@ -307,7 +357,17 @@ def test_callingStartLogMultipleTimes(self): mock.emptyStdout() def test_concatenateLogs(self): - """Simple test of the concat logs function.""" + """ + Simple test of the concat logs function. + + .. test:: The run log combines logs from different processes. + :id: T_ARMI_LOG_MPI + :tests: R_ARMI_LOG_MPI + + .. test:: The run log can log to file. + :id: T_ARMI_LOG_IO1 + :tests: R_ARMI_LOG_IO + """ with TemporaryDirectoryChanger(): # create the log dir logDir = "test_concatenateLogs" @@ -351,7 +411,12 @@ def test_concatenateLogs(self): self.assertFalse(os.path.exists(stderrFile)) def test_createLogDir(self): - """Test the createLogDir() method.""" + """Test the createLogDir() method. + + .. test:: Test that log directories can be created for logging output files. + :id: T_ARMI_LOG6 + :tests: R_ARMI_LOG + """ with TemporaryDirectoryChanger(): logDir = "test_createLogDir" self.assertFalse(os.path.exists(logDir)) @@ -384,6 +449,12 @@ def test_allowStopDuplicates(self): self.assertEqual(len(self.rl.filters), 1) def test_write(self): + """Test that we can write text to the logger output stream. + + .. test:: Write logging text to the logging stream and/or file. + :id: T_ARMI_LOG7 + :tests: R_ARMI_LOG + """ # divert the logging to a stream, to make testing easier stream = StringIO() handler = logging.StreamHandler(stream) diff --git a/armi/tests/test_tests.py b/armi/tests/test_tests.py index 938cd03a8..25f471bf4 100644 --- a/armi/tests/test_tests.py +++ b/armi/tests/test_tests.py @@ -18,7 +18,7 @@ from armi import tests -class Test_CompareFiles(unittest.TestCase): +class TestCompareFiles(unittest.TestCase): def test_compareFileLine(self): expected = "oh look, a number! 3.14 and some text and another number 1.5" diff --git a/armi/tests/test_user_plugins.py b/armi/tests/test_user_plugins.py index 8e12b2b2d..526797e5b 100644 --- a/armi/tests/test_user_plugins.py +++ b/armi/tests/test_user_plugins.py @@ -34,6 +34,7 @@ class UserPluginFlags(plugins.UserPlugin): @staticmethod @plugins.HOOKIMPL def defineFlags(): + """Function to provide new Flags definitions.""" return {"SPECIAL": utils.flags.auto()} @@ -43,6 +44,7 @@ class UserPluginFlags2(plugins.UserPlugin): @staticmethod @plugins.HOOKIMPL def defineFlags(): + """Function to provide new Flags definitions.""" return {"FLAG2": utils.flags.auto()} @@ -52,6 +54,7 @@ class UserPluginFlags3(plugins.UserPlugin): @staticmethod @plugins.HOOKIMPL def defineFlags(): + """Function to provide new Flags definitions.""" return {"FLAG3": utils.flags.auto()} @@ -74,6 +77,7 @@ class UserPluginBadDefinesSettings(plugins.UserPlugin): @staticmethod @plugins.HOOKIMPL def defineSettings(): + """Define settings for the plugin.""" return [1, 2, 3] @@ -83,6 +87,7 @@ class UserPluginBadDefineParameterRenames(plugins.UserPlugin): @staticmethod @plugins.HOOKIMPL def defineParameterRenames(): + """Return a mapping from old parameter names to new parameter names.""" return {"oldType": "type"} @@ -96,6 +101,7 @@ class UserPluginOnProcessCoreLoading(plugins.UserPlugin): @staticmethod @plugins.HOOKIMPL def onProcessCoreLoading(core, cs, dbLoad): + """Function to call whenever a Core object is newly built.""" blocks = core.getBlocks(Flags.FUEL) for b in blocks: b.p.height += 1.0 @@ -110,6 +116,7 @@ class UpInterface(interfaces.Interface): name = "UpInterface" def interactEveryNode(self, cycle, node): + """Logic to be carried out at every time node in the simulation.""" self.r.core.p.power += 100 @@ -119,6 +126,7 @@ class UserPluginWithInterface(plugins.UserPlugin): @staticmethod @plugins.HOOKIMPL def exposeInterfaces(cs): + """Function for exposing interface(s) to other code.""" return [ interfaces.InterfaceInfo( interfaces.STACK_ORDER.PREPROCESSING, UpInterface, {"enabled": True} diff --git a/armi/tests/tutorials/data_model.ipynb b/armi/tests/tutorials/data_model.ipynb index 2e88a1b52..b71c61cc9 100644 --- a/armi/tests/tutorials/data_model.ipynb +++ b/armi/tests/tutorials/data_model.ipynb @@ -516,7 +516,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "With this `CaseSettings` object, we could create a brand new `Case` and `Operator` and do all sorts of magic. This way of interacting with ARMI is rather advanced, and beyond the scope of this tutorial." + "With this `Settings` object, we could create a brand new `Case` and `Operator` and do all sorts of magic. This way of interacting with ARMI is rather advanced, and beyond the scope of this tutorial." ] }, { diff --git a/armi/utils/asciimaps.py b/armi/utils/asciimaps.py index 127dc6afe..20cee223f 100644 --- a/armi/utils/asciimaps.py +++ b/armi/utils/asciimaps.py @@ -40,7 +40,6 @@ but in other geometries (like hex), it is a totally different coordinate system. - See Also -------- armi.reactor.grids : More powerful, nestable lattices with specific dimensions @@ -97,8 +96,21 @@ def __init__(self): def writeAscii(self, stream): """Write out the ascii representation.""" + stream.write(self.__str__()) + + def __str__(self): + """Build the human-readable ASCII string representing the lattice map. + + This method is useful for quickly printing out a lattice map. + + Returns + ------- + str : The custom ARMI ASCII-art-style string representing the map. + """ + # Do some basic validation if not self.asciiLines: - raise ValueError("Cannot write ASCII map before ASCII lines are processed") + raise ValueError("Cannot write ASCII map before ASCII lines are processed.") + if len(self.asciiOffsets) != len(self.asciiLines): runLog.error(f"AsciiLines: {self.asciiLines}") runLog.error(f"Offsets: {self.asciiOffsets}") @@ -106,17 +118,27 @@ def writeAscii(self, stream): f"Inconsistent lines ({len(self.asciiLines)}) " f"and offsets ({len(self.asciiOffsets)})" ) + + # Finally, build the string representation. + txt = "" fmt = f"{{val:{len(self._placeholder)}s}}" for offset, line in zip(self.asciiOffsets, self.asciiLines): data = [fmt.format(val=v) for v in line] line = self._spacer * offset + self._spacer.join(data) + "\n" - stream.write(line) + txt += line + + return txt def readAscii(self, text): """ Read ascii representation from a stream. Update placeholder size according to largest thing read. + + Parameters + ---------- + text : str + Custom string that describes the ASCII map of the core. """ text = text.strip().splitlines() @@ -127,6 +149,7 @@ def readAscii(self, text): self.asciiLines.append(columns) if len(columns) > self._asciiMaxCol: self._asciiMaxCol = len(columns) + self._asciiMaxLine = li + 1 self._updateDimensionsFromAsciiLines() self._asciiLinesToIndices() @@ -212,10 +235,10 @@ def gridContentsToAscii(self): # if entire newline is wiped out, it's a full row of placeholders! # but oops this actually still won't work. Needs more work when # doing pure rows from data is made programmatically. - # newLines.append(line) raise ValueError( "Cannot write asciimaps with blank rows from pure data yet." ) + if not newLines: raise ValueError("No data found") self.asciiLines = newLines @@ -251,6 +274,7 @@ def __setitem__(self, ijKey, item): def _makeOffsets(self): """Build offsets.""" + raise NotImplementedError def items(self): return self.asciiLabelByIndices.items() @@ -296,7 +320,7 @@ def _getIJFromColRow(self, columnNum, lineNum): def _makeOffsets(self): """Cartesian grids have 0 offset on all lines.""" - AsciiMap._makeOffsets(self) + self.asciiOffsets = [] for _line in self.asciiLines: self.asciiOffsets.append(0) @@ -308,9 +332,9 @@ class AsciiMapHexThirdFlatsUp(AsciiMap): """ Hex ascii map for 1/3 core flats-up map. - Indices start with (0,0) in the bottom left (origin). - i increments on the 30-degree ray - j increments on the 90-degree ray + - Indices start with (0,0) in the bottom left (origin). + - i increments on the 30-degree ray + - j increments on the 90-degree ray In all flats-up hex maps, i increments by 2*col for each col and j decrements by col from the base. @@ -319,7 +343,6 @@ class AsciiMapHexThirdFlatsUp(AsciiMap): there are 2 ascii lines for every j index (jaggedly). Lines are read from the bottom of the ascii map up in this case. - """ def _asciiLinesToIndices(self): @@ -396,7 +419,7 @@ def _makeOffsets(self): # renomalize the offsets to start at 0 minOffset = min(self.asciiOffsets) - for li, (_, offset) in enumerate(zip(self.asciiLines, self.asciiOffsets)): + for offset in self.asciiOffsets: newOffsets.append(offset - minOffset) self.asciiOffsets = newOffsets @@ -527,20 +550,16 @@ class AsciiMapHexFullTipsUp(AsciiMap): """ Full hex with tips up of the smaller cells. - I axis is pure horizontal here - J axis is 60 degrees up. (upper right corner) - - (0,0) is in the center of the hexagon. + - I axis is pure horizontal here + - J axis is 60 degrees up. (upper right corner) + - (0,0) is in the center of the hexagon. Frequently used for pins inside hex assemblies. - This does not currently support omitted positions on - the hexagonal corners. + This does not currently support omitted positions on the hexagonal corners. - In this geometry, the outline-defining _ijMax is equal - to I at the far right of the hex. Thus, ijMax represents - the number of positions from the center to the outer edge - towards any of the 6 corners. + In this geometry, the outline-defining _ijMax is equal to I at the far right of the hex. Thus, ijMax represents the + number of positions from the center to the outer edge towards any of the 6 corners. """ def _asciiLinesToIndices(self): @@ -560,7 +579,7 @@ def _getIJFromColAndBase(self, columnNum, iBase, jBase): Indices simply increment from the base across the rows. """ - return iBase + columnNum, jBase + return iBase + columnNum + jBase, -(iBase + columnNum) def _getIJFromColRow(self, columnNum, lineNum): """ @@ -568,8 +587,7 @@ def _getIJFromColRow(self, columnNum, lineNum): Notes ----- - Not used in reading from file b/c inefficient/repeated base calc - but required for writing from ij data + Not used in reading from file b/c inefficient/repeated base calc but required for writing from ij data. """ iBase, jBase = self._getIJBaseByAsciiLine(lineNum) return self._getIJFromColAndBase(columnNum, iBase, jBase) @@ -580,8 +598,7 @@ def _getIJBaseByAsciiLine(self, asciiLineNum): Upper left is shifted by (size-1)//2 - for a 19-line grid, we have the top left as (-18,9) - and then: (-17, 8), (-16, 7), ... + for a 19-line grid, we have the top left as (-18,9) and then: (-17, 8), (-16, 7), ... """ shift = self._ijMax iBase = -shift * 2 + asciiLineNum @@ -590,8 +607,7 @@ def _getIJBaseByAsciiLine(self, asciiLineNum): def _updateDimensionsFromAsciiLines(self): """Update dimension metadata when reading ascii.""" - # ijmax here can be inferred directly from the max number of columns - # in the asciimap text + # ijmax here can be inferred directly from the max number of columns in the asciimap text self._ijMax = (self._asciiMaxCol - 1) // 2 def _updateDimensionsFromData(self): @@ -610,7 +626,7 @@ def _getLineNumsToWrite(self): def _makeOffsets(self): """Full hex tips-up grids have linearly incrementing offset.""" - AsciiMap._makeOffsets(self) + self.asciiOffsets = [] for li, _line in enumerate(self.asciiLines): self.asciiOffsets.append(li) diff --git a/armi/utils/codeTiming.py b/armi/utils/codeTiming.py index 543b00132..b205bbe11 100644 --- a/armi/utils/codeTiming.py +++ b/armi/utils/codeTiming.py @@ -16,6 +16,7 @@ import copy import os import time +import functools def timed(*args): @@ -36,9 +37,7 @@ def mymethod2(stuff) """ def time_decorator(func): - time_decorator.__doc__ = func.__doc__ - time_decorator.__name__ = func.__name__ - + @functools.wraps(func) def time_wrapper(*args, **kwargs): generated_name = "::".join( [ diff --git a/armi/utils/densityTools.py b/armi/utils/densityTools.py index 2d6dc13ac..94b46a82d 100644 --- a/armi/utils/densityTools.py +++ b/armi/utils/densityTools.py @@ -24,13 +24,21 @@ def getNDensFromMasses(rho, massFracs, normalize=False): """ Convert density (g/cc) and massFracs vector into a number densities vector (#/bn-cm). + .. impl:: Number densities are retrievable from masses. + :id: I_ARMI_UTIL_MASS2N_DENS + :implements: R_ARMI_UTIL_MASS2N_DENS + + Loops over all provided nuclides (given as keys in the ``massFracs`` vector) and calculates + number densities of each, at a given material ``density``. Mass fractions can be provided + either as normalized to 1, or as unnormalized with subsequent normalization calling + ``normalizeNuclideList`` via the ``normalize`` flag. + Parameters ---------- rho : float density in (g/cc) massFracs : dict - vector of mass fractions -- normalized to 1 -- keyed by their nuclide - name + vector of mass fractions -- normalized to 1 -- keyed by their nuclide name Returns ------- @@ -168,6 +176,25 @@ def formatMaterialCard( """ Formats nuclides and densities into a MCNP material card. + .. impl:: Create MCNP material card. + :id: I_ARMI_UTIL_MCNP_MAT_CARD + :implements: R_ARMI_UTIL_MCNP_MAT_CARD + + Loops over a vector of nuclides (of type ``nuclideBase``) provided in ``densities`` and + formats them into a list of strings consistent with MCNP material card syntax, skipping + dummy nuclides and LFPs. + + A ``matNum`` may optionally be provided for the created material card: if not provided, it + is left blank. The desired number of significant figures for the created card can be + optionally provided by ``sigFigs``. Nuclides whose number density falls below a threshold + (optionally specified by ``minDens``) are set to the threshold value. + + The boolean ``mcnp6Compatible`` may optionally be provided to include the nuclide library at + the end of the vector of individual nuclides using the "nlib=" syntax leveraged by MCNP. If + this boolean is turned on, the associated value ``mcnpLibrary`` should generally also be + provided, as otherwise, the library will be left blank in the resulting material card + string. + Parameters ---------- densities : dict @@ -196,6 +223,7 @@ def formatMaterialCard( mCard = ["m{matNum}\n".format(matNum=matNum)] else: mCard = ["m{}\n"] + for nuc, dens in sorted(densities.items()): # skip LFPs and Dummies. if isinstance(nuc, (nuclideBases.LumpNuclideBase)): @@ -214,6 +242,7 @@ def formatMaterialCard( if mcnp6Compatible: mCard.append(" nlib={lib}c\n".format(lib=mcnpLibrary)) + return mCard @@ -250,7 +279,14 @@ def filterNuclideList(nuclideVector, nuclides): def normalizeNuclideList(nuclideVector, normalization=1.0): """ - normalize the nuclide vector. + Normalize the nuclide vector. + + .. impl:: Normalize nuclide vector. + :id: I_ARMI_UTIL_DENS_TOOLS + :implements: R_ARMI_UTIL_DENS_TOOLS + + Given a vector of nuclides ``nuclideVector`` indexed by nuclide identifiers (``nucNames`` or ``nuclideBases``), + normalizes to the provided ``normalization`` value. Parameters ---------- @@ -285,15 +321,29 @@ def expandElementalMassFracsToNuclides( ----- This indirectly updates number densities through mass fractions. + .. impl:: Expand mass fractions to nuclides. + :id: I_ARMI_UTIL_EXP_MASS_FRACS + :implements: R_ARMI_UTIL_EXP_MASS_FRACS + + Given a vector of elements and nuclides with associated mass fractions (``massFracs``), + expands the elements in-place into a set of nuclides using + ``expandElementalNuclideMassFracs``. Isotopes to expand into are provided for each element + by specifying them with ``elementExpansionPairs``, which maps each element to a list of + particular NuclideBases; if left unspecified, all naturally-occurring isotopes are included. + + Explicitly specifying the expansion isotopes provides a way for particular + naturally-occurring isotopes to be excluded from the expansion, e.g. excluding O-18 from an + expansion of elemental oxygen. + Parameters ---------- massFracs : dict(str, float) - dictionary of nuclide or element names with mass fractions. - Elements will be expanded in place using natural isotopics. + dictionary of nuclide or element names with mass fractions. Elements will be expanded in + place using natural isotopics. elementExpansionPairs : (Element, [NuclideBase]) pairs - element objects to expand (from nuclidBase.element) and list - of NuclideBases to expand into (or None for all natural) + element objects to expand (from nuclidBase.element) and list of NuclideBases to expand into + (or None for all natural) """ # expand elements for element, isotopicSubset in elementExpansionPairs: diff --git a/armi/utils/dochelpers.py b/armi/utils/dochelpers.py deleted file mode 100644 index f0cc57d6a..000000000 --- a/armi/utils/dochelpers.py +++ /dev/null @@ -1,307 +0,0 @@ -# Copyright 2019 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. -""" -Helpers for sphinx documentation. - -Can be used by armi docs or docs of anything else that -can import armi. -""" -import datetime -import inspect -import os -import shutil -import subprocess -import sys - -from docutils import nodes, statemachine -from docutils.parsers.rst import Directive, directives - -APIDOC_DIR = ".apidocs" - - -def create_figure(path, caption=None, align=None, alt=None, width=None): - """ - This method is available within ``.. exec::``. It allows someone to create a figure with a - caption. - """ - rst = [".. figure:: {}".format(path)] - if align: - rst += [" :align: {}".format(align)] - if alt: - rst += [" :alt: {}".format(alt)] - if width: - rst += [" :width: {}".format(width)] - if caption: - rst += [""] - if caption: - rst += [" {}".format(caption)] - return rst - - -def create_table(rst_table, caption=None, align=None, widths=None, width=None): - """ - This method is available within ``.. exec::``. It allows someone to create a table with a - caption. - - The ``rst_table`` - """ - rst = [".. table:: {}".format(caption or "")] - if align: - rst += [" :align: {}".format(align)] - if width: - rst += [" :width: {}".format(width)] - if widths: - rst += [" :widths: {}".format(widths)] - rst += [""] - rst += [" " + line for line in rst_table.split("\n")] - return "\n".join(rst) - - -class ExecDirective(Directive): - """ - Execute the specified python code and insert the output into the document. - - The code is used as the body of a method, and must return a ``str``. The string result is - interpreted as reStructuredText. - - Error handling informed by https://docutils.sourceforge.io/docs/howto/rst-directives.html#error-handling - The self.error function should both inform the documentation builder of the error and also - insert an error into the built documentation. - - .. warning:: This only works on a single node in the doctree, so the rendered code - may not contain any new section names or labels. They will result in - ``WARNING: Unexpected section title`` warnings. - """ - - has_content = True - - def run(self): - try: - code = inspect.cleandoc( - """ - def usermethod(): - {} - """ - ).format("\n ".join(self.content)) - exec(code) - result = locals()["usermethod"]() - - if result is None: - - raise self.error( - "Return value needed! The body of your `.. exec::` is used as a " - "function call that must return a value." - ) - - para = nodes.container() - # tab_width = self.options.get('tab-width', self.state.document.settings.tab_width) - lines = statemachine.StringList(result.split("\n")) - self.state.nested_parse(lines, self.content_offset, para) - return [para] - except Exception as e: - docname = self.state.document.settings.env.docname - raise self.error( - "Unable to execute embedded doc code at {}:{} ... {}\n{}".format( - docname, self.lineno, datetime.datetime.now(), str(e) - ) - ) - - -class PyReverse(Directive): - """Runs pyreverse to generate UML for specified module name and options. - - The directive accepts the same arguments as pyreverse, except you should not specify - ``--project`` or ``-o`` (output format). These are automatically specified. - - If you pass ``-c`` to this, the figure generated is forced to be the className.png - like ``BurnMatrix.png``. For .gitignore purposes, this is a pain. Thus, we - auto-prefix ALL images generated by this directive with ``pyrev_``. - """ - - has_content = True - required_arguments = 1 - optional_arguments = 50 - option_spec = { - "alt": directives.unchanged, - "height": directives.length_or_percentage_or_unitless, - "width": directives.length_or_percentage_or_unitless, - "align": lambda arg: directives.choice(arg, ("left", "right", "center")), - "filename": directives.unchanged, - } - - def run(self): - try: - args = list(self.arguments) - args.append("--project") - args.append(f"{args[0]}") - args.append("-opng") - - # NOTE: cannot use "pylint.pyreverse.main.Run" because it calls `sys.exit`. - fig_name = self.options.get("filename", "classes_{}.png".format(args[0])) - command = [sys.executable, "-m", "pylint.pyreverse.main"] - print("Running {}".format(command + args)) - env = dict(os.environ) - # apply any runtime path mods to the pythonpath env variable (e.g. sys.path - # mods made during doc confs) - env["PYTHONPATH"] = os.pathsep.join(sys.path) - subprocess.check_call(command + args, env=env) - - try: - os.remove(os.path.join(APIDOC_DIR, fig_name)) - except OSError: - pass - - shutil.move(fig_name, APIDOC_DIR) - # add .gitignore helper prefix - shutil.move( - os.path.join(APIDOC_DIR, fig_name), - os.path.join(APIDOC_DIR, f"pyr_{fig_name}"), - ) - new_content = [f".. figure:: /{APIDOC_DIR}/pyr_{fig_name}"] - - # assume we don't need the packages_, and delete. - try: - os.remove("packages_{}.png".format(args[0])) - except OSError: - pass - - # pass the other args through (figure args like align) - for opt, val in self.options.items(): - if opt in ("filename",): - continue - new_content.append(" :{}: {}\n".format(opt, val)) - - new_content.append("\n") - - for line in self.content: - new_content.append(" " + line) - - para = nodes.container() - # tab_width = self.options.get('tab-width', self.state.document.settings.tab_width) - lines = statemachine.StringList(new_content) - self.state.nested_parse(lines, self.content_offset, para) - return [para] - except Exception as e: - docname = self.state.document.settings.env.docname - # add the error message directly to the built documentation and also tell the - # builder - raise self.error( - "Unable to execute embedded doc code at {}:{} ... {}\n{}".format( - docname, self.lineno, datetime.datetime.now(), str(e) - ) - ) - - -def generateParamTable(klass, fwParams, app=None): - """ - Return a string containing one or more restructured text list tables containing - parameter descriptions for the passed ArmiObject class. - - Parameters - ---------- - klass : ArmiObject subclass - The Class for which parameter tables should be generated - - fwParams : ParameterDefinitionCollection - A parameter definition collection containing the parameters that are always - defined for the passed ``klass``. The rest of the parameters come from the - plugins registered with the passed ``app`` - - app : App, optional - The ARMI-based application to draw plugins from. - - Notes - ----- - It would be nice to have better section labels between the different sources - but this cannot be done withing an ``exec`` directive in Sphinx so we settle - for just putting in anchors for hyperlinking to. - """ - from armi import apps - - if app is None: - app = apps.App() - - defs = {None: fwParams} - - app = apps.App() - for plugin in app.pluginManager.get_plugins(): - plugParams = plugin.defineParameters() - if plugParams is not None: - pDefs = plugParams.get(klass, None) - if pDefs is not None: - defs[plugin] = pDefs - - headerContent = """ - .. list-table:: {} Parameters from {{}} - :header-rows: 1 - :widths: 30 40 30 - - * - Name - - Description - - Units - """.format( - klass.__name__ - ) - - content = [] - - for plugin, pdefs in defs.items(): - srcName = plugin.__name__ if plugin is not None else "Framework" - content.append(f".. _{srcName}-{klass.__name__}-param-table:") - pluginContent = headerContent.format(srcName) - for pd in pdefs: - pluginContent += f""" * - {pd.name} - - {pd.description} - - {pd.units} - """ - content.append(pluginContent + "\n") - - return "\n".join(content) - - -def generatePluginSettingsTable(settings, pluginName): - """ - Return a string containing one or more restructured text list tables containing - settings descriptions for a plugin. - - Parameters - ---------- - settings : list of Settings - This is a list of settings definitions, typically returned by a - ``defineSettings`` plugin hook. - """ - headerContent = """ - .. list-table:: Settings defined in the {} - :header-rows: 1 - :widths: 20 10 50 20 - - * - Name - - Label - - Description - - Default Value - """.format( - pluginName - ) - - content = [f".. _{pluginName}-settings-table:"] - pluginContent = headerContent - for setting in settings: - default = None if setting.default == "" else setting.default - pluginContent += f""" * - ``{setting.name}`` - - {setting.label} - - {setting.description} - - {default} - """ - content.append(pluginContent + "\n") - return "\n".join(content) diff --git a/armi/utils/flags.py b/armi/utils/flags.py index 7b478ae0c..08ca04532 100644 --- a/armi/utils/flags.py +++ b/armi/utils/flags.py @@ -119,9 +119,22 @@ class Flag(metaclass=_FlagMeta): """ A collection of bitwise flags. - This is intended to emulate ``enum.Flag``, except with the possibility of extension - after the class has been defined. Most docs for ``enum.Flag`` should be relevant here, - but there are sure to be occasional differences. + This is intended to emulate ``enum.Flag``, except with the possibility of extension after the + class has been defined. Most docs for ``enum.Flag`` should be relevant here, but there are sure + to be occasional differences. + + .. impl:: No two flags have equivalence. + :id: I_ARMI_FLAG_DEFINE + :implements: R_ARMI_FLAG_DEFINE + + A bitwise flag class intended to emulate the standard library's ``enum.Flag``, with the + added functionality that it allows for extension after the class has been defined. Each Flag + is unique; no two Flags are equivalent. + + Note that while Python allows for arbitrary-width integers, exceeding the system-native + integer size can lead to challenges in storing data, e.g. in an HDF5 file. In this case, the + ``from_bytes()`` and ``to_bytes()`` methods are provided to represent a Flag's values in + smaller chunks so that writeability can be maintained. .. warning:: Python features arbitrary-width integers, allowing one to represent an @@ -216,11 +229,19 @@ def extend(cls, fields: Dict[str, Union[int, auto]]): This alters the class that it is called upon! Existing instances should see the new data, since classes are mutable. + .. impl:: Set of flags are extensible without loss of uniqueness. + :id: I_ARMI_FLAG_EXTEND0 + :implements: R_ARMI_FLAG_EXTEND + + A class method to extend a ``Flag`` with a vector of provided additional ``fields``, + with field names as keys, without loss of uniqueness. Values for the additional + ``fields`` can be explicitly specified, or an instance of ``auto`` can be supplied. + Parameters ---------- fields : dict - A dictionary containing field names as keys, and their desired values, or - an instance of ``auto`` as values. + A dictionary containing field names as keys, and their desired values, or an instance of + ``auto`` as values. Example ------- @@ -248,8 +269,8 @@ def to_bytes(self, byteorder="little"): This is useful when storing Flags in a data type of limited size. Python ints can be of arbitrary size, while most other systems can only represent integers - of 32 or 64 bits. For compatibiliy, this function allows to convert the flags to - a sequence of single-byte elements. + of 32 or 64 bits. For compatibility, this function allows to convert the flags + to a sequence of single-byte elements. Note that this uses snake_case to mimic the method on the Python-native int type. diff --git a/armi/utils/hexagon.py b/armi/utils/hexagon.py index 643b8d670..3831d43be 100644 --- a/armi/utils/hexagon.py +++ b/armi/utils/hexagon.py @@ -29,7 +29,19 @@ def area(pitch): - """Area of a hex given the flat-to-flat pitch.""" + """ + Area of a hex given the flat-to-flat pitch. + + .. impl:: Compute hexagonal area + :id: I_ARMI_UTIL_HEXAGON0 + :implements: R_ARMI_UTIL_HEXAGON + + Computes the area of a hexagon given the flat-to-flat ``pitch``. + + Notes + ----- + The pitch is the distance between the center of the hexagons in the lattice. + """ return SQRT3 / 2.0 * pitch**2 @@ -44,6 +56,10 @@ def side(pitch): \frac{s}{2}^2 + \frac{p}{2}^2 = s^2 which you can solve to find p = sqrt(3)*s + + Notes + ----- + The pitch is the distance between the center of the hexagons in the lattice. """ return pitch / SQRT3 @@ -78,6 +94,13 @@ def corners(rotation=0): def pitch(side): + """ + Calculate the pitch from the length of a hexagon side. + + Notes + ----- + The pitch is the distance between the center of the hexagons in the lattice. + """ return side * SQRT3 @@ -112,5 +135,13 @@ def numRingsToHoldNumCells(numCells): def numPositionsInRing(ring): - """Number of positions in ring (starting at 1) of a hex lattice.""" + """Number of positions in ring (starting at 1) of a hex lattice. + + .. impl:: Compute number of positions in a ring of a hex lattice + :id: I_ARMI_UTIL_HEXAGON1 + :implements: R_ARMI_UTIL_HEXAGON + + In a hexagonal lattice, calculate the number of positions in a given ``ring``. The number of + rings is indexed to 1, i.e. the centermost position in the lattice is ``ring=1``. + """ return (ring - 1) * 6 if ring != 1 else 1 diff --git a/armi/utils/mathematics.py b/armi/utils/mathematics.py index 7b4b3d0dc..afff43f4d 100644 --- a/armi/utils/mathematics.py +++ b/armi/utils/mathematics.py @@ -84,7 +84,7 @@ def convertToSlice(x, increment=False): Examples -------- - a = np.array([10, 11, 12, 13]) + >>> a = np.array([10, 11, 12, 13]) >>> convertToSlice(2) slice(2, 3, None) diff --git a/armi/utils/plotting.py b/armi/utils/plotting.py index cdfad1219..b915ece16 100644 --- a/armi/utils/plotting.py +++ b/armi/utils/plotting.py @@ -42,7 +42,6 @@ from armi import runLog from armi.bookkeeping import report from armi.materials import custom -from armi.nuclearDataIO.cccc.rtflux import RtfluxData from armi.reactor import grids from armi.reactor.components import Helix, Circle, DerivedShape from armi.reactor.components.basicShapes import Hexagon, Rectangle, Square @@ -1450,88 +1449,6 @@ def plotBlockDiagram( return os.path.abspath(fName) -def plotTriangleFlux( - rtfluxData: RtfluxData, - axialZ, - energyGroup, - hexPitch=math.sqrt(3.0), - hexSideSubdivisions=1, - imgFileExt=".png", -): - """ - Plot region total flux for one core-wide axial slice on triangular/hexagonal geometry. - - .. warning:: This will run on non-triangular meshes but will look wrong. - - Parameters - ---------- - rtfluxData : RtfluxData object - The RTFLUX/ATFLUX data object containing all read file data. - Alternatively, this could be a FIXSRC file object, - but only if FIXSRC.fixSrc is first renamed FIXSRC.triangleFluxes. - axialZ : int - The DIF3D axial node index of the core-wide slice to plot. - energyGroup : int - The energy group index to plot. - hexPitch: float, optional - The flat-to-flat hexagonal assembly pitch in this core. - By default, it is sqrt(3) so that the triangle edge length is 1 if hexSideSubdivisions=1. - hexSideSubdivisions : int, optional - By default, it is 1 so that the triangle edge length is 1 if hexPitch=sqrt(3). - imgFileExt : str, optional - The image file extension. - - Examples - -------- - >>> rtflux = rtflux.RtfluxStream.readBinary("RTFLUX") - >>> plotTriangleFlux(rtflux, axialZ=10, energyGroup=4) - """ - triHeightInCm = hexPitch / 2.0 / hexSideSubdivisions - sideLengthInCm = triHeightInCm / (math.sqrt(3.0) / 2.0) - s2InCm = sideLengthInCm / 2.0 - - vals = rtfluxData.groupFluxes[:, :, axialZ, energyGroup] - patches = [] - colorVals = [] - for i in range(vals.shape[0]): - for j in range(vals.shape[1]): - # use (i+j)%2 for rectangular meshing - flipped = i % 2 - xInCm = s2InCm * (i - j) - yInCm = triHeightInCm * j + sideLengthInCm / 2.0 / math.sqrt(3) * ( - 1 + flipped - ) - - flux = vals[i][j] - - if flux: - triangle = patches.mpatches.RegularPolygon( - (xInCm, yInCm), - 3, - radius=sideLengthInCm / math.sqrt(3), - orientation=math.pi * flipped, - linewidth=0.0, - ) - - patches.append(triangle) - colorVals.append(flux) - - collection = PatchCollection(patches, alpha=1.0, linewidths=(0,), edgecolors="none") - # add color map to this collection ONLY (pins, not ducts) - collection.set_array(numpy.array(colorVals)) - - plt.figure() - ax = plt.gca() - ax.add_collection(collection) - colbar = plt.colorbar(collection) - colbar.set_label("n/s/cm$^3$") - plt.ylabel("cm") - plt.xlabel("cm") - ax.autoscale_view() - plt.savefig("RTFLUX-z" + str(axialZ + 1) + "-g" + str(energyGroup + 1) + imgFileExt) - plt.close() - - def plotNucXs( isotxs, nucNames, xsNames, fName=None, label=None, noShow=False, title=None ): diff --git a/armi/utils/reportPlotting.py b/armi/utils/reportPlotting.py index 277f6cd47..e0b5ced3b 100644 --- a/armi/utils/reportPlotting.py +++ b/armi/utils/reportPlotting.py @@ -451,8 +451,6 @@ def _getNeutronicVals(r): ("Rx. Swing", r.core.p.rxSwing), ("Fast Flux Fr.", r.core.p.fastFluxFrAvg), ("Leakage", r.core.p.leakageFracTotal), - ("Void worth", r.core.p.voidWorth), - ("Doppler", r.core.p.doppler), ("Beta", r.core.p.beta), ("Peak flux", r.core.p.maxFlux), ] diff --git a/armi/utils/tests/test_asciimaps.py b/armi/utils/tests/test_asciimaps.py index 17fbc366c..b154b1b50 100644 --- a/armi/utils/tests/test_asciimaps.py +++ b/armi/utils/tests/test_asciimaps.py @@ -108,6 +108,7 @@ EX IC IC PC OC """ +# This is a "corners-up" hexagonal map. HEX_FULL_MAP = """- - - - - - - - - 1 1 1 1 1 1 1 1 1 4 - - - - - - - - 1 1 1 1 1 1 1 1 1 1 1 - - - - - - - 1 8 1 1 1 1 1 1 1 1 1 1 @@ -129,6 +130,7 @@ 1 1 1 1 1 1 1 1 1 1 """ +# This is a "flats-up" hexagonal map. HEX_FULL_MAP_FLAT = """- - - - ORS ORS ORS - - - ORS ORS ORS ORS - - - ORS IRS IRS IRS ORS @@ -289,15 +291,69 @@ def test_troublesomeHexThird(self): self.assertEqual(asciimap[5, 0], "TG") - def test_hexFull(self): - """Test sample full hex map against known answers.""" - # hex map is 19 rows tall, so it should go from -9 to 9 + def test_hexFullCornersUpSpotCheck(self): + """Spot check some hex grid coordinates are what they should be.""" + # The corners and a central line of non-zero values. + corners_map = """- - - - - - - - - 3 0 0 0 0 0 0 0 0 2 + - - - - - - - - 0 0 0 0 0 0 0 0 0 0 0 + - - - - - - - 0 0 0 0 0 0 0 0 0 0 0 0 + - - - - - - 0 0 0 0 0 0 0 0 0 0 0 0 0 + - - - - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + - - - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + - - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + - - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + - 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 4 0 0 0 0 0 0 0 0 0 1 2 3 4 5 6 7 0 1 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 + 5 0 0 0 0 0 0 0 0 6 + """ + + # hex map is 19 rows tall: from -9 to 9 asciimap = asciimaps.AsciiMapHexFullTipsUp() - with io.StringIO() as stream: - stream.write(HEX_FULL_MAP) - stream.seek(0) - asciimap.readAscii(stream.read()) + asciimap.readAscii(corners_map) + + # verify the corners + self.assertEqual(asciimap[9, -9], "1") + self.assertEqual(asciimap[9, 0], "2") + self.assertEqual(asciimap[0, 9], "3") + self.assertEqual(asciimap[-9, 9], "4") + self.assertEqual(asciimap[-9, 0], "5") + self.assertEqual(asciimap[0, -9], "6") + + # verify a line of coordinates + self.assertEqual(asciimap[0, 0], "0") + self.assertEqual(asciimap[1, -1], "1") + self.assertEqual(asciimap[2, -2], "2") + self.assertEqual(asciimap[3, -3], "3") + self.assertEqual(asciimap[4, -4], "4") + self.assertEqual(asciimap[5, -5], "5") + self.assertEqual(asciimap[6, -6], "6") + self.assertEqual(asciimap[7, -7], "7") + + def test_hexFullCornersUp(self): + """Test sample full hex map (with hex corners up) against known answers.""" + # hex map is 19 rows tall: from -9 to 9 + asciimap = asciimaps.AsciiMapHexFullTipsUp() + asciimap.readAscii(HEX_FULL_MAP) + + # spot check some values in the map + self.assertIn("7 1 1 1 1 1 1 1 1 0", str(asciimap)) + self.assertEqual(asciimap[-9, 9], "7") + self.assertEqual(asciimap[-8, 0], "6") + self.assertEqual(asciimap[-1, 0], "2") + self.assertEqual(asciimap[-1, 8], "8") + self.assertEqual(asciimap[0, -6], "3") + self.assertEqual(asciimap[0, 0], "0") + self.assertEqual(asciimap[9, 0], "4") + # also test writing from pure data (vs. reading) gives the exact same map asciimap2 = asciimaps.AsciiMapHexFullTipsUp() for ij, spec in asciimap.items(): asciimap2.asciiLabelByIndices[ij] = spec @@ -308,13 +364,40 @@ def test_hexFull(self): stream.seek(0) output = stream.read() self.assertEqual(output, HEX_FULL_MAP) - self.assertEqual(asciimap[0, 0], "0") - self.assertEqual(asciimap[0, -1], "2") - self.assertEqual(asciimap[0, -8], "6") - self.assertEqual(asciimap[0, 9], "4") - self.assertEqual(asciimap[-9, 0], "7") - self.assertEqual(asciimap[-8, 7], "8") - self.assertEqual(asciimap[6, -6], "3") + + self.assertIn("7 1 1 1 1 1 1 1 1 0", str(asciimap)) + self.assertIn("7 1 1 1 1 1 1 1 1 0", str(asciimap2)) + + def test_hexFullFlatsUp(self): + """Test sample full hex map (with hex flats up) against known answers.""" + # hex map is 21 rows tall: from -10 to 10 + asciimap = asciimaps.AsciiMapHexFullFlatsUp() + asciimap.readAscii(HEX_FULL_MAP_FLAT) + + # spot check some values in the map + self.assertIn("VOTA ICS IC IRT ICS OC", str(asciimap)) + self.assertEqual(asciimap[-3, 10], "ORS") + self.assertEqual(asciimap[0, -9], "ORS") + self.assertEqual(asciimap[0, 0], "IC") + self.assertEqual(asciimap[0, 9], "ORS") + self.assertEqual(asciimap[4, -6], "RR7") + self.assertEqual(asciimap[6, 0], "RR7") + self.assertEqual(asciimap[7, -1], "RR89") + + # also test writing from pure data (vs. reading) gives the exact same map + asciimap2 = asciimaps.AsciiMapHexFullFlatsUp() + for ij, spec in asciimap.items(): + asciimap2.asciiLabelByIndices[ij] = spec + + with io.StringIO() as stream: + asciimap2.gridContentsToAscii() + asciimap2.writeAscii(stream) + stream.seek(0) + output = stream.read() + self.assertEqual(output, HEX_FULL_MAP_FLAT) + + self.assertIn("VOTA ICS IC IRT ICS OC", str(asciimap)) + self.assertIn("VOTA ICS IC IRT ICS OC", str(asciimap2)) def test_hexFullFlat(self): """Test sample full hex map against known answers.""" @@ -335,7 +418,7 @@ def test_hexFullFlat(self): self.assertEqual(asciimap[-5, 2], "VOTA") self.assertEqual(asciimap[2, 3], "FS") - # also test writing from pure data (vs. reading) gives the exact same map :o + # also test writing from pure data (vs. reading) gives the exact same map with io.StringIO() as stream: asciimap2 = asciimaps.AsciiMapHexFullFlatsUp() asciimap2.asciiLabelByIndices = asciimap.asciiLabelByIndices diff --git a/armi/utils/tests/test_densityTools.py b/armi/utils/tests/test_densityTools.py index 3a1b45a60..557cdb2bc 100644 --- a/armi/utils/tests/test_densityTools.py +++ b/armi/utils/tests/test_densityTools.py @@ -14,13 +14,35 @@ """Test densityTools.""" import unittest +from armi.materials.material import Material from armi.materials.uraniumOxide import UO2 from armi.nucDirectory import elements, nuclideBases from armi.utils import densityTools -class Test_densityTools(unittest.TestCase): +class UraniumOxide(Material): + """A test material that needs to be stored in a different namespace. + + This is a duplicate (by name only) of :py:class:`armi.materials.uraniumOxide.UraniumOxide` + and is used for testing in :py:meth:`armi.materials.tests.test_materials.MaterialFindingTests.test_namespacing` + """ + + def pseudoDensity(self, Tk=None, Tc=None): + return 0.0 + + def density(self, Tk=None, Tc=None): + return 0.0 + + +class TestDensityTools(unittest.TestCase): def test_expandElementalMassFracsToNuclides(self): + """ + Expand mass fraction to nuclides. + + .. test:: Expand mass fractions to nuclides. + :id: T_ARMI_UTIL_EXP_MASS_FRACS + :tests: R_ARMI_UTIL_EXP_MASS_FRACS + """ element = elements.bySymbol["N"] mass = {"N": 1.0} densityTools.expandElementalMassFracsToNuclides(mass, [(element, None)]) @@ -43,7 +65,6 @@ def test_expandElementalZeroMassFrac(self): self.assertAlmostEqual(sum(mass.values()), 1.0) def test_getChemicals(self): - u235 = nuclideBases.byName["U235"] u238 = nuclideBases.byName["U238"] o16 = nuclideBases.byName["O16"] @@ -99,7 +120,21 @@ def test_applyIsotopicsMix(self): ) # HM blended self.assertAlmostEqual(uo2.massFrac["O"], massFracO) # non-HM stays unchanged + def test_getNDensFromMasses(self): + """ + Number densities from masses. + + .. test:: Number densities are retrievable from masses. + :id: T_ARMI_UTIL_MASS2N_DENS + :tests: R_ARMI_UTIL_MASS2N_DENS + """ + nDens = densityTools.getNDensFromMasses(1, {"O": 1, "H": 2}) + + self.assertAlmostEqual(nDens["O"], 0.03764, 5) + self.assertAlmostEqual(nDens["H"], 1.19490, 5) + def test_getMassFractions(self): + """Number densities to mass fraction.""" numDens = {"O17": 0.1512, "PU239": 1.5223, "U234": 0.135} massFracs = densityTools.getMassFractions(numDens) @@ -108,6 +143,7 @@ def test_getMassFractions(self): self.assertAlmostEqual(massFracs["U234"], 0.07937081219437897) def test_calculateNumberDensity(self): + """Mass fraction to number density.""" nDens = densityTools.calculateNumberDensity("U235", 1, 1) self.assertAlmostEqual(nDens, 0.0025621344549254283) @@ -128,6 +164,13 @@ def test_getMassInGrams(self): self.assertAlmostEqual(m, 843.5790671316283) def test_normalizeNuclideList(self): + """ + Normalize a nuclide list. + + .. test:: Normalize nuclide vector + :id: T_ARMI_UTIL_DENS_TOOLS + :tests: R_ARMI_UTIL_DENS_TOOLS + """ nList = {"PU239": 23.2342, "U234": 0.001234, "U235": 34.152} norm = densityTools.normalizeNuclideList(nList) @@ -136,6 +179,12 @@ def test_normalizeNuclideList(self): self.assertAlmostEqual(norm["U235"], 0.5951128604216736) def test_formatMaterialCard(self): + """Formatting material information into an MCNP input card. + + .. test:: Create MCNP material card + :id: T_ARMI_UTIL_MCNP_MAT_CARD + :tests: R_ARMI_UTIL_MCNP_MAT_CARD + """ u235 = nuclideBases.byName["U235"] pu239 = nuclideBases.byName["PU239"] o16 = nuclideBases.byName["O16"] diff --git a/armi/utils/tests/test_directoryChangers.py b/armi/utils/tests/test_directoryChangers.py index 5a42b1f95..30aec94fc 100644 --- a/armi/utils/tests/test_directoryChangers.py +++ b/armi/utils/tests/test_directoryChangers.py @@ -143,8 +143,7 @@ def f(name): self.assertTrue(os.path.exists(os.path.join("temp", f("file1.txt")))) self.assertTrue(os.path.exists(os.path.join("temp", f("file2.txt")))) - os.remove(os.path.join("temp", f("file1.txt"))) - os.remove(os.path.join("temp", f("file2.txt"))) + shutil.rmtree("temp") def test_file_retrieval_missing_file(self): """Tests that the directory changer still returns a subset of files even if all do not exist.""" diff --git a/armi/utils/tests/test_dochelpers.py b/armi/utils/tests/test_dochelpers.py deleted file mode 100644 index 160513a55..000000000 --- a/armi/utils/tests/test_dochelpers.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright 2019 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 documentation helpers.""" -import unittest - -from armi.reactor import reactors -from armi.reactor import reactorParameters -from armi.utils.dochelpers import ( - create_figure, - create_table, - generateParamTable, - generatePluginSettingsTable, -) - - -class TestDocHelpers(unittest.TestCase): - """Tests for the utility dochelpers functions.""" - - def test_paramTable(self): - - table = generateParamTable( - reactors.Core, - reactorParameters.defineCoreParameters(), - ) - self.assertIn("keff", table) - self.assertNotIn("notAParameter", table) - - def test_settingsTable(self): - from armi.settings.fwSettings import globalSettings - - table = generatePluginSettingsTable( - globalSettings.defineSettings(), - "Framework", - ) - self.assertIn("numProcessors", table) - self.assertNotIn("notASetting", table) - - def test_createFigure(self): - rst = create_figure( - "/path/to/thing.png", - caption="caption1", - align="right", - alt="test1", - width=300, - ) - - self.assertEqual(len(rst), 6) - self.assertIn("thing.png", rst[0]) - self.assertIn("right", rst[1]) - self.assertIn("test1", rst[2]) - self.assertIn("width", rst[3]) - self.assertIn("caption1", rst[5]) - - def test_createTable(self): - rst = "some\nthing" - table = create_table( - rst, caption="awesomeTable", align="left", widths=[200, 300], width=250 - ) - - self.assertEqual(len(table), 100) - self.assertIn("awesomeTable", table) - self.assertIn("width: 250", table) - self.assertIn("widths: [200, 300]", table) - self.assertIn("thing", table) diff --git a/armi/utils/tests/test_flags.py b/armi/utils/tests/test_flags.py index 5e93ee647..8d97abc81 100644 --- a/armi/utils/tests/test_flags.py +++ b/armi/utils/tests/test_flags.py @@ -69,14 +69,44 @@ class F(Flag): f2 = F.from_bytes(array) self.assertEqual(f, f2) - def test_collision(self): - """Make sure that we catch value collisions.""" + def test_collision_extension(self): + """Ensure the set of flags cannot be programmatically extended if duplicate created. + + .. test:: Set of flags are extensible without loss of uniqueness. + :id: T_ARMI_FLAG_EXTEND0 + :tests: R_ARMI_FLAG_EXTEND + """ + + class F(Flag): + foo = auto() + bar = 1 + baz = auto() + + F.extend({"a": auto()}) + F.extend({"b": 1}) + + def test_collision_creation(self): + """Make sure that we catch value collisions upon creation. + + .. test:: No two flags have equivalence. + :id: T_ARMI_FLAG_DEFINE + :tests: R_ARMI_FLAG_DEFINE + """ with self.assertRaises(AssertionError): class F(Flag): foo = 1 bar = 1 + class D(Flag): + foo = auto() + bar = auto() + baz = auto() + + self.assertEqual(D.foo._value, 1) + self.assertEqual(D.bar._value, 2) + self.assertEqual(D.baz._value, 4) + def test_bool(self): f = ExampleFlag() self.assertFalse(f) diff --git a/armi/utils/tests/test_hexagon.py b/armi/utils/tests/test_hexagon.py new file mode 100644 index 000000000..ea0873f87 --- /dev/null +++ b/armi/utils/tests/test_hexagon.py @@ -0,0 +1,45 @@ +# Copyright 2023 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. +"""Test hexagon tools.""" +import math +import unittest + +from armi.utils import hexagon + + +class TestHexagon(unittest.TestCase): + def test_hexagon_area(self): + """ + Area of a hexagon. + + .. test:: Hexagonal area is retrievable. + :id: T_ARMI_UTIL_HEXAGON0 + :tests: R_ARMI_UTIL_HEXAGON + """ + # Calculate area given a pitch + self.assertEqual(hexagon.area(1), math.sqrt(3.0) / 2) + self.assertEqual(hexagon.area(2), 4 * math.sqrt(3.0) / 2) + + def test_numPositionsInRing(self): + """ + Calculate number of positions in a ring of hexagons. + + .. test:: Compute number of positions in ring. + :id: T_ARMI_UTIL_HEXAGON1 + :tests: R_ARMI_UTIL_HEXAGON + """ + self.assertEqual(hexagon.numPositionsInRing(1), 1) + self.assertEqual(hexagon.numPositionsInRing(2), 6) + self.assertEqual(hexagon.numPositionsInRing(3), 12) + self.assertEqual(hexagon.numPositionsInRing(4), 18) diff --git a/armi/utils/tests/test_reportPlotting.py b/armi/utils/tests/test_reportPlotting.py index 61dde08c7..5057857ff 100644 --- a/armi/utils/tests/test_reportPlotting.py +++ b/armi/utils/tests/test_reportPlotting.py @@ -44,11 +44,7 @@ def tearDown(self): def test_radar(self): """Test execution of radar plot. Note this has no asserts and is therefore a smoke test.""" - self.r.core.p.doppler = 0.5 - self.r.core.p.voidWorth = 0.5 r2 = copy.deepcopy(self.r) - r2.core.p.voidWorth = 1.0 - r2.core.p.doppler = 1.0 plotCoreOverviewRadar([self.r, r2], ["Label1", "Label2"]) def test_createPlotMetaData(self): diff --git a/armi/utils/tests/test_utils.py b/armi/utils/tests/test_utils.py index d5aac0314..c2131c308 100644 --- a/armi/utils/tests/test_utils.py +++ b/armi/utils/tests/test_utils.py @@ -36,6 +36,7 @@ getPreviousTimeNode, getCumulativeNodeNum, hasBurnup, + codeTiming, ) @@ -154,6 +155,17 @@ def test_classesInHierarchy(self): self.assertGreater(len(r.core.getAssemblies()), 50) self.assertGreater(len(r.core.getBlocks()), 200) + def test_codeTiming(self): + """Test that codeTiming preserves function attributes when it wraps a function.""" + + @codeTiming.timed + def testFunc(): + """Test function docstring.""" + pass + + self.assertEqual(getattr(testFunc, "__doc__"), "Test function docstring.") + self.assertEqual(getattr(testFunc, "__name__"), "testFunc") + class CyclesSettingsTests(unittest.TestCase): """ diff --git a/doc/.static/__init__.py b/doc/.static/__init__.py new file mode 100644 index 000000000..cea7c661d --- /dev/null +++ b/doc/.static/__init__.py @@ -0,0 +1,14 @@ +# 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. +"""Helper tools to build the ARMI docs.""" diff --git a/doc/.static/dochelpers.py b/doc/.static/dochelpers.py new file mode 100644 index 000000000..217d5fa3f --- /dev/null +++ b/doc/.static/dochelpers.py @@ -0,0 +1,144 @@ +# 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. +"""Helpers for Sphinx documentation.""" + + +def createTable(rst_table, caption=None, align=None, widths=None, width=None): + """ + This method is available within ``.. exec::``. It allows someone to create a table with a + caption. + + The ``rst_table`` + """ + rst = [".. table:: {}".format(caption or "")] + if align: + rst += [" :align: {}".format(align)] + if width: + rst += [" :width: {}".format(width)] + if widths: + rst += [" :widths: {}".format(widths)] + rst += [""] + rst += [" " + line for line in rst_table.split("\n")] + return "\n".join(rst) + + +def createListTable( + rows, caption=None, align=None, widths=None, width=None, klass=None +): + """Take a list of data, and produce an RST-type string for a list-table. + + Parameters + ---------- + rows: list + List of input data (first row is the header). + align: str + "left", "center", or "right" + widths: str + "auto", "grid", or a list of integers + width: str + length or percentage of the line, surrounded by backticks + klass: str + Should be "class", but that is a reserved keyword. + "longtable", "special", or something custom + + Returns + ------- + str: RST list-table string + """ + # we need valid input data + assert len(rows) > 1, "Not enough input data." + len0 = len(rows[0]) + for row in rows[1:]: + assert len(row) == len0, "Rows aren't all the same length." + + # build the list-table header block + rst = [".. list-table:: {}".format(caption or "")] + rst += [" :header-rows: 1"] + if klass: + rst += [" :class: {}".format(klass)] + if align: + rst += [" :align: {}".format(align)] + if width: + rst += [" :width: {}".format(width)] + if widths: + rst += [" :widths: " + " ".join([str(w) for w in widths])] + rst += [""] + + # build the list-table data + for row in rows: + rst += [f" * - {row[0]}"] + rst += [f" - {word}" for word in row[1:]] + + return "\n".join(rst) + + +def generateParamTable(klass, fwParams, app=None): + """ + Return a string containing one or more restructured text list tables containing + parameter descriptions for the passed ArmiObject class. + + Parameters + ---------- + klass : ArmiObject subclass + The Class for which parameter tables should be generated + + fwParams : ParameterDefinitionCollection + A parameter definition collection containing the parameters that are always + defined for the passed ``klass``. The rest of the parameters come from the + plugins registered with the passed ``app`` + + app : App, optional + The ARMI-based application to draw plugins from. + """ + from armi import apps + + if app is None: + app = apps.App() + + defs = {None: fwParams} + + app = apps.App() + for plugin in app.pluginManager.get_plugins(): + plugParams = plugin.defineParameters() + if plugParams is not None: + pDefs = plugParams.get(klass, None) + if pDefs is not None: + defs[plugin] = pDefs + + headerContent = """ + .. list-table:: {} Parameters from {{}} + :header-rows: 1 + :widths: 30 40 30 + + * - Name + - Description + - Units + """.format( + klass.__name__ + ) + + content = [] + + for plugin, pdefs in defs.items(): + srcName = plugin.__name__ if plugin is not None else "Framework" + content.append(f".. _{srcName}-{klass.__name__}-param-table:") + pluginContent = headerContent.format(srcName) + for pd in pdefs: + pluginContent += f""" * - {pd.name} + - {pd.description} + - {pd.units} + """ + content.append(pluginContent + "\n") + + return "\n".join(content) diff --git a/doc/user/inputs/looseCouplingIllustration.dot b/doc/.static/looseCouplingIllustration.dot similarity index 100% rename from doc/user/inputs/looseCouplingIllustration.dot rename to doc/.static/looseCouplingIllustration.dot diff --git a/doc/user/inputs/tightCouplingIllustration.dot b/doc/.static/tightCouplingIllustration.dot similarity index 100% rename from doc/user/inputs/tightCouplingIllustration.dot rename to doc/.static/tightCouplingIllustration.dot diff --git a/doc/conf.py b/doc/conf.py index 7b370cd42..d65645502 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -26,21 +26,27 @@ """ # ruff: noqa: E402 import datetime +import inspect import os import pathlib import re import shutil +import subprocess import sys import warnings -import sphinx_rtd_theme +from docutils import nodes, statemachine +from docutils.parsers.rst import Directive, directives from sphinx.domains.python import PythonDomain +import sphinx_rtd_theme # handle python import locations for this execution PYTHONPATH = os.path.abspath("..") sys.path.insert(0, PYTHONPATH) # Also add to os.environ which will be used by the nbsphinx extension environment os.environ["PYTHONPATH"] = PYTHONPATH +# Add dochelpers.py from doc/.static/ directory +sys.path.insert(0, ".static") from armi import apps from armi import configure as armi_configure @@ -48,7 +54,6 @@ from armi import disableFutureConfigures from armi import meta from armi.bookkeeping import tests as bookkeepingTests -from armi.utils.dochelpers import ExecDirective, PyReverse context.Mode.setMode(context.Mode.BATCH) @@ -74,6 +79,141 @@ def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode): ) +class ExecDirective(Directive): + """ + Execute the specified python code and insert the output into the document. + + The code is used as the body of a method, and must return a ``str``. The string result is + interpreted as reStructuredText. + + Error handling informed by https://docutils.sourceforge.io/docs/howto/rst-directives.html#error-handling + The self.error function should both inform the documentation builder of the error and also + insert an error into the built documentation. + + Warning + ------- + This only works on a single node in the doctree, so the rendered code may not contain any new + section names or labels. They will result in ``WARNING: Unexpected section title`` warnings. + """ + + has_content = True + + def run(self): + try: + code = inspect.cleandoc( + """ + def usermethod(): + {} + """ + ).format("\n ".join(self.content)) + exec(code) + result = locals()["usermethod"]() + + if result is None: + + raise self.error( + "Return value needed! The body of your `.. exec::` is used as a " + "function call that must return a value." + ) + + para = nodes.container() + lines = statemachine.StringList(result.split("\n")) + self.state.nested_parse(lines, self.content_offset, para) + return [para] + except Exception as e: + docname = self.state.document.settings.env.docname + raise self.error( + "Unable to execute embedded doc code at {}:{} ... {}\n{}".format( + docname, self.lineno, datetime.datetime.now(), str(e) + ) + ) + + +class PyReverse(Directive): + """Runs pyreverse to generate UML for specified module name and options. + + The directive accepts the same arguments as pyreverse, except you should not specify + ``--project`` or ``-o`` (output format). These are automatically specified. + + If you pass ``-c`` to this, the figure generated is forced to be the className.png + like ``BurnMatrix.png``. For .gitignore purposes, this is a pain. Thus, we + auto-prefix ALL images generated by this directive with ``pyrev_``. + """ + + has_content = True + required_arguments = 1 + optional_arguments = 50 + option_spec = { + "alt": directives.unchanged, + "height": directives.length_or_percentage_or_unitless, + "width": directives.length_or_percentage_or_unitless, + "align": lambda arg: directives.choice(arg, ("left", "right", "center")), + "filename": directives.unchanged, + } + + def run(self): + try: + args = list(self.arguments) + args.append("--project") + args.append(f"{args[0]}") + args.append("-opng") + + # NOTE: cannot use "pylint.pyreverse.main.Run" because it calls `sys.exit`. + fig_name = self.options.get("filename", "classes_{}.png".format(args[0])) + command = [sys.executable, "-m", "pylint.pyreverse.main"] + print("Running {}".format(command + args)) + env = dict(os.environ) + # apply any runtime path mods to the pythonpath env variable (e.g. sys.path + # mods made during doc confs) + env["PYTHONPATH"] = os.pathsep.join(sys.path) + subprocess.check_call(command + args, env=env) + + try: + os.remove(os.path.join(APIDOC_REL, fig_name)) + except OSError: + pass + + shutil.move(fig_name, APIDOC_REL) + # add .gitignore helper prefix + shutil.move( + os.path.join(APIDOC_REL, fig_name), + os.path.join(APIDOC_REL, f"pyr_{fig_name}"), + ) + new_content = [f".. figure:: /{APIDOC_REL}/pyr_{fig_name}"] + + # assume we don't need the packages_, and delete. + try: + os.remove("packages_{}.png".format(args[0])) + except OSError: + pass + + # pass the other args through (figure args like align) + for opt, val in self.options.items(): + if opt in ("filename",): + continue + new_content.append(" :{}: {}\n".format(opt, val)) + + new_content.append("\n") + + for line in self.content: + new_content.append(" " + line) + + para = nodes.container() + # tab_width = self.options.get('tab-width', self.state.document.settings.tab_width) + lines = statemachine.StringList(new_content) + self.state.nested_parse(lines, self.content_offset, para) + return [para] + except Exception as e: + docname = self.state.document.settings.env.docname + # add the error message directly to the built documentation and also tell the + # builder + raise self.error( + "Unable to execute embedded doc code at {}:{} ... {}\n{}".format( + docname, self.lineno, datetime.datetime.now(), str(e) + ) + ) + + def autodoc_skip_member_handler(app, what, name, obj, skip, options): """Manually exclude certain methods/functions from docs.""" # exclude special methods from unittest diff --git a/doc/developer/documenting.rst b/doc/developer/documenting.rst index 5544000e6..224ed16c1 100644 --- a/doc/developer/documenting.rst +++ b/doc/developer/documenting.rst @@ -1,5 +1,9 @@ +.. _armi-docing: + +**************** Documenting ARMI -================ +**************** + ARMI uses the `Sphinx <https://www.sphinx-doc.org/en/master/>`_ documentation system to compile the web-based documentation from in-code docstrings and hand-created `ReStructedText files <https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_. @@ -17,7 +21,7 @@ We use some special Sphinx plugins that run the tutorial jupyter notebooks durin build with the most up-to-date code. Building the documentation --------------------------- +========================== Before building documentation, ensure that you have installed the test requirements into your ARMI virtual environment with:: @@ -56,11 +60,11 @@ files to a clone of the `documentation repository rsync -ahv --delete _build/html/ path/to/terrapower.github.io/armi Documentation for ARMI plugins ------------------------------- +============================== The following subsections apply to documentation for ARMI plugins. Linking to ARMI documentation from plugins -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------------------ ARMI plugin documentation can feature rich hyperlinks to the ARMI API documentation with the help of the `intersphinx Sphinx plugin <http://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html>`_. The @@ -79,7 +83,7 @@ Now you can link to the ARMI documentation with links like:: Automatically building apidocs of namespace packages -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +---------------------------------------------------- Activating the ``"sphinxcontrib.apidoc",`` `Sphinx plugin <https://github.com/sphinx-contrib/apidoc>`_ enables plugin API documentation to be built with the standard ``make html`` Sphinx workflow. If @@ -88,15 +92,15 @@ your ARMI plugin is a namespace package, the following extra config is required: apidoc_extra_args = ["--implicit-namespaces"] Updating the Gallery --------------------- -The :doc:`ARMI example gallery </gallery/index>` is a great way to quickly +==================== +The :ref:`sphx_glr_gallery` is a great way to quickly highlight neat features and uses of ARMI. To add a new item to the gallery, add your example code (including the required docstring) to the ``doc/gallery-src`` folder in the ARMI source tree. The example will be added to the gallery during the next documentation build. Using Jupyter notebooks ------------------------ +======================= For interactive tutorials, it's convenient to build actual Jupyter notebooks and commit them to the documentation to be rendered by Sphinx using the nbsphinx plugin. When this is done, notebooks without any output should be committed to the repository diff --git a/doc/developer/entrypoints.rst b/doc/developer/entrypoints.rst index 5e0673487..81987ba34 100644 --- a/doc/developer/entrypoints.rst +++ b/doc/developer/entrypoints.rst @@ -1,5 +1,6 @@ +************ Entry Points -============ +************ **Entry Points** are like the verbs that your App can *do*. The :py:mod:`built-in entry points <armi.cli>` diff --git a/doc/developer/first_time_contributors.rst b/doc/developer/first_time_contributors.rst index f85c80120..e4130048b 100644 --- a/doc/developer/first_time_contributors.rst +++ b/doc/developer/first_time_contributors.rst @@ -13,7 +13,7 @@ Help Wanted There are a lot of places you can get started to help the ARMI project and team: -* Better :doc:`documentation </developer/documenting>` +* Better :ref:`armi-docing` * Better test coverage * Many more type annotations are desired. Type issues cause lots of bugs. * Targeted speedups (e.g. informed by a profiler) @@ -53,15 +53,15 @@ The process for opening a PR against ARMI goes something like this: 3. Make your code changes to your new branch 4. Submit a Pull Request against `ARMIs main branch <https://github.com/terrapower/armi/pull/new/main>`_ a. See `GitHubs general guidance on Pull Requests <https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/creating-a-pull-request>`_ - b. See :doc:`ARMIs specific guidance </developer/tooling>` on what makes a "good" Pull Request. + b. See ARMIs specific guidance on what makes a "good" Pull Request: :ref:`armi-tooling`. 5. Actively engage with your PR reviewer's questions and comments. > Note that a bot will require that you sign our `Contributor License Agreement <https://gist.github.com/youngmit/8654abcf93f309771ae9296abebe9d4a>`_ before we can accept a pull request from you. -See our published documentation for a complete guide to our :doc:`coding standards and practices </developer/standards_and_practices.html>`. +See our published documentation for a complete guide to our coding standards and practices: :ref:`armi-stds`. -Also, please check out our (quick) synopsis on :doc:`good commit messages </developer/tooling>`. +Also, please check out our (quick) synopsis on good commit messages: :ref:`armi-tooling`. Licensing of Tools ================== diff --git a/doc/developer/guide.rst b/doc/developer/guide.rst index b513b8c6f..86e434bf6 100644 --- a/doc/developer/guide.rst +++ b/doc/developer/guide.rst @@ -5,9 +5,8 @@ Framework Architecture Here we will discuss some big-picture elements of the ARMI architecture. Throughout, links to the API docs will lead to additional details. ------------------ The Reactor Model ------------------ +================= The :py:mod:`~armi.reactor` package is the central representation of a nuclear reactor in ARMI. All modules can be expected to want access to some element of the state data @@ -16,14 +15,14 @@ package's code during runtime. An approximation of `Composite Design Pattern <http://en.wikipedia.org/wiki/Composite_pattern>`_ is used to represent the **Reactor** -in ARMI. In this hierarchy the **Reactor** object has a child **Core** object, and -potentially many generic **Composite** child objects representing ex-core structures. -The **Core** is made of **Assembly** objects, which are in turn made up as a collection -of **Block** objects. :term:`State <reactor state>` variables may be stored at any level -of this hierarchy using the :py:mod:`armi.reactor.parameters` system to contain results +in ARMI. In this hierarchy the **Reactor** object has a child **Core** object, and +potentially many generic **Composite** child objects representing ex-core structures. +The **Core** is made of **Assembly** objects, which are in turn made up as a collection +of **Block** objects. :term:`State <reactor state>` variables may be stored at any level +of this hierarchy using the :py:mod:`armi.reactor.parameters` system to contain results (e.g., ``keff``, ``flow rates``, ``power``, ``flux``, etc.). Within each block are - **Components** that define the pin-level geometry. Associated with each Component are -**Material** objects that contain material properties (``density``, ``conductivity``, +**Components** that define the pin-level geometry. Associated with each Component are +**Material** objects that contain material properties (``density``, ``conductivity``, ``heat capacity``, etc.) and isotopic mass fractions. .. note:: Non-core structures (spent fuel pools, core restraint, heat exchangers, etc.) @@ -34,14 +33,14 @@ of this hierarchy using the :py:mod:`armi.reactor.parameters` system to contain .. figure:: /.static/armi_reactor_objects.png :align: center - **Figure 1.** The primary data containers in ARMI + The primary data containers in ARMI Each level of the composite pattern hierarchy contains most of its state data in a collection of parameters detailing considerations of how the reactor has progressed through time to any given point. This information also constitutes the majority of what gets written to the database for evaluation and/or follow-on analysis. -Review the :doc:`/tutorials/data_model` section for examples +Review the data model :ref:`armi-tutorials` section for examples exploring a populated instance of the **Reactor** model. Finding objects in a model @@ -113,9 +112,8 @@ defined. During a run, they can be used to create new instances of reactor model such as when a new assembly is fabricated during a fuel management operation in a later cycle. ---------- Operators ---------- +========= Operators conduct the execution sequence of an ARMI run. They basically contain the main loop. When any operator is instantiated, several actions occur: @@ -208,7 +206,7 @@ interface stack is traversed in order. .. figure:: /.static/armi_general_flowchart.png :align: center - **Figure 1.** The computational flow of the interface hooks + The computational flow of the interface hooks For example, input checking routines would run at beginning-of-life (BOL), calculation modules might run at every time node, etc. To accommodate these various needs, interface @@ -223,8 +221,8 @@ hooks include: * :py:meth:`interactBOC <armi.interfaces.Interface.interactBOC>` -- Beginning of cycle. Happens once per cycle. -* :py:meth:`interactEveryNode <armi.interfaces.Interface.interactEveryNode>` -- happens - after every node step/flux calculation +* :py:meth:`interactEveryNode <armi.interfaces.Interface.interactEveryNode>` -- Happens + after every node step/flux calculation. * :py:meth:`interactEOC <armi.interfaces.Interface.interactEOC>` -- End of cycle. @@ -233,6 +231,9 @@ hooks include: * :py:meth:`interactError <armi.interfaces.Interface.interactError>` -- When an error occurs, this can run to clean up or print debugging info. +* :py:meth:`interactCoupled <armi.interfaces.Interface.interactCoupled>` -- Happens + after every node step/flux calculation, if tight physics coupling is active. + These interaction points are optional in every interface, and you may override one or more of them to suit your needs. You should not change the arguments to the hooks, which are integers. @@ -265,13 +266,12 @@ as deemed necessary to have the interfaces work properly. To use interfaces in parallel, please refer to :py:mod:`armi.mpiActions`. -------- Plugins -------- +======= Plugins are higher-level objects that can bring in one or more Interfaces, settings definitions, parameters, validations, etc. They are documented in -:doc:`/developer/making_armi_based_apps` and :py:mod:`armi.plugins`. +:ref:`armi-app-making` and :py:mod:`armi.plugins`. Entry Points @@ -281,7 +281,7 @@ cases, launch the GUI, and perform various testing and utility operations. When invoke ARMI with ``python -m armi run``, the ``__main__.py`` file is loaded and all valid Entry Points are dynamically loaded. The proper entry point (in this case, :py:class:`armi.cli.run.RunEntryPoint`) is invoked. As ARMI initializes itself, settings -are loaded into a :py:class:`CaseSettings <armi.settings.caseSettings.CaseSettings>` +are loaded into a :py:class:`Settings <armi.settings.caseSettings.Settings>` object. From those settings, an :py:class:`Operator <armi.operators.operator.Operator>` subclass is built by a factory and its ``operate`` method is called. This fires up the main ARMI analysis loop and its interface stack is looped over as indicated by user diff --git a/doc/developer/making_armi_based_apps.rst b/doc/developer/making_armi_based_apps.rst index 0c3c2b6e7..433abe5a3 100644 --- a/doc/developer/making_armi_based_apps.rst +++ b/doc/developer/making_armi_based_apps.rst @@ -1,3 +1,5 @@ +.. _armi-app-making: + ********************** Making ARMI-based Apps ********************** @@ -8,8 +10,7 @@ interfaces to automate your work is the next step to unlocking ARMI's potential. .. admonition:: Heads up - A full :doc:`tutorial on making an ARMI-based app is here - </tutorials/making_your_first_app>`. + A full tutorial on :ref:`armi-make-first-app` is here. To really make ARMI your own, you will need to understand a couple of concepts that enable developers to adapt and extend ARMI to their liking: @@ -32,9 +33,8 @@ enable developers to adapt and extend ARMI to their liking: Both of these concepts are discussed in depth below. ------------- ARMI Plugins ------------- +============ An ARMI Plugin is the primary means by which a developer or qualified analyst can go about building specific capability on top of the ARMI Framework. Even some of the @@ -45,8 +45,9 @@ getting started to get an idea of what is available. Some implementation details --------------------------- -One can just monkey-see-monkey-do their own plugins without fully understanding the -following. However, having a deeper understanding of what is going on may be useful. + +Plugins are designed to make it easy to build a plugin by copy/pasting from an existing +plugin. However, having a deeper understanding of what is going on may be useful. Feel free to skip this section. The plugin system is built on top of a Python library called `pluggy @@ -101,9 +102,9 @@ some guidance. Once you have a plugin together, continue reading to see how to plug it into the ARMI Framework as part of an Application. ------------------------ ARMI-Based Applications ------------------------ +======================= + On its own, ARMI doesn't *do* much. Plugins provide more functionality, but even they aren't particularly useful on their own either. The magic really happens when you collect a handful of Plugins and plug them into the ARMI Framework. Such a collection is diff --git a/doc/developer/parallel_coding.rst b/doc/developer/parallel_coding.rst index ad3d5b779..565f570ef 100644 --- a/doc/developer/parallel_coding.rst +++ b/doc/developer/parallel_coding.rst @@ -1,6 +1,6 @@ -##################### +********************* Parallel Code in ARMI -##################### +********************* ARMI simulations can be parallelized using the `mpi4py <https://mpi4py.readthedocs.io/en/stable/mpi4py.html>`_ module. You should go there and read about collective and point-to-point communication if you want to @@ -17,7 +17,7 @@ these instructions, you can have your code working in parallel in no time. In AR you need them to in parallel. MPI communication crash course ------------------------------- +============================== First, let's do a crash course in MPI communications. We'll only discuss a few important ideas, you can read about more on the ``mpi4py`` web page. The first method of communication is called the ``broadcast``, which happens when the primary processor sends information to all others. An example of this would be when you want to @@ -27,23 +27,28 @@ are expected to do next. Here is an example:: - if rank == 0: + from armi import context + + cmd = f"val{context.MPI_RANK}" + + if context.MPI_RANK == 0: # The primary node will send the string 'bob' to all others - cmd = 'bob' - comm.bcast(cmd, root=0) + cmd = "bob" + context.MPI_COMM.bcast(cmd, root=0) else: # these are the workers. They receive a value and set it to the variable cmd - cmd = comm.bcast(None, root=0) + context.MPI_COMM = comm.bcast(None, root=0) Note that the ``comm`` object is from the ``mpi4py`` module that deals with the MPI drivers. The value of cmd on the worker before and after the ``bcast`` command are shown in the table. -============ ===== ===== ===== ===== - Proc1 Proc2 Proc3 Proc4 -============ ===== ===== ===== ===== -Before bcast 'bob' 4 'sam' 3.14 -After bcast 'bob' 'bob' 'bob' 'bob' -============ ===== ===== ===== ===== ++--------------+-------+--------+--------+--------+ +| | Proc0 | Proc1 | Proc2 | Proc3 | ++--------------+-------+--------+--------+--------+ +| Before bcast | "bob" | "val1" | "val2" | "val3" | ++--------------+-------+--------+--------+--------+ +| After bcast | "bob" | "bob" | "bob" | "bob" | ++--------------+-------+--------+--------+--------+ The second important type of communication is the ``scatter``/``gather`` combo. These are used when you have a big list of work you'd like to get done in parallel and you want to farm it off to a bunch of processors. To do @@ -63,56 +68,50 @@ transmition to each CPU). This is called *load balancing*. ARMI has utilities that can help called :py:func:`armi.utils.iterables.chunk` and :py:func:`armi.utils.iterables.flatten`. Given an arbitrary list, ``chunk`` breaks it up into a certain number of chunks and ``unchunk`` does the -opposite to reassemble the original list after processing. Check it out:: +opposite to reassemble the original list after processing. Let's look at an example script:: + + """mpi_example.py""" + import random + from armi import context from armi.utils import iterables - if rank == 0: - # primary. Make data and send it. - workListLoadBalanced = iterables.split(workList, nCpu, padWith=()) - # this list looks like: - # [[v1,v2,v3,v4...], [v5,v6,v7,v8,...], ...] - # And there's one set of values for each processor - myValsToAdd = comm.scatter(workListLoadBalanced, root=0) - # now myValsToAdd is the first entry from the work list, or [v1,v2,v3,v4,...]. + # Generate a list of random number pairs: [[(v1,v2),(v3,v4),...]] + workList = [(random.random(), random.random()) for _i in range(1000)] + + if context.MPI_RANK == 0: + # Primary Process: Split the data and send it to the workers + workListLoadBalanced = iterables.split(workList, context.MPI_SIZE, padWith=()) + myValsToAdd = context.MPI_COMM.scatter(workListLoadBalanced, root=0) else: - # workers. Receive data. Pass a dummy variable to scatter (None) - myValsToAdd = comm.scatter(None, root=0) - # now for the first worker, myValsToAdd==[v5,v6,v7,v8,...] - # and for the second worker, it is [v9,v10,v11,v12,...] and so on. - # Recall that in this example, each vn is a tuple like (randomnum, randomnum) + # Worker Process: Receive data, pass a dummy value to scatter (None) + myValsToAdd = context.MPI_COMM.scatter(None, root=0) - # all processors do their bit of the work + # All processes do their bit of this work (adding) results = [] for num1, num2 in myValsToAdd: results.append(num1 + num2) - # now results is a list of results with one entry per myValsToAdd, or - # [r1,r2,r3,r4,...] - - # all processors call gather to send their results back. it all assembles on the primary processor. - allResultsLoadBalanced = comm.gather(results, root=0) - # So we now have a list of lists of results, like this: - # [[r1,r2,r3,r4,...], [r5,r6,r7,r8,...], ...] + # All processes call gather to send their results back to the root process. + # (The result lists above are simply added to make one list with MPI_SIZE sub-lists.) + allResultsLoadBalanced = context.MPI_COMM.gather(results, root=0) - # primary processor does stuff with the results, like print them out. - if rank == 0: - # first take the individual result lists and reassemble them back into the big list. - # These results correspond exactly to workList from above. All ordering has been preserved. + # Primary Process: Flatten the multiple lists (from each process), and sum them. + if context.MPI_RANK == 0: + # Flatten the MPI_SIZE number of sub lists into one list allResults = iterables.flatten(allResultsLoadBalanced) - # allResults now looks like: [r1,r2,r3,r4,r5,r6,r7,...] - print('The total sum is: {0:10.5f}'.format(sum(allResults))) + # Sum the final list, and print the result + print("The total sum is: {0:10.5f}".format(sum(allResults))) -Remember that this code is running on all processors. So it's just the ``if rank == 0`` statements that differentiate -between the primary and the workers. Try writing this program as a script and submitting it to a cluster via the command -line to see if you really understand what's going on. You will have to add some MPI imports before you can do that -(see :py:mod:`twr_shuffle.py <armi.twr_shuffle>` in the ARMI code for a major hint!). +Remember that this code is running on all processors. So it's just the ``if rank == 0`` statements that differentiate between the primary and the workers. To really understand what this script is doing, try to run it in parallel and see what it prints out:: + + mpiexec -n 4 python mpi_example.py MPI Communication within ARMI ------------------------------ -Now that you understand the basics, here's how you should get your :doc:`code interfaces </developer/dev_task_support/interfaces>` +============================= +Now that you understand the basics, here's how you should get your :py:class:`armi.interfaces.Interface` to run things in parallel in ARMI. You don't have to worry too much about the ranks, etc. because ARMI will set that up for you. Basically, @@ -136,17 +135,19 @@ mechanisms that can help you get the data back to the primary reactor. Example using ``bcast`` -*********************** +----------------------- Some actions that perform the same task are best distributed through a broadcast. This makes sense for if your are parallelizing code that is a function of an individual assembly, or block. In the following example, the interface simply -creates an ``Action`` and broadcasts it as appropriate.:: +creates an ``Action`` and broadcasts it as appropriate:: + + from armi import context class SomeInterface(interfaces.Interface): def interactEverNode(self, cycle, node): action = BcastAction() - armi.MPI_COMM.bcast(action) + context.MPI_COMM.bcast(action) results = action.invoke(self.o, self.r, self.cs) # allResults is a list of len(self.r) @@ -180,10 +181,10 @@ creates an ``Action`` and broadcasts it as appropriate.:: Example using ``scatter`` -************************* +------------------------- When trying two independent actions at the same time, you can use ``scatter`` to distribute the work. The following example -shows how different operations can be performed in parallel.:: +shows how different operations can be performed in parallel:: class SomeInterface(interfaces.Interface): @@ -220,10 +221,10 @@ shows how different operations can be performed in parallel.:: A simplified approach -********************* +--------------------- -Transferring state to and from a Reactor can be complicated and add a lot of code. An alternative approachis to ensure -that the reactor state is synchronized across all nodes, and then use the reactor instead of raw data.:: +Transferring state to and from a Reactor can be complicated and add a lot of code. An alternative approach is to ensure +that the reactor state is synchronized across all nodes, and then use the reactor instead of raw data:: class SomeInterface(interfaces.Interface): diff --git a/doc/developer/profiling.rst b/doc/developer/profiling.rst index 8fd2e4af0..f7074c627 100644 --- a/doc/developer/profiling.rst +++ b/doc/developer/profiling.rst @@ -1,6 +1,7 @@ ************** Profiling ARMI ************** + Python in slow, so it's important to profile code to keep it running reasonbly quickly. Using the basic `Python profiler <https://docs.python.org/3/library/profile.html>`_ is the best way to get started. Once you have a ``.stats`` file, however, we highly recommend using a visualizer. @@ -18,4 +19,4 @@ This produces images like this: .. figure:: /.static/buildMacros.png :align: center - **Figure 1.** An example of the profiler output rendered to a png. + An example of the profiler output rendered to a png. diff --git a/doc/developer/reports.rst b/doc/developer/reports.rst index 814e8d777..17b666708 100644 --- a/doc/developer/reports.rst +++ b/doc/developer/reports.rst @@ -1,5 +1,7 @@ +*************** Reports in ARMI -================ +*************** + .. note:: The resulting report itself is an HTML page with table of contents on the left. ARMI provides the ability to make a variety of plots and tables describing the state of the reactor. @@ -12,7 +14,7 @@ from a specific plugin. Currently it is implemented in the bookkeeping and neutronics plugin. The Hook: getReportContents() ------------------------------ +============================= getReportContents takes in 5 arguments (r, cs, report, stage, blueprint) @@ -29,7 +31,6 @@ The Hook: getReportContents() +---------------+--------------------------------------------------------------------------------------------------------------------------+ - ReportContent, at its core, is a transient represention of the report itself (until it is fully collected and converted to an html page), so in the call to getReportContents() additions are made to this object. Generally, you would want to break up the added contents into stages within the function getReportContents() as so:: @@ -52,7 +53,7 @@ so one can imagine the functionality of the below to allow for ``Standard`` addi ReportContent acts as a dicionary of ``Section``'s behind the scenes and the further description of these objects will be found in the following topics. What is ReportContent? ----------------------- +====================== At the start of any report creation, creation of the ReportContent object is key. ReportContent when created, needs the name of the reactor for the title of the report. @@ -82,7 +83,7 @@ A major component of the ReportContent class is the function to ``writeReports() Overall, the important functionality examples for ``ReportContent`` additions are summarized below. Sections --------- +======== The first level of ``ReportContent``'s is made up of ``Section``'s. ``Section``'s have a ``title``, and themselves a dictionary of contents (``.childContents``), but again the ability to just directly access a sections children like ``report[Comprehensive][Setting]`` exists, as long as the section already exists, (if not, a key error persists, and so it is safer to do ``report[Comprehensive].get(Setting, Table(...)))``, where ``Table`` would be the default. @@ -116,7 +117,7 @@ It is also possible to do the following through dictionary access for the same r Tables ------- +====== Making sure a ``Table`` isn't already created is important. Due to the repeated call to ``getReportContents()`` at different cycles/nodes of the reactor life cycle, some sections may have already been called before, and we want to be careful about not overwriting a ``Table``/``TimeSeries``. (most ``Image``'s may only be called at a single time and not dependent on multiple plugins, so those cases have less to worry about at this time) @@ -149,7 +150,6 @@ Suppose in Bookkeeping a ``Table`` is accessed with the following code:: - Similarily that same ``Table`` is accessed within Neutronics for additional settings additions:: >>> section = report[newReportUtils.COMPREHENSIVE_REPORT] @@ -181,9 +181,8 @@ The result (with some additional Bookkeeping additions) is outlined in this imag :align: center - Images ------- +====== Images may generally be things to add at stage = Beg, or stage = End. (For example, a core map at BOL would be inserted at stage = Beg) Images require a ``caption`` and a ``filename`` and have an optional ``title`` argument. (They would also have a call to another function before hand to create the image file (for example)) @@ -209,10 +208,9 @@ In this case, Block Diagrams is the Section Title, and it is expandable, for eas TimeSeries ----------- -This is where information for later graphing is collected. The TimeSeries contains many elements. A ``title`` a ``rname`` (reactor name), ``labels`` list, ``yaxis`` title, and ``filename``. - +========== +This is where information for later graphing is collected. The TimeSeries contains many elements. A ``title`` a ``rname`` (reactor name), ``labels`` list, ``yaxis`` title, and ``filename``. Like ``Table``, these objects need to have a check on whether they already exist. In this case, you could just check and create the object when ``stage`` is set to Begin (and then when ``stage`` is Standard always know it exists to add content to), but for good measure, you may also just check if the Plot already exists in the Section, and if not, add it. @@ -236,22 +234,20 @@ Here is code for adding to a K-effective plot:: labels[0], r.p.time, r.core.p.keff, r.core.p.keffUnc >>> ) - Here, only one label exists, so we only add one line for ``label[0]``. There are further examples of this in the docstring of ``TimeSeries`` for information on adding multiple lines. In summary, to add multiple lines (say, for different assembly types on a Peak DPA plot), the label would be the assembly type and the data would be the dpa at the time for that type. The ``uncertainty`` value --> which in general denotes an error bar on the graph---> would be None or 0, for each point if there is no uncertainty. HTML Elements -------------- +============= One may also want to add just plain prose. To do this, Sections also allow for the addition of htmltree elements so you can add paragraphs, divs, etc, as outlined in htmltree. These parts however will not be titled unless wrapped within a Section, and similarily will not have a direct link in the table of contents without a Section wrap as well (due to their inherent lack of title). However, thier addition may add beneficial information to reports in between Tables and Images that could prove useful to the user and any readers. - - Summary -------- +======= + ``ReportContent`` is made up of many different types of elements (``Sections``, ``Tables``, ``Images``, ``HtmlElements``, ``TimeSeries``), that when ``writeReports()`` is called on the ``ReportContent`` object, have the ability to be rendered through their ``render()`` method in order to be translated to html for the resulting document. This document is saved in a new folder titled reportsOutputFiles. diff --git a/doc/developer/standards_and_practices.rst b/doc/developer/standards_and_practices.rst index 3d7464a29..4d099db0a 100644 --- a/doc/developer/standards_and_practices.rst +++ b/doc/developer/standards_and_practices.rst @@ -1,3 +1,5 @@ +.. _armi-stds: + ********************************** Standards and Practices for Coding ********************************** diff --git a/doc/developer/tooling.rst b/doc/developer/tooling.rst index 1cc8127f0..a423ea6fc 100644 --- a/doc/developer/tooling.rst +++ b/doc/developer/tooling.rst @@ -1,8 +1,11 @@ +.. _armi-tooling: + +************************** Tooling and Infrastructure -========================== +************************** Good Commit Messages --------------------- +==================== The ARMI project follows a few basic rules for "good" commit messages: * The purpose of the message is to explain to the changes you made to a stranger 5 years from now. @@ -23,14 +26,14 @@ The ARMI project follows a few basic rules for "good" commit messages: * optional. Good Pull Requests ------------------- +================== A good commit is like a sentence; it expresses one complete thought. In that context, a good Pull Request (PR) is like a paragraph; it contains a few sentences that contain one larger thought. A good PR is *not* a chapter or an entire book! It should not contain multiple independent ideas. One Idea = One PR -^^^^^^^^^^^^^^^^^ +----------------- .. important :: If you *can* break a PR into smaller PRs, containing unrelated changes, please do. @@ -40,7 +43,8 @@ They are busy people, and it will save them time and effort if your PR only has If your PRs are smaller, you will notice a great increase in the quality of the reviews you get. Don't open until it is ready -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +---------------------------- + .. important :: Wait until your PR is complete to open it. @@ -53,7 +57,7 @@ prefer to keep the PR list as short as possible. A good rule of thumb is: don't you think it is ready for final review. Test It -^^^^^^^ +------- .. important :: If a PR doesn't have any changes to testing, it probably isn't complete. @@ -68,7 +72,8 @@ If the changes in the PR are worth the time to make, they are worth the time to reviewer by proving your code works. Document It -^^^^^^^^^^^ +----------- + .. important :: If it isn't documented, it doesn't exist. @@ -79,7 +84,7 @@ Also consider (if you are making a major change) that you might be making someth out-of-date. Watch for Requirements -^^^^^^^^^^^^^^^^^^^^^^ +---------------------- When you are touching code in ARMI, watch out for the docstrings in the methods, classes, or modules you are editing. These docstrings might have bread crumbs that link back to requirements. Such breadcrumbs will look like: @@ -87,27 +92,48 @@ Such breadcrumbs will look like: .. code-block:: """ - .. req: This is a requirement breadcrumb. + .. test: This is a requirement test breadcrumb. - .. test: This is a test breadcrumb. - - .. impl: This is an implementation breadcrumb. + .. impl: This is an requirement implementation breadcrumb. """ -If you touch any code that has such a docstring, even in a file, you are going to be +If you touch any code that has such a docstring, even at the top of the file, you are going to be responsible for not breaking that code/functionality. And you will be required to explicitly call out that you touch such a code in your PR. +Your PR reviewer will take an extra look at any PR that touches a requirement test or implementation. +And you will need to add a special release note under the "Changes that Affect Requirements" section header. + +Add Release Notes +----------------- +For most PRs, you will need to add release notes to the +`Release Notes documentation <https://github.com/terrapower/armi/tree/main/doc/release>`_. The goal +here is to help track all the important changes that happened in ARMI, so ARMI can document what +has changed during the next `release <https://github.com/terrapower/armi/releases>`_. To that end, +minor PRs won't require a release note. + +In particular, in the release notes, you will find four sections in the releasee notes: + +1. **New Features** - A new feature (or major addition to a current feature) was added to the code. +2. **API Changes** - ANY change to the public-facing API of ARMI. +3. **Bug Fixes** - ANY bug fix in the code (not the documentation), no matter how minor. +4. **Changes that Affect Requirements** - If you touch the code (``impl``) or test (``test``) for + anything that currently has a requirement crumb. (This must be a non-trivial change.) + +If your PR fits more than one of these categories, great! Put a description of your change under +all the categories that apply. + + Packaging and dependency management ------------------------------------ +=================================== The process of packaging Python projects and managing their dependencies is somewhat challenging and nuanced. The contents of our ``pyproject.toml`` follow existing conventions as much as possible. In particular, we follow `the official Python packaging guidance <https://packaging.python.org/en/latest/>`_. pyproject.toml -^^^^^^^^^^^^^^ +-------------- As much as possible, the ARMI team will try to centralize our installation and build systems through the top-level ``pyproject.toml`` file. The only exception will be our documentation, which has much customization done through the Sphinx ``doc/conf.py`` file. @@ -119,7 +145,7 @@ packages that are not strictly required, but if installed enable extra functiona like unit testing or building documentation. Third-Party Licensing -^^^^^^^^^^^^^^^^^^^^^ +--------------------- Be careful when including any dependency in ARMI (say in the ``pyproject.toml`` file) not to include anything with a license that superceeds our Apache license. For instance, any third-party Python library included in ARMI with a GPL license will make the whole @@ -130,7 +156,7 @@ For that reason, it is generally considered best-practice in the ARMI ecosystem only use third-party Python libraries that have MIT or BSD licenses. Releasing a New Version of ARMI -------------------------------- +=============================== We use the common ``major.minor.bump`` version scheme where a version string might look like ``0.1.7``, ``1.0.0``, or ``12.3.123``. Each number has a specific meaning: @@ -158,13 +184,14 @@ Every release should follow this process: - Or from another commit: ``git tag <commit-hash> 1.0.0 -m "Release v1.0.0"`` - Pushing to the repo: ``git push origin 1.0.0`` - **NOTE** - The ONLY tags in the ARMI repo are for official version releases. + 5. Also add the release notes on `the GitHub UI <https://github.com/terrapower/armi/releases>`__. 6. Follow the instructions `here <https://github.com/terrapower/terrapower.github.io>`_ to archive the new documentation. 7. Tell everyone! Module-Level Logging --------------------- +==================== In most of the modules in ``armi``, you will see logging using the ``runLog`` module. This is a custom, global logging object provided by the import: diff --git a/doc/release/0.1.rst b/doc/release/0.1.rst index 01f5a1eb9..fb6d3fc26 100644 --- a/doc/release/0.1.rst +++ b/doc/release/0.1.rst @@ -1,6 +1,6 @@ -======================= +*********************** ARMI v0.1 Release Notes -======================= +*********************** ARMI v0.1.7 =========== diff --git a/doc/release/0.2.rst b/doc/release/0.2.rst index 883418687..9cfed61e6 100644 --- a/doc/release/0.2.rst +++ b/doc/release/0.2.rst @@ -1,23 +1,6 @@ -======================= +*********************** ARMI v0.2 Release Notes -======================= - -ARMI v0.2.10 -============ -Release Date: TBD - -What's new in ARMI ------------------- -#. The ``_copyInputsHelper()`` gives relative path and not absolute after copy. (`PR#1416 <https://github.com/terrapower/armi/pull/1416>`_) -#. ARMI now mandates ``ruff`` linting. (`PR#1419 <https://github.com/terrapower/armi/pull/1419>`_) -#. Removed all old ARMI requirements, to start the work fresh. (`PR#1438 <https://github.com/terrapower/armi/pull/1438>`_) -#. Downgrading Draft PRs as policy. (`PR#1444 <https://github.com/terrapower/armi/pull/1444>`_) -#. Attempt to set representative block number densities by component if possible. (`PR#1412 <https://github.com/terrapower/armi/pull/1412>`_) -#. TBD - -Bug fixes ---------- -#. TBD +*********************** ARMI v0.2.9 =========== @@ -26,7 +9,7 @@ Release Date: 2023-09-27 What's new in ARMI ------------------ #. Moved the ``Reactor`` assembly number from the global scope to a ``Parameter``. (`PR#1383 <https://github.com/terrapower/armi/pull/1383>`_) -#. Removed the global ``CaseSettings`` object, ``getMasterCs()``, and ``setMasterCs()``. (`PR#1399 <https://github.com/terrapower/armi/pull/1399>`_) +#. Removed the global ``Settings`` object, ``getMasterCs()``, and ``setMasterCs()``. (`PR#1399 <https://github.com/terrapower/armi/pull/1399>`_) #. Moved the Spent Fuel Pool (``sfp``) from the ``Core`` to the ``Reactor``. (`PR#1336 <https://github.com/terrapower/armi/pull/1336>`_) #. Made the ``sfp`` a child of the ``Reactor`` so it is stored in the database. (`PR#1349 <https://github.com/terrapower/armi/pull/1349>`_) #. Broad cleanup of ``Parameters``: filled in all empty units and descriptions, removed unused params. (`PR#1345 <https://github.com/terrapower/armi/pull/1345>`_) diff --git a/doc/release/0.3.rst b/doc/release/0.3.rst new file mode 100644 index 000000000..f241dff6f --- /dev/null +++ b/doc/release/0.3.rst @@ -0,0 +1,55 @@ +*********************** +ARMI v0.3 Release Notes +*********************** + +ARMI v0.3.1 +=========== +Release Date: TBD + +New Features +------------ +#. TBD + +API Changes +----------- +#. Renaming ``structuredgrid.py`` to camelCase. (`PR#1650 <https://github.com/terrapower/armi/pull/1650>`_) +#. Removing unused argument from ``Block.coords()``. (`PR#1651 <https://github.com/terrapower/armi/pull/1651>`_) +#. Removing unused method ``HexGrid.allPositionsInThird()``. (`PR#1655 <https://github.com/terrapower/armi/pull/1655>`_) +#. Removed unused methods: ``Reactor.getAllNuclidesIn()``, ``plotTriangleFlux()``. (`PR#1656 <https://github.com/terrapower/armi/pull/1656>`_) +#. Removed ``armi.utils.dochelpers``; not relevant to nuclear modeling. (`PR#1662 <https://github.com/terrapower/armi/pull/1662>`_) +#. Removing old tools created to help people convert to the current database format: ``armi.bookkeeping.db.convertDatabase()`` and ``ConvertDB``. (`PR#1658 <https://github.com/terrapower/armi/pull/1658>`_) +#. TBD + +Bug Fixes +--------- +#. Fixed four bugs with "corners up" hex grids. (`PR#1649 <https://github.com/terrapower/armi/pull/1649>`_) +#. TBD + +Changes that Affect Requirements +-------------------------------- +#. Very minor change to ``Block.coords()``, removing unused argument. (`PR#1651 <https://github.com/terrapower/armi/pull/1651>`_) +#. Touched ``HexGrid`` by adding a "cornersUp" property and fixing two bugs. (`PR#1649 <https://github.com/terrapower/armi/pull/1649>`_) +#. TBD + + +ARMI v0.3.0 +=========== +Release Date: 2024-01-26 + +What's new in ARMI? +------------------- +#. The ``_copyInputsHelper()`` gives relative path and not absolute after copy. (`PR#1416 <https://github.com/terrapower/armi/pull/1416>`_) +#. Attempt to set representative block number densities by component if possible. (`PR#1412 <https://github.com/terrapower/armi/pull/1412>`_) +#. Use ``functools`` to preserve function attributes when wrapping with ``codeTiming.timed`` (`PR#1466 <https://github.com/terrapower/armi/pull/1466>`_) +#. Remove a number of deprecated block, assembly, and core parameters related to a defunct internal plugin. + +Bug Fixes +--------- +#. ``StructuredGrid.getNeighboringCellIndices()`` was incorrectly implemented for the second neighbor. (`PR#1614 <https://github.com/terrapower/armi/pull/1614>`_) + +Quality Work +------------ +#. ARMI now mandates ``ruff`` linting. (`PR#1419 <https://github.com/terrapower/armi/pull/1419>`_) +#. Many new references to requirement tests and implementations were added to docstrings. +#. Removed all old ARMI requirements, to start the work fresh. (`PR#1438 <https://github.com/terrapower/armi/pull/1438>`_) +#. Downgrading Draft PRs as policy. (`PR#1444 <https://github.com/terrapower/armi/pull/1444>`_) diff --git a/doc/release/index.rst b/doc/release/index.rst index 5a56678a4..efb4e4938 100644 --- a/doc/release/index.rst +++ b/doc/release/index.rst @@ -1,5 +1,6 @@ +############# Release Notes -============= +############# Each ARMI release has a set of corresponding notes, found within this section. diff --git a/doc/tutorials/armi-example-app b/doc/tutorials/armi-example-app new file mode 160000 index 000000000..60becb513 --- /dev/null +++ b/doc/tutorials/armi-example-app @@ -0,0 +1 @@ +Subproject commit 60becb5137cf3c3671ebd44b06b894287611c181 diff --git a/doc/tutorials/index.rst b/doc/tutorials/index.rst index b3cdb1f7c..9e6881317 100644 --- a/doc/tutorials/index.rst +++ b/doc/tutorials/index.rst @@ -1,3 +1,5 @@ +.. _armi-tutorials: + ######### Tutorials ######### diff --git a/doc/tutorials/making_your_first_app.rst b/doc/tutorials/making_your_first_app.rst index a5f62b0de..4509bd97c 100644 --- a/doc/tutorials/making_your_first_app.rst +++ b/doc/tutorials/making_your_first_app.rst @@ -2,9 +2,11 @@ Note that this file makes use of Python files in a ``armi-example-app`` folder so that they can be put under testing. -================================ +.. _armi-make-first-app: + +******************************** Making your first ARMI-based App -================================ +******************************** In this tutorial we will build a nuclear analysis application that runs (dummy) neutron flux and thermal/hydraulics calculations. Applications that do real analysis can be @@ -385,7 +387,7 @@ from 360 |deg|\ C to 510 |deg|\ C (as expected given our simple TH solver). program to the data in the primary ARMI HDF5 file. However it is slightly more finicky and has slightly less support in some tools (looking at VisIT). -A generic description of the outputs is provided in :doc:`/user/outputs/index`. +A generic description of the outputs is provided in :doc:`/user/outputs`. You can add your own outputs from your plugins. diff --git a/doc/tutorials/walkthrough_inputs.rst b/doc/tutorials/walkthrough_inputs.rst index c4302d31c..f2e7ffa87 100644 --- a/doc/tutorials/walkthrough_inputs.rst +++ b/doc/tutorials/walkthrough_inputs.rst @@ -1,13 +1,15 @@ -======================================= +.. _walkthrough-inputs: + +*************************************** Building input files for a fast reactor -======================================= +*************************************** The true power of ARMI comes when you have a reactor at your fingertips. To get this, you must describe the reactor via input files. This tutorial will walk you through building input files from scratch for a reactor. We will model the CR=1.0 sodium-cooled fast reactor documented in `ANL-AFCI-177 <https://publications.anl.gov/anlpubs/2008/05/61507.pdf>`_. The full :doc:`documentation -for input files is available here </user/inputs/index>`. +for input files is available here </user/inputs>`. .. tip:: The full inputs created in this tutorial are available for download at the bottom of this page. diff --git a/doc/tutorials/walkthrough_lwr_inputs.rst b/doc/tutorials/walkthrough_lwr_inputs.rst index 693a32457..e4fa3def6 100644 --- a/doc/tutorials/walkthrough_lwr_inputs.rst +++ b/doc/tutorials/walkthrough_lwr_inputs.rst @@ -1,6 +1,6 @@ -========================================== +****************************************** Building input files for a thermal reactor -========================================== +****************************************** In the :doc:`previous tutorial </tutorials/walkthrough_inputs>`, we introduced the basic input files and made a full @@ -222,7 +222,7 @@ This should show a simple representation of the block. .. figure:: https://terrapower.github.io/armi/_static/c5g7-mox.png :figclass: align-center - **Figure 1.** A representation of a C5G7 fuel assembly. + A representation of a C5G7 fuel assembly. Here are the full files used in this example: diff --git a/doc/user/accessingEntryPoints.rst b/doc/user/accessingEntryPoints.rst index ade8b1e33..5ab5c7a12 100644 --- a/doc/user/accessingEntryPoints.rst +++ b/doc/user/accessingEntryPoints.rst @@ -1,9 +1,9 @@ +********************** Accessing Entry Points -====================== - +********************** Reports Entry Point -------------------- +=================== There are two ways to access the reports entry point in ARMI. diff --git a/doc/user/assembly_parameters_report.rst b/doc/user/assembly_parameters_report.rst index efa998fc9..964815fbe 100644 --- a/doc/user/assembly_parameters_report.rst +++ b/doc/user/assembly_parameters_report.rst @@ -1,14 +1,16 @@ +.. _assembly-parameters-report: + +******************* Assembly Parameters -=================== +******************* + This document lists all of the Assembly Parameters that are provided by the ARMI Framework. .. exec:: from armi.reactor import assemblies from armi.reactor import assemblyParameters - from armi.utils.dochelpers import generateParamTable + from dochelpers import generateParamTable return generateParamTable( assemblies.Assembly, assemblyParameters.getAssemblyParameterDefinitions() ) - - diff --git a/doc/user/block_parameters_report.rst b/doc/user/block_parameters_report.rst index 20549d892..1c54b1aa9 100644 --- a/doc/user/block_parameters_report.rst +++ b/doc/user/block_parameters_report.rst @@ -1,11 +1,15 @@ +.. _block-parameters-report: + +**************** Block Parameters -================ +**************** + This document lists all of the Block Parameters that are provided by the ARMI Framework. .. exec:: from armi.reactor import blocks from armi.reactor import blockParameters - from armi.utils.dochelpers import generateParamTable + from dochelpers import generateParamTable return generateParamTable( blocks.Block, blockParameters.getBlockParameterDefinitions() diff --git a/doc/user/component_parameters_report.rst b/doc/user/component_parameters_report.rst index 62ef2b142..75d356680 100644 --- a/doc/user/component_parameters_report.rst +++ b/doc/user/component_parameters_report.rst @@ -1,10 +1,14 @@ +.. _component-parameters-report: + +******************** Component Parameters -==================== +******************** + This document lists all of the Component Parameters that are provided by the ARMI Framework. .. exec:: from armi.reactor.components import Component from armi.reactor.components.componentParameters import getComponentParameterDefinitions - from armi.utils.dochelpers import generateParamTable + from dochelpers import generateParamTable return generateParamTable(Component, getComponentParameterDefinitions()) diff --git a/doc/user/core_parameters_report.rst b/doc/user/core_parameters_report.rst index 9f5633bf4..dc0ad5c4b 100644 --- a/doc/user/core_parameters_report.rst +++ b/doc/user/core_parameters_report.rst @@ -1,13 +1,16 @@ +.. _core-parameters-report: + +*************** Core Parameters -=============== +*************** + This document lists all of the Core Parameters that are provided by the ARMI Framework. .. exec:: from armi.reactor import reactors from armi.reactor import reactorParameters - from armi.utils.dochelpers import generateParamTable + from dochelpers import generateParamTable return generateParamTable( reactors.Core, reactorParameters.defineCoreParameters() ) - diff --git a/doc/user/index.rst b/doc/user/index.rst index 5c5df9eb6..360dea653 100644 --- a/doc/user/index.rst +++ b/doc/user/index.rst @@ -13,8 +13,8 @@ analyzing ARMI output files, etc. :numbered: user_install - inputs/index - outputs/index + inputs + outputs manual_data_access reactor_parameters_report core_parameters_report diff --git a/doc/user/inputs/blueprints.rst b/doc/user/inputs.rst similarity index 55% rename from doc/user/inputs/blueprints.rst rename to doc/user/inputs.rst index 810682c36..01212e1b3 100644 --- a/doc/user/inputs/blueprints.rst +++ b/doc/user/inputs.rst @@ -1,6 +1,419 @@ -************************* +****** +Inputs +****** + +ARMI input files define the initial state of the reactor model and tell ARMI what kind of analysis should be +performed on it. + +.. note:: We have a :ref:`walkthrough-inputs` tutorial for a quick + overview of the inputs. + +There are several input files: + +Settings file + Contains simulation parameters (like full power, cycle length, and which physics modules to + activate) and all kind of modeling approximation settings (e.g. convergence criteria) + +Blueprints file + Contains dimensions and composition of the components/blocks/assemblies in your reactor systems, from fuel + pins to heat exchangers + +Fuel management file + Describes how fuel moves around during a simulation + + +Depending on the type of analysis, there may be additional inputs required. These include things like +control logic, ex-core models for transients and shielding, etc. + +The core map input files can be graphically manipulated with the +:py:mod:`Grid editor <armi.utils.gridEditor>`. + + +The Settings Input File +======================= +The **settings** input file defines a series of key/value pairs the define various information about the system you are +modeling as well as which modules to run and various modeling/approximation settings. For example, it includes: + +* The case title +* The reactor power +* The number of cycles to run +* Which physics solvers to activate +* Whether or not to perform a critical control search +* Whether or not to do tight coupling iterations +* What neutronics approximations specific to the chosen physics solver to apply +* Environment settings (paths to external codes) +* How many CPUs to use on a computer cluster + +This file is a YAML file that you can edit manually with a text editor or with the ARMI GUI. + +Here is an excerpt from a settings file: + +.. literalinclude:: ../../../armi/tests/armiRun.yaml + :language: yaml + :lines: 3-15 + +A full listing of settings available in the framework may be found in the `Table of all global settings <#settings-report>`_ . + +Many settings are provided by the ARMI Framework, and others are defined by various plugins. + +.. _armi-gui: + +The ARMI GUI +------------ +The ARMI GUI may be used to manipulate many common settings (though the GUI can't change all of the settings). The GUI +also enables the graphical manipulation of a reactor core map, and convenient automation of commands required to submit to a +cluster. The GUI is a front-end to +these files. You can choose to use the GUI or not, ARMI doesn't know or care --- it just reads these files and runs 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 + +The assembly clicker +^^^^^^^^^^^^^^^^^^^^ +The assembly clicker (in the ``grids`` 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. + +If you want one assembly type to fill all positions in a ring, right click it once it is placed and choose ``Make ring +like this hex``. Once you submit the job or save the settings file (File -> Save), you will be prompted for a new name +of the geometry file before the settings file is saved. The geometry setting in the main tab will also be updated. + +The ARMI Environment Tab +^^^^^^^^^^^^^^^^^^^^^^^^ +The environment tab contains important settings about which version of ARMI you will run +and with which version of Python, etc. Most important is the ``ARMI location`` setting. This +points to the codebase that will run. If you want to run the released version of ARMI, +ensure that it is set in this setting. If you want to run a developer version, then be sure +to update this setting. + +Other settings on this tab may need to be updated depending on your computational environment. +Talk to your system admins to determine which settings are best. + +Some special settings +--------------------- +A few settings warrant additional discussion. + +.. _detail-assems: + +Detail assemblies +^^^^^^^^^^^^^^^^^ +Many plugins perform more detailed analysis on certain regions of the reactor. Since the analyses +often take longer, ARMI has a feature, called *detail assemblies* to help. Different plugins +may treat detail assemblies differently, so it's important to read the plugin documentation +as well. For example, a depletion plugin may perform pin-level depletion and rotation analysis +only on the detail assemblies. Or perhaps CFD thermal/hydraulics will be run on detail assemblies, +while subchannel T/H is run on the others. + +Detail assemblies are specified by the user in a variety of ways, +through the GUI or the settings system. + +.. warning:: The Detail Assemblies mechanism has begun to be too broad of a brush + for serious multiphysics calculations with each plugin treating them differently. + It is likely that this feature will be extended to be more flexible and less + surprising in the future. + +Detail Assembly Locations BOL + The ``detailAssemLocationsBOL`` setting is a list of assembly location strings + (e.g. ``004-003`` for ring 4, position 3). Assemblies that are in these locations at the + beginning-of-life will be activated as detail assemblies. + +Detail assembly numbers + The ``detailAssemNums`` setting is a list of ``assemNum``\ s that can be inferred from a previous + case and specified, regardless of when the assemblies enter the core. This is useful for + activating detailed treatment of assemblies that enter the core at a later cycle. + +Detail all assemblies + The ``detailAllAssems`` setting makes all assemblies in the problem detail assemblies + +.. _kinetics-settings: + +Kinetics settings +^^^^^^^^^^^^^^^^^ +In reactor physics analyses it is standard practice to represent reactivity +in either absolute units (i.e., dk/kk' or pcm) or in dollars or cents. To +support this functionality, the framework supplies the ``beta`` and +``decayConstants`` settings to apply the delayed neutron fraction and +precursor decay constants to the Core parameters during initialization. + +These settings come with a few caveats: + + 1. The ``beta`` setting supports two different meanings depending on + the type that is provided. If a single value is given, then this setting + is interpreted as the effective delayed neutron fraction for the + system. If a list of values is provided, then this setting is interpreted + as the group-wise (precursor family) delayed neutron fractions (useful for + reactor kinetics simulations). + + 2. The ``decayConstants`` setting is used to define the precursor + decay constants for each group. When set, it must be + provided with a corresponding ``beta`` setting that has the + same number of groups. For example, if six-group delayed neutron + fractions are provided, the decay constants must also be provided + in the same six-group structure. + + 3. If ``beta`` is interpreted as the effective delayed neutron fraction for + the system, then the ``decayConstants`` setting will not be utilized. + + 4. If both the group-wise ``beta`` and ``decayConstants`` are provided + and their number of groups are consistent, then the effective delayed + neutron fraction for the system is calculated as the summation of the + group-wise delayed neutron fractions. + +.. _cycle-history: + +Cycle history +^^^^^^^^^^^^^ +For all cases, ``nCycles`` and ``power`` must be specified by the user. +In the case that only a single state is to be examined (i.e. no burnup), the user need only additionally specify ``nCycles = 1``. + +In the case of burnup, the reactor cycle history may be specified using either the simple or detailed +option. +The simple cycle history consists of the following case settings: + + * ``power`` + * ``nCycles`` (default = 1) + * ``burnSteps`` (default = 4) + * ``availabilityFactor(s)`` (default = 1.0) + * ``cycleLength(s)`` (default = 365.2425) + +In addition, one may optionally use the ``powerFractions`` setting to change the reactor +power between each cycle. +With these settings, a user can define a history in which each cycle may vary +in power, length, and uptime. +The history is restricted, however, to each cycle having a constant power, to +each cycle having the same number of burnup nodes, and to those burnup nodes being +evenly spaced within each cycle. +An example simple cycle history might look like + +.. code-block:: yaml + + power: 1000000 + nCycles: 3 + burnSteps: 2 + cycleLengths: [100, R2] + powerFractions: [1.0, 0.5, 1.0] + availabilityFactors: [0.9, 0.3, 0.93] + +Note the use of the special shorthand list notation, where repeated values in a list can be specified using an "R" followed by the number of times the value is to be repeated. + +The above scheme would represent 3 cycles of operation: + + 1. 100% power for 90 days, split into two segments of 45 days each, followed by 10 days shutdown (i.e. 90% capacity) + + 2. 50% power for 30 days, split into two segments of 15 days each, followed by 70 days shutdown (i.e. 15% capacity) + + 3. 100% power for 93 days, split into two segments of 46.5 days each, followed by 7 days shutdown (i.e. 93% capacity) + +In each cycle, criticality calculations will be performed at 3 nodes evenly-spaced through the uptime portion of the cycle (i.e. ``availabilityFactor``*``powerFraction``), without option for changing node spacing or frequency. +This input format can be useful for quick scoping and certain types of real analyses, but clearly has its limitations. + +To overcome these limitations, the detailed cycle history, consisting of the ``cycles`` setting may be specified instead. +For each cycle, an entry to the ``cycles`` list is made with the following optional fields: + + * ``name`` + * ``power fractions`` + * ``cumulative days``, ``step days``, or ``burn steps`` + ``cycle length`` + * ``availability factor`` + +An example detailed cycle history employing all of these fields could look like + +.. code-block:: yaml + + power: 1000000 + nCycles: 4 + cycles: + - name: A + step days: [1, 1, 98] + power fractions: [0.1, 0.2, 1] + availability factor: 0.1 + - name: B + cumulative days: [2, 72, 78, 86] + power fractions: [0.2, 1.0, 0.95, 0.93] + - name: C + step days: [5, R5] + power fractions: [1, R5] + - cycle length: 100 + burn steps: 2 + availability factor: 0.9 + +Note that repeated values in a list may be again be entered using the shorthand notation for ``step days``, ``power fractions``, and ``availability factors`` (though not ``cumulative days`` because entries must be monotonically increasing). + +Such a scheme would define the following cycles: + + 1. A 2 day power ramp followed by full power operations for 98 days, with three nodes clustered during the ramp and another at the end of the cycle, followed by 900 days of shutdown + + 2. A 2 day power ramp followed by a prolonged period at full power and then a slight power reduction for the last 14 days in the cycle + + 3. Constant full-power operation for 30 days split into six even increments + + 4. Constant full-power operation for 90 days, split into two equal-length 45 day segments, followed by 10 days of downtime + +As can be seen, the detailed cycle history option provides much greated flexibility for simulating realistic operations, particularly power ramps or scenarios that call for unevenly spaced burnup nodes, such as xenon buildup in the early period of thermal reactor operations. + +.. note:: Although the detailed cycle history option allows for powers to change within each cycle, it should be noted that the power over each step is still considered to be constant. + +.. note:: The ``name`` field of the detailed cycle history is not yet used for anything, but this information will still be accessible on the operator during runtime. + +.. note:: Cycles without names will be given the name ``None`` + +.. warning:: When a detailed cycle history is combined with tight coupling, a subclass of :py:meth:`LatticePhysicsInterface.interactCoupled <armi.physics.neutronics.latticePhysics.latticePhysicsInterface.LatticePhysicsInterface.interactCoupled>` should be used. + +.. _restart-cases: + +Restart cases +^^^^^^^^^^^^^ +Oftentimes the user is interested in re-examining just a specific set of time nodes from an existing run. +In these cases, it is sometimes not necessary to rerun an entire reactor history, and one may instead use one of the following options: + + 1. Snapshot, where the reactor state is loaded from a database and just a single time node is run. + + 2. Restart, where the cycle history is loaded from a database and the calculation continues through the remaining specified time history. + +For either of these options, it is possible to alter the specific settings applied to the run by simply adjusting the case settings for the run. +For instance, a run that originally had only neutronics may incorporate thermal hydraulics during a snapshot run by adding in the relevant TH settings. + +.. note:: For either of these options, it is advisable to first create a new case settings file with a name different than the one from which you will be restarting off of, so as to not overwrite those results. + +To run a snapshot, the following settings must be added to your case settings: + + * Set ``runType`` to ``Snapshots`` + * Add a list of cycle/node pairs corresponding to the desired snapshots to ``dumpSnapshot`` formatted as ``'CCCNNN'`` + * Set ``reloadDBName`` to the existing database file that you would like to load the reactor state from + +An example of a snapshot run input: + +.. code-block:: yaml + + runType: Snapshots + reloadDBName: my-old-results.h5 + dumpSnapshot: ['000000', '001002'] # would produce 2 snapshots, at BOL and at node 2 of cycle 1 + +To run a restart, the following settings must be added to your case settings: + + * Set ``runType`` to ``Standard`` + * Set ``loadStyle`` to ``fromDB`` + * Set ``startCycle`` and ``startNode`` to the cycle/node that you would like to continue the calculation from (inclusive). ``startNode`` may use negative indexing. + * Set ``reloadDBName`` to the existing database file from which you would like to load the reactor history up to the restart point + * If you would like to change the specified reactor history (see :ref:`restart-cases`), keep the history up to the restarting cycle/node unchanged, and just alter the history after that point. This means that the cycle history specified in your restart run should include all cycles/nodes up to the end of the simulation. For complicated restarts, it may be necessary to use the detailed ``cycles`` setting, even if the original case only used the simple history option. + +A few examples of restart cases: + + - Restarting a calculation at a specific cycle/node and continuing for the remainder of the originally-specified cycle history: + .. code-block:: yaml + + # old settings + nCycles: 2 + burnSteps: 2 + cycleLengths: [100, 100] + runType: Standard + loadStyle: fromInput + loadingFile: my-blueprints.yaml + + .. code-block:: yaml + + # restart settings + nCycles: 2 + burnSteps: 2 + cycleLengths: [100, 100] + runType: Standard + loadStyle: fromDB + startCycle: 1 + startNode: 0 + reloadDBName: my-original-results.h5 + + - Add an additional cycle to the end of a case: + .. code-block:: yaml + + # old settings + nCycles: 1 + burnSteps: 2 + cycleLengths: [100] + runType: Standard + loadStyle: fromInput + loadingFile: my-blueprints.yaml + + .. code-block:: yaml + + # restart settings + nCycles: 2 + burnSteps: 2 + cycleLengths: [100, 100] + runType: Standard + loadStyle: fromDB + startCycle: 0 + startNode: -1 + reloadDBName: my-original-results.h5 + + - Restart but cut the reactor history short: + .. code-block:: yaml + + # old settings + nCycles: 3 + burnSteps: 2 + cycleLengths: [100, 100, 100] + runType: Standard + loadStyle: fromInput + loadingFile: my-blueprints.yaml + + .. code-block:: yaml + + # restart settings + nCycles: 2 + burnSteps: 2 + cycleLengths: [100, 100] + runType: Standard + loadStyle: fromDB + startCycle: 1 + startNode: 0 + reloadDBName: my-original-results.h5 + + - Restart with a different number of steps in the third cycle using the detailed ``cycles`` setting: + .. code-block:: yaml + + # old settings + nCycles: 3 + burnSteps: 2 + cycleLengths: [100, 100, 100] + runType: Standard + loadStyle: fromInput + loadingFile: my-blueprints.yaml + + .. code-block:: yaml + + # restart settings + nCycles: 3 + cycles: + - cycle length: 100 + burn steps: 2 + - cycle length: 100 + burn steps: 2 + - cycle length: 100 + burn steps: 4 + runType: Standard + loadStyle: fromDB + startCycle: 2 + startNode: 0 + reloadDBName: my-original-results.h5 + +.. note:: The ``skipCycles`` setting is related to skipping the lattice physics calculation specifically, it is not required to do a restart run. + +.. note:: The X-SHUFFLES.txt file is required to do explicit repeated fuel management. + +.. note:: The restart.dat file is required to repeat the exact fuel management methods during a branch search. These can potentially modify the reactor state in ways that cannot be captures with the SHUFFLES.txt file. + +.. note:: The ISO binary cross section libraries are required to run cases that skip the lattice physics calculation (e.g. MC^2) + +.. note:: The multigroup flux is not yet stored on the output databases. If you need to do a restart with these values (e.g. for depletion), then you need to reload from neutronics outputs. + +.. note:: Restarting a calculation with an different version of ARMI than what was used to produce the restarting database may result in undefined behavior. + +.. _bp-input-file: + The Blueprints Input File -************************* +========================= The **blueprints** input defines the dimensions of structures in the reactor, as well as their material makeup. In a typical case, pin dimensions, isotopic composition, control definitions, coolant type, etc. are @@ -33,8 +446,7 @@ ARMI models are built hierarchically, first by defining components, and then by collections of the levels of the reactor. Blueprint sections -================== - +------------------ The **blueprints** input file has several sections that corresponds to different levels of the reactor hierarchy. You will generally build inputs "bottoms up", first by defining elementary pieces (like pins) and then collecting them into the core and reactor. @@ -44,7 +456,7 @@ The ARMI data model is represented schematically below, and the blueprints are d .. figure:: /.static/armi_reactor_objects.png :align: center - **Figure 1.** The primary data containers in ARMI + The primary data containers in ARMI :ref:`blocks <blocks-and-components>`: Defines :py:class:`~armi.reactor.components.component.Component` inputs for a @@ -79,7 +491,7 @@ The ARMI data model is represented schematically below, and the blueprints are d .. _blocks-and-components: Blocks and Components -===================== +--------------------- Blocks and components are defined together in the **blueprints** input. We will start with a component, and then define the whole ``blocks:`` @@ -106,7 +518,7 @@ input. The structure will be something like:: is not fully implemented yet. Defining a Component --------------------- +^^^^^^^^^^^^^^^^^^^^ The **Components** section defines the pin (if modeling a pin-type reactor) and assembly in-plane dimensions (axial dimensions are defined in the :ref:`assemblies` input) and the material makeups of each :py:mod:`Component <armi.reactor.components>`. :py:mod:`Blocks <armi.reactor.blocks>` are @@ -168,18 +580,20 @@ od .. _componentTypes: Component Types ---------------- +^^^^^^^^^^^^^^^ Each component has a variety of dimensions to define the shape and composition. All dimensions are in cm. The following is a list of included component shapes and their dimension inputs. Again, additional/custom components with arbitrary dimensions may be provided by the user via plugins. .. exec:: - from tabulate import tabulate from armi.reactor.components import ComponentType + from dochelpers import createListTable - return create_table(tabulate(headers=('Component Name', 'Dimensions'), - tabular_data=[(c.__name__, ', '.join(c.DIMENSION_NAMES)) for c in ComponentType.TYPES.values()], - tablefmt='rst'), caption="Component list") + rows = [['Component Name', 'Dimensions']] + for c in ComponentType.TYPES.values(): + rows.append([c.__name__, ', '.join(c.DIMENSION_NAMES)]) + + return createListTable(rows, widths=[25, 65], klass="longtable") When a ``DerivedShape`` is specified as the final component in a block, its area is inferred from the difference between the area of the block and the sum of the areas @@ -189,7 +603,7 @@ a lattice of pins. .. _componentLinks: Component Links ---------------- +^^^^^^^^^^^^^^^ Dimensions of a component may depend on the dimensions of a previously-defined component in the same block. For instance, the sodium bond between fuel and cladding. The format is simply ``<componentName>.<dimensionName>``. The dimension names are available in the table above. @@ -233,7 +647,7 @@ reduced. This is physical since, in reality, the fluid would be displaced as dim change. Pin lattices ------------- +^^^^^^^^^^^^ Pin lattices may be explicitly defined in the block/component input in conjunction with the ``grids`` input section. A block may assigned a grid name, and then each component may be assigned one or more grid specifiers. @@ -272,7 +686,7 @@ cladding as the fuel pins. :: .. _naming-flags: Flags and naming -================ +---------------- All objects in the ARMI Reactor Model possess a set of :py:class:`armi.reactor.flags.Flags`, which can be used to affect the way that the @@ -310,7 +724,7 @@ will get the ``CLAD`` flag from its name. .. _assemblies: Assemblies -========== +---------- Once components and blocks are defined, Assemblies can be created as extruded stacks of blocks from bottom to top. The assemblies use YAML anchors to refer to the blocks defined in the previous section. @@ -521,7 +935,7 @@ other structure. .. _systems: Systems -======= +------- Once assemblies are defined they can be grouped together into the Core, the spent fuel pool (SFP), etc. A complete reactor structure with a core and a SFP may be seen below:: @@ -546,7 +960,7 @@ in units of cm. This allows you to define the relative position of the various s The ``grid name`` inputs are string mappings to the grid definitions described below. Plugin Behavior ---------------- +^^^^^^^^^^^^^^^ The :meth:`armi.plugins.ArmiPlugin.defineSystemBuilders` method can be provided by plugins to control how ARMI converts the ``systems`` section into ``Composite``\ s @@ -566,7 +980,7 @@ and new mappings of values to builders. .. _grids: Grids -===== +----- Grids are described inside a blueprint file using ``lattice map`` or ``grid contents`` fields to define arrangements in Hex, Cartesian, or R-Z-Theta. The optional ``lattice pitch`` entry allows you to specify spacing between objects that is different from tight packing. This input is required @@ -622,7 +1036,7 @@ Example grid definitions are shown below:: .. _custom-isotopics: Custom Isotopics -================ +---------------- In some cases (such as benchmarking a previous reactor), the default mass fractions from the material library are not what you want to model. In these cases, you may override the isotopic composition provided by the material library in this section. There are three ways to specify @@ -649,10 +1063,10 @@ nuclear data library). The (mass) ``density`` input is invalid when specifying ``number densities``; the code will present an error message. Advanced topics -=============== +--------------- Overlapping shapes ------------------- +^^^^^^^^^^^^^^^^^^ Solids of different compositions in contact with each other present complications during thermal expansion. The ARMI Framework does not perform calculations to see exactly how such scenarios will behave mechanically; it instead focuses on conserving mass. To do this, users should @@ -675,7 +1089,7 @@ component and get the siblings ``mult``. If you are concerned about performance with a YAML anchor and alias. Component area modifications ----------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ In some scenarios, it is desired to have one component's area be subtracted or added to another. For example, the area of the skids in a skid duct design needs to be subtracted from the interstitial coolant. The mechanism to handle this involves adding a parameter to the component to be @@ -704,7 +1118,7 @@ without explicitly defining new components. modArea: holes.sub # "holes" is the name of the other component Putting it all together to make a Block ---------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Here is a complete fuel block definition:: @@ -769,7 +1183,7 @@ Here is a complete fuel block definition:: Making blocks with unshaped components --------------------------------------- +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Sometimes you will want to make a homogenous block, which is a mixture of multiple materials, and will not want to define an exact shape for each of the components in @@ -848,15 +1262,13 @@ are now four components, but only three that have actual area and composition:: This can similarly be done for hex geometry and and a hexagon with Outer Pitch (``op``). ---------- - .. warning:: The rest of the input described below are scheduled to be moved into the settings input file, since their nature is that of a setting. .. _nuclide-flags: Nuclide Flags -============= +------------- The ``nuclide flags`` setting allows the user to choose which nuclides they would like to consider in the problem, and whether or not each nuclide should transmute and decay. For example, sometimes you may not want to deplete trace @@ -914,3 +1326,253 @@ 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 in ARMI is specified through custom Python scripts that often reside +in the working directory of a run (but can be anywhere if you use full paths). During a normal run, +ARMI checks for two fuel management settings: + +``shuffleLogic`` + The path to the Python source file that contains the user's custom fuel + management logic + +``fuelHandlerName`` + The name of a FuelHandler class that ARMI will look for in the Fuel Management Input file + pointed to by the ``shuffleLogic`` path. Since it's input, it's the user's responsibility + to design and place that object in that file. + +.. note:: We consider the limited syntax needed to express fuel management in Python + code itself to be sufficiently expressive and simple for non-programmers to + actually use. Indeed, this has been our experience. + +The ARMI Operator will call its fuel handler's ``outage`` method before each cycle (and, if requested, during branch +search calculations). The :py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.outage` method +will perform bookkeeping operations, and eventually +call the user-defined ``chooseSwaps`` method (located in Fuel Management Input). ``chooseSwaps`` will +generally contain calls to :py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.findAssembly`, +:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.swapAssemblies` , +:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.swapCascade`, and +:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.dischargeSwap`, which are the primary +fuel management operations and can be found in the fuel management module. + +Also found in the user-defined Fuel Management Input module is a ``getFactors`` method, which is used to control which +shuffling routines get called and at which time. + +.. note:: + + See the :py:mod:`fuelHandlers module <armi.physics.fuelCycle.fuelHandlers>` for more details. + +Fuel Management Operations +-------------------------- +In the ARMI, the assemblies can be moved as units around the reactor with swapAssemblies, +dischargeSwap, and swapCascade of a ``FuelHandler`` interface. + +swapAssemblies +^^^^^^^^^^^^^^ +swapAssemblies is the simplest fuel management operation. Given two assembly objects, this method will switch +their locations. :: + + self.swapAssemblies(a1,a2) + +dischargeSwap +^^^^^^^^^^^^^ +A discharge swap is a simple operation that puts a new assembly into the reactor while discharging an +outgoing one. :: + + self.dischargeSwap(newIncoming,oldOutgoing) + +This operation keeps track of the outgoing assembly in a AssemblyList object that the Reactor object has access to so you can see how much of what you discharged. + +swapCascade +^^^^^^^^^^^ +SwapCascade is a more powerful swapping function that can swap a list of assemblies in a "daisy-chain" type +of operation. These are useful for doing the main overtone shuffling operations such as convergent shuffling +and/or convergent-divergent shuffling. If we load up the list of assemblies, the first one will be put in the +last one's position, and all others will shift accordingly. + +As an example, consider assemblies 1 through 5 in core positions A through E.:: + + self.swapCascade([a1,a2,a3,a4,a5]) + +This table shows the positions of the assemblies before and after the swap cascade. + + +======== ============================ =========================== +Assembly Position Before Swap Cascade Position After Swap Cascade +======== ============================ =========================== +1 A E +2 B A +3 C B +4 D C +5 E D +======== ============================ =========================== + +Arbitrarily complex cascades can thusly be assembled by choosing the order of the assemblies passed into swapCascade. + +Choosing Assemblies to Move +--------------------------- +The methods described in the previous section require known assemblies to shuffle. Choosing these assemblies is +the essence of fuel shuffling design. The single method used for these purposes is the FuelHandler's ``findAssembly`` +method. This method is very general purpose, and ranks in the top 3 most important +methods of the ARMI altogether. + +To use it, just say:: + + a = self.findAssembly(param='maxPercentBu',compareTo=20) + +This will return the assembly in the reactor that has a maximum burnup closest to 20%. +Other inputs to findAssembly are summarized in the API docs of +:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.findAssembly`. + + +Fuel Management Examples +------------------------ + +Convergent-Divergent +^^^^^^^^^^^^^^^^^^^^ +Convergent-divergent shuffling is when fresh assemblies march in from the outside until +they approach the jump ring, at which point they jump to the center and diverge until +they reach the jump ring again, where they now jump to the outer periphery of the core, +or become discharged. + +If the jump ring is 6, the order of target rings is:: + + [6, 5, 4, 3, 2, 1, 6, 7, 8, 9, 10, 11, 12, 13] + +In this case, assemblies converge from ring 13 to 12, to 11, to 10, ..., to 6, and then +jump to 1 and diverge until they get back to 6. In a discharging equilibrium case, the +highest burned assembly in the jumpRing should get discharged and the lowest should +jump by calling a dischargeSwap on cascade[0] and a fresh feed after this cascade is +run. + +The convergent rings in this case are 7 through 13 and the divergent ones are 1 +through 5 are the divergent ones. + + +Fuel Management Tips +-------------------- +Some mistakes are common. Follow these tips. + + * Always make sure your assembly-level types in the settings file are up to date + with the grids in your bluepints file. Otherwise you'll be moving feeds when you + want to move igniters, or something. + * Use the exclusions list! If you move a cascade and then the next cascade tries + to run, it will choose your newly-moved assemblies if they fit your criteria in + ``findAssemblies``. This leads to very confusing results. Therefore, once you move + assemblies, you should default to adding them to the exclusions list. + * Print cascades during debugging. After you've built a cascade to swap, print it + out and check the locations and types of each assembly in it. Is it what you want? + * Watch ``typeNum`` in the database. You can get good intuition about what is + getting moved by viewing this parameter. + +Running a branch search +----------------------- +ARMI can perform a branch search where a number of fuel management operations +are performed in parallel and the preferred one is chosen and proceeded with. +The key to any branch search is writing a fuel handler that can interpret +**fuel management factors**, defined as keyed values between 0 and 1. + +As an example, a fuel handler may be written to interpret two factors, ``numDischarges`` +and ``chargeEnrich``. One method in the fuel handler would then take +the value of ``factors['numDischarges']`` and multiply it by the maximum +number of discharges (often set by another user setting) and then discharge +this many assemblies. Similarly, another method would take the ``factors['chargeEnrich']`` +value (between 0 and 1) and multiply it by the maximum allowable enrichment +(again, usually controlled by a user setting) to determine which enrichment +should be used to fabricate new assemblies. + +Given a fuel handler that can thusly interpret factors between 0 and 1, the +concept of branch searches is simple. They simply build uniformly distributed +lists between 0 and 1 across however many CPUs are available and cases on all +of them, passing one of each of the factors to each CPU in parallel. When the cases +finish, the branch search determines the optimal result and selects the corresponding +value of the factor to proceed. + +Branch searches are controlled by custom `getFactorList` methods specified in the +`shuffleLogic` input files. This method should return two things: + + * A ``defaultFactors``; a dictionary with user-defined keys and values between + 0 and 1 for each key. These factors will be passed to the ``chooseSwaps`` + method, which is typically overridden by the user in custom fuel handling code. + The fuel handling code should interpret the values and move the fuel + according to what is sent. + + * A ``factorSearchFlags`` list, which lists the keys to be branch searched. + The search will optimize the first key first, and then do a second pass + on the second key, holding the optimal first value constant, and so on. + +Such a method may look like this:: + + def getFactorList(cycle,cs=None): + + # init default shuffling factors + defaultFactors = {'chargeEnrich':0,'numDischarges':1} + factorSearchFlags=[] # init factors to run branch searches on + + # determine when to activate various factors / searches + if cycle not in [0,5,6]: + # shuffling happens before neutronics so skip the first cycle. + defaultFactors['chargeEnrich']=1 + else: + defaultFactors['numDischarges']=0 + factorSearchFlags = ['chargeEnrich'] + + return defaultFactors,factorSearchFlags + +Once a proper ``getFactorList`` method exists and a fuel handler object +exists that can interpret the factors, activate a branch search +during a regular run by selecting the **Branch Search** option on the GUI. + +The **best** result from the branch search is determined by comparing the *keff* values +with the ``targetK`` setting, which is available for setting in the GUI. The branch +with *keff* closest to the setting, while still being above 1.0 is chosen. + +.. _settings-report: + +Settings Report +=============== +This document lists all the `settings <#the-settings-input-file>`_ in ARMI. + +They are all accessible to developers +through the :py:class:`armi.settings.caseSettings.Settings` object, which is typically +stored in a variable named ``cs``. Interfaces have access to a simulation's settings +through ``self.cs``. + + +.. exec:: + from armi import settings + import textwrap + + def looks_like_path(s): + """Super quick, not robust, check if a string looks like a file path.""" + if s.startswith("\\\\") or s.startswith("//") or s[1:].startswith(":\\"): + return True + return False + + subclassTables = {} + cs = settings.Settings() + + # User textwrap to split up long words that mess up the table. + wrapper = textwrap.TextWrapper(width=25, subsequent_indent='') + wrapper2 = textwrap.TextWrapper(width=10, subsequent_indent='') + content = '\n.. list-table:: ARMI Settings\n :header-rows: 1\n :widths: 20 30 15 15\n \n' + content += ' * - Name\n - Description\n - Default\n - Options\n' + + for setting in sorted(cs.values(), key=lambda s: s.name): + content += ' * - {}\n'.format(' '.join(wrapper.wrap(setting.name))) + content += ' - {}\n'.format(' '.join(wrapper.wrap(str(setting.description) or ''))) + default = str(getattr(setting, 'default', None)).split("/")[-1] + options = str(getattr(setting,'options','') or '') + if looks_like_path(default): + # We don't want to display default file paths in this table. + default = "" + options = "" + content += ' - {}\n'.format(' '.join(['``{}``'.format(wrapped) for wrapped in wrapper2.wrap(default)])) + content += ' - {}\n'.format(' '.join(['``{}``'.format(wrapped) for wrapped in wrapper.wrap(options)])) + + content += '\n' + + return content diff --git a/doc/user/inputs/fuel_management.rst b/doc/user/inputs/fuel_management.rst deleted file mode 100644 index 4fe3fb912..000000000 --- a/doc/user/inputs/fuel_management.rst +++ /dev/null @@ -1,194 +0,0 @@ -********************* -Fuel Management Input -********************* - -Fuel management in ARMI is specified through custom Python scripts that often reside -in the working directory of a run (but can be anywhere if you use full paths). During a normal run, -ARMI checks for two fuel management settings: - -``shuffleLogic`` - The path to the Python source file that contains the user's custom fuel - management logic - -``fuelHandlerName`` - The name of a FuelHandler class that ARMI will look for in the Fuel Management Input file - pointed to by the ``shuffleLogic`` path. Since it's input, it's the user's responsibility - to design and place that object in that file. - -.. note:: We consider the limited syntax needed to express fuel management in Python - code itself to be sufficiently expressive and simple for non-programmers to - actually use. Indeed, this has been our experience. - -The ARMI Operator will call its fuel handler's ``outage`` method before each cycle (and, if requested, during branch -search calculations). The :py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.outage` method -will perform bookkeeping operations, and eventually -call the user-defined ``chooseSwaps`` method (located in Fuel Management Input). ``chooseSwaps`` will -generally contain calls to :py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.findAssembly`, -:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.swapAssemblies` , -:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.swapCascade`, and -:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.dischargeSwap`, which are the primary -fuel management operations and can be found in the fuel management module. - -Also found in the user-defined Fuel Management Input module is a ``getFactors`` method, which is used to control which -shuffling routines get called and at which time. - -.. note:: - - See the :py:mod:`fuelHandlers module <armi.physics.fuelCycle.fuelHandlers>` for more details. - -Fuel Management Operations -========================== -In the ARMI, the assemblies can be moved as units around the reactor with swapAssemblies, -dischargeSwap, and swapCascade of a ``FuelHandler`` interface. - -swapAssemblies --------------- -swapAssemblies is the simplest fuel management operation. Given two assembly objects, this method will switch -their locations. :: - - self.swapAssemblies(a1,a2) - -dischargeSwap -------------- -A discharge swap is a simple operation that puts a new assembly into the reactor while discharging an -outgoing one. :: - - self.dischargeSwap(newIncoming,oldOutgoing) - -This operation keeps track of the outgoing assembly in a AssemblyList object that the Reactor object has access to so you can see how much of what you discharged. - -swapCascade ------------ -SwapCascade is a more powerful swapping function that can swap a list of assemblies in a "daisy-chain" type -of operation. These are useful for doing the main overtone shuffling operations such as convergent shuffling -and/or convergent-divergent shuffling. If we load up the list of assemblies, the first one will be put in the -last one's position, and all others will shift accordingly. - -As an example, consider assemblies 1 through 5 in core positions A through E.:: - - self.swapCascade([a1,a2,a3,a4,a5]) - -This table shows the positions of the assemblies before and after the swap cascade. - - -======== ============================ =========================== -Assembly Position Before Swap Cascade Position After Swap Cascade -======== ============================ =========================== -1 A E -2 B A -3 C B -4 D C -5 E D -======== ============================ =========================== - -Arbitrarily complex cascades can thusly be assembled by choosing the order of the assemblies passed into swapCascade. - -Choosing Assemblies to Move -=========================== - -The methods described in the previous section require known assemblies to shuffle. Choosing these assemblies is -the essence of fuel shuffling design. The single method used for these purposes is the FuelHandler's ``findAssembly`` -method. This method is very general purpose, and ranks in the top 3 most important -methods of the ARMI altogether. - -To use it, just say:: - - a = self.findAssembly(param='maxPercentBu',compareTo=20) - -This will return the assembly in the reactor that has a maximum burnup closest to 20%. Other -inputs to findAssembly are summarized in the API docs of -:py:meth:`~armi.physics.fuelCycle.fuelHandlers.FuelHandler.findAssembly`. - - -Fuel Management Examples -======================== - -Convergent-Divergent --------------------- - -Convergent-divergent shuffling is when fresh assemblies march in from the outside until they approach the jump ring, -at which point they jump to the center and diverge until they reach the jump ring again, where they now jump to the -outer periphery of the core, or become discharged. - -If the jump ring is 6, the order of target rings is:: - - [6, 5, 4, 3, 2, 1, 6, 7, 8, 9, 10, 11, 12, 13] - -In this case, assemblies converge from ring 13 to 12, to 11, to 10, ..., to 6, and then jump to 1 and diverge -until they get back to 6. In a discharging equilibrium case, the highest burned assembly in the jumpRing should -get discharged and the lowest should jump by calling a dischargeSwap on cascade[0] and a fresh feed after this -cascade is run. - -The convergent rings in this case are 7 through 13 and the divergent ones are 1 through 5 are the divergent ones. - - -Fuel Management Tips -==================== -Some mistakes are common. Follow these tips. - - * Always make sure your assembly-level types in the settings file are up to date with the grids in your bluepints file. Otherwise you'll be moving feeds when you want to move igniters, or something. - * Use the exclusions list! If you move a cascade and then the next cascade tries to run, it will choose your newly-moved assemblies if they fit your criteria in ``findAssemblies``. This leads to very confusing results. Therefore, once you move assemblies, you should default to adding them to the exclusions list. - * Print cascades during debugging. After you've built a cascade to swap, print it out and check the locations and types of each assembly in it. Is it what you want? - * Watch ``typeNum`` in the database. You can get good intuition about what is getting moved by viewing this parameter. - -Running a branch search -======================= -ARMI can perform a branch search where a number of fuel management operations -are performed in parallel and the preferred one is chosen and proceeded with. -The key to any branch search is writing a fuel handler that can interpret -**fuel management factors**, defined as keyed values between 0 and 1. - -As an example, a fuel handler may be written to interpret two factors, ``numDischarges`` -and ``chargeEnrich``. One method in the fuel handler would then take -the value of ``factors['numDischarges']`` and multiply it by the maximum -number of discharges (often set by another user setting) and then discharge -this many assemblies. Similarly, another method would take the ``factors['chargeEnrich']`` -value (between 0 and 1) and multiply it by the maximum allowable enrichment -(again, usually controlled by a user setting) to determine which enrichment -should be used to fabricate new assemblies. - -Given a fuel handler that can thusly interpret factors between 0 and 1, the -concept of branch searches is simple. They simply build uniformly distributed -lists between 0 and 1 across however many CPUs are available and cases on all -of them, passing one of each of the factors to each CPU in parallel. When the cases finish, -the branch search determines the optimal result and selects the corresponding -value of the factor to proceed. - -Branch searches are controlled by custom `getFactorList` methods specified in the -`shuffleLogic` input files. This method should return two things: - - * A ``defaultFactors``; a dictionary with user-defined keys and values between - 0 and 1 for each key. These factors will be passed to the ``chooseSwaps`` - method, which is typically overridden by the user in custom fuel handling code. - The fuel handling code should interpret the values and move the fuel - according to what is sent. - - * A ``factorSearchFlags`` list, which lists the keys to be branch searched. - The search will optimize the first key first, and then do a second pass - on the second key, holding the optimal first value constant, and so on. - -Such a method may look like this:: - - def getFactorList(cycle,cs=None): - - # init default shuffling factors - defaultFactors = {'chargeEnrich':0,'numDischarges':1} - factorSearchFlags=[] # init factors to run branch searches on - - # determine when to activate various factors / searches - if cycle not in [0,5,6]: - # shuffling happens before neutronics so skip the first cycle. - defaultFactors['chargeEnrich']=1 - else: - defaultFactors['numDischarges']=0 - factorSearchFlags = ['chargeEnrich'] - - return defaultFactors,factorSearchFlags - -Once a proper ``getFactorList`` method exists and a fuel handler object -exists that can interpret the factors, activate a branch search -during a regular run by selecting the **Branch Search** option on the GUI. - -The **best** result from the branch search is determined by comparing the *keff* values -with the ``targetK`` setting, which is available for setting in the GUI. The branch -with *keff* closest to the setting, while still being above 1.0 is chosen. diff --git a/doc/user/inputs/index.rst b/doc/user/inputs/index.rst deleted file mode 100644 index fa3f7a977..000000000 --- a/doc/user/inputs/index.rst +++ /dev/null @@ -1,39 +0,0 @@ -****** -Inputs -****** - -ARMI input files define the initial state of the reactor model and tell ARMI what kind of analysis should be -performed on it. - -.. note:: We have an :doc:`input walkthrough </tutorials/walkthrough_inputs>` tutorial for a quick - overview of the inputs. - -There are several input files: - -Settings file - Contains simulation parameters (like full power, cycle length, and which physics modules to - activate) and all kind of modeling approximation settings (e.g. convergence criteria) - -Blueprints file - Contains dimensions and composition of the components/blocks/assemblies in your reactor systems, from fuel - pins to heat exchangers - -Fuel management file - Describes how fuel moves around during a simulation - - -Depending on the type of analysis, there may be additional inputs required. These include things like -control logic, ex-core models for transients and shielding, etc. - -The core map input files can be graphically manipulated with the -:py:mod:`Grid editor <armi.utils.gridEditor>`. - - ------------ - -.. toctree:: - - settings - blueprints - fuel_management - settings_report diff --git a/doc/user/inputs/settings.rst b/doc/user/inputs/settings.rst deleted file mode 100644 index d0f9cc92c..000000000 --- a/doc/user/inputs/settings.rst +++ /dev/null @@ -1,387 +0,0 @@ -*********************** -The Settings Input File -*********************** - -The **settings** input file defines a series of key/value pairs the define various information about the system you are -modeling as well as which modules to run and various modeling/approximation settings. For example, it includes: - -* The case title -* The reactor power -* The number of cycles to run -* Which physics solvers to activate -* Whether or not to perform a critical control search -* Whether or not to do tight coupling iterations -* What neutronics approximations specific to the chosen physics solver to apply -* Environment settings (paths to external codes) -* How many CPUs to use on a computer cluster - -This file is a YAML file that you can edit manually with a text editor or with the ARMI GUI. - -Here is an excerpt from a settings file: - -.. literalinclude:: ../../../armi/tests/armiRun.yaml - :language: yaml - :lines: 3-15 - -A full listing of settings available in the framework may be found in the :doc:`Table of all global settings </user/inputs/settings_report>`. - -Many settings are provided by the ARMI Framework, and others are defined by various plugins. - -.. _armi-gui: - -The ARMI GUI -============ -The ARMI GUI may be used to manipulate many common settings (though the GUI can't change all of the settings). The GUI -also enables the graphical manipulation of a reactor core map, and convenient automation of commands required to submit to a -cluster. The GUI is a front-end to -these files. You can choose to use the GUI or not, ARMI doesn't know or care --- it just reads these files and runs 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 - -The assembly clicker --------------------- -The assembly clicker (in the ``grids`` editor) allows users to define the 2-D layout of the assemblies defined in the -:doc:`/user/inputs/blueprints`. 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. - -If you want one assembly type to fill all positions in a ring, right click it once it is placed and choose ``Make ring -like this hex``. Once you submit the job or save the settings file (File -> Save), you will be prompted for a new name -of the geometry file before the settings file is saved. The geometry setting in the main tab will also be updated. - -The ARMI Environment Tab ------------------------- -The environment tab contains important settings about which version of ARMI you will run -and with which version of Python, etc. Most important is the ``ARMI location`` setting. This -points to the codebase that will run. If you want to run the released version of ARMI, -ensure that it is set in this setting. If you want to run a developer version, then be sure -to update this setting. - -Other settings on this tab may need to be updated depending on your computational environment. -Talk to your system admins to determine which settings are best. - -Some special settings -===================== -A few settings warrant additional discussion. - -.. _detail-assems: - -Detail assemblies ------------------ -Many plugins perform more detailed analysis on certain regions of the reactor. Since the analyses -often take longer, ARMI has a feature, called *detail assemblies* to help. Different plugins -may treat detail assemblies differently, so it's important to read the plugin documentation -as well. For example, a depletion plugin may perform pin-level depletion and rotation analysis -only on the detail assemblies. Or perhaps CFD thermal/hydraulics will be run on detail assemblies, -while subchannel T/H is run on the others. - -Detail assemblies are specified by the user in a variety of ways, -through the GUI or the settings system. - -.. warning:: The Detail Assemblies mechanism has begun to be too broad of a brush - for serious multiphysics calculations with each plugin treating them differently. - It is likely that this feature will be extended to be more flexible and less - surprising in the future. - -Detail Assembly Locations BOL - The ``detailAssemLocationsBOL`` setting is a list of assembly location strings - (e.g. ``004-003`` for ring 4, position 3). Assemblies that are in these locations at the - beginning-of-life will be activated as detail assemblies. - -Detail assembly numbers - The ``detailAssemNums`` setting is a list of ``assemNum``\ s that can be inferred from a previous - case and specified, regardless of when the assemblies enter the core. This is useful for - activating detailed treatment of assemblies that enter the core at a later cycle. - -Detail all assemblies - The ``detailAllAssems`` setting makes all assemblies in the problem detail assemblies - -.. _kinetics-settings: - -Kinetics settings ------------------ -In reactor physics analyses it is standard practice to represent reactivity -in either absolute units (i.e., dk/kk' or pcm) or in dollars or cents. To -support this functionality, the framework supplies the ``beta`` and -``decayConstants`` settings to apply the delayed neutron fraction and -precursor decay constants to the Core parameters during initialization. - -These settings come with a few caveats: - - 1. The ``beta`` setting supports two different meanings depending on - the type that is provided. If a single value is given, then this setting - is interpreted as the effective delayed neutron fraction for the - system. If a list of values is provided, then this setting is interpreted - as the group-wise (precursor family) delayed neutron fractions (useful for - reactor kinetics simulations). - - 2. The ``decayConstants`` setting is used to define the precursor - decay constants for each group. When set, it must be - provided with a corresponding ``beta`` setting that has the - same number of groups. For example, if six-group delayed neutron - fractions are provided, the decay constants must also be provided - in the same six-group structure. - - 3. If ``beta`` is interpreted as the effective delayed neutron fraction for - the system, then the ``decayConstants`` setting will not be utilized. - - 4. If both the group-wise ``beta`` and ``decayConstants`` are provided - and their number of groups are consistent, then the effective delayed - neutron fraction for the system is calculated as the summation of the - group-wise delayed neutron fractions. - -.. _cycle-history: - -Cycle history -------------- -For all cases, ``nCycles`` and ``power`` must be specified by the user. -In the case that only a single state is to be examined (i.e. no burnup), the user need only additionally specify ``nCycles = 1``. - -In the case of burnup, the reactor cycle history may be specified using either the simple or detailed -option. -The simple cycle history consists of the following case settings: - - * ``power`` - * ``nCycles`` (default = 1) - * ``burnSteps`` (default = 4) - * ``availabilityFactor(s)`` (default = 1.0) - * ``cycleLength(s)`` (default = 365.2425) - -In addition, one may optionally use the ``powerFractions`` setting to change the reactor -power between each cycle. -With these settings, a user can define a history in which each cycle may vary -in power, length, and uptime. -The history is restricted, however, to each cycle having a constant power, to -each cycle having the same number of burnup nodes, and to those burnup nodes being -evenly spaced within each cycle. -An example simple cycle history might look like - -.. code-block:: yaml - - power: 1000000 - nCycles: 3 - burnSteps: 2 - cycleLengths: [100, R2] - powerFractions: [1.0, 0.5, 1.0] - availabilityFactors: [0.9, 0.3, 0.93] - -Note the use of the special shorthand list notation, where repeated values in a list can be specified using an "R" followed by the number of times the value is to be repeated. - -The above scheme would represent 3 cycles of operation: - - 1. 100% power for 90 days, split into two segments of 45 days each, followed by 10 days shutdown (i.e. 90% capacity) - - 2. 50% power for 30 days, split into two segments of 15 days each, followed by 70 days shutdown (i.e. 15% capacity) - - 3. 100% power for 93 days, split into two segments of 46.5 days each, followed by 7 days shutdown (i.e. 93% capacity) - -In each cycle, criticality calculations will be performed at 3 nodes evenly-spaced through the uptime portion of the cycle (i.e. ``availabilityFactor``*``powerFraction``), without option for changing node spacing or frequency. -This input format can be useful for quick scoping and certain types of real analyses, but clearly has its limitations. - -To overcome these limitations, the detailed cycle history, consisting of the ``cycles`` setting may be specified instead. -For each cycle, an entry to the ``cycles`` list is made with the following optional fields: - - * ``name`` - * ``power fractions`` - * ``cumulative days``, ``step days``, or ``burn steps`` + ``cycle length`` - * ``availability factor`` - -An example detailed cycle history employing all of these fields could look like - -.. code-block:: yaml - - power: 1000000 - nCycles: 4 - cycles: - - name: A - step days: [1, 1, 98] - power fractions: [0.1, 0.2, 1] - availability factor: 0.1 - - name: B - cumulative days: [2, 72, 78, 86] - power fractions: [0.2, 1.0, 0.95, 0.93] - - name: C - step days: [5, R5] - power fractions: [1, R5] - - cycle length: 100 - burn steps: 2 - availability factor: 0.9 - -Note that repeated values in a list may be again be entered using the shorthand notation for ``step days``, ``power fractions``, and ``availability factors`` (though not ``cumulative days`` because entries must be monotonically increasing). - -Such a scheme would define the following cycles: - - 1. A 2 day power ramp followed by full power operations for 98 days, with three nodes clustered during the ramp and another at the end of the cycle, followed by 900 days of shutdown - - 2. A 2 day power ramp followed by a prolonged period at full power and then a slight power reduction for the last 14 days in the cycle - - 3. Constant full-power operation for 30 days split into six even increments - - 4. Constant full-power operation for 90 days, split into two equal-length 45 day segments, followed by 10 days of downtime - -As can be seen, the detailed cycle history option provides much greated flexibility for simulating realistic operations, particularly power ramps or scenarios that call for unevenly spaced burnup nodes, such as xenon buildup in the early period of thermal reactor operations. - -.. note:: Although the detailed cycle history option allows for powers to change within each cycle, it should be noted that the power over each step is still considered to be constant. - -.. note:: The ``name`` field of the detailed cycle history is not yet used for anything, but this information will still be accessible on the operator during runtime. - -.. note:: Cycles without names will be given the name ``None`` - -.. warning:: When a detailed cycle history is combined with tight coupling, a subclass of :py:meth:`LatticePhysicsInterface.interactCoupled <armi.physics.neutronics.latticePhysics.latticePhysicsInterface.LatticePhysicsInterface.interactCoupled>` should be used. - -.. _restart-cases: - -Restart cases -------------- - -Oftentimes the user is interested in re-examining just a specific set of time nodes from an existing run. -In these cases, it is sometimes not necessary to rerun an entire reactor history, and one may instead use one of the following options: - - 1. Snapshot, where the reactor state is loaded from a database and just a single time node is run. - - 2. Restart, where the cycle history is loaded from a database and the calculation continues through the remaining specified time history. - -For either of these options, it is possible to alter the specific settings applied to the run by simply adjusting the case settings for the run. -For instance, a run that originally had only neutronics may incorporate thermal hydraulics during a snapshot run by adding in the relevant TH settings. - -.. note:: For either of these options, it is advisable to first create a new case settings file with a name different than the one from which you will be restarting off of, so as to not overwrite those results. - -To run a snapshot, the following settings must be added to your case settings: - - * Set ``runType`` to ``Snapshots`` - * Add a list of cycle/node pairs corresponding to the desired snapshots to ``dumpSnapshot`` formatted as ``'CCCNNN'`` - * Set ``reloadDBName`` to the existing database file that you would like to load the reactor state from - -An example of a snapshot run input: - -.. code-block:: yaml - - runType: Snapshots - reloadDBName: my-old-results.h5 - dumpSnapshot: ['000000', '001002'] # would produce 2 snapshots, at BOL and at node 2 of cycle 1 - -To run a restart, the following settings must be added to your case settings: - - * Set ``runType`` to ``Standard`` - * Set ``loadStyle`` to ``fromDB`` - * Set ``startCycle`` and ``startNode`` to the cycle/node that you would like to continue the calculation from (inclusive). - ``startNode`` may use negative indexing. - * Set ``reloadDBName`` to the existing database file from which you would like to load the reactor history up to the restart point - * If you would like to change the specified reactor history (see :ref:`restart-cases`), keep the history up to the restarting cycle/node - unchanged, and just alter the history after that point. This means that the cycle history specified in your restart run should include - all cycles/nodes up to the end of the simulation. For complicated restarts, it - may be necessary to use the detailed ``cycles`` setting, even if the original case only used the simple history option. - -A few examples of restart cases: - - - Restarting a calculation at a specific cycle/node and continuing for the remainder of the originally-specified cycle history: - .. code-block:: yaml - - # old settings - nCycles: 2 - burnSteps: 2 - cycleLengths: [100, 100] - runType: Standard - loadStyle: fromInput - loadingFile: my-blueprints.yaml - - .. code-block:: yaml - - # restart settings - nCycles: 2 - burnSteps: 2 - cycleLengths: [100, 100] - runType: Standard - loadStyle: fromDB - startCycle: 1 - startNode: 0 - reloadDBName: my-original-results.h5 - - - Add an additional cycle to the end of a case: - .. code-block:: yaml - - # old settings - nCycles: 1 - burnSteps: 2 - cycleLengths: [100] - runType: Standard - loadStyle: fromInput - loadingFile: my-blueprints.yaml - - .. code-block:: yaml - - # restart settings - nCycles: 2 - burnSteps: 2 - cycleLengths: [100, 100] - runType: Standard - loadStyle: fromDB - startCycle: 0 - startNode: -1 - reloadDBName: my-original-results.h5 - - - Restart but cut the reactor history short: - .. code-block:: yaml - - # old settings - nCycles: 3 - burnSteps: 2 - cycleLengths: [100, 100, 100] - runType: Standard - loadStyle: fromInput - loadingFile: my-blueprints.yaml - - .. code-block:: yaml - - # restart settings - nCycles: 2 - burnSteps: 2 - cycleLengths: [100, 100] - runType: Standard - loadStyle: fromDB - startCycle: 1 - startNode: 0 - reloadDBName: my-original-results.h5 - - - Restart with a different number of steps in the third cycle using the detailed ``cycles`` setting: - .. code-block:: yaml - - # old settings - nCycles: 3 - burnSteps: 2 - cycleLengths: [100, 100, 100] - runType: Standard - loadStyle: fromInput - loadingFile: my-blueprints.yaml - - .. code-block:: yaml - - # restart settings - nCycles: 3 - cycles: - - cycle length: 100 - burn steps: 2 - - cycle length: 100 - burn steps: 2 - - cycle length: 100 - burn steps: 4 - runType: Standard - loadStyle: fromDB - startCycle: 2 - startNode: 0 - reloadDBName: my-original-results.h5 - -.. note:: The ``skipCycles`` setting is related to skipping the lattice physics calculation specifically, it is not required to do a restart run. - -.. note:: The *-SHUFFLES.txt file is required to do explicit repeated fuel management. - -.. note:: The restart.dat file is required to repeat the exact fuel management methods during a branch search. These can potentially modify the reactor state in ways that cannot be captures with the SHUFFLES.txt file. - -.. note:: The ISO* binary cross section libraries are required to run cases that skip the lattice physics calculation (e.g. MC**2) - -.. note:: The multigroup flux is not yet stored on the output databases. If you need to do a restart with these values (e.g. for depletion), then you need to reload from neutronics outputs. - -.. note:: Restarting a calculation with an different version of ARMI than what was used to produce the restarting database may result in undefined behavior. \ No newline at end of file diff --git a/doc/user/inputs/settings_report.rst b/doc/user/inputs/settings_report.rst deleted file mode 100644 index 5008e31d0..000000000 --- a/doc/user/inputs/settings_report.rst +++ /dev/null @@ -1,30 +0,0 @@ -Settings Report -=============== -This document lists all the :doc:`settings </user/inputs/settings>` in ARMI. - -They are all accessible to developers -through the :py:class:`armi.settings.caseSettings.Settings` object, which is typically stored in a variable named -``cs``. Interfaces have access to a simulation's settings through ``self.cs``. - - -.. exec:: - from armi import settings - import textwrap - - subclassTables = {} - cs = settings.Settings() - # User textwrap to split up long words that mess up the table. - wrapper = textwrap.TextWrapper(width=25, subsequent_indent='') - wrapper2 = textwrap.TextWrapper(width=10, subsequent_indent='') - content = '\n.. list-table:: ARMI Settings\n :header-rows: 1\n :widths: 30 30 10 10\n \n' - content += ' * - Name\n - Description\n - Default\n - Options\n' - - for setting in sorted(cs.values(), key=lambda s: s.name): - content += ' * - {}\n'.format(' '.join(wrapper.wrap(setting.name))) - content += ' - {}\n'.format(' '.join(wrapper.wrap(str(setting.description) or ''))) - content += ' - {}\n'.format(' '.join(['``{}``'.format(wrapped) for wrapped in wrapper2.wrap(str(getattr(setting, 'default', None)).split("/")[-1])])) - content += ' - {}\n'.format(' '.join(['``{}``'.format(wrapped) for wrapped in wrapper.wrap(str(getattr(setting,'options','') or ''))])) - - content += '\n' - - return content diff --git a/doc/user/manual_data_access.rst b/doc/user/manual_data_access.rst index fe138ac33..3a322f308 100644 --- a/doc/user/manual_data_access.rst +++ b/doc/user/manual_data_access.rst @@ -8,20 +8,20 @@ in programmatically building and manipulating inputs and gathering detailed info out of ARMI results. Let's now go into a bit more detail for the power user. Settings and State Variables ----------------------------- +============================ The following links contain large tables describing the various global settings and state parameters in use across ARMI. -* :doc:`Table of all global settings </user/inputs/settings_report>` -* :doc:`Reactor Parameters </user/reactor_parameters_report>` -* :doc:`Core Parameters </user/core_parameters_report>` -* :doc:`Component Parameters </user/component_parameters_report>` -* :doc:`Assembly Parameters </user/assembly_parameters_report>` -* :doc:`Block Parameters </user/block_parameters_report>` +* :ref:`settings-report` +* :ref:`reactor-parameters-report` +* :ref:`core-parameters-report` +* :ref:`component-parameters-report` +* :ref:`assembly-parameters-report` +* :ref:`block-parameters-report` Accessing Some Interesting Info -------------------------------- +=============================== Often times, you may be interested in the geometric dimensions of various blocks. These are stored on the :py:mod:`components <armi.reactor.components>`, and may be accessed as follows:: diff --git a/doc/user/outputs/database.rst b/doc/user/outputs.rst similarity index 70% rename from doc/user/outputs/database.rst rename to doc/user/outputs.rst index dc159d827..8267fa93c 100644 --- a/doc/user/outputs/database.rst +++ b/doc/user/outputs.rst @@ -1,7 +1,71 @@ -***************** -The Database File -***************** +******* +Outputs +******* + +ARMI output files are described in this section. Many outputs may be generated during an ARMI run. They fall into +various categories: + +Framework outputs + Files like the **stdout** and the **database** are produced in nearly all runs. + +Interface outputs + Certain plugins/interfaces produce intermediate output files. + +Physics kernel outputs + If ARMI executes an external physics kernel during a run, its associated output files are often available in the + working directory. These files are typically read by ARMI during the run, and relevant data is transferred onto the + reactor model (and ends up in the ARMI **database**). If the user desires to retain all of the inputs and outputs + associated with the physics kernel runs for a given time step, this can be specified with the ``savePhysicsIO`` setting. + For any time step specified in the list under ``savePhysicsIO``, a ``cXnY/`` folder will be created, and ARMI will store all + inputs and outputs associated with each physics kernel executed at this time step in a folder inside of ``cXnY/``. + The format for specifying a state point is 00X00Y for cycle X, step Y. + +Together the output fully define the analyzed ARMI case. + + +The Standard Output +=================== +The Standard Output (or **stdout**) is a running log of things an ARMI run prints out as it executes a case. It shows +what happened during a run, which inputs were used, which warnings were issued, and in some cases, what the summary +results are. Here is an excerpt:: + + =========== Completed BOL Event =========== + + =========== Triggering BOC - cycle 0 Event =========== + =========== 01 - main BOC - cycle 0 =========== + [impt] Beginning of Cycle 0 + =========== 02 - fissionProducts BOC - cycle 0 =========== + =========== 03 - xsGroups BOC - cycle 0 =========== + [xtra] Generating representative blocks for XS + [xtra] Cross section group manager summary + +In a standard run, the various interfaces will loop through and print out messages according to the `verbosity` +setting. In multi-processing runs, the **stdout** shows messages from the primary node first and then shows information +from all other nodes below (with verbosity set by the `branchVerbosity` setting). Sometimes a user will want to set the +verbosity of just one module (.py file) in the code higher than the rest of ARMI, to do so they can set up a custom +logger by placing this line at the top of the file:: + runLog = logging.getLogger(__name__) + +These single-module (file) loggers can be controlled using a the `moduleVerbosity` setting. All of these logger +verbosities can be controlled from the settings file, for example:: + + branchVerbosity: debug + moduleVerbosity: + armi.reactor.reactors: info + verbosity: extra + +If there is an error, a useful message may be printed in the **stdout**, and a full traceback will be provided in the +associated **stderr** file. + +Some Linux users tend to use the **tail** command to monitor the progress of an ARMI run:: + + tail -f myRun.stdout + +This provides live information on the progress. + +The Database File +================= The **database** file is a self-contained complete (or nearly complete) binary representation of the ARMI composite model state during a case. The database contains the text of the input files that were used to create the case, and for each time node, @@ -9,7 +73,7 @@ the values of all composite parameters as well as layout information to help ful reconstruct the structure of the reactor model. Loading Reactor State -===================== +--------------------- Among other things, the database file can be used to recover an ARMI reactor model from any of the time nodes that it contains. This can be useful for performing restart runs, or for doing custom post-processing tasks. To load a reactor state, you will need to @@ -28,7 +92,7 @@ load the reactor state at cycle 5, time node 2 with the following:: r = db.load(5, 2) Extracting Reactor History -========================== +-------------------------- Not only can the database reproduce reactor state for a given time node, it can also extract a history of specific parameters for specific objects through the :py:meth:`armi.bookkeeping.db.Database3.getHistory()` and @@ -48,15 +112,14 @@ following:: Extracting Settings and Blueprints -================================== +---------------------------------- As well as the reactor states for each time node, the database file also stores the input files (blueprints and settings files) used to run the case that generated it. These can be recovered using the `extract-inputs` ARMI entry point. Use `python -m armi extract-inputs --help` for more information. File format -=========== - +----------- The database file format is built on top of the HDF5 format. There are many tools available for viewing, editing, and scripting HDF5 files. The ARMI database uses the `h5py` package for interacting with the underlying data and metadata. @@ -80,13 +143,14 @@ There are many other features of HDF5, but from a usability standpoint that is e information to get started. Database Structure -================== +------------------ The database structure is outlined below. This shows the broad strokes of how the database is put together, but many more details may be gleaned from the in-line documentation of the database modules. .. list-table:: Database structure :header-rows: 1 + :class: longtable * - Name - Type @@ -168,7 +232,6 @@ documentation of the database modules. the ``/c{CC}n{NN}/layout/``. See the next table to see a description of the attributes. - Python supports a rich and dynamic type system, which is sometimes difficult to represent with the HDF5 format. Namely, HDF5 only supports dense, homogeneous N-dimensional collections of data in any given dataset. Some parameter values do not fit diff --git a/doc/user/outputs/index.rst b/doc/user/outputs/index.rst deleted file mode 100644 index 9851b956e..000000000 --- a/doc/user/outputs/index.rst +++ /dev/null @@ -1,31 +0,0 @@ -******* -Outputs -******* - -ARMI output files are described in this section. Many outputs may be generated during an ARMI run. They fall into -various categories: - -Framework outputs - Files like the **stdout** and the **database** are produced in nearly all runs. - -Interface outputs - Certain plugins/interfaces produce intermediate output files. - -Physics kernel outputs - If ARMI executes an external physics kernel during a run, its associated output files are often available in the - working directory. These files are typically read by ARMI during the run, and relevant data is transferred onto the - reactor model (and ends up in the ARMI **database**). If the user desires to retain all of the inputs and outputs - associated with the physics kernel runs for a given time step, this can be specified with the ``savePhysicsIO`` setting. - For any time step specified in the list under ``savePhysicsIO``, a ``cXnY/`` folder will be created, and ARMI will store all - inputs and outputs associated with each physics kernel executed at this time step in a folder inside of ``cXnY/``. - The format for specifying a state point is 00X00Y for cycle X, step Y. - -Together the output fully define the analyzed -ARMI case. - ------------ - -.. toctree:: - - stdout - database diff --git a/doc/user/outputs/stdout.rst b/doc/user/outputs/stdout.rst deleted file mode 100644 index 206e0a613..000000000 --- a/doc/user/outputs/stdout.rst +++ /dev/null @@ -1,43 +0,0 @@ -******************* -The Standard Output -******************* - -The Standard Output (or **stdout**) is a running log of things an ARMI run prints out as it executes a case. It shows -what happened during a run, which inputs were used, which warnings were issued, and in some cases, what the summary -results are. Here is an excerpt:: - - =========== Completed BOL Event =========== - - =========== Triggering BOC - cycle 0 Event =========== - =========== 01 - main BOC - cycle 0 =========== - [impt] Beginning of Cycle 0 - =========== 02 - fissionProducts BOC - cycle 0 =========== - =========== 03 - xsGroups BOC - cycle 0 =========== - [xtra] Generating representative blocks for XS - [xtra] Cross section group manager summary - -In a standard run, the various interfaces will loop through and print out messages according to the `verbosity` -setting. In multi-processing runs, the **stdout** shows messages from the primary node first and then shows information -from all other nodes below (with verbosity set by the `branchVerbosity` setting). Sometimes a user will want to set the -verbosity of just one module (.py file) in the code higher than the rest of ARMI, to do so they can set up a custom -logger by placing this line at the top of the file:: - - runLog = logging.getLogger(__name__) - -These single-module (file) loggers can be controlled using a the `moduleVerbosity` setting. All of these logger -verbosities can be controlled from the settings file, for example:: - - branchVerbosity: debug - moduleVerbosity: - armi.reactor.reactors: info - verbosity: extra - -If there is an error, a useful message may be printed in the **stdout**, and a full traceback will be provided in the -associated **stderr** file. - -Some Linux users tend to use the **tail** command to monitor the progress of an ARMI run:: - - tail -f myRun.stdout - -This provides live information on the progress. - diff --git a/doc/user/physics_coupling.rst b/doc/user/physics_coupling.rst index 391cb5ac9..15872a385 100644 --- a/doc/user/physics_coupling.rst +++ b/doc/user/physics_coupling.rst @@ -1,20 +1,20 @@ -********************** +**************** Physics Coupling -********************** +**************** Loose Coupling ----------------- +============== ARMI supports loose and tight coupling. Loose coupling is interpreted as one-way coupling between physics for a single time node. For example, a power distribution in cycle 0 node 0 is used to calculate a temperature distribution in cycle 0 node 0. This temperature is then used in cycle 0 node 1 to compute new cross sections and a new power distribution. This process repeats itself for the lifetime of the simulation. -.. graphviz:: inputs/looseCouplingIllustration.dot +.. graphviz:: /.static/looseCouplingIllustration.dot Loose coupling is enabled by default in ARMI simulations. Tight Coupling ------------------ +============== Tight coupling is interpreted as two-way communication between physics within a given time node. Revisiting our previous example, enabling tight coupling results in the temperature distribution being used to generate updated cross sections (new temperatures induce changes such as Doppler broadening feedback) and ultimately an updated power distribution. This process is repeated iteratively until a numerical convergence criteria is met. -.. graphviz:: inputs/tightCouplingIllustration.dot +.. graphviz:: /.static/tightCouplingIllustration.dot The following settings are involved with enabling tight coupling in ARMI: @@ -41,14 +41,14 @@ The ``tightCouplingSettings`` settings interact with the interfaces available in In the global flux interface, the following norms are used to compute the convergence of :math:`k_{\text{eff}}` and block-wise power. Eigenvalue -^^^^^^^^^^ +---------- The convergence of the eigenvalue is measured through an L2-norm. .. math:: \epsilon = \| k_\text{eff} \|_2 = \left( \left( k_\text{eff,old} - k_\text{eff,new} \right)^2 \right) ^ \frac{1}{2} Block-wise Power -^^^^^^^^^^^^^^^^ +---------------- The block-wise power can be used as a convergence mechanism to avoid the integral effects of :math:`k_{\text{eff}}` (i.e., over and under predictions cancelling each other out) and in turn, can have a different convergence rate. To measure the convergence of the power distribution with the prescribed tolerances (e.g., 1e-4), the power is scaled in the following manner (otherwise the calculation struggles to converge). For an assembly, :math:`a`, we compute the total power of the assembly, @@ -75,4 +75,4 @@ These assembly-wise convergence parameters are then stored in an array of conver The total convergence of the power distribution is finally measured through the infinity norm (i.e, the max) of :math:`\xi`, .. math:: - \epsilon = \| \xi \|_\inf = \max \xi. + \epsilon = \| \xi \|_{\inf} = \max \xi. diff --git a/doc/user/radial_and_axial_expansion.rst b/doc/user/radial_and_axial_expansion.rst index aa3498f6f..2b47c6006 100644 --- a/doc/user/radial_and_axial_expansion.rst +++ b/doc/user/radial_and_axial_expansion.rst @@ -1,13 +1,13 @@ -******************************************* +****************************************** 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. .. _thermalExpansion: Thermal Expansion ------------------ +================= ARMI treats thermal expansion as a linear phenomena using the standard linear expansion relationship, .. math:: @@ -23,28 +23,28 @@ where, :math:`\Delta L` and :math:`\Delta T` are the change in length and temper Given Equation :eq:`linearExp`, we can create expressions for the change in length between our "hot" temperature (Equation :eq:`hotExp`) .. math:: - \begin{align} + \begin{aligned} \frac{L_h - L_0}{L_0} &= \alpha(T_h)\left(T_h - T_0\right),\\ \frac{L_h}{L_0} &= 1 + \alpha(T_h)\left(T_h - T_0\right). - \end{align} + \end{aligned} :label: hotExp and "non-reference" temperature, :math:`T_c` (Equation :eq:`nonRefExp`), .. math:: - \begin{align} + \begin{aligned} \frac{L_c - L_0}{L_0} &= \alpha(T_c)\left(T_c - T_0\right),\\ \frac{L_c}{L_0} &= 1 + \alpha(T_c)\left(T_c - T_0\right). - \end{align} + \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`, .. math:: - \begin{align} + \begin{aligned} \frac{L_h - L_0}{L_0} - \frac{L_c - L_0}{L_0} &= \frac{L_h}{L_0} - 1 - \frac{L_c}{L_0} + 1, \\ &= \frac{L_h - L_c}{L_0}. - \end{align} + \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, @@ -64,13 +64,3 @@ Equation :eq:`linearExpansionFactor` is the expression used by ARMI in :py:meth: .. note:: :py:meth:`linearExpansionPercent <armi.materials.material.Material.linearExpansionPercent>` returns :math:`\frac{L - L_0}{L_0}` in %. - -.. _radialExpansion: - -Radial Expansion ----------------- - -.. _axialExpansion: - -Axial Expansion ---------------- diff --git a/doc/user/reactor_parameters_report.rst b/doc/user/reactor_parameters_report.rst index 1f3ab6980..fe2dd2dea 100644 --- a/doc/user/reactor_parameters_report.rst +++ b/doc/user/reactor_parameters_report.rst @@ -1,13 +1,16 @@ +.. _reactor-parameters-report: + +****************** Reactor Parameters -================== +****************** + This document lists all of the Reactor Parameters that are provided by the ARMI Framework. .. exec:: from armi.reactor import reactors from armi.reactor import reactorParameters - from armi.utils.dochelpers import generateParamTable + from dochelpers import generateParamTable return generateParamTable( reactors.Reactor, reactorParameters.defineReactorParameters() ) - diff --git a/doc/user/user_install.rst b/doc/user/user_install.rst index 4313d6528..6a454b58f 100644 --- a/doc/user/user_install.rst +++ b/doc/user/user_install.rst @@ -1,17 +1,18 @@ ************ Installation ************ + This section will guide you through installing the ARMI Framework on your machine. Prerequisites -------------- +============= These instructions target users with some software development knowledge. In particular, we assume familiarity with `Python <https://www.python.org/>`__, `virtual environments <https://docs.python.org/3/tutorial/venv.html>`_, and `Git <https://git-scm.com/>`_. You must have the following installed before proceeding: -* `Python <https://www.python.org/downloads/>`__ version 3.6 or later (preferably 64-bit) +* `Python <https://www.python.org/downloads/>`__ version 3.7 or newer (preferably 64-bit). .. admonition:: The right Python command @@ -27,7 +28,7 @@ You also likely need the following for interacting with the source code reposito * `Git <https://git-scm.com/>`_ Preparing a Virtual Environment -------------------------------- +=============================== While not *technically* required, we highly recommend installing ARMI into a `virtual environment <https://docs.python.org/3/library/venv.html>`_ to assist in dependency management. In short, virtual environments are a mechanism by which a Python user can @@ -59,11 +60,23 @@ library. On Linux, doing so will require some MPI development libraries (e.g. ``sudo apt install libopenmpi-dev``). Getting the code ----------------- +================ Choose one of the following two installation methods depending on your needs. +Step 0: Update PIP +------------------ +In order to use the commands below, you're going to want to use a version of ``pip>=22.1``. +Two common ways of solving that are:: + + (armi-venv) $ pip install pip>=22.1 + +or, in most cases:: + + (armi-venv) $ pip install -U pip + + Option 1: Install as a library -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +------------------------------ If you plan on running ARMI without viewing or modifying source code, you may install it with ``pip``, which will automatically discover and install the dependencies. This is useful for quick evaluations or to use it as a dependency @@ -71,8 +84,9 @@ in another project:: (armi-venv) $ pip install https://github.com/terrapower/armi/archive/main.zip + Option 2: Install as a repository (for developers) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +-------------------------------------------------- If you'd like to view or change the ARMI source code (common!), you need to clone the ARMI source and then install its dependencies. Clone the ARMI source code from the git repository with:: @@ -85,7 +99,7 @@ the git repository with:: Now install ARMI with all its dependencies:: (armi-venv) $ cd armi - (armi-venv) $ pip install -e .[test] + (armi-venv) $ pip install -e ".[test]" .. tip:: If you don't want to install ARMI into your venv, you will need to add the ARMI source location to your system's ``PYTHONPATH`` environment variable so that @@ -96,7 +110,7 @@ Now install ARMI with all its dependencies:: Verifying installation -^^^^^^^^^^^^^^^^^^^^^^ +---------------------- Check the installation status by running:: (armi-venv) $ armi @@ -121,11 +135,11 @@ If it works, congrats! So far so good. Optional Setup --------------- +============== This subsection provides setup for optional items. GUI input -^^^^^^^^^ +--------- To use the :py:mod:`graphical core-map editor <armi.utils.gridEditor>` you will need to also install `wxPython <https://wxpython.org/pages/downloads/index.html>`_. This is not installed by default during armi installation because it can cause installation complexities on some platforms. @@ -134,7 +148,7 @@ In any case, all GUI dependencies can be installed by:: (armi-venv) $ pip install armi[grids] GUI output -^^^^^^^^^^ +---------- ARMI can write VTK and XDMF output files which can be viewed in tools such as `ParaView <https://www.paraview.org/>`_ and `VisIT <https://wci.llnl.gov/simulation/computer-codes/visit>`_. Download and install those diff --git a/pyproject.toml b/pyproject.toml index c84985d7f..a766d4902 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ build-backend = "setuptools.build_meta" [project] name = "armi" -version = "0.2.9" +version = "0.3.0" description = "An open-source nuclear reactor analysis automation framework that helps design teams increase efficiency and quality." license = {file = "LICENSE.md"} requires-python = ">3.6" @@ -30,25 +30,22 @@ authors = [ { name="TerraPower, LLC", email="armi-devs@terrapower.com" }, ] dependencies = [ - "configparser", - "coverage", - "h5py>=3.0,<=3.9", - "htmltree", - "matplotlib", - "numpy>=1.21,<=1.23.5", - "ordered-set", - "pillow", - "pluggy", - "pyDOE", - "pyevtk", - "ruamel.yaml<=0.17.21", - "ruamel.yaml.clib<=0.2.7", - "scipy", - "tabulate", - "toml>0.9.5", - "voluptuous", - "xlrd", - "yamlize==0.7.1", + "coverage>=7.2.0", # Code coverage tool. Sadly baked into every Case. + "h5py>=3.0,<=3.9", # Needed because our database files are H5 format + "htmltree>=0.7.6", # Our reports have HTML output + "matplotlib>=3.5.3", # Important plotting library + "numpy>=1.21,<=1.23.5", # Important math library + "ordered-set>=3.1.1", # A useful data structure + "pluggy>=1.2.0", # Central tool behind the ARMI Plugin system + "pyDOE>=0.3.8", # We import a Latin-hypercube algorithm to explore a phase space + "pyevtk>=1.2.0", # Handles binary VTK visualization files + "ruamel.yaml.clib<=0.2.7", # C-based core of ruamel below + "ruamel.yaml<=0.17.21", # Our foundational YAML library + "scipy>=1.7.0", # Used for curve-fitting and matrix math + "tabulate>=0.8.9", # Used to pretty-print tabular data + "toml>0.9.5", # Needed to parse the pyproject.toml file + "voluptuous>=0.12.1", # Used to validate YAML data files + "yamlize==0.7.1", # Custom YAML-to-object library ] classifiers = [ "Development Status :: 4 - Beta", @@ -78,17 +75,14 @@ grids = ["wxpython==4.2.1"] memprof = ["psutil"] mpi = ["mpi4py"] test = [ - "black==22.6.0", - "docutils", - "ipykernel", - "jupyter-contrib-nbextensions", - "jupyter_client", - "nbconvert", - "pytest", - "pytest-cov", - "pytest-html", - "pytest-xdist", - "ruff==0.0.272", + "black==22.6.0", # Code formatter (version-pinned) + "ipykernel>=6.0.0", # IPython Kernel (We run test notebooks from the doc tutorials.) + "jupyter_client>=7.0.0", # Reference implementation of the Jupyter protocol + "nbconvert>=7.0.0", # Converting Jupyter Notebooks to other formats + "pytest>=7.0.0", # Our primary test tooling + "pytest-cov>=4.0.0", # coverage plugin + "pytest-xdist>=3.0.0", # To spread our tests over multiple CPUs + "ruff==0.0.272", # Linting and code formatting (version-pinned) ] docs = [ ####################################################################### @@ -100,8 +94,8 @@ docs = [ # # We are only building our docs with Python 3.9. ####################################################################### - "Sphinx==5.3.0", - "docutils==0.18.1", + "Sphinx==5.3.0", # central library used to build our docs + "docutils==0.18.1", # Needed by sphinx-rtd-them "sphinx-rtd-theme==1.2.2", # Read-The-Docs theme for Sphinx "nbsphinx==0.9.2", # Parses Jupyter notebooks "nbsphinx-link==1.3.0", # Adds Jupyter NBs to Sphinx source root @@ -114,7 +108,9 @@ docs = [ "ipykernel==6.25.1", # iPython kernel to run Jupyter notebooks "pylint==2.17.5", # Generates UML diagrams "Jinja2==3.0.3", # Used in numpydoc and nbconvert - "sphinxcontrib-jquery==4.1", # Handle missing jquery errors + "sphinxcontrib-jquery==4.1", # Handle missing jquery errors + "jupyter-contrib-nbextensions", # A collections of JS extensions for jupyter notebooks + "lxml<5.0.0", # Needed because the dep above is no longer an active project ] [project.scripts] @@ -134,8 +130,8 @@ required-version = "0.0.272" # Assume Python 3.9 target-version = "py39" -# Setting line-length to 88 to match Black -line-length = 88 +# Setting line-length to 140 (though blacks default is 88) +line-length = 140 # Enable pycodestyle (E) and Pyflakes (F) codes by default. # D - NumPy docstring rules @@ -165,12 +161,11 @@ select = ["E", "F", "D", "N801", "SIM", "TID"] # # D105 - we don't need to document well-known magic methods # D205 - 1 blank line required between summary line and description -# E501 - line length, because we use Black for that # E731 - we can use lambdas however we want # RUF100 - no unused noqa statements (not consistent enough yet) # SIM118 - this does not work where we overload the .keys() method # -ignore = ["D100", "D101", "D102", "D103", "D105", "D106", "D205", "D401", "D404", "E501", "E731", "RUF100", "SIM102", "SIM105", "SIM108", "SIM114", "SIM115", "SIM117", "SIM118"] +ignore = ["D100", "D101", "D102", "D103", "D105", "D106", "D205", "D401", "D404", "E731", "RUF100", "SIM102", "SIM105", "SIM108", "SIM114", "SIM115", "SIM117", "SIM118"] # Exclude a variety of commonly ignored directories. exclude = [ diff --git a/tox.ini b/tox.ini index 9c330e78e..c7e4a6693 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ commands = git submodule update make html -# First, run code coverage over the unit tests that run MPI library code. +# First, run code coverage over the rest of the usual unit tests. [testenv:cov1] deps= mpi4py @@ -33,9 +33,9 @@ allowlist_externals = /usr/bin/mpiexec commands = pip install -e .[memprof,mpi,test] - mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=.coveragerc -m pytest --cov=armi --cov-config=.coveragerc --cov-report=lcov --ignore=venv --cov-fail-under=80 armi/tests/test_mpiFeatures.py + coverage run --rcfile=.coveragerc -m pytest -n 4 --cov=armi --cov-config=.coveragerc --cov-report=lcov --ignore=venv armi -# Second, run code coverage over the rest of the unit tests, and combine the coverage results together +# Second, run code coverage over the unit tests that run MPI library code, and combine the coverage results together. [testenv:cov2] deps= mpi4py @@ -43,7 +43,8 @@ allowlist_externals = /usr/bin/mpiexec commands = pip install -e .[memprof,mpi,test] - coverage run --rcfile=.coveragerc -m pytest -n 4 --cov=armi --cov-config=.coveragerc --cov-report=lcov --cov-append --ignore=venv armi + mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=.coveragerc -m pytest --cov=armi --cov-config=.coveragerc --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiFeatures.py + mpiexec -n 2 --use-hwthread-cpus coverage run --rcfile=.coveragerc -m pytest --cov=armi --cov-config=.coveragerc --cov-report=lcov --cov-append --ignore=venv armi/tests/test_mpiParameters.py coverage combine --rcfile=.coveragerc --keep -a # NOTE: This only runs the MPI unit tests. @@ -56,6 +57,7 @@ allowlist_externals = commands = pip install -e .[memprof,mpi,test] mpiexec -n 2 --use-hwthread-cpus pytest armi/tests/test_mpiFeatures.py + mpiexec -n 2 --use-hwthread-cpus pytest armi/tests/test_mpiParameters.py [testenv:lint] deps= From 22589c822f92c9fb8945f25277f7aee00ffc8e9f Mon Sep 17 00:00:00 2001 From: aalberti <c-aalberti@terrapower.com> Date: Fri, 22 Mar 2024 15:52:43 -0700 Subject: [PATCH 20/25] fix ruff and black --- armi/reactor/blueprints/__init__.py | 22 ------------------- .../tests/buildAxialExpAssembly.py | 2 +- .../tests/test_assemblyAxialLinkage.py | 8 +++---- .../tests/test_axialExpansionChanger.py | 2 -- .../tests/test_expansionData.py | 10 ++++----- 5 files changed, 10 insertions(+), 34 deletions(-) diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index 1b6f4da70..b640f3556 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -85,28 +85,6 @@ from armi.reactor import assemblies from armi.reactor import geometry from armi.reactor import systemLayoutInput -from armi.reactor.blueprints import isotopicOptions -from armi.reactor.blueprints.assemblyBlueprint import AssemblyKeyedList -from armi.reactor.blueprints.blockBlueprint import BlockKeyedList -from armi.reactor.blueprints.componentBlueprint import ComponentGroups -from armi.reactor.blueprints.componentBlueprint import ComponentKeyedList -from armi.reactor.blueprints.gridBlueprint import Grids, Triplet -from armi.reactor.blueprints.reactorBlueprint import Systems, SystemBlueprint -from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( - expandColdDimsToHot, -) -from armi.reactor.converters.axialExpansion import makeAssemsAbleToSnapToUniformMesh -from armi.reactor.flags import Flags -from armi.settings.fwSettings.globalSettings import ( - CONF_DETAILED_AXIAL_EXPANSION, - CONF_ASSEM_FLAGS_SKIP_AXIAL_EXP, - CONF_INPUT_HEIGHTS_HOT, - CONF_NON_UNIFORM_ASSEM_FLAGS, - CONF_ACCEPTABLE_BLOCK_AREA_ERROR, - CONF_GEOM_FILE, -) -from armi.physics.neutronics.settings import CONF_LOADING_FILE - # NOTE: using non-ARMI-standard imports because these are all a part of this package, # and using the module imports would make the attribute definitions extremely long # without adding detail diff --git a/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py index 1e48535fd..df7d754fc 100644 --- a/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py +++ b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py @@ -21,7 +21,7 @@ def buildTestAssembly(materialName: str, hot: bool = False): - """Create test assembly + """Create test assembly. Parameters ---------- diff --git a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py index 2cdcfe171..8bfdd8a7c 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py @@ -25,7 +25,7 @@ class TestGetLinkedComponents(unittest.TestCase): - """Runs through AssemblyAxialLinkage::_determineAxialLinkage() and does full linkage + """Runs through AssemblyAxialLinkage::_determineAxialLinkage() and does full linkage. The individual methods, _getLinkedBlocks, _getLinkedComponents, and _determineLinked are then tested in individual tests by asserting that the linkage is as expected. @@ -58,7 +58,7 @@ def test_getLinkedBlocks(self): ) def test_getLinkedComponents(self): - """spot check to ensure component linkage is as expected""" + """Spot check to ensure component linkage is as expected.""" ## Test 1: check for shield -- fuel -- fuel linkage shieldBlock = self.a[0] shieldComp = shieldBlock.getComponent(Flags.SHIELD) @@ -78,7 +78,7 @@ def test_getLinkedComponents(self): ) def test_getLinkedComponent_runLogs(self): - """check runLogs get hit right""" + """Check runLogs get hit right.""" a = buildAxialExpAssembly.buildTestAssembly("HT9") a[0].remove(a[0][1]) # remove clad from shield block a[3].remove(a[3][1]) # remove clad from plenum block @@ -100,7 +100,7 @@ def test_getLinkedComponent_RuntimeError(self): class TestDetermineLinked(unittest.TestCase): - """Test assemblyAxialLinkage.py::AssemblyAxialLinkage::_determineLinked for anticipated configrations + """Test assemblyAxialLinkage.py::AssemblyAxialLinkage::_determineLinked for anticipated configrations. This is the primary method used to determined if two components are linked axial linkage between components. """ diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 13320672e..dcab6366c 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -20,12 +20,10 @@ from armi.materials import custom from armi.reactor.assemblies import HexAssembly, grids from armi.reactor.blocks import HexBlock -from armi.reactor.components.basicShapes import Circle from armi.reactor.converters.axialExpansion import getSolidComponents from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( AxialExpansionChanger, ) -from armi.reactor.converters.axialExpansion.expansionData import ExpansionData from armi.reactor.converters.axialExpansion.tests import AxialExpansionTestBase from armi.reactor.converters.axialExpansion.tests.buildAxialExpAssembly import ( buildTestAssembly, diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index b5e186bf3..b98b4b872 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -82,7 +82,7 @@ def tearDown(self): AxialExpansionTestBase.tearDown(self) def test_computeThermalExpansionFactors_FromTinput2Thot(self): - """expand from Tinput to Thot""" + """Expand from Tinput to Thot.""" self.expData = ExpansionData(self.a, False, True) self.expData.computeThermalExpansionFactors() for b in self.a: @@ -90,7 +90,7 @@ def test_computeThermalExpansionFactors_FromTinput2Thot(self): self.assertEqual(self.expData._expansionFactors[c], 1.044776119402985) def test_computeThermalExpansionFactors_NoRefTemp(self): - """occurs when not expanding from Tinput to Thot and no new temperature prescribed""" + """Occurs when not expanding from Tinput to Thot and no new temperature prescribed.""" self.expData = ExpansionData(self.a, False, False) self.expData.computeThermalExpansionFactors() for b in self.a: @@ -98,7 +98,7 @@ def test_computeThermalExpansionFactors_NoRefTemp(self): self.assertEqual(self.expData._expansionFactors[c], 1.0) def test_computeThermalExpansionFactors_withRefTemp(self): - """occurs when expanding from some reference temp (not equal to Tinput) to Thot""" + """Occurs when expanding from some reference temp (not equal to Tinput) to Thot.""" self.expData = ExpansionData(self.a, False, False) for b in self.a: for c in getSolidComponents(b): @@ -155,7 +155,7 @@ def test_updateComponentTempsBy1DTempFieldRuntimeError(self): class TestSetTargetComponents(unittest.TestCase): - """Runs through _setTargetComponents in the init and checks to make sure they're all set right + """Runs through _setTargetComponents in the init and checks to make sure they're all set right. Coverage for isTargetComponent is provided when querying each component for their target component """ @@ -165,7 +165,7 @@ def setUpClass(cls): cls.a = buildTestAssembly("HT9") def test_checkTargetComponents(self): - """make sure target components are set right. Skip the dummy block.""" + """Make sure target components are set right. Skip the dummy block.""" expData = ExpansionData(self.a, False, False) for b in self.a[-1]: for c in b: From 1b76896d280fab433ee3950cc5765a8f6c7ffb73 Mon Sep 17 00:00:00 2001 From: aalberti <c-aalberti@terrapower.com> Date: Fri, 22 Mar 2024 15:57:47 -0700 Subject: [PATCH 21/25] rm erroneous merge conflict --- armi/reactor/blueprints/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index b640f3556..ef35ae187 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -85,9 +85,6 @@ from armi.reactor import assemblies from armi.reactor import geometry from armi.reactor import systemLayoutInput -# NOTE: using non-ARMI-standard imports because these are all a part of this package, -# and using the module imports would make the attribute definitions extremely long -# without adding detail from armi.reactor.blueprints import isotopicOptions from armi.reactor.blueprints.assemblyBlueprint import AssemblyKeyedList from armi.reactor.blueprints.blockBlueprint import BlockKeyedList From 2bc7e1aa27b38c22b7376f8f335ed62c7239f0e2 Mon Sep 17 00:00:00 2001 From: aalberti <c-aalberti@terrapower.com> Date: Fri, 22 Mar 2024 16:09:08 -0700 Subject: [PATCH 22/25] include changes for PR 1427 --- .../axialExpansion/assemblyAxialLinkage.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py index b0dc63d16..8dd73bf8e 100644 --- a/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py @@ -80,15 +80,32 @@ def _getLinkedBlocks(self, b): lowerLinkedBlock = None upperLinkedBlock = None block_list = self.a.getChildren() - for otherBlk in block_list: + for idx, otherBlk in enumerate(block_list): if b.name != otherBlk.name: if b.p.zbottom == otherBlk.p.ztop: lowerLinkedBlock = otherBlk elif b.p.ztop == otherBlk.p.zbottom: upperLinkedBlock = otherBlk + else: + bIdx = idx self.linkedBlocks[b] = [lowerLinkedBlock, upperLinkedBlock] + if lowerLinkedBlock is None and bIdx != 0: + # only print if this isn't the bottom block + runLog.debug( + f"Assembly {self.a.getName()} at location {self.a.getLocation()}, Block {b}" + "is not linked to a block below!", + single=True, + ) + if upperLinkedBlock is None and bIdx != idx: + # only print if this isn't the topmost block + runLog.debug( + f"Assembly {self.a.getName()} at location {self.a.getLocation()}, Block {b}" + "is not linked to a block above!", + single=True, + ) + def _getLinkedComponents(self, b, c): """Retrieve the axial linkage for component c. From 567e02c7f1ccfe31ac680da609f5bd18129aefaf Mon Sep 17 00:00:00 2001 From: aalberti <c-aalberti@terrapower.com> Date: Mon, 25 Mar 2024 08:51:59 -0700 Subject: [PATCH 23/25] update black formatter --- armi/reactor/blueprints/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/reactor/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index ef35ae187..59a575746 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -154,7 +154,7 @@ def __new__(mcs, name, bases, attrs): else: pluginSections = pm.hook.defineBlueprintsSections() for plug in pluginSections: - for (attrName, section, resolver) in plug: + for attrName, section, resolver in plug: assert isinstance(section, yamlize.Attribute) if attrName in attrs: raise plugins.PluginError( From 55a40f7242259f5ed7f2b911533d8c6e3e2c47c4 Mon Sep 17 00:00:00 2001 From: aalberti <c-aalberti@terrapower.com> Date: Tue, 26 Mar 2024 08:42:54 -0700 Subject: [PATCH 24/25] add missing test tags --- .../tests/test_axialExpansionChanger.py | 22 ++++++++++++++++++- .../tests/test_expansionData.py | 7 +++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index dcab6366c..842df4c92 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -293,6 +293,10 @@ def _getMass(a): def test_PrescribedExpansionContractionConservation(self): """Expand all components and then contract back to original state. + .. test:: Expand all components and then contract back to original state. + :id: T_ARMI_AXIAL_EXP_PRESC0 + :tests: R_ARMI_AXIAL_EXP_PRESC + Notes ----- - uniform expansion over all components within the assembly @@ -374,7 +378,12 @@ def test_TargetComponentMassConservation(self): ) def test_NoMovementACLP(self): - """Ensures that above core load pad (ACLP) does not move during fuel-only expansion.""" + """Ensures that above core load pad (ACLP) does not move during fuel-only expansion. + + .. test:: Ensure the ACLP does not move during fuel-only expansion. + :id: T_ARMI_AXIAL_EXP_PRESC1 + :tests: R_ARMI_AXIAL_EXP_PRESC + """ # build test assembly with ACLP assembly = HexAssembly("testAssemblyType") assembly.spatialGrid = grids.axialUnitGrid(numCells=1) @@ -555,8 +564,19 @@ def setUp(self): def test_coldAssemblyExpansion(self): """Block heights are cold and should be expanded. + .. test:: Preserve the total height of a compatible ARMI assembly. + :id: T_ARMI_ASSEM_HEIGHT_PRES + :tests: R_ARMI_ASSEM_HEIGHT_PRES + + .. test:: Axial expansion can be prescribed in blueprints for core constuction. + :id: T_ARMI_INP_COLD_HEIGHT + :tests: R_ARMI_INP_COLD_HEIGHT + Notes ----- + For R_ARMI_INP_COLD_HEIGHT, the action of axial expansion occurs in setUp() during core + construction, specifically in :py:meth:`constructAssem <armi.reactor.blueprints.Blueprints.constructAssem>` + Two assertions here: 1. total assembly height should be preserved (through use of top dummy block) 2. in armi.tests.detailedAxialExpansion.refSmallReactorBase.yaml, diff --git a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py index b98b4b872..6775a4884 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_expansionData.py +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -308,7 +308,12 @@ def test_specifyTargetComponet_MultipleFound(self): self.assertEqual(the_exception.error_code, 3) def test_manuallySetTargetComponent(self): - """Ensures that target components can be manually set (is done in practice via blueprints).""" + """Ensures that target components can be manually set (is done in practice via blueprints). + + .. test:: Allow user-specified target axial expansion components on a given block. + :id: T_ARMI_MANUAL_TARG_COMP + :tests: R_ARMI_MANUAL_TARG_COMP + """ b = HexBlock("dummy", height=10.0) ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 17, "ip": 0.0, "mult": 1.0} duct = Hexagon("duct", "HT9", **ductDims) From da2be57ddff20db71a1d31ad2f74a4edf330e9f2 Mon Sep 17 00:00:00 2001 From: aalberti <c-aalberti@terrapower.com> Date: Tue, 26 Mar 2024 08:49:13 -0700 Subject: [PATCH 25/25] missed one test tag --- .../axialExpansion/tests/test_axialExpansionChanger.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 842df4c92..2594a1223 100644 --- a/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py @@ -189,7 +189,11 @@ def expandAssemForMassConservationTest(self): self._getConservationMetrics(self.a) def test_ThermalExpansionContractionConservation_Simple(self): - r"""Thermally expand and then contract to ensure original state is recovered. + """Thermally expand and then contract to ensure original state is recovered. + + .. test:: Thermally expand and then contract to ensure original assembly is recovered. + :id: T_ARMI_AXIAL_EXP_THERM0 + :tests: R_ARMI_AXIAL_EXP_THERM Notes -----