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 |
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_ratingto 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¶
limitsmodule reference, the fullLimitSetAPI.Materialreference, the five ways to build an inventory.- Getting started, for reading a
ClearanceResultin detail. - Overview, for how the index itself is put together.