Skip to content

Limit sets

A limit set is one named table of per-nuclide activity limits, plus the metadata needed to trace it back to the document it came from. The arithmetic applied to it never changes: divide each radionuclide's activity by its tabulated limit, sum the ratios, and compare the sum against a threshold of 1. Germany calls this the Summenformel, the UK the summation rule, the NRC the sum of fractions rule, and Fetter calls the answer a waste disposal rating.

Everything that varies between jurisdictions is therefore data, not code: 22 sets holding 4706 limit values, loaded from JSON at import time and generated from official sources by the scripts in tools/. Adding a jurisdiction means adding a file, never an elif in the calculation.

Naming is deliberate, the sets are not interchangeable

A set name records both the instrument and the specific column or table within it, for example StrlSchV_landfill_1000 is Anlage 4 Tabelle 1 Spalte 10, not "the German limits". Two columns of the same table can differ by a factor of ten, and two regulations covering the same ground can disagree, so picking the right set matters as much as the arithmetic.

The 22 sets

The tables below were generated by running this script against the installed package, so they cannot drift from the data. Run it yourself to confirm nothing has changed:

"""Generate the limit set tables in this page, grouped by jurisdiction."""
from collections import defaultdict

from radiological_material_clearance_finder import get_limit_set, limit_sets

by_jurisdiction = defaultdict(list)
for name in limit_sets():
    by_jurisdiction[get_limit_set(name).jurisdiction].append(name)

for jurisdiction in sorted(by_jurisdiction):
    print(f"### {jurisdiction}\n")
    print("| Set | Units | Nuclides | Default | Source |")
    print("| --- | --- | --- | --- | --- |")
    for name in by_jurisdiction[jurisdiction]:
        s = get_limit_set(name)
        default = "none" if s.default_limit is None else f"{s.default_limit:g} {s.units}"
        print(f"| `{s.name}` | {s.units} | {len(s.limits)} | {default} | {s.source} |")
    print()

total = sum(len(get_limit_set(n).limits) for n in limit_sets())
print(f"{len(limit_sets())} sets, {total} limit values")

Its output, pasted verbatim:

EU

Set Units Nuclides Default Source
EU_BSS_clearance Bq/g 291 none Council Directive 2013/59/Euratom, Annex VII Table A Part 1

Germany

Set Units Nuclides Default Source
StrlSchV_exemption_activity Bq 755 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 2
StrlSchV_incineration_100 Bq/g 175 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 9
StrlSchV_incineration_1000 Bq/g 175 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 11
StrlSchV_landfill_100 Bq/g 157 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 8
StrlSchV_landfill_1000 Bq/g 157 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 10
StrlSchV_metal_recycling Bq/g 283 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 14
StrlSchV_rubble Bq/g 279 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 6
StrlSchV_soil Bq/g 113 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 7
StrlSchV_unrestricted Bq/g 763 none Strahlenschutzverordnung (StrlSchV) 2018, Anlage 4 Tabelle 1, Spalte 3

IAEA

Set Units Nuclides Default Source
IAEA_GSR3_clearance Bq/g 257 none IAEA GSR Part 3 (2014), Schedule I, Table I.2

UK

Set Units Nuclides Default Source
UK_EPR16_exempt_material Bq/g 298 none Environmental Permitting (England and Wales) Regulations 2016, Schedule 23, Part 6 Table 5
UK_EPR16_norm Bq/g 15 none Environmental Permitting (England and Wales) Regulations 2016, Schedule 23, Part 3 Table 1
UK_EPR16_out_of_scope Bq/g 282 0.01 Bq/g Environmental Permitting (England and Wales) Regulations 2016, Schedule 23, Part 3 Table 2
UK_IRR17_natural Bq/g 11 none Ionising Radiations Regulations 2017, Schedule 7, Part 2 column 2
UK_IRR17_notification Bq/g 300 0.01 Bq/g Ionising Radiations Regulations 2017, Schedule 7, Part 1 column 2
UK_IRR17_registration Bq/g 300 0.1 Bq/g Ionising Radiations Regulations 2017, Schedule 7, Part 1 column 4

US

Set Units Nuclides Default Source
Fetter Ci/m3 81 none Fetter et al. (1990)
NRC_long Ci/m3 3 none 10 CFR 61.55
NRC_short_A Ci/m3 5 none 10 CFR 61.55
NRC_short_B Ci/m3 3 none 10 CFR 61.55
NRC_short_C Ci/m3 3 none 10 CFR 61.55
22 sets, 4706 limit values

The Nuclides column counts entries in limits only. It excludes metal_overrides, limits_per_gram, unlimited and the catch-all default_limit, all of which can also supply a limit. That is why the NRC sets look tiny: NRC_long has three ordinary rows and twenty more expressed in nCi/g.

