diff --git a/docs/source/usersguide/materials.rst b/docs/source/usersguide/materials.rst index 69647342a4..b629d6b12c 100644 --- a/docs/source/usersguide/materials.rst +++ b/docs/source/usersguide/materials.rst @@ -61,6 +61,20 @@ following would add 3.2% enriched uranium to a material:: In addition to U235 and U238, concentrations of U234 and U236 will be present and are determined through a correlation based on measured data. +It is also possible to perform enrichment of any element that is composed +of two naturally-occurring isotopes (e.g., Li or B) in terms of atomic percent. +To invoke this, provide the additional argument `enrichment_target` to +:meth:`Material.add_element`. For example the following would enrich B10 +to 30ao%:: + + mat.add_element('B', 1.0, enrichment=30.0, enrichment_target='B10') + +In order to enrich an isotope in terms of mass percent (wo%), provide the extra +argument `enrichment_type`. For example the following would enrich Li6 to 15wo%:: + + mat.add_element('Li', 1.0, enrichment=15.0, enrichment_target='Li6', + enrichment_type='wo') + Often, cross section libraries don't actually have all naturally-occurring isotopes for a given element. For example, in ENDF/B-VII.1, cross section evaluations are given for O16 and O17 but not for O18. If OpenMC is aware of diff --git a/openmc/data/data.py b/openmc/data/data.py index 5440dec7cf..5a0b16c7e3 100644 --- a/openmc/data/data.py +++ b/openmc/data/data.py @@ -10,6 +10,7 @@ from warnings import warn # pp. 293-306 (2013). The "representative isotopic abundance" values from # column 9 are used except where an interval is given, in which case the # "best measurement" is used. +# Note that the abundances are given as atomic fractions! NATURAL_ABUNDANCE = { 'H1': 0.99984426, 'H2': 0.00015574, 'He3': 0.000002, 'He4': 0.999998, 'Li6': 0.07589, 'Li7': 0.92411, diff --git a/openmc/element.py b/openmc/element.py index e364603348..3df2ff1f24 100644 --- a/openmc/element.py +++ b/openmc/element.py @@ -4,6 +4,7 @@ import os from xml.etree import ElementTree as ET import openmc.checkvalue as cv +from numbers import Real from openmc.data import NATURAL_ABUNDANCE, atomic_mass @@ -35,6 +36,7 @@ class Element(str): return self def expand(self, percent, percent_type, enrichment=None, + enrichment_target=None, enrichment_type=None, cross_sections=None): """Expand natural element into its naturally-occurring isotopes. @@ -52,9 +54,15 @@ class Element(str): percent_type : {'ao', 'wo'} 'ao' for atom percent and 'wo' for weight percent enrichment : float, optional - Enrichment for U235 in weight percent. For example, input 4.95 for - 4.95 weight percent enriched U. Default is None - (natural composition). + Enrichment of an enrichment_taget nuclide in percent (ao or wo). + If enrichment_taget is not supplied then it is enrichment for U235 + in weight percent. For example, input 4.95 for 4.95 weight percent + enriched U. Default is None (natural composition). + enrichment_target: str, optional + Single nuclide name to enrich from a natural composition (e.g., 'O16') + enrichment_type: {'ao', 'wo'}, optional + 'ao' for enrichment as atom percent and 'wo' for weight percent. + Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment cross_sections : str, optional Location of cross_sections.xml file. Default is None. @@ -65,16 +73,47 @@ class Element(str): is a tuple consisting of a nuclide string, the atom/weight percent, and the string 'ao' or 'wo'. + Raises + ------ + ValueError + No data is available for any of natural isotopes of the element + ValueError + If only some natural isotopes are available in the cross-section data + library and the element is not O, W, or Ta + ValueError + If a non-naturally-occurring isotope is requested + ValueError + If enrichment is requested of an element with more than two + naturally-occurring isotopes. + ValueError + If enrichment procedure for Uranium is used when element is not + Uranium. + ValueError + Uranium enrichment is requested with enrichment_type=='ao' + Notes ----- When the `enrichment` argument is specified, a correlation from `ORNL/CSD/TM-244 `_ is used to calculate the weight fractions of U234, U235, U236, and U238. Namely, the weight fraction of U234 and U236 are taken to be 0.89% and 0.46%, - respectively, of the U235 weight fraction. The remainder of the isotopic - weight is assigned to U238. + respectively, of the U235 weight fraction. The remainder of the + isotopic weight is assigned to U238. + + When the `enrichment` argument is specified with `enrichment_target`, a + general enrichment procedure is used for elements composed of exactly + two naturally-occurring isotopes. `enrichment` is interpreted as atom + percent by default but can be controlled by the `enrichment_type` + argument. """ + # Check input + if enrichment_type is not None: + cv.check_value('enrichment_type', enrichment_type, {'ao', 'wo'}) + + if enrichment is not None: + cv.check_less_than('enrichment', enrichment, 100.0, equality=True) + cv.check_greater_than('enrichment', enrichment, 0., equality=True) # Get the nuclides present in nature natural_nuclides = set() @@ -110,8 +149,8 @@ class Element(str): mutual_nuclides = sorted(list(mutual_nuclides)) absent_nuclides = sorted(list(absent_nuclides)) - # If all natural nuclides are present in the library, expand element - # using all natural nuclides + # If all natural nuclides are present in the library, + # expand element using all natural nuclides if len(absent_nuclides) == 0: for nuclide in mutual_nuclides: abundances[nuclide] = NATURAL_ABUNDANCE[nuclide] @@ -164,7 +203,20 @@ class Element(str): abundances[nuclide] = NATURAL_ABUNDANCE[nuclide] # Modify mole fractions if enrichment provided - if enrichment is not None: + # Old treatment for Uranium + if enrichment is not None and enrichment_target is None: + + # Check that the element is Uranium + if self.name != 'U': + msg = ('Enrichment procedure for Uranium was requested, ' + 'but the isotope is {} not U'.format(self)) + raise ValueError(msg) + + # Check that enrichment_type is not 'ao' + if enrichment_type == 'ao': + msg = ('Enrichment procedure for Uranium requires that ' + 'enrichment value is provided as wo%.') + raise ValueError(msg) # Calculate the mass fractions of isotopes abundances['U234'] = 0.0089 * enrichment @@ -181,6 +233,73 @@ class Element(str): for nuclide in abundances.keys(): abundances[nuclide] /= sum_abundances + # Modify mole fractions if enrichment provided + # New treatment for arbitrary element + elif enrichment is not None and enrichment_target is not None: + + # Provide more informative error message for U235 + if enrichment_target == 'U235': + msg = ("There is a special procedure for enrichment of U235 " + "in U. To invoke it, the arguments 'enrichment_target'" + "and 'enrichment_type' should be omitted. Provide " + "a value only for 'enrichment' in weight percent.") + raise ValueError(msg) + + # Check if it is two-isotope mixture + if len(abundances) != 2: + msg = ('Element {} does not consist of two naturally-occurring ' + 'isotopes. Please enter isotopic abundances manually.' + .format(self)) + raise ValueError(msg) + + # Check if the target nuclide is present in the mixture + if enrichment_target not in abundances: + msg = ('The target nuclide {} is not one of the naturally-occurring ' + 'isotopes ({})'.format(enrichment_target, list(abundances))) + raise ValueError(msg) + + # If weight percent enrichment is requested convert to mass fractions + if enrichment_type == 'wo': + # Convert the atomic abundances to weight fractions + # Compute the element atomic mass + element_am = sum(atomic_mass(nuc)*abundances[nuc] for nuc in abundances) + + # Convert Molar Fractions to mass fractions + for nuclide in abundances: + abundances[nuclide] *= atomic_mass(nuclide) / element_am + + # Normalize to one + sum_abundances = sum(abundances.values()) + for nuclide in abundances: + abundances[nuclide] /= sum_abundances + + # Enrich the mixture + # The procedure is more generic that it needs to be. It allows + # to enrich mixtures of more then 2 isotopes, keeping the ratios + # of non-enriched nuclides the same as in natural composition + + # Get fraction of non-enriched isotopes in nat. composition + non_enriched = 1.0 - abundances[enrichment_target] + tail_fraction = 1.0 - enrichment / 100.0 + + # Enrich all nuclides + # Do bogus operation for enrichment target but overwrite immediatly + # to avoid if statement in the loop + for nuclide, fraction in abundances.items(): + abundances[nuclide] = tail_fraction * fraction / non_enriched + abundances[enrichment_target] = enrichment / 100.0 + + # Convert back to atomic fractions if requested + if enrichment_type == 'wo': + # Convert the mass fractions to mole fractions + for nuclide in abundances: + abundances[nuclide] /= atomic_mass(nuclide) + + # Normalize the mole fractions to one + sum_abundances = sum(abundances.values()) + for nuclide in abundances: + abundances[nuclide] /= sum_abundances + # Compute the ratio of the nuclide atomic masses to the element # atomic mass if percent_type == 'wo': diff --git a/openmc/material.py b/openmc/material.py index 122d7d8ca8..3952fe4e99 100644 --- a/openmc/material.py +++ b/openmc/material.py @@ -499,7 +499,8 @@ class Material(IDManagerMixin): if macroscopic == self._macroscopic: self._macroscopic = None - def add_element(self, element, percent, percent_type='ao', enrichment=None): + def add_element(self, element, percent, percent_type='ao', enrichment=None, + enrichment_target=None, enrichment_type=None): """Add a natural element to the material Parameters @@ -512,9 +513,22 @@ class Material(IDManagerMixin): 'ao' for atom percent and 'wo' for weight percent. Defaults to atom percent. enrichment : float, optional - Enrichment for U235 in weight percent. For example, input 4.95 for - 4.95 weight percent enriched U. Default is None - (natural composition). + Enrichment of an enrichment_taget nuclide in percent (ao or wo). + If enrichment_taget is not supplied then it is enrichment for U235 + in weight percent. For example, input 4.95 for 4.95 weight percent + enriched U. + Default is None (natural composition). + enrichment_target: str, optional + Single nuclide name to enrich from a natural composition (e.g., 'O16') + enrichment_type: {'ao', 'wo'}, optional + 'ao' for enrichment as atom percent and 'wo' for weight percent. + Default is: 'ao' for two-isotope enrichment; 'wo' for U enrichment + + Notes + ----- + General enrichment procedure is allowed only for elements composed of + two isotopes. If `enrichment_target` is given without `enrichment` + natural composition is added to the material. """ @@ -540,7 +554,7 @@ class Material(IDManagerMixin): 'macroscopic data-set has already been added'.format(self._id) raise ValueError(msg) - if enrichment is not None: + if enrichment is not None and enrichment_target is None: if not isinstance(enrichment, Real): msg = 'Unable to add an Element to Material ID="{}" with a ' \ 'non-floating point enrichment value "{}"'\ @@ -567,7 +581,11 @@ class Material(IDManagerMixin): # Add naturally-occuring isotopes element = openmc.Element(element) - for nuclide in element.expand(percent, percent_type, enrichment): + for nuclide in element.expand(percent, + percent_type, + enrichment, + enrichment_target, + enrichment_type): self.add_nuclide(*nuclide) def add_s_alpha_beta(self, name, fraction=1.0): @@ -936,7 +954,7 @@ class Material(IDManagerMixin): Fractions of each material to be combined percent_type : {'ao', 'wo', 'vo'} Type of percentage, must be one of 'ao', 'wo', or 'vo', to signify atom - percent (molar percent), weight percent, or volume percent, + percent (molar percent), weight percent, or volume percent, optional. Defaults to 'ao' name : str The name for the new material, optional. Defaults to concatenated @@ -1005,7 +1023,7 @@ class Material(IDManagerMixin): zip(materials, fracs)]) new_mat = openmc.Material(name=name) - # Compute atom fractions of nuclides and add them to the new material + # Compute atom fractions of nuclides and add them to the new material tot_nuclides_per_cc = np.sum([dens for dens in nuclides_per_cc.values()]) for nuc, atom_dens in nuclides_per_cc.items(): new_mat.add_nuclide(nuc, atom_dens/tot_nuclides_per_cc, 'ao') diff --git a/tests/unit_tests/test_element.py b/tests/unit_tests/test_element.py new file mode 100644 index 0000000000..bacb988b9a --- /dev/null +++ b/tests/unit_tests/test_element.py @@ -0,0 +1,81 @@ +import openmc +from pytest import approx, raises + +from openmc.data import NATURAL_ABUNDANCE, atomic_mass + + +def test_expand_no_enrichment(): + """ Expand Li in natural compositions""" + lithium = openmc.Element('Li') + + # Verify the expansion into ATOMIC fraction against natural composition + for isotope in lithium.expand(100.0, 'ao'): + assert isotope[1] == approx(NATURAL_ABUNDANCE[isotope[0]] * 100.0) + + # Verify the expansion into WEIGHT fraction against natural composition + natural = {'Li6': NATURAL_ABUNDANCE['Li6'] * atomic_mass('Li6'), + 'Li7': NATURAL_ABUNDANCE['Li7'] * atomic_mass('Li7')} + li_am = sum(natural.values()) + for key in natural: + natural[key] /= li_am + + for isotope in lithium.expand(100.0, 'wo'): + assert isotope[1] == approx(natural[isotope[0]] * 100.0) + + +def test_expand_enrichment(): + """ Expand and verify enrichment of Li """ + lithium = openmc.Element('Li') + + # Verify the enrichment by atoms + ref = {'Li6': 75.0, 'Li7': 25.0} + for isotope in lithium.expand(100.0, 'ao', 25.0, 'Li7', 'ao'): + assert isotope[1] == approx(ref[isotope[0]]) + + # Verify the enrichment by weight + for isotope in lithium.expand(100.0, 'wo', 25.0, 'Li7', 'wo'): + assert isotope[1] == approx(ref[isotope[0]]) + + +def test_expand_exceptions(): + """ Test that correct exceptions are raised for invalid input """ + + # 1 Isotope Element + with raises(ValueError): + element = openmc.Element('Be') + element.expand(70.0, 'ao', 4.0, 'Be9') + + # 3 Isotope Element + with raises(ValueError): + element = openmc.Element('Cr') + element.expand(70.0, 'ao', 4.0, 'Cr52') + + # Non-present Enrichment Target + with raises(ValueError): + element = openmc.Element('H') + element.expand(70.0, 'ao', 4.0, 'H4') + + # Enrichment Procedure for Uranium if not Uranium + with raises(ValueError): + element = openmc.Element('Li') + element.expand(70.0, 'ao', 4.0) + + # Missing Enrichment Target + with raises(ValueError): + element = openmc.Element('Li') + element.expand(70.0, 'ao', 4.0, enrichment_type='ao') + + # Invalid Enrichment Type Entry + with raises(ValueError): + element = openmc.Element('Li') + element.expand(70.0, 'ao', 4.0, 'Li7', 'Grand Moff Tarkin') + + # Trying to enrich Uranium + with raises(ValueError): + element = openmc.Element('U') + element.expand(70.0, 'ao', 4.0, 'U235', 'wo') + + # Trying to enrich Uranium with wrong enrichment_target + with raises(ValueError): + element = openmc.Element('U') + element.expand(70.0, 'ao', 4.0, enrichment_type='ao') diff --git a/tests/unit_tests/test_material.py b/tests/unit_tests/test_material.py index 095df8df9b..6f51135ab2 100644 --- a/tests/unit_tests/test_material.py +++ b/tests/unit_tests/test_material.py @@ -29,10 +29,17 @@ def test_elements(): m = openmc.Material() m.add_element('Zr', 1.0) m.add_element('U', 1.0, enrichment=4.5) + m.add_element('Li', 1.0, enrichment=60.0, enrichment_target='Li7') + m.add_element('H', 1.0, enrichment=50.0, enrichment_target='H2', + enrichment_type='wo') with pytest.raises(ValueError): m.add_element('U', 1.0, enrichment=100.0) with pytest.raises(ValueError): m.add_element('Pu', 1.0, enrichment=3.0) + with pytest.raises(ValueError): + m.add_element('U', 1.0, enrichment=70.0, enrichment_target='U235') + with pytest.raises(ValueError): + m.add_element('He', 1.0, enrichment=17.0, enrichment_target='He6') def test_elements_by_name(): """Test adding elements by name"""