From 56b81fbf2d39139163aea417ae5de57d60e652c4 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 23 Sep 2024 16:44:39 -0700 Subject: [PATCH 01/33] Flatten and rename determine axial linkage function Use early `return` statements to dedent the function. Renamed to `areAxiallyLinked` to be a bit more declarative, even though this function lives in the axial expansion routine. Add some type hints --- .../assemblyAxialLinkage.py | 56 +++++++++---------- .../tests/test_axialExpansionChanger.py | 14 ++--- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index c04b1281e..4de6db5cd 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -13,15 +13,24 @@ # limitations under the License. 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, ) -def _determineLinked(componentA, componentB): +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 compatible types (e.g., ``Circle`` and ``Circle``). + 3. Their multiplicities are the same. + 4. The smallest inner bounding diameter of the two is less than the largest outer + bounding diameter of the two. + Parameters ---------- componentA : :py:class:`Component ` @@ -33,8 +42,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. @@ -57,29 +64,18 @@ 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), - ) - - 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 + 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) + if biggerID >= smallerOD: + return False + return True + return False class AssemblyAxialLinkage: @@ -112,7 +108,7 @@ def _determineAxialLinkage(self): for c in getSolidComponents(b): self._getLinkedComponents(b, c) - def _getLinkedBlocks(self, b): + def _getLinkedBlocks(self, b: Block): """Retrieve the axial linkage for block b. Parameters @@ -168,7 +164,7 @@ def _getLinkedBlocks(self, b): single=True, ) - def _getLinkedComponents(self, b, c): + def _getLinkedComponents(self, b: Block, c: Component): """Retrieve the axial linkage for component c. Parameters @@ -187,7 +183,7 @@ def _getLinkedComponents(self, b, c): for ib, linkdBlk in enumerate(self.linkedBlocks[b]): if linkdBlk is not None: for otherC in getSolidComponents(linkdBlk.getChildren()): - if _determineLinked(c, otherC): + if areAxiallyLinked(c, otherC): if lstLinkedC[ib] is not None: errMsg = ( "Multiple component axial linkages have been found for " diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index bf69ac64a..62f4f74de 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -33,7 +33,7 @@ getSolidComponents, ) from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import ( - _determineLinked, + areAxiallyLinked, ) from armi.reactor.flags import Flags from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings @@ -697,7 +697,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.""" @@ -1027,26 +1027,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 +1145,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): From 6316e6795b4894279ca625c3178b38637b227ed8 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 23 Sep 2024 16:58:50 -0700 Subject: [PATCH 02/33] Provide axialAssemblyLinkage.AxialLink for improved readability The values in the linkage dictionaries on `AssemblyAxialLinkage` were lists of two items where the position meant something: `0` for linked item below the current thing, and `1` for linked item above the current thing. Some functionality was based on `links[1] is not None` which is not super readable unless you're familiar with what's going on. This introduces a small data class with two attributes: `lower` and `upper` such that `links.lower is links[0]` and `links.upper is links[1]`. This improves readability and extensibility by making a more explicit and declarative thing. The position-based getters and setters are retained and tested. The class is "templated" with a `typing.Generic[typing.TypeVar]` that could be either `Block` or `Component`. This is because the the two linked dictionaries on the `AssemblyAxialLinkage` class only differ based on the types of their values. We still need to know lower/upper as determined by the values, but for the block link dict we want blocks and then components from the component link dict. --- .../assemblyAxialLinkage.py | 111 ++++++++++++++++-- .../tests/test_axialExpansionChanger.py | 44 +++++++ 2 files changed, 143 insertions(+), 12 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 4de6db5cd..dae119224 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -12,6 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +import typing +import dataclasses + from armi import runLog from armi.reactor.blocks import Block from armi.reactor.components import Component, UnshapedComponent @@ -78,25 +81,110 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: 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) + + +@dataclasses.dataclass +class AxialLink(typing.Generic[Comp]): + """Small class for named references to objects above and below a specific object. + + Axial expansion in ARMI works by identifying what objects occupy the same space axially. + For components, what components in the blocks 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) + + def __getitem__(self, index: int) -> typing.Optional[Comp]: + """Get by position. + + Discouraged since ``linkage.lower`` is more explicit and readable than ``linkage[0]``. + + Parameters + ---------- + index : int + ``0`` for :attr:`lower`, ``1`` for :attr:`upper` + + Raises + ------ + AttributeError + If ``index`` is not ``0`` nor ``1`` + """ + if index == 0: + return self.lower + if index == 1: + return self.upper + raise AttributeError(f"{index=}") + + def __setitem__(self, index: int, o: Comp): + """Set by position. + + Discouraged since ``linkage.upper = x`` is more explicit and readable than ``linkage[1] = x``. + """ + if index == 0: + self.lower = o + elif index == 1: + self.upper = o + else: + raise AttributeError(f"{index=}") + + def __iter__(self): + return iter([self.lower, self.upper]) + + +if typing.TYPE_CHECKING: + from armi.reactor.assemblies import Assembly + + 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 + linkedBlocks: typing.Dict[Block, AxialLink[Block]] + linkedComponents: typing.Dict[Component, AxialLink[Component]] + + def __init__(self, assem: "Assembly"): + self.a = assem self.linkedBlocks = {} self.linkedComponents = {} self._determineAxialLinkage() @@ -133,15 +221,14 @@ def _getLinkedBlocks(self, b: Block): """ lowerLinkedBlock = None upperLinkedBlock = None - block_list = self.a.getChildren() - for otherBlk in block_list: + for otherBlk in self.a: 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] + self.linkedBlocks[b] = AxialLink(lowerLinkedBlock, upperLinkedBlock) if lowerLinkedBlock is None: runLog.debug( @@ -179,7 +266,7 @@ def _getLinkedComponents(self, b: Block, c: Component): RuntimeError multiple candidate components are found to be axially linked to a component """ - lstLinkedC = [None, None] + lstLinkedC = AxialLink(None, None) for ib, linkdBlk in enumerate(self.linkedBlocks[b]): if linkdBlk is not None: for otherC in getSolidComponents(linkdBlk.getChildren()): diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index 62f4f74de..fd775af7f 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -34,6 +34,7 @@ ) from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import ( areAxiallyLinked, + AxialLink, ) from armi.reactor.flags import Flags from armi.reactor.tests.test_reactors import loadTestReactor, reduceTestReactorRings @@ -1266,3 +1267,46 @@ 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 setUp(self): + self.link = AxialLink(self.LOWER_BLOCK, self.UPPER_BLOCK) + + def test_positionGet(self): + """Test the getitem accessor.""" + self.assertIs(self.link[0], self.link.lower) + self.assertIs(self.link[1], self.link.upper) + with self.assertRaises(AttributeError): + self.link[3] + + def test_positionSet(self): + """Test the setitem setter.""" + self.link[0] = None + self.assertIsNone(self.link.lower) + self.link[0] = self.LOWER_BLOCK + self.assertIs(self.link.lower, self.LOWER_BLOCK) + self.link[1] = None + self.assertIsNone(self.link.upper) + self.link[1] = self.UPPER_BLOCK + self.assertIs(self.link.upper, self.UPPER_BLOCK) + with self.assertRaises(AttributeError): + self.link[-1] = None + + def test_iteration(self): + """Test the ability to iterate over the items in the link.""" + genItems = iter(self.link) + first = next(genItems) + self.assertIs(first, self.link.lower) + second = next(genItems) + self.assertIs(second, self.link.upper) + # No items left + with self.assertRaises(StopIteration): + next(genItems) From 727d04d8daf8c193f7985f6c4cd1062e9d3e9b5a Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 24 Sep 2024 09:16:49 -0700 Subject: [PATCH 03/33] Only warn about missing linked components if no linked block --- .../converters/axialExpansionChanger/assemblyAxialLinkage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index dae119224..6bba39ae7 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -284,12 +284,12 @@ def _getLinkedComponents(self, b: Block, c: Component): 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, From 4b9f67d4eb9a88e1a824c7ddf797893301721cd1 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 09:48:46 -0700 Subject: [PATCH 04/33] Streamline check for component axial linkage a bit more --- .../converters/axialExpansionChanger/assemblyAxialLinkage.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 6bba39ae7..3e1028df0 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -75,9 +75,7 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: odB = componentB.getBoundingCircleOuterDiameter(cold=True) biggerID = max(idA, idB) smallerOD = min(odA, odB) - if biggerID >= smallerOD: - return False - return True + return biggerID < smallerOD return False From 9e0550d74753c68c0ecf6e9e6b55d77311756859 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 10:15:43 -0700 Subject: [PATCH 05/33] Add type hints for ExpansionData methods Also rename componentLst to components in setExpansionFactors in an effort to remove type names from variable names --- .../axialExpansionChanger/expansionData.py | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index bf8259d97..29f099b31 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 from armi import runLog from armi.materials import material @@ -28,8 +28,13 @@ 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 getSolidComponents(b: "Block") -> list["Component"]: """ Return list of components in the block that have solid material. @@ -44,7 +49,7 @@ def getSolidComponents(b): class ExpansionData: """Data container for axial expansion.""" - def __init__(self, a, setFuel: bool, expandFromTinputToThot: bool): + def __init__(self, a: "Assembly", setFuel: bool, expandFromTinputToThot: bool): """ Parameters ---------- @@ -66,7 +71,9 @@ 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 @@ -81,10 +88,10 @@ def setExpansionFactors(self, componentLst: List, expFrac: List): RuntimeError If componentLst 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 @@ -103,7 +110,7 @@ def setExpansionFactors(self, componentLst: List, expFrac: List): ) 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 +159,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 @@ -189,7 +196,7 @@ def computeThermalExpansionFactors(self): # we'll assume that the expansion factor is 1.0. self._expansionFactors[c] = 1.0 - def getExpansionFactor(self, c): + def getExpansionFactor(self, c: "Component"): """Retrieves expansion factor for c. Parameters @@ -200,7 +207,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 @@ -223,7 +230,9 @@ def _setTargetComponents(self, setFuel): else: self.determineTargetComponent(b) - def determineTargetComponent(self, b, flagOfInterest=None): + def determineTargetComponent( + self, b: "Block", flagOfInterest: Optional[Flags] = None + ): """Determines target component, stores it on the block, and appends it to self._componentDeterminesBlockHeight. @@ -275,7 +284,7 @@ def determineTargetComponent(self, b, flagOfInterest=None): self._componentDeterminesBlockHeight[componentWFlag[0]] = True b.p.axialExpTargetComponent = componentWFlag[0].name - def _isFuelLocked(self, b): + def _isFuelLocked(self, b: "Block"): """Physical/realistic implementation reserved for ARMI plugin. Parameters @@ -299,7 +308,7 @@ def _isFuelLocked(self, b): self._componentDeterminesBlockHeight[c] = True b.p.axialExpTargetComponent = c.name - def isTargetComponent(self, c): + def isTargetComponent(self, c: "Component") -> bool: """Returns bool if c is a target component. Parameters From bfbdc4691a4ae414fedf9bef3ab70832116b774c Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 10:16:50 -0700 Subject: [PATCH 06/33] Combine check for zero and negative expansion factors --- .../converters/axialExpansionChanger/expansionData.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 29f099b31..ddefde277 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -95,17 +95,10 @@ def setExpansionFactors( 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) From 006b6fb6ebc768f4e85cfefa7e378507915a6231 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 10:55:56 -0700 Subject: [PATCH 07/33] Refactor ExpansionData.computeThermalExpansionFactors Single method that iterate over all blocks in an assembly and then all components in each block is now broken across a per-block method and a per-component method. This distinction will also allow subclasses to provide specific expansion methods on a more granular level. --- .../axialExpansionChanger/expansionData.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index ddefde277..690ff3e6f 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -49,6 +49,9 @@ def getSolidComponents(b: "Block") -> list["Component"]: class ExpansionData: """Data container for axial expansion.""" + _expansionFactors: dict["Component", float] + componentReferenceTemperature: dict["Component", float] + def __init__(self, a: "Assembly", setFuel: bool, expandFromTinputToThot: bool): """ Parameters @@ -71,9 +74,7 @@ def __init__(self, a: "Assembly", setFuel: bool, expandFromTinputToThot: bool): self._setTargetComponents(setFuel) self.expandFromTinputToThot = expandFromTinputToThot - def setExpansionFactors( - self, components: list["Component"], expFrac: list[float] - ): + def setExpansionFactors(self, components: list["Component"], expFrac: list[float]): """Sets user defined expansion fractions. Parameters @@ -174,20 +175,28 @@ def updateComponentTemp(self, c: "Component", 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 + self._setComponentThermalExpansionFactors(b) + + def _setComponentThermalExpansionFactors(self, b: "Block"): + """For each component in the block, set the thermal expansion factors.""" + for c in getSolidComponents(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. From 00ce21eb7b956b1998b5b25b7c87e0a7367fe87d Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 11:10:12 -0700 Subject: [PATCH 08/33] Provide axialExpansionChanger.iterSolidComponents The getSolidComponents produces a list which may not be needed in all situations. Lots of usage of getSolidComponents was for iteration, e.g., `for c in getSolidComponents(b)` where producing an iterable is sufficient. --- .../axialExpansionChanger/__init__.py | 1 + .../assemblyAxialLinkage.py | 6 ++--- .../axialExpansionChanger.py | 4 ++-- .../axialExpansionChanger/expansionData.py | 16 +++++++++++--- .../tests/test_axialExpansionChanger.py | 22 ++++++++++++++----- doc/release/0.4.rst | 1 + 6 files changed, 36 insertions(+), 14 deletions(-) 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 3e1028df0..2cee4b97c 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -19,7 +19,7 @@ from armi.reactor.blocks import Block from armi.reactor.components import Component, UnshapedComponent from armi.reactor.converters.axialExpansionChanger.expansionData import ( - getSolidComponents, + iterSolidComponents, ) @@ -191,7 +191,7 @@ 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: Block): @@ -267,7 +267,7 @@ def _getLinkedComponents(self, b: Block, c: Component): lstLinkedC = AxialLink(None, None) for ib, linkdBlk in enumerate(self.linkedBlocks[b]): if linkdBlk is not None: - for otherC in getSolidComponents(linkdBlk.getChildren()): + for otherC in iterSolidComponents(linkdBlk.getChildren()): if areAxiallyLinked(c, otherC): if lstLinkedC[ib] is not None: errMsg = ( diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py index d7369d258..c25129dd5 100644 --- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py @@ -21,7 +21,7 @@ ) from armi.reactor.converters.axialExpansionChanger.expansionData import ( ExpansionData, - getSolidComponents, + iterSolidComponents, ) from armi.reactor.flags import Flags @@ -325,7 +325,7 @@ def axiallyExpandAssembly(self): b.p.zbottom = self.linked.linkedBlocks[b][0].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 diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 690ff3e6f..ec1cd4492 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 TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Iterable from armi import runLog from armi.materials import material @@ -34,6 +34,11 @@ from armi.reactor.assemblies import Assembly +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. @@ -42,8 +47,13 @@ def getSolidComponents(b: "Block") -> list["Component"]: ----- 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: @@ -179,7 +189,7 @@ def computeThermalExpansionFactors(self): def _setComponentThermalExpansionFactors(self, b: "Block"): """For each component in the block, set the thermal expansion factors.""" - for c in getSolidComponents(b): + for c in iterSolidComponents(b): self._perComponentThermalExpansionFactors(c) def _perComponentThermalExpansionFactors(self, c: "Component"): diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index fd775af7f..6e3b512ff 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -31,6 +31,7 @@ AxialExpansionChanger, ExpansionData, getSolidComponents, + iterSolidComponents, ) from armi.reactor.converters.axialExpansionChanger.assemblyAxialLinkage import ( areAxiallyLinked, @@ -88,7 +89,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( @@ -321,7 +322,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 ) @@ -375,7 +376,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): @@ -419,7 +420,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 @@ -858,9 +859,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): @@ -929,7 +939,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() diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst index 45f7dba34..7b005b1bc 100644 --- a/doc/release/0.4.rst +++ b/doc/release/0.4.rst @@ -20,6 +20,7 @@ New Features matches a given condition. (`PR#1899 `_) #. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion. (`PR#1870 Date: Fri, 27 Sep 2024 11:35:59 -0700 Subject: [PATCH 09/33] Update ExpansionData docstring Move docstring to class definition and outside `__init__` for consistency. --- .../axialExpansionChanger/expansionData.py | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index ec1cd4492..c6c17a453 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -57,26 +57,36 @@ def getSolidComponents(b: "Block") -> list["Component"]: 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 component in the assembly. Expansion factors can be compute 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`` + """ _expansionFactors: dict["Component", float] componentReferenceTemperature: dict["Component", float] def __init__(self, a: "Assembly", 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 = {} From 81d39c31644532e3df047bb2b2bf8ac3dc0c47dd Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 12:06:21 -0700 Subject: [PATCH 10/33] ExpansionData.determineTargetComponent returns target Useful for testing what you get is what you expect. Tests added to this effect. Renamed an internal variable from `componentWFlag` to `candidates` because the name implied `componentWFlag` was a single component when it was a (potentially empty) list of components. Finally, bring out the setting of the target component to a separate method. This is useful for subclasses that may need custom logic to determine their target component, but want to perform the same actions on the setter. --- .../axialExpansionChanger/expansionData.py | 36 ++++++++++++------- .../tests/test_axialExpansionChanger.py | 33 ++++++++--------- 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index c6c17a453..993688128 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -254,7 +254,7 @@ def _setTargetComponents(self, setFuel: bool): def determineTargetComponent( self, b: "Block", flagOfInterest: Optional[Flags] = None - ): + ) -> "Component": """Determines target component, stores it on the block, and appends it to self._componentDeterminesBlockHeight. @@ -265,6 +265,11 @@ def determineTargetComponent( 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 @@ -281,30 +286,35 @@ def determineTargetComponent( 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: "Block"): """Physical/realistic implementation reserved for ARMI plugin. diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index 6e3b512ff..24ad84e39 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -24,7 +24,7 @@ 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 ( @@ -733,18 +733,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): @@ -758,12 +762,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.""" @@ -788,11 +787,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. From 9a9031123ebc5afe994063dedf2cae1cd5cb3ab9 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 12:10:44 -0700 Subject: [PATCH 11/33] Add some type hints for AxialExpansionChanger --- .../axialExpansionChanger/axialExpansionChanger.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py index c25129dd5..64ceacb9c 100644 --- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py @@ -12,10 +12,12 @@ # 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, ) @@ -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, componentLst: list, percents: list, setFuel=True ): """Perform axial expansion/contraction of an assembly given prescribed expansion percentages. @@ -186,7 +191,7 @@ def performPrescribedAxialExpansion( 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 From 319e47409368917c186ca13dd7001e7f94ead496 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 27 Sep 2024 12:11:47 -0700 Subject: [PATCH 12/33] Remove type name from componentLst in favor of components --- .../axialExpansionChanger/axialExpansionChanger.py | 8 ++++---- .../converters/axialExpansionChanger/expansionData.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py index 64ceacb9c..e3a03b824 100644 --- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py @@ -154,7 +154,7 @@ def expandColdDimsToHot( b.completeInitialLoading() def performPrescribedAxialExpansion( - self, a: Assembly, 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. @@ -173,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. @@ -186,7 +186,7 @@ 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( diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 993688128..97ca2f32d 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -99,15 +99,15 @@ def setExpansionFactors(self, components: list["Component"], expFrac: list[float 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(components) != len(expFrac): runLog.error( From 2ddda079e0a82491a73dce5a9ed49af4ae2f5425 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 30 Sep 2024 14:21:28 -0700 Subject: [PATCH 13/33] Provide and use AssemblyAxialLinkage.areAxiallyLinked Useful for subclasses to override. Calls back to existing function for small code change. --- .../assemblyAxialLinkage.py | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 2cee4b97c..34c701149 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -268,7 +268,7 @@ def _getLinkedComponents(self, b: Block, c: Component): for ib, linkdBlk in enumerate(self.linkedBlocks[b]): if linkdBlk is not None: for otherC in iterSolidComponents(linkdBlk.getChildren()): - if areAxiallyLinked(c, otherC): + if self.areAxiallyLinked(c, otherC): if lstLinkedC[ib] is not None: errMsg = ( "Multiple component axial linkages have been found for " @@ -292,3 +292,34 @@ def _getLinkedComponents(self, b: Block, c: Component): 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. + + Components are considered linked if the following are found to be true: + + 1. Both contain solid materials. + 2. They have compatible types (e.g., ``Circle`` and ``Circle``). + 3. Their multiplicities are the same. + 4. The smallest inner bounding diameter of the two is less than the largest outer + bounding diameter of the two. + + 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. This method is provided to allow + subclasses the ability to override the linkage check. + """ + return areAxiallyLinked(componentA, componentB) From d93b537981f52b1ab1be3e466684c04f00387a6c Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 30 Sep 2024 16:30:08 -0700 Subject: [PATCH 14/33] Skip setting target component for dummy block axial expansion Closes #1453 --- .../reactor/converters/axialExpansionChanger/expansionData.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 97ca2f32d..719a6c15c 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -246,7 +246,9 @@ def _setTargetComponents(self, setFuel: bool): 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 not real and not used. Therefore we don't need to assign anything + # special for them + pass elif setFuel and b.hasFlags(Flags.FUEL): self._isFuelLocked(b) else: From 11352a4fc3671b212f512491d7cea98d010d9160 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 30 Sep 2024 16:31:37 -0700 Subject: [PATCH 15/33] Update ExpansionData.determineTargetComponent docstring --- .../converters/axialExpansionChanger/expansionData.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 719a6c15c..021914f1d 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -257,8 +257,10 @@ def _setTargetComponents(self, setFuel: bool): def determineTargetComponent( self, b: "Block", flagOfInterest: Optional[Flags] = None ) -> "Component": - """Determines target component, stores it on the block, and appends it to - self._componentDeterminesBlockHeight. + """Determines target component, or 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 ---------- From 3a785b6907026b85e4a4c019f28a0dc0ccb56c33 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 30 Sep 2024 16:33:16 -0700 Subject: [PATCH 16/33] Use _setExpansionTarget in _isFuelLocked --- armi/reactor/converters/axialExpansionChanger/expansionData.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 021914f1d..7a900bc4f 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -341,8 +341,7 @@ def _isFuelLocked(self, b: "Block"): 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: "Component") -> bool: """Returns bool if c is a target component. From 5d39876adcd7cae00be802ded5a8c678b9b25f35 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 1 Oct 2024 09:00:17 -0700 Subject: [PATCH 17/33] Release notes --- doc/release/0.4.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/release/0.4.rst b/doc/release/0.4.rst index 7b005b1bc..7aaf032e6 100644 --- a/doc/release/0.4.rst +++ b/doc/release/0.4.rst @@ -20,7 +20,7 @@ New Features matches a given condition. (`PR#1899 `_) #. Plugins can provide the ``getAxialExpansionChanger`` hook to customize axial expansion. (`PR#1870 `_) #. TBD API Changes @@ -51,6 +51,7 @@ Quality Work ------------ #. Removing deprecated code ``axialUnitGrid``. (`PR#1809 `_) #. Refactoring ``axialExpansionChanger``. (`PR#1861 `_) +#. Changes to make axial expansion related classes more extensible. (`PR#1920 `_) #. TBD Changes that Affect Requirements From d4080bcca8d41fb40637b4b5421db60e46b54e9b Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 1 Oct 2024 09:29:08 -0700 Subject: [PATCH 18/33] Simplify type hints for AssemblyAxialLinkage block and component links --- .../converters/axialExpansionChanger/assemblyAxialLinkage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 34c701149..7f06fd491 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -178,8 +178,8 @@ class AssemblyAxialLinkage: below this block. """ - linkedBlocks: typing.Dict[Block, AxialLink[Block]] - linkedComponents: typing.Dict[Component, AxialLink[Component]] + linkedBlocks: dict[Block, AxialLink[Block]] + linkedComponents: dict[Component, AxialLink[Component]] def __init__(self, assem: "Assembly"): self.a = assem From 2dfd9bc440ce6c96a35b4084c0d24ae9fac3f4e7 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 1 Oct 2024 10:23:39 -0700 Subject: [PATCH 19/33] Provide AssemblyAxialLinkage.getLinkedBlocks This implementation an O(N^2) iteration where each block also iterates over all blocks in the assembly. The use of itertools.chain and itertools.islice provide a more efficient and streamlined iteration look. The call signature is for a `Sequence[Block]` which could just as easily be `Assembly`. We can't use just `Iterable[Block]` because we need to perform multiple iterations on the object at once. And things like a generator may get consumed part way through the iteration. And for testing, passing a list of blocks gets the same behavior as passing one assembly. Tests for zero blocks, one block, and multiple blocks added. --- .../assemblyAxialLinkage.py | 95 ++++++++----------- .../tests/test_axialExpansionChanger.py | 66 ++++++++++++- 2 files changed, 103 insertions(+), 58 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 7f06fd491..44b6f8339 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -14,6 +14,7 @@ import typing import dataclasses +import itertools from armi import runLog from armi.reactor.blocks import Block @@ -183,72 +184,52 @@ class AssemblyAxialLinkage: def __init__(self, assem: "Assembly"): self.a = assem - self.linkedBlocks = {} + 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 of Block -> AxialLink + 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 iterSolidComponents(b): self._getLinkedComponents(b, c) - def _getLinkedBlocks(self, b: Block): - """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 - for otherBlk in self.a: - 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] = AxialLink(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: Block, c: Component): """Retrieve the axial linkage for component c. diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index 24ad84e39..6316d3039 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -29,6 +29,7 @@ from armi.reactor.components.complexShapes import Helix from armi.reactor.converters.axialExpansionChanger import ( AxialExpansionChanger, + AssemblyAxialLinkage, ExpansionData, getSolidComponents, iterSolidComponents, @@ -983,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): @@ -1315,3 +1316,66 @@ def test_iteration(self): # No items left with self.assertRaises(StopIteration): next(genItems) + + +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.assertRaises(ValueError): + 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) From 0ca94ce9ac87b402b7581004b61310776acaa6da Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Tue, 1 Oct 2024 10:45:09 -0700 Subject: [PATCH 20/33] Refactor AssemblyAxialLinkage._getLinkedComponents --- .../assemblyAxialLinkage.py | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 44b6f8339..64178cdc2 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -14,6 +14,7 @@ import typing import dataclasses +import functools import itertools from armi import runLog @@ -230,6 +231,28 @@ def _determineAxialLinkage(self): for c in iterSolidComponents(b): self._getLinkedComponents(b, c) + 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: Block, c: Component): """Retrieve the axial linkage for component c. @@ -245,22 +268,10 @@ def _getLinkedComponents(self, b: Block, c: Component): RuntimeError multiple candidate components are found to be axially linked to a component """ - lstLinkedC = AxialLink(None, None) - for ib, linkdBlk in enumerate(self.linkedBlocks[b]): - if linkdBlk is not None: - for otherC in iterSolidComponents(linkdBlk.getChildren()): - if self.areAxiallyLinked(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 self.linkedBlocks[b].lower is None and lstLinkedC.lower is None: From d6b5d92fcc6374b7eda6d404141ee07a14313ba8 Mon Sep 17 00:00:00 2001 From: Drew Johnson Date: Fri, 4 Oct 2024 09:02:44 -0700 Subject: [PATCH 21/33] Update armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py Co-authored-by: John Stilley <1831479+john-science@users.noreply.github.com> --- .../converters/axialExpansionChanger/assemblyAxialLinkage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 64178cdc2..63eed70e8 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -89,8 +89,8 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: class AxialLink(typing.Generic[Comp]): """Small class for named references to objects above and below a specific object. - Axial expansion in ARMI works by identifying what objects occupy the same space axially. - For components, what components in the blocks above and below axially align? This is used + 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. From 6e8337dcede5a3c992e20bf2149146b93050c7e0 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 4 Oct 2024 09:04:43 -0700 Subject: [PATCH 22/33] Move type-check guarded import of assembly --- .../axialExpansionChanger/assemblyAxialLinkage.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 63eed70e8..a5192b9be 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -25,6 +25,10 @@ ) +if typing.TYPE_CHECKING: + from armi.reactor.assemblies import Assembly + + def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: """Determine axial component linkage for two components. @@ -154,10 +158,6 @@ def __iter__(self): return iter([self.lower, self.upper]) -if typing.TYPE_CHECKING: - from armi.reactor.assemblies import Assembly - - class AssemblyAxialLinkage: """Determines and stores the block- and component-wise axial linkage for an assembly. From 920872e92299be68329b3e908fc2ae1aab6bc7d4 Mon Sep 17 00:00:00 2001 From: Drew Johnson Date: Mon, 7 Oct 2024 11:49:58 -0700 Subject: [PATCH 23/33] Apply suggestions from code review Co-authored-by: Tony Alberti --- .../converters/axialExpansionChanger/expansionData.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 7a900bc4f..cc4658b17 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -60,13 +60,13 @@ class ExpansionData: r"""Data container for axial expansion. The primary responsibility of this class is to determine the axial expansion factors - for each component in the assembly. Expansion factors can be compute from the component + 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 + The target component, determined by :meth:`determineTargetComponents`, will drive the total height of the block post-expansion. Parameters @@ -291,7 +291,7 @@ def determineTargetComponent( # Follow expansion of most neutronically important component, fuel then control/poison for targetFlag in TARGET_FLAGS_IN_PREFERRED_ORDER: candidates = [c for c in b.getChildren() if c.hasFlags(targetFlag)] - if candidates != []: + if candidates: break # some blocks/components are not included in the above list but should still be found if not candidates: From c082768ecbdbb24e60038719731ae0a6a7eae46f Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 11:50:56 -0700 Subject: [PATCH 24/33] Additional call to setExpansionTarget if we've found the target before --- .../converters/axialExpansionChanger/expansionData.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index cc4658b17..2de0c7ed7 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -240,9 +240,8 @@ def _setTargetComponents(self, setFuel: bool): """ 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): From 5d9fb81aaf336e13b208b9478ca0a6de0777916c Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 11:51:11 -0700 Subject: [PATCH 25/33] Update comment on dummy components not needing target comps --- armi/reactor/converters/axialExpansionChanger/expansionData.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 2de0c7ed7..946707ef9 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -245,8 +245,7 @@ def _setTargetComponents(self, setFuel: bool): elif b.hasFlags(Flags.PLENUM) or b.hasFlags(Flags.ACLP): self.determineTargetComponent(b, Flags.CLAD) elif b.hasFlags(Flags.DUMMY): - # Dummy blocks are not real and not used. Therefore we don't need to assign anything - # special for them + # 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) From f89367e7f6da04fee5d02384bc2fd3f5e1596b18 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 11:51:25 -0700 Subject: [PATCH 26/33] Update ExpansionData.determineTargetComponent docstring --- armi/reactor/converters/axialExpansionChanger/expansionData.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/reactor/converters/axialExpansionChanger/expansionData.py b/armi/reactor/converters/axialExpansionChanger/expansionData.py index 946707ef9..39d4dacaa 100644 --- a/armi/reactor/converters/axialExpansionChanger/expansionData.py +++ b/armi/reactor/converters/axialExpansionChanger/expansionData.py @@ -255,7 +255,7 @@ def _setTargetComponents(self, setFuel: bool): def determineTargetComponent( self, b: "Block", flagOfInterest: Optional[Flags] = None ) -> "Component": - """Determines target component, or the component who's expansion will determine block height. + """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. From a8645a04538cf2771270a8623b0e3a02cb7e21aa Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 12:01:00 -0700 Subject: [PATCH 27/33] Use AxialLink API in axiallyExpandAssembly --- .../axialExpansionChanger/axialExpansionChanger.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py index e3a03b824..86f881874 100644 --- a/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py +++ b/armi/reactor/converters/axialExpansionChanger/axialExpansionChanger.py @@ -327,7 +327,7 @@ 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 iterSolidComponents(b): @@ -338,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 = { From 46a3f554cda44bf5fefc12be267fa070110456ec Mon Sep 17 00:00:00 2001 From: Drew Johnson Date: Mon, 7 Oct 2024 12:02:31 -0700 Subject: [PATCH 28/33] Apply suggestions from code review Co-authored-by: Tony Alberti --- .../converters/axialExpansionChanger/assemblyAxialLinkage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index a5192b9be..01b76ca7b 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -37,7 +37,7 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: 1. Both contain solid materials. 2. They have compatible types (e.g., ``Circle`` and ``Circle``). 3. Their multiplicities are the same. - 4. The smallest inner bounding diameter of the two is less than the largest outer + 4. The biggest inner bounding diameter of the two is less than the smallest outer bounding diameter of the two. Parameters @@ -204,7 +204,7 @@ def getLinkedBlocks( Returns ------- - dict of Block -> AxialLink + dict[Block, AxialLink[Block]] Dictionary where keys are individual blocks and their corresponding values point to blocks above and below. """ From e1ccf0e4c6221ba7ec13f274cf15dc2177a84343 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 12:04:19 -0700 Subject: [PATCH 29/33] Point AssemblyAxialLinkage.areAxiallyLinked doc to the function for linking criteria --- .../axialExpansionChanger/assemblyAxialLinkage.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 01b76ca7b..9f30a8cc8 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -289,14 +289,6 @@ def _getLinkedComponents(self, b: Block, c: Component): def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: """Check if two components are axially linked. - Components are considered linked if the following are found to be true: - - 1. Both contain solid materials. - 2. They have compatible types (e.g., ``Circle`` and ``Circle``). - 3. Their multiplicities are the same. - 4. The smallest inner bounding diameter of the two is less than the largest outer - bounding diameter of the two. - Parameters ---------- componentA : :py:class:`Component ` @@ -311,7 +303,7 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: See Also -------- - :func:`areAxiallyLinked` for more details. This method is provided to allow - subclasses the ability to override the linkage check. + :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) From 74fcd300def5109ae2a91f91743424904683ca88 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 16:57:11 -0700 Subject: [PATCH 30/33] Require axially linked components to have identical types --- .../converters/axialExpansionChanger/assemblyAxialLinkage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 9f30a8cc8..96f549d6e 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -35,7 +35,7 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: Components are considered linked if the following are found to be true: 1. Both contain solid materials. - 2. They have compatible types (e.g., ``Circle`` and ``Circle``). + 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. @@ -62,7 +62,7 @@ def areAxiallyLinked(componentA: Component, componentB: Component) -> bool: """ 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): From 1cc156a9cd0e6bf0c8c6fa33003461bd48636911 Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Mon, 7 Oct 2024 17:01:15 -0700 Subject: [PATCH 31/33] Remove getitem, setitem, iter on AxialLink Only really provided for back compatability with the existing list API. But this is far enough down the axial expansion API, which already has dubious widespread usage, that we're okay removing this unceremoniously. --- .../assemblyAxialLinkage.py | 36 -------------- .../tests/test_axialExpansionChanger.py | 48 ++++++------------- 2 files changed, 15 insertions(+), 69 deletions(-) diff --git a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py index 96f549d6e..ae546b51e 100644 --- a/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py +++ b/armi/reactor/converters/axialExpansionChanger/assemblyAxialLinkage.py @@ -121,42 +121,6 @@ class AxialLink(typing.Generic[Comp]): lower: typing.Optional[Comp] = dataclasses.field(default=None) upper: typing.Optional[Comp] = dataclasses.field(default=None) - def __getitem__(self, index: int) -> typing.Optional[Comp]: - """Get by position. - - Discouraged since ``linkage.lower`` is more explicit and readable than ``linkage[0]``. - - Parameters - ---------- - index : int - ``0`` for :attr:`lower`, ``1`` for :attr:`upper` - - Raises - ------ - AttributeError - If ``index`` is not ``0`` nor ``1`` - """ - if index == 0: - return self.lower - if index == 1: - return self.upper - raise AttributeError(f"{index=}") - - def __setitem__(self, index: int, o: Comp): - """Set by position. - - Discouraged since ``linkage.upper = x`` is more explicit and readable than ``linkage[1] = x``. - """ - if index == 0: - self.lower = o - elif index == 1: - self.upper = o - else: - raise AttributeError(f"{index=}") - - def __iter__(self): - return iter([self.lower, self.upper]) - class AssemblyAxialLinkage: """Determines and stores the block- and component-wise axial linkage for an assembly. diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index 6316d3039..590d42bd3 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -1283,39 +1283,21 @@ def setUpClass(cls): cls.LOWER_BLOCK = _buildDummySodium(20, 10) cls.UPPER_BLOCK = _buildDummySodium(300, 50) - def setUp(self): - self.link = AxialLink(self.LOWER_BLOCK, self.UPPER_BLOCK) - - def test_positionGet(self): - """Test the getitem accessor.""" - self.assertIs(self.link[0], self.link.lower) - self.assertIs(self.link[1], self.link.upper) - with self.assertRaises(AttributeError): - self.link[3] - - def test_positionSet(self): - """Test the setitem setter.""" - self.link[0] = None - self.assertIsNone(self.link.lower) - self.link[0] = self.LOWER_BLOCK - self.assertIs(self.link.lower, self.LOWER_BLOCK) - self.link[1] = None - self.assertIsNone(self.link.upper) - self.link[1] = self.UPPER_BLOCK - self.assertIs(self.link.upper, self.UPPER_BLOCK) - with self.assertRaises(AttributeError): - self.link[-1] = None - - def test_iteration(self): - """Test the ability to iterate over the items in the link.""" - genItems = iter(self.link) - first = next(genItems) - self.assertIs(first, self.link.lower) - second = next(genItems) - self.assertIs(second, self.link.upper) - # No items left - with self.assertRaises(StopIteration): - next(genItems) + 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): From df8ad61965813ba0055fccd3c7d636317c316c56 Mon Sep 17 00:00:00 2001 From: Drew Johnson Date: Fri, 11 Oct 2024 11:10:20 -0700 Subject: [PATCH 32/33] Update armi/reactor/converters/tests/test_axialExpansionChanger.py Co-authored-by: Tony Alberti --- armi/reactor/converters/tests/test_axialExpansionChanger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index 590d42bd3..dd1b20d44 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -1336,7 +1336,7 @@ def test_multiBlock(self): def test_emptyBlocks(self): """Test even smaller edge case when no blocks are passed.""" - with self.assertRaises(ValueError): + with self.assertRaisesRegex(ValueError, "No blocks passed. Cannot determine links"): AssemblyAxialLinkage.getLinkedBlocks([]) def test_onAssembly(self): From 7f4c8ffb0826428af72c9e635190cb6a374c62bb Mon Sep 17 00:00:00 2001 From: Andrew Johnson Date: Fri, 11 Oct 2024 11:13:58 -0700 Subject: [PATCH 33/33] Black --- armi/reactor/converters/tests/test_axialExpansionChanger.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/armi/reactor/converters/tests/test_axialExpansionChanger.py b/armi/reactor/converters/tests/test_axialExpansionChanger.py index dd1b20d44..98cc6ea08 100644 --- a/armi/reactor/converters/tests/test_axialExpansionChanger.py +++ b/armi/reactor/converters/tests/test_axialExpansionChanger.py @@ -1336,7 +1336,9 @@ def test_multiBlock(self): def test_emptyBlocks(self): """Test even smaller edge case when no blocks are passed.""" - with self.assertRaisesRegex(ValueError, "No blocks passed. Cannot determine links"): + with self.assertRaisesRegex( + ValueError, "No blocks passed. Cannot determine links" + ): AssemblyAxialLinkage.getLinkedBlocks([]) def test_onAssembly(self):