diff --git a/armi/reactor/assemblies.py b/armi/reactor/assemblies.py index 3dd396249..a4ca430d1 100644 --- a/armi/reactor/assemblies.py +++ b/armi/reactor/assemblies.py @@ -1196,100 +1196,6 @@ def countBlocksWithFlags(self, blockTypeSpec=None): """ return len(self.getBlocks(blockTypeSpec)) - def axiallyExpandBlockHeights(self, heightList, nucNamesToConserveMass): - """ - Takes a list of new fuel block heights and then scales the fuel blocks to the - new heights, adjusting densities to preserve mass - - Adjusts the height of the lowest plenum block to keep total assembly height - constant. - - Parameters - ---------- - heightList : list of floats - Entry 0 represents the height (in cm) of the bottom fuel block closest to - the grid plate Entry n represents the height (in cm) of the top fuel block - closest to the plenum - nucNamesToConserveMass : list - The nuclides to conserve mass of - - See Also - -------- - axiallyExpand : expands blocks uniformly. could be combined. - - """ - if self.countBlocksWithFlags(Flags.FUEL) != len(heightList): - raise RuntimeError( - "number of blocks {} and len of height list {} not equal in " - "assembly {}. Cannot axially expand".format( - self.countBlocksWithFlags(Flags.FUEL), len(heightList), self - ) - ) - - initialHeight = self.getHeight() - - cumulativeHeightAdded = 0.0 - for b, newHeight in zip(self.getBlocks(Flags.FUEL), heightList): - originalHeight = b.getHeight() - b.setHeight(newHeight, conserveMass=True, adjustList=nucNamesToConserveMass) - cumulativeHeightAdded += newHeight - originalHeight - - # shrink/grow plenum accordingly to keep T/H parameters, etc. consistent. - plenumBlocks = self.getBlocks(Flags.PLENUM) - if not plenumBlocks: - runLog.warning( - "No plenum blocks found in {0}. Axial expansion is modifying the " - "total assembly height and volume.".format(self) - ) - # Alter assembly number density to account conserve attoms during volume - # change. - # This is analogous to what happens to component/block number density - # during `armi.reactor.blocks.Block.adjustDensity`, which gets called when - # block heights change. - if self.p.detailedNDens is not None: - self.p.detailedNDens *= initialHeight / ( - initialHeight + cumulativeHeightAdded - ) - else: - plenumBlock = plenumBlocks[-1] # adjust the top plenum block - plenumHeight = plenumBlock.getHeight() - if cumulativeHeightAdded < plenumHeight: - plenumBlock.setHeight(plenumHeight - cumulativeHeightAdded) - else: - raise RuntimeError( - "Cannot subtract {0} cm from plenum block " - "that is only {1} tall.".format(cumulativeHeightAdded, plenumHeight) - ) - - def axiallyExpand(self, percent, adjustList): - """ - Axially expands an entire assembly. - - Parameters - ---------- - percent : float - Number from 0 to 100 to make this assembly grow by that much. - If you pass a negative number, the code will actually shrink the assembly by - that percent. - adjustList : list - Nuclides to modify. Omit things like sodium to let it flow. - - See Also - -------- - axiallyExpandBlockHeights : allows non-uniform expansions. Does the work. - """ - growFrac = percent / 100.0 - fuelBlockHeights = [] - for b in self.getBlocks(Flags.FUEL): - heightOriginal = b.getHeight() - if growFrac > 0: - fuelBlockHeights.append(heightOriginal * (1.0 + growFrac)) - else: - # NOTICE THE SIGN SWITCH! SINCE WE GAVE NEGATIVE NUMBER AS AN INDICATOR! - fuelBlockHeights.append(heightOriginal * (1.0 / (1.0 - growFrac))) - - self.axiallyExpandBlockHeights(fuelBlockHeights, adjustList) - def getDim(self, typeSpec, dimName): """ Search through blocks in this assembly and find the first component of compName. diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index 0df7b3082..594c91255 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -91,6 +91,7 @@ def __init__(self, name: str, height: float = 1.0): """ composites.Composite.__init__(self, name) self.p.height = height + self.p.heightBOL = height self.p.orientation = numpy.array((0.0, 0.0, 0.0)) diff --git a/armi/reactor/converters/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger.py new file mode 100644 index 000000000..8eb8a6922 --- /dev/null +++ b/armi/reactor/converters/axialExpansionChanger.py @@ -0,0 +1,608 @@ +# 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. +"""enable component-wise axial expansion for assemblies and/or a reactor""" + +from statistics import mean +from numpy import array +from armi import runLog +from armi.reactor.flags import Flags + + +class AxialExpansionChanger: + """ + Axially expand or contract assemblies or an entire core. + + Useful for fuel performance, thermal expansion, reactivity coefficients, etc. + + Attributes + ---------- + linked : :py:class:`AssemblyAxialLinkage` object. + establishes object containing axial linkage information + expansionData : :py:class:`ExpansionData ` object. + establishes object to store and access relevant expansion data + """ + + def __init__(self, converterSettings: dict): + """ + Build an axial expansion converter. + + Parameters + ---------- + converterSettings : dict + A set of str, value settings used in mesh conversion. Required + settings are implementation specific. + """ + self._converterSettings = converterSettings + self.linked = None + self.expansionData = None + + def prescribedAxialExpansion( + self, a, componentLst: list, percents: list, setFuel=True + ): + """do prescribed axial expansion of an assembly""" + self.setAssembly(a, setFuel) + self.expansionData.setExpansionFactors(componentLst, percents) + self.axiallyExpandAssembly(thermal=False) + + def thermalAxialExpansion(self, a, tempGrid: list, tempField: list, setFuel=True): + """do thermal expansion for an assembly given an axial temperature grid and field""" + self.setAssembly(a, setFuel) + self.expansionData.mapHotTempToComponents(tempGrid, tempField) + self.expansionData.computeThermalExpansionFactors() + self.axiallyExpandAssembly(thermal=True) + + def reset(self): + self.linked = None + self.expansionData = None + + def setAssembly(self, a, setFuel=True): + """set the armi assembly to be changed and init expansion data class for assembly + + Parameters + ---------- + a : :py:class:`Assembly ` object. + ARMI assembly to be changed + """ + self.linked = AssemblyAxialLinkage(a) + self.expansionData = ExpansionData(a, setFuel) + self._isTopDummyBlockPresent() + + def _isTopDummyBlockPresent(self): + """determines if top most block of assembly is a dummy block + + Notes + ----- + - If true, then axial expansion will be physical for all blocks. + - If false, the top most block in the assembly is artificially chopped + to preserve the assembly height. A runLog.Warning also issued. + """ + blkLst = self.linked.a.getBlocks() + if not blkLst[-1].hasFlags(Flags.DUMMY): + runLog.warning( + "No dummy block present at the top of {0}! " + "Top most block will be artificially chopped " + "to preserve assembly height".format(self.linked.a) + ) + if "detailedAxialExpansion" in self._converterSettings: # avoid KeyError + if self._converterSettings["detailedAxialExpansion"]: + runLog.error( + "Cannot run detailedAxialExpansion without a dummy block" + "at the top of the assembly!" + ) + raise RuntimeError + + def axiallyExpandAssembly(self, thermal: bool = False): + """Utilizes assembly linkage to do axial expansion + + Parameters + ---------- + thermal : bool, optional + boolean to determine whether or not expansion is thermal or non-thermal driven + + Notes + ----- + The "thermal" parameter plays a role as thermal expansion is relative to the + BOL heights where non-thermal is relative to the most recent height. + """ + mesh = [0.0] + numOfBlocks = self.linked.a.countBlocksWithFlags() + for ib, b in enumerate(self.linked.a): + if thermal: + blockHeight = b.p.heightBOL + else: + blockHeight = b.p.height + ## set bottom of block equal to top of block below it + # if ib == 0, leave block bottom = 0.0 + if ib > 0: + b.p.zbottom = self.linked.linkedBlocks[b][0].p.ztop + ## if not in the dummy block, get expansion factor, do alignment, and modify block + if ib < (numOfBlocks - 1): + for c in b: + growFrac = self.expansionData.getExpansionFactor(c) + if growFrac >= 0.0: + c.height = (1.0 + growFrac) * blockHeight + else: + c.height = (1.0 / (1.0 - growFrac)) * blockHeight + # align linked components + if ib == 0: + c.zbottom = 0.0 + else: + if self.linked.linkedComponents[c][0] is not None: + # use linked components below + c.zbottom = self.linked.linkedComponents[c][0].ztop + else: + # otherwise there aren't any linked components + # so just set the bottom of the component to + # the top of the block below it + c.zbottom = self.linked.linkedBlocks[b][0].p.ztop + c.ztop = c.zbottom + c.height + # redistribute block boundaries if on the target component + if self.expansionData.isTargetComponent(c): + b.p.ztop = c.ztop + + ## see also b.setHeight() + # - the above not chosen due to call to calculateZCoords + oldComponentVolumes = [c.getVolume() for c in b] + oldHeight = b.getHeight() + b.p.height = b.p.ztop - b.p.zbottom + _checkBlockHeight(b) + _conserveComponentMass(b, oldHeight, oldComponentVolumes) + ## set block mid point and redo mesh + # - functionality based on assembly.calculateZCoords() + b.p.z = b.p.zbottom + b.p.height / 2.0 + mesh.append(b.p.ztop) + b.spatialLocator = self.linked.a.spatialGrid[0, 0, ib] + + bounds = list(self.linked.a.spatialGrid._bounds) + bounds[2] = array(mesh) + self.linked.a.spatialGrid._bounds = tuple(bounds) + + def axiallyExpandCoreThermal(self, r, tempGrid, tempField): + """ + Perform thermally driven axial expansion of the core. + + Parameters + ---------- + r : :py:class:`Reactor ` object. + ARMI reactor to be expanded + tempGrid : dictionary + keys --> :py:class:`Assembly ` object + values --> grid (list of floats) + tempField : dictionary + keys --> :py:class:`Assembly ` object. + values --> temperatures (list of floats) + + """ + for a in r.core.getAssemblies(includeBolAssems=True): + self.setAssembly(a) + self.expansionData.mapHotTempToComponents(tempGrid[a], tempField[a]) + self.expansionData.computeThermalExpansionFactors() + self.axiallyExpandAssembly() + + self._manageCoreMesh(r) + + def axiallyExpandCorePercent(self, r, components, percents): + """ + Perform axial expansion of the core driven by user-defined expansion percentages. + + Parameters + ---------- + r : :py:class:`Reactor ` object. + ARMI reactor to be expanded + components : dict + keys --> :py:class:`Assembly ` object + values --> list of :py:class:`Component ` to be expanded + percents : dict + keys --> :py:class:`Assembly ` object + values --> list of percentages to expand :py:class:`Component ` by # pylint: disable=line-too-long + """ + for a in r.core.getAssemblies(includeBolAssems=True): + self.setAssembly(a) + self.expansionData.setExpansionFactors(components[a], percents[a]) + self.axiallyExpandAssembly() + + self._manageCoreMesh(r) + + def _manageCoreMesh(self, r): + """ + manage core mesh post assembly-level expansion + + Parameters + ---------- + r : :py:class:`Reactor ` object. + ARMI reactor to have mesh modified + + Notes + ----- + - if no detailedAxialExpansion, then do "cheap" approach to uniformMesh converter. + - update average core mesh values with call to r.core.updateAxialMesh() + """ + if not self._converterSettings["detailedAxialExpansion"]: + # loop through again now that the reference is adjusted and adjust the non-fuel assemblies. + refAssem = r.core.refAssem + axMesh = refAssem.getAxialMesh() + for a in r.core.getAssemblies(includeBolAssems=True): + # See ARMI Ticket #112 for explanation of the commented out code + a.setBlockMesh( + axMesh + ) # , conserveMassFlag=True, adjustList=adjustList) + + oldMesh = r.core.p.axialMesh + r.core.updateAxialMesh() # floating point correction + runLog.important( + "Adjusted full core fuel axial mesh uniformly " + "From {0} cm to {1} cm.".format(oldMesh, r.core.p.axialMesh) + ) + + +def _conserveComponentMass(b, oldHeight, oldVolume): + """Update block height dependent component parameters + + 1) update component volume (used to compute block volume) + 2) update number density + + Parameters + ---------- + oldHeight : list of floats + list containing block heights pre-expansion + oldVolume : list of floats + list containing component volumes pre-expansion + """ + for ic, c in enumerate(b): + c.p.volume = oldVolume[ic] * b.p.height / oldHeight + for key in c.getNuclides(): + c.setNumberDensity(key, c.getNumberDensity(key) * oldHeight / b.p.height) + + +def _checkBlockHeight(b): + if b.p.height < 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.p.height + ) + ) + if b.p.height < 0.0: + raise ArithmeticError( + "Block {0:s} ({1:s}) has a negative height! ({2:.12e})".format( + b.name, str(b.p.flags), b.p.height + ) + ) + + +class AssemblyAxialLinkage: + """Determines and stores the block- and component-wise axial linkage for an assembly + + Attributes + ---------- + a : :py:class:`Assembly ` object. + reference to original assembly; is directly modified/changed during expansion. + linkedBlocks : dict + keys --> :py:class:`Block ` object + 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 ` object + values --> list of axially linked components; index 0 = lower linked component; index 1: upper linked component. + see also: self._getLinkedComponents + """ + + _TOLERANCE = 1.0e-03 + + 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 b: + self._getLinkedComponents(b, c) + + def _getLinkedBlocks(self, b): + """retrieve the axial linkage for block b + + Parameters + ---------- + b : :py:class:`Block ` object + 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] + + def _getLinkedComponents(self, b, c): + """retrieve the axial linkage for component c + + Parameters + ---------- + b : :py:class:`Block ` object + key to access blocks containing linked components + c : :py:class:`Component ` object + component to determine axial linkage for + """ + lstLinkedC = [None, None] + for ib, linkdBlk in enumerate(self.linkedBlocks[b]): + if linkdBlk is not None: + for otherC in linkdBlk.getChildren(): + if isinstance( + otherC, type(c) + ): # equivalent to type(otherC) == type(c) + area_diff = abs(otherC.getArea() - c.getArea()) + if area_diff < self._TOLERANCE: + lstLinkedC[ib] = otherC + + self.linkedComponents[c] = lstLinkedC + + if lstLinkedC[0] is None: + runLog.debug( + "Assembly {0:22s} at location {1:22s}, Block {2:22s}, Component {3:22s} " + "has nothing linked below it!".format( + str(self.a.getName()), + str(self.a.getLocation()), + str(b.p.flags), + str(c.p.flags), + ) + ) + if lstLinkedC[1] is None: + runLog.debug( + "Assembly {0:22s} at location {1:22s}, Block {2:22s}, Component {3:22s} " + "has nothing linked above it!".format( + str(self.a.getName()), + str(self.a.getLocation()), + str(b.p.flags), + str(c.p.flags), + ) + ) + + +class ExpansionData: + """object containing data needed for axial expansion""" + + def __init__(self, a, setFuel): + self._a = a + self._oldHotTemp = {} + self._expansionFactors = {} + self._componentDeterminesBlockHeight = {} + self._setTargetComponents(setFuel) + + def setExpansionFactors(self, componentLst, percents): + """sets user defined expansion factors + + Parameters + ---------- + componentLst : list of :py:class:`Component ` + list of :py:class:`Component ` objects to have their heights changed # pylint: disable=line-too-long + percents : list of floats + list of height changes in percent that are to be applied to componentLst + + Raises + ------ + RuntimeError + If componentLst and percents are different lengths + + Notes + ----- + - requires that the length of componentLst and percents be the same + """ + if len(componentLst) != len(percents): + runLog.error( + "Number of components and percent changes must be the same!\n\ + len(componentLst) = {0:d}\n\ + len(percents) = {1:d}".format( + len(componentLst), len(percents) + ) + ) + raise RuntimeError + for c, p in zip(componentLst, percents): + self._expansionFactors[c] = p + + def mapHotTempToComponents(self, tempGrid, tempField): + """map axial temp distribution to blocks and components in self.a + + Parameters + ---------- + tempGrid : numpy array + axial temperature grid (i.e., physical locations where temp is stored) + tempField : numpy array + temperature values along grid + + Notes + ----- + - maps the radially uniform axial temperature distribution to components + - searches for temperatures that fall within the bounds of a block, + averages them, and assigns them as appropriate + - The second portion, when component volume is set, is functionally very similar + to c.computeVolume(), however differs in the temperatures that get used to compute dimensions. + - In c.getArea() -> c.getComponentArea(cold=cold) -> self.getDimension(str, cold=cold), + cold=False results in self.getDimension to use the cold/input component temperature. + However, we want the "old hot" temp to be used. So, here we manually call + c.getArea and pass in the correct "cold" (old hot) temperature. This ensures that + component mass is conserved. + + 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._oldHotTemp = {} # 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( + "Block {0:s} has no temperature points within it! \ + Likely need to increase the refinement of the temperature grid.".format( + str(b.name) + ) + ) + + blockAveTemp = mean(tmpMapping) + for c in b: + self._oldHotTemp[c] = c.temperatureInC # stash the "old" hot temp + # set component volume to be evaluated at "old" hot temp + c.p.volume = c.getArea(cold=self._oldHotTemp[c]) * c.parent.getHeight() + # DO NOT use self.setTemperature(). This calls changeNDensByFactor(f) + # and ruins mass conservation via number densities. Instead, + # set manually. + c.temperatureInC = blockAveTemp + + def computeThermalExpansionFactors(self): + """computes expansion factors for all components via thermal expansion""" + + for b in self._a: + for c in b: + self._expansionFactors[c] = c.getThermalExpansionFactor() - 1.0 + + def getExpansionFactor(self, c): + """retrieves expansion factor for c + + Parameters + ---------- + c : :py:class:`Component ` object + :py:class:`Component ` object to retrive expansion factor for + + """ + if c in self._expansionFactors: + value = self._expansionFactors[c] + else: + runLog.debug("No expansion factor for {}! Setting to 0.0".format(c)) + value = 0.0 + return value + + def _setTargetComponents(self, setFuel): + """sets target component for each block + + - To-Do: allow users to specify target component for a block in settings + """ + for b in self._a: + if b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP): + self.specifyTargetComponent(b, Flags.CLAD) + elif b.hasFlags(Flags.DUMMY): + self.specifyTargetComponent(b, Flags.COOLANT) + elif setFuel and b.hasFlags(Flags.FUEL): + self._isFuelLocked(b) + else: + self.specifyTargetComponent(b) + + def specifyTargetComponent(self, b, flagOfInterest=None): + """appends target component to self._componentDeterminesBlockHeight + + Parameters + ---------- + b : :py:class:`Block ` object + block to specify target component for + flagOfInterest : :py:class:`Flags ` object + 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 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: + 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: + raise RuntimeError("No target component found!\n Block {0}".format(b)) + if len(componentWFlag) > 1: + raise RuntimeError( + "Cannot have more than one component within a block that has the target flag!" + "Block {0}\nflagOfInterest {1}\nComponents {2}".format( + b, flagOfInterest, componentWFlag + ) + ) + self._componentDeterminesBlockHeight[componentWFlag[0]] = True + + def _isFuelLocked(self, b): + """physical/realistic implementation reserved for ARMI plugin + + Parameters + ---------- + b : :py:class:`Block ` object + 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.getChildrenWithFlags(Flags.FUEL) + if len(c) == 0: # pylint: disable=no-else-raise + raise RuntimeError("No fuel component within {0}!".format(b)) + elif len(c) > 1: + raise RuntimeError( + "Cannot have more than one fuel component within {0}!".format(b) + ) + self._componentDeterminesBlockHeight[c[0]] = True + + def isTargetComponent(self, c): + """returns bool if c is a target component + + Parameters + ---------- + c : :py:class:`Component ` object + :py:class:`Component ` object to check target component status + """ + return bool(c in self._componentDeterminesBlockHeight) diff --git a/armi/reactor/converters/meshConverters.py b/armi/reactor/converters/meshConverters.py index 4d3eb8f1e..108eb63bc 100644 --- a/armi/reactor/converters/meshConverters.py +++ b/armi/reactor/converters/meshConverters.py @@ -388,107 +388,3 @@ def generateBins(totalNumDataPoints, numPerBin, minNum): if currentNum > minNum: listToFill.append(currentNum) return listToFill - - -class AxialExpansionModifier(MeshConverter): - """ - Axially expand or contract a reactor. - - Useful for fuel performance, thermal expansion, reactivity coefficients, etc. - """ - - def __init__(self, percent, fuelLockedToClad=False, cs=None): - """ - Build an axial expansion converter. - - Parameters - ---------- - percent : float - the desired axial expansion in percent. If negative, use special - treatment of down-expanding - - fuelLockedToClad : bool - Specify whether or not to conserve mass on structure due to - the fuel being locked to the clad. Note: this should - generally be set to False even if the fuel is locked to the clad - because the duct will not be axially expanding. - """ - MeshConverter.__init__(self, cs) - self._percent = percent - self._fuelLockedToClad = fuelLockedToClad - - def convert(self, r=None, converterSettings=None): - """ - Perform an axial expansion of the core. - - Notes - ----- - This loops through the fuel blocks, making their height larger by a fraction of - maxPercent. It reduces the homogenized actinide number densities to conserve - atoms. - - This is a first approximation, adjusting the whole core uniformly and adjusting - fuel with structure and everything. - - When fuel is locked to clad, this only expands the actinides! So the structural - materials and sodium stay as they are in terms of density. By growing the mesh, - we are introducing NEW ATOMS of these guys, thus violating conservation of - atoms. However, the new ones are effectively piled up on top of the reactor - where they are neutronically uninteresting. This approximates fuel movement - without clad/duct movement. - """ - adjustFlags = Flags.FUEL | Flags.CLAD if self._fuelLockedToClad else Flags.FUEL - adjustList = getAxialExpansionNuclideAdjustList(r, adjustFlags) - - runLog.extra( - "Conserving mass during axial expansion for: {0}".format(str(adjustList)) - ) - - # plenum shrinks so we should just measure the fuel height. - oldMesh = r.core.p.axialMesh - for a in r.core.getAssemblies(includeBolAssems=True): - a.axiallyExpand(self._percent, adjustList) - - r.core.p.axialExpansionPercent = self._percent - - if not self._converterSettings["detailedAxialExpansion"]: - # loop through again now that the reference is adjusted and adjust the non-fuel assemblies. - refAssem = r.core.refAssem - axMesh = refAssem.getAxialMesh() - for a in r.core.getAssemblies(includeBolAssems=True): - # See ARMI Ticket #112 for explanation of the commented out code - a.setBlockMesh( - axMesh - ) # , conserveMassFlag=True, adjustList=adjustList) - - r.core.updateAxialMesh() # floating point correction - newMesh = r.core.p.axialMesh - runLog.important( - "Adjusted full core fuel axial mesh uniformly " - "{0}% from {1} cm to {2} cm.".format(self._percent, oldMesh, newMesh) - ) - - -def getAxialExpansionNuclideAdjustList(r, componentFlags: TypeSpec = None): - r""" - Determine which nuclides should have their mass conserved during axial expansion - - Parameters - ---------- - r : Reactor - The Reactor object to search for nuclide instances - componentFlags : TypeSpec, optional - A type specification to use for filtering components that should conserve mass. - If None, Flags.FUEL is used. - - - """ - - if componentFlags is None: - componentFlags = [Flags.FUEL] - - adjustSet = { - nuc for c in r.core.iterComponents(componentFlags) for nuc in c.getNuclides() - } - - return list(adjustSet) diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py new file mode 100644 index 000000000..6789da958 --- /dev/null +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -0,0 +1,682 @@ +# 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. + +"""Test axialExpansionChanger""" + +from statistics import mean +import unittest +from numpy import linspace, ones, array, vstack, zeros +from armi.reactor.tests.test_reactors import loadTestReactor +from armi.tests import TEST_ROOT +from armi.reactor.assemblies import grids +from armi.reactor.assemblies import HexAssembly +from armi.reactor.blocks import HexBlock +from armi.reactor.components import DerivedShape +from armi.reactor.components.basicShapes import Circle, Hexagon +from armi.reactor.converters.axialExpansionChanger import ( + AxialExpansionChanger, + ExpansionData, +) +from armi.reactor.flags import Flags +from armi import materials +from armi.utils import units + +# set namespace order for materials so that fake HT9 material can be found +materials.setMaterialNamespaceOrder( + ["armi.reactor.converters.tests.test_axialExpansionChanger", "armi.materials"] +) + + +class Base(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._converterSettings = {} + self.obj = AxialExpansionChanger(self._converterSettings) + self.massAndDens = {} + self.steelMass = [] + self.blockHeights = {} + + 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 + """ + mass = 0.0 + for b in a: + for c in b: + ## store mass and density of target component + if self.obj.expansionData.isTargetComponent(c): + self._storeTargetComponentMassAndDensity(c) + ## store steel mass for assembly + if c.p.flags in self.Steel_Component_Lst: + mass += c.getMass() + + # store block heights + tmp = array([b.p.zbottom, b.p.ztop, b.p.height, b.getVolume()]) + if b.name not in self.blockHeights: + self.blockHeights[b.name] = tmp + else: + self.blockHeights[b.name] = vstack((self.blockHeights[b.name], tmp)) + + self.steelMass.append(mass) + + def _storeTargetComponentMassAndDensity(self, c): + tmp = array( + [ + c.getMass(), + c.material.getProperty("density", c.temperatureInK), + ] + ) + if c.parent.name not in self.massAndDens: + self.massAndDens[c.parent.name] = tmp + else: + self.massAndDens[c.parent.name] = vstack( + (self.massAndDens[c.parent.name], tmp) + ) + + +class Temperature: + """create and store temperature grid/field""" + + def __init__( + self, + L, + coldTemp=25.0, + hotInletTemp=360.0, + numTempGridPts=25, + tempSteps=100, + uniform=False, + ): + """ + Parameters + ---------- + L : float + length of self.tempGrid. Should be the height of the corresponding assembly. + coldTemp : float + component as-built temperature + hotInletTemp : float + temperature closest to bottom of assembly. Interpreted as + inlet temp at nominal operations. + numTempGridPts : integer + the number of temperature measurement locations along the + z-axis of the assembly + tempSteps : integer + the number of temperatures to create (analogous to time steps) + """ + self.tempSteps = tempSteps + self.tempGrid = linspace(0.0, L, num=numTempGridPts) + self.tempField = zeros((tempSteps, numTempGridPts)) + self._generateTempField(coldTemp, hotInletTemp, uniform) + + def _generateTempField(self, coldTemp, hotInletTemp, uniform): + """ + generate temperature field and grid + + - all temperatures are in C + - temperature field : temperature readings (e.g., from T/H calculation) + - temperature grid : physical locations in which + temperature is measured + """ + ## Generate temp field + self.tempField[0, :] = coldTemp + if not uniform: + for i in range(1, self.tempSteps): + self.tempField[i, :] = ( + coldTemp + + (i + 1) / (self.tempSteps / 3) * self.tempGrid + + (hotInletTemp - coldTemp) * (i + 1) / self.tempSteps + ) + else: + tmp = linspace(coldTemp, hotInletTemp, self.tempSteps) + for i in range(1, self.tempSteps): + self.tempField[i, :] = tmp[i] + + +class TestAxialExpansionHeight(Base, unittest.TestCase): + """verify that test assembly is expanded correctly""" + + def setUp(self): + Base.setUp(self) + self.a = buildTestAssemblyWithFakeMaterial(name="Fake") + + self.temp = Temperature( + self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10 + ) + + # get the right/expected answer + self._generateComponentWiseExpectedHeight() + + # do the axial expansion + self.axialMeshLocs = zeros((self.temp.tempSteps, len(self.a))) + for idt in range(self.temp.tempSteps): + self.obj.thermalAxialExpansion( + self.a, self.temp.tempGrid, self.temp.tempField[idt, :], setFuel=True + ) + self._getConservationMetrics(self.a) + self.axialMeshLocs[idt, :] = self.a.getAxialMesh() + + def test_AssemblyAxialExpansionHeight(self): + """test the axial expansion gives correct heights for component-based expansion""" + for idt in range(self.temp.tempSteps): + for ib, b in enumerate(self.a): + self.assertAlmostEqual( + self.trueZtop[ib, idt], + self.blockHeights[b.name][idt][1], + places=7, + msg="Block height is not correct.\ + Temp Step = {0:d}, Block ID = {1:}.".format( + idt, b.name + ), + ) + + def test_AxialMesh(self): + """test that mesh aligns with block tops for component-based expansion""" + for idt in range(self.temp.tempSteps): + for ib, b in enumerate(self.a): + self.assertEqual( + self.axialMeshLocs[idt][ib], + self.blockHeights[b.name][idt][1], + msg="\ + Axial mesh and block top do not align and invalidate the axial mesh.\ + Block ID = {0:s},\n\ + Top = {1:.12e}\n\ + Mesh Loc = {2:.12e}".format( + str(b.name), + self.blockHeights[b.name][idt][1], + self.axialMeshLocs[idt][ib], + ), + ) + + def _generateComponentWiseExpectedHeight(self): + """calculate the expected height, external of AssemblyAxialExpansion()""" + assem = buildTestAssemblyWithFakeMaterial(name="Fake") + aveBlockTemp = zeros((len(assem), self.temp.tempSteps)) + self.trueZtop = zeros((len(assem), self.temp.tempSteps)) + self.trueHeight = zeros((len(assem), self.temp.tempSteps)) + self.trueZtop[-1, :] = assem[-1].p.ztop + + for idt in range(self.temp.tempSteps): + # get average block temp + for ib in range(len(assem)): + aveBlockTemp[ib, idt] = self._getAveTemp(ib, idt, assem) + # get block ztops + for ib, b in enumerate(assem[:-1]): + if ib > 0: + b.p.zbottom = assem[ib - 1].p.ztop + if idt > 0: + dll = ( + 0.02 * aveBlockTemp[ib, idt] - 0.02 * aveBlockTemp[ib, idt - 1] + ) / (100.0 + 0.02 * aveBlockTemp[ib, idt - 1]) + thermExpansionFactor = 1.0 + dll + b.p.ztop = thermExpansionFactor * b.p.height + b.p.zbottom + self.trueZtop[ib, idt] = b.p.ztop + # get block heights + for ib, b in enumerate(assem): + b.p.height = b.p.ztop - b.p.zbottom + self.trueHeight[ib, idt] = b.p.height + + def _getAveTemp(self, ib, idt, assem): + tmpMapping = [] + for idz, z in enumerate(self.temp.tempGrid): + if assem[ib].p.zbottom <= z <= assem[ib].p.ztop: + tmpMapping.append(self.temp.tempField[idt][idz]) + if z > assem[ib].p.ztop: + break + + return mean(tmpMapping) + + +class TestCoreExpansion(Base, unittest.TestCase): + """verify core-based expansion changes r.core.p.axialMesh + + Notes + ----- + - Just checks that the mesh changes after expansion. + - Actual verification of axial expansion occurs in class TestAxialExpansionHeight + """ + + def setUp(self): + Base.setUp(self) + self.o, self.r = loadTestReactor(TEST_ROOT) + self.temp = Temperature(self.r.core.refAssem.getTotalHeight()) + # populate test temperature and percent expansion data + self.tempGrid = {} + self.tempField = {} + self.componentLst = {} + self.percents = {} + # just use self.tempField[-1], no need to use all steps in temp.tempField + for a in self.r.core.getAssemblies(includeBolAssems=True): + self.tempGrid[a] = self.temp.tempGrid + self.tempField[a] = self.temp.tempField[-1] + self.componentLst[a] = [c for b in a for c in b] + self.percents[a] = list(0.01 * ones(len(self.componentLst[a]))) + + def test_axiallyExpandCoreThermal(self): + self.obj._converterSettings[ + "detailedAxialExpansion" + ] = False # pylint: disable=protected-access + oldMesh = self.r.core.p.axialMesh + self.obj.axiallyExpandCoreThermal(self.r, self.tempGrid, self.tempField) + self.assertNotEqual( + oldMesh, + self.r.core.p.axialMesh, + msg="The core mesh has not changed with the expansion. That's not right.", + ) + + def test_axiallyExpandCorePercent(self): + self.obj._converterSettings[ + "detailedAxialExpansion" + ] = False # pylint: disable=protected-access + oldMesh = self.r.core.p.axialMesh + self.obj.axiallyExpandCorePercent(self.r, self.componentLst, self.percents) + self.assertNotEqual( + oldMesh, + self.r.core.p.axialMesh, + msg="The core mesh has not changed with the expansion. That's not right.", + ) + + +class TestConservation(Base, unittest.TestCase): + """verify that conservation is maintained in assembly-level axial expansion""" + + def setUp(self): + Base.setUp(self) + self.a = buildTestAssemblyWithFakeMaterial(name="Fake") + + # initialize class variables for conservation checks + self.oldMass = {} + for b in self.a: + self.oldMass[b.name] = 0.0 + + # do the expansion and store mass and density info + self.temp = Temperature( + self.a.getTotalHeight(), coldTemp=1.0, hotInletTemp=1000.0 + ) + for idt in range(self.temp.tempSteps): + self.obj.thermalAxialExpansion( + self.a, self.temp.tempGrid, self.temp.tempField[idt, :], setFuel=True + ) + self._getConservationMetrics(self.a) + + def test_ExpansionContractionConservation(self): + """expand all components and then contract back to original state + + Notes + ----- + - uniform expansion over all components within the assembly + - 10 total expansion steps: 5 at +1%, and 5 at -1% + - assertion on if original axial mesh matches the final axial mesh + """ + a = buildTestAssemblyWithFakeMaterial(name="Fake") + obj = AxialExpansionChanger(converterSettings={}) + oldMesh = a.getAxialMesh() + componentLst = [c for b in a for c in b] + for i in range(0, 10): + # get the percentage change + if i < 5: + percents = 0.01 + zeros(len(componentLst)) + else: + percents = -0.01 + zeros(len(componentLst)) + # set the expansion factors + oldMasses = [c.getMass() for b in a for c in b] + # do the expansion + obj.prescribedAxialExpansion(a, componentLst, percents, setFuel=True) + newMasses = [c.getMass() for b in a for c in b] + for old, new in zip(oldMasses, newMasses): + self.assertAlmostEqual(old, new) + + self.assertEqual( + oldMesh, + a.getAxialMesh(), + msg="Axial mesh is not the same after the expansion and contraction!", + ) + + def test_TargetComponentMassConservation(self): + """tests mass conservation for target components""" + for idt in range(self.temp.tempSteps): + for b in self.a[:-1]: # skip the dummy sodium block + if idt != 0: + self.assertAlmostEqual( + self.oldMass[b.name], + self.massAndDens[b.name][idt][0], + places=7, + msg="Conservation of Mass Failed on time step {0:d}, block name {1:s},\ + with old mass {2:.7e}, and new mass {3:.7e}.".format( + idt, + b.name, + self.oldMass[b.name], + self.massAndDens[b.name][idt][0], + ), + ) + self.oldMass[b.name] = self.massAndDens[b.name][idt][0] + + def test_SteelConservation(self): + """tests mass conservation for total assembly steel + + Component list defined by, Steel_Component_List, in GetSteelMass() + """ + for idt in range(self.temp.tempSteps - 1): + self.assertAlmostEqual( + self.steelMass[idt], + self.steelMass[idt + 1], + places=7, + msg="Conservation of steel mass failed on time step {0:d}".format(idt), + ) + + def test_NoMovementACLP(self): + """ensures that above core load pad (ACLP) does not move during fuel-only expansion""" + # build test assembly with ACLP + assembly = HexAssembly("testAssemblyType") + assembly.spatialGrid = grids.axialUnitGrid(numCells=1) + assembly.spatialGrid.armiObject = assembly + assembly.add(_buildTestBlock("shield", "Fake")) + assembly.add(_buildTestBlock("fuel", "Fake")) + assembly.add(_buildTestBlock("fuel", "Fake")) + assembly.add(_buildTestBlock("plenum", "Fake")) + assembly.add(_buildTestBlock("aclp", "Fake")) # "aclp plenum" also works + assembly.add(_buildTestBlock("plenum", "Fake")) + assembly.add(_buildDummySodium()) + assembly.calculateZCoords() + assembly.reestablishBlockOrder() + + ## get zCoords for aclp + aclp = assembly.getChildrenWithFlags(Flags.ACLP)[0] + aclpZTop = aclp.p.ztop + aclpZBottom = aclp.p.zbottom + + ## expand fuel + # get fuel components + cList = [c for b in assembly for c in b if c.hasFlags(Flags.FUEL)] + # 10% growth of fuel components + pList = zeros(len(cList)) + 0.1 + chngr = AxialExpansionChanger(converterSettings={}) + chngr.prescribedAxialExpansion(assembly, cList, pList, setFuel=True) + + ## do assertion + self.assertEqual( + aclpZBottom, + aclp.p.zbottom, + msg="ACLP zbottom has changed. It should not with fuel component only expansion!", + ) + self.assertEqual( + aclpZTop, + aclp.p.ztop, + msg="ACLP ztop has changed. It should not with fuel component only expansion!", + ) + + +class TestExceptions(Base, unittest.TestCase): + """Verify exceptions are caught""" + + def setUp(self): + Base.setUp(self) + self.a = buildTestAssemblyWithFakeMaterial(name="FakeException") + self.obj.setAssembly(self.a) + + def test_isTopDummyBlockPresent(self): + # build test assembly without dummy + assembly = HexAssembly("testAssemblyType") + assembly.spatialGrid = grids.axialUnitGrid(numCells=1) + assembly.spatialGrid.armiObject = assembly + assembly.add(_buildTestBlock("shield", "Fake")) + assembly.calculateZCoords() + assembly.reestablishBlockOrder() + # create instance of expansion changer + obj = AxialExpansionChanger({"detailedAxialExpansion": True}) + with self.assertRaises(RuntimeError) as cm: + obj.setAssembly(assembly) + 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() + percents = range(len(cList) + 1) + self.obj.expansionData.setExpansionFactors(cList, percents) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_mapHotTempToComponentsValueError(self): + tempGrid = [5.0, 15.0, 35.0] + tempField = linspace(25.0, 310.0, 3) + with self.assertRaises(ValueError) as cm: + self.obj.expansionData.mapHotTempToComponents(tempGrid, tempField) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_mapHotTempToComponentsRuntimeError(self): + tempGrid = [5.0, 15.0, 35.0] + tempField = linspace(25.0, 310.0, 10) + with self.assertRaises(RuntimeError) as cm: + self.obj.expansionData.mapHotTempToComponents(tempGrid, tempField) + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_AssemblyAxialExpansionException(self): + """test that negative height exception is caught""" + temp = Temperature(self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10) + with self.assertRaises(ArithmeticError) as cm: + for idt in range(temp.tempSteps): + self.obj.expansionData.mapHotTempToComponents( + temp.tempGrid, temp.tempField[idt, :] + ) + self.obj.expansionData.computeThermalExpansionFactors() + self.obj.axiallyExpandAssembly() + + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_specifyTargetComponentRuntimeErrorFirst(self): + # build block for testing + b = HexBlock("test", 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} + mainType = Circle("main", "Fake", **fuelDims) + clad = Circle("clad", "Fake", **cladDims) + b.add(mainType) + b.add(clad) + b.setType("test") + b.getVolumeFractions() + # do test + with self.assertRaises(RuntimeError) as cm: + self.obj.expansionData.specifyTargetComponent(b) + + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_specifyTargetComponentRuntimeErrorSecond(self): + # build block for testing + b = HexBlock("test", 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} + mainType = Circle("test", "Fake", **fuelDims) + clad = Circle("test", "Fake", **cladDims) + b.add(mainType) + b.add(clad) + b.setType("test") + b.getVolumeFractions() + # do test + with self.assertRaises(RuntimeError) as cm: + self.obj.expansionData.specifyTargetComponent(b) + + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + def test_isFuelLocked(self): + b_TwoFuel = HexBlock("fuel", height=10.0) + fuelDims = {"Tinput": 25.0, "Thot": 25.0, "od": 0.76, "id": 0.00, "mult": 127.0} + fuel2Dims = { + "Tinput": 25.0, + "Thot": 25.0, + "od": 0.80, + "id": 0.77, + "mult": 127.0, + } + fuel = Circle("fuel", "Fake", **fuelDims) + fuel2 = Circle("fuel", "Fake", **fuel2Dims) + b_TwoFuel.add(fuel) + b_TwoFuel.add(fuel2) + b_TwoFuel.setType("test") + expdata = ExpansionData(HexAssembly("testAssemblyType"), setFuel=True) + # do test + with self.assertRaises(RuntimeError) as cm: + expdata._isFuelLocked(b_TwoFuel) # pylint: disable=protected-access + + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + b_NoFuel = HexBlock("fuel", height=10.0) + shield = Circle("shield", "Fake", **fuelDims) + b_NoFuel.add(shield) + with self.assertRaises(RuntimeError) as cm: + expdata._isFuelLocked(b_NoFuel) # pylint: disable=protected-access + + the_exception = cm.exception + self.assertEqual(the_exception.error_code, 3) + + +def buildTestAssemblyWithFakeMaterial(name): + """Create test assembly consisting of list of fake material + + Parameters + ---------- + name : string + determines which fake material to use + """ + assembly = HexAssembly("testAssemblyType") + assembly.spatialGrid = grids.axialUnitGrid(numCells=1) + assembly.spatialGrid.armiObject = assembly + assembly.add(_buildTestBlock("shield", name)) + assembly.add(_buildTestBlock("fuel", name)) + assembly.add(_buildTestBlock("fuel", name)) + assembly.add(_buildTestBlock("plenum", name)) + assembly.add(_buildDummySodium()) + assembly.calculateZCoords() + assembly.reestablishBlockOrder() + return assembly + + +def _buildTestBlock(blockType, name): + """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 fake material to use + """ + b = HexBlock(blockType, 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} + ductDims = {"Tinput": 25.0, "Thot": 25.0, "op": 16, "ip": 15.3, "mult": 1.0} + intercoolantDims = { + "Tinput": 25.0, + "Thot": 25.0, + "op": 17.0, + "ip": ductDims["op"], + "mult": 1.0, + } + coolDims = {"Tinput": 25.0, "Thot": 25.0} + 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(): + """Build a dummy sodium block.""" + b = HexBlock("dummy", height=10.0) + + sodiumDims = {"Tinput": 25.0, "Thot": 25.0, "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 Fake(materials.ht9.HT9): # pylint: disable=abstract-method + """Fake material used to verify armi.reactor.converters.axialExpansionChanger + + Notes + ----- + - specifically used 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 = "Fake" + + def __init__(self): + materials.ht9.HT9.__init__(self) + + def linearExpansionPercent(self, Tk=None, Tc=None): + """ A fake linear expansion percent""" + Tc = units.getTc(Tc, Tk) + return 0.02 * Tc + + +class FakeException(materials.ht9.HT9): # pylint: disable=abstract-method + """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 = "FakeException" + + def __init__(self): + materials.ht9.HT9.__init__(self) + + 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/tests/test_meshConverters.py b/armi/reactor/converters/tests/test_meshConverters.py index f46814718..3036f4e4c 100644 --- a/armi/reactor/converters/tests/test_meshConverters.py +++ b/armi/reactor/converters/tests/test_meshConverters.py @@ -116,120 +116,6 @@ def test_meshByRingCompositionAxialCoordinatesLargeCore(self): self.assertListEqual(meshConvert.thetaMesh, expectedThetaMesh) -class AxialExpandNucs(unittest.TestCase): - def setUp(self): - self.o, self.r = loadTestReactor(TEST_ROOT) - - def test_getAxialExpansionNuclideAdjustList(self): - nucs = meshConverters.getAxialExpansionNuclideAdjustList(self.r) - expected = [ - "NP238", - "LFP35", - "PU242", - "LFP39", - "ZR94", - "LFP38", - "CM245", - "PU241", - "U235", - "CM244", - "ZR96", - "AM243", - "U236", - "NP237", - "U238", - "AM242", - "PU236", - "ZR90", - "LFP40", - "DUMP2", - "DUMP1", - "CM242", - "LFP41", - "PU240", - "CM246", - "CM243", - "PU238", - "ZR92", - "CM247", - "AM241", - "U234", - "PU239", - "ZR91", - ] - self.assertEqual(sorted(nucs), sorted(expected)) - - nucs = meshConverters.getAxialExpansionNuclideAdjustList( - self.r, [Flags.FUEL, Flags.CLAD] - ) - expected = [ - "U235", - "U238", - "ZR90", - "ZR91", - "ZR92", - "ZR94", - "ZR96", - "NP237", - "LFP38", - "DUMP1", - "PU239", - "NP238", - "PU236", - "U236", - "DUMP2", - "PU238", - "LFP35", - "LFP39", - "PU240", - "LFP40", - "PU241", - "LFP41", - "PU242", - "AM241", - "CM242", - "AM242", - "CM243", - "U234", - "AM243", - "CM244", - "CM245", - "CM246", - "CM247", - "C", - "V", - "CR50", - "CR52", - "CR53", - "CR54", - "FE54", - "FE56", - "FE57", - "FE58", - "MO92", - "MO94", - "MO95", - "MO96", - "MO97", - "MO98", - "MO100", - "NI58", - "NI60", - "NI61", - "NI62", - "NI64", - "SI28", - "SI29", - "SI30", - "MN55", - "W182", - "W183", - "W184", - "W186", - ] - self.assertEqual(sorted(nucs), sorted(expected)) - - if __name__ == "__main__": # import sys;sys.argv = ['', 'TestRZReactorMeshConverter.test_meshByRingCompositionAxialBinsSmallCore'] unittest.main() diff --git a/armi/reactor/tests/test_assemblies.py b/armi/reactor/tests/test_assemblies.py index 91e8de491..ef87884bb 100644 --- a/armi/reactor/tests/test_assemblies.py +++ b/armi/reactor/tests/test_assemblies.py @@ -832,166 +832,6 @@ def test_countBlocksOfType(self): cur = self.Assembly.countBlocksWithFlags(Flags.IGNITER | Flags.FUEL) self.assertEqual(cur, 3) - def test_axiallyExpandBlockHeights(self): - r"""heightList = list of floats. Entry 0 represents the bottom fuel block closest to the grid plate. - Entry n represents the top fuel block closes to the plenum - adjust list = list of nuclides to modify""" - - self.assemNum = 5 - - # Remake original assembly - self.r.core.removeAssembly(self.Assembly) - self.Assembly = makeTestAssembly(self.assemNum, self.assemNum, r=self.r) - self.r.core.add(self.Assembly) - - # add some blocks with a component - for i in range(self.assemNum): - b = blocks.HexBlock("TestBlock") - - # Set the 1st block to have higher params than the rest. - self.blockParamsTemp = {} - for key, val in self.blockParams.items(): - b.p[key] = self.blockParamsTemp[key] = val * ( - i + 1 - ) # Iterate with i in self.assemNum, so higher assemNums get the high values. - - b.setHeight(self.height) - - self.hexDims = { - "Tinput": 273.0, - "Thot": 273.0, - "op": 0.76, - "ip": 0.0, - "mult": 1.0, - } - - if (i == 0) or (i == 4): - b.setType("plenum") - h = components.Hexagon("intercoolant", "Sodium", **self.hexDims) - else: - b.setType("fuel") - h = components.Hexagon("fuel", "UZr", **self.hexDims) - - b.add(h) - - self.Assembly.add(b) - - expandFrac = 1.15 - heightList = [self.height * expandFrac for x in range(self.assemNum - 2)] - adjustList = ["U238", "ZR", "U235"] - - # Get the original block heights and densities to compare to later. - heights = {} # Dictionary with keys of block number, values of block heights. - densities = ( - {} - ) # Dictionary with keys of block number, values of dictionaries with keys of nuclide, values of block nuclide density - for i, b in enumerate(self.Assembly): - heights[i] = b.getHeight() - densities[i] = {} - for nuc, dens in b.getNumberDensities().items(): - densities[i][nuc] = dens - - self.Assembly.axiallyExpandBlockHeights(heightList, adjustList) - - for i, b in enumerate(self.Assembly): - # Check height - if i == 0: - ref = heights[i] - elif i == 4: - ref = heights[i] - (expandFrac - 1) * 3 * heights[i] - else: - ref = heights[i] * expandFrac - cur = b.getHeight() - places = 6 - self.assertAlmostEqual(cur, ref, places=places) - - # Check densities - for nuc, dens in b.getNumberDensities().items(): - if (i == 0) or (i == 4): - ref = densities[i][nuc] - else: - ref = densities[i][nuc] / expandFrac - cur = b.getNumberDensity(nuc) - places = 6 - self.assertAlmostEqual(cur, ref, places=places) - - def test_axiallyExpand(self): - """Build an assembly, grow it, and check it.""" - self.assemNum = 5 - - # Remake original assembly - self.r.core.removeAssembly(self.Assembly) - self.Assembly = makeTestAssembly(self.assemNum, self.assemNum, r=self.r) - self.r.core.add(self.Assembly) - - # add some blocks with a component - for blockI in range(self.assemNum): - b = blocks.HexBlock("TestBlock") - - # Set the 1st block to have higher params than the rest. - self.blockParamsTemp = {} - for key, val in self.blockParams.items(): - b.p[key] = self.blockParamsTemp[key] = val * ( - blockI + 1 - ) # Iterate with i in self.assemNum, so higher assemNums get the high values. - b.setHeight(self.height) - self.hexDims = { - "Tinput": 273.0, - "Thot": 273.0, - "op": 0.76, - "ip": 0.0, - "mult": 1.0, - } - if (blockI == 0) or (blockI == 4): - b.setType("plenum") - h = components.Hexagon("intercoolant", "Sodium", **self.hexDims) - else: - b.setType("fuel") - h = components.Hexagon("fuel", "UZr", **self.hexDims) - b.add(h) - self.Assembly.add(b) - - expandFrac = 1.15 - adjustList = ["U238", "ZR", "U235"] - - # Get the original block heights and densities to compare to later. - heights = {} # Dictionary with keys of block number, values of block heights. - densities = ( - {} - ) # Dictionary with keys of block number, values of dictionaries with keys of nuclide, values of block nuclide density - for i, b in enumerate(self.Assembly): - heights[i] = b.getHeight() - densities[i] = {} - for nuc, dens in b.getNumberDensities().items(): - densities[i][nuc] = dens - - expandPercent = (expandFrac - 1) * 100 - self.Assembly.axiallyExpand(expandPercent, adjustList) - - for i, b in enumerate(self.Assembly): - # Check height - if i == 0: - # bottom block should be unchanged (because plenum) - ref = heights[i] - elif i == 4: - # plenum on top should have grown by 15% of the uniform height * 3 (for each fuel block) - ref = heights[i] - (expandFrac - 1) * 3 * heights[i] - else: - # each of the three fuel blocks should be 15% bigger. - ref = heights[i] * expandFrac - self.assertAlmostEqual(b.getHeight(), ref) - - # Check densities - for nuc, dens in b.getNumberDensities().items(): - if (i == 0) or (i == 4): - # these blocks should be unchanged in mass/density. - ref = densities[i][nuc] - else: - # fuel blocks should have all three nuclides reduced. - ref = densities[i][nuc] / expandFrac - places = 6 - self.assertAlmostEqual(dens, ref, places=places) - def test_getDim(self): cur = self.Assembly.getDim(Flags.FUEL, "op") ref = self.hexDims["op"] diff --git a/armi/reactor/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py index f41873be5..3449cccf8 100644 --- a/armi/reactor/tests/test_reactors.py +++ b/armi/reactor/tests/test_reactors.py @@ -37,6 +37,7 @@ from armi.reactor.converters import geometryConverters from armi.tests import ARMI_RUN_PATH, mockRunLogs, TEST_ROOT from armi.utils import directoryChangers +from armi.reactor.converters.axialExpansionChanger import AxialExpansionChanger TEST_REACTOR = None # pickled string of test reactor (for fast caching) @@ -746,7 +747,8 @@ def test_createAssemblyOfType(self): # creation with modified enrichment on an expanded BOL assem. fuelComp = fuelBlock.getComponent(Flags.FUEL) bol = self.r.blueprints.assemblies[aOld.getType()] - bol.axiallyExpand(0.05, fuelComp.getNuclides()) + changer = AxialExpansionChanger(converterSettings={}) + changer.prescribedAxialExpansion(bol, [fuelComp], [0.05]) aNew3 = self.r.core.createAssemblyOfType(aOld.getType(), 0.195) self.assertAlmostEqual( aNew3.getFirstBlock(Flags.FUEL).getUraniumMassEnrich(), 0.195