What the sets mean, jurisdiction by jurisdiction

The three regulatory concepts these tables cover are distinct, and the distinction is not cosmetic:

  • Clearance (also "out of scope", "release", "Freigabe") removes material from regulatory control so it can go to an ordinary landfill, incinerator or scrap merchant.
  • Exemption removes an activity from a permitting or notification requirement while the material itself is still radioactive material.
  • Disposal classification does not release anything. It decides which class of licensed disposal facility may take the waste.

UK, six sets

Two instruments, doing two different jobs.

The Environmental Permitting (England and Wales) Regulations 2016 Schedule 23 governs radioactive substances activities. UK_EPR16_out_of_scope is the clearance set: below these concentrations a solid substance is not radioactive material or radioactive waste at all. UK_EPR16_norm is the same idea for naturally occurring radionuclides arising from a NORM industrial activity, and it is a separate table because natural series are handled differently. UK_EPR16_exempt_material is not clearance: it is exemption from needing a permit to keep and use radioactive material, and the regulation pairs the concentration with a maximum total activity on the premises that this index does not include.

The Ionising Radiations Regulations 2017 Schedule 7 governs the employer's duties. UK_IRR17_notification is the concentration below which work with a radionuclide needs no notification, UK_IRR17_registration the higher one below which no registration is needed for amounts up to 1000 kg, and UK_IRR17_natural covers naturally occurring radionuclides that have not been processed for their radioactive, fissile or fertile properties.

EPR 2016 and IRR 2017 are not two views of one table

IRR 2017 Schedule 7 Part 1 transcribes the EU and IAEA values, and agrees with them almost exactly. EPR 2016 Schedule 23 Table 2 is an independent derivation from a 10 microsievert per year dose criterion via Euratom RP 122, and is generally ten times stricter. The comparison further down quantifies this. Do not substitute one for the other.

Three UK sets are also the only ones with a catch-all default limit, discussed below.

Germany, nine sets

All nine come from a single table, Strahlenschutzverordnung 2018 Anlage 4 Tabelle 1, and each corresponds to one column of it. StrlSchV_unrestricted (Spalte 3) is unrestricted clearance of solid and liquid substances. The rest are clearance for a specified disposal or recycling route: building rubble, soil surfaces, metal scrap for recycling, and landfill or incineration at a site accepting up to 100 or up to 1000 tonnes per year.

The tonnage in StrlSchV_landfill_100 and friends is the receiving facility's annual throughput of cleared material, not the mass of the item being cleared. A lower annual throughput permits a higher specific activity, because the collective dose it can produce is smaller.

StrlSchV_exemption_activity (Spalte 2) is the odd one out: it is a limit on total activity in Bq, not concentration, so it needs an absolute mass or volume rather than a composition. The regulation grants the exemption when either it or the Spalte 3 concentration limit is met, which is straightforward to evaluate:

from radiological_material_clearance_finder import Material, clearance_index, get_limit_set

s = get_limit_set("StrlSchV_exemption_activity")
print(repr(s), "| Co-60 limit:", s.limits["Co60"], s.units)

# 1 kg of steel carrying a little Co-60.
kg = Material.from_masses({"Fe56": 999.0, "Co60": 1.0e-9})
conc = clearance_index(kg, "StrlSchV_unrestricted")
total = clearance_index(kg, "StrlSchV_exemption_activity")
print(f"Spalte 3, Bq/g : index={conc.index:.4g} clearable={conc.clearable}")
print(f"Spalte 2, Bq   : index={total.index:.4g} clearable={total.clearable}")
print("exempt if either is met:", conc.clearable or total.clearable)
LimitSet('StrlSchV_exemption_activity', 755 nuclides, Bq) | Co-60 limit: 100000.0 Bq
Spalte 3, Bq/g : index=419.1 clearable=False
Spalte 2, Bq   : index=0.4187 clearable=True
exempt if either is met: True

Anlage 4 Tabelle 1 also tabulates a high activity source threshold in TBq (Spalte 4) and three surface contamination columns in Bq/cm2 (Spalte 5, and Spalten 12 and 13 for building surfaces). None of those are modelled here, because this package works from a bulk inventory rather than from a surface.

EU, one set

EU_BSS_clearance is Annex VII Table A of Council Directive 2013/59/Euratom, the Basic Safety Standards, which serves as both the exemption level for bulk amounts and the clearance level. The directive prints the values in kBq/kg, numerically identical to Bq/g.

Table A Part 2 is the reason this set has 291 entries against the IAEA's 257: it gives a single value for a whole natural decay series rather than a value per nuclide. The U-238 and Th-232 series values of 1 Bq/g are expanded across their members by walking the IAEA decay mode data in tools/_series.py, and where a nuclide also has an explicit Part 1 row that row wins. Potassium-40 is listed separately at 10 Bq/g.

