Skip to content

Add Material methods for assessing recoverable product from activated material (isotopic purity, specific activity, element separation) #132

Description

@shimwell

Motivation

OpenMC can already compute the nuclide inventory of an activated or transmuted material. What it cannot easily answer is the follow-on question: if you chemically recover a product element from that material, what do you actually get?

The two figures of merit for that are:

  • isotopic purity — the fraction of a recovered element that is the nuclide of interest
  • specific activity — activity per unit mass of the recovered element

These matter because chemical separation cannot separate isotopes of the same element. If a route produces Mo99 inside a molybdenum feedstock, the product is permanently diluted by the stable Mo already present, and no amount of processing will concentrate it. If a route instead produces Mo99 inside a ruthenium feedstock via (n,a), the only Mo present is what was created, so chemistry recovers it essentially carrier-free. Same nuclide, same activity per atom, but the specific activity of the recovered product differs by orders of magnitude.

This is a general result and it drives route selection in any production or transmutation study: reactions that change the proton number ((n,p), (n,a), or (n,2n) followed by beta decay) give a recoverable product, while those that do not leave it diluted in its own feedstock. The same quantities are useful for activation and waste characterisation, where the question is what a given element carries once it is separated out.

Today, getting from a depletion result to these numbers means a few dozen lines of manual density and mass bookkeeping, written differently by every user.

What already exists

Stated so the proposal does not duplicate it:

  • Material.get_activity(units, by_nuclide, volume, chain_file) — but Bq/g is per gram of the whole material, not per gram of a recovered element
  • Material.get_element_atom_densities(), get_nuclide_atom_densities(), get_mass(), get_nuclides()
  • Material.remove_element(), remove_nuclide()
  • Material.deplete(), get_decay_heat(), decay_photon_energy
  • openmc.data.half_life(), decay_constant(), atomic_mass(), isotopes()

waste_classification() and waste_disposal_rating() are existing precedent for domain-specific helper methods on Material, so this class of addition fits the current design.

Proposed additions

Workflow steps with no current API

Material.separate_element(element, efficiency=1.0) -> Material

The inverse of the existing remove_element: keep only the isotopes of one element, preserving absolute atom counts and recomputing density. This models chemical separation, which is the step that makes the whole question computable. An efficiency argument (or per-element decontamination factor) would cover non-ideal separations.

Material.decay(timesteps, units='s') -> list[Material]

Pure radioactive decay with no neutron flux. Currently this is only possible by calling deplete() with a multigroup flux, an energy group structure, and zero source rates, purely to let a material sit for some cooling period. Returning list[Material] mirrors deplete(). Cooling between separation and use is a routine step and it materially changes the result, because short-lived contaminant isotopes of the same element decay away and the recovered product becomes purer with time.

Metrics

Material.isotopic_purity(nuclide, basis='mass') -> float

Fraction of the nuclide's own element that is that nuclide. basis='atom' for at%, since feedstock enrichment is conventionally quoted that way. Derivable from the existing getters, but it is the central metric and deserves one canonical implementation.

Material.specific_activity(nuclide, units='Bq/g', basis='element') -> float

basis='element' gives activity per gram of the parent element, which is what you hold after separation. basis='material' reproduces current get_activity behaviour. Making the basis explicit is most of the value, since conflating the two is an easy order-of-magnitude error.

openmc.data.carrier_free_specific_activity(nuclide, units='Bq/g') -> float

lambda * N_A / M, the ceiling reached at 100% isotopic purity. A pure nuclear property with no material state, so it belongs in openmc.data rather than on Material. A few lines on top of decay_constant() and atomic_mass(), and it lets results be reported as a fraction of carrier-free rather than an unanchored Bq/g.

Worth noting that this ceiling is fixed by half-life alone, so specific activity is not a purity measure on its own: a long-lived nuclide at 100% isotopic purity can have far lower specific activity than a short-lived one that is mostly carrier. Exposing both quantities separately is the point.

Material.radionuclidic_purity(nuclide, chain_file=None) -> float

Fraction of the material's total activity contributed by one nuclide. A different axis from isotopic purity, and the one that specifications are usually written against. A thin wrapper over get_activity(by_nuclide=True), but naming the concept discourages conflating the two.

Optional

Material.optimal_decay_time(nuclide, metric='specific_activity', bounds=None) -> float

Purity rises as short-lived same-element contaminants decay, while the product itself also decays, so there is a genuine optimum. Cooling times in the literature are typically hand-picked round numbers; this would locate them. metric='isotopic_purity' and metric='specific_activity' generally give different answers.

Example

irradiated = feedstock.deplete(flux, groups, timesteps, source_rates)[-1]
product = irradiated.separate_element('Mo').decay(24, 'h')[-1]

product.isotopic_purity('Mo99')
product.specific_activity('Mo99', units='Ci/g')
product.specific_activity('Mo99') / openmc.data.carrier_free_specific_activity('Mo99')

Design questions

  1. Naming. Material.decay() sits alongside the existing decay_photon_energy attribute and get_decay_heat() method, which is a little crowded. cool() is unambiguous but jargon. Preference?
  2. Getter convention. Existing accessors are mostly get_*. Should these be get_isotopic_purity() and so on, or do waste_classification() and mean_free_path() establish that bare names are acceptable?
  3. Chain file plumbing. Everything activity-related needs decay data. I would follow the existing chain_file=None signature from get_activity exactly rather than introduce a new convention.
  4. Scope. Are radionuclidic_purity and optimal_decay_time in scope, or should this land as the minimal set first (separate_element, decay, isotopic_purity, specific_activity, carrier_free_specific_activity)?

Next steps

Happy to implement. Suggested plan is to prototype the minimal set and validate against published per-pathway purity and specific activity tables (for example Table I of arXiv:2511.02814), so the tests exercise real production routes rather than synthetic compositions.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions