Add methods on Material class for waste disposal rating / classification (#3366)

Co-authored-by: Ethan Peterson <eepeterson3@gmail.com>
This commit is contained in:
Paul Romano 2025-04-11 18:43:27 -05:00 committed by GitHub
parent bd95b52f4d
commit c1a4d43da8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 475 additions and 8 deletions

View file

@ -18,6 +18,7 @@ import openmc.checkvalue as cv
from ._xml import clean_indentation, reorder_attributes
from .mixin import IDManagerMixin
from .utility_funcs import input_path
from . import waste
from openmc.checkvalue import PathLike
from openmc.stats import Univariate, Discrete, Mixture
from openmc.data.data import _get_element_symbol
@ -30,6 +31,7 @@ DENSITY_UNITS = ('g/cm3', 'g/cc', 'kg/m3', 'atom/b-cm', 'atom/cm3', 'sum',
# Smallest normalized floating point number
_SMALLEST_NORMAL = sys.float_info.min
_BECQUEREL_PER_CURIE = 3.7e10
NuclideTuple = namedtuple('NuclideTuple', ['name', 'percent', 'percent_type'])
@ -1058,7 +1060,6 @@ class Material(IDManagerMixin):
nuc_densities.append(nuc.percent)
nuc_density_types.append(nuc.percent_type)
nucs = np.array(nucs)
nuc_densities = np.array(nuc_densities)
nuc_density_types = np.array(nuc_density_types)
@ -1137,17 +1138,16 @@ class Material(IDManagerMixin):
def get_activity(self, units: str = 'Bq/cm3', by_nuclide: bool = False,
volume: float | None = None) -> dict[str, float] | float:
"""Returns the activity of the material or for each nuclide in the
material in units of [Bq], [Bq/g], [Bq/kg] or [Bq/cm3].
"""Returns the activity of the material or of each nuclide within.
.. versionadded:: 0.13.1
Parameters
----------
units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'}
units : {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Ci', 'Ci/m3'}
Specifies the type of activity to return, options include total
activity [Bq], specific [Bq/g, Bq/kg] or volumetric activity
[Bq/cm3]. Default is volumetric activity [Bq/cm3].
activity [Bq,Ci], specific [Bq/g, Bq/kg] or volumetric activity
[Bq/cm3,Ci/m3]. Default is volumetric activity [Bq/cm3].
by_nuclide : bool
Specifies if the activity should be returned for the material as a
whole or per nuclide. Default is False.
@ -1165,17 +1165,24 @@ class Material(IDManagerMixin):
of the material is returned as a float.
"""
cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3'})
cv.check_value('units', units, {'Bq', 'Bq/g', 'Bq/kg', 'Bq/cm3', 'Ci', 'Ci/m3'})
cv.check_type('by_nuclide', by_nuclide, bool)
if volume is None:
volume = self.volume
if units == 'Bq':
multiplier = volume if volume is not None else self.volume
multiplier = volume
elif units == 'Bq/cm3':
multiplier = 1
elif units == 'Bq/g':
multiplier = 1.0 / self.get_mass_density()
elif units == 'Bq/kg':
multiplier = 1000.0 / self.get_mass_density()
elif units == 'Ci':
multiplier = volume / _BECQUEREL_PER_CURIE
elif units == 'Ci/m3':
multiplier = 1e6 / _BECQUEREL_PER_CURIE
activity = {}
for nuclide, atoms_per_bcm in self.get_nuclide_atom_densities().items():
@ -1316,6 +1323,83 @@ class Material(IDManagerMixin):
raise ValueError("Volume must be set in order to determine mass.")
return volume*self.get_mass_density(nuclide)
def waste_classification(self, metal: bool = False) -> str:
"""Classify the material for near-surface waste disposal.
This method determines a waste classification for the material based on
the NRC regulations (10 CFR 61.55). Note that the NRC regulations do not
consider many long-lived radionuclides relevant to fusion systems; for
fusion applications, it is recommended to calculate a waste disposal
rating based on limits by Fetter et al. using the
:meth:`~openmc.Material.waste_disposal_rating` method.
Parameters
----------
metal : bool, optional
Whether or not the material is in metal form.
Returns
-------
str
The waste disposal classification, which can be "Class A", "Class
B", "Class C", or "GTCC" (greater than class C).
"""
return waste._waste_classification(self, metal=metal)
def waste_disposal_rating(
self,
limits: str | dict[str, float] = 'Fetter',
metal: bool = False,
) -> float:
"""Return the waste disposal rating for the material.
This method returns a waste disposal rating for the material based on a
set of specific activity limits. The waste disposal rating is a single
number that represents the sum of the ratios of the specific activity
for each radionuclide in the material against a nuclide-specific limit.
A value less than 1.0 indicates that the material "meets" the limits
whereas a value greater than 1.0 exceeds the limits.
Note that the limits for NRC do not consider many long-lived
radionuclides relevant to fusion systems. A paper by `Fetter et al.
<https://doi.org/10.1016/0920-3796(90)90104-E>`_ applies the NRC
methodology to calculate specific activity limits for an expanded set of
radionuclides.
Parameters
----------
limits : str or dict, optional
The name of a predefined set of specific activity limits or a
dictionary that contains specific activity limits for radionuclides,
where keys are nuclide names and values are activities in units of
[Ci/m3]. The predefined options are:
- 'Fetter': Uses limits from Fetter et al. (1990)
- 'NRC_long': Uses the 10 CFR 61.55 limits for long-lived
radionuclides
- 'NRC_short_A': Uses the 10 CFR 61.55 class A limits for
short-lived radionuclides
- 'NRC_short_B': Uses the 10 CFR 61.55 class B limits for
short-lived radionuclides
- 'NRC_short_C': Uses the 10 CFR 61.55 class C limits for
short-lived radionuclides
metal : bool, optional
Whether or not the material is in metal form (only applicable for
NRC based limits)
Returns
-------
float
The waste disposal rating for the material.
See also
--------
Material.waste_classification()
"""
return waste._waste_disposal_rating(self, limits, metal)
def clone(self, memo: dict | None = None) -> Material:
"""Create a copy of this material with a new unique ID.

279
openmc/waste.py Normal file
View file

@ -0,0 +1,279 @@
from __future__ import annotations
import openmc
from openmc.data import half_life
def _waste_classification(mat: openmc.Material, metal: bool = True) -> str:
"""Classify a material for near-surface waste disposal.
This method determines a waste classification for a material based on the
NRC regulations (10 CFR 61.55).
Parameters
----------
mat : openmc.Material
The material to classify.
metal : bool, optional
Whether or not the material is in metal form. This changes the
acceptable limits in Tables 1 and 2 for certain nuclides.
Returns
-------
str
The waste disposal classification, which can be "Class A", "Class B",
"Class C", or "GTCC" (greater than class C).
"""
# Determine metrics based on Tables 1 and 2 using sum of fractions rule for
# mixture of radionuclides from §61.55(a)(7)
ratio1 = _waste_disposal_rating(mat, 'NRC_long', metal=metal)
ratio2 = [
_waste_disposal_rating(mat, 'NRC_short_A', metal=metal),
_waste_disposal_rating(mat, 'NRC_short_B', metal=metal),
_waste_disposal_rating(mat, 'NRC_short_C', metal=metal),
]
# Determine which nuclides are present in Table 1 and Table 2
table1_nuclides_present = (ratio1 > 0.0)
table2_nuclides_present = any(x > 0.0 for x in ratio2)
# Helper function for classifying based on Table 2
def classify_table2(col1, col2, col3):
if col1 < 1.0:
return "Class A"
elif col2 < 1.0:
return "Class B"
elif col3 < 1.0:
return "Class C"
else:
return "GTCC"
if table1_nuclides_present and table2_nuclides_present:
# Classification based on §61.55(a)(5)
if ratio1 < 0.1:
return classify_table2(*ratio2)
elif ratio1 < 1.0:
return "Class C" if ratio2[2] < 1.0 else "GTCC"
else:
return "GTCC"
elif table1_nuclides_present:
# Classification based on §61.55(a)(3)
if ratio1 < 0.1:
return "Class A"
elif ratio1 < 1.0:
return "Class C"
else:
return "GTCC"
elif table2_nuclides_present:
# Classification based on §61.55(a)(4)
return classify_table2(*ratio2)
else:
# Classification based on §61.55(a)(6)
return "Class A"
def _waste_disposal_rating(
mat: openmc.Material,
limits: str | dict[str, float] = 'Fetter',
metal: bool = False,
) -> float:
"""Return the waste disposal rating for a material.
This method returns a waste disposal rating for the material based on a set
of specific activity limits. The waste disposal rating is a single number
that represents the sum of the ratios of the specific activity for each
radionuclide in the material against a nuclide-specific limit. A value less
than 1.0 indicates that the material "meets" the limits whereas a value
greater than 1.0 exceeds the limits.
Parameters
----------
mat : openmc.Material
The material to classify.
limits : str or dict, optional
The name of a predefined set of specific activity limits or a dictionary
that contains specific activity limits for radionuclides, where keys are
nuclide names and values are activities in units of [Ci/m3]. The
predefined options are:
- 'Fetter': Uses limits from Fetter et al. (1990)
- 'NRC_long': Uses the 10 CFR 61.55 limits for long-lived radionuclides
- 'NRC_short_A': Uses the 10 CFR 61.55 class A limits for short-lived
radionuclides
- 'NRC_short_B': Uses the 10 CFR 61.55 class B limits for short-lived
radionuclides
- 'NRC_short_C': Uses the 10 CFR 61.55 class C limits for short-lived
radionuclides
metal : bool, optional
Whether or not the material is in metal form (only applicable for NRC
based limits)
Returns
-------
float
The waste disposal rating for the material.
"""
if limits == 'Fetter':
# Specific activity limits for radionuclides with half-lives between 5
# years and 1e12 years from Table 2 in Fetter
limits = {
"Be10": 5.0e3,
"C14": 6.0e2,
"Al26": 9.0e-2,
"Si32": 6.0e2,
"Cl36": 1.0e1,
"Ar39": 2.0e4,
"Ar42": 2.0e4,
"K40": 2.0e0,
"Ca41": 1.0e4,
"Ti44": 2.0e2,
"Fe60": 1.0e-1,
"Co60": 3.0e8,
"Ni59": 9.0e2,
"Ni63": 7.0e5,
"Se79": 5.0e1,
"Kr81": 3.0e1,
"Sr90": 8.0e5,
"Nb91": 2.0e2,
"Nb92": 2.0e-1,
"Nb94": 2.0e-1,
"Mo93": 4.0e3,
"Tc97": 4.0e-1,
"Tc98": 1.0e-2,
"Tc99": 6.0e-2,
"Pd107": 9.0e2,
"Ag108_m1": 3.0e0,
"Sn121_m1": 7.0e5,
"Sn126": 1.0e-1,
"I129": 2.0e0,
"Cs137": 5.0e4,
"Ba133": 2.0e8,
"La137": 2.0e2,
"Sm151": 5.0e7,
"Eu150_m1": 3.0e3,
"Eu152": 3.0e5,
"Eu154": 5.0e6,
"Gd148": 2.0e5,
"Gd150": 2.0e3,
"Tb157": 5.0e3,
"Tb158": 4.0e0,
"Dy154": 1.0e3,
"Ho166_m1": 2.0e-1,
"Hf178_m1": 9.0e3,
"Hf182": 2.0e-1,
"Re186_m1": 2.0e1,
"Ir192_m1": 1.0e0,
"Pt193": 2.0e8,
"Hg194": 5.0e-1,
"Pb202": 6.0e-1,
"Pb210": 3.0e7,
"Bi207": 9.0e3,
"Bi208": 8.0e-2,
"Bi210_m1": 1.0e0,
"Po209": 3.0e3,
"Ra226": 1.0e-1,
"Ra228": 3.0e7,
"Ac227": 5.0e5,
"Th229": 2.0e0,
"Th230": 3.0e-1,
"Th232": 1.0e-1,
"Pa231": 7.0e-1,
"U232": 3.0e1,
"U233": 2.0e1,
"U234": 9.0e1,
"U235": 2.0e0,
"Np236": 1.0e0,
"Np237": 1.0e0,
"Pu238": 7.0e4,
"Pu239": 1.0e3,
"Pu240": 1.0e3,
"Pu241": 2.0e3,
"Pu242": 1.0e3,
"Pu244": 9.0e-1,
"Am241": 5.0e1,
"Am242_m1": 3.0e2,
"Am243": 2.0e0,
"Cm243": 6.0e2,
"Cm244": 5.0e5,
"Cm245": 5.0e0,
"Cm246": 8.0e2,
"Cm248": 8.0e2,
}
elif limits == 'NRC_long':
# Specific activity limits for long-lived radionuclides from Table 1 in
# 10 CFR 61.55 in Ci/m3.
limits = {
'C14': 8.0,
'Tc99': 3.0,
'I129': 0.08,
}
if metal:
limits['C14'] = 80.0
limits['Ni59'] = 220.0
limits['Nb94'] = 0.2
# Convert values in nCi/g to Ci/m3
factor = (1e6 * mat.get_mass_density()) / 1e9
limits.update({
'Pu241': 3500.0 * factor,
'Cm242': 20000.0 * factor,
'Np237': 100.0 * factor,
'Pu238': 100.0 * factor,
'Pu239': 100.0 * factor,
'Pu240': 100.0 * factor,
'Pu242': 100.0 * factor,
'Pu244': 100.0 * factor,
'Am241': 100.0 * factor,
'Am243': 100.0 * factor,
'Cm243': 100.0 * factor,
'Cm244': 100.0 * factor,
'Cm245': 100.0 * factor,
'Cm246': 100.0 * factor,
'Cm247': 100.0 * factor,
'Cm248': 100.0 * factor,
'Bk247': 100.0 * factor,
'Cf249': 100.0 * factor,
'Cf250': 100.0 * factor,
'Cf251': 100.0 * factor,
})
elif limits == 'NRC_short_A':
# Get Class A specific activity limits for short-lived radionuclides
# from Table 2 in 10 CFR 61.55
limits = {
'H3': 40.0,
'Co60': 700.0,
'Ni63': 35.0 if metal else 3.5,
'Sr90': 0.04,
'Cs137': 1.0
}
# Add radionuclides with half-lives < 5 years to limits for class A
five_years = 60.0 * 60.0 * 24.0 * 365.25 * 5.0
for nuc in mat.get_nuclides():
if half_life(nuc) is not None and half_life(nuc) < five_years:
limits[nuc] = 700.0
elif limits == 'NRC_short_B':
# Get Class B specific activity limits for short-lived radionuclides
# from Table 2 in 10 CFR 61.55
limits = {'Ni63': 700.0 if metal else 70.0, 'Sr90': 150.0, 'Cs137': 44.0}
elif limits == 'NRC_short_C':
# Get Class C specific activity limits for short-lived radionuclides
# from Table 2 in 10 CFR 61.55
limits = {'Ni63': 7000.0 if metal else 700.0, 'Sr90': 7000.0, 'Cs137': 4600.0}
# Calculate the sum of the fractions of the activity of each radionuclide
# compared to the specified limits
ratio = 0.0
for nuc, ci_m3 in mat.get_activity(units="Ci/m3", by_nuclide=True).items():
if nuc in limits:
ratio += ci_m3 / limits[nuc]
return ratio

View file

@ -590,6 +590,12 @@ def test_get_activity():
# Test with volume specified as argument
assert pytest.approx(m4.get_activity(units='Bq', volume=1.0)) == 355978108155965.94*3/2
# Test units based on Ci
bq = m4.get_activity(units='Bq')
m3 = m4.volume * 1e-6
assert (ci := m4.get_activity(units='Ci')) == pytest.approx(bq/3.7e10)
assert m4.get_activity(units='Ci/m3') == pytest.approx(ci/m3)
def test_get_decay_heat():
# Set chain file for testing

View file

@ -0,0 +1,98 @@
import random
import openmc
import pytest
@pytest.mark.parametrize("metal", [False, True])
def test_waste_classification_long(metal):
"""Test classification when determined by long-lived radionuclides"""
f = 10.0 if metal else 1.0
limit = 8.0*f
mat = openmc.Material()
mat.add_nuclide('C14', 1e-9*f)
assert mat.get_activity('Ci/m3') < 0.1 * limit
assert mat.waste_classification(metal=metal) == 'Class A'
mat = openmc.Material()
mat.add_nuclide('C14', 1e-8*f)
assert 0.1 * limit < mat.get_activity('Ci/m3') < limit
assert mat.waste_classification(metal=metal) == 'Class C'
mat = openmc.Material()
mat.add_nuclide('C14', 1e-7*f)
assert mat.get_activity('Ci/m3') > limit
assert mat.waste_classification(metal=metal) == 'GTCC'
@pytest.mark.parametrize("metal", [False, True])
def test_waste_classification_short(metal):
"""Test classification when determined by short-lived radionuclides"""
f = 10.0 if metal else 1.0
col1, col2, col3 = 3.5*f, 70.0*f, 700.0*f
mat = openmc.Material()
mat.add_nuclide('Ni63', 1e-10*f)
assert mat.get_activity('Ci/m3') < col1
assert mat.waste_classification(metal=metal) == 'Class A'
mat = openmc.Material()
mat.add_nuclide('Ni63', 1e-10*10*f)
assert col1 < mat.get_activity('Ci/m3') < col2
assert mat.waste_classification(metal=metal) == 'Class B'
mat = openmc.Material()
mat.add_nuclide('Ni63', 1e-10*200*f)
assert col2 < mat.get_activity('Ci/m3') < col3
assert mat.waste_classification(metal=metal) == 'Class C'
mat = openmc.Material()
mat.add_nuclide('Ni63', 1e-10*2000*f)
assert mat.get_activity('Ci/m3') > col3
assert mat.waste_classification(metal=metal) == 'GTCC'
def test_waste_classification_mix():
"""Test classification when determined by a mix of radionuclides"""
# Check example from 10 CFR 61.55 with mix of Sr90 and Cs137
mat = openmc.Material()
mat.add_nuclide('Sr90', 2.425e-9)
mat.add_nuclide('Cs137', 1.115e-9)
# In example, activity of Sr90 is 50.0 Ci/m3 and Cs137 is 22.0 Ci/m3
activity = mat.get_activity(units='Ci/m3', by_nuclide=True)
assert activity['Sr90'] == pytest.approx(50.0, 0.01)
assert activity['Cs137'] == pytest.approx(22.0, 0.01)
# According to example, the waste should be class B
assert mat.waste_classification() == 'Class B'
def test_waste_rating_fetter():
"""Test waste classification using the Fetter limits"""
# For Tc99, Fetter has a more strict limit. Here, we create a material with
# Tc99 at 1 Ci/m3 which exceeds Fetter but not NRC
density = 3.5561e-7
mat = openmc.Material()
mat.add_nuclide('Tc99', density)
assert mat.get_activity('Ci/m3') == pytest.approx(1.0, 1e-3)
assert mat.waste_disposal_rating(limits='NRC_short_C') < 1.0
assert mat.waste_disposal_rating(limits='Fetter') > 1.0
# With a lower density, it should be Class C under Fetter limits and Class A
# under NRC limits
mat = openmc.Material()
mat.add_nuclide('Tc99', 5.0e-2*density)
assert mat.waste_disposal_rating(limits='NRC_short_A') < 1.0
assert mat.waste_disposal_rating(limits='Fetter') < 1.0
def test_waste_disposal_rating():
"""Test waste_disposal_rating method"""
mat = openmc.Material()
mat.add_nuclide('K40', random.random())
# Check for correct classification based on actual activity
ci_m3 = mat.get_activity('Ci/m3')
assert mat.waste_disposal_rating(limits={'K40': 2*ci_m3}) < 1.0
assert mat.waste_disposal_rating(limits={'K40': 0.5*ci_m3}) > 1.0