IAEA, one set

IAEA_GSR3_clearance is GSR Part 3 (2014) Schedule I Table I.2, the levels for exemption of bulk amounts of solid material and for clearance of solid material, both "without further consideration". It covers artificial radionuclides. The EU directive adopts these values, which is why the two sets agree exactly on every nuclide they share.

RS-G-1.7 is superseded

An earlier form of these values circulated as IAEA RS-G-1.7, which has since been superseded by GSG-17 and GSG-18. If you are comparing against an older calculation, check which vintage it used.

US, five sets

None of the US sets is a clearance set. Four of them, NRC_long, NRC_short_A, NRC_short_B and NRC_short_C, are the near-surface disposal classification tables of 10 CFR 61.55: Table 1 for long lived radionuclides and the three columns of Table 2 for the short lived ones. Passing them means the waste may go to a licensed near-surface facility in that class, not that it has been released. nrc_waste_class applies the sum of fractions to both tables and combines them with the rules in 61.55(a)(3) to (a)(7).

These sets carry two things a plain table cannot express:

from radiological_material_clearance_finder import Material, clearance_index, get_limit_set

s = get_limit_set("NRC_long")
print("NRC_long limits         :", s.limits)
print("activated metal rows    :", s.metal_overrides)

mat = Material({"Fe56": 1.0e23, "C14": 1.0e18, "Ni63": 1.0e18, "Nb94": 1.0e15},
               density=7.87)
for metal in (False, True):
    r = clearance_index(mat, "NRC_long", metal=metal)
    print(f"\nmetal={metal!s:5s} index={r.index:.4f} clearable={r.clearable}")
    print(f"   limits_used={r.limits_used}")
    print(f"   uncovered={sorted(r.uncovered)}")

a = get_limit_set("NRC_short_A")
print("\nNRC_short_A dynamic_rule:", a.dynamic_rule)
print("NRC_short_A tabulated   :", a.limits)
short = Material({"Fe56": 1.0e23, "Co60": 1.0e15, "Mn54": 1.0e14}, density=7.87)
r = clearance_index(short, "NRC_short_A")
print("limits_used (700 Ci/m3 came from the rule):", r.limits_used)
NRC_long limits         : {'C14': 8.0, 'Tc99': 3.0, 'I129': 0.08}
activated metal rows    : {'C14': 80.0, 'Ni59': 220.0, 'Nb94': 0.2}

metal=False index=11.0304 clearable=False
   limits_used={'C14': 8.0}
   uncovered=['Nb94', 'Ni63']

metal=True  index=1.2269 clearable=False
   limits_used={'C14': 80.0, 'Nb94': 0.2}
   uncovered=['Ni63']

NRC_short_A dynamic_rule: nrc_short_lived_class_a
NRC_short_A tabulated   : {'H3': 40.0, 'Co60': 700.0, 'Ni63': 3.5, 'Sr90': 0.04, 'Cs137': 1.0}
limits_used (700 Ci/m3 came from the rule): {'Co60': 700.0, 'Mn54': 700.0}

Passing metal=True swaps in the regulation's activated metal rows, which both replace values (C-14 goes from 8 to 80 Ci/m3) and add nuclides that have no non-metal row at all (Ni-59, Nb-94). NRC_short_A additionally carries a dynamic_rule, because 10 CFR 61.55 Table 2 row 1 sets a 700 Ci/m3 Class A limit for all nuclides with a half-life under five years, which cannot be tabulated without knowing the material. Mn-54 above gets its limit from that rule, not from a row.

Fetter is different again: the class C disposal limits for activated metal from Table 2 of Fetter, Cheng and Mann, Long-term radioactive waste from fusion reactors: Part II, Fusion Engineering and Design 13(2) 239-246 (1990). It is a research paper, not a regulation, and it is widely used in fusion because it covers the long lived activation products a fusion first wall actually produces. Two features are worth knowing:

from radiological_material_clearance_finder import Material, clearance_index, get_limit_set

s = get_limit_set("Fetter")
print(repr(s), "| threshold", s.threshold)
print("explicitly unlimited (marked TMSA in the paper):", len(s.unlimited))
print(" ", s.unlimited)
print("limits published as a range:", len(s.limits_upper))
for n in ("C14", "Cl36", "Ca41"):
    print(f"  {n:6s} lower {s.limits[n]:<10g} upper {s.limits_upper[n]:g}  Ci/m3")

metal = Material({"Fe56": 1.0e23, "Nb94": 5.0e15, "C14": 1.0e17, "H3": 1.0e18},
                 density=7.87, name="activated steel")
