diff --git a/armi/reactor/converters/axialExpansionChanger/__init__.py b/armi/reactor/converters/axialExpansionChanger/__init__.py index 1eecb064e..9676af29d 100644 --- a/armi/reactor/converters/axialExpansionChanger/__init__.py +++ b/armi/reactor/converters/axialExpansionChanger/__init__.py @@ -26,4 +26,5 @@ from armi.reactor.converters.axialExpansionChanger.expansionData import ExpansionData from armi.reactor.converters.axialExpansionChanger.expansionData import ( getSolidComponents, + iterSolidComponents, ) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index c04b1281e..ae546b51e 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -12,16 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing +import dataclasses +import functools +import itertools + from armi import runLog -from armi.reactor.components import UnshapedComponent +from armi.reactor.blocks import Block +from armi.reactor.components import Component, UnshapedComponent from armi.reactor.converters.axialExpansionChanger.expansionData import ( - getSolidComponents, + iterSolidComponents, ) -def _determineLinked(componentA, componentB): +if typing.TYPE_CHECKING: + from armi.reactor.assemblies import Assembly + + +def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: """Determine axial component linkage for two components. + Components are considered linked if the following are found to be true: + + 1. Both contain solid materials. + 2. They have identical types (e.g., ``Circle``). + 3. Their multiplicities are the same. + 4. The biggest inner bounding diameter of the two is less than the smallest outer + bounding diameter of the two. + Parameters ---------- componentA : :py:class:`Component ` @@ -33,8 +51,6 @@ def _determineLinked(componentA, componentB): ----- - 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. @@ -46,7 +62,7 @@ def _determineLinked(componentA, componentB): """ if ( (componentA.containsSolidMaterial() and componentB.containsSolidMaterial()) - and isinstance(componentA, type(componentB)) + and type(componentA) is type(componentB) and (componentA.getDimension("mult") == componentB.getDimension("mult")) ): if isinstance(componentA, UnshapedComponent): @@ -57,118 +73,151 @@ def _determineLinked(componentA, componentB): "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), - ) + return False + # Check if one component could fit within the other + idA = componentA.getCircleInnerDiameter(cold=True) + odA = componentA.getBoundingCircleOuterDiameter(cold=True) + idB = componentB.getCircleInnerDiameter(cold=True) + odB = componentB.getBoundingCircleOuterDiameter(cold=True) + biggerID = max(idA, idB) + smallerOD = min(odA, odB) + return biggerID < smallerOD + return False + + +# Make a generic type so we can "template" the axial link class based on what could be above/below a thing +Comp = typing.TypeVar("Comp", Block, Component) - 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 +@dataclasses.dataclass +class AxialLink(typing.Generic[Comp]): + """Small class for named references to objects above and below a specific object. - return linked + Axial expansion in ARMI works by identifying what objects occupy the same axial space. + For components in blocks, identify which above and below axially align. This is used + to determine what, if any, mass needs to be re-assigned across blocks during expansion. + For blocks, the linking determines what blocks need to move as a result of a specific block's + axial expansion. + + Attributes + ---------- + lower : Composite or None + Object below, if any. + upper : Composite or None + Object above, if any. + + Notes + ----- + This class is "templated" by the type of composite that could be assigned and fetched. A + block-to-block linkage could be type-hinted via ``AxialLink[Block]`` or ``AxialLink[Component]`` + for component-to-component link. + + See Also + -------- + * :attr:`AxialAssemblyLinkage.linkedBlocks` + * :attr:`AxialAssemblyLinkage.linkedComponents` + """ + + lower: typing.Optional[Comp] = dataclasses.field(default=None) + upper: typing.Optional[Comp] = dataclasses.field(default=None) class AssemblyAxialLinkage: """Determines and stores the block- and component-wise axial linkage for an assembly. + Parameters + ---------- + assem : armi.reactor.assemblies.Assembly + Assembly to be linked + 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. + Keys are blocks in the assembly. Their values are :class:`AxialLink` with + ``upper`` and ``lower`` attributes for the blocks potentially above and + below this block. linkedComponents : dict - - keys = :py:class:`Component ` - - values = list of axially linked components; index 0 = lower linked component; - index 1: upper linked component. + Keys are solid components in the assembly. Their values are :class:`AxialLink` with + ``upper`` and ``lower`` attributes for the solid components potentially above and + below this block. """ - def __init__(self, StdAssem): - self.a = StdAssem - self.linkedBlocks = {} + linkedBlocks: dict[Block, AxialLink[Block]] + linkedComponents: dict[Component, AxialLink[Component]] + + def __init__(self, assem: "Assembly"): + self.a = assem + self.linkedBlocks = self.getLinkedBlocks(assem) self.linkedComponents = {} self._determineAxialLinkage() + @classmethod + def getLinkedBlocks( + cls, + blocks: typing.Sequence[Block], + ) -> dict[Block, AxialLink[Block]]: + """Produce a mapping showing how blocks are linked. + + Parameters + ---------- + blocks : sequence of armi.reactor.blocks.Block + Ordered sequence of blocks from bottom to top. Could just as easily be an + :class:`armi.reactor.assemblies.Assembly`. + + Returns + ------- + dict[Block, AxialLink[Block]] + Dictionary where keys are individual blocks and their corresponding values point + to blocks above and below. + """ + nBlocks = len(blocks) + if nBlocks: + return cls._getLinkedBlocks(blocks, nBlocks) + raise ValueError("No blocks passed. Cannot determine links") + + @staticmethod + def _getLinkedBlocks( + blocks: typing.Sequence[Block], nBlocks: int + ) -> dict[Block, AxialLink[Block]]: + # Use islice to avoid making intermediate lists of subsequences of blocks + lower = itertools.chain((None,), itertools.islice(blocks, 0, nBlocks - 1)) + upper = itertools.chain(itertools.islice(blocks, 1, None), (None,)) + links = {} + for low, mid, high in zip(lower, blocks, upper): + links[mid] = AxialLink(lower=low, upper=high) + return links + def _determineAxialLinkage(self): """Gets the block and component based linkage.""" for b in self.a: - self._getLinkedBlocks(b) - for c in getSolidComponents(b): + for c in iterSolidComponents(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 _findComponentLinkedTo( + self, c: Component, otherBlock: typing.Optional[Block] + ) -> typing.Optional[Component]: + if otherBlock is None: + return None + candidate = None + # Iterate over all solid components in the other block that are linked to this one + areLinked = functools.partial(self.areAxiallyLinked, c) + for otherComp in filter(areLinked, iterSolidComponents(otherBlock)): + if candidate is None: + candidate = otherComp + else: + errMsg = ( + "Multiple component axial linkages have been found for " + f"Component {c} in Block {c.parent} in Assembly {c.parent.parent}. " + "This is indicative of an error in the blueprints! Linked " + f"components found are {candidate} and {otherComp} in {otherBlock}" + ) + runLog.error(msg=errMsg) + raise RuntimeError(errMsg) + return candidate - def _getLinkedComponents(self, b, c): + def _getLinkedComponents(self, b: Block, c: Component): """Retrieve the axial linkage for component c. Parameters @@ -183,31 +232,42 @@ def _getLinkedComponents(self, b, c): 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 - + linkedBlocks = self.linkedBlocks[b] + lowerC = self._findComponentLinkedTo(c, linkedBlocks.lower) + upperC = self._findComponentLinkedTo(c, linkedBlocks.upper) + lstLinkedC = AxialLink(lowerC, upperC) self.linkedComponents[c] = lstLinkedC - if lstLinkedC[0] is None: + if self.linkedBlocks[b].lower is None and lstLinkedC.lower is None: runLog.debug( f"Assembly {self.a}, Block {b}, Component {c} has nothing linked below it!", single=True, ) - if lstLinkedC[1] is None: + if self.linkedBlocks[b].upper is None and lstLinkedC.upper is None: runLog.debug( f"Assembly {self.a}, Block {b}, Component {c} has nothing linked above it!", single=True, ) + + @staticmethod + def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: + """Check if two components are axially linked. + + Parameters + ---------- + componentA : :py:class:`Component ` + component of interest + componentB : :py:class:`Component ` + component to compare and see if is linked to componentA + + Returns + ------- + bool + Status of linkage check + + See Also + -------- + :func:`areAxiallyLinked` for more details, including the criteria for considering components linked. + This method is provided to allow subclasses the ability to override the linkage check. + """ + return areAxiallyLinked(componentA, componentB) diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py index d7369d258..86f881874 100644 --- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py @@ -12,16 +12,18 @@ # 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.""" +import typing from numpy import array from armi import runLog +from armi.reactor.assemblies import Assembly from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import ( AssemblyAxialLinkage, ) from armi.reactor.converters.axialExpansionChanger.expansionData import ( ExpansionData, - getSolidComponents, + iterSolidComponents, ) from armi.reactor.flags import Flags @@ -70,6 +72,9 @@ class AxialExpansionChanger: - Useful for fuel performance, thermal expansion, reactivity coefficients, etc. """ + linked: typing.Optional[AssemblyAxialLinkage] + expansionData: typing.Optional[ExpansionData] + def __init__(self, detailedAxialExpansion: bool = False): """ Build an axial expansion converter. @@ -149,7 +154,7 @@ def expandColdDimsToHot( b.completeInitialLoading() def performPrescribedAxialExpansion( - self, a, componentLst: list, percents: list, setFuel=True + self, a: Assembly, components: list, percents: list, setFuel=True ): """Perform axial expansion/contraction of an assembly given prescribed expansion percentages. @@ -168,10 +173,10 @@ def performPrescribedAxialExpansion( ---------- a : :py:class:`Assembly ` ARMI assembly to be changed - componentLst : list[:py:class:`Component `] + components : list[:py:class:`Component `] list of Components to be expanded percents : list[float] - list of expansion percentages for each component listed in componentList + list of expansion percentages for each component listed in components 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. @@ -181,12 +186,12 @@ def performPrescribedAxialExpansion( - percents may be positive (expansion) or negative (contraction) """ self.setAssembly(a, setFuel) - self.expansionData.setExpansionFactors(componentLst, percents) + self.expansionData.setExpansionFactors(components, percents) self.axiallyExpandAssembly() def performThermalAxialExpansion( self, - a, + a: Assembly, tempGrid: list, tempField: list, setFuel: bool = True, @@ -233,7 +238,7 @@ def reset(self): self.linked = None self.expansionData = None - def setAssembly(self, a, setFuel=True, expandFromTinputToThot=False): + def setAssembly(self, a: Assembly, setFuel=True, expandFromTinputToThot=False): """Set the armi assembly to be changed and init expansion data class for assembly. Parameters @@ -322,10 +327,10 @@ def axiallyExpandAssembly(self): # 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 + b.p.zbottom = self.linked.linkedBlocks[b].lower.p.ztop isDummyBlock = ib == (numOfBlocks - 1) if not isDummyBlock: - for c in getSolidComponents(b): + for c in iterSolidComponents(b): growFrac = self.expansionData.getExpansionFactor(c) runLog.debug(msg=f" Component {c}, growFrac = {growFrac:.4e}") c.height = growFrac * blockHeight @@ -333,14 +338,14 @@ def axiallyExpandAssembly(self): if ib == 0: c.zbottom = 0.0 else: - if self.linked.linkedComponents[c][0] is not None: + if self.linked.linkedComponents[c].lower is not None: # use linked components below - c.zbottom = self.linked.linkedComponents[c][0].ztop + c.zbottom = self.linked.linkedComponents[c].lower.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.zbottom = self.linked.linkedBlocks[b].lower.p.ztop c.ztop = c.zbottom + c.height # update component number densities newNumberDensities = { diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index bf8259d97..39d4dacaa 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -14,7 +14,7 @@ """Data container for axial expansion.""" from statistics import mean -from typing import List +from typing import TYPE_CHECKING, Optional, Iterable from armi import runLog from armi.materials import material @@ -28,8 +28,18 @@ Flags.SLUG, ] +if TYPE_CHECKING: + from armi.reactor.components import Component + from armi.reactor.blocks import Block + from armi.reactor.assemblies import Assembly -def getSolidComponents(b): + +def iterSolidComponents(b: "Block") -> Iterable["Component"]: + """Iterate over all solid components in the block.""" + return filter(lambda c: not isinstance(c.material, material.Fluid), b) + + +def getSolidComponents(b: "Block") -> list["Component"]: """ Return list of components in the block that have solid material. @@ -37,28 +47,46 @@ def getSolidComponents(b): ----- 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. + + See Also + -------- + :func:`iterSolidComponents` produces an iterable rather than a list and may be better + suited if you simply want to iterate over solids in a block. """ - return [c for c in b if not isinstance(c.material, material.Fluid)] + return list(iterSolidComponents(b)) class ExpansionData: - """Data container for axial expansion.""" + r"""Data container for axial expansion. + + The primary responsibility of this class is to determine the axial expansion factors + for each solid component in the assembly. Expansion factors can be computed from the component + temperatures in :meth:`computeThermalExpansionFactors` or provided directly to the class + via :meth:`setExpansionFactors`. + + This class relies on the concept of a "target" expansion component for each block. While + components will expand at different rates, the final height of the block must be determined. + The target component, determined by :meth:`determineTargetComponents`, will drive the total + height of the block post-expansion. + + 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 caculated from + - ``c.inputTemperatureInC`` to ``c.temperatureInC`` when ``True``, or + - some other reference temperature and ``c.temperatureInC`` when ``False`` + """ - 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) - """ + _expansionFactors: dict["Component", float] + componentReferenceTemperature: dict["Component", float] + + def __init__(self, a: "Assembly", setFuel: bool, expandFromTinputToThot: bool): self._a = a self.componentReferenceTemperature = {} self._expansionFactors = {} @@ -66,44 +94,37 @@ def __init__(self, a, setFuel: bool, expandFromTinputToThot: bool): self._setTargetComponents(setFuel) self.expandFromTinputToThot = expandFromTinputToThot - def setExpansionFactors(self, componentLst: List, expFrac: List): + def setExpansionFactors(self, components: list["Component"], expFrac: list[float]): """Sets user defined expansion fractions. Parameters ---------- - componentLst : List[:py:class:`Component `] + components : 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 + list of L1/L0 height changes that are to be applied to components Raises ------ RuntimeError - If componentLst and expFrac are different lengths + If components and expFrac are different lengths """ - if len(componentLst) != len(expFrac): + if len(components) != len(expFrac): runLog.error( "Number of components and expansion fractions must be the same!\n" - f" len(componentLst) = {len(componentLst)}\n" + f" len(components) = {len(components)}\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: + if exp <= 0.0: msg = ( - "A negative expansion fraction, L1/L0, is not physical. Expansion fractions " + f"Expansion factor {exp}, 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): + for c, p in zip(components, expFrac): self._expansionFactors[c] = p def updateComponentTempsBy1DTempField(self, tempGrid, tempField): @@ -152,7 +173,7 @@ def updateComponentTempsBy1DTempField(self, tempGrid, tempField): for c in b: self.updateComponentTemp(c, blockAveTemp) - def updateComponentTemp(self, c, temp: float): + def updateComponentTemp(self, c: "Component", temp: float): """Update component temperatures with a provided temperature. Parameters @@ -174,22 +195,30 @@ def updateComponentTemp(self, c, temp: float): 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): + self._setComponentThermalExpansionFactors(b) + + def _setComponentThermalExpansionFactors(self, b: "Block"): + """For each component in the block, set the thermal expansion factors.""" + for c in iterSolidComponents(b): + self._perComponentThermalExpansionFactors(c) + + def _perComponentThermalExpansionFactors(self, c: "Component"): + """Set the thermal expansion factors for a single component.""" + 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: "Component"): """Retrieves expansion factor for c. Parameters @@ -200,7 +229,7 @@ def getExpansionFactor(self, c): value = self._expansionFactors.get(c, 1.0) return value - def _setTargetComponents(self, setFuel): + def _setTargetComponents(self, setFuel: bool): """Sets target component for each block. Parameters @@ -211,21 +240,25 @@ def _setTargetComponents(self, setFuel): """ for b in self._a: if b.p.axialExpTargetComponent: - self._componentDeterminesBlockHeight[ - b.getComponentByName(b.p.axialExpTargetComponent) - ] = True + target = b.getComponentByName(b.p.axialExpTargetComponent) + self._setExpansionTarget(b, target) 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) + # Dummy blocks are intended to contain only fluid and do not need a target component + pass 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. + def determineTargetComponent( + self, b: "Block", flagOfInterest: Optional[Flags] = None + ) -> "Component": + """Determines the component who's expansion will determine block height. + + This information is also stored on the block at ``Block.p.axialExpTargetComponent`` for faster + retrieval later. Parameters ---------- @@ -234,6 +267,11 @@ def determineTargetComponent(self, b, flagOfInterest=None): flagOfInterest : :py:class:`Flags ` the flag of interest to identify the target component + Returns + ------- + Component + Component identified as target component, if found. + Notes ----- - if flagOfInterest is None, finds the component within b that contains flags that @@ -250,32 +288,37 @@ def determineTargetComponent(self, b, flagOfInterest=None): 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 != []: + candidates = [c for c in b.getChildren() if c.hasFlags(targetFlag)] + if candidates: 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] + if not candidates: + candidates = [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: + candidates = [c for c in b.getChildren() if c.hasFlags(flagOfInterest)] + if len(candidates) == 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: + candidates = solidMaterials + if len(candidates) == 0: raise RuntimeError(f"No target component found!\n Block {b}") - if len(componentWFlag) > 1: + if len(candidates) > 1: raise RuntimeError( "Cannot have more than one component within a block that has the target flag!" - f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {componentWFlag}" + f"Block {b}\nflagOfInterest {flagOfInterest}\nComponents {candidates}" ) - self._componentDeterminesBlockHeight[componentWFlag[0]] = True - b.p.axialExpTargetComponent = componentWFlag[0].name + target = candidates[0] + self._setExpansionTarget(b, target) + return target + + def _setExpansionTarget(self, b: "Block", target: "Component"): + self._componentDeterminesBlockHeight[target] = True + b.p.axialExpTargetComponent = target.name - def _isFuelLocked(self, b): + def _isFuelLocked(self, b: "Block"): """Physical/realistic implementation reserved for ARMI plugin. Parameters @@ -296,10 +339,9 @@ def _isFuelLocked(self, b): 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 + self._setExpansionTarget(b, c) - def isTargetComponent(self, c): + def isTargetComponent(self, c: "Component") -> bool: """Returns bool if c is a target component. Parameters diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index bf69ac64a..98cc6ea08 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -24,16 +24,19 @@ from armi.materials import _MATERIAL_NAMESPACE_ORDER, 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 import Component, DerivedShape, UnshapedComponent from armi.reactor.components.basicShapes import Circle, Hexagon, Rectangle from armi.reactor.components.complexShapes import Helix from armi.reactor.converters.axialExpansionChanger import ( AxialExpansionChanger, + AssemblyAxialLinkage, ExpansionData, getSolidComponents, + iterSolidComponents, ) from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import ( - _determineLinked, + areAxiallyLinked, + AxialLink, ) from armi.reactor.flags import Flags from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings @@ -87,7 +90,7 @@ def _getConservationMetrics(self, a): for b in a: # store block ztop self.blockZtop[b].append(b.p.ztop) - for c in getSolidComponents(b): + for c in iterSolidComponents(b): # store mass and density of component self.componentMass[c].append(c.getMass()) self.componentDensity[c].append( @@ -320,7 +323,7 @@ def complexConservationTest(self, a): for temp in tempAdjust: # adjust component temperatures by temp for b in a: - for c in getSolidComponents(b): + for c in iterSolidComponents(b): axialExpChngr.expansionData.updateComponentTemp( c, c.temperatureInC + temp ) @@ -374,7 +377,7 @@ def test_prescribedExpansionContractionConservation(self): axExpChngr = AxialExpansionChanger() origMesh = a.getAxialMesh() origMasses, origNDens = self._getComponentMassAndNDens(a) - componentLst = [c for b in a for c in getSolidComponents(b)] + componentLst = [c for b in a for c in iterSolidComponents(b)] expansionGrowthFrac = 1.01 contractionGrowthFrac = 1.0 / expansionGrowthFrac for i in range(0, 10): @@ -418,7 +421,7 @@ def _getComponentMassAndNDens(a): masses = {} nDens = {} for b in a: - for c in getSolidComponents(b): + for c in iterSolidComponents(b): masses[c] = c.getMass() nDens[c] = c.getNumberDensities() return masses, nDens @@ -697,7 +700,7 @@ def test_determineLinked(self): compDims = {"Tinput": 25.0, "Thot": 25.0} compA = UnshapedComponent("unshaped_1", "FakeMat", **compDims) compB = UnshapedComponent("unshaped_2", "FakeMat", **compDims) - self.assertFalse(_determineLinked(compA, compB)) + self.assertFalse(areAxiallyLinked(compA, compB)) def test_getLinkedComponents(self): """Test for multiple component axial linkage.""" @@ -731,18 +734,22 @@ def test_determineTargetComponent(self): b.add(fuel) b.add(clad) b.add(self.coolant) - # make sure that b.p.axialExpTargetComponent is empty initially + self._checkTarget(b, fuel) + + def _checkTarget(self, b: HexBlock, expected: Component): + """Call determineTargetMethod and compare what we get with expected.""" + # Value unset initially self.assertFalse(b.p.axialExpTargetComponent) - # call method, and check that target component is correct - self.expData.determineTargetComponent(b) + target = self.expData.determineTargetComponent(b) + self.assertIs(target, expected) self.assertTrue( - self.expData.isTargetComponent(fuel), - msg=f"determineTargetComponent failed to recognize intended component: {fuel}", + self.expData.isTargetComponent(target), + msg=f"determineTargetComponent failed to recognize intended component: {expected}", ) self.assertEqual( b.p.axialExpTargetComponent, - fuel.name, - msg=f"determineTargetComponent failed to recognize intended component: {fuel}", + expected.name, + msg=f"determineTargetComponent failed to recognize intended component: {expected}", ) def test_determineTargetComponentBlockWithMultipleFlags(self): @@ -756,12 +763,7 @@ def test_determineTargetComponentBlockWithMultipleFlags(self): 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}", - ) + self._checkTarget(b, fuel) def test_specifyTargetComponent_NotFound(self): """Ensure RuntimeError gets raised when no target component is found.""" @@ -786,11 +788,7 @@ def test_specifyTargetComponent_singleSolid(self): 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}", - ) + self._checkTarget(b, duct) def test_specifyTargetComponet_MultipleFound(self): """Ensure RuntimeError is hit when multiple target components are found. @@ -857,9 +855,18 @@ def setUp(self): self.a = buildTestAssemblyWithFakeMaterial(name="HT9") def test_getSolidComponents(self): + """Show that getSolidComponents produces a list of solids, and is consistent with iterSolidComponents.""" for b in self.a: - for c in getSolidComponents(b): + solids = getSolidComponents(b) + ids = set(map(id, solids)) + for c in iterSolidComponents(b): self.assertNotEqual(c.material.name, "Sodium") + self.assertIn(id(c), ids, msg=f"Found non-solid {c}") + ids.remove(id(c)) + self.assertFalse( + ids, + msg="Inconsistency between getSolidComponents and iterSolidComponents", + ) class TestInputHeightsConsideredHot(unittest.TestCase): @@ -928,7 +935,7 @@ def test_coldAssemblyExpansion(self): self.checkColdHeightBlockMass(bStd, bExp, Flags.CONTROL, "B10") if not aStd.hasFlags(Flags.TEST) and not hasCustomMaterial: - for cExp in getSolidComponents(bExp): + for cExp in iterSolidComponents(bExp): if cExp.zbottom == bExp.p.zbottom and cExp.ztop == bExp.p.ztop: matDens = cExp.material.density(Tc=cExp.temperatureInC) compDens = cExp.density() @@ -977,7 +984,7 @@ def checkColdBlockHeight(bStd, bExp, assertType, strForAssertion): ) -class TestLinkage(AxialExpansionTestBase, unittest.TestCase): +class TestComponentLinks(AxialExpansionTestBase, unittest.TestCase): """Test axial linkage between components.""" def setUp(self): @@ -1027,26 +1034,26 @@ def runTest( typeB = method(*common, **dims[1]) if assertionBool: self.assertTrue( - _determineLinked(typeA, typeB), + areAxiallyLinked(typeA, typeB), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), ) self.assertTrue( - _determineLinked(typeB, typeA), + areAxiallyLinked(typeB, typeA), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), ) else: self.assertFalse( - _determineLinked(typeA, typeB), + areAxiallyLinked(typeA, typeB), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), ) self.assertFalse( - _determineLinked(typeB, typeA), + areAxiallyLinked(typeB, typeA), msg="Test {0:s} failed for component type {1:s}!".format( name, str(method) ), @@ -1145,7 +1152,7 @@ def test_liquids(self): def test_unshapedComponentAndCircle(self): comp1 = Circle(*self.common, od=1.0, id=0.0) comp2 = UnshapedComponent(*self.common, area=1.0) - self.assertFalse(_determineLinked(comp1, comp2)) + self.assertFalse(areAxiallyLinked(comp1, comp2)) def buildTestAssemblyWithFakeMaterial(name: str, hot: bool = False): @@ -1266,3 +1273,93 @@ def linearExpansionPercent(self, Tk=None, Tc=None): """A fake linear expansion percent.""" Tc = units.getTc(Tc, Tk) return 0.08 * Tc + + +class TestAxialLinkHelper(unittest.TestCase): + """Tests for the AxialLink dataclass / namedtuple like class.""" + + @classmethod + def setUpClass(cls): + cls.LOWER_BLOCK = _buildDummySodium(20, 10) + cls.UPPER_BLOCK = _buildDummySodium(300, 50) + + def test_override(self): + """Test the upper and lower attributes can be set after construction.""" + empty = AxialLink() + self.assertIsNone(empty.lower) + self.assertIsNone(empty.upper) + empty.lower = self.LOWER_BLOCK + empty.upper = self.UPPER_BLOCK + self.assertIs(empty.lower, self.LOWER_BLOCK) + self.assertIs(empty.upper, self.UPPER_BLOCK) + + def test_construct(self): + """Test the upper and lower attributes can be set at construction.""" + link = AxialLink(self.LOWER_BLOCK, self.UPPER_BLOCK) + self.assertIs(link.lower, self.LOWER_BLOCK) + self.assertIs(link.upper, self.UPPER_BLOCK) + + +class TestBlockLink(unittest.TestCase): + """Test the ability to link blocks in an assembly.""" + + def test_singleBlock(self): + """Test an edge case where a single block exists.""" + b = _buildDummySodium(300, 50) + links = AssemblyAxialLinkage.getLinkedBlocks([b]) + self.assertEqual(len(links), 1) + self.assertIn(b, links) + linked = links.pop(b) + self.assertIsNone(linked.lower) + self.assertIsNone(linked.upper) + + def test_multiBlock(self): + """Test links with multiple blocks.""" + N_BLOCKS = 5 + blocks = [_buildDummySodium(300, 50) for _ in range(N_BLOCKS)] + links = AssemblyAxialLinkage.getLinkedBlocks(blocks) + first = blocks[0] + lowLink = links[first] + self.assertIsNone(lowLink.lower) + self.assertIs(lowLink.upper, blocks[1]) + for ix in range(1, N_BLOCKS - 1): + current = blocks[ix] + below = blocks[ix - 1] + above = blocks[ix + 1] + link = links[current] + self.assertIs(link.lower, below) + self.assertIs(link.upper, above) + top = blocks[-1] + lastLink = links[top] + self.assertIsNone(lastLink.upper) + self.assertIs(lastLink.lower, blocks[-2]) + + def test_emptyBlocks(self): + """Test even smaller edge case when no blocks are passed.""" + with self.assertRaisesRegex( + ValueError, "No blocks passed. Cannot determine links" + ): + AssemblyAxialLinkage.getLinkedBlocks([]) + + def test_onAssembly(self): + """Test assembly behavior is the same as sequence of blocks.""" + assembly = HexAssembly("test") + N_BLOCKS = 5 + assembly.spatialGrid = grids.AxialGrid.fromNCells(numCells=N_BLOCKS) + assembly.spatialGrid.armiObject = assembly + + blocks = [] + for _ in range(N_BLOCKS): + b = _buildDummySodium(300, 10) + assembly.add(b) + blocks.append(b) + + fromBlocks = AssemblyAxialLinkage.getLinkedBlocks(blocks) + fromAssem = AssemblyAxialLinkage.getLinkedBlocks(assembly) + + self.assertSetEqual(set(fromBlocks), set(fromAssem)) + + for b, bLink in fromBlocks.items(): + aLink = fromAssem[b] + self.assertIs(aLink.lower, bLink.lower) + self.assertIs(aLink.upper, bLink.upper) diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst index c5d523384..5c9131c37 100644 --- a/doc/release/0.4.rst +++ b/doc/release/0.4.rst @@ -60,6 +60,7 @@ Quality Work ------------ #. Removing deprecated code ``axialUnitGrid``. (`PR#1809 `_) #. Refactoring ``axialExpansionChanger``. (`PR#1861 `_) +#. Changes to make axial expansion related classes more extensible. (`PR#1920 `_) #. Raise a ValueError when database load fails. (`PR#1940 `_) #. TBD