diff --git a/armi/materials/material.py b/armi/materials/material.py index 97162eeedd..0c8054b52f 100644 --- a/armi/materials/material.py +++ b/armi/materials/material.py @@ -350,7 +350,7 @@ def density(self, Tk: float = None, Tc: float = None) -> float: density3 should be in agreement at both cold and hot temperatures as long as the block height is correct for the specified temperature. In the case of Fluids, density and density3 are the same as density is not driven by linear expansion, but - rather an exilicit density function dependent on Temperature. linearExpansionPercent is zero for a fluid. + rather an explicit density function dependent on Temperature. linearExpansionPercent is zero for a fluid. See Also -------- diff --git a/armi/reactor/blueprints/blockBlueprint.py b/armi/reactor/blueprints/blockBlueprint.py index dd705fb211..6ee555cf38 100644 --- a/armi/reactor/blueprints/blockBlueprint.py +++ b/armi/reactor/blueprints/blockBlueprint.py @@ -122,12 +122,12 @@ def construct( if cs["inputHeightsConsideredHot"]: if "group" in c.name: for component in c: - component.adjustNDensForHotHeight() + component.applyHotHeightDensityReduction() componentBlueprint.insertDepletableNuclideKeys( component, blueprint ) else: - c.adjustNDensForHotHeight() + c.applyHotHeightDensityReduction() componentBlueprint.insertDepletableNuclideKeys(c, blueprint) components[c.name] = c if spatialGrid: diff --git a/armi/reactor/blueprints/tests/test_blockBlueprints.py b/armi/reactor/blueprints/tests/test_blockBlueprints.py index 767e5d6fbb..cd8095494a 100644 --- a/armi/reactor/blueprints/tests/test_blockBlueprints.py +++ b/armi/reactor/blueprints/tests/test_blockBlueprints.py @@ -18,6 +18,7 @@ from armi.reactor import blueprints from armi import settings from armi.reactor.flags import Flags +from armi.reactor.tests import test_blocks FULL_BP = """ blocks: @@ -29,15 +30,15 @@ Tinput: 25.0 Thot: 600.0 id: 0.0 - od: 0.86602 + od: 0.7 latticeIDs: [1] - clad: + clad: # same args as test_blocks (except mult) shape: Circle material: HT9 Tinput: 25.0 - Thot: 470.0 - id: 1.0 - od: 1.09 + Thot: 450.0 + id: .77 + od: .80 latticeIDs: [1,2] coolant: shape: DerivedShape @@ -69,15 +70,15 @@ Tinput: 25.0 Thot: 600.0 id: 0.0 - od: 0.86602 + od: 0.67 latticeIDs: [1] clad: shape: Circle material: HT9 Tinput: 25.0 - Thot: 470.0 - id: 1.0 - od: 1.09 + Thot: 450.0 + id: .77 + od: .80 latticeIDs: [1,2] coolant: shape: DerivedShape @@ -317,6 +318,40 @@ def test_explicitFlags(self): self.assertTrue(a1.hasFlags(Flags.FUEL, exact=True)) self.assertTrue(a2.hasFlags(Flags.FUEL | Flags.TEST, exact=True)) + def test_densityConsistentWithComponentConstructor(self): + # when comparing to 3D density, the comparison is not quite correct. + # We need a bigger delta, this will be investigated/fixed in another PR + biggerDelta = 0.001 # g/cc + a1 = self.blueprints.assemDesigns.bySpecifier["IC"].construct( + self.cs, self.blueprints + ) + fuelBlock = a1[0] + clad = fuelBlock.getComponent(Flags.CLAD) + + # now construct clad programmatically like in test_Blocks + programmaticBlock = test_blocks.buildSimpleFuelBlock() + programaticClad = programmaticBlock.getComponent(Flags.CLAD) + self.assertAlmostEqual( + clad.getMassDensity(), + clad.material.density3(Tc=clad.temperatureInC), + delta=biggerDelta, + ) + # This should be equal, but block construction calls applyHotHeightDensityReduction + # while programmatic construction allows components to exist in a state where + # their density is not consistent with material density. + self.assertNotAlmostEqual( + clad.getMassDensity(), + programaticClad.getMassDensity(), + delta=biggerDelta, + ) + # its off by a factor of thermal expansion + self.assertNotAlmostEqual( + clad.getMassDensity(), + programaticClad.getMassDensity() + * programaticClad.getThermalExpansionFactor(), + delta=biggerDelta, + ) + if __name__ == "__main__": # import sys;sys.argv = ['', 'Test.testName'] diff --git a/armi/reactor/components/component.py b/armi/reactor/components/component.py index 9c6868b67f..9e5ee72d89 100644 --- a/armi/reactor/components/component.py +++ b/armi/reactor/components/component.py @@ -232,7 +232,7 @@ def __init__( self.temperatureInC = Thot self.material = None self.setProperties(material) - self.setNDensFromMassFracsAtTempInC() # not necessary when duplicating... + self.applyMaterialMassFracsToNumberDensities() # not necessary when duplicating... self.setType(name) self.p.mergeWith = mergeWith self.p.customIsotopicsName = isotopics @@ -327,7 +327,7 @@ def setProperties(self, properties): self.material.parent = self self.clearLinkedCache() - def setNDensFromMassFracsAtTempInC(self): + def applyMaterialMassFracsToNumberDensities(self): """ Set number densities for the component based on material mass fractions using hot temperatures. @@ -339,15 +339,17 @@ def setNDensFromMassFracsAtTempInC(self): See Also -------- - self.adjustNDensForHotHeight + self.applyHotHeightDensityReduction """ + # note, that this is not the actual material density, but rather 2D expanded + # `density3` is 3D density density = self.material.getProperty("density", Tc=self.temperatureInC) self.p.numberDensities = densityTools.getNDensFromMasses( density, self.material.p.massFrac ) - def adjustNDensForHotHeight(self): + def applyHotHeightDensityReduction(self): """ Adjust number densities to account for prescribed hot block heights (axial expansion). @@ -359,7 +361,7 @@ def adjustNDensForHotHeight(self): See Also -------- - self.setNDensFromMassFracsAtTempInC + self.applyMaterialMassFracsToNumberDensities """ axialExpansionFactor = 1.0 + self.material.linearExpansionFactor( self.temperatureInC, self.inputTemperatureInC @@ -735,15 +737,32 @@ def getMass(self, nuclideNames=None): mass : float The mass in grams. """ - nuclideNames = self._getNuclidesFromSpecifier(nuclideNames) volume = self.getVolume() / ( self.parent.getSymmetryFactor() if self.parent else 1.0 ) + return self.getMassDensity(nuclideNames) * volume + + def getMassDensity(self, nuclideNames=None): + """ + Return the mass density of the component, in g/cc. + + Parameters + ---------- + nuclideNames : str, optional + The nuclide/element specifier to get the partial density of in + the object. If omitted, total density is returned. + + Returns + ------- + density : float + The density in grams/cc. + """ + nuclideNames = self._getNuclidesFromSpecifier(nuclideNames) + # densities comes from self.p.numberDensities densities = self.getNuclideNumberDensities(nuclideNames) - return sum( - densityTools.getMassInGrams(nucName, volume, numberDensity) - for nucName, numberDensity in zip(nuclideNames, densities) - ) + nDens = {nuc: dens for nuc, dens in zip(nuclideNames, densities)} + massDensity = densityTools.calculateMassDensity(nDens) + return massDensity def setDimension(self, key, val, retainLink=False, cold=True): """ diff --git a/armi/reactor/converters/tests/test_blockConverter.py b/armi/reactor/converters/tests/test_blockConverter.py index 579e30ed49..5aced75744 100644 --- a/armi/reactor/converters/tests/test_blockConverter.py +++ b/armi/reactor/converters/tests/test_blockConverter.py @@ -55,7 +55,7 @@ def _perturbTemps(self, block, cName, tCold, tHot): "Give the component different ref and hot temperatures than in test_Blocks." c = block.getComponent(Flags.fromString(cName)) c.refTemp, c.refHot = tCold, tHot - c.adjustNDensForHotHeight() + c.applyHotHeightDensityReduction() c.setTemperature(tHot) return block diff --git a/armi/reactor/tests/test_blocks.py b/armi/reactor/tests/test_blocks.py index e313c69ac7..cacad79e3e 100644 --- a/armi/reactor/tests/test_blocks.py +++ b/armi/reactor/tests/test_blocks.py @@ -2282,7 +2282,7 @@ def test_coldMass(self): and hot height. """ fuel = self.b.getComponent(Flags.FUEL) - fuel.adjustNDensForHotHeight() + fuel.applyHotHeightDensityReduction() # set ref (input/cold) temperature. Thot = fuel.temperatureInC Tcold = fuel.inputTemperatureInC diff --git a/armi/reactor/tests/test_components.py b/armi/reactor/tests/test_components.py index e925693e54..edf7e3dae8 100644 --- a/armi/reactor/tests/test_components.py +++ b/armi/reactor/tests/test_components.py @@ -218,7 +218,7 @@ def test_getComponentArea(self): ) # show that area expansion is consistent with the density change in the material - self.component.adjustNDensForHotHeight() + self.component.applyHotHeightDensityReduction() hotDensity = self.component.density() hotArea = self.component.getArea() thermalExpansionFactor = self.component.getThermalExpansionFactor( @@ -234,7 +234,7 @@ def test_getComponentArea(self): area=math.pi, ) ) - coldComponent.adjustNDensForHotHeight() + coldComponent.applyHotHeightDensityReduction() coldDensity = coldComponent.density() coldArea = coldComponent.getArea() @@ -497,47 +497,158 @@ def test_changeNumberDensities(self): self.component.changeNDensByFactor(3.0) self.assertEqual(self.component.getNumberDensity("NA23"), 3.0) - def test_amountConserved(self): - """Demonstrate that volume integrated ndense is conserved at different temperatures""" + def test_demonstrateWaysToExpand(self): + """Demonstrate that material is conserved at during expansion""" + ########### + # # 1 2D Expansion + ########### # expansion only happens in 2D so only area is necessary - # since component expansion is only in 2D - tHotC = 20 - circle1 = Circle("circle", "HT9", 20, tHotC, 1.0) + + # when comparing to 3D density, the comparison is not quite correct. + # We need a bigger delta, this will be investigated/fixed in another PR + biggerDelta = 0.001 # g/cc + tCold = 50 + circle1 = Circle("circle", "HT9", 20, tCold, 1.0) tHotC = 500 circle2 = Circle("circle", "HT9", 20, tHotC, 1.0) self.assertAlmostEqual( circle1.p.numberDensities["FE"] * circle1.getArea(), circle2.p.numberDensities["FE"] * circle2.getArea(), ) + # material.density is the 2D density of a material + # material.density3 is true density and not equal in this case + # density must be density by calling applyHotHeightDensityReduction + # or other methods (see rest of test). + for circle in [circle1, circle2]: + self.assertAlmostEqual( + circle.getMassDensity(), + circle.material.density(Tc=circle.temperatureInC), + ) + # True density not equal because we expand in 2D + self.assertNotAlmostEqual( + circle.getMassDensity(), + circle.material.density3(Tc=circle.temperatureInC), + delta=biggerDelta, + ) + # True density off by factor of thermal expansion + expFac = circle.getThermalExpansionFactor() + self.assertAlmostEqual( + circle.getMassDensity() / expFac, + circle.material.density3(Tc=circle.temperatureInC), + delta=biggerDelta, + ) + # Change temp forward and backward and show equal + oldArea = circle1.getArea() + circle1.setTemperature(tHotC) + self.assertAlmostEqual( + circle1.p.numberDensities["FE"], + circle2.p.numberDensities["FE"], + ) + self.assertAlmostEqual( + circle1.getMassDensity(), + circle1.material.density(Tc=circle2.temperatureInC), + ) + circle1.setTemperature(tCold) + self.assertAlmostEqual(oldArea, circle1.getArea()) + self.assertAlmostEqual( + circle1.p.numberDensities["FE"] * circle1.getArea(), + circle2.p.numberDensities["FE"] * circle2.getArea(), + ) - # now 3D with HotHeightDensityReduction and equal height - height = 1.0 - circle1.adjustNDensForHotHeight() - circle2.adjustNDensForHotHeight() + ########### + # # 2 3D with applyHotHeightDensityReduction and equal hot height + ########### + hotHeight = 1.0 + circle1.applyHotHeightDensityReduction() + circle2.applyHotHeightDensityReduction() + # circle 1 has bigger mass because it has taller cold height and same + # cold radius + coldHeight1 = hotHeight / circle1.getThermalExpansionFactor() + coldHeight2 = hotHeight / circle2.getThermalExpansionFactor() + self.assertGreater(coldHeight1, coldHeight2) + self.assertGreater( + circle1.p.numberDensities["FE"] * circle1.getArea() * hotHeight, + circle2.p.numberDensities["FE"] * circle2.getArea() * hotHeight, + ) + # they are off in mass by a factor of thermal expansion self.assertAlmostEqual( circle1.p.numberDensities["FE"] * circle1.getArea() - * height + * hotHeight * circle1.getThermalExpansionFactor(), circle2.p.numberDensities["FE"] * circle2.getArea() - * height + * hotHeight * circle2.getThermalExpansionFactor(), ) + # Because of applyHotHeightDensityReduction the mass density is now + # Consistent with density3 but different from density (2D) by a factor of + # thermal expansion + self.assertAlmostEqual( + circle2.getMassDensity(), + circle2.material.density(Tc=circle2.temperatureInC) + / circle2.getThermalExpansionFactor(), + ) + self.assertAlmostEqual( + circle2.getMassDensity(), + circle2.material.density3(Tc=circle2.temperatureInC), + delta=biggerDelta, + ) + coldMass = ( + circle2.material.density(Tc=circle2.inputTemperatureInC) + * circle2.getArea(cold=True) + * coldHeight2 + ) + hotMass = circle2.getArea() * hotHeight * circle2.getMassDensity() + self.assertAlmostEqual(coldMass, hotMass) + + # now change the temperature of Circle 2 + newHot = tHotC / 2.0 + # undo original applyHotHeightDensityReduction + axialExpansionFactor = 1.0 + circle2.material.linearExpansionFactor( + circle2.temperatureInC, circle2.inputTemperatureInC + ) + circle2.changeNDensByFactor(axialExpansionFactor) + # change temp + circle2.setTemperature(newHot) + densityAdjustment = circle2.getThermalExpansionFactor(Tc=newHot, T0=tHotC) + circle2.applyHotHeightDensityReduction() + self.assertAlmostEqual( + circle2.getMassDensity(), + circle2.material.density3(Tc=circle2.temperatureInC), + delta=biggerDelta, + ) - # now start with cold and make hot and show how quantity is conserved - circle1 = Circle("circle", "HT9", 20, 20, 1.0) - feNum = circle1.p.numberDensities["FE"] * circle1.getArea() * height - circle1.setTemperature(500) - # New height will be taller - newHeight = height * circle1.getThermalExpansionFactor() - # when block.setHeight is called (which effectively changes component height) - # component.setNumberDensity is called (for solid isotopes) to adjust the number - # density so that now the 2D expansion will be approximated around the hot temp - newN = circle1.p.numberDensities["FE"] / circle1.getThermalExpansionFactor() - circle1.setNumberDensity("FE", newN) - feNumHot = circle1.p.numberDensities["FE"] * circle1.getArea() * newHeight - self.assertAlmostEqual(feNum, feNumHot) + ########### + # # 3 "True" 3D start with cold or hot and show how quantity is + # conserved with inputHeightsConsideredHot + ########### + coldHeight = 1.0 + circle1 = Circle("circle", "HT9", 20, tCold, 1.0) + circle2 = Circle("circle", "HT9", 20, tHotC, 1.0) + circle1.setTemperature(500) # the should be the same now + + for circle in [circle1, circle2]: + + # when block.setHeight is called (which effectively changes component height) + # component.setNumberDensity is called (for solid isotopes) to adjust the number + # density so that now the 2D expansion will be approximated around the hot temp + circle.changeNDensByFactor(1 / circle.getThermalExpansionFactor()) + self.assertAlmostEqual( + circle.getMassDensity(), + circle.material.density3(Tc=circle.temperatureInC), + delta=biggerDelta, + ) + # total mass consistent + # New height will be taller + hotHeight = coldHeight * circle.getThermalExpansionFactor() + self.assertAlmostEqual( + coldHeight + * circle.getArea(cold=True) + * circle.material.density3(Tc=circle.inputTemperatureInC), + hotHeight * circle.getArea() * circle.getMassDensity(), + delta=biggerDelta, + ) class TestTriangle(TestShapedComponent):