r = clearance_index(metal, "Fetter")
print(f"\nindex = {r.index:.4f}  clearable = {r.clearable}")
for n, ratio in r.dominant(3):
    print(f"  {n:6s} {r.activities[n]:.4g} Ci/m3 / {r.limits_used[n]:g} = {ratio:.4f}")
print("unlimited here:", r.unlimited)
LimitSet('Fetter', 81 nuclides, Ci/m3) | threshold 1.0
explicitly unlimited (marked TMSA in the paper): 20
  ('Cd113_m1', 'Cs135', 'H3', 'Kr85', 'La138', 'Lu176', 'Mn53', 'Nb93_m1', 'Os194', 'Pb205', 'Pm145', 'Pm146', 'Pt190', 'Rb87', 'Re187', 'Sm146', 'Sm147', 'U236', 'U238', 'Zr93')
limits published as a range: 22
  C14    lower 600        upper 6000  Ci/m3
  Cl36   lower 10         upper 100  Ci/m3
  Ca41   lower 10000      upper 30000  Ci/m3

index = 0.6342  clearable = True
  Nb94   0.1239 Ci/m3 / 0.2 = 0.6194
  C14    8.824 Ci/m3 / 600 = 0.0147
unlimited here: ('H3',)

Twenty nuclides are marked TMSA (theoretical maximum specific activity) in the paper, meaning no limit applies to them however concentrated they are. They live in unlimited, so a result reports them as covered and contributing nothing, which is not the same as activity the index quietly ignored. Twenty-two limits are published as a range; limits holds the conservative lower bound and limits_upper keeps the other end for reference. The index never uses limits_upper.

Catch-all default limits

Only three of the 22 sets apply a limit to a nuclide that is absent from their table:

Set default_limit What the regulation says
UK_EPR16_out_of_scope 0.01 Bq/g Applies to any artificial radionuclide the table does not list
UK_IRR17_notification 0.01 Bq/g Qualified with "unless the Executive has approved some other quantity for that radionuclide"
UK_IRR17_registration 0.1 Bq/g As above, for amounts up to 1000 kg

Every other set, including the other three UK sets and all the German, US, EU and IAEA sets, has no catch-all. An unlisted nuclide there gets no limit, contributes nothing to the sum, and is recorded in ClearanceResult.uncovered instead. This is the single most dangerous failure mode in the whole calculation, because a small index is what you were hoping for:

from radiological_material_clearance_finder import Material, clearance_index

# Stable iron bulk, a trace of Co-60, and Ca-41, which the IAEA and EU tables
# do not list at all.
steel = Material({"Fe56": 1.0e23, "Co60": 1.0e8, "Ca41": 1.0e15}, name="activated steel")

for name in ("IAEA_GSR3_clearance", "UK_IRR17_notification", "StrlSchV_unrestricted"):
    r = clearance_index(steel, name)
    print(f"{name:22s} index={r.index:9.2f}  clearable={r.clearable!s:5s}  "
          f"uncovered_fraction={r.uncovered_fraction:.4f}")
    print(f"{'':22s} defaulted={sorted(r.defaulted)}  uncovered={sorted(r.uncovered)}")
IAEA_GSR3_clearance    index=     0.45  clearable=True   uncovered_fraction=0.9981
                       defaulted=[]  uncovered=['Ca41']
UK_IRR17_notification  index=  2318.85  clearable=False  uncovered_fraction=0.0000
                       defaulted=['Ca41']  uncovered=[]
StrlSchV_unrestricted  index=     0.68  clearable=True   uncovered_fraction=0.0000
                       defaulted=[]  uncovered=[]

Always check uncovered_fraction

The IAEA result above says clearable with an index of 0.45, having ignored 99.81 per cent of the material's activity. It is arithmetically correct and practically worthless. The UK set reaches 2319 for the same material only because its 0.01 Bq/g catch-all catches Ca-41. The German set genuinely limits Ca-41, at 100 Bq/g, and reports nothing uncovered. uncovered, uncovered_fraction and defaulted exist so that a bare ratio cannot hide this from you.

The catch-all is applied unless you say otherwise. Pass apply_default_limit=False to see what the table alone covers:

from radiological_material_clearance_finder import Material, clearance_index

steel = Material({"Fe56": 1.0e23, "Co60": 1.0e8, "Ca41": 1.0e15})

for apply_default in (True, False):
    r = clearance_index(steel, "UK_IRR17_notification", apply_default_limit=apply_default)
    print(f"apply_default_limit={apply_default!s:5s} index={r.index:9.2f} "
          f"clearable={r.clearable!s:5s} defaulted={sorted(r.defaulted)} "
          f"uncovered={sorted(r.uncovered)} uncovered_fraction={r.uncovered_fraction:.4f}")
