diff --git a/armi/reactor/blocks.py b/armi/reactor/blocks.py index af5833a353..ecb0ccf435 100644 --- a/armi/reactor/blocks.py +++ b/armi/reactor/blocks.py @@ -1352,7 +1352,6 @@ def add(self, c): if self.p.percentBuByPin is None or len(self.p.percentBuByPin) < mult: # this may be a little wasteful, but we can fix it later... self.p.percentBuByPin = [0.0] * mult - self._updatePitchComponent(c) def addComponent(self, c): @@ -1846,20 +1845,16 @@ def getPitch(self, returnComp=False): setPitch : sets pitch """ - c, p = self._pitchDefiningComponent + c, _p = self._pitchDefiningComponent - # Admittedly awkward here, but allows for a clean comparison when adding components to the - # block as opposed to initializing _pitchDefiningComponent to (None, None) if c is None: - p = None - else: - # ask component for dimensions, since they could have changed - p = c.getDimension("op") + raise ValueError("{} has no valid pitch defining component".format(self)) - if returnComp: - return p, c - else: - return p + # ask component for dimensions, since they could have changed, + # due to temperature, for example. + p = c.getPitchData() + + return (p, c) if returnComp else p def hasPinPitch(self): """Return True if the block has enough information to calculate pin pitch.""" @@ -2343,7 +2338,8 @@ def breakFuelComponentsIntoIndividuals(self): # update moles at BOL for each pin self.p.molesHmBOLByPin = [] for pinNum, pin in enumerate(self.iterComponents(Flags.FUEL)): - pin.p.flags = fuelFlags # Update the fuel component flags to be the same as before the split (i.e., DEPLETABLE) + # Update the fuel component flags to be the same as before the split (i.e., DEPLETABLE) + pin.p.flags = fuelFlags self.p.molesHmBOLByPin.append(pin.getHMMoles()) pin.p.massHmBOL /= nPins @@ -2432,10 +2428,7 @@ class HexBlock(Block): LOCATION_CLASS = locations.HexLocation - PITCH_COMPONENT_TYPE: ClassVar[_PitchDefiningComponent] = ( - components.UnshapedComponent, - components.Hexagon, - ) + PITCH_COMPONENT_TYPE: ClassVar[_PitchDefiningComponent] = (components.Hexagon,) def __init__(self, name, height=1.0, location=None): Block.__init__(self, name, height, location) @@ -2702,7 +2695,8 @@ def getPinToDuctGap(self, cold=False): face to face in cm. """ if self.LOCATION_CLASS is None: - return None # can't assume anything about dimensions if there is no location type + # can't assume anything about dimensions if there is no location type + return None wire = self.getComponent(Flags.WIRE) ducts = sorted(self.getChildrenWithFlags(Flags.DUCT)) @@ -2882,7 +2876,7 @@ class CartesianBlock(Block): LOCATION_CLASS = locations.CartesianLocation PITCH_DIMENSION = "widthOuter" - PITCH_COMPONENT_TYPE = (components.UnshapedComponent, components.Rectangle) + PITCH_COMPONENT_TYPE = components.Rectangle def getMaxArea(self): """Get area of this block if it were totally full.""" @@ -2895,30 +2889,6 @@ def setPitch(self, val, updateBolParams=False, updateNumberDensityParams=True): "not supported" ) - def getPitch(self, returnComp=False): - """ - Get xw and yw of the block. - - See Also - -------- - Block.getPitch - """ - c, _p = self._pitchDefiningComponent - - # Admittedly awkward here, but allows for a clean comparison when adding components to the - # block as opposed to initializing _pitchDefiningComponent to (None, None) - if c is None: - raise ValueError("{} has no valid pitch".format(self)) - else: - # ask component for dimensions, since they could have changed - maxLength = c.getDimension("lengthOuter") - maxWidth = c.getDimension("widthOuter") - - if returnComp: - return (maxLength, maxWidth), c - else: - return (maxLength, maxWidth) - def getSymmetryFactor(self): """ Return a factor between 1 and N where 1/N is how much cut-off by symmetry lines this mesh diff --git a/armi/reactor/components/__init__.py b/armi/reactor/components/__init__.py index ce052aae5c..0166cca478 100644 --- a/armi/reactor/components/__init__.py +++ b/armi/reactor/components/__init__.py @@ -119,7 +119,6 @@ def __init__( Tinput, Thot, area=numpy.NaN, - op=None, modArea=None, isotopics=None, # pylint: disable=too-many-arguments mergeWith=None, @@ -136,7 +135,7 @@ def __init__( mergeWith=mergeWith, components=components, ) - self._linkAndStoreDimensions(components, op=op, modArea=modArea) + self._linkAndStoreDimensions(components, modArea=modArea) def getComponentArea(self, cold=False): """ diff --git a/armi/reactor/components/basicShapes.py b/armi/reactor/components/basicShapes.py index f1fc855dbb..e50e564ca2 100644 --- a/armi/reactor/components/basicShapes.py +++ b/armi/reactor/components/basicShapes.py @@ -144,6 +144,18 @@ def getPerimeter(self, Tc=None): perimeter = 6 * (ip / math.sqrt(3)) * mult return perimeter + def getPitchData(self): + """ + Return the pitch data that should be used to determine block pitch. + + Notes + ----- + This pitch data should only be used if this is the pitch defining component in + a block. The block is responsible for determining which component in it is the + pitch defining component. + """ + return self.getDimension("op") + class Rectangle(ShapedComponent): """A rectangle component.""" @@ -209,6 +221,19 @@ def isLatticeComponent(self): """Return true if the component is a `lattice component` containing void material and zero area.""" return self.containsVoidMaterial() and self.getArea() == 0.0 + def getPitchData(self): + """ + Return the pitch data that should be used to determine block pitch. + + Notes + ----- + For rectangular components there are two pitches, one for each dimension. + This pitch data should only be used if this is the pitch defining component in + a block. The block is responsible for determining which component in it is the + pitch defining component. + """ + return (self.getDimension("lengthOuter"), self.getDimension("widthOuter")) + class SolidRectangle(Rectangle): """Solid rectangle component.""" @@ -318,6 +343,20 @@ def getBoundingCircleOuterDiameter(self, Tc=None, cold=False): widthO = self.getDimension("widthOuter", Tc, cold=cold) return math.sqrt(widthO ** 2 + widthO ** 2) + def getPitchData(self): + """ + Return the pitch data that should be used to determine block pitch. + + Notes + ----- + For rectangular components there are two pitches, one for each dimension. + This pitch data should only be used if this is the pitch defining component in + a block. The block is responsible for determining which component in it is the + pitch defining component. + """ + # both dimensions are the same for a square. + return (self.getDimension("widthOuter"), self.getDimension("widthOuter")) + class Triangle(ShapedComponent): """ diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py index 1b4f0fcd4b..9588f43eae 100644 --- a/armi/reactor/components/component.py +++ b/armi/reactor/components/component.py @@ -1239,6 +1239,21 @@ def makeCrossSectionTable(self, nuclides=None): def getMicroSuffix(self): return self.parent.getMicroSuffix() + def getPitchData(self): + """ + Return the pitch data that should be used to determine block pitch. + + Notes + ----- + This pitch data should only be used if this is the pitch defining component in + a block. The block is responsible for determining which component in it is the + pitch defining component. + """ + raise NotImplementedError( + f"Method not implemented on component {self}. " + "Please implement if this component type can be a pitch defining component." + ) + class ShapedComponent(Component): """A component with well-defined dimensions.""" diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py index 2d864fa282..833d05673a 100644 --- a/armi/reactor/tests/test_blocks.py +++ b/armi/reactor/tests/test_blocks.py @@ -30,6 +30,7 @@ from armi.utils.units import MOLES_PER_CC_TO_ATOMS_PER_BARN_CM from armi.tests import TEST_ROOT from armi.utils import units +from armi.utils import hexagon from armi.reactor.flags import Flags from armi import tests from armi.reactor import grids @@ -1118,20 +1119,6 @@ def test102_setPitch(self): self.Block.getComponent(Flags.INTERCOOLANT).getDimension("op"), pitch ) - def test_UnshapedGetPitch(self): - """ - Test that a homogenous block can be created with a specific pitch. - This functionality is necessary for making simple homogenous reactors. - """ - block = blocks.HexBlock("TestHexBlock", location=None) - outerPitch = 2.0 - block.addComponent( - UnshapedComponent( - "TestComponent", "Void", Tinput=25.0, Thot=25.0, op=outerPitch - ) - ) - self.assertEqual(block.getPitch(), outerPitch) - def test106_getAreaFractions(self): cur = self.Block.getVolumeFractions() @@ -1522,13 +1509,196 @@ def test_getPinCoords(self): self.assertNotAlmostEqual(x[1], x[2]) self.assertEqual(len(xyz), self.HexBlock.getNumPins()) + def test_getPitchHomogenousBlock(self): + """ + Demonstrate how to communicate pitch on a hex block with unshaped components. + + Notes + ----- + This assumes there are 3 materials in the homogeneous block, one with half + the area fraction, and 2 with 1/4 each. + """ + desiredPitch = 14.0 + hexTotalArea = hexagon.area(desiredPitch) + + compArgs = {"Tinput": 273.0, "Thot": 273.0} + areaFractions = [0.5, 0.25, 0.25] + materials = ["HT9", "UZr", "Sodium"] + + # There are 2 ways to do this, the first is to pick a component to be the pitch + # defining component, and given it the shape of a hexagon to define the pitch + # The hexagon outer pitch (op) is defined by the pitch of the block/assembly. + # the ip is defined by whatever thickness is necessary to have the desired area + # fraction. The second way is shown in the second half of this test. + hexBlock = blocks.HexBlock("TestHexBlock") + + hexComponentArea = areaFractions[0] * hexTotalArea + + # Picking 1st material to use for the hex component here, but really the choice + # is arbitrary. + # area grows quadratically with op + ipNeededForCorrectArea = desiredPitch * areaFractions[0] ** 0.5 + self.assertEqual( + hexComponentArea, hexTotalArea - hexagon.area(ipNeededForCorrectArea) + ) + + hexArgs = {"op": desiredPitch, "ip": ipNeededForCorrectArea, "mult": 1.0} + hexArgs.update(compArgs) + pitchDefiningComponent = components.Hexagon( + "pitchComp", materials[0], **hexArgs + ) + hexBlock.addComponent(pitchDefiningComponent) + + # hex component is added, now add the rest as unshaped. + for aFrac, material in zip(areaFractions[1:], materials[1:]): + unshapedArgs = {"area": hexTotalArea * aFrac} + unshapedArgs.update(compArgs) + name = f"unshaped {material}" + comp = components.UnshapedComponent(name, material, **unshapedArgs) + hexBlock.addComponent(comp) + + self.assertEqual(desiredPitch, hexBlock.getPitch()) + self.assertAlmostEqual(hexTotalArea, hexBlock.getMaxArea()) + self.assertAlmostEqual(sum(c.getArea() for c in hexBlock), hexTotalArea) + + # For this second way, we will simply define the 3 components as unshaped, with + # the desired area fractions, and make a 4th component that is an infinitely + # thin hexagon with the the desired pitch. The downside of this method is that + # now the block has a fourth component with no volume. + hexBlock = blocks.HexBlock("TestHexBlock") + for aFrac, material in zip(areaFractions, materials): + unshapedArgs = {"area": hexTotalArea * aFrac} + unshapedArgs.update(compArgs) + name = f"unshaped {material}" + comp = components.UnshapedComponent(name, material, **unshapedArgs) + hexBlock.addComponent(comp) + + # We haven't set a pitch defining component this time so set it now with 0 area. + pitchDefiningComponent = components.Hexagon( + "pitchComp", "Void", op=desiredPitch, ip=desiredPitch, mult=1, **compArgs + ) + hexBlock.addComponent(pitchDefiningComponent) + self.assertEqual(desiredPitch, hexBlock.getPitch()) + self.assertAlmostEqual(hexTotalArea, hexBlock.getMaxArea()) + self.assertAlmostEqual(sum(c.getArea() for c in hexBlock), hexTotalArea) + class CartesianBlock_TestCase(unittest.TestCase): + """Tests for blocks with rectangular/square outer shape.""" + + PITCH = 70 + def setUp(self): caseSetting = settings.Settings() - caseSetting["xw"] = 5.0 - caseSetting["yw"] = 3.0 - self.CartesianBlock = blocks.CartesianBlock("TestCartesianBlock", caseSetting) + self.cartesianBlock = blocks.CartesianBlock("TestCartesianBlock", caseSetting) + + self.cartesianComponent = components.HoledSquare( + "duct", + "UZr", + Tinput=273.0, + Thot=273.0, + holeOD=68.0, + widthOuter=self.PITCH, + mult=1.0, + ) + self.cartesianBlock.addComponent(self.cartesianComponent) + self.cartesianBlock.addComponent( + components.Circle( + "clad", "HT9", Tinput=273.0, Thot=273.0, od=68.0, mult=169.0 + ) + ) + + def test_getPitchSquare(self): + self.assertEqual(self.cartesianBlock.getPitch(), (self.PITCH, self.PITCH)) + + def test_getPitchHomogenousBlock(self): + """ + Demonstrate how to communicate pitch on a hex block with unshaped components. + + Notes + ----- + This assumes there are 3 materials in the homogeneous block, one with half + the area fraction, and 2 with 1/4 each. + """ + desiredPitch = (10.0, 12.0) + rectTotalArea = desiredPitch[0] * desiredPitch[1] + + compArgs = {"Tinput": 273.0, "Thot": 273.0} + areaFractions = [0.5, 0.25, 0.25] + materials = ["HT9", "UZr", "Sodium"] + + # There are 2 ways to do this, the first is to pick a component to be the pitch + # defining component, and given it the shape of a rectangle to define the pitch + # The rectangle outer dimensions is defined by the pitch of the block/assembly. + # the inner dimensions is defined by whatever thickness is necessary to have + # the desired area fraction. + # The second way is shown in the second half of this test. + cartBlock = blocks.CartesianBlock("TestCartBlock") + + hexComponentArea = areaFractions[0] * rectTotalArea + + # Picking 1st material to use for the hex component here, but really the choice + # is arbitrary. + # area grows quadratically with outer dimensions. + # Note there are infinitely many inner dims that would preserve area, + # this is just one of them. + innerDims = [dim * areaFractions[0] ** 0.5 for dim in desiredPitch] + self.assertAlmostEqual( + hexComponentArea, rectTotalArea - innerDims[0] * innerDims[1] + ) + + rectArgs = { + "lengthOuter": desiredPitch[0], + "lengthInner": innerDims[0], + "widthOuter": desiredPitch[1], + "widthInner": innerDims[1], + "mult": 1.0, + } + rectArgs.update(compArgs) + pitchDefiningComponent = components.Rectangle( + "pitchComp", materials[0], **rectArgs + ) + cartBlock.addComponent(pitchDefiningComponent) + + # Rectangle component is added, now add the rest as unshaped. + for aFrac, material in zip(areaFractions[1:], materials[1:]): + unshapedArgs = {"area": rectTotalArea * aFrac} + unshapedArgs.update(compArgs) + name = f"unshaped {material}" + comp = components.UnshapedComponent(name, material, **unshapedArgs) + cartBlock.addComponent(comp) + + self.assertEqual(desiredPitch, cartBlock.getPitch()) + self.assertAlmostEqual(rectTotalArea, cartBlock.getMaxArea()) + self.assertAlmostEqual(sum(c.getArea() for c in cartBlock), rectTotalArea) + + # For this second way, we will simply define the 3 components as unshaped, with + # the desired area fractions, and make a 4th component that is an infinitely + # thin rectangle with desired pitch. the downside of this method is that now + # the block has a fourth component with no volume. + cartBlock = blocks.CartesianBlock("TestCartBlock") + for aFrac, material in zip(areaFractions, materials): + unshapedArgs = {"area": rectTotalArea * aFrac} + unshapedArgs.update(compArgs) + name = f"unshaped {material}" + comp = components.UnshapedComponent(name, material, **unshapedArgs) + cartBlock.addComponent(comp) + + # We haven't set a pitch defining component this time so set it now with 0 area. + pitchDefiningComponent = components.Rectangle( + "pitchComp", + "Void", + lengthOuter=desiredPitch[0], + lengthInner=desiredPitch[0], + widthOuter=desiredPitch[1], + widthInner=desiredPitch[1], + mult=1, + **compArgs, + ) + cartBlock.addComponent(pitchDefiningComponent) + self.assertEqual(desiredPitch, cartBlock.getPitch()) + self.assertAlmostEqual(rectTotalArea, cartBlock.getMaxArea()) + self.assertAlmostEqual(sum(c.getArea() for c in cartBlock), rectTotalArea) class MassConservationTests(unittest.TestCase): @@ -1538,7 +1708,6 @@ class MassConservationTests(unittest.TestCase): def setUp(self): # build a block that has some basic components in it. - cs = settings.Settings() self.b = blocks.HexBlock("fuel", height=10.0) fuelDims = {"Tinput": 25.0, "Thot": 600, "od": 0.76, "id": 0.00, "mult": 127.0}