diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index 46524a54c..fcc75f3c3 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -1623,7 +1623,7 @@ class during axial expansion. 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/blueprints/__init__.py b/armi/reactor/blueprints/__init__.py index e600c4f75..59a575746 100644 --- a/armi/reactor/blueprints/__init__.py +++ b/armi/reactor/blueprints/__init__.py @@ -92,7 +92,10 @@ 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 from armi.reactor.flags import Flags from armi.settings.fwSettings.globalSettings import ( CONF_DETAILED_AXIAL_EXPANSION, @@ -151,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( @@ -307,7 +310,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] ) @@ -326,7 +329,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..400e8010f --- /dev/null +++ b/armi/reactor/converters/axialExpansion/__init__.py @@ -0,0 +1,56 @@ +# 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..8dd73bf8e --- /dev/null +++ b/armi/reactor/converters/axialExpansion/assemblyAxialLinkage.py @@ -0,0 +1,220 @@ +# 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.flags import Flags +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 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. + + 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 self._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 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 + 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, + ) + + @staticmethod + 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 new file mode 100644 index 000000000..bf165845b --- /dev/null +++ b/armi/reactor/converters/axialExpansion/axialExpansionChanger.py @@ -0,0 +1,395 @@ +# 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. + +"""Enable component-wise axial expansion for assemblies and/or a reactor.""" + +from armi import runLog +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 + + +def expandColdDimsToHot( + assems: list, + isDetailedAxialExpansion: bool, + referenceAssembly=None, +): + """ + 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 `] + list of assemblies to be thermally expanded + isDetailedAxialExpansion: bool + If False, assemblies will be forced to conform to the reference mesh after expansion + referenceAssembly: :py:class:`Assembly `, optional + Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False. + If not provided, will assume the finest mesh assembly which is typically fuel. + + Notes + ----- + Calling this method will result in an increase in mass via applyColdHeightMassIncrease! + + See Also + -------- + :py:meth:`armi.reactor.converters.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease` + """ + assems = list(assems) + if not referenceAssembly: + referenceAssembly = _getDefaultReferenceAssem(assems) + axialExpChanger = AxialExpansionChanger(isDetailedAxialExpansion) + for a in assems: + axialExpChanger.setAssembly(a, expandFromTinputToThot=True) + axialExpChanger.applyColdHeightMassIncrease() + axialExpChanger.expansionData.computeThermalExpansionFactors() + axialExpChanger.axiallyExpandAssembly() + if not isDetailedAxialExpansion: + for a in assems: + a.setBlockMesh(referenceAssembly.getAxialMesh()) + # update block BOL heights to reflect hot heights + for a in assems: + for b in a: + b.p.heightBOL = b.getHeight() + b.completeInitialLoading() + + +class AxialExpansionChanger: + """ + Axially expand or contract assemblies or an entire core. + + Attributes + ---------- + linked : :py:class:`AssemblyAxialLinkage` + establishes object containing axial linkage information + expansionData : :py:class:`ExpansionData ` + establishes object to store and access relevant expansion data + + Notes + ----- + - Is designed to work with general, vertically oriented, pin-type assembly designs. It is not set up to account + for any other assembly type. + - Useful for fuel performance, thermal expansion, reactivity coefficients, etc. + """ + + def __init__(self, detailedAxialExpansion: bool = False): + """ + Build an axial expansion converter. + + Parameters + ---------- + detailedAxialExpansion : bool, optional + A boolean to indicate whether or not detailedAxialExpansion is to be utilized. + """ + self._detailedAxialExpansion = detailedAxialExpansion + self.linked = None + self.expansionData = None + + def performPrescribedAxialExpansion( + self, a, componentLst: list, percents: list, setFuel=True + ): + """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 + ---------- + a : :py:class:`Assembly ` + ARMI assembly to be changed + componentLst : list[:py:class:`Component `] + list of Components to be expanded + percents : list[float] + list of expansion percentages for each component listed in componentList + setFuel : boolean, optional + Boolean to determine whether or not fuel blocks should have their target components set + This is useful when target components within a fuel block need to be determined on-the-fly. + + Notes + ----- + - percents may be positive (expansion) or negative (contraction) + """ + self.setAssembly(a, setFuel) + self.expansionData.setExpansionFactors(componentLst, percents) + self.axiallyExpandAssembly() + + def performThermalAxialExpansion( + self, + a, + tempGrid: list, + tempField: list, + setFuel: bool = True, + expandFromTinputToThot: bool = False, + ): + """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 + ---------- + a : :py:class:`Assembly ` + ARMI assembly to be changed + tempGrid : float, list + Axial temperature grid (in cm) (i.e., physical locations where temp is stored) + tempField : float, list + Temperature values (in C) along grid + setFuel : boolean, optional + Boolean to determine whether or not fuel blocks should have their target components set + This is useful when target components within a fuel block need to be determined on-the-fly. + 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.setAssembly(a, setFuel, expandFromTinputToThot) + self.expansionData.updateComponentTempsBy1DTempField(tempGrid, tempField) + self.expansionData.computeThermalExpansionFactors() + self.axiallyExpandAssembly() + + def reset(self): + self.linked = None + self.expansionData = None + + def setAssembly(self, a, setFuel=True, expandFromTinputToThot=False): + """Set the armi assembly to be changed and init expansion data class for assembly. + + Parameters + ---------- + a : :py:class:`Assembly ` + ARMI assembly to be changed + setFuel : boolean, optional + Boolean to determine whether or not fuel blocks should have their target components set + This is useful when target components within a fuel block need to be determined on-the-fly. + 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) + + Notes + ----- + When considering thermal expansion, if there is an axial temperature distribution on the assembly, + the axial expansion methodology will NOT perfectly preseve mass. The magnitude of the gradient of + the temperature distribution is the primary factor in determining the cumulative loss of mass conservation. + Additional details will be documented in :ref:`axialExpansion` of the documentation. + """ + self.linked = AssemblyAxialLinkage(a) + self.expansionData = ExpansionData( + a, setFuel=setFuel, expandFromTinputToThot=expandFromTinputToThot + ) + self._isTopDummyBlockPresent() + + def applyColdHeightMassIncrease(self): + """ + Increase component mass because they are declared at cold dims. + + Notes + ----- + A cold 1 cm tall component will have more mass that a component with the + same mass/length as a component with a hot height of 1 cm. This should be + called when the setting `inputHeightsConsideredHot` is used. This adjusts + the expansion factor applied during applyMaterialMassFracsToNumberDensities. + """ + for c in self.linked.a.getComponents(): + axialExpansionFactor = 1.0 + c.material.linearExpansionFactor( + c.temperatureInC, c.inputTemperatureInC + ) + c.changeNDensByFactor(axialExpansionFactor) + + 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( + f"No dummy block present at the top of {self.linked.a}! " + "Top most block will be artificially chopped " + "to preserve assembly height" + ) + if self._detailedAxialExpansion: + msg = "Cannot run detailedAxialExpansion without a dummy block at the top of the assembly!" + runLog.error(msg) + raise RuntimeError(msg) + + def axiallyExpandAssembly(self): + """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( + "Printing component expansion information (growth percentage and 'target component')" + f"for each block in assembly {self.linked.a}." + ) + for ib, b in enumerate(self.linked.a): + runLog.debug(msg=f" Block {b}") + blockHeight = b.getHeight() + # 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 + isDummyBlock = ib == (numOfBlocks - 1) + if not isDummyBlock: + for c in getSolidComponents(b): + growFrac = self.expansionData.getExpansionFactor(c) + runLog.debug(msg=f" Component {c}, growFrac = {growFrac:.4e}") + c.height = 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 + # update component number densities + newNumberDensities = { + nuc: c.getNumberDensity(nuc) / growFrac + for nuc in c.getNuclides() + } + c.setNumberDensities(newNumberDensities) + # redistribute block boundaries if on the target component + if self.expansionData.isTargetComponent(c): + b.p.ztop = c.ztop + b.p.height = b.p.ztop - b.p.zbottom + else: + b.p.height = b.p.ztop - b.p.zbottom + + b.p.z = b.p.zbottom + b.getHeight() / 2.0 + + 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() + # redo mesh -- functionality based on assembly.calculateZCoords() + 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 manageCoreMesh(self, r): + """Manage core mesh post assembly-level expansion. + + Parameters + ---------- + r : :py:class:`Reactor ` + 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() + - oldMesh will be None during initial core construction at processLoading as it has not yet + been set. + """ + if not self._detailedAxialExpansion: + # loop through again now that the reference is adjusted and adjust the non-fuel assemblies. + for a in r.core.getAssemblies(): + a.setBlockMesh(r.core.refAssem.getAxialMesh()) + + oldMesh = r.core.p.axialMesh + r.core.updateAxialMesh() + if oldMesh: + runLog.extra("Updated r.core.p.axialMesh (old, new)") + 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. + + 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() + ) + ) diff --git a/armi/reactor/converters/axialExpansion/expansionData.py b/armi/reactor/converters/axialExpansion/expansionData.py new file mode 100644 index 000000000..d4b1f38c3 --- /dev/null +++ b/armi/reactor/converters/axialExpansion/expansionData.py @@ -0,0 +1,289 @@ +# 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..0e8043c35 --- /dev/null +++ b/armi/reactor/converters/axialExpansion/tests/__init__.py @@ -0,0 +1,83 @@ +# 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 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..df7d754fc --- /dev/null +++ b/armi/reactor/converters/axialExpansion/tests/buildAxialExpAssembly.py @@ -0,0 +1,142 @@ +# 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(materialName: str, hot: bool = False): + """Create test assembly. + + Parameters + ---------- + materialName: string + determines which material to use + hot: boolean + determines if assembly should be at hot temperatures + """ + 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", 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, 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 + materialName : 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, materialName, **fuelDims) + clad = Circle("clad", materialName, **cladDims) + duct = Hexagon("duct", materialName, **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..8bfdd8a7c --- /dev/null +++ b/armi/reactor/converters/axialExpansion/tests/test_assemblyAxialLinkage.py @@ -0,0 +1,279 @@ +# 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 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. + + 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.""" + self.common = ("test", "HT9", 25.0, 25.0) # name, material, Tinput, Thot + + 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 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(), + ) diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py similarity index 51% rename from armi/reactor/converters/tests/test_axialExpansionChanger.py rename to armi/reactor/converters/axialExpansion/tests/test_axialExpansionChanger.py index 49a2d7cfd..2594a1223 100644 --- a/armi/reactor/converters/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. @@ -13,92 +13,31 @@ # 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.converters.axialExpansionChanger import ( +from armi.reactor.converters.axialExpansion import getSolidComponents +from armi.reactor.converters.axialExpansion.axialExpansionChanger import ( AxialExpansionChanger, - ExpansionData, - _determineLinked, - getSolidComponents, +) +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.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, @@ -153,12 +92,12 @@ 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): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssemblyWithFakeMaterial(name="FakeMat") + self.a = buildTestAssembly(materialName="FakeMat") self.temp = Temperature( self.a.getTotalHeight(), numTempGridPts=11, tempSteps=10 @@ -190,7 +129,7 @@ def test_AssemblyAxialExpansionHeight(self): def _generateComponentWiseExpectedHeight(self): """Calculate the expected height, external of AssemblyAxialExpansion().""" - assem = buildTestAssemblyWithFakeMaterial(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)) @@ -227,12 +166,12 @@ 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): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssemblyWithFakeMaterial(name="FakeMat") + self.a = buildTestAssembly(materialName="FakeMat") def tearDown(self): AxialExpansionTestBase.tearDown(self) @@ -249,7 +188,7 @@ def expandAssemForMassConservationTest(self): ) self._getConservationMetrics(self.a) - def test_thermalExpansionContractionConservation_simple(self): + def test_ThermalExpansionContractionConservation_Simple(self): """Thermally expand and then contract to ensure original state is recovered. .. test:: Thermally expand and then contract to ensure original assembly is recovered. @@ -261,7 +200,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(materialName="HT9") origMesh = a.getAxialMesh()[:-1] origMasses, origNDens = self._getComponentMassAndNDens(a) axialExpChngr = AxialExpansionChanger(detailedAxialExpansion=True) @@ -289,8 +228,8 @@ def test_thermalExpansionContractionConservation_simple(self): self._checkMass(origMasses, newMasses) self._checkNDens(origNDens, newNDens, 1.0) - def test_thermalExpansionContractionConservation_complex(self): - """Thermally expand and then contract to ensure original state is recovered. + def test_ThermalExpansionContractionConservation_Complex(self): + r"""Thermally expand and then contract to ensure original state is recovered. Notes ----- @@ -355,7 +294,7 @@ def _getMass(a): newMass = a.getMass("B10") return newMass - def test_prescribedExpansionContractionConservation(self): + 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. @@ -367,7 +306,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(materialName="FakeMat") axExpChngr = AxialExpansionChanger() origMesh = a.getAxialMesh() origMasses, origNDens = self._getComponentMassAndNDens(a) @@ -420,7 +359,7 @@ def _getComponentMassAndNDens(a): nDens[c] = c.getNumberDensities() return masses, nDens - def test_targetComponentMassConservation(self): + def test_TargetComponentMassConservation(self): """Tests mass conservation for target components.""" self.expandAssemForMassConservationTest() for cName, masses in self.componentMass.items(): @@ -442,8 +381,8 @@ def test_targetComponentMassConservation(self): msg="Total assembly steel mass is not conserved.", ) - def test_noMovementACLP(self): - """Ensures the above core load pad (ACLP) does not move during fuel-only expansion. + def test_NoMovementACLP(self): + """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 @@ -453,15 +392,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() @@ -550,12 +489,12 @@ def test_manageCoreMesh(self): self.assertLess(old, new) -class TestExceptions(AxialExpansionTestBase, unittest.TestCase): +class TestExceptions(AxialExpansionTestBase): """Verify exceptions are caught.""" def setUp(self): AxialExpansionTestBase.setUp(self) - self.a = buildTestAssemblyWithFakeMaterial(name="FakeMatException") + self.a = buildTestAssembly(materialName="FakeMatException") self.obj.setAssembly(self.a) def tearDown(self): @@ -566,7 +505,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 @@ -576,48 +515,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) - 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 @@ -634,194 +531,12 @@ 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) - - 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)) - - 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 TestDetermineTargetComponent(AxialExpansionTestBase, unittest.TestCase): - """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). - - .. 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", "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.""" def setUp(self): - self.a = buildTestAssemblyWithFakeMaterial(name="HT9") + self.a = buildTestAssembly(materialName="HT9") def test_getSolidComponents(self): for b in self.a: @@ -940,293 +655,3 @@ def checkColdBlockHeight(bStd, bExp, assertType, strForAssertion): strForAssertion, ), ) - - -class TestLinkage(AxialExpansionTestBase, unittest.TestCase): - """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( - _determineLinked(typeA, typeB), - msg="Test {0:s} failed for component type {1:s}!".format( - name, str(method) - ), - ) - self.assertTrue( - _determineLinked(typeB, typeA), - msg="Test {0:s} failed for component type {1:s}!".format( - name, str(method) - ), - ) - else: - self.assertFalse( - _determineLinked(typeA, typeB), - msg="Test {0:s} failed for component type {1:s}!".format( - name, str(method) - ), - ) - self.assertFalse( - _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(_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..6775a4884 --- /dev/null +++ b/armi/reactor/converters/axialExpansion/tests/test_expansionData.py @@ -0,0 +1,336 @@ +# 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 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.tests import AxialExpansionTestBase +from armi.reactor.converters.axialExpansion.expansionData import ( + ExpansionData, + getSolidComponents, +) +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_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) + 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 TestComputeThermalExpansionFactors(AxialExpansionTestBase): + 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) + 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): + 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 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.""" + + 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). + + .. 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) + 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)) diff --git a/armi/reactor/converters/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger.py deleted file mode 100644 index a9eda0989..000000000 --- a/armi/reactor/converters/axialExpansionChanger.py +++ /dev/null @@ -1,909 +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. -"""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 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, - isDetailedAxialExpansion: bool, - referenceAssembly=None, -): - """ - 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 `] - list of assemblies to be thermally expanded - isDetailedAxialExpansion: bool - If False, assemblies will be forced to conform to the reference mesh after expansion - referenceAssembly: :py:class:`Assembly `, optional - Assembly whose mesh other meshes will conform to if isDetailedAxialExpansion is False. - If not provided, will assume the finest mesh assembly which is typically fuel. - - Notes - ----- - Calling this method will result in an increase in mass via applyColdHeightMassIncrease! - - See Also - -------- - :py:meth:`armi.reactor.converters.axialExpansionChanger.AxialExpansionChanger.applyColdHeightMassIncrease` - """ - assems = list(assems) - if not referenceAssembly: - referenceAssembly = getDefaultReferenceAssem(assems) - axialExpChanger = AxialExpansionChanger(isDetailedAxialExpansion) - for a in assems: - axialExpChanger.setAssembly(a, expandFromTinputToThot=True) - axialExpChanger.applyColdHeightMassIncrease() - axialExpChanger.expansionData.computeThermalExpansionFactors() - axialExpChanger.axiallyExpandAssembly() - if not isDetailedAxialExpansion: - for a in assems: - a.setBlockMesh(referenceAssembly.getAxialMesh()) - # update block BOL heights to reflect hot heights - for a in assems: - for b in a: - b.p.heightBOL = b.getHeight() - b.completeInitialLoading() - - -class AxialExpansionChanger: - """ - Axially expand or contract assemblies or an entire core. - - Attributes - ---------- - linked : :py:class:`AssemblyAxialLinkage` - establishes object containing axial linkage information - expansionData : :py:class:`ExpansionData ` - establishes object to store and access relevant expansion data - - Notes - ----- - - Is designed to work with general, vertically oriented, pin-type assembly designs. It is not set up to account - for any other assembly type. - - Useful for fuel performance, thermal expansion, reactivity coefficients, etc. - """ - - def __init__(self, detailedAxialExpansion: bool = False): - """ - Build an axial expansion converter. - - Parameters - ---------- - detailedAxialExpansion : bool, optional - A boolean to indicate whether or not detailedAxialExpansion is to be utilized. - """ - self._detailedAxialExpansion = detailedAxialExpansion - self.linked = None - self.expansionData = None - - def performPrescribedAxialExpansion( - self, a, componentLst: list, percents: list, setFuel=True - ): - """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 - ---------- - a : :py:class:`Assembly ` - ARMI assembly to be changed - componentLst : list[:py:class:`Component `] - list of Components to be expanded - percents : list[float] - list of expansion percentages for each component listed in componentList - setFuel : boolean, optional - Boolean to determine whether or not fuel blocks should have their target components set - This is useful when target components within a fuel block need to be determined on-the-fly. - - Notes - ----- - - percents may be positive (expansion) or negative (contraction) - """ - self.setAssembly(a, setFuel) - self.expansionData.setExpansionFactors(componentLst, percents) - self.axiallyExpandAssembly() - - def performThermalAxialExpansion( - self, - a, - tempGrid: list, - tempField: list, - setFuel: bool = True, - expandFromTinputToThot: bool = False, - ): - """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 - ---------- - a : :py:class:`Assembly ` - ARMI assembly to be changed - tempGrid : float, list - Axial temperature grid (in cm) (i.e., physical locations where temp is stored) - tempField : float, list - Temperature values (in C) along grid - setFuel : boolean, optional - Boolean to determine whether or not fuel blocks should have their target components set - This is useful when target components within a fuel block need to be determined on-the-fly. - 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.setAssembly(a, setFuel, expandFromTinputToThot) - self.expansionData.updateComponentTempsBy1DTempField(tempGrid, tempField) - self.expansionData.computeThermalExpansionFactors() - self.axiallyExpandAssembly() - - def reset(self): - self.linked = None - self.expansionData = None - - def setAssembly(self, a, setFuel=True, expandFromTinputToThot=False): - """Set the armi assembly to be changed and init expansion data class for assembly. - - Parameters - ---------- - a : :py:class:`Assembly ` - ARMI assembly to be changed - setFuel : boolean, optional - Boolean to determine whether or not fuel blocks should have their target components set - This is useful when target components within a fuel block need to be determined on-the-fly. - 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) - - Notes - ----- - When considering thermal expansion, if there is an axial temperature distribution on the - assembly, the axial expansion methodology will NOT perfectly preseve mass. The magnitude of - the gradient of the temperature distribution is the primary factor in determining the - cumulative loss of mass conservation. Additional details will be documented in - :ref:`axialExpansion` of the documentation. - """ - self.linked = AssemblyAxialLinkage(a) - self.expansionData = ExpansionData( - a, setFuel=setFuel, expandFromTinputToThot=expandFromTinputToThot - ) - self._isTopDummyBlockPresent() - - def applyColdHeightMassIncrease(self): - """ - Increase component mass because they are declared at cold dims. - - Notes - ----- - A cold 1 cm tall component will have more mass that a component with the - same mass/length as a component with a hot height of 1 cm. This should be - called when the setting `inputHeightsConsideredHot` is used. This adjusts - the expansion factor applied during applyMaterialMassFracsToNumberDensities. - """ - for c in self.linked.a.getComponents(): - axialExpansionFactor = 1.0 + c.material.linearExpansionFactor( - c.temperatureInC, c.inputTemperatureInC - ) - c.changeNDensByFactor(axialExpansionFactor) - - 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( - f"No dummy block present at the top of {self.linked.a}! " - "Top most block will be artificially chopped " - "to preserve assembly height" - ) - if self._detailedAxialExpansion: - msg = "Cannot run detailedAxialExpansion without a dummy block at the top of the assembly!" - runLog.error(msg) - raise RuntimeError(msg) - - def axiallyExpandAssembly(self): - """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( - "Printing component expansion information (growth percentage and 'target component')" - f"for each block in assembly {self.linked.a}." - ) - for ib, b in enumerate(self.linked.a): - runLog.debug(msg=f" Block {b}") - blockHeight = b.getHeight() - # 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 - isDummyBlock = ib == (numOfBlocks - 1) - if not isDummyBlock: - for c in getSolidComponents(b): - growFrac = self.expansionData.getExpansionFactor(c) - runLog.debug(msg=f" Component {c}, growFrac = {growFrac:.4e}") - c.height = 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 - # update component number densities - newNumberDensities = { - nuc: c.getNumberDensity(nuc) / growFrac - for nuc in c.getNuclides() - } - c.setNumberDensities(newNumberDensities) - # redistribute block boundaries if on the target component - if self.expansionData.isTargetComponent(c): - b.p.ztop = c.ztop - b.p.height = b.p.ztop - b.p.zbottom - else: - b.p.height = b.p.ztop - b.p.zbottom - - b.p.z = b.p.zbottom + b.getHeight() / 2.0 - - _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() - # redo mesh -- functionality based on assembly.calculateZCoords() - 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 manageCoreMesh(self, r): - """Manage core mesh post assembly-level expansion. - - Parameters - ---------- - r : :py:class:`Reactor ` - 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() - - oldMesh will be None during initial core construction at processLoading as it has not yet - been set. - """ - if not self._detailedAxialExpansion: - # loop through again now that the reference is adjusted and adjust the non-fuel assemblies. - for a in r.core.getAssemblies(): - a.setBlockMesh(r.core.refAssem.getAxialMesh()) - - oldMesh = r.core.p.axialMesh - r.core.updateAxialMesh() - if oldMesh: - runLog.extra("Updated r.core.p.axialMesh (old, new)") - for old, new in zip(oldMesh, r.core.p.axialMesh): - runLog.extra(f"{old:.6e}\t{new:.6e}") - - -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() - ) - ) - - 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() - ) - ) - - -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. - - 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 - - self._getLinkedBlocks() - """ - - 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 " - f"components found are {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 & 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 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/tests/test_reactors.py b/armi/reactor/tests/test_reactors.py index 2052a8b1b..06f0445b7 100644 --- a/armi/reactor/tests/test_reactors.py +++ b/armi/reactor/tests/test_reactors.py @@ -37,7 +37,9 @@ from armi.reactor.components import Hexagon, Rectangle from armi.reactor.composites import Composite 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