apply_default_limit=True  index=  2318.85 clearable=False defaulted=['Ca41'] uncovered=[] uncovered_fraction=0.0000
apply_default_limit=False index=     0.45 clearable=True  defaulted=[] uncovered=['Ca41'] uncovered_fraction=0.9981

Units decide what the material must supply

A set's units field is one of three values, and it determines what the material has to know about itself.

Specific activity. Scale invariant, so atom counts, atom densities, masses and mass fractions all give the same index and no density is needed. Sixteen of the 22 sets are in these units.

Volumetric. Needs a density, or atom densities in atoms/barn-cm, which carry the density with them. The five US sets are in these units.

Total activity, so it needs an absolute mass or volume rather than a composition. Only StrlSchV_exemption_activity uses this.

clearance_indices skips a set whose units the material cannot supply, rather than raising, so one missing density does not hide every result that did not need it:

from radiological_material_clearance_finder import Material, clearance_indices, limit_sets

counts = Material.from_atom_counts({"Fe56": 1.0e23, "Co60": 1.0e8, "Ca41": 1.0e15})
fractions = Material.from_mass_fractions({"Fe56": 0.999, "Co60": 1.0e-16, "Ca41": 4.5e-10})

for label, mat in (("atom counts", counts), ("mass fractions", fractions)):
    got = clearance_indices(mat)
    skipped = sorted(set(limit_sets()) - set(got))
    print(f"{label:15s} evaluated {len(got):2d} of {len(limit_sets())}, skipped {skipped}")
atom counts     evaluated 17 of 22, skipped ['Fetter', 'NRC_long', 'NRC_short_A', 'NRC_short_B', 'NRC_short_C']
mass fractions  evaluated 16 of 22, skipped ['Fetter', 'NRC_long', 'NRC_short_A', 'NRC_short_B', 'NRC_short_C', 'StrlSchV_exemption_activity']

Absolute atom counts give a mass, so the Bq set is evaluated; mass fractions do not, so it is skipped. Neither supplies a volume, so the five volumetric sets are skipped in both cases. Add a density and all 22 become available:

from radiological_material_clearance_finder import Material, clearance_indices, limit_sets

with_density = Material.from_atom_counts(
    {"Fe56": 1.0e23, "Co60": 1.0e8, "Ca41": 1.0e15}, density=7.87
)
got = clearance_indices(with_density)
print(f"evaluated {len(got)} of {len(limit_sets())}")
print("skipped:", sorted(set(limit_sets()) - set(got)) or "none")
for name in ("NRC_short_A", "NRC_long", "Fetter"):
    print(f"  {name:12s} index={got[name].index:.3e}")
evaluated 22 of 22
skipped: none
  NRC_short_A  index=1.363e-08
  NRC_long     index=0.000e+00
  Fetter       index=4.931e-07

Stable isotopes are part of the inventory

Bq/g is scale invariant but the denominator is still the mass of everything passed in. An inventory filtered to its radioactive nuclides has a mass thousands of times too small and an index thousands of times too large. Material warns when nothing you passed is stable. See the Material reference for the constructors.

Secular equilibrium belongs to the regulation

Each regulation publishes its own parent-to-daughter table, so no decay chain calculation is needed and none is done: StrlSchV Anlage 4 Tabelle 2, EPR 2016 Schedule 23 Table 3 for Part 3 and Table 8 for Part 6, IRR 2017 Schedule 7 Note 2, and a footnote in IAEA GSR Part 3. Each limit set therefore carries its own secular_equilibrium mapping, and the lists genuinely differ:

from radiological_material_clearance_finder import get_limit_set

for n in ("UK_EPR16_out_of_scope", "UK_EPR16_exempt_material", "UK_IRR17_notification",
          "StrlSchV_unrestricted", "IAEA_GSR3_clearance", "EU_BSS_clearance"):
    s = get_limit_set(n)
    print(f"{n:26s} parents={len(s.secular_equilibrium):3d}  Zr95 -> {s.daughters_of('Zr95')}")
UK_EPR16_out_of_scope      parents= 57  Zr95 -> ('Nb95_m1',)
UK_EPR16_exempt_material   parents= 31  Zr95 -> ('Nb95',)
UK_IRR17_notification      parents= 56  Zr95 -> ('Nb95',)
StrlSchV_unrestricted      parents=187  Zr95 -> ('Nb95_m1',)
IAEA_GSR3_clearance        parents= 36  Zr95 -> ('Nb95',)
EU_BSS_clearance           parents= 36  Zr95 -> ('Nb95',)

