This commit is contained in:
Jonathan Shimwell 2026-07-17 09:00:16 -04:00 committed by GitHub
commit 0543dca247
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 2042 additions and 4 deletions

View file

@ -1298,6 +1298,54 @@ class Chain:
return new_chain
def get_decay_daughters(self, nuclide, max_half_life=None):
"""Return all nuclides reachable via decay from a given nuclide.
Unlike :meth:`reduce`, this follows only decay paths (not
reactions such as neutron capture) and returns a set of nuclide
names rather than a new :class:`Chain`.
Parameters
----------
nuclide : str
Name of the parent nuclide.
max_half_life : float, optional
Maximum daughter half-life in seconds. Stops descending a
branch when a daughter's half-life exceeds this value. If
None, all radioactive daughters are returned regardless of
half-life.
Returns
-------
set of str
Names of all decay daughters reachable from the parent.
"""
daughters = set()
stack = [nuclide]
visited = set()
while stack:
name = stack.pop()
if name in visited:
continue
visited.add(name)
if name not in self.nuclide_dict:
continue
for mode in self[name].decay_modes:
target = mode.target
if target is None or target in visited:
continue
if target not in self.nuclide_dict:
continue
target_nuc = self[target]
if target_nuc.half_life is None or target_nuc.half_life <= 0:
continue
if max_half_life is not None and target_nuc.half_life >= max_half_life:
continue
daughters.add(target)
stack.append(target)
return daughters
def _follow(self, isotopes, level):
"""Return all isotopes present up to depth level"""
found = isotopes.copy()

View file