Zr-95 accounts for Nb-95m under EPR 2016 Part 3 and for Nb-95 under IRR 2017, and the two Parts of EPR 2016 Schedule 23 do not even agree with each other, because Part 6 designates Table 8 rather than Table 3. Copying one regulation's equilibrium list into another's calculation would be wrong, so each set keeps its own.

A parent only accounts for a daughter if the parent itself has a limit in that set, and only up to the parent's own activity, since secular equilibrium means equal activities. A daughter fully accounted for appears in excluded; where the parent covers only part of it, the excess stays in the sum and the covered part is reported in credited.

Rows listed twice, plain and marked "+"

Some nuclides have two rows in the source, one plain and one marked "+", and which applies depends on whether the daughters are actually present. The marked value is held in limits_secular_equilibrium and selected from the material, not from the table:

from radiological_material_clearance_finder import Material, clearance_index, get_limit_set

s = get_limit_set("StrlSchV_unrestricted")
print("plain Th-232 row       :", s.limits["Th232"], s.units)
print("Th-232 row marked '+'  :", s.limits_secular_equilibrium["Th232"], s.units)
print("daughters the '+' covers:", s.daughters_of("Th232"))

bare = Material({"O16": 1.0e23, "Th232": 1.0e22})
print("\nTh-232 alone,   limit used:", clearance_index(bare, s).limits_used["Th232"])

chain = Material({"O16": 1.0e23, "Th232": 1.0e22, "Ra228": 1.0e10, "Th228": 1.0e10})
r = clearance_index(chain, s)
print("with daughters, limit used:", r.limits_used["Th232"])
print("excluded (parent covers them fully):", sorted(r.excluded))
print("index rose from", f"{clearance_index(bare, s).index:.1f}", "to", f"{r.index:.1f}")
plain Th-232 row       : 10.0 Bq/g
Th-232 row marked '+'  : 0.01 Bq/g
daughters the '+' covers: ('Tl208', 'Pb212', 'Bi212', 'Po212', 'Po216', 'Rn220', 'Ra224', 'Ra228', 'Ac228', 'Th228')

Th-232 alone,   limit used: 10.0
with daughters, limit used: 0.01
excluded (parent covers them fully): ['Ra228', 'Th228']
index rose from 240.2 to 240173.2

Adding two trace daughters multiplied the index by a thousand, because it moved Th-232 from its 10 Bq/g row to its 0.01 Bq/g row. That is the regulation's intent, not a bug: the strict value is what applies when a Th-232 bearing material carries its progeny.

Keys ending in _sec

Sets covering natural series can carry a whole-chain value alongside a per-nuclide one. The whole-chain value is stored under a key with a _sec suffix so the two stay distinguishable, and no nuclide name can ever match it directly:

from radiological_material_clearance_finder import get_limit_set

nat = get_limit_set("UK_IRR17_natural")
print(nat.limits)
print("whole-chain keys:", [k for k in nat.limits if k.endswith("_sec")])
{'K40': 10.0, 'Rb87': 1.0, 'Pb210': 1.0, 'Po210': 1.0, 'Ra226': 1.0, 'Ra228': 1.0, 'Th228': 1.0, 'Th232_sec': 1.0, 'U238_sec': 1.0, 'Th232': 1.0, 'U238': 1.0}
whole-chain keys: ['Th232_sec', 'U238_sec']

Where a nuclide's only row is the _sec one, the loader also promotes it to the plain key, otherwise the set would apply no limit at all to unprocessed natural uranium rather than the published 1 Bq/g. Where a plain row exists too, the plain row stays the limit.

Provenance

Every set records where its numbers came from. Ask the package rather than trusting a citation copied into a report:

from radiological_material_clearance_finder import get_limit_set

s = get_limit_set("UK_EPR16_out_of_scope")
print(s.label)
print(s.source)
print(s.url)
print("retrieved", s.retrieved)
print("units", s.units, "| nuclides", len(s.limits), "| default", s.default_limit)
print(s.notes)
UK out of scope concentration, artificial radionuclides in solids
Environmental Permitting (England and Wales) Regulations 2016, Schedule 23, Part 3 Table 2
https://www.legislation.gov.uk/ukdsi/2016/9780111150184/schedule/23
retrieved 2026-09-10
units Bq/g | nuclides 282 | default 0.01
Below these concentrations a substance is not radioactive material or radioactive waste, which is the UK equivalent of clearance. The catch-all of 0.01 Bq/g applies to any artificial radionuclide the table does not list, so unlisted nuclides are limited rather than unregulated. Part 2 paragraph 7 places a substance outside the regulation when none of its radionuclides has a half-life exceeding 100 seconds, which is a test on the whole material. These values are a separate derivation from the exemption values of EU directive 2013/59/Euratom Annex VII, based on a 10 microsievert per year dose criterion via Euratom RP 122 part 1, and are generally ten times stricter, so they are not expected to match EU_BSS_clearance or UK_IRR17_notification.