@ -1627,6 +1627,7 @@ class Material(IDManagerMixin):
limits: str | dict[str, float] = 'Fetter',
metal: bool = False,
by_nuclide: bool = False,
chain=None,
) -> float | dict[str, float]:
"""Return the waste disposal rating for the material.
@ -1660,6 +1661,37 @@ class Material(IDManagerMixin):
short-lived radionuclides
- 'NRC_short_C': Uses the 10 CFR 61.55 class C limits for
short-lived radionuclides
The following options use clearance values from the German
Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4, Tabelle 1.
These limits are in [Bq/g] and the material activity will be
compared using the same units.
- 'StrlSchV_unrestricted': Unrestricted clearance of solid and
liquid substances (column 3)
- 'StrlSchV_metal_recycling': Metal scrap recycling (column 14)
- 'StrlSchV_landfill_100': Landfill disposal for facilities
receiving 100 Mg/a (column 8)
- 'StrlSchV_landfill_1000': Landfill disposal for facilities
receiving 1000 Mg/a (column 10)
- 'StrlSchV_incineration_100': Incineration for facilities
receiving 100 Mg/a (column 9)
- 'StrlSchV_incineration_1000': Incineration for facilities
receiving 1000 Mg/a (column 11)
- 'StrlSchV_soil': Soil surface clearance (column 7)
- 'StrlSchV_rubble': Building rubble clearance for >1000 Mg/a
(column 6)
.. note::
Some nuclides in Anlage 4 are listed with a '+' suffix,
indicating that their clearance value includes daughter
nuclides in secular equilibrium. When a depletion chain is
provided via the ``chain`` parameter, daughters in secular
equilibrium are automatically excluded from the
sum-of-fractions to avoid double-counting. Without a
chain, all nuclides with clearance entries are counted
individually, which may overestimate the rating for some
decay chains.
metal : bool, optional
Whether or not the material is in metal form (only applicable for
NRC based limits)
@ -1669,6 +1701,11 @@ class Material(IDManagerMixin):
nuclide names and the values are the waste disposal ratings for each
nuclide. If False, a single float value is returned that represents
the overall waste disposal rating for the material.
chain : openmc.deplete.Chain, optional
Depletion chain used to identify daughter nuclides in secular
equilibrium for StrlSchV limits. When provided, daughters
covered by a parent's '+' clearance value are excluded from
the sum-of-fractions to avoid double-counting.
Returns
-------
@ -1681,7 +1718,7 @@ class Material(IDManagerMixin):
Material.waste_classification()
"""
return waste._waste_disposal_rating(self, limits, metal, by_nuclide)
return waste._waste_disposal_rating(self, limits, metal, by_nuclide, chain)
def clone(self, memo: dict | None = None) -> Material:
"""Create a copy of this material with a new unique ID.

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,8 @@
import random
import openmc
from openmc.deplete import Chain
from openmc.deplete.nuclide import Nuclide, DecayTuple
import pytest
@ -100,3 +102,212 @@ def test_waste_disposal_rating():
wdr = mat.waste_disposal_rating(limits={'K40': 4*ci_m3}, by_nuclide=True)
assert isinstance(wdr, dict)
assert wdr['K40'] == pytest.approx(1/4)
def test_strlschv_unrestricted():
"""Test German StrlSchV unrestricted clearance rating"""
# Co-60 unrestricted clearance limit is 0.1 Bq/g.
# Create a material with Co60 and stable Co59 (which should not
# contribute to the waste disposal rating).
mat = openmc.Material()
mat.add_nuclide('Co59', 1e-10)
mat.add_nuclide('Co60', 1e-12)
bq_g = mat.get_activity('Bq/g', by_nuclide=True)['Co60']
# Rating should equal activity / limit (Co59 is stable, no contribution)
expected = bq_g / 0.1
rating = mat.waste_disposal_rating(limits='StrlSchV_unrestricted')
assert rating == pytest.approx(expected, rel=1e-3)
# by_nuclide should return dict with Co60 entry
wdr = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted', by_nuclide=True
)
assert isinstance(wdr, dict)
assert 'Co60' in wdr
assert wdr['Co60'] == pytest.approx(expected, rel=1e-3)
def test_strlschv_metal_recycling():
"""Test German StrlSchV metal scrap recycling clearance rating"""
# Co-60 metal recycling limit is 0.6 Bq/g, which is less restrictive
# than the unrestricted clearance limit of 0.1 Bq/g.
mat = openmc.Material()
mat.add_nuclide('Co60', 1e-12)
unrestricted = mat.waste_disposal_rating(limits='StrlSchV_unrestricted')
metal = mat.waste_disposal_rating(limits='StrlSchV_metal_recycling')
# Metal recycling limit is 6x higher, so rating should be 6x lower
assert unrestricted == pytest.approx(6.0 * metal, rel=1e-3)
def test_strlschv_sum_rule():
"""Test that the sum-of-fractions rule works for StrlSchV limits"""
# Create material with two nuclides, each at half their unrestricted limit
# Co-60 limit = 0.1 Bq/g, Cs-137 limit = 0.1 Bq/g
mat = openmc.Material()
mat.add_nuclide('Co60', 1e-12)
mat.add_nuclide('Cs137', 1e-12)
wdr = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted', by_nuclide=True
)
assert 'Co60' in wdr
assert 'Cs137' in wdr
# Total rating should be the sum of individual contributions
total = mat.waste_disposal_rating(limits='StrlSchV_unrestricted')
assert total == pytest.approx(wdr['Co60'] + wdr['Cs137'], rel=1e-6)
@pytest.mark.parametrize("pathway", [
'StrlSchV_unrestricted',
'StrlSchV_metal_recycling',
'StrlSchV_landfill_100',
'StrlSchV_landfill_1000',
'StrlSchV_incineration_100',
'StrlSchV_incineration_1000',
'StrlSchV_soil',
'StrlSchV_rubble',
])
def test_strlschv_all_pathways(pathway):
"""Test that all StrlSchV pathways return valid ratings"""
mat = openmc.Material()
mat.add_nuclide('Co60', 1e-12)
rating = mat.waste_disposal_rating(limits=pathway)
assert isinstance(rating, float)
assert rating > 0.0
wdr = mat.waste_disposal_rating(limits=pathway, by_nuclide=True)
assert isinstance(wdr, dict)
assert rating == pytest.approx(sum(wdr.values()), rel=1e-6)
def _make_test_chain():
"""Build a minimal chain with Cs137 -> Ba137_m1 and Sr90 -> Y90."""
chain = Chain()
cs137 = Nuclide("Cs137")
cs137.half_life = 9.49e8 # ~30 years in seconds
cs137.add_decay_mode("beta-", "Ba137_m1", 0.947)
cs137.add_decay_mode("beta-", "Ba137", 0.053)
ba137_m1 = Nuclide("Ba137_m1")
ba137_m1.half_life = 153.12 # seconds
ba137_m1.add_decay_mode("IT", "Ba137", 1.0)
ba137 = Nuclide("Ba137")
ba137.half_life = None # stable
sr90 = Nuclide("Sr90")
sr90.half_life = 9.09e8 # ~28.8 years in seconds
sr90.add_decay_mode("beta-", "Y90", 1.0)
y90 = Nuclide("Y90")
y90.half_life = 2.304e5 # ~2.67 days in seconds
y90.add_decay_mode("beta-", "Zr90", 1.0)
zr90 = Nuclide("Zr90")
zr90.half_life = None # stable
co60 = Nuclide("Co60")
co60.half_life = 1.66e8 # ~5.27 years
co60.add_decay_mode("beta-", "Ni60", 1.0)
ni60 = Nuclide("Ni60")
ni60.half_life = None # stable
chain.nuclides = [cs137, ba137_m1, ba137, sr90, y90, zr90, co60, ni60]
chain.nuclide_dict = {n.name: i for i, n in enumerate(chain.nuclides)}
return chain
def test_strlschv_chain_excludes_daughters():
"""Test that providing a chain excludes '+' daughters from the rating."""
chain = _make_test_chain()
# Sr-90 is a '+' parent. Y-90 is its daughter in secular equilibrium.
# Both have entries in the unrestricted clearance table (Sr90 at 1 Bq/g,
# Y90 at 1000 Bq/g). Without a chain, both contribute to the sum.
# With a chain, Y-90 should be excluded.
mat = openmc.Material()
mat.add_nuclide('Sr90', 1e-12)
mat.add_nuclide('Y90', 1e-12)
rating_no_chain = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted'
)
rating_with_chain = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted', chain=chain
)
# With chain, Y90 should be excluded so rating should be lower
assert rating_with_chain < rating_no_chain
# by_nuclide with chain should not contain Y90
wdr = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted', by_nuclide=True, chain=chain
)
assert 'Sr90' in wdr
assert 'Y90' not in wdr
def test_strlschv_chain_excludes_sr90_daughter():
"""Test that Y90 is excluded as a daughter of Sr90+."""
chain = _make_test_chain()
mat = openmc.Material()
mat.add_nuclide('Sr90', 1e-12)
mat.add_nuclide('Y90', 1e-12)
wdr_no_chain = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted', by_nuclide=True
)
wdr_with_chain = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted', by_nuclide=True, chain=chain
)
# Without chain, both Sr90 and Y90 contribute
assert 'Sr90' in wdr_no_chain
assert 'Y90' in wdr_no_chain
# With chain, Y90 is excluded as daughter of Sr90+
assert 'Sr90' in wdr_with_chain
assert 'Y90' not in wdr_with_chain
def test_strlschv_chain_no_effect_on_non_plus():
"""Test that the chain does not exclude non-'+' nuclides."""
chain = _make_test_chain()
# Co-60 is NOT a '+' parent (it has a non-'+' entry in the table).
# Its daughter Ni-60 is stable and has no clearance entry.
# The chain should not affect the Co-60 rating.
mat = openmc.Material()
mat.add_nuclide('Co60', 1e-12)
rating_no_chain = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted'
)
rating_with_chain = mat.waste_disposal_rating(
limits='StrlSchV_unrestricted', chain=chain
)
assert rating_no_chain == pytest.approx(rating_with_chain, rel=1e-6)
def test_strlschv_chain_no_effect_on_nrc():
"""Test that the chain parameter has no effect on NRC/Fetter limits."""
chain = _make_test_chain()
mat = openmc.Material()
mat.add_nuclide('Cs137', 1e-10)
rating_no_chain = mat.waste_disposal_rating(limits='Fetter')
rating_with_chain = mat.waste_disposal_rating(
limits='Fetter', chain=chain
)
assert rating_no_chain == pytest.approx(rating_with_chain, rel=1e-6)