source names the instrument and the exact table or column, url points at the version the tooling read, retrieved is the date it was read, and notes carries the caveats specific to that set, including which columns of the source were deliberately not modelled. label is the human readable description that reports use.

retrieved is the date the generating script last read the source, not a guarantee that the source has not changed since. Re-run the relevant script in tools/ if currency matters.

The whole-material scope test

UK_EPR16_out_of_scope carries one field no other set uses: min_half_life_scope, set to 100 seconds. EPR 2016 Part 2 paragraph 7 places a substance outside the regulation when none of its radionuclides has a half-life exceeding 100 seconds. That is a test on the material, not a per-nuclide filter, so a short lived nuclide can never be dropped from the sum on its own:

from radiological_material_clearance_finder import Material, clearance_index, half_life

for n in ("N16", "Co60"):
    print(f"half_life({n!r}) = {half_life(n):.4g} s")

# Every radionuclide under 100 s: outside EPR 2016 altogether.
short = Material({"O16": 1.0e23, "N16": 1.0e14})
r = clearance_index(short, "UK_EPR16_out_of_scope")
print(f"short only : index={r.index:.4g} clearable={r.clearable} out_of_scope={r.out_of_scope}")

# Add one long lived nuclide and the whole material is back in scope.
mixed = Material({"O16": 1.0e23, "N16": 1.0e14, "Co60": 1.0e10})
r = clearance_index(mixed, "UK_EPR16_out_of_scope")
print(f"plus Co-60 : index={r.index:.4g} clearable={r.clearable} out_of_scope={r.out_of_scope}")
half_life('N16') = 7.13 s
half_life('Co60') = 1.663e+08 s
short only : index=3.66e+14 clearable=True out_of_scope=True
plus Co-60 : index=3.66e+14 clearable=False out_of_scope=False

clearable is not the same as a small index

clearable is out_of_scope or index < threshold. In the first case above the index is 3.66e14 and the material is still clearable, because the regulation does not apply to it at all. Reporting the index without out_of_scope would be misleading in one direction, and reporting clearable without the index misleading in the other.

Registering your own set

register_limit_set takes a LimitSet and makes it available to everything that accepts a set name, which is how a site-specific criterion, a draft table or a sensitivity study gets in without patching the package:

from radiological_material_clearance_finder import (
    LimitSet,
    Material,
    clearance_index,
    get_limit_set,
    limit_sets,
    register_limit_set,
)

site = LimitSet(
    name="site_metal_reuse",
    label="On-site reuse of activated metal, internal criterion",
    jurisdiction="site",
    units="Bq/g",
    limits={"Co-60": 0.05, "Fe-55": 500.0, "Ni-63": 50.0},
    default_limit=0.01,
    secular_equilibrium={"Cs-137": ("Ba-137m",)},
    source="Internal criterion, engineering note ENG-001",
    url="https://example.invalid/eng-001",
    retrieved="2026-09-10",
    notes="Not a regulatory table. Co-60 halved against the German value.",
)
register_limit_set(site)

print("registered:", "site_metal_reuse" in limit_sets())
print("jurisdiction filter:", limit_sets(jurisdiction="site"))
print("canonical keys:", sorted(get_limit_set("site_metal_reuse").limits))
print("daughters_of('Cs137'):", site.daughters_of("Cs137"))

steel = Material({"Fe56": 1.0e23, "Co60": 1.0e8, "Ca41": 1.0e15})
r = clearance_index(steel, "site_metal_reuse")
print(f"index={r.index:.2f} clearable={r.clearable} defaulted={sorted(r.defaulted)}")
registered: True
jurisdiction filter: ('site_metal_reuse',)
canonical keys: ['Co60', 'Fe55', 'Ni63']
daughters_of('Cs137'): ('Ba137_m1',)
index=2319.30 clearable=False defaulted=['Ca41']

Three things to notice. Nuclide names are canonicalised on construction, so "Co-60" and "Ba-137m" become Co60 and Ba137_m1 and a hand-built set behaves exactly like a loaded one. Setting jurisdiction makes the set findable through limit_sets(jurisdiction=...). And filling in source, url, retrieved and notes is worth the minute it costs, because a result records only the set's name.

Construction validates, so mistakes surface at once rather than as a silently wrong index:

from radiological_material_clearance_finder import LimitSet

for kwargs in ({"units": "Bq/g", "limits": {"Co-60": 0.0}},
               {"units": "mSv/y", "limits": {"Co-60": 1.0}}):
    try:
        LimitSet(name="bad", label="x", **kwargs)
    except ValueError as exc:
        print("ValueError:", exc)
ValueError: bad: limits[Co60] is 0.0, but a limit must be positive. A limit of zero cannot be expressed as a ratio and would be read as no limit at all.
ValueError: bad: units 'mSv/y' is not one of Bq, Bq/g, Ci/m3

A limit of zero would mean "no activity of this is permitted", the strictest possible value, which the sum of fractions cannot express and which an earlier version read as no limit at all, the exact opposite. It is rejected rather than misinterpreted.

Reusing a shipped name shadows the regulation

Registering a set whose name is already taken replaces it, and warns first, because a result records only the name while using the new table:

import warnings

from radiological_material_clearance_finder import LimitSet, register_limit_set

with warnings.catch_warnings(record=True) as caught:
    warnings.simplefilter("always")
    register_limit_set(
        LimitSet(name="UK_IRR17_notification", label="draft", units="Bq/g",
                 limits={"Co-60": 1.0})
    )
for w in caught:
    print(f"{w.category.__name__}: {w.message}")
UserWarning: replacing the registered limit set 'UK_IRR17_notification', which came from the shipped regulatory data. Results will report that name while using the new table.

Prefer a fresh name such as site_metal_reuse, or a suffixed variant like UK_IRR17_notification_draft, so the published table stays reachable for comparison.

What has been checked, and what has not

The sets agree with each other and with independent extractions of the same sources, which is worth demonstrating rather than asserting:

from radiological_material_clearance_finder import get_limit_set

eu = get_limit_set("EU_BSS_clearance")
iaea = get_limit_set("IAEA_GSR3_clearance")
irr = get_limit_set("UK_IRR17_notification")
epr = get_limit_set("UK_EPR16_out_of_scope")


def compare(a, b):
    common = set(a.limits) & set(b.limits)
    differ = {n for n in common if a.limits[n] != b.limits[n]}
    return len(common), sorted(differ)


for label, a, b in (
    ("IAEA vs EU  ", iaea, eu),
    ("IAEA vs IRR17", iaea, irr),
    ("IAEA vs EPR16", iaea, epr),
):
    common, differ = compare(a, b)
    print(f"{label}: {len(differ):3d} of {common} common values differ")

_, differ = compare(iaea, irr)
for n in differ:
    print(f"   {n}: IAEA {iaea.limits[n]:g} against IRR17 {irr.limits[n]:g}")

ratios = sorted(epr.limits[n] / iaea.limits[n] for n in set(epr.limits) & set(iaea.limits))
print(f"\nEPR16 / IAEA ratio, {len(ratios)} common nuclides: "
      f"min {ratios[0]:g}, median {ratios[len(ratios) // 2]:g}, max {ratios[-1]:g}")
IAEA vs EU  :   0 of 257 common values differ
IAEA vs IRR17:   3 of 257 common values differ
IAEA vs EPR16: 184 of 257 common values differ
   Na24: IAEA 1 against IRR17 0.1
   Pt197: IAEA 1000 against IRR17 10
   U240: IAEA 100 against IRR17 0.01

EPR16 / IAEA ratio, 257 common nuclides: min 0.01, median 0.1, max 10

The IAEA and EU sets reached this package by completely separate routes, extraction from the IAEA PDF and a parse of the EUR-Lex XHTML, and agree on all 257 values they share, which is the expected result since the directive adopts the IAEA values. IRR 2017 transcribes the same values and differs on three. Two of those, Na-24 and Pt-197, are real differences in the transcription. The third is a representation difference: IRR 2017 prints U-240 twice, 0.01 Bq/g plain and 100 Bq/g marked "+", and this package stores the plain row in limits and the marked one in limits_secular_equilibrium, whereas the IAEA table carries only the with-daughters value of 100. The EPR 2016 set differs on 184 of 257 with a median ratio of 0.1, exactly as its independent derivation from a stricter dose criterion would predict.

Beyond that:

  • The five US sets agree with openmc.Material.waste_disposal_rating to floating point round-off, covered by 24 tests that run in CI.
  • All 81 Fetter lower bounds match the 1990 paper.
  • All four regulatory tables re-derived from live sources match the committed data.

Every check above is machine to machine

No human has verified any of these 4706 values against the source regulations. The agreement demonstrated here is between independent extractions and between this package and other software, which catches transcription slips and parser bugs but cannot catch a misreading shared by both paths, a footnote nobody modelled, or a regulation amended since retrieved. Nothing here is regulatory advice, and none of it substitutes for checking the numbers you are going to rely on against the instrument itself.

Also unmodelled by design, and named in each set's notes rather than left implicit: the German surface contamination and high activity source columns, the total activity ceiling that EPR 2016 pairs with UK_EPR16_exempt_material, and the Fetter upper bounds. If your question depends on one of those, this package does not answer